ReShade 注入式后处理 · 第 七 篇

深度缓冲的工程学:自动关闭、MSAA、UI 缺失与子矩形错位

2026-10-1116,024 字24 张配图本文由@溯光 出品,转载请注明出处。

站点链接:技术博客页 | 公众号:溯光的图形笔记

题图:深度缓冲可视化那一档的画面
题图深度缓冲可视化那一档(本机现开现拍):深度图被直接画到屏幕上,远处的粒度与近处的台阶一眼可见——这一篇要解释的就是它为什么时灵时不灵。@ 本机现开现拍

切口:为什么"深度时灵时不灵"是可以指到源码的

好了,第六篇讲完 add-on 接口面之后,会在读者心里留下一个很自然的期待:既然接口都摊开了,那写个深度类效果应该是一路顺风了。 现实恰恰相反——中文圈里关于 ReShade 最深的一条经验是"深度这东西时灵时不灵"。

这句话本身没错,但它是症状,不是诊断。症状背后有四件事,每一件都能在官方仓库里指到具体的文件与行号:

  1. 自动关闭——深度会被"关掉",而且关它的代码不在深度那边;
  2. MSAA——开了多重采样之后,深度要么取不到,要么干脆整条链路缺席;
  3. UI 缺失——深度缓冲里天生就没有界面,于是效果会糊在 UI 上;
  4. 子矩形错位——同一个深度缓冲可能服务好几种分辨率,启发式会选错。

这四件事有一个共同点:它们都不是 bug,而是工程条件。 这一篇要做的,就是把"时灵时不灵"这句经验,翻译成四句可以核对的判断。

先说清楚这一篇和上一篇的关系。第六篇讲的是"接口面长什么样",其中有一样东西当时只提了一句就放下了:内置 add-on。官方在 5.0 发布说明里写过一句很关键的话——

internally only hooks a very limited set of graphics API functions, just enough to make the built-in depth buffer detection work in singleplayer games still.

那句话里"内置的深度缓冲检测"到底是什么、它凭什么"刚好够用",正是这一篇的第一、第二与第六章。

这一篇在系列里的位置也清楚:本篇占的是"深度这一类的工程条件"那一层——左边第五、六篇把抽象层与接口面讲完了,本篇往下钻一层,讲"这一类效果为什么会被条件卡住";右边第八篇讲效果生态与许可,第九、十篇才是自跑。(全系列的地图见下面 §0.1 那张表。)

读那张表的正确姿势是从上往下看:先接受"深度不是取一次就永远有"这个前提,再看下面四章各自把那件事钉在哪个文件上。边界先钉死,后面几章才敢往深里走——不钉死的话,写到 MSAA 就会滑进抗锯齿算法,写到 UI 就会滑进后处理链的顺序设计,那两件事都不属于本篇。

0.1 全系列目录总览(十一篇)

依然是惯例,先给系列地图。本系列沿"一个外部注入的运行时"从入口一路拆到效果生态,十一篇的落点如下(本篇即第七行):

# 篇目 这一篇要回答的那一问
一 总论:注入式后处理与管线内后处理的边界 它到底动的是哪一段?和引擎自带的后处理差在哪?
二 代理 DLL、加载顺序与导出表挂钩 它凭什么进得来?进来之后怎么把调用接过去?
三 ReShade FX:语言血统、独立编译器与纹理语义 效果是拿什么写的?编译器为什么能单独拿出去用?
四 effect runtime:technique / pass 图、render state 与运行时数据源 一个 effect 从源码到画面,中间过了几道?
五 跨 API 抽象:D3D9/10/11/12、OpenGL、Vulkan 的差异与统一 同一套东西怎么盖住六种图形 API?
六 Add-on API:事件模型、跨 API 资源交换与两种构建的分野 想在运行时插手更深的事,接口长什么样?
七 本篇:深度缓冲的工程学——自动关闭、MSAA、UI 缺失与子矩形错位 为什么深度类效果时灵时不灵?
八 效果生态与许可地图:同一份清单里的 MIT 与"禁止再分发" 那些效果到底能不能用、能不能转?
九 自跑一:注入本地 D3D11 demo——逐效果前后对比 真跑一遍是什么样子?
十 自跑二:效果栈与帧耗账,外加自写一个小 .fx 代价是多少?自己写一个要几步?
十一 合规与工程取舍的合集:把十一篇的账并到一处 这条路线值不值得用、边界在哪?

阅读路径:只想知道"我的深度为什么不动"的朋友,读第一章与第二章就够;想搞明白"MSAA 到底动了什么"的,第三章是核心;被"效果糊在 UI 上"困扰的,第四章直接回答。

0.2 本篇要讲清的一件事

先把结论放这儿:"深度时灵时不灵"不是一个可以"修好"的故障,而是四道各自独立的工程条件叠在一起的结果。 这四道条件是:

  • 一道来自运行时策略:检测到网络流量就整体关掉 add-on(第二章);
  • 一道来自图形 API 的限制:多采样深度必须先 resolve,而 resolve 需要设备能力(第三章);
  • 一道来自渲染顺序的事实:界面在深度写入之后才画,所以深度里没有它(第四章);
  • 一道来自检测算法与"子矩形"的冲突:一个深度缓冲可能服务多种分辨率,而"该取哪一次"只能靠启发式(第五章)。

这四道条件的层数完全不同——第一道在 runtime.cpp,第二道在图形 API 的能力位,第三道在游戏的渲染顺序里,第四道在 1,549 行的启发式代码里。把它们混在一起谈,就只能得到"时灵时不灵"这种没有信息量的结论;分开谈,每一道都能给出是/否。

四道条件各自的层数与源码落点
图 7-1四道条件各自的层数与源码落点@ 本系列绘制

这一篇还会补一处上一轮的尾巴:签名版的构建为什么恰好还能做深度检测。第六篇数出签名版在编译期禁掉了 30 个事件,而本篇会数出这个内置深度 add-on 需要 27 个事件——两者的交集是空的。这不是巧合,是设计。

0.3 本篇不涉及什么(边界声明)

依然是惯例,先把边界摆在前面:

不涉及 为什么
深度类效果的算法——SSAO、MXAO、DOF、光追 GI 怎么算 算法归后处理链那一系列;本篇只讲"这类效果拿深度时会遇到什么条件"
抗锯齿算法本身——MSAA/FXAA/DLSS 的实现与画质对比 本篇只在"MSAA 如何影响深度缓冲的可取性"这个角度提它
hook 机制本身——导出函数、vtable、inline hook 归 T12《图形 API Hook 机制》;本篇只在讲"网络计数是怎么来的"时提一句 Winsock
一个具体深度的调参教程——"点哪个选项就能让深度出来" 那是使用向内容;本篇只解释每个选项在源码里对应什么
任何绕过做法——绕过深度限制、绕过网络检测、绕过反作弊 红线。本系列全部实验都在自有开源 demo 上做,商业游戏一次都不碰

参数口径也说一句:系列里所有"本机读数"都来自同一台机器——Windows 11(build 26200)、RTX 3060 Laptop(6144 MiB 显存)、.NET 运行时 4.8 以上。第七篇不新做实验,全部读数分两类:一类是对随篇归档的官方源码原件做离线解析,一类是对官方仓库文件清单做清单级核对。

0.4 四条读法约定(沿用本系列体例)

好了,写这个系列的过程中,有四条约定帮我们避开了四类常见误读,先摊在这里:

  1. 官方文字优先,二手转述只作线索。 凡涉及版本、行为与立场的说法,一律追到官网、发布说明或代码仓库原文;二手内容明标为二手。本篇特别要守这一条——"深度被网络检测关掉"这件事,社区帖里到处都在讲,但判定逻辑与阈值只写在源码里,本篇照源码写。
  2. "社区经验"与"源码事实"必须分开摆。 社区里最流行的三条经验是"关掉 MSAA""检查是不是在联网""试试反转深度"——它们都对,但都不是机制。本篇会把每一条对到它的源码落点上,并指出哪几条其实是对现象的误归因。
  3. "没复现"就是"没复现"。 本机做不到的,一律写"测不了",不拿别人的结论替自己作证。这一条在本篇尤其重要:本机没有签名版与 Addon 版两份二进制,所以"某个效果实际会怎么失败"这类运行期问题,本篇不给结论,只给"编译期被关掉"与"源码里怎么判"这两层。
  4. 读数的口径必须写在读数旁边。 本篇会出现很多"多少行""多少个事件""多少帧"——这些数全部由脚本从归档原件现读,不是估算、不是转述。凡是引用别人的数字,一律注明转述。

这四条在后面几篇里都会再用到。

一、深度检测住在哪:一个 1,549 行的内置 add-on

第一章先解决一个"位置问题"。因为后面四章都会说"这个条件在某某文件里",如果位置本身没定死,四章的证据就没有立足点。

