同一把椅子放三次,为什么加载了三次模型?

文章封面

前言

前两篇搭好了工程骨架,也搞清楚了坐标体系。这一篇开始往房间里放真正的家具模型:沙发、桌子、椅子、台灯、绿植、柜子。

然后马上就遇到一个问题:我在房间里放了三把一样的椅子,放在不同位置。跑起来一看,内存涨了不少,加载时间也变长了。

我当时第一反应是:三把椅子而已,至于这么慢吗?

然后打印日志才发现:每放一把椅子,模型都重新加载了一遍。三个椅子节点,对应了三份完全一样的模型资源。

这就是 3D 开发里另一个常见误区:把 Resource(模型资源)和 Instance(场景实例)搞混了。一个椅子模型文件,只需要加载一次到内存里,然后可以创建出任意多个实例,每个实例有自己的位置、旋转和缩放,但共享同一份模型数据。

这一篇就解决这个问题:怎么建立模型资源缓存,怎么区分 Resource 和 Instance,以及模型异步加载回来的时候,页面已经退出了怎么办。


一、先搞清楚:Resource 和 Instance 不是一回事

很多初学者刚开始做 3D 的时候,以为"创建一个模型"就是加载一次文件。放一把椅子,加载一次;放两把椅子,再加载一次。这在家具少的时候看不出来,家具一多,内存和加载时间就都上去了。

先把概念拆开:

概念是什么有几个举例
Resource(资源)模型文件本身,包含Mesh、Material、纹理同一个模型文件只加载一次chair.gltf
Instance(实例)场景里的一个节点,引用Resource同一个Resource可以有多个Instance三把椅子就是三个Instance
Node(节点)场景树里的对象,有Position、Rotation、Scale每个Instance对应一个Node每个椅子有自己的位置

打个比方:Resource 就像是椅子的设计图纸,Instance 就像是按照图纸做出来的真实椅子。图纸只需要一份,但可以做出很多把椅子,每把椅子可以放在不同的位置。

如果每次放一把椅子都重新读一遍图纸,那就太浪费了。


二、第一版:直接加载,放一个加载一个

先写第一版代码,看看问题出在哪:

import { scene } from '@kit.ArkGraphics3DKit';
import { SceneManager } from './SceneManager';

export class ModelManager {
  constructor(private sceneMgr: SceneManager) {}

  // 加载椅子模型并放到指定位置
  addChair(position: {x: number, y: number, z: number}): void {
    const scene = this.sceneMgr.getScene();
    if (!scene) return;

    // 每次都重新加载模型文件
    const chairNode = scene.createNode('Chair');
    // 加载 chair.gltf
    chairNode.loadModel('resources/models/chair.gltf');
    chairNode.position = position;

    scene.getRoot().addChild(chairNode);
    console.info('ModelManager: chair added at', position);
  }
}

这个代码看起来没问题,但如果我调用三次 addChair(),放三把椅子,就会加载三次 chair.gltf。

这在小模型的时候可能感觉不出来,但如果是沙发、床这种大模型,每个都几MB甚至几十MB,放几个就把内存占满了。

更麻烦的是:模型加载是异步的。如果用户连续点了五次"添加椅子",就会触发五次模型加载请求,就算后面取消了,加载还是会继续。


三、第二版:加资源缓存,Resource 只加载一次

改进一下:加一个资源缓存表,记录已经加载过的模型。同一个模型文件,只加载一次,后面直接复用。

import { scene } from '@kit.ArkGraphics3DKit';
import { SceneManager } from './SceneManager';

// 模型资源缓存
interface ModelResource {
  resource: scene.ModelResource | null;  // 模型资源
  refCount: number;  // 引用计数
  loading: boolean;  // 是否正在加载
}

export class ModelResourceManager {
  private resourceCache: Map<string, ModelResource> = new Map();

  constructor(private sceneMgr: SceneManager) {}

  /**
   * 加载模型资源(带缓存)
   */
  async loadModelResource(modelPath: string): Promise<scene.ModelResource | null> {
    // 1. 先查缓存
    const cached = this.resourceCache.get(modelPath);
    if (cached && cached.resource) {
      cached.refCount++;
      console.info('ModelResourceManager: cache hit', modelPath);
      return cached.resource;
    }

    // 2. 正在加载中,等待加载完成
    if (cached && cached.loading) {
      // 简单处理:轮询等待,实际工程里用Promise队列
      await new Promise(resolve => setTimeout(resolve, 100));
      return this.loadModelResource(modelPath);
    }

    // 3. 开始加载
    this.resourceCache.set(modelPath, {
      resource: null,
      refCount: 0,
      loading: true
    });

    try {
      const scene = this.sceneMgr.getScene();
      if (!scene) return null;

      // 异步加载模型资源
      const resource = await scene.loadModelResource(modelPath);

      // 更新缓存
      this.resourceCache.set(modelPath, {
        resource: resource,
        refCount: 1,
        loading: false
      });

      console.info('ModelResourceManager: model loaded', modelPath);
      return resource;
    } catch (e) {
      console.error('ModelResourceManager: load failed', modelPath, e);
      this.resourceCache.delete(modelPath);
      return null;
    }
  }

