04 结束以后,SceneForge 已经不再只有一个运行中的 HMS_SpatialRecon_Session,而是拿到了真正可以长期使用的结果资产:

tabletop_tea_set_02_3dgs.ply
86.4MB

tabletop_tea_set_02_orbit.mp4
18.7MB

manifest.json

到这里,空间重建和空间渲染第一次真正接上。

用户不会因为目录里多了一个 PLY 文件就觉得“3DGS 已经做好了”。真正的下一步是:把 PLY 加载进 ArkGraphics 3D 的 Scene,让用户能围着模型旋转、缩放、复位,同时保证退出页面后 GSNode、Scene 与渲染资源都能正确收口。

HarmonyOS 当前空间计算能力页已经明确把“3DGS 端侧渲染”列为 3DGS 端侧重建之后的配套能力;当前 spatialRender API 里也已经提供 GSPlugin、GSImportSettings、GSNode,其中 GSPlugin.loadGSNode(scene, params, parent?) 用于把 3DGS 模型节点异步加载进 ArkGraphics 3D Scene。官方近期 API 变更文档还明确记录了 GSPlugin.PLUGIN_ID 和 loadGSNode 这一组接口。

所以 05 的工程主线不是“怎么画一个 3D 页面”,而是:

结果文件 → Manifest 校验 → RenderContext 插件 → Scene → GSNode → Camera → 交互 → 释放资源。

本轮统一数据:

taskId:
recon_20261003_05

assetId:
sf_asset_tabletop_02_v1

modelFile:
tabletop_tea_set_02_3dgs.ply

modelSize:
86.4MB

modelUri:
file:///data/storage/el2/base/files/
sceneforge/recon_03/results/
tabletop_tea_set_02_3dgs.ply

manifestHash:
c7a8f3d1

renderContextReady:
true

pluginLoaded:
true

sceneLoadCost:
42ms

gsLoadCost:
684ms

firstFrameCost:
71ms

cameraPreset:
ORBIT_DEFAULT

cameraDistance:
1.65m

yaw:
28°

pitch:
-12°

fov:
45°

modelScale:
1.0

modelCenter:
x=0.02
y=-0.11
z=0.06

avgFps:
57.6

p95FrameTime:
19.4ms

peakGpuMemory:
238.7MB

reloadCount:
1

duplicateLoadBlocked:
2

releasedGsNodes:
1

renderResourcesReleased:
true

status:
RENDER_READY

一、05 先把“结果文件”和“渲染对象”彻底分开

04 的 manifest.json 只描述结果资产。

05 页面真正需要的是运行时对象:

RenderContext
Scene
GSNode
Camera
Gesture Controller

我没有把这些对象塞回 Manifest,也没有把 GSNode 挂到全局单例。

项目分层继续保持:

ResultManifest
负责结果身份

GSModelLoader
负责加载

OrbitCameraController
负责交互

SceneRenderHost
负责页面生命周期

RenderResourceRegistry
负责收口

这样模型文件可以长期存在,渲染对象却只在查看页活跃期间存在。

二、加载之前先确认 Manifest 与 PLY 是同一份结果

当前 PLY:

tabletop_tea_set_02_3dgs.ply
86.4MB

manifestHash=c7a8f3d1 是 SceneForge 当前结果索引的短摘要展示值。

进入 Viewer 前先确认:

taskId
scene
PLY path
fileSize
hash

都和 04 的结果一致。

如果用户手工替换了同名 PLY:

MODEL_ASSET_MISMATCH

不继续调用 GSPlugin。

渲染器不能因为“路径存在”就默认文件可信。

三、RenderContext 准备好以后,先加载 GSPlugin,再创建 GSNode

当前官方社区与 API 变更信息给出的调用主线很清楚:

Scene.getDefaultRenderContext()

renderContext.loadPlugin(
  spatialRender.GSPlugin.PLUGIN_ID
)

Scene.load()

spatialRender.GSPlugin.loadGSNode(...)

SceneForge 的 Loader 包一层状态控制:

import { spatialRender } from '@kit.SpatialReconKit'
import {
  Scene,
  RenderContext
} from '@kit.ArkGraphics3D'

