《GPU Zen 3: Advanced Rendering Techniques》第 7 章中文译稿,第二篇。原文作者:Jakub Knapik、Giovanni De Francesco、Dmitrii Zhdan、Edward Liu、Evgeny Makarov、Jon Kennedy、Juho Marttila、Michael Murphy、Nathan Hoobler、Tim Cheblokov、Pawel Kozlowski。
系列阅读: 第一篇:Ray Tracing · 第二篇:Path Tracing(本篇) · 第三篇:AI 图像重建与 Frame Generation
7.8 迈向 Physically Based Lighting
经过数十年研究与开发,传统 Rasterization 已能生成令人信服的画面。但它所依赖的投影算法有一个根本缺陷:G-buffer 只包含相机可见的几何信息,每个像素接收到的光却取决于整个场景的几何体。为了模拟阴影和间接光照等全局交互,Rasterization Pipeline 只能依赖 G-buffer 中有限信息的 屏幕空间 技术、预先把光照烘焙进资产(制作成本高,且只适用于静态现象),或建立 世界空间 数据结构近似全局光照。这些方案各自独立,场景规模扩大后,大多数都难以保持效率。
计算这些全局光照效果,都要先解决可见性问题。阴影要判断像素对应表面能否看见光源;间接光照既要判断次级表面的光照,也要判断主表面能否看见该次级表面。
投影算法的关键局限在于,除计算大成像平面上的 Primary Visibility 外,其他可见性测试都非常低效:每次测试都要投影所有可能可见的三角形。计算相机的 Primary Visibility 时,这项成本可以有效摊销——一次三角形投影可能覆盖许多像素,所有三角形经历相同投影,最终每个像素也都会显示给玩家。但把同样的做法用于单个像素的可见性测试,这个优势就消失了。例如,对每个像素都用投影判断灯光可见性,深度缓冲计算成本先要乘以像素数,再乘以灯光数。即便只有一盏灯的简单场景也不现实,更不用说有几十、几百盏灯的实际场景。Shadow Map 改为从光源视角近似求解可见性,仍然要投影场景中的全部三角形。
投影算法还有一点与现实不符:它在概念上并不对应光的传播方式。更符合物理的思路,是从每个像素发出一条光线,找到最近的相交三角形;这一几何过程可类比光子的传播。理论上仍要考虑场景中的所有三角形,但空间划分数据结构使 光线与三角形相交测试 只需检查数量大致随总量呈对数增长的三角形。这就是 Ray Tracing;它更适合求解一般性的可见性,也是许多 Physically Based Rendering 算法的基础。《赛博朋克 2077》采用的 Path Tracing 便是其中之一。要理解它,可以先从描述光照问题的渲染方程(Rendering Equation)看起。
7.8.1 背景:Rendering Equation
James Kajiya 于 1986 年提出 渲染方程,用以准确描述表面朝特定方向发出的光量 [Kajiya 86]。它同时考虑表面自身发出的光、照射到表面的全部光,以及决定反射方式的材质属性。点 x 朝方向 ωₒ 发出的光 Lₒ 可写为:
Lₒ(x, ωₒ) = Lₑ(x, ωₒ) + ∫Ω fᵣ(x, ωᵢ, ωₒ) Lᵢ(x, ωᵢ) (ωᵢ · n) dωᵢ
其中,Lₑ 是点 x 沿 ωₒ 自身发出的光;fᵣ 是 BRDF,描述从 ωᵢ 入射的光有多少会沿 ωₒ 反射;Lᵢ 是从 ωᵢ 抵达 x 的光量;ωᵢ · n 表示入射方向与表面法线 n 的夹角项。积分域 Ω 是该点上方所有可能入射方向构成的半球,积分将这些方向的入射光汇总起来。
这里的关键是 Lᵢ。它既取决于场景中的光源,也取决于其他几何体是否遮挡光线,或是否把光反射到当前表面。光从光源直接抵达点 x,称为直接光照(图 7.29);先被其他物体反射再抵达点 x,称为间接光照(图 7.30)。Lᵢ 可能包含间接光照,所以 渲染方程 隐含递归关系。例如,要计算镜面反射的光,必须先求出镜中可见表面的 渲染方程。也就是说,计算一个点的光照,还要计算许多其他点的光照,问题很快就复杂起来。

x 的直接光照。光从光源射出,沿入射方向 ωᵢ 抵达表面点 x,再沿出射方向 ωₒ 反射至相机。
x 的间接光照。光先从次级表面点 x′ 反射,再抵达主表面点 x;抵达主表面前,也可能经过多个次级表面。更麻烦的是,需要求解的点有无限多个。光可能从无限多的方向抵达一个点,再反射到相机;若表面并非完全不透明,光还可能从内部到来,需要考虑的方向就又多了一组。沿这些方向命中的场景点,又各自需要以同样方式递归求解。
除了最简单的场景,渲染方程 的解析解在计算上一直不可行。Kajiya 因此提出以概率方法求解,也就是 Path Tracing。
7.8.2 什么是 Path Tracing?
Path Tracing 在几何上把光表示为场景中不断反弹的光线。每条光线从相机成像平面的一个像素出发,延伸到它命中的第一个可见表面。表面通常由三角形表示,所以需要高效计算 光线与三角形相交测试;通过空间划分数据结构组织三角形,光线只需与靠近自身路径的三角形测试相交。
找到表面后,路径追踪器(Path Tracer)接下来要做的是:计算它有多少光会反射回这个像素。它不会穷举所有方向,而是从表面沿一个随机生成的新方向再发出一条光线;命中下一个表面后,又以同样方法近似求解该处的 渲染方程。如此递归,直到命中光源、离开场景或达到其他终止条件。最终得到的是沿场景中一条路径反射回某个像素的光量,“Path Tracing”因此得名。
算法不会只计算这一条随机路径。在所有可能贡献最终出射光的路径中,它只是一个样本。路径追踪器会继续生成许多这样的路径,再对结果求平均值,估计渲染方程的真实结果。
从数学上看,Path Tracing 构造的是光照真实值的估计量(Estimator)。若它无偏,其期望值就等于真实值;单个样本仍可能偏离真实值,随着样本增多,平均结果才会逐渐接近正确图像。设计 Path Tracer 的第一目标,是保证 估计量 无偏,使结果在平均意义上正确;更难的目标则是让它迅速收敛,因为 Path Tracing 天然较慢。
7.8.3 Path Tracing 为什么天然较慢?
开销主要来自以下几个方面。首先,借助空间划分数据结构追踪光线,虽远比光栅化整个场景高效,仍需要大量计算和内存访问。光线与三角形相交测试 的数学运算无法省略,三角形与数据结构也要从内存读取;为求值 BRDF 和执行 Alpha Test,还要读取材质属性和纹理。主表面 的读取往往具有较好的空间一致性,因为相邻像素很可能在着色同一表面;次级表面 则相反,相邻像素随机生成的出射光线很少指向同一表面。
第二,光源通常只占场景中很小的一部分。想想普通房间:灯具相对于墙、地板、天花板和家具,只占很小的面积。从表面沿随机方向发射的光线,命中光源的概率因而很低。要找到足够多条命中光源的路径,就得追踪大量路径。这也就是 Path Tracing 收敛慢的原因。
加快收敛主要有两条路:在同样计算时间内提高有效样本数,或一开始就提高样本质量。相比光线追踪操作本身,这两方面都有更大的优化空间。下一节介绍 Reservoir-based Spatio-Temporal Importance Resampling(ReSTIR):它增加有效样本数,并尽可能重用高价值样本;7.10 节介绍 Radiance Caching 如何改善初始采样质量。两者都基于同一个观察:与其随意发射光线,不如优先选择最有可能贡献光照的方向。

