☰
HarmonyOS 7 Spatial Recon Kit:3DGS 分块渲染与端侧重建状态治理【鸿蒙心迹】
2026/10/1 21:03:52 网站建设 项目流程

我这次没有把重点放在“把一个 3DGS 模型显示出来”,而是把一次真实的空间重建任务拆成了可追踪、可恢复、可诊断的工程链路:任务有 ID,进度有来源,状态有边界,分块渲染有视口驱动,异常也能知道停在了哪一步。

HarmonyOS 7 对应 API 26.0.0,空间计算这一块最让我感兴趣的变化之一,就是 Spatial Recon Kit 不再只是“做个三维效果看看”。官方能力说明已经把 3DGS 端侧重建、渲染和编辑放进了一条完整链路里;在渲染侧,API 26 还提供了面向大场景的TiledGSNode,核心思路是把完整 3DGS 场景切成瓦片,根据当前相机视口按需加载,而不是把所有高斯点一次性压进内存。

我用一个叫GaussRoom的小工程做验证。场景很简单:用户围着客厅走一圈采集素材,任务进入重建,重建结束后进入 3DGS 预览;如果模型规模较大,预览阶段切换为分块加载。最终我保留了一组固定数据作为调试样本:任务 ID 为GS-20260930-017,重建进度停在 68% 时关键帧数量是142 / 208,进入分块渲染后可见瓦片36 / 52、已请求瓦片 44、当前 LOD 为 2、相机距离约 3.8 m。

这些数字不是官方性能指标,也不代表设备上限,它们只是本文 Demo 的一次调试快照。真正有价值的是:当这些数字被纳入状态模型之后,很多以前只能靠“感觉卡了一下”的问题,开始可以被复现和解释。

一、我先改掉了“页面就是任务”的写法

最早的版本里,我在页面aboutToAppear()里直接开始重建,页面销毁时再尝试停止任务。代码看起来很省事,问题也很快出现:用户切到别的页面再回来,页面对象换了,但底层任务未必结束;一次失败后重新开始,上一轮回调还可能继续更新 UI;进度条看起来在动,却不知道当前进度属于哪一个任务。

这类问题在普通列表页面里不算大事,在三维重建里就会被放大。因为重建不是一次几十毫秒的函数调用,它更像一个有生命周期的长任务。页面只是观察者,任务本身应该有独立身份。

所以我先定义了一套很克制的状态:

  • IDLE:尚未开始;
  • CAPTURING:正在采集输入;
  • RECONSTRUCTING:端侧正在计算;
  • READY:模型已经可用于预览;
  • TILED_RENDERING:分块模型正在按视口加载;
  • FAILED:任务失败,需要保留错误上下文。

这段代码解决什么问题:把任务状态和页面生命周期拆开,并且保证旧任务回调不能污染新任务。

// entry/src/main/ets/model/ReconState.etsexportenumReconState{IDLE='IDLE',CAPTURING='CAPTURING',RECONSTRUCTING='RECONSTRUCTING',READY='READY',TILED_RENDERING='TILED_RENDERING',FAILED='FAILED'}exportinterfaceReconSnapshot{taskId:stringstate:ReconState progress:numberkeyFrames:numberexpectedFrames:numberoutputFile?:stringerrorCode?:numbererrorMessage?:string}exportclassReconTaskStore{privatecurrentTaskId:string=''privatesnapshot?:ReconSnapshotbegin(taskId:string):void{this.currentTaskId=taskIdthis.snapshot={taskId,state:ReconState.CAPTURING,progress:0,keyFrames:0,expectedFrames:208}}update(next:ReconSnapshot):boolean{if(next.taskId!==this.currentTaskId){returnfalse}this.snapshot=nextreturntrue}getSnapshot():ReconSnapshot|undefined{returnthis.snapshot}}

这里最关键的并不是枚举,而是taskId。每次启动新任务都生成新的任务 ID,底层回调带着同一个 ID 回来;页面收到回调后先核对 ID,再决定是否更新状态。这样用户快速退出、重进、重新开始,旧任务即使晚到几百毫秒,也不会把新任务的状态覆盖掉。

我后来发现,这个做法还顺手解决了日志难追的问题。以前 HiLog 里只有progress=68,现在会打印task=GS-20260930-017 progress=68 state=RECONSTRUCTING。一条日志本身没变复杂多少,但排查时终于能把同一次任务串起来。

二、68% 必须有业务含义,不能只是一个进度条动画

重建页面最容易做成“看起来很忙”:一个百分比、一条进度条、一个旋转动画。真正调试时却会问三个问题:68% 是哪一步的 68%?这个值多久没变化了?它和采集帧数有没有关系?

我的处理方式是把 UI 上的百分比只当作ReconSnapshot的一个投影,不在页面里自己累加。底层重建层给什么,页面就显示什么;如果 15 秒没有新的进度回调,页面单独进入“进度停滞”提示,而不是偷偷把数字从 68 补到 69。

