Back to case study

Detailed technical write-up.

大场景整体重建与 LOD 3DTiles 生成

大场景整体重建与 LOD 3DTiles 生成

大场景整体重建与 LOD 3DTiles 生成流水线

1. 项目信息

字段 内容
项目名 大场景整体重建与 LOD 3DTiles 生成
时间 2024-07 起,持续维护
应用场景 大空间/园区/多楼层室内外一体化的模型重建与前端渐进浏览
角色 算法负责人
输入 多点位全景图、激光点云、点位位姿、楼层估计等辅助信息
输出 LOD 3DTiles 模型(glb + tileset.json)

整体流水线

整体流水线分为九个阶段:先做工程创建与点云流式分块(A),再对每块并行做 mesh 重建与无缝合并(B),随后进入面片可见性(C)、多楼层分割(D)、纹理贴图与匀色(E);模型侧完成之后进入 LOD 生成,先按 工作瓦片层分块把最精细几层填齐(F),再按 粗层 到 顶层 分层向上生成(G),最后组织 tileset 索引并做包围盒收紧(H),并额外输出与旧线上系统兼容的模型文件(I)。整条链路的目标是在内存与算力可控的前提下,把任意大小的场景一次性打通到可流式浏览的 3DTiles。

2. 项目背景

在此之前,公司内部的整体重建采用一套面向单户室内的流水线,输入尺度基本在几十到上百平米,几何合并、纹理映射和 LOD 均在全局作为单次任务完成。当空间尺度从单个户型扩展到园区、酒店整栋乃至校区量级时,该单次全局处理方式会同时暴露以下问题:内存溢出、单节点计算无法在合理时间内完成、跨块边界不一致、以及多楼层无法有效分离。行业上下游主要有两类做法:一类是航拍/倾斜摄影链路(如商业化的 ContextCapture、OpenMVS 类流程),侧重于自顶向下的稀疏采集与网格重建;另一类是室内 SLAM/激光扫描的整体建模,通常在单站或小尺度做闭环。两者都不天然支持”室内外一体、多楼层、可浏览”的组合。

项目的定位是把重建这件事完整拆成”绝对尺度金字塔上的分块任务”,让每个阶段都能按块并行、按层依赖、按尺度收敛。 项目的重心不在单点算法上的显著创新,而是通过把点云分块、无缝合并、可见性、多楼层、纹理、LOD 六个环节按同一套瓦片编码组织起来,使整条链路的内存峰值不随场景规模线性增长。

3. 问题描述

3.1 大场景重建的四个共性缺陷

规模爆炸:全局数据结构容纳不下整个场景。 一栋综合体的稠密点云动辄十亿点,直接对全场景构建 BVH、执行 Poisson 重建或多标签 Graph-Cut,都会超出单机内存上限;即便使用大内存机型,单机耗时也不可接受。

边界不一致:朴素分块必然带来接缝。 如果按外接盒直接切点云、逐块做 Poisson 再拼回来,块与块之间的顶点无法在几何上对齐,接缝处要么出现”墙面双层”要么留下缝隙。传统的增量合并每一步都要与已经很大的整体模型交互,内存占用和单步耗时都随累积规模持续上升。

多楼层耦合:局部信息判不出楼层归属。 一个面片属于哪一层楼,本质上取决于它被哪些点位看到,而点位跨楼层共视只发生在楼梯、电梯这类小区域。全局做多标签 Graph-Cut 虽然能得到最优解,但问题规模跟场景大小成正比。

LOD 依赖不清:上层与下层耦合,浏览体验受限。 直接从最精细层向上做几何简化,边界一旦不固定,简化到高层就会出现塌陷、面片丢失、纹理串色等问题;若每层从头重建,计算冗余又过大。

