3DGS 场景的体验很容易被一张好看的效果图说服:点云有层次,展厅能旋转,画面中看不出明显的洞。但一旦用户沿着展馆长廊快速转向,真正决定流畅度的就不只是“模型生成得好不好”,而是此刻应该让哪些数据驻留、哪些请求可以提前准备、哪些已经过期的结果必须丢弃。数据越大,单靠“能读取就全部放进缓存”的策略越站不住脚。

这一篇不重复此前的模型成果清单缺块校验。前者关注模型文件是否完整到可以交付,本篇讨论的是已经存在可用模型之后的运行期资源预算。我们以 ViewportLoom 为演示项目,在应用业务层建立视口瓦片的热集计划器。它不会冒充 Spatial Recon Kit 的内置调度器,也不宣称只靠这些 ArkTS 片段就能直接控制 3DGS 底层 GPU 显存。

一、先把一帧“看起来正常”的画面拆成几种不同资源

官方 Spatial Recon Kit 文档把 Gaussian Point 描述为带位置、颜色、透明度、旋转和缩放等属性的空间元素,并介绍了 Tiled 3D Gaussian Splatting(分块高斯泼溅):大场景按空间瓦片划分,视口需要时再加载相关数据。这给应用层资源规划一个比较清楚的入口,但瓦片调度是否开放、加载成本如何核算,还取决于实际渲染接口和素材格式。

我们设置一个展馆演示场景 museum_hall_07,任务 TILE-1009-06,总瓦片 96。当前视野真正需要的可见瓦片有 12 个,沿相机运动方向提前准备 4 个,内存里维持 18 个常驻瓦片。为什么不是 16 个?因为另外两块是短暂保留的回访热集,用于避免用户刚转过头又转回去时立即重复加载。它们不属于当前可见视口,也不属于当前预取集合。

业务层演示预算设为 100 MB,模拟驻留资源共 68 MB,于是看板显示 68%。这一数字是我们给调度器的自定义资源记账值,不是操作系统提供的实时 GPU 占用,更不是所有设备的最佳阈值。真实 3DGS 文件的解码数据、渲染缓冲、纹理上传和缓存元数据往往具有不同的内存口径,正式性能报告必须区分这些量。把“本项目估算68MB”误写成“系统 GPU 显存68MB”,会让后续定位完全偏离方向。

这轮演示还有一组明确的压力结果:经过预算整理,共淘汰 3 块冷瓦片 t_014、t_035、t_061;视口快速变化导致第 7、8 代的两条预取请求晚到,被标记为 staleDropped=2;当前有效代次为 9,状态 BUDGET_STABLE。所有数字都只是为了验收状态机而设定的模拟数据。

二、把“缓存命中”与“这块瓦片仍然该显示”分开

看似节省资源的做法,是将每次读取成功的瓦片都放进一张 Map:下次同样的 ID 来了就直接复用。问题在于,瓦片存在与瓦片属于当前视口不是同一回事。相机旋转了三次,第一代预取结果可能刚刚完成,缓存确实命中了,却不该把它当作第九代视口的可见集合。

ViewportLoom 因此维护三份概念不同的数据。visibleIds 是当前必须显示的集合,优先级最高;prefetchIds 是仅为下一步方向做准备的候选集合;resident 是已经持有业务资源句柄、可以考虑复用或淘汰的 Map。可见集合与驻留集合不是包含关系的固定真理:异步加载尚未完成时,某些 visible ID 也可能还不 resident,这时需要占位、降级或等待,不能伪造“渲染完成”。

为了避免把系统框架误说成调度接口,下面这些类都是我们自己实现的业务模型:TileBudgetPlanner 决定顺序,TileLease 表示一次持有关系,ViewportEpochGate 丢弃迟到结果。它们可以封装来自文件、服务端、3DGS 渲染桥接层或本地缓存的数据,但具体接入 Spatial Recon Kit 时需要遵守当前版本的 spatialRender 及图形引擎约束。官方参考展示了 @kit.SpatialReconKit 的 spatialRender 模块和 GSNode,并说明相应 Stage 模型与设备能力条件;它并没有赋予本文自建方法“系统 API”的身份。

