3DGS 场景热加载与内存回收

商品列表滑动切换不同 3DGS 场景,每个场景 30-50MB 高斯点数据,连续切 10 个就是 300-500MB。不主动回收内存的话,切到第 6-7 个就 OOM 崩了。我们做商品展示项目时第一版没管内存回收,内测切 8 个场景后应用闪退,日志是 OOM。这篇文章记录我们怎么把内存回收做到能连续切 30+ 场景不崩。

能力面:加载与卸载 API

Spatial Recon Kit 提供 loadGSNode 加载场景,对应的卸载是 release。API 看着对称,实际行为不对称——加载是同步分配内存,卸载是异步释放,且释放不一定干净。

// ArkTS:3DGS 场景加载与卸载
import { spatialRender } from '@kit.SpatialReconKit';
import { Node } from '@kit.ArkGraphics3D';

class SceneManager {
  private currentScene: spatialRender.GSNode | null = null;
  private sceneRoot: Node;

  // 加载新场景
  async loadScene(uri: string): Promise<void> {
    // 先卸载当前场景
    if (this.currentScene !== null) {
      await this.unloadCurrentScene();
    }

    // 加载新场景
    this.currentScene = await spatialRender.GSPlugin.loadGSNode(
      scene, { uri, offset: 0 }, this.sceneRoot
    );
  }

  // 卸载当前场景
  private async unloadCurrentScene(): Promise<void> {
    if (this.currentScene === null) return;

    // 1. 从场景树移除
    this.sceneRoot.removeChild(this.currentScene);
    // 2. 调 release 释放高斯点数据
    this.currentScene.release();
    // 3. 置空引用,帮助 GC
    this.currentScene = null;
    // 4. 主动触发 GC(可选,见后文讨论)
    // globalThis.gc?.();
  }
}

四步看着完整,但第 2 步 release 调了之后内存不一定立刻降下来。这是第一个坑,后面详说。

release 的实际行为

release 内部做的是释放 GPU 资源(顶点缓冲、纹理)和标记 CPU 端数据可回收。GPU 资源释放是同步的,调完就还了。CPU 端数据(高斯点数组、SH 系数)是标记可回收,实际回收等 GC。

GPU 内存和 CPU 内存是两块,要分开看。我们的内存监控也分两块统计。

约束面:内存增长曲线与 GC 不可控

我们在麒麟 9030 上连续加载 10 个 3DGS 场景,每个场景约 35MB(CPU 端)+ 28MB(GPU 端),记录内存变化。

不做任何回收(对照组)

加载序号CPU 内存GPU 内存总内存状态
初始180MB120MB300MB正常
场景 1215MB148MB363MB正常
场景 2250MB176MB426MB正常
场景 3285MB204MB489MB正常
场景 4320MB232MB552MB正常
场景 5355MB260MB615MB警告
场景 6390MB288MB678MB警告
场景 7425MB316MB741MB危险
场景 8———OOM 崩溃

每个场景增量约 35MB CPU + 28MB GPU = 63MB。麒麟 9030 应用可用内存上限约 768MB,到第 8 个场景总内存超过 800MB,OOM。

只调 release(第一版回收)

加载序号CPU 内存GPU 内存总内存备注
初始180MB120MB300MB—
场景 1215MB148MB363MB—
场景 2220MB150MB370MB场景 1 已 release
场景 3255MB178MB433MB场景 2 已 release,CPU 没降全
场景 4250MB152MB402MBGC 触发,CPU 回收了一部分
场景 5285MB180MB465MB—
场景 6280MB154MB434MBGC 触发
场景 7315MB182MB497MB—
场景 8310MB156MB466MBGC 触发
场景 9345MB184MB529MB—
场景 10340MB158MB498MBGC 触发

GPU 内存基本回收干净(每次 release 后降回 150MB 左右)。CPU 内存回收不干净——每次 release 后 CPU 内存只降 5-10MB,不是 35MB。原因是 CPU 端高斯点数组等 GC 回收,GC 不一定在 release 后立刻触发。

CPU 内存呈锯齿状增长,每次 GC 触发降一截但降不到前一个场景的水平。10 个场景后总内存 498MB,没 OOM 但一直在涨。继续切 20 个场景大概率还是会 OOM。