7.9 用 ReSTIR 提高有效样本数
以均匀概率选择出射光线方向有两个优点:容易实现,而且无偏。但最大缺陷也很明显——被选方向很可能没有光照贡献(图 7.31a)。重要性采样(Importance Sampling)可以大幅提高选中有效方向的机会。
重要性采样 不再均匀采样,而是让预计贡献较大的区域获得更高的概率密度(图 7.31b)。例如,一种分布可优先选择直指光源的方向;另一种则依据表面材质,优先选择更可能把光反射回相机的方向——光泽表面可偏重镜面反射方向。
但如果直接改变采样概率,又不修正结果,估计量 就会有偏:频繁选中的方向在样本平均值中占比过大。重要性采样 因此按采样概率的倒数对贡献加权,抵消非均匀采样带来的偏差。例如,某方法选中指向光源方向的机会是其他方向的 10 倍,来自这些方向的光照值便要相应缩小 10 倍。
若事先掌握许多场景细节,设计 重要性采样 较容易;要适用于一般场景则困难得多。常见的两种通用策略,一是偏重指向光源的方向——毕竟所有光最终都来自光源;二是依据材质属性,偏重最可能产生反射贡献的方向,比如镜面或光泽表面的反射方向。某方案可以用 40% 的概率选灯光方向、40% 选表面 Specular Lobe 附近的方向、20% 选其他方向,求最终平均值时再对各项作相应校正。只要每项策略本身无偏,按策略划分采样空间后仍可保持无偏。
不过,要从每项策略都获益,Path Tracer 仍必须抽取多个样本。在这里按策略分别取样的方式下,若有 n 项策略,至少要抽取 n 个样本,才有机会覆盖每一项。这样虽能明显提高样本质量、缩短收敛时间,要得到高质量画面仍需大量样本。下一步便是把多种 重要性采样 策略合入同一次采样。
7.9.1 Resampled Importance Sampling(RIS)
构建重要性采样分布时,需要先决定把哪些信息算进去。这并不容易。例如,除了优先指向光源,还可以按每盏灯的功率(亮度)调整概率。这种分布每帧计算一次,采样成本不高。但若希望优先采样可见灯光,就必须为每个 首次命中 点分别求出其可见灯光集合:每个点到每盏灯都要发射 Visibility Ray,代价很大。同样,若按灯光经表面反射后的贡献采样,就要在每个 首次命中 点对每盏灯计算 BRDF,并重新建立分布。如果把这些信息都算进去,构建采样分布的开销可能已经接近直接求解渲染方程,采样也就失去了性能优势。
RIS 把采样拆成几步,逐步加入计算成本更高的信息。先从采样成本低的分布中抽取大量初始样本,再只针对这些样本计算成本较高的信息,构建第二个分布,最后从中选出一个样本。这样得到的样本比第一步直接抽到的更好,又不必完整计算第二个分布所需的全部信息。
例如,先从按灯光功率排列的分布抽取 10 个样本(图 7.32a),再对它们求值 BRDF,计算向出射方向反射的光量,形成第二个分布(图 7.32b);最后从中选出下一条入射光线的方向(图 7.32c)。这个方向比只依据灯光功率抽取的样本更好,同时又不必计算场景中所有灯光的 BRDF 贡献,开销更小。

