Three.js 에서 dispose()는 어떻게 작동할까?

April 28, 2023

threejs에서 dispose를 안 하면 gc가 제대로 수거하지 못 한다. dispose() 명령어를 이용해서 명시적으로 제거해야 한다. 링크

기하학은 속성 집합으로 정의된 꼭짓점 정보를 표시하는데, three.js는 속성마다 하나의 WebGLBuffer 유형의 대상을 만들어 내부에 저장합니다. 이러한 개체는 BufferGeometry.dispose를 호출할 때만 폐기됩니다. 만약 애플리케이션에서 기하학을 더이상 사용하지 않는다면, 이 방법을 실행하여 모든 관련 자원을 폐기하세요.

왜 Three.js에서는 dispose()를 해야만 지울 수 있는걸까? Three.js에서 dispose()를 하면 어떤 일이 일어날까?

dispose()

대부분의 만들어져 있는 Object 코드들을 보면, dispose() 함수에 다음과 같이 오버라이딩을 한다.

// src/helpers/Box3Helper.js dispose() { this.geometry.dispose(); this.material.dispose(); }

각각의 geometry와 material에 dispose()를 실행하는데, geometry부터 확인해 보자.

Geometry

src/core/BufferGeometry.js를 보면,

dispose() { this.dispatchEvent( { type: 'dispose' } ); }

다음과 같이 dispose 이벤트를 dispatch한다. 그럼 어디서 저 이벤트를 받을까?

찾아보니 다음과 같은 파일들에서 이벤트를 받고 있다:

  • src/renderers/WebGLRenderer.js
  • src/renderers/webgl/WebGLCubeMaps.js
  • src/renderers/webgl/WebGLCubeUVMaps.js
  • src/renderers/webgl/WebGLGeometries.js
  • src/renderers/webgl/WebGLMorphtargets.js
  • src/renderers/webgl/WebGLObjects.js
  • src/renderers/webgl/WebGLTextures.js
  • src/renderers/webgl/WebGLUniformsGroups.js

그 중 geometry는 WebGLGeometries.js에서 관리한다:

// src/renderers/webgl/WebGLGeometries.js function WebGLGeometries( gl, attributes, info, bindingStates ) { const geometries = {}; const wireframeAttributes = new WeakMap(); function get( object, geometry ) { if ( geometries[ geometry.id ] === true ) return geometry; geometry.addEventListener( 'dispose', onGeometryDispose ); geometries[ geometry.id ] = true; info.memory.geometries ++; return geometry; } function onGeometryDispose( event ) { const geometry = event.target; if ( geometry.index !== null ) { attributes.remove( geometry.index ); } for ( const name in geometry.attributes ) { attributes.remove( geometry.attributes[ name ] ); } geometry.removeEventListener( 'dispose', onGeometryDispose ); delete geometries[ geometry.id ]; const attribute = wireframeAttributes.get( geometry ); if ( attribute ) { attributes.remove( attribute ); wireframeAttributes.delete( geometry ); } bindingStates.releaseStatesOfGeometry( geometry ); if ( geometry.isInstancedBufferGeometry === true ) { delete geometry._maxInstanceCount; } // info.memory.geometries --; } ...

내부 geometrieswireframeAttributes에서 값을 제거하고, 그리고 bindingStates.releaseStatesOfGeometry()를 호출한다.

// src/renderers/webgl/WebGLBindingStates.js function releaseStatesOfGeometry( geometry ) { if ( bindingStates[ geometry.id ] === undefined ) return; const programMap = bindingStates[ geometry.id ]; for ( const programId in programMap ) { const stateMap = programMap[ programId ]; for ( const wireframe in stateMap ) { deleteVertexArrayObject( stateMap[ wireframe ].object ); delete stateMap[ wireframe ]; } delete programMap[ programId ]; } delete bindingStates[ geometry.id ]; } function deleteVertexArrayObject( vao ) { if ( capabilities.isWebGL2 ) return gl.deleteVertexArray( vao ); return extension.deleteVertexArrayOES( vao ); }

releaseStatesOfGeometry()에서 바인딩 되어있는 programMap을 제거하고, deleteVertexArray()를 호출해서 vertex를 제거한다(WebGL API이다)

  • 이 부분이 중요, 크롬 기준으로 delete bindingStates[ geometry.id ];로 bindingStates 안에서 지우기만 해도 메모리 누수가 생기지 않는다.

Material

Material은 어떨까? 똑같이 dispatchEvent()를 트리거한다.

// src/materials/Material.js dispose() { this.dispatchEvent( { type: 'dispose' } ); }

그리고 src/renderers/WebGLRenderer.js 에서 받는다.