release + 手动释放纹理(最终方案)

加载序号CPU 内存GPU 内存总内存备注
初始180MB120MB300MB—
场景 1215MB148MB363MB—
场景 2218MB150MB368MB手动释放后
场景 3221MB152MB373MB—
场景 4219MB150MB369MBGC + 手动释放
场景 5222MB152MB374MB—
场景 6220MB150MB370MB—
场景 7223MB152MB375MB—
场景 8221MB150MB371MB—
场景 9224MB152MB376MB—
场景 10222MB150MB372MB—

CPU 内存稳定在 220MB 左右,不再持续增长。秘诀是在 release 之外,手动释放了 3DGS 渲染用的纹理资源——这些纹理是 loadGSNode 内部创建的,release 不一定覆盖。

// ArkTS:手动释放 3DGS 相关纹理
import { spatialRender } from '@kit.SpatialReconKit';

class SceneManager {
  private currentScene: spatialRender.GSNode | null = null;
  private textureCache: Map<string, Resource> = new Map();

  async loadScene(uri: string): Promise<void> {
    if (this.currentScene !== null) {
      await this.unloadCurrentScene();
    }
    this.currentScene = await spatialRender.GSPlugin.loadGSNode(
      scene, { uri, offset: 0 }, this.sceneRoot
    );
    // 记录该场景创建的纹理,卸载时手动释放
    this.trackTextures(uri);
  }

  private async unloadCurrentScene(): Promise<void> {
    if (this.currentScene === null) return;

    this.sceneRoot.removeChild(this.currentScene);
    this.currentScene.release();

    // 关键:手动释放纹理,release 不覆盖这部分
    this.releaseTrackedTextures();

    this.currentScene = null;
  }

  private releaseTrackedTextures(): void {
    for (const [key, texture] of this.textureCache) {
      // 释放 GPU 纹理资源
      if (texture.release) {
        texture.release();
      }
      this.textureCache.delete(key);
    }
  }
}

手动释放纹理这个发现来之不易。我们一开始只调 release,看 GPU 内存确实降了,以为回收干净了。但 CPU 内存持续涨,用内存分析工具查,发现是纹理对象的 CPU 端镜像没释放——GPU 纹理释放了,但 CPU 端的 ImageData 副本还在,等 GC 回收。手动调纹理的 release 才把 CPU 端镜像也释放了。

场景落地:商品列表滑动切换

我们的实际场景是商品列表,每个商品卡片有一个 3DGS 预览。用户上下滑动列表,可见的商品加载 3DGS 场景,滑出去的卸载。

预加载策略

不能等用户滑到才加载——加载耗时 0.5-0.8 秒,用户滑过去看到的是空白。我们做了预加载:当前可见商品 ±1 个预加载,滑出去的 ±2 个卸载。

// ArkTS:列表滑动时的场景加载策略
@Component
struct ProductList {
  @State currentIndex: number = 0;
  private sceneManager: SceneManager = new SceneManager();
  private loadedIndices: Set<number> = new Set();

  build() {
    List() {
      ForEach(this.products, (product, index) => {
        ListItem() {
          ProductCard({ product: product })
        }
      })
    }
    .onScrollIndex((start, end) => {
      // 可见范围 + 预加载
      this.handleSceneLoadUnload(start, end);
    })
  }

  private handleSceneLoadUnload(visibleStart: number, visibleEnd: number): void {
    const preloadRange = 1;  // 预加载可见范围 ±1
    const keepRange = 2;     // 保留可见范围 ±2,超出则卸载

    const shouldLoad = new Set<number>();
    for (let i = visibleStart - preloadRange; i <= visibleEnd + preloadRange; i++) {
      if (i >= 0 && i < this.products.length) {
        shouldLoad.add(i);
      }
    }

    // 卸载不在保留范围的场景
    for (const loaded of this.loadedIndices) {
      if (loaded < visibleStart - keepRange || loaded > visibleEnd + keepRange) {
        this.sceneManager.unloadScene(this.products[loaded].uri);
        this.loadedIndices.delete(loaded);
      }
    }

    // 加载需要预加载的场景
    for (const toLoad of shouldLoad) {
      if (!this.loadedIndices.has(toLoad)) {
        this.sceneManager.loadScene(this.products[toLoad].uri);
        this.loadedIndices.add(toLoad);
      }
    }
  }
}

