Three.js 页面退出后为什么还占内存?怎样管理模型和渲染器的生命周期?
从产品预览反复打开的场景出发,区分场景移除、GPU 资源释放和 JavaScript 引用,给出独占资源的挂载与清理示例,并解释共享纹理和异步加载的边界。
简答
- scene.remove 只把对象移出场景,不会释放 geometry、material 和 texture。
- 独占资源由页面在卸载时 dispose,共享资源由资源管理层在最后一个使用者释放后处理。
- 异步加载的结果可能在页面离开后才返回,完成时要先检查是否仍被需要。
- 验证时重复挂载和卸载,比较多轮之后的 JS 堆和 renderer.info,不要求所有数字归零。
产品详情页打开三维预览,关闭后删掉 canvas,再打开另一个商品。几次切换后内存越来越高:DOM 已移除,但动画循环仍在调用 render,几何体和纹理的 GPU 资源也没有释放。浏览器垃圾回收与 Three.js 资源生命周期不是一回事。
本文按 Three.js r180 官方资源释放说明编写。示例适用于安装对应版本 three 的浏览器模块项目,需要可用的 WebGL2;不是把题库网站改成三维应用,也不承诺所有设备都支持。
移除、释放、断开引用分别解决什么
scene.remove(mesh) 让对象不再参与该场景的遍历,但不会自动 dispose 它的 geometry、material 和 texture。因为这些对象可能被其他 mesh 共用,渲染器无法替业务决定是否还能使用。
geometry.dispose() 释放关联的 GPU 缓冲;material.dispose() 通知释放材质相关资源,但不会递归释放纹理。texture.dispose() 要单独调用。若纹理由 ImageBitmap 提供,还需在确定没有其他消费者时调用 bitmap.close() 释放相应 CPU 侧资源。
最后,JavaScript 数组、缓存、组件状态和闭包中的引用也应移除。dispose 并不意味着对象变量变成 null,更不意味着动画回调、ResizeObserver 或 DOM 监听器自动停止。
一个独占资源的完整挂载例子
假设容器由页面提供明确高度,例如 style="height: 320px"。把函数放入浏览器模块,调用 const cleanup = mountPreview(container),路由卸载时调用 cleanup。这个场景只有一个立方体,没有共享模型或外部资源,因而所有资源都由这次挂载独占。
import * as THREE from 'three';
export function mountPreview(container) {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.domElement.style.display = 'block';
container.append(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.z = 4;
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshNormalMaterial();
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
let disposed = false;
function resize() {
if (disposed) return;
const width = Math.max(1, container.clientWidth);
const height = Math.max(1, container.clientHeight);
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
const observer = new ResizeObserver(resize);
observer.observe(container);
resize();
renderer.setAnimationLoop((time) => {
if (disposed) return;
cube.rotation.y = time * 0.0005;
renderer.render(scene, camera);
});
return function cleanup() {
if (disposed) return;
disposed = true;
renderer.setAnimationLoop(null);
observer.disconnect();
scene.remove(cube);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.domElement.remove();
};
}
顺序先停止生产新帧和回调,再释放被回调使用的资源。disposed 使重复清理安全,也保护已经排队的尺寸回调。宿主调用 cleanup 后还应删除保存它的引用,否则闭包仍保留相关 JavaScript 对象。
创建 WebGLRenderer 可能失败,宿主需捕获异常并显示静态产品图或说明。示例聚焦正常挂载后的资源生命周期,没有实现初始化中途异常的逐项回滚。实际组件若还创建控制器、后处理目标或加载器,应让每一步成功创建的资源都登记到清理范围。
为什么不能遍历场景后无条件 dispose 所有东西
两个商品可能共享同一张环境贴图,多个 mesh 可能共享一个材质。关闭 A 时直接 dispose 共享资源,会让仍在显示的 B 重新上传或编译资源,产生卡顿。更合理的方式是明确所有者:页面独占资源由页面清理,共享资源由资源管理层在最后一个消费者释放后处理。
同一个对象在遍历中出现多次,可以用 Set 去重;但 Set 只解决重复调用,不证明所有权。scene.environment、后处理 render target、控制器监听器和动画 mixer 也不一定包含在 mesh.material 的遍历路径里,要按所用类的文档分别管理。
异步加载结束时页面可能已经离开
用户切走后 glTF 请求仍可能完成。仅取消页面 render 不会让返回的模型自动释放。加载流程应记录所属页面或请求代次,完成时先检查是否仍被需要;过期结果不要加入场景,而应交给资源所有者释放。如果加载器支持取消就调用取消,但仍保留迟到结果检查。
不要声称所有 Three.js loader 都接受同一个 AbortSignal。文件、纹理与解码 Worker 的取消能力需逐一确认。对用户模型限制文件大小、纹理尺寸和解码资源,并控制外部资源来源;跨源加载与 CORS 也要由资源服务器配合。
怎样判断修复有效
固定同一个模型,重复挂载、渲染、卸载,观察 JS 堆快照及 renderer.info 中的几何体、纹理和程序统计。比较多轮操作后的稳定基线,不要求所有数字归零:Three.js 官方说明部分内部缓存会复用,并可能在场景清理后仍显示。
renderer.info 不是显存字节精确计量器。纹理尺寸、像素比和后处理目标都会增加实际资源成本;devicePixelRatio 翻倍可能使画布像素数约为四倍。真实 GPU、移动设备和上下文丢失行为需要另外验证,单元测试只能验证清理调用和生命周期协议。
静态预览可考虑按需渲染而不是永久动画循环;大量页面共享一个 renderer 时,页面卸载只释放页面拥有的资源,不能把宿主 renderer 一并销毁。选择策略应从资源所有权和实际交互出发。