Skip to main content

Chapter 56: Bounding Volume Hierarchies

一条光线进入场景后,最直接的相交测试方式是把它依次拿去测试每个三角形、曲面代理或过程几何。这个做法的输入很清楚:一条 ray、一个 primitive 列表和一段 hit 记录;输出也很清楚:最近命中、任意命中或 miss。问题在于它把查询成本绑定到 primitive 总量。场景从几千个三角形增长到几百万个三角形时,每条光线都扫描完整列表,primary ray、shadow ray、reflection ray 和 visibility query 会把相交测试放大成帧级瓶颈。

Bounding Volume Hierarchy(BVH)把 primitive 按空间邻近关系组织成一棵层级树。每个节点保存一个能包住其子树几何的包围盒,叶子节点保存少量 primitive 引用。遍历时,光线先测试节点包围盒;包围盒未命中时,整棵子树直接跳过;包围盒命中时,继续进入子节点或叶子 primitive。BVH 的核心收益来自“用较便宜的盒子测试替换大量昂贵的 primitive 测试”,同时把失败查询尽早收束在树的高层。

本章围绕一个贯穿帧展开:一个离线或实时路径追踪 pass 正在渲染由静态建筑、若干实例化道具和一个蒙皮角色组成的场景。每个像素发出 primary ray,命中后继续发出 shadow ray 和 reflection ray。读者读完后应能定位 BVH 节点在查询路径中的角色,比较 median split、SAH、LBVH、HLBVH 的构建取舍,设计 CPU 或 GPU 侧的节点布局,判断动态场景应使用 refit、partial rebuild 还是 TLAS/BLAS 分离,并用 build time、node count、ray steps、memory bandwidth、hit/miss ratio 复盘 BVH 质量。

在现代图形 API 中,BVH 往往以 acceleration structure 的形式暴露。Khronos 的 VK_KHR_acceleration_structure 把 acceleration structure 定位为让 ray tracing 快速识别潜在相交 primitive 的空间组织,并提供对象创建、构建、复制和设备地址相关命令。Microsoft 的 DirectX Raytracing Functional Spec 把 top-level / bottom-level acceleration structure、更新约束、构建标志、post-build 信息和 shader 访问放入同一套 API 合同。算法层面可参考 PBRT 4e 的 BVH 章节,其中把 BVH 构建拆成 primitive bounds、构建树、压平成线性节点三个阶段。正文会把这些材料转成工程判断,重点放在一帧中 ray、node、buffer、build/update pass 和 profiler 指标之间的因果关系。

56.1 BVH 节点、包围盒与遍历角色

BVH 节点的工作定义是:一个保存空间范围和子树入口的查询单元。内部节点保存当前子树的包围盒以及子节点索引;叶子节点保存当前空间簇中的 primitive 起始位置和数量。常见包围盒是轴对齐包围盒(Axis-Aligned Bounding Box, AABB),它用 minmax 两个三维点表达一个闭合盒子。AABB 与世界坐标轴对齐,计算和测试成本低,内存布局稳定,适合作为 GPU buffer 中的大规模节点表示。

在贯穿帧中,primary ray 从相机出发,先进入 TLAS 或场景级 BVH 的根节点。根节点包围整个可查询场景。光线与根节点 AABB 相交后,遍历器读取左右子节点的 AABB;若左子节点对应建筑群,右子节点对应角色和道具,光线会根据 AABB 的进入距离决定先访问更近的子树。这个顺序影响 closest-hit 查询的早停机会。较近子树已经产生一个小的 tHit 时,后续节点只要 AABB 的进入距离大于当前最近命中距离,就可以跳过。

BVH 的数据流可以简化为以下路径。图中 traversal 既可以由软件 shader 实现,也可以由硬件 ray tracing 单元、驱动内部结构或 CPU 查询代码承担。应用层仍需要理解输入和输出,因为构建质量、资源状态和更新策略由工程代码控制。

这个流程的关键点是“节点测试决定搜索范围,primitive 测试决定真实命中”。节点 AABB 的作用类似粗筛选,它不判断三角形表面是否真的被击中,只判断当前光线有没有进入某个空间范围。叶子节点才会把 ray 拿去测试三角形、AABB procedural primitive、curve proxy 或自定义 intersection shader。由此可得到第一个工程判断:BVH 的质量首先体现在“光线访问了多少节点和叶子”,其次才体现在单个 primitive 相交函数有多快。

