本文正在参加「金石计划」
问题出现背景
公司在做大屏可视化项目时 ,有多个子页面,每个页面都有 3D 场景 , 我负责的是 threejs 模块 , 在项目起步时 , 还没有意识到问题的严重性 , 项目没有出现过崩溃的迹象 , 但是在项目的模块逐渐增加 , 项目体量逐渐庞大之后 , 项目开始在频繁的切屏之后崩溃 。F12 打开控制台 , 发现浏览器报错 : 内存溢出
在 threejs 项目中 , 加载模型之后 , 模型的材质 , 几何体 , 贴图等等都会增加浏览器的内存 , F12 选择memory(内存) 可以查看到
针对这个问题 , 项目创建之初我们做了一些基础的操作 , 例如在每个页面销毁的时候 , 使用常规的清理方法 clear(),尝试清除 threejs 场景中加载模型带来的内存,但是这并没有实现一个好的效果
因为项目中的每一个子页面 , 都需要展示特定的对应的模型 , 所以在重复的切屏之后, 每一次路由切换都会因为需要重新加载场景,并且之前的场景的内存在浏览器中无法清除,内存逐渐增多 , 直到崩溃
探索解决方案
经过一个上午的思考 , 选定的解决方案有 2 个 , 这里主要讲 方案一 , 方案二粗略带过一下
- 对 threejs 创建出来的场景进行统一管理 , 对每一块内存进行追踪,在页面销毁时统一处理
- 利用 iframe 标签,将 3D场景 的渲染统一交给 iframe ,利用 MessageChannel 进行交互
方案一
首先 ,装一个类 MemoryManager 对内存进行统一管理
import * as THREE from "three";
export default class MemoryManager {
resources: Set<unknown>;
constructor() {
this.resources = new Set();
}
// 收集资源
track(resource: any) {
if (!resource) {
return resource;
}
// 当资源是一组材质,或者一组纹理,递归每一项
if (Array.isArray(resource)) {
resource.forEach((resource) => this.track(resource));
return resource;
}
// 当资源有 dispose 方法,或者 是 Object3D 对象
if (resource.dispose || resource instanceof THREE.Object3D) {
this.resources.add(resource);
}
// Object3D 对象
if (resource instanceof THREE.Object3D) {
// Mesh 对象时,追踪 几何体和材质
if (resource instanceof THREE.Mesh) {
this.track(resource.geometry);
this.track(resource.material);
}
this.track(resource.children);
} else if (resource instanceof THREE.Material) {
// We have to check if there are any textures on the material
for (const value of Object.values(resource)) {
if (value instanceof THREE.Texture) {
this.track(value);
}
}
// We also have to check if any uniforms reference textures or arrays of textures
if ((resource as any).uniforms) {
for (const value of Object.values((resource as any).uniforms)) {
if (value) {
const uniformValue = (value as any).value;
if (
uniformValue instanceof THREE.Texture ||
Array.isArray(uniformValue)
) {
this.track(uniformValue);
}
}
}
}
}
return resource;
}
untrack(resource: any) {
this.resources.delete(resource);
}
// 进行统一释放GPU内存
dispose() {
for (const resource of this.resources) {
if (resource instanceof THREE.Object3D) {
if (resource.parent) {
resource.parent.remove(resource);
}
}
if ((resource as any).dispose) {
(resource as any).dispose();
}
}
this.resources.clear();
}
}
然后,封装一个类SceneManager对场景进行统一管理 , 以下是简化后的核心代码, 在该类中,对 Scene.add() 方法进行了封装,在往场景添加对象时会同时进行内存的收集。
import * as THREE from "three";
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import ResourceTracker from "./memoryManager";
export class SceneManager {
dom: HTMLElement;
scene: THREE.Scene;
camera!: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
controls!: OrbitControls;
animationId: number = 0;
trackManager: ResourceTracker;
constructor(dom: HTMLElement) {
// trackManager
this.trackManager = new ResourceTracker();
}
// 使用封装的 add 方法向场景添加 Object3D 对象,对对象进行追踪处理
add(obj: THREE.Object3D) {
this.scene && this.scene.add(obj);
this.trackManager && this.trackManager.track(obj);
}
// track object 3d
track(obj: THREE.Object3D) {
return this.trackManager.track(obj);
}
// animation loop
animate() {
this.animationId = requestAnimationFrame(this.animate.bind(this));
this.renderer && this.renderer.render(this.scene, this.camera);
this.controls && this.controls.update();
}
// 统一处理所有资源
destroy() {
// dispose geometry and material
this.scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
obj.material.map?.dispose();
obj.material.map = null;
obj.material.dispose();
}
if (obj instanceof THREE.Light) {
obj.dispose();
}
});
this.trackManager.dispose();
// console.log(this.renderer.info.memory);
// dispose all
cancelAnimationFrame(this.animationId);
this.scene.clear();
this.renderer.renderLists.dispose();
this.renderer.dispose();
this.renderer.forceContextLoss();
this.dom.removeChild(this.renderer.domElement);
this.dom = null;
this.renderer.domElement = null;
this.scene = null;
this.camera = null;
this.renderer = null;
THREE.Cache.clear();
}
}
方案二
来自于官网的方法,利用 iframe 创建新的 window窗口渲染 3D场景,在路由切换时,也不会造成额外的内存,具体原理暂时还不清楚 。 需要解决的问题就是与主窗口的通信问题,这里我使用的是 MessageChannel