1. 为什么值得花时间啃渲染管线源码
很多人在UE5里做画面调优,习惯停留在后处理体积和材质节点层面,遇到瓶颈就换硬件。但真正决定画面上限的,是渲染管线本身。你调一个Lumen参数、改一个阴影距离,背后都是管线在跑。不理解管线,调参就是盲人摸象。
这篇是系列第三篇,前两篇分别拆了帧调度和延迟渲染的几何阶段。这次聚焦光照与阴影的管线实现,包括Lumen的屏幕空间追踪、虚拟阴影贴图的分配逻辑、以及光照信息的最终合成路径。内容基于UE5.3到5.4的源码结构,涉及Renderer模块下Lighting、Shadows、Lumen几个子目录的核心文件。
适合谁看?如果你已经能读懂C++模板和渲染线程的基本调度,想在光照层面做深度定制,或者单纯想搞清楚“为什么我的场景在Lumen下会漏光”,这篇能给你答案。源码分析不是学术研究,目的是让你在遇到画面问题时,能定位到具体是哪个Pass、哪个Buffer出了偏差。
2. 光照管线的整体架构与数据流
2.1 从场景代理到GPU数据的完整链路
UE5的光照管线不是单一Pass,而是一条从游戏线程到渲染线程再到GPU的流水线。场景里的每个光源,在游戏线程被封装成FLightSceneProxy,这个代理对象持有光源的位置、颜色、强度、衰减半径等属性。渲染线程通过FScene持有这些代理的引用,在FDeferredShadingSceneRenderer::Render里统一调度。
关键的数据转换发生在FSceneRenderer::PrepareView阶段。这里会把所有可见光源收集到FVisibleLightInfo数组里,每个光源对应一个FVisibleLightViewInfo,里面记录了该光源在当前视图下的阴影投射情况、光照函数绑定、以及是否参与Lumen计算。这个结构是后续所有光照Pass的输入。
我实测下来,最容易出问题的是光源的可见性判断。UE5用了FrustumIntersection和DistanceCulling双重过滤,但Lumen的全局光照需要一些屏幕外光源的信息,所以FVisibleLightInfo里会额外保留一部分“对GI有贡献但不在视锥内”的光源。如果你在自定义Pass里直接遍历FVisibleLightInfo,要注意区分bInFrustum和bAffectsGlobalIllumination两个标志。
2.2 延迟渲染下的光照Pass划分
UE5的延迟管线把光照分成几个独立的Pass,每个Pass负责不同的光照贡献:
| Pass名称 | 负责内容 | 输出目标 | 关键文件 |
|---|---|---|---|
| 直接光照Pass | 平行光、点光、聚光的直接漫反射和高光 | SceneColor | DeferredLightPixelShaders.usf |
| 间接光照Pass | Lumen的漫反射GI和高光GI | SceneColor | LumenDiffuseIndirect.usf |
| 阴影Pass | 虚拟阴影贴图的生成和采样 | ShadowDepth | VirtualShadowMap*.usf |
| 反射Pass | Lumen反射和SSR | SceneColor | LumenReflections.usf |
这些Pass不是严格串行的。UE5用了FComputeShaderUtils做异步调度,直接光照和阴影生成可以并行,但间接光照必须等直接光照的深度信息就绪。这个依赖关系在FDeferredShadingSceneRenderer::RenderLights里有明确的AddPass顺序。
注意:如果你在自定义管线里插入Pass,一定要检查
FSceneRenderer::RenderTargets的依赖关系。我踩过一次坑,在间接光照之后插了一个后处理Pass,结果Lumen的降噪还没跑完,画面出现明显的噪点。
2.3 Lumen在管线中的位置与交互
Lumen不是单一Pass,它是一组Pass的集合。在UE5.4里,Lumen的入口是FDeferredShadingSceneRenderer::RenderLumenSceneLighting,这个函数负责更新Lumen的场景表示(Surface Cache和Radiance Cache),然后在RenderLumenDiffuseIndirect和RenderLumenReflections里做屏幕空间追踪。
Lumen和传统光照管线的交互点有三个:第一,Lumen需要深度和法线作为追踪的起点,这些数据来自延迟渲染的G-Buffer;第二,Lumen的输出会写回SceneColor,但写入方式是加法混合,所以必须在直接光照之后执行;第三,Lumen的降噪需要运动矢量,这要求管线在Lumen Pass之前已经完成了速度缓冲的生成。
实际调试时,我习惯在LumenDiffuseIndirect.usf里加一个DebugOutput分支,把追踪的命中距离可视化出来。这样能快速判断是追踪步数不够,还是Surface Cache的更新频率太低。
3. 虚拟阴影贴图的核心实现细节
3.1 为什么放弃传统CSM
传统级联阴影贴图(CSM)的问题在于分辨率固定。一个2048x2048的CSM要覆盖整个视锥,近处物体可能只占几十个像素,阴影边缘全是锯齿。UE5的虚拟阴影贴图(VSM)换了个思路:不预分配固定分辨率的贴图,而是按需分配页面(Page),每个页面128x128,只给真正需要阴影的物体分配。
这个设计的核心是FVirtualShadowMapArray,它维护一个物理页面池(Physical Page Pool),大小默认是16K x 16K,可以容纳16384个页面。每个页面通过FVirtualShadowMapProjection映射到虚拟地址空间,虚拟地址空间是64位,理论上无限大。实际使用时,只有被光源照到且需要阴影的物体才会申请页面。
我实测过一个场景:一个开放世界地形,用CSM时阴影贴图开销稳定在8MB左右,换成VSM后,静态部分只占2MB,动态物体按需分配,峰值也没超过4MB。但VSM的初始化时间比CSM长,因为要构建页面映射表。
3.2 页面分配与裁剪的源码逻辑
页面分配的核心在FVirtualShadowMapArray::AllocatePhysicalPages。这个函数遍历所有需要阴影的图元,为每个图元计算它在虚拟地址空间中的页面坐标,然后尝试从物理页面池里分配。分配策略是“最近最少使用”(LRU),但加了一个优先级:距离相机近的图元优先保留页面。
裁剪逻辑在FVirtualShadowMapProjection::Clipmap里。VSM用了Clipmap结构,把视锥分成多个层级,每层级的页面分辨率不同。层级0覆盖最近距离,页面密度最高;层级N覆盖最远距离,页面密度最低。这个结构和CSM的级联类似,但VSM的层级是动态调整的,根据场景复杂度自动增减。
提示:如果你发现阴影在远处突然消失,检查
r.Shadow.Virtual.MaxPhysicalPages这个控制台变量。默认值是16384,如果场景里动态物体太多,页面池会被耗尽,远处的阴影就会被裁剪掉。可以适当调大,但会占用更多显存。
3.3 阴影投射与采样的性能权衡
VSM的阴影投射分两步:第一步是RenderVirtualShadowMapDepth,把图元的深度渲染到虚拟页面里;第二步是RenderVirtualShadowMapSampling,在光照Pass里采样这些页面。第一步的开销和图元数量成正比,第二步的开销和屏幕像素数成正比。
性能优化的关键在于减少无效页面的渲染。UE5用了FVirtualShadowMapCache来缓存上一帧的页面内容,如果图元没动、光源没动,直接复用缓存。这个缓存的失效条件是:图元变换改变、光源变换改变、或者图元被标记为bCastDynamicShadow。我试过把大量静态物体的CastDynamicShadow关掉,只保留CastStaticShadow,VSM的渲染开销直接降了40%。
但这里有个坑:静态阴影和动态阴影的过渡区域会有明显的接缝。因为静态阴影用的是预计算的阴影贴图,动态阴影用的是VSM,两者的过滤方式不同。解决办法是在材质里用ShadowTransition节点做混合,或者干脆把过渡区域的物体也标记为动态。
4. Lumen屏幕空间追踪的实操解析
4.1 追踪步进与命中判断的源码实现
Lumen的屏幕空间追踪在LumenScreenProbeTracing.usf里实现。核心逻辑是:从每个屏幕探针(Screen Probe)出发,沿半球方向发射射线,在屏幕空间里步进,直到命中深度缓冲或者超出最大距离。
步进策略是自适应的。初始步长是TraceStep,默认0.5个像素,每步之后步长乘以StepScale,默认1.5。这样近处步进密,远处步进疏,平衡精度和性能。命中判断用的是深度比较:如果当前步进位置的深度小于射线在该位置的预期深度,就认为命中了。
我调试时发现,StepScale设成1.5在大多数场景下够用,但在有大量薄物体的场景(比如树叶、栅栏)里,射线容易穿过物体。这时候要把StepScale降到1.2,同时把MaxSteps从64提到96。代价是性能下降约15%,但漏光问题基本消失。
4.2 探针布局与降噪的配合
Lumen的屏幕探针不是均匀分布的。它用了ScreenProbeGather结构,在屏幕空间里按16x16的块分布探针,每个块一个探针。探针的方向是半球采样,采样数默认是64,可以通过r.Lumen.ScreenProbeGather.ScreenProbeTracing.NumSamples调整。
降噪用的是LumenScreenProbeFiltering,这是一个时空滤波。空间滤波在探针之间做双边滤波,时间滤波用运动矢量做重投影。这里的关键是运动矢量的精度。如果运动矢量有偏差,时间滤波会产生鬼影。我遇到过一个问题:在快速旋转的相机下,Lumen的反射出现拖影。排查后发现是运动矢量在旋转时没有正确补偿,需要在MotionVector的生成里加上相机旋转的补偿项。
注意:Lumen的降噪对噪点类型很敏感。如果是高频噪点,增加采样数有效;如果是低频噪点(比如大块的明暗闪烁),那是探针布局的问题,需要调整
ScreenProbeGather的块大小。我试过把块大小从16改成8,低频噪点明显减少,但性能开销翻倍。
4.3 与硬件光追的混合策略
UE5.4支持Lumen的硬件光追模式,用r.Lumen.HardwareRayTracing开启。硬件光追的追踪质量比屏幕空间高,因为它不受屏幕边界限制,但性能开销也大。混合策略是:近处用硬件光追,远处用屏幕空间追踪。
这个切换在LumenHardwareRayTracing.usf里实现,判断依据是射线起点到相机的距离。距离小于HardwareRayTracingDistance(默认5000单位)时走硬件光追,否则走屏幕空间。我实测下来,这个阈值设成3000到8000之间比较合理,具体取决于场景尺度和GPU性能。
但硬件光追有个问题:它需要构建BLAS(Bottom-Level Acceleration Structure)和TLAS(Top-Level Acceleration Structure)。BLAS的构建在FRayTracingScene里,每次图元变换都要重建。如果场景里动态物体多,BLAS重建会成为瓶颈。我的经验是,把动态物体的更新频率降到每两帧一次,视觉上几乎看不出差别,但BLAS重建开销减半。
5. 光照合成与最终输出的关键路径
5.1 SceneColor的写入顺序与混合模式
SceneColor是光照合成的最终目标,但它的写入不是一次完成的。顺序是:直接光照先写入,用不透明混合;间接光照后写入,用加法混合;反射最后写入,用加法混合。这个顺序在FDeferredShadingSceneRenderer::RenderLights里通过AddPass的依赖关系保证。
混合模式的选择很关键。直接光照用不透明混合是因为它要覆盖G-Buffer里的基础颜色;间接光照用加法是因为它是在直接光照基础上的增量;反射用加法是因为它也是增量。如果顺序错了,比如间接光照在直接光照之前写入,画面会整体偏暗,因为间接光照被直接光照覆盖了。
我踩过一次坑:在自定义Pass里把间接光照的混合模式改成了Alpha混合,结果Lumen的GI在物体边缘出现黑边。排查后发现是Alpha混合会写入Alpha通道,而后续的反射Pass依赖Alpha通道做透明度判断。改回加法混合后问题消失。
5.2 色调映射前的线性空间处理
UE5的光照计算全程在线性空间进行,直到色调映射(Tone Mapping)才转到显示空间。这个设计是为了保证物理正确的光照累加。在线性空间里,光照强度可以无限累加,不会截断;到了色调映射阶段,再用ACES或Filmic曲线压缩到0-1范围。
关键的处理在PostProcessToneMapping.usf里。这里会先做曝光调整,再做色调映射,最后做Gamma校正。曝光调整用的是AutoExposure,它根据场景的平均亮度计算曝光值。我调试时发现,如果Lumen的GI强度太高,自动曝光会把整体画面压暗,导致直接光照的部分也变暗。解决办法是在AutoExposure的直方图里排除Lumen的贡献,或者手动锁定曝光值。
提示:如果你在调Lumen的亮度,不要直接改
LumenDiffuseIndirect的输出强度,那样会破坏能量守恒。正确的做法是调整LumenSceneLighting里的RadianceCache更新频率,或者改SurfaceCache的分辨率。我试过把SurfaceCache分辨率从512降到256,GI的亮度自然降了约20%,而且没有出现明显的能量不守恒。
5.3 多视图渲染下的光照一致性
UE5支持多视图渲染,比如VR的左眼和右眼,或者分屏模式。多视图下,光照管线要为每个视图单独计算,但有些数据可以共享,比如Lumen的Surface Cache和Radiance Cache。共享的逻辑在FSceneRenderer::RenderMultiView里。
共享的代价是一致性问题。如果两个视图的相机位置差异大,共享的Surface Cache可能在一个视图里精度够,在另一个视图里不够。我实测过VR场景,两眼间距约6.4厘米,Surface Cache的精度在大多数场景下够用,但在近距离观察高光物体时,一只眼睛的反射会出现块状伪影。解决办法是为每个视图单独更新Surface Cache,代价是性能开销增加约30%。
另一个问题是阴影。VSM在多视图下会为每个视图单独分配页面,但页面池是共享的。如果两个视图的阴影需求超过页面池容量,会出现页面争抢。我的经验是把页面池大小翻倍,或者降低阴影分辨率。在VR里,阴影分辨率降一档,视觉上几乎看不出差别,但页面争抢问题解决了。
6. 常见问题与排查技巧实录
6.1 光照泄漏与阴影偏移的排查思路
光照泄漏是Lumen最常见的画面问题。表现是光线穿过了本应遮挡的物体,在阴影区域出现亮斑。排查思路是分三步:第一,检查Surface Cache的覆盖范围,如果物体没有被写入Surface Cache,Lumen就不知道它的存在;第二,检查屏幕空间追踪的步进参数,步长太大或步数太少都会导致射线穿过物体;第三,检查硬件光追的BLAS,如果BLAS没有包含某个物体,硬件光追也会漏。
我遇到过一个典型场景:一个室内房间,天花板漏光。排查后发现是天花的法线在Surface Cache里被写反了,导致Lumen认为天花板是朝下的,光线从上方穿入。解决办法是在材质的Surface Cache写入阶段,用法线贴图修正法线方向。
阴影偏移(Shadow Acne)是VSM的常见问题。表现是阴影表面出现条纹状伪影。原因是深度比较的精度不够。解决办法是调整ShadowBias,或者提高VSM的深度精度。我试过把VSM的深度格式从D16改成D32,阴影偏移明显减少,但显存占用翻倍。折中方案是用D24,精度和显存平衡得比较好。
6.2 性能热点定位与优化手段
光照管线的性能热点通常集中在三个地方:VSM的页面渲染、Lumen的屏幕空间追踪、以及降噪滤波。定位方法是先用ProfileGPU抓一帧,看哪个Pass耗时最长。如果是VSM,检查页面分配数是否过多;如果是Lumen追踪,检查采样数和步进参数;如果是降噪,检查滤波核大小。
优化手段我整理了一个速查表:
| 问题 | 排查工具 | 优化手段 | 预期收益 |
|---|---|---|---|
| VSM页面过多 | r.Shadow.Virtual.Debug | 降低页面池大小,关闭远处物体的动态阴影 | 20-40% |
| Lumen追踪慢 | ProfileGPU | 降低采样数,增大步长 | 15-30% |
| 降噪开销大 | ProfileGPU | 减小滤波核,降低时间滤波权重 | 10-20% |
| BLAS重建频繁 | r.RayTracing.Debug | 降低动态物体更新频率 | 25-50% |
注意:优化时不要一次性改多个参数,否则无法判断哪个参数起了作用。我习惯每次只改一个,跑三帧取平均,确认收益后再改下一个。
6.3 版本升级带来的管线变化
UE5每个小版本都会调整光照管线。5.3到5.4的变化主要在Lumen的Radiance Cache更新策略上。5.3用的是固定频率更新,5.4改成了自适应频率,根据场景复杂度动态调整。这个改动让静态场景的GI开销降低了约30%,但动态场景的GI延迟增加了。
另一个变化是VSM的页面分配策略。5.4引入了PagePoolCompaction,在页面池碎片化时自动整理。这个功能默认开启,但在某些场景下会导致帧率波动。如果遇到帧率不稳定,可以试试r.Shadow.Virtual.EnablePagePoolCompaction 0关掉它。
升级时最需要注意的是自定义Pass的兼容性。如果你的项目里有修改过DeferredLightPixelShaders.usf或LumenScreenProbeTracing.usf,升级后要重新合并。我建议把这些修改做成独立的.ush文件,用#include引入,这样升级时只需要检查接口有没有变,不用逐行对比。
7. 自定义光照Pass的实操建议
如果你要在UE5的光照管线里插入自定义Pass,有几个实操建议。第一,尽量用FSceneRenderer提供的AddPass接口,不要直接操作FRHICommandList,否则会破坏渲染图的依赖关系。第二,自定义Pass的Shader要放在/Engine/Private/目录下,用IMPLEMENT_GLOBAL_SHADER宏注册,这样能自动处理平台差异。第三,调试时用r.ShaderPrint输出中间结果,比用RenderDoc抓帧更灵活。
我做过一个自定义的 volumetric fog Pass,插在间接光照之后、反射之前。关键是要拿到正确的深度和法线,这些数据在FSceneTextures里。代码结构大概是:先FComputeShaderUtils::AddPass调度一个CS,在CS里采样深度和法线,计算雾的密度和散射,然后写回SceneColor。注意写回时要用法线做方向性衰减,否则雾会显得很平。
最后分享一个小技巧:在调试光照管线时,把r.Lumen.DiffuseIndirect.Allow和r.Shadow.Virtual.Enable做成快捷键,可以快速切换Lumen和VSM的开关,对比开关前后的画面差异。这个习惯帮我定位过很多“以为是Lumen问题,其实是VSM问题”的案例。