RIS 也带来了存储问题:第一步抽出的样本要保存下来,而 GPU 的存储资源很有限。放在寄存器中,会减少可同时运行的 Warp 或 Shader Thread;放在显存中,会增加内存流量;放在 Group Shared Memory 等中间存储里,则可能同时带来这两类开销。因此,GPU Path Tracing 要高效实现 RIS,就必须大幅降低存储开销。加权蓄水池采样(Weighted Reservoir Sampling,WRS)可以降低这部分开销。
7.9.2 Weighted Reservoir Sampling(WRS)
WRS 可从一系列带权重的样本中选出一个,无须同时保存完整序列。它只维护一个很小的蓄水池(Reservoir),其中包括当前选中样本、与其被选概率成正比的权重,以及目前为止所有已处理样本的权重总和。每来一个新样本,先将其权重加入总和,再用该样本权重除以更新后的总和,得到它替换 Reservoir 中现有样本的概率。最终,某个样本被选中的概率等于自身权重除以全部样本权重之和。
有了 WRS,就可以一边构建分布、一边采样,只占用很少的内存。把它与 RIS 结合起来,就是《赛博朋克 2077》Path Tracer 初始采样流程的基础。
7.9.3 结合 RIS 与 WRS
结合起来看,对每个像素,先从易于求值的简单分布中选一个样本,例如按灯光功率加权的灯光方向;再对该方向求值 BRDF,得到真正沿入射光线反射回来的贡献。这个贡献成为样本权重,与灯光方向一起存入像素的 Reservoir。随后重复这个过程:先以较低成本选方向,再花更多计算量求值 BRDF,把后续样本依次送入 Reservoir。达到预设的初始样本数后,Reservoir 中保留的样本便近似服从同时考虑灯光功率和 BRDF 的分布。
这套组合也很灵活。初始样本数量可按性能或画质目标调整;各阶段分布也可替换,而不必改动算法整体结构。例如,如果灯光功率并非好的采样启发式,可以改用 均匀采样,BRDF 计算保持不变;也可以让初始分布同时考虑灯光功率与到相机的距离。可以从表面向灯发射 Shadow Ray,给部分或全部样本计算实际可见性;可以用计算成本较低的 BRDF 近似代替完整求值;甚至可以一开始就按 BRDF 比例抽取方向,完全放弃预选灯光方向。
WRS 还有一个实用特点:可以把多个 Reservoir 合并为新的 Reservoir,让结果样本按其权重占合并后总权重的比例被选中。因此,各类光源可分别处理,再汇入同一个 Reservoir。例如,一个 Pass 处理 Point Light,另一个处理 Area Light,第三个处理 Environment Light,第四个处理 Infinite Light,第五个处理 BRDF 决定的方向;它们独立计算,最后合并。
《赛博朋克 2077》的 Path Tracer 起初按灯光功率和到相机的距离为初始样本加权。但实践中,这种策略并不比 均匀采样 好,因为灯光功率和接近相机的程度都不能有效代表最终光照贡献。于是,初始样本改为从灯光列表均匀抽取;第二阶段再用灯光的 BRDF 贡献形成 Reservoir 权重。
7.9.4 ReSTIR DI
RIS 与 WRS 的组合可以高效采样直接光照方向,但对以 60 FPS、甚至 30 FPS 运行的现代游戏而言,单靠它仍不足以得到理想画面。现代场景过于复杂,即便从精心设计的分布中抽样,Path Tracer 仍需要更多样本。简单增加每帧样本数,直到画面看起来足够好,成本高昂,在当代硬件上也不可行。事实上,Path-Traced 图像质量的收敛速度与样本数的平方根成正比;若想让画质指标提高一倍,原始样本数就得增至四倍。
前面讨论的都是怎样生成更好的新样本。其中有些样本对最终图像价值很高。实时渲染具有时空连续性,这些高价值样本对后续帧像素和邻近像素也可能同样有用。如果相邻像素和前后帧能够共享样本,就能提高每像素的有效样本数,而无须继续增加每个像素真正生成的初始样本数。《赛博朋克 2077》的 Path Tracer 使用 ReSTIR DI 做到这一点。
ReSTIR DI,即 Reservoir-based Spatio-Temporal Importance Resampling for Direct Illumination [Bitterli et al. 20],通过额外两个 Render Pass,在时间和空间上对 Reservoir 进行概率重采样。时域重采样 Pass(图 7.33)把每个像素重投影到上一帧,判断对应表面是否与当前像素的表面足够相似。若相似,便把上一帧的 Reservoir 与当前 Reservoir 合并,并以与先前样本质量成正比的概率选用它对应的灯光。这样,样本质量和数量会随时间积累。

空间重采样 Pass(图 7.34)检查每个像素的邻居;若邻居的表面与当前表面足够相似,也按同样方式合并 Reservoir。空间重采样使每像素有效样本数成倍增长。时间与空间重采样结合,使每帧有效样本数呈指数级增加,显著缩短收敛时间。

ReSTIR DI 能显著改善直接光照,却没有解决间接光照。后者通常更难:不像直接光照,间接光源的集合无法事先确定。

