WebGL的内存泄露与清理

1,483 阅读10分钟

源码:github.com/buglas/thre…

1-内存泄漏的概念

在c++ 里,内存泄漏的概念是:指针在指向一块内存空间的时候,指针因为各种原因没有了,然而指针指向的内存空间却没有被清理掉,这时你再想清理这块空间却不知道怎么找到它了,这就导致了内存泄漏。

1-1-js垃圾回收机制

在js 里,内存泄漏可以在多数情况下被自动解决,因为js 有一套自己的垃圾回收机制。

js 的垃圾回收机制类似于一张内存空间引用表,如下图所示:

img

每一个点代表了一块内存空间,若内存空间与上层根节点断开了引用关系,如左下角的蓝点,那它就会被清理掉,从而释放内存空间。

在js中如下所示:

const a={b:{c:new Array(1e6).join('x')}}
a.b.c=null
a.b=null

我通过将b或c设置为null,都可以清理掉c指向的内存空间。

如果我们不想让数组变成null,也可以将数组清空:

a.b.c.length = 0

在此,我们要知道,把null 赋值给变量并不是目的,我们的目的是让变量指向的内存空间断开与上层节点的联系。

因此,我们将undefined 赋值给变量也可以实现清理内存的目的。

a.b.c=undefined 
a.b=undefined 

如果变量是局部变量,那出了它所在的局部作用域后,这个变量就会被js的垃圾回收机制清理掉。

{
    const a=[1,2,3]
}
a //a is not defined

js 中,引起内存泄漏的原因有很多,比如setTimeout()、setInterval()、requestAnimationFrame() 等,你若不给它一个指针,你就无法将其清理掉,从而造成内存泄漏。

1-2-WebGLBuffer中的内存

WebGL 里的WebGLBuffer中的内存并不受js 的垃圾回收机制的管理,所以我们需要手动清理。

以下面的代码为例,我们通过gl.createBuffer() 方法建立了一个WebGLBuffer对象-vertexBuffer。

const gl = canvas.getContext('webgl')
const vertices = new Float32Array(new Array(1e8).join(1))
let vertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
gl.bufferData(
  gl.ARRAY_BUFFER,
  vertices,
  gl.STATIC_DRAW
)

vertexBuffer 对象里的顶点数据vertices 必然是存储在一块内存空间里的,vertexBuffer 可以理解为指向这块内存空间的指针。

如果我们想要清理这块内存空间,我们无法像js 那样写:

vertices=null
vertexBuffer=null

vertices=null 清理的是顶点数据在js中的内存空间,当我们将vertices传递给WebGLBuffer对象的时候,是以深拷贝的原理传递的,所以我们将vertices设置为null,或者改写vertices 中的数据,都不会对WebGLBuffer对象产生影响,除非你再通过bufferData()方法传递一次数据。

vertexBuffer=null 清理的只是WebGLBuffer对象,它不等于清理掉了WebGLBuffer对象中的顶点数据。

因此,我们要清理WebGLBuffer对象中的顶点数据,就需要使用WebGL 提供的deleteBuffer(buffer) 方法手动清理。

gl.deleteBuffer(vertexBuffer)

同理类推,所有存储在WebGLBuffer对象中数据都需要手动清理,除了顶点相关的attribute数据,还有uniform、texture 相关的数据。

2-清理WebGL内存的必要性

WebGL相关的图形项目往往是比较费内存的,在很多情况下,我们需要做好资源管理和内存管理。

举2个需要清理内存的情况:

  • 单面项目

    在vue、react之类的单页面项目里,我们在切换页面的时候,并不会清理WebGLBuffer对象里的数据,这就造成了内存泄漏。

  • 频繁切换模型数据的项目

    比如我们在一个虚拟家居体验馆里,有上百个沙发模型供用户切换,若不做内存清理,那随着不同模型的切换,就会造成内存堆积。

3-在three.js项目中清理内存

3-1-基本操作

在three.js 中,geometry、material和texture 相关的对象都是会用到webGL的WebGLBuffer对象的。

因此,我们需要针对这3种对象做清理,方法如下:

const geometry = new BoxGeometry(1, 1, 1)
const loader = new TextureLoader()
const texture = loader.load('./textures/wall.jpg')
const material = new THREE.MeshPhongMaterial({map: texture});
geometry.dispose();
texture.dispose();
material.dispose();

然而随着项目中的模型资源增加,这种写法会变得繁琐,所以我们需要建立一个资源追踪器,以便对资源进行追踪和统一清理。

3-2-封装ResourceTracker类

建立一个ResourceTracker类:

import { BufferGeometry, Material, Texture } from "three";

type SourceType=BufferGeometry|Material|Texture

