HarmonyOS 7 Spatial Recon Kit + ArkGraphics 3D:Tiled 3DGS 瓦片请求去重与相机快速移动下的回压调度【鸿蒙心迹】
这次我没有继续做“把一个 3DGS 模型加载出来”这种演示,而是盯着一个更像真实产品的问题:模型一旦变大,渲染器开始按视口请求瓦片,用户连续拖动相机时,应用侧怎样避免把网络、磁盘和解码队列一起塞满。
Demo 我叫它 TiledGSFlowLab。运行编号固定为 tiled_run_20261001_02,演示里把最大并发下载限制为 4。快速移动相机经过 B-07 区域时,渲染器一度请求 24 个瓦片,应用侧进入 BACKPRESSURE,最终 22 个瓦片就绪、2 个旧请求被判定为过期,状态重新回到 STABLE。

真正让我觉得这个问题值得单独写一篇的,是 HarmonyOS 7 / API 26 对 Tiled 3DGS 的支持已经把“谁决定要哪些瓦片”这件事交给渲染器:TiledGSNode.setCamera() 绑定驱动瓦片选择的相机,setTileRequestCallback() 把当前所需的 GSTile[] 交给应用,应用把数据准备好以后再调用 notifyTileReady()。接口本身并不替我们做下载队列、重复请求合并和并发上限,这部分正好是工程层最容易失控的地方。
一、我先把问题拆成“渲染需求”和“数据供应”两条线
开始时我写得很直接:回调来了多少 GSTile,就循环下载多少个。小场景完全正常,相机不动时甚至看不出问题。但连续拖动视角以后,日志会出现一个很明显的特征:同一个 URI 在很短时间内重复出现,上一批瓦片还没落盘,下一批又进来了。
这里不能简单理解成“系统重复请求”。Tiled 3DGS 的瓦片选择由相机驱动,相机位置和视锥持续变化,本来就可能让某些瓦片在不同帧中多次成为候选。真正需要控制的是应用自己的供应节奏。
我最后把流程拆成四个状态:
READY → STREAMING → BACKPRESSURE → STABLE
STREAMING 表示应用正常接收瓦片请求;当 inFlight 达到 4 时进入 BACKPRESSURE;队列下降并且当前批次没有积压时,再回到 STABLE。这几个状态不是系统 API,而是 Demo 自己的可观测状态。这样做的好处是,UI、HiLog 和调度器看到的是同一份事实,而不是各自猜“现在是不是卡住了”。
二、第一步不是下载,而是先把 TiledGSNode 建对
这段代码解决的是“瓦片调度器应该挂在哪里”。Spatial Recon Kit 的 3DGS 渲染插件需要先加载,再加载场景和 Tiled 3DGS 节点,最后把相机交给 TiledGSNode。我把回调入口也放在这里,避免页面层直接处理网络与文件。
import { Scene, Camera, Node } from '@kit.ArkGraphics3D'
import { spatialRender } from '@kit.SpatialReconKit'
private async initTiledScene(): Promise<void> {
const renderContext = Scene.getDefaultRenderContext()
if (!renderContext) {
throw new Error('RenderContext unavailable')
}
renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID)
const scene = await Scene.load()
const root = scene.root as Node
const factory = scene.getResourceFactory()
const camera: Camera = await factory.createCamera({
name: 'tiledCamera',
path: root.path
})
camera.enabled = true
const params: spatialRender.TiledGSImportSettings = {
uri: 'file:///data/storage/el2/base/files/courtyard/courtyard.scene.json'
}
this.tiledNode = await spatialRender.GSPlugin.loadTiledGSNode(scene, params, root)
this.tiledNode.setCamera(camera)
this.tiledNode.setTileRequestCallback((tiles: spatialRender.GSTile[]) => {
this.scheduler.enqueue(tiles)
})
}
这里有两个边界我特意保留。第一,loadTiledGSNode() 在当前能力里属于 Stage 模型场景,工程不能只看 ArkTS 代码能不能编译,还要确认应用模型和设备能力。第二,TiledGSImportSettings.uri 指向的是瓦片场景清单,不是随便一个 .gs 文件。清单负责描述瓦片层级,后续回调里的 tile.uri 才是具体资源路径。
页面退出时我会先执行 setTileRequestCallback(null),再让调度器停止接收新任务。这样不是为了“释放插件”,而是避免页面已经离开后,应用层队列还继续写缓存和刷新统计。
三、重复请求不能靠 Set 一挡了之
最早我确实用了一个 Set<string>:URI 出现过就忽略。很快就发现逻辑不对,因为“出现过”和“已经就绪”完全不是一回事。一个瓦片可能正在下载、可能下载失败、也可能上一次属于旧视角被我主动丢弃。
所以真正需要记录的是任务状态。我在 TileRequestScheduler 里维护 pendingMap,Key 是 tile.uri,Value 里放 epoch、状态和原始 GSTile。同一个 URI 如果已经是 DOWNLOADING 或 READY,才合并;如果属于更早的视角世代并且已经被标记过期,则允许下一次重新入队。
下面这段代码解决的是“一个 callback 里有 24 个请求时,不让 24 个请求一起冲出去”。
private readonly maxConcurrent: number = 4
private inFlight: number = 0
private viewEpoch: number = 0
private pendingMap: Map<string, TileJob> = new Map()
private queue: TileJob[] = []
public enqueue(tiles: spatialRender.GSTile[]): void {
for (const tile of tiles) {
const existed = this.pendingMap.get(tile.uri)
if (existed && (existed.state === 'DOWNLOADING' || existed.state === 'READY')) {
this.stats.duplicated++
continue
}
const job: TileJob = {
tile,
uri: tile.uri,
epoch: this.viewEpoch,
state: 'WAITING'
}
this.pendingMap.set(tile.uri, job)
this.queue.push(job)
}
this.stats.requested = this.pendingMap.size
this.pump()
}
private pump(): void {
while (this.inFlight < this.maxConcurrent && this.queue.length > 0) {
const job = this.queue.shift()!
if (job.epoch !== this.viewEpoch) {
this.markStale(job)
continue
}
this.runJob(job)
}
this.state = this.inFlight >= this.maxConcurrent
? FlowState.BACKPRESSURE
: FlowState.STREAMING
}
这里的 viewEpoch 同样是业务层策略,不是 GSTile 自带字段。我的处理方式是:开始一次明显的相机快速移动时让 epoch 加一,旧队列里还没开始执行的任务直接标记为 STALE。已经进入下载的任务我不强制打断,因为不同网络实现的取消语义不一样;下载完成后会再次比较 epoch,确认它是否还值得通知渲染器。
这也是“回压”比“取消一切”更稳的原因。快速拖相机的过程中,最怕的是一边疯狂 cancel,一边重新建请求,最后 CPU 花在调度,网络却没有真正把有效瓦片送回来。
四、notifyTileReady 之前必须再做一次有效性判断
官方接口对 notifyTileReady(tile) 有一个很有用的行为:如果渲染器已经不再需要这个瓦片,例如相机早就移走,通知不会继续产生实际加载动作。即便如此,我还是在应用层做了一次 epoch 判断,因为这样可以少一次无意义的落盘通知,也方便统计“到底是渲染器自然淘汰,还是我的调度器主动丢弃”。
这段代码解决“下载完成不等于当前仍需要”的问题。
private async runJob(job: TileJob): Promise<void> {
this.inFlight++
job.state = 'DOWNLOADING'
try {
await this.tileStore.ensureLocal(job.uri)
if (job.epoch !== this.viewEpoch) {
this.markStale(job)
return
}
job.state = 'READY'
this.stats.ready++
this.tiledNode?.notifyTileReady(job.tile)
} catch (err) {
job.state = 'FAILED'
this.stats.failed++
hilog.error(0x0000, 'TiledGSFlow', `tile failed: ${job.uri}`)
} finally {
this.inFlight--
this.updateFlowState()
this.pump()
}
}
tileStore.ensureLocal() 是我自己的缓存层,职责很单纯:先查本地文件是否可读,命中就直接返回;没命中再通过项目已有的网络层拉取并写入约定路径。它不是 Spatial Recon Kit API,所以正式项目里可以替换成 CDN、局域网传输或者离线资源包。
这段 finally 很关键。无论下载成功、失败还是中途判断为 stale,inFlight 都必须归还。不然只要某个异常分支漏减一次,并发槽就会永久少一个,最终表现成“跑几分钟后越来越慢”,而不是立刻报错。

