【共创稿事节】HarmonyOS 7文旅展陈展厅大空间 3DGS 重建的分块策略与拼接踩坑

展厅几十平米的空间,单次 3DGS 重建覆盖不了------采集时长不够、内存峰值顶到天花板、输出点数大到渲染跑不动。分块重建是直觉方案,但拼起来才发现真正的难题不在重建,在拼接:两块高斯点在交界处重叠,渲染时按深度 alpha 混合会闪烁;分块边界正好切断一件展品,半边清晰半边空洞。这篇文章记录我们把一个 30 平米展厅分 4 块重建、再拼回一个 Tiled 场景的全过程,包括拼接闪烁的成因、边界切断的取舍,以及最终采用的"重叠区软融合"方案。

能力面:分块采集 → 独立重建 → 空间拼接

Spatial Recon Kit 的 ReconSession 单次重建有覆盖范围上限(实测约 15-20 平米视场内稳定,更大空间内存峰值会逼近 OOM)。展厅 30 平米超了这个上限,必须分块。

分块链路分三段:

  1. 分块采集:把展厅按空间切成 N 块,每块独立环绕拍摄。块与块之间留 1-1.5 米重叠区,给拼接留锚点。
  2. 独立重建 :每块各起一个 ReconSession(串行,因为 session 单例),输出 N 个 PLY 文件,各自带局部坐标系。
  3. 空间拼接 :把 N 个 PLY 转到统一世界坐标系,写入 Tiled 清单文件,用 spatialRender.GSPlugin.loadTiledGSNode 加载。

这三段链路对应下面这张图,拼接是最容易出问题的一段。
#mermaid-svg-Kwlg8XzygZFzBzXd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Kwlg8XzygZFzBzXd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Kwlg8XzygZFzBzXd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Kwlg8XzygZFzBzXd .error-icon{fill:#552222;}#mermaid-svg-Kwlg8XzygZFzBzXd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Kwlg8XzygZFzBzXd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Kwlg8XzygZFzBzXd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Kwlg8XzygZFzBzXd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Kwlg8XzygZFzBzXd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Kwlg8XzygZFzBzXd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Kwlg8XzygZFzBzXd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Kwlg8XzygZFzBzXd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Kwlg8XzygZFzBzXd .marker.cross{stroke:#333333;}#mermaid-svg-Kwlg8XzygZFzBzXd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Kwlg8XzygZFzBzXd p{margin:0;}#mermaid-svg-Kwlg8XzygZFzBzXd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Kwlg8XzygZFzBzXd .cluster-label text{fill:#333;}#mermaid-svg-Kwlg8XzygZFzBzXd .cluster-label span{color:#333;}#mermaid-svg-Kwlg8XzygZFzBzXd .cluster-label span p{background-color:transparent;}#mermaid-svg-Kwlg8XzygZFzBzXd .label text,#mermaid-svg-Kwlg8XzygZFzBzXd span{fill:#333;color:#333;}#mermaid-svg-Kwlg8XzygZFzBzXd .node rect,#mermaid-svg-Kwlg8XzygZFzBzXd .node circle,#mermaid-svg-Kwlg8XzygZFzBzXd .node ellipse,#mermaid-svg-Kwlg8XzygZFzBzXd .node polygon,#mermaid-svg-Kwlg8XzygZFzBzXd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Kwlg8XzygZFzBzXd .rough-node .label text,#mermaid-svg-Kwlg8XzygZFzBzXd .node .label text,#mermaid-svg-Kwlg8XzygZFzBzXd .image-shape .label,#mermaid-svg-Kwlg8XzygZFzBzXd .icon-shape .label{text-anchor:middle;}#mermaid-svg-Kwlg8XzygZFzBzXd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Kwlg8XzygZFzBzXd .rough-node .label,#mermaid-svg-Kwlg8XzygZFzBzXd .node .label,#mermaid-svg-Kwlg8XzygZFzBzXd .image-shape .label,#mermaid-svg-Kwlg8XzygZFzBzXd .icon-shape .label{text-align:center;}#mermaid-svg-Kwlg8XzygZFzBzXd .node.clickable{cursor:pointer;}#mermaid-svg-Kwlg8XzygZFzBzXd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Kwlg8XzygZFzBzXd .arrowheadPath{fill:#333333;}#mermaid-svg-Kwlg8XzygZFzBzXd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Kwlg8XzygZFzBzXd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Kwlg8XzygZFzBzXd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Kwlg8XzygZFzBzXd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Kwlg8XzygZFzBzXd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Kwlg8XzygZFzBzXd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Kwlg8XzygZFzBzXd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Kwlg8XzygZFzBzXd .cluster text{fill:#333;}#mermaid-svg-Kwlg8XzygZFzBzXd .cluster span{color:#333;}#mermaid-svg-Kwlg8XzygZFzBzXd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Kwlg8XzygZFzBzXd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Kwlg8XzygZFzBzXd rect.text{fill:none;stroke-width:0;}#mermaid-svg-Kwlg8XzygZFzBzXd .icon-shape,#mermaid-svg-Kwlg8XzygZFzBzXd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Kwlg8XzygZFzBzXd .icon-shape p,#mermaid-svg-Kwlg8XzygZFzBzXd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Kwlg8XzygZFzBzXd .icon-shape .label rect,#mermaid-svg-Kwlg8XzygZFzBzXd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Kwlg8XzygZFzBzXd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Kwlg8XzygZFzBzXd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Kwlg8XzygZFzBzXd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不通过
通过
空间切分