AABB 测试本身通常使用 slab 方法。对每个坐标轴,光线与盒子两侧平面产生一段进入/离开区间,三个轴区间取交集后得到整体相交区间。若区间为空,光线未进入盒子;若区间与 ray 的有效范围 [tMin, tMax] 有重叠,节点进入候选集。下面的伪代码只展示判断路径,省略除零保护、无穷值处理和平台精度细节。

struct Ray {
float3 origin;
float3 direction;
float tMin;
float tMax;
};

struct AABB {
float3 minPoint;
float3 maxPoint;
};

bool IntersectAABB(const Ray& ray, const AABB& box) {
float3 invDir = 1.0f / ray.direction;
float3 t0 = (box.minPoint - ray.origin) * invDir;
float3 t1 = (box.maxPoint - ray.origin) * invDir;

float3 nearT = min(t0, t1);
float3 farT = max(t0, t1);

float enterT = max(max(nearT.x, nearT.y), max(nearT.z, ray.tMin));
float exitT = min(min(farT.x, farT.y), min(farT.z, ray.tMax));
return enterT <= exitT;
}

这段代码说明 AABB 测试回答的问题很窄:当前 ray 的有效段是否穿过该盒子。它没有访问三角形索引、材质、法线、UV 或 shader table。由于它的输入固定为 ray 和两个三维点,节点 buffer 的内存布局会直接影响带宽和缓存。一个节点若需要跨多个 cache line 才能读完两个子节点的 AABB,遍历器会在 miss-heavy 的 shadow ray 场景中消耗大量无效带宽。

内部节点的职责是组织搜索顺序。二叉 BVH 常见,宽 BVH 也常见。二叉节点每次最多测试两个子节点,构建简单,树高相对较大。宽节点一次容纳 4、8 或更多 child bounds,可以减少层数,适合 SIMD / SIMT 批量测试,但单节点读取量和 child 排序成本增加。CPU ray tracer 常把宽节点配合 SIMD lane 使用;GPU 路径则更关注内存对齐、warp 内分支一致性和节点访问的空间局部性。

叶子节点的职责是把粗筛选转成精确测试。叶子太小会产生更多内部节点和栈操作;叶子太大会让每次命中叶子后执行过多 primitive 测试。若本章贯穿帧的建筑墙面被切成大量小三角形,叶子大小可设为 1 到 4 个三角形以提高 closest-hit 精度;若查询是宽松的碰撞或 shadow any-hit,适当增大叶子能减少节点数量和构建开销。这个判断依赖查询类型,而非固定参数。

遍历角色还包括栈。递归遍历在算法描述中直观,但 GPU shader 和高性能 CPU 实现通常使用显式栈、短栈、rope、parent pointer 或栈压缩策略。栈保存“稍后访问”的节点索引。closest-hit 查询会优先访问更近 child,把较远 child 压栈;any-hit shadow ray 一旦找到遮挡物即可返回,栈压力通常更小。若 profiler 显示 shadow ray 的 traversal steps 很高,先检查节点重叠和 child 访问顺序,再检查三角形相交函数。

包围盒还承担调试角色。把每层节点 AABB 画成线框、按访问次数给节点上色、按叶子 primitive 数量显示热力图,可以把抽象树结构转成可观察结果。RenderDoc 或引擎自带 debug view 里,若一个节点盒子巨大且覆盖多个无关物体,说明构建阶段把空间上分离的 primitive 聚到了一起。遍历会不断命中这个大盒子,然后在子树内部继续做无效测试。这个现象通常对应 SAH 成本高、ray steps 高、hit/miss ratio 低。

因此,BVH 节点、包围盒和遍历的关系可以收束为一个检查顺序:先看 ray 是否频繁访问大范围节点,再看叶子 primitive 数量是否偏大,接着看 sibling bounds 是否大量重叠,最后看节点 buffer 是否导致带宽压力。这个顺序把视觉或性能症状回到了具体资源:ray query、node buffer、primitive index buffer 和 hit 记录。

56.2 构建 BVH 的算法策略

BVH 构建算法回答的问题是:如何把 primitive 列表划分成一棵能让未来查询少走路的树。输入包括 primitive bounds、centroid、primitive index、构建参数和目标查询类型;输出包括节点数组、叶子 primitive 范围、primitive 重排结果和可选的 compacted size。构建算法的核心取舍是 build time 与 traversal quality。构建越精细,遍历通常越省;构建越快,节点重叠、树深和叶子质量可能变差。