1.1 清单级:405 个文件里只有 2 个

先看看结论:5.0 之后,深度缓冲检测不再是 ReShade 核心的一部分,而是一个内置 add-on。

这个结论不是"翻了几个目录没看到",而是清单级核对出来的。本机把官方仓库的扁平清单取回来,逐个文件名匹配 depth/z_buffer/msaa/multisample/resolve 五个词元:

位置问题:405 个文件里只有 2 个名字含深度,且都在同一个示例目录
图 7-2位置问题:405 个文件里只有 2 个名字含深度,且都在同一个示例目录@ 官方仓库文件清单(jsdelivr 扁平清单,随篇归档,脚本现数)

405 个文件,命中 2 个——examples/09-depth/generic_depth_addon.cpp 与 examples/09-depth/generic_depth.vcxproj。而 source/ 那 72 个文件里,一个都没有。

为什么这件事值得单列一节? 因为它解释了一个常见的误判:很多排障帖会让人去 source/ 里找"深度检测的代码",然后一无所获。代码不在了——它被搬进了 examples/09-depth/,成了示例工程里的内置 add-on。 这个搬动就发生在 5.0:

Reworked depth buffer detection into a built-in add-on, improved the algorithm and made all features available for all APIs.

这句话来自 5.0 的发布说明(官方原文,本机亲核)。注意它同时说了三件事:重构成内置 add-on、算法改进了、所有 API 都能用了。第一件决定了本机要读哪个文件,后两件解释了为什么 5.0 之前"深度工作率低"而 5.0 之后明显变好。

1.2 这个 add-on 有多大

下面进入第一章的第二件事:把它量一下。本机对归档的原件逐行现数:

一个 1,549 行的内置 add-on:27 个事件、8 个配置键、11 处 MSAA 判据
图 7-3一个 1,549 行的内置 add-on:27 个事件、8 个配置键、11 处 MSAA 判据@ 官方源码 examples/09-depth/generic_depth_addon.cpp(随篇归档,脚本现读)· 行号为该文件真实行号

1,549 行——比第六篇那 100 个事件的 addon_event 头文件还长一点。它注册了 27 个 add-on 事件,而注册与注销的数目正好成对(27 对 27),这说明它对自己的生命周期管得很干净。

还有三个数值得先放这儿,第三章会用到:texture.samples > 1 这个判据在文件里出现 11 次,resolve_texture_region 这个调用出现 2 次,备份纹理的销毁时机是 destroy_after_frame = frame_index + 50(源码里那句注释逐字是 Destroy after 50 frames)。

那 50 帧是什么意思? 深度数据不是"取一次就完",而是要在 clear 之前把它拷一份出来存着,供后面所有效果用。拷出来的这份备份什么时候丢?等 50 帧之后——这是一个"保守到有点浪费"的选择:宁可多占一会儿显存,也不要因为提前释放而让某个效果读到已销毁的资源。

1.3 它注册了哪些事件

27 个事件是哪些?本机把它们逐条列出来(按文件里的注册顺序):

init_device · init_command_list · init_command_queue · init_effect_runtime
destroy_device · destroy_command_list · destroy_command_queue · destroy_effect_runtime
create_resource · create_resource_view · init_resource · destroy_resource
draw · draw_indexed · draw_or_dispatch_indirect · dispatch_mesh
bind_viewports · begin_render_pass · bind_render_targets_and_depth_stencil
clear_depth_stencil_view · reset_command_list
execute_command_list · execute_secondary_command_list · present
reshade_begin_effects · reshade_finish_effects · reshade_reloaded_effects

这份名单不是随便挑的,它对应"一个检测器需要知道什么"。 把它按用途分一下类,三类就清楚了:

组 包含哪些 检测器拿它做什么
生命周期(8 个) init_*/destroy_* 的 device、command list、command queue、effect runtime 建立与拆掉"进程级状态表":哪个设备在跑、哪些命令队列要跟踪
资源(4 个) create_resource、create_resource_view、init_resource、destroy_resource 认出哪些资源可能是深度缓冲:只要在创建时看一眼描述(格式、尺寸、用途)
工作量与时机(12 个) draw*/dispatch_mesh/bind_viewports/begin_render_pass/bind_render_targets_and_depth_stencil/clear_depth_stencil_view/reset_command_list/execute_command_list/execute_secondary_command_list/present 判断"这一帧的主场景是哪一个":谁在画、视口多大、什么时候被 clear、什么时候呈上屏
ReShade 专有(3 个) reshade_begin_effects、reshade_finish_effects、reshade_reloaded_effects 和 ReShade 自己的效果链对齐时点

这张分类表是这一篇的骨架之一:后面四章讲的四道条件,本质上都在问"这三组事件里,哪一组的信息不够用了"。

1.4 8 个配置键:这个 add-on 的调节面

言归正传,一个 1,549 行的组件对用户暴露几个旋钮?答案是 8 个,全在 ReShade.ini 的 [DEPTH] 段下。

旋钮的数目本身就是一条信息:这一类"需要用户自己试出来"的组件,通常会把可调的东西压到个位数——8 个就是这个检测器摆在明面上的全部调节面,没有第九个。本机把它们逐个现数出来:

ReShade.ini 的 DEPTH 段的 8 个配置键:1,549 行的组件,用户能拧的旋钮就这些
图 7-4ReShade.ini 的 DEPTH 段的 8 个配置键:1,549 行的组件,用户能拧的旋钮就这些@ 官方源码 examples/09-depth/generic_depth_addon.cpp(随篇归档,脚本现读)· 行号为该文件真实行号

这 8 个键可以二次归成三类,归类本身就把"检测器怎么工作"讲了一半:

类 键 它在管什么
拷贝时机 DepthCopyAtClearIndex、DepthCopyBeforeClears 什么时候把深度拷一份出来存着
筛选 DrawStatsHeuristic、UseAspectRatioHeuristics、FilterFormat、FilterResolutionWidth、FilterResolutionHeight 从几百个候选里挑哪一个当"主深度"
格式策略 DisableINTZ 要不要禁用 Intel 的特殊深度格式(INTZ)

第二类有 5 个键,占了多数——这本身就是一条信息:这个检测器的大部分复杂度都在"筛选"上。第五章会把这件事展开。

1.5 三组枚举:检测器的"词汇表"

说白了,筛选要有判据,判据用的就是枚举。本机把文件里的三组枚举逐字抄了出来:

enum class draw_stats_heuristic : unsigned int   { prefer_vertices = 0, vertices, drawcalls };
enum class aspect_ratio_heuristic : unsigned int { none = 0, similar_aspect_ratio,
                                                   multiples_of_resolution,
                                                   match_resolution_exactly,
                                                   match_custom_resolution_exactly };
enum class clear_op : uint8_t                    { clear_depth_stencil_view, fullscreen_draw,
                                                   unbind_depth_stencil_view };

三组枚举各说一件事:

  • draw_stats_heuristic:判断"哪一份快照更像主场景"时,比顶点数还是比绘制次数?默认值是 prefer_vertices,而它的实现是一个混合判据——源码里那句话写得很明白:如果间接绘制次数不到总绘制次数的三分之一,就比顶点数;否则比绘制次数(因为用了间接绘制时顶点数不准)。
  • aspect_ratio_heuristic:宽高比检查做到多严?从"不检查"到"必须与自定义分辨率完全相等",一共五档。默认是 similar_aspect_ratio。
  • clear_op:clear 事件有三种类型,检测器对它们的处理不一样——这一点第五章会展开。
三组枚举:绘制统计 3 档、宽高比 5 档、clear 类型 3 种
图 7-5三组枚举:绘制统计 3 档、宽高比 5 档、clear 类型 3 种@ 官方源码 examples/09-depth/generic_depth_addon.cpp(随篇归档,脚本现读)· 行号为该文件真实行号

读到这儿,第一章的"位置问题"就有答案了:深度检测的全部逻辑住在 examples/09-depth/generic_depth_addon.cpp 这一个文件里,它有 1,549 行、注册 27 个事件、对外只有 8 个配置键,而它的复杂度主要集中在"从候选里挑一个"这件事上。

那"自动关闭"呢? 它不在这里。这是第二章要讲的、也是这一篇最反直觉的一处。

二、自动关闭:关它的代码不在深度那边

2.1 先纠一个直觉

上一章最后留了一句:自动关闭不在 generic_depth_addon.cpp 里。这不是推测——本机在那个文件里搜 network 与 multiplayer 两个词,零命中。

这一节的图就是那条链条的全貌:它从 Winsock 上的字节计数开始,到纹理语义被改绑成空视图结束;中间那三步——窗口、阈值、翻假——各自落在核心的哪个文件里,下面四处逐一展开。

