news 2026/10/17 5:59:50

深入理解BVH:从包围体到光线追踪加速的核心算法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解BVH:从包围体到光线追踪加速的核心算法

从第一次在渲染器里面对几百万三角形的场景开始,我就在幻想一件事:能不能让每根光线只跟很少的三角形做求交,而不是把所有三角形都测一遍。暴力遍历在纯理论演示里还能忍,一旦模型稍微复杂一点,帧时间立刻变成分钟级。后来接触并真正吃透BVH(Bounding Volume Hierarchies,层次包围体结构)之后,才明白原来加速结构的核心思路早就被前辈们解决得非常漂亮:用一片“廉价包围体”把三角形组织成树,光线先跟树的节点做粗测试,大部分光线根本走不到三角形那一步就被直接拒绝。

这篇文章我就按自己理解BVH的顺序来写:先拆名字搞清楚它在干什么,再讲如何构建一棵能用、好用的BVH,然后聊光线怎么在这棵树上遍历,最后补上实际工程里常见的坑。适合刚接触光线追踪、碰撞检测或任何需要空间加速结构的开发者,也适合想把自己手搓渲染器性能再拉一把的人。内容不涉及特定商业引擎的细节,只讲原理和通用实践,读完你可以在自己的代码里按同样的思路落地。

1. 什么是BVH:从名字拆解核心思想

1.1 Bounding Volume(包围体)到底是什么,为什么需要它

BVH 这个名字拆开看就两个词:包围体(Bounding Volume)和层级(Hierarchies)。包围体是指用一套比原始几何简单得多的形状,把一组三角形或几何物体“包起来”。最常见的包围体是轴向对齐包围盒(AABB),也有用有向包围盒(OBB)、球体、胶囊体的。它们的共同点是:求交测试极其便宜,而且可以严格包含住内部所有几何。

为什么需要这层外套?因为在实际场景里,光线跟三角形的求交测试是很贵的,要做重心坐标换算、浮点运算、边缘判定。如果场景里有五十万个三角形,每一根光线都想跟它们逐个做测试,那就彻底失控了。包围体的作用就是一个“先遣队”:光线先测这个盒子,盒子都没碰到,里面的三角形就完全不用管。只有光线穿过了盒子,才进到盒子内部去做更细的测试。

这里有个很容易被忽略的前提:包围体必须“紧”。盒子包得越松,光线越容易撞上它,然后白跑进去做一堆无意义的子节点测试。所以构建BVH的时候,“叶子节点的包围体要尽量贴合内部三角形”是一项核心目标,不是可有可无的优化。

1.2 Hierarchies(层级结构)怎么把这些包围体串起来

单个包围体只能解决“一整个场景是否被光线碰到”的粗筛问题。如果场景本身很大、三角形分布不均匀,那一个大盒子命中之后,你仍然要面对所有三角形。这时候就需要层级:把三角形集合递归地分成小组,每一组都套一个更小的包围体,小包围体再被上一级的大包围体罩住,最终形成一棵二叉树。

树的最上层是根节点,它用一个大包围体罩住整个场景。根节点往下分左右两个子节点,每个子节点又各自罩住一部分三角形,再往下继续细分,直到叶子节点里存放真正的三角形数据。这个结构的好处是:光线撞击根节点后,只需要决定进入左子树还是右子树;如果某一侧的包围体没被击中,那一整块子树都可以直接跳过。

用生活化的例子来类比,BVH 就像快递仓库的分区管理。仓库入口处先看“这个片区有没有可能包含你的包裹”,没有就直接拒收;有就进入片区,再看这个片区里的某个货架,再看货架上的某个格子。这个“层层深入、逐级排除”的过程,本质上就是BVH遍历的过程。

1.3 为什么树结构比网格和八叉树更适合大规模场景

在讨论加速结构的时候,经常绕不开均匀网格、八叉树、KD树。每一样都在特定场景里有一席之地,但BVH能在现代渲染器里成为默认选项之一,有自己的逻辑。