最朴素的 top-down 构建会先计算当前 primitive 集合的整体 bounds,再选一个轴,把 primitive 分成两组,然后递归构建子树。median split 是这种思路的代表。它选择 centroid 分布最宽的轴,按 centroid 中位数把 primitive 划成数量接近的两半。它的优点是实现简单、树结构平衡、构建成本低;它的缺点是没有显式评估 sibling bounds 的表面积和重叠。对于均匀分布的小物体,它足够稳定;对于长条墙面、稀疏建筑群和聚集道具,它可能把空间上交错的物体放进相邻子树,导致遍历命中更多节点。

SAH(Surface Area Heuristic,表面积启发式)把划分质量转成成本估计。它的基本思想是:子节点表面积越大,随机 ray 命中它的概率越高;子树 primitive 越多,命中后潜在 primitive 测试越多。因此一次 split 的成本可近似为“遍历当前节点的成本 + 左子节点命中概率乘左侧 primitive 成本 + 右子节点命中概率乘右侧 primitive 成本”。实际实现常使用 bucket 近似,在候选轴上把 primitive centroid 放入若干桶,对每个桶边界估算左右 bounds 和 primitive 数量,再选择估计成本最低的划分。

// 简化伪代码:用 bucket SAH 选择一个节点的 split。
Split ChooseSAHSplit(span<PrimitiveBound> items) {
int axis = LargestCentroidExtent(items);
Bucket buckets[12] = {};

for (const PrimitiveBound& item : items) {
int bucketIndex = BucketOf(item.centroid, axis, buckets.length);
buckets[bucketIndex].bounds = Union(buckets[bucketIndex].bounds, item.bounds);
buckets[bucketIndex].count += 1;
}

float bestCost = Infinity;
int bestSplit = 0;
for (int split = 0; split + 1 < buckets.length; ++split) {
AABB leftBounds = UnionBuckets(buckets, 0, split);
AABB rightBounds = UnionBuckets(buckets, split + 1, buckets.length - 1);
int leftCount = CountBuckets(buckets, 0, split);
int rightCount = CountBuckets(buckets, split + 1, buckets.length - 1);
float cost = NodeTraversalCost
+ SurfaceArea(leftBounds) * leftCount
+ SurfaceArea(rightBounds) * rightCount;
if (cost < bestCost) {
bestCost = cost;
bestSplit = split;
}
}
return Split{axis, bestSplit, bestCost};
}

这段伪代码要证明的点是:SAH 的构建质量来自“显式估算未来 traversal 成本”。它并未真正追踪帧中的每条 ray,而是用 bounds 表面积作为命中概率代理。对于相机 primary ray 或方向分布偏置明显的 shadow ray,SAH 仍是通用近似;若生产渲染里某类 ray 占比极高,可以在更高级的构建器中加入方向分布、实例重要性或场景分区策略。

LBVH(Linear BVH)把构建问题转成排序问题。它先为 primitive centroid 计算 Morton code,把三维空间位置映射到一维 Z-order 序列,再按 Morton code 排序,最后根据相邻 code 的公共前缀生成树。这个方法的构建成本低,适合 GPU 并行。它的质量依赖 Morton 顺序对空间邻近关系的保持程度。空间中相近的点通常在 Morton 序列中相近,但细长几何、非均匀分布和跨区域大 primitive 会让节点 bounds 变松。

HLBVH(Hierarchical LBVH)把 LBVH 的并行构建和 SAH 的上层质量结合起来。常见做法是先用 Morton code 快速构建底层 treelet,再对 treelet 根节点使用 SAH 建立上层。底层获得高并行度,上层控制大范围节点重叠。对于本章贯穿帧,静态建筑可使用高质量 SAH 构建并缓存,动态道具或每帧更新的粒子代理可使用 LBVH / HLBVH 快速重建,角色或可变形网格则根据变形幅度在 refit 和 rebuild 之间切换。

四类策略的工程比较应使用同一组维度。median split 的定位是“低实现成本与稳定树高”;SAH 的定位是“高 traversal quality 与较高构建成本”;LBVH 的定位是“并行快速构建与中等查询质量”;HLBVH 的定位是“底层快速聚类与上层质量修正”。这些算法没有独立于场景的统一胜者,选择条件来自 frame budget、primitive 数量、动态比例、ray 类型和更新频率。

策略构建输入重点构建成本遍历质量适合场景主要风险
median splitcentroid 与最大轴中等均匀静态几何、教学实现、CPU fallbacksibling bounds 重叠增加
SAHbounds 表面积、primitive 数量、bucket较高静态环境、离线渲染、高质量 BLAS构建时间和内存临时开销上升
LBVHMorton code、排序、并行前缀关系低到中中等GPU 快速构建、动态小物体、粒子代理非均匀几何下节点质量下降
HLBVHMorton treelet 与上层 SAH中到高大量动态 primitive、实时 ray tracing实现复杂度高于单一策略