三、一个瓦片究竟由谁负责释放

在任何预取方案之前,我都会先把资源所有权写在接口注释里。一个刚解码完成的瓦片,若尚未交给常驻池,由本次加载请求负责释放;一旦登记进 resident,由常驻池负责最终清理。否则相机一边取消预取、一边淘汰旧缓存,很容易发生双重释放或没人释放。下面的代码先建立一个足够小的所有权接口,避免直接假定厂商渲染器暴露 releaseTile 这类不存在或未经验证的方法。

export interface TileLease {
  id: string;
  estimatedMB: number;
  dispose(): void; // 应用接入层实现,非系统 API
}

export interface TileEntry {
  id: string;
  epoch: number;
  lastVisibleTick: number;
  pinned: boolean;
  lease: TileLease;
}

export class TileBudgetPlanner {
  private readonly budgetMB: number = 100;
  private resident: Map<string, TileEntry> = new Map();
  private usedMB: number = 0;

  attach(entry: TileEntry): void {
    const old = this.resident.get(entry.id);
    if (old) { // 相同 ID 已由常驻池持有
      entry.lease.dispose();
      return;
    }
    this.resident.set(entry.id, entry);
    this.usedMB += entry.lease.estimatedMB;
  }
  getUsedMB(): number { return this.usedMB; }
  getBudgetMB(): number { return this.budgetMB; }
}

这个片段关注的是责任转移,不是内存自动统计。estimatedMB 应从项目实际数据格式推导,不能靠屏幕显示瓦片数乘一个常数就认定准确。真实实现中,dispose() 还要考虑线程归属和渲染管线的释放时机:如果底层资源必须在渲染线程释放,业务层就应提交释放请求,而不是在 UI 回调中直接调用未知对象方法。

特别需要防止“入口持有一份、UI 持有一份、回调闭包又持有一份”的隐性引用。即使常驻池已删掉 Map 项,闭包仍可能引用旧数据,垃圾回收也未必立刻收回原生资源。最好让页面只持有 ID 和状态,真正的加载句柄集中交给规划器或适配器管理,避免让 UI 组件成为第二个资源所有者。

四、预算触发淘汰时,可见热集绝不能排在牺牲名单前面

如果只按最早访问时间排序,容易把长时间静止但仍在屏幕里的瓦片误判为最冷。相机停在展品前阅读说明时,这些瓦片可能没有新的“访问”事件,却是绝对不能移除的一组。淘汰策略需要先从候选池里排除 pinned=true 的当前可见瓦片,再处理过期预取和冷保留。缓存策略不能凌驾于画面正确性。

第二段代码处理预算超限后先回收谁。代码是自定义 TileBudgetPlanner 的扩展方法,真实接入时应与上面类合并;这里用 lastVisibleTick 作为演示排序依据,生产方案还需要考虑瓦片距离、预计重访概率、解码代价和释放延迟。

export function evictColdTiles(
  resident: Map<string, TileEntry>,
  usedMB: number, budgetMB: number
): { usedMB: number; evicted: string[] } {
  const candidates: TileEntry[] = Array.from(resident.values())
    .filter((item: TileEntry) => !item.pinned)
    .sort((a: TileEntry, b: TileEntry) =>
      a.lastVisibleTick - b.lastVisibleTick);
  const evicted: string[] = [];
  for (const item of candidates) {
    if (usedMB <= budgetMB) break;
    resident.delete(item.id); // 先解除业务所有权
    usedMB -= item.lease.estimatedMB;
    item.lease.dispose();     // 后执行接入层释放
    evicted.push(item.id);
  }
  return { usedMB, evicted };
}

