1-内存泄漏的概念
在c++ 里,内存泄漏的概念是:指针在指向一块内存空间的时候,指针因为各种原因没有了,然而指针指向的内存空间却没有被清理掉,这时你再想清理这块空间却不知道怎么找到它了,这就导致了内存泄漏。
1-1-js垃圾回收机制
在js 里,内存泄漏可以在多数情况下被自动解决,因为js 有一套自己的垃圾回收机制。
js 的垃圾回收机制类似于一张内存空间引用表,如下图所示:
每一个点代表了一块内存空间,若内存空间与上层根节点断开了引用关系,如左下角的蓝点,那它就会被清理掉,从而释放内存空间。
在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内存清理的原因、必要性和方法。
严谨的内存管理是避免我们在造船的时候越造越烂的方法之一。