【Three.js内存管理】那些你以为释放了,其实还在占着的资源

前言

你以为你 dispose 了,它就没了吗?Too young ~

三个月前,我差点被一个 Bug 搞到怀疑人生。

事情是这样的:我负责的一个智慧园区项目,上线前测试同学跑过来说:"页面打开久了会卡,你瞅瞅?"

我打开页面,刚开始确实丝滑,60fps 稳稳的。然后我开始疯狂切页面、关弹窗、加载新模型......五分钟后再看帧率,30fps 。十分钟后,15fps。二十分钟后,页面直接白屏,Chrome 弹出一个熟悉的提示:

"喔唁,崩溃啦。"

我懵了。

代码里明明写了 dispose(),该释放的都释放了,怎么还能崩?打开 Chrome 任务管理器一看,GPU 内存那一栏的数字,像坐了火箭一样往上涨,根本停不下来。

那天下午,我干了一件事:把所有以为释放了、实际还占着的资源,一个个揪出来。今天就把这些"装死"的资源全曝光,省得你们也踩坑。


第一个坑:几何体,你 dispose 了吗?

先看一段我当时的代码:

javascript 复制代码
// 加载一个模型
loader.load('big-model.glb', (gltf) => {
  const model = gltf.scene;
  scene.add(model);
});

// 后来某个时刻,移除模型
scene.remove(model);
// 心想:移除就完事儿了,内存会自动释放吧?

天真的我,以为 remove 就万事大吉。结果呢?几何体数据还赖在 GPU 里不走。

正确做法

javascript 复制代码
// 移除前,遍历模型,dispose 所有几何体和材质
function disposeModel(model) {
  model.traverse((obj) => {
    if (obj.isMesh) {
      if (obj.geometry) {
        obj.geometry.dispose();
      }
      if (obj.material) {
        if (Array.isArray(obj.material)) {
          obj.material.forEach(m => m.dispose());
        } else {
          obj.material.dispose();
        }
      }
    }
  });
  scene.remove(model);
}

你以为这就够了?太天真了。材质里的纹理呢?你不 dispose,它还在!


第二个坑:纹理,你 dispose 了吗?

材质 dispose 只会释放材质本身的 GPU 资源,但纹理是单独分配的。你得手动把纹理也干掉。

javascript 复制代码
// 错误:只 dispose 材质
material.dispose(); // 纹理还在!

// 正确:先 dispose 纹理
if (material.map) material.map.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.roughnessMap) material.roughnessMap.dispose();
// ... 还有 aoMap、emissiveMap、metalnessMap ...
material.dispose();

有一次我忘了 dispose 纹理,结果加载了 100 个不同的模型,每个模型都带一张 4K 贴图。你们猜 GPU 内存用了多少?直接爆了,页面黑屏。

更坑的是,有些纹理是多个材质共用的。如果你 dispose 了共用纹理,其他材质也跟着完蛋。所以必须做好引用计数,或者用 ResourceTracker 统一管理。


第三个坑:RenderTarget,你不 dispose 试试?

做后期处理的时候,经常用到 WebGLRenderTarget。比如 ping-pong buffer、阴影贴图、反射纹理......

javascript 复制代码
const rt = new THREE.WebGLRenderTarget(1024, 1024);
// 用完之后,忘了 dispose

这个玩意儿,不 dispose 的话,显存占用一直不释放。而且你肉眼看不见,Chrome 任务管理器里 GPU 内存悄悄上涨。

正确:用完就扔。

javascript 复制代码
rt.dispose();

特别是做动态效果,每帧新建一个 RenderTarget 又不释放,那内存涨得比股票还快。


第四个坑:InstancedMesh 的矩阵,你以为删了就没了?

InstancedMesh 是个好东西,能把成千上万个实例压缩成一个 Draw Call。但如果你动态增删实例,得小心。

javascript 复制代码
// 创建
const instancedMesh = new THREE.InstancedMesh(geo, mat, 1000);
scene.add(instancedMesh);

// 后来想删掉一部分实例,直接把 count 改小?
instancedMesh.count = 500;
// 你以为剩下的 500 个实例的内存就释放了?

图样图森破InstancedMesh 内部的矩阵缓冲区(instanceMatrix)还是 1000 的大小,只是渲染时只画前 500 个。那 500 个被"删掉"的实例数据还占着显存。

正确做法:重新创建一个新的 InstancedMesh,只保留需要的数量。或者更狠一点,自己维护一个动态数组,每帧重新上传矩阵。


第五个坑:BufferAttribute,你 dispose 了吗?

有时候我们手动创建几何体:

javascript 复制代码
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([...]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

当你 geometry.dispose() 时,这些 BufferAttribute 也会被 dispose 吗?答案是:会,但前提是这些 attribute 没有被其他地方引用

如果你把同一个 BufferAttribute 赋值给两个几何体,dispose 其中一个,另一个的 attribute 还在,但底层 GPU 缓冲区可能已经被释放了,导致另一个几何体渲染出错。

所以,共用 attribute 要小心,要么就别共用,要么就用引用计数自己管理。


第六个坑:Texture 的 image,你还得手动 revoke?

如果你用 URL.createObjectURL() 加载图片,比如从本地文件上传生成纹理:

javascript 复制代码
const url = URL.createObjectURL(file);
const texture = loader.load(url);
// 用完 texture 后,dispose 了纹理,但 URL 还没释放

URL.revokeObjectURL(url) 得自己调用,否则内存泄漏。而且这个泄漏不在 GPU,而在 JS 堆里,Chrome 任务管理器看不出来,但用久了页面一样卡。


第七个坑:动画混合器,你 stop 了吗?

如果你用了 AnimationMixer 播放动画,直接移除模型而不停止动画,mixer 内部还有对模型的引用,导致模型无法被垃圾回收。

javascript 复制代码
// 错误
scene.remove(model);
// mixer 还在引用 model 内部的骨骼、材质等

// 正确
mixer.stopAllAction();
mixer.uncacheRoot(model); // 重要!
scene.remove(model);

这个坑我踩过,找了半天才发现 mixer 偷偷摸摸抱着模型不放手。


第八个坑:画布弹窗,我把每帧都变成了内存地雷

这事儿说起来有点丢人,但为了大伙儿不重蹈覆辙,我还是交代了吧。

去年做第一个正式项目,有个需求:点击设备,弹出一个悬浮面板,显示实时数据。当时我年轻气盛,心想这弹窗得跟3D场景"天衣无缝"啊,用普通的HTML div多掉价,飘在画布上面,一点儿都不酷。

于是我想了个自认为很牛的办法:用 Sprite + Konva

Konva 是个Canvas 2D库,可以在上面画各种UI元素。我把它画好的Canvas转成Three.js的 CanvasTexture,然后贴到 Sprite 材质上,再把 Sprite 放到3D空间里。完美!弹窗像模型一样存在于场景中,可以旋转、缩放,跟设备严丝合缝。

更让我得意的是,数据是实时更新的,比如温度、压力每秒都在变。我就写了个定时器,每秒重新画一次Konva画布,生成新的CanvasTexture,赋值给Sprite材质。

javascript 复制代码
// 伪代码:每秒更新弹窗纹理
setInterval(() => {
  // 1. 清空Konva画布,重新画UI
  konvaLayer.clear();
  konvaLayer.draw();
  
  // 2. 把画布转成Three纹理
  const canvas = konvaLayer.toCanvas();
  const texture = new THREE.CanvasTexture(canvas);
  
  // 3. 赋给Sprite
  sprite.material.map = texture;
  sprite.material.needsUpdate = true;
}, 1000);

刚开始测试,一切正常,数据跳动,弹窗灵动,我美滋滋地交付了。

然后噩梦开始了。

上线第一天,现场反馈:系统运行四个小时左右就崩溃了。我远程一看,页面白屏,Chrome报错"Out of Memory"。打开任务管理器,GPU内存已经顶到2GB(我的笔记本才4GB)。

我第一反应:是不是Konva画布太大?压缩图片,降低分辨率,从512x512降到256x256。重新上线,六小时崩溃

我又想:是不是Canvas转纹理的时候没释放旧的?于是我加了一行:

javascript 复制代码
if (sprite.material.map) sprite.material.map.dispose();

再上线,八小时崩溃

我开始怀疑人生了。不断优化,不断测试,内存泄漏的时间从四小时延长到十二小时、十八小时,但始终无法根除。最后,我把Konva换成原生Canvas画图,自己管理画布,甚至手动调用 canvas.width = canvas.width 来清空,二十小时崩溃一次

我盯着那个"二十小时崩溃"的数据,突然明白了一个道理:这条路,走不通

问题到底出在哪儿?

后来用Chrome Memory面板拍快照对比,发现罪魁祸首有三个:

  1. 每秒钟新建一个CanvasTexture ,旧的虽然调了 dispose,但底层的 Canvas 对象还在内存里,因为Konva的 toCanvas() 每次都会生成新的Canvas,这些Canvas被 CanvasTexture 引用着,无法释放。
  2. Konva内部也有缓存 ,每次 draw 都会产生新的离屏Canvas,虽然我调用了 clear,但Konva为了性能,会保留一些内部对象,这些对象里又引用了画布。
  3. Sprite材质每次重新赋值 map,旧纹理即使dispose了,也可能会被GPU管线延迟释放,积累多了就爆了。

折腾了两周,我最终做了一个耻辱的决定:放弃Sprite方案,改用普通HTML div

就是那种最简单、最没技术含量的 position: absolute,通过CSS把div定位到画布上方,监听mousemoveintersect来更新位置。什么"天衣无缝"?去他的吧,不崩溃才是王道。

说来也怪,换了div之后,再也没崩过。内存稳如老狗,帧率也回来了。产品经理问:"为啥弹窗变成2D的了?"我面不改色:"这是最新设计风格,扁平化,通透。"

从那以后,我明白了一个道理:有时候"最优解"是幻觉,能稳定运行的方案才是真·最优解

如果你也遇到类似的需求,听我一句劝:别在Sprite里玩动态画布,老老实实用HTML overlay。3D就该干3D的事,2D就该干2D的事,强行融合,只会让你半夜爬起来查内存泄漏。


教训:CanvasTexture 配合实时更新的画布,每帧都要注意释放旧的纹理,并且确保画布本身没有被意外引用。但如果可能,直接用HTML元素覆盖更简单可靠。

怎么查内存泄漏?

上面说了这么多,怎么发现自己项目里有泄漏?我总结了三个方法:

1. Chrome 任务管理器

Shift + Esc 打开,找到你的页面,看两列:

  • 内存占用空间:JS 堆内存,如果持续增长,可能是 JS 对象没释放。
  • GPU 内存:显存占用,如果持续增长,肯定是 Three.js 资源没 dispose。

2. 内存快照

Chrome DevTools -> Memory 面板,拍快照,对比两次之间的差异。可以过滤 Three. 关键词,看看哪些对象没被回收。

3. 写个简单的监控

在动画循环里定期打印 renderer.info.memory

javascript 复制代码
setInterval(() => {
  console.log(renderer.info.memory);
}, 5000);

geometriestextures 的数量如果只增不减,那就是泄漏了。


最后的忠告:写个 ResourceTracker

被坑多了之后,我学聪明了:写一个统一的资源追踪器,所有几何体、材质、纹理、RenderTarget 都交给它管理。

javascript 复制代码
class ResourceTracker {
  constructor() {
    this.resources = new Set();
  }

  track(resource) {
    if (resource.dispose) {
      this.resources.add(resource);
    }
    return resource;
  }

  disposeAll() {
    this.resources.forEach(resource => {
      if (resource.dispose) {
        resource.dispose();
      }
    });
    this.resources.clear();
  }
}

// 使用
const tracker = new ResourceTracker();
const geometry = tracker.track(new THREE.BoxGeometry());
const material = tracker.track(new THREE.MeshStandardMaterial());
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// 销毁时
tracker.disposeAll();
scene.remove(mesh);

这样就不会漏掉任何资源了。


写在最后

那个让我崩溃一下午的 Bug,最后发现是 RenderTarget 忘了 dispose。一行代码的事,让我查了三个小时。

从那以后,我养成了一个习惯:每次写完一个功能,就打开 Chrome 任务管理器,盯着 GPU 内存看十秒。要是数字往上涨,就一个个排查,直到它稳定为止。

内存管理这玩意儿,不出事的时候你觉得它屁用没有,一出事它就让你怀疑人生。

所以,如果你也在写 Three.js,记住这句话:

你以为释放了的资源,99% 都还在那儿装死。


互动

你遇到过最隐蔽的内存泄漏是啥?评论区晒出来,让大伙一起避坑 😏

下篇预告:【Three.js 多相机渲染】如何在同一场景里实现"画中画"效果

相关推荐
BigByte20 小时前
我用 6 个 WASM 编码器干掉了 Canvas.toBlob(),图片压缩率直接提升 15%
性能优化·webassembly·图片资源
烛阴1 天前
Three.js 零基础入门:手把手打造交互式 3D 几何体展示系统
javascript·webgl·three.js
DemonAvenger2 天前
Kafka性能调优:从参数配置到硬件选择的全方位指南
性能优化·kafka·消息队列
桦说编程2 天前
实战分析 ConcurrentHashMap.computeIfAbsent 的锁冲突问题
java·后端·性能优化
叶智辽2 天前
【ThreeJS调试技巧】那些让 Bug 无所遁形的“脏套路”
webgl·three.js
小马爱打代码3 天前
MySQL性能优化核心:InnoDB Buffer Pool 详解
数据库·mysql·性能优化
叶智辽3 天前
【ThreeJS急诊室】一个生产事故:我把客户的工厂渲染“透明”了
webgl·three.js
顾青3 天前
仅仅一行 CSS,竟让 2000 个节点的页面在弹框时卡成 PPT?
前端·vue.js·性能优化
山峰哥3 天前
吃透 SQL 优化:告别慢查询,解锁数据库高性能
服务器·数据库·sql·oracle·性能优化·编辑器