3.2 下游的具体症状

  • 首屏加载 tileset.json 过大:单一 tileset 描述整栋楼时文件本身就压不下来,浏览端加载慢。
  • 高精层单文件过大:如果 最精细层的 glb 因为图像密集拍摄而堆到 5–过大,用户滑动到密集区就会明显掉帧。
  • 楼层过滤失效:单个 face 没有可靠的楼层标签,浏览器端”只看 3 楼”就实现不了。
  • 纹理色差断带:跨块拍摄光照不同,做完贴图后块与块之间可以肉眼看到颜色跳变。
  • 简化后墙体扭曲:常规二次误差简化不知道”哪里是墙”,直接把平面塌成折线。

这几个症状加在一起,让”渲染流畅、结构可控、跨楼层可分”这三个下游需求在旧流水线里不能同时满足;这是 lod_recon 必须做的原因。

4. 解决方法

4.1 绝对尺度金字塔与流式分块

绝对尺度金字塔

动机。 让流水线里的每个阶段都以同一套瓦片编码通信,是并行化和内存控制的前提。如果每个阶段都自己发明一套分块,跨阶段就必须做重投影和索引重建,一致性和调试成本会指数上升。

做法。 参考 OpenStreetMap 的做法,用绝对尺度的 xyz 编码替代随场景大小变化的相对分块。层级从 顶层一直细到 最精细层,并在需要时向下扩展到 超精细层。瓦片名统一由”层级、xyz 三个空间坐标、楼层号”五个字段构成,父子关系仍然是标准八叉树关系。这样一来,无论场景多大,每一块的物理尺寸是固定的、可预测的,跨阶段只需要传递瓦片编码,不用传递具体范围。

设计要点:将 工作瓦片层设为算法工作层。 点云分块、mesh 重建、可见性计算、纹理贴图等计算密集环节,都在 这一层完成。选择这一层有两点考虑:一是其尺度接近激光的有效测距范围,单块内部的信息既足够支撑重建、又不会过量;二是在该尺度下每块稠密点云约在千万点量级,与单进程可承载的处理窗口相匹配。粗层 是向上收敛的 LOD 层,细层 是向下细化的浏览层,该层承担主要计算负载。

流式分块。 把点云切到 工作瓦片层只是第一步,重建过程还需要邻域信息,否则边界会因为缺少邻域点云而缺失重要几何。做法是先流式扫一遍构建外包围盒,再流式扫一遍构建八叉树,然后按块写出;同时把每块内部按 margin 大小再做一次 立体的切分,外圈 若干小块流式追加拷贝给相应邻居(见下图)。这样每一块拿到的都是”内块 + 一圈冗余”的完整数据,重建之后再按内块范围切回,就得到边界干净的结果。整个过程始终不需要将全量点云同时驻留内存。

流式分块与 周围邻域拷贝

4.2 分块 Mesh 重建与平面引导的无缝合并

动机。 分块 Poisson 之后,两块之间的顶点几乎不可能天然对齐,直接拼接会在墙面上留下双层或缝隙。做增量合并的方案在内存上不友好,做全场景一次合并也不现实。需要一种”每次只处理两块、可以流式往前推”的合并算法。

做法。 每一块 点云在 Poisson 重建之后先做一次 一定比例 左右的初步简化和保边界的双边法向滤波,然后按 工作瓦片层的六个切平面做精确切割,得到边界严格贴合分块盒的网格。关键观察是:因为切平面本身位置已知,切完之后落在边界上的顶点都严格躺在这个平面上,合并问题因此从三维的”网格拼接”退化成二维的”平面上重新三角化”。 这一步把整个合并算法的复杂度和实现难度都降了一个数量级。

具体合并策略:把两块相邻网格在共同切平面上的边界顶点收集起来,按最近点对入栈,出栈时寻找可以构成的最优三角形(可能是一到两个),把新生成的边压回栈,重复到栈空。合并完成后对新加的三角形做局部平滑,让接缝在几何和光照上都不显眼。

平面引导的分块无缝合并

在真实模型上的对比更直观:下图左侧是分块 Poisson 之后直接拼接的结果,切分边界处可以清楚看到墙面双层、地面开裂等接缝伪影;右侧是经过切平面降维 + 二维三角化 + 局部平滑之后的合并结果,同一处边界在几何上已经完全贯通、无法在肉眼上定位到分块位置。

