技术博客

Lumen 的 Surface Cache 与 Radiance Cache:两级缓存结构拆解

第 35 / 100 篇 · 2026-04-04 · ue5 · Lumen全局光照源码分析

Lumen 能在主机上跑动态 GI,靠的是把最贵的东西——场景表面的材质与光照——全部缓存化,着色时只做插值。核心是两级缓存:Surface Cache(存"表面长什么样")和 Radiance Cache(存"各方向来光多少")。源码在 Engine/Source/Runtime/Renderer/Private/Lumen/(UE5.3)。

先看问题本身有多贵

间接光照在数学上是渲染方程

$$L_o(x, \omega_o) = L_e(x, \omega_o) + \int_\Omega f_r(x, \omega_i, \omega_o)\, L_i(x, \omega_i)\, (\omega_i \cdot n)\, d\omega_i$$

的求解。麻烦在于 $L_i$ 本身又是其他表面的 $L_o$——方程是递归的,严格解相当于一个无穷维的积分方程。离线渲染器用路径追踪暴力蒙特卡洛积分,单帧几分钟;实时预算只有零点几毫秒。Lumen 的策略是把方程拆开:传输部分(光在表面之间怎么弹)跨帧缓存,采样部分(这个像素收到多少)在线插值。两级缓存分别对应这两个部分。理解了这一点,后面每个设计决策——为什么命中的是 Card 而不是三角形、为什么探针那么稀、为什么反弹是一帧一步——都是这个拆分的直接推论。

Surface Cache:把整个世界拍成图集

Lumen 的场景表示叫 FLumenScene,独立于主场景渲染。每个 mesh(FLumenCard 的集合)在离线或运行时被抓拍成若干张 Card——可以简单理解成把 mesh 从少数几个轴向方向"拍扁"成面片。所有 Card 塞进一个大图集(Surface Cache Atlas,LumenSurfaceCache.cpp 里管理分配),每张 Card 上存:

  • albedo、normal、emissive、roughness 等材质属性(LumenCardCapture pass 渲染产出);
  • 深度(用于把 Card 当 proxy 几何做命中测试)。

关键设计决策:GI 追踪命中的不是真实三角形,而是 Card。软件光追路径下,先追全局 SDF(Global SDF)做远距离粗命中,再切 mesh SDF 做近处精命中,命中点直接到 Surface Cache 里采样材质和直接光照。这就把"间接光着色"从"对任意三角形跑材质 shader"降成了"对图集纹理采样"——成本从不可控变成固定。用渲染方程的语言说:递归求值 $L_i$ 需要知道任意命中点的出射辐射,Surface Cache 把"任意表面点的材质与直接光照"预计算成了可 O(1) 采样的纹理,递归的深度被摊到了时间维度上。

图集容量是有限的(r.LumenScene.SurfaceCache.AtlasSize,默认 4096²),所以 Card 有优先级和逐出机制。远离相机的 mesh Card 分辨率会被压低甚至被逐出,r.Lumen.Visualize 1 能直接看到图集内容和 Card 覆盖情况——实测过植被密集的开放世界,图集压力最大的就是高频率小 mesh,调 r.LumenScene.CardCaptureMargin 和 mesh 的 Card 数量上限比换大图集划算。

命中 Surface Cache 时读到的"光照"是上一次 Radiosity 更新的结果,这就是 Lumen 无限反弹的来源:上一帧算好的间接光写回 Surface Cache 的 emissive,下一帧追踪时又把它当光源采进来。每个 frame 只推进一步辐射传输,时间换收敛。

反弹收敛的数学形态

把一次全局辐射传输记作算子 $T$(输入场景各表面的辐射分布,输出一次反弹后的分布),光照的完全解是不动点

$$L^* = L_e + T L^* = (I - T)^{-1} L_e$$

直接求逆不可行,Lumen 实际跑的是 Jacobi 迭代:$L^{(n+1)} = L_e + T L^{(n)}$,每帧推进一步,$L^{(n)}$ 就存在 Surface Cache 的 emissive 通道里。只要 $T$ 是收缩映射(物理上能量守恒的传输算子谱半径小于 1),迭代按几何级数收敛——这就是为什么"开灯后间接光几帧内逐渐波及",你看到的不是 bug,是 Jacobi 迭代的收敛过程本身。反弹次数在这里体现为帧数,一次反弹误差每帧衰减一个数量级左右,视觉上三五帧就稳定了。

Radiance Cache:按探针缓存方向光

就算命中成本固定,每个屏幕像素追一根光线、对命中点完整着色还是太贵。Lumen 的第二刀是 Radiance Cache——空间上分稀采样点(探针),每个探针只存低方向分辨率的 radiance,着色时在邻近探针间插值。

这一步的理论依据是间接光本身的频谱特性:漫反射间接光在空间上和方向上都以低频为主,低频信号不需要逐像素逐方向采样——用稀疏探针加插值重建,误差可控。glossy 反射是方向高频信号,这正是低方向分辨率探针搞不定、必须上 HWRT 补的那部分。