// src/renderers/WebGLRenderer.js function onMaterialDispose( event ) { const material = event.target; material.removeEventListener( 'dispose', onMaterialDispose ); deallocateMaterial( material ); } // Buffer deallocation function deallocateMaterial( material ) { releaseMaterialProgramReferences( material ); properties.remove( material ); } function releaseMaterialProgramReferences( material ) { const programs = properties.get( material ).programs; if ( programs !== undefined ) { programs.forEach( function ( program ) { programCache.releaseProgram( program ); } ); if ( material.isShaderMaterial ) { programCache.releaseShaderCache( material ); } } }

properties: src/renderers/webgl/WebGLProperties.js 에서 import한 WebGLProperties()이며, WeakMap이다.

WeakMap은 Map처럼 key-value object인데, key에는 객체 또는 등록되지 않은 symbol이어야 한다는 점이 큰 차이점이다. 그리고 WeakMap에서 참조하고 있는 key는 gc 수집 대상이다. 따라서 WeakMap에서만 참고하고 있는 객체는 gc한테 수거당한다.

해당 material이 있는 program의 캐시를 지우고, shader cache까지 지우는 걸 확인할 수 있다(WebGLShaderCache 안에서 Map으로 관리하고 있다).

// src/renderers/webgl/WebGLPrograms.js programs = [] ... function releaseProgram( program ) { if ( -- program.usedTimes === 0 ) { // Remove from unordered set const i = programs.indexOf( program ); programs[ i ] = programs[ programs.length - 1 ]; programs.pop(); // Free WebGL resources program.destroy(); } }

위와 같은 방법으로 리스트에서 제거한다.

  • splice() 보다 이 방법이 더 빨라서 해당 방법으로 제거하는 것으로 보인다.
// src/renderers/webgl/WebGLProgram.js this.destroy = function () { bindingStates.releaseStatesOfProgram( this ); gl.deleteProgram( program ); this.program = undefined; };

program.destry()에선 gl.deleteProgram()을 호출한다(WebGL API이다).

Object

// src/renderers/webgl/WebGLObjects.js function dispose() { updateMap = new WeakMap(); } function onInstancedMeshDispose( event ) { const instancedMesh = event.target; instancedMesh.removeEventListener( 'dispose', onInstancedMeshDispose ); attributes.remove( instancedMesh.instanceMatrix ); if ( instancedMesh.instanceColor !== null ) { attributes.remove( instancedMesh.instanceColor ); } }

Control

// OrbitControls.js this.dispose = function () { scope.domElement.removeEventListener( 'contextmenu', onContextMenu ); scope.domElement.removeEventListener( 'pointerdown', onPointerDown ); scope.domElement.removeEventListener( 'pointercancel', onPointerUp ); scope.domElement.removeEventListener( 'wheel', onMouseWheel ); scope.domElement.removeEventListener( 'pointermove', onPointerMove ); scope.domElement.removeEventListener( 'pointerup', onPointerUp ); if ( scope._domElementKeyEvents !== null ) { scope._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown ); scope._domElementKeyEvents = null; } //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? };

control 관련 코드들에서는 추가적으로 removeEventListener()를 호출해서 정리한다.

결론

geometry의 경우 내부 geometries array와, WebGLBindingStates의 내부 프로퍼티 bindingStates에 있는 programMap을 지우고,

material의 경우 관련 정보는 다 WeakMap()에 저장되어 있고, dispose를 할 때도 program cache와 shader cache에서 값을 삭제한다.

따라서 dispose()를 한 뒤 다시 사용해도 런타임 에러는 발생하지 않는다(캐시에 없기 때문에 다시 만들 뿐).

Spector.js 등으로 찍어보면 geometry, texture를 dispose 안 한 경우 어떤 일이 일어나는지 쉽게 확인 가능하다.

spector.png

의문이 하나 드는데, scene에서 없어진 material은 weakmap 외의 어느 곳에서도 참조되고 있지 않기 때문에, gc가 수거해가지 않나? 하는 점이다. 실제로 three.js example 중 memory test를 돌릴 때, geometry만 dispose()해 줘도 chrome expector에서 메모리 누수가 발생하는 것으로 보이지 않았다.

no_dispose_material.png

no_dispose.png (비교용 - dispose를 하지 않았을 때)

WebGL spector 등으로 찍어봐도 material.dispose(); 만 주석처리 한 경우에는 메모리에 별 다른 변화를 확인할 수 없었다. BasicMaterial이라 그런가 싶다.

spector_no_dispose_material.png

Reference

WeakMap이 알고 싶다 - https://ui.toast.com/posts/ko_20210901

Spector.js - https://spector.babylonjs.com/


Profile picture

Written by Mingyu Kim who works as a front-end engineer.