7.9.5 ReSTIR GI
前面的灯光采样解决的是某一点的直接光照;渲染方程 还要求计算间接光照,即光在抵达当前点之前,至少先从另一表面反射一次(图 7.35a)。为此,研究人员提出用于 Global Illumination 的 ReSTIR GI [Ouyang et al. 21]。
ReSTIR GI 的思路是:先计算某个间接点的出射光,再把这个点视为光源(图 7.35b)。Path Tracer 从主表面发出光线,但与 ReSTIR DI 不同,光线不瞄准灯光,而是按均匀分布或 BRDF 采样,命中另一个表面。命中的次级表面成为样本点,然后估计它接收到的光。最简单的情况只估计该点的直接光照,也可以进一步纳入多次 Bounce 的间接光照。样本点、它向主表面发出的 Radiance,以及少量额外变量,都像 ReSTIR DI 一样存入 Reservoir。
间接样本存入 Reservoir 后,ReSTIR GI 开始重采样:先重采样上一帧的 Reservoir,随时间积累样本质量;再重采样邻近像素,提高每帧有效样本数。每一步都要检查偏差可能从哪里来,再根据计算成本决定是否修正。例如,照亮某个表面的光线路径集合,不一定与照亮其邻居的集合相同;一个采样域的样本到了另一个域可能无效。ReSTIR 可以从当前表面向邻居重采样得到的次级表面发射 Visibility Ray,修正这项偏差;也可采用成本更低、准确性略差的修正方法,以性能换精度。重采样与偏差修正结合后,画质比普通间接 Path Tracing 明显改善。
7.9.6 实际应用中的 ReSTIR GI
ReSTIR DI 只处理直接光照,其 Shader 只需访问待照亮的主表面、供采样的光源,以及用于可见性测试的场景几何体(Alpha 材质还需要材质信息)。因此,引擎从 Rasterization 迁移到 Path Tracing 时,它适合先行实现。ReSTIR GI 则另外需要 Path Tracer 生成间接样本。《赛博朋克 2077》也是按这个顺序开发的:先建立间接光照 Path Tracer,再在其上加入 ReSTIR GI。
由于需要 Path Tracing Pass,ReSTIR GI 的计算成本高于 ReSTIR DI。最初,《赛博朋克 2077》把漫反射与镜面间接光照拆成各自的 Path Tracing Pass 和 ReSTIR GI Pass,成本因此翻倍。优化后,两类 Path Tracing 合为一个 Pass,随机选择其中一个 Lobe 采样。初始样本随后送入漫反射和镜面两套 ReSTIR GI 管线。只要调整概率计算以反映源分布的不同(清单 7.2、7.3),画质仍明显好于基线 Path Tracer。
清单 7.2|调整 ReSTIR GI 样本概率的辅助函数。
// The general idea of this function is to estimate diffuse probability
// but also clamp it to 1/20 pixels (unless full metal).
float EstimateDiffuseProbability(SurfaceData baseSurface, float3 viewDir)
{
float roughness = baseSurface.Roughness;
float NoV = abs(dot(baseSurface.Normal, viewDir));
// [Hirvonen et al. 19]
float3 Fenv = EnvBRDFApprox(baseSurface.Specular, roughness * roughness, NoV);
float lumSpec = RGBToLuminance(Fenv);
float lumDiff = RGBToLuminance(baseSurface.Albedo * (1.0 - Fenv));
float lumSum = lumDiff + lumSpec;
float diffProb = lumSum > 0.f ? lumDiff / lumSum : 1.f;
if ((0.f < diffProb) && (diffProb < 1.f))
{
diffProb = clamp(diffProb, 0.05f, 0.95f);
}
return diffProb;
}
清单 7.3|调整 ReSTIR GI 样本概率。初始样本由 Path Tracing Pass 存入 initReservoir,随后在第一个 ReSTIR GI Pass 中按下列方式调整其概率参数。
float3 N = primarySurf.normal;
float3 L = normalize(initReservoir.position - primarySurf.worldPos);
float3 V = primarySurf.viewDir;
float3 H = normalize(V + L);
float NoV = saturate(dot(N, V));
float NoH = saturate(dot(N, H));
float roughness = primarySurf.surface.Roughness;
float alpha = roughness * roughness;
float alphaSquared = alpha * alpha;
float diffProbability = EstimateDiffuseProbability(primarySurf.surface, V);
float diffPdf = EvalCosHemispherePdf(N, L);
float specProbability = 1.f - diffProbability;
// See [Heitz 18]
float specPdf = SampleGGXVNDFReflectionPdf(alpha, alphaSquared, NoH, NoV);
float pdf = diffuseProbability * diffPdf + specProbability * specPdf;
inputReservoir = RTXDI_MakeGIReservoir(initReservoir.position,
initReservoir.normal, sampleRadianceHitDist.rgb, pdf);
译注:清单 7.3 声明的是
diffProbability,计算diffuseProbability。原书如此,代码按原样保留。
《赛博朋克 2077》的引擎对原版 ReSTIR GI 做了两项重要改动。第一项是限制样本保留的时间:随着时间推移,场景和视角变化,旧样本更可能失效,表现为间接光照更新滞后。原版实现增加 Validation Pass;我们发现,把样本的历史长度直接限制为八帧,就能得到相近的结果,还能省去一个 Pass。
第二项改动与镜面光照有关。ReSTIR GI 原本为漫反射重采样设计:时空相近的像素,对于同一次级光源的漫反射贡献通常差异很小。材质越光滑,Specular Lobe 越窄,可能反射或发出有效光照的间接表面范围也越小。在理想镜面的极端情况下,一个点可能只对应唯一的间接光源,而相邻点甚至完全不会把这个光源反射到相机。因此,对镜面路径作 空间重采样 时,我们根据表面 粗糙度 缩小像素周围的重采样圆形半径;粗糙度 小于 0.1 时,完全停用空间重采样。可能贡献光照的间接光源范围越窄,额外的采样优化越不必要,继续使用甚至可能降低画质。因此,我们只在需要的区域使用 ReSTIR GI,改善这些区域的间接光照,同时避免影响其他区域的画质。
7.10 用 Radiance Caching 提高采样质量
理想的辐亮度缓存(Radiance Cache)应该能回答:在任意时刻、任意位置,沿任意方向的辐亮度是多少。现实中的缓存只能以有限的空间、方向和时间分辨率,近似场景中的 Radiance 分布。
实时 Path Tracing 直到近年才变得可行,因此许多 Radiance Cache 最初为实时应用直接估计间接光照而设计。例如,预烘焙的均匀 3D Probe Grid,后来逐渐具备一定动态性 [Hooker 16],至今仍很常用。硬件 Ray Tracing 进一步提高了这种网格的动态程度,并减少遮挡错误和漏光 [Majercik et al. 19]。为了避免在任意几何体周围摆放 Probe 的困难,还可以把 Probe 绑定到特定表面的特定位置 [Brinck et al. 21]。Unreal Engine 5 的 Lumen 使用基于 屏幕空间 Probe 的 Radiance Cache,把追踪分辨率与渲染分辨率分离 [Wright 21];在此基础上,还有把 屏幕空间 Probe 与 Hash Grid 结合的双层 Radiance Cache [Boisse 23]。这些方案通常只需 Path Tracing 运行时成本的一小部分,但画质也无法达到 Path Tracing 的水平。
另一类辐亮度缓存则专门配合 Path Tracing 使用。Neural Radiance Cache(NRC)[Muller et al. 21] 先用 Path Tracing 训练一个描述场景 Radiance 分布的神经网络,再供主 Path Tracer 推理。Spatially Hashed Radiance Cache(SHaRC)[Gameworks 24] 的思路相近,但不训练神经网络,而是在 GPU 显存中的 Hash Grid 积累 Radiance。图 7.36 展示了 Path Tracing 如何与此类缓存结合,提高整体信号质量:与其追踪漫长、最终也未必找到重要光源的路径,不如在较少 Bounce 之后查询缓存。只要缓存质量足够高,两者结合就可能提高整体画质。图 7.37 以《赛博朋克 2077》一处主要由间接光照亮的地点,对比使用和不使用 Radiance Cache 时的信号。开发期间 NRC 尚不可用,因此 Ray Tracing: Overdrive 选择了 SHaRC。


7.10.1 Spatially Hashed Radiance Cache
SHaRC 是 世界空间 Radiance Cache,用 GPU Hash Grid 存储 Radiance。空间中的每个点按其与相机的距离,以对数尺度离散化(图 7.38),得到均匀 世界空间 Grid 中的一组坐标和 LOD。用于 Path Tracer 时,SHaRC 能提高每条路径的质量,支持更多光照 Bounce。与其他缓存一样,它可能增加时间滞后;不过,可以根据需要调整性能、画质、分辨率和滞后程度之间的取舍。SHaRC 也不需要大幅修改现有 Path Tracer;在最终使用缓存的 Path Tracing 之前,渲染循环只需额外执行两个更新缓存的 Pass。