这段方法还有一个故意留在项目设计层面的条件:当所有候选都是 pinned,而内存仍超过预算时,算法不会强行清空当前屏幕。它必须返回“预算暂时无法满足”的信号,由上层决定降低预取范围、减小精度、等待释放或提示用户。正式版本还要把“正在释放”和“已释放”的资源分开,避免本地计数已经减去、底层内存却仍占用的时差造成连续过量加载。

不能为了让演示图好看,随意声称一次淘汰马上节省了20MB。图中的 68/100 MB 是淘汰及回收流程后的业务估算总量;evicted=3 只是本次演示策略选出的冷瓦片数。至于 t_014 等每个瓦片的真实内存量,仍需要在具体素材和设备上测量。为防止命中率下降,建议记录“淘汰后多少时间又被重新加载”,这是判断回收策略是否过于激进的重要依据。

五、错误通常不在加载失败,而在加载成功得太晚

最值得排查的时序是:第7代视口向走廊左侧预取,第8代改向展厅中央,第9代已经停在雕塑前。第7、8代的两个请求慢慢返回,如果它们一完成就写入 resident,预算和显示集合就会被旧方向挤占。即使最后把旧块清理掉,也白白经历了解码、上传、再释放的额外成本。

这里给每次视口计划一个单调增加的 epoch。发起请求时附上代次,回调时先看它与当前代次是否一致,再考虑将结果交给常驻池。过期的结果进入丢弃计数,释放仍由回调持有者负责。第三段代码解决业务结果迟到时如何避免越权接管内存。

export class ViewportEpochGate {
  private currentEpoch: number = 9;
  private staleDropped: number = 0;
  beginEpoch(): number { return ++this.currentEpoch; }
  acceptLoaded(loadedEpoch: number, lease: TileLease,
    planner: TileBudgetPlanner): boolean {
    if (loadedEpoch !== this.currentEpoch) {
      this.staleDropped++;
      lease.dispose();   // 未进入常驻池,加载方自行释放
      return false;
    }
    planner.attach({
      id: lease.id, epoch: loadedEpoch,
      lastVisibleTick: this.currentEpoch,
      pinned: false, lease: lease
    });
    return true;
  }
  getStaleDropped(): number { return this.staleDropped; }
}

示例对象初始化为9,是为了与演示日志保持一致;实际运行应由相机变化事件递增 epoch,不能把9硬编码到产品代码。另一个更重要的问题是:只判断相同代次仍不够。如果某个瓦片在第9代已经不属于 visible 或 prefetch 集合,也不应该获得常驻资格。真实 acceptLoaded 必须同时检查请求票据、当前集合成员关系以及是否已经持有同一 ID。这里把它们列为适配层待补充的契约,而不伪称短代码实现了所有并发边界。

如果底层异步任务支持可靠取消,离开旧视口时可以先取消;但取消和完成可能竞态,仍然需要代次判断作兜底。取消不等于资源自动释放,成功完成也不等于一定被采用。日志里最好分别记录 cancelRequested、completed、accepted、staleDropped,否则排查时常把“丢弃旧回调”误认为“后台接口报错”。

六、先看主页面,再看资源账本;两张图回答的不是同一个问题

示意开发环境左侧是 ViewportPlanPage.ets、TileBudgetPlanner.ets 和 MemoryAuditPage.ets,中间的业务代码聚焦 reserveVisibleTiles、prefetchNearViewport、evictColdTiles 等应用自定义方法,右侧是展馆瓦片监控页面,底部日志有 TILE-1009-06 的预算记录。它不是真实 DevEco Studio 性能跟踪截图,也不代表系统公开了这些方法。阅读时要把“代码组织的演示”和“底层渲染能力”分开。