预加载范围调过几版。±1 预加载时滑动流畅但快速滑动会看到空白(预加载来不及)。±2 预加载时快速滑动不空白,但内存占用高(同时 5 个场景在内存)。最后定 ±1 预加载 ±2 保留,快速滑动时接受短暂空白。

这一整套加载、卸载、控水位的动作,串起来是这样一条流程:

是

否

超过

未超过

列表滑动, 请求场景

是否在已加载集合

直接挂载复用

检查进程内存水位

是否超过 BLOCK 阈值

先卸载超出保留范围的旧场景

手动释放纹理资源

加载新场景

挂载到场景树

更新已加载集合

命中缓存的直接复用,没命中的先过水位检查再加载,超阈值就先把旧场景卸载干净。

同时加载多个场景的内存

预加载意味着同时有多个场景在内存。我们测了同时加载 2、3、4 个场景的内存:

同时场景数CPU 内存GPU 内存总内存帧率
1215MB148MB363MB58fps
2250MB176MB426MB55fps
3285MB204MB489MB49fps
4320MB232MB552MB42fps

帧率随同时场景数下降,因为同屏高斯点数增加——3DGS 渲染 fillrate 敏感,同屏高斯点数是帧率主要瓶颈。4 个场景同屏时帧率掉到 42fps,可以接受但不流畅。

我们的选择是预加载 ±1(最多同时 3 个场景),帧率 49fps 够用。如果用户快速滑动,预加载来不及的场景显示占位图,滑停后再加载。

踩坑与取舍

坑 1:卸载 API 调了但内存没降

前面说过,release 调了 CPU 内存只降 5-10MB,不是 35MB。原因是 GC 没跟上。我们试过手动触发 GC(globalThis.gc()),但 ArkTS 没有暴露 GC 接口,调不了。

手动释放纹理是绕过 GC 的方法——把大头资源手动释放掉,剩下的零碎等 GC 回收,即使 GC 慢,零碎也不会涨太多。

这个坑让我们重新审视了"卸载"这个概念。release 不是 dispose,它不保证资源立刻回收。要保证回收干净,得自己管理资源生命周期,不能全靠 API。

坑 2:快速滑动时加载取消

用户快速滑动列表,预加载触发了一堆 loadGSNode,用户又滑过去了,加载完的场景要立刻卸载。问题是 loadGSNode 是异步的,加载中途不能取消(API 26 没有取消加载的接口)。

结果是快速滑动时,后台有一堆加载任务排队跑完,每个加载完立刻被卸载。CPU 和 IO 被这些无效加载占满,用户滑停后真正要看的场景加载反而慢。

解决方法是加加载队列和取消标记。loadGSNode 不可取消,但我们可以在加载完之后检查是否还需要——不需要就立刻卸载。

// ArkTS:加载取消标记
class SceneManager {
  private loadTokens: Map<string, { cancelled: boolean }> = new Map();

  async loadScene(uri: string): Promise<void> {
    // 创建加载令牌
    const token = { cancelled: false };
    this.loadTokens.set(uri, token);

    // 加载
    const node = await spatialRender.GSPlugin.loadGSNode(
      scene, { uri, offset: 0 }, this.sceneRoot
    );

    // 加载完检查是否已取消
    if (token.cancelled) {
      // 已取消,立刻卸载
      node.release();
      this.loadTokens.delete(uri);
      return;
    }

    this.currentScene = node;
  }

  cancelLoad(uri: string): void {
    const token = this.loadTokens.get(uri);
    if (token) {
      token.cancelled = true;
    }
  }
}

这个方案不完美——加载还是跑完了,浪费了算力。但至少加载完不会挂到场景树上占内存。真正的取消加载要等 API 后续版本支持。

坑 3:GC 时机不可控导致内存峰值不可预测

GC 什么时候触发、回收多少,开发者控制不了。我们观察到 GC 倾向于在内存压力大时触发,但"压力大"的阈值不固定。有时 450MB 就触发,有时到 550MB 才触发。

