LBVH 项目常见问题解决方案,这题目乍看像一份 FAQ 目录,真正蹲在 GPU 光线追踪管线里调过 LBVH 的人都知道,问题很少是“有没有文档”,更多是“同一份代码在 A 场景很稳,换到 B 场景就炸”。LBVH 是线性包围盒层次结构,核心思路是先用莫顿码把空间邻近的图元排成一条线,再在这条线上并行合并成树。它构建快、结构规整、适合大规模并行,代价是树质量通常不如严格 SAH,遍历性能对参数和实现细节非常敏感。这个项目适合已经写过基础 BVH、准备把构建搬到 GPU 的图形程序员,也适合在做实时渲染、路径追踪、碰撞查询时被构建耗时卡住的同学。下面我不按教科书顺序讲原理,而是按实际项目里最容易踩的故障顺序,把 LBVH 构建、内存、遍历、验证和性能回归里的常见问题拆开,给可直接复现的排查路径和参数基线。
1. 先厘清 LBVH 项目的问题边界与整体设计
1.1 LBVH 到底在解决什么:从莫顿码到线性 BVH
传统自顶向下 BVH 往往依赖 SAH 逐层划分,质量好,但构建过程中要做大量候选分割评估,并行度不高,动态场景每帧重建很容易成为瓶颈。LBVH 换了一个思路:先取每个图元 AABB 的中心点,把中心点量化到三维网格里,再把三维网格坐标交织成一个莫顿码。莫顿码的特点是空间上接近的点,码值也往往接近。按码值排序后,原本在三维空间里相邻的图元就变成数组里相邻的元素,接下来只需要在这条有序数组上做并行合并,就能得到一棵结构规整的二叉树。
这个思路的优势很直接:构建的主要成本落在并行排序和几轮合并上,GPU 上容易做宽,节点数组也天然适合线性存储。问题同样直接:莫顿码是离散化后的近似,场景尺度、量化位宽、重复码、退化包围盒都会影响树质量;排序后的数组虽然有序,但合并时的边界判断一旦写错,就会出现空节点、越界访问或者树深度异常。我在模拟项目里见过最常见的情况是构建时间确实降下来了,但遍历命中率变差,最后整体帧时间没有收益。所以 LBVH 不是“构建快就完事”的方案,它需要把构建指标和遍历指标放在一起看。
1.2 方案选型:为什么常见实现偏爱并行构建与自底向上合并
LBVH 的构建路线大致有两类。一类是自顶向下并行划分:先算根节点范围,再并行找分割点,递归或迭代生成子节点。这类实现思路直观,但任务队列和负载均衡不好控制,深层节点数量多时容易出现大量小任务,调度开销反而盖过计算。另一类是把排序后的图元当作叶子序列,自底向上并行合并内部节点:每个内部节点负责一段连续叶子区间,计算区间 AABB,再挂到父节点上。它的数据流更规整,适合一次性铺开大量线程。
我通常建议动态场景优先考虑自底向上合并,因为它的并行结构稳定,节点数量固定为 2N-1,内存可以提前算清楚,构建过程也容易做成无递归的迭代版本。静态场景则不必强行上 LBVH,可以离线用质量更好的 SAH 构建,再把结果烘进资源。混合方案也常见:用 LBVH 做快速初建,再对高频访问的子树做局部优化。选型时不要只看构建耗时,要看“构建 + 遍历”的总账。一个构建快 2 毫秒但遍历多 5 毫秒的方案,在实时管线里并不划算。
1.3 项目落地前先定指标:构建耗时、SAH 质量、遍历代价、内存占用
LBVH 项目最容易出现的争论是“这版到底算不算快”。没有基线,讨论就会变成感觉。我习惯在动手前先定四个指标:构建耗时、树质量、遍历吞吐和内存峰值。构建耗时按 P50 和 P95 分开记录,P95 更能反映卡顿;树质量可以用 SAH 代价估算,也可以简单统计节点平均访问数和最大深度;遍历吞吐用每秒光线数或每帧求交耗时;内存峰值则要包含排序临时缓冲区、节点数组和图元索引。
下面这张表可以作为项目初期的基线记录模板。参数不是死的,但有了它,后面每次调参都能判断是变好还是变坏。
| 指标 | 建议记录方式 | 观察重点 |
|---|---|---|
| 构建耗时 | 连续 100 帧的 P50/P95 | P95 是否被排序或同步拖高 |
| 节点数量 | 实际分配与理论值对比 | 是否出现多余节点或空叶子 |
| 最大深度 | 统计树深度分布 | 是否因重复码或退化场景过深 |
| SAH 代价 | 抽样计算或离线对比 | 与自顶向下 BVH 差距是否可接受 |
| 遍历吞吐 | 固定相机路径和分辨率 | 构建优化是否真的转化为帧收益 |
| 内存峰值 | 构建前后显存差值 | 临时缓冲是否按帧泄漏 |
指标定完后,再决定莫顿码位宽、叶节点大小、是否 refit、是否双缓冲。很多“常见问题”其实不是代码 bug,而是指标没定清楚,导致优化方向跑偏。
2. 构建阶段的高频故障与参数解法
2.1 莫顿码映射与场景尺度:精度、重复键、空叶子
莫顿码映射的第一步不是算码,而是确定场景全局包围盒。常见错误是每个对象用自己的局部包围盒做归一化,结果对象之间的空间关系完全丢失,排序后相邻的图元在空间上并不相邻。正确做法是先扫一遍所有图元,得到全局最小点和最大点,再把每个图元中心平移到以全局最小点为原点,除以场景对角线长度,得到 0 到 1 之间的归一化坐标。这样做的目的是让量化网格覆盖整个场景,而不是让每个对象各自占满网格。
量化位宽 b 决定网格分辨率。三维各取 b 位,总码宽通常是 3b。b 取 10 到 12 在多数场景里够用;b 太小会让大量图元挤进同一个网格,重复码增多;b 太大则对精度提升有限,还会让码值分布更稀疏。可以用一个粗略公式判断重复程度:网格数量约为 2 的 3b 次方,图元数量为 N,平均每格图元数约为 N 除以网格数量。N 等于一百万、b 等于 10 时,平均每格远小于 1,重复并不严重;但如果场景高度聚集,局部重复仍然会出现。所以不要只看平均值,要统计重复码段的长度。
空叶子问题也常被误解。LBVH 的叶子通常对应图元或图元分桶,不是网格里的空格。排序后每个图元都会进入数组,不会因为网格为空而生成叶子。真正要小心的是“一个叶子里放多个图元”时,分桶边界必须覆盖完整,不能把同一个图元拆到两个叶子。我的做法是排序后按固定大小 K 分组,最后一组不足 K 也保留,合并 AABB 时从组内第一个图元扫到最后一个,避免边界漏算。
// 莫顿码量化伪代码:先全局归一化,再量化 uint32_t quantize(float c, float sceneMin, float sceneScale, int bits) { float u = (c - sceneMin) / sceneScale; u = clamp(u, 0.0f, 0.999999f); return (uint32_t)(u * float(1 << bits)); } uint64_t morton3D(uint32_t x, uint32_t y, uint32_t z) { // 交织位,实际项目可用查表或位运算展开 return interleaveBits(x) | (interleaveBits(y) << 1) | (interleaveBits(z) << 2); }这段代码的关键不在位运算本身,而在sceneScale必须用全局包围盒对角线,且要处理零对角线。如果场景里只有一个点或所有图元共面,对角线可能为零,除法会产生 Inf 或 NaN。此时要把 scale 下限钳到一个小值,例如场景尺度的 1e-6,或者直接走退化路径。
2.2 排序与分段边界:并行前缀、重复莫顿码处理
排序是 LBVH 构建里最容易被低估的环节。莫顿码只是键,图元索引必须跟着一起排。常见错误是只排码值,图元索引没有同步交换,后面合并出来的叶子顺序和码值对不上,遍历结果看起来“有时对有时错”。另一个错误是使用不稳定排序,重复码的图元顺序每次构建都变化,导致树结构抖动,性能测试不可复现。我的建议是排序键使用“码值 + 图元索引”的复合比较,或者在稳定排序后保留原始索引作为 tie-breaker。
重复码带来的真正麻烦在分段。自底向上合并时,需要用相邻码值的共同前缀长度来判断两个叶子是否属于同一子树。如果两个码值完全相同,共同前缀长度会达到码宽上限,边界判断容易把整个重复段吞掉,生成零长度子节点或者让某个内部节点的区间越过数组末尾。处理办法有两个:一是排序键里加入唯一低位,让重复码也有稳定顺序;二是在合并前对重复段做一次局部扫描,把相同码值的连续段标记出来,按图元数量而不是按码值差异来切分。
并行前缀和也常出问题。排序后的数组要做“每个位置之前有多少个元素”的前缀统计时,线程块边界容易漏加偏移。表现是某些内部节点的范围整体偏移一位,树看起来能建,但 AABB 覆盖不全,遍历时出现漏交。排查时不要只看最终图像,先把排序后的码值打印成单调序列,再检查每个内部节点的左右范围是否首尾相接、是否覆盖全部叶子。这个检查在最小复现里非常有效。
2.3 自底向上合并树:经典并行合并法的常见实现坑
自底向上合并的目标是生成 2N-1 个节点,其中 N 个叶子、N-1 个内部节点。常见实现是给每个内部节点分配一个线程,让它负责一段连续叶子区间,计算这段区间的 AABB,再根据区间边界找父节点。听起来简单,实际有三个坑。第一个坑是节点索引映射:叶子节点和内部节点如果共用一套索引,必须明确“前 N 个是叶子还是后 N 个是叶子”。我用过的稳定做法是内部节点索引从 0 到 N-2,叶子节点索引从 N-1 到 2N-2,负值或高位标记表示叶子,避免遍历时多一次判断。
第二个坑是区间计算的方向判断。两个相邻叶子的共同前缀长度如果左边更长,说明当前节点应该向左扩展;如果右边更长,则向右扩展。边界位置要单独处理,不能直接访问越界索引。第三个坑是 AABB 计算顺序。如果内部节点的 AABB 依赖子节点,而子节点又可能在同一轮并行计算中尚未完成,就会出现读到未初始化数据。解决方式要么按层级推进,要么让每个内部节点直接扫描自己负责的叶子区间计算 AABB。后者计算量稍大,但依赖少,调试更稳。
我在模拟项目里更偏向“先算范围,再单独算 AABB”的两段式实现。第一段只确定每个内部节点覆盖哪些叶子,第二段从叶子 AABB 向上合并。两段之间加一次全局同步,逻辑清楚,性能也够用。不要为了省一次同步把范围计算和 AABB 合并塞进同一个核函数,除非你已经有完整的边界测试。
2.4 退化包围盒与数值稳定性:NaN、Inf、零面积节点
退化包围盒是 LBVH 项目里最隐蔽的问题之一。点图元、共面三角形、零厚度板子都会产生某个轴向上 min 等于 max 的 AABB。AABB 求交时如果直接除以方向分量,零厚度轴会产生 Inf 或 NaN,最终表现为随机漏交或整条光线消失。处理方式不是简单加一个固定 epsilon,而是按场景尺度扩张。我通常用场景对角线的 1e-5 到 1e-6 作为扩张量,把每个 AABB 的 min 减一点、max 加一点,保证三个轴都有正厚度。
NaN 的来源还包括未初始化图元和非法变换。构建前要扫一遍图元 AABB,检查 min 是否小于等于 max,检查所有分量是否有限。发现非法图元时,可以选择剔除、替换成极小包围盒,或者记录日志后跳过。不要直接让 NaN 进入排序,因为 NaN 的比较结果不满足全序,排序后数组可能局部乱序,后面所有范围判断都会失效。零面积节点合并时也要小心:两个零面积 AABB 合并后仍然是零面积,必须在合并函数里统一做扩张,而不是只在叶子阶段做。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 遍历随机漏交 | 零厚度 AABB 产生 NaN | 按场景尺度扩张 AABB |
| 树构建后局部乱序 | NaN 参与排序比较 | 构建前过滤非法图元 |
| 某些角度全黑 | 方向分量接近零,除法不稳定 | 对零方向分量做 slab 区间判断 |
| 节点 AABB 不覆盖图元 | 合并时漏掉边界图元 | 检查叶节点分组范围 |
3. 内存布局、并行构建与更新策略的实操
3.1 节点数组布局:AoS/SoA、对齐、缓存行
LBVH 的节点数组一旦确定,遍历性能很大程度就被布局决定了。常见节点包含 AABB 的最小点、最大点、左子索引、右子索引、图元起始和数量。按结构体数组存储写起来方便,但遍历时每个节点可能只用到 AABB 和子索引,其他字段会浪费缓存行。更稳的做法是把热字段拆开:AABB 放一组连续数组,子索引放另一组,叶节点标记单独编码。这样遍历时连续读取包围盒,缓存命中率更好。
对齐也很关键。一个节点如果跨两个缓存行,遍历时就要多一次内存事务。我通常把节点结构对齐到 64 字节,或者按 32 字节对齐后把常用字段放在前 32 字节。叶节点可以用负的子索引表示图元起始,这样不需要额外类型字段。内部节点的子索引直接存节点数组下标。对于 N 等于一百万规模的场景,节点数组大约两百万项,每项 64 字节就是一百多 MB,这个量级必须提前算清楚,不能等运行时再发现显存不够。
3.2 构建临时缓冲区与内存峰值计算
LBVH 构建的内存峰值往往比最终节点数组大。排序需要键数组、索引数组和临时交换缓冲;量化需要每个图元的中心和 AABB;合并需要节点数组和可能的父节点索引。以 N 个图元为例,键如果使用 64 位,索引 32 位,排序临时缓冲再翻倍,光排序就可能占到十几字节每图元。节点数组按 64 字节乘 2N 估算,图元 AABB 按 24 到 32 字节估算。整体峰值通常是最终结构的 1.5 到 2 倍。
我的做法是建立一个 workspace 结构,把排序缓冲、节点数组、临时索引全部放进去,按帧复用,不要每帧重新分配。每帧只更新内容,不改变容量。容量按最大图元数预留,并留 20% 余量。如果场景图元数会动态增长,采用分块扩容,扩容时把旧数据拷过去,避免频繁申请释放。显存碎片在长时间运行的项目里很常见,尤其是每帧分配不同大小缓冲,最后表现为构建偶尔失败或耗时突然升高。
| 缓冲项 | 粗略占用 | 备注 |
|---|---|---|
| 排序键 | 8N 字节 | 64 位码值或复合键 |
| 图元索引 | 4N 字节 | 跟键同步排序 |
| 排序临时缓冲 | 8N 到 16N 字节 | 基数排序按 pass 计算 |
| 节点数组 | 64 × (2N-1) 字节 | 对齐后的热字段布局 |
| 图元 AABB | 24N 到 32N 字节 | 构建期临时使用 |
| 其他标记 | 4N 到 8N 字节 | 重复码、叶节点分组等 |
3.3 动态场景:重建、增量更新与 refit 的取舍
动态场景里最常问的问题是“每帧重建还是 refit”。refit 只更新已有节点的 AABB,不改变树结构,成本低,适合顶点变形但图元拓扑不变的情况。它的缺点是树质量会随着变形逐渐变差,原来分开的图元可能跑到一起,节点 AABB 变得很大,遍历时无效访问增多。全重建则每次重新排序和合并,树质量稳定,但构建成本高。我的经验是看“移动图元比例”和“空间分布变化”两个信号:移动比例低于 10% 到 20%,且没有大规模聚集变化,可以先 refit;超过阈值或者每 30 到 60 帧做一次全重建。
refit 的实现顺序必须是后序,先更新叶子,再更新内部节点,否则父节点会读到旧子节点数据。GPU 上做后序 refit 需要按层级推进,或者用原子计数保证子节点完成后父节点才执行。不要在一个核函数里同时读写父子节点而不加同步,这种 bug 在简单场景里可能看不出来,复杂场景会表现为局部漏交。双缓冲也很重要:构建和遍历不要共用同一份节点数组,至少在构建完成的同步点之后切换,避免遍历读到半成品。
3.4 多流与异步构建:避免构建和遍历抢资源
构建和遍历如果在同一个流里串行执行,构建耗时就直接加到帧时间上。很多项目会尝试异步构建:上一帧的树继续用于遍历,后台流构建下一帧的树,完成后交换。这个思路可行,但要注意三个问题。第一,构建和遍历会争抢计算资源和显存带宽,异步不等于免费,重载场景下可能两边都变慢。第二,交换点必须明确,不能在遍历核函数还没结束时释放旧树。第三,构建临时缓冲如果也被异步使用,需要按帧或按流分开,不能共用一份 workspace。
我通常先做同步版本,把构建和遍历耗时分别测准,再决定是否异步。异步构建适合构建耗时占帧时间比例较高、且图元变化有滞后容忍度的场景。如果项目本身对输入延迟敏感,异步树可能让画面和碰撞查询不同步,这时宁可降低构建频率,也不要把同步问题藏到后面。
4. 遍历与查询侧:为什么构建完还是慢
4.1 遍历栈与有序命中:前序布局与节点访问局部性
LBVH 构建出来的节点数组不一定按遍历顺序排列。自底向上合并生成的节点索引和空间前序没有必然关系,遍历时子节点索引可能来回跳,缓存局部性差。一个有效的优化是把节点数组重排成前序布局:根节点在前,左子树连续,右子树连续。这样遍历时访问的节点更集中,GPU 上的缓存命中率会明显改善。重排本身需要一次额外遍历,但只做一次,长期收益通常大于成本。
栈大小也要注意。LBVH 在重复码多、场景聚集严重时可能退化得很深,固定 64 或 128 的栈不一定够。栈溢出在 GPU 上不会总是报错,可能表现为部分光线提前结束,画面出现随机黑块。我的做法是记录最大深度,如果超过阈值就在构建阶段增加叶节点分桶,把过深的子树压平。遍历时先访问距离更近的子节点,可以更早拿到命中结果,减少后续无效访问。这个“有序命中”优化对阴影线和反射线效果尤其明显。
4.2 光线求交的 AABB 测试优化:预计算倒数、epsilon、tmin/tmax
AABB 测试是遍历里最热的代码。常见写法是每个节点用光线方向分量做除法,实际项目里可以预计算方向倒数,减少重复除法。但预计算倒数会放大零方向分量的数值问题,必须配合零分量判断。更稳的做法是保留原始方向,对绝对值小于阈值的分量走 slab 区间判断,其余分量用倒数乘法。阈值不要用固定 1e-8,要跟场景尺度关联。
另一个常见问题是 tmin 和 tmax 的更新顺序。遍历时当前节点的 tmin 应该继承光线的全局 tmin,tmax 继承当前最近命中距离。如果子节点测试通过但 tmin 大于 tmax,说明这个分支不可能有更近命中,应该直接跳过。很多漏交不是因为 AABB 测试错,而是因为 tmax 在递归过程中被错误覆盖。我的建议是把 AABB 测试写成纯函数,输入节点包围盒、光线原点和方向、当前 tmin/tmax,输出是否相交和新的区间,方便单独做单元测试。
4.3 叶节点大小与图元分桶:三角形数量选择
一个叶子放一个图元,树最深、节点最多,但叶内求交最简单。一个叶子放多个图元,节点数减少,遍历层数降低,但叶内要暴力测试多个图元。叶节点大小 K 的选择没有万能值,我常用的基线是 4 到 8。K 等于 4 时节点数大约减少到单图元叶子的四分之一,叶内测试成本可控;K 等于 8 时树更矮,但叶内测试次数增加,适合图元较小、命中率较高的场景。
分桶时要注意 AABB 合并范围必须覆盖组内全部图元。如果按排序后的固定步长分组,最后一组要单独处理。分组后叶节点 AABB 可能比单个图元大不少,尤其是组内图元在空间上不够接近时。莫顿码排序已经保证了局部性,所以固定分组通常够用。如果发现叶节点 AABB 异常大,先检查排序是否稳定、重复码是否导致相邻图元实际距离很远。
4.4 实例化与分层结构:TLAS/BLAS 的常见取舍
场景里有大量重复模型时,不要把所有三角形都塞进一棵 LBVH。更常见的做法是分层:每个模型内部构建 BLAS,实例层构建 TLAS。实例的包围盒由模型局部包围盒经变换得到,TLAS 只负责实例级求交,命中后再进入 BLAS。这样构建成本低,内存也省,因为 BLAS 可以复用。常见问题是实例变换后没有更新包围盒,导致光线在实例边缘漏交;或者每帧重建所有 BLAS,白白浪费。
我的经验是静态模型 BLAS 只构建一次,动态变形模型单独 refit 或重建;TLAS 每帧根据实例变换更新。实例包围盒更新时要用变换后的八个角点重新计算 AABB,不要只变换 min 和 max 两个点,否则旋转后会偏小。分层太深也会增加遍历开销,通常两层足够,不要为了“结构清晰”叠三四层。
5. 常见问题速查与排查技巧实录
5.1 症状、指标、根因速查表
排查 LBVH 问题最怕一上来就改参数。更有效的方式是先看症状,再看指标,最后定位根因。下面这张表是我在模拟项目里反复用到的速查表,覆盖构建、遍历和内存三类问题。
| 症状 | 优先看什么指标 | 常见根因 | 处理方向 |
|---|---|---|---|
| 构建时间突然翻倍 | 排序耗时、同步点数量 | 重复码过多或排序键变复杂 | 统计重复段,检查码宽和场景归一化 |
| 构建完成但遍历很慢 | 最大深度、平均节点访问数 | 树退化或叶节点 AABB 过大 | 调整叶大小,检查分组边界 |
| 画面随机黑块 | 栈溢出计数、漏交统计 | 遍历栈不够或 tmax 被覆盖 | 增加栈深,单元测试 AABB 函数 |
| 模型边缘漏交 | 实例包围盒覆盖检查 | 只变换 min/max 导致 AABB 偏小 | 变换八个角点重算包围盒 |
| 显存缓慢增长 | 每帧分配次数 | workspace 未复用 | 预分配缓冲,按最大规模预留 |
| 同一场景结果不稳定 | 重复码顺序、排序稳定性 | 不稳定排序导致树结构抖动 | 复合键排序,固定 tie-breaker |
| refit 后性能下降 | 节点 AABB 面积增长 | 树质量退化 | 提高重建频率或局部重建 |
| 光线越界崩溃 | 节点索引范围 | 子节点索引映射错误 | 打印节点范围,检查叶节点编码 |
5.2 调试工具与最小复现:CPU 参考实现、哈希校验、可视化
LBVH 的调试不要只依赖 GPU 调试器。更快的路径是先写一个 CPU 参考实现,规模可以很小,只做排序、合并、遍历三件事。用两个三角形、三个共面三角形、中心点完全相同的图元做最小复现,逐步增加复杂度。CPU 版本跑通后,再用它校验 GPU 版本的排序结果、节点范围和遍历命中。两边结果对不上时,先比排序数组,再比节点 AABB,最后比命中距离,不要一上来就怀疑光线求交。
哈希校验也很实用。排序后检查码值是否单调不减;节点数组检查每个内部节点的左右子范围是否连续覆盖;叶节点检查图元索引是否恰好出现一次;遍历结果用暴力求交做对照,统计漏交和错交。可视化方面,可以把节点 AABB 画成线框,观察是否明显偏离图元;也可以按深度着色,快速看出树是否严重偏斜。最小复现加哈希校验,通常比在复杂场景里盲调快得多。
5.3 性能回归排查顺序:先正确性后性能
性能回归时,我按固定顺序排查:正确性、构建、遍历、内存、同步。先确认遍历结果和暴力求交一致,再单独测构建耗时,关闭遍历只跑构建;然后关闭构建只跑遍历,用固定树测试;接着看内存峰值和分配次数;最后检查同步点和流依赖。这个顺序能避免把正确性问题误判成性能问题。
如果构建变慢,先看排序 pass 数和重复码比例,再看节点 AABB 计算是否退化成串行。如果遍历变慢,先看树深度和叶节点大小,再看 AABB 测试是否引入了额外分支。不要在没有基线的情况下改莫顿码位宽,位宽变化会同时影响排序、树质量和遍历,三个变量一起动,很难判断谁起了作用。
5.4 踩坑清单与参数基线
下面这些参数可以作为第一版基线,后续再按场景调。莫顿码位宽 10 到 12;叶节点图元数 4 到 8;AABB 扩张量取场景对角线的 1e-5;遍历栈初始 64,最大深度超过 48 时考虑分桶;动态场景重建阈值按移动图元比例 20% 或每 30 到 60 帧;节点数组按 64 字节对齐;workspace 按最大图元数的 2 倍预留。参数不是越“精确”越好,稳定和可复现更重要。
踩坑清单里最值得记住的是:排序键必须带唯一 tie-breaker;零面积 AABB 必须统一扩张;叶节点分组边界不能漏;refit 必须后序;实例包围盒要变换八个角点;异步构建要双缓冲。这些问题看起来分散,本质都是“边界没有覆盖完整”。LBVH 的代码量不大,难点几乎全在边界和数值稳定性上。
6. 交付前的验证与工程化收尾
6.1 正确性验证:遍历结果与暴力求交对照
交付前至少要做一轮随机光线对照测试。生成一批覆盖场景的随机光线,分别用 LBVH 遍历和暴力遍历所有图元求最近命中,比较命中距离和命中图元 ID。距离容差按场景尺度设置,不要用固定 1e-6。除了随机光线,还要专门测退化场景:光线平行于轴、光线起点在包围盒内部、图元共面、图元中心重复、超大和超小图元混合。这些场景最容易暴露 AABB 和排序边界问题。
我通常会把测试分成三层:最小几何单元测试、单模型测试、整场景测试。最小测试保证 AABB 函数和节点编码正确;单模型测试保证排序和合并正确;整场景测试保证实例和分层结构正确。每层都记录漏交率、错交率和最大深度。漏交率不为零时不要急着看图像,先定位是哪个节点范围没有覆盖到哪些图元。
6.2 性能基准:构建与遍历分离、热缓存与冷缓存
性能基准要分离构建和遍历,否则无法判断优化落在哪一段。构建基准固定图元数量和图元分布,连续跑多帧取 P50 和 P95;遍历基准固定树和相机路径,测不同分辨率下的光线吞吐。热缓存和冷缓存也要分开看:热缓存反映稳定运行时的表现,冷缓存更接近加载或切换场景后的第一帧。不要只测一次就下结论,GPU 频率和后台负载都会影响结果。
记录基准时把参数一起写下来:莫顿码位宽、叶节点大小、AABB 扩张量、节点布局、是否异步。否则过两周再回看,根本不知道那组数据对应哪版配置。我的习惯是每次改参数都生成一个配置快照,和性能数据放在一起,回归时直接对比。
6.3 配置版本化与日志:让下次故障可追
LBVH 项目的参数很多,硬编码在代码里迟早会出问题。更稳的做法是把码宽、叶大小、扩张量、重建阈值、栈深、workspace 倍率放进配置文件,启动时打印关键参数和实际分配内存。运行中记录构建耗时、最大深度、节点数量、漏交计数和显存峰值。日志不用很花哨,但必须能在出问题时回答“当时用的是哪组参数”。
我个人的体会是,LBVH 的常见问题很少是某个高深算法没写对,更多是边界条件、数值稳定性和参数基线没有管住。先把最小场景和 CPU 参考实现跑通,再把排序、节点范围、AABB 测试这三条线分别校验,最后才去调性能和树质量。这样走下来,构建快和遍历稳并不是互相矛盾的目标,关键是别让一个隐蔽的边界错误把整棵树的收益吃掉。