主页面的数据应一眼能答出四个问题:场景是不是 museum_hall_07,当前任务是不是 TILE-1009-06,用户可见的瓦片数是不是12,业务账面使用是不是 68/100MB。在这组数据下,96个瓦片构成模型总池,12个是当前视口必需,4个是预测要进入视口的预取对象,18个是常驻;这些集合之间可能交叠,不能把96、12、4、18简单相加当作“实际加载了130个”。

另一个值得解释的小细节是按钮“查看淘汰诊断”。用户遇到画面跳动,会比查看大段技术介绍更需要知道到底是内存预算不足,还是相机改变导致旧请求迟到。主页面适合展示状态摘要,调试页面再展示事件级证据。不要让日志吞噬主页面,也不要让主页面的绿灯掩盖底层仍有异常。

七、日志里要能看到代次7、8为何失效,而不是只有一个“成功”

MemoryAuditPage 专门展示资源账本:预算100MB、使用68MB、可见12、预取4、常驻18、总池96;淘汰瓦片 t_014、t_035、t_061,过期请求代次7、8被忽略,当前有效代次9,最终状态 BUDGET_STABLE。日志时间从10:24:06到10:24:08,这些是设计时约定的示例记录,不是对真实设备所做的性能采样。

为什么要把过期丢弃单独列出来?因为它和资源不足不是一回事。过期请求被丢弃,说明我们的代次门禁阻止了旧方向的结果接管资源;若频繁发生,则说明预取距离、任务取消时机或相机预测策略可能需要优化。相反,如果 staleDropped=0 但缓存占用持续上涨,也可能是根本没有执行代次校验,不能拿零丢弃数直接当优化成绩。

诊断表还要明确资源口径。residentMB 是应用台账,decodedBytes 是解码后的业务内存,uploadedBytes 若底层能可靠提供才可单列,系统进程 RSS 又是另一个尺度。没有测量能力的指标不要伪造值。用户看到68%,应该知道它代表自定义预算利用率,而非设备剩余可用内存百分比。多个口径一旦混在同一张图里,很容易出现“业务估算下降但系统内存没变”的错误结论。

八、哪些边界不能由演示数据代替

正式验收需要真机上对三种相机操作做压力测试:平稳推进、突然180度转向、原地来回旋转。每种都观察瓦片可见性、预取量、解码与上传耗时、UI帧率、资源释放滞后以及峰值内存。模型尺寸至少要有小、中、大三个等级;只使用一个96瓦片样本,很难说明策略在其它场景可复用。

边界一是并发:两个相同ID的加载任务几乎同时完成,谁负责采用,谁负责释放?需要原子去重或序列化归档。边界二是错误:加载失败的 tile 是否立即重试,重试是否附带新的 epoch,失败可见瓦片是否保持占位?边界三是暂停与恢复:应用进入后台后,不应让大量预取继续占住资源;恢复时要重新取得视口状态而不是盲信后台前的旧集合。

边界四是用户主动切换模型。museum_hall_07 的请求不能进入新场景的驻留 Map,所以请求身份需要同时绑定 sceneId 与 epoch。边界五是关闭页面与销毁渲染对象。UI 页面销毁不保证所有原生对象立刻释放;如果存在跨页面预热服务,也要有引用租约或显式的最后消费者。正式工程不能简单在 aboutToDisappear 里同步释放一切,否则快速返回上一页时可能造成多次重建和卡顿。

最后,官方的 spatialRender、GSNode 以及分块3DGS说明是接入与术语依据,不代表本文自建的预算、淘汰、代次模型已经在任何系统版本编译通过。要完成系统层集成,仍须在目标 SDK 中查证可用渲染管线与资源回调,确认具体设备限制,并用 DevEco Profiler、日志和实机数据核对效果。

1. 用一张代次表理解“热集”和“迟到”的差异