export class GSModelLoader {
  private renderContext:
    RenderContext | null = null

  private scene:
    Scene | null = null

  private node:
    spatialRender.GSNode | null = null

  private loading:
    boolean = false

  async load(
    modelUri: string
  ): Promise<void> {
    if (this.loading || this.node) {
      throw new Error(
        'GS_NODE_ALREADY_LOADING_OR_READY'
      )
    }

    this.loading = true

    try {
      this.renderContext =
        Scene.getDefaultRenderContext()

      if (!this.renderContext) {
        throw new Error(
          'RENDER_CONTEXT_NOT_READY'
        )
      }

      this.renderContext.loadPlugin(
        spatialRender.GSPlugin.PLUGIN_ID
      )

      this.scene =
        await Scene.load()

      this.node =
        await spatialRender.GSPlugin
          .loadGSNode(
            this.scene,
            {
              uri: modelUri,
              offset: 0
            },
            this.scene.root
          )
    } finally {
      this.loading = false
    }
  }
}

本轮:

renderContextReady=true
pluginLoaded=true

重复点“加载模型”两次被项目层挡住:

duplicateLoadBlocked=2

四、为什么 GSPlugin 要在 loadGSNode 以前加载

GSPlugin 本质上是空间渲染能力接入 ArkGraphics 3D 的插件入口。

如果没有插件准备,页面不应该继续创建 GSNode。

SceneForge 把阶段拆成:

RENDER_CONTEXT_READY

PLUGIN_READY

SCENE_READY

GS_NODE_READY

这样日志里看到:

GS load failed

时,可以知道到底卡在:

插件
Scene
文件
GSNode

哪一层。

五、42ms 和 684ms 不是同一个“加载耗时”

本轮:

sceneLoadCost=42ms

gsLoadCost=684ms

前者只统计空 Scene / 渲染上下文准备。

后者才是:

86.4MB PLY
→ GSPlugin.loadGSNode
→ GSNode ready

第一帧真正可见还要再加:

firstFrameCost=71ms

所以用户从点开模型到看到首帧,不能简单写成:

684ms

真实链路更接近:

RenderContext / Scene
+ GS load
+ first render

六、相机初始值要来自结果校准,而不是每次手调

当前相机基线:

distance=1.65m

yaw=28°

pitch=-12°

fov=45°

模型中心:

x=0.02
y=-0.11
z=0.06

这些是 SceneForge 当前茶具场景的工程参数,不是 GSPlugin 官方推荐值。

结果资产在 05 第一次被人工校准以后,会把 Viewer Preset 写进项目侧 asset metadata。

下一次重新打开:

同一个 assetId
→ 同一个 ORBIT_DEFAULT

用户不会每次看到模型都从一个奇怪角度开始。

七、拖动旋转只修改相机,不去修改 3DGS 模型数据

Viewer 交互采用轨道相机思路。

拖动:

dx
→ yaw

dy
→ pitch

双指缩放:

→ distance

模型节点本身保持:

modelScale=1.0

示意控制器:

export class OrbitCameraController {
  yaw: number = 28
  pitch: number = -12
  distance: number = 1.65

  onDrag(
    dx: number,
    dy: number
  ): void {
    this.yaw +=
      dx * 0.18

    this.pitch =
      Math.max(
        -70,
        Math.min(
          70,
          this.pitch +
          dy * 0.15
        )
      )

    this.apply()
  }

  onPinch(
    scale: number
  ): void {
    this.distance =
      Math.max(
        0.8,
        Math.min(
          3.5,
          this.distance / scale
        )
      )

    this.apply()
  }

  private apply(): void {
    // 项目封装:
    // 将 yaw / pitch / distance
    // 转成 ArkGraphics 3D Camera 位姿
  }
}

这里项目只改变相机观察位置,不对 PLY 结果重新编辑。

八、模型中心不正确时,旋转会出现“绕空气转”的感觉

3D Viewer 最影响体验的细节之一,不是 FPS,而是旋转中心。

如果模型中心偏离桌面主体:

用户拖一下
茶壶从屏幕边缘绕圈

会非常别扭。

所以 modelCenter 进入 Snapshot:

0.02 / -0.11 / 0.06

Orbit Camera 始终看向这个中心。

后续不同场景会有不同中心点,不能硬编码茶具参数。

九、57.6 FPS 只属于当前 Viewer 基线

本轮:

avgFps=57.6
p95FrameTime=19.4ms

这是一组当前真机、当前模型、当前相机交互下的项目数据。

它不能写成:

HarmonyOS 3DGS 固定 60FPS

3DGS 模型规模、屏幕分辨率、GPU、视口和设备热状态都会影响渲染成本。

06 会把多场景 Load / FPS / GPU Memory 一起放进回归。

十、238.7MB 的 GPU 峰值必须和模型大小一起看

PLY 文件:

86.4MB

但渲染峰值:

238.7MB

并不矛盾。

运行时还可能包含:

高斯数据上传
排序 / 可见性数据
场景资源
帧缓冲
插件内部资源

所以 SceneForge 不用“文件大小”推断 GPU 内存。

只把当前实测:

peakGpuMemory=238.7MB

作为回归基线。

十一、页面退出不能只把 GSNode 引用设成 null

这一篇最重要的资源问题是:

页面退了
模型不见了
不代表资源已经释放。

ArkGraphics 3D 的 SceneResource 当前提供 destroy() 用于销毁资源并释放关联资源或引用。

GSNode 的实际释放细节仍以当前 SDK 类型关系为准,SceneForge 不把未知方法硬写在业务页面里,而是统一封装:

export class RenderResourceRegistry {
  async dispose(
    host: SceneRenderHost
  ): Promise<void> {
    await host.detachGsNode()

    host.destroyOwnedResources()

    host.clearSceneReferences()
  }
}

项目约束是:

页面只调用 dispose

真正 detach / destroy
集中在 RenderHost

本轮最终:

releasedGsNodes=1
renderResourcesReleased=true

十二、为什么还要做一次 reloadCount=1

首次 Viewer 加载成功后,我主动执行一次:

退出页面
→ 资源释放
→ 重新进入
→ 再次加载同一个 PLY

如果第一次资源没有释放干净,第二次加载最容易暴露:

GPU 内存继续抬升
节点重复挂载
交互控制器重复订阅

本轮:

reloadCount=1

第二次仍能正常进入 RENDER_READY。

十三、DevEco 图重点看“插件 → GSNode → 相机 → Release”的完整顺序

开发图:

HiLog:

taskId=
recon_20261003_05

manifest=
c7a8f3d1

model=
tabletop_tea_set_02_3dgs.ply

size=
86.4MB

renderContextReady=
true

pluginLoaded=
true

sceneLoadCost=
42ms

GSPlugin.loadGSNode
cost=
684ms

firstFrame=
71ms

duplicateLoadBlocked=
2

camera:
yaw=28
pitch=-12
distance=1.65m

avgFps=
57.6

p95FrameTime=
19.4ms

release gsNode=
1

renderResourcesReleased=
true

status=
RENDER_READY

十四、手机运行图终于进入真正的 3D 交互

最终运行图:

页面不再是:

采集诊断
进度诊断
保存诊断

而是真正的模型 Viewer。

用户可以:

拖动旋转

双指缩放

双击复位

进入全屏

底部仍然保留工程数据:

GS load 684ms

first frame 71ms

57.6 FPS

GPU 238.7MB

最终:

RENDER_READY

十五、05 最后固定七组渲染测试

第一组,Manifest 与 PLY hash 一致,允许加载。

第二组,RenderContext 为空,不调用 GSPlugin。

第三组,插件未加载完成时不创建 GSNode。

第四组,重复点击加载被拦截,不出现两个 GSNode。

第五组,拖动 / Pinch 只改变 Camera,不修改模型资产。

第六组,退出页面释放 GSNode 与渲染资源。

第七组,释放后重新进入,模型可再次正常加载。

全部通过以后,SceneForge 才真正完成:

重建
→ 保存
→ 渲染

这条闭环。

十六、下一篇只做总验收,不再增加空间能力

到现在 SceneForge 已经有:

DataFrame 输入

Frame Gate

Session Lifecycle