按展品分布划块
逐块采集

块间留 1 米重叠
逐块重建

session 串行
边界对齐

AR 锚点位姿校正
拼接合并

重叠区软融合
整体校验

接缝与帧率
Tiled 场景交付

loadTiledGSNode 加载

图里的边界对齐和拼接合并在真机上各踩了一个坑。

第 3 步是工程量大头。每块重建出来的点云坐标系是局部的,原点在采集起点。要拼到一起,得知道每块在世界坐标系里的位姿(位置 + 朝向)。我们用的方案是采集时记录每块起点的 AR 锚点,重建完成后按锚点位姿做刚体变换。

typescript 复制代码
// entry/src/main/ets/recon/ExhibitionChunkRecon.ets
import { spatialRecon } from '@kit.SpatialReconKit';
import { world } from '@kit.ArkAR';

interface ChunkDef {
  id: string;            // chunk-01 ~ chunk-04
  anchor: world.Anchor;  // 该块采集起点的 AR 锚点,提供世界位姿
  plyPath: string;       // 重建结果落盘路径
}

export class ExhibitionChunkRecon {
  private session: spatialRecon.ReconSession | null = null;

  // 串行重建 4 块,每块独立 session,复用单例
  async reconAll(chunks: ChunkDef[]): Promise<ChunkDef[]> {
    const results: ChunkDef[] = [];
    for (const chunk of chunks) {
      // session 单例:上一块释放再起下一块
      this.session?.release();
      this.session = await spatialRecon.ReconSession.create({
        mode: spatialRecon.ReconMode.FULL_SCENE, // 展厅用全场景,不剔背景
        outputFormat: spatialRecon.OutputFormat.PLY,
        maxPointCount: 1_200_000, // 单块上限 120 万点
      });
      this.session.bindCameraInput(this.cameraInput);
      const plyUri = await this.waitReconDone();
      results.push({ ...chunk, plyPath: plyUri });
    }
    return results;
  }

  // 把每块 PLY 按锚点位姿变换到世界坐标系,写 Tiled 清单
  async buildTiledScene(chunks: ChunkDef[]): Promise<string> {
    // 实际项目里调 C++ 侧的点云变换工具,按 chunk.anchor.pose 做刚体变换
    // 输出 campus.scene.json 清单 + 各块变换后的 .ply
    const manifestPath = await nativePointCloudTools.mergeChunks(chunks);
    return manifestPath;
  }

  private waitReconDone(): Promise<string> { /* 省略,同第 1 篇 */ }
}

FULL_SCENE 模式在这里是对的------展厅不像手办要剔背景,墙面、地面、展柜都是场景的一部分。但代价是单块耗时和体量都比 TARGET_OBJECT 大。

约束面:分块重建的几条硬约束

约束 实测值 应对
单块覆盖上限 约 15-20 平米 30 平米展厅切 4 块,每块 7-8 平米
单块内存峰值 1.8-2.3 GB 重建时其他后台必须清干净
Session 单例 串行重建,不能并行 4 块串行总耗时约 4 倍单块
重叠区宽度 ≥ 1 米才有拼接锚点 太窄拼不上,太厚浪费采集
Tiled 总体量 4 块拼后 90-110 MB 走loadTiledGSNode 按需加载

内存峰值这条最致命。单块重建峰值 2.3 GB,Pura 80 Pro 12 GB 内存看似够,但系统和其他后台要占 4-5 GB,留给应用的常驻 4 GB 左右。重建峰值一顶上来,GC 来不及回收就 OOM。我们被迫在每块重建前主动调 gc() + 清掉渲染侧已加载的上一块 GSNode,把内存压到最低再起 session。

需要主动指出:Spatial Recon Kit 仅保证麒麟 9020/9030S/9030/9030 Pro 及后续旗舰芯片。展厅重建这种重负载场景,中端机连单块都跑不完,更别说 4 块串行。展陈项目交付时直接按机型白名单开放,其他设备回退到全景图。

场景落地:30 平米展厅分 4 块