自动关闭:从累加字节到改绑纹理语义
图 7-6自动关闭:从累加字节到改绑纹理语义@ 本系列绘制

所以正确的问法是: 既然关它的不是深度模块,那是谁?答案有两层:判定在核心的 runtime.cpp 里,而它的输入是 Winsock 上的字节计数。这两样都不在 examples/09-depth/ 里——一个深度效果失效,起因可以完全落在它自己的模块之外。

先把链条摆出来(这一段是本篇的第一个"四处源码",后面会逐处展开):

Winsock 的 send / recv 族被挂钩 → 每收发一次累加 g_network_traffic
        ↓
runtime.cpp 每帧检查:这一帧有没有流量?→ 有就给 traffic 计数加一
        ↓
每 61 帧结算一次:traffic < 10 ?
        ↓
否 → addon_enabled = false,并把所有纹理语义绑定改绑成空

2.2 第一处:Winsock 上的字节计数

先来看看链条的起点,它在 source/windows/ws2_32.cpp。本机现读到的第一条是:

volatile long g_network_traffic = 0;

这一个全局变量就是全部"网络检测"的数据源。它在 Winsock 的 send/recv 族函数里被累加——本机现数到 8 处 InterlockedAdd(&g_network_traffic, …)(八个 Winsock 钩子各一处):

InterlockedAdd(&g_network_traffic, lpBuffers[i].len);
InterlockedAdd(&g_network_traffic, *lpNumberOfBytesRecvd);
InterlockedAdd(&g_network_traffic, num_bytes_sent);

注意它累加的是"字节数",不是"连接数"或"包数"。 这个选择有实际后果:一个游戏即使只在单机跑,只要它后台在同步云存档、拉排行榜,字节数就会涨。

不过这处实现里有一条很关键的豁免,本机在第 13 行读到它(逐字):

// Do not count network traffic for local sockets (localhost), so to avoid blocking occuring in games running local servers in single player too

"本地回环不计"——这条豁免解决的是"单机游戏自己开本地服务器"这一类。5.0 发布说明的修 bug 列表里也有一条与此对应:Fixed network detection mistaking traffic on localhost for genuine network traffic。

字节计数从哪来、哪条豁免、以及两个永远开不了的开关
图 7-7字节计数从哪来、哪条豁免、以及两个永远开不了的开关@ 官方源码 source/windows/ws2_32.cpp、source/dll_main.cpp、source/runtime_gui.cpp(随篇归档)· 行号为各文件真实行号

同一段的另外三处也在这里一并列出(都来自随篇归档的原件):

位置 逐字内容 作用
source/dll_main.cpp:293 if (!GetEnvironmentVariableW(L"RESHADE_DISABLE_NETWORK_HOOK", nullptr, 0)) 一个环境变量:设了它就完全不去挂钩网络
source/dll_main.cpp:301 g_network_traffic = std::numeric_limits<long>::max(); // Special value to indicate that add-ons should never be enabled 一个哨兵值:把计数直接顶满,等价于"永远不要启用 add-on"
source/runtime_gui.cpp:3249 ImGui::TextColored(COLOR_YELLOW, _("High network activity discovered.\nAll add-ons are disabled to prevent exploitation.")); 用户看到的那句话

那张 UI 串值得读两遍:All add-ons are disabled——注意是 add-ons(复数),不是"深度被关了"。它关的是整个 add-on 层,深度检测只是恰好住在这一层里的一个组件而已。这就是"关它的代码不在深度那边"的第一层含义。

2.3 第二处:61 帧的窗口与 ≥10 的阈值

有了字节计数,接下来要有判定。判定在 source/runtime.cpp 第 953 行起,本机把整段逐字抄出来:

#if RESHADE_ADDON == 1
    // Detect high network traffic
    extern volatile long g_network_traffic;

    static int cooldown = 0, traffic = 0;
    if (cooldown-- > 0)
    {
        traffic += g_network_traffic > 0;
    }
    else
    {
        const bool was_enabled = addon_enabled;
        addon_enabled = traffic < 10;
        traffic = 0;
        cooldown = 60;
        ...
    }
关掉深度检测的那段代码:61 帧一个窗口,10 帧有流量就整体停掉
图 7-8关掉深度检测的那段代码:61 帧一个窗口,10 帧有流量就整体停掉@ 官方源码 source/runtime.cpp(随篇归档)· 行号为该文件真实行号

这段只有十来行,但每条都是可核的判据:

  • 窗口长度 61 帧:cooldown = 60 意味着结算之后要等 60 帧,加上结算那一帧本身,一个窗口是 61 帧。以 60 fps 算,大约 1 秒钟。
  • 计数口径是"帧"不是"字节":traffic += g_network_traffic > 0; 累加的是一个布尔值——这一帧有没有流量。所以它数的是"61 帧里有几帧在通信"。
  • 阈值是 10:addon_enabled = traffic < 10;——61 帧里有 10 帧以上在通信,就关。反过来,只要 51 帧是安静的,深度就还在。
  • 判定结果落在 addon_enabled 这一个全局标志上:整套机制只有一个出口。

这个阈值解释了社区里很多"奇怪"的观察:比如"我在单机里深度也没了"——如果那个单机游戏每帧都在跟后端通信(在线验证、云存档),61 帧里凑够 10 帧并不难。

也解释了另一条:"我切出去看了一下网页,回来深度就没了。"——切出去的时候游戏可能没在渲染,回来之后的第一秒里如果后台有同步流量,判定就会翻。

2.4 第三处:深度为什么是"突然没了"

好了,判定翻假之后会发生什么?本机在第 968–981 行读到的是这一段(逐字摘):

        if (addon_enabled != was_enabled)
        {
            if (was_enabled)
                _backup_texture_semantic_bindings = _texture_semantic_bindings;

            for (const auto &binding : _backup_texture_semantic_bindings)
            {
                ...
                update_texture_bindings(binding.first.c_str(), addon_enabled ? binding.second.first : api::resource_view { 0 }, addon_enabled ? binding.second.second : api::resource_view { 0 });
            }
        }

关键在最后那个三元表达式:addon_enabled ? 原来的视图 : api::resource_view { 0 }。状态一变,就把纹理语义逐个改绑到一个空视图。

为什么深度是突然没了,而不是变黑
图 7-9为什么深度是突然没了,而不是变黑@ 本系列绘制

这件事对效果的后果很值得说清:深度类效果看到的是"没有这个输入",而不是"输入是黑的"。

  • 如果深度被替换成一张全黑的纹理,你会看到"效果还在按黑深度工作"——多半是糊成一片或者全屏压暗;
  • 而现在是把绑定整个撤掉,效果拿到的是一张空贴图——表现是"整片效果消失"或"报错跳过"。

这就是"突然没了"的准确含义。 它不是渐变的,也不是画面变黑,而是一次状态翻转:从"有深度"跳到"没有深度",中间没有过渡。

顺带说一处细节:翻假的时候它会把当时的绑定存一份备份(_backup_texture_semantic_bindings),翻真的时候再装回去。所以"网络安静下来之后深度自己回来了"是真的,但不是"功能恢复了",而是"绑定装回去了"。

2.5 第四处:这件事只签名版做

还有一层必须点明:上面那段判定裹在 #if RESHADE_ADDON == 1 里。

这句话要把第六篇的结论接过来用:RESHADE_ADDON = 1 正是签名版(Release Signed)那个配置的取值。往下看一层——

构建 RESHADE_ADDON 做不做网络检测
签名版(官网主下载) = 1 做(本段代码被编译进去)
全量版(Addon 版) = 2 不做
测试构建(Release App) 未定义 不存在 add-on 面,无从谈起

这与 5.0 发布说明完全对得上(官方原文):签名版 When it detects a multiplayer game, this too is disabled completely,而 Addon 版 does not do any multiplayer detection to disable features。

所以"深度被网络关掉"这个现象,是签名版特有的。 如果你在用 Addon 版却遇到了深度消失,那原因不在这里——应该往第三、四、五章那三道条件上找。这是本篇给出的第一条实用判据。

2.6 小结:这一章的四处落点

言归正传,把第二章收一下。它回答的是"深度为什么会被关",答案是四处源码:

处 文件与位置 它是什么
① source/windows/ws2_32.cpp:60 + 8 处 InterlockedAdd 字节计数的来源(含"本地回环不计"的豁免)
② source/runtime.cpp:953 起 61 帧窗口 × "有流量的帧数" ≥ 10 的判定
③ source/runtime.cpp:968 起 翻假时把纹理语义改绑成空 → 效果"突然没了"
④ 同一段的 #if RESHADE_ADDON == 1 这件事只签名版做

四处里没有一处属于 examples/09-depth/。 一个深度效果失效,原因可以完全在它自己的模块之外——这是第一章那句"位置问题"的真正价值。

三、MSAA:三条不同层次的处理