Hash Key. Hash 方案的核心是 Key。SHaRC 根据网格位置、由相机距离决定的 LOD,以及有助于避免薄几何体瑕疵的几何法线,生成 Hash Key。清单 7.4 给出《赛博朋克 2077》为各部分分配的位数,并定义全局网格参数:相机位置;控制 LOD 分布及相邻层 Voxel 尺寸比例的对数底数;以及直接控制 Voxel 尺寸的场景尺度。清单 7.5 从 世界空间 样本位置计算 LOD、Voxel 尺寸和网格坐标;清单 7.6 最终得到 64 位 Spatial Hash Key。
Hash Grid Data Structure. SHaRC 在显存中只需几项资源:
- Hash entries buffer:每项 64 位的 Structured Buffer,存储 Hash Key。
- Voxel data buffer:每项 128 位的 Raw Buffer,存储积累的 Radiance、样本数量及距上次更新的帧数;分别用两份保存上一帧和当前帧数据。
清单 7.4|SHaRC Hash Key 的位分配与网格参数。
#define POS_BIT_NUM 18
#define POS_BIT_MASK ((1u << POS_BIT_NUM) - 1)
#define LEVEL_BIT_NUM 7
#define LEVEL_BIT_MASK ((1u << LEVEL_BIT_NUM) - 1)
#define NORMAL_BIT_NUM 3
#define NORMAL_BIT_MASK ((1u << NORMAL_BIT_NUM) - 1)
struct GridParams
{
float3 cameraPos;
float logBase;
float sceneScale;
};
清单 7.5|计算 SHaRC 的 LOD、Voxel 尺寸与网格坐标。
float LogBase(float x, float base)
{
return log(x) / log(base);
}
uint GetGridLevel(float3 samplePos, GridParams gridParams)
{
float d = length(gridParams.cameraPos - samplePos);
float logBase = floor(LogBase(d, gridParams.logBase));
return clamp(logBase, 1, LEVEL_BIT_MASK);
}
float GetVoxelSize(uint gridLevel, GridParams gridParams)
{
return pow(gridParams.logBase, gridLevel) / gridParams.sceneScale;
}
// Based on logarithmic caching by Johannes Jendersie
int4 GetGridPositionLog(float3 samplePos, GridParams gridParams)
{
uint gridLevel = GetGridLevel(samplePos, gridParams);
float voxelSize = GetVoxelSize(gridLevel, gridParams);
int3 gridPos = floor(samplePos / voxelSize);
return int4(gridPos.xyz, gridLevel);
}
清单 7.6|计算 SHaRC Spatial Hash Key。
uint64_t ComputeSpatialHash(float3 samplePos, float3 sampleNormal,
GridParams gridParams)
{
// gridPos.xyz - position; gridPos.w - level
uint4 gridPos = asuint(GetGridPositionLog(samplePos, gridParams));
uint64_t hashKey =
(((uint64_t)gridPos.x & POS_BIT_MASK) << (POS_BIT_NUM * 0))
| (((uint64_t)gridPos.y & POS_BIT_MASK) << (POS_BIT_NUM * 1))
| (((uint64_t)gridPos.z & POS_BIT_MASK) << (POS_BIT_NUM * 2))
| (((uint64_t)gridPos.w & LEVEL_BIT_MASK) << (POS_BIT_NUM * 3));
uint normalBits =
(sampleNormal.x >= 0 ? 1 : 0)
+ (sampleNormal.y >= 0 ? 2 : 0)
+ (sampleNormal.z >= 0 ? 4 : 0);
hashKey |= ((uint64_t)normalBits << (POS_BIT_NUM * 3 + LEVEL_BIT_NUM));
return hashKey;
}

每个 Buffer 的条目数相同,也就是用于缓存 Radiance 的场景 Voxel 数。《赛博朋克 2077》使用 2²² 个条目,SHaRC 因而占用约 167 MB 显存。Hash entries 缓冲保存完整 Hash Key,供检测和处理 哈希碰撞 使用。
Hash Collisions. 两个不同 Key 映射到同一 哈希值,就发生碰撞(图 7.39)。哈希函数 应尽量降低碰撞概率,但无法彻底避免。SHaRC 采用 开放寻址 和 线性探测:在内存中使用连续的 Cell 数组;发生碰撞时,按确定顺序依次检查 Hash Grid 中后续的 Cell(图 7.40)。清单 7.7 展示 Hash entries 缓冲如何实现这一过程。

清单 7.7|SHaRC 的 Hash Collision 处理。
#define BUCKET_SIZE 32
#define CAPACITY 2 * 1024 * 1024 // 2^22
bool HashMapInsert(const uint64_t hashKey, out uint cacheEntry)
{
uint hash = Hash(hashKey);
uint slot = hash % CAPACITY;
uint64_t prevHashKey = INVALID_HASH_KEY;
for (uint offset = 0; offset < BUCKET_SIZE; ++offset)
{
AtomicCompareExchange(
slot + offset, INVALID_HASH_KEY, hashKey, prevHashKey);
if (prevHashKey == INVALID_HASH_KEY ||
prevHashKey == hashKey)
{
cacheEntry = slot + offset;
return true;
}
}
return false;
}
bool HashMapFind(const uint64_t hashKey, inout uint cacheEntry)
{
uint hash = Hash(hashKey);
uint slot = hash % CAPACITY;
uint64_t prevHashKey = INVALID_HASH_KEY;
for (uint offset = 0; offset < BUCKET_SIZE; ++offset)
{
uint64_t storedHashKey = hashEntriesBuffer[slot + offset];
if (storedHashKey == hashKey)
{
cacheEntry = slot + offset;
return true;
}
}
return false;
}
译注:原书正文写
2²²个条目,但清单 7.7 写2 * 1024 * 1024 // 2^22;表达式实际等于2²¹。这里按原书保留。
Updating and Resolving the Cache. 更新缓存分两步:先由 Path Tracer 写入当前帧缓存(图 7.41),再由 Resolve Pass 合并上一帧和当前帧的数据,同时清除过期数据(清单 7.8)。
更新缓存的目标不是直接生成图像,而是让随机选中的 Voxel 获得最新信息,因此无需对每个像素都执行 Path Tracing。我们可以只追踪一部分路径,却让每条路径经历更多 Bounce。《赛博朋克 2077》只从随机选出的 4% 像素发射更新路径,但最多追踪四次 Bounce,而非两次。SHaRC 还支持 Backpropagation,并可在路径终点查询上一帧缓存。