真实模型上的分块合并效果对比:左为直接拼接,右为流水线处理后

设计要点:合并只做”两两之间”,不做增量。 每次合并输入是两块 mesh,输出还是两块——只不过边界上的顶点变成了共享的。合并完成后把新结果覆盖原始的两个文件,下一对邻居再来合并。这样内存里始终只有两块 mesh,跟场景规模无关,天然支持流式扫描。

边界属性沿链路向下游传递至 LOD 阶段。 在合并阶段就将”边界顶点、平面顶点、天花板顶点、楼层边界顶点”四类属性写入顶点的位标记。这套标记贯穿后续的可见性、纹理与简化阶段——简化阶段直接读取相应位来决定哪些顶点固定、哪些顶点只能沿边收缩、哪些顶点可以自由折叠。将属性下沉到顶点属性上,可避免每一阶段重新识别边界。

4.3 流式并行可见性

动机。 纹理映射的前置条件是每个 face 都要知道自己被哪些点位看到、以及看得多”清楚”。在小场景里可以对整个 mesh 建一棵 BVH,对每个点位遍历一遍。到了大场景,一棵覆盖全场的 BVH 建不出来,也没意义——绝大多数面片和绝大多数视角之间就没有几何交集。

做法。 把可见性拆成三个阶段,见下图。第一阶段以块为单位建 BVH:先根据每张全景图(切成针孔后)的视锥和距离范围,圈定它可能看到的分块列表;对每一块单独建 BVH,把当前块面片的可见性算出来。这一步得到的可见性”偏多”,因为没有考虑跨块的遮挡。第二阶段计算邻域关系:以块中心距离小于 合理测距上限(激光测距上限的经验值)为准,为每一块找出需要用于遮挡检验的邻居列表。第三阶段做遮挡剔除:对每一块加载它自己和邻居的 BVH,把第一阶段候选中的射线重新测一次,被邻居遮挡的可见性丢弃。最后按到相机的距离排序,取最近的 TopK 保留(K 约为 60)。

流式并行可见性

设计要点:BVH 落盘 + 邻居按需重载。 单块 BVH 构建一次就写到磁盘,第三阶段跑到哪块加载哪块 + 它的邻居。这个”BVH 是可以流式换页的资源”的思路,让整块 BVH 内存占用不随场景大小增长,只随并行度增长。

取 TopK 而不是全保留,是为了下游纹理的稳定性。 每个 face 保留 60 个候选视角,覆盖率足够让下游的 Graph-Cut 视角选择做出好选择,同时避免把”角度过差或距离过远”的视角带进后面的匀色,节省内存和 IO。

4.4 多楼层分割与纹理链路

多楼层分割:把全局问题限制在 局部窗口内。 多楼层分割抽象成给每个 face 一个楼层 label 的问题,等价于 mesh 面片图上的多标签划分,标准解法是多标签 Graph-Cut。data 项利用点位的楼层可见性给出初始估计,smooth 项则鼓励沿平面边界切割。

若直接对全场景求解,问题规模与总面片数成正比。观察到楼层间的共视区域通常局限在楼梯、电梯附近,绝大多数区域的楼层判断为局部性质,因此可按 工作瓦片层并行处理——对每一块 mesh,合并周围 周围若干块得到局部窗口的合并模型,仅在合并模型上执行 Graph-Cut,保留中心块中每个 face 的楼层判定结果。 局部尺度 的窗口尺度显著大于常见楼梯的水平尺度,实测结果与全局求解一致。

多楼层 Graph-Cut

纹理映射:Graph-Cut 视角选择 + 稀疏边界匀色。 视角选择的部分沿用 Let It Be Color 的思路,为每个 face 从候选视角中选取一个,能量项包括视角覆盖质量和邻接面视角一致性。与场景规模直接相关的环节是全局色彩不一致:跨块拍摄光照差异会导致相邻两块的贴图在边界处出现颜色跳变。