第三章换一道条件。MSAA 是社区里被讲得最多、也讲得最含糊的一条:几乎所有排障帖都写"关掉 MSAA 就好了"。这句话在 5.0 之前基本对,在 5.0 之后只能说对了一半。

3.1 MSAA 的问题在哪

下面进入机制。先把 MSAA 做了什么说清楚:MSAA(multisample anti-aliasing)的做法是:不是每个像素只有一个深度值,而是每个像素有几个子样本、各自带深度(本系列的图形学一篇讲过,"深度测试是对每个子样本做的,所以深度缓冲的大小要按倍数涨")。这句话的后果很直接:

  • 无 MSAA 时,深度缓冲是每像素一个值;
  • 开了 4x MSAA 时,深度缓冲是每像素四个值。

对深度检测器来说,这意味着一件事:它不能用"整块拷走"的办法拿到深度。 一份四采样的深度资源,拷到任何"单采样"的地方都会失败——必须先解算(resolve):把四个子样本合成一个值。

先看一眼"MSAA 到底改了什么画面",再谈它对深度的后果:

MSAA 与 FXAA 的抗锯齿效果对比(原图 212×300,只作原理小图)
图 7-10MSAA 与 FXAA 的抗锯齿效果对比(原图 212×300,只作原理小图)@ Wikimedia Commons · Phreneticc(CC BY-SA 4.0)

这张图给的是"效果级"的对照:同一条边缘,MSAA 与 FXAA 各自把锯齿压到什么程度。它帮读者建立"MSAA 在做什么"的直觉,但它不是本篇的主角——本篇关心的是同一件事在深度缓冲上留下的后果,而那是"效果图"看不出来的部分。

社区那句"MSAA 会挡住深度"的原始出处也在这儿。 作者在兼容性帖里给过一次精确解释(二手引述):It prevents ReShade from binding the grabbed depth buffer to the shaders, making it unusable.——"抓住了但绑不上去"。抓住 ≠ 能用,这句话是理解第三章的钥匙。

3.2 三条不同层次的处理

那么 5.0 的检测器是怎么处理的?它没有只用一招,而是在三个不同的层次各做了一件事。 本机现数到的判据分布:

MSAA 的三层处理:备份侧降采样、取用侧先 resolve、筛选侧没能力位就整条跳过
图 7-11MSAA 的三层处理:备份侧降采样、取用侧先 resolve、筛选侧没能力位就整条跳过@ 官方源码 examples/09-depth/generic_depth_addon.cpp(随篇归档,脚本现读)· 行号为该文件真实行号

第一处:备份纹理侧——"我要的是一张单采样图"(源码第 258–264 行,逐字):

        if (desc.texture.samples > 1)
        {
            desc.texture.samples = 1;
            desc.usage |= resource_usage::resolve_dest;
        }

它在创建备份纹理之前,把描述里的采样数改成 1,并且标记这张图要当 resolve 的目标。所以备份永远是单采样的——后面所有效果拿到的都是单采样深度,不必自己处理多采样。

第二处:取用侧——"MSAA 就先解算,别直接拷"(源码第 459–467 行):

            if (desc.texture.samples > 1)
            {
                assert(device->check_capability(device_caps::resolve_depth_stencil) && (desc.usage & resource_usage::resolve_source) != 0);

                cmd_list->barrier(depth_stencil, resource_usage::depth_stencil_write, resource_usage::resolve_source);
                cmd_list->resolve_texture_region(depth_stencil, 0, nullptr, depth_stencil_backup->backup_texture, 0, 0, 0, 0, format_to_default_typed(desc.texture.format));
                cmd_list->barrier(depth_stencil, resource_usage::resolve_source, resource_usage::depth_stencil_write);
            }

这三行是本篇最"教科书"的一段:先加屏障把资源从"深度写入"切到"解算源",做 resolve_texture_region,再加一道屏障切回去。两道屏障不能省——深度资源和普通贴图不一样,GPU 需要明确的用途声明才能安全地在两种用途之间切换。

第三处:筛选侧——能力不够就整条缺席(源码第 979–980 行,逐字):

        if (info.desc.texture.samples > 1 && !device->check_capability(device_caps::resolve_depth_stencil))
            continue; // Ignore multisampled textures, since they would need to be resolved first

这是三个层次里"最狠"的一处:如果设备不支持深度解算,那这张多采样深度纹理就根本进不了候选池。注释写得很直白:Ignore multisampled textures, since they would need to be resolved first。

MSAA 下的三层处理与各自的分工
图 7-12MSAA 下的三层处理与各自的分工@ 本系列绘制

这三层不是"三个备选方案",而是"三道叠加的关卡":第一层决定备份长什么样,第二层决定怎么拿,第三层决定"要不要这张"。任何一层断了,效果都拿不到深度——但断的方式不同:第一、二层断了会走断言或跳过,第三层断了是静默缺席(候选池里就没有它)。

3.3 一条硬边界:resolve_depth_stencil

第三层里那个能力位值得单列,因为它是本篇第三章真正的"边界":

device->check_capability(device_caps::resolve_depth_stencil)

这是一个设备能力查询,不是一个配置项。 这两句话要分开看:

  • 用户改不了它——不支持的驱动就是不支持;
  • 它决定了"MSAA 下能不能用深度"这件事,而不是"画质好不好"。

这也解释了为什么社区那句"关掉 MSAA"要分版本说:

版本 MSAA 下的深度 依据
5.0 之前 基本不可用 没有解算路径,抓住也绑不上
5.0 能发现但不能用 5.0.2 的更新条目:Added notice when MSAA depth buffers were found
6.0 起 能用(前提是驱动支持) 6.0 更新条目:Added support for resolving MSAA depth buffers in D3D9 (using RESZ), D3D12, OpenGL and Vulkan (when supported by the driver)

所以"关掉 MSAA"这条经验,在 6.0 之后的更新版上已经不是唯一答案了——如果驱动支持解算,MSAA 开着也能拿到深度。这处版本差异,社区帖子里基本没人更新,本篇按发布说明写。

把它当一张"边界图"看更清楚:这一节的三个层次里,只有第三层是用户与驱动都改不了的:

一条硬边界:resolve_depth_stencil
图 7-13一条硬边界:resolve_depth_stencil@ 本系列绘制

这张图的读法是从下往上看:最底下是驱动能力(改不了)→ 中间是检测器的三层处理(只能绕,不能改)→ 最上面是用户在 UI 上看到的两处反馈(一个标签、一个禁用的勾选框)。三者之间没有"调一下就好"的中间态,这也是第三章最该记住的一句。

3.4 顺带纠一处:MSAA 的"通知"是什么

5.0.2 那条 Added notice when MSAA depth buffers were found 的"通知",在源码里也有落点。本机在第 1342 行与第 1312 行读到:

            info.desc.texture.samples > 1 ? " Multisampled" : "",
        if (info.desc.texture.samples > 1 && !device->check_capability(device_caps::resolve_depth_stencil)) // Disable widget for multisampled textures

第一行给条目加标签,第二行直接禁用勾选。 那个"通知"不是一个弹窗,而是深度缓冲列表里多了一个 Multisampled 的字样,并且那一项点不动。

这条比弹窗好用:它把"为什么这一项不能选"直接写在了那一项旁边。

3.5 采样点分布:MSAA 与 supersampling 不是一回事

第三章最后补一个容易混的概念。检索素材的时候,本机在 Commons 上找到一族很漂亮的"采样点分布"图——N-Rooks、Quincunx、Checker、RGSS、Poisson Disc……它们的文件名里写的是 supersampling,不是 MSAA。

采样点分布图案(N-Rooks,原图 420×420)——这是 supersampling 的采样模式,不是 MSAA 的样本覆盖
图 7-14采样点分布图案(N-Rooks,原图 420×420)——这是 supersampling 的采样模式,不是 MSAA 的样本覆盖@ Wikimedia Commons · Vierge Marie(Public domain)

两者的差别值得用一句话说清:

  • supersampling(SSAA):把整帧渲染到更大的分辨率再缩回来——每个输出像素对应多个完整样本,这些样本都着色;
  • MSAA:只在几何边缘的像素上多放几个样本,而且这些样本共享一次着色(除非是覆盖到不同三角形)。

为什么这个区别对本篇重要? 因为"MSAA 到底动了深度缓冲的什么"这件事,答案取决于采样点在哪里:

维度 SSAA MSAA
样本分布 每个像素内规则/抖动网格 只在被几何覆盖的像素上,每像素固定位置的样本
深度缓冲 整帧多份 每像素多份(本像素的样本位置对应的深度)
与深度的关系 缩回来时深度已经没了(在色彩空间里平均) 深度需要单独解算(不能平均,要挑一个代表值)