  /**
   * 创建模型实例
   */
  createInstance(modelPath: string, position: {x: number, y: number, z: number}): scene.Node | null {
    const scene = this.sceneMgr.getScene();
    if (!scene) return null;

    const node = scene.createNode(modelPath + '_' + Date.now());
    // 实例使用已经加载好的资源
    node.setModelResource(this.resourceCache.get(modelPath)?.resource ?? null);
    node.position = position;

    scene.getRoot().addChild(node);
    return node;
  }
}

现在放三把椅子,只会加载一次 chair.gltf。后面两把椅子直接复用缓存的资源。

这里有个关键概念:引用计数。每创建一个实例,refCount 加1;删除一个实例,refCount 减1。当 refCount 降到 0 的时候,说明这个资源没人用了,可以释放掉。

模型资源缓存流程图


四、模型异步加载回来,页面已经退出了怎么办

模型加载是异步的,这个过程中可能发生很多事:

  • 用户点了添加椅子,然后马上退出了页面
  • 模型还在加载中,Scene 已经被释放了
  • 加载完成的回调里,sceneMgr 已经是空的了

如果不处理这种情况,就会出现:页面都退出了,日志还在打印"模型加载完成",甚至还会有内存泄漏。

处理办法:加载的时候记录一个"是否有效"的标记,加载完成以后先检查一下:

async loadModelResource(modelPath: string): Promise<scene.ModelResource | null> {
  // ...前面的加载逻辑

  // 开始加载前,记录当前是否有效
  const isValid = true;

  try {
    const resource = await scene.loadModelResource(modelPath);

    // 加载完成后,检查一下页面是不是还在
    if (!isValid || !this.sceneMgr.getScene()) {
      console.warn('ModelResourceManager: page already closed, discard loaded resource');
      // 直接释放刚加载的资源
      resource?.release();
      return null;
    }

    // ...正常更新缓存
  }
}

这个问题在真实工程里特别常见。我第一次做的时候,用户快速进出页面好几次,内存涨得特别快,就是因为每次加载的模型都没释放。


五、删除实例的时候,记得释放引用

创建实例的时候 refCount 加了1,删除实例的时候就得减1,不然资源永远释放不掉。

/**
 * 删除模型实例
 */
releaseInstance(node: scene.Node, modelPath: string): void {
  const scene = this.sceneMgr.getScene();
  if (!scene) return;

  // 从场景树移除节点
  node.removeFromParent();

  // 引用计数减1
  const cached = this.resourceCache.get(modelPath);
  if (cached) {
    cached.refCount--;
    console.info('ModelResourceManager: refCount', modelPath, cached.refCount);

    // 引用计数为0,释放资源
    if (cached.refCount <= 0) {
      cached.resource?.release();
      this.resourceCache.delete(modelPath);
      console.info('ModelResourceManager: resource released', modelPath);
    }
  }
}

这个逻辑很简单,但特别容易忘。很多人只记得创建实例,不记得删除的时候要减引用计数,结果就是退出页面以后,所有模型资源都还留在内存里。


六、几个最容易踩的资源坑

把这一篇遇到的坑总结一下:

坑现象解决办法
每个实例都重新加载模型放N个家具就加载N次文件加资源缓存,Resource只加载一次
忘了引用计数退出页面后内存不释放创建实例加1,删除实例减1,为0时释放
异步加载回来页面已退出页面都没了还在加载模型加载完成后检查页面状态,无效就丢弃
用户连续点击创建触发重复加载请求加载中的模型不再发新请求,等待即可
Node删了但资源没释放节点从场景树移除了,资源还在删除节点时同步减引用计数

这些问题在家具少的时候都看不出来,一旦房间里有几十上百个模型,内存和性能问题就全暴露出来了。这也是为什么 Resource 和 Instance 要分开管理——不是为了写代码好看,是为了真的能在手机上跑起来。

3D房间三把椅子

资源缓存调试日志


总结

第三篇的核心就一句话:Resource 和 Instance 是两回事,别把它们混在一起。

  1. 同一个模型文件只需要加载一次,多个实例共享同一份资源
  2. 用引用计数管理资源生命周期:实例创建加1,实例删除减1,为0时释放
  3. 模型是异步加载的,加载完成后要检查页面是不是还在
  4. 节点从场景树移除的时候,记得同步释放资源引用
  5. 资源缓存不是什么高级优化,是 3D 应用能跑起来的基本要求

SpaceRoom 现在能加载多个家具了,而且不会重复加载。下一篇解决视觉效果:为什么东西都有了,但看起来像塑料——材质、纹理和灯光到底是怎么决定 3D 场景质感的。

Logo

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

更多推荐