可以把这轮场景想成同一用户的连续三次转身。代次7时,视口朝向走廊左侧,调度器为左侧一组瓦片发出了预取票据;代次8时,用户开始回看展厅,新的可见集合已经变化;代次9时,相机停在中央雕塑前,调度器计算出12个可见瓦片及4个预取瓦片。此时7和8的请求就算成功返回,也只能说明“请求处理成功”,不能证明“内容仍值得放入缓存”。如果把网络或磁盘层的成功标记直接映射为业务 accepted,常驻池很快会被已经没有价值的内容填满。

所以在应用级状态模型中,loaded、admitted、visible 要分别计数。一个加载完成的句柄先经过身份与代次校验,再经过预算准入,最后才有机会成为可见资源;三道门禁失败时采取不同补救方式。身份不符要彻底拒绝;代次过期通常丢弃;预算不足则可以排队、降级或者取消较弱的预取。这样处理异常时,日志能够指出究竟是哪一层拒绝了请求,而不是都叫“加载失败”。

2. 提前算预算,不要等加载完成才发现装不下

evictColdTiles 只是预算维护的最后一环。在发起新的预取之前,还应把正在解码但尚未登记为常驻的潜在内存算进临时预留账。假设台账中只有68MB,看起来距100MB还有32MB;若同时发起六个预取任务,每个可能解码出十几MB的数据,就算最终只留下四块,加载期间的峰值也可能明显超出预算。应用层应为加载中任务保留额度,成功采用时将额度转为常驻占用,失败或取消时及时撤销预留。

预留账不等于 GPU 驻留值。其目的只是阻止业务在可预测的范围内同时发起过多昂贵任务。正式接入时最好单独记录 reservedMB、residentMB 和 releasingMB,避免释放请求尚未完成就把额度反复用在新任务上。如果底层无法提供准确字节数,可先用保守估算并记录误差,后续按不同模型类型校正,不应该拿估算当作真机测量报告。

3. 对有损降级也要明确画质责任

预算告急时不能把“删除当前可见瓦片”当作自然动作。更合理的顺序通常是减少远处预取、缩短回访保留时间、清理确认过期的未采用结果,再考虑由渲染适配层支持的质量降级。若框架没有公开的LOD或动态质量控制接口,应用不能凭想象调用一个 setQualityLevel。可以先降低业务层预取激进程度,把画质或帧率调整留给经过 SDK 验证的渲染能力。

有些短暂空洞是等待加载造成的,另一些是数据本身缺失造成的。前者可以靠调度改善,后者要回到成果校验链路处理。把两类空洞混在同一个“3DGS加载失败”错误提示里,既不利于用户理解,也会让技术文章失去排查价值。运行阶段要有运行阶段的证据,生产成果完整性要有独立的证据。

九、最终保留的不是18个瓦片,而是一套可以解释的取舍

从应用工程的角度,常驻18个瓦片只是某个时刻的快照;真正应该留下来的是规则:当前可见资源优先、预取必须服从内存上限、迟到请求没有资格改变当前视口、资源由唯一持有方释放。符合这些约束以后,再去调参提高命中率,才有可能获得可复现的收益。

这也解释了为什么本篇没有直接给出“最佳预取半径”或“每块瓦片固定几MB”。产品形态、模型格式、屏幕刷新率、设备内存和视口移动速度都不同,拍脑袋给出的数字只会掩盖设备差异。可以先用本篇演示状态机开展单元级验证,再逐步接真实渲染适配层。模型能显示,与资源策略可长期稳定运行,是两次不同的验收。

参考资料(核对日期:2026-10-09):华为开发者文档《Spatial Recon Kit术语》(2026-07-28);spatialRender 官方 API 参考(2026-09-30);华为 HarmonyOS 空间计算能力介绍。本文所有 TileBudgetPlanner、TileLease、ViewportEpochGate 都是应用自建类,不是系统 API;展示的瓦片数量、内存预算、时间和日志属于演示数据。

官方原文链接:Spatial Recon Kit 术语 (2026-07-28);SpatialRender 模块与 GSNode (2026-09-30);华为 HarmonyOS 空间计算能力介绍。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