SpatialReconKit + ArkGraphics 3D:分块 3DGS 的请求去重、并发背压与相机切换【鸿蒙心迹】
分块 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';
private async openScene(): Promise<void> {
const context: RenderContext | null = Scene.getDefaultRenderContext();
if (context === null) {
throw new Error('default RenderContext unavailable');
}
context.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
const scene: Scene = await Scene.load();
if (!scene.root) {
throw new Error('scene root unavailable');
}
const root = scene.root as Node;
const factory = scene.getResourceFactory();
const camera: Camera = await factory.createCamera({
name: 'GSMainCamera', path: root.path
});
camera.enabled = true;
const manifest = 'file:///data/storage/el2/base/files/gs/' +
'courtyard/courtyard.scene.json';
const node = await spatialRender.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 去重,并把新请求压入有上限的队列,而不是直接启动无限并发。
type TileState = 'QUEUED' | 'DOWNLOADING' | 'READY' | 'FAILED';
class TileRequestCoordinator {
private ready: Set<string> = new Set();
private queued: Set<string> = new Set();
private inflight: Set<string> = new Set();
private queue: string[] = [];
private disposed: boolean = false;
private readonly maxConcurrent: number = 3;
accept(tiles: spatialRender.GSTile[]): void {
if (this.disposed) return;
for (const tile of tiles) {
const uri = 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();
}
private pump(): void {
while (!this.disposed &&
this.inflight.size < this.maxConcurrent &&
this.queue.length > 0) {
const uri = this.queue.shift()!;
this.queued.delete(uri);
this.inflight.add(uri);
void this.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 卡住。
private retryCount: Map<string, number> = new Map();
private async downloadOne(uri: string): Promise<void> {
try {
await this.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) {
const count = (this.retryCount.get(uri) ?? 0) + 1;
this.retryCount.set(uri, count);
if (count <= 2) {
const delayMs = 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,并把当前 tile L2_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。
class TileRequestCoordinator {
// 前文成员省略
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
更多推荐




所有评论(0)