做法是把匀色写成稀疏线性系统:每个顶点求一个色彩修正量,能量包括平滑项(相邻顶点修正后颜色接近)、边界项(分块边界两侧色彩趋近于两块的均值)和正则项(修正量本身尽量小)。求解本身是稀疏矩阵问题,可以分块求解并流式装配。 内部平滑在块内计算,边界一致在相邻块之间约束——问题的规模也因此不随整个场景增长。

Atlas 生成阶段做低分辨率 packing。 直接对高分辨率图做 texture packing 内存开销很大。工程实现里先在低分辨率上做 packing 决定布局,然后按 patch 流式 IO 把高分辨率纹理拷进最终 atlas。这个做法在密集拍摄的场景里,能让单块的峰值内存从较高水平降到可控范围。

4.5 带纹理的网格简化

带纹理的网格简化:几何简化与属性采样解耦

以斯坦福 Bunny 为例,下图完整呈现了这一策略的四步:右下方原始高精 mesh 先做纯几何简化得到左下方的低面片 mesh;对低面片 mesh 重新进行参数化,得到右上方的 chart 划分;再对 chart 做 packing,得到左上方新的 atlas;最后从原始高精 mesh 上采样属性回填到新 atlas。整个过程中,简化算法本身完全不需要关心 UV 布局,chart 划分也完全在简化后的低面片模型上重新进行,两者彻底解耦。

带纹理简化的四步流程:原始 mesh → 几何简化 → chart 重新划分 → atlas 重打包

动机。 LOD 每一层往上做的时候都要同时压面片数和压纹理,两者是耦合的:常规折边简化只看几何误差,一旦一条被折的边跨越了 UV 上的两块 chart,简化后这个大三角形就不知道自己该采哪块贴图。学界最早的做法(学界早期提出)是”简化时约束不跨 chart 边界”,或者进一步”先简化,再对简化后的模型重做 chart 划分并对原始模型做参数化”。两条路都行,但需要把简化算法本身改造成 chart-aware 的,而且 chart 边界会硬性限制简化率,尤其在纹理碎片很多的场景里。

换一种思路:把几何和属性解耦。 Cignoni 等在 1998 年的一篇工作里提出了另一条路——先只按几何做简化,得到低面片 mesh,再对低面片 mesh 从零生成 atlas,然后在这个新 atlas 上从原始模型采样属性。 这条路的好处是简化算法不用改,纹理不再是简化的约束;坏处是采样本身有两个经典问题——采样率不足和走样。第一个用过采样解决,第二个用向外扩展一像素的方式抑制。

采样细节:从简化 mesh 反向映射到原始 mesh。 通用做法是:对每个 atlas 像素反算它在简化 mesh 上的三维位置,再从该位置沿某个方向向原始 mesh 投射一条射线,在原始 mesh 上取最近交点,读取该点的属性(可以是颜色,也可以是法向量、材质)作为采样值。反投射方向有两种选择:沿法向方向,或沿最短距离方向。

  • 按面片法向方向反投:方向明确,但简化后的面片是一整块大平面,沿其法向发出的射线与原始 mesh 常常没有交点,无法建立对应。
  • 按最短距离反投:一定能找到最近点,但采样位置全部收敛到局部最近点,容易导致欠采样,尤其在原始几何有细节起伏时。

做法:顶点法向量插值 + 距离约束。 项目里最后采用的组合是——不直接使用面片法向量,而是用三角形三个顶点的法向量做重心坐标插值,得到每个像素位置一个平滑变化的采样方向;再对采样距离设上限,超出阈值的采样点直接丢弃。 顶点法向量插值让采样方向不再退化为面片法向,能跟随简化 mesh 的局部朝向变化;距离约束则过滤掉偏离过大的错误采样。两个约束叠加,采样率和采样准确性都比任一单一策略稳定。

