ARTICLE DETAIL

资讯详情

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

从显存爆炸到流畅重建:Voxel Hashing如何重构TSDF体素存储

从显存爆炸到流畅重建:Voxel Hashing如何重构TSDF体素存储 接触过实时三维重建的朋友十有八九都被 TSDF 的显存占用折磨过。明明只是扫一个几十平米的房间内存却从几百 MB 一路爬到几个 GB最后直接吃满显卡显存程序当场崩掉。我第一次处理这种内存爆炸问题时第一反应是换更大的显卡后来发现根本不是卡的问题而是数据结构的账算错了。等到把固定体素网格改成 Voxel Hashing 之后同样一个场景显存直接从 4.7GB 降到 600MB 左右帧率还稳住了。这篇文章从内存危机的源头开始把 Voxel Hashing 解决 TSDF 内存爆炸的完整思路和数据层面细节拆开讲会涉及体素数据结构、哈希函数、冲突策略、GPU 并行更新路径以及我实际调参时踩到的各种坑。1. 内存爆炸从哪来稠密 TSDF 网格的账本1.1 TSDF 到底在存什么TSDFTruncated Signed Distance Field的核心思想是把三维空间离散成均匀的体素网格每个体素保存两个值一个带符号的距离值表示该体素到最近物体表面的距离一个权重值表示当前距离值的置信度。距离值为正说明体素在表面前方通常属于自由空间距离值为负说明体素已经进入物体内部距离接近零的地方就是表面穿过的位置。融合更新时新一帧深度数据算出的距离测量值会和体素里已有历史值做加权平均D_new (D_old * W_old d_measure * w_measure) / (W_old w_measure) W_new min(W_old w_measure, W_max)这个公式保证了多帧观测能互相纠错深度噪声被逐步平均掉。而截断指的是只保留表面两侧一定范围内的距离值超出截断距离的体素不更新、也不可信。因为消费级深度相机的误差通常随距离增大而增大太远的体素的测量值没有意义把整个空间都存满反而浪费。问题就在这儿TSDF 的物理意义决定了真正有信息的体素只分布在表面截断带附近但稠密体素网格在实现时是给整个空间里每一个体素都预留内存的。无论这个位置有没有被激活无论它离表面多远内存都已经占了。1.2 分辨率每翻一倍内存涨 8 倍体素网格的内存和分辨率的关系是所有做过重建的人都要时刻记住的分辨率翻倍三个轴向的体素数量都翻倍总共就是 8 倍内存。假设一个 6m × 4m × 3m 的房间用 10mm 体素那么体素数量是 600 × 400 × 300 7200 万个。如果按 4 字节 float 存距离值、2 字节存权重、再算上颜色和内存对齐一个体素 8 字节是非常保守的估计。7200 万 × 8B ≈ 576MB。这还没算法线、深度金字塔、渲染缓冲。很多人以为一个房间几百 MB 能接受那就试试 5mm 体素。分辨率提高一倍内存直接变成 4.6GB。现实项目里既要 5mm 精度又要扫一整层楼的场景比比皆是内存不爆才奇怪。更麻烦的是稠密体素网格必须在初始化时就确定范围和分辨率。范围定小了扫到一半出界范围定大了还没开始扫显存先空了一半。我见过不少团队为了让系统稳定统一分配 512^3 的体素块结果小物体场景浪费严重大场景又不够用。这个矛盾是结构性的靠调参调不出来。1.3 一个客厅就要数百 MB真实场景占用估算为了把问题说清楚下面这张表我按不同场景和分辨率算了一笔账单位是体素边长和理论内存值场景体素大小空间范围体素数量理论内存8B/体素桌面小物体1mm0.5×0.5×0.5m1.25 亿约 1GB客厅5mm6×4×3m5.76 亿约 4.6GB一层办公室10mm30×10×3m9 亿约 7.2GB这还只是模型数据。实际实时重建系统里显存里同时还有当前帧深度图、彩色图、上一帧的 raycast 结果、相机追踪的中间缓冲固定开销动辄几百 MB。可以这么说在稠密网格体系下只要你想扫的空间稍微大一点显存就成了比算力更稀缺的资源。但从另一面看室内环境真正存在表面的截断带区域通常只占整个空间体积的几个百分点。墙、地板、家具表面都是薄薄一层中间大片空白体素全部是无用信息。Voxel Hashing 正是抓住了表面稀疏这个特征把内存账本彻底重算了一遍。2. Voxel Hashing 的破局思路只给表面附近建抽屉2.1 从连续网格退到按需块Voxel Hashing 的做法和预先买下整栋楼完全相反更像是入住时才给新住户分配房间。整个流程可以简化成三步初始化时只申请一个容量固定的哈希表和一个存储 block 数据的存储池每一帧处理时根据当前深度图找到哪些位置需要新建 block通过哈希表插进去如果 block 已经存在就直接更新它内部的 512 个体素。哈希表在这里的角色是索引它把三维空间里的 block 坐标映射到存储池中的实际地址。体素数据不再连续铺满整个空间而是只出现在被观测到的表面附近。一个 30m 长的走廊体积可能超过 100m³但墙和地面面积有限真正需要分配的 block 数量比空间对应的潜在体素数量少了好几个数量级。分配 block 的触发条件需要精确控制只有当前帧深度图里的 3D 测量点落到某个 block 的截断带范围内且这个 block 尚未分配时才执行插入。这样新建 block 的速度和扫描轨迹的覆盖面积挂钩而不是和环境体积挂钩。2.2 为什么以 block 为单位而不是单个体素新接触这个方案的人总会问一个问题既然只存表面为什么不直接给单个体素做哈希那样不是更省答案是哈希表本身有开销三维空间的访存也需要局部性。假设哈希表里每条 entry 用 16 字节存 key、地址和标志位如果一个体素一条 entry那么 1000 万个体素就要 160MB 的哈希表内存比体素数据本身还贵。但如果把 8^3 512 个体素打包成一个 block1000 万个体素只需约 2 万条 entry哈希表开销可以忽略不计。block 的另一大优势是访存局部性。GPU 在处理一个 block 内部的体素时数据在存储池里是连续的缓存命中率远高于随机访问单个体素。并行调度也更自然一个 block 对应一个线程组组内 512 个线程各处理一个体素大家共享同一个哈希查询结果查询代价被分摊了。论文默认的 8^3 块大小是一个经验平衡点。块太小哈希表膨胀、并行粒度细、内存碎片多块太大一个 block 覆盖的空间范围变大里面即使只有一小部分贴近表面整个 block 也必须保留稀疏性会下降。一般我会从 8^3 起步如果场景表面非常薄、分辨率很高可以试 4^3如果场景很空旷、分辨率要求不高16^3 反而更划算。2.3 哈希表只做索引block 数据放在独立存储池这里有个非常容易混淆的点Voxel Hashing 的哈希表和常见编程语言里的哈希表存的东西不一样。在数据密集型系统里哈希表的 value 通常就是数据本身。但在 Voxel Hashing 里哈希表只存两样东西block 的三维坐标 key以及该 block 在存储池里的偏移量。真正的 TSDF 体素数据放在一个独立的、预先分配好容量的 block 存储池中。这样做的好处是解耦了索引结构和数据内存。哈希表变大变小只影响查询效率不触碰体素数据body 存储池的分配回收只跟真实观测到的表面有关。另一个好处是 GPU 上方便做内存管理存储池可以是一整块显存block 数据连续存放驱动层的分配开销小。当然代价也有多了一层间接寻址查询体素的时候要先查哈希表拿地址再跳到数据区读取。不过和稠密网格动辄爆显存的问题相比这点间接开销完全值得。3. 哈希表设计坐标、冲突与扩容3.1 从世界坐标到哈希 key 的两次取整实现 Voxel Hashing 时坐标换算是第一个容易写错的地方。想从一个三维世界坐标得到哈希表的 key需要经过两次取整世界坐标 p 除以体素大小 voxel_size再对结果做 floor得到体素坐标 vi体素坐标 vi 除以 block 尺寸 block_size再对结果做 floor得到 block 坐标 b。之后用 block 坐标 (bx, by, bz) 作为 key 去查哈希表。第二步的 block 坐标才是真正参与哈希运算的整数三元组。这里必须使用 floor 而不是强制类型转换截断。C/C 里int(p / voxel_size)对正数是 floor对负数则是向零取整结果会差 1。而三维重建的坐标系里相机位置通常不是原点网格坐标出现负值非常常见稍微偏一点哈希查询就会落到错误的 block 上最终模型出现错位和裂缝。我在早期版本里就吃过这个亏排查了两天才发现是符号取整问题。3.2 用大质数做乘积异或一个够用的哈希函数哈希函数的选择直接影响查询速度和冲突率。最经典、也被大量重建系统验证过的方案是三个大质数乘积再异或uint32_t hashKey(int bx, int by, int bz) { uint32_t h (uint32_t)bx * 73856093u; h ^ (uint32_t)by * 19349663u; h ^ (uint32_t)bz * 83492791u; return h (table_size - 1); // 要求 table_size 是 2 的幂 }为什么要乘大质数很简单相邻 block 的坐标在低位上非常相似如果直接将坐标相加或取模连续空间里的 block 会映射到哈希表的相邻区域造成严重的聚类冲突。大质数的乘法会把高位信息扩散到低位让空间上相邻的 block 在哈希表里尽可能分散。我这里用了 (table_size - 1)而不是% table_size前提是 table_size 必须设计成 2 的幂。这样取模运算在 GPU 上是一条位运算指令比整数除法便宜很多。如果 table_size 不是 2 的幂就要用取模但性能会差一点。别在哈希函数里放太多花活。更新和 raycast 每帧要调用海量次 sampleVolume哈希函数是绝对的 hot path复杂函数带来的 avalanche 收益通常抵不过多出来的指令开销。上面这个版本对绝大多数室内扫描场景都够用了。3.3 冲突策略线性探测、Cuckoo 与 bucket 方案哈希表一定会遇到冲突Voxel Hashing 的几个候选方案我分别说下实际体验线性探测最简单冲突时往后逐个找空位。负载因子低时效果不错但一旦接近 0.7会出现明显的 primary clustering连续 cluster 会让查询路径变得很长。GPU 上 warp 内线程的分支不一致一个线程多跳几次整个 warp 的时长就被拖上去了所以我只用过一段就放弃了。Cuckoo hashing 是原始 Voxel Hashing 论文采用的方案。它给每个 key 准备 2 到 3 个候选 slot插入时如果目标 slot 被占就把旧项踢出去让旧项去自己的另一个候选位置。查询时只需要检查固定数量的 slot最坏 O(1)。但这个方案在 GPU 上并发插入时非常麻烦多线程同时踢来踢去处理不好会活锁或丢失条目调试成本高。工程上我更推荐第三种变体bucket 哈希。每个哈希桶里放 4 个或 8 个 entryentry 是连续的 16 字节结构体。查询时一次内存访问把整个 bucket 加载进来逐个比较 key。插入时在 bucket 内找空位原子操作抢占即可。这牺牲了一点点内存但换来了 GPU 友好的数据布局和大幅降低的插入复杂度。我后面自己写系统时默认用的就是 4-way bucket。无论选哪种哈希表的负载因子都不要超过 0.5。负载因子高冲突概率和探测长度会迅速恶化这不是哈希函数能救回来的。3.4 扩容时机预估、卡死与 rehash哈希表容量必须提前留余量。一个很常见的失败模式初始化时给了 65536 个 slot觉得够多了结果扫到一半 block 数超过 5 万负载因子接近 1插入几乎每次都失败画面突然缺一大块控制台刷 rehash。重新 rehash 的过程中重建要暂停场景卡顿一下然后内存又被打满。更稳妥的做法是按场景预估 block 数量。有个经验公式预估 block 数量 ≈ 表面积 × 截断带宽度 ÷ block 体积。比如 100m² 的表面、截断距离 8cm、block 尺寸 4cm那么表面带体积大约 16m³每个 block 体积 64cm³block 数约 25 万。之后把 table_size 取这个数值的 2 倍以上并且在初始化时直接定大。有人会担心 table_size 太大浪费显存。表格里一条 entry 就算 16 字节100 万条也就 16MB和 TSDF 数据动辄几百 MB 相比是九牛一毛。多花这十几 MB能省掉运行时 rehash 的全部复杂度非常划算。4. GPU 上跑起来的几个关键动作4.1 更新流程遍历已分配 block而不是整个空间稠密 TSDF 的更新逻辑是逐体素投影到深度图比较深度Voxel Hashing 则完全换了一套节奏。每帧 integrate 时不是从坐标原点开始遍历整个空间而是先拿到一个紧凑的已分配 block 列表把所有 block 的坐标和存储偏移放进一个数组。GPU kernel 里每个 block 分配给一个线程组组内 512 个线程并行处理 block 内部的体素。每个体素把世界坐标投影到当前深度图取深度值计算 sdf再做加权融合。如果一个体素所在的 block 尚未分配就先把它记入待分配列表统一插入哈希表。这套流程的计算量只和表面附近的 block 数量成正比和环境总体积基本无关。所以扫描一个 6m 的客厅和一个 30m 的长走廊integrate 耗时的差异远小于稠密网格。我第一次在长走廊数据上跑通时最直观的感受是模型一直在变大但帧率没有随空间范围下降这点和之前完全不一样。4.2 光线投射时的哈希查询路径从 TSDF 场还原表面点云和网格最常用的是 raycasting。每个输出像素沿着相机光线一步步往前采样每次采样都要执行一次 sampleVolume 函数判断当前位置是否穿过了表面。sampleVolume 的完整路径是世界坐标 - 体素坐标 - block 坐标 - 哈希查询得到 block 在存储池中的地址 - 读取 block 内体素值必要时做三线性插值。也就是说raycast 的每一步采样都伴随一次哈希查询这一步是整个系统最热的热点。优化思路通常有两个方向。第一把哈希查询本身做快比如用 2 的幂取模、用紧凑的 bucket 结构、避免复杂分支。第二在 raycast 时利用 block 的空区域信息做大步跳过。当采样点所在 block 的 TSDF 值全为正说明光线还在自由空间中还没碰到表面可以直接跳到下一个 block 的起始位置而不是逐体素小步走。这个block skipping技巧能让 raycast 提速数倍代价是实现复杂度增加一点但完全值得。4.3 并发插入新 block 的处理GPU 上有大量线程同时做 integrate它们可能同时发现不同的位置需要新增 block也可能同时发现同一个位置需要新增 block。后者如果处理不好会出现重复分配甚至把哈希表里的地址覆盖成错误值。常规做法是分两步第一步把需要新增的 block 坐标写到一个统一 buffer 里第二步做去重和批量插入。去重时用 atomicCAS 在哈希表对应的 slot 上做比较交换谁先写成功谁负责分配存储池中的 block失败者重新读取最终地址即可。存储池的分配器也要线程安全。最简单的实现是维护一个全局计数器新增 block 时用 atomicAdd 取一段连续内存。这个方案没有回收机制block 只增不减对短期扫描没问题但如果要做动态物体移除或长期运行就得维护空闲 block 链表复杂度明显上升建议先把基础版本做出来再考虑。4.4 数据布局short 量化、权重和颜色的压缩如果每个体素都老老实实用 float 存距离内存还是偏高。实际工程里几乎都会压缩距离值限制在 [-truncation, truncation] 区间用 signed short 存2 字节权重用 uint8 或 uint16达到上限后饱和颜色用 uchar33 字节。这样算下来一个体素大约 7 字节一个 8^3 block 约 4KB100 万个体素也只有 7MB 左右。注意 short 存的是缩放后的整数读写时要乘除一个缩放因子。这个量化误差在截断带内通常影响很小因为深度数据本身就有噪声提高存储精度带来的收益非常有限。选择量化方案时一个容易踩的细节是内存对齐。GPU 上向量化访问通常要求 4 字节或 16 字节对齐如果把 sdf、weight、color 三个字段紧凑打包成 7 字节线程访问时反而会因地址不对齐产生额外开销。这时候宁愿填充到 8 字节或者把字段重新排序成 44 结构性能和内存的平衡点要实测确定。5. 实测数据与收益边界5.1 不同场景下的内存对比下面这组数字来自我自己的复现和改造系统不是基准测试但量级可以给大家做个参考。对比的都是同一份数据、同一套体素参数只是一个是稠密网格方案一个是哈希稀疏方案。扫描场景稠密 TSDF 内存估算Voxel Hashing 实测说明桌面小物体 0.5×0.5×0.5m1mm约 1GB120~180MB物体表面占比高收益也很明显客厅 6×4×3m5mm约 4.6GB400~700MB显存压力主要来自连续空间长走廊 30×5×3m1cm约 3.6GB300~500MB环境越空收益越大规律很清楚环境越空、横向尺度越大内存收益越夸张。反过来如果扫描对象表面密度特别高分配的 block 会迅速增多节省比例下降。但即使是最密集的桌面小物体场景哈希方案也比稠密网格省了 80% 以上。需要提醒的是Voxel Hashing 省的是 TSDF 模型数据不是所有 GPU 缓冲。深度金字塔、颜色图像、渲染缓冲这些东西该占多少还占多少所以别把哈希当成解决所有显存问题的银弹。5.2 时间开销更新和渲染有没有被拖慢哈希查询引入了额外间接寻址性能上会不会得不偿失我实测的结论是不会。在 1080Ti 时代一个 20 万 block 的场景每帧 integrate 加 raycast 总共大约 4 到 8ms。体素分辨率 5mm、截断距离 40mm大约 30fps 的实时刷新完全能跑起来。相比之下同样数据用稠密 512^3 的方案虽然单次查询是直接数组索引更快但每一帧要遍历的体素总量大得多总耗时不降反升。如果用了 Voxel Hashing 之后性能不升反降先查三件事哈希冲突是不是太高raycast 的采样步长是不是设得太小block 数量是不是因为噪声块失控。这三个是我见过最多的性能杀手前两个是结构问题第三个通常是分配策略太激进。5.3 哪些场景不适合用 Voxel Hashing任何技术都有边界Voxel Hashing 也一样。如果你只是扫描一个 0.3m 的小物件空间范围本来就不超过 1m固定 256^3 或 512^3 的稠密网格实现更简单、更可控几乎没有哈希的必要。另一个不太适合的场景是路径规划类的体素地图。机器人导航经常需要快速查询某个位置是否是障碍物、周围多大范围是 free space这类随机访问用稠密栅格天然方便。哈希结构虽然也可以通过坐标换算查但每次都要跳转而且周围邻域体素可能分布在不同 block访问局部性差麻烦事不少。还有一个隐藏陷阱如果应用需要支持模型的随机编辑和删除block 的回收和哈希表的删除操作会变得非常复杂。删除一个 block 后存储池里会留下空洞需要维护空闲链表、做内存整理。这种情况下surfel 类表示或稠密体素反而更有优势。Voxel Hashing 最适合的仍然是一次性扫描重建模型只增不改的经典实时重建流水线。6. 工程化中的坑与建议6.1 哈希表容量设太小扫描到一半插入失败这是最典型的翻车现场。初始化时觉得 65536 个 slot 足够结果扫到一半 block 数量超过 5 万负载因子接近 1插入频繁失败画面突然出现缺失区域控制台还不停报 rehash。想彻底避开就要在初始化前先估算这个场景大概会有多少 block。如果完全无法预估环境大小我建议直接给 1M 条 entry大约 16MB 显存。绝大多数室内扫描项目用 1M 哈希表都能覆盖。在显存比 16MB 稀缺得多的时代这个选择是奢侈的但在今天16MB 换掉一整套 rehash 复杂度和运行时卡顿非常划算。6.2 无脑按当前帧分配 block噪声块爆炸新手实现最容易犯的第二个错误为了让表面不缺失把当前帧里所有截断带内的体素都分配成新 block。结果深度噪声产生了大量孤立测量点几个关键帧之后 block 数量暴涨模型里出现密密麻麻的漂浮碎片。解决方案是给新 block 一个观察期。block 刚分配时先不参与 raycast等它连续被多帧观测到、内部有效 TSDF 体素数量超过阈值之后才正式进入渲染和更新流程。我一般在 block 元数据里加一个计数器连续被观测到 2 到 3 帧就激活。代价是表面刚出现时有一段极短的透明期但换来的是 block 数量稳定增长不会因为几帧噪声就失控。这个延迟激活策略在长走廊、大空间扫描时尤其重要。空间越大单帧深度图的边缘噪声也越容易生成无效 block提前控制比事后清理省事得多。6.3 导出模型时丢坐标block 变无主孤魂保存重建结果时光把 block 数据和哈希表 dump 到硬盘是远远不够的。哈希表只存了 block 坐标和存储偏移block 数据本身是连续存储池里的原始体素没有几何位置信息。如果导出时忘了把坐标写进去重新加载时这些 block 根本不知道自己在三维空间中的哪里。正确做法是导出时遍历哈希表的所有有效 entry读出每个 block 的三维坐标再把 block 内体素坐标乘以体素大小转换到世界坐标系最后写点云或网格文件。这里还要注意坐标一致性问题如果开发时用的坐标系和导出工具用的坐标系不完全一致哪怕只是原点偏移拼接出来的模型也会出现裂缝。最稳妥的方案是导出前把所有坐标统一归到体素网格中心而不是直接使用 float 累加产生的世界坐标。6.4 从 Voxel Hashing 到现成库我的选型建议自己动手实现一份最小版 Voxel Hashing 是非常值得做的学习项目能让你把哈希、插入、更新、raycast 这条链路完整走一遍遇到问题时也能从原理层判断是哈希的问题还是权重的问题。但如果目标是快速产出可用系统我建议优先看现成库。InfiniTAM 是稀疏体素实时重建的代表性开源项目CPU/GPU 版本都有大量工程细节可以直接参考。Open3D 的 TSDF integration 也基于哈希表适合离线处理和批量数据实验。机器人领域还有 Voxblox做 occupancy 和 ESDF 场很强路径规划场景直接用它更省心。我的习惯是做一个学习 demo 时自己写一个极简哈希版做真实产品时踩一遍基本坑然后尽早切换到成熟库上继续叠业务逻辑。另外有个小建议无论自己写还是用库都建议把每帧的 block 数量变化曲线打出来。block 数量是一个非常好的健康指标——增长太陡说明分配策略激进、噪声 block 多增长太慢说明表面可能被漏掉。看这个曲线比盯点云效果更容易早发现问题。我个人在实际使用中的一个体会是Voxel Hashing 的最大价值不只是省内存这三个字而是它让重建流程从预先划定扫描范围变成了随轨迹无限生长。原来扫一个房间需要先测量尺寸、设定 volume现在只要哈希表容量足够就可以沿着轨迹一直往下扫内存只跟着真实表面积走。做工程很多时候不怕慢就怕被硬性边界卡住哈希化的稀疏结构恰好把这种边界松开了。如果你正准备往大场景实时重建方向走把这份数据结构吃透后面再接触各种稀疏八叉树、out-of-core 容器时都会轻松不少。
返回列表