均匀网格是把场景切成一堆大小完全相同的格子,光线沿格子走,命中一个格子后再测格子里的三角形。对于粒子、流场这类分布非常均匀的数据,网格简单粗暴又好用。但真实渲染场景的三角形分布极不均匀:一个角色模型的头部可能有几万个三角形,而周围空荡荡的背景可能只有几百个。均匀网格要么为了照顾密集区域把格子切得很细,要么为了节省内存把格子设得很粗,结果在哪头都吃亏。

八叉树则是对空间做自适应划分,空区域不细分,密集区域多细分。它在体素渲染和一些游戏引擎里很常见,但它的节点存储和更新逻辑要复杂一些。相比之下,BVH是围绕几何自身来构建的,树的每个节点对应的是“一组几何物体在空间里占住的区域”,而不是把场景切成均等的体块。这种以物体为中心的性质,让它在处理动态模型、不规则分布时都非常自然。

还有个实际考量是二叉树在实现上最简单。递归构建、递归遍历、数组存储、栈遍历,逻辑都非常干净。而KD树通常在静态场景里加速效果很猛,但动态更新麻烦得多,所以它更多出现在离线烘焙等场景中,不太适合需要频繁更新几何的现代实时路径。

2. 构建BVH的关键环节:从原始模型到搜索树

2.1 收集图元并确定拓扑:构建一棵树的基础步骤

构建BVH的第一步,是把场景里的所有三角形(或者其他图元)收集进一个数组。这个数组就是叶子节点最终要引用的数据源。为什么要强调这一点?因为很多新手在构建时会把三角形数据直接复制进每个节点,结果内存爆炸。正确做法是维护一个统一的三角形数组,树节点只保存三角形索引的范围或索引列表。

接下来是选择构建方式,最常见的是自顶向下。它的流程是:

  1. 把当前三角形集合的所有重心点收集起来,计算出它们的外包围盒。
  2. 选定一个分割位置,把三角形分成左右两组。
  3. 对左右两组分别递归执行同样的过程,直到某一组的三角形数量小于设定阈值。

构建时最关键的一步是“分割位置的确定”。最简单的方案是按重心坐标的中位数切开,把三角形平分成两半,也就是所谓的中点分割。这种方案写起来容易,构建也快,但很容易切出不合理的结构。比如三角形聚集在场景左上角,中位线切下去后两边包围体一大一小,出现一堆空荡地带,光线遍历时就会做许多无效测试。

所以工程里更靠谱的做法是引入SAH(Surface Area Heuristic,表面积启发式)来决定分割位置。这个启发式不是一个“绝对正确”的答案,而是一个经过大量实践验证的成本模型,能够尽量让树的整体求交开销最小。

2.2 SAH启发式:算一算切在哪里最划算

SAH的核心是把“包围体层级结构的总开销”转化成可度量的公式,然后找让这个成本最小的分割方式。它的典型形式如下:

C = C_trav + (A_L / A_parent) * C_intersect * N_L + (A_R / A_parent) * C_intersect * N_R

其中,C_trav 是访问一个节点的固定开销,A_parent 是父节点包围体的表面积,A_L 和 A_R 分别是左右子节点包围体的表面积,N_L 和 N_R 是左右子节点里的三角形数量,C_intersect 是跟单个三角形求交的平均开销。

理解这个公式的关键是“表面积比值”。一个随机的光线在命中父包围体后,进入左子节点还是右子节点,概率近似等同于那个子包围体的表面积占父包围体表面积的比例。所以左边的预期求交开销 = 左边表面积占比 × 里面三角形的数量 × 每个三角形的求交成本。把所有候选分割位置按这个公式算一遍,选最少的那一刀就对了。