设计要点:Atlas packing 按高度分行。 简化后重新生成 atlas 时,packing 策略会直接影响纹理利用率和最终 glb 大小。工程实现中采用一种简单策略——按三角形纹理块高度排序,将高度相同的三角形排列在同一行。 该策略实现简单、无回溯过程,对高度相近的一组三角形可形成整齐的水平条带,空间利用率较高。相比 通用装箱算法,这种”高度分行”策略在带纹理 mesh 简化的场景中在效率与效果间取得平衡。

设计要点:低分辨率 packing + 高分辨率流式回写。 直接在原始分辨率上做 packing,一块 mesh 峰值内存会到 较高水平。做法是先在低分辨率上确定布局,再按 patch 从原始高分辨率贴图流式读回填到最终 atlas 里。这个做法在密集拍摄的场景下能把单块峰值内存压到可控范围。

属性通过 mesh 顶点向下游传递。 由于整套采样机制本质是通用的属性重采样,除 RGB 之外,法向量、材质编号,以及本项目中的顶点固定位(楼层、天花板、平面属性)都能沿同一路径处理。这也是下游”输出带法向量的点云”这一需求能够直接满足的原因——简化后的顶点法向量来自原始 mesh 上的插值采样,而非在低面片几何上重新计算。

4.6 LOD 两阶段生成与形状感知简化

LOD 两阶段

动机。 LOD 生成的核心矛盾是”上层依赖下层”和”高精层数据量大”之间的冲突。若所有层采用同一种并行方式,要么高精层由于块小、任务碎导致调度开销过高;要么低精层由于块大、依赖多难以在单节点完成。分层来看:

  • 精细层之间,模型总量相对可控,每一块(固定尺度)的所有精细化都能装进单机内存;但块之间没有依赖。→ 按 工作瓦片层并行,一块任务内部多线程逐层做完 各精细层。
  • 粗层 到 顶层 之间,每一块面片数量大、内存占用重,且下一层要用上一层结果。→ 按层顺序,每一层内部按 tile 并行

做法:阶段一(各精细层 逐 tile)。 每个 瓦片任务加载一块高精 mesh,多线程完成 各精细层 的多级简化和纹理采样。每一级的输入是它下一级 8 个子块的合并结果,简化时保留边界固定点和楼层/天花板属性,纹理侧则按 UV 面积调整贴图分辨率。这样每一层的几何和纹理是同步收敛的。

做法:阶段二(粗层 → 顶层 逐层)。 粗层 及以上每个 tile 都要合并 8 个下层子块,做几何简化和纹理重采样。因为每一块都可能很大,这里 tile 之间并行、层与层之间串行,避免下层还没算完就调度上层。

设计要点:最精细层按 UV 面积自适应细分到 超精细层。 原本最精细层采用固定分辨率分块。在拍摄密度高或者用了 高分辨率全景图切贴的场景里,单个精细层会包含 多张大分辨率贴图 贴图,压出来单个 glb 到 过大,前端浏览滑到密集区就会卡。做法是给最精细层增加一个”UV 面积超过一张 标准分辨率 就继续向下细分”的规则,一直细到 超精细层为止;细分层之间的几何简化率固定为 1(不再动几何),纹理简化率按子块 UV 面积占比线性分配。这样 该细分层及以下细分只起”控制单文件大小”的作用,浏览端加载策略不变。 单 glb 稳定在 合理范围,滑动流畅度可控。

设计要点:该细分层及以下 geometricError 分布不上溢。 3DTiles 的 geometricError 决定了浏览端何时切换精度。该细分层的 geometricError 被压缩到原 精细层 范围内,上层及以上不受影响;浏览端看到的”什么位置该加载什么精度”跟细分前一致。

设计要点:形状感知简化 + 平面识别 QEM 双通道。 常规二次误差简化在墙面这种大平面上会因为误差都很小,把平面塌成之字形折线。工程里默认走一遍形状感知简化,它能显式识别”共面区域”并把误差方向锁在法向量方向上,退化基本上不会发生;失败或不适用的情况回退到带平面识别的 QEM 简化,把落在盒面上的顶点识别为固定点作为兜底。双通道的意义在于让”能保平面就保平面”,兜底通道保证不会因为主算法失败而中断流水线。

