做实时渲染和物理仿真的同行多半都在某个阶段被包围盒层次结构折腾过。传统自顶向下递归分裂的 BVH 建树方式在 CPU 上跑得好好的一旦搬到 GPU 上就立刻暴露问题递归深度不可控、分支发散严重、每个线程干活的量完全不均等。于是 LBVHLinear Bounding Volume Hierarchy线性包围盒层次结构这条路线就成了绕不开的选项它把建树问题转化成一次排序加一轮并行扫描构建复杂度从 O(n log n) 的递归分裂降到接近线性在 GPU 上的并行度极其漂亮。漂亮归漂亮真动手把它跑通并且跑对是另一回事。我见过太多项目在建树阶段指标全部正常、三角形一个不少、内存不越界但画面就是偶尔闪一下或者相机一转某些角度就漏掉一整块几何体。这类问题的麻烦在于它们不属于崩溃级别日志抓不到断言也不触发只能靠肉眼在动态画面里捕捉。这篇就把 LBVH 从量化、排序、建树、合并、refit 到遍历各个环节最容易踩的坑摊开讲重点不在复述算法流程而在讲清楚每个坑为什么会出现、怎么用最小的代价定位、以及我实际验证过的修法。读者最好是已经写过一版能用但不稳的 LBVH或者正准备把自顶向下建树换成线性建树的人新手上手也能看懂我会在必要的地方补基础概念。1. 先定位症状LBVH 出问题时到底该看哪几个指标动手改代码之前先学会看症状。LBVH 的 bug 有一个特点它们几乎不会以程序崩溃的形式出现而是以几何体局部消失、画面周期性闪烁、或者遍历耗时莫名其妙地抖这三种面貌出现。不同的症状指向的问题层级完全不同如果一上来就盲改代码很容易在完全无关的环节浪费一整天。1.1 从三类症状倒推问题层级局部几何体消失且消失区域边界很方——这九成是量化或建树范围确定的问题。因为消失区域的边界恰好对应某一段连续的 Morton 码说明那一段图元在构建过程中被整块跳过了。我遇到过一次屏幕上一条狭长的走廊隔三差五地不见最后定位到节点区间二分搜索的终止条件写错导致最后一个内部节点的覆盖范围少算了右端点。整帧或半帧闪烁随时间随机——这类多半是 AABB 自底向上合并时的依赖顺序问题。GPU 上多个线程同时写同一个父节点的 AABB如果没有正确同步就会发生读到一个还没初始化完的子节点 AABB合并结果里混进 ±INF 或者极端大的值于是该节点在光线求交时被判定为不命中整块几何体瞬间消失下一帧又碰巧算对了就闪一下。构建耗时正常但遍历耗时剧烈抖动——这是构建质量问题不是正确性问题。LBVH 按 Morton 码分块天然不优化表面积启发式SAH遇到空间分布极不均匀的场景会生成大量互相重叠的节点 AABB光线穿过时命中的节点数暴涨。相机朝向不同抖动幅度能差三到五倍。排查顺序我一般这么走先确认几何体数量和三角形索引是否完整排除数据上传问题再可视化节点 AABB 看重叠率区分正确性问题与质量问题最后才动具体的建树代码。跳过前两步直接读代码效率极低。1.2 三个必须埋进去的验证钩子我建议在项目里固定加三个调试开关它们救过我很多次暴力对照开关打开后光线求交退化成线性遍历所有三角形不走 BVH。任何BVH 结果和暴力结果不一致的情况都能立刻被抓出来。代价是慢只在调试构建里开。节点 AABB 可视化把内部节点的包围盒按深度着色画出来深度越大颜色越暖。正常情况应该看到层次分明的嵌套结构如果看到大量同色盒子相互穿插重叠说明量化粒度太粗或者树严重不平衡。统计直方图每次重建后输出叶子数量、内部节点数量、最大树深、平均树深、节点 AABB 的平均重叠对数。前四个是正确性指标最后一个是质量指标。这里有个容易忽略的点内部节点数量必须精确等于叶子数量减一。如果你用的是满二叉树结构每个内部节点恰好两个子节点这个恒等式一旦不成立说明建树循环里有节点被漏掉或者重复写入了。我见过有人在叶子数为奇数时做特殊处理结果引入了额外的内部节点整个索引偏移全乱了。这个断言成本几乎为零一定要加。提示把这三个钩子做成编译期开关而不是运行期开关避免它们混进正式版本的性能测试里也避免被后续维护者顺手删掉。2. Morton 码那一步量化位宽与场景尺度不匹配是最常见的根因如果只能挑一个环节来重点防守我会选量化。LBVH 的全部正确性和大部分质量都建立在Morton 码能合理区分图元这个前提上而这个前提在真实场景里非常脆弱。2.1 为什么 10 位每轴会成为默认值把三个坐标轴各量化为 10 位拼成 30 位整数这个配置流传极广原因是它刚好塞进 32 位整数排序开销最低而且 1024 个格子对大多数中等尺度场景够用。但够用和正确是两码事。量化过程是这样的先算出整个场景所有图元包围盒中心的最小值和最大值把每个中心按(c - min) / (max - min)归一化到 [0,1]再乘以2^b - 1取整。问题在于这个映射是全局统一的场景里最大跨度决定了整个格子的尺寸跨度 10 公里的地形上格子边长接近 10 米那么一个 5 厘米大小的螺丝钉和旁边 8 厘米的螺母中心距离可能只有 3 厘米它们必然落进同一个格子Morton 码完全一样。更麻烦的是 clamp 的边界。归一化理论上落在 [0,1]但浮点除法在极值处可能算出略大于 1 的结果乘以2^b - 1再取整就可能得到2^b直接溢出到下一个轴的位置上把一个图元的坐标编码到完全错误的分区。修法很土但有效// 量化单轴b 为位宽 inline unsigned quantizeAxis(float c, float minV, float maxV, int b) { float inv 1.0f / (maxV - minV); // 场景退化时这里会出 INF float t (c - minV) * inv; t fminf(fmaxf(t, 0.0f), 1.0f); // 先夹住别指望后面的取整 unsigned q (unsigned)(t * (float)((1u b) - 1)); return q; // 结果必然 2^b - 1 }两个细节maxV - minV在某些轴上可能为零所有图元在同一个平面上这时inv是无穷大t变成 NaN 或者 ±INFfminf/fmaxf对 NaN 的行为是实现相关的夹不住。所以要么提前给包围盒加一个 epsilon 膨胀要么在inv那里判一下分母绝对值小于阈值时直接返回 0。我倾向后者因为膨胀包围盒会影响后续的 AABB 合并精度。2.2 尺度差异大的场景怎么救分块与局部归一化全局统一量化在跨尺度场景里注定失效这不是调参数能解决的问题。工程上的标准做法是两级结构先用一个自顶向下的粗粒度划分可以用网格、也可以用低分辨率的 SAH 或者简单的空间中位数切分把整个场景切成若干块让每块内部的空间跨度接近然后每块独立做局部归一化各自量化为 Morton 码各自建一棵 LBVH最后在块之间再建一个简单的顶层结构。这样做的收益非常直接。一个 10 公里的地形切成 200 米见方的块之后块内格子边长降到 20 厘米前面那个螺丝钉的问题就消失了。代价是跨块的光线要额外遍历顶层结构以及块的划分会在边界处产生额外重叠。经验上如果场景的图元尺寸差异超过两个数量级两级结构基本是必选项差异在一个数量级以内单级还能撑住。块的大小怎么定我一般按每块图元数在 2 万到 8 万之间来切。低于两万排序的并行度吃不满构建开销里固定成本占比太高高于八万块内格子边长又开始变粗质量下滑。这只是起点真实项目里还要看显卡的占用率和原子操作竞争程度需要实测微调。2.3 量化键冲突当一堆图元落进同一个格子即使做了分块仍然会有图元共享同一个 Morton 码。最典型的是完全重合的图元双面渲染的两层、同一个模型被复制粘贴到同一位置或者大面积的地面网格其中心点恰好集中。这时候如果只拿 30 位 Morton 码做排序键排序结果里这些图元的相对顺序取决于排序算法是否稳定而后面建树环节假设的是严格递增的码序列一旦出现相等最长公共前缀就退化成完全相同父节点查找逻辑会陷入死循环或者越界。修法是在 Morton 码后面拼上图元索引构成 64 位复合键高 32 位放 Morton 码低 32 位放索引。这样保证了键的唯一性排序之后顺序完全确定。有人担心 64 位整数排序比 32 位慢实测在这个数据量级上差异很小GPU 基数排序做 8 位一桶、64 位也就是 8 趟比起排查一个诡异的死循环这点开销完全值。还有一个隐蔽变种如果图元数量超过 2^32索引放不下 32 位。这种情况不多见但存在超大城市级场景的细分小三角形那就用 64 位索引或者用两个 32 位字段拼。判断方法很简单构建前断言一下图元数量。3. 建树阶段的高频翻车父节点索引、节点区间与 AABB 合并顺序量化做对了建树环节照样能翻车。这一段的代码通常只有几十行但几乎每一行都存在边界条件。3.1 最长公共前缀那一堆 ±1 的边界线性建树的核心是最长公共前缀LCP函数给定两个有序叶子位置 i 和 j返回它们 Morton 码从最高位开始连续相同的位数。整个算法围绕它展开——对每个叶子比较它和右邻居的 LCP、它和左邻居的 LCP哪个大就说明它属于哪个方向的子树。这里有两个必须处理的边界第一越界返回 -1。当 i 或 j 超出[0, n-1]范围时LCP 应该返回 -1表示不存在公共前缀从而保证边界叶子的方向判断正确。但如果返回值类型写成无符号整数-1 会变成 4294967295所有比较全部反转最小和最大的叶子会被分到一起整棵树彻底错乱。返回值必须是有符号的 32 位或者 64 位整数。第二从最高位开始扫的效率。朴素写法是一位一位往下比最坏 30 次循环。GPU 上每个线程都跑这个循环开销不低。用__clz(xor(a, b))一类的前导零计数指令能把它压成一条指令注意异或结果为 0 时要返回位宽而不是 32。// delta 返回两码的公共前缀长度越界返回 -1 inline int delta(const unsigned* codes, int n, int i, int j) { if (j 0 || j n) return -1; unsigned x codes[i] ^ codes[j]; if (x 0) return 32; // 完全相同返回位宽 return __clz(x); // 前导零数即公共前缀长度 }注意codes[i] ^ codes[j]相同的情况返回 32而不是 30。虽然我们的码只有 30 位有效但返回 32 更安全因为后续比较只看相对大小。有人在这里返回 30遇到相同的码时反而给出了一个比有 5 位公共前缀还大的值方向判断就翻车了——这种情况在加复合键之后基本不会出现但如果用的是纯 30 位码就是必踩的坑。3.2 节点区间二分搜索的终止条件确定了叶子是左子还是右子之后要沿着那个方向做二分搜索找到这棵子树的叶子区间端点。搜索的目标是找到满足 delta(i, k) nodeDelta的最大 k其中 nodeDelta 是节点自身的前缀长度。标准写法里分两种情况向左搜时找最小的 k 满足条件向右搜时找最大的 k 满足条件。这里最容易错的是循环收缩时用的是开区间还是闭区间以及mid 的取整方向。向右搜的时候mid (lo hi 1) / 2需要上取整否则当lo 1 hi时会死循环向左搜时mid (lo hi) / 2下取整。写反了不会立刻崩而是区间少包含一个叶子那个叶子从此在遍历中被完全跳过——这正是第 1 节里边界很方的局部几何体消失的典型来源。注意这两个取整方向我建议直接用注释标死在代码旁边因为半年后回头看没有注释你绝对会怀疑自己当初为什么这么写。我在两个不同项目里都因为重构时顺手统一了一下取整方式而引入过这个 bug。3.3 AABB 自底向上合并的依赖顺序与初始化节点区间确定之后每个内部节点都知道自己覆盖哪一段叶子。接下来算 AABB内部节点的 AABB 等于两个子节点 AABB 的并集。问题在于在 GPU 上所有内部节点是并行计算的但父节点必须等两个子节点都算完才能算。标准做法是原子计数器加一次值每个内部节点维护一个计数器两个子节点各自完成后对父节点的计数器做原子加谁把计数器加到 2谁就负责计算父节点的 AABB。这个方案本身没问题坑在初始化和内存序上。初始化方面所有节点的 AABB 必须显式初始化为反向无穷min 分量设 INFmax 分量设 -INF。有些实现为了省事用 0 初始化结果在合并时会得到错误的并集——一个从原点出发的包围盒会污染整个上层。别赌这个概率老老实实批量初始化一遍。内存序方面负责计算父节点的那个线程要读子节点的 AABB必须保证子节点的写入对它是可见的。计算着色器里用原子操作时通常需要配合栅栏或者显式的线程组同步。我遇到过一次只在某些显卡驱动版本上闪的问题最后确认就是对写没有正确同步子节点的 AABB 还没落到全局内存父节点就开始读了读到一个中间态。修法是在写子节点之后插入一个内存屏障或者把整个合并循环拆成先全部写完、再统一同步、再算父节点两趟。后者更容易写对代价是多一次全局同步性能损失在构建总耗时里大概 5% 到 10%我认为值。再补一个精度上的细节如果某个内部节点下面只有一个叶子这在退化情况下会发生它的 AABB 就是叶子本身的 AABB。有些实现会在这里做特判直接拷贝有些则走统一的合并路径。走统一路径时要注意那个不存在的子节点的 AABB 必须是反向无穷才能保证并集结果正确。用一个虚拟的无穷大包围盒参与合并比写一堆 if 分支要干净得多。4. 建得快但追得慢LBVH 构建质量的补救手段到这一步你的 LBVH 大概已经能正确渲染了。接下来会遇到第二个层面的问题性能不达预期。这跟正确性无关纯粹是构建质量。4.1 LBVH 和 SAH-BVH 的差距到底从哪来表面积启发式的本质是在构建时尝试多种切分位置挑出使左子表面积×左子图元数 右子表面积×右子图元数最小的那个。这需要大量候选测试天然不适合大规模并行。LBVH 换了个思路空间位置相邻的图元在 Morton 码上相邻按码排序之后就按位置切分完全不考虑图元大小和疏密。差距体现在哪举个具体例子。一条长走廊两侧是密集的小物件走廊尽头是一整面墙。SAH 建树会倾向于先把墙和走廊里的东西分开因为墙的包围盒很大单独成组能显著减少光线穿过的节点数。LBVH 按位置切会把走廊按长度均匀分段每一段都跨越整面墙的一部分——于是光线无论射向哪一段都要命中那个包含墙的大包围盒白跑很多次。实测数据上我经手过的几个场景LBVH 相比 SAH-BVH 的光线遍历耗时高出 20% 到 50%场景越不均匀差距越大。这个差距不是不能接受但你得心里有数别指望 LBVH 在遍历性能上追平 SAH。4.2 局部优化与两级结构既然建树速度快是 LBVH 的核心优势那就别推倒重来做局部补丁。我验证过有效的有两条路第一条是节点重排。建完树之后固定拓扑不变只在每个内部节点下面做一次小范围的重新分配把该节点区间内的叶子按某个辅助度量重新排序比如按图元包围盒的最大边长降序或者按到区间中心的距离。这样做的成本是 O(n log n) 但常数很小收益不平滑但确实存在在不均匀场景里通常能削掉 5% 到 15% 的遍历开销。第二条更彻底是两级混合结构。前面 2.2 节讲的分块如果用稍微讲究一点的切分在块级别用一次简化的 SAH 评估块内用 LBVH能得到接近纯 SAH 的遍历质量而构建成本仍然远低于纯 SAH。这是我目前最推荐的路线尤其适合那些既要实时重建又要高帧率的场景。方案构建耗时遍历质量适用场景纯 LBVH最快一般图元分布均匀、动态性强LBVH 节点重排快中多数静态或半动态场景分块 块级 SAH中良跨尺度、不均匀、需实时重建纯 SAH-BVH慢优静态离线场景构建一次用到底表格里质量一列是我的主观评级参考基准是纯 SAH 在相同场景下的遍历耗时。要注意的是构建耗时的绝对值强依赖硬件和实现别拿别人的数字直接套。还有一个常被忽视的因素图元本身的分裂。大三角形比如那面墙在 BVH 里可以预先切成若干小三角形再参与构建这一步在 CPU 上是常规操作在 GPU 上因为增加图元数而显得不划算。但如果你的场景里存在少量相对尺寸极大的图元切分的收益会非常明显构建时间增加 10% 换来遍历时间下降 30% 是常见的交换。5. 动态场景下的 refit 与重建判定条件比实现更考验人真正让 LBVH 有价值的场景是动态的布料飘动、粒子飞散、刚体翻滚。这些情况下每一帧都要更新结构而全量重建的开销往往吃掉大部分帧时间预算。5.1 refit 什么时候够用什么时候不够refit指的是保持树的拓扑结构不变只自底向上重新计算每个节点的 AABB。它极其便宜——只是一次从叶子到根的自底向上遍历没有排序、没有区间搜索。但它有个致命前提拓扑结构对应的是旧位置新位置的图元可能在空间上跑到了树的另一端而树不认这个仍然按老归属组织。后果很具体。假设一束粒子从向左飞改成向右飞refit 之后所有节点的 AABB 都会膨胀到覆盖两个位置的并集光线穿过这些膨胀盒子的概率大幅上升遍历耗时可能翻两三倍而且渲染结果是对的你只在性能报表里看到异常会误以为是别的地方的问题。判断 refit 是否够用的经验法则如果每帧图元的位移量小于该图元所在叶子区间尺度的 10%refit 通常够用超过 30% 就该重建了。这个阈值不是理论值是实测出来的经验数不同场景需要调整。5.2 一个可落地的重建触发策略我目前用的是三重触发任何一个满足就重建全部不满足就 refit固定帧数上限。比如每 30 帧强制重建一次。这条看起来粗暴但它兜住了所有我没想到的退化情况是最后的保险。位移累计量。每个图元记录自上次重建以来的累计位移平方和超过阈值就重建。这个指标比单帧位移更靠谱因为它能捕捉慢慢漂移的情形。AABB 膨胀比。每次 refit 之后统计所有节点 AABB 的总表面积和上次重建时的总表面积比。比值超过 1.5 就重建。这个指标直接对应遍历性能是最贴近实际体感的判据。第三条需要多算一次表面积统计成本可以忽略本来就是遍历一遍节点但效果最直接。实测在布料仿真场景里三重触发能把遍历耗时稳定在重建后水平的 1.2 倍以内而纯固定帧数策略的抖动范围是 1.0 到 2.5 倍。提示这些统计量在 GPU 上求和使用原子加会产生竞争如果图元数量在百万级原子加本身会变成瓶颈。我一般在节点数超过某个阈值后改成两段式每个线程组内做局部累加最后只对组间结果做一次原子加。这个优化能让统计耗时从几毫秒降到零点几毫秒。还有一点关于 refit 的细节自底向上的遍历顺序必须严格是从叶子层往上一层一层来。如果你用一个数组一次性并行处理所有内部节点前面的节点可能读到还没更新的子节点。正确做法是按层遍历每层之间做一次同步层数就是树深通常不超过三十层同步开销可控。6. 遍历阶段的精度地狱与栈管理建树全对了遍历环节还有一批问题在等着。这一段的 bug 有一个共同特点只在特定相机角度或者特定几何体上出现测试用例很难覆盖全靠经验预判。6.1 slab 测试的 NaN 与安全倒数光线和轴对齐包围盒的相交测试用 slab 方法对每个轴算光线进入和离开该轴 slab 的参数 t取三个轴进入值的最大值和离开值的最小值若最大值小于最小值则相交。数学上很干净浮点上不干净。问题出在光线方向分量为零的时候。1.0f / 0.0f得到正无穷或负无穷然后(boxMin - origin) * inf在boxMin origin的情况下得到0 * inf NaN。NaN 参与fminf/fmaxf的比较结果由实现决定可能是命中也可能是不命中而且在同一个驱动上不同批次可能不一致——听起来夸张但我确实遇到过同一份代码在不同场景里表现不同的情况。修法是安全倒数// 安全倒数方向分量接近 0 时用一个极大值代替无穷 inline float safeInv(float d) { const float eps 1e-8f; if (fabsf(d) eps) d (d 0.0f) ? -eps : eps; return 1.0f / d; }把接近零的方向分量钳到 ±eps倒数就变成有限的大数0 * 大数仍然是 0NaN 消失。这个方法会让光线完全平行于某个面的边界情况变成几乎平行代价是极少数情况下多访问几个节点性能影响可以忽略。相比之下用 NaN 的传播去做分支判断是自找麻烦。另一个精度问题是 t 的初始值。把最小 t 设成 0会导致起点恰好位于包围盒表面的光线被判定为不命中因为浮点误差算出略微小于 0。我一般设成一个极小的正数比如 1e-6具体值取决于场景尺度——单位是米的话 1e-6 对应微米级够用如果场景单位是厘米这个值就该相应缩小。6.2 固定栈的大小怎么估GPU 上不能用递归遍历靠显式栈。栈用固定大小的数组在寄存器或共享内存里常见配置是 32 到 64 层。栈溢出没有被检测的话后果是覆盖其他线程的数据或者静默丢弃节点画面表现为随机的三角形丢失。栈深度够不够取决于树的最大深度。理想情况下 LBVH 的深度是 O(log n)百万图元大约二十层留一倍余量完全够。但前面 2.3 节讲过的 Morton 码冲突如果没处理好树可能退化成一条链深度达到上千层栈必然爆。所以最大树深是一个必须监控的指标我一般在建树结束后用一个归约求出实际最大深度超过预设值就打印警告。推测起来如果最大深度明显超过2 * ceil(log2(n))说明树结构有问题而不是栈需要加大。这时候加大栈是在掩盖 bug不是修 bug。真正需要加大栈的情况很少除非你的场景本身极度不平衡比如一条笔直延伸到远方的图元链。遍历顺序上有个小优化根据光线方向的符号位决定先访问哪个子节点让更近的那个先被测试这样近处的命中能把最大 t 压下来远处的节点被快速剪枝。实现上就是取方向的符号位做一次条件交换成本极低收益在遮挡密集的场景里能到 20% 以上。这个不算 bug 修法但属于必须做的常规操作。6.3 遍历顺序与提前退出还有一类问题来自提前退出逻辑写错。遍历过程中维护一个当前最近命中的 t如果某个节点的进入 t 大于这个值就可以跳过。这里的比较用的是节点的进入 t 还是离开 t差一个符号就完全错了。正确的判据是节点的进入 t 大于当前最近的命中 t则整个节点可以被跳过因为光线进入这个盒子的时候已经越过了最近的命中点盒子里的任何东西都不可能更近。写成离开 t 大于命中 t就会漏掉一部分本应被剪枝的节点正确性还在但性能下降属于那种能跑但慢的隐性 bug很难被发现。我建议在调试构建里加一个计数器统计每个像素访问的节点数和三角形数和理论预期对比。正常情况下每像素访问的三角形数应该远小于总三角形数如果接近总数说明剪枝基本没生效。7. 内存布局与并行构建的收尾调优前面六节解决的都是对不对的问题这一节聊快不快。当你已经有一个正确且质量尚可的 LBVH最后能抠出来的性能通常在内存访问模式上。7.1 节点数组的两种排布节点数据有两种排法结构体数组AoS每个节点一个结构体里面放包围盒、左子索引、右子索引、叶子范围以及数组的结构体SoA包围盒的六个分量各占一个连续数组索引信息占另外的数组。遍历时主要是读包围盒AoS 一次内存事务能取到全部六个分量前提是对齐正确SoA 的好处是如果某些阶段只需要部分分量能省带宽。实测下来在 LBVH 遍历这个具体场景里AoS 配合 32 字节对齐通常略优因为遍历几乎总是要用到完整的六个分量一次取完最划算。关键的细节是结构体大小必须是 32 的倍数否则跨越缓存行会导致每次读取两行。一个节点如果存六个 float24 字节加两个整型子索引8 字节正好 32 字节很理想。如果要加额外字段比如父节点索引、图元计数要么补齐到 64 字节要么把额外字段放到另一个数组里。叶子和内部节点是否分开存放也有讲究。我倾向统一编号内部节点在前、叶子在后这样两个子节点的索引可以用一个简单的算术关系表示比如父节点索引 p 的左子是p offset省掉两个 32 位字段把节点压到 24 字节配合 8 字节的对齐填充仍然是 32。这个技巧在节点数上千万时能省下可观的内存量。7.2 原子操作的取舍建树阶段的原子操作有三个来源复合键排序时的桶计数、父节点依赖的计数器、以及 AABB 合并的完成标记。前两个是必需的第三个可以省。AABB 合并那一步如果你的实现是子节点写完就原子加加到 2 的那个线程算父节点那千万级节点的场景里原子加的次数接近两千万次热点明显。替代方案是按层做的两趟扫描先算出每个节点的层号可以在建树时顺便记下然后从最底层往上每层一次全局同步用该层的所有节点两两合并出上一层的 AABB。这样完全没有原子操作只有同步实测在中等规模场景里比原子方案快 20% 左右大规模场景优势更明显。代价是实现复杂度上升需要额外的层号数组和每层的起止索引。如果项目还在早期、图元数在几十万以内我建议先用原子方案把功能跑通性能优化留到后面。过早优化建树阶段的原子操作收益远不如先把遍历质量和 refit 策略调好。最后提一个容易忽略的点排序用的临时缓冲区。基数排序需要一份等大的输出缓冲来回倒腾。有些实现每次重建都重新分配这在大场景里会造成显著的内存分配抖动。正确做法是启动时按最大图元数一次性分配好之后复用。同理栈、计数器、统计数组都应该预分配。我自己踩过一次坑重建耗时在不同的运行时高达 30% 的波动查了很久才发现是某个中间数组在重建路径上被反复申请释放。提示预分配之后记得在每次重建前把计数器数组清零清零本身也是一次全量写不能省。有些实现为了省这次清零用纪元标记每个计数器带一个版本号版本不匹配就视为 0能省掉清零但增加读取逻辑值不值得看场景规模。图元数在五十万以内直接清零更简单可靠。这套东西全部打通之后LBVH 在动态场景里的表现会相当扎实构建耗时稳定在毫秒级遍历质量接近 SAH 的八成refit 在大多数帧里能顶住。剩下能优化的空间基本都在具体场景的调参上没有太多通用手段了。