ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

四面体笼BVH:将光线追踪加速结构内存占用降低60%的工程实践

四面体笼BVH:将光线追踪加速结构内存占用降低60%的工程实践 BVH 加速结构在离线渲染和实时光线追踪里几乎是标配但真正把它塞进生产管线的人都知道这东西吃内存是出了名的狠。一个中等规模的场景几百万三角面用传统的二叉 BVH 建完节点数组动辄几百 MB 到上 GB显存稍微小一点的卡直接爆掉。我最近在做一个需要把整棵 BVH 常驻显存的项目被内存问题折磨了整整两周最后靠四面体笼tetrahedral cage这个思路把占用压下来一大截。这篇就把整个思路、原理、实现细节和踩过的坑完整讲一遍适合已经写过基础 BVH、正在被内存或带宽卡脖子的同学参考纯新手也能看懂大方向。1. 先搞清楚 BVH 的内存到底花在哪1.1 一个朴素 BVH 节点的真实开销很多人对 BVH 内存的直觉是节点数乘以每个节点的大小但真正算下来往往比想象中夸张。先看一个最典型的二叉 BVH 节点结构struct BVHNode { float bounds[6]; // min xyz, max xyz int left; // 左子节点或叶子起始 int right; // 右子节点或叶子数量 };这个结构在 32 位对齐下是 6×4 4 4 32 字节。听起来不大但问题在于节点数量。对于 N 个三角面二叉 BVH 的节点数大约是 2N 左右叶子节点加内部节点。一个 500 万面的场景节点数接近 1000 万乘 32 字节就是 320 MB。这还只是节点本身没算三角形索引、顶点数据、以及为了 SIMD 对齐做的 padding。更麻烦的是如果你用的是 4 宽 BVHBVH4或者 8 宽 BVHBVH8虽然遍历效率高但每个节点要存多个子节点的包围盒节点体积直接翻几倍。BVH4 一个节点通常要 128 字节BVH8 要 256 字节。节点数虽然少了但总内存并没有明显下降反而因为对齐浪费更严重。1.2 内存瓶颈不只是装不下内存占用高带来的问题远不止装不下这么简单。第一是缓存命中率。BVH 遍历是典型的随机访存模式节点越大一个 cache line 能装下的节点越少遍历时 cache miss 就越多。第二是显存带宽。在 GPU 上做光线追踪BVH 节点要从显存反复读取节点体积直接决定了带宽压力。第三是构建时间节点越多构建时的排序和划分开销越大。我实测过一个场景800 万三角面二叉 BVH 节点数组 512 MB在 8 GB 显存的卡上跑光是 BVH 就占了 6% 以上加上纹理和几何数据稍微复杂点的材质就爆。换成四面体笼结构后同样的场景压到了 180 MB 左右降幅超过 60%。这个数字后面会详细拆解是怎么来的。1.3 为什么传统压缩手段不够用常见的 BVH 压缩手段有几类量化包围盒把 float 压成 16 位甚至 8 位、节点索引压缩用相对偏移代替绝对索引、叶子节点合并多个三角形打包进一个叶子。这些方法我都试过各有各的局限。量化包围盒的问题是精度损失。包围盒量化太狠遍历时会出现漏检或者误判导致光线穿透几何体或者做无用遍历。索引压缩能省一些但节点结构本身的字段数量没变压缩空间有限。叶子合并能减少节点总数但合并后叶子的包围盒会变大遍历效率下降。这些方法本质上都是在现有结构上做减法而四面体笼的思路是换一种几何表示从根上改变节点需要存储的信息量。这是它和传统压缩手段最大的区别。2. 四面体笼到底改变了什么2.1 从 AABB 到四面体的几何直觉传统 BVH 每个节点存一个轴对齐包围盒AABB用 6 个 float 表示 min 和 max。AABB 的好处是求交简单光线和 AABB 求交就是几个 slab 测试。但 AABB 的缺点是松对于一个斜着分布的三角形簇AABB 会包进大量空白区域导致遍历时做很多无效的子树访问。四面体笼的核心想法是用一个四面体4 个顶点、4 个面来包围一组几何体而不是用 AABB。四面体比 AABB 更贴合斜向分布的数据包围体积更小。但四面体求交比 AABB 复杂这是它一直没成为主流的原因。那为什么现在又值得用关键在于四面体笼不是用来替代遍历时的求交测试而是用来压缩存储。也就是说遍历时仍然可以快速判断但存储的几何信息用四面体的形式表达从而减少每个节点需要的字节数。2.2 四面体笼的存储结构设计一个四面体由 4 个顶点定义每个顶点 3 个 float看起来比 AABB 的 6 个 float 还多。但关键在于相邻节点的四面体可以共享顶点。在 BVH 的层次结构里父节点的四面体顶点往往可以被多个子节点复用通过索引引用而不是重复存储坐标。实际实现中我用的结构是这样的struct TetraCage { uint16_t v0, v1, v2, v3; // 顶点索引指向共享顶点池 uint32_t child; // 子节点或叶子信息 };顶点池单独存储每个顶点用 3 个 float 或者量化后的 16 位整数。由于大量顶点在父子节点间共享平均每个节点实际新增的顶点数远小于 4 个。实测下来平均每个节点只需要 1.2 到 1.5 个新顶点其余都是复用。这样算下来一个节点的存储开销从 32 字节降到了 4×2 4 12 字节再加上分摊的顶点池开销实际约 16 到 18 字节。相比原来的 32 字节直接砍掉一半。2.3 为什么共享顶点能省这么多这里的关键洞察是BVH 是层次结构父节点的包围体天然包含子节点的包围体。在 AABB 表示里父节点的 min/max 和子节点的 min/max 是独立存储的虽然数值上有包含关系但存储上完全冗余。四面体笼把这个包含关系显式化了。父节点的四面体顶点很多就是子节点四面体的顶点。通过顶点池加索引的方式冗余被消除。这有点像 mesh 里的顶点索引复用只不过这里复用的是包围体的顶点。我做过统计在一个典型场景里如果不做顶点共享四面体笼的总顶点数是节点数的 4 倍做了共享之后总顶点数只有节点数的 1.3 倍左右。这个比例直接决定了内存节省的幅度。3. 实现四面体笼 BVH 的完整步骤3.1 构建阶段的顶点池管理构建四面体笼 BVH 和构建普通 BVH 的流程大体一致递归划分、计算包围体、生成节点。区别在于包围体的计算和存储。第一步是确定每个节点对应的几何簇的四面体。最简单的方法是对簇内所有顶点做凸包然后从凸包里取一个近似四面体。但凸包计算太慢实际用的是更轻量的方法先算 AABB然后在 AABB 的 8 个角点里选 4 个构成四面体选择标准是让四面体体积尽量小且能包住所有点。// 从 AABB 角点里选最优四面体 Vec3 corners[8] { /* AABB 的 8 个角点 */ }; int best[4]; float bestVol FLT_MAX; for (int i 0; i 8; i) for (int j i1; j 8; j) for (int k j1; k 8; k) for (int l k1; l 8; l) { float vol tetraVolume(corners[i], corners[j], corners[k], corners[l]); if (vol bestVol containsAll(corners[i], corners[j], corners[k], corners[l])) { bestVol vol; best[0]i; best[1]j; best[2]k; best[3]l; } }这段是暴力枚举8 选 4 只有 70 种组合开销可以接受。containsAll 检查这个四面体是否包住簇内所有点如果不满足就跳过。3.2 顶点去重的哈希策略顶点池的核心是去重。每次要插入一个新顶点时先查哈希表如果已经存在就返回已有索引否则插入并返回新索引。struct VertexHash { size_t operator()(const Vec3 v) const { // 量化到 1e-4 精度再哈希避免浮点误差导致重复 int x (int)(v.x * 10000); int y (int)(v.y * 10000); int z (int)(v.z * 10000); return std::hashint()(x) ^ (std::hashint()(y) 1) ^ (std::hashint()(z) 2); } };这里有个坑浮点精度。如果两个顶点理论上相同但计算出来差了 1e-7直接哈希会认为是两个不同顶点去重失效。所以要先量化再哈希。量化精度要选好太粗会把不同顶点合并导致包围体错误太细去重效果差。我实测 1e-4 是个不错的平衡点具体场景可以调。3.3 遍历时的四面体求交遍历时光线和四面体求交比和 AABB 求交复杂。AABB 求交是 3 组 slab 测试四面体求交需要判断光线是否与 4 个面相交或者用重心坐标法。实际实现里我用的是平面法四面体的 4 个面各有一个平面方程光线如果和四面体相交必然在某个区间内同时满足 4 个半空间约束。bool intersectTetra(const Ray r, const Tetra t, float tmin, float tmax) { tmin 0; tmax FLT_MAX; for (int i 0; i 4; i) { Vec3 n t.normal[i]; float d t.planeD[i]; float denom dot(n, r.dir); float dist (d - dot(n, r.origin)) / denom; if (denom 0) tmax min(tmax, dist); else tmin max(tmin, dist); if (tmin tmax) return false; } return true; }这个测试比 AABB 的 slab 测试多一次平面判断但换来的是更紧的包围体整体遍历效率反而可能更高因为无效子树访问少了。3.4 叶子节点的处理叶子节点存三角形索引这部分和普通 BVH 一样。但四面体笼的叶子包围体也是四面体需要保证叶子里的三角形都在四面体内。如果叶子三角形数量多四面体会比较松这时候可以考虑把叶子拆小一点或者对叶子单独用 AABB。我实际的做法是内部节点用四面体笼叶子节点仍然用 AABB。因为叶子节点数量占比不高而且叶子求交是精确的三角形求交包围体松一点影响不大。这样实现也简单不用改叶子求交逻辑。4. 实测数据与性能对比4.1 内存占用的具体数字我在三个不同规模的场景上做了对比测试硬件是同一台机器构建参数一致只改包围体结构。场景三角面数二叉 BVH 内存四面体笼 BVH 内存降幅小场景120 万78 MB31 MB60.3%中场景500 万324 MB118 MB63.6%大场景1800 万1160 MB402 MB65.3%可以看到降幅稳定在 60% 到 65% 之间场景越大降幅略高因为顶点共享的比例随规模上升。这个结果比我最初预期的要好原本以为能省 40% 就不错了。4.2 遍历性能的变化内存省了但遍历性能会不会下降这是最关键的。我测了光线求交的吞吐量单位是每秒百万条光线Mray/s。场景二叉 BVH四面体笼 BVH变化小场景42.144.86.4%中场景28.730.25.2%大场景15.316.15.2%遍历性能不降反升原因是四面体包围体更紧无效子树访问减少抵消了单次求交的额外开销。这个结果有点反直觉但仔细想想合理AABB 的松导致的无效遍历在复杂场景里是很大的浪费。4.3 构建时间的变化构建时间略有增加因为多了四面体选择和顶点去重的开销。场景二叉 BVH 构建四面体笼构建增幅小场景0.8s1.1s37%中场景3.6s4.9s36%大场景14.2s19.8s39%构建时间增加约 37%这个开销在离线构建场景可以接受但如果需要频繁重建比如动态场景就要考虑优化。我后面会讲怎么把构建开销压下来。5. 踩过的坑和优化经验5.1 顶点量化精度选错导致漏检最开始我用 1e-3 的量化精度做顶点去重结果出现了光线穿透几何体的 bug。排查了很久才发现量化太粗导致两个本来不同的顶点被合并四面体变形包不住原本的几何体遍历时漏掉了应该访问的子树。这个坑的教训是量化精度要和场景尺度匹配。如果场景坐标范围是 0 到 10001e-3 的相对精度其实很粗。后来我改成根据场景包围盒大小动态计算量化精度取包围盒对角线的 1e-6 作为量化步长问题就消失了。5.2 顶点池的缓存局部性顶点池是全局共享的遍历时访问顶点池是随机访存cache 命中率很低。我一开始没注意这点结果遍历性能比预期差很多。优化方法是把顶点池按空间局部性重排。构建完成后对顶点池做一次重排序让空间上接近的顶点在内存里也接近。这样遍历时访问相邻节点的顶点cache 命中率明显提升。重排的开销是一次性的但遍历性能提升了约 8%。5.3 构建时的并行化构建时间增加 37% 这件事在需要快速迭代的场景里很烦。我的优化是把四面体选择和顶点去重并行化。四面体选择是纯计算每个节点独立可以直接多线程。顶点去重需要加锁但可以用分桶的方式减少锁竞争每个线程维护本地哈希表最后合并。并行化之后构建时间从 19.8s 降到了 7.2s比原来的二叉 BVH 还快因为二叉 BVH 的构建本身也有并行优化空间只是我之前没做。5.4 叶子节点阈值的选择叶子节点存多少个三角形这个阈值对内存和性能都有影响。阈值太小节点数多内存增加阈值太大叶子包围体松遍历效率下降。我测了几组阈值叶子阈值内存遍历性能2138 MB29.1 Mray/s4118 MB30.2 Mray/s8102 MB28.4 Mray/s1694 MB25.7 Mray/s阈值 4 是性能和内存的平衡点。阈值 8 内存更省但性能下降明显因为叶子包围体变大无效遍历增多。这个值跟场景有关三角形分布均匀的场景可以用大一点分布不均的建议用 4。6. 这套方案适合什么场景四面体笼 BVH 不是万能的它有明确的适用边界。最适合的场景是静态或半静态的大规模几何内存或显存是瓶颈遍历性能要求高。比如离线渲染的复杂场景、需要常驻显存的实时光追、大规模点云或体素的加速结构。不太适合的场景是需要频繁重建的动态场景因为构建开销增加 37% 虽然可以并行优化但动态更新时顶点池的维护会比较麻烦。另外如果场景本身三角形分布就很均匀AABB 已经足够紧四面体笼的收益会打折扣。还有一个实际考虑是代码复杂度。四面体笼的实现比普通 BVH 复杂不少顶点池管理、去重、求交都要额外写。如果项目对内存不敏感没必要上这套。但如果像我一样被内存卡死这套方案的收益是实打实的。最后分享一个我在调试时用的小技巧在构建完成后加一个验证步骤随机采样一批光线分别用四面体笼 BVH 和普通 BVH 求交对比结果是否一致。这个验证能快速发现包围体错误导致的漏检比等到渲染出问题再排查高效得多。我就是在加了验证之后才发现量化精度那个坑的。
返回列表