class ResourceTracker {
  resources:Set<SourceType>
  constructor() {
    this.resources = new Set();
  }
  track(resource:SourceType) {
    this.resources.add(resource);
    return resource;
  }
  untrack(resource:SourceType) {
    this.resources.delete(resource);
  }
  dispose() {
    for (const resource of this.resources) {
      resource.dispose();
    }
    this.resources.clear();
  }
}
export {ResourceTracker}

画一个box,用来演示ResourceTracker 对象的用法。

const scene = new Scene()
const resTracker = new ResourceTracker()

const geometry = new boxGeometry(1, 1, 1)
resTracker.track(geometry)

const loader = new TextureLoader()
const texture = loader.load(
    'https://threejs.org/manual/examples/resources/images/wall.jpg'
)
texture.colorSpace = SRGBColorSpace
resTracker.track(texture)

const material = new MeshBasicMaterial({
    map: texture,
})
resTracker.track(material)
const box = new Mesh(geometry, material)
scene.add(box)

接下来,要清理box所占用的内存,我们需要这样做:

scene.remove(box)
resTracker.dispose()

scene.remove(box) 是为了清理模型在js中占用的内存空间,当一个模型的geometry和material 都被清理掉后,这个模型也就没必要存在了。

如果你的scene 对象是一个局部变量,那出了局部作用域后,scene 以及其内部的数据就会被js的垃圾回收机制清理掉。

3-3-remove模型的封装

为了考虑各种情况,我们可以把scene.remove(box) 这样的操作一起封装到ResourceTracker 中。

import { BufferGeometry, Material, Mesh, Object3D, Texture } from "three";

type SourceType=BufferGeometry|Material|Texture|Object3D

class ResourceTracker {
  resources:Set<SourceType>
  constructor() {
    this.resources = new Set();
  }
  track(resource:SourceType) {
    this.resources.add(resource);
    return resource;
  }
  untrack(resource:SourceType) {
    this.resources.delete(resource);
  }
  dispose() {
    for (const resource of this.resources) {
      if(resource instanceof Object3D){
        resource.parent?.remove(resource);
      }else{
        resource.dispose();
      }
    }
    this.resources.clear();
  }
}
export {ResourceTracker}

这样我就可以全部使用dispose() 方法清理内存:

const resTracker = new ResourceTracker()
const geometry = new BoxGeometry(1, 1, 1)
resTracker.track(geometry)
const loader = new TextureLoader()
const texture = loader.load(
    'https://threejs.org/manual/examples/resources/images/wall.jpg'
)
texture.colorSpace = SRGBColorSpace
resTracker.track(texture)
const material = new MeshBasicMaterial({
    map: texture,
})
resTracker.track(material)
const box= new Mesh(geometry, material)
resTracker.track(box)
scene.add(box)

resTracker.dispose()

然而我们逐条追踪模型geometry、material 和texture 的方法还是有点繁琐的,我们可以以Object3D 为单位追踪资源。

3-4-优化资源追踪

在下面的代码里,我们对Mesh 中的geometry和material进行了追踪。

track(resource:SourceType) {
  this.resources.add(resource);
  if(resource instanceof Object3D){
    if (resource instanceof Mesh) {
      this.track(resource.geometry);
      this.track(resource.material);
    }
  }
  return resource;
}

因为material 可能是数组,Object3D 中可能有子元素,所以我们需要再做深度追踪。

track(resource:SourceType|Material[]|Object3D[]) {
  if(Array.isArray(resource)){
    for(let child of resource){
      this.track(child);
    }
  }else{
    this.resources.add(resource);
    if(resource instanceof Object3D){
      if (resource instanceof Mesh) {
        this.track(resource.geometry);
        this.track(resource.material);
      }
      this.track(resource.children);
    }
  }
  return resource;
}

接下来,我们还得追踪material 中的texture。

texture 可能直接在Material中,也可能在Material 的uniforms 中。

track(resource:SourceType|Material[]|Object3D[]) {
  if(Array.isArray(resource)){
    ……
  }else{
    this.resources.add(resource);
    if(resource instanceof Material){
      for (const value of Object.entries(resource)) {
        if (value instanceof Texture) {
          this.track(value);
        }
      }
      if ('uniforms' in resource) {
        for (const uniform of Object.values(resource.uniforms as object)) {
          if(!uniform){continue}
          const uniformValue = uniform.value;
          if (uniformValue instanceof Texture ||Array.isArray(uniformValue)) {
              this.track(uniformValue);
          }
        }
      }
    }
    ……
  }
  return resource;
}

接下来,咱们举个完整些的例子。

4-内存清理示例

4-1-封装图形项目

通常我们在开发图形项目的时候,会把图形学部分封装成一个类,如下所示:

import { BoxGeometry, Color, EquirectangularReflectionMapping, EventDispatcher, Mesh, MeshStandardMaterial, PerspectiveCamera, Scene, SRGBColorSpace, TextureLoader, WebGLRenderer } from "three";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader"
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"
import { ResourceTracker } from "./ResourceTracker";

class Box extends EventDispatcher<any>{
  robotType:string
  renderer=new WebGLRenderer({ antialias: true })
  scene=new Scene()
  camera=new PerspectiveCamera(45, 1, 0.01, 200)
  controls=new OrbitControls(this.camera,this.renderer.domElement)
  box=new Mesh()
  resourceTracker=new ResourceTracker()
  frame=0

  constructor(type:string){
    super()
    this.robotType=type
  }
  init(){
    const {resourceTracker}=this
    this.renderer.setPixelRatio(window.devicePixelRatio)
    this.scene.background=new Color(0xf6f6f8)
    this.camera.position.set(0, 1.2, 3.6)
    this.camera.lookAt(0,0,0)
    new RGBELoader().loadAsync(
      './textures/venice_sunset_1k.hdr',
    ).then((texture)=>{
      texture.mapping = EquirectangularReflectionMapping
          this.scene.environment = texture
      resourceTracker.track(texture)
    })

    const boxGeometry = new BoxGeometry(1, 1, 1)
    const boxMaterial = new MeshStandardMaterial({
      metalness:1,
      roughness:0.3
    })
    this.box = new Mesh(boxGeometry, boxMaterial)
    this.scene.add(this.box );
    resourceTracker.track(this.box)

    new TextureLoader().load(
      './textures/wall.jpg',
      (map)=>{
        map.colorSpace = SRGBColorSpace
        boxMaterial.map=map
        boxMaterial.needsUpdate=true
        resourceTracker.track(map)
      }
    )
  }

  rotate(angle:number){
    this.box.rotation.y=angle
  }

  resize(width: number, height: number) {
    const {renderer,camera}=this
    camera.aspect = width / height
    camera.updateProjectionMatrix()
    renderer.setSize(width, height, true)
  }

  render(time=0){
    const { renderer,scene, camera} = this
    this.dispatchEvent( { type: 'beforerender',time});
    renderer.render(scene, camera);
    this.dispatchEvent( { type: 'affterrender',time});
  }

  animate(time=0){
    this.render(time)
    this.frame=requestAnimationFrame(this.animate.bind(this))
  }

  dispose(){
    cancelAnimationFrame(this.frame)
    this.resourceTracker.dispose()
    this.renderer?.dispose()
    this.controls?.dispose()
  }
}

export {Box}

这个Box 是个demo,但你可以把它想象成机器人、汽车等物体。

这个demo 具备我们在架构图形项目时常用的属性和方法,代码比较简单,大家可以自己看,我就不再赘述了。

接下咱们实例化这个Box 。

4-2-实例化图形项目

我在react 项目里建立了一个Memory02.tsx 文件,用于实例化图形项目。

我这里重点说原理,无论你学的是react,还是vue,都没有关系。

整体代码如下:

import React, { useRef, useEffect } from 'react'
import './fullScreen.css'
import { Box } from '../component/Box'
import { Link } from 'react-router-dom'


const Memory02: React.FC = (): JSX.Element => {
  const divRef = useRef<HTMLDivElement>(null)
  const box=new Box('Memory02')
  box.init()
  box.addEventListener('beforerender',({time})=>{
    box.rotate(time*0.001)
  })
  box.animate()
  useEffect(() => {
    const { current } = divRef
    if (!current) {
        return
    }
      const {renderer:{domElement}}=box
      current.append(domElement)
      box.resize(current.clientWidth,current.clientHeight)
      return () => {
        box.dispose()
        domElement.remove()
      }
  }, [])
  return <div ref={divRef} className="canvasWrapper">
    <div style={{position:'absolute',margin:'15px'}}>
      <Link to="/">home</Link>
    </div>
  </div>
}

export default Memory02

解释一下上面的代码。

useEffect

useEffect 是 React 的一个 Hook,它是componentDidMount、componentDidUpdate 和 componentWillUnmount 三个生命周期方法的组合,其原理如下:

const Memory02: React.FC = (): JSX.Element => {
    const [count1, setCount1] = useState(0);  
    const [count2, setCount2] = useState(0); 
    useEffect(() => {  
      // 只具备componentDidMount 功能
      return ()=>{
        // 只具备componentWillUnmount 功能
      }
    },[]); 
    useEffect(() => {  
      // 具备componentDidMount、componentDidUpdate 功能,随所有State的更新而执行
    });
    useEffect(() => {  
      // 具备componentDidMount、componentDidUpdate 功能,随count1 的更新而执行
    },[count1]);  
    useEffect(() => {  
      // 具备componentDidMount、componentDidUpdate 功能,随count2 的更新而执行
    },[count2]);  
    ……
}

