HarmonyOS 7 Spatial Recon Kit 开发实录 06:ArkGraphics 3D × GSNode 模型加载、资源释放与渲染性能收口【鸿蒙心迹】
ReconRoom 做到第五期,终于有了一份可以被独立消费的 3DGS 结果:room_20261002_05.ply,84.6 MB,1,284,560 个高斯点,manifest 校验已经通过。
系列最后一期我没有再改重建链路,而是把项目切到另一个视角:一个已经生成好的 3DGS 资产,怎样在应用里被加载、观察、退出,再次进入,而且不把内存越堆越高。
当前官方 Spatial Render 能力通过 @kit.SpatialReconKit 提供 GSNode / GSPlugin.loadGSNode,并基于 ArkGraphics 3D Scene 组织渲染。API 26 还增加了面向大场景的 TiledGSNode、瓦片请求回调等能力。ReconRoom 这一轮先用标准 GSNode 把单房间模型收口,测试任务固定为 render_20261002_06。

一、结果页能看到文件,不等于渲染页已经准备好
第五期结束时,结果页只做了两件事:确认文件存在,确认 manifest 是 PASSED。
如果直接在按钮点击里写:
点击查看
→ Scene.load()
→ loadGSNode()
→ 页面展示
单次体验通常没问题。真正连续进出几次以后,加载耗时、旧 Node、Scene 强引用和页面状态会混在一起。
所以第六期先把“文件资产”和“渲染实例”分开。
文件资产:
room_20261002_05.ply
84.6 MB
1,284,560 points
verify=PASSED
渲染任务:
renderTask=render_20261002_06
state=LOADING / READY / RELEASING / RELEASED
loadCost=842 ms
fps=58.7
renderTask 不是新的重建任务,它只是用来记录一次渲染生命周期。这样模型文件可以长期存在,而 GSNode 可以进入页面时创建、离开页面时释放。
二、插件加载、Scene 创建和 GSNode 加载要按顺序做
官方 spatialRender 文档里有一个很关键的前置条件:调用 3DGS 加载能力前,需要先在 RenderContext 中加载 GSPlugin.PLUGIN_ID。
这一段代码解决的是“页面里到处散落 Scene 初始化”的问题。我把它统一放进 GSSceneController.ets:
import {
Scene,
RenderContext
} from '@kit.ArkGraphics3D'
import { spatialRender } from '@kit.SpatialReconKit'
export class GSSceneController {
private scene?: Scene
private gsNode?: spatialRender.GSNode
async load(modelUri: string): Promise<void> {
const renderContext: RenderContext | null =
Scene.getDefaultRenderContext()
if (renderContext === null) {
throw new Error('RENDER_CONTEXT_UNAVAILABLE')
}
renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID)
this.scene = await Scene.load()
if (!this.scene.root) {
throw new Error('SCENE_ROOT_UNAVAILABLE')
}
this.gsNode = await spatialRender.GSPlugin.loadGSNode(
this.scene,
{
uri: modelUri,
offset: 0
},
this.scene.root
)
this.gsNode.visible = true
}
}
这里我没有把 Scene.load() 放进 ArkUI build()。build() 只负责声明页面结构,真正有副作用的加载动作放到 Controller,页面只观察 LOADING → READY。
这和前面 Session Manager 的处理思路是一致的:页面不是资源所有者,页面只是状态消费者。
三、加载时间不要只记一个“总耗时”
第一次进入模型页时,我只记了一个 842 ms。看起来已经能用,但这个数字没有告诉我慢在哪里。
后来把加载拆成三段:
manifest read: 12 ms
scene + plugin prepare: 126 ms
GSNode load: 704 ms
total: 842 ms
这个拆分很有用。
manifest 只要十几毫秒,后面就没必要在“读取文件列表”上做过度优化。真正主要耗时在 GSNode 的模型载入,这才是需要观察设备、模型大小和重复加载策略的地方。
当前版本没有为了追求二次进入速度长期缓存 GSNode。因为 84.6 MB 的模型对应的不只是一个 JavaScript 对象,渲染资源也会占内存。把它一直留着,页面切走看起来快,后台占用却持续存在。
最后我的取舍是:短时间页面切换不做“假缓存”,先保证释放正确;等有明确产品需求,再决定是否加入模型缓存池。
四、FPS 要看一段时间,不看刚进页面的第一秒
模型刚加载完成时,帧率波动最明显。只截一瞬间的 60 FPS 很容易得到错误结论。
我加了 RenderPerfTracker.ets,用滑动窗口保存最近 120 个 frame interval。这个代码解决的是“只看即时 FPS 导致数据抖动”的问题:
export class RenderPerfTracker {
private samples: number[] = []
private readonly maxSamples: number = 120
pushFrameInterval(ms: number): void {
this.samples.push(ms)
if (this.samples.length > this.maxSamples) {
this.samples.shift()
}
}
averageFps(): number {
if (this.samples.length === 0) {
return 0
}
const total = this.samples.reduce(
(sum: number, item: number) => sum + item,
0
)
const avgFrameMs = total / this.samples.length
return Number((1000 / avgFrameMs).toFixed(1))
}
clear(): void {
this.samples = []
}
}
这一轮稳定观察后的结果是 58.7 FPS。
我没有把 58.7 写成“Spatial Render 的固定性能”。它只代表 ReconRoom 当前模型、当前设备、当前镜头运动和当前页面负载下的结果。换成更大的场景,或者引入更多叠加 UI,这个数字都会变。
性能数据的价值不是拿来宣传,而是用于回归。下一次代码改完,如果同一模型从 58.7 掉到 45 左右,至少知道应该回头看渲染路径。
五、真正的问题出现在第五次进入模型页之后
前四次测试都很顺,直到我连续执行:
结果页
→ 查看模型
→ 返回
→ 再查看模型
重复五轮后,内存没有回到接近基线。
第一次加载峰值大约 612 MB,返回结果页以后仍然维持在 500 MB 左右。再进入一次,峰值继续往上走。
这时候不能把问题简单归结成“3D 模型本来就吃内存”。页面已经离开,GSNode 也不再可见,如果强引用和场景资源仍然保持,就属于我们自己的生命周期问题。
当前修正后的策略分三步:
停止性能采样
→ 销毁 GSNode / 相关 SceneResource
→ 清空页面和 Controller 的强引用
ArkGraphics 3D 的 SceneResource 提供 destroy() 用于释放关联资源;GSNode 继承 Node,渲染对象退出时也要按图形资源生命周期处理,而不是只写一句 this.gsNode = undefined。
六、释放代码必须允许重复调用
页面退出、加载失败、系统销毁窗口,这几个路径都可能进入释放逻辑。
如果 release 只能调用一次,第二次就抛异常,清理本身反而会成为新的故障源。
这一段代码解决的是“多生命周期入口重复清理”的问题:
export class GSSceneController {
private scene?: Scene
private gsNode?: spatialRender.GSNode
private released: boolean = false
release(): void {
if (this.released) {
return
}
this.released = true
try {
if (this.gsNode !== undefined) {
this.gsNode.visible = false
this.gsNode.destroy()
this.gsNode = undefined
}
} finally {
this.scene = undefined
}
}
}
这里最重要的是幂等,不是代码长度。
如果加载 GSNode 过程中就失败,gsNode 可能还没有值;如果已经加载完成,先隐藏再释放;无论哪条路径都允许最终把引用清空。
正式工程里还要继续清理相机、Effect、额外材质等由页面创建的 SceneResource。ReconRoom 这一轮只加载一个 GSNode,所以资源树相对简单。
七、DevEco 里这次看的不是“模型漂亮不漂亮”
系列最后一张 DevEco 图,我特意把性能和释放日志放在同一屏。