PLY / MP4 保存

GSPlugin 模型加载

3D Viewer

06 不会再加编辑、滤镜或分享。

最后只做:

多场景
多轮
采集覆盖率
重建耗时
保存耗时
GS 加载
FPS
GPU / 内存
Session / GSNode 资源收口

整个系列会在 06 正式结束。

十七、模型 URI 的来源必须固定,不允许页面自己拼路径

04 已经把结果目录和 Manifest 固定下来。

05 页面只接收:

assetId
modelUri
manifestHash

不会在页面里自己写:

/data/storage/.../results/

否则只要结果目录结构调整一次,Viewer 就会和保存模块失联。

SceneForge 现在把 URI 解析统一放进 SceneAssetRepository:

export interface SceneAsset {
  assetId: string
  modelUri: string
  modelSizeMb: number
  manifestHash: string
}

export class SceneAssetRepository {
  load(
    assetId: string
  ): SceneAsset {
    // 从 04 的 manifest 索引中解析
    return this.index
      .getRequired(assetId)
  }
}

这段代码解决的是“渲染入口只能消费正式资产”。

临时 PLY、未写 Manifest 的文件、用户手工复制进来的同名文件,都不会绕过资产层直接进入 GSPlugin。

十八、GSPlugin 加载要做进程级去重,不能每个页面重复 loadPlugin

renderContext.loadPlugin(...) 是渲染能力准备动作,不应该页面每次重组都重新调用。

SceneForge 维护一个轻量状态:

NOT_LOADED
LOADING
READY
FAILED

页面重复进入时:

READY
→ 直接复用当前 RenderContext

如果第一次加载还没完成:

LOADING
→ 等待同一个 Promise

不能重新触发第二次插件准备。

本轮页面快速进入两次,真正被拦截的是:

duplicateLoadBlocked=2

它既包含 GSNode 重复加载,也包含渲染准备阶段的重复请求。

十九、首帧 71ms 要单独记录,因为“节点 ready”不等于“用户已经看到模型”

GSPlugin.loadGSNode resolve 后:

GSNode 已经可用

但 Component3D / Scene 真正把内容提交到渲染管线,还需要至少一次绘制。

所以 SceneForge 在:

GS_NODE_READY

以后继续等待首个可见帧事件,再记录:

firstFrameCost=71ms

UI 状态也分成:

LOADING_MODEL

WAITING_FIRST_FRAME

RENDER_READY

这样用户不会看到空背景却已经显示:

加载完成

如果 GSNode 成功、首帧迟迟不出现,日志可以明确归到渲染阶段,而不是继续怀疑 PLY 文件。

二十、相机手势必须在 GSNode Ready 后再启用

Viewer 页面在模型未加载完成时仍然能收到:

Drag
Pinch
DoubleTap

如果此时 Camera / Scene 还没 ready,控制器不断写入位姿,容易出现初始化竞态。

SceneForge 只有:

status=RENDER_READY

以后才启用:

拖动旋转
双指缩放
双击复位

加载阶段手势直接忽略。

这条边界看起来很小,却能消掉“第一次打开模型拖一下就跳到奇怪角度”的典型问题。

二十一、双击复位要回到 Preset,不是把所有值写成 0

当前初始视角:

yaw=28°
pitch=-12°
distance=1.65m
fov=45°

所以双击复位应该回到:

ORBIT_DEFAULT

而不是:

yaw=0
pitch=0
distance=0

尤其 distance 如果被错误归零,相机会直接落到模型中心。

SceneForge 把 Preset 设计成资产侧配置:

asset
→ viewer preset

同一个 GS 模型无论从哪个页面打开,都得到一致首视角。

二十二、前后台切换时 Viewer 和重建 Session 的策略已经不同

03 里的 Native Recon Session 在后台可以切:

RunningMode

05 的 Viewer 则是一个已经完成重建的渲染页面。

应用进入后台后,项目策略更保守:

停止高频手势更新
暂停 Viewer Metrics 采样
保留 AssetId
按当前 ArkGraphics 3D 生命周期要求回收不再需要的渲染引用

回到前台时根据页面是否仍存在决定:

继续当前 Scene
或
从 Asset 重新 Load

不能把 03 的 Session 规则原样搬到渲染器。

二十三、模型 Reload 必须先完成旧资源 detach

本轮专门做一次:

reloadCount=1

流程不是:

旧 GSNode 还在
→ 再 load 一个新 GSNode

而是:

停止交互

detach 旧 node

release owned resources

确认 Registry 空

再重新 load

如果 Registry 里仍有:

activeGsNode=1

新的 reload 直接失败。

这也是为什么 releasedGsNodes=1 被写进最终快照。

资源释放不能只靠“变量覆盖掉了”。

二十四、RenderResourceRegistry 要能回答“现在还有什么活着”

最终 Registry 不只统计 GSNode。

至少包含:

GSNode
Scene
Gesture subscription
Metrics sampler
Viewer page binding

退出页面以后要求:

activeGsNodes=0
activeGestureSubscriptions=0
activeMetricsSamplers=0

本轮手机图只展示:

renderResourcesReleased=true

详细回归会把这些资源拆开统计。

06 的 activeResourcesAfterFinish=0 就来自这套 Registry。

二十五、模型加载失败时,不能把 Viewer 留在半初始化状态

可能失败的位置很多:

Manifest 校验失败
RenderContext 不可用
Plugin 加载失败
Scene 创建失败
GSNode 加载失败
首帧超时

SceneForge 每一层失败都回滚到:

RENDER_FAILED

并且调用统一 dispose。

不会留下:

Scene ready
GSNode null
Gesture 已注册

这种半初始化状态。

下一次用户点击重试,必须从一个干净资源基线重新开始。

二十六、05 的最终资产状态和文件状态继续分开

04 的:

RESULT_READY

表示 PLY / MP4 文件就绪。

05 的:

RENDER_READY

表示当前 PLY 已经成功进入 Viewer,并且首帧、相机与资源生命周期都通过。

一个文件可能:

RESULT_READY

但在某台设备上:

RENDER_FAILED

两种状态不能混成同一个 Ready。

这也是最终 06 要分别统计:

PLY Save Success
GS Load Success

的原因。

二十七、模型加载失败后的重试要保留原始错误上下文

如果第一次 loadGSNode 失败,SceneForge 不会只留下:

LOAD_FAILED

而是保存:

assetId
modelUri
manifestHash
modelSize
pluginState
sceneState
loadDuration
errorCode
errorMessage

用户点击重试时,新一次 Load 会生成新的 renderAttemptId,不会覆盖第一次失败记录。这样后面如果出现“第一次失败、第二次成功”,仍然可以判断是文件 I/O、插件准备还是瞬时资源压力导致,而不是把所有问题都归结为模型损坏。

二十八、05 的 Viewer 仍然只做浏览,不在这一篇加入编辑能力

GSPlugin 当前还提供风格化效果相关能力,但 SceneForge 这一篇故意不把滤镜、编辑、裁剪一起加入。原因很简单:当前主矛盾仍然是能不能稳定加载、交互和释放。

如果在同一篇里加入:

复古滤镜
色彩编辑
节点变换
模型裁剪

渲染性能和资源问题就很难定位。05 只把 Viewer 做成可重复打开、可稳定拖动、可退出释放的基线,为后续真正空间编辑类项目保留清晰入口。

补充验收时还会检查 Viewer 连续旋转 60 秒后没有额外 GSNode 创建、相机参数没有 NaN、页面退出后手势订阅数量回到 0。这些看似细小的指标,能把“偶尔能看”提升成“可以长期反复使用”。

参考资料

  • HarmonyOS 空间计算能力 / 3DGS 端侧渲染
    https://developer.huawei.com/consumer/cn/features/spatialization
  • Spatial Recon Kit API 变更:spatialRender / GSPlugin / GSNode
    https://developer.huawei.com/consumer/en/doc/harmonyos-releases/js-apidiff-spatialreconkit-6101
  • ArkGraphics 3D
    https://developer.huawei.com/consumer/cn/sdk/arkgraphics-3d/
  • ArkGraphics 3D SceneResource.destroy
    https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/js-apis-inner-scene-resources
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