多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

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

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 → STABLESTREAMING表示应用正常接收瓦片请求当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(): Promisevoid { 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 一挡了之最早我确实用了一个SetstringURI 出现过就忽略。很快就发现逻辑不对因为“出现过”和“已经就绪”完全不是一回事。一个瓦片可能正在下载、可能下载失败、也可能上一次属于旧视角被我主动丢弃。所以真正需要记录的是任务状态。我在TileRequestScheduler里维护pendingMapKey 是tile.uriValue 里放epoch、状态和原始GSTile。同一个 URI 如果已经是DOWNLOADING或READY才合并如果属于更早的视角世代并且已经被标记过期则允许下一次重新入队。下面这段代码解决的是“一个 callback 里有 24 个请求时不让 24 个请求一起冲出去”。private readonly maxConcurrent: number 4 private inFlight: number 0 private viewEpoch: number 0 private pendingMap: Mapstring, 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): Promisevoid { 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 很关键。无论下载成功、失败还是中途判断为 staleinFlight都必须归还。不然只要某个异常分支漏减一次并发槽就会永久少一个最终表现成“跑几分钟后越来越慢”而不是立刻报错。图里的中间状态就是我刻意制造的压力点Requested 为 24InFlight 4 / 4Ready 为 18Stale 为 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回到STABLERequested 24Ready 22Stale 2InFlight 0/4Camera 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 的关键日志只有三段进入区域时requested24, inFlight4压力顶满时stateBACKPRESSURE队列清空时ready22, stale2, stateSTABLE。单 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 KitspatialRenderTiledGSNode、setTileRequestCallback、notifyTileReady、loadTiledGSNode与 ArkGraphics 3DScene/Camera。
返回列表