层次包围结构
需要预先学习:
1. 层次包围结构
两个看似无关的问题,其实是同一件事:
两者的共同点:绝大多数测试的结果都是"没有命中",但每一次"没有"都要花一次测试的代价换回来。层次包围结构(hierarchical bounding structure)的思路是让"没有"变得成片:把物体按空间位置组织成一棵树,每个节点用包围体框住它负责的所有内容;光线或视锥与某个节点的包围体不相交,整棵子树一次排除。单次查询的复杂度从 降到 。
按"切的东西"不同,层次结构分两大族:
| 族类 | 切的是什么 | 代表结构 |
|---|---|---|
| 空间划分(spatial subdivision) | 空间本身 | 均匀网格、八叉树、BSP 树、k-d tree |
| 物体划分(object subdivision) | 物体的集合 | BVH |
注:剔除总结 提到过的场景图(scene graph)虽然也是"树 + 包围体",但它表达的是物体间的变换父子关系(父物体移动带动子物体),不是加速结构,本篇不展开。
2. 包围体:松与紧的权衡
层次结构的每个节点都要回答同一个问题:"这次查询有没有可能碰到我负责的内容?"回答它靠的是包围体(bounding volume)。可选的形状从松到紧大致排成一列:
| 包围体 | 紧密度 | 求交成本 | 特点 |
|---|---|---|---|
| 包围球 | 最松 | 最低 | 旋转不变,物体怎么转都不用重算 |
| AABB | 较松 | 低 | slab 法逐轴求区间(见 光线追踪),物体旋转后需重算 |
| OBB | 较紧 | 较高 | 随物体旋转,长条形物体贴合度远好于 AABB |
| k-DOP | 更紧 | 中 | 用 k 组斜半空间夹住物体,AABB 是 k=3 的特例 |
| 凸包 | 最紧 | 最高 | 精确贴住物体,构建与测试都贵,实践中少用 |
选择是纯粹的权衡:越紧的包围体让树越"准"(误命中越少,深入子树的次数越少),但每次测试越贵。实践中 AABB 是绝对主流——测试足够便宜、足够好算,且树结构带来的指数级收益远大于单个包围体松紧的线性差异。
还有一个后面要用到的性质:包围体的表面积越大,一条随机方向的光线击中它的概率越高。这直觉上说得通——盒子越大,越容易挡住路。这个"表面积 ↔ 命中概率"的对应关系是 SAH 构建策略(见后文)的理论基础。
3. 空间划分结构
空间划分结构递归地切空间本身:每个节点对应一块空间区域,子节点把这块区域继续细分。区域之间不重叠,合起来恰好是父区域。
3.1 均匀网格
均匀网格(uniform grid)是最朴素的方案:把空间切成大小相同的立方体格子,每个物体登记到它覆盖的所有格子里。查询一条光线时,沿着光线方向逐格前进(一种 3D 直线光栅化),只与途经格子里的物体求交。
它不建树,构建近乎免费,每物体 O(1)。但格子的粒度是个两难:
- 格子太大:一个格子里挤满物体,逐格前进退化成逐物体测试;
- 格子太小:大物体(长墙、地板)横跨成百上千个格子,登记开销与内存爆炸,而大部分格子空无一人——“体育场里一只茶壶”(teapot in a stadium)说的就是这种空间密度极不均匀的场景。
网格适合"物体小而均匀分布"的场合(粒子、弹幕),密度悬殊的场景就力不从心了。
3.2 八叉树
八叉树(octree)把空间递归地八等分:每个节点对应一个立方体区域,切成 2×2×2 八个子立方体,物体分给完全包含它的子节点。一个物体跨在几个子区域的边界上怎么办?两种处理:
- 上浮:物体存放在能完全容纳它的最浅节点里。物体只存一份,但高层节点会积累跨边界的物体,包围体变松;
- 下沉重复:物体切开登记进每个相交的子节点。查得准,但同一份图元在树里存了多份。
这是所有空间划分结构的共同痛点:空间与物体不是一一对应的,切割面总要切到一些物体身上。四叉树(quadtree)是八叉树的二维版本,同样的思想切平面,常用于地形、开阔场景的剔除。
3.3 BSP 树
BSP 树(Binary Space Partitioning,二叉空间划分)用任意朝向的平面递归二分空间——不再限于轴对齐。切割平面通常直接选场景中的多边形所在平面,于是树的"前侧"与"后侧"恰好对应几何的前后关系:从任意视角出发,按"远子树 → 自身 → 近子树"的顺序遍历,就得到严格的从后到前的绘制序——这是画家算法的完美替代,也是早期 FPS 引擎(Quake 系)室内场景可见性处理的基础。
代价是构建:切割平面要精心挑选(尽量少切动物体、尽量均衡),好的构建很贵;而且物体一旦移动,树的划分就失去意义。BSP 是静态场景专属的结构。
3.4 k-d tree
k-d tree(k 维树,三维场景就是 3d-tree)是 BSP 的轴对齐版:每个节点沿某一个坐标轴放一个切割平面,位置任意,把空间严格二分:
┌─ x 轴切:x = 3
│ ┌─ y 轴切:y = 1
空间 ────┤
│ └─ y 轴切:y = 5
└─ x 轴切:x = 9
k-d tree 对光线查询有一个独一无二的优势:空间被不重叠地划分,光线穿过树天然有序。光线先到达的区域先被访问,一旦命中立即终止——而 BVH 的子节点包围盒允许重叠,两个子树都可能命中,往往都要走一遍。因此 k-d tree 曾长期统治离线渲染器(PBRT 的经典实现)。
它的软肋同样明显:构建昂贵(通常也要靠 SAH 选切割平面,代价高于 BVH 构建),且物体移动后无法局部修补——空间划分定了,物体却不在原位了。k-d tree 属于静态场景。
3.5 四种结构对比
| 结构 | 划分方式 | 构建成本 | 动态更新 | 适合 |
|---|---|---|---|---|
| 均匀网格 | 均匀切块 | 近乎免费 | 容易(重登记格子) | 均匀分布的小物体 |
| 八叉树 | 递归八等分 | 中 | 较难 | 密度不均的空间查询 |
| BSP 树 | 任意平面 | 贵 | 几乎不可用 | 静态几何的严格前后序 |
| k-d tree | 轴对齐任意位置 | 贵 | 几乎不可用 | 静态场景光线求交 |
4. BVH:把物体组织成树
层次包围盒(BVH,Bounding Volume Hierarchy,见 光线追踪 的构建与遍历基础)是物体划分的代表:递归切的是物体的集合——把三角形按空间邻近性分成两组,各自求包围盒,再递归分组。空间划分与物体划分的一字之差,带来几个本质区别:
- 图元只存一份。每个三角形属于恰好一个叶节点,不存在跨边界登记多份的问题;
- 节点包围盒允许重叠。两组物体的包围盒可以有公共区域,这不影响正确性,只影响效率;
- 物体可以动。一个物体移动,只需让它所在路径上的节点包围盒"长大"一点,树的结构不必推翻(见后文 refit)。
4.1 构建:分割策略与 SAH
光线追踪 里用"最长轴中点分割"演示了自顶向下的构建框架,这里展开分割策略本身。朴素的两种:
- 等量分割(equal split):按最长轴坐标排序,从中间切成数量相等的两半。优点是树一定平衡;缺点是空间上一侧可能稀疏、一侧密集,切出来的包围盒又大又重叠;
- 中点分割(center split):以包围盒中心为界分成两侧。对"一坨物体 + 一条离群长条"的分布会切出一侧为空——空的一侧继续切还是空,最坏时递归不收敛。
两者都没回答真正的问题:哪种分割让光线求交最快?SAH(Surface Area Heuristic,表面积启发式)直接对求交代价建模。它基于两个假设:
- 访问一个内部节点要付一次遍历代价 (包围盒求交);
- 测试一个图元要付一次求交代价 。
于是一个候选分割的期望代价是:
其中 是父节点包围盒表面积,、 是两侧子盒的表面积,、 是两侧的图元数。 正是"随机光线击中左盒的概率"(前文表面积性质的直接引用):一侧的概率大且图元多,它就贡献更多的期望求交。AABB 的表面积好算:
遍历所有候选分割,取 最小的一个。候选位置不可能枚举连续值,实践中用 binning(分桶):把父包围盒沿某轴切成 8~16 个桶,每个物体按质心落进桶里,枚举"桶边界"作为候选分割——每个轴只有十几个候选,整个构建保持近似线性:
// 伪代码:SAH + binning 选分割
Split find_best_split(const vector<Triangle>& tris, const AABB& box)
{
Split best; // 记录最优分割:轴 + 桶边界
float best_cost = cost_of_leaf(tris.size()); // 与"不再分割"比较
for (int axis = 0; axis < 3; ++axis)
{
int buckets[NUM_BUCKETS];
AABB bucketBox[NUM_BUCKETS];
fill_buckets(tris, box, axis, buckets, bucketBox);
// 枚举桶边界:前 i 个桶为左侧,其余为右侧
for (int i = 1; i < NUM_BUCKETS; ++i)
{
AABB left = union_of(bucketBox, 0, i);
AABB right = union_of(bucketBox, i, NUM_BUCKETS);
float cost = C_TRAV
+ (surface_area(left) * count(buckets, 0, i)) / surface_area(box) * C_ISECT
+ (surface_area(right) * count(buckets, i, N)) / surface_area(box) * C_ISECT;
if (cost < best_cost)
{
best_cost = cost;
best = {axis, i};
}
}
}
return best; // 若没有任何分割优于当叶节点,返回"不分割"
}
SAH 的构建结果明显好于中点法:它天然避开空侧(概率为零的一侧不贡献代价),也天然在密度高的地方切得更细。
4.2 遍历:先近后远
光线追踪 给出的遍历是"两个子树都递归"。完整的版本有一个重要优化——有序遍历(ordered traversal):
- 先与两个子节点的包围盒求交,得到各自的进入距离 ;
- 先访问近的一侧——近侧的命中点一定比远侧更近;
- 近侧返回后得到当前最近命中 ,若远侧的 ,远侧整棵子树直接跳过。
// 伪代码:BVH 有序遍历(与 [[光线追踪]] 基础版的差异在第三步)
bool intersect_bvh(const Ray& ray, const Node* node, Hit* hit)
{
float tl, tr;
bool inL = intersect_ray_aabb(ray, node->left ->box, &tl);
bool inR = intersect_ray_aabb(ray, node->right->box, &tr);
if (!inL && !inR) return false;
const Node* first = (inL && (!inR || tl <= tr)) ? node->left : node->right;
const Node* second = (first == node->left) ? node->right : node->left;
float t_second = (first == node->left) ? tr : tl;
bool hitAny = intersect_bvh(ray, first, hit); // 先走近侧
// 远侧的进入点已经比最近命中还远 -> 整棵远子树不可能更近,剪掉
if (inR && inL && t_second < hit->t)
hitAny |= intersect_bvh(ray, second, hit);
return hitAny;
}
注:k-d tree 的空间严格不重叠,这个"先近后远 + 提前终止"是完备的——命中即停;BVH 子盒可能重叠,远侧无法彻底跳过,只能被 裁剪。这是两者遍历效率此消彼长的根源。
4.3 动态场景:refit 与 rebuild
物体移动后,旧 BVH 的包围盒不再正确。两类应对:
- Refit(重新拟合):树结构不动,从叶到根重算每个节点的包围盒,一趟 。极快,但有退化效应——物体各自乱动后,节点的包围盒被迫"迁就"所有孩子,越长越松,查询效率持续下滑;
- Rebuild(重建):推倒重来,构建一棵全新的 SAH 树。质量最好,构建一次的代价不低。
实时应用(游戏、物理引擎)的常规打法是混合:每帧(或每物体移动时)refit,隔一段时间或检测到退化时 rebuild;更进一步的是渐进式重建——把重建工作切成小片,每帧做一片,摊平尖峰。BVH 能这么灵活,正是物体划分的红利:图元与节点的归属关系是"登记制",包围盒松一点无伤正确性。k-d tree 没有这个待遇——空间划分一旦定死,物体一移动整个前提就塌了。
这也是 BVH 统治实时领域的根本原因:RTX 显卡上硬件光线追踪遍历的就是 BVH,游戏引擎的碰撞检测、剔除也几乎清一色 BVH 或八叉树。
4.4 紧凑表示:线性 BVH
树节点用指针连接,是 CPU 的习惯、GPU 的噩梦:指针无法搬上显存,且节点散布堆上,缓存行利用率低。线性 BVH 把树压进一个数组:
- 节点按深度优先顺序连续排布;
- 不存指针,左孩子固定是
当前下标 + 1,右孩子存一个 skip 偏移(跳过整棵左子树到达的位置); - 叶节点不存图元列表,存图元数组的区间
[start, count)——图元重排成与叶节点一一对应的连续段。
深度优先排布(数字为数组下标):
0: 内部 左 = 1,右 = 4(skip 3)
1: 内部 左 = 2,右 = 3(skip 1)
2: 叶 [0, 2) 图元 0~1
3: 叶 [2, 3) 图元 2
4: 叶 [3, 5) 图元 3~4
数组: [0 内部][1 内部][2 叶][3 叶][4 叶] ← 节点连续,无指针
图元: [t0 t1 | t2 | t3 t4] ← 与叶节点区间对应
一个节点可以压到 32 字节(AABB 六个 float + 两个索引 + 图元计数),整棵树就是一块连续显存。GPU 遍历用显式栈(数组模拟),跳过子树时直接 i += skip——RTX 的硬件遍历单元、诸离线渲染器的 GPU 后端,内核里都是这副模样。
5. 层次结构用在哪里
| 应用 | 查询内容 | 常用结构 |
|---|---|---|
| 光线追踪求交 | 光线 × 图元 | BVH(实时 / GPU)、k-d tree(离线) |
| 视锥 / 遮挡剔除 | 视锥六平面 × 物体 | BVH、八叉树(见 剔除总结) |
| 碰撞检测 broad phase | 物体包围盒 × 物体包围盒 | BVH、空间哈希(网格的哈希变体) |
| 射线拾取(picking) | 鼠标射线 × 物体 | 直接复用渲染 / 物理的 BVH |
| 大规模粒子 | 点 × 邻域 | 均匀网格、空间哈希 |
值得注意的一点是结构复用:场景的 BVH 建一次,剔除、拾取、光追查询可以共用——它们本质上都是"空间里的东西跟一个查询体相交吗"。现代引擎的 GPU driven pipeline 更是把 BVH 搬上 GPU,剔除与求交在 compute shader 里并行完成。
6. 总结
| 结构 | 切什么 | 图元存储 | 构建 | 动态 | 典型场景 |
|---|---|---|---|---|---|
| 均匀网格 | 空间(均匀) | 可能多份 | 免费 | 易 | 均匀小物体 |
| 八叉树 | 空间(八等分) | 上浮或多份 | 中 | 难 | 稀疏剔除 |
| BSP | 空间(任意面) | 可能切开 | 贵 | 不可 | 静态前后序 |
| k-d tree | 空间(轴对齐面) | 可能多份 | 贵 | 不可 | 静态光追 |
| BVH | 物体集合 | 恰好一份 | SAH 中等 | refit / rebuild | 几乎全部实时场景 |
选型的几条主线:
- 静态还是动态——静态可考虑 k-d tree 的极致查询效率;动态一律 BVH(或八叉树);
- 分布均匀吗——均匀的小物体用网格/空间哈希更便宜;密度悬殊用层次结构;
- 查询多样吗——剔除、拾取、光追共用一棵 BVH,比各建各的划算。
所有这些结构背后是同一个想法:与其逐个回答"这个有没有可能命中",不如把空间里的东西先分好堆,让一次否定剪掉一大片——把逐个检查变成成片排除。