构建还要决定叶子阈值、最大深度、空间 split 和 primitive 重排。叶子阈值控制 node count 与 primitive tests 的平衡;最大深度保护构建器免受退化几何拖累;空间 split 会复制跨分割面的 primitive,从而改善 bounds 质量,但增加内存和重复测试风险;primitive 重排让叶子中的 primitive 在内存中连续,提高 leaf traversal 的 cache locality。PBRT 的线性化流程就体现了这个思路:先构建指针树,再把节点压平成 pointerless array,同时把 primitive 放到叶子连续范围中。

在图形 API 的硬件 ray tracing 管线里,应用常无法直接控制底层 BVH 内部算法。应用能控制的是输入几何、build flags、update flags、geometry flags、instance flags、scratch buffer、compaction 和构建频率。Vulkan 和 DXR 都把 acceleration structure 构建作为显式命令暴露,驱动负责内部布局。工程判断仍然有效:高质量静态结构可使用偏向 fast trace 的标志,频繁更新结构可使用允许 update 的标志,构建后可读取 compacted size 并做压缩,减少后续遍历带宽。

把算法策略放回贯穿帧,可得到一个实用选择顺序。静态建筑 BLAS 使用高质量构建,构建一次并序列化或缓存;实例化道具使用共享 BLAS 和每帧更新的 TLAS;蒙皮角色若骨骼变形幅度小,先尝试 refit,若 ray steps 和 node overlap 明显升高,则周期性 rebuild;大量短生命周期 VFX 几何使用 LBVH / HLBVH 或更粗代理进入 ray query。这样分层后,BVH 构建成本服务于帧预算,遍历质量服务于 ray-heavy pass。

56.3 高效 BVH 管线实现技巧

高效 BVH 管线的目标是让构建输出直接适配遍历器。它包含四条数据路径:primitive bounds 生成、节点构建、节点线性化、debug / profiling 输出。每条路径都应该回答一个明确问题。primitive bounds 回答“每个 primitive 在查询空间覆盖哪里”;节点构建回答“哪些 primitive 应该共享一个子树”;节点线性化回答“遍历器怎样以最少带宽读取节点”;debug 输出回答“树质量和访问热点能否被观察”。

primitive bounds 是构建质量的基础。三角形 bounds 来自三个顶点的逐分量最小值和最大值;实例 bounds 来自 BLAS bounds 经过实例 transform 后的世界空间 AABB;蒙皮网格 bounds 可来自 CPU skinning 后的顶点 bounds、骨骼包围代理或保守膨胀 bounds。若 bounds 漏掉真实几何,ray 会出现错误 miss;若 bounds 过度膨胀,遍历会访问大量空子树。保守性优先于紧致性,但长期性能来自紧致的保守 bounds。

节点布局决定 traversal 的内存行为。一个常见 CPU/GPU 友好布局是把内部节点压平成数组,用整数 child index 代替指针。二叉节点可以把左右 child bounds 连续存放,并把 child metadata 放在同一 cache line 附近。叶子节点用 firstPrimitiveprimitiveCount 指向另一个连续 primitive index array。这样 traversal 访问内部节点时无需读取 primitive 数据,命中叶子后再进入 primitive buffer。

struct LinearBVHNode {
float3 boundsMin;
uint leftFirst; // internal: left child index; leaf: first primitive index
float3 boundsMax;
uint primitiveCount; // 0 means internal node
uint rightChild; // valid when primitiveCount == 0
uint axis;
uint debugVisitCount;
uint flags;
};

这个结构只是示意,真实实现会根据 API、对齐规则和向量加载方式调整。primitiveCount == 0 可表示内部节点;leftFirstrightChild 指向两个 child;叶子节点使用 leftFirst 指向 primitive index array。axis 可用于 child ordering;debugVisitCount 可在调试构建中统计访问频率,正式渲染路径可拆到单独 buffer 中,以免污染节点带宽。关键原则是:遍历内部节点时读取的数据应集中服务于 AABB 测试和 child 选择。

stack traversal 是软件 BVH 的标准实现。closest-hit 查询维护当前最近命中距离,访问 child 时按 near/far 顺序压栈。any-hit 查询命中可遮挡 primitive 后直接返回。下面的伪代码展示数据路径,不展开三角形求交。

