乐于分享
好东西不私藏

我在 UE5 源码里蹲了一周,终于把硬件光追管线的每一层都扒干净了

我在 UE5 源码里蹲了一周,终于把硬件光追管线的每一层都扒干净了

我在 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_UPDATE flag,之后每次 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 的 compaction
  • r.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.0f1.0f);
const float AgePriority =
    1.0f - FMath::Clamp(Age * InvMaxAge, 0.0f1.0f);

const float AgeWeight = FMath::Clamp(
    CVarRTDynGeomPriorityBasedUpdateLastUpdateWeight
    .GetValueOnRenderThread(), 0.0f1.0f);
float Priority = AgeWeight * AgePriority
    + (1.0f - AgeWeight) * DistancePriority;

距离近的 + 很久没更新的 = 高优先级。如果物体在视锥体内,还会再乘一个额外的加权系数。这个算法简单但有效——它保证玩家眼前的东西总有最新的 BLAS,远处的可以等几帧。

Tracing Feedback:跳过没被光线击中的实例

r.RayTracing.Scene.UseTracingFeedbackRayTracingScene.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+)/ Vulkan rayQueryEXT

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 硬件光追的完整帧流程:

  1. CPU 端收集实例GatherRayTracingWorldInstancesForViewRayTracing.cpp:2171 附近)遍历场景中所有标记支持光追的 Primitive,收集变换矩阵、几何体引用、材质信息
  2. 实例剔除RayTracingInstanceCulling 根据距离和角度剔除不需要的实例,同时按 Instance Mask 分桶
  3. 动态几何体更新RayTracingDynamicGeometryUpdateManager 按优先级 refit 或 rebuild 变形 Mesh 的 BLAS
  4. TLAS 构建FRayTracingScene::Update() 收集所有可见实例,走 BuildRaytracingAccelerationStructure(D3D12)或 vkCmdBuildAccelerationStructuresKHR(Vulkan),Batched 模式下多个 build 一次提交
  5. SBT 更新FRayTracingShaderBindingTable 只更新脏段,绑定 Hit Group
  6. 光线发射: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 底层系统,从源码级讲到可运行的参数配置——想把技术讲透。觉得有用,点个「在看」让更多同行看到。还想看哪个子系统的深度拆解?评论区点名。