技术博客

Lumen Final Gather:屏幕探针与 SDF 追踪的间接光闭环

第 36 / 100 篇 · 2026-02-24 · ue5 · Lumen全局光照渲染源码分析

Surface Cache 那篇讲了 Lumen 怎么把场景预烘成卡片化的近场辐射表示。Final Gather 是把这张缓存变成每一像素间接光的最后一步,也是 Lumen 管线里最重、技巧最密集的一段。代码在 Engine/Source/Runtime/Renderer/Private/Lumen/LumenScreenProbeGather.cpp 和 LumenRadianceCache.cpp(UE5.3)。这条链路要回答一个问题:给定 Surface Cache 里的低频辐射场,怎么用有限的追踪预算算出每帧稳定、无噪点的全屏间接光?

为什么不用每像素直接追踪

最直接的想法:屏幕每个像素发射线去 Trace 距离场拿 Radiance。算一笔账就知道不行——一帧 200 万像素,每像素哪怕只 trace 4 条射线,也是 800 万次 SDF ray march,GPU 直接过载。而且相邻像素的间接光高度相关,逐像素独立采样等于把相关性白白浪费掉。

Lumen 的答案是把采样和重建解耦成两个分辨率:

  • 低分辨率采样:屏幕上按 16×16 像素一个探针(screen probe)布置稀疏采样点,每个探针只 trace 几十条射线;
  • 高分辨率重建:每个像素不重采样,而是从邻近探针插值重建,插值权重由深度和法线的差异决定。

这样一来 trace 次数降到全屏的 1/256,而插值重建的开销是廉价的过滤操作。这和 irradiance probe 类 GI(如 ddGI)的思路同源,但 Lumen 的探针是逐帧重新布置在屏幕空间的,没有预烘焙的世界空间网格,也没有泄漏问题需要预修。

屏幕探针的采样与追踪

每个 screen probe 做的事:以探针位置为原点,在半球方向(由待处理像素的法线聚合出一个代表法线)上生成一批方向样本,沿每个方向做 SDF ray march(软件光追路径,LumenTraceMeshSDFs.usf)。射线命中 Surface Cache 的 card 就取辐射,没命中就看是否命中远处的 Radiance Cache 探针或直接取天空。

方向采样不是均匀分布,Lumen 用的是余弦加权重要性采样 + 后续帧的反馈优化:LumenScreenProbeGather 里维护一个低分辨率的"重要性"纹理,记录上一帧哪些方向的贡献大(比如有亮窗的方向),本帧按这个分布偏置新样本。用重要性采样的标准权衡:同样的样本数,方差更低——亮度集中在少数方向的环境(白天只有一个窗户的室内),均匀采样要几百条射线才收敛,偏置分布几十条就够。代价是分布本身有估计误差,所以保留了部分均匀样本做兜底,比例由 cvar 控制。

每条射线命中的辐射写进探针的八面体映射(octahedral)辐射纹理,probe 的数据布局就是一张八面体环境贴图,这也是插值阶段能直接用双线性过滤的原因——八面体映射在二维纹理上连续,过滤不会跨接缝出错。

Final Gather:低分辨率采样,高分辨率重建 Screen Probe 布置 16×16 px / 探针 重要性采样 + SDF Trace 几十条射线 / 探针 Octahedral 辐射图 像素插值重建 Screen Probe(稀疏) 采样点 + 射线 全屏像素(稠密) 每像素插值邻近探针 trace 量 ≈ 全屏的 1/256 成本大头在重建过滤

世界空间 Radiance Cache:远场的第二层

屏幕探针只覆盖近处和当前可见区域。射线走远了以后(SDF 步进到 MaxTraceDistance 还没命中 card),继续 march 太贵,Lumen 在这里切到世界空间 Radiance Cache:一层固定在三维网格上的探针(LumenRadianceCache.cpp),同样存八面体辐射,但位置是锁死在世界空间的。近处由屏幕探针管,远处由 Radiance Cache 管,射线在两者之间交接。

Radiance Cache 探针的数量按相机周围的空间hash 网格分配,本身也靠 budget 限制(每帧只能更新固定数量的探针),久未更新的探针靠时域累积维持有效。这就引出 Lumen 全系统的统一主题——所有缓存都是带预算的、靠时间换空间。

时域累积与滤波

单帧的采样数太少,直接显示会闪烁。Lumen 对探针辐射做时域指数累积:

$$L_t = \alpha\, L_{\text{sample}} + (1-\alpha)\, L_{t-1}$$

$\alpha$ 越小越稳但延迟越大。光照突变(开灯)时,纯指数累积会让新光照爬几秒才到位,所以还有一路辐照度变化检测:当前帧采样和历史的差异超过阈值时强制重置历史(LumenScreenProbeFiltering 里的 clamp/reject 逻辑),差异小则继续累积。这套机制和所有时域抗噪(TAA/TSR)的重投影-拒绝逻辑同源,只是拒绝条件换成了辐射亮度差。

滤波分两级:探针之间的空间过滤(相邻探针八面体图交叉滤波,去掉探针间的不连续)和插值阶段的双线性加权(按深度/法线差异调制权重,防止把墙对面的探针光漏过来——这就是 Lumen 官方说的"自遮挡问题的处理在插值权重里")。

性能旋钮与实测

调 Final Gather 就是调"采样数"和"累积稳定性"的交换:

  • r.Lumen.ScreenProbeGather.RadianceCache:开关远场 Radiance Cache,纯室内小场景关掉能省一层追踪;
  • r.Lumen.ScreenProbeGather.ScreenTraces:屏幕空间追踪优先——先走一帧的 depth buffer 做 screen trace,miss 了才 SDF march,绝大多数近处命中靠 screen trace 就解决了,SDF 只处理真三维遮挡;
  • r.Lumen.ScreenProbeGather.ProbeFullResolutionOffset:探针密度的激进档位,性能吃紧时先动它;
  • 闪烁排查顺序:Stat Lumen 看 trace 耗时 → 调低累积 $\alpha$(更响应)→ 检查重要性纹理是不是被高光噪声污染(关反馈分布对比)。

源码入口

  • 主 pass 链:LumenScreenProbeGather.cpp 的 LumenScreenProbeGather(RDG pass 序列:布置探针 → 采样 → 八面体写入 → 过滤 → 重建)。
  • SDF 追踪:LumenTraceMeshSDFs.usf,ray march 的步进加速靠 cone tracing + 全局距离场的 mip 加速(大步跳过空区域)。
  • Radiance Cache:LumenRadianceCache.cpp 的 FRadianceCache 类,看 UpdateProbes 的预算分配逻辑。

Final Gather 的价值不在于某个单点技巧,而在于它把"采样预算"这件事贯穿到了每一层:探针比像素稀疏 256 倍,Radiance Cache 比探针又稀疏几个量级,每层都用时域累积把稀疏采样的方差磨平。做实时 GI 系统,预算分配的设计空间比采样器本身大得多。