Hit TraceBVH(const Ray& inputRay, const LinearBVHNode* nodes) {
Ray ray = inputRay;
Hit bestHit = Miss();
uint stack[64];
uint stackSize = 0;
uint nodeIndex = 0;

while (true) {
const LinearBVHNode& node = nodes[nodeIndex];
if (IntersectAABB(ray, AABB{node.boundsMin, node.boundsMax})) {
if (node.primitiveCount > 0) {
bestHit = IntersectLeaf(ray, node.leftFirst, node.primitiveCount, bestHit);
ray.tMax = bestHit.t;
} else {
uint nearChild = ChooseNearChild(ray, node.leftFirst, node.rightChild, node.axis);
uint farChild = OtherChild(nearChild, node.leftFirst, node.rightChild);
stack[stackSize++] = farChild;
nodeIndex = nearChild;
continue;
}
}

if (stackSize == 0) {
break;
}
nodeIndex = stack[--stackSize];
}
return bestHit;
}

这段伪代码的关键点是 ray.tMax 会被最近命中持续缩短。closest-hit 查询中,越早找到近处命中,后续远处节点越容易被 AABB 区间测试排除。child ordering、节点 bounds 紧致性和叶子 primitive 排列共同决定了这个早停收益。对于 shadow ray,IntersectLeaf 可在任意遮挡命中后返回,进一步减少栈访问。

SIMD-friendly data 并非简单地把结构改成向量类型。CPU 宽 BVH 需要让 4 或 8 个 child bounds 能被同一次向量加载和比较覆盖。GPU SIMT 则需要更多关注 warp 内 ray coherence:相邻线程若访问完全不同节点,内存请求和控制流会分散。primary ray 的空间相干性通常较好,secondary ray 在粗糙反射、透明、多 bounce 场景下发散更强。BVH 布局无法单独解决 ray divergence,但紧致节点和局部连续的 leaf primitive 可以降低每次发散访问的代价。

debug visualization 应作为 BVH 管线的输出之一。最少应能输出四类视图:按树深显示节点盒子、按 leaf primitive count 显示叶子热力、按 visit count 显示 traversal 热点、按 bounds overlap 显示 sibling 交叠区域。对于本章贯穿帧,若 reflection ray 在玻璃幕墙附近访问大量室内家具子树,visit heatmap 会在玻璃反射方向形成热点;若 sibling overlap view 显示几个巨大节点同时覆盖大厅空间,构建划分需要重新评估。

GPU 构建管线还要处理 scratch buffer 和资源状态。Vulkan acceleration structure 构建使用 build input、scratch address 和目标 acceleration structure;DXR 构建使用 prebuild info、scratch buffer、destination buffer 和 build flags。应用层应把 scratch buffer 生命周期放入 frame graph:构建前写入 geometry / instance input,构建命令写 acceleration structure storage,构建后 ray tracing pass 读 acceleration structure。若同一帧中 build 与 trace 跨 queue 或跨 pass,barrier 和队列所有权转移需要显式表达。

实现技巧的收束判断是:构建器输出的树必须让遍历器少读、少分支、少回退。primitive bounds 要保守且紧致;节点布局要服务连续读取;栈策略要适配 closest-hit 和 any-hit;debug 输出要能把访问热点映射回节点和 primitive;API 资源状态要把 build write 与 trace read 分开。若这些条件缺失,算法名再高级也会在帧里表现为带宽、栈溢出、ray steps 或同步等待。

56.4 动态场景中的 BVH 更新策略

动态场景的 BVH 问题来自几何变化。静态建筑可以构建一次并复用;道具实例只改 transform;角色网格顶点每帧受骨骼影响;streaming geometry 会加载和卸载 chunk;破碎物体会改变 primitive 集合。不同变化类型对应不同更新策略。把所有变化都做 full rebuild 会消耗构建预算;把所有变化都做 refit 会让树质量持续下降。

refit 的定义是:保持树拓扑和叶子 primitive 分配不变,只从叶子向上重新计算 bounds。它适合 primitive 集合稳定、局部形变幅度有限的场景。蒙皮角色的某个 BLAS 如果每帧三角形连接关系不变,refit 可快速更新叶子 bounds 和内部 bounds。它的风险是拓扑固定,变形后的空间分布可能已经和原始划分不匹配。角色手臂从身体旁边挥到远处时,原来属于同一子树的 bounds 会被拉长,sibling overlap 增加,traversal steps 上升。

partial rebuild 的定义是:只重建变化明显的子树或 chunk。它适合局部编辑、局部破坏、局部 streaming。开放世界场景中,远处城市分块进入相机影响范围时,可以给新 chunk 构建 BLAS,同时保持旧 chunk 的 acceleration structure。若某个建筑内部家具被大量移动,只重建该建筑的子树或 BLAS,TLAS 保持实例级组织。partial rebuild 的关键是 dirty region 划分要和场景资源组织一致;若 dirty region 粒度过细,调度和 scratch 管理会吞掉收益。