最后一行是 MSAA 的独家麻烦:颜色的多采样可以"平均"(那就是抗锯齿本身),而深度的多采样不能平均——把 4 个深度值取平均会得到一个"物理上不存在"的深度。所以深度解算必须用 MIN/MAX 或"取第 0 号样本"这类非平均的模式。

这也解释了官方在 6.0.1 修的那一处:Changed D3D12 depth-stencil resolve to prefer average filtering over min/max——在 D3D12 上它改成了偏好平均。这个改动与"深度不该平均"的直觉相反,原因得看 D3D12 的解算模式限制(D3D12_RESOLVE_MODE 里 MIN/MAX 对深度是允许的,AVERAGE 对非整数格式才是允许的,而深度格式正属于此类)。

四、UI 缺失:深度缓冲里本就没有 UI

第四章换第三道条件。这一道和前两道有个根本差别:前两道是"能不能拿到",这一道是"拿到的东西里本来就没有它"。

4.1 顺序决定一切

先给结论:深度缓冲里没有界面,这件事不是 ReShade 造成的,是渲染顺序的必然。

正常的游戏渲染一帧,大致是这个顺序:

1. 清空深度与颜色
2. 画世界几何(此时写入深度)
3. 画透明物体(读深度、不写深度)
4. 画界面 / HUD(通常不写深度,或者干脆关掉深度测试)
5. 呈上屏

界面在第 4 步,而深度缓冲的主要内容在第 2 步就写完了。 如果界面不写深度(绝大多数游戏如此),那么深度缓冲里就只有世界几何——界面在深度图上根本不存在。

一帧的渲染顺序:深度在第 2 步就定稿
图 7-15一帧的渲染顺序:深度在第 2 步就定稿@ 本系列绘制

所以"深度里没有 UI"是设计目标,不是缺陷。 对深度类效果来说,这恰恰是好事——SSAO、DOF、MXAO 这些效果本来就是冲着"世界几何"去的,如果深度里混进了 HUD,阴影和环境光就会算到界面上去。

但另一面就是那条常见的抱怨:"为什么我的 MXAO 在 UI 上也有一层暗影?"

4.2 深度图长什么样

先来看看深度图本身,要理解这个抱怨得先看一眼它。深度缓冲不是一个抽象概念,它就是一张图——一张灰度图,越近越亮(或越远越亮,取决于方向)。

一张灰度深度图的实际样子(原图 640×480,只作原理小图)
图 7-16一张灰度深度图的实际样子(原图 640×480,只作原理小图)@ Wikimedia Commons · IzE_Design(Public domain)

这张图值得盯着看两秒。它没有纹理、没有颜色、没有光照——只有距离。 这就是所有深度类效果唯一的输入。

看清楚了就明白第四章的核心矛盾:这张图里只有世界。界面不在里面,天空盒可能是最远处那一片平坦的灰,透明物体基本不出现。效果只看得见这张图,所以它也只能对这张图里的东西作出反应。

4.3 为什么"糊在 UI 上"

好了,那"糊在 UI 上"是怎么发生的?答案在后处理的顺序里。

ReShade 的效果链挂在 present 之前——也就是上面那段流程的第 5 步之前。在它跑的时候,界面已经画进颜色缓冲了,但深度缓冲里没有界面。

于是效果面对的是一组"错配"的输入:

  • 颜色缓冲:世界 + UI(UI 已经画上去了);
  • 深度缓冲:只有世界(UI 不在里面)。

效果按深度判断"这里是远处的地面",于是给它加了环境光遮蔽;可这个像素上现在画着的其实是 UI。 结果就是暗影落在了界面上。

这不是 bug,是"两个输入的时间点不一致"。 颜色是第 4 步之后的,深度是第 2 步之后的——它们差了两步。

这也是为什么它没法在 ReShade 这一侧"修好":效果手上只有这两张图,它能做的只是"猜哪一块是 UI"。

4.4 三种处置与它们的代价

说白了,面对这件事,实际可选的路只有三条。本机把它们和代价列在一起:

处置 做法 代价
不管 接受界面被影响 UI 上会有一层不该有的效果
UI 遮罩(UI mask) 对界面不变的游戏,预先划出界面区域,让效果跳过 只适用于静态 UI;界面一变就要重划
等深度里出现 UI 指望游戏把界面也写进深度 极少见;写进去之后反而会污染 SSAO/DOF

第二条是本系列里唯一"能用"的方案,但它的适用面被第一句限制死了:界面不变。一个会弹提示、会换 HUD 的游戏,遮罩就得跟着改。

社区里还有一条更硬的说法值得引一次。 那位第三方效果的作者在论坛上写过(二手引述):

if an object doesn't show up in the depth buffer, it's invisible to any depth shader.

"深度缓冲里没有的东西,对任何深度着色器来说都是不存在的。" 这句话把第四章的全部内容压成了一句。他还举过一个更具体的例子:某个游戏在小地图打开时,深度缓冲里会出现一个白方块——那是"界面写进了深度"的反例,而且是污染型。

所以第四章的结论是一条判据:遇到"效果糊在 UI 上",不要去找 ReShade 的 bug——去看那个游戏的界面是不是写深度。写了,就是上面表格第三行那种污染;没写,就只能用遮罩,或者接受它。

五、子矩形错位:启发式为什么会选错

第五章是四道条件里最复杂的一道。前四章讲的都是"能不能拿到",这一章讲的是"拿到的是不是我要的那一张"。

5.1 一个深度缓冲,多种分辨率

先把问题的形状说清楚。一个游戏在一帧里可能创建几十个深度资源,但其中只有一个(或几个)是"主场景深度"。检测器要从这几十个里挑一个出来,靠的是启发式——而启发式会看一件事:宽高比。

为什么要看宽高比?因为一个深度资源可能被用到完全不同的分辨率上。源码里的注释直接点了名(逐字):

Mass Effect 3 in Mass Effect Legendary Edition sometimes uses a larger common depth buffer for shadow map and scene rendering, where the former uses a 1024x1024 viewport and the latter uses a viewport matching the render resolution

同一张深度缓冲,先用 1024×1024 的视口画阴影图,再用"与渲染分辨率一致"的视口画主场景。 如果检测器只看"这张图有多大",它就无法判断当前这一次到底是阴影还是主场景。

本机把那个判据函数逐字抄了出来:

宽高比判据:容差 0.1、缩放比 0.5–1.85、近似整数倍 0.02,共 3 处调用
图 7-17宽高比判据:容差 0.1、缩放比 0.5–1.85、近似整数倍 0.02,共 3 处调用@ 官方源码 examples/09-depth/generic_depth_addon.cpp(随篇归档,脚本现读)· 行号为该文件真实行号

函数的判据是一条双条件(源码第 372–378 行):

    float w_ratio = width / width_to_check;
    float h_ratio = height / height_to_check;
    const float aspect_ratio_delta = (width / height) - (width_to_check / height_to_check);

    // Accept if dimensions are similar in value or almost exact multiples
    return std::abs(aspect_ratio_delta) <= 0.1f && ((w_ratio <= 1.85f && w_ratio >= 0.5f && h_ratio <= 1.85f && h_ratio >= 0.5f) ||
        (s_aspect_ratio_heuristic == aspect_ratio_heuristic::multiples_of_resolution && std::modf(w_ratio, &w_ratio) <= 0.02f && std::modf(h_ratio, &h_ratio) <= 0.02f));

两个条件缺一不可:

  1. 宽高比之差 ≤ 0.1:16:9(1.778)与 4:3(1.333)差 0.44,直接排除;16:9 与 1920×1088(1.765)差 0.013,通过。
  2. 两个方向上的缩放比都在 [0.5, 1.85] 之间——尺寸不能差太多:一半到 1.85 倍是可接受的,再往外就认为"这不是同一件事"。

为什么这两个数要写得这么具体? 因为它们是被具体游戏逼出来的:

  • 下限 0.5、上限 1.85 是"阴影图与主场景共存"的合理范围(1024×1024 对 1080p 大致落在里面);
  • 0.1 的宽高比容差 是为了容忍"同样 16:9 但高度差几像素"的情况——第五章最后会讲那个"多 1 像素"的真实案例。

这处判据也不是总是开启的。 它的严格度由 [DEPTH] UseAspectRatioHeuristics 控制,一共五档(第一章那张枚举表里的 aspect_ratio_heuristic):从"完全不检查"一直到"必须与自定义分辨率完全相等"。默认是 similar_aspect_ratio——也就是上面那两个条件的形式。

5.2 视口与子矩形:为什么会有"分辨率不一样"

先来看看一个更基础的问题。上一节说到"同一张深度缓冲服务两种分辨率",读者可能会问:一张图怎么会服务两种分辨率? 答案在视口(viewport)这个概念上。

视口是"把归一化设备坐标(NDC)映射到渲染目标的哪一块矩形"的那一步。换句话说,同一张渲染目标,可以被切成任意多个子矩形分别渲染:

视口变换:把 NDC 映射到渲染目标的子矩形(原图 816×384)
图 7-18视口变换:把 NDC 映射到渲染目标的子矩形(原图 816×384)@ Wikimedia Commons · Martin Kraus(CC BY-SA 3.0)

这张图解释了第五章的根源:渲染目标是一张图,而视口是"这张图上的哪一块"。 于是同一张深度缓冲:

  • 可以用 全屏视口 渲染主场景;
  • 也可以用 角上一小块 渲染小地图、水下效果、镜面反射;
  • 还可以用 一张小图(比如 1024×1024)当阴影图,然后用它渲染到全屏。

"子矩形错位"这个说法的准确含义就在这里:检测器看到的是一张深度资源,但它服务的是哪一个子矩形,取决于当帧的视口。视口一会儿大一会儿小,检测器如果只看"资源本身多大",就会选错。

5.3 四个具名游戏的补丁

下面进入最有说服力的一节:源码注释里直接写了游戏名。 本机把四处逐字抄出来:

四个具名游戏的补丁:Mirror's Edge、Portal、Mass Effect 3 (LE)、Garry's Mod
图 7-19四个具名游戏的补丁:Mirror's Edge、Portal、Mass Effect 3 (LE)、Garry's Mod@ 官方源码 examples/09-depth/generic_depth_addon.cpp(随篇归档,脚本现读)· 行号为该文件真实行号
游戏 注释逐字(节选) 对应的补丁
Mirror's Edge occasionally render something into a small viewport (16x16 in Mirror's Edge, ...) 视口太小就不拷贝:last_viewport.width > 1024
Portal (512x512 in Portal to render underwater geometry) 同上
Mass Effect 3(LE) uses a larger common depth buffer for shadow map and scene rendering 走 check_aspect_ratio 判据
Garry's Mod Special case for Garry's Mod which chooses the last clear operation that has a high workload do_copy = current_stats.vertices >= 5000;

这四处合起来,把"子矩形错位"讲透了:

  • 前两个是"太小的视口":16×16 和 512×512 都远小于 1080p。检测器如果老老实实按"谁画的次数多"来选,就会把这种小视口的 clear 当成主场景的 clear。所以它加了一条闸门——本机在第 414 行读到:
        do_copy = current_stats.last_viewport.width > 1024 || (current_stats.last_viewport.width == 0 || depth_stencil_backup->frame_width <= 1024);

"视口宽度超过 1024 才拷贝"——这条闸门的代价是可预期的:真正的主场景如果也被渲染到小于 1024 的视口里(比如游戏内部分辨率缩放),这条规则就会把它一起筛掉。

  • 第三个是"两种分辨率共存":这正是 5.1 节那段注释讲的情形,处理办法是走宽高比判据。
  • 第四个是"Garry's Mod 特例":它不走宽度判据,而是走顶点数——>= 5000 才算主场景。一个游戏一条特例,这就是启发式的真实形态。

读到这里应该有一个清醒的认识:这套检测器不是"算法",而是"一批具体案例的堆积"。 它的 1,549 行里,相当一部分是在处理"某个游戏会怎么出人意料"。这既是它能工作的原因,也是它必然不完美的原因。

5.4 为什么"时灵时不灵"

第五章最后回答一开始那个问题。"时灵时不灵"的机制层原因,是"该取哪一次"这条规则会随帧漂移。

具体地说,检测器在"自动模式"下的做法是:把当前帧里所有 clear 事件的信息攒起来,挑一个"最有可能是主场景"的。这个"挑"用的判据,就是 5.3 节那些——比顶点数、比绘制次数、看视口宽度、看宽高比。

问题在于:这些量每帧都在变。

  • 玩家视角一转,可见的物体变了,vertices 与 drawcalls 就变了;
  • 场景里多了一个反射面,就多了一次 clear;
  • 阴影图与小地图的渲染次数也可能随场景变。

于是同一条规则,这一帧挑中的是 A,下一帧挑中的是 B。 在读者眼里,那就是"深度时不时就不对了"。

为什么时灵时不灵:规则随帧漂移
图 7-20为什么时灵时不灵:规则随帧漂移@ 本系列绘制

社区把这件事归因给"游戏 bug"是常见误判。 官方在 PR 里给过机制层的说明(二手引述):某一次修复的动机是 because the number of clearing instance evolves on each frame——"clear 的次数每帧都在变"。这句话就是 5.4 节的全部内容。

这也解释了为什么"固定一个 clear 索引"这个选项有用。 回头翻第一章那 8 个配置键,DepthCopyAtClearIndex 的作用就是把"自动挑"换成"固定挑第几个":

  • 值为 0(默认):自动检测;
  • 值为正数 n:固定取第 n 次 clear;
  • 值为 INT32_MAX:走 Garry's Mod 那种"末次高负载 clear"的特例。

用户手动固定一个索引,等于把"随帧漂移"这件事关掉。 代价是:如果那个游戏的主场景 clear 次数本身不稳定,固定索引就会稳定地选错。这是一次纯粹的两难,没有"更好的默认值"。

5.5 一像素的经典案例

好了,第五章收尾,讲一个本机在社区里核到的最小、也最有说服力的案例。

有一个玩家报过这样的问题(二手引述):某个游戏里有 3 个深度模板,其中 2 个的高度比窗口多 1 像素,1 个与窗口等高;而"应该选的那一个",恰好是多 1 像素的那个。检测器按"最匹配"去挑,挑了等高那个——于是深度全错。

1 像素的差别,让整个深度选择翻车。

这处案例的价值在于它把 5.1 节那两个判据解释清楚了:

  • 宽高比容差 0.1 是从哪来的:1080p 的宽高比是 1.7778;高度多 1 像素(1081)之后是 1.7761——差 0.0017,远小于 0.1。所以这两个候选在"宽高比"这一关上是同时通过的,检测器必须靠别的判据来分。
  • 为什么"必须完全相等"会成为一个选项:match_resolution_exactly 那一档就是为这类游戏准备的——不要容差,要求像素级相等。但它的代价同样明显:一旦游戏做了任何分辨率缩放,这一档就什么都选不中。

"子矩形错位"这个词,说的就是这一类:不是差得离谱,而是差得刚好——刚好在容差之内、刚好在两个候选之间。

六、与第六篇接上:27 ∩ 30 = 空

第六章把这一篇和第六篇接起来。这件事在第五章结束时已经埋了伏笔:签名版在编译期禁掉了一大批事件,那深度检测凭什么还能用?

6.1 一边是 30 个被禁事件

先来看看第六篇那份名单,读数搬过来:第六篇现数出:签名版(RESHADE_ADDON = 1)在编译期禁掉了 30 个事件,手段是 source/addon_manager.hpp 里的两处 static_assert(13 + 17 = 30),错误串是:

"Event that is disabled with limited add-on support was used!"

那 30 个事件的共同特征,第六篇里的原话是:"被砍掉的全是'能改渲染命令本身'的那一类。" 具体包括 bind_pipeline/bind_pipeline_states/push_constants/push_descriptors/bind_descriptor_tables/bind_index_buffer/bind_vertex_buffers/bind_stream_output_buffers/barrier/map_*/unmap_*/copy_*/update_*/resolve_texture_region/generate_mipmaps/begin_query/end_query/get_query_heap_results 等等。

注意最后几个"拷贝与查询"类——它们在名单里,这件事第六章会用到。

6.2 另一边是 27 个被需要的事件

另一边,本篇第一章现数出:内置深度 add-on 注册了 27 个事件(init_*/destroy_*/create_resource/create_resource_view/init_resource/destroy_resource/draw*/dispatch_mesh/bind_viewports/begin_render_pass/bind_render_targets_and_depth_stencil/clear_depth_stencil_view/reset_command_list/execute_command_list/execute_secondary_command_list/present/reshade_*)。

把它们按用途分一下,就是第一章那张四组分类表的来源:生命周期组八个、资源组四个、工作量与时机组十二个、ReShade 专有组三个。现在的问题是:这四组里的每一条,签名版都留着吗?

现在把两个名单做交集。 本机用的是第六篇归档的探针读数(那 30 个名单)与本篇现数的 27 个名单:

两栏并置:签名版禁掉的 30 个事件 ∩ 深度检测注册的 27 个事件 = 0(空集)
图 7-21两栏并置:签名版禁掉的 30 个事件 ∩ 深度检测注册的 27 个事件 = 0(空集)@ 第六篇与本篇的探针读数(随篇归档,脚本现读)

结果是:交集为空。27 个里,一个都没落在那 30 个里——签名版砍掉的那一批,与这个检测器需要的这一批,重合的部分是零。这个零不是碰巧凑出来的:它是"哪一类事件能被砍"这条设计边界的直接后果。