图里的中间状态就是我刻意制造的压力点:Requested 为 24,InFlight = 4 / 4,Ready 为 18,Stale 为 2,状态为 BACKPRESSURE。这时候真正要看的不是进度条,而是队列有没有继续增长、inFlight 是否能稳定回落,以及旧视角任务有没有被识别出来。
五、相机快速移动时,我不再把每次回调都当成“新任务”
后来我又发现一个细节:只靠 URI 去重还不够。用户从 B-06 快速拖到 B-07,再稍微回拉,可能出现一批旧 URI 再次成为有效瓦片。如果“旧 URI = 永远不请求”,画面会出现局部缺块;如果“每次出现 = 都新建任务”,又会回到最初的风暴。
所以现在我的判断顺序是:
- 本地缓存可读:直接
notifyTileReady(),这类请求几乎不占网络并发; - 同 URI 正在下载:合并等待,不新建第二个任务;
- 同 URI 已就绪:只更新命中统计;
- 同 URI 曾经 stale/failed:当前 epoch 允许重新排队;
- 新 URI:按并发槽进入队列。
Demo 最终的 Cache Hit 是 75%,并不是为了追求一个漂亮数字,而是验证“快速来回拖动视角时,已经落盘的瓦片真的被复用”。如果命中率一直是 0,说明缓存路径或可读性判断有问题;如果命中率很高但画面仍然缺块,就要继续检查 manifest 的相对路径和文件写入时机。
六、页面生命周期里最容易漏的是回调解绑
Tiled 3DGS 看起来像纯渲染问题,真正上线以后却会撞上很普通的页面生命周期。页面 aboutToDisappear() 之后,如果回调还挂着,调度器可能继续收到请求;用户再次进入页面,又注册一个新回调,日志就会出现同一批瓦片被处理两遍。
我没有在页面销毁时做激进的“把所有 Scene 资源立即清空”,而是先收口应用层行为:
aboutToDisappear(): void {
this.tiledNode?.setTileRequestCallback(null)
this.scheduler.stopAccepting()
this.scheduler.clearWaitingJobs()
this.state = FlowState.READY
}
clearWaitingJobs() 只清等待队列,不粗暴终止已经开始的文件写入。正式项目如果网络层支持可靠取消,可以在调度器内部加入 Abort 机制;如果不支持,宁愿让少量正在执行的写入完整结束,也不要留下半文件。下一次进入页面时,缓存层必须先校验文件存在且长度/校验信息有效,再决定是否命中。
七、最后我用三个数字验收,而不是“看起来不卡”
这次运行结束时,页面状态从 BACKPRESSURE 回到 STABLE:Requested 24,Ready 22,Stale 2,InFlight 0/4,Camera Zone 为 B-07,最后一个有效瓦片为 L3/x12_y08.gs。

