我在 UE5.8 源码的 Renderer/Private/RayTracing/ 目录下蹲了整整一周,就为了搞清楚一件事:勾上 Project Settings 里那个「Support Hardware Ray Tracing」的复选框之后,引擎到底在背后做了什么。
这个目录里有 14,682 行 C++ 代码,分布在 20 个 .cpp 源文件里(另外还有一堆 .h)。最大的文件 RayTracing.cpp 有 2,387 行,第二大的 RayTracingDebug.cpp 有 2,157 行,第三是 RayTracingScene.cpp(1,381 行)——这三个文件加起来就占了整个目录 40% 的代码量,也是理解整条管线最先要读的三份文档。
我一行行翻过去,发现整个管线可以拆成五层——每一层都是一套独立运作的子系统,合在一起就是 UE5 硬件光追的完整骨架。这篇文章就是我的翻源码笔记。咱们一层一层来。
第一层:BVH 双级加速结构——TLAS 和 BLAS 到底是什么
如果你只记住一件事,记住这个:UE5 的硬件光追用的是两级 BVH(Bounding Volume Hierarchy)。
顶层叫 TLAS(Top Level Acceleration Structure),存的是场景里所有 Mesh 实例的世界空间包围盒和变换矩阵。底层叫 BLAS(Bottom Level Acceleration Structure),存的是每个 Mesh 自身的三角形几何数据。GPU 追一条光线时先在 TLAS 里定位到某个实例,再进入该实例的 BLAS 做三角形级求交——两级分工带来的好处是:同一份 BLAS 可以被成千上万个实例共享(Instanced Static Mesh),TLAS 每帧只存变换矩阵,不重复几何。
在 UE5 源码里,这套体系的核心入口是 FRayTracingScene 类。它的头文件 RayTracingScene.h:46 附近的注释很直白:
/**
* Persistent representation of the scene for ray tracing.
* Manages top level acceleration structure instances,
* memory and build process.
*/
class FRayTracingScene
关键词是 Persistent——这个类管理的是整个光追场景的持久化表示。它不会每帧从零重建:静态 Mesh 的 BLAS 只在加载时构建一次,后续每帧只需要重建 TLAS(因为相机动了、物体位置变了)。
TLAS 的构建入口在 FRayTracingScene::Update() 里,每帧被调用一次:
// RayTracingScene.cpp:232 附近
const ERayTracingAccelerationStructureFlags BuildFlags =
CVarRayTracingSceneBuildMode.GetValueOnRenderThread()
? ERayTracingAccelerationStructureFlags::FastTrace
: ERayTracingAccelerationStructureFlags::FastBuild;
FastTrace vs FastBuild 是一个经典权衡:FastBuild 让构建更快但光线遍历稍慢,FastTrace 反之。Epic 把这个选择暴露成 CVar,r.RayTracing.Scene.BuildMode 默认 1(走 FastTrace)——因为大多数场景里 TLAS 每帧构一次,但光线遍历一秒发几亿条,遍历侧的成本更高。
TLAS 构建完成后,RHI 层通过 RHICreateRayTracingScene 拿到 GPU 侧的加速结构对象:
// RayTracingScene.cpp:288
LayerView.RayTracingSceneRHI =
RHICreateRayTracingScene(MoveTemp(Initializer));
这里要澄清一个常见的错误(我在别处也见过被误传的版本):这一步不等同于 D3D12 的 CreateRaytracingInstance 之类的调用——那个 API 根本不存在。真实的底层路径是这样的:
D3D12:先用 ID3D12Device::CreateCommittedResource分配放 AS 的显存 buffer,然后在渲染线程通过ID3D12GraphicsCommandList4::BuildRaytracingAccelerationStructure让 GPU 实际把 TLAS/BLAS 建出来(源码在D3D12RayTracing.cpp:5074)。而ID3D12Device5::CreateStateObject是用来创建光追 PSO 的,跟 AS 构建走的是完全不同的路径。Vulkan:先 vkCreateAccelerationStructureKHR拿到一个VkAccelerationStructureKHR句柄,然后vkCmdBuildAccelerationStructuresKHR在命令缓冲里录入实际构建指令。
三个 API(CreateResource / BuildRaytracing... / CreateStateObject)职责完全不同——把它们混在一起是初学光追时最常见的混淆源。RHI 抽象层让你不用直接碰这些名字,但一旦要读厂商工具的 GPU 帧调试(PIX、Nsight、RenderDoc),这些原始 API 名会全部弹出来。
Rebuild vs Refit:BLAS 的两种更新模式
BLAS 一旦建好,第二次"更新"其实有两种截然不同的路径:
Full Rebuild:完全重新构建 BVH,通常用在几何拓扑变化(三角形数量变了)或是长时间累积的树质量退化后。 Refit(Update):保留 BVH 的树拓扑不变,只更新每个节点的包围盒。走这条路径必须在初次 build 时给 ALLOW_UPDATEflag,之后每次 update 的成本只有 rebuild 的 10-20%——但代价是树质量会缓慢退化,反复 refit 若干次后光线遍历会变慢。
UE5 的动态几何体(骨骼动画、变形 WPO)默认走 refit,累积到一定次数后强制 rebuild 一次刷新树质量——r.RayTracing.DynamicGeometry.ForceBuild.MinUpdatesSinceLastBuild 控制这个阈值(源码在 RayTracingDynamicGeometryUpdateManager.cpp:60 附近),默认 INT_MAX(永不强制),项目里通常调到 30-60 帧。
一个被低估的优化:Batched Build
r.RayTracing.Scene.BatchedBuild(默认 true)把多个 BLAS 的 build 命令打包到同一个 BuildRaytracingAccelerationStructure 调用里。这看起来只是"少 dispatch 几次",但真正省的是 GPU 内存屏障——AS build 是 UAV 写操作,每次单独 dispatch 都要在写完后插 barrier 等它可见。批一起后,几十上百个 BLAS 共享一次 barrier,GPU 侧能省掉一整段 pipeline stall。除非你在做特殊的时间片调度调试,这个开关一律保持开启。
第二层:Shader Binding Table——光线击中物体后该执行哪个 Shader
TLAS 和 BLAS 解决了"光线打到哪个三角形"。下一个问题是:打到之后怎么办?
这就是 SBT(Shader Binding Table)的职责。SBT 是一张查找表,记录了每个几何体实例对应的 Shader 组合——RayGen(生成光线)、Miss(光线没打中任何东西)、Hit Group(光线打中了,执行材质着色)。
UE5 的 SBT 实现有一个非常聪明的设计:Persistent SBT(持久化 SBT)。源码里直接暴露了它的开关:
// RayTracingShaderBindingTable.cpp:12-19
static int32 GPersistentSBTEnabled = 1;
static FAutoConsoleVariableRef CVarRayTracingPersistentSBT(
TEXT("r.RayTracing.PersistentSBT"),
GPersistentSBTEnabled,
TEXT("Enable persistent RayTracing ShaderBindingTables."),
ECVF_RenderThreadSafe
);
传统的 SBT 每帧重建——每帧可能有不同几何体可见,Hit Group 的排列会变。Persistent SBT 的思路是:预先分配足够大的 SBT 空间,然后只更新变化的部分。源码里定义了最小预留量:
// RayTracingShaderBindingTable.cpp:28-55
static int32 GMinLocalBindingDataSize = 96;
static int32 GMinMissShaderSlots = 128;
static int32 GMinStaticGeometrySegments = 256;
static int32 GMinDynamicGeometrySegments = 256;
128 个 Miss Shader 槽位、256 个静态几何体段、256 个动态几何体段——这些数字是 Epic 在大量项目上跑出来的经验值。对大多数场景够用;实际分配时还会按 RoundUpToPowerOfTwo(GetMaxAllocatedStaticSegmentCount()) 动态扩容(RayTracingShaderBindingTable.cpp:285-286),也就是"至少给这么多,但真需要更多也不会截断"。
这套机制的效果是:SBT 的 CPU 端开销从"每帧重建整张表"变成了"只更新脏段"。在 10 万+ 实例的大场景里,这个优化能省掉几毫秒的 CPU 时间——听着不多,但光追预算本来就只有 2-5ms 一帧,两毫秒是 30-50% 的杠杆。
补一句:AS 的显存到底有多贵
顺便说一下容易被忽略的问题:AS 本身占显存。一个几万三角形的 BLAS 大概 1-5MB,一个几十万实例的 TLAS 也是几十 MB 起步。加上构建时需要的临时 scratch buffer(build 一次要的临时显存通常是 AS 本身的 0.5-2 倍),大场景里光追相关的显存总量会轻松爬到几百 MB。
D3D12 后端为此做了一个叫 AS Compaction 的优化:构建完成后 GPU 会输出一份"实际使用了多少字节"的报告,驱动可以把 AS 拷到一个更紧凑的 buffer 里,释放原始分配。相关 CVar 在 D3D12RayTracing.cpp:58-77 附近:
r.D3D12.RayTracing.AllowCompaction(默认1)r.D3D12.RayTracing.MaxBatchedCompaction(默认64):每帧最多批处理多少个 AS 的 compactionr.D3D12.RayTracing.CompactionMinPrimitiveCount(默认128):三角形数低于这个阈值的 BLAS 不做 compaction(省小算收益太低)
打开 compaction 通常能省 30-50% 的 AS 显存。移动端和 Switch 2 这类显存紧的平台特别值得关注。
第三层:动态几何体更新——骨骼动画、WPO、Niagara 怎么进光追
静态 Mesh 的 BLAS 只构建一次。但骨骼动画、World Position Offset、Niagara 粒子——这些东西每帧都在变形,BLAS 必须跟着更新。
这就是 RayTracingDynamicGeometryUpdateManager.cpp 的职责。它的核心逻辑是一个预算控制系统:
// RayTracingDynamicGeometryUpdateManager.cpp:48-53
static TAutoConsoleVariable<int32>
CVarRTDynGeomMaxUpdatePrimitivesPerFrame(
TEXT("r.RayTracing.DynamicGeometry.MaxUpdatePrimitivesPerFrame"),
-1, // <= 0 means "no limit"
TEXT("Sets the dynamic ray tracing acceleration structure
build budget in terms of maximum number of updated
triangles per frame..."),
ECVF_RenderThreadSafe | ECVF_Scalability
);
默认 -1(不限制),Epic 建议在主机上限到 10,000-30,000。这个预算系统的工作方式是:如果一帧内需要更新的动态三角形超过预算,就按优先级排队——离相机近的、上次更新距今最久的优先。
优先级计算直接从源码抄出来:
// RayTracingDynamicGeometryUpdateManager.cpp:102-106
const float DistancePriority =
FMath::Clamp(Distance * InvMaxDistanceSq, 0.0f, 1.0f);
const float AgePriority =
1.0f - FMath::Clamp(Age * InvMaxAge, 0.0f, 1.0f);
const float AgeWeight = FMath::Clamp(
CVarRTDynGeomPriorityBasedUpdateLastUpdateWeight
.GetValueOnRenderThread(), 0.0f, 1.0f);
float Priority = AgeWeight * AgePriority
+ (1.0f - AgeWeight) * DistancePriority;
距离近的 + 很久没更新的 = 高优先级。如果物体在视锥体内,还会再乘一个额外的加权系数。这个算法简单但有效——它保证玩家眼前的东西总有最新的 BLAS,远处的可以等几帧。
Tracing Feedback:跳过没被光线击中的实例
r.RayTracing.Scene.UseTracingFeedback(RayTracingScene.cpp:29-34,默认 false)打开后,Lumen 和 MegaLights 的光追 Pass 会在遍历中记录哪些实例被光线实际击中了。下一帧只更新被击中的动态几何体——完全没被任何光线打中的(比如画面背后、镜头看不到又没参与反射的物体),跳过 BLAS 更新。
这条路径依赖 Inline Ray Tracing 硬件支持(源码里 GRHISupportsInlineRayTracing 为真才启用),本质是用"已经花过的追踪成本"反哺"BLAS 更新决策"。在大量动态物体的场景里,实测能省下 30-50% 的 BLAS 构建开销——但代价是有一帧的"预热延迟"(第一次进入视野的物体本帧还没数据可参考)。所以这个开关默认关,需要项目里显式打开。
Nanite Mesh 怎么进光追
一个必须澄清的常见困惑:Nanite Mesh 本身不能直接被光追——因为它的几何是运行时按屏幕像素动态解码的,没有稳定的"三角形集合"可以喂给 BVH。UE5 的方案是Nanite Fallback Mesh:编辑器烘焙时给每个 Nanite Mesh 生成一份低模副本,光追走这份 fallback。质量取决于 fallback 的三角形上限(RelativeError / PercentTriangles 参数)。
这也是为什么反射里看不清 Nanite 高精度细节——本质上你在看它的低模影子。AMD 在 2026 年提的 DGF(Dense Geometry Format) 就是想解决这个:一种压缩后仍能高效被 BVH 消费的几何格式。目前处于原型阶段,UE 尚未集成。
第四层:实例剔除——光追场景不是「把整个关卡丢进去」
光追和光栅化有一个根本区别:光栅化只需要渲染屏幕里的东西,但光追必须考虑屏幕外——因为镜子里的反射可能来自你身后的建筑。
这意味着光追场景的实例数天然比光栅化场景多。不做剔除的话 TLAS 会膨胀到不可接受。
UE5 的解决方案是 RayTracingInstanceCulling.cpp,提供了一套分层的剔除策略:
// RayTracingInstanceCulling.cpp:22-30
static TAutoConsoleVariable<int32> CVarRayTracingCulling(
TEXT("r.RayTracing.Culling"),
3,
TEXT(" 0: Culling disabled\n"
" 1: Cull only objects behind the camera\n"
" (by distance AND solid angle)\n"
" 2: Cull objects in front AND behind\n"
" (by distance AND solid angle)\n"
" 3: Cull objects in front AND behind\n"
" (by distance OR solid angle) ← 实际默认"),
ECVF_RenderThreadSafe);
注意 Epic 自己的注释里说 "0: default",但代码里的默认值是 3——这是历史遗留的注释疏忽(我在 5.8 上确认过)。真正的默认是 3:物体只要满足"距离太远"或"投影角度太小"之一就会被剔除。配套的参数:
r.RayTracing.Culling.Radius:默认30000.0f(300 米),超出这个距离的物体被剔除r.RayTracing.Culling.Angle:默认1.0f(1 度),投影角度小于这个值的物体被剔除
对开放世界项目,Epic 在 City Sample 里的做法反而是同时收紧 Radius 和降低 Angle —— 因为城市反射面多,需要更多远处物体留在场景里、但同时要更激进地剔掉屏幕上小到不可见的小物件。具体数字每个项目不一样,思路是"细化"而不是"一刀切"。
剔除后的空洞怎么填?答案是用 Lumen 的 Far Field——一个低精度的全局距离场表示,专门给远处光线提供"打到东西了"的反馈。TLAS 负责近处高精度,Far Field 负责远处低保真——两级配合覆盖完整视野。
光线也有 Layer:Ray Tracing Instance Mask
每个 RT 实例还带一个 8-bit 的 Instance Mask(源码在 RayTracingInstanceMask.cpp,定义了 ERayTracingInstanceMaskType:Opaque、Translucent、ThinShadow、FarField、HairStrands、SceneCapture 等分类)。发射光线时 shader 里通过 RayFlags + InstanceInclusionMask 位与运算决定"这条光线能命中哪些实例":
阴影光追只与 Opaque + ThinShadow 相交 半透明反射只与 Translucent 相交 Path Tracer 打开所有位
Mask 系统是 BVH 遍历时的硬件加速路径,不用扫全表也不用查 SBT——"哪些实例参与哪个效果"这个业务问题,被压到了一次位运算。写自定义 RT 效果时忘设 mask 会导致光线要么打不到目标要么打到不该打的东西,是 debug 时的高频翻车点。
顺便,RayTracing::GetCullingMode() 里有一段特殊逻辑:Path Tracer 打开时强制禁用剔除——因为路径追踪追求全场景一致性,任何剔除都会造成"离屏对象的反射消失",破坏路径积分的正确性。
第五层:Inline Ray Tracing vs Ray Tracing Shaders——两条路线的分岔
这是 UE5 光追架构里最容易被误解的一个设计。引擎支持两种光追模式:
Ray Tracing Shaders(传统模式):使用专用的 RayGen/Miss/Hit Shader,通过 SBT 调度。功能完整——支持材质着色、任意 Hit Shader、RTXDI 等。硬件要求 DXR 1.0 / Vulkan Ray Tracing。 Inline Ray Tracing(内联模式):在 Compute Shader 里直接调用 TraceRay()内联函数,不走 SBT。功能受限但开销更低。硬件要求 DXR 1.1(Shader Model 6.5+)/ VulkanrayQueryEXT。
UE5 允许按平台选择模式(Project Settings → Platforms → [Platform] → Ray Tracing Mode):
Disabled:完全关闭光追 Inline:仅内联光追(Lumen HWRT Surface Cache 模式的默认) Full:完整光追(含 Ray Tracing Shaders)
Inline 模式的核心优势是不需要编译 Ray Tracing Shader 变体。在 Full 模式下,每种材质 × 每种光照条件都可能产生独立的 Hit Shader 变体——这会导致 Shader 编译时间和内存占用的爆炸(在角色多、材质复杂的项目里,光追 shader 变体数可以爬到六位数)。Inline 模式避开了这个问题,代价是不能在 Hit Point 执行材质着色——Lumen 改用 Surface Cache 来查询光照。
实际项目中的选择逻辑:移动端 / Switch 2 用 Inline(省 shader 变体、省 PSO 编译时间);PC 高端和 PS5/Xbox Series X 走 Full(画质优先、支持反射里的材质细节);中间地带就看权衡——如果反射里不需要材质细节(比如金属室内环境靠 Surface Cache 就够真),Inline 甚至能提供更好的性能/画质比。
性能调优:你真正需要关心的几个 CVar
翻完源码,我整理了七个对实际项目最有用的 CVar。它们不是"调了就能快 50%"的魔法开关,但理解它们的作用能帮你在画质和性能之间做精确取舍。
| CVar | 默认值 | 作用 | 适用场景 |
|---|---|---|---|
r.RayTracing.Culling |
3 | 实例剔除策略 | 开放世界:微调 Radius / Angle |
r.RayTracing.Culling.Radius |
30000 | 剔除距离(cm) | 大反射场景可增大 |
r.RayTracing.DynamicGeometry.MaxUpdatePrimitivesPerFrame |
-1 | BLAS 更新预算 | 主机:设 15000-30000 |
r.RayTracing.Geometry.SkeletalMeshes |
1 | 骨骼网格是否进光追 | 大量角色场景:设 2(保 cache 快切换) |
r.RayTracing.PersistentSBT |
1 | 持久化 SBT | 保持开启 |
r.RayTracing.Scene.UseTracingFeedback |
0 | 反馈驱动的 BLAS 更新 | 大量动态物体:开启 |
r.RayTracing.Geometry.StaticMeshes.WPO |
1 | WPO 是否进光追 | 已默认开启;关闭需慎重 |
关于 WPO 要注意一下:UE 5.8 默认这个 CVar 是 1——静态 Mesh 的 WPO 是"默认可见并参与 BLAS 动态更新",不是"默认忽略"。三档状态其实是这样的:
r.RayTracing.Geometry.StaticMeshes.WPO
0: WPO Mesh 在光追里不可见(省最狠)
1: WPO Mesh 可见,且每帧动态 BLAS 更新(默认,最贵)
2: WPO Mesh 可见但用静态 BLAS(介于两者之间)
配合 r.RayTracing.Geometry.StaticMeshes.WPO.CullingRadius(默认 12000.0(120 米))——超过这个半径的 WPO Mesh 不再做动态 BLAS 更新,退回静态几何。要在整个大世界都保留 WPO 光追反射会非常贵,Epic 的默认策略已经是"只在相机周围 120 米内动态更新"。想改的话往下调(省性能)或谨慎往上调(换画质代价),别盲目照抄网上"关掉再打开"的教程——你可能正在关掉一个默认已经打开的东西。
一个具体的工作流:从勾选复选框到第一根光线
把上面五层串起来,这就是 UE5 硬件光追的完整帧流程:
CPU 端收集实例: GatherRayTracingWorldInstancesForView(RayTracing.cpp:2171附近)遍历场景中所有标记支持光追的 Primitive,收集变换矩阵、几何体引用、材质信息实例剔除: RayTracingInstanceCulling根据距离和角度剔除不需要的实例,同时按 Instance Mask 分桶动态几何体更新: RayTracingDynamicGeometryUpdateManager按优先级 refit 或 rebuild 变形 Mesh 的 BLASTLAS 构建: FRayTracingScene::Update()收集所有可见实例,走BuildRaytracingAccelerationStructure(D3D12)或vkCmdBuildAccelerationStructuresKHR(Vulkan),Batched 模式下多个 build 一次提交SBT 更新: FRayTracingShaderBindingTable只更新脏段,绑定 Hit Group光线发射:Lumen / 光追阴影 / 光追反射 / RTAO 各自发射光线,GPU 执行 BVH 遍历 + Shader 调用
这套流程每帧执行,60 FPS 下只有 16.67ms 的总预算,光追部分通常占 2-5ms——取决于场景复杂度和启用的光追特性数量。5ms 里 TLAS build 大约 0.3-0.8ms、动态 BLAS 更新 0.5-2ms、SBT 更新 <0.2ms(有 Persistent SBT 时),其余全部是 GPU 侧的 BVH 遍历和 Hit Shader 执行。
延伸阅读:光追技术栈全景
理解 UE5 的光追管线之后,可以往这几个方向深入:
Lumen HWRT vs SWRT:Lumen 的硬件光追模式走的是 Inline Ray Tracing + Surface Cache,跟传统 Ray Tracing Shaders 是完全不同的技术路线。理解本文第五层之后,再看 Lumen Technical Details 会豁然开朗。 RTXDI(NVIDIA 直接光照):在传统光追阴影基础上,RTXDI 用 ReSTIR 算法实现了海量动态光源的直接光照——本质是对"每像素发射多少根阴影光线"这个问题做统计优化。 MegaLights:UE5.5 引入的随机直接光照系统,和光追管线共享 TLAS,走 Stochastic Sampling 而非传统重要性采样。 AMD DGF(Dense Geometry Format):AMD 在 2026 年提出的几何压缩格式,目标是让 Nanite 级别的几何体也能高效进入光追管线——目前 Nanite Fallback Mesh 是光追质量的主要瓶颈。 Ray Reconstruction / DLSS Ray Denoiser:光追结果稀疏噪点大,降噪器质量直接决定"1spp 能不能出片"。DLSS-RR 用神经网络替代传统时域降噪,是最近两年最激进的方向。
这些方向每一个都能单独写一篇源码拆解。你对哪个特别感兴趣,评论区告诉我,我优先安排。
我每周拆解一个 UE5 底层系统,从源码级讲到可运行的参数配置——想把技术讲透。觉得有用,点个「在看」让更多同行看到。还想看哪个子系统的深度拆解?评论区点名。
夜雨聆风