本轮统一数据:
renderTask: render_20261002_06
model: room_20261002_05.ply
modelSize: 84.6 MB
points: 1,284,560
loadCost: 842 ms
avgFps: 58.7
peakMemory: 612 MB
releasedMemory: 286 MB
cycles: 5
HiLog 里最关键的不是 load success,而是后面几轮:
cycle=1 release done memory=289MB
cycle=2 release done memory=287MB
cycle=3 release done memory=286MB
cycle=4 release done memory=287MB
cycle=5 release done memory=286MB
内存不会每次完全回到同一个字节数,系统缓存和图形驱动都有自己的行为。我更在意的是它有没有随着进入次数持续阶梯式上涨。
修正以后,第 3~5 轮已经稳定在 286~287 MB 左右,没有再出现“每进一次涨一截”。
八、单房间用 GSNode,大场景再考虑 TiledGSNode
API 26 的 Spatial Render 还提供 TiledGSNode、loadTiledGSNode()、setTileRequestCallback() 和 notifyTileReady(),用于按需加载瓦片的大型 3DGS 场景。
ReconRoom 这一期没有为了“新 API”强行切过去。
原因很简单:当前模型 84.6 MB,单房间场景,标准 GSNode 已经能稳定达到 58.7 FPS。现在换成瓦片体系,会引入 manifest、tile 请求、加载调度、相机驱动选择等更多状态,工程复杂度会上升,而当前场景没有明显收益。
如果后续把项目扩展成多房间、楼层或者室外大空间,TiledGSNode 才会变成下一条合理路线。
这个判断也算整个系列最后的工程取舍:不是看到新能力就全部接进来,而是先让当前规模的问题闭环。
九、最后一张运行图,我更关心退出以后能不能回到基线
最终运行页把展示和诊断数据放在一起:

统一状态如下:
renderTask: render_20261002_06
state: READY
source: room_20261002_05.ply
points: 1,284,560
loadCost: 842 ms
avgFps: 58.7
memoryNow: 548 MB
lastReleased: 286 MB
cycle: 5 / 5
memoryNow=548 MB 是模型正在显示时的当前占用;lastReleased=286 MB 是上一轮离开模型页后的观测值。这两个数字放在一起,比只展示一个“峰值 612 MB”更容易解释生命周期是否正常。
到这里,ReconRoom 这条主线就完整闭环了:
设备支持检测
→ Session 创建
→ 多角度帧输入
→ 位姿与质量门
→ 暂停恢复
→ 系统压力保护
→ 结果写出
→ 完整性校验
→ GSNode 加载
→ 性能观察
→ 资源释放
前几期一直在处理“怎么把重建跑起来”,最后一篇落到“怎么把结果稳定地放进真正的产品页面”。
这个系列到 06 正式结束。再继续写第 07,就会开始重复已有问题。下一条连载应该换到明显不同的技术方向和 Demo,而不是把 ReconRoom 无限拉长。
十、模型页也要防止重复点击造成并发加载
第六期还有一个非常现实的交互问题:用户在结果页连续点两次“查看模型”。
如果按钮层没有立即反馈,两个 load() 可能几乎同时进入。最后就会出现两个 Scene 初始化、两个 GSNode 加载 Promise 同时工作,而页面只保存了后一次引用。前一个对象即使加载成功,也可能失去业务层引用,变成很难追踪的资源占用。
我没有靠按钮防抖解决这个问题,而是在 Controller 里增加状态门:
IDLE
→ LOADING
→ READY
→ RELEASING
→ RELEASED
LOADING 状态下新的加载请求直接复用当前 Promise;READY 时再次传入同一 URI 只返回当前实例;只有 RELEASED 才允许开启下一轮加载。
页面按钮仍然会在点击后变成“加载中”,那只是用户体验。真正防止并发加载的是资源层状态机。
这和第一期 Session 的幂等 prepare() 前后呼应。做到第六期以后再回头看,会发现很多稳定性问题都不是“API 不会调”,而是同一个动作从两个入口重复发生。
十一、相机和手势也属于渲染生命周期,不应该散在页面里
GSNode 出来以后,最容易继续往页面里堆的是手势代码:拖动旋转、双指缩放、恢复默认视角。
我最初也是这么写的。后来连续做页面重建时发现,手势回调闭包里还保留着旧 camera 和旧 node 引用。即使 Controller 已经 release,某些交互对象仍然没有被清掉。
现在模型页只把手势转换成一个稳定的命令:
ORBIT(dx, dy)
ZOOM(scale)
RESET_CAMERA
真正修改相机姿态的是 GSSceneController。页面离开时先解除手势订阅,再销毁 GSNode。
这种写法多了一层,但它把“用户输入”和“图形资源”之间的引用关系收紧了。后续如果把页面迁移到平板或 PC,自由窗口的手势来源变化,也不用重新改渲染资源生命周期。
1. 前后台切换时,我不再自动销毁模型
这里和重建任务的策略不完全一样。
模型查看进入后台时,如果立刻销毁 GSNode,用户切回应用就要重新经历 842 ms 的加载;如果永远保留,又会在长时间后台占着较大的图形资源。
ReconRoom 当前采用一个折中策略:
- 短暂进入后台只暂停性能采样,保持模型;
- 页面真正退出时执行完整 release;
- 系统内存压力明显时,由业务主动释放当前模型;
- 回到页面后如果资源已经释放,再从 manifest 重新加载。
这个策略没有写成“所有应用都应该这么做”。模型大小、设备内存和业务形态不同,最优选择也会不同。这里只记录当前项目的取舍,以及释放发生以后 UI 要能识别 RELEASED,不能继续让用户操作一个已经销毁的 Node。
十二、最后的性能验收我用的是同一路径重复五轮
只跑一次 58.7 FPS 不能说明问题解决。
我把验收脚本固定成同一条路径:
打开结果页
→ 进入模型页
→ 等待 10 秒
→ 绕模型旋转 20 秒
→ 缩放两次
→ 返回结果页
→ 等待内存回落
连续五轮,每轮记录加载耗时、120 帧滑窗平均 FPS、峰值内存和退出后的内存。
最终数据大致稳定在:
load: 820~870 ms
fps: 57.9~59.1
peak: 604~616 MB
released: 286~289 MB
我没有把这些范围写成性能承诺,它们只是这一版 ReconRoom 的回归基线。
真正重要的是趋势:第五轮没有比第一轮多出一大截内存,加载耗时没有随着进入次数不断拉长,FPS 也没有出现持续下降。
如果后续加滤镜、编辑能力或者换成 TiledGSNode,这组数据还可以继续作为对照。到那时优化就不是“感觉变卡了”,而是能明确看到哪个指标发生了变化。
十三、整个系列最后留下的是一条可维护的工程边界
把 01 到 06 连起来看,ReconRoom 最后并没有变成一个“大而全”的 3D 编辑器。
它留下的是几条更有用的边界:
- Native Session 有自己的拥有者,页面不直接控制它;
- 帧输入先做质量门,不能用数量代替有效视角;
- 暂停、恢复、热压力和结果保存都属于任务状态机;
- 文件落盘以后还要有 staging、manifest 和完整性校验;
- 模型渲染和模型文件是两套生命周期;
- ArkGraphics 3D 资源必须有明确释放路径;
- 性能判断要用重复路径和稳定窗口,而不是一张截图。
这些边界能继续带到别的 3DGS 项目里,也能解释这个系列为什么应该停在 06。
再往后写,如果只是给 ReconRoom 增加一个按钮、一个滤镜或者再换一种页面布局,本质上已经不是这条主线需要解决的新工程问题了。
所以系列在这里收住。下一轮应该换一个明显不同的 HarmonyOS 7 方向,从新的 Demo 重新建立 01,而不是让编号无限往后延。
参考资料
- Spatial Render API:GSNode / GSPlugin / loadGSNode:https://developer.huawei.com/consumer/en/doc/harmonyos-references/spatial-recon-spatialrender
- HarmonyOS 空间计算能力:https://developer.huawei.com/consumer/cn/features/spatialization
- ArkGraphics 3D SceneResource 资源销毁:https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/js-apis-inner-scene-resources
更多推荐

所有评论(0)