方向上的存储用 octahedral 编码:单位方向 $\omega=(x,y,z)$ 先按 $L_1$ 范数投影,

$$u = \frac{x}{|x|+|y|+|z|}, \qquad v = \frac{y}{|x|+|y|+|z|}$$

$z<0$ 的半球做一次折叠映回。球面方向被压成一张二维纹理,双线性采样天然对应方向插值,比 cubemap 省内存、比经纬图没有极点拉伸。

UE5.3 里 Radiance Cache 有两层,在 LumenRadianceCache.cpp:

  1. Screen Probe Radiance Cache:屏幕上按固定间距(默认 16×16 像素一个,r.Lumen.ScreenProbeGather.DownsampleFactor)放探针,每个探针追踪若干方向(octahedral 编码存储,分辨率由 RadianceCache.ProbeResolution 控制)。主 GI 用的就是这一层。
  2. World Space Radiance Cache:挂在相机周围若干 clipmap 层的体素探针上,给反射、半透明这些没法用屏幕探针的路径用。相机移动时 clipmap 滚动更新,类似 VXGI 的 mip 结构。

探针的生命周期管理在 FRadianceCacheState:新出现的探针是"黑"的,需要几帧 warm-up 收敛;走远了的探针释放。这里有个实测细节:相机快速移动时画面边缘出现低频的间接光"滞后"或变暗,多数就是 screen probe 在边缘重新生成、还没收敛,不是 bug。r.Lumen.RadianceCache.Visualize 可以确认。

Final Gather 怎么消费这两级缓存

LumenScreenProbeGather.cpp 是把这些拼起来的地方。流程:

  1. 每个屏幕探针发射一组方向采样(用 BRDF 重要性采样 + 空间时间滤波复用邻居样本);
  2. 每条光线走 SDF tracing 命中 Surface Cache,读出 radiance 写进探针的 octahedral 纹理;
  3. 逐像素着色时,用像素法线方向对周围探针做空间+方向加权插值——权重大致是到探针的距离权重乘上法向一致性项($\max(0, n \cdot d)$ 形态,$d$ 为像素指向探针的方向),和 DDGI 的探针加权是同族做法。

所以最终画面的间接光 = 插值出的探针辐射,不是逐像素追踪结果。这解释了 Lumen 的两个典型 artifact:低频漏光(探针间距大于几何厚度时插值跨墙)和 gloss 反射模糊(方向分辨率就那么多)。前者靠 Screen Probe 的 world-space 距离拒绝(RadianceCache 里的 probe validity 权重)缓解,后者只能上 HWRT 路径(r.Lumen.Reflections.HardwareRayTracing)。

Radiosity 的更新调度

Radiosity(把间接光回写进 Surface Cache)不是全场景每帧更新,而是按优先级轮询:LumenRadiosity.cpp 里每张 Card 有 update budget,靠近相机、被大量探针引用的 Card 优先。所以光源突变(开灯、爆炸)时,间接光的扩散是几帧内逐渐波及的,这在风格上是特性不是 bug,但对"开关灯瞬间全亮"的玩法需求要提前知道。

Lumen 两级缓存数据流:传输跨帧缓存,采样在线插值 Card Capture 逐 mesh 抓拍材质 Surface Cache 图集 材质 + 深度 + emissive 任意命中点 O(1) 采样 SDF Tracing Global SDF → Mesh SDF Radiance Cache 探针 octahedral 方向编码 屏幕 16px 间距 / world clipmap Final Gather 探针插值 逐像素间接光 Radiosity:间接光回写 emissive(帧 n+1 的输入,Lⁿ⁺¹ = L_e + T·Lⁿ) 帧内闭环:追踪 → 探针 → 插值 跨帧闭环:Radiosity 回写, 每帧推进一步辐射传输 低频信号稀疏采样 + 插值重建:探针稀是故意的,漏光与模糊是这步的固有代价

串起来的数据流

Card Capture → Surface Cache(材质+上次Radiosity)
SDF Tracing(Global→Mesh SDF)→ 命中 Surface Cache
→ Screen Probe Radiance Cache(方向光缓存)
→ Final Gather 插值 → 间接光输出
→ Radiosity 回写 Surface Cache emissive(下一帧用)

调参优先级按我的经验:先看 Screen Probe 下采样率和方向分辨率,再看 Surface Cache 图集够不够,最后才考虑 HWRT。Stat Lumen 加 profilegpu 配合能把每个环节的开销拆开,比拍脑袋调 r 命令有效得多。

Lumen 的工程启示和 Nanite 类似:用分层缓存把"任意精度"的需求切成"粗结构 + 细插值",让每一帧的边际成本恒定。GI 领域的可扩展性难题,被它拆成了缓存管理问题。