清单 7.8|SHaRC Resolve。
#define RADIANCE_SCALE 1e4f
#define LINEAR_BLOCK_SIZE 256
#define SAMPLE_NUM_MAX 64
#define STALE_FRAME_NUM_MAX 64
#define SAMPLE_BIT_NUM 20
#define SAMPLE_BIT_MASK ((1u << SAMPLE_BIT_NUM) - 1)
#define FRAME_BIT_NUM (32 - SAMPLE_BIT_NUM)
#define FRAME_BIT_MASK ((1u << FRAME_BIT_NUM) - 1)
struct SharcVoxelData
{
float3 radiance;
uint sampleNum;
uint frameNum;
};
SharcVoxelData SharcUnpackVoxelData(uint4 dataPacked)
{
SharcVoxelData data = (SharcVoxelData)0;
data.radiance = dataPacked.xyz / RADIANCE_SCALE;
data.sampleNum = (dataPacked.w >> 0) & SAMPLE_BIT_MASK;
data.frameNum = (dataPacked.w >> SAMPLE_BIT_NUM) & FRAME_BIT_MASK;
return data;
}
[NUMTHREADS(LINEAR_BLOCK_SIZE, 1, 1)]
CS_MAIN(in uint2 did : SYS_DISPATCH_THREAD_ID)
{
HashKey hashKey = u_SharcHashEntries[did.x];
if (hashKey == INVALID_HASH_KEY) return;
uint4 dataPackedPrev = u_SharcVoxelDataPrev.Load4(did.x * 16);
uint4 dataPacked = u_SharcVoxelData.Load4(did.x * 16);
uint4 packedData = dataPacked + dataPackedPrev;
uint sampleNum = packedData.w & SAMPLE_BIT_MASK;
if (sampleNum > SAMPLE_NUM_MAX)
{
packedData.xyz *= (float)SAMPLE_NUM_MAX / sampleNum;
packedData.w = sampleNum = SAMPLE_NUM_MAX;
}
uint frame = (dataPackedPrev.w >> SAMPLE_BIT_NUM) & FRAME_BIT_MASK;
packedData.w = sampleNum;
// Increment frame counter for stale samples
if ((dataPacked.w & SAMPLE_BIT_MASK) == 0)
{
++frame;
packedData.w |= ((frame & FRAME_BIT_MASK) << SAMPLE_BIT_NUM);
}
// Evict stale samples
if (frame > STALE_FRAME_NUM_MAX)
{
packedData = 0;
u_SharcHashEntries[did.x] = INVALID_HASH_KEY;
}
u_SharcVoxelData.Store4(did.x * 16, packedData);
}
Querying the Cache. 查询 SHaRC 的 Radiance 只需 世界空间 位置和几何法线,不需要材质求值。缓存的主要数据保存在 GPU Buffer 中,任何 Shader 都可以查询。图 7.42 展示渲染期间 Path Tracer 与 SHaRC 的交互。
更难的是决定什么时候读取缓存,什么时候让光线继续反弹。为避免部分画面瑕疵,路径片段的长度应先超过 Voxel 尺寸。Specular Lobe 也要谨慎处理。对于光泽 Specular Lobe,我们可以估计它的有效锥体扩散范围(图 7.43);若范围大于 Voxel Grid 的空间分辨率,便可使用缓存。估计公式为:
coneSpread = 2.0 * ray.length * sqrt((0.5 * a²) / (1 - a²))
其中 a 是材质粗糙度的平方 [Akenine-Möller et al. 21]。


满足上述条件后,Path Tracer 使用 SHaRC 的效果见图 7.44。

7.11 Denoising 与 Supersampling
《赛博朋克 2077》的 Ray Tracing: Overdrive 模式采用完整的路径追踪,场景因此更接近真实,但降噪也明显更复杂。我们仍使用 Part 1 介绍的 SIGMA 降噪器处理光追太阳阴影;其余所有直接辐亮度和间接辐亮度则改用 NRD 库中的 ReLAX,而不再使用 ReBLUR。ReLAX 专为处理 7.9.4—7.9.6 节所述 ReSTIR DI 和 ReSTIR GI 等算法产生的信号而设计。图 7.45 展示了《赛博朋克 2077》中直接光照与间接光照如何共同构成真实的场景光照。下文以这个场景说明游戏路径追踪的降噪方案。