另外,关键帧数量要和进度并排看。比如本文这次快照是142 / 208,进度 68%。如果关键帧仍在增长但重建进度暂时不动,说明问题可能还在输入阶段;如果关键帧已经稳定,进度也长时间不变,就该去看重建线程、设备负载或错误事件。一个单独的进度条给不了这种判断。

我还给状态转换加了最小约束:CAPTURING可以进入RECONSTRUCTING或FAILED;RECONSTRUCTING可以进入READY或FAILED;READY才能进入TILED_RENDERING。这样 UI 就不会出现“模型还没准备好,渲染按钮已经能点”的半成品状态。

三、渲染阶段真正的变化是 Tiled 3DGS

普通 3DGS 模型可以通过GSPlugin.loadGSNode()载入。到了 API 26,大场景还可以使用loadTiledGSNode()创建TiledGSNode。官方 API 说明里明确提到,这类节点面向大规模 3DGS 场景,瓦片选择由相机驱动;setCamera()的作用就是把当前相机交给分块节点,让它知道视口在哪里。

这里有两个工程细节值得先记住。

一个是插件加载。调用 GS 相关加载能力前,需要先把对应插件加载到渲染上下文;另一个是模型层级。TiledGSNode属于 Stage 模型场景能力,项目结构和页面上下文不要再按旧 FA 模型思路套。

这段代码解决什么问题:在 ArkGraphics 3D 场景中加载分块 3DGS,并把相机交给瓦片选择逻辑。

// entry/src/main/ets/pages/SpatialPreviewPage.etsimport{Scene,Camera,RenderContext}from'@kit.ArkGraphics3D'import{spatialRender}from'@kit.SpatialReconKit'exportclassSpatialPreviewController{privatescene?:Sceneprivatecamera?:CameraprivatetiledNode?:spatialRender.TiledGSNodeasyncinit():Promise<void>{constrenderContext:RenderContext|null=Scene.getDefaultRenderContext()if(!renderContext){thrownewError('RenderContext unavailable')}renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID)this.scene=awaitScene.load()this.camera=awaitthis.createSceneCamera(this.scene)consturi='OhosRawFile://assets/room/tiles/manifest.json'this.tiledNode=awaitspatialRender.GSPlugin.loadTiledGSNode(this.scene,{uri},this.scene.root)this.tiledNode.setCamera(this.camera)}privateasynccreateSceneCamera(scene:Scene):Promise<Camera>{// 项目中由实际 ArkGraphics 3D 相机创建逻辑返回 Camera。returnscene.root.getComponent('MainCamera')asCamera}}

上面的createSceneCamera()是工程包装层,重点不是这一行怎么取相机,而是TiledGSNode最后必须拿到真正驱动当前视图的 Camera。不要创建一个“专门给 TiledGSNode 的假相机”,UI 又用另一台相机渲染,否则瓦片选择会和用户眼前看到的视口错开。

图里的 DevEco Studio 我故意把几个信息放在一起:左边是tiles/manifest.json和瓦片资源,中间是loadTiledGSNode()与setCamera(),右侧是正在重建的 GaussRoom 页面,底部 HiLog 则记录requested=44、ready=36、total=52。它们不是四张拼图,而是一次真实调试时应该同时观察的四个区域。

四、不要把“分块”理解成简单的文件切片

一开始我也容易把 Tiled 3DGS 理解成“模型大了,拆成几十个文件就行”。真正接入后才意识到,工程层关注的是空间组织、视口、LOD 和加载节奏。

官方术语对 Tiled 3D Gaussian Splatting 的描述很直接:完整模型按空间划分为多个瓦片,渲染时按需加载当前视口需要的数据,以降低内存和渲染开销。也就是说,瓦片只是载体,真正让它有价值的是“只加载现在值得加载的那部分”。

这也是为什么相机状态会从 UI 层一路进入渲染层。用户转身、拉近、拉远,看见的空间发生变化;瓦片请求集合跟着变化;LOD 也可能变化。要是把这套链路当成普通分页加载,就会设计出很奇怪的缓存策略——比如按文件序号预加载相邻瓦片,但这些“相邻序号”在空间里未必相邻。

我在 Demo 里没有自己干预底层瓦片选择,而是把可观测数据做出来:当前可见瓦片、已请求瓦片、LOD、相机距离、平均瓦片就绪时间。这样性能出现波动时,先判断“请求是否合理”,再决定要不要优化应用层资源组织,而不是一上来就调线程数。

五、手机上的 68% 页面,其实是给开发者看的状态面板

这张运行图里有几个我刻意保留的字段:

GS-20260930-017是任务 ID;RECONSTRUCTING是当前状态;142 / 208是关键帧数量;room_017.ply是当前任务的输出文件名;68% 是底层回调上来的重建进度。

