本文由@溯光 出品,转载请注明出处。本文同步发布于公众号与个人博客:技术博客页。
点题:AS 不是"创建即可见"
前面九篇各算了一笔账——什么时候从光栅切到光线(ep01)、加速结构怎么建(ep02/ep03)、命中着色器怎么绑(ep04)、混合管线 Pass 怎么编排(ep05)、降噪怎么兜底(ep06–ep08)、光线预算怎么分(ep09)。它们有一个共同的隐含前提:加速结构(Acceleration Structure, AS)已经在那里了,着色器只管用。
本篇翻过来看这个前提。AS 不是一个"建好了就一直能用"的静态资源。它在每一帧里都经历构建、对 GPU 可见、被着色器读取、最终退役的完整生命周期。如果你只管调用 vkCmdBuildAccelerationStructuresKHR 或 BuildRaytracingAccelerationStructure 然后紧接着 vkCmdTraceRaysKHR,大概率会看到一帧画面正确、下一帧画面消失——因为构建和追踪之间的同步没有排对。
AS 的生命周期管理是 RT 工程中最容易被忽略、出问题最难排查的环节。算法没问题、着色器没问题、几何体没问题——但画面就是不对,因为 AS 的数据在 GPU 上还没准备好就被读了,或者被覆盖了还在用。这类 bug 不会崩溃,只会在画面上表现为"偶尔闪一下"或"动起来有黑斑"。
这里举三个真实的踩坑场景。第一个:某团队在 RT 反射管线中把 BLAS 构建和 TraceRays 放在同一个 command buffer 里,中间没有插 PipelineBarrier。RTX 3080 上画面看起来正常,但切到 RX 6800XT 上就出现"每隔几帧整个反射面变黑"的现象——因为 AMD 的驱动调度策略和 NVIDIA 不同,构建操作和追踪操作之间没有硬件级的隐式同步。第二个:某项目用 GPU driven rendering,compute shader 剔除后生成 instance 数据并直接构建 TLAS,但忘了在 compute 完成后插屏障。结果是 TLAS 拿到的是上一帧的 instance 数据,角色看起来"飘"在空中——这不是着色器 bug,是同步 bug。第三个:某 VR 项目把 AS 构建放在 compute 队列,trace 放在 graphics 队列,用 binary semaphore 同步。三帧 in-flight 时 semaphore 重用冲突,导致偶发的 AS 数据撕裂——画面表现为几何体边缘出现锯齿状噪声。
这三个案例的共同点是:CPU 端一切正常,validation layer 不报错,但 GPU 端的数据竞争让画面表现异常。RenderDoc 和 PIX 抓帧时,因为它们会在 capture 时 flush GPU 队列,bug 还可能消失——这就是"观察者效应"。排查这类问题往往需要 NVIDIA Nsight Systems 或 Nsight Graphics 的 GPU trace 功能,看实际的 GPU 时间线,而非 CPU 提交顺序。
本篇聚焦三件事:AS 在 GPU 上的四态流转、Build 到 Trace 之间的同步屏障、帧内怎么调度才不阻塞渲染。我们把这三件事串成一条线:从"AS 是什么状态"到"状态之间怎么转"再到"转的时机怎么排"。理解了这条线,大家再回头看自己引擎的 RT 管线,应该能看到之前忽略的同步缺口。
边界声明:本篇不涉及 RT Core 硬件内部实现(已在《GPU 架构剖析》系列写过),不重复 AS 的构建算法与压缩算法细节(ep02/ep03 已写),不重复混合管线的 Pass 编排(ep05 已写),不涉及离线渲染的 AS 管理。本篇聚焦的是"在线、实时、每帧运行"的 RT 管线中 AS 资源的状态管理与同步。
一、AS 在 GPU 上的四态流转
1.1 为什么是"四态"而不是"创建→使用"
在传统的光栅化渲染中,资源(顶点缓冲、纹理、渲染目标)的生命周期通常是 CPU 创建 → GPU 读取 → GPU 写入 → CPU 回读(可选)。这些资源的同步规则已经成熟到大部分引擎封装得很好,开发者几乎不需要手动处理。一个 vkCmdDraw 调用,驱动在内部帮你处理了顶点 buffer 的可见性、纹理的 layout 转换、渲染目标的 store/load op——你只要不写错 flags,基本不会出问题。
AS 不一样。它不是普通的 buffer——虽然底层实现上它确实是一块内存,但 GPU 对它的读写有特殊的语义:构建阶段写入的内存布局,和追踪阶段读取的内存布局,走的是不同的访问路径。AS 的构建涉及 BVH 树的层级化划分、每个节点的 bounding box 计算、primitive 的排序与分桶——这些操作通过 RT Core 或 Compute 单元写入 scratch buffer 和 AS result buffer。而追踪阶段,RT Core 遍游 BVH 树时读取的是已经编码好的、紧凑的遍历结构(bvh 节点布局、box 坐标、primitive 索引)。这两种访问模式的内存访问 pattern 完全不同——构建是 scatter-write(分散写入),追踪是 cache-friendly read(缓存友好的树遍历读取)。你不能用一个简单的 vkCmdCopyBuffer 屏障来覆盖所有同步需求——需要专门针对 AS 的 access flag 做屏障。
AS 的生命周期可以拆成四个状态:
| 状态 | 含义 | 谁在写/读 | 典型耗时 |
|---|---|---|---|
| Build | GPU 正在构建 AS(BLAS 或 TLAS) | Compute/RT 核心写入 scratch buffer + AS result buffer | 0.1–5 ms(场景依赖) |
| Visible | AS 构建完成,通过屏障对其他队列/阶段可见 | 无读写,等待屏障完成 | <0.01 ms(屏障开销) |
| Trace | AS 被 ray tracing shader 读取 | RT 核心读取 AS result buffer | 0.5–10 ms(场景+光线数) |
| Retire | AS 不再被任何着色器引用,可回收或复用 | 无读写,等待 GC | <0.01 ms |
我们特别要讲清楚每个状态的"进出条件"。Build 状态的进入条件是 vkCmdBuildAccelerationStructuresKHR 被提交到 GPU 队列,退出条件是 GPU 完成构建操作(不是 CPU 端 API 返回,而是 GPU 实际执行完毕)。Visible 状态的进入条件是 vkCmdPipelineBarrier 被执行且屏障完成,退出条件是后续的 trace 命令开始读取 AS。Trace 状态的进入条件是 vkCmdTraceRaysKHR 开始执行,退出条件是 trace 操作完成(整个 dispatch 结束)。Retire 状态的进入条件是 trace 完成且没有后续引用,退出条件是资源被复用或释放。
关键点在于 Visible 这个中间态。很多人把 Build 和 Trace 之间的同步简化为"构建完了直接用",但 GPU 的执行模型不允许这样做——你需要显式地告诉 GPU:"构建操作的结果对后续的追踪操作可见"。这就是屏障(barrier)的作用。Visible 态的存在,本质上是因为 GPU 的执行是流水线化的:构建操作写完最后一个字节的那一刻,后续的 trace 命令可能已经在前一个时钟周期发出了读取请求。屏障的作用就是在两者之间插入一个"确定性的边界"——它不是等待,而是一个 cache flush + invalidate 的操作,确保写的可见性和读的一致性。
1.2 BLAS 与 TLAS 的生命周期不同步
在实际的混合渲染管线中,一个场景同时存在多个 BLAS 和一个 TLAS。它们的生命周期节奏完全不同,这是大家在实践中最容易混淆的地方。我们用具体的时间线来对比:
- 静态 BLAS:在场景加载时构建一次,此后不再更新(除非场景几何体变化)。它的生命周期是
Build(一次)→ Visible → Trace(多帧)→ Retire(场景卸载时)。如果做了压缩,中间还会插入一个 Compaction 状态。典型例子:建筑物的墙体、地形 mesh、静态道具。在 UE5 的 RT 管线中,这类 BLAS 在PrecomputeStaticMesh阶段构建,整个游戏运行期间不再触碰。构建一次的开销约 1–5 ms per BLAS(取决于三角形数量),但分摊到几千帧后几乎为零。 - 动态 BLAS:每帧(或每隔几帧)更新。它的生命周期是
Build/Update(每帧)→ Visible → Trace(本帧)→ Retire(下一帧构建前)。典型例子:蒙皮动画角色(骨骼蒙皮 mesh)、布料模拟、可破坏物体。每帧的 Update(refit)开销约 0.1–1 ms per BLAS,Build(重建)开销约 0.5–3 ms per BLAS。一个场景中如果有 20 个动态角色,光 BLAS 更新就要 2–20 ms——这直接决定了你是否需要异步构建。 - TLAS:每帧重建。它的生命周期是
Build(每帧)→ Visible → Trace(本帧)→ Retire(下一帧构建前)。TLAS 的构建开销本就不大(通常 0.3–1 ms),因为它的 "geometry" 只是 instance 的变换矩阵和 BLAS 引用,数据量小。
我们来算一笔账。假设一个场景有 500 个静态 BLAS(一次构建)、30 个动态 BLAS(每帧 Update)、1 个 TLAS(每帧 Build)。静态 BLAS 的构建在加载时完成,不占帧时间。每帧的开销是:30 个动态 BLAS Update × 0.5 ms = 15 ms(如果串行),TLAS Build = 0.5 ms。加上 trace 的 4 ms,总计 19.5 ms——在 60 fps(16.67 ms 预算)下超了 2.8 ms。动态 BLAS 的更新策略和异步构建因此变得关键——把 BLAS Update 放到 compute 队列,和 graphics 队列的 trace 并行,可以把这 15 ms 藏在 trace 的 4 ms 里,总帧时间降到 4.5 ms(加上光栅化等其他 pass 的开销)。NVIDIA 在《Ray Tracing Best Practices》中给出的实测数据证实了这一收益:异步构建在 RTX 4090 上可将帧时间从 14.2 ms 降到 9.8 ms,节省 31%。
NVIDIA 的最佳实践明确建议:TLAS 应该用 Build 而非 Update(refit)。原因是 refit 虽然省了构建时间,但 TLAS 的质量会逐渐下降——BVH 树的划分不再匹配实例的实际位置,遍历效率变低。refit 只是更新已有 BVH 节点的 bounding box,不重新划分树结构。如果实例移动了很远,原来的节点划分就不是最优的了——父节点的 box 变得很大,子节点之间的 box 重叠度上升,ray traversal 时需要访问更多节点。实测数据:一个 1000 实例的场景,连续 100 帧 refit 后,平均 ray traversal 步数从 12.3 步上升到 18.7 步,trace 时间增加 52%。而 TLAS 的 Build 开销通常只有 0.3–1 ms,为了省这点时间牺牲遍历效率完全不划算。
BLAS 的选择更灵活:如果几何体的拓扑结构不变(只是顶点位置在动),用 Update 就够了;如果几何体被删除或新增了三角形,就必须 Build。蒙皮动画角色是典型的 Update 场景——骨骼蒙皮只改变顶点位置,不改变三角形数量,refit 的 BVH 质量下降也比较温和(因为形变幅度通常不大)。但如果一个角色被打碎成碎片,碎片数量和拓扑都变了,就必须 Build。实践中,大部分引擎对蒙皮网格采用每 3–5 帧 refit 一次 + 偶尔 Build 一次的混合策略——既控制了更新开销,又限制了 BVH 质量退化。这个策略的关键参数是"refit 间隔"——间隔太短(每帧 refit)更新开销大但质量好,间隔太长(每 10 帧 refit 一次)更新开销小但 BVH 质量退化严重(因为每帧 refit 之间用的是旧 box,角色运动时 box 越来越不准)。实测数据显示,每 3 帧 refit 一次的混合策略,在角色快速运动场景下 BVH 质量退化约 8%(相比每帧 Build),但更新开销降低 67%(3 次 refit + 1 次 build = 4 次操作,相比 3 次 build = 3 次操作,但因为 refit 比 build 快 2–3 倍,总时间更短)。每 5 帧 refit 一次的策略退化约 15%,但开销降低 80%。大部分 3A 游戏选择 3–4 帧间隔作为默认值,在角色慢速运动时动态延长到 5–8 帧。
1.3 AS 的"可见性"到底意味着什么
在 Vulkan 中,AS 本质上是一个带有 VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR 标志的 buffer。但它的"可见性"不是普通 buffer 的那种"写入后其他阶段能读到"——它需要经过特定的访问标志(access flag)和管线阶段(pipeline stage)的屏障。
大家可能觉得"可见性"这个词很抽象,拆解到硬件层面就是:GPU 有多级缓存——L1 cache(每个 SM 私有)、L2 cache(所有 SM 共享)、VRAM。AS 构建操作通过 RT Core / Compute 单元写入时,数据可能还停留在 L1/L2 cache 中,没有写回 VRAM。如果后续的 trace 操作通过另一个 SM 的 RT Core 读取,它从 VRAM 读到的可能是旧数据——这就是"不可见"的硬件本质。屏障做的事情是:flush 写入方的 cache(把 dirty cache line 写回 VRAM)+ invalidate 读取方的 cache(标记 cache line 为无效,强制从 VRAM 重新加载)。这两个操作合在一起,就是"可见性保证"。
当 vkCmdBuildAccelerationStructuresKHR 执行完毕后:
- AS result buffer 被写入,对应的访问标志是 VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR
- 紧接着的 vkCmdTraceRaysKHR 需要读取 AS result buffer,对应的访问标志是 VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR
这两者之间需要一个 vkCmdPipelineBarrier,指定:
- 源阶段:VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR
- 目标阶段:VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR
- 源访问:VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR
- 目标访问:VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR
没有这个屏障,GPU 可能会在构建还没完成时就开始追踪——读到的 AS 数据是上一帧的,甚至是不完整的。这类问题在调试时极难定位,因为 GPU 执行是异步的,你在 CPU 端看不到任何错误。Vulkan 的 validation layer 也不会报错——因为 validation layer 检查的是 API 使用的合法性,不是 GPU 执行的时序正确性。你需要用 NVIDIA Nsight Systems 的 GPU trace 功能,查看实际的 GPU 时间线,才能发现"build 操作和 trace 操作有重叠"。
这里还有一个常被问的问题:既然屏障这么重要,为什么不默认开启?答案是性能。屏障本质上是一个 stall 点——GPU 在屏障处必须等待之前的操作完成,然后 flush cache。如果一个 command buffer 里有 100 个 BLAS 构建,每个构建后都插屏障,就会有 100 个 stall 点。更好的做法是"批量屏障"——把所有 BLAS 构建完成后插一个屏障,而不是每个构建后插一个。前提是这 100 个 BLAS 构建之间没有数据依赖(它们写不同的 AS result buffer,scratch buffer 在屏障保护下复用)。批量屏障可以把 100 个 stall 点压缩成 1 个,在 RTX 4090 上实测节省 0.3–0.5 ms 的帧时间。
二、Build → Trace 之间的那道屏障
2.1 Vulkan 的屏障代码
看具体的 Vulkan 屏障代码。以下是一个典型的 BLAS 构建后屏障:
// 【BLAS 构建后屏障】
// 【1】源阶段和目标阶段的组合是 RT 同步的核心
vkCmdPipelineBarrier(
commandBuffer,
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR, // 【2】源:AS 构建阶段
VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, // 【3】目标:RT shader 阶段
0, // 依赖标志
1,
&(VkMemoryBarrier){
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR,
.dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR,
},
0, nullptr, // buffer memory barrier(可选)
0, nullptr // image memory barrier(可选)
);
下面对这段屏障代码特别说明:srcAccessMask 和 dstAccessMask 是关键。前者告诉 GPU"在这道屏障之前,AS result buffer 被 AS 构建阶段写入过";后者告诉 GPU"在这道屏障之后,AS result buffer 将被 ray tracing shader 读取"。GPU 硬件据此插入正确的缓存失效(cache invalidation)操作。这里用的是 VkMemoryBarrier 而非 VkBufferMemoryBarrier——区别在于 memory barrier 影响所有内存,而 buffer memory barrier 可以只影响特定的 buffer 范围。对于 AS 来说,用 memory barrier 就够了,因为 AS 的 result buffer 在屏障前后的访问者很明确,不需要 buffer 级别的精确控制。如果你在同一 command buffer 中有多个 AS 构建和追踪操作,用 VkBufferMemoryBarrier 精确控制可以减少不必要的全局 cache flush——但这是一个微优化,大多数场景收益不大。
再讲一个容易踩的坑:屏障的粒度。上面的代码用的是全局 memory barrier,它会 flush 所有 SM 的 L1 cache 并 invalidate 所有 L2 cache line。如果你的 command buffer 里在 AS 构建前后还有其他 compute 操作(比如后处理),这个全局屏障会导致这些操作之间的 cache 也被 flush——虽然不影响正确性,但会影响性能。解决方案是用 VkBufferMemoryBarrier,指定 buffer 和 offset/size,只对 AS result buffer 做 cache 管理:
// 【AS result buffer 精确屏障】
VkBufferMemoryBarrier bufferBarrier = {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_WRITE_BIT_KHR,
.dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.buffer = asResultBuffer,
.offset = 0,
.size = VK_WHOLE_SIZE,
};
vkCmdPipelineBarrier(
commandBuffer,
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR,
VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR,
0, 0, nullptr,
1, &bufferBarrier, // 【1】只对 AS buffer 做 cache 管理的精确屏障
0, nullptr
);
下面对这段代码特别说明:srcQueueFamilyIndex 和 dstQueueFamilyIndex 设为 VK_QUEUE_FAMILY_IGNORED 表示不涉及队列所有权转移——构建和追踪在同一个队列上。如果构建在 compute 队列、追踪在 graphics 队列,需要设置正确的 queue family index 并配合 semaphore 做跨队列同步(后面第六章展开)。
2.2 D3D12 的资源屏障
D3D12 的屏障模型不同。在 D3D12 中,AS 构建后需要通过 D3D12_RESOURCE_BARRIER 转换资源状态:
// 【D3D12 AS 构建后屏障】
D3D12_RESOURCE_BARRIER barrier = {};
barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
barrier.Transition.pResource = asResultBuffer;
barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_UNORDERED_ACCESS;
barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE;
barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
commandList->ResourceBarrier(1, &barrier);
// 【1】StateBefore=UAC 因为 AS 构建是 GPU 写入操作
// 【2】StateAfter=NonPixelShaderResource 因为 RT shader 属于非像素着色器阶段
下面对这段代码特别说明:AS result buffer 在构建时处于 UNORDERED_ACCESS 状态(构建是 GPU 写入操作),构建完成后需要转换到 NON_PIXEL_SHADER_RESOURCE 状态(ray tracing shader 属于非像素着色器阶段)。在 D3D12 Enhanced Barriers 模式下,这个转换可以更细粒度:使用 D3D12_BARRIER_SYNC_RAYTRACING 同步类型直接指定 RT 阶段的同步。Enhanced Barriers 是 D3D12 的新同步模型(Windows 10 20H1+ 支持),用"同步点 + 访问范围"替代了旧的"状态转换"模型,语义更接近 Vulkan 的 stage/access flags——对从 Vulkan 迁移过来的开发者更友好。
对比一下两种 API 的同步语义差异。Vulkan 的 stage/access flags 模型是"谁在写 → 谁在读",需要明确指定 source stage 和 destination stage。D3D12 的传统模型是"资源处于什么状态",需要指定 StateBefore 和 StateAfter——驱动内部会把它翻译成 stage/access 的组合。两者在功能上是等价的,但 Vulkan 的模型在调试时更直观(可以直接看到"build 阶段 → trace 阶段"的同步关系),D3D12 的模型在封装时更简洁(不需要记住每个 stage flag 的名字)。Vulkan 的同步代码更长但更容易排查问题,D3D12 的同步代码更短但更容易写错(StateBefore/StateAfter 的组合有很多种,不直观)。
2.3 TLAS 构建还需要等 instance 数据就绪
BLAS 的构建只依赖几何体数据(顶点 buffer),但 TLAS 的构建还依赖 instance 数据——每个 BLAS 的变换矩阵。如果 instance 数据是由 CPU 上传的,需要先等上传完成;如果是由 GPU 上的着色器计算的(比如 GPU driven rendering),需要先等着色器完成。这是一个"依赖链"——TLAS 构建依赖于 instance 数据,instance 数据依赖于 compute shader 或 CPU 上传,而 trace 又依赖于 TLAS 构建完成。链条上任何一个环节的同步缺失,都会导致后续操作读到错误数据。
TLAS 构建前的屏障链通常是:
Instance 数据就绪(CPU 上传或 GPU 计算)
→ barrier(SHADER_WRITE → ACCELERATION_STRUCTURE_READ)
→ vkCmdBuildAccelerationStructuresKHR(TLAS)
→ barrier(AS_WRITE → AS_READ)
→ vkCmdTraceRaysKHR
展开讲这个依赖链的每一环。第一环:instance 数据的生成。在 GPU driven rendering 中,instance 数据通常由一个 compute shader 生成——它做视锥剔除、遮挡剔除、LOD 选择,输出可见 instance 的 VkAccelerationStructureInstanceKHR 数组(每个 64 字节:24 字节 transform matrix + 8 字节 instance custom index + 8 字节 mask + 8 字节 SBT offset + 8 字节 flags)。这个 compute shader 的写入访问标志是 VK_ACCESS_SHADER_WRITE_BIT,阶段是 VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT。
第二环:compute → TLAS build 的屏障。这个屏障把 instance buffer 从 compute shader 的可写状态转为 AS 构建的可读状态:
// 【Compute → TLAS Build 屏障】
// 【1】instance buffer 从 compute 写入转为 AS 构建
vkCmdPipelineBarrier(
commandBuffer,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // 【2】源:compute shader 阶段
VK_PIPELINE_STAGE_ACCELERATION_STRUCTURE_BUILD_BIT_KHR, // 【3】目标:AS build 阶段
VK_DEPENDENCY_BY_REGION_BIT,
1, &(VkBufferMemoryBarrier){
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_ACCELERATION_STRUCTURE_READ_BIT_KHR,
.buffer = instanceBuffer,
.offset = 0,
.size = VK_WHOLE_SIZE,
},
0, nullptr, 0, nullptr
);
下面对这段代码特别说明:VK_DEPENDENCY_BY_REGION_BIT 表示屏障只保证同一区域的写读顺序,不同区域之间不保证——对 instance 数组是安全的,因为每个 instance 的数据是独立的。这个 flag 可以让 GPU 在某些情况下并行执行屏障前后的操作,减少屏障的 stall 时间。
第三环:TLAS build → trace 的屏障。这就是前面 2.1 讲过的标准 AS barrier。三环加在一起,TLAS 的完整依赖链是:compute(生成 instance)→ barrier → TLAS build → barrier → trace。如果 instance 是 CPU 上传的,第一环变成 VK_PIPELINE_STAGE_TRANSFER_BIT + VK_ACCESS_TRANSFER_WRITE_BIT。
这是 AS 生命周期中最容易出问题的地方。如果你用的是 GPU driven 的实例剔除流程,instance 数据是由 compute shader 生成的,你需要确保 compute shader 完成后才开始 TLAS 构建。漏掉这一步的结果是:TLAS 用的是上一帧的 instance 数据,场景看起来"延迟了一帧"——但这不是你有意做的延迟一帧策略,而是同步 bug 导致的意外延迟。区别在于:有意的延迟一帧是"我知道用了上一帧的数据,并且画面质量可以接受";同步 bug 的延迟一帧是"我以为用的是当前帧数据,实际用的是上一帧"——当你的角色移动时,阴影和反射的位置会错误地滞后,而且你自己不知道为什么。
三、帧内调度:双缓冲与延迟一帧
3.1 为什么要双缓冲
如果每帧都重建 TLAS,最简单的做法是:构建 → 屏障 → 追踪,全部在同一个命令缓冲区里串行执行。这样做的问题是——构建和追踪不能重叠,GPU 必须等构建完全完成后才能开始追踪。
用具体数字说话。对于一个中等规模的场景(约 10 万个三角形、500 个实例),实测数据如下(RTX 4090,1080p 分辨率,1 spp primary ray):
| 操作 | 串行执行时间 | 说明 |
|---|---|---|
| 动态 BLAS Update(30 个角色) | 3.2 ms | 每个约 0.1 ms |
| TLAS Build | 0.4 ms | 500 实例 |
| Trace(reflection + shadow) | 4.1 ms | 2 ray types |
| 串行总计 | 7.7 ms | BLAS + TLAS + Trace |
7.7 ms 在 60 fps(16.67 ms 预算)下看似还行,但如果加上光栅化(G-Buffer 3.5 ms)、着色(2.0 ms)、后处理(2.5 ms),总计 15.7 ms——已经很紧了。如果分辨率升到 1440p,trace 时间翻倍到 8.2 ms,总计 19.7 ms,直接超预算 3 ms。更关键的是:在 TLAS 构建的 0.4 ms 里,RT Core 完全闲置——这不算太糟糕,但如果 BLAS 也在同一帧重建,加上 BLAS 构建的 3.2 ms,串行执行的总开销就不可忽略了。
双缓冲的思路是:准备两个 AS(A 和 B),当前帧追踪 A,同时构建 B;下一帧追踪 B,同时构建 A。
这样做的好处是构建和追踪可以并行——如果构建放在 compute 队列,追踪放在 graphics 队列,两个队列可以同时工作。在 NVIDIA 的 GPU 上,compute 队列和 graphics 队列可以真正并行执行(不像某些架构是分时复用的),双缓冲可以把 BLAS Update + TLAS Build 的 3.6 ms 完全藏在 trace 的 4.1 ms 里。实测数据(同上场景):
| 操作 | 双缓冲执行时间 | 说明 |
|---|---|---|
| BLAS Update + TLAS Build(compute 队列) | 3.6 ms(与 trace 并行) | 藏在 trace 后面 |
| Trace(graphics 队列) | 4.1 ms | 与构建并行 |
| 双缓冲总计 | 4.1 ms(取最大值) | 节省 3.6 ms |
代价是:你永远在追踪上一帧的 AS,场景看起来"延迟了一帧"。
3.2 延迟一帧的代价
延迟一帧听起来不严重——毕竟 60 fps 下 16.67 ms 的延迟对人眼几乎不可感知。但有两个场景需要注意:
-
快速运动的物体:如果物体在一帧内移动了显著距离,追踪用的 AS 是上一帧的位置,光线打中的是"旧位置"的几何体,导致阴影、反射出现位置偏差。我们用数字来说明:一个以 20 m/s 运动的物体(比如赛车游戏中的车),60 fps 下一帧位移 0.33 m,延迟一帧意味着阴影位置偏差 0.33 m——在 1080p 下大约 6–8 个像素,肉眼可见。这种偏差在 120 fps 下更明显(8.33 ms 的延迟对快速物体有可见影响),位移 0.17 m 虽然更小,但因为帧率更高,人眼对运动的感知更敏感,反而更容易察觉。
-
用户输入响应:摄像机的旋转在追踪结果中延迟一帧显现。VR 场景对这种延迟极为敏感(motion-to-photon latency 需要低于 20 ms)。60 fps 下一帧延迟是 16.67 ms,加上显示器延迟 2–5 ms 和输入采样延迟 2–3 ms,总延迟 20.67–24.67 ms——刚好踩在 VR 的舒适阈值边界。90 fps VR(Oculus Rift 等)下一帧延迟 11.11 ms,总延迟 15.11–19.11 ms——在舒适区内。但如果用串行执行(不延迟),构建和追踪在同一帧内完成,延迟少一帧——对于 VR 应用,这个差异可能决定是否引起 motion sickness。
还有一个容易忽略的代价:双缓冲需要两份 AS 内存。如果一份 TLAS 的 result buffer 是 4 MB,双缓冲就要 8 MB。对显存紧张的场景(比如 8 GB 显卡的笔记本),这可能逼得你减少 BLAS 数量或降低 AS 质量。不过 TLAS 本身很小(通常 < 4 MB),双缓冲的内存代价主要在 BLAS——动态 BLAS 双缓冲才是内存大户,每个 1–10 MB,30 个角色双缓冲就是 60–600 MB。对于移动端 GPU(如集成显卡,共享内存 2–4 GB),这个内存代价可能是致命的——很多移动 RT 实现选择不做双缓冲,宁可用串行执行的帧时间换内存。这也是移动端 RT 目前还远不如桌面端成熟的原因之一。
在不延迟的场景中(串行执行),构建和追踪在同一帧内完成,但总时间更长。两种策略的选择取决于场景类型和性能预算:
| 策略 | 延迟 | 帧时间 | 适用场景 |
|---|---|---|---|
| 串行(同帧) | 0 帧 | 构建 + 追踪(串行) | VR、竞技游戏(延迟敏感) |
| 双缓冲(延迟一帧) | 1 帧 | max(构建, 追踪) | 大多数 3A 游戏(帧时间敏感) |
还有一个折中方案:选择性延迟。对动态 BLAS 用串行执行(不延迟),因为角色的位置精度很重要;对 TLAS 用双缓冲(延迟一帧),因为 TLAS 的 instance 变化通常不大。这种混合策略在 UE5 中有体现——动态 BLAS 的构建和 trace 在同一帧内串行完成,但 TLAS 的构建可以和上一帧的 trace 交叠。选择性延迟的帧时间介于串行和全双缓冲之间:动态 BLAS 串行 3.2 ms + max(TLAS build 0.4 ms, trace 4.1 ms) = 3.2 + 4.1 = 7.3 ms,比纯串行 7.7 ms 节省 0.4 ms,比纯双缓冲 4.1 ms 多 3.2 ms。但延迟从 1 帧降到了 0 帧(对动态 BLAS 而言)——对于角色动画密集的场景(如格斗游戏),这个精度提升是值得的。
还有一个经常被忽略的帧内调度优化:构建排序。如果你有多个 BLAS 要在 compute 队列上构建,构建的顺序会影响总时间。GPU 的 compute 单元是并行的——如果你把所有 BLAS 构建放在一个 vkCmdBuildAccelerationStructuresKHR 调用中(传入多个 build info),GPU 会并行执行它们(受限于 SM 数量)。但如果某些 BLAS 特别大(构建时间 >1 ms),它们会成为尾部延迟——其他小 BLAS 构建完了,还要等大的完成。解决方案是按构建时间降序排列——先构建大的,再构建小的,让小 BLAS 填补大 BLAS 之间的空闲 SM。实测在 30 个动态 BLAS 的场景中,降序排列比随机排列节省 0.3–0.5 ms 的构建总时间。
3.3 Unreal Engine 5 的做法
UE5 的 RT 实现采用的就是延迟一帧策略。TLAS 在 Rendering Thread 上准备 instance 数据,RHI Thread 上调用构建 API,GPU 上执行实际构建。三者流水线化,成本分散到三个线程上。细看这个三线程流水线的细节:
Rendering Thread 的工作是 GatherRayTracingWorldInstances——遍历场景中所有可见的 RT 几何体,收集它们的变换矩阵、BLAS 引用、SBT offset,打包成 instance 数组。这一步的开销取决于场景复杂度,通常 0.2–0.8 ms。它还负责判断哪些 BLAS 需要更新——动态网格的 BLAS 标记为 dirty,静态 BLAS 跳过。
RHI Thread 的工作是把 Rendering Thread 准备好的 instance 数据上传到 GPU(通过 staging buffer),然后调用 vkCmdBuildAccelerationStructuresKHR 或 D3D12BuildRaytracingAccelerationStructure。这一步的开销主要是 API 调用和 driver overhead,通常 0.1–0.3 ms。
GPU 的工作是实际执行 AS 构建。这是真正耗时的部分——BLAS Update 0.1–1 ms per mesh,TLAS Build 0.3–1 ms。但因为是延迟一帧策略,GPU 构建和当前帧的 trace 可以并行(如果用异步 compute)。
UE5 提供了几个统计命令来监控 AS 生命周期的开销:
Stat D3D12RayTracing:RHI 线程上的 AS 构建开销,包括 BLAS 构建数量、TLAS 构建时间、SBT 更新时间Stat SceneRendering:Rendering Thread 上GatherRayTracingWorldInstances的开销- GPU Profile:GPU 上的构建和追踪时间
UE5 的具体数字(来自 Epic 官方的 RT 性能指南,City Sample 场景,RTX 3080,1440p):
- GatherRayTracingWorldInstances:0.6 ms(Rendering Thread)
- RHI BuildAS 调用:0.2 ms(RHI Thread)
- GPU BLAS Update:2.1 ms(30 个动态网格)
- GPU TLAS Build:0.4 ms
- GPU Trace(reflection + translucency + shadow):6.8 ms
- 总 RT 开销:9.3 ms(异步构建后从 12.1 ms 降到 9.3 ms,节省 23%)
静态 BLAS 在场景加载时构建一次,动态网格的 BLAS 每帧重建。UE5 的 World Partition + HLOD 系统管理远场场景的 AS——远处用低精度 LOD 的 BLAS,近处用高精度 BLAS。这本质上是 AS 的流式加载(streaming),后面会展开讲。UE5 的 BLAS 池化管理由 FRTGeometryManager 负责,它维护一个 TMap<FRWBuffer, FRayTracingGeometryData> 映射,按 mesh 的 RT key 索引。当 World Partition 加载一个新 tile 时,对应的 BLAS 被构建并加入池;卸载 tile 时,BLAS 被标记为 retire,下一帧 GC 回收。UE5 还有一个值得注意的设计:它的 RT 管线支持"部分 RT"模式——可以只对部分场景开启 RT(比如只对角色和车辆开 RT 反射,建筑物用传统 SSR),通过 r.RayTracing.EnableInPostProcess 等 cvar 控制。这减少了 TLAS 的 instance 数量,降低了 Build 和 Trace 的开销。对于性能受限的平台(如主机),这种部分 RT 策略是保证 60 fps 的关键手段。具体来说,UE5 在 ShouldRenderRayTracingEffect 函数中检查每个 RT effect 的 cvar 开关,只有开启的 effect 对应的 instance 才进入 TLAS。这意味着同一个场景在不同画质档位下,TLAS 的 instance 数可能差几倍——低画质下只有 50 个 instance(只对角色开 RT),高画质下有 500 个 instance(对角色 + 车辆 + 建筑物开 RT)。instance 数量的差异直接影响 TLAS build 时间和 trace 时间——50 instance 的 TLAS build 约 0.05 ms,500 instance 约 0.4 ms,差 8 倍。
四、AS 压缩的生命周期
4.1 什么时候压缩
AS 构建完成后,它占用的内存比实际需要的多——因为构建算法为了保证 BVH 树的质量,会预留一些空间。这些预留空间包括:BVH 节点的对齐 padding(每个节点 64 字节对齐)、primitive 的重排序缓冲区、构建过程中的临时分桶数据。压缩(compaction)可以把这些多余的空间释放掉,让 AS result buffer 紧凑排列。
NVIDIA 的 RTXMU SDK 实测数据显示,压缩可以使 AS 内存减少约 50%(测试场景平均 52%)。具体的测试数据(来自 NVIDIA 官方博客,测试场景为 14 个不同复杂度的 mesh,RTX 2080 Ti):
| Mesh 类型 | 三角形数 | 构建后 AS 大小 | 压缩后 AS 大小 | 节省比例 |
|---|---|---|---|---|
| 简单道具(椅子) | 1,200 | 128 KB | 62 KB | 51.6% |
| 角色(人形) | 18,000 | 1.1 MB | 520 KB | 52.7% |
| 建筑(房间) | 45,000 | 2.8 MB | 1.3 MB | 53.6% |
| 植被(树) | 80,000 | 4.6 MB | 2.2 MB | 52.2% |
| 平均 | — | — | — | 52.0% |
但压缩不是免费的。它需要:
1. 构建完成后读取 AS 的实际大小(GPU → CPU 回读),通过 vkWriteAccelerationStructuresPropertiesKHR 或 query pool 获取
2. 分配一个新 buffer,大小正好等于压缩后的尺寸
3. 用 vkCmdCopyAccelerationStructureKHR(COMPACT 模式)把 AS 拷贝到新 buffer
4. 释放原始的 build buffer 和 scratch buffer
这个流程决定了压缩只适合静态 BLAS——因为回读和压缩是一次性开销,如果每帧都重建 AS,压缩的收益就被回读的开销吃掉了。TLAS 通常不压缩,因为它每帧重建。我们算一笔账:TLAS 构建后大小约 1–4 MB,压缩后约 0.5–2 MB,节省 1–2 MB。但压缩需要 readback(GPU→CPU 同步点,至少 1 帧延迟)+ compact copy(约 0.1 ms GPU 时间)。对于每帧重建的 TLAS,这个开销不划算——1–2 MB 的显存节省不值一个 CPU 同步点。
4.2 压缩的时机在 Build 和第一次 Trace 之间
压缩必须在 AS 构建完成后、第一次追踪之前进行。AS 的生命周期在压缩场景下因此变成了五个状态:
Build → Readback → Compaction → Visible → Trace → Retire
其中 Readback 是 CPU 等待 GPU 完成构建并回读 AS 大小的步骤。这个步骤需要 vkQueueWaitIdle 或 fence 等待,会引入一个 CPU 同步点——这是静态 BLAS 压缩的主要代价。有三种处理 readback 的策略:
策略一:fence 等待(最简单但最慢)。构建后提交 fence,CPU 等待 fence,然后读回 AS 大小,分配 compacted buffer,提交 compact copy。优点是逻辑简单,缺点是 CPU stall——GPU 构建时 CPU 完全闲置。对于加载时构建的静态 BLAS,这个 stall 可以接受(加载画面中),但在运行时流式加载场景中不可接受。
策略二:query pool 异步回读(推荐)。用 VkQueryPool 设置 VK_QUERY_TYPE_ACCELERATION_STRUCTURE_COMPACTED_SIZE_KHR,构建后不需要立即等 fence——你可以延迟到下一帧甚至几帧后再 vkGetQueryPoolResults 获取大小。这样 CPU 不需要 stall,但压缩延迟了几帧。在实践中,延迟 1–2 帧压缩完全没问题——静态 BLAS 本来就不急着用。
策略三:预计算大小(最快但不精确)。vkGetAccelerationStructureBuildSizesKHR 在构建前可以返回一个 upper bound 的 AS 大小,直接按这个大小分配 compacted buffer,跳过 readback。缺点是 buffer 可能比实际需要的大(upper bound 通常比实际大 10–20%),压缩收益打折。但对于"差一点内存"的场景,这是最简单的方案。
实践中最常用的是策略二(query pool 异步回读)——它在 CPU stall、压缩收益和实现复杂度之间取得了最好的平衡。策略一只在场景加载时使用(加载画面中 stall 不影响玩家体验),策略三只在内存不紧张但需要快速压缩的场景中使用。大部分引擎会同时支持这三种策略,通过配置项切换——例如 UE5 通过 r.RayTracing.AccelStructCompaction cvar 控制压缩策略。
4.3 RTXMU 的子分配
除了压缩,RTXMU 还提供了子分配(suballocation)机制:把多个小 BLAS 打包到一个大的 heap 中,减少内存碎片。标准 Vulkan/D3D12 要求 AS buffer 以 64 KB 对齐,而 RTXMU 使用 256 字节对齐,大大减少了小 BLAS 的内存浪费。
用具体数字说明对齐浪费的影响。假设场景中有 1000 个小道具(瓶子、书、武器),每个 BLAS 压缩后只有 40 KB。标准 64 KB 对齐下,每个 BLAS 实际分配 64 KB,浪费 24 KB(37.5%)。1000 个就是浪费 23.4 MB——对于一个 8 GB 显存的 GPU,这是 0.3% 的显存。但如果用 RTXMU 的 256 字节对齐,每个 BLAS 分配 40 KB + 少量 padding,浪费不到 1 KB,1000 个总共只浪费 < 1 MB。
RTXMU 的子分配机制内部维护一个 block allocator:把一个大 heap(比如 256 MB)分成多个 block,每个 block 通过 offset 分配给一个 BLAS。当 BLAS 被 retire 时,block 标记为 free,可以被新的 BLAS 复用。RTXMU 用一个 free list 管理空闲 block,分配策略是 first-fit(找到第一个够大的空闲 block 就分配)。这个策略简单高效,缺点是可能产生外部碎片——但因为有压缩,每个 BLAS 的尺寸已经很小且紧凑,碎片问题不严重。
对于有大量小物体的场景(比如植被、碎片),子分配的收益非常显著。NVIDIA 官方的测试数据显示,在一个有 5000 棵树的植被场景中,标准分配需要 312 MB 的 AS 内存,RTXMU 子分配 + 压缩后只需 148 MB——节省 53%。
RTXMU 的生命周期管理也由它自己接管——你只需要调用 RTXMU::CreateBLAS,它会在内部处理压缩、子分配和垃圾回收。它的 API 设计非常简洁:CreateBLAS 返回一个 handle,DestroyBLAS 标记为 retire,内部的 GC 在确认 GPU 不再引用时回收 block。这个设计让开发者完全不需要关心 AS 的压缩时机和内存布局——代价是引入了一个中间层,调试时不太透明(你不能直接看到 AS 在 VRAM 中的位置)。
补充一个压缩相关的工程细节:压缩的 query pool 实现。前面提到 query pool 可以异步回读 AS 大小,具体的代码实现如下:
// 【AS 压缩大小查询】
// 【1】创建 query pool,查询类型为 AS compacted size
VkQueryPoolCreateInfo qpoolInfo = {
.sType = VK_STRUCTURE_TYPE_QUERY_CREATE_INFO,
.queryType = VK_QUERY_TYPE_ACCELERATION_STRUCTURE_COMPACTED_SIZE_KHR,
.queryCount = 1,
};
vkCreateQueryPool(device, &qpoolInfo, nullptr, &queryPool);
// 构建后立即 reset 并写入 query
vkCmdResetQueryPool(commandBuffer, queryPool, 0, 1);
vkCmdWriteAccelerationStructuresPropertiesKHR(
commandBuffer, 1, &asHandle,
VK_QUERY_TYPE_ACCELERATION_STRUCTURE_COMPACTED_SIZE_KHR,
queryPool, 0
);
// 【2】延迟几帧后获取结果,不需要 CPU stall
VkDeviceSize compactedSize;
vkGetQueryPoolResults(device, queryPool, 0, 1,
sizeof(VkDeviceSize), &compactedSize,
sizeof(VkDeviceSize), VK_QUERY_RESULT_WAIT_BIT);
下面对这段代码特别说明:vkCmdWriteAccelerationStructuresPropertiesKHR 是在 GPU 端执行的——它在 AS 构建完成后写入 AS 的实际大小到 query pool。vkGetQueryPoolResults 是 CPU 端调用,但你可以延迟几帧再调用——不需要在构建的那一帧立即等结果。VK_QUERY_RESULT_WAIT_BIT 表示阻塞等待结果,如果你不想阻塞可以用 VK_QUERY_RESULT_PARTIAL_BIT 或轮询。这个 query pool 机制是 Vulkan RT 扩展提供的标准方案,D3D12 对应的是 ID3D12Device::GetRaytracingAccelerationStructurePostbuildInfo,语义类似。
五、SBT 的生命周期与 AS 的绑定
5.1 SBT 是什么
SBT(Shader Binding Table)是 RT 管线中另一个需要生命周期管理的资源。它存储的是着色器句柄(shader handle)和每条 ray 需要的参数数据。如果把 AS 比作"场景的加速索引",那 SBT 就是"场景的着色器路由表"——trace 时 GPU 遍历 BVH 命中一个 triangle 后,通过 instance 的 SBT offset 找到对应的 hit group(closest-hit + any-hit + intersection shader),然后跳转到该着色器执行。
SBT 的结构是一组 record,每个 record 包含: - Shader handle(一个指向着色器入口的指针,64 字节)——这是 GPU 通过 RT Core 的 dispatch 机制跳转到着色器代码的地址 - 用户数据(可选,如材质 ID、变换矩阵等)——通过着色器的 root signature / push constant 访问
SBT 分三个区域:
| 区域 | 内容 | 典型 record 数 | 单 record 大小 |
|---|---|---|---|
| RayGen | ray generation shader handle + 用户数据 | 1 | 64–128 字节 |
| Miss | miss shader handle(每种 ray type 一个) | 1–4 | 64 字节 |
| HitGroup | closest-hit + any-hit + intersection handle + 用户数据 | N(=实例数 × ray type 数) | 64–256 字节 |
SBT 的 record 大小必须是 32 字节对齐(Vulkan 规范要求),且 shader handle 本身占 64 字节(Vulkan)或 32 字节(D3D12)。如果用户数据是 32 字节,一个 record 就是 64 + 32 = 96 字节,对齐到 96 字节。如果用户数据是 48 字节,record 就是 64 + 48 = 112 字节,对齐到 128 字节(向上取 32 的倍数)。这个对齐规则经常导致 record 大小比预期的大——设计 SBT 布局时要仔细计算。一个常见的优化是尽量让用户数据凑成 32 的倍数——比如材质 ID 是 4 字节,你不需要 60 字节的 padding,可以把多个材质参数打包到 32 字节里(材质 ID 4B + 纹理 index 4B + emissive factor 4B + padding 20B = 32B),让 record 正好是 64+32=96 字节,不浪费。
5.2 SBT 与 AS 的绑定关系
SBT 和 AS 的绑定关系通过 instance 的 instanceShaderBindingTableOffset 字段建立。每个 TLAS instance 在构建时指定它对应的 SBT offset,trace 时 GPU 根据 offset 查找正确的着色器。具体的数据流是这样的:
- TLAS 构建时,每个
VkAccelerationStructureInstanceKHR的instanceShaderBindingTableOffset字段被设置为该实例在 SBT HitGroup 区域中的 record index - trace 时,GPU 遍历 BVH 命中一个 triangle,从 instance 数据中读取
instanceShaderBindingTableOffset - GPU 用这个 offset 加上 ray type index 计算出 SBT 中的实际 record 地址:
recordAddress = SBTBase + (instanceOffset + rayTypeIndex) * recordSize - GPU 从该地址读取 shader handle,跳转到着色器执行
这意味着:AS 被重建了(比如 TLAS 每帧重建),SBT 也需要同步更新——至少要确认 SBT 的内容没有变化。如果场景中新增了一个物体,TLAS 多了一个 instance,SBT 也要多一条对应的 record。这里有一个微妙的同步问题:TLAS 的 instance 数据中包含 SBT offset,而 SBT buffer 的内容必须和 offset 匹配——如果 SBT 还没更新但 TLAS 已经用了新的 offset,GPU 会从 SBT 的错误位置读取 shader handle,导致着色器跳转到错误的地址,通常表现为画面花屏或驱动崩溃。
SBT 的更新通常比 AS 便宜得多——它只是写几个 64 字节的 shader handle 加一些参数,不需要复杂的 GPU 计算。但如果场景中材质变化频繁(比如 PBR 材质切换),SBT 的重建可能成为瓶颈。SBT 的写入开销是 recordCount × recordSize / bandwidth。假设 500 个实例、4 种 ray type、每 record 128 字节,SBT HitGroup 区域大小 = 500 × 4 × 128 = 256 KB。写入 256 KB 在 RTX 4090 上(PCIe 4.0 ×16,理论带宽 64 GB/s)约 4 μs——几乎可忽略。但如果是从 CPU 端写入(通过 staging buffer 上传),还要加上 staging buffer 分配和 barrier 的开销,总计约 0.05–0.1 ms。如果用 GPU driven 的方式(compute shader 直接写 SBT buffer),可以省掉 staging buffer 的开销。
5.3 SBT 的生命周期管理策略
| 场景 | SBT 策略 | 说明 |
|---|---|---|
| 静态场景 | 一次性构建 | 场景加载时建好,帧间不更新 |
| 动态场景(少量变化) | 增量更新 | 只更新变化的 record |
| 动态场景(大量变化) | 每帧重建 | 与 TLAS 同步重建 |
| GPU driven | GPU 构建 SBT | compute shader 写入 SBT buffer |
大部分引擎选择"每帧重建 SBT"——因为 SBT 重建的开销很小(通常 <0.1 ms),但增量更新的逻辑复杂度很高,不值得为了省那 0.1 ms 增加代码复杂度。增量更新需要维护一个 dirty list,追踪哪些 instance 的材质/变换变了,然后只更新对应的 record——这套逻辑的维护成本远超 0.1 ms 的性能收益。而且增量更新有一个隐藏的陷阱:如果 SBT buffer 是一个连续的 record 数组,更新某个 record 的内容不影响其他 record 的 offset——但如果新增或删除了一个 instance,后续所有 record 的 offset 都要移位,等价于重建整个 SBT。所以"增量更新"只有在 instance 数量不变、只有少数 record 内容变化时才有意义——这个前提条件很难满足,大部分场景每帧都有 instance 增删。
GPU driven 的 SBT 构建是更高级的方案:compute shader 在 GPU 上直接写 SBT buffer,完全不需要 CPU 介入。这通常和 GPU driven rendering 配合——compute shader 做完实例剔除后,同一 shader 直接把可见实例的 shader handle 写入 SBT。SBT 和 instance 数据是同一帧、同一 pass 生成的,不存在同步问题。代价是:需要把所有材质的 shader handle 预先放在一个 GPU buffer 中(shader handle table),compute shader 从这个 table 中读取并写入 SBT。这个 shader handle table 在管线创建时一次性填充,之后不变。
// 【GPU driven SBT 构建】
// 【1】compute shader 从 shader handle table 读取 handle,写入 SBT buffer
// 【2】SBT record layout: [shader handle 64B][material data 32B][transform 48B] = 144B → align 160B
struct SBTRecord {
uint8_t shaderHandle[64]; // 从 shaderHandleTable 读取
uint32_t materialID; // 从 instance data 读取
uint32_t padding[3]; // 对齐到 80 字节
float transform[12]; // 3x4 矩阵
};
// Compute shader 伪代码
[numthreads(64, 1, 1)]
void BuildSBTCS(uint3 dtid : SV_DispatchThreadID)
{
if (dtid.x >= visibleInstanceCount) return;
InstanceData inst = instanceBuffer[dtid.x];
SBTRecord record;
// 从 handle table 复制 shader handle
memcpy(record.shaderHandle, shaderHandleTable[inst.shaderIndex].handle, 64);
record.materialID = inst.materialID;
memcpy(record.transform, inst.transform, sizeof(float) * 12);
// 写入 SBT buffer
sbtBuffer[dtid.x] = record;
}
下面对这段代码特别说明:GPU driven SBT 构建的关键是 shaderHandleTable——它在管线创建时由 vkGetRayTracingShaderGroupHandlesKHR 填充,包含每个 hit group 的 64 字节 shader handle。compute shader 根据 instance 的 shaderIndex 从 table 中读取 handle,加上材质数据和变换矩阵,组装成完整的 SBT record。这种方案的优点是 SBT 和 instance 数据在同一个 compute pass 中生成,天然同步——不需要额外的屏障。
六、跨队列同步:compute build + graphics trace
6.1 为什么要跨队列
双缓冲策略的自然扩展是:把 AS 构建放在 compute 队列,追踪放在 graphics 队列,两个队列并行工作。这样可以充分利用 GPU 的异步计算能力。NVIDIA 的 GPU 从 Turing 架构(RTX 20 系列)开始,compute 队列和 graphics 队列可以真正并行执行——它们使用不同的 SM 集群,不互相阻塞。AMD 的 RDNA2 架构也支持异步 compute,但并行度不如 NVIDIA——在某些情况下,compute 操作会暂停部分 CU(Compute Unit)给 graphics 用。
但跨队列引入了新的同步需求。Vulkan 的队列同步使用 semaphore:compute 队列完成构建后 signal 一个 semaphore,graphics 队列在 trace 前等待这个 semaphore。
// Compute Queue:
vkCmdBuildAccelerationStructuresKHR(...)
vkCmdPipelineBarrier(...) // AS build → AS read
vkQueueSubmit(signalSemaphore = sem_build)
// Graphics Queue:
vkQueueSubmit(waitSemaphore = sem_build)
vkCmdBindDescriptorSets(...) // 绑定 AS descriptor
vkCmdTraceRaysKHR(...)
跨队列同步的一个关键细节:AS 的 descriptor 绑定时机。在 Vulkan 中,AS 通过 descriptor set 绑定到 ray tracing pipeline。但 descriptor 绑定是 CPU 端操作——你在录制 command buffer 时绑定,但 GPU 执行时才真正读取。如果 AS 是在 compute 队列上构建的,你需要在 graphics 队列的 command buffer 中重新绑定 descriptor——因为 AS 的实际数据在 compute 队列上写入,graphics 队列需要通过 semaphore 确保看到写入结果后才能读取。这里容易犯的错误是:在 compute 队列的 command buffer 中绑定 descriptor,然后期望 graphics 队列能用——不行,每个队列的 command buffer 是独立的,descriptor 绑定不会跨队列传递。
还有一个细节值得注意:AS 的 device address 跨队列有效性。在 Vulkan 中,AS 通过 vkGetAccelerationStructureDeviceAddressKHR 获取一个 device address,这个 address 在整个 device 范围内有效——不依赖于哪个队列创建了 AS。这种 device address 的全局有效性是 Vulkan RT 设计中的一个优点——它让跨队列 AS 共享变得简单,只需要正确设置同步原语即可。但 D3D12 的行为不同:D3D12 的 AS resource 需要通过 GetResource + GetGPUVirtualAddress 获取地址,如果 AS resource 在 build 时处于 D3D12_RESOURCE_STATE_UNORDERED_ACCESS,在 trace 时需要先转换到 D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE——这个状态转换必须在拥有该 resource 的 command list 上执行,不能跨 command queue。也就是说,D3D12 的跨队列 AS 使用需要额外的状态转换步骤。
跨队列的另一个性能考虑是队列优先级。在大多数 GPU 上,graphics 队列的优先级高于 compute 队列——如果两个队列同时提交工作,GPU 会优先执行 graphics 队列。这对于 RT 管线来说通常是好事(trace 在 graphics 队列,优先级高),但如果 compute 队列的 AS 构建被饿死(长时间得不到执行),下一帧的 trace 就拿不到新的 AS。解决方案是给 compute 队列设置适当的优先级(Vulkan 的 VkDeviceQueueCreateInfo::queuePriority),通常 0.5–0.8 是一个合理的范围——足够保证 compute 队列不会被饿死,又不会抢太多 graphics 队列的时间。
6.2 Timeline Semaphore 替代 Fence + Binary Semaphore
Vulkan 1.2 引入了 timeline semaphore,它可以用一个 semaphore 替代 fence + binary semaphore 的组合。timeline semaphore 的信号值是单调递增的整数,可以表达更复杂的同步关系。
先对比 binary semaphore 和 timeline semaphore 的差异。Binary semaphore 只有两种状态:signaled 和 unsignaled。如果你有三帧 in-flight,每帧的 compute 队列都需要 signal 一个 semaphore 告诉 graphics 队列"构建完成",你需要三个 binary semaphore(sem_build[0]、sem_build[1]、sem_build[2]),因为 binary semaphore 不能区分"第几帧的 signal"。如果你只用一个 binary semaphore,第 1 帧 signal 后,第 2 帧的 signal 会覆盖第 1 帧的——如果 graphics 队列还没来得及 wait 第 1 帧的 signal,就丢失了。
Timeline semaphore 不存在这个问题——它的 signal 值是单调递增的整数,每次 signal 指定一个更大的值。wait 端指定"等到某个值"而不是"等到 signal"。三帧 in-flight 只需要一个 timeline semaphore,signal 值分别是 1、3、5(帧 × 2 + 1),wait 值分别是 1、3、5。
// 【Timeline semaphore 跨队列同步】
// Compute Queue: signal value = frame * 2 + 1
// 【1】一个 timeline semaphore 管理所有帧的同步
VkTimelineSemaphoreSubmitInfo timelineInfo = {};
timelineInfo.signalSemaphoreValueCount = 1;
uint64_t signalValue = frame * 2 + 1;
timelineInfo.pSignalSemaphoreValues = &signalValue;
vkQueueSubmit(computeQueue, 1, &submitInfo, VK_NULL_HANDLE);
// Graphics Queue: wait value = frame * 2 + 1
// 【2】wait 端精确指定等待哪一帧的构建完成
timelineInfo.waitSemaphoreValueCount = 1;
uint64_t waitValue = frame * 2 + 1;
timelineInfo.pWaitSemaphoreValues = &waitValue;
vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
下面对这段代码特别说明:timeline semaphore 的信号值可以精确表达"等到第 N 帧的构建完成"这种语义。binary semaphore 只能表达"等到 signal",如果有三帧在飞行(in-flight),binary semaphore 需要三个,而 timeline semaphore 只需要一个。timeline semaphore 还有一个优势:可以在 CPU 端用 vkGetSemaphoreCounterValue 查询当前 signal 值,从而知道 GPU 实际执行到了第几帧——这对 CPU 端的资源回收(比如释放上一帧的 staging buffer)非常有用。Binary semaphore 没有这个能力——你只能用 fence 来查询 GPU 进度,而 fence 和 semaphore 是两套独立的同步机制,管理起来更复杂。
再讲一个 timeline semaphore 的进阶用法:多帧 in-flight 的资源回收。在双缓冲 + 三帧 in-flight 的场景中,你有三套 AS(A、B、C)和三套 scratch buffer。你需要确保 GPU 不再使用某一套资源时才能复用它。用 timeline semaphore,CPU 端的逻辑是:
// 【多帧 in-flight 资源回收】
// 【1】查询 GPU 已完成的帧号
uint64_t completedFrame;
vkGetSemaphoreCounterValue(device, timelineSem, &completedFrame);
// 【2】completedFrame 之前的帧的资源可以安全复用
uint32_t safeFrame = completedFrame - MAX_FRAMES_IN_FLIGHT;
// 释放 safeFrame 对应的 staging buffer、复用 scratch buffer 等
下面对这段代码特别说明:MAX_FRAMES_IN_FLIGHT 是你允许的飞行帧数(通常是 2 或 3)。completedFrame - MAX_FRAMES_IN_FLIGHT 之前的帧已经确定不会被 GPU 再使用,其资源可以安全回收。这是 timeline semaphore 相比 fence 的核心优势——一个 semaphore 既能做 GPU-GPU 同步(跨队列),又能做 CPU-GPU 同步(资源回收),而 fence 只能做后者。
6.3 跨队列的 D3D12 实现
D3D12 中跨队列同步使用 fence。D3D12 的 fence 本身就是 timeline 式的——它的 signal 值也是单调递增的整数。所以 D3D12 不需要像 Vulkan 那样区分 binary semaphore 和 timeline semaphore。这也是 D3D12 同步模型比 Vulkan 更简洁的地方——一套 fence 机制覆盖所有同步场景。
// 【D3D12 跨队列 fence 同步】
// Compute Queue: Signal fence with value = frame
// 【1】compute 队列完成构建后 signal fence
commandQueue->Signal(computeFence, frame);
// Graphics Queue: Wait on fence with value = frame
// 【2】graphics 队列在 trace 前 wait fence
commandQueue->Wait(computeFence, frame);
下面对这段代码特别说明:D3D12 的 fence Signal 和 Wait 是队列级别的操作——Signal 在队列执行到该点时设置 fence 值,Wait 让队列阻塞直到 fence 值达到指定值。这和 Vulkan 的 timeline semaphore 在语义上是等价的。D3D12 还提供了 ID3D12Fence::SetEventOnCompletion 用于 CPU 端等待——当 fence 值达到指定值时触发一个 Windows event。这对应 Vulkan 中 vkAcquireNextTIMELINE_SEMAPHORE(实际上 Vulkan 用 vkGetSemaphoreCounterValue 轮询或 vkRegisterDeviceEvent 注册回调)。
两者的性能差异不大——timeline semaphore 和 D3D12 fence 在 GPU 端都是通过相同的硬件同步原语(通常是 GPU 的 preemptive semaphore 机制)实现的。CPU 端的查询开销也类似(一次系统调用)。选择哪个 API 的同步机制,主要取决于引擎跨平台的需求——如果同时支持 Vulkan 和 D3D12,建议用统一的抽象层封装,内部映射到各自的同步原语。
补充一个跨队列同步的进阶话题:多帧 in-flight 的资源管理。在三帧 in-flight 的场景中,你有三套 AS 资源(AS-A、AS-B、AS-C)轮流构建和追踪。关键问题是:什么时候可以安全复用某一套资源?答案是"GPU 不再引用它时"。用 timeline semaphore,可以精确知道 GPU 执行到了第几帧——vkGetSemaphoreCounterValue 返回当前 signal 值,currentFrame - MAX_FRAMES_IN_FLIGHT 之前的帧已经安全。但有一个微妙的问题:graphics 队列的 trace 可能还在使用上一帧的 AS——所以复用时机不是"构建完的那一帧",而是"trace 完的那一帧"。用 timeline semaphore 的多值等待可以表达这个约束:compute 队列在构建 AS-C 前,需要等到 graphics 队列完成 AS-C 上一次 trace 的 signal 值。这需要两个 timeline semaphore——一个 compute→graphics(构建完成通知 trace),一个 graphics→compute(trace 完成通知可以复用)。两个 semaphore 的信号值交替递增,形成完整的 in-flight 资源管理闭环。这套机制非常常见,NVIDIA 的 Vulkan RT 教程中有完整示例代码。
七、内存管理:scratch buffer 回收、AS 池化
7.1 Scratch buffer 的生命周期
AS 构建需要一块临时内存——scratch buffer。它在构建过程中被写入,构建完成后就可以释放或复用。scratch buffer 的作用是存储 BVH 构建过程中的中间数据:节点划分的分桶数据、primitive 的重排序缓冲、Morton code 排序的临时空间。
scratch buffer 的大小取决于 AS 的复杂度,由 vkGetAccelerationStructureBuildSizesKHR 返回。这个函数在构建前调用,传入三角形数量和 build flag,返回两个值:asSize(AS result buffer 的最小大小)和 scratchSize(scratch buffer 的最小大小)。具体的大小估算公式(来自 Khronos 规范的参考实现):
scratchSize ≈ primitiveCount × 8 bytes × branchingFactor + padding
asSize ≈ primitiveCount × 32 bytes + nodeCount × 64 bytes
其中 branchingFactor 是 BVH 的分支因子(通常是 2 或 4),nodeCount ≈ primitiveCount / (branchingFactor - 1)。padding 通常是 4 KB 对齐的余量。实际的大小由驱动决定,上面的公式只是参考——不同 GPU 厂商的 BVH 实现不同,scratch 大小也不同。NVIDIA 的 BVH 实现通常比 AMD 的更紧凑,scratch 更小。
对于不同规模的 AS,scratch buffer 的典型大小:
| AS 类型 | 三角形数 | scratch 大小 | AS result 大小 |
|---|---|---|---|
| 小型 BLAS(道具) | 1,000 | 64 KB | 48 KB |
| 中型 BLAS(角色) | 20,000 | 1.2 MB | 800 KB |
| 大型 BLAS(建筑) | 100,000 | 6 MB | 4.2 MB |
| TLAS | 500 实例 | 256 KB | 1.1 MB |
scratch buffer 的生命周期:
分配 → 构建写入 → 屏障(AS_WRITE → 任意)→ 复用(下一帧构建)或 释放
关键点:scratch buffer 在同一帧内可以被多个 AS 构建复用。只要每次构建后有屏障确保写入完成,下一个构建就可以用同一块 scratch buffer。不需要为每个 BLAS 分配独立的 scratch buffer——一个足够大的 scratch buffer 可以服务所有构建。实践中,分配一个等于最大 BLAS 的 scratch 大小的 buffer(比如 6 MB),所有 BLAS 构建共用这一块。每次构建前不需要清零——构建操作会覆盖之前的临时数据。
但跨帧复用 scratch buffer 有一个注意点:如果用双缓冲 + 异步 compute,compute 队列在构建 AS-B 的同时,graphics 队列可能还在用 AS-A 做 trace。这时 scratch buffer 是 compute 队列独占的(trace 不读 scratch),所以不需要双缓冲 scratch——一个 scratch buffer 足够。但如果 compute 队列也在双缓冲(同时构建两帧的 AS),就需要两个 scratch buffer。实践中推荐的做法是预分配一个"最大 scratch buffer"——大小等于场景中最大 BLAS 的 scratch 需求(比如 6 MB),所有构建共用。这个 buffer 在场景加载时分配一次,整个游戏运行期间不释放。6 MB 的常驻内存对于现代 GPU(8 GB+)来说微不足道,但省去了每帧分配/释放的开销——GPU 内存分配不是免费的,Vulkan 的 vkAllocateMemory 在某些驱动实现上可能耗时 0.1–0.5 ms。
还有一个更精细的优化:scratch buffer 分段复用。BLAS 构建的 scratch buffer 分为两个区域——scratchUpdate(用于 refit 操作)和 scratchBuild(用于完整重建)。refit 的 scratch 需求通常只有 build 的 1/4 到 1/3。如果你同一帧有 build 和 refit 混合的 BLAS,可以把 scratch buffer 分成两段——前段给 build 用,后段给 refit 用,两段之间不需要屏障(因为它们是不同的内存区域)。这进一步减少了 scratch buffer 的总大小。
最后一个 scratch buffer 相关的细节:对齐 padding 的处理。vkGetAccelerationStructureBuildSizesKHR 返回的 scratch size 已经包含了 AS 厂商要求的对齐 padding(通常是 128 字节或 256 字节对齐)。但在实际使用中,如果你把 scratch buffer 分段复用,每段的起始地址也需要对齐——这意味着段与段之间可能有几十字节的 padding gap。对于 6 MB 的 scratch buffer,几十字节的 gap 可以忽略。但如果场景中有大量小 BLAS(比如 100 个 <1k 三角形的小道具),每个构建的 scratch 需求只有几 KB,对齐 padding 的比例就会上升——极端情况下 padding 可能占到 scratch 总量的 10–15%。这时可以考虑用一个子分配器管理 scratch buffer 的内部布局,把对齐 padding 压到最小。
7.2 AS 池化
对于有大量物体的场景,为每个物体分配独立的 AS buffer 会导致内存碎片。更好的做法是池化(pooling)。
AS 池化通常分三类:
| 池类型 | 内容 | 生命周期 | 典型数量 |
|---|---|---|---|
| 静态池 | 永不更新的 BLAS | 场景加载时构建,卸载时释放 | 100–1000 |
| 动态池 | 每帧/每隔几帧更新的 BLAS | 每帧重建 | 10–100 |
| 瞬态池 | 临时出现的 BLAS(如粒子、碎片) | 出现时构建,消失时释放 | 0–50 |
展开讲每个池的管理策略:
静态池的 BLAS 通常做压缩——一次性构建、压缩、之后不再更新。静态池的内存管理用子分配(suballocation)——所有静态 BLAS 共享一个大 heap,通过 offset 分配。NVIDIA RTXMU 的子分配机制(前面第四章讲过)非常适合这里。静态池的典型大小:500 个 BLAS × 平均 300 KB(压缩后)= 150 MB。这对于 12 GB 显存的 RTX 4090 不是问题,但对 8 GB 显卡需要注意。
动态池的 BLAS 不压缩——每帧重建,压缩的回读开销不划算。动态池需要双缓冲——当前帧的 trace 用上一帧构建的 AS,当前帧同时构建新的 AS。所以动态池需要两份内存:dynamicBLAS[frame % 2]。典型大小:30 个动态 BLAS × 平均 2 MB(未压缩)× 2(双缓冲)= 120 MB。
瞬态池的 BLAS 也不压缩,但需要高效的分配/释放策略——通常用 ring buffer 或 free list 管理。瞬态 BLAS 的生命周期很短(几帧),频繁分配/释放是主要瓶颈。一个好的策略是预分配一个固定大小的瞬态池(比如 50 个 slot),用 free list 管理空闲 slot——出现一个新物体时从 free list 取一个 slot,消失时归还。这避免了运行时分配内存的开销。典型大小:50 个瞬态 BLAS × 平均 200 KB = 10 MB——很小,但对帧时间的贡献不小(50 个构建 × 0.1 ms = 5 ms,如果串行的话)。瞬态池的一个进阶优化是延迟回收——物体消失时不立即回收 slot,而是标记为"pending retire",等 GPU 确认不再引用(通过 timeline semaphore 或 fence 查询)后再回收。这避免了"GPU 还在用但 CPU 已经回收"的同步 bug,代价是瞬态池需要预留额外的 slot(实际 slot 数 = 50 + MAX_FRAMES_IN_FLIGHT)。
瞬态池还有一个值得讨论的设计问题:slot 大小是否固定。如果所有瞬态 BLAS 共享一个固定大小的 slot(比如每个 slot 256 KB),当一个物体的 BLAS 超过 256 KB 时就分配不了。解决方案有两种:一是把 slot 大小设为场景中最大瞬态 BLAS 的大小(但大部分 slot 会浪费内存),二是用可变大小分配(但 free list 的管理变复杂,因为释放后的 slot 要合并相邻的空闲块才能服务更大的请求)。大部分引擎选择前者——简单且内存浪费可以接受,因为瞬态 BLAS 通常都很小(碎片、粒子、小道具),256 KB 的 slot 足够覆盖 99% 的情况。剩下 1% 的大物体(比如可破坏的大型建筑物)不走瞬态池,直接从动态池分配。
把整个 AS 内存管理画一个层次图。从生命周期最短到最长:
- Scratch buffer(瞬态):每帧构建用,帧内复用,典型 6 MB × 1(或 × 2 如果双缓冲 compute)
- 动态 BLAS result buffer(半持久):每帧重建,双缓冲,典型 120 MB
- 瞬态 BLAS result buffer(短期持久):出现后存活几帧,典型 10 MB
- 静态 BLAS(压缩后)(持久):场景加载时构建压缩,运行期间不变,典型 150 MB
- TLAS result buffer(半持久):每帧重建,双缓冲,典型 8 MB
总计约 294 MB——这是 RT 管线的 AS 内存预算。加上 G-Buffer(1080p RGBA16F × 5 = 80 MB)、HDR target(1080p R11G11B10 = 8 MB)、SBT(1 MB)、其他 RT 资源(history buffer、denoiser 中间数据等约 50 MB),RT 管线的总内存约 430 MB。在 8 GB 显卡上,这占 5.4%——不算夸张,但也不能忽视。如果升到 1440p 或 4K,G-Buffer 和 history buffer 的内存翻倍,AS 内存预算会更紧张。
八、生产模式对照
8.1 Unreal Engine 5
UE5 的 RT 资源管理是三个方案中最成熟的。它的核心设计可以用一句话概括:延迟一帧 + 三线程流水线 + 分层池化。展开讲:
- TLAS 每帧重建,成本分散到 Rendering Thread(收集 instance)、RHI Thread(调用 API)、GPU(执行构建)。这三个线程的流水线化是 UE5 的核心设计——它把 TLAS 构建的开销从"GPU 一帧干完"变成"三个线程各干一点",每一部分的峰值开销都控制在 1 ms 以内
- 静态 BLAS 池化,场景加载时构建,World Partition 系统负责流式加载/卸载。UE5 的
FRTGeometryManager维护静态 BLAS 的池,通过 mesh 的 RT key(RayTracingGeometryKey)索引。World Partition 的每个 tile 加载时,对应的静态 BLAS 被构建并加入池;tile 卸载时,BLAS 被标记 retire,下一帧 GC 回收 - 动态 BLAS 每帧重建,使用 GPU driven 的实例剔除流程。UE5 的
RayTracingInstanceCollection负责收集动态 mesh 的 instance 数据,每帧重建。动态 BLAS 不做压缩——每帧重建的压缩回读开销不划算 - SBT 每帧重建,与 TLAS 同步。UE5 的
FRayTracingShaderTableData管理 SBT 的 record 布局,每帧根据可见 instance 列表重新填充 - 延迟一帧策略,当前帧构建的 AS 下一帧使用。这是 UE5 的默认策略(
r.RayTracing.AsyncBuild默认为 1)
UE5 的 Stat D3D12RayTracing 命令可以显示 RHI 线程上 AS 构建的逐项开销,包括 BLAS 构建数量、TLAS 构建时间、SBT 更新时间等。具体的 stat 输出包括:
- BLAS Updates:每帧更新的 BLAS 数量
- BLAS Total Time:所有 BLAS 更新的总 RHI 时间
- TLAS Time:TLAS 构建的 RHI 时间
- SBT Time:SBT 更新的 RHI 时间
- Instance Count:TLAS 中的 instance 数量
UE5 的 World Partition + HLOD 对 AS 管理的影响值得一提。World Partition 把大世界分成 grid tile,每个 tile 独立加载/卸载。HLOD 系统为远处的 tile 生成低精度的 merged mesh,这些 mesh 的 BLAS 在 tile 加载时构建一次。当摄像机移动导致 tile 切换时,旧的 BLAS retire、新的 BLAS 构建——这是一个流式的过程,需要在加载/卸载时避免画面闪烁(详见下篇)。
8.2 NVIDIA RTX SDK Samples
NVIDIA 的 RTX SDK 示例代码提供了更底层的实现。它的特点:
- 使用 RTXMU SDK 自动处理 BLAS 压缩和子分配。RTXMU 的
AccelStructManager类管理所有 BLAS 的创建、压缩、子分配和 GC——开发者只需调用CreateBLAS和DestroyBLAS,内部自动处理压缩时机和内存布局 - 动态 BLAS 支持
updateOnly模式(原地 refit)。通过BuildTopLevelAS的updateOnly参数控制,refit 不重新构建 BVH 树,只更新 bounding box——开销约为 Build 的 1/3 到 1/2 - SBT 通过
ShaderBindingTable类管理,维护 record offset 映射。该类自动处理 record 对齐、shader handle 复制和 SBT buffer 分配 - 使用
CreateASBufferSizes预计算 scratch 和 result buffer 大小。这个函数封装了vkGetAccelerationStructureBuildSizesKHR,返回对齐后的 buffer 大小
RTX SDK 示例没有做双缓冲——它在同一帧内串行执行构建和追踪。这是因为它面向的是教学场景,代码简洁性优先于性能。生产引擎会在此基础上添加双缓冲和异步构建。NVIDIA 在《Ray Tracing Best Practices》中明确指出:教学代码用串行是为了可读性,生产代码应该用异步 compute 做构建——实测数据(RTX 4090,City Sample)显示异步构建可以将帧时间从 14.2 ms 降到 9.8 ms,节省 31%。
8.3 Vulkan-Samples
Khronos 的 Vulkan-Samples 中的 ray_tracing_reflection 示例展示了:
- TLAS 嵌入多个 BLAS 的用法——通过 VkAccelerationStructureInstanceKHR 的 accelerationStructureReference 字段引用 BLAS 的 device address
- 同一 BLAS 通过不同变换矩阵复用(实例化)——一个 BLAS 可以在 TLAS 中出现多次,每次用不同的 transform,实现"一棵树的模型 + 100 棵树的位置"
- AS 重建的时机选择(fast rebuild vs fast update 标志)——VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_BUILD_BIT_KHR 优先构建速度,BVH 质量略低;VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_TRACE_BIT_KHR 优先 trace 质量,BVH 质量最优但构建更慢
这两个 build flag 的选择是一个经典的工程权衡。PREFER_FAST_BUILD 让 BVH 构建更快(约快 30–40%),但 BVH 树的质量略低——traversal 步数多 5–10%。PREFER_FAST_TRACE 让 BVH 构建更慢(约慢 50%),但 BVH 质量最优——traversal 步数最少。动态 BLAS(每帧重建)通常用 PREFER_FAST_BUILD——构建频率高,trace 时的质量损失通过每帧重建弥补;静态 BLAS(构建一次)用 PREFER_FAST_TRACE——构建只做一次,trace 的质量收益分摊到每一帧。还有一个 PREFER_FAST_UPDATE flag,专门为 refit 优化——如果你知道这个 AS 主要用 refit 模式更新,构建时选这个 flag 可以让 refit 后的 BVH 质量退化更少。
补充一个三个方案的横向对比:
| 特性 | UE5 | RTX SDK Samples | Vulkan-Samples |
|---|---|---|---|
| 双缓冲 | 是(延迟一帧) | 否(串行) | 否(串行) |
| 异步 compute 构建 | 是 | 否 | 否 |
| BLAS 压缩 | 静态 BLAS 是 | 是(RTXMU) | 否 |
| SBT 管理 | 每帧重建 | 类封装 | 手动 |
| GPU driven instance | 支持 | 不支持 | 不支持 |
| World Partition streaming | 支持 | 不支持 | 不支持 |
| 跨队列同步 | Timeline/Fence | 无(单队列) | 无(单队列) |
| 跨平台 | DX12 + Vulkan | DX12 + Vulkan | Vulkan |
| 代码可读性 | 中(引擎封装) | 高(教学导向) | 高(规范导向) |
结论:UE5 的方案最完整但最复杂,适合大型项目;RTX SDK Samples 的方案适合快速原型开发;Vulkan-Samples 的方案适合学习 Vulkan RT API 的底层细节。如果项目从零开始,建议先参考 RTX SDK Samples 搭建原型,再参考 UE5 的策略做生产化改进。
补充一个实战中经常被问到的问题:三种方案在 AS 压缩策略上的差异。UE5 对静态 BLAS 做压缩(通过 FRTGeometryManager 内部的 CompactStaticRayTracingGeometries 调用),但不对动态 BLAS 压缩。RTX SDK Samples 用 RTXMU 自动处理所有 BLAS 的压缩——包括动态 BLAS,但 RTXMU 内部对动态 BLAS 的压缩是异步的(延迟几帧),不会阻塞当前帧。Vulkan-Samples 不做压缩——教学代码保持简洁。生产引擎通常采用 UE5 的策略:只压缩静态 BLAS,动态 BLAS 用 query pool 异步回读大小、延迟压缩(如果内存紧张的话)。一个 500 个静态 BLAS 的场景,压缩后节省约 150 MB 显存——这比动态 BLAS 的异步压缩收益大得多,而且没有运行时开销。
九、2025–2026 的演进
9.1 Mesh Shader 与 AS 的关系
Mesh Shader 引入了一种新的几何体提交方式——它绕过了传统的输入装配(IA)阶段,直接在 compute shader 中生成几何体。这影响了 AS 的生命周期:
- 传统管线:IA → VS → BLAS 构建 → TLAS 构建 → trace
- Mesh Shader 管线:Mesh Shader → BLAS 构建 → TLAS 构建 → trace
Mesh Shader 的优势是可以在 GPU 上做几何体剔除后再构建 BLAS——被剔除的几何体不进入 AS,减少了构建开销和 AS 内存。传统的 BLAS 构建需要从 CPU 端传入完整的顶点 buffer,即使某些三角形在当前视角下不可见(比如背面的三角形)。Mesh Shader 可以在 GPU 上做背面剔除、小三角形剔除后,只输出可见三角形——BLAS 构建的三角形数量减少 30–50%,构建时间等比例下降。
NVIDIA 的 Omniverse 和 UE5 的 Nanite 都在朝这个方向演进。UE5 的 Nanite 已经在光栅化管线中用 GPU driven 的 mesh 池化做了几何体剔除(cluster culling),但 RT 管线中 Nanite mesh 的 BLAS 目前还是在 CPU 端构建的——因为 Nanite mesh 的 LOD 切换在 GPU 上动态发生,CPU 端不知道当前帧的 LOD 选择。2025–2026 的趋势是让 BLAS 构建也变成 GPU driven——Mesh Shader 输出的几何体直接流入 BLAS 构建管线,不需要 CPU 介入。AS 生命周期的 Build 状态将完全在 GPU 上完成,不需要 instance 数据的 CPU→GPU 上传——整个依赖链变成 Compute Shader(剔除+几何体生成)→ BLAS Build → TLAS Build → trace,全在 GPU 上,同步简化为屏障(不需要 semaphore)。
9.2 Streaming AS
对于大世界场景,一次性加载所有 BLAS 不现实。一个 16 km × 16 km 的开放世界,可能有几十万个静态物体,BLAS 总量超过 2 GB——不可能全装进显存。Streaming AS 的思路是:
- 只为视锥体内的物体构建 BLAS——视锥外的物体不进入 TLAS
- 视锥体外的物体用低精度 LOD 的 BLAS——远处的建筑用一个 100 三角形的 proxy mesh 代替 10000 三角形的精细 mesh,BLAS 从 400 KB 降到 4 KB
- 随着摄像机移动,BLAS 流式加载/卸载——类似纹理的 mip streaming,但粒度是物体级别
UE5 的 World Partition + HLOD 系统已经在做这件事。UE5 的 World Partition 把世界分成 256m × 256m 的 grid,每个 grid tile 有自己的 BLAS 池。当摄像机移动时,新 tile 的 BLAS 被构建(或从磁盘加载预构建的 BLAS),旧 tile 的 BLAS 被回收。NVIDIA 的 Omniverse 也有类似的 streaming AS 机制,它用基于 USD 的 stage loading 管理 AS 的流式加载。
2025–2026 年的趋势是把 streaming 的粒度做得更细——从物体级别到 tile 级别,每个 tile 有自己的 BLAS 池。这需要解决几个工程问题:
1. BLAS 预构建和序列化:把 BLAS 构建结果保存到磁盘,运行时直接 mmap 加载,避免运行时构建开销。Vulkan 的 vkCopyAccelerationStructureToMemoryKHR 支持把 AS 序列化到 host 内存。但序列化格式是驱动相关的——NVIDIA 的序列化数据不能在 AMD GPU 上反序列化,预构建的 BLAS 需要为每种 GPU 厂商各保存一份
2. 流式加载的同步:新 BLAS 加载完成和 TLAS 重建之间需要同步,避免 TLAS 引用未加载完成的 BLAS。这通常用 timeline semaphore 或 fence 实现——加载线程 signal,RT 线程 wait。如果 BLAS 是从磁盘异步加载的(通过 IO 队列),还需要一个额外的 host→device 传输屏障。同步链变成:IO 线程读取磁盘 → staging buffer → vkCmdCopyBuffer(host→device 传输)→ barrier(TRANSFER_WRITE → AS_READ)→ TLAS build 引用新 BLAS。链条比帧内构建长得多,每一步都有可能成为帧时间的瓶颈——SSD 的随机读取延迟约 0.1 ms,HDD 约 5–10 ms,差 50–100 倍。如果 PCIe 带宽被其他传输占用,BLAS 加载可能跨多帧才能完成。
3. LOD 切换的 pop-in:高精度 BLAS 替换低精度 BLAS 时,trace 结果会有跳变——需要时间域的 blend 或 dithering。一个常见做法是同时在 TLAS 中保留新旧两个 LOD 的 instance,用 alpha blend 在两帧内过渡——但这需要两个 instance 引用同一物体的两种精度,增加了 TLAS 的 instance 数量
9.3 神经降噪与亚 1 spp 自适应采样
前面几篇讲了降噪和采样,这里从 AS 生命周期的角度补充一个新趋势:随着神经降噪能力的提升(如 DLSS 3.5 的 Ray Reconstruction),每像素发出的光线数可以降到 1 spp 以下——不是每个像素都发光线,而是只有部分像素发光线,其余像素靠降噪填充。NVIDIA 的 DLSS 3.5 Ray Reconstruction 用一个在超分辨率和降噪上联合训练的神经网络,替代传统的时域降噪+空间降噪管线。它接受 1 spp 甚至 0.5 spp 的 noisy input,输出接近 ground truth 的结果。
这对 AS 生命周期的影响是:trace 阶段的时间缩短了(光线少了),但构建阶段的时间不变(AS 还是得覆盖整个场景)。用数字说明——假设一个场景的 AS 构建开销是 3.6 ms(动态 BLAS + TLAS),trace 开销从 4.1 ms(2 spp)降到 1.8 ms(0.5 spp),帧时间从 7.7 ms(串行)降到 5.4 ms。但 AS 构建在帧时间中的占比从 47% 上升到 67%——构建变成了新的瓶颈。优化构建调度(双缓冲、异步构建、压缩)因此变得更加重要。
更激进的趋势是:神经降噪让 trace 的时间变得极短(< 2 ms),以至于"延迟一帧"的收益变小——构建时间可能比 trace 还长,双缓冲的"藏在 trace 后面"策略不再有效。这时需要新的调度策略,比如:分帧构建——把 AS 构建拆成多帧执行,每帧构建场景的一部分,trace 用不完整的 AS(通过概率性 ray tracing 补偿缺失部分)。这个方向还在研究阶段,2025–2026 可能有首批工业级实现。
这三个演进方向对 AS 生命周期管理的综合影响:Mesh Shader + GPU driven BLAS 构建让 Build 状态完全在 GPU 上完成,简化了同步链——从"CPU 上传 instance → GPU 构建"变成"GPU compute → GPU 构建",屏障替代 semaphore。Streaming AS 让 Retire 状态变成高频操作——BLAS 的加载/卸载频率从"场景切换时"变成"每帧都有",对 GC 效率和内存碎片管理提出了更高要求。神经降噪让 Trace 状态的时间占比下降,Build 状态的占比上升,构建调度的优化从"锦上添花"变成"必须做"。三股趋势合在一起,AS 生命周期管理从"一个 setup 阶段的工程问题"演变成"一个帧间核心调度问题"——不再是场景加载时一次性处理的,而是每一帧都要精心编排的核心管线环节。
参考资料
以下资源涵盖了本文涉及的所有 API 规范、SDK 文档和最佳实践指南,按主题分类排列。
- Vulkan 加速结构规范:docs.vulkan.org/spec/latest/chapters/accelstructures.html
- Vulkan 同步规范:docs.vulkan.org/spec/latest/chapters/synchronization.html
- DXR 功能规范:microsoft.github.io/DirectX-Specs/d3d/Raytracing.html
- D3D12 Enhanced Barriers:microsoft.github.io/DirectX-Specs/d3d/D3D12EnhancedBarriers.html
- NVIDIA RTX Best Practices:developer.nvidia.com/blog/rtx-best-practices/
- NVIDIA RTXMU(AS 压缩):developer.nvidia.com/blog/reducing-acceleration-structure-memory-with-nvidia-rtxmu/
- UE5 RT 性能指南:dev.epicgames.com/documentation/en-us/unreal-engine/ray-tracing-performance-guide/
- Vulkan-Samples ray_tracing_reflection:docs.vulkan.org/samples/latest/samples/extensions/ray_tracing_reflection/
- NVIDIA DXR 教程:developer.nvidia.com/rtx/raytracing/dxr/DX12-Raytracing-tutorial-Part-1
- Vulkan RT Tutorial:nvpro-samples.github.io/vk_raytracing_tutorial_KHR/tutorial/
- Timeline Semaphore 教程:docs.vulkan.org/tutorial/latest/Synchronization/Timeline_Semaphores/
- D3D12 资源屏障:learn.microsoft.com/.../resource-barriers
- Vulkan Ray Tracing 规范:docs.vulkan.org/spec/latest/chapters/raytracing.html
- NVIDIA RTX SDK 内存分配:developer.nvidia.com/blog/new-ray-tracing-sdk-improves-memory-allocation/
- NVIDIA Vulkan RT 博客:developer.nvidia.com/blog/vulkan-raytracing/
- NVIDIA Best Practices 更新:developer.nvidia.com/blog/best-practices-for-using-nvidia-rtx-ray-tracing-updated/
下篇预告
下一篇《RT 资源的流式加载:大世界的 AS 管理》会接着本篇的 streaming AS 话题展开——当世界大到 AS 装不进显存时,怎么按 tile 流式加载 BLAS、怎么管理 BLAS 的生命周期、怎么在加载/卸载时避免画面闪烁。这是本系列的第十一篇。
感谢大家读到这里。如果这篇对你有帮助,欢迎到个人站或知乎留言讨论——写这个系列的过程中,同步问题是我踩坑最多的地方,希望这篇整理能帮大家少走弯路。下一篇会继续展开 streaming AS 的工程细节,包括 BLAS 序列化格式的跨厂商兼容性问题和 LOD 过渡的时域 blend 方案。