这导致内存峰值不可预测。我们的内存预算按 GC 在 500MB 触发算,但偶尔 GC 到 550MB 才触发,峰值就超预算。如果此时用户再切一个场景,可能 OOM。

应对方法是主动监控内存,接近阈值时拒绝加载新场景,等 GC 触发内存降下来再加载。

// ArkTS:内存阈值控制
class MemoryGuard {
  private static readonly WARN_THRESHOLD = 480;  // MB
  private static readonly BLOCK_THRESHOLD = 580; // MB

  static canLoadNewScene(): boolean {
    const currentMem = this.getProcessMemory();
    if (currentMem > this.BLOCK_THRESHOLD) {
      return false; // 内存太高,拒绝加载
    }
    return true;
  }

  static getProcessMemory(): number {
    // 通过 hidumper 或 process.getInfo 获取进程内存
    const info = process.getInfo();
    return info.memoryUsage / 1024 / 1024; // 转 MB
  }
}

// 使用
if (MemoryGuard.canLoadNewScene()) {
  await sceneManager.loadScene(uri);
} else {
  // 显示"内存不足,请稍候"或降级为图片展示
  showFallbackImage(product);
}

坑 4:重建 session 和渲染场景的内存叠加

如果用户在重建一个新场景的同时滑动列表查看已有场景,重建 session 的内存和渲染场景的内存叠加。重建峰值约 1.8GB,加上几个渲染场景的内存,轻松超限。

我们的解决方法是重建时暂停列表预加载,只保留当前可见的一个场景。重建完恢复预加载。这会牺牲一些滑动流畅度,但避免 OOM。

真机数据:30 场景连续切换

最终方案(release + 手动释放纹理 + 预加载 ±1 + 内存阈值控制)在麒麟 9030 上连续切 30 个场景的内存监控:

切换序号CPU 内存GPU 内存总内存是否 OOM帧率
1215MB148MB363MB否58fps
5224MB152MB376MB否55fps
10222MB150MB372MB否57fps
15226MB153MB379MB否55fps
20223MB151MB374MB否56fps
25227MB153MB380MB否54fps
30225MB152MB377MB否55fps

内存稳定在 370-380MB,没有持续增长,没有 OOM。帧率稳定在 54-58fps。30 个场景切完总内存跟切 1 个差不多,回收有效。

对比麒麟 9020:

切换序号CPU 内存GPU 内存总内存是否 OOM
1235MB165MB400MB否
10268MB178MB446MB否
15295MB185MB480MB否
18340MB210MB550MB警告
20———OOM

9020 上到第 20 个 OOM。9020 的 GC 策略不同,内存回收不如 9030 积极,加上 9020 应用可用内存上限更低。内存回收效果跟芯片和系统版本强相关,9020 上我们的方案不够用,需要更激进的回收策略(比如每个场景都等 GC 完成才加载下一个,牺牲流畅度换稳定)。

总结一下下

  1. 卸载场景不能只调 release,要手动释放纹理等附加资源
  2. CPU 内存回收依赖 GC,时机不可控,要主动监控内存阈值
  3. 预加载范围 ±1,保留范围 ±2,平衡流畅度和内存
  4. 快速滑动时加载不可取消,用取消标记避免加载完挂到场景树
  5. 同时渲染场景数不超过 3 个,避免 fillrate 过载掉帧
  6. 重建时暂停列表预加载,避免重建内存和渲染内存叠加 OOM
  7. 内存回收效果跟芯片相关,9020 上需要更激进策略
  8. 接近内存阈值时降级为图片展示,不要硬加载

优化计划

我们在试 LRU 缓存策略替代当前的"可见范围 ±N"策略。LRU 按最近访问时间淘汰场景,不受滑动位置限制。好处是用户来回滑动时已加载的场景不重复加载,坏处是 LRU 的淘汰时机跟用户预期可能不一致——用户想看的场景刚好被淘汰了。

另一个方向是场景分级加载:先加载低精度版本(高斯点数 1/10),用户停留 500ms 后再加载高精度版本。低精度版本内存只占 3-5MB,可以同时缓存更多场景。这个方案需要重建时生成多精度版本,目前 Spatial Recon Kit 不直接支持,要自己后处理。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