7.11.1 Direct Radiance 与 Indirect Radiance 的噪声特征
在《赛博朋克 2077》的 Ray Tracing: Overdrive 模式中,直接辐亮度和间接辐亮度的噪声特征差别很大,因此我们在降噪架构上也要区别处理。直接辐亮度来自 ReSTIR DI Pass;得益于时域复用和空间复用,它的信号相当密集,噪声较低。间接辐亮度来自间接光路径追踪 Pass。即便有 SHaRC 和 ReSTIR GI 带来的明显改进,它的输出仍无法达到 ReSTIR DI 信号的密度和纯净程度。图 7.46 和图 7.47 分别展示了 Overdrive 模式中带噪声的直接光照与间接光照。
7.11.2 Direct Radiance 与 Indirect Radiance 的 Denoising
ReLAX 在经典的时空方差引导滤波(Spatiotemporal Variance-Guided Filtering,SVGF)降噪器 [Schied et al. 17] 基础上做了大量改进。它引入多种启发式方法、中间缓冲和降噪阶段,以改善画质、稳定性和响应速度。图 7.48 对比了经典 SVGF 与 ReLAX 的处理流程。ReLAX 的各个 Pass 简述如下:
- Classify tiles:把屏幕划分为 16 × 16 的 Tile。如果某个 Tile 的全部像素都在降噪范围之外,便赋值为 0,以加速对天空区域的处理。
- Hit distance reconstruction:使用表面相似性启发式方法补全命中距离中的空缺;这一 Pass 用于采用概率式漫反射/镜面采样的情形。
- Pre-pass:在 BRDF 波瓣确定的范围内,以空间复用改善输入信号。若输入信号的稀疏程度超过搜索半径,这一步效果会变差:一个少见的亮像素可能扩散到邻域,让整片原本较暗的区域变成亮斑。因此必须谨慎调节搜索半径。这一 Pass 还估计镜面反射波瓣的虚拟运动,它是镜面重投影成功的关键。
- Temporal accumulation:将新数据与重投影后的历史信号累积。若干置信度因子控制累积速度,并利用基于几何的平面距离跟踪遮挡解除。漫反射重投影只依赖表面运动;镜面重投影还考虑反射世界的虚拟运动。
- History fix:只处理历史较短的像素,也就是刚解除遮挡的区域,以及解除遮挡后的一小段时间。它通过超宽双边模糊重建这些区域缺失的历史信号;较大的像素间距有助于打破重复图案和块状伪影。
- History clamping:依据方差,将变化较慢的常规历史信号限制到时域积累 Pass 生成的快速历史信号范围内。这有助于减少滞后并缓解重投影误差,但仅在局部方差较低时有效。
- Firefly suppression:在当前像素周围 3 × 3 区域执行交叉双边 Rank-Conditioned Rank Selection(RCRS)滤波,抑制孤立的异常亮点。
- Spatial variance estimation:在刚发生遮挡解除、时间方差尚不可用的区域,从当前像素周围 5 × 5 区域估计信号的空间方差,供后续 À-Trous 空间 Pass 使用。
- À-Trous:一组基于交叉双边小波的空间滤波器,由信号方差和引导缓冲的内容引导。



ReLAX 需要两组输入:存放带噪声辐亮度值的缓冲,以及辅助降噪的引导缓冲。辐亮度使用全屏缓冲存储,先分为直接和间接分量,再各自分为漫反射与镜面分量。表 7.5 列出了 ReLAX 的输入和输出。目前多数先进降噪器也需要这些缓冲。
| 输入 | 说明 |
|---|---|
| Diffuse 与 specular radiance、hit distance | 带噪声的 HDR 辐亮度,按漫反射和镜面分量分开;命中距离存于 Alpha 通道。 |
| ViewZ | 引导缓冲;线性化深度,用于重建世界空间位置。 |
| Normal 与 粗糙度 | 引导缓冲;世界空间法线与线性粗糙度,用于确定波瓣和空间权重。 |
| Motion vectors | 引导缓冲;首次命中点的运动矢量,供时域积累 Pass 使用。 |
| 输出 | 说明 |
|---|---|
| Denoised diffuse 与 specular | 降噪后的 HDR 辐亮度,按漫反射和镜面分量分开。 |
表 7.5。ReLAX 的输入与输出。
从性能角度看,很自然会想到把直接与间接光照的漫反射信号合并,镜面信号也同样合并,再一起交给降噪器处理。可 ReLAX 依赖信号方差;一旦合并,我们无论怎样调节参数,降噪质量都会下降。若偏向保留直接光照的空间细节和响应速度,间接光照信号带来的较大时空方差会使输出不稳定。若偏向稳定性,又会失去空间细节、清晰的阴影以及直接光照的响应速度。
先看直接辐亮度:它输入 ReLAX 时,方差就已经很低。对于这种方差引导的降噪器,我们可以在空间滤波阶段更积极地按方差拒绝样本,从而尽量保留输出中的空间细节:阴影更清晰,光照细节也更明确。
较低的输入方差也有利于提高 ReLAX 的响应速度。图 7.48 的流程表明,除了随时间累积的常规历史信号(regular history),ReLAX 还使用快速历史信号(fast history)。两者遵循相同逻辑,但快速历史信号的时域积累权重更激进,可能不够稳定,却能迅速响应光照变化。
ReLAX 的一项重要改进是加入快速响应的历史缓冲。它与历史信号限幅阶段配合,使降噪器能更快响应高度动态的光照。
具体做法是使用颜色限幅,让常规历史信号更快跟随快速历史信号,同时尽量避免引入噪声和不稳定性:根据像素邻域内快速历史信号的方差,在 YCoCg 色彩空间中,把常规历史信号的颜色限制到由快速历史信号颜色和方差乘以限幅强度系数确定的包围盒内。
ReSTIR DI 给降噪器的输入方差很低,因此可以让快速历史信号只保留一到两帧,及时跟上光照变化,同时使用较强的颜色限幅。两项设置共同提高了常规历史信号的响应速度,因而使降噪器能及时跟上场景变化。
图 7.49 的场景中,光线穿过旋转的风扇叶片进入暗室,正好可以观察快速历史信号对动态光照响应的帮助。图 7.50 对比了启用与不启用快速历史信号时的直接辐亮度降噪结果。使用上述快速历史信号和颜色限幅后,ReLAX 能跟上光照变化,旋转叶片投下的移动阴影轮廓清楚;只用跨许多帧累积的常规历史信号,就无法及时响应这些阴影。

间接辐亮度输入的空间方差和时间方差都高于直接辐亮度。这意味着间接光照在空间分布上的变化更大,随时间变化也更明显。好在它通常较为柔和,多数情况下无须像直接光照形成的阴影那样,追求同等的锐利程度和空间精度。我们因此可以调节 ReLAX 的间接光照信号参数,让稳定性优先于细节和即时响应。
放宽空间滤波的启发式限制,有助于 ReLAX 适应间接光照信号较高的空间方差;增加时域循环中的累积帧数,则能改善这种本身较不稳定的信号经过降噪后的稳定性。
最终,我们运行两个参数不同的 ReLAX 实例。一个处理直接光照的漫反射和镜面信号,以保留空间细节和响应速度为目标;另一个处理间接光照的漫反射和镜面信号,优先保证稳定性,以应对间接光照较高的时空方差。图 7.51 和图 7.52 分别展示了《赛博朋克 2077》Overdrive 模式经过降噪的直接光照与间接光照。