6.3 交集为空意味着什么

说白了,这不是巧合,是设计。它同时解释了四件事:

第一,它解释了 5.0 发布说明那句"just enough"。 官方原话是签名版 internally only hooks a very limited set of graphics API functions, just enough to make the built-in depth buffer detection work in singleplayer games still。第六章这一节的交集为空,就是"just enough"的机制层含义:签名版留下的那一小部分 hook,恰好覆盖了深度检测需要的全部事件。

第二,它解释了"为什么是这 27 个"。 反过来看:如果深度检测需要注册 resolve_texture_region 这个事件的回调(它在 30 个名单里),那么签名版就会编译不过——而它其实不需要注册这个事件,它只需要调用这个 API 方法。

这处细微差别很关键,值得单独说清:

事 签名版能不能做 为什么
注册 resolve_texture_region 事件的回调 不能 它在两处 static_assert 的名单里,编译期就报错
调用 cmd_list->resolve_texture_region(...) 能 那是 command_list 对象的方法,不是事件回调;与 add-on 事件的开关无关

"注册事件"与"调用 API"是两件事。 第三章那两处 resolve 调用之所以在签名版里也能跑,正是因为它是后者。

第三,它给了一条判断某段代码"能不能在签名版里跑"的实用办法:看它注册了哪些事件,而不是看它调用了什么。注册的事件全部落在那 30 个之外,那段代码就能在签名版里编译通过。

第四,它解释了为什么"内置"这个词这么重要。 一个第三方 add-on 如果想做深度检测,它要注册的事件多半也在那 27 个里(因为需要的都是一样的信息)——所以它其实也编译得过。那为什么检测器要做成"内置"?

因为内置的才能被签名版带着走。 回想第六篇的读数:签名版不加载任何外部 add-on(ReShadeRegisterAddon 对非内置模块直接拒绝,源码里那句判定是 info->handle != g_module_handle)。内置的意思不是"代码在核心目录里",而是"它不受'不加载外部 add-on'这条限制"。

第六章把前面五章串起来了:第二章说网络检测只签名版做,第六章说深度检测刻意留在签名版可用——两件事都落在同一个宏 RESHADE_ADDON 上。这个宏一手砍功能、一手保功能,而"保下来的那部分"正好是深度。

七、两个常被混进这一题的概念

第七章处理两处"高频道听途说"。它们都不是本篇的四道条件,但经常被当成条件讲,不把它们分开会说乱。

7.1 三个宏:REVERSED、UPSIDE_DOWN、LOGARITHMIC

先来看看社区里出现频率最高的三条建议——一定是这三个词:

  • RESHADE_DEPTH_INPUT_IS_UPSIDE_DOWN:设成 1,修"深度图上下颠倒";
  • RESHADE_DEPTH_INPUT_IS_REVERSED:设成 1,修"深度图远近反了";
  • RESHADE_DEPTH_INPUT_IS_LOGARITHMIC:设成 1,修"深度范围不对"。

本机在这一节要纠一个很常见的误会。 不少人以为"这三个是 ReShade 里的开关"。本机在归档的 source/ 与 include/ 全量搜 RESHADE_DEPTH_INPUT_IS/IS_REVERSED/REVERSED/LOGARITHMIC——零命中。

它们是着色器侧约定的宏。 逐条说清楚:

事 事实
谁定义它 用户——在 ReShade 的 [GENERAL] PreprocessorDefinitions 里自己填,或者在预设文件里写
谁读它 效果文件(.fx)——效果在编译时读这个宏,决定怎么解释深度
核心代码管不管它 不管。ReShade 不定义、不校验、不自动设置这三个宏

ReShade 真正自动注入的宏是另一族(本机在 runtime.cpp 第 1651–1665 行读到,逐字):

        pp.add_macro_definition("__RESHADE__", ...);
        pp.add_macro_definition("__RESHADE_PERMUTATION__", ...);
        pp.add_macro_definition("__RESHADE_PERFORMANCE_MODE__", ...);
        pp.add_macro_definition("__VENDOR__", ...);
        pp.add_macro_definition("__DEVICE__", ...);
        pp.add_macro_definition("__RENDERER__", ...);
        pp.add_macro_definition("BUFFER_WIDTH", ...);
        pp.add_macro_definition("BUFFER_HEIGHT", ...);
        pp.add_macro_definition("BUFFER_COLOR_BIT_DEPTH", ...);

注意这一族里没有"深度方向"。 __RENDERER__ 与 __DEVICE__ 是硬件标识,BUFFER_* 是分辨率——ReShade 告诉效果"你在什么硬件上、画布多大",但不告诉它"深度是正是反"。

这个分工不是疏漏,是必然。 深度方向取决于游戏怎么建那张深度缓冲,ReShade 只能"观察到"而不能"知道"——所以它把这个判断交给了效果作者,用宏的形式让作者去试。

这就解释了为什么"三个宏"在社区里是万能答案、在源码里却找不到:它们本来就不在源码里。这一处是本篇要给读者的第二条件实用判据:如果某个教程让你"去 ReShade 里改三个宏",那只是在说"让效果换个方式读同一张深度"——它不会让被网络关掉的深度回来,也不会让 MSAA 下的深度出现。

7.2 一处必须澄清的口径:LOGARITHMIC 不是万能修复

那三个宏里,最容易被滥用的是 LOGARITHMIC。社区经常建议"开上它试试";而第三方效果的作者明确反对过(二手引述):

LOGARITHMIC ... the formula will cause the depth buffer to lose a lot of precision ... it is recommended to leave this at 0 at all times.

"它会损失精度,建议恒为 0。" 换句话说,LOGARITHMIC 是修特定游戏的对数深度格式用的,不是通用优化——在正常的线性/反转深度游戏上开它,只会让精度变差。

对照一下另外两个:

宏 什么时候真的需要 误用的后果
UPSIDE_DOWN 深度图上下颠倒(少数后端/翻转 Y 的情形) 深度图方向更乱
REVERSED 游戏用了反转深度(近处值大、远处值小) 深度图远近反了
LOGARITHMIC 游戏真的写了对数深度 精度下降——作者建议恒为 0

这张表值得和第五章横向看:第五章那四道条件都是"拿不到"或"选错了",而这三个宏是"拿到了但读法不对"。两类问题必须分开诊断——这也是本篇在边界声明里写"不写调参教程"的原因:读法是效果侧的事,条件是运行时侧的事。

7.3 深度精度与 z-fighting

第七章最后补一个背景概念:为什么"深度的值"这么容易出问题。 关键词是 z-fighting。

z-fighting 的现象很好认:两个几乎共面的多边形,渲染出来是一片细碎闪烁的斑。原因是它们的深度值在深度缓冲的精度里无法区分——差值小于一个深度单位的可表示间隔。

两个重叠立方体的 z-fighting 对照(原图 941×435)
图 7-22两个重叠立方体的 z-fighting 对照(原图 941×435)@ Wikimedia Commons · CompuHacker (talk)(Public domain)

这张图里两组立方体摆在几乎相同的位置上,一组出现细碎斑点,另一组干净。差异只来自它们的深度值能不能被区分。

这件事和本篇的四道条件有什么关系? 关系在"精度预算"上:

  • 非反转深度(近处值小)的精度分布极不均匀:绝大部分精度挤在近处,远处几乎没有——16 位深度缓冲下,远平面附近可能出现"整整几十米共用一个深度值";
  • 反转深度(近处值大)把精度翻过来,让远处也有可用的分辨率——这就是 NVIDIA 那篇《Depth Precision Visualized》给出的结论:"在透视投影下,直接用反转 Z 的浮点深度缓冲"。

为什么这值得放一篇关于"工程条件"的文章里? 因为它说明深度类效果对深度的要求不止是"有没有",还有"够不够"。

一个具体后果:检测器拷出来的是那张深度缓冲的原样(copy_resource 或 resolve_texture_region),它不做精度提升。所以如果游戏用的是 16 位深度,效果拿到的也是 16 位的粗糙深度——这时候再怎么调效果的参数也补不回精度。

这一处也顺便解释了一个现象:同一个效果在 A 游戏上干净、在 B 游戏上"有一圈圈阶梯"——那多半是两张深度缓冲的位深或精度分布不同,而不是效果本身的问题。

八、本机的口径与边界

第八章照例把"能说什么、不能说什么"钉死。

8.1 没有两个二进制

下面进入本篇最硬的一条边界。本机只有一份 ReShade 运行环境,没有"签名版"与"Addon 版"两份二进制可以对比。

所以下面这类问题,本篇不给结论:

  • 「签名版里,一个深度效果实际会怎么失败?」——测不了;
  • 「网络检测翻假之后,画面是黑一帧还是直接静默?」——测不了(源码只告诉我们"绑定被撤掉",不告诉我们"第几帧开始");
  • 「Addon 版里深度会不会更稳?」——测不了(源码告诉我们它不做网络检测,但运行期表现不在本机可测范围)。