我最终保留了三个验收指标。
第一个是 峰值并发。无论用户怎么甩动相机,网络层同时执行的瓦片任务不超过 4;第二个是 过期任务比例,它能告诉我相机移动是否频繁制造无效工作;第三个是 缓存命中率,它决定用户回看同一区域时能不能快速恢复细节。
如果只看 FPS,很容易把问题看错。瓦片请求压力大时,渲染线程未必立刻掉帧,真正先恶化的是下载队列、文件 IO 和内存中的临时缓冲。等到这些一起积压,卡顿已经是最后一个表现。
八、这套调度还不能直接照搬到所有项目
当前 Demo 把最大并发写死为 4,只是为了让行为容易观察。正式项目应该至少考虑网络类型、设备性能、单瓦片大小和磁盘写入速度。瓦片很小的时候并发 4 可能太保守,瓦片很大或者还要做额外解码时,并发 4 也可能偏高。
另外,viewEpoch 是我为了演示“快速视角切换”加的策略。真正做产品时,可以结合相机移动速度、相邻瓦片层级、视口停留时间做更精细的优先级,而不是每次移动都让 epoch 变化。
但有一条原则我会保留:渲染器负责告诉应用现在需要什么,应用负责控制数据什么时候、以多大压力送过去。 把这两个角色拆开以后,Tiled 3DGS 才不只是“能加载大场景”,而是开始接近一个可持续运行的工程方案。
九、缓存文件不能“写完一半就算命中”
把并发压住以后,我又专门模拟了一次网络中断。这个测试很有必要,因为瓦片缓存最大的隐患并不是下载失败,而是失败发生在文件已经创建之后。下一次请求到来,如果 ensureLocal() 只判断 exists(),就会把半文件当成缓存命中,随后 notifyTileReady() 把一个不可读资源交给渲染器,表现往往不是明确异常,而是某一小块场景始终缺失。
所以缓存层后来改成“临时文件 + 校验 + 原子替换”。下载目标先写成 L3/x12_y08.gs.part,完成以后校验长度以及服务端能提供的摘要信息,通过后再重命名成正式文件。应用被杀、网络切换或者磁盘写失败时,只会留下 .part,下次启动先清理这些临时文件,不会污染正常缓存。
我还把缓存结果区分成 HIT、MISS、INVALID 三类。HIT 才能直接通知;MISS 进入下载;INVALID 先删除旧文件再重新拉取。这样 Cache Hit 75% 才有工程意义,否则“文件存在率”很容易被误当成“可用率”。
这部分看上去和 Spatial Recon Kit 没有直接关系,却决定了大场景能不能长时间稳定运行。3DGS 瓦片一多,偶发的半文件迟早会碰到;如果没有明确状态,最后只能在渲染缺块时倒查文件系统,定位成本很高。
十、我最终把日志按一次相机移动聚合,而不是按单个 Tile 打满屏
另一个变化是 HiLog。最开始每个 Tile 都打一行:入队、下载、写盘、ready、stale。二十几个瓦片还看得下去,真机快速移动几秒后就完全淹没了关键状态。后来我按一个 viewEpoch 聚合,每 200 ms 输出一条摘要:requested / inFlight / ready / stale / cacheHit / state。
比如这次 B-07 的关键日志只有三段:进入区域时 requested=24, inFlight=4;压力顶满时 state=BACKPRESSURE;队列清空时 ready=22, stale=2, state=STABLE。单 Tile 详情只在失败或者调试开关打开时记录 URI。
这样做以后,判断问题也更直接。如果 requested 持续增长但 ready 不动,优先查网络和文件写入;如果 inFlight 长时间等于 4,查 Promise 是否有分支没有进入 finally;如果 stale 特别高,说明相机移动期间做了太多无效工作;如果 cacheHit 很高、ready 仍然慢,就应该把视线移到磁盘读取和渲染加载,而不是继续调网络并发。
我还会在页面上保留一个“调度快照”按钮,把当前 pendingMap、队列长度和 epoch 以 JSON 写到应用缓存目录。线上复现偶发缺块时,这份快照比一长串普通日志更有价值,因为它能还原当时究竟有哪些 URI 被认为 READY、哪些还在 WAITING、哪些已经被判定 STALE。
做到这里,这个 Demo 才从“演示 API”变成了一个能定位自己问题的小型调度系统。对大规模 3DGS 来说,我更在意的也正是这一点:不是一次加载成功,而是在用户不断移动视角、网络偶尔抖动、页面反复进入的情况下,系统仍然能解释自己正在做什么。
十一、并发数最后还是要回到设备和瓦片大小上
Demo 把 maxConcurrent 固定成 4,是为了让状态变化更容易观察,不代表 4 就是通用最优值。正式项目里我会至少采集单瓦片平均大小、P95 下载耗时、写盘耗时和设备可用内存,再决定并发。比如单瓦片只有几百 KB,网络延迟反而占主要成本,并发 4 可能偏保守;如果单瓦片已经是数 MB,还要做额外校验和解码,并发 4 又可能让内存峰值太高。
更稳妥的做法是把并发上限当成运行参数,而不是常量写死。出现连续超时或内存压力时逐级降低;网络稳定、缓存命中低且设备余量充足时再缓慢恢复。调度器每次只允许改变一级,避免在 2、6、2、6 之间来回振荡。即使暂时不做自动调节,也建议把这些数据写进 HiLog,这样以后调整不是凭感觉,而是有真实负载作为依据。
参考接口:Spatial Recon Kit spatialRender(TiledGSNode、setTileRequestCallback、notifyTileReady、loadTiledGSNode)与 ArkGraphics 3D Scene / Camera。
更多推荐





所有评论(0)