在正式产品里,普通用户当然不需要看这么多技术字段。但开发阶段最好先把它们显示出来。很多问题只有“同屏”才能看出关系。例如用户说“到 68% 卡住了”,我不再只问“卡了多久”,而会继续看关键帧是否还增长、任务 ID 有没有变化、输出文件是否已经创建、页面是否发生过重建。

等链路稳定之后,这些字段可以移到 Debug 面板或仅在测试包打开。我的经验是,太早把调试信息藏起来,后面每次复现都要重新加日志,反而浪费时间。

六、相机更新要区分“渲染需要”和“日志需要”

相机每一帧都可能发生细微变化。setCamera()负责告诉分块节点使用哪台相机,真正的瓦片选择由能力内部完成;但如果应用自己还要把相机位置、瓦片数量、内存等信息写入日志,就不能每帧都打印一大串字符串。

我最后把“相机驱动渲染”和“相机驱动诊断”拆成两条节奏。渲染保持正常更新,诊断采样则控制在一个较低频率,只在相机位移或朝向变化达到阈值时记录一次。

这段代码解决什么问题:给分块渲染增加轻量诊断采样,而不是让 HiLog 反过来拖慢渲染。

// entry/src/main/ets/common/RenderTelemetry.etsexportinterfaceRenderMetrics{visibleTiles:numberrequestedTiles:numberlod:numbermemoryMB:numbercameraDistance:numberaverageTileReadyMs:number}exportclassRenderTelemetry{privatelastReportAt:number=0report(taskId:string,metrics:RenderMetrics):void{constnow=Date.now()if(now-this.lastReportAt<1000){return}this.lastReportAt=nowconsole.info(`[GaussRoom] task=${taskId}`+`visible=${metrics.visibleTiles}`+`requested=${metrics.requestedTiles}`+`lod=${metrics.lod}`+`memory=${metrics.memoryMB}MB`+`camera=${metrics.cameraDistance.toFixed(1)}m`+`tileReady=${metrics.averageTileReadyMs}ms`)}}

这段代码没有控制TiledGSNode的内部调度,它只负责应用自己的可观测性。两者要分清:官方能力决定“怎么渲染”,业务诊断决定“我怎么知道它现在发生了什么”。

七、36 / 52 比“画面有点卡”有用得多

这张诊断页对应的是同一个任务GS-20260930-017,但状态已经进入TILED_RENDERING。我保留的快照是:可见瓦片36 / 52、已请求 44、LOD 2、相机距离 3.8 m、内存占用约 1.42 GB、平均瓦片加载 28 ms。

再强调一次,这不是 Spatial Recon Kit 的官方基准,只是我这次 Demo 的观测值。它的意义在于建立排查顺序。

如果用户旋转视角后画面出现空洞,我会先看请求瓦片有没有增加;请求增加但 ready 长时间不变,再看 IO 或模型资源;ready 正常但画面仍缺失,就检查瓦片内容、相机和场景变换。如果内存持续上涨,才进一步判断是瓦片缓存、业务对象还是其他资源没有释放。

这种排查和传统“掉帧了就开 Profiler”不冲突,但顺序更合理。3D 场景的问题往往有业务语义:视口、LOD、瓦片、场景节点。先把这些语义数据补齐,再进性能工具,定位速度会快很多。

八、我给异常链路留了四种出口

三维重建和普通页面最大的区别,是它非常依赖设备能力、输入质量和长任务稳定性。因此我没有设计一个万能的catch,而是把错误按处理方式拆开。

第一类是设备不支持。官方 Spatial Recon Kit 专题材料明确提醒要对不支持设备做好判断和降级,真机能力也要以当前文档和设备要求为准。遇到这类错误,页面不要给“重试”按钮骗用户,应该直接说明当前设备不可执行此任务,并提供替代流程。

第二类是输入不足。例如关键帧不足、相机运动覆盖不够。这时保留已采集数据比直接清空更重要,让用户能补拍,而不是重新走一圈。

第三类是模型加载失败。uri为空、manifest 不存在、资源不完整,都应该在进入渲染页之前被发现。我的页面只有在READY状态且模型资源校验通过后才允许进入预览。

第四类是运行期渲染异常。某个瓦片失败时不要立即把整场景判死,可以记录瓦片 ID 和错误,并根据能力实际行为决定重试或提示。应用层至少要做到:错误属于哪个任务、哪个阶段、哪个资源,日志里能看出来。

九、真机调试比“模拟器里能跑”更重要

Spatial Recon Kit 涉及真实设备图形和重建能力。官方专题材料目前明确提示,空间重建相关验证需要使用满足要求的真机或远程真机环境,不能把模拟器截图当作最终验证依据。具体支持范围、设备芯片要求和版本限制仍应以你开发时的最新官方文档为准,因为这类能力迭代很快。