能给的只有两层,而这两层本篇都给足了:

层 能说到什么程度 本篇的落点
编译期 哪些代码在哪个构建里被编译进去、哪些在编译期就报错 第二章那处 #if RESHADE_ADDON == 1;第六章那两处 static_assert
源码逻辑 判定的输入、阈值、输出分别是什么 第二章的 61 帧/≥10;第三章的三层处理;第五章的宽高比判据

这条边界看起来是"少说了",实际是"把话说实了"。 一个"可能在签名版下会失败"的推断,价值远低于"这段代码只在 = 1 时编译进去"这句事实。

8.2 一处工具自纠

第八章还有一处要照实写:本篇用来做检索初筛的脚本,第一版判准偏高。

阶段 2 的题域初筛脚本(evidence/build/ep07_screen_pool.py)按标题词元判"题域内/题域外",它给出的是 96 条题域内。但人工复核之后,这 96 条里有一批是假命中:

假命中 为什么被误收
Benesi - Pine Tree ... 8K zbuffer.png 等一批 8000×8000 的图 标题含 zbuffer——但那是用 z-buffer 渲染出来的分形艺术,与深度缓冲的工程条件无关
Beringia ... snow depth ...(雪深地图) 标题含 depth——那是地理数据
Subpixel-Rendering*.JPG(次像素字体渲染) 标题含 anti-aliasing 的词元近邻——那是 ClearType,与 3D 抗锯齿无关
Tudor-stuart scissor handle(剪刀实物) 标题含 scissor——"子矩形"那个 scissor 与"剪刀"同名

这篇记录的意义有两个:一是说明"自动筛一遍就能定论"是不成立的——词元匹配会把所有"词形撞车"收进来;二是说明那份 318 条候选池里真正可用的部分很小,这一点已经在 material-inventory.md 与 render-assets-plan.md 里如实登记(题域外 222 条,占 69.8%)。

这与本篇的选题判断是一致的:这一题的外部素材本来就薄,初筛偏偏又是机器判不准的地方——所以"薄"这个结论是两条通道互相印证之后得到的,不是单点判断。

8.3 一版时间线

好了,第八章最后把散在各章的版本信息拼成一条时间线。年份口径取自官方发布说明的页面标识(本机亲核 5.0 与首页;6.0 的条目引自发布说明原文,标为转述):

版本 与深度有关的变化 依据
5.0(2021) 深度缓冲检测重构成内置 add-on;算法改进;所有 API 都能用 发布说明原文(本机亲核)
5.0.0 修 Fixed network detection mistaking traffic on localhost for genuine network traffic 同上
5.0.2 Added notice when MSAA depth buffers were found——MSAA 深度会被发现并标注 同上
5.2 [DEPTH] 新增"帧内拷贝深度"选项(D3D12/Vulkan),用于抖动时的取舍 发布说明(转述)
6.0 Added support for resolving MSAA depth buffers in D3D9 (using RESZ), D3D12, OpenGL and Vulkan (when supported by the driver) 发布说明(转述)
6.0.1 修 D3D12 MSAA 下 "Failed to close immediate command list";Changed D3D12 depth-stencil resolve to prefer average filtering over min/max 发布说明(转述)

这条时间线有两条叙事线:一条是精度(加解算),一条是兼容(一个游戏一个补丁)。前者解决"理论上能不能",后者解决"这个游戏上能不能"——第五章那张四个具名游戏的表就是后者的样本。

九、三条结论与路线图

9.1 本篇三条结论

言归正传,这一章只给三条结论,每条都带数。

结论一:深度不是"取一次就永远有"的资源,它受四道条件约束,而四道条件的落点全在深度模块之外。

三条结论与它们各自的判据
图 7-23三条结论与它们各自的判据@ 本系列绘制

四道条件的落点,本机逐个查过:

条件 落点 是不是 examples/09-depth/
自动关闭 source/runtime.cpp + source/windows/ws2_32.cpp + source/dll_main.cpp + source/runtime_gui.cpp 不是(四处都在核心)
MSAA examples/09-depth/generic_depth_addon.cpp(三层处理)+ 图形 API 的能力位 是(但受能力位约束)
UI 缺失 游戏的渲染顺序(第 2 步定稿、第 4 步才画 UI) 不是(在游戏那边)
子矩形错位 同上的 1,549 行启发式 是

四道里有两道完全不在深度模块里。 这就是本篇开头那句"位置问题"的最终答案。

结论二:一套看似"算法"的检测器,实际是 1,549 行的具体案例堆积。

1,549 行里能数出来的具体案例至少四处:Mirror's Edge 的 16×16、Portal 的 512×512、Mass Effect 3(LE)的 1024×1024、Garry's Mod 的 vertices >= 5000。没有一个是"通用算法",每一条都对应"某个游戏会怎么出人意料"。

这个形态决定了它的上限:新游戏永远可能撞上没见过的模式,所以"时灵时不灵"不会消失,只会被一条条补丁压得更窄。第五章 5.4 节那条"clear 次数随帧漂移"就是它的下限——连同一个游戏内部都不稳定。

结论三:RESHADE_ADDON 这个宏一手砍功能、一手保功能,而保下来的正好是深度。

砍的那一手:签名版编译期禁掉 30 个"插手渲染命令"的事件。保的那一手:内置深度检测需要的 27 个事件与那 30 个的交集为空,所以它照样编译得过、跑得起来。第六章那处空集,就是官方那句 "just enough" 的机制层解释。

9.2 从这一篇往后怎么走

  • 第八篇(效果生态与许可地图) 接的是"效果从哪来"这条线——本篇只在第六章用了"一个第三方 add-on 要注册哪些事件"这个角度,那些效果各自的许可与可分发性,留给第八篇;
  • 第九、十篇(自跑) 接的是"真跑一遍"这条线——本篇所有读数都是离线解析源码得来的,一次实验都没做;实际注入与前后对比在第九、十篇;
  • 第十一篇(合规与取舍合集) 会把本篇这四道条件并进合集的"代价"那一栏。

9.3 三类读者的最短路径

  • 只想修自己那个游戏:读第一章(确认代码在哪)+ 第二章 2.5 节(先判"你是不是签名版")+ 第三章那三条处理(判"是不是 MSAA")。这三处的判据都是"是/否";
  • 想知道这套东西怎么工作:第一章(规模与枚举)+ 第三章(三层处理)+ 第五章(启发式与具名补丁)。第五章那四个游戏是最有信息量的一节;
  • 想接着上一篇读代码:第三章 3.3 节(能力位)+ 第六章(27 ∩ 30 = 0)。第六章是本篇与第六篇的接缝。

9.4 本篇与后面几篇的接口

  • 本篇讲"深度这类效果会被什么条件卡住",第八篇讲"这些效果从哪来、能不能用":两篇共用"add-on 注册事件"这个机制(第六篇的产物),但本篇不涉及任何效果的许可与来源;
  • 本篇的读数全部是离线的,第九、十篇的读数是运行期的:两边的口径在第九篇开头会再交代一次,本篇的"测不了"那一节(8.1)是它的前置声明;
  • 第二、三章那两处"只签名版做"与"签名版恰好保留",在第十一篇的取舍栏里会合成一条:安全策略与功能可用性是同一个开关的两面。

资源清单

官方源码(本篇引用并随篇归档,逐行可复核):

官方页面(本机亲核):

官方论坛与 PR(本篇标注为二手引述的部分):

第三方效果与竞品文章(只引述,不转载):

图形 API 与通用技术权威:

外部示意图(Wikimedia Commons,图注内已逐张署名):

本系列前六篇(跨篇指代的落点):

下篇预告

下一篇是《效果生态与许可地图:同一份清单里的 MIT 与"禁止再分发"》——把"这些效果从哪来、能不能用"讲透。本篇提到的那些深度类效果(SSAO、MXAO、DOF、光追 GI),会在下一篇里被按许可分成几档:MIT 的可以自由改,"禁止公开再分发"的只能自己用,而 Patreon 付费的那一类连获取路径都不写。里面有一条和第二、三章直接相关:为什么有些效果要求"深度必须可用",而另一些能退化成"没有深度也跑"。

写在最后

好了,这一篇是系列里最"工程"的一篇:它讲的不是画面,也不是接口,而是四个条件。分层、判据、落点——三件事说清了,"深度时灵时不灵"就不再是一句玄学;反过来,这三件事里任何一件没搞清,遇到问题都会以"深度又没了""效果糊在 UI 上""怎么调都不对"这三种形式回报你。希望能对大家有所帮助——尤其是那句"深度被网络关掉",它的判定其实只有十来行,而这十来行一直在 runtime.cpp 里,只是很少有人去翻。