核心问题:上一篇我们划出了"什么时候该从光栅切到光线"这条分界线,并把加速结构当成一个已知前提引用了两次——"TLAS 每帧重建,成本与 instance 数相关"、"加速结构的内存是可以查的"。这一篇开始还这笔账。加速结构的本质是一笔预付:你花一次构建的钱,买下之后每一次遍历的便宜。但"一次"这个词是假的——场景会动,几何会变,显存是有限的。所以这笔账要分三科记:构建(一次要花多少、标志怎么选、建树器怎么挑)、更新(什么能改、什么不能改、改完之后遍历会变成什么样)、压缩(能省多少、省下来的钱要不要还回去)。三科合起来,才是"这笔预付到底划不划算"的完整答案。
本篇不涉及 BVH 的硬件遍历实现与光追单元的内部控制逻辑(《GPU 架构剖析》已经写透,本篇只在"遍历是固定功能"这条规范结论上引用);不做建树算法的完整数学推导(只给"该选哪个建树器"的判定口径);下册的题内——动态场景的分区加速结构、三角形簇结构、按需增量更新——只在本篇末尾列名交棒,不给结论;也不写引擎编辑器的操作步骤。我们只把规范给的接口与可核对的数字,摆成一本能算的账。
本系列(实时光线追踪与混合渲染,共 12 篇)的前置阅读:第一篇《分界线》给出了三类效果判据与三条成本模型;本篇承接它的第二章(成本与什么成正比)与第四章(两条接口路线),其余前置专题为算法层《实时渲染管线探析》5 篇、约束层《移动端图形与功耗》7 篇、硬件实现层《GPU 架构剖析》12 篇。
本文由@溯光 出品,转载请注明出处。本文同步发布于公众号与个人博客:技术博客页。
点题:用一次构建换每次遍历
0.0 内容总览:本篇知识地图
先来看看一张总表,把九章各自回答的问题钉在这里。后面每一章都在展开其中的某一行。
| 章 | 回答的问题 | 核心结论 | 主要依据 |
|---|---|---|---|
| 一 | 两级结构分别装什么 | BLAS 装几何、TLAS 装实例;BVH 属"对象划分",不依赖场景边界 | Vulkan 规范 · Acceleration Structures 章、DXR 规范 |
| 二 | 一次构建的成本花在哪 | 三个尺寸必须分开查;五个标志是"把成本放哪一侧"的声明 | vkGetAccelerationStructureBuildSizesKHR、VkBuildAccelerationStructureFlagBitsKHR |
| 三 | 更新能改什么、代价是什么 | 身份不能变、只有位置能变;refit 快 10×,代价转移给遍历 | 规范 update 约束条款、SIGGRAPH 2019 课程实测 |
| 四 | 压缩省多少、要不要还回去 | 三档口径并列;压缩不牺牲遍历,但要留额外信息与一次拷贝 | VkCopyAccelerationStructureModeKHR、Intel HPG 2018 |
| 五 | 内存怎么算成一张预算表 | 结构本体 + 两种 scratch + 实例数组 64 B×N;三类查询分别回答三个问题 | 规范三类查询、实例结构布局 |
| 六 | 引擎替你做了哪些选择 | 蒙皮网格每帧重建;"更新开销与蒙皮三角形数成正比" | UE 光线追踪性能指南、Lumen 技术说明 |
| 七 | 哪些常见做法其实亏 | 逐条给依据,不给口号 | 本篇归纳 + 前六章证据 |
| 八 | 三科合起来是几句话 | 三句话 + 交棒 ep03 | 本篇归纳 |
读法建议:只想知道"我这套配置有没有选错"的朋友,直接读第二章的标志表与第三章的决策树;要排显存预算的,读第五章的预算表模板;关心动态场景的,第三章与第六章;第七章那份逐条检查清单,任何一条都够一次技术评审用。
0.1 这笔预付账的形状
好了,先回答第一个问题:加速结构到底是什么。规范给了两句最短的定义:它是"把几何按空间整理好、以便快速找出潜在相交"的最常见做法;而它分两级——顶层结构是实例数组(遍历的起点),底层结构是几何数组。
这个"用一次整理换每次查找"的形状,决定了它有别于普通资源的三个特点:
- 它是预付的:构建成本一次性付出,收益摊在之后的每一帧、每一条射线上。所以帧率越低(每帧射线越多),这笔预付越值;
- 它是有保质期的:几何一动,整理结果就过时。所以"场景是不是静态"直接决定这笔账的算法;
- 它是可测量的:规范明确给了三类查询(现在多大、压缩后多小、序列化后多大),所以"AS 占多少显存"不该靠猜。
这三条特点对应本篇的三科:预付对应"构建"(第二章),保质期对应"更新"(第三章),可测量对应"压缩与内存账"(第四、五章)。
0.2 先归类:空间划分与对象划分
在往下走之前,先把"加速结构"这个词放进一张更大的图里。先来看看这两族怎么分——分法只看一件事:它切的是空间,还是对象?
| 归类 | 切什么 | 代表 | 边界依赖 | 对本篇的意义 |
|---|---|---|---|---|
| 空间划分 | 把空间切成互不重叠的区域,每个区域记"这里有什么" | 八叉树、BSP 树、四叉树、k-d 树 | 依赖场景边界:必须先有一个包围整个场景的盒子 | 是 BVH 的对照物,理解它才能理解 BVH 的选择 |
| 对象划分 | 不切空间,把对象按包围体分组、逐级求并 | BVH、R 树 | 不依赖场景边界:对象在哪,盒子就在哪 | 本篇主角 BVH 属这一族 |
这个归类不是学术分类,它直接决定三件工程事实:
- 对象划分不需要知道场景有多大——所以动态场景里新增一个物体,不必重算全局边界;
- 对象划分的包围体会互相重叠——所以"重叠越少越好"成了建树质量的核心指标(第二章);
- 空间划分的格子边界会切到对象——一个三角形跨了两个格子该归谁,这类问题在对象划分里根本不存在。
0.3 口径声明与边界
本篇所有数字按四档标注,正文里每次出现都会说明它属于哪一档:
| 口径 | 含义 | 本篇中的例子 |
|---|---|---|
| 规范事实 | 规范原文或由规范结构体布局直接推出的值 | 实例结构 64 B;TLAS 最大实例数 2²⁴;update 的六条禁止项 |
| 论文设定 | 论文/课程在特定硬件与场景下的测量 | refit ≈ 10× 快于 full build;compaction 后约 33 B/三角形 |
| 厂商官方口径 | 厂商官方文档或分享给出的目标与收益 | 构建/更新目标 ≤ 2 ms;压缩"可能高达 50% 左右" |
| 引擎官方口径 | 引擎文档给出的现实约束 | 蒙皮网格每帧重建;更新开销与蒙皮三角形数成正比 |
边界:不写 BVH 的硬件遍历实现;不写建树算法的数学推导(只给选择口径);下册题内(分区加速结构、三角形簇结构)只列名交棒;不写引擎编辑器操作。另有两条纪律:"AS 占多少 MB"必须来自查询或实测,规范只给了可测量的接口、没给任何比例;论文里的绝对毫秒数不换算成项目结论,只取其中的相对关系与量级。
0.4 这一篇不管什么
一本账要能算,边界必须划死。加速结构这个题目至少牵涉四个层面的问题,本篇只碰其中一层:
| 层面 | 典型问题 | 归属 |
|---|---|---|
| 结构本身 | 怎么建、能改什么、压缩省多少、占多少内存 | 本篇(构建、更新、压缩三科) |
| 遍历过程 | 射线怎么走树、硬件怎么加速、相交测试怎么算 | 《GPU 架构剖析》(已完成)与 ep01 第四章 |
| 接进管线 | 构建与主渲染怎么同步、能不能异步构建、显存怎么别名复用 | 后续篇目(资源生命周期与同步) |
| 动态场景的高级做法 | 分区结构、簇结构、增量更新 | 下册 ep03 |
这张表的作用是让"我不知道"变成"我知道该去哪一篇找"。它同时意味着:本篇不会出现"构建放在哪个队列上""用几个栅栏等它完成"这类内容——那些问题的答案取决于你的帧结构,而不取决于结构本身的账。
出处:Vulkan 规范 · Acceleration Structures 章/VK_KHR_acceleration_structure refpage/DirectX Raytracing (DXR) Functional Spec。
一、两级结构:BLAS 装几何,TLAS 装实例
1.1 规范里的定义只有两句
好了,进入第一章。规范对两级结构的描述非常短,但每一句都有工程后果:
顶层结构是"针对一个实例数组的不透明加速结构,引用它的描述符或设备地址就是遍历的起点";它"持有对其实例所引用的任意底层结构的引用",而这些底层结构在顶层结构被访问时必须有效。
底层结构是"针对一个几何数组的不透明加速结构"。
两句里有三个词需要单独拿出来:
- "不透明":规范不承诺内部布局,所以你不能把它当普通缓冲去读里面的节点。想知道它多大,只能查(第五章);
- "数组":两级都是数组——BLAS 是几何的数组,TLAS 是实例的数组。"实例"这个粒度,就是两层结构之间的接口;
- "必须有效":规范写的是访问时有效,不是创建时有效。这意味着 BLAS 与 TLAS 的生命周期要自己对齐——TLAS 里存的是一份 8 字节的引用,而它指向的对象什么时候能被回收,规范不管。
1.2 实例是 64 字节,规模上限是三个常数
既然 TLAS 是实例数组,那么"TLAS 占多大"这个问题就有了一半答案:把实例结构的字节数乘以实例数。这个字节数可以直接从规范的布局算出来:
其中:
- $T$ = 变换矩阵,3×4 个 float(48 B);
- $C$ = instanceCustomIndex 24 位 + mask 8 位(4 B);
- $S$ = 着色器绑定表记录偏移 24 位 + flags 8 位(4 B);
- $A$ = 指向底层结构的引用(8 B)。
四项相加,每个实例 64 字节,与具体实现无关。这 64 B 是额外的——它不包含 BLAS 本身;实例数组是 TLAS 构建的输入,因此是纯附加开销。
规模上限也是三个可以直接引用的常数:BLAS 内最多 2²⁴ 个几何体、跨几何体最多 2²⁹ 个图元、TLAS 最多 2²⁴ 个实例。另有两条对齐要求:结构起始地址 64 B 对齐、scratch 地址 256 B 对齐。
这三条数字加上 64 B,就是第一章能给出的全部"不算也知道"的账。 剩下的必须查。顺手做个纯算术的估量(由规范事实直接推得):一个放了 10 万个实例的 TLAS,光是实例数组就是 100000 × 64 B = 6.4 MB——而这只是它的输入,还不含 TLAS 本身与为它准备的暂存。实例多起来之后,"64 字节"这个看着很小的数字会被放大成"几十兆",这也是后面第二章要谈"实例粒度"的原因。
1.3 对象划分的原型:包围体层次
先来看看 BVH 的形状。它可以用一句话说清:父节点的盒子包住所有子节点的盒子,叶子里装几何。 它不是切空间,而是不断把"一堆对象的包围体"向上合并。
入门时最该记住的一点:包住 ≠ 切开。父节点的盒子常常比子节点盒子之和"松"一些,而这份"松"就是遍历要额外付出的代价——射线必须走进这些空余区域,才能确认那里没有东西。建树算法的全部意义,就是把这份"松"压到最小(第二章)。
1.4 对照物:空间划分那一族
要把 BVH 讲清楚,最省事的办法是把它和"切空间"那一族并排看。先来看看最简的一维情形:
空间划分有一个共同的形式特征:同一份数据,总能画成"分区图"和"树"两种样子,这两种画法互为翻译。
把它推到三维,就是八叉树:每一级把一个立方体切成八块,层级越深、格子越细。
空间划分的代价清单(这是它与 BVH 对照的关键):格子必须互不重叠,于是跨边界的对象只能被切开,或者被重复存放;切开的做法要处理几何分裂,重复存放的做法让同一份几何在多个格子里出现。BVH 用"允许重叠"换掉了这两个麻烦——代价是遍历时要在重叠区域里多走几步。
1.5 回到对象划分:为什么"包对象"更耐用
另一支对象划分结构能帮我们把这件事看得更清楚——它也是用包围体组织对象,而不是切空间:
把 1.3 到 1.5 的几张图连起来读,能读出本章真正的结论:BVH 这个名字里的"层次"比"包围体"更重要。包围体只是它用来分组的手段;真正决定性能的是这棵层次树建得好不好——盒子紧不紧、重叠多不多、树深是否均衡。这也解释了为什么第二章要花一整节讲"建树器选择":层次结构是一族的形状,树的质量才是这一族的性能。
1.6 实例怎么分组,TLAS 就是什么质量
好了,几何那一层说完了,来看 TLAS。BLAS 建得好,只是把"几何这一层"整理好了。真正决定遍历起点的,是 TLAS——而 TLAS 的质量几乎完全由"实例怎么分组"决定。这件事有一个很容易踩的坑,业界材料把它说得很直接(论文与课程口径):
按材质分组会产生"大而重叠、内部大量空白"的实例包围盒。 一栋楼里所有用同一种材质的部件被塞进同一个实例,这个实例的包围盒就得把整栋楼包住——而楼板、外墙、楼梯之间全是没有几何的空空间。后果有两条:每条射线都要对这个大盒子做一次坐标变换(实例命中次数多),而且 TLAS 的层次质量会很差(盒子大、重叠多、空空间多)。
正确的分组依据是空间局部性:把空间上挨着的对象放进同一个实例或相邻的实例,让每个实例的包围盒尽量"贴着"它包住的东西。AMD 的官方技术材料从遍历侧给出了同一结论——遍历成本高度依赖 BVH 质量,要点是最小化实例重叠、最小化空空间。
这里有一笔容易算错的账:实例切得越细,TLAS 里的条目越多(而每条 64 字节,第一章第 1.2 节),TLAS 的构建与更新成本也跟着涨;但切得越粗,每个实例的盒子越大、越松,遍历就越贵。所以"实例粒度"不是"越细越好"或"越粗越好",而是在构建/更新科与遍历科之间找平衡点——这又是本篇反复出现的那笔账,只不过这次换成了实例。
1.7 两级结构不必同频更新
两级结构最容易被忽略的一个好处是:它的两层可以有不同的更新频率。
几何本身静止、只是整体在动(车、门、旋转的机关)——只改 TLAS 里的实例变换矩阵,BLAS 完全复用; 几何自身在形变、位置不变(蒙皮角色、飘动的旗)——只更新那个 BLAS,TLAS 不用动; 整个场景的静态部分——两层都不动。
如果只有一级结构,这三件事就得用同一种方式处理。"实例"这个粒度把"整体运动"与"自身形变"解耦了,而这两种变化在游戏里的成本差着一个数量级:前者只是往一个 64 字节的结构里写一个矩阵,后者要重跑一次建树。判断一份几何属于哪一类,只需问一句:它是在动,还是在变形?
出处:VkAccelerationStructureBuildGeometryInfoKHR/VkAccelerationStructureInstanceKHR/Vulkan 规范 · Acceleration Structures 章/SIGGRAPH 2019 课程 · Acceleration Data Structure Hardware (and Software)/AMD · RDNA3 Beyond the current gen。
二、构建:三个尺寸与五个标志
2.1 三个尺寸必须分开查
好了,进入第二章。构建加速结构的第一件事不是写代码,是问清楚要多少内存。规范提供的入口一次给出三个尺寸:
其中:
- accelerationStructureSize = 结构本体要占多少字节;
- buildScratchSize = 构建过程需要的暂存空间;
- updateScratchSize = 更新过程需要的暂存空间。
第三个值是这一节的全部重点。 规范写明:build 用的构建命令要用 buildScratchSize 的暂存,而 update 模式的构建命令要用 updateScratchSize 的暂存——两者是不同的分配。实现里最常见的错误,就是只查了前两个(或者把 scratch 当成"构建时用一下就释放"的临时量),等到第一次更新时才发现少了半块缓冲。
还有一个细节值得单独记:查尺寸时,srcAccelerationStructure、dstAccelerationStructure 与 mode 都会被忽略——也就是说,同一个几何描述既可以用来查"构建要多大",也可以用来查"更新要多大",用其中一个尺寸值就能覆盖对应模式的任意一次构建。另有三个构建类型可选(设备端、主机端、两者皆可),主机端构建会把部分工作放到 CPU 侧,是"预构建 + 序列化复用"(第四章)的基础。
2.2 五个标志,各自在换什么
构建时能声明的标志有五个,规范对每一个的语义都写了原文。它们不是"性能档位",而是"你打算把这笔成本放在哪一侧"的声明:
| 标志 | 换来什么 | 规范写明的代价 |
|---|---|---|
ALLOW_UPDATE |
之后可以用 update 模式改它 | "可能比普通构建更慢、更费内存,因此只在这些能力真的需要时才用" |
ALLOW_COMPACTION |
之后可以作为压缩拷贝的源 | 同上;DXR 侧补一句"可能增大压缩前的体积" |
PREFER_FAST_TRACE |
更好的遍历性能 | 与 PREFER_FAST_BUILD 互斥(规范 VUID:设置前者时必须不设置后者) |
PREFER_FAST_BUILD |
更短的构建时间 | 换来的是"牺牲遍历性能" |
LOW_MEMORY |
更小的 scratch 与最终结构 | "可能以构建时间或遍历性能为代价" |
DXR 侧对后两者的量级给了更具体的说法(厂商官方口径):PREFER_FAST_TRACE 通常会让构建时间变成默认设置的 2–3 倍,用来换更好的遍历性能;PREFER_FAST_BUILD 通常把构建时间压到默认的 1/2 到 1/3,代价是遍历性能。
规范还留了一条组合规则,工程上很有用:ALLOW_UPDATE 与 ALLOW_COMPACTION 可以同时用——此时"压缩拷贝的结果可以作为 update 模式的源"来执行更新。换句话说,你可以先建一棵带更新的结构、压掉它、再在压缩结果上继续更新。
PREFER_FAST_TRACE 与 PREFER_FAST_BUILD 互斥(本系列依据@ Vulkan 规范与 DXR 规范原文归纳 · 本站自绘)2.3 建树器怎么挑:两个方向,不是两个档位
先来看看建树这件事。它有两类目标完全相反的算法,业界口径把它们说得很直白(论文与官方教程口径):
- 静态场景:用表面积启发式(Surface Area Heuristic,SAH)这类高质量建树——建得慢,但树紧、遍历快。适合"建一次、跑很多帧"的几何;
- 动态场景:用莫顿码(Morton)排序这类快速建树——建得快,但树松、遍历慢。适合"每帧都要重建"的几何。
这不是"好算法"与"差算法"的差别,而是同一笔钱在两个科目之间的转移:SAH 把成本记在构建科,Morton 把成本记在遍历科。选择依据只有一个问题——这份几何会被遍历多少次?
这个问题有现成的官方答案。Vulkan-Samples 的 ray_tracing_extended 样例自己在 README 里写明了它的分工:一次性构建的大型静态几何(Sponza 场景)用 PREFER_FAST_TRACE,而每帧都需要做更新的折射模型用 PREFER_FAST_BUILD。 一个样例里同时出现两种选择,理由正是"遍历次数不同"——这是本篇能找到的最干净的实证。
2.4 层次的质量是看得见的
"树紧不紧"这件事听起来抽象,但把它画出来就一目了然。用真实空间数据建出的包围盒层次,盒子的包裹松紧、互相重叠的多少,全都能直接看出来:
同一份数据用不同的分割策略去组织,得到的层次结构完全不同——这也解释了为什么"建树器"本身是一个需要选的东西,而不是一个固定步骤:
2.5 在哪构建:主机端、设备端与间接构建
好了,前面四节都在谈"构建什么"与"怎么构建",还剩一个维度:在哪里构建。这个选择由构建类型决定,规范给了三个取值——设备端、主机端、两者皆可。
这个选项的实用价值在"预构建"场景里:主机端构建允许把建树工作放到 CPU 侧完成,于是"构建"这件事不再占用 GPU 帧时间——它可以在加载阶段做掉,甚至可以做成离线步骤,配合第四章的序列化复用,运行时只剩一次拷贝。
规范相对早期版本还新增了两项与"在哪构建"配套的能力(规范事实):
- 间接/批量构建:把多次构建的几何描述与参数放进缓冲,由一次调用按 GPU 侧的数据批量发起——和图形侧的间接绘制是同一个思路,让"要构建哪些结构"由 GPU 自己决定,而不是 CPU 逐次下命令;
- 主机端构建命令:不再必须通过图形/计算队列发起构建。
判据:如果你的场景是"很多个小结构、每个结构各自构建一次",间接/批量构建省的是命令开销;如果是"一个大结构、只构建一次",主机端 + 序列化省的是GPU 帧时间。两者解决的都不是"建树算法快不快",而是"构建这个动作本身要占谁的时间"。
把这一章收成一份构建前的检查清单,可以直接贴进项目文档:
【加速结构构建前检查清单】
1. 尺寸:三类尺寸是否都查了?(结构本体 / build scratch / update scratch)
2. 对齐:结构起始地址 64 B、scratch 地址 256 B 是否满足?
3. 标志:逐个问"它买的能力我真的要用吗"
3.1 ALLOW_UPDATE -> 不用就删掉,它会加构建时间与内存
3.2 ALLOW_COMPACTION -> 只在"结构长期存活且不常重建"时开(第四章)
3.3 FAST_TRACE / FAST_BUILD -> 二选一,判据是"这份几何会被遍历多少次"
3.4 LOW_MEMORY -> 只在显存压力大时开,它同时伤构建与遍历
4. 建树:静态几何用高质量建树,每帧重建的几何用快速建树
5. 实例:实例切分粒度与空间局部性是否会拉低 TLAS 质量?(第二章结尾与第三章)
第 3.1 条是最容易省下钱的一条:ALLOW_UPDATE 的代价是无条件付出的——只要声明了,构建就更慢更费内存;而"以后也许会想更新"是一个很容易高估的假设。先问"这份几何这一帧会不会动",再决定要不要买这个能力。
出处:vkGetAccelerationStructureBuildSizesKHR/VkBuildAccelerationStructureFlagBitsKHR/VkAccelerationStructureBuildGeometryInfoKHR/VkAccelerationStructureBuildSizesInfoKHR/Vulkan-Samples · ray_tracing_extended README/Embree · Building Ray Tracing Applications(SIGGRAPH 2014)。
三、更新:能改什么,不能改什么
3.1 规范的六条禁止项
好了,进入第三章。更新(update,业界也常叫 refit)看起来像是"重建的廉价版",但规范给它的约束是非常硬的。原文的表述是:更新时应用必须提供完整的结构描述,但除了实例定义、变换矩阵、顶点或 AABB 位置之外,不许改任何东西;其余所有方面必须与原始构建完全一致。
规范逐条列出的禁止项有六条:
- 把活跃/非活跃状态互换(原文另有一句更狠的话:"任何活跃与非活跃之间的转换都要求完整重建");
- 改三角形索引或顶点格式;
- 改几何变换指针的 null / 非 null 状态;
- 改几何或实例的数量;
- 改任何 geometry flags;
- 改任何几何的顶点数或图元数。
判据收成一句话:几何的身份不能变,只有位置能变。 数量、拓扑、标志、指针形态全都锁死。有一条细节值得注意——退化的图元与实例可以在更新中在"退化/非退化"之间来回,前提是原始构建带上了 ALLOW_UPDATE;否则退化图元可能在构建时就被丢弃。
3.2 refit 的收益与它的反向代价
更新为什么值得做?因为快。论文口径给出的量级是:refit 大约比完整构建快 10 倍;同一份材料给的吞吐经验值是 refit 约 1000 M 三角形/秒、build 约 100 M 三角形/秒——注意这两个数差了整整一个数量级,这正是"更新"存在的理由。
但同一份材料也给出了一个反向恶化的实测,这是本篇最值得记住的一个数字:
在 2000 个粒子的场景里,每帧完整重建时
TraceRays约 0.5 ms;改成每帧 refit 之后,遍历时间退化到 8 ms。
也就是说,为了省构建时间而改用 refit,结果遍历慢了 16 倍。原因不神秘:粒子这种"每帧位置大幅变化"的几何,refit 出来的盒子会严重变松、重叠急剧增加,遍历要在这些空余区域里多走很多步。同源材料还补了一条边界:refit 只在"从退化几何(全零坐标)开始"这种极端情形之外才可用,且它在批量小 BLAS 上要到约 5000 三角形以上才保持效率(构建约 1000 三角形即可)。
DXR 规范把这件事写得更概括:update 比完整构建快,但可能损害光线追踪性能,尤其是底层对象位置相对原始构建变化很大时。
这一节给出的判据是:refit 省的是构建科的钱,但它会把成本记到遍历科,而且记账的倍率取决于位移幅度——小幅位移(角色细微动作、风中的树)几乎免费,大幅位移(粒子、破碎)会亏到十几倍。
3.3 一张更新决策树
说白了,把规范的约束与实测的数据合起来,就能写出一张可以直接用的判定顺序:
第一问:几何的身份变了吗? 数量、拓扑、索引格式、几何标志里任何一项要变——rebuild,没有商量余地(规范的六条禁止项)。身份不变,进入第二问。
第二问:顶点位移幅度有多大? 这是决定 refit 划不划算的唯一变量,官方分享给了一个很好记的经验判据:"树在风中摆动"适合 update,而"网格炸开"不适合。前者位移小、盒子的松紧变化有限;后者位移大,盒子会迅速变松。
第三问:需要增删图元吗? 需要——rebuild。refit 不能改图元数量。
三条都通过(身份不变、位移小、不增删)才走 refit。刚体动画还要更省一步:变换矩阵变了、几何本身没动——这种情况连 refit 都不用做,只改 TLAS 里的实例变换,BLAS 原样复用。
至于"位移小不小"这个判断,蒙皮角色是最典型的难题:按 T-pose 建好的树,角色走路时脚部的包围盒会明显失真。工作室给出的解法很实用——预先为若干关键 pose 建好加速结构,运行时挑最接近的 pose 做 refit。这相当于把"位移幅度"从"逐帧连续变化"压成"每帧都离某个预建 pose 不远",refit 的前提就成立了。
3.4 盒子为什么会"变松"
前面反复说"盒子变松",这件事在三维层次结构上看得最清楚:
这解释了 3.2 那个 16 倍数字的机理:refit 只更新坐标,不重新分组。对象跑到别处去了,但它留在原来的树位置上,父节点为了继续包住它,只能把盒子撑大。分组一旦过时,盒子就再也收不回来了——想收回来,就得重新分组,也就是 rebuild。
3.5 refit 的节奏:不要一路 refit 到底
好了,3.4 节说清了"盒子只会变松",那自然就有下一个问题:一直 refit 会怎样?
先看规范允许什么。DXR 侧对更新给了一句明确的话:加速结构的更新可以无限次连续进行(PERFORM_UPDATE 的原话是"可以无限次连续执行"),而且"输出结构的内存需求与输入结构相同"——也就是说,从 API 的角度看,你完全可以永远 refit 下去、永远不重建,且内存不变。
但 3.4 节的机理说明这条路走不通:每一次 refit 都在把盒子的"松"累积下来,因为 refit 只挪坐标、不重新分组。连续 refit N 次之后,盒子的松紧程度取决于"这 N 帧里累计走了多远",而不是"当前这一帧离原始位置多远"。累积位移才是决定遍历退化的量。
由此得到一条很实用的节奏建议:把"每帧 refit"改成"每帧 refit + 定期 rebuild"的混合节奏。 例如每 N 帧做一次完整重建,其余帧做 refit——用一次较贵的构建,把累积的"松"清零。这里的 N 没有通用取值,它取决于几何位移的累积速度和你对遍历成本恶化的容忍度;工程上的做法是设一个可观测指标(比如遍历耗时或盒子总体积),越过阈值就触发重建,而不是按固定帧数。"事件驱动"比"固定节拍"更省,因为它只在必要的时候付那笔钱。
这条建议的适用边界也要说清楚:对位移很小、幅度有界的几何(摆动幅度固定的树、来回巡逻的机关),累积位移不会无限增长,这类几何可以长期只 refit 而不重建;对位移无界或突变的几何(粒子、破碎、角色冲刺),累积速度很快,混合节奏也救不了——必须走 3.3 节的 rebuild 分支。
3.6 更新允许改的三类东西
禁止项的另一面是许可项。规范把"可以改什么"也列清楚了,恰好三类:实例定义、变换矩阵、顶点或 AABB 位置。这三类不是随手列的,它们对应三种不同的几何变化方式,各自的实现代价差得很远:
| 允许改的 | 对应的现实变化 | 动的是哪一级 | 代价量级 |
|---|---|---|---|
| 实例定义 | 增删/替换实例、改它的掩码与自定义索引 | 只动 TLAS | 最小:往 64 字节的结构里写字段 |
| 变换矩阵 | 刚体运动:车、门、旋转机关 | 只动 TLAS | 小:写一个 3×4 矩阵 |
| 顶点或 AABB 位置 | 形变:蒙皮、布料、飘动、粒子 | 动该 BLAS | 中到大:走 refit,且随位移幅度恶化 |
这张表与第 1.7 节的三种更新频率是同一件事的两种说法:前者从"改什么"切入,后者从"哪一层动"切入,合起来就是一份归类的完整判据。有一点要特别注意:"实例定义"也在许可之列,但实例的"数量"不在(禁止项第 4 条把数量锁死了)。所以更新可以改一个实例是什么,不能改一共有多少个实例——后者只能重建。
出处:Vulkan 规范 · Acceleration Structures 章/VkBuildAccelerationStructureModeKHR/DirectX Raytracing (DXR) Functional Spec/SIGGRAPH 2019 课程 · Acceleration Data Structure Hardware (and Software)/NVIDIA · RTX Best Practices。
四、压缩与复用
4.1 压缩的语义与它的前置条件
好了,进入第四章。压缩(compaction)不是"就地改小",而是拷一份更紧凑的副本:把一个已构建的结构作为源,用压缩模式拷到另一个目标结构里,目标结构更小,功能等价。它的前置条件写在规范里,而且是一条硬校验:
- 源结构必须在构建时带上
ALLOW_COMPACTION——查压缩后尺寸时这条是强制的(规范里对应的 VUID 明确写了这一点); - 目标结构的尺寸至少要达到查询到的压缩后尺寸;
- 压缩不改变功能:拷完之后遍历行为一致,只是占的内存小了。
DXR 规范补了两条容易误解的说明:压缩保证"压缩后不超过未压缩",但不保证"图元更少就一定压得更小"——那条单调性只在未压缩态成立;另外,ALLOW_COMPACTION 本身可能让压缩前的体积变大,所以它的收益要按"压缩后 vs 不开压缩的普通构建"来比,而不是按"压缩后 vs 压缩前"来比。
还有一条与内容相关的限制值得记住:聚类几何(clustered geometry)不支持压缩——规范里有专门一节解释为什么。这类几何的构建方式本身就走的是另一条路径,压缩的前提不在。
4.2 三档收益数字,三个口径
说白了,"压缩能省多少"是本篇里口径差异最大的一个问题。本轮取到了三档来自不同来源的数字,它们不能互换,也不能取平均:
| 数字 | 口径 | 说的是什么 |
|---|---|---|
| "内存消耗的减少可能高达 50% 左右" | 厂商官方口径 | 实际几何相关的经验区间,且适合"只构建一次"的 BLAS |
| compaction 后存储 约 33 B/三角形 | 论文口径(实测经验值) | 绝对量级:一个三角形在压缩后的结构里大约占多少字节 |
| 节点尺寸 256 B → 136 B → 72 B(>3×) | 论文设定(Intel HPG 2018) | 同一棵树在三种节点编码下的单节点开销 |
第三行值得展开,因为它是"压缩到底压什么"的最直接证据。Intel 那篇论文给的是同一棵八叉 BVH 在三种布局下的节点尺寸:未压缩的 BVH8 多节点 256 B(8 个 float 盒子 + 8 个 64 位指针);六面体量化后的 QBVH8 136 B(约 1.9 倍压缩);而只压叶节点的 CLBVH 多叶节点 72 B(超过 3 倍)。它同时给了一个关键的观察:BVH8 的节点里叶节点占比超过 80%,而遍历开销主要发生在内部节点上——所以"只压叶节点"能拿到大部分内存收益,而几乎不损失遍历性能(代价是解压开销,CPU 上约 24 条指令/次节点测试)。
判据:报压缩收益时报的是哪一档,比报出的数字本身更重要。
顺带把压缩与另一个容易混的选项对比一下,因为两者"省内存"的方式完全不同:压缩是靠去掉结构内部的冗余,官方口径明确说它"不会牺牲光追性能";而 LOW_MEMORY 是靠降低结构质量来省内存,规范的原文写着它"可能以构建时间或遍历性能为代价"。所以"我要省内存"这句话对应的是两个不同的动作——先想清楚省内存的代价能不能不付,能就用压缩,不能才动 LOW_MEMORY。
4.3 四种拷贝模式:压缩只是其中一种
压缩属于一组更广的操作。规范给了四种拷贝模式,它们回答四个不同的问题:
| 模式 | 做什么 | 关键前置条件 |
|---|---|---|
CLONE |
复制一份等价结构 | 目标结构参数与源一致,"尺寸"除外(至少不低于查询到的当前尺寸) |
COMPACT |
复制一份更紧凑的等价结构 | 源必须带 ALLOW_COMPACTION |
SERIALIZE |
序列化成半不透明的格式 | 目的是"可在兼容实现上重新载入" |
DESERIALIZE |
把序列化数据还原成结构 | 还原后按原结构的类型处理 |
CLONE 与 COMPACT 的差别只有一处:后者保证不更大,前者只保证等价。SERIALIZE / DESERIALIZE 则完全是另一件事——它们不解内存问题,解的是"能不能不做第二次构建"。
读这张图要抓住一个分界:左侧两种(CLONE / COMPACT)改变的是"这份结构在显存里的形态",右侧两种(SERIALIZE / DESERIALIZE)改变的是"这份结构能不能不重新构建"。所以它们虽然同在"复制"这个类别下,解决的问题却分属两科——前者属于压缩科,后者属于构建科。
4.4 序列化:省掉的是第二次构建
好了,序列化这条路径的价值需要单独说:它把"构建"这件事变成了可以离线做一次、运行时直接载入的工作。对于内容固定的场景(关卡里那些从不改动的静态几何),这意味着加载时间与构建时间都可以从运行时里拿掉。
规范对它的限制也很清楚:反序列化只能在同一设备与同一驱动版本上进行,否则结果是未定义的。这句话的实际含义是——序列化产物不能当资源资产跨机器分发,它只适合"同一台机器/同一套驱动下的二次启动"。DXR 侧还额外提供了一个"驱动匹配标识符"(CheckDriverMatchingIdentifier)专门用来校验这件事,等于把这层限制摆到了 API 表面上。
还有一个细节能让预算算得更细:序列化的尺寸查询会同时给出字节数与底层结构指针的数量——而且只有顶层结构才有指针列表(底层结构的序列化指针数为 0)。所以"序列化后多大"这件事,同样是查出来的,不是估出来的。
4.5 压缩的时机:该压几次
压缩不是"建完就压"这么简单,DXR 侧给了一条很实用的建议(厂商官方口径):值得考虑压两次——初次构建之后压一次(把构建过程中临时膨胀的部分收掉),等结构稳定下来之后再压一次。理由是构建刚完成时,实现内部可能还留着一些与构建过程相关的开销;等结构定型之后再做第二次,能拿到更紧的结果。
这条建议背后是一笔明账:压缩的收益要按"省下的常驻内存 × 结构存活时长"来衡量,而成本是"峰值翻倍 + 一次拷贝"。于是它的适用条件就清楚了:
| 场景 | 压不压 | 理由 |
|---|---|---|
| 静态关卡几何、整个关卡期间常驻 | 压,而且压两次 | 常驻内存的收益乘上很长的存活时间,一次峰值翻倍完全值得 |
| 每帧重建的动态几何 | 不压 | 结构只活一帧,省下的内存还没摊开就已释放,只剩拷贝开销 |
| 长生命周期、但每隔若干帧要重建一次 | 按节奏压 | 只在"最近这次构建之后会存活较久"的时候压 |
| 聚类几何 | 压不了 | 规范不支持(第 4.1 节) |
这张表的读法是"存活时长换峰值":压缩是一笔以"瞬时内存翻倍"为代价、换"长期内存下降"的交易,它划不划算只取决于那个"长期"有多长。
出处:VkCopyAccelerationStructureModeKHR/vkCmdWriteAccelerationStructuresPropertiesKHR/DirectX Raytracing (DXR) Functional Spec/Benthin/Wald/Woop/Áfra(Intel)· Compressed-Leaf BVHs(HPG 2018)/NVIDIA 中文博客 · RTX 光线跟踪最佳实践。
五、内存账:把三科合成一张预算表
5.1 三类查询,回答三个不同的问题
好了,进入第五章。第一章说过"不透明"这个词的后果:你读不了结构内部,只能查。规范给的三类查询恰好对应三个不同的问题:
| 查询类型 | 回答的问题 | 什么时候用 |
|---|---|---|
ACCELERATION_STRUCTURE_SIZE |
现在这个结构有多大(当前尺寸) | 分配、统计、"到底占了多少显存" |
ACCELERATION_STRUCTURE_COMPACTED_SIZE |
压缩后能降到多少 | 决定要不要走压缩流程(前置:构建时带 ALLOW_COMPACTION) |
ACCELERATION_STRUCTURE_SERIALIZATION_SIZE |
序列化后多大、含多少 BLAS 指针 | 决定要不要做序列化复用 |
这三条查询把 ep01 留下的那句纪律落到了实处——"AS 占多少 MB"不该是猜出来的。规范给了接口,就没有理由在文章里编一个百分比。
这张图的读法是从左往右:左边是常驻的部分(结构本体、实例数组),中间是一次性的部分(两个 scratch),右边是可选的部分(压缩后的常驻与序列化产物)。预算算错的常见原因,就是把中间那一列当成了右边那一列——把 scratch 当成"用完就没"的临时量,于是忘了它在更新路径上其实是常驻的。
5.2 一张可以套用的预算表
把前三章的结论合起来,加速结构的内存预算可以写成一份固定结构的清单。它不能替你算出最终数字,但能保证没有一项被漏掉:
【加速结构内存预算表(逐项查,不要估)】
1. 结构本体
1.1 BLAS × N -> vkGetAccelerationStructureBuildSizesKHR 查 accelerationStructureSize
1.2 TLAS -> 同上(注意 TLAS 的 geometryCount 必须为 1)
2. 暂存(两项,不是一项)
2.1 build_scratch = Σ 每次构建命令的 buildScratchSize(可复用同一块,取最大值)
2.2 update_scratch = Σ 每次更新命令的 updateScratchSize(与上者不同!)
3. 实例数组(TLAS 构建的输入,纯附加开销)
3.1 = 实例数 × 64 B
4. 可选:压缩后的常驻
4.1 压缩后尺寸 = 查询 COMPACTED_SIZE(前置:构建时 ALLOW_COMPACTION)
4.2 压缩期间的峰值 = 原结构 + 压缩副本(拷贝完成前两者并存)
5. 可选:序列化复用
5.1 序列化尺寸 = 查询 SERIALIZATION_SIZE(只有 TLAS 有 BLAS 指针列表)
6. 对齐税:结构 64 B 对齐、scratch 256 B 对齐,逐项向上取整后再求和
第 4.2 条是最容易漏的一项:压缩不是零拷贝的原地操作,峰值内存 = 原结构 + 压缩副本。在显存吃紧的项目里,"压缩省了 40%"和"压缩那一刻要有 140% 的空间"这两句话必须一起说。
其中: - 第 2 项的两个 scratch 为什么要分开:因为规范对构建与更新给的是两个不同的尺寸字段,而更新通常发生在每帧——update_scratch 往往是常驻的,build_scratch 却可以是临时的,这个区别直接决定显存预算; - 第 3 项为什么算"附加":实例数组是 TLAS 构建的输入,不是结构内部的东西——它在描述符集里,与 TLAS 本身是两份内存; - 第 6 项为什么单独列:对齐是乘在每一项上的税,小结构多的时候,它可能比结构本体还显眼。
5.3 节点与叶子的比例,决定内存花在哪里
先来看看最后一条事实:为什么压缩有这么大空间。层次结构里的节点分两种:内部节点(存边界、指路)与叶节点(装对象)。它们的内存开销与遍历开销并不在同一处:
这条事实的工程含义:既然叶节点占了节点的八成以上,只压叶节点就能拿到大部分内存收益;又因为遍历开销主要在内部节点上,只压叶节点几乎不损失遍历性能。这不是"压缩算法更聪明",而是看清楚了"内存主要花在哪、遍历主要花在哪"这两件事不在同一处——先分清这两件事,再决定压哪一段,是压缩这一科最实用的心法。
5.4 两个实操细节:对齐税与 scratch 复用
预算表里有两项容易被当成"实现细节"而漏掉,但它们直接吃内存。
第一项是对齐。 规范要求结构起始地址 64 B 对齐、scratch 地址 256 B 对齐。这意味着每一块分配都要向上取整,而且这个取整是逐项的、不是整体的:十个 300 字节的小结构,若逐个按 64 B 对齐,各占 320 字节(合计 3200 字节),比"把总字节数整体向上取整一次"多出 200 字节。小结构多的时候,对齐税可能比结构本体更显眼——这也是"把许多小 BLAS 合并成一个"的一条正当理由。
第二项是 scratch 的复用策略。 第三章把它们分成两块之后,还要再问一句"各自活多久":
- build_scratch 可以是临时的:构建完成即可释放,或归还到内存池;
- update_scratch 往往是常驻的:这份几何每帧都要更新,这块内存每帧都要用。
这里还有一条规范给的便利,能省掉一处运行期判断:更新用的 scratch 尺寸只需在原始构建时查询一次,它"定义了此后每一次更新的 scratch 需求";而且如果构建时没带 ALLOW_UPDATE,这个字段返回 0。后半句反过来用就是一条很好的自检:看到更新 scratch 尺寸为 0,说明这份结构根本没打算被更新——预算表里可以直接把它归到"静态"那一档,不必再查它的更新节奏。
这两项合起来是一句话:预算表要按"分配"算,而不是按"结构"算。结构本体的大小是规范给的,占用的内存是你的分配器给的,两者之间的差额就是对齐税、scratch 复用策略与分配器粒度共同吃掉的部分——这部分不会出现在任何一条查询的返回值里,却实实在在地占着显存。
出处:vkCmdWriteAccelerationStructuresPropertiesKHR/vkGetAccelerationStructureBuildSizesKHR/VkAccelerationStructureBuildSizesInfoKHR/DirectX Raytracing (DXR) Functional Spec/Benthin/Wald/Woop/Áfra(Intel)· Compressed-Leaf BVHs(HPG 2018)。
六、引擎侧的现实:选择权在规范,默认值在引擎
6.1 引擎把哪些东西放在"每帧重建"这一档
好了,进入第六章。前面三章谈的都是"规范给了你什么选择"。但真实项目里,大部分选择早就被引擎替你做了——读引擎文档比读规范更能说明"业界实际怎么分档"。
引擎官方文档给出的每帧重建加速结构的对象清单里有两位固定成员:经 GPUSkinCache 处理的蒙皮网格,以及因连续形变 LOD 而重建的地形(Landscape)。前者是角色动画,后者是大地形。这两类的共同点是:几何身份不变,但顶点每帧都在动,而且动得不小。
同一份引擎技术说明还给了这条开销的方向性口径:动态形变网格每帧更新加速结构的开销,与蒙皮三角形的数量成正比。这句话把第三章的结论从"定性"推到了"可算"——蒙皮三角形数翻倍,这笔开销跟着翻倍。
6.2 这份默认值意味着什么
说白了,把引擎的默认值与第三章的决策树对照着看,会发现一个务实的分工:
- 蒙皮走"重建"而不是"refit"——按第三章的判据,角色的顶点位移幅度不小,refit 出来的盒子会迅速变松;引擎直接选了 rebuild,这是把成本放在构建科、把质量留在遍历科;
- 刚体走"改 TLAS 实例变换"——这一档最省,连 BLAS 都不动;
- 静态几何走"建一次、永不更新"——不开
ALLOW_UPDATE,把构建成本压到最低。
这三个默认值合起来就是一句工程建议:先看引擎把这份几何放在了哪一档,再决定要不要动它。你要改的通常不是"算法选错了",而是"这份几何被分错档了"——比如把一批实际不动的装饰物做成了独立蒙皮网格,它们就白白进了"每帧重建"这一档。
6.3 把几何分对档:三个自查问题
上一节的结论是"判据其实在于这份几何被分错档了没有"。把它变成三个可以直接问的问题:
第一问:它真的会动吗? 关卡里大量"看起来会动"的东西其实是静态的——装饰物、挂在墙上的杆件、被绑在角色骨骼上一起移动的装备(它们跟着角色动、但自身不形变)。只要整体在动而不形变,就属于"只改 TLAS 实例变换"这一档,成本是往 64 字节的结构里写一个矩阵,而不是任何形式的建树。这一档的归类错误代价最小,但最容易被忽略。
第二问:它是在动,还是在变形? 两者的处理方式完全不同(第 1.7 节)。变形(蒙皮、布料、形变 LOD 的地形)才需要动 BLAS;只是移动的几何不该动 BLAS。
第三问:它的变形幅度有多大、频率有多高? 这就是第三章决策树的第二问。变形幅度小、有界的(幅度固定的摆动、循环动画)可以用 refit 加定期重建;变形幅度大、无界的(破碎、粒子、冲刺)只能重建。
这三个问题问完,一份几何的"档位"就定了,而且定出来的档位与引擎的默认分档(第 6.1 节)一对照,就能看出哪些是"被引擎保守地放进了昂贵档"。这类"保守分档"通常是性能优化里最省力的一类收益——它不需要改算法,只需要把一份几何从错误的档位里挪出来。
出处:Epic · 光线追踪性能指南(UE 5.5)/Epic · Lumen Technical Details/NVIDIA · Fast Ray Tracing of Dynamic Scenes(OptiX 9 / RTX Mega Geometry)。
七、八条常见做法,逐条检查
好了,进入第七章。这一章是本篇的差异化段落。前面六章给了规范约束与实测数字,现在把它们反过来用——把工程上常见的做法逐条检查一遍,每条给出依据。
7.1 做法一:几何一变就 refit
站不住,而且有一个精确的反例。 粒子类场景里,每帧 refit 会让遍历从 0.5 ms 退化到 8 ms(第三章)。判据不是"变没变",而是变多少——位移小(风中的树)适合 refit,位移大(粒子、破碎)必须 rebuild。
7.2 做法二:为了省内存,一律开 LOW_MEMORY
站不住。 规范对这个标志的原文是"应当最小化 scratch 与最终结构的大小,可能以构建时间或遍历性能为代价"。它是一个压力阀,不是一个常规选项——常规省内存手段应该是压缩(第四章),而压缩不牺牲遍历性能。
7.3 做法三:压缩一定值得
要看三个前提。 第一,压缩有峰值:拷贝完成前,原结构与压缩副本并存,那一瞬的占用是"140%"而不是"60%";第二,聚类几何不支持压缩(规范专门有一节解释);第三,ALLOW_COMPACTION 可能让压缩前的体积更大,所以收益要按"压缩后 vs 不开压缩的普通构建"来比。三条里有一条不成立,"压缩一定值得"就不成立。
7.4 做法四:scratch 只留一块就够
站不住。 规范给的是两个尺寸字段:build 用的与 update 用的。只按前者分配,第一次更新就会越界。而且这两个 scratch 的生命周期特性不同——update_scratch 常驻(每帧要用),build_scratch 可以是临时的。混作一块,会让最吃紧的那一项按最贵的方式计费。
7.5 做法五:AS 占多少内存,估一下就行
站不住,而且没有必要。 规范给了三类查询(当前尺寸 / 压缩后尺寸 / 序列化尺寸),把"估"变成"查"。这条纪律在 ep01 就写过——规范只给可测量的接口,没给任何百分比;本轮补上的正是这个接口的用法(第五章)。
7.6 做法六:序列化产物可以当资源资产分发
站不住。 规范写明反序列化只能在同一设备与同一驱动版本上进行,否则结果未定义。DXR 侧甚至专门给了一个"驱动匹配标识符"来校验这件事。序列化省的是"同一环境下第二次启动的构建时间",不是跨平台的资源格式。
7.7 做法七:反正开了 ALLOW_UPDATE 不要钱
站不住。 规范的原文是它"可能比普通构建更慢、更费内存",并建议"只在这些能力真的需要时才用"。这是一个无条件付出的声明——只要写上去,构建就更贵,而"以后也许想更新"是很容易高估的假设(第二章第 3.1 条)。
7.8 做法八:树建得好不好是硬件的事
站不住。 建树器是应用侧选的:静态几何用高质量建树(慢建、紧树、快遍历),每帧重建的几何用快速建树(快建、松树、慢遍历)。Vulkan-Samples 的 ray_tracing_extended 样例在自己仓库里就写明了这两种分工。硬件负责遍历,建树质量由你负责——这也是第一章那句结论的落点:层次结构是一族的形状,树的质量才是这一族的性能。
7.9 这八条的共同点
好了,把八条重新排一遍,会看到它们分三类,而三类对应三种不同的错误:
| 类型 | 条目 | 错在哪 |
|---|---|---|
| 把"以后可能需要"当成"现在就需要" | 7.2(一律 LOW_MEMORY)、7.7(无脑开 ALLOW_UPDATE) | 为一个未必发生的将来,现在就付了确定代价 |
| 只看收益、不看代价搬到哪去了 | 7.1(一变就 refit)、7.3(压缩一定值得)、7.4(scratch 只留一块) | 省下的钱记在一个科目,代价记在另一个科目,只看了一边 |
| 把手头能查的东西交给经验 | 7.5(内存靠估)、7.6(序列化当资产)、7.8(建树靠硬件) | 规范或引擎已经把答案写成接口/默认值,却仍在靠印象 |
第三类是本篇最想劝住的一类——因为它不需要任何新知识,只需要把"我印象里"换成"我查一下"。三类里它的门槛最低,收益却最直接。
出处:Vulkan 规范 · Acceleration Structures 章/DirectX Raytracing (DXR) Functional Spec/SIGGRAPH 2019 课程 · Acceleration Data Structure Hardware (and Software)。
八、收束与交棒
8.1 三句话
好了,九章下来。如果只能带走三句话,我希望是下面这三句——它们分别对应本篇的三科:
- 构建科:五个标志不是性能档位,是"把成本放在哪一侧"的声明。
ALLOW_UPDATE买的是以后能改、先付一次更贵的构建;PREFER_FAST_TRACE与PREFER_FAST_BUILD是同一笔账内部的选择,判据只有一个——这份几何会被遍历多少次; - 更新科:几何的身份不能变,只有位置能变;而省下的构建钱会记到遍历科,倍率取决于位移幅度。 "树在风中摆动"适合 refit,"网格炸开"必须 rebuild——同一个 0.5 ms 与 8 ms 的对照把这件事说得很清楚;
- 压缩科:报收益要报口径。 "可能高达 50%"是经验区间,"约 33 B/三角形"是实测量级,节点尺寸的三档是布局差异——三档不能互换,也不能取平均;而且要记住压缩不是零拷贝,峰值是"原结构 + 副本"。
这三句话的顺序同样有意义:先定成本放在哪一侧(构建),再算位移幅度决定改不改(更新),最后才谈省内存(压缩)。反过来先抠内存,很容易在构建与更新上付出更大的代价。
补一句"本篇没写什么",好让你知道边界在哪:没写 BVH 的硬件遍历实现、建树算法的完整推导、构建与主渲染之间的同步与异步安排、引擎编辑器里的具体操作;也没有给出任何"AS 占多少 MB"的百分比——那不是没查到,而是规范只提供可测量的接口、不提供比例,凡出现具体数值的地方都必须能指回一次查询或一次实测。
把三科摆在一张表里,本篇的全部内容就可以收成这样一张对照:
| 科目 | 一句话判据 | 规范给的可查项 | 最常见的错误 |
|---|---|---|---|
| 构建 | 这份几何会被遍历多少次? | 三类尺寸(本体 / build scratch / update scratch) | 统一写上一个用不到的 ALLOW_UPDATE |
| 更新 | 位移幅度有多大? | 两个构建模式(build / update)与六条禁止项 | 一路 refit 到底,盒子的"松"不断累积 |
| 压缩 | 报的是哪一档口径? | 三类查询(当前 / 压缩后 / 序列化) | 只算省下的常驻,不算压缩那一刻的峰值 |
这张表也是本篇的目录:三行分别对应第二章、第三章、第四与第五章;而第一章给了这三科共同的对象(两级结构),第六章到第八章则把三科放回项目里怎么用。
8.2 交棒:下半册要算的是"动态"
OK,分界线与上半册讲完,剩下的问题都指向同一件事:场景会动。本篇给的是判据(什么能改、什么该重建),但没展开三条正在被工程界用来"绕开重建"的路径,它们正是下半册的题内:
- 分区分层:只重建发生变化的那一部分顶层结构,而不是整个 TLAS(
VK_NV_partitioned_acceleration_structure走的就是这条路); - 三角形簇结构:把预生成的簇结构直接作为 BLAS 的输入,用"簇实例化"替代"逐三角形建树"(
VK_NV_cluster_acceleration_structure); - 按需增量更新:把"什么时候重建"从固定节奏改成事件驱动。
这三条本轮只核到规范片段,没有取到足以给结论的完整材料,因此本篇不给数字、不下判断——没读到的不写,这条纪律沿用 ep01。
下一篇《加速结构工程账(下):动态场景与内存开销》就来算这本账。 那里会用到本篇第一章的两级结构、第三章的更新约束,以及第五章的预算表。
8.3 如果这一篇只让你动一处
前面八章给了不少东西:三个尺寸、五个标志、六条禁止项、三档口径、一张预算表、一张决策树、八条检查。如果你现在只准备动一个地方,我建议动这里——去查一遍项目里所有加速结构的构建标志,然后把没在用的 ALLOW_UPDATE 删掉。
理由有三条,都能在本篇找到依据:
- 它的代价是无条件付出的——规范原文写着它"可能比普通构建更慢、更费内存"(第 2.2 节);
- 它买的那个能力,大概率没被用上——真正需要更新的几何(蒙皮、变形、粒子)在项目里是少数,而标志往往是一份全局构建函数里统一写上的;
- 删掉它的判断成本极低——只要有一份几何在可见的未来不需要更新,删掉就纯赚;万一以后需要,改回来也只是一次构建时的参数变更。
这条建议不涉及任何算法改动,也不要求你先建好性能分析工具,它只需要把"我以后可能会更新"这句话,换成"我这一份会不会更新"。
出处:VkBuildAccelerationStructureFlagBitsKHR/Vulkan 规范 · Acceleration Structures 章/DirectX Raytracing (DXR) Functional Spec。
参考资料与技术资源清单
规范与官方接口
- Vulkan 规范 · Acceleration Structures 章(BLAS/TLAS 定义、update 的禁止项、活跃状态转换要求完整重建)
- VK_KHR_acceleration_structure refpage(扩展元数据、序列化/反序列化、间接构建、两类 query)
- VkBuildAccelerationStructureFlagBitsKHR/VkBuildAccelerationStructureModeKHR(五个标志的语义与代价、build/update 两模式)
- vkGetAccelerationStructureBuildSizesKHR/VkAccelerationStructureBuildSizesInfoKHR(三个尺寸字段)
- vkCmdWriteAccelerationStructuresPropertiesKHR/VkCopyAccelerationStructureModeKHR(三类查询与四种拷贝模式)
- VkAccelerationStructureInstanceKHR(实例结构布局 → 64 B/实例)
- DirectX Raytracing (DXR) Functional Spec v1.47(update 的性能影响、压缩的非单调性、聚类几何不支持压缩、驱动匹配标识符)
官方样例与源码(本篇真图与分工实证的来源) - Khronos Vulkan-Samples · ray_tracing_reflection(Apache-2.0,本篇 3 张官方图来源:BLAS 复用实例化、两级结构、管线结构) - ray_tracing_extended README(静态几何 FAST_TRACE / 每帧更新几何 FAST_BUILD 的官方分工) - ray_tracing_basic README(最小完整链路)
论文与课程(只引用数字,不搬图) - SIGGRAPH 2019 课程 · Acceleration Data Structure Hardware (and Software)(refit ≈ 10×、约 33 B/三角形、2000 粒子 0.5→8 ms) - Benthin/Wald/Woop/Áfra(Intel)· Compressed-Leaf BVHs(HPG 2018)(节点尺寸 256→136→72 B、叶节点占比 >80%) - Embree · Building Ray Tracing Applications(SIGGRAPH 2014 tutorial)(静态/动态场景的建树器选择口径)
厂商与引擎官方口径(配图不转载,文字引用) - NVIDIA · RTX Best Practices/NVIDIA · Fast Ray Tracing of Dynamic Scenes/NVIDIA 中文博客 · 最佳实践 - Epic · 光线追踪性能指南(UE 5.5)/Lumen Technical Details - AMD GPUOpen · HIP RT/RDNA3 Beyond the current gen
素材出处:本篇 12 张外部真图来自 Wikimedia Commons(9 张)与 Khronos Vulkan-Samples(3 张,Apache-2.0),逐张来源与许可见工作目录的
image-credits.json;14 张示意图为本系列依据上述来源归纳后自绘,统一走色板单源。
下一篇(预告):【实时光线追踪与混合渲染】(三) 加速结构工程账(下):动态场景与内存开销
With Best Wishes.