设计要点:tileset.json 按节点拆分。 一个 tileset 描述整栋楼时,root tileset 本身就会很大。工程里把除根节点以外的每一层节点各自拆成一个独立 tileset.json,前端拉取时只在需要时按节点拉子 tileset。首屏加载时间因此不再随场景规模膨胀。

4.7 实际效果

多房间大场景的整体重建效果,白色栅格是 工作瓦片层边界

上图是一个真实拍摄场景在 lod_recon 流水线里跑完之后的整体俯视截图,白色栅格是 工作瓦片层(固定尺度×固定尺度)的边界。可以直观看到几件事:多间空间在同一个坐标系下拼接成一体,块与块之间的地板和墙面没有可见的接缝或分层;室外的绿植、玻璃、以及室内的家具、桌椅、装饰细节在同一模型里同时保留;同时整个场景是按 工作瓦片层组织的,前端渲染时可以按分块粒度做剔除和调度。

更多的实际重建效果和交互浏览案例,可以参考如视 Discovery 上的公开样例:

https://www.realsee.com/discovery

5. 总结

项目覆盖了从点云、mesh 模型、纹理贴图到 LOD 3DTiles 生成的完整链路,工作同时在算法内核与工程框架两个层面展开。算法内核方面,针对链路上每个环节的核心难点均给出了具体解法:点云侧解决在有限内存下按块切分并保留邻域的流式分块问题;mesh 侧解决分块 Poisson 重建之后的无缝合并问题,采用切平面引导将三维拼接问题降维为二维三角化;可见性侧解决单机无法为全场景构建统一 BVH 的问题,采用分块建 BVH + 邻居 BVH 复测的多阶段方案;多楼层侧解决全场景 Graph-Cut 规模过大的问题,通过合并 周围若干块得到局部窗口进行局部求解;纹理侧解决跨块视角选择与全局色彩不一致问题,视角选择沿用 经典多视角纹理映射的能量项,全局匀色改造为稀疏线性系统的分块求解;LOD 侧解决上下层依赖与单文件大小控制问题,提出分块并行 + 分层并行的两阶段生成方案,并将 最精细层按 UV 面积自适应细分至 超精细层;带纹理的网格简化采用几何简化与属性重采样解耦的思路,配合顶点法向量插值与距离约束的采样策略,使简化率不再受 UV 布局限制。

上述算法工作被统一在同一套绝对尺度金字塔坐标框架下。 参考 OpenStreetMap 的层级设计,将场景放入从 顶层到 超精细层的固定金字塔中,每一层瓦片的物理尺寸预先确定,与场景绝对规模解耦。该金字塔同时承担空间索引、并行调度单位与跨阶段通信协议三重角色——点云分块、mesh 重建、可见性计算、多楼层分割、纹理映射、LOD 生成均以同一套瓦片编码为最小处理单元。工作瓦片层作为算法工作层,承担重建与纹理的主要计算;粗层 向上进行 LOD 收敛,细层 向下进行浏览细化,超精细层 用于适应超密拍摄场景。语义信息(楼层、天花板、平面、分块边界)在生成阶段被写入顶点标志位,向下游传递至 LOD 简化环节,避免各阶段重复识别几何语义。

工程框架方面,整条链路以 Docker 容器化模块 + 云端全并发调度的方式实现。 每个流水线阶段封装为独立可执行单元,输入输出以文件与瓦片编码为接口,阶段之间无内存耦合。同一阶段内部按瓦片粒度并行,阶段之间按依赖顺序在云端调度。流式处理策略贯穿整个链路:点云、mesh、BVH、纹理均支持按块流式读写,单节点内存峰值受控于单块处理规模而非场景总规模。整体上,链路在单机稳定不超过 可控范围 内存的前提下,将处理能力线性扩展到任意规模的大场景。

综上,项目在算法层完成了各环节针对大场景的具体改造,在框架层完成了统一坐标索引、流式处理与云端并发调度。两方面的工作共同支撑了任意规模大场景到可流式浏览 3DTiles 的一次性打通。