TLAS/BLAS 分离是现代 ray tracing API 中最常用的动态组织方式。BLAS(bottom-level acceleration structure)保存 mesh 或 procedural geometry 的局部结构;TLAS(top-level acceleration structure)保存实例列表、实例 transform、instance mask、shader binding 相关索引和指向 BLAS 的引用。实例移动时,应用可以更新 TLAS;mesh 顶点变化时,应用更新对应 BLAS。这个分离把“几何拓扑变化”和“实例变换变化”拆开,减少每帧构建范围。

图中 BLAS 到 TLAS 的箭头表示实例引用关系。一个树木 mesh 的 BLAS 可以被几百个实例共享,TLAS 为每个实例保存不同 transform。primary ray 先在 TLAS 中找到候选实例,再进入对应 BLAS 的局部空间查询 triangle。若树木随风摆动且使用顶点动画,BLAS 也需要更新;若树木只是实例位置变化,更新 TLAS 即可。这个区分直接影响 build time 和 memory bandwidth。

蒙皮网格需要单独判断。CPU skinning 后上传顶点再构建 BLAS,路径简单但 CPU 和上传带宽压力大;GPU skinning 后直接用变形顶点构建 BLAS,可减少 CPU 参与,但 build pass、skin pass 和 trace pass 之间需要清晰 barrier;使用骨骼 capsule 或粗代理进入 ray query,可降低成本,但反射、阴影和接触细节会变粗。角色在屏幕上很小或只参与 shadow any-hit 时,粗代理可能足够;角色近景反射和透明折射需要更高质量 BLAS。

streaming geometry 的 BVH 更新要兼顾资源生命周期。一个地形 chunk 加载后,先生成 vertex/index buffer,再构建 BLAS,随后把实例写入 TLAS 输入。chunk 卸载时,先从 TLAS 输入列表移除实例,确认 GPU 不再读取相关 acceleration structure,再释放 BLAS buffer。若直接释放仍被上一帧 trace pass 使用的 BLAS,会产生 GPU 访问非法内存。frame graph 或延迟销毁队列应记录 acceleration structure 的读写帧号。

动态策略的选择可以使用一组阈值,而非固定偏好。先记录每个结构的 build time、update time、compacted size、平均 ray steps 和最大 bounds expansion。若 refit 后 bounds 表面积相对原始构建增长较小,继续 refit;若 sibling overlap 和 ray steps 连续上升,触发 rebuild;若对象仅 transform 变化,优先更新 TLAS;若 primitive 数量变化,进入 BLAS rebuild 或局部重建;若 geometry 生命周期短,使用粗代理或批量 LBVH。阈值应按平台和 frame budget 标定。

动态更新还要处理 API 标志。DXR 和 Vulkan 都提供偏向 fast build、fast trace、allow update、allow compaction 等不同构建意图。allow update 往往会影响底层结构选择和内存需求;fast trace 可能增加构建成本但降低 traversal;compaction 可以减少长期驻留结构的内存和带宽。应用应把标志绑定到资源类型:静态建筑偏向 fast trace 与 compaction,动态角色偏向 allow update,短生命周期 VFX 偏向 fast build。

把这些策略放回贯穿帧,稳定方案是:静态建筑 BLAS 高质量构建并压缩;道具实例共享 BLAS,只更新 TLAS transform;角色 BLAS 在小幅动画时 refit,在动作剧烈或镜头近距离时周期性 rebuild;streaming chunk 使用异步构建队列并延迟接入 TLAS;粒子和临时碎片使用粗 bounds 或快速 LBVH。这样每种变化都被限制在合适的 acceleration structure 层级中。

56.5 BVH 构建对追踪性能的影响

BVH 构建质量最终要用追踪性能验证。build time 表示构建阶段占用的 CPU/GPU 时间;node count 表示结构规模;ray steps 表示每条 ray 访问的节点或层级数量;memory bandwidth 表示遍历读取节点、primitive index 和 vertex 的带宽;hit/miss ratio 表示查询进入叶子并产生候选命中的比例。这些指标需要放在同一个 frame 中看,因为单个指标无法独立说明质量。

构建时间过高会压缩渲染预算。实时管线中,TLAS 每帧更新、动态 BLAS refit、particle BVH rebuild 都会与 skinning、culling、shadow map、lighting 和 post-processing 争夺 GPU 时间。若 build pass 在 GPU timeline 中阻塞后续 trace pass,应该先减少动态结构数量、合并小结构、使用 fast build 标志或把静态结构移出每帧构建路径。若 build 在 async compute queue 上执行,还要检查 trace pass 是否等待构建完成,等待时间同样计入帧成本。

