UE5延迟剔除:像素级光源优化与渲染性能提升
1. 项目概述:深入UE5渲染管线的“守门员”
在UE5引擎的渲染世界里,每一帧画面都像是一场精密的接力赛。从场景中的成千上万个物体,到最终呈现在屏幕上的像素,中间要经过几何处理、光照计算、后处理等多个环节。如果让所有物体,无论远近、是否在视野内,都完整地跑完整个渲染流程,那对GPU来说无疑是场灾难。这时,就需要一个高效的“守门员”——延迟剔除。
延迟剔除,在UE5的语境下,特指在延迟渲染管线中,发生在几何通道之后、光照通道之前的一个关键优化步骤。它的核心任务非常明确:在像素级别,精确地判断哪些片元(Fragment)需要继续进行昂贵的光照计算,哪些可以直接丢弃。这听起来简单,但要在保证画面绝对正确的前提下,实现极致的性能提升,里面充满了工程智慧。
我花了相当长的时间,在UE5的源码海洋里追踪这个编号为“169”的提交或模块(具体指代需结合引擎版本,但核心逻辑相通),试图理清它的脉络。这不仅仅是为了理解一段代码,更是为了掌握现代游戏引擎如何在高复杂度场景下,依然保持流畅帧率的核心秘诀。无论你是正在攻坚性能瓶颈的图形程序员,还是对UE5底层机制充满好奇的技术美术,理解延迟剔除,都能让你对渲染管线的掌控力提升一个维度。
2. 延迟剔除的核心价值与设计哲学
2.1 为什么要“延迟”剔除?
在传统的正向渲染中,剔除主要发生在三角形层面,即视锥体剔除和遮挡剔除。这些剔除发生在顶点处理阶段之前,目的是减少进入光栅化的三角形数量。然而,在延迟渲染中,情况发生了变化。
延迟渲染将几何信息(位置、法线、材质属性等)先渲染到一系列缓冲区中,我们称之为G-Buffer。之后,光照计算作为一个独立的Pass,读取G-Buffer中的信息,在屏幕空间进行。这就带来了一个新问题:G-Buffer中的每一个像素,都代表了一个世界空间中的点,但并非所有点都需要被所有光源照亮。
例如,一个像素位于阴影中,或者它距离某个光源过远以至于该光源的影响可以忽略不计。如果在光照Pass中,仍然对所有像素计算所有光源,会造成巨大的浪费。因此,“延迟剔除”应运而生,它的剔除发生在像素级别,在光照计算之前,目标是减少每个像素需要处理的光源数量。
2.2 设计目标:在精度与性能间寻找平衡
延迟剔除系统的设计,始终围绕着几个核心目标展开:
- 最大化剔除率:这是最直接的目标,即尽可能多地识别出那些完全不需要进行光照计算的像素-光源对。剔除得越多,GPU在光照Pass的工作负载就越轻。
- 保证视觉正确性:剔除必须是保守的。宁可多算,不可漏算。因为漏掉一个本该计算的光源,会导致画面出现黑块、光照错误,这是绝对不允许的。因此,所有剔除判断都需要一个“安全边界”。
- 开销可控:剔除操作本身也是有成本的。如果为了剔除而进行的计算比直接进行光照计算还要昂贵,那就本末倒置了。因此,剔除算法必须非常高效,通常依赖于预先计算好的数据结构和简单的数学比较。
- 适应多种光源类型:UE5场景中可能包含平行光、点光源、聚光灯等多种光源,它们的影响范围(边界球、锥体)不同,剔除算法需要能统一或分别高效处理。
UE5的延迟剔除系统,正是基于这些目标,构建了一套多层次、渐进式的剔除策略。
3. 源码脉络解析:从FDeferredShadingSceneRenderer开始
要找到延迟剔除的代码,我们的起点通常是负责延迟渲染的主要类——FDeferredShadingSceneRenderer。在Render函数调用链中,我们会追踪到光照处理的部分。
一个关键的函数是RenderLights或RenderLight相关的函数。在这些函数中,引擎会遍历场景中的所有可见光源,并为每个光源准备绘制调用。而剔除逻辑,就嵌入在这个准备过程中。
具体到“169”这个线索,它可能指向一个特定的提交哈希、代码文件行号,或是一个内部模块标识。在UE5的源码中,与延迟剔除紧密相关的代码通常分布在以下文件中:
DeferredShadingRenderer.cpp:这是延迟渲染实现的核心文件,大量的渲染Pass逻辑都在这里。LightRendering.h/.cpp:光源渲染的具体实现,包含光源边界计算、着色器参数设置等。SceneVisibility.cpp:虽然更侧重于早期的物体可见性判断,但其中一些数据结构(如分块)也可能被延迟剔除复用。Shader目录下的各种光照着色器(DeferredLightPixelShaders.usf等):剔除逻辑的GPU端实现。
剔除的核心思想可以概括为:为每个光源计算其在屏幕空间的潜在影响区域(通常是一个2D边界矩形),然后利用深度缓冲区(Depth Buffer)和屏幕空间信息,快速判断这个区域内哪些像素实际上位于该光源的有效范围之外。
3.1 CPU端的粗粒度剔除
在提交绘制命令到GPU之前,CPU会先进行一轮粗剔除:
- 光源视锥体剔除:与物体剔除类似,首先判断光源的包围球或包围盒是否在摄像机视锥体内。完全在外的光源直接跳过。
- 计算屏幕空间影响矩形:对于通过视锥体测试的光源,将其在世界空间中的影响范围(如点光源的球体)投影到屏幕空间,得到一个2D的矩形区域。这个矩形是光源可能影响到的所有像素的超集。
- 分块(Tiled)剔除的准备工作:现代GPU渲染常用分块延迟渲染。CPU端会将屏幕划分为许多小块(例如16x16像素),并预先为每个块计算一些共享信息(如深度范围),但更精细的剔除通常在GPU着色器中完成。
注意:CPU端的剔除必须是保守的。计算屏幕矩形时,通常会稍微扩大一些(比如加上一个偏移),以确保不会意外裁剪掉真正应该被照亮的像素。这个“安全边界”的大小需要根据场景尺度精心调整。
3.2 GPU端的精粒度剔除:着色器中的奥秘
真正的性能杀手锏在GPU着色器里。在光照像素着色器开始计算辐照度之前,会先执行一系列的剔除测试。以点光源为例,在一个典型的DeferredLightPixelShaders中,你可能会看到类似下面的逻辑(以伪代码形式说明):
// 从G-Buffer中读取当前像素的世界位置和深度 float3 WorldPos = GetWorldPositionFromGBuffer(ScreenUV); float PixelDepth = SampleDepthBuffer(ScreenUV); // 获取当前光源的参数(已在常量缓冲区中) float3 LightPosition; float LightRadius; float LightFalloffExponent; // 计算像素到光源的距离 float DistanceToLight = distance(WorldPos, LightPosition); // 核心剔除判断1:距离剔除 if (DistanceToLight > LightRadius) { discard; // 或 return 0; 直接丢弃该像素的光照贡献 } // 核心剔除判断2:基于深度范围的优化(在分块渲染中) // 假设我们已知当前像素所在屏幕分块的最近和最远深度(MinDepth, MaxDepth) // 可以快速判断整个块是否完全在光源范围之外或之内,进行块级别的提前退出。 // 这通常在计算着色器或更早的阶段完成。 // 如果通过所有剔除测试,则进行昂贵的光照计算(如Phong/BRDF) float3 Lighting = CalculatePhongLighting(WorldPos, Normal, LightPosition, LightColor, ...); return Lighting;这里的关键在于discard或提前返回。一旦像素被判定为不受此光源影响,后续所有复杂的光照模型计算、纹理采样、复杂数学运算都将被跳过,节省了大量的GPU周期。
对于聚光灯,还需要增加一个方向锥体的测试;对于平行光,虽然通常没有距离剔除,但可能会有基于阴影贴图的屏幕空间遮挡剔除。
4. 深入核心:深度边界与屏幕分块加速
4.1 利用深度缓冲区进行快速剔除
这是延迟剔除中最常用且高效的技巧之一。对于每个光源的屏幕空间影响矩形,我们可以获取这个矩形区域内深度缓冲区的最大值和最小值(Zmax和Zmin)。
- 情况A:如果计算发现,该矩形内所有像素的最近深度(
Zmin)都比光源的最大影响范围(投影到深度值)还要远,那么整个矩形区域都可以被剔除,这个光源无需对该区域进行任何渲染。 - 情况B:反之,如果最远深度(
Zmax)比光源的最近影响范围还要近,说明整个矩形区域都在光源影响范围内,可以跳过逐像素的距离比较,直接进行光照计算。但这需要谨慎,因为深度缓冲区存储的是最浅深度,可能存在被遮挡的物体,所以此优化通常用于不透明物体且需要特定条件。
在实际实现中,UE5可能会使用Hi-Z(层次化深度缓冲区)技术来加速这个过程。Hi-Z构建了一个深度缓冲区的金字塔链,每一层是下一层的降采样。在高层级上,可以快速判断一个大区域与光源范围的粗略关系,从而避免对低层级(高分辨率)的深度缓冲区进行大量采样。
4.2 分块延迟渲染(Tiled Deferred Rendering)
这是将延迟剔除发挥到极致的架构。它将屏幕分割成许多固定大小的瓦片(Tile,如32x32)。在光照计算之前,先运行一个“光源分配”的计算着色器:
- 收集光源:对每个屏幕瓦片,遍历所有光源。利用瓦片的包围盒(在视图空间或世界空间)和该瓦片的深度范围(从深度缓冲区计算得出),快速剔除掉那些完全不影响该瓦片的光源。
- 创建光源列表:为每个瓦片生成一个紧凑的光源索引列表,只包含真正可能影响该瓦片的光源。
- 执行光照:随后,像素着色器在运行时,只需根据像素所在的瓦片,从对应的紧凑光源列表中读取光源数据进行计算。这样,每个像素处理的光源数量从场景总光源数锐减到其所在瓦片的相关光源数,性能提升巨大,尤其适合拥有大量小型光源的场景(如霓虹灯、粒子效果丰富的场景)。
在UE5的源码中,你可能会在FDeferredShadingSceneRenderer::RenderTiledDeferredLighting或类似函数中找到这套逻辑。计算着色器部分会看到大量的线程组(Thread Group)操作、共享内存(Shared Memory)的使用,用于高效地协作完成每个瓦片的光源剔除和列表构建。
5. 实战中的注意事项与调优心得
阅读源码理解了原理,但要在实际项目中用好延迟剔除,避免踩坑,还需要一些实战经验。
5.1 性能分析与调试工具
- GPU Profiler:使用RenderDoc或Nsight等工具抓取一帧。观察
RenderLights相关的Pass。如果一个光源的绘制调用覆盖了整个屏幕,但像素着色器耗时却很低,说明延迟剔除(特别是discard)效率很高。反之,如果耗时高,可能需要检查光源半径是否过大,或者剔除判断是否有问题。 - 可视化剔除结果:可以编写一个简单的后期材质或调试着色器,将不同剔除原因(如距离剔除、锥体剔除)的像素用不同颜色显示出来。这能直观地看到剔除的效果和边界是否准确。
- Stat GPU和Stat SceneRendering:在UE编辑器内使用这些命令,可以查看每帧处理的光源数量、绘制调用次数等宏观指标,帮助判断剔除系统是否正常工作。
5.2 常见性能陷阱与调优
- 光源半径(Attenuation Radius)设置不当:这是最常见的性能杀手。美术同学有时为了让光照效果“看起来够亮”,会把衰减半径设得非常大。这会导致光源的屏幕空间影响矩形巨大,剔除效率急剧下降。务必在编辑器中强制审查并优化每个光源的衰减半径,确保它和视觉影响范围基本匹配。可以编写自动化检查脚本,标记出半径异常的光源。
- 半透明物体的干扰:延迟渲染通常只处理不透明物体。半透明物体是在延迟渲染之后,用正向渲染叠加的。这意味着,延迟剔除基于的深度缓冲区不包含半透明物体。如果一个半透明物体后面有一个点光源,该光源可能因为被不透明物体深度剔除而无法照亮那个半透明物体,导致视觉错误。对于重要的半透明物体(如窗户玻璃后的光源),可能需要特殊处理,或让美术调整光源位置和范围。
- 动态阴影与剔除的交互:如果一个光源开启了动态阴影(如级联阴影贴图CSM),即使某个像素在延迟剔除中被判定为不受该光源直接照射,它仍然可能需要参与阴影深度的渲染。这意味着剔除不能完全避免该光源带来的开销。对于移动平台或性能紧张的场景,需要严格控制开启动态阴影的光源数量。
- 分块大小的选择:在分块延迟渲染中,瓦片大小是一个权衡。瓦片越小,光源分配越精确,每个像素处理的光源越少,但分配阶段的开销和光源列表的管理开销会增大。UE5通常会根据平台选择默认值(如PC用32x32,移动端可能用16x16)。除非有充分理由和性能测试数据,否则不要轻易修改。
5.3 针对特定场景的优化策略
- 室内场景:室内通常有大量小型点光源和聚光灯。分块延迟渲染在这里收益最高。确保墙壁、地板等大型物体有良好的光照贴图或反射探针,可以减少实时光源的数量和强度。
- 超大开放世界:平行光是主角,点光源/聚光灯可能集中在玩家据点。对于远处的众多小光源,可以考虑使用一种称为“光源簇(Light Clustering)”的升级版技术,它在视图空间的三维网格中进行光源分配,比屏幕空间的2D分块更适合深度变化剧烈的场景。UE5的移动渲染器可能使用了变种。同时,积极使用光照重要性体积(Light Importance Volume)来动态禁用远处光源的渲染。
- 大量粒子特效:每个发光的粒子都可能是一个小型点光源。如果数量成百上千,即使是分块剔除也压力巨大。这时应考虑将粒子光照烘焙到体积纹理中,或者使用更简化的光照模型(如只影响漫反射的顶点光照)。
6. 从“169”提交看引擎演进
虽然我无法直接定位到“169”这个具体标识对应的代码行,但追踪UE版本间延迟渲染和剔除代码的改动,是理解引擎发展的绝佳窗口。例如,从UE4到UE5,在延迟渲染路径上,一个显著的变化是Lumen全局光照和Nanite虚拟几何体的集成。
Lumen使用屏幕空间追踪和Mesh Distance Field,这改变了场景的可见性信息和间接光照的计算方式。延迟剔除系统需要与Lumen协作,确保被Lumen考虑到的间接光照贡献者(通常是较大的表面)不会被过早剔除。你可能在源码中看到新的判断条件,例如检查像素是否在Mesh Distance Field的某个影响范围内。
Nanite则通过其自身的超精细裁剪和细节层次,在几何阶段就进行了极其高效的剔除。这实际上减轻了延迟剔除阶段的部分压力,因为提交到光栅化的三角形已经是最优集合。但延迟剔除仍需处理这些超密集几何产生的像素。
阅读这类提交的启示在于:优化不是孤立的。延迟剔除的演进,始终跟随着渲染架构的整体变革。当你修改或调试剔除相关代码时,必须考虑它与其他系统(阴影、GI、后处理)的联动。一个看似单纯的剔除优化,可能会在另一个系统引起难以察觉的视觉瑕疵。
7. 自定义剔除:当默认方案不够用时
UE5提供的延迟剔除系统已经非常强大和通用。但在某些极端或特殊的项目需求下,你可能需要实现自定义的剔除逻辑。
场景案例:一个需要模拟真实光线传播、存在大量微小缝隙和复杂遮挡的密室逃脱游戏。标准的光源半径剔除和屏幕分块可能效果不佳,因为光线通过缝隙照亮另一侧局部区域的情况很常见,标准剔除容易误杀。
实现思路:
- 扩展G-Buffer:可以在自定义渲染通道中,向G-Buffer添加额外信息,例如粗略的体素化场景表示ID,或者预先计算好的“光照连通性”贴图。
- 自定义计算着色器:编写一个
CS,在光源分配阶段,不仅考虑深度范围,还读取这些额外信息。例如,对于每个光源,采样其位置周围小范围的“连通性贴图”,如果贴图显示与当前瓦片区域不连通,则即使几何距离足够,也可以安全剔除。 - 集成到渲染管线:这需要修改引擎的着色器编译管线,添加你自己的
CS,并挂钩到FDeferredShadingSceneRenderer的渲染流程中。你需要仔细研究AddRenderPass的调用、RDG(渲染依赖图)的构建,确保你的Pass在正确的时间,以正确的依赖关系被执行。
警告:自定义剔除逻辑的调试极其困难。你必须使用前文提到的可视化工具,并准备大量的测试场景来验证剔除的正确性。一个错误的剔除判断导致的画面错误,可能在复杂的场景中潜伏很久才被发现。因此,除非万不得已,尽量通过调整光源参数、使用光照贴图、设计关卡等上层手段来规避性能问题,而非直接修改底层剔除引擎。
理解UE5的延迟剔除,就像拿到了一张高性能渲染管线的微观电路图。它告诉你,引擎是如何在像素的洪流中,聪明地省下每一分计算力。这份理解,能让你在遇到渲染性能问题时,不再盲目地降低分辨率或关闭特效,而是能够进行精准的诊断和调优。从宏观的架构选择,到微观的着色器指令,性能优化之路,正是由这样一个个深刻理解所铺就的。