所以我把验证拆成两层:页面状态、任务模型、日志格式可以在普通工程环境里先完成;真正的重建、模型生成、分块渲染性能和设备兼容性,再上真机确认。

这能避免一个常见误区:因为真机环境准备麻烦,就把所有逻辑都拖到最后一起测。结果一旦出错,你很难知道是 UI 状态机、桥接层、模型资源还是设备能力的问题。

十、任务生命周期、页面生命周期和模型生命周期必须分开

做到后面我又发现一个很容易被忽略的问题:虽然我已经把任务从页面里拆出来,但如果模型资源仍然跟着页面创建和销毁,切一次前后台、旋转一次页面结构,还是可能出现重复加载。于是我又把生命周期拆成了三套。

页面生命周期最短。它负责订阅ReconTaskStore,展示进度和诊断数据;页面消失时只取消订阅,不自动把任务杀掉。任务生命周期稍长,从begin()开始,到成功、失败或用户明确取消结束。模型生命周期则取决于预览需求,任务已经重建完成以后,模型可能还要被用户反复查看,所以不能因为重建任务结束就立刻释放。

这三个生命周期如果绑成一条线,最典型的现象就是“返回列表再进详情,模型又重新加载一次”。如果设备内存比较紧,第二次加载甚至还没等第一次完全释放,就出现瞬时内存峰值。我的做法是给模型仓库维护modelKey和引用计数:同一个room_017已经存在就复用,最后一个预览页面离开后再进入可释放状态。真正释放之前还要确认没有导出、截图或分享任务在引用它。

这里我没有把缓存策略写成某个固定数字,因为不同模型差异太大。小型物体扫描和整间客厅的资源占用完全不是一个量级。工程上更重要的是先定义清楚“谁拥有资源”“谁有权释放资源”,再根据真机数据确定缓存上限。

还有一个细节是失败任务的资源。FAILED不等于所有东西都应该立刻删除。如果失败发生在 80% 以后,前面已经采集的关键帧、错误码、设备信息和最后一次进度快照都很有价值。我会保留一个很小的诊断摘要,让用户可以重新开始,同时开发者还能知道上一轮为什么失败。

十一、性能回归不要只看平均帧率

3DGS 场景接入完成以后,我给自己加了一份很朴素的回归表。每次改瓦片资源、相机逻辑或 UI,我都用同一条测试路径:打开room_017,从默认视角旋转 90 度,前进到 3.8 m,再退回初始位置。这样不同版本的日志可以横向比较。

我关注的也不只有 FPS。至少还会记录首批瓦片可见时间、一次视角变化后新请求的瓦片数量、ready/requested的收敛速度、内存峰值,以及页面退出后内存能否回落。平均帧率看起来很漂亮,但如果每次转身都要等两秒模型才补齐,用户依然会觉得卡;反过来,短时帧率波动如果发生在后台预取阶段,实际感知可能并不明显。

我还会故意做几次“不友好操作”:快速拖动视角、连续进出预览页、重建完成后立即打开模型、应用切后台再回来。稳定的空间应用不能只在一条温柔的 Demo 路径里表现正常。

等这些指标稳定后,才适合去做更激进的优化,比如调整资源组织、压缩包策略、预加载范围或诊断采样频率。否则很容易出现一种错觉:某次改动让单个指标变好了,却把另一个阶段的等待时间转移到了用户更敏感的位置。

十二、这次最大的收获不是“3D 更炫”

做完 GaussRoom 之后,我对 Spatial Recon Kit 的理解反而更朴素了。3DGS 当然很吸引人,点云逐渐变成完整场景的过程也很有视觉冲击,但一个真正能交付的空间应用,核心仍然是工程治理。

任务什么时候开始、什么时候算结束;68% 从哪里来;用户退出后任务怎么办;模型准备好之前能不能点预览;大场景为什么要分块;相机和瓦片之间是什么关系;某次掉帧到底是内存、IO 还是视口请求异常——这些问题如果没有答案,模型再漂亮也只适合做演示。

我现在更愿意把 3DGS 的接入顺序写成一句话:先把任务变成可观察的状态,再把模型变成可控制的资源,最后才谈视觉效果。

当状态、日志、资源和渲染指标都能对上,同样一个 68%,就不再是进度条上的数字,而是一次可以被解释、被复现、也可以被优化的工程过程。

参考资料

  • HarmonyOS 7 新能力一览:https://developer.huawei.com/consumer/cn/features/
  • Spatial Recon Kit / spatialRender API:https://developer.huawei.com/consumer/en/doc/harmonyos-references/spatial-recon-spatialrender
  • Spatial Recon Kit 术语:https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/spatial-recon-glossary
  • 3DGS 端侧重建相关官方专题与文档入口:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询