举个具体数字例子。假设一个场景根节点包围体表面积是1000,内部有5万个三角形。如果某个分割让左右两个子节点表面积都是500,两边各有2.5万个三角形,那预期求交成本就大得吓人。可如果另一个分割让左子节点表面积只有20,里面只有500个三角形;右子节点表面积480,里面有2.45万个三角形,那成本就会比前一种方案低很多,因为光线很大概率会直接错过左侧那个小而紧的区域,即便碰到右侧,成本也低于两边各半的切法。

当然,SAH的实现不能真的对每个可能的切点都构建完整包围体,那会回到O(N^3)级别的复杂度。常规做法是桶化(binning):把当前节点在某条轴线上分成几十个桶(比如32桶),三角形按重心坐标落入对应桶,再通过一次扫描累积每个切分点两侧的包围体表面积,找出成本最低的分割。这样构建复杂度能被压到接近O(N log N),百万三角形场景也能在可接受时间内Build完成。

注意:C_trav 和 C_intersect 的实际取值在不同平台上有差异。CPU软渲染里通常把相交测试权重设得更高一点,例如C_trav=1、C_intersect=2;而在GPU上,节点访问和三角形求交的损耗模型不同,需要微调。不要对我写的权重盲目照抄,实测后根据帧耗时来调整才是正道。

2.3 自顶向下与自底向上:两种主流构建路线的取舍

绝大多数渲染器默认使用自顶向下,因为实现简单、递归逻辑清晰。你在把三角形集合不断二分的过程中,能顺手计算每个节点的包围体,建树过程就是一次深度优先遍历。

自底向上的路线则相反:先把每个三角形都变成独立叶子,然后一轮轮去“合并”最合适的两个相邻组,直到最终合并出根节点。它的好处在于合并策略能全局考虑包围体表面积增量,往往能得到更紧的树结构。但代价是实现复杂度显著上升,特别是要维护一个优先队列来动态寻找表面积增量最小的组合,构建成本相对较高。所以自底向上通常用在离线计算量大但不要求实时的高级工具中,对实时渲染和教学Demo而言,自顶向下加SAH已经足够。

2.4 叶子节点大小与终止条件:为什么不要只放一个三角形

有人会觉得,BVH的叶子节点干脆只放一个三角形,树就最精确了。这话听起来有道理,但实际性能会很难看。因为每个叶子节点都会对应一个包围体,如果一个包围体里就一个三角形,那这棵树的高度会非常高,递归遍历和节点访问的固定开销会吃掉所有优势。

实际工程里通常给叶子节点设置一个三角形数量上限,常见值是8个或16个。也就是说,当递归划分后某一组三角形的数量不超过这个阈值,就把它们整个当成一个叶子节点,不再继续切下去。为什么是8到16?因为叶子节点里三角形数量少时,遍历深度和三角形逐一求交的开销能达到一个较为划算的平衡点。现代CPU和GPU都有比较强的并行吞吐能力,一次性把16个三角形的数据喂给SIMD单元依次测试,往往比再往下深挖两层树结构更划算。

叶子节点里的三角形数量也不是越少越好。如果场景中的三角形非常密集且相互穿插,一个8三角形的叶子造成的包围体可能并不小;而如果场景很稀疏,16甚至32个三角形塞在一个大包围体里也可能拖慢求交。最稳的做法是把它做成一个可配置参数,根据自己场景的三角形分布来实测调优。

3. 遍历与相交测试:BVH真正加速之处

3.1 光线与BVH的标准相交流程:递归与最近命中

有了BVH树,光线的求交过程就变成一棵树的深度优先遍历。最基本的写法是:

Intersect(ray, node): if ray 与 node.bbox 不相交: return no_hit if node 是叶子节点: return 对叶子内所有三角形求交 else: hit_left = Intersect(ray, node.left) hit_right = Intersect(ray, node.right) return 合并左右结果

这个递归版本在理解上最直观,但在追求性能的实际实现里,会把递归改成显式栈遍历。尤其是GPU上,递归很容易受调用栈深度限制,显式栈配合数组节点存储是更普遍的选择。

“最近命中”查询是渲染里最常用的操作。它要求光线在所有命中位置里找到t值最小(距离最近)的交点,而不是命中就停。具体业务流程是:遍历到某个节点时,先跟包围体做相交测试,失败的直接跳过;成功进入子节点后,优先访问距离光线起点更近的那个子树。这能尽早找到一个较小的t作为候选,后面再遇到包围体最近距离已经大于当前候选t的子树时,就可以直接剪枝跳过。

3.2 最近命中与任意命中:两种截然不同的遍历策略

渲染里还有个常见的操作是“任意命中”,典型场景是阴影测试。判断一个点是否被阴影遮挡,只需要知道光线有没有打到任何物体,不需要知道最近的物体是谁。这时候可以在第一次命中后就提前终止遍历,能省下大量计算。

所以写遍历代码时,最好把最近命中和任意命中分开实现。最近命中需要完整探索所有可能子树,只有靠剪枝来减少无效访问;任意命中则可以在命中后立即返回。把两条路径混在一起,代码上看着“通用”,性能上却容易为阴影测试白白付出最近命中才需要的排序成本。

3.3 栈式遍历与近远子树排序

显式栈遍历的实现思路是:维护一个节点栈,一开始把根节点压入。弹出节点后,先做包围体相交测试。如果命中内部节点,就分别计算左右子节点包围体与光线的相交距离。先访问近节点,把远节点存入栈中留待后面处理。这样能保证在访问远节点之前,你手上已经有一个更近的候选命中可以做剪枝。

这里有个非常容易踩的误区:有些实现图省事,不计算左右子节点的访问顺序,只按固定顺序入栈。对于最近命中查询,这会破坏剪枝的机会,让性能突然掉一截。实测在中等规模场景里,省略近远排序可能让渲染时间增加两成到三成。

栈式遍历还需要注意栈的容量。树的深度在最坏情况下可能达到几千层,虽然常见场景一般不会这么深,但用固定大小数组当栈的话,记得设上限并加上溢出保护。我在调试一个复杂模型时遇到过树深异常增大导致栈爆掉的诡异崩溃,最后定位原因发现是构建阶段没有正确排除空节点,导致某条路径上生成了大量无意义节点。

3.4 包围体选型对比:AABB、OBB还是球体

包围体类型构建复杂度相交测试速度包围紧密度适用场景
AABB低高中渲染加速结构默认选择
OBB中中高物体自身旋转明显,需要更紧密包裹
球体最低最快低距离粗筛、粒子系统
胶囊体中中高骨骼、关节等长条几何

AABB之所以是渲染领域的默认选择,是因为它的构建和维护最简单。一个AABB只需要记录最小点和最大点,更新包围体就遍历子节点取min和max。OBB虽然能把倾斜细长的物体包得更紧,但每次物体旋转都要更新整个树的旋转矩阵,相交测试也更贵。球体和胶囊体在个别场景有优势,但当你要组织成千上万个三角形时,它们的紧密度不足会直接导致包围体命中概率偏高,反而拖慢整体遍历。

实时渲染中还有一个非常重要的场景:当物体在做刚性旋转时,AABB会比原始几何大出一圈,导致包围体测试误命中率上升。这时你可以选择每帧重算AABB,或者在一部分模型上换用OBB。就我的经验而言,九成情况下重算AABB已经够用,除非是类似长条刀刃快速旋转这种极端紧密度要求的场景。

3.5 遍历性能的直观感受

构建一棵结构正确的BVH后,性能提升是非常直观的。对一个百万三角形的角色模型,如果把光线直接跟所有三角形求交,每根光线要做约百万次三角形测试。而使用BVH后,大多数光线只需要做十几个包围体测试,以及个位数次的三角形测试。差距是几个数量级的,这也是BVH能成为现代渲染器核心结构的原因。

4. 实际应用:BVH在渲染引擎里的角色

4.1 光线追踪加速:离线渲染与实时渲染