vue中与之对应的三个生命周期就是mounted、updated和beforeUnmount。

哪里适合实例化图形项目

图形项目的实例化会执行构造函数,其功能通常是做一些整体的属性配置。比如声明图形项目要展示的模型类型,以便在后面根据这个类型加载相应的数据。

图形项目的实例化有以下作用:

  • 使图形项目生效。
  • 做整体配置。这个需要根据项目的需求走,有时候项目的实例化和初始化是合在一起的。

图形项目可以在3个地方实例化:

//位置1
const box=new Box('Memory02')
const Memory02: React.FC = (): JSX.Element => {
    //位置2
    const box=new Box('Memory02')
    useEffect(() => {
        //位置3
        const box=new Box('Memory02')
    }, [])    
}

位置1

如果页面不是懒加载的,Box 只会在项目启动时就被实例化一次。

  • 优点:是避免页面切换时,重复实例化。
  • 缺点:占用首页渲染时间和内存,当类似页面很多时,会造成内存堆积。

如果页面是懒加载的,Box 会随页面的切换重复实例化。

在react 中,页面的懒加载方法如Memory02 所示。

import React, { Suspense, lazy } from 'react'
import { useRoutes } from 'react-router-dom'
import './App.css'
//正常加载
import Memory03 from './view/Memory03'
//懒加载
const Memory02 = lazy(()=>import('./view/Memory02'))

const App: React.FC = (): JSX.Element => {
    const routing = useRoutes([
        {
            path: 'Memory02',
            element: <Suspense><Memory02 /></Suspense>,
        },
        {
            path: 'Memory03',
            element: <Memory03 />,
        },
        ……
    ])
    return <>{routing}</>
}

export default App

vue中也有页面懒加载功能,我就不再赘述了。

位置2

此处可以让图形项目在每次进入页面的时候都示例化一次,离开页面的时候,图形项目在js中的内存都会被清理掉。当然,WebGLBuffer 中的内存还需要手动清理。

如果对象的实例化比较占内存,推荐此方法。

位置3

在componentDidMount 时实例化,若实例化与dom元素无关,不推荐此位置。

初始化

初始化方法一般是跟在对象的实例化后面的,或者直接合到构造函数里。

但初始化方法不能在位置1,这里的代码在非懒加载时,只会在项目启动时运行一次,所以当我们在切换页面前,清理内存后,再切换回来时,图形项目会无法进行初始化。

代码如下:

  • App.tsx
import Memory03 from './view/Memory03'
const App: React.FC = (): JSX.Element => {
    const routing = useRoutes([
        {
              path: 'Memory03',
            element: <Memory03 />,
        }
    ])
    return <>{routing}</>
}
export default App
  • Memory03.tsx
//位置1
const box=new Box('Memory03')
//错误位置
box.init()
const Memory03: React.FC = (): JSX.Element => {
    ……
}

init() 方法只要跟dom 无关,建议放到位置2:

const Memory03: React.FC = (): JSX.Element => {
    ……
    //位置2
    box.init()
    useEffect(() => {
        ……
    }, [])
    ……
}

将canvas嵌入dom 元素

WebGL 中的canvas 是需要嵌入到dom 元素中的,这就需要我们在componentDidMount 生命周期中执行此操作。

如果WebGL 要从此dom 元素上获取渲染尺寸,那我们也要在componentDidMount 生命周期中,让WebGL适配dom 元素尺寸。

代码如下:

const Memory03: React.FC = (): JSX.Element => {
    const divRef = useRef<HTMLDivElement>(null)
    const box=new Box('Memory03')
    box.init()
    box.addEventListener('beforerender',({time})=>{
        box.rotate(time*0.001)
    })
    useEffect(() => {
        const { current } = divRef
        if (!current) {
            return
        }
        const {renderer:{domElement}}=box
        current.append(domElement)
        box.resize(current.clientWidth,current.clientHeight)
        box.animate()
        return () => {
              box.dispose()
            domElement.remove()
        }
    }, [])
    return <div ref={divRef} className="canvasWrapper">
        <div style={{position:'absolute',margin:'15px'}}>
          <Link to="/">home</Link>
        </div>
    </div>
}

内存的清理

内存的清理是要放到beforeUnmount 或者unmounted 生命周期中的。

    useEffect(() => {
        ……
        return () => {
            box.dispose()
            domElement.remove()
        }
    }, [])

总结

这一章我们说了js 垃圾回收机制,WelGL内存清理的原因、必要性和方法。

严谨的内存管理是避免我们在造船的时候越造越烂的方法之一。