学习 threejs 一定看过来,内存溢出?项目崩溃?threejs 进阶之内存管理

3,644 阅读3分钟

本文正在参加「金石计划」

问题出现背景

公司在做大屏可视化项目时 ,有多个子页面,每个页面都有 3D 场景 , 我负责的是 threejs 模块 , 在项目起步时 , 还没有意识到问题的严重性 , 项目没有出现过崩溃的迹象 , 但是在项目的模块逐渐增加 , 项目体量逐渐庞大之后 , 项目开始在频繁的切屏之后崩溃 。F12 打开控制台 , 发现浏览器报错 : 内存溢出

3b415a2cc7bc49907a30e4bffb0bbf9.png

在 threejs 项目中 , 加载模型之后 , 模型的材质 , 几何体 , 贴图等等都会增加浏览器的内存 , F12 选择memory(内存) 可以查看到

image.png

针对这个问题 , 项目创建之初我们做了一些基础的操作 , 例如在每个页面销毁的时候 , 使用常规的清理方法 clear(),尝试清除 threejs 场景中加载模型带来的内存,但是这并没有实现一个好的效果

image.png

因为项目中的每一个子页面 , 都需要展示特定的对应的模型 , 所以在重复的切屏之后, 每一次路由切换都会因为需要重新加载场景,并且之前的场景的内存在浏览器中无法清除,内存逐渐增多 , 直到崩溃

image.png

探索解决方案

经过一个上午的思考 , 选定的解决方案有 2 个 , 这里主要讲 方案一 , 方案二粗略带过一下

  1. 对 threejs 创建出来的场景进行统一管理 , 对每一块内存进行追踪,在页面销毁时统一处理
  2. 利用 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

image.png