离线渲染器在光线反弹时,要反复寻找场景中离光线起点最近的交点。这个操作每反弹一次就要执行一遍,而一帧画面可能有上亿根光线。BVH正是让这个“找交点”的过程从暴力搜索变成路径搜索的关键。大多数离线渲染器的加速结构就是BVH,或者以BVH为核心再套一层网格加速。

实时渲染这些年大规模普及硬件光追后,BVH的概念又被推到了前台。实际光追流程中通常存在两层加速结构:顶层管理场景中各个对象的包围体,底层管理单个对象内部的三角形网格。光线先测顶层的BVH,快速定位到可能命中的物体,再进入对应物体的底层BVH,最终在物体内部找到三角形的交点。这套机制的底层逻辑跟软件实现完全一致,只是硬件单元对包围体测试做了专用加速。

4.2 动画与变形网格的更新策略

动态模型对BVH提出了一个“旧树还能不能复用”的问题。模型在动画中发生形变时,原有BVH里节点包围体内的三角形可能已经移动到了别处,再直接拿旧树做求交结果就不对了。常见的三种处理方式:

第一种是重拟合(refit)。保持树的拓扑结构不变,只从叶子向上重新计算每个节点的包围体范围。这样做的成本很低,每帧只需要重新遍历一遍所有节点求min和max。缺点是随着形变加剧,树结构逐渐不能反映物体真实分布,包围体越来越松,遍历性能明显下降。

第二种是完全重建(rebuild)。每帧从三角形索引开始重新构建整棵BVH,构建成本最高但加速效果最好。对于meson级形变或拓扑变化的模型,重建是唯一稳妥的方案。

第三种是局部重建(partial rebuild)。只对几何变化剧烈的局部区域重建子树,其他区域继续沿用旧树。这是一种折中方案,在布料模拟等应用中非常有用。

我的建议是:写入一个可切换的策略,默认用refit观察效果。如果跟踪到的渲染耗时明显恶化,再换成局部重建或全量重建。不要一上来就在每帧重建,往往refit的收益和成本比已经足够满足大多数实时需求了。

4.3 碰撞检测里的BVH:不止为渲染服务

BVH的另一个重要应用场景是物体之间的碰撞检测。想象两个实体模型,每个模型各自拥有一棵BVH。要判断它们是否碰撞,可以把两棵BVH的根节点做包围体相交测试。不相交就直接判定两个物体未碰撞;相交则继续递归比较它们的子节点,直到深入到叶子节点,再对叶子里的三角形或碰撞体做精确求交。

这个“两棵树相互遍历”的过程让碰撞检测从O(N*M)的暴力复杂度,下降到接近对数级别。游戏里的复杂角色碰撞、柔体仿真、机器人路径规划,到处都有BVH的身影。如果你已经理解了单棵BVH的构建和遍历,双树碰撞检测只是把遍历时的“光线”换成“另一个节点包围体”而已。

5. 常见问题与排查技巧实录

5.1 构建期:退化的三角形与浮点数灾难

构建BVH时最常碰到的一类问题,是几何数据本身带有异常。比如场景里存在面积为0的退化三角形,或者大量共面的三角形重叠在一起。这类数据会导致包围体表面积计算结果为0,进而在SAH公式里出现分母为0的除零错误。还真不是逻辑写得不对,就是数据有问题。处理办法很简单:建树前先筛掉退化三角形,或者给包围体计算时加一个极小厚度做保护。

另一个常见的诡异现象是,模型在导入或变换后出现了负缩放翻转,导致三角形法线方向反了,同时包围体的min和max字段写反,树结构直接崩掉。测试的时候养成一个习惯:构建完根节点后,先手动检查根节点的包围体是否覆盖了场景里所有顶点的坐标范围,不对就直接在构建入口打调试日志。

5.2 遍历期:剪枝失效与近远顺序错乱

遍历BVH时最大的性能杀手是剪枝失效。某轮迭代后已经找到了一个比较近的候选交点,可下一个子树包围体的最近距离明明已经比这个候选交点更远了,代码却还是钻进去做了一轮完整遍历。出现这种情况的原因,大多是没有记录全局的最近t值,或者没有在递归入口把它传进去。

还有一种错误是近远子树的访问顺序反了。先访问远处的子树会给后续带来很大干扰;只要把访问顺序换成先近后远,并利用近节点的命中结果对远节点做剪枝,很多场景的遍历开销会明显下降。这两条我都踩过坑,第一次实现BVH时保性能暴差,查了很久才发现是递归入口没有把当前最佳命中的t传下去,导致所有剪枝判断全部失效。

5.3 更新期:refit和rebuild的度要怎么拿

处理动态模型时,我见过最典型的坑是高频次重构整棵BVH。比如一个角色动画每帧都做全树重建,构建时间占比高到让帧率大幅下跌。实际上,角色骨骼动画造成的形变如果相对局部、缓慢,refit就完全够用。只有当模型本身的变形非常大,比如角色做剧烈弯折或者网格拓扑结构变化时,才需要重建。

反之,也有喜欢死守refit不放手的使用者:物体发生了剧烈形变,refit虽然每帧更新包围体,树结构却已经乱套,包围体越来越大,遍历性能急剧下降。这时就算重建成本再高,也得果断切换。我的判断标准是:当某帧的refit遍历耗时比rebuild后耗时高出一倍,并且持续多帧时,就必须切重建。

5.4 内存布局与缓存友好的细节

很多人把BVH建好就完事,忽略了节点在内存里的排布方式。其实BVH遍历的性能跟缓存命中率有很大关系。理想情况是把整棵树放在一块连续内存里,按照深度优先顺序存成数组,这样子节点和父节点的地址距离很近,遍历时CPU缓存能吃到更多数据。把节点定义成紧凑的小结构体,避免塞入大量与求交无关的辅助字段。

关于三角形数据的存放,也建议把需要用到的顶点信息排列成SoA(Structure of Arrays)或紧凑的连续结构,保持内存顺序与遍历顺序一致。数据量大起来时,这两项优化带来的帧时间差异非常明显,而且代码改动量并不大。

写在后面:我对BVH的一些实际操作体会

自己动手写过BVH之后,我最大的感受是:这结构概念听起来不难,但每一步都有隐藏的取舍。第一次实现时我用中点分割偷懒,结果场景稍大性能就不行;后来认真补上SAH和桶化,性能立刻有可见提升。可紧接着又被退化的共面三角形和剪枝失效的问题折磨了好久,这才意识到构建和遍历的性能瓶颈往往出在那些不起眼的边界条件里。

我自己的建议很简单:先写一个正确但简单的BVH,用中点分割能跑通就好。然后加入SAH并保留开关,方便对比性能变化。等基础结构稳定了,再去考虑显式栈遍历、内存布局、动态更新这些进阶内容。每加一层优化,你都能清楚看到它带来的差异。这种循序渐进的风格,比我一开始就硬啃全套优化方案要顺畅得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 5:58:59

Seaborn三大核心函数:histplot、scatterplot、boxplot实战精要

1. 为什么是这三个函数?——从真实项目场景反推Seaborn的“核心三角”你有没有过这种经历:打开Jupyter Notebook,想画个图分析数据,结果在Seaborn文档里翻了二十分钟,光是catplot、relplot、displot、jointplot、pairp…

作者头像 李华
网站建设 2026/10/17 5:58:36

基于MP2642GR-Z与ATmega1284的两串电池被动均衡方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 5:58:15

gmapping地图重置实战:从杀进程到根治坐标系漂移与旧图残留

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 5:57:56

jQuery筛选系统实战:DOM显隐驱动的高性能过滤方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 5:57:56

程序设计第7讲OJ作业:高频题型、常见陷阱与AC自查清单

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华