被测展厅 6×5×3 米,4 面墙 2 面有展板,中间 3 个独立展柜,地面铺装复杂。分块方案:

复制代码
展厅俯视图(6m × 5m):
┌──────────┬──────────┐
│  chunk-1 │  chunk-2 │
│  (入口)  │  (展板墙) │
│   3×2.5m │   3×2.5m  │
├──────────┼──────────┤
│  chunk-3 │  chunk-4 │
│  (展柜区) │  (出口)  │
│   3×2.5m │   3×2.5m  │
└──────────┴──────────┘
重叠区:块间 1m,外圈不重叠

每块采集约 90 秒环绕走动拍摄,重建耗时见真机数据。4 块串行总耗时约 5-6 分钟,对展陈项目可接受------这是部署期一次性投入,不是用户每次访问都要付。

拼接采用 AR 锚点定位。采集者在每块起点用 AR 工具打一个锚点,锚点提供该位置在世界坐标系里的位姿。重建完成后,把每块点云按锚点位姿做刚体变换(旋转 + 平移),转到统一世界坐标系。

typescript 复制代码
// entry/src/main/ets/recon/TiledSceneLoader.ets
import { Scene, Node } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';

export class TiledSceneLoader {
  async load(scene: Scene, manifestUri: string, root: Node): Promise<void> {
    const ctx = Scene.getDefaultRenderContext();
    ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);

    // Tiled 加载:清单文件描述 4 块的层级和世界位姿
    const tiled = await spatialRender.GSPlugin.loadTiledGSNode(
      scene, { uri: manifestUri }, root
    );
    // 指定驱动分块选择的相机
    tiled.setCamera(this.camera);
    // 瓦片按需加载回调:实际项目里从本地缓存读,没有则提示需预重建
    tiled.setTileRequestCallback((tiles: spatialRender.GSTile[]) => {
      for (const tile of tiles) {
        // 展陈场景瓦片已预重建落盘,直接通知就绪
        tiled.notifyTileReady(tile);
      }
    });
  }
}

真机数据:4 块串行重建 + 拼接

设备 Pura 80 Pro,展厅 30 平米,4 块各 7-8 平米,重叠区 1 米。

指标 数值 备注
单块重建耗时 48-72 秒 chunk-2 展板墙最慢,纹理复杂
4 块串行总耗时 4 分 50 秒 - 5 分 40 秒 含 session 释放间隔
单块 PLY 体量 22-31 MB 点数 80-120 万
拼接后总点数 约 380 万 含重叠区冗余点
拼接后 Tiled 清单 96 MB 4 块 PLY + 清单
拼接后帧率(中景) 54-58 fps 同屏约 25 万点
拼接后帧率(走到边界) 46-52 fps 重叠区同屏点数翻倍

单块耗时 48-72 秒波动 24 秒,比手办场景的 3 秒波动大得多。原因是展厅内容差异大:chunk-2 是展板墙,纹理细节多,重建迭代久;chunk-4 是出口空走廊,几乎没纹理,48 秒就完。这个波动对工程排期有影响,部署期重建不能按"单块 60 秒 × 4"估,得按最坏 72 秒 × 4 留 5 分钟。

拼接后帧率比单块低 4-6 fps,主因是重叠区同屏点数翻倍。走到两块交界处,左右两块的高斯点都进视锥,同屏点数从 25 万涨到 40 万,fillrate 压力上来。3DGS 渲染 fillrate 敏感,同屏高斯点数是帧率主要瓶颈,这条在拼接场景里尤其要盯。

踩坑与取舍

坑 1:拼接处高斯点重叠导致渲染闪烁

这是最严重的坑。两块在重叠区各重建了一组高斯点,描述的是同一片墙面,但点位、属性不完全一致。渲染时两组点按深度 alpha 混合,相机移动时两组点的相对深度变化不同步,出现高频闪烁------像墙面在抖。

我们试过三种方案:

方案 做法 效果 是否采用
直接拼 不处理重叠区 闪烁明显,不可用 否
重叠区裁剪 按块边界硬切,重叠区只留一块 闪烁消失,但边界出现接缝缝 否
软融合 重叠区按距离做权重融合,两块都留但权重渐变 闪烁基本消除,接缝不可见 是

软融合方案在 C++ 侧实现:对重叠区内的每个高斯点,按它到所属块边界的距离算一个权重(0 到 1),渲染时两块同位置点的 alpha 按权重叠加。这个方案要改点云数据,工程量大,但效果是三种里唯一能交付的。

我们内部为这个吵了一架。一派主张"重叠区裁剪 + 后处理补洞",理由是软融合要写 C++ 工具链,周期长;另一派主张"软融合一次解决",理由是裁剪后补洞的洞边界又会闪烁。最后软融合派赢了,因为裁剪方案在另一个项目试过,补洞补出来的颜色和周围对不上,更难看。