node count 影响内存驻留和 traversal 带宽。节点过多通常来自叶子阈值过低、空间 split 复制过多、过度细碎的 BLAS 组织或动态结构碎片化。节点过少则可能来自叶子过大或构建过粗,结果是 leaf primitive tests 增加。node count 的合理范围要和 primitive count、leaf size、wide node fanout 一起看。一个二叉 BVH 单 primitive 叶子理论上接近 2N - 1 个节点;多 primitive 叶子或宽 BVH 会改变这个关系。

ray steps 是最直接的 traversal 质量指标。对贯穿帧,可以分别记录 primary ray、shadow ray 和 reflection ray 的平均 steps、P95 steps 和最大 steps。primary ray steps 高,常见原因是相机前方大范围节点重叠或 child ordering 差;shadow ray steps 高,常见原因是 any-hit 目标被透明材质、alpha test 或大叶子拖慢;reflection ray steps 高,常见原因是 secondary ray 发散导致缓存局部性变差。不同 ray 类型混在一个平均值里会掩盖问题。

memory bandwidth 把结构质量和硬件资源连接起来。BVH 遍历通常是 pointer chasing 和随机访问混合负载。节点布局松散、child bounds 分散、叶子 primitive index 不连续、vertex fetch 格式膨胀,都会让遍历从计算瓶颈转为带宽瓶颈。若 profiler 显示 ray tracing pass 的 ALU 利用率不高、L2 miss 或 memory transaction 高,优化方向应优先指向节点压缩、primitive 重排、compaction、BLAS 粒度和 ray coherence。

hit/miss ratio 需要结合查询语义解释。shadow ray 的 miss 可能表示无遮挡,属于正常结果;但如果 miss ray 访问大量节点后才失败,说明 BVH 无法尽早排除空区域。closest-hit primary ray 命中率高时,早停能力取决于是否先访问近处节点;reflection ray 的 miss 区域若集中在天空方向,TLAS 可通过场景 bounds 和 ray flags 减少无效查询。hit/miss ratio 的价值在于配合 steps:低 steps miss 是好失败,高 steps miss 是昂贵失败。

一个可复用的性能复盘顺序如下。先在 GPU timeline 中分离 build/update pass 与 trace pass,确认问题发生在构建阶段还是遍历阶段;再按 ray type 分开统计 steps 和命中;接着观察 node count、BLAS/TLAS 数量、compacted size 和内存带宽;然后用 debug view 检查热点节点、巨大 bounds、sibling overlap 和叶子 primitive count;最后回到构建策略,调整 split method、leaf size、refit/rebuild 阈值、TLAS/BLAS 粒度和 build flags。这个顺序能把“帧变慢”转成可验证的 BVH 结构问题。

下面是一个贯穿帧的判断样例。场景加入一个动画角色后,reflection ray pass 从 3.2 ms 上升到 5.6 ms,build pass 只增加 0.2 ms。按 ray type 统计后发现 reflection ray P95 steps 明显上升,shadow ray 基本稳定。debug view 显示角色 BLAS 的上半身 bounds 在挥手动作中覆盖了大范围空间,refit 后 sibling overlap 累积。此时瓶颈在 traversal quality,处理顺序是提高角色 BLAS rebuild 频率、把角色拆成多个局部 BLAS、或为反射 pass 使用更保守的 LOD / proxy。若改成 full rebuild 后 build pass 上升 1.5 ms,trace pass 下降 1.8 ms,总帧仍收益;若平台 build 成本更高,则可以只在近景或高反射材质可见时触发 rebuild。

另一个样例是静态建筑场景中 node count 很高但 ray steps 偏低。此时 BVH 查询质量可能很好,问题未必在 traversal。若 memory bandwidth 和 residency 压力高,可启用 compaction、合并过小 BLAS、压缩节点格式或调整 leaf size;若带宽仍充足且 trace pass 稳定,保持结构质量更合理。性能指标要服务主瓶颈,不能为了降低 node count 牺牲 ray steps。

BVH 构建对追踪性能的最终影响可以归纳为三条因果链。第一,split quality 决定 sibling overlap 和树深,进一步决定 ray steps。第二,node layout 与 compaction 决定节点读取带宽,进一步影响 GPU 利用率。第三,动态更新策略决定构建时间和长期树质量,进一步影响 frame budget。工程上应同时记录构建成本和遍历成本,因为真正的目标是整帧更稳定,而非某个单项指标更漂亮。