7.11.3 BRDF Demodulation
ReLAX 最适合处理从特定方向传来的纯辐亮度。混有材质响应的信号也能降噪,但要获得最佳结果,需把材质因素从输入信号中分离。换句话说,光线追踪器通常生成的 irradiance,需要转换成辐亮度。常见的反照率解调让表面反照率不再经过降噪流程,因此明显改善了反照率细节;然而,使用高细节法线贴图的材质却损失了精度。其中一个原因是:画面有运动或抖动时,尺寸小于像素的微小镜面高光和漫反射细节会逐帧明显变化,而 ReLAX 的时域积累跨多帧累积它们,结果把这些细节抹掉了。
译注:这里沿用原文对
irradiance的宽泛用法,指包含材质响应、尚待 demodulation 的信号;它并非严格辐射度量学定义中的 irradiance。
为改善这一问题,我们设计了 BRDF 比值解调(清单 7.9)。具体做法是计算当前像素的 BRDF 反射辐亮度与周围区域平均值的比值,并保存这个比值。
每个像素分别计算漫反射与镜面辐亮度的比例,存入双通道 FP16 缓冲。降噪完成后,再把比例应用到降噪后的漫反射和镜面信号。由于间接光照比直接光照柔和得多,为节约性能,我们只对直接光照使用这项技术。
清单 7.9。BRDF demodulation。
// BRDF demodulation for denoiser
Surface centerSurface = GetGBufferSurface(pixelPos);
float3 N = centerSurface.normal;
float3 V = centerSurface.viewDir;
float3 L = normalize(lightWorldPos - centerSurface.worldPos);
float NdotL = dot(N, L);
float centerDepth = centerSurface.depth;
float2 factor = float2(1.0f, 1.0f);
if (RTXDI_IsValidReservoir(centerSample) && (NdotL > 0))
{
// Calculate center BRDF
LightingComponents centerBRDF = BRDF(L, N, V, centerSurface.params);
float2 centerBRDFf =
calcLuminances(centerBRDF.Diffuse, centerBRDF.Specular);
centerBRDFf = clampFloat2(centerBRDFf, 0.001f, 1000.0f);
// Average BRDF for surrounding pixels in "+" pattern
float2 sumBRDFf = float2(0, 0);
float neighborCount = 0;
for (int i = 0; i <= 3; ++i)
{
int2 offset;
switch (i)
{
case 0: offset = int2(1, 0); break;
case 1: offset = int2(-1, 0); break;
case 2: offset = int2(0, 1); break;
case 3: offset = int2(0, -1); break;
}
// Sample neighbor surface
Surface sampleSurface = GetGBufferSurface(pixelPos + offset);
float sampleDepth = sampleSurface.depth;
N = sampleSurface.normal;
// Ignore neighbors with very different depth
if (abs(centerDepth - sampleDepth) > centerDepth * 0.02f) continue;
// Calculate neighbor BRDF
LightingComponents sampleBRDF = BRDF(L, N, V, centerSurface.params);
float2 sampleBRDFf =
calcLuminances(sampleBRDF.Diffuse, sampleBRDF.Specular);
sampleBRDFf = clampFloat2(sampleBRDFf, 0.001f, 1000.0f);
// Accumulate BRDF for the sample
sumBRDFf += sampleBRDFf;
neighborCount += 1.0f;
}
if (neighborCount > 0)
{
sumBRDFf /= neighborCount;
factor.x = (sumBRDFf.x > 0.0f) ? centerBRDFf.x / sumBRDFf.x : 1.0f;
factor.y = (sumBRDFf.y > 0.0f) ? centerBRDFf.y / sumBRDFf.y : 1.0f;
factor = pow(factor, 0.7f);
factor = clamp(factor, float2(0, 0), float2(15.0f, 15.0f));
}
}
// Write out BRDF denodulation factor
brdfFactor[pixelPos] = factor;
完成降噪后计算最终光照结果时,将直接辐亮度乘以先前求得的比例(清单 7.10)。
清单 7.10。BRDF modulation。
// BRDF modulation
float2 brdfFactor = brdfFactor[pixelPos];
directDiffuseRadiance *= brdfFactor.x;
directSpecularRadiance *= brdfFactor.y;
7.11.4 Antialiasing 与 Supersampling
《赛博朋克 2077》Ray Tracing: Overdrive 模式起初沿用 7.6.3 节所述其他模式的抗锯齿与超采样方案。最终,7.13 节介绍的统一图像重建成为更好的方案。
7.12 性能
表 7.6 列出测试所用几款 GPU 对应的分辨率与 DLSS-SR 模式;表 7.7 给出这些配置下《赛博朋克 2077》Ray Tracing: Overdrive 模式中各个路径追踪相关 Pass 的耗时。
| GPU | 分辨率 | DLSS-SR 模式 |
|---|---|---|
| GeForce RTX 4060 | 1080p | Quality |
| GeForce RTX 4070 | 1440p | Balanced |
| GeForce RTX 4080 | 2160p | Performance |
| GeForce RTX 4090 | 2160p | Performance |
表 7.6。各 GPU 的默认分辨率与 DLSS-SR 模式。
| Render Pass | GeForce RTX 4060 | GeForce RTX 4070 | GeForce RTX 4080 | GeForce RTX 4090 |
|---|---|---|---|---|
| BLAS Static | 0.12 | 0.10 | 0.09 | 0.09 |
| BLAS Dynamic | 0.62 | 0.42 | 0.37 | 0.38 |
| TLAS | 0.73 | 0.43 | 0.38 | 0.36 |
| RT Sun Shadows | 0.75 | 0.52 | 0.49 | 0.36 |
| ReSTIR DI | 4.20 | 3.02 | 2.95 | 1.79 |
| Indirect Light Path Tracer | 6.05 | 4.29 | 3.81 | 2.66 |
| RT Transparent Reflections | 0.14 | 0.11 | 0.11 | 0.09 |
| ReSTIR GI | 2.09 | 1.75 | 1.69 | 1.33 |
| NRD | 5.02 | 3.85 | 3.77 | 2.60 |
| DLSS-SR | 0.63 | 0.61 | 0.80 | 0.57 |
| Other Render Passes | 7.29 | 5.54 | 5.51 | 4.43 |
| Total Frame Time | 27.64 | 20.64 | 19.97 | 14.66 |
表 7.7。《赛博朋克 2077》Ray Tracing: Overdrive 模式游戏内基准测试的耗时,单位为 ms。
《《赛博朋克 2077》实时光照管线的演进|第二篇:Path Tracing——未来的统一光照管线》有1条评论