坑 2:分块边界正好切断一件展品

chunk-2 和 chunk-3 的边界正好穿过一个独立展柜,展柜上半部分在 chunk-2,下半部分在 chunk-3。两块各自重建了这个展柜的一半,拼起来后展柜中间有一道水平接缝,上半和下半的纹理对不齐------因为两块采集时光照角度不同,同一件展品的颜色重建出来有偏差。

解决思路是把分块边界避开展品。采集前先在展厅里走一遍,标记展品位置,分块边界划在展品之间的空隙上。这次 chunk-2/3 边界改到展柜右侧 30cm 空隙后,接缝消失。

这个坑的教训是分块方案不能纯按面积切,要先看展品分布。我们一开始按 3×2.5m 等分,纯粹图省事,结果切坏了两件展品。改方案后采集者要多走一遍标记展品,部署期多花 10 分钟,但避免了重重建。

坑 3:单块内存峰值逼近 OOM

单块重建峰值 2.3 GB,有一次 chunk-2 重建到 80% 时 OOM 退出,前 70 秒白跑。原因是采集前没清后台,微信占着 800 MB。后来定了一条规矩:重建前调 gc() + 主动释放上一块 GSNode + 提示用户清后台。加这道之后没再 OOM。

坑 4:AR 锚点漂移导致拼接错位

AR 锚点提供的世界位姿有漂移,4 个锚点拼起来累计漂移到 4-6 cm。展厅地面铺装是对缝瓷砖,4 cm 错位肉眼可见接缝。后来改成采集起点打锚点 + 终点复打一个锚点做校正,用首末两个锚点的平均位姿做变换,漂移压到 1-2 cm,瓷砖接缝基本不可见。

工程约束清单

  • 单块覆盖 ≤ 15-20 平米,更大空间必须分块
  • 重叠区 ≥ 1 米,否则拼接锚点不够
  • 分块边界避开展品,先标记展品位置再划块
  • 重建前 gc() + 释放上一块 GSNode + 清后台,压低内存峰值
  • session 单例,4 块串行,按最坏单块耗时估总耗时
  • 拼接走软融合而非硬裁剪,避免接缝闪烁
  • AR 锚点首末双打做校正,压漂移到 2 cm 内
  • 仅保证机型开放,中端机回退全景图

下一步可验证的动作

  1. 测 50 平米以上展厅(6 块以上),看软融合在多块三叉交界处的表现,三叉交界是两块软融合没覆盖的情况
  2. 把拼接工具链抽成 C++ 离线工具,部署期在 PC 上跑而非端侧,省端侧 5 分钟重建时间
  3. 对比 AR 锚点定位和"特征点自动配准"两种拼接方案,看自动配准能否省掉人工打锚点
  4. 测 Tiled 场景在折叠屏/平板大屏上的同屏点数压力,确认是否需要更细的瓦片层级

在我们测的这一个 30 平米展厅范围内,4 块串行 + 软融合拼接 + Tiled 加载这条链路能交付。软融合的 C++ 工具链是工程量大头,写完一次能复用。如果后续 API 26 的 Tiled 接口不变,更大展厅按这套扩到 6-8 块应该也成立,但三叉交界还没验证,不敢打包票。

相关推荐
西红柿炖牛腩3543 小时前
GLB 转 OBJ/PLY 后体积没变?3D 模型不减面变小靠什么
前端·javascript·3d
熊猫钓鱼>_>3 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 kotlin-inject 实现依赖注入适配
开发语言·华为·kotlin·ai编程·inject·鸿蒙·openharmony
m0_738185823 小时前
Flutter 鸿蒙化实战:media_info 适配 OpenHarmony,媒体信息与缩略图
flutter·华为·harmonyos·鸿蒙·媒体
特视界说4 小时前
没有完整 3D 模型,能做产品三维动画吗?
3d·微信·音视频·新浪微博
m0_738185824 小时前
Flutter 鸿蒙化实战:just_audio 适配 OpenHarmony,功能强大的播放器
flutter·华为·harmonyos·鸿蒙
翼辉cto4 小时前
Kotlin Multiplatform 三方库 SQLDelight 的 OpenHarmony 鸿蒙化适配实战
开发语言·kotlin·harmonyos
SuperHeroWu75 小时前
TraeCode 国内版接入 DevEco CLI:用官方知识开发鸿蒙应用
ai编程·harmonyos·知识库·trae·aicoding·skills·deveco cli
2501_919749035 小时前
华为鸿蒙免费口算练习APP—小羊口算
华为·harmonyos·鸿蒙
熊猫钓鱼>_>15 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 KMPNotifier 实现 OpenHarmony 本地通知引擎
kotlin·ai编程·harmonyos·鸿蒙·openharmony·适配·kmp