分块 3DGS 最容易让人误判的时刻,是场景已经显示出来了。模型有轮廓,相机也能转,似乎剩下的只是把网络请求写完。真正开始追踪 tile 请求后,问题才变得具体:相机轻轻回摆,同一块数据可能再次进入回调;下载任务跑得比存储慢,队列会持续拉长;页面已经离开,较晚返回的任务仍在改状态。
这篇文章用一个演示工程TileWatch把这些边界摊开。页面名是TiledScenePage,任务编号gs_20261001_04,清单文件为files/gs/courtyard/courtyard.scene.json,主相机叫GSMainCamera。演示页固定显示:请求 36 个 tile,已就绪 24 个,下载中 3 个,队列 9 个,总进度 67%,当前观察项L2_014.sog。这些数据用于说明调度关系,不是设备跑分或真实项目实测。
一、tile 回调不是下载按钮,而是渲染侧的需求信号
官方接口提供TiledGSNode,应用加载分块模型清单后,可以设置驱动 tile 选择的相机,并通过 tile 请求回调取得当前需要的GSTile。GSTile的核心信息是.sog文件 URI。这个回调表达的是“渲染器现在需要哪些块”,而不是“请无条件创建一批永不取消的下载任务”。
两者差别很大。相机移动时,可见范围和细节层级会变化,同一 URI 可能在不同批次出现。若每次回调都直接Promise.all(),同一 tile 会被重复下载;如果回调密集到达,瞬时并发会由相机动作决定,而不是由设备与网络能力决定。
TileWatch因此把链路拆成四层:SpatialReconKit 只产生请求;TileRequestCoordinator做 URI 去重与状态转换;下载器负责把远端内容写入清单约定的位置;ArkUI 只显示调度快照。这样出现卡顿时,可以先看是渲染请求多、队列积压、下载失败,还是 UI 更新过密,而不是把所有问题都叫作“3DGS 加载慢”。
二、先让 Scene、插件、相机与分块节点形成闭环
这段代码解决什么问题:加载 3DGS 渲染插件、场景、相机与分块节点,并把 tile 选择明确绑定到同一台相机。
import{Scene,RenderContext,Camera,Node}from'@kit.ArkGraphics3D';import{spatialRender}from'@kit.SpatialReconKit';privateasyncopenScene():Promise<void>{constcontext:RenderContext|null=Scene.getDefaultRenderContext();if(context===null){thrownewError('default RenderContext unavailable');}context.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);constscene:Scene=awaitScene.load();if(!scene.root){thrownewError('scene root unavailable');}constroot=scene.rootasNode;constfactory=scene.getResourceFactory();constcamera:Camera=awaitfactory.createCamera({name:'GSMainCamera',path:root.path});camera.enabled=true;constmanifest='file:///data/storage/el2/base/files/gs/'+'courtyard/courtyard.scene.json';constnode=awaitspatialRender.GSPlugin.loadTiledGSNode(scene,{uri:manifest},root);node.setCamera(camera);this.bindTileRequests(node);}这里没有把Scene.load()、相机创建与节点加载塞进一串没有中间检查的then()。默认渲染上下文为空、场景根节点不可用、清单路径为空,都应该在靠近发生点的位置中止。否则错误会在后面的setCamera()或回调注册处表现出来,日志看起来像 tile 调度失败,根因却在更前面。
TiledGSImportSettings的 URI 指向清单 JSON。文中的路径是 Demo 约定,并不代表平台要求固定目录。官方资料说明分块导入能力和GSTile属于较新的接口,项目必须按目标设备、Stage 模型限制及实际 SDK 声明确认可用性;不能只因为编辑器能补全类型,就推断所有设备都支持。
还有一个容易遗漏的关系:tile 选择由相机驱动。应用切换观察相机时,必须同步更新TiledGSNode使用的相机。如果 UI 显示的是 B 相机,节点仍由 A 相机驱动,队列会请求用户看不到的区域,画面与网络日志看起来像彼此矛盾。
三、用四个集合说明一个 URI 当前到底在哪
请求协调器不需要理解 3DGS 数学,它只维护 URI 的所有权。ready表示本地已经可用,queued表示等待下载,inflight表示正在执行,failed保存可重试项。一个 URI 在同一时刻只能属于一个主要状态。
这段代码解决什么问题:对回调里的 URI 去重,并把新请求压入有上限的队列,而不是直接启动无限并发。
typeTileState='QUEUED'|'DOWNLOADING'|'READY'|'FAILED';classTileRequestCoordinator{privateready:Set<string>=newSet();privatequeued:Set<string>=newSet();privateinflight:Set<string>=newSet();privatequeue:string[]=[];privatedisposed:boolean=false;privatereadonlymaxConcurrent:number=3;accept(tiles:spatialRender.GSTile[]):void{if(this.disposed)return;for(consttileoftiles){consturi=tile.uri;if(!uri||this.ready.has(uri)||this.queued.has(uri)||this.inflight.has(uri)){continue;}this.queued.add(uri);this.queue.push(uri);}this.pump();}privatepump():void{while(!this.disposed&&this.inflight.size<this.maxConcurrent&&this.queue.length>0){consturi=this.queue.shift()!;this.queued.delete(uri);this.inflight.add(uri);voidthis.downloadOne(uri);}}}并发上限固定为 3,只是 Demo 的调度参数,不是 SpatialReconKit 推荐值。真实项目需要结合 tile 大小、网络、写入速度、内存与渲染帧稳定性测量。这里的关键不是数字 3,而是并发权归协调器所有。相机回调可以一次带来 2 个或 20 个 URI,都不会把同时执行数推到上限之外。
集合比一个Map<string, boolean>更啰嗦,却更容易查错。queued与数组队列保持一致,inflight可以直接生成诊断页,ready用于抑制回摆时的重复请求。实际实现还应处理本地文件已经存在但记录丢失的情况:启动时扫描缓存、验证文件后再恢复ready,不能把“文件名存在”直接当成完整内容可用。
图中的 DevEco Studio 是演示配图。左侧工程目录、中间协调器代码、右侧模拟器和底部 HiLog 使用同一组数据:task=gs_20261001_04 state=STREAMING ready=24 inflight=3 queued=9。红圈只标并发上限和去重集合,它不构成真机性能证据。
四、完成、失败与重试必须触发同一个泵
如果只在accept()末尾调用pump(),首批三个任务结束后,队列不会继续推进。下载完成和失败都必须归还并发槽位,再触发下一轮调度。失败项还要决定立即重试、延迟重试还是等待相机再次请求。
这段代码解决什么问题:让每个下载任务在成功或失败后归还槽位,并用有限重试避免队列被单个坏 tile 卡住。
privateretryCount:Map<string,number>=newMap();privateasyncdownloadOne(uri:string):Promise<void>{try{awaitthis.downloader.fetchAndPersist(uri);if(this.disposed)return;this.inflight.delete(uri);this.ready.add(uri);this.onSnapshot?.(this.snapshot());}catch(error){this.inflight.delete(uri);if(!this.disposed){constcount=(this.retryCount.get(uri)??0)+1;this.retryCount.set(uri,count);if(count<=2){constdelayMs=count===1?420:1200;setTimeout(()=>this.requeue(uri),delayMs);}else{this.onFailed?.(uri,error);}}}finally{this.pump();}}L2_009.sog在演示里第一次失败,420 ms 后回到队列。这个退避时间同样是示例参数,不应被写成平台结论。有限重试的意义是给瞬时网络波动机会,同时阻止永久失败项占满所有槽位。第二次仍失败时进入FAILED,诊断页保留 URI、次数与阶段,用户移动回该区域时再决定是否重新触发。
注意finally里仍然调用pump()。即使状态快照回调抛出异常,也不应该永久冻结后续队列。生产代码还应隔离观察者错误,避免 UI 日志函数影响下载调度。下载器的文件写入应采用临时文件与校验后发布,避免渲染器读到正在增长的.sog。
11:18 的运行页显示STREAMING、67%、24/36、下载中 3、队列 9,并把当前 tileL2_014.sog圈出。这里的 67% 是按ready / requested四舍五入后的演示口径;它描述请求集合完成度,不代表整个场景的几何质量或视觉完整度。
五、相机切换时不清空 ready,只重估尚未开始的请求
相机从庭院入口切到屋顶观察位时,已有 tile 缓存仍然有效,直接清空ready会产生重复网络开销。真正需要重新判断的是尚未开始的队列:旧视角排队的远处 tile 可能已经不再紧急,新视角中心区域应该获得更高优先级。
简单版本可以在每次回调到达时,把新 URI 放到队首;更完整的版本需要给请求附带层级、屏幕贡献或距离信息,再由调度器排序。本文没有假设GSTile暴露这些额外字段,因此只用 URI 做去重,不编造优先级 API。若需要更精细的顺序,应以当前接口提供的信息和业务清单元数据为准。
TileWatch的相机切换流程是:先让 UI 激活GSMainCamera或备用相机,再调用节点的setCamera(),增加一次cameraEpoch,最后对后续状态快照附带 epoch。旧下载可以继续落盘,但较晚返回的 UI 快照若 epoch 不匹配,就不更新“当前视角请求”计数。
这与取消下载不是一回事。已经进行到一半的大 tile 是否中断,需要考虑浪费的流量和很快切回视角的可能性。演示选择“下载继续、UI 按 epoch 过滤、未开始队列可重新排序”,这是相对克制的策略。
六、页面离开后,最先停止的是状态写入
这段代码解决什么问题:页面销毁或场景切换时使协调器失效,拒绝晚到回调继续更新 ArkUI。
classTileRequestCoordinator{// 前文成员省略dispose():void{this.disposed=true;this.queue.length=0;this.queued.clear();this.onSnapshot=undefined;this.onFailed=undefined;}}aboutToDisappear():void{this.viewEpoch++;this.coordinator?.dispose();this.coordinator=undefined;this.pageState='IDLE';}这段代码只声明了协调器自己的边界:清空待启动任务、断开 UI 观察者、让完成回调不再发布状态。它没有声称取消底层网络,也没有假造一个 SpatialReconKit 销毁接口。正在执行的下载是否可取消,要由下载实现提供信号;场景、相机和节点的资源释放,则应遵循实际承载组件与当前 ArkGraphics 3D 对象的生命周期。
若页面频繁进入退出,最危险的是把旧TiledGSNode、旧相机和新协调器混在一起。建议让一次场景会话拥有独立 epoch:创建场景时生成,所有异步结果都带回该值;值不匹配就只做资源收尾,不碰当前页面。
诊断页与运行页不同。它展示REQUESTED → QUEUED → DOWNLOADING → READY,列出L2_009.sog的 420 ms 重试以及 3 个并发槽位,并在生命周期末尾标出“观察者已断开”。红色标注解释背压和晚到回调,不是为了装饰完成状态。
七、用日志回答“慢在哪里”,而不是只记录百分比
单一进度数字无法定位问题。建议至少记录五类事件:tile 批次到达、去重后新增数量、队列等待长度、下载耗时与落盘结果、相机 epoch 变化。每条日志都带任务 ID,但 URI 可以只保留文件名或哈希,避免把完整远端地址写入日志。
当queued持续增长、inflight稳定为 3,说明下载或落盘吞吐跟不上请求;当回调批次很大但去重后新增接近 0,说明相机抖动或重复需求被缓存吸收;当ready增长而画面不变化,需要检查相机绑定、文件发布位置和渲染侧读取,而不是继续提高并发。
还要把 UI 刷新节流。每个 tile 完成就触发一整页重绘,在快速网络下反而会制造主线程压力。协调器可以高频维护内部集合,但以 100~250 ms 合并一次快照给 ArkUI。诊断页需要精确事件时,从环形缓冲读取,不要让主页面承担完整日志列表。
八、这条链路目前能证明什么
本文能证明的是一种工程分层:官方 3DGS 分块节点与相机负责产生需求,应用调度层控制重复、并发、重试和页面状态,文件层保证 tile 完整发布。它不能证明并发 3 最优,也不能证明 67% 时画面达到某个质量,更没有声称在特定机型完成性能测试。
接入真实项目时,还需要验证清单与.sog文件关系、远端缓存策略、失败码、网络切换、前后台行为、内存峰值和多相机切换。尤其是较新的接口,要以目标 SDK 的 API 声明、系统能力和设备支持范围为准。
真正值得保留的结论只有三个:回调是需求信号,不是无限并发指令;URI 状态要有唯一所有者;页面生命周期与下载生命周期必须分开。把这三件事做清楚,3DGS 的“偶尔糊、偶尔卡、偶尔重复下载”才会变成可观察、可调节的工程问题。
九、缓存不是越多越好,淘汰也要服从场景会话
分块模型一旦能稳定下载,下一个问题通常是缓存。最直接的实现是所有.sog永久保留,短期看命中率很好,长期却会让应用目录不可控。另一种极端是页面退出立即删除,本次会话刚结束,用户返回就要重新拉取,同样不合理。
比较稳妥的做法是把缓存分成“会话热集”和“可复用冷集”。当前相机附近、正在下载和刚完成的 tile 属于热集,不能被清理线程碰;其他已校验文件进入冷集,根据总大小、最后访问时间和模型版本淘汰。清单版本变化时,不要只凭同名 URI 判断可复用,还要核对清单标识、文件长度或内容摘要。
缓存索引本身也可能损坏。启动时应允许从磁盘文件重建基础索引,而不是因为一份 JSON 解析失败就删除全部模型。重建过程先把文件标为“待验证”,通过长度与摘要后才进入ready。若只看文件存在,之前异常中断留下的半成品会被误当成命中,最终表现为渲染缺块,却没有任何网络错误。
并发下载与清理必须共享所有权信息。某个 URI 在inflight时,清理器不能根据旧访问时间删除它的临时文件;清理开始后,也不能让新的下载立即写入同一路径。可以为每个模型目录设置轻量会话锁,或让所有磁盘操作通过同一个仓储对象串行决策。这里不要求所有 I/O 真正串行,而是要求“能不能删、能不能发布”只有一个判断者。
存储空间不足时,调度器不应继续接受几十个新请求再一起失败。仓储层返回空间压力后,协调器可以暂停pump(),先淘汰冷集,再恢复队列;若无法释放足够空间,页面进入PAUSED_STORAGE,保留相机与已就绪 tile,让用户至少能查看当前质量。把这类错误归入普通网络重试,只会反复写盘和清理。
十、验收要故意制造相机抖动与页面离开
顺着正常路径转一圈,看见模型逐渐清晰,只能证明最乐观的情况。调度器真正的验收应该围绕边界动作设计。
第一组用例是重复请求。同一批 URI 连续调用accept()三次,队列长度只能增加一次;其中一个 URI 已进入inflight后再次出现,也不能生成第二个下载。完成后再请求同一 URI,应由ready直接吸收。报告记录三次输入数量与实际新增数量,便于发现集合状态失配。
第二组是并发背压。一次放入 50 个不同 URI,观察任何时刻inflight.size都不超过 3。让前两个任务成功、第三个失败,确认三个槽位都能归还,后续队列继续前进。尤其要测试观察者抛错,避免 UI 代码阻断finally里的泵。
第三组是相机快速切换。A、B 相机交替五次,旧 epoch 的状态快照不得覆盖当前页面;已经完成的 tile 仍保留在缓存,新回调只增加尚未出现的 URI。若队列支持重新排序,还要确认重排没有同时把 URI 留在旧位置,导致同一项执行两次。
第四组是页面离开。三个下载进行中时触发aboutToDisappear(),页面状态立刻回到IDLE,后续完成回调不再改 ArkUI。若下载实现支持取消,检查临时文件被关闭并按策略删除;若不支持,允许任务完成落盘,但不能复活旧页面。
第五组是坏文件。下载器返回成功,但长度或摘要不符,URI 不能进入ready;临时文件不能改成最终名;诊断页应显示“校验失败”而不是笼统的FAILED。下一次重试要从新临时文件开始,不能续写损坏内容。
第六组是空间压力。预留空间不足时暂停调度,冷缓存淘汰完成后恢复;无法恢复则稳定停在可解释状态。此时画面仍可使用已有 tile,不应该因为后台存储错误把整个 Scene 立即销毁。
这些用例大部分可以在协调器和仓储层用可控下载器完成,不需要每次依赖真实 3DGS 服务。真机阶段再集中验证回调批次、相机驱动、插件加载、文件落盘与渲染读取之间的实际关系。这样测试失败时更容易判断是平台接入、调度逻辑还是数据文件问题。
十一、从一次诊断快照反推优化顺序
假设诊断页长期显示ready=24、inflight=3、queued=9,不能立刻得出“把并发改成 6”。先看三个进行中任务的阶段:如果主要时间花在网络等待,提高并发可能有效;如果都卡在写盘或摘要计算,提高并发会让 I/O 竞争更严重;如果下载很快但渲染仍缺块,瓶颈可能根本不在下载器。
再看队列年龄。9 个等待项都刚刚进入,属于相机移动的正常波动;若最老项等待数秒仍未开始,才说明吞吐跟不上。队列只显示长度而不显示最老等待时间,会把短峰值与持续积压混为一谈。
还要看去重率。请求 36 个、实际新增 12 个,说明缓存和集合吸收了大量重复需求;请求 36 个、实际新增也是 36 个,则要确认是否换了模型、缓存键是否包含了不稳定参数,或相机进入了全新区域。去重率高不是坏事,它说明回调在表达动态需求,而协调层没有把动态性放大成网络风暴。
最后看帧体验。下载完成数增长只是后台指标,用户在意的是相机操作是否连贯、中心区域是否先清晰、页面退出是否迅速。优化顺序应由可感知问题决定:先阻止主线程更新过密,再处理存储争用,然后根据网络与 tile 大小调整并发。只盯着总耗时,很容易用更高资源消耗换来一个并不明显的数字改善。
这些指标必须放在同一次场景会话里解释,跨模型比较时还要注明清单规模、缓存状态与网络条件,避免把不可比的快照放到同一条趋势线上。
十二、参考资料
- 华为开发者文档:3DGS 空间重建流程
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline - 华为开发者 API:SpatialReconKit 空间渲染
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/spatialrender-api - 华为开发者 API:ArkGraphics 3D
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkgraphics3d-api