HarmonyOS API 26 分块 3DGS 实战:相机驱动、瓦片回调与并发去重
大规模 3DGS 场景与单文件模型的工程问题并不相同:首屏不应等待全部数据到齐,相机移动会不断改变需要的空间区域,同一瓦片还可能被重复请求。如果仍把它当成“下载完成后一次性加载”,内存、带宽和交互延迟都会变得难以控制。
HarmonyOS API 26 为 Spatial Recon Kit 增加了TiledGSNode。官方 API 给出的核心链路非常明确:通过loadTiledGSNode()加载分块场景清单,用setCamera()指定驱动分块选择的相机,通过setTileRequestCallback()接收渲染器当前需要的瓦片,数据落盘后再调用notifyTileReady()。本文在这个官方边界上,补齐去重、限流、失败重试与页面状态设计。
1. 先划清版本边界:普通 3DGS 不等于分块 3DGS
GSNode与TiledGSNode不应混用。普通GSNode和loadGSNode()从 HarmonyOS 6.0.1(API 21)开始提供;表示大规模分块场景的TiledGSNode、loadTiledGSNode()、setCamera()、瓦片请求回调等能力,从 API 26 开始提供。
这一区分会直接影响工程的最低 SDK、兼容策略与测试设备。若项目需要兼容 API 21~25,可以保留普通 3DGS 路径;只有运行环境满足 API 26 时才进入分块加载路径。本文示例以 Stage 模型和SystemCapability.Graphics.SpatialRender为前提,不把 API 26 接口包装成旧版本也可调用的能力。
| 对比项 | 普通 3DGS | 分块 3DGS |
|---|---|---|
| 核心节点 | GSNode | TiledGSNode |
| 加载入口 | loadGSNode() | loadTiledGSNode() |
| 起始版本 | API 21 | API 26 |
| 数据入口 | 模型 URI,可带 offset | 描述瓦片层级的 JSON 清单 URI |
| 调度依据 | 一次加载模型 | 相机驱动当前所需瓦片 |
| 应用责任 | 提供可访问模型 | 获取、保存瓦片并通知就绪 |
2. 分块加载的真实数据流
分块 3DGS 的关键并不是多调用一个加载函数,而是建立“渲染器提出需求,应用准备数据,应用回告就绪”的闭环。相机位置变化后,渲染器计算当前视口需要哪些瓦片,并将GSTile[]交给应用;应用完成数据准备后,对对应瓦片调用notifyTileReady()。
从职责上看,渲染层只决定“需要什么”,业务层负责“如何取得”。瓦片可以来自已落盘缓存,也可以来自项目自己的远端数据服务。官方 API 没有替应用规定网络库、鉴权协议和缓存目录,因此这些内容应放在独立仓储中,而不是塞进页面或回调闭包。
3. 初始化顺序:插件、场景、相机、节点缺一不可
调用GSPlugin接口前,需要先通过默认渲染上下文加载GSPlugin.PLUGIN_ID。之后加载Scene、创建并启用Camera,再根据分块场景清单创建TiledGSNode。清单的uri不能为空;官方示例使用应用文件目录下的.scene.json文件。
import { Camera, Node, RenderContext, Scene } from '@kit.ArkGraphics3D' import { spatialRender } from '@kit.SpatialReconKit' export interface TiledSceneHandle { scene: Scene camera: Camera node: spatialRender.TiledGSNode } export async function createTiledScene(manifestUri: string): Promise<TiledSceneHandle> { if (!manifestUri.trim()) { throw new Error('分块场景清单 URI 不能为空') } const renderContext: RenderContext | null = Scene.getDefaultRenderContext() if (renderContext === null) { throw new Error('当前环境没有可用的 ArkGraphics 3D 渲染上下文') } renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID) const scene: Scene = await Scene.load() if (!scene.root) { throw new Error('场景根节点创建失败') } const root: Node = scene.root as Node const camera: Camera = await scene.getResourceFactory().createCamera({ name: 'TiledGSCamera', path: root.path }) camera.nearPlane = 0.5 camera.farPlane = 10000.0 camera.fov = 60.0 * Math.PI / 180.0 camera.enabled = true const settings: spatialRender.TiledGSImportSettings = { uri: manifestUri } const node: spatialRender.TiledGSNode = await spatialRender.GSPlugin.loadTiledGSNode(scene, settings, root) node.setCamera(camera) node.visible = true return { scene, camera, node } }这里故意把每个失败点转换为可理解的错误,而不是用空分支直接返回。页面只有知道失败发生在渲染上下文、场景根节点还是清单加载阶段,才能给出有效的重试提示。
4. 不要在瓦片回调里直接堆网络请求
相机连续移动时,回调可能在短时间内多次产生请求。如果在回调中直接遍历并启动下载,会遇到三个典型问题:同一 URI 重复下载、瞬时并发过高、旧视口请求挤占新视口资源。
更稳妥的结构是:回调只负责入队;调度器负责去重和并发;仓储负责缓存检查、下载与原子落盘;成功后再通知节点。这样既保持 ArkUI 页面轻量,也便于分别测试。
export interface TileRepository { prepare(tile: spatialRender.GSTile): Promise<void> } type TileJobState = 'queued' | 'loading' | 'ready' | 'failed' interface TileJob { tile: spatialRender.GSTile state: TileJobState attempts: number }prepare()的契约很重要:Promise 成功只能表示数据已经写入清单约定的相对路径,并且渲染器此时能够读取;不能只代表 HTTP 响应成功或临时缓冲区已经拿到字节。
5. 用 URI 做去重,用固定并发保护首帧
GSTile提供瓦片 URI。它适合作为调度键,但生产项目还应结合场景版本或清单版本,避免不同场景恰好使用相同相对 URI 时发生缓存串用。
export class TileScheduler { private readonly jobs: Map<string, TileJob> = new Map() private readonly waiting: string[] = [] private running: number = 0 constructor( private readonly node: spatialRender.TiledGSNode, private readonly repository: TileRepository, private readonly maxConcurrent: number = 4 ) {} enqueue(tiles: spatialRender.GSTile[]): void { for (const tile of tiles) { const key: string = tile.uri const old: TileJob | undefined = this.jobs.get(key) if (old && (old.state === 'queued' || old.state === 'loading' || old.state === 'ready')) { continue } this.jobs.set(key, { tile, state: 'queued', attempts: 0 }) this.waiting.push(key) } this.pump() } private pump(): void { while (this.running < this.maxConcurrent && this.waiting.length > 0) { const key: string | undefined = this.waiting.shift() if (!key) break const job: TileJob | undefined = this.jobs.get(key) if (!job || job.state !== 'queued') continue this.running += 1 job.state = 'loading' job.attempts += 1 this.execute(key, job) } } private async execute(key: string, job: TileJob): Promise<void> { try { await this.repository.prepare(job.tile) job.state = 'ready' this.node.notifyTileReady(job.tile) } catch (error) { job.state = 'failed' console.error(`瓦片准备失败: ${key}`, error) } finally { this.running -= 1 this.pump() } } }并发数4只是清晰的工程起点,不是官方推荐值。最终值应结合设备、网络、单瓦片大小、解码和落盘耗时实测。最重要的是让它成为可观察、可调整的参数,而不是让请求无限展开。
6. 正确注册回调:空数组不是异常
节点、相机和调度器准备完成后,再注册回调。回调可能收到空数组,直接忽略即可;不要因此把整个页面切换为错误态。
export function bindTileRequests( node: spatialRender.TiledGSNode, scheduler: TileScheduler ): void { node.setTileRequestCallback((tiles: spatialRender.GSTile[]): void => { if (!tiles || tiles.length === 0) { return } scheduler.enqueue(tiles) }) }官方语义是“数据准备完成后通知渲染器”。因此notifyTileReady()必须放在仓储成功之后。若下载失败仍通知就绪,渲染层随后读取不到文件,问题会从清晰的网络错误变成难定位的渲染缺块。
7. 重试要有上限,还要允许新视口优先
固定失败的 URI、权限问题或磁盘空间不足,不会因为无限重试自行恢复。调度器应限制尝试次数,并记录最后错误。若业务确实需要自动重试,可以采用有限次数与退避时间;用户点击“重试失败瓦片”时,再把failed任务放回队列。
public retryFailed(maxAttempts: number = 3): void { for (const [key, job] of this.jobs) { if (job.state !== 'failed' || job.attempts >= maxAttempts) continue job.state = 'queued' this.waiting.push(key) } this.pump() }进一步优化时,可以给任务增加视口世代号:每次相机发生显著变化便生成新世代,新世代任务排在队首,尚未开始的旧世代任务降级。是否取消正在执行的下载,则取决于项目使用的网络能力是否提供安全取消接口,不应在通用示例中假设。
8. 页面状态要区分“场景可用”和“所有瓦片完成”
分块渲染本来就是渐进过程。页面若把“还有瓦片加载中”等同于全屏 Loading,用户移动相机时界面会持续闪烁。建议至少区分以下状态:
initializing:插件、场景、相机和清单尚未完成初始化;interactive:场景已经可交互,局部瓦片仍可在后台补齐;degraded:部分瓦片失败,但已有区域仍能浏览;fatal:渲染上下文、场景或清单无法建立,核心体验不可用。
状态栏可以显示排队数、加载数和失败数,但不要让它遮挡 3D 内容。失败列表应允许用户重试,并展示经过脱敏的 URI 或区域标识,避免输出鉴权参数。
9. 缓存落盘的四条底线
第一,临时文件与最终文件分离。下载到临时路径,校验成功后再原子替换,避免半文件被渲染器读取。
第二,缓存键包含场景版本。只用tile.uri可能造成跨版本污染;可将清单版本、内容摘要或服务端资源版本纳入目录。
第三,设置容量上限与淘汰策略。分块降低了运行时加载压力,但若缓存从不清理,磁盘仍会持续增长。
第四,不把访问令牌写进日志或文件名。瓦片服务如果需要鉴权,应由网络层注入,日志只保留可诊断且不敏感的信息。
10. 最小验收清单
仅看到模型首帧并不代表链路完成。至少覆盖以下用例:
- API 26 支持设备能加载有效
.scene.json,并建立可交互场景; - 空 URI、清单不存在和清单格式错误进入明确失败态;
- 快速移动相机时,同一瓦片不会重复并发下载;
- 并发运行数始终不超过设置值;
- 瓦片落盘成功后才调用
notifyTileReady(); - 单个瓦片失败不会让已显示区域整体消失;
- 重试有次数上限,失败信息可定位但不泄露凭据;
- 应用退后台、页面返回和场景销毁时,没有继续向失效节点通知;
- 弱网、断网、磁盘不足与缓存命中分别验证;
- Phone、Tablet、PC/2in1、TV 按项目目标范围做真实设备验证。
11. 常见误区
误区一:把TiledGSNode写成 API 21 能力。普通 3DGS 从 API 21 起提供,但分块节点及其调度接口从 API 26 起提供。
误区二:收到回调就立即通知 ready。回调表示渲染器提出数据需求,不表示数据已经存在;必须在文件可读之后通知。
误区三:每次回调都启动一批无上限请求。相机移动会放大请求规模,需要去重、并发限制和失败治理。
误区四:用页面直接管理文件和网络。页面生命周期与瓦片任务生命周期不同,数据准备应由服务或仓储统一管理。
误区五:只在模拟器判断是否可用。Spatial Recon Kit 的支持范围和设备能力应按官方说明核对,并用目标真机验证;本文未把静态代码审查描述为真机通过。
12. 总结
HarmonyOS API 26 的分块 3DGS 把大场景渲染拆成了一条可控的数据链:清单建立层级,相机决定当前需求,回调把瓦片请求交给应用,应用完成准备后通知渲染器。真正决定体验质量的,是这条链路之外的工程治理——去重、并发限制、缓存原子性、有限重试、生命周期隔离与可观察状态。
如果只记住一个原则,就是:setTileRequestCallback()收到的是“需求”,notifyTileReady()表达的是“数据已可读取”,两者之间必须有一个可靠的数据准备层。
参考资料
- 华为开发者联盟:《spatialRender》API 参考:文档中心
- 华为开发者联盟:《Spatial Recon Kit 术语》:文档中心
- 华为开发者联盟:《OS 新增和增强特性》:文档中心
验证边界:本文依据公开官方文档核对 API 名称、起始版本和调用关系;调度器、仓储接口与状态设计属于工程化示例,未在具体商用瓦片服务、目标真机或发布包中执行。接入时应按项目 SDK、清单格式、数据服务与设备范围补充验证。
CSDN-SERIES:HARMONYOS-SPATIAL-002