HarmonyOS 7 SceneForge 3DGS端侧重建实录 05:spatialRender × GSPlugin:3DGS模型加载、相机交互与渲染资源收口【鸿蒙心迹】
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
更多推荐




所有评论(0)