news 2026/10/2 23:46:42

HarmonyOS 7 Spatial Recon Kit + ArkGraphics 3D:Tiled 3DGS 瓦片请求去重与相机快速移动下的回压调度【鸿蒙心迹】

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 = 永远不请求”,画面会出现局部缺块;如果“每次出现 = 都新建任务”,又会回到最初的风暴。

所以现在我的判断顺序是:

  1. 本地缓存可读:直接notifyTileReady(),这类请求几乎不占网络并发;
  2. 同 URI 正在下载:合并等待,不新建第二个任务;
  3. 同 URI 已就绪:只更新命中统计;
  4. 同 URI 曾经 stale/failed:当前 epoch 允许重新排队;
  5. 新 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 KitspatialRender(TiledGSNode、setTileRequestCallback、notifyTileReady、loadTiledGSNode)与 ArkGraphics 3DScene/Camera。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 23:42:27

Agent 缓存命中率提升与 Token 成本控制:从架构到工程落地

1. 为什么 Token 成本是 Agent 系统的第一瓶颈 2025 年之后,业界对 Agent 的讨论重心已经从「能不能跑通」转移到了「能不能规模化、能不能赚钱」。一个简单的客服 Agent demo 可能每次对话消耗几千 token,但一旦铺开到日均百万次调用,token 账单会直接吃掉毛利。 Agent 与…

作者头像 李华
网站建设 2026/10/2 23:42:10

石家庄材料复试检测服务商客户口碑力荐,正规资质实力参考

打铁工社(北京)供应链管理有限公司&#xff0c;是一家专注于建设工程报建报验与全过程项目管理的咨询服务平台。企业成立于2021年&#xff0c;2024年7月正式完成工商注册登记&#xff0c;法定代表人陆玥含&#xff0c;经营范围涵盖工程管理服务、工程造价咨询、招投标代理、商务…

作者头像 李华
网站建设 2026/10/2 23:33:35

Zabbix 7.0 LTS数据库分区实战:从部署到性能优化

简介&#xff1a;Zabbix 7.0 LTS部署后&#xff0c;历史与趋势数据表容易快速膨胀&#xff0c;甚至出现Zabbix housekeeper进程繁忙告警&#xff0c;而数据库分区是缓解这类问题的有效手段。这份PDF操作记录基于MySQL或MariaDB环境&#xff0c;围绕zbx_db_partitiong.sql分区脚…

作者头像 李华