☰
从显存爆炸到流畅重建:Voxel Hashing如何重构TSDF体素存储
2026/10/2 1:04:59 网站建设 项目流程

接触过实时三维重建的朋友,十有八九都被 TSDF 的显存占用折磨过。明明只是扫一个几十平米的房间,内存却从几百 MB 一路爬到几个 GB,最后直接吃满显卡显存,程序当场崩掉。我第一次处理这种内存爆炸问题时,第一反应是换更大的显卡,后来发现根本不是卡的问题,而是数据结构的账算错了。等到把固定体素网格改成 Voxel Hashing 之后,同样一个场景,显存直接从 4.7GB 降到 600MB 左右,帧率还稳住了。这篇文章从内存危机的源头开始,把 Voxel Hashing 解决 TSDF 内存爆炸的完整思路和数据层面细节拆开讲,会涉及体素数据结构、哈希函数、冲突策略、GPU 并行更新路径,以及我实际调参时踩到的各种坑。

1. 内存爆炸从哪来:稠密 TSDF 网格的账本

1.1 TSDF 到底在存什么

TSDF(Truncated 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 个体素打包成一个 block,1000 万个体素只需约 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,需要经过两次取整:

  1. 世界坐标 p 除以体素大小 voxel_size,再对结果做 floor,得到体素坐标 vi;
  2. 体素坐标 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 个 entry,entry 是连续的 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,达到上限后饱和;
  • 颜色用 uchar3,3 字节。

这样算下来一个体素大约 7 字节,一个 8^3 block 约 4KB,100 万个体素也只有 7MB 左右。注意 short 存的是"缩放后的整数",读写时要乘除一个缩放因子。这个量化误差在截断带内通常影响很小,因为深度数据本身就有噪声,提高存储精度带来的收益非常有限。

选择量化方案时,一个容易踩的细节是内存对齐。GPU 上向量化访问通常要求 4 字节或 16 字节对齐,如果把 sdf、weight、color 三个字段紧凑打包成 7 字节,线程访问时反而会因地址不对齐产生额外开销。这时候宁愿填充到 8 字节,或者把字段重新排序成 4+4 结构,性能和内存的平衡点要实测确定。

5. 实测数据与收益边界

5.1 不同场景下的内存对比

下面这组数字来自我自己的复现和改造系统,不是基准测试,但量级可以给大家做个参考。对比的都是同一份数据、同一套体素参数,只是一个是稠密网格方案,一个是哈希稀疏方案。

扫描场景稠密 TSDF 内存估算Voxel Hashing 实测说明
桌面小物体 0.5×0.5×0.5m,1mm约 1GB120~180MB物体表面占比高,收益也很明显
客厅 6×4×3m,5mm约 4.6GB400~700MB显存压力主要来自连续空间
长走廊 30×5×3m,1cm约 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 容器时都会轻松不少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询