最小自检任务

给定一个实时 ray tracing 场景:静态城市建筑约 300 万个三角形,1000 个共享模型的路灯实例,一个近景蒙皮角色,以及一组每帧生成的碎片粒子。当前帧中 reflection ray pass 变慢,GPU timeline 显示 TLAS build 0.4 ms、动态 BLAS update 0.7 ms、reflection ray trace 6.1 ms。统计结果显示 reflection ray 平均 steps 和 P95 steps 都上升,node count 基本稳定,角色 BLAS 使用连续 refit,粒子使用每帧快速构建。请设计一次 BVH 排查和调整方案,要求说明先看哪些指标、如何区分构建成本和遍历质量、哪些结构使用 refit / rebuild / TLAS update / LBVH,以及预期观察到什么结果。

答案要点

先把问题定位到 trace pass,因为 build 和 update 总耗时为 1.1 ms,而 reflection ray trace 达到 6.1 ms,并且平均 steps 与 P95 steps 同时上升。第一步按 ray type 保留 reflection ray 的 steps、hit/miss ratio、L2 / memory transaction 和热点节点访问统计,确认慢点来自 traversal,而非 shader shading 或材质采样。第二步打开 BVH debug view,检查角色 BLAS refit 后的 bounds expansion、sibling overlap 和访问热点;若角色挥动导致大 bounds 覆盖反射方向,应把角色从连续 refit 改成周期性 rebuild,或拆成上身、下身、武器等多个局部 BLAS。第三步检查粒子 BVH。粒子每帧生成且生命周期短,适合 LBVH / HLBVH 或粗代理;若其热点主要来自大量透明碎片参与 reflection ray,可通过 ray flags、LOD 或更粗 bounds 降低候选数量。第四步保持城市建筑 BLAS 静态高质量构建并压缩,路灯共享 BLAS,仅通过 TLAS 更新实例 transform。最终判断看两组结果:build/update pass 上升是否小于 trace pass 下降,reflection ray P95 steps 是否回落,热点 bounds 是否从角色和粒子区域收缩。若 rebuild 让 build pass 增加但 trace pass 下降更多,总帧收益成立;若 build pass 增加超过 trace pass 下降,应提高 rebuild 触发阈值或使用局部 BLAS 拆分。

本章知识点总结

  • BVH 定位:BVH 用层级包围盒把 ray 查询从全量 primitive 扫描转成节点筛选和叶子精确测试。
  • 节点职责:内部节点负责缩小搜索范围,叶子节点负责提交少量 primitive 给精确相交函数。
  • AABB 角色:AABB 用低成本区间测试判断 ray 是否进入子树,是 traversal 带宽和早停能力的核心输入。
  • 遍历质量:高质量 BVH 会减少访问节点、减少叶子 primitive 测试,并让 closest-hit 查询更早缩短 tMax
  • Median Split:median split 构建快且树高稳定,适合均匀分布和教学实现,但在非均匀场景中容易产生重叠节点。
  • SAH 取舍:SAH 用表面积和 primitive 数量估算未来查询成本,适合静态高质量结构,构建成本高于简单划分。
  • LBVH 特征:LBVH 用 Morton code 把构建转成排序问题,适合 GPU 并行快速构建和短生命周期动态几何。
  • HLBVH 组合:HLBVH 用 LBVH 构建底层 treelet,再用 SAH 优化上层,平衡构建速度和 traversal quality。
  • 布局影响:线性节点数组、连续叶子 primitive、紧凑 metadata 和 compaction 会直接降低 traversal 的内存带宽压力。
  • 动态更新:refit 保持拓扑并更新 bounds,rebuild 重新划分 primitive,partial rebuild 把成本限制在 dirty region。
  • TLAS/BLAS:TLAS 管理实例 transform 和 BLAS 引用,BLAS 管理 mesh 几何结构,二者分离能降低动态场景更新范围。
  • 蒙皮判断:蒙皮角色在小幅变形时适合 refit,bounds expansion 和 ray steps 上升时应触发 rebuild 或局部 BLAS 拆分。
  • 性能指标:build time、node count、ray steps、memory bandwidth、hit/miss ratio 需要联合解释,单个数字无法判断 BVH 质量。
  • 复盘顺序:先分离 build 与 trace,再按 ray type 看 steps,然后检查节点热点、bounds overlap、叶子大小和资源带宽。
  • 工程结论:BVH 优化的目标是整帧稳定,构建成本、遍历质量、内存带宽和动态更新策略必须放在同一个 frame budget 中评估。