前言
你以为你
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面板拍快照对比,发现罪魁祸首有三个:
- 每秒钟新建一个CanvasTexture ,旧的虽然调了
dispose,但底层的Canvas对象还在内存里,因为Konva的toCanvas()每次都会生成新的Canvas,这些Canvas被CanvasTexture引用着,无法释放。 - Konva内部也有缓存 ,每次
draw都会产生新的离屏Canvas,虽然我调用了clear,但Konva为了性能,会保留一些内部对象,这些对象里又引用了画布。 - Sprite材质每次重新赋值
map,旧纹理即使dispose了,也可能会被GPU管线延迟释放,积累多了就爆了。
折腾了两周,我最终做了一个耻辱的决定:放弃Sprite方案,改用普通HTML div。
就是那种最简单、最没技术含量的 position: absolute,通过CSS把div定位到画布上方,监听mousemove和intersect来更新位置。什么"天衣无缝"?去他的吧,不崩溃才是王道。
说来也怪,换了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);
geometries、textures 的数量如果只增不减,那就是泄漏了。
最后的忠告:写个 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 多相机渲染】如何在同一场景里实现"画中画"效果