☰
UE5渲染管线源码深度解析:Lumen与虚拟阴影贴图实现
2026/10/14 6:40:13 网站建设 项目流程

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平行光、点光、聚光的直接漫反射和高光SceneColorDeferredLightPixelShaders.usf
间接光照PassLumen的漫反射GI和高光GISceneColorLumenDiffuseIndirect.usf
阴影Pass虚拟阴影贴图的生成和采样ShadowDepthVirtualShadowMap*.usf
反射PassLumen反射和SSRSceneColorLumenReflections.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问题”的案例。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询