☰
光线追踪加速结构实战:从KD-Tree到BVH的构建与选型
2026/10/14 1:18:44 网站建设 项目流程

这段时间我在做离线渲染器里的光线追踪模块,场景从几万个三角形一路加到几十万,原来那个“每根光线遍历全部三角形”的暴力版本直接跑不动了。刚开始我还觉得是代码写得不够快,后来才知道,问题不在求交函数本身,而在“无谓的求交”太多。真正解决问题的思路,是把三角形组织成一种能快速排除大部分候选者的结构——这就是加速光线追踪要解决的核心问题。这篇文章我系统讲一遍KD-Tree和BVH两种主流方案,包括原理、构建、遍历、实现细节和我在实际工程中踩过的坑。

1. 暴力求交的瓶颈:每根光线都在“全量扫描”三角形

先看暴力方法到底慢在哪。假设一个场景有N个三角形,一根光线要和每个三角形做一次Möller–Trumbore求交,也就是解一个3x3线性方程组。一次求交的运算量大约是几十次乘加,听起来不贵,但问题在于光线数量。

一个1920x1080的画面,即使只算主光线也有约200万根。200万乘以N次求交,当N是10万时就是2000亿次求交。这还只是主光线,还没算阴影光线、反射和折射产生的次级光线。实测下来,一个普通室内场景在这种暴力实现下,一张图要跑十几分钟到半小时,完全没法用于迭代调参。

更关键的是,绝大多数求交都是失败的。三角形在屏幕上的覆盖率通常很低,场景越大,光线命中三角形的概率越小。也就是说,暴力求交的大部分时间都花在“确认不相交”上。

所以加速光线追踪的核心不是让单次求交更快,而是减少需要尝试的三角形数量。空间加速结构做的就是这件事:把三角形按空间位置组织起来,让光线在遍历时能整块跳过不可能命中的区域。

这里有个很直观的类比。你去图书馆找一本书,如果所有书都堆在一个大房间里,你只能一本一本翻;如果按照分类放到不同书架上,你只需要根据索书号直奔某个书架。KD-Tree和BVH要做的事,就是给三角形做“分类上架”。

2. KD-Tree:把空间递归切成二叉树

2.1 核心思想:分割的是空间,而不是三角形集合

KD-Tree的全称是K-Dimensional Tree,在光线追踪里就是轴对齐的二叉空间剖分树。它每次用一个轴对齐平面把当前空间一分为二,然后递归地分割子空间,直到每个叶子区域里的三角形数量足够少。

注意这里的措辞:切的是空间。每个节点对应一个空间区域,左右子树分别覆盖该区域被分割平面分出的两个子区域。三角形按照它们的位置被归到某个叶子节点的三角形列表里。

这个“分割空间”的思路和后面要讲的BVH有本质区别,先记住这一点。因为切的是空间,所以KD-Tree能很自然地处理空区域——一片空旷的空间会被快速切掉,光线经过时直接跳过,不用做任何三角形求交。

构建时的三个关键决策是:沿着哪个轴切、在什么位置切、什么时候停止切。

2.2 构建流程:选轴、选位置、处理跨平面三角形

最朴素的KD-Tree构建方法是轮换切分轴,比如x轴切完切y轴,y轴切完切z轴;分割位置取当前节点包围盒的中心。然后用一个三角形数量阈值(比如8个)作为叶子终止条件,再加上最大深度限制。

但轮换轴加中心切分的做法有一个很明显的问题:如果三角形分布不均匀,中心切会把大量三角形卡在分割面附近,存储和遍历效率都差。更常用的做法是用SAH(第4节详谈)来选择切轴和切位置,但初版实现可以先用“最长轴中点切分”起步。

构建时会遇到一个KD-Tree特有的麻烦:三角形会跨越分割平面。比如一个三角形横在分割面正中间,它既属于左半空间也属于右半空间。处理办法是把这个三角形同时放进左右两个子树对应的叶子节点里,也就是重复存。

