1. 从一次渲染卡顿说起:全局光照加速结构到底在解决什么
如果你做过离线渲染或者实时渲染里带全局光照的场景,大概率遇到过这种局面:场景里塞进几十个面光源、几块大面积发光板,再叠上漫反射反弹,预览窗口的采样数一往上调,进度条就跟冻住了一样。CPU 占用拉满,内存曲线一路爬升,最后要么等上几个小时,要么直接爆内存退出。很多人第一反应是"机器不够强",但真正的问题往往不在算力,而在加速结构没设计对。
全局光照加速结构,说白了就是一套让光线在场景里"少走冤枉路"的空间组织方案。光线追踪的本质是反复回答一个问题:这条射线会撞到谁?朴素做法是把场景里所有三角形挨个测一遍,复杂度直接和三角形数量线性挂钩,几百万面片的场景根本跑不动。加速结构的作用,就是提前把空间或物体按层次切好,让每次求交只需要检查少数几个候选区域,把"全场景扫描"降级成"沿树下降"。这个思路在路径追踪、光子映射、辐照度缓存、实时 GI 探针更新里都是通用的底层支撑。
这篇内容适合三类人看:一是刚接触光线追踪、想搞明白 BVH 和八叉树到底差在哪的入门者;二是已经在写渲染器、但加速结构搭得别扭、性能上不去的实践者;三是做实时 GI 方案、需要权衡构建开销和查询效率的工程向读者。我会从结构选型的判断逻辑讲起,拆到构建细节、遍历实现、动态场景的更新策略,再补上几个我实际踩过的坑。所有代码和参数都是可复现的,你可以直接拿去改。
需要先说明一点:加速结构不是"建得越精细越好"。它本质是一场构建时间、内存占用、查询效率三者之间的博弈,不同场景的最优解完全不同。下面所有的取舍分析,都围绕这个三角展开。
2. 选 BVH 还是网格:加速结构的选型判断逻辑
2.1 三种主流结构的本质差异
做全局光照,绕不开三类加速结构:BVH(层次包围盒)、均匀网格(Uniform Grid)、八叉树/kd 树。它们的分歧点在于"怎么切空间"。
BVH 是按物体切。它把一组图元用一个大包围盒框住,然后递归地一分为二,每个节点只关心自己框里那批图元。优点是包围盒贴合几何、对非均匀分布的场景友好,缺点是构建时要反复计算图元的包围盒和划分代价,构建偏慢。
均匀网格是把整个场景切成等大的小格子,图元往格子里塞。优点是结构简单、遍历时步进逻辑固定、缓存友好,缺点是遇到"大物体跨很多格子"或者"场景里有一大片空白"时效率骤降——前者导致同一图元被重复存很多份,后者导致光线在空网格里空转。
八叉树/kd 树是沿坐标轴递归切分空间,每次切成八份或两份。它对空间划分更灵活,但节点数量容易膨胀,且 kd 树的构建代价在动态场景里很难接受。
我一般用一张表来快速判断:
| 结构类型 | 构建速度 | 查询效率 | 内存占用 | 适合场景 |
|---|---|---|---|---|
| BVH | 中等 | 高 | 中等 | 静态复杂几何、非均匀分布 |
| 均匀网格 | 快 | 中等 | 高(大物体重复存) | 均匀分布、动态中等场景 |
| 八叉树 | 慢 | 中高 | 高 | 稀疏体素、点云类数据 |
| kd 树 | 很慢 | 高 | 中等 | 离线渲染、静态场景 |
2.2 为什么全局光照场景我更偏向 BVH
全局光照和普通主光线追踪有个关键区别:次级光线方向极其发散。主光线从相机出发,方向集中,网格结构还能靠空间连贯性吃到缓存红利;但漫反射反弹出来的光线是半球随机采样,方向散得一塌糊涂,这时候网格结构的"步进式遍历"优势就没了,反而 BVH 的"沿树下降、快速剔除整块区域"更稳。
另一个原因是全局光照场景里经常有大面积发光体和细碎几何混在一起。比如一个房间,地板墙面是大三角形,桌上摆着一堆小摆件。均匀网格会把大三角形拆散重复存储,内存直接翻几倍;BVH 则能把大三角形单独框在一个节点里,小摆件聚在另一个子树,各管各的,非常自然。
所以我的默认选择是 BVH,只有在场景高度均匀、且需要频繁重建(比如每帧都在动的粒子系统)时,才会考虑网格结构。这个判断不是拍脑袋,是构建代价和查询收益算过账的结果——BVH 构建虽然慢一点,但全局光照里查询次数是构建次数的成千上万倍,查询效率的权重远高于构建效率。
2.3 一个容易被忽略的选型维度:光线类型分布
还有一点很少有人提:你的全局光照用的是哪种光线分布,直接决定加速结构的调优方向。
路径追踪的次级光线是宽发散的,需要结构在任意方向上都能快速剔除,BVH 的球形/轴对齐包围盒剔除效率高。而光子映射里的光子追踪,光子方向相对集中(从光源出发),这时候如果做的是光子查询(最近邻搜索),kd 树反而更合适,因为它的空间划分对最近邻更友好。
我见过有人拿 BVH 去做光子最近邻查询,结果每次查询要遍历大量节点,性能还不如老老实实用 kd 树。所以选型前先问自己:我的加速结构主要服务的是射线求交,还是最近邻/范围查询?前者选 BVH,后者选 kd 树或专门的哈希网格,别混用。
3. BVH 构建的核心:划分策略与代价函数怎么定
3.1 从"随便切"到"按 SAH 切"的差别
BVH 构建最核心的一步是:拿到一批图元,怎么把它分成两堆。最朴素的做法是按图元数量对半分,或者按某个轴的中位数切。这两种做法构建快,但树的质量差,查询时容易遇到"包围盒重叠严重"的节点,剔除效率低。
工业级做法是SAH(Surface Area Heuristic,表面积启发式)。它的核心思想很直白:一个节点的包围盒表面积越大,光线撞进这个盒子的概率越高,遍历它的代价就越大。所以划分时应该让左右子节点的"表面积 × 图元数"之和最小。公式大致是:
cost = C_traversal + (SA_left / SA_parent) * N_left * C_intersect + (SA_right / SA_parent) * N_right * C_intersect其中C_traversal是遍历一个节点的固定开销,C_intersect是单次求交开销,SA是表面积,N是图元数。这个公式的直觉是:把"光线进入某侧的概率"(表面积占比)乘以"进入后要测多少图元",得到期望求交次数,取最小的划分方案。
实测下来,SAH 构建的 BVH 相比中位数切分,查询性能通常能提升 30% 到 50%,代价是构建时间增加 2 到 3 倍。对于静态场景,这笔账非常划算。
3.2 SAH 的工程实现:分桶扫描而不是暴力枚举
理论上 SAH 要枚举所有可能的划分点,图元多了根本算不完。工程上的标准做法是分桶(binning):沿某个轴把空间分成固定数量的桶(比如 16 或 32 个),每个图元按质心落进对应桶,然后从左到右扫描,维护"左侧累积包围盒和数量",再从右到左扫一遍,两边一拼就能算出每个划分点的代价。
// 简化的分桶 SAH 划分(单轴) const int NUM_BINS = 16; struct Bin { BBox bounds; int count = 0; }; Bin bins[NUM_BINS]; // 1. 按质心把图元分进桶 for (auto& prim : prims) { int b = clamp(int((prim.centroid[axis] - minC) / (maxC - minC) * NUM_BINS), 0, NUM_BINS - 1); bins[b].bounds.expand(prim.bounds); bins[b].count++; } // 2. 从左到右累积 BBox leftBox[NUM_BINS]; int leftCount[NUM_BINS]; BBox acc; int cnt = 0; for (int i = 0; i < NUM_BINS - 1; i++) { acc.expand(bins[i].bounds); cnt += bins[i].count; leftBox[i] = acc; leftCount[i] = cnt; } // 3. 从右到左累积并计算代价,取最小 float bestCost = INF; int bestSplit = -1; acc = BBox(); cnt = 0; for (int i = NUM_BINS - 1; i > 0; i--) { acc.expand(bins[i].bounds); cnt += bins[i].count; float cost = leftBox[i-1].area() * leftCount[i-1] + acc.area() * cnt; if (cost < bestCost) { bestCost = cost; bestSplit = i; } }这段代码的关键点在于:分桶数量是个可调参数。桶太少,划分粒度粗,树质量下降;桶太多,构建变慢但收益递减。我一般用 16,场景特别大或者图元分布特别不均匀时调到 32。这个值不需要精确,SAH 本身是个启发式,桶够用就行。
3.3 三个轴都要试,但可以偷懒
完整 SAH 要对 x、y、z 三个轴分别做分桶扫描,取全局最优。三个轴都算当然最准,但构建时间也三倍。实践中我常用一个偷懒策略:只对图元分布方差最大的轴做完整 SAH,其余轴用中位数快速切。原因是分布最散的轴往往就是最优划分轴,其他轴大概率不是最优,算了也白算。
这个策略在大多数场景下能省 40% 左右的构建时间,树质量只下降几个百分点。如果你的场景是那种特别规整的(比如全是轴对齐的盒子堆),三个轴都算更稳;如果是自然场景、几何杂乱,偷懒策略完全够用。
注意:SAH 的代价函数里
C_traversal和C_intersect的比值会影响划分倾向。如果遍历开销相对求交开销更大(比如图元求交很便宜),SAH 会倾向于切出更少、更大的节点;反之则切得更细。这两个常数最好根据你的实际硬件和求交实现标定一次,别直接用论文里的默认值。
4. 遍历实现:光线怎么在树里高效下降
4.1 递归下降的栈开销不能忽视
BVH 遍历的标准写法是递归:光线撞到节点包围盒,就递归进左右子树。逻辑清晰,但递归调用有函数栈开销,在光线数量上千万的全局光照里,这点开销会累积成可观的数字。
工程上一般改成显式栈的迭代遍历。用一个固定大小的数组当栈(深度一般不超过 64,够用了),手动压栈弹栈。这样既避免了递归开销,又能控制栈深度防止爆栈。
// 迭代式 BVH 遍历(简化) int stack[64]; int sp = 0; stack[sp++] = 0; // 根节点 while (sp > 0) { int node = stack[--sp]; if (!intersectBox(ray, nodes[node].bounds)) continue; if (nodes[node].isLeaf) { for (int i = 0; i < nodes[node].count; i++) intersectPrim(ray, prims[nodes[node].start + i]); } else { stack[sp++] = nodes[node].left; stack[sp++] = nodes[node].right; } }4.2 近远排序:先走更近的那棵子树
上面这段代码有个可以优化的地方:压栈顺序。如果光线先撞到的是左子树的包围盒,那应该先遍历左子树,因为最近命中大概率在近侧,一旦近侧找到了命中点,远侧的包围盒测试就能用"命中距离"提前剔除,省掉大量无用遍历。
具体做法是:比较左右子节点包围盒的进入距离(ray 与 box 的 t_min),先压远的、后压近的(因为栈是后进先出,后压的先处理)。这个改动只有几行,但在遮挡严重的场景里能带来 20% 以上的性能提升。
float tLeft = boxEntry(ray, nodes[left].bounds); float tRight = boxEntry(ray, nodes[right].bounds); if (tLeft < tRight) { if (tRight < ray.tmax) stack[sp++] = right; if (tLeft < ray.tmax) stack[sp++] = left; } else { if (tLeft < ray.tmax) stack[sp++] = left; if (tRight < ray.tmax) stack[sp++] = right; }4.3 全局光照里的"任意命中"与"最近命中"要分开处理
这是全局光照加速结构里一个特别容易做错的地方。主光线追踪要的是最近命中(找到最近的交点算颜色),但全局光照里的阴影射线和遮挡测试只需要知道"有没有挡住",也就是任意命中——撞到第一个就返回,不用继续找最近的。
这两种查询的遍历逻辑应该分开写。任意命中版本一旦命中就可以立刻返回,不需要维护 tmax,也不需要近远排序,遍历能砍掉一大半。我见过有人图省事,阴影射线也走最近命中逻辑,结果性能白白损失 30% 到 40%。
// 任意命中:撞到就返回,不维护 tmax bool anyHit(ray) { int stack[64]; int sp = 0; stack[sp++] = 0; while (sp > 0) { int node = stack[--sp]; if (!intersectBox(ray, nodes[node].bounds)) continue; if (nodes[node].isLeaf) { for (int i = 0; i < nodes[node].count; i++) if (intersectPrim(ray, prims[nodes[node].start + i])) return true; // 立刻返回 } else { stack[sp++] = nodes[node].left; stack[sp++] = nodes[node].right; } } return false; }提示:全局光照里阴影射线占比很高(每个着色点至少一条),把任意命中单独优化,收益比优化主光线还大。别偷懒复用同一套遍历代码。
5. 动态场景怎么办:重建、增量更新与质量权衡
5.1 每帧重建 BVH 的代价算过没有
静态场景建一次 BVH 就完事,但全局光照里经常有动态物体——转动的风扇、飘动的布料、移动的角色。这些物体一动,BVH 就失效了。最直接的做法是每帧重建整棵树,但 BVH 构建是 O(n log n) 级别的开销,几百万图元每帧重建,帧率直接崩。
我实测过一个中等场景(约 80 万三角形),完整 SAH 构建单帧耗时约 120 毫秒,根本没法实时。所以动态场景必须换策略,不能硬重建。
5.2 三种更新策略的适用边界
动态 BVH 更新一般有三条路:
第一条是整体重建,适合变化剧烈、几乎全场景都在动的极端情况,或者离线渲染不在乎帧率。实时场景基本排除。
第二条是增量更新(refit),树结构不动,只自底向上重新计算每个节点的包围盒。这个操作是 O(n) 的,快得多。缺点是树的质量会随物体移动逐渐退化——原本分得合理的节点,物体挪位置后包围盒重叠越来越严重,查询效率慢慢下降。所以 refit 适合"物体小幅移动"的场景,比如轻微晃动的植被。
第三条是局部重建,把动态物体单独拎出来建一棵子树,静态部分保持不动,查询时两棵树都遍历。这个方案在全局光照里特别实用,因为大部分场景是"静态环境 + 少量动态物体",动静分离后,静态 BVH 一次建好永不重建,动态子树每帧重建代价也小。
// 动静分离的查询:两棵树都测,取最近 Hit hitStatic = traverse(staticBVH, ray); Hit hitDynamic = traverse(dynamicBVH, ray); return (hitStatic.t < hitDynamic.t) ? hitStatic : hitDynamic;5.3 refit 的质量退化怎么监控
如果你选了 refit,一定要监控树质量。我的做法是定期(比如每 30 帧)统计一次平均节点包围盒重叠率——随机采样一批节点,算它和兄弟节点的包围盒交叠体积占比。这个值超过某个阈值(我一般设 0.3)就触发一次局部重建,把退化的子树重新用 SAH 划分。
这个监控逻辑不复杂,但能避免"跑着跑着突然变慢"的诡异现象。很多人用 refit 后觉得"怎么越跑越卡",其实就是树质量悄悄退化了,没监控根本发现不了。
| 更新策略 | 单帧开销 | 树质量保持 | 适合场景 |
|---|---|---|---|
| 整体重建 | 高(O(n log n)) | 最优 | 离线、全动态 |
| refit | 低(O(n)) | 逐渐退化 | 小幅移动 |
| 动静分离 | 低(只重建动态部分) | 静态部分最优 | 静态环境+少量动态 |
6. 踩过的坑:几个让性能悄悄溜走的细节
6.1 叶子节点图元数量不是越多越好
BVH 的叶子节点里塞多少个图元,直接影响遍历效率。塞太少(比如 1 个),树会变得很深,节点数量爆炸,遍历时栈操作和包围盒测试的开销压过求交本身;塞太多(比如 16 个),叶子内部要线性扫描,求交次数上去了。
我一般把叶子图元数控制在4 到 8之间。这个区间是实测出来的平衡点:再少,树深度带来的遍历开销明显;再多,叶子内线性扫描的代价开始显现。不同场景可以微调,但别偏离太远。有个简单办法是构建时把叶子大小当参数扫一遍,看哪个值查询最快,一般扫 2、4、8、16 四个值就够了。
6.2 包围盒的浮点误差会导致漏交
这是个隐蔽的坑。光线和包围盒求交时,如果包围盒的边界刚好贴着图元,浮点误差可能让光线"擦边"穿过却被判定为未命中,表现为画面上的漏光或者黑点。
解决办法是给包围盒加一个极小的保守膨胀(比如每个方向扩 1e-4 的相对量),确保图元一定被包住。代价是包围盒略微变大、剔除效率微降,但换来的是正确性,这笔账必须算。我早期为了追求极致剔除效率不加膨胀,结果调试了整整两天才定位到是浮点误差导致的漏交。
6.3 构建时的图元排序会打乱原始索引
SAH 构建过程中会按划分结果重排图元数组,这样叶子节点里的图元是连续存储的,缓存友好。但重排之后,图元的原始索引就乱了。如果你的渲染管线里其他地方(比如材质绑定、动画数据)依赖原始索引,就会对不上号。
标准做法是维护一个索引映射表,重排的是索引数组而不是图元数据本身,图元数据按原始顺序存着,通过映射表间接访问。这样既拿到了连续存储的缓存优势,又不破坏原始索引。这个细节在写渲染器时特别容易忘,等发现颜色错乱才回头补。
6.4 空节点的处理别浪费遍历预算
构建时如果某个子树里一个图元都没有,应该直接不生成这个节点,而不是生成一个空叶子让遍历时去测。空节点在遍历时虽然包围盒测试会失败,但那也是一次完整的求交计算,白白浪费。构建阶段就该把空分支剪掉,让父节点直接指向有效子节点。
这个优化看起来微不足道,但在稀疏场景(比如大量空白区域的室外场景)里,能省掉相当一部分无效遍历。我做过对比,剪掉空节点后,室外大场景的平均遍历节点数下降了约 15%。
7. 收尾的一点个人体会
加速结构这东西,理论文章讲得都很漂亮,但真正落地时的性能差异,往往藏在那些"看起来无所谓"的细节里——叶子大小差一倍、遍历顺序反一下、阴影射线复用错逻辑,单看都是小事,叠在一起就是几倍的性能差距。
我自己的习惯是:每改一处加速结构相关的代码,都跑一遍固定的基准场景,记录构建时间、平均遍历节点数、总渲染耗时三个指标,对比着看。别凭感觉判断"应该变快了",数据不会骗人。另外,加速结构的调优一定要在真实的光线分布下做,拿主光线测出来的最优参数,放到全局光照的次级光线上未必成立,这点我吃过亏。
如果你刚开始搭全局光照的加速结构,建议先把 BVH 的构建和遍历跑通,用最简单的场景验证正确性,再逐步加 SAH、加动静分离、加任意命中优化。每一步都留好基准数据,出问题能快速定位是哪一步引入的。这套流程走下来,后面再换结构或者调参数,心里就有底了。