这样做的后果是内存里同一个三角形可能出现在多个叶子节点中,KD-Tree的内存开销会膨胀。这也是它后来在工程实践中逐渐被BVH替代的重要原因之一。但在三角形比较均匀、网格比较细的场景里,这个问题的实际影响是可控的。

2.3 遍历逻辑:一个栈解决回溯

KD-Tree的遍历过程值得认真写一遍伪代码,因为它和后面BVH的遍历结构相似,但有一个关键差异。

stack.Push(root); while (!stack.IsEmpty()) { Node *node = stack.Pop(); if (node->isLeaf) { for (Triangle &tri : node->triangles) Intersect(tri, ray); continue; } // 计算光线与左右子树空间区域的相交情况 bool hitL = IntersectAABB(ray, node->left->bounds); bool hitR = IntersectAABB(ray, node->right->bounds); if (hitL && hitR) { // 先处理近的那一侧,远的压栈等回溯 stack.Push(node->farChild); StackTraverse(node->nearChild); } else if (hitL) { StackTraverse(node->left); } else if (hitR) { StackTraverse(node->right); } // 都没有命中就直接跳过 }

KD-Tree内部节点不存三角形,只存左右子空间范围。遍历时靠栈来保存还没访问的另一侧子树。由于左右子树是互补的空间划分,光线要么只进入一侧,要么两侧都进入但存在先后顺序,不会有第三条路径。这也是KD-Tree遍历效率高的原因——分支结构很干净。

但我在实现时很快发现,KD-Tree的构建质量直接决定性能。同一个模型,轴轮换分割和SAH分割,渲染时间可能差出2到3倍。问题在于,SAH的KD-Tree构建代码比BVH的SAH要难写不少,因为要反复用三角形列表的子集来算候选分割面的代价。

2.4 KD-Tree的成败:空区域剔除强,但动态场景是软肋

KD-Tree最大的优势是能快速剔除空区域。比如一个房间里有一堵墙,墙面以外的空间被分成几个大块的空节点,阴影光线穿过这些区域时直接就跳过去了。

但它的缺点也很致命:结构完全绑定静态几何。物体移动、网格变形,KD-Tree都得重新构建。构建一个百万三角形的KD-Tree,用SAH实现要好几秒,这在实时渲染里根本无法接受。再加上跨平面三角形的重复存储,内存占用也比BVH高一个档次。

说到这我想提一句:很多人觉得KD-Tree是“老技术”,过时了。其实不是。早期Pixar的RenderMan用的就是基于KD-Tree的加速方案,在很多大型离线场景里它的空区域剔除能力依然有效。只不过它确实不如BVH好实现、好维护,所以在现代渲染器里逐渐退居二线。

3. BVH:让包围盒跟着物体走

3.1 核心差异:分割的是三角形集合,而不是空间

BVH(Bounding Volume Hierarchy,层次包围盒)的思路和KD-Tree正好相反。KD-Tree先切空间,再看三角形落在哪边;BVH则是先把三角形分成两组,然后分别计算这两组三角形的最小包围盒。递归进行,直到每个叶子节点里的三角形数量小于阈值。

这是一切的关键区别:BVH的节点包围盒是“内容”决定的,而不是“位置”决定的。三角形不会被重复存储,每个三角形在树里只出现一次,内存可控,构建自由度也大。

因为包围盒跟着物体走,BVH有一种很实用的特性:当场景中某个物体移动时,可以只更新其所在节点的包围盒,而不需要重建整棵树。这个操作叫refit,虽然会导致包围盒不再紧凑,但配合适当的定时重建,在很多动态场景中已经够用。

3.2 构建:自顶向下的递归分组

BVH的经典构建方法是自顶向下(Top-Down):

  1. 把当前节点所有三角形的包围盒合并,作为当前节点的包围盒。
  2. 选一条轴(通常是最长轴),把三角形按包围盒中心在该轴上的位置排序。
  3. 在某个位置把三角形分成左右两组,递归构建左右子树。
  4. 叶子条件:三角形数量小于阈值(我用的是4到8个)。
Node *BuildBVH(vector<Triangle> &tris, int start, int end) { Node *node = new Node(); node->bounds = UnionBounds(tris, start, end); if (end - start <= LeafSize) { node->leaf = true; node->triStart = start; node->triCount = end - start; return node; } // 按最长轴上的中心排序 [start, end) int mid = PartitionByCentroid(tris, start, end); node->left = BuildBVH(tris, start, mid); node->right = BuildBVH(tris, mid, end); return node; }

第4行和第12行是关键:构建子节点时,直接对三角形数组做原地分区,而不是拷贝新的三角形列表。这样做的好处是内存布局紧凑,构建过程中几乎不产生额外分配。我推荐的工程做法是:先把所有三角形放进一个连续数组,然后通过索引范围[start, end)来划分,指针或下标移动即可。

3.3 遍历:包围盒命中才进入子树

BVH的遍历和KD-Tree很像,但也有区别。KD-Tree的左右子树是空间互补的,BVH的左右包围盒可能重叠,所以遍历时可能两侧都要进,不能用“先近后远、只回溯一侧”的简单策略。

void TraverseBVH(Stack &stack, Node *root, Ray &ray) { stack.Push(root); while (!stack.IsEmpty()) { Node *node = stack.Pop(); if (node->leaf) { for (int i = node->triStart; i < node->triStart + node->triCount; i++) Intersect(triangles[i], ray); } else { float t1, t2; bool hitL = IntersectAABB(ray, node->left->bounds, t1, t2); bool hitR = IntersectAABB(ray, node->right->bounds, t1, t2); if (hitL) stack.Push(node->left); if (hitR) stack.Push(node->right); } } }

注意这里用栈保存所有命中的子树,顺序并不重要。因为BVH节点包围盒重叠,光线可能进入左右两个子树,所以遍历路径比KD-Tree更“宽”,但每次进入子树前都已经做了一次AABB求交过滤,所以实际性能依然很好。

我做过一次简单统计:在同等场景下,BVH把光线需要测试的三角形数量从十多万降到了平均几十个,过滤率在99.9%以上。对离线渲染来说,这个投入产出比非常划算。

3.4 为什么现代渲染器偏爱BVH

第一个原因是构建效率高。BVH只需要排序和分区,不需要像KD-Tree那样反复计算分割面、处理跨面三角形。百万三角形的BVH构建,普通CPU上几百毫秒就能完成,KD-Tree通常要慢数倍。

第二个原因是遍历时缓存友好。BVH的三角形存在连续数组里,叶子节点用索引范围访问,访问局部性好;KD-Tree的三角形列表分散在各个叶子节点中,访问跳跃大。

第三个原因是GPU友好。GPU光线追踪加速硬件(RT Core)底层用的就是类似BVH的层次结构。CPU实现BVH后,可以比较平滑地迁移到GPU架构上,这也是业界花了大量精力做BVH优化的现实动机。

当然BVH也有弱点。因为包围盒重叠,它的空区域剔除能力不如KD-Tree。比如一个空旷场景里远方有个小物体,BVH的根节点包围盒覆盖了整片空间,光线要先测试一个大包围盒才能进入更小范围的节点,而KD-Tree可以早早把大片空空间切掉。只不过实践中,这种情况对性能的影响远小于KD-Tree带来的内存和动态构建问题。

4. SAH:把“怎么切”这件事交给数学模型

4.1 为什么朴素切分不够用

不管是KD-Tree选分割位置,还是BVH选分组位置,如果你都用“平均分”或“中点切”,会碰见一个很经典的问题——三角形分布不均匀时,切出来的左右子树一个巨大一个巨小,或者分割面位置不合适,导致光线经常两个子树都要进。

举个我自己的例子:一个会议室场景,墙壁和地板是几万个三角形,中间只有几把椅子。用中点切分的话,切出来的左右子树三角形数量可能只差几个,但几何分布差异极大,光线很多次在两个子树之间来回试探。

SAH(Surface Area Heuristic,表面积启发式)就是来解决这个问题的。它给每个候选分割方案一个代价估计,你直接选代价最小的那个就行。

4.2 代价模型的推导

SAH的出发点是估计“遍历这个节点的期望开销”:

[ C(A, B) = C_{trav} + p_A \cdot C_{I} \cdot N_A + p_B \cdot C_{I} \cdot N_B ]

其中:

  • (C_{trav}):光线与当前节点包围盒求交的开销,通常是常数。
  • (p_A)、(p_B):光线穿过当前节点后,继续穿过左/右子节点的概率。
  • (C_I):每次与三角形求交的开销。
  • (N_A)、(N_B):左右子树中的三角形数量。

问题在于怎么估算 (p_A)。渲染领域有一个被广泛使用的结论:如果包围盒是均匀朝向的,光线命中一个包围盒的概率近似正比于它的表面积。所以:

[ p_A \approx \frac{S(A)}{S(parent)}, \quad p_B \approx \frac{S(B)}{S(parent)} ]

把式子整理一下,因为 (C_{trav}) 和 (C_I) 是常数,实际比较时可以只算:

[ C'(A, B) = \frac{S(A) \cdot N_A + S(B) \cdot N_B}{S(parent)} ]

这个公式的意思很直白:分割出来的两个子节点,如果包围盒的表面积小、三角形还少,那光线命中它们之后需要做的求交工作就少,代价就低。反过来,一个包围盒巨大但里面只有几个三角形,代价反而高——因为光线“路过”它的概率太高了。

4.3 在KD-Tree里用SAH:候选分割面逐个试

KD-Tree的SAH实现中,每个候选分割位置都要把三角形分成左右两部分,分别算左右空间范围的表面积和三角形数量。最朴素的做法是:对当前节点的三角形,按照每个三角形在分割轴上的范围端点生成候选分割面,然后对每个候选面扫描一遍三角形统计左右数量。这样做是 (O(n^2)),三角形多的时候构建慢得让人想放弃。

优化后常见的做法是:先把三角形按包围盒中心排序,然后从左往右扫描,记录每个前缀的包围盒表面积和三角形数量,再一次性算出所有候选位置的代价。我当年第一次实现木有做这个优化,100万三角形的KD-Tree构建花了几十秒,优化后降到两三秒。这部分是KD-Tree构建成本高的关键。

4.4 在BVH里用SAH:Binned SAH更实用

BVH的SAH实现要简单得多。因为BVH分割的是三角形集合,我们可以对每个轴独立处理:

  1. 把所有三角形按包围盒中心在该轴上的位置排序。
  2. 把排序后的三角形分成若干桶(比如32个桶),按顺序扫描时累计每个桶的三角形数量和包围盒。
  3. 对每个桶边界,用SAH公式算出把左侧桶和右侧桶分成两组的代价。
  4. 选代价最低的轴和桶边界作为分割位置。

用桶来近似而不是逐个三角形试,构建速度提升明显,质量损失很小。业界有个经验结论:用32个桶的Binned SAH,构建质量已经接近精确SAH,但构建速度几乎和朴素中位切法相当。

我实际测过:同一个场景,BVH用朴素中位切构建,每帧光线求交数量大约是SAH版的两倍。所以BVH配SAH基本是标配,值得一写。

5. 两种结构的实测对比与选型

这里直接放一张我整理过的对比表,是我在不同场景下实测和查阅资料总结出来的经验值,具体数字可能因实现而异,但能力差异基本稳定:

指标KD-TreeBVH
构建速度慢(需处理跨面三角形)快(只需分区和排序)
内存占用较高(三角形可能重复存储)较低(每个三角形只存一次)
空区域剔除强(可快速跳过空旷区域)弱(包围盒重叠较多)
遍历效率(静态场景)略优,尤其空旷室外场景整体相当,室内复杂场景略逊
动态场景适配差(基本只能重建)好(支持refit部分更新)
GPU/硬件加速适配差好(工业界事实标准)
实现复杂度高(构建和遍历都有细节坑)中等(最关键就是SAH分区)

选型建议很明确:

  • 如果是离线渲染器,默认选BVH+SAH。性能足够、实现简洁、后续想移植到GPU也方便。
  • 如果场景是大量空旷空间+极小物体的组合,可以试试KD-Tree,空区域剔除的优势能补回不少性能。
  • 如果要做动态场景或实时渲染,BVH几乎是唯一合理选项。
  • 如果只是几万三角形的教学级场景,其实用什么区别都不大。这时候选你自己更熟悉的结构就好。

我自己的做法是:渲染器主加速结构用BVH,但在做室外大场景测试时保留了一个KD-Tree实现做对照,用来确认性能差异是不是来自加速结构本身。这种做法对调优很有帮助。

6. 工程落地时最容易踩的五个坑

6.1 包围盒求交的除零问题

用slab法做AABB求交时,很经典的一个坑是光线方向分量为0的情况。比如dir.x = 0时,t1 = (box.min.x - origin.x) / 0,在IEEE浮点里会得到正负无穷或NaN,如果你没做处理,NaN会让比较逻辑失灵,结果是包围盒要么全部命中要么全部不中。

处理方法有两种:

// 方法一:如果是标量实现,直接判断方向是否为零 if (ray.dir.x == 0.0f) { tNear = -INFINITY; tFar = +INFINITY; } // 方法二(更推荐):用倒数并处理无穷与NaN float invDir = 1.0f / ray.dir.x; float t1 = (box.min.x - ray.origin.x) * invDir; float t2 = (box.max.x - ray.origin.x) * invDir;

方法二的关键是计算中NaN不参与错误的最小值/最大值判断。很多现代实现选择1.0f / 0.0f得到无穷,然后依赖min/max的语义处理。但要小心不同编译器的优化可能改变NaN行为,最好写完后做一轮边界测试:光线正好平行于某个轴且从包围盒外经过,看结果是否正确。

6.2 KD-Tree跨分割面三角形的重复存储

这是KD-Tree实现里最隐蔽的坑。一个三角形有时候同时被判定为“在左”和“在右”,主要原因有两种:几何上它真横跨分割面,或者浮点精度上它的包围盒与分割面距离小于epsilon。

如果不处理,左边叶子存一份三角形,右边叶子又存一份,可能导致同一根光线对一个三角形做多次求交,浪费一次是一回事,但更严重的是后续如果要做纹理、法线插值等操作,这些重复记录会导致结果不一致。

我的建议是:构建时给三角形和分割面之间留一个容差范围(比如1e-6级别),三角形包围盒和分割面距离在这个范围内就丢到“两侧都要存”的列表里。遍历时再用三角形ID去重。不用过度优化去重,因为重复求交的占比通常不高,但一定要保证逻辑一致性。

6.3 栈深度与显式栈管理

递归实现BVH或KD-Tree遍历非常直观,但树深了以后(比如深度超过100层)容易爆栈,尤其是调试版编译器下。我建议一开始就写成显式栈:

struct StackNode { Node *node; float t; }; StackNode stack[64]; // 按经验大部分场景64层就够 int sp = 0;

我见过一个案例:某开发者用递归写BVH遍历,场景一切换到极深树直接崩溃,临时改成显式栈后稳定了。64层的栈数组在绝大多数场景里够用,但如果你处理的是极不平衡的树,可以按最大三角形数量算出理论最大深度再把栈开大。栈数组比vector更高效,避免遍历时频繁动态扩容。

6.4 包围盒一定要“外扩”一点

AABB求交在浮点环境下有个恼人的问题:三角形的顶点正好落在包围盒边界上时,光线和包围盒的求交结果可能因为舍入误差判定为不相交,导致三角形被意外剔除。最终画面上会出现零星的暗点或裂痕。

解决方法是构建包围盒时把边界往外扩一个很小的量,我一般用epsilon = 1e-4,对应场景尺寸的百万分之一量级。别小看这一行代码,它能避免你在调试阴影时浪费一整天。“阴影为什么有一粒一粒的噪点”这类问题,有一半是包围盒过紧导致的,另一半才是光线偏移量没调好。

6.5 叶子大小和终止条件要场景自适应

我把叶子阈值设为4个三角形,构建出来的BVH有更深的层次,遍历效率略高,但内存占用也上去了。阈值设为16时构建速度更快、内存更少,但每个叶子的求交数量多了。测试了几轮之后我发现,没有绝对最优阈值,它和场景三角形大小、分布都有关系。稳妥的做法是默认8,然后针对自己的场景做一组从4到16的小参数扫描。这个参数扫描你只需要做一次,因为结果通常是单调的:同一场景下,阈值翻倍,构建时间降一点,遍历时间升一点,找出交叉点就行。

另外终止条件不能只看三角形数量,还得加最大深度限制和“当前包围盒极小”的判断。否则某些畸形网格(比如大量三角形重叠在一个点上)会让树退化成链,深度几千层,遍历退化成暴力求交。

7. 从“能跑”到“跑得快”的实践路线

如果让我给一个学习路线的建议,先别急着上SAH和Binned优化。我见过有人第一次写BVH就直接上最复杂的分桶SAH,结果代码跑不通,调试了两周,最后退回到中点切分反而立刻出图。我的建议是按这个顺序推进:

  1. 先实现暴力求交版,能跑通整个渲染流程,确认光照和着色逻辑正确。
  2. 换成朴素BVH,中点分裂,不做SAH。把包围盒求交做对,把栈遍历写对。
  3. 确认正确后,再加入SAH分割。这一步能看到明显的性能提升。
  4. 有条件的话,再实现Binned SAH,优化构建速度。
  5. 最后才是考虑要不要并行化构建、要不要做refit、要不要GPU化。

每多走一步都要用同一张测试图对比时间和结果。如果某一步性能反而降了,大概率是构建或遍历逻辑里有隐藏bug,而不是算法本身的问题。

8. 一些调试技巧和个人经验

最后分享几个调试时特别有用的技巧。

第一,画包围盒。把树的前几层节点包围盒输出为可视化线框,叠加到渲染图上。你会立刻看出SAH分割得好不好——包围盒重叠严重、彼此穿插的,就是构建逻辑有问题;包围盒层层包含、层次分明的,通常是对的。这个技巧救过我很多次。

第二,写一个“暴力版”做对照。不要删掉最初那个遍历所有三角形的函数,留着当金标准。每帧渲染时随机采样一些光线,分别用暴力版和加速版求交,比较结果是否一致。任何一个不一致都说明加速结构出错了,这个自检能让你在调试阶段少走弯路。

第三,打印遍历统计。在BVH遍历里加一个计数器,统计每根光线平均测试了多少个三角形、命中了几次AABB。如果平均值比场景三角形总数低了几个数量级,说明加速结构工作正常;如果这个值突然暴增,八成是SAH退化或者包围盒计算错了。

我在实际项目里走过的弯路算是比较多:一开始迷信KD-Tree的空区域剔除能力,写了复杂构建代码,结果调试花费远超节省的时间;后来换BVH,又因为忘记处理除零问题,阴影画面上出现了一堆莫名其妙的暗斑。这些经验总结起来就一句话:加速结构的选择不是越“高级”越好,而是越匹配你的场景类型和工程约束越好。BVH加SAH这套组合,在绝大多数情况下是性价比最高的方案,先把这套跑通、跑稳,再谈更花哨的优化才是正路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询