Three.js: WebGLRenderer memory leak

Created on 27 Feb 2020  路  9Comments  路  Source: mrdoob/three.js

Description of the problem

Environment

  • Three.js 0.113.2
  • Chrome 80.0.3987.122
  • macOS 10.15.3 (19D76)

Test code

I did not supply test case in any online fiddles shmiddles, as they have background processes which leak memory (I tried 馃槃 ).

<style>
  body {
    height: 100vh;
  }
  canvas {
    border: 1px solid green;
  }
</style>

<h1>Hi there</h1>
<p>Lorem ipsum</p>
<canvas class="canvasStatic"></canvas>
'use strict';

import {
  REVISION as ThreeREVISION,
  WebGLRenderer as ThreeWebGLRenderer
} from 'three';

// Setting ENABLE_RENDERER to
// true: has WebGLRenderer creation and destruction in the cycle
// false: does not have WebGLRenderer creation and destruction in the cycle
// useful for A/B test
const ENABLE_RENDERER = true;

// Setting ENABLE_AUTOTICK to
// true: enable automatic cycling
// false: cycling is done by mouse clicking
// useful for heap snapshot stepping
const ENABLE_AUTOTICK = true;

const instance = {
  alive: false,
  canvasElStatic: document.querySelector('.canvasStatic'),
  canvasElOnFly: null
};

function setup () {
  console.log('setup()');
  createLargeScopedMemBlock();
  initCanvasDynamic();
  ENABLE_RENDERER && initRenderer();
}

function shutdown () {
  console.log('shutdown()');
  ENABLE_RENDERER && destroyRenderer();
  destroyCanvasDynamic();
}

function createLargeScopedMemBlock () {
  // create mem usage in order to tickle GC to fire
  // some large string
  new Array(1e6).join('x');
  // numbaaas
  const numArray = [];
  for (let i = 0; i < 1e6; ++i) {
    numArray.push(Math.random());
  }
}

function initCanvasDynamic () {
  console.log('initCanvasDynamic()');
  instance.canvasElOnFly = document.createElement('canvas');
  instance.canvasElOnFly.className = 'canvasOnFly';
  document.body.appendChild(instance.canvasElOnFly);
}

function destroyCanvasDynamic () {
  console.log('destroyCanvasDynamic()');
  instance.canvasElOnFly.parentNode.removeChild(instance.canvasElOnFly);
}

function initRenderer () {
  console.log('initRenderer()');
  instance.rendererObj = new ThreeWebGLRenderer({
    canvas: instance.canvasElStatic,
    // canvas: instance.canvasElOnFly,
    context: null,
    precision: 'highp',
    alpha: false,
    premultipliedAlpha: true,
    antialias: false,
    stencil: false,
    preserveDrawingBuffer: false,
    powerPreference: 'default',
    failIfMajorPerformanceCaveat: false,
    depth: false,
    logarithmicDepthBuffer: false
  });

  instance.rendererObj.autoClear = false;
  instance.rendererObj.debug.checkShaderErrors = true;
  instance.rendererObj.sortObjects = false;
}

function destroyRenderer () {
  console.log('destroyRenderer()');
  instance.rendererObj.renderLists.dispose();
  instance.rendererObj.dispose();
}

function tick () {
  console.log('TICK');
  instance.alive = !instance.alive;
  if (instance.alive) {
    setup();
  }
  else {
    shutdown();
  }
}

// use setTimeout instead of setInterval
function cycle () {
  window.clearTimeout(instance.timeoutObj);
  instance.timeoutObj = window.setTimeout(() => {
    tick();
    cycle();
  }, 1000);
}

console.log('Three REVISION', ThreeREVISION);
if (ENABLE_AUTOTICK) {
  cycle();
}
else {
  document.body.addEventListener('click', tick, false);
}

Case 1

Running for 400 seconds.

ENABLE_RENDERER = false
ENABLE_AUTOTICK = true

Results overview

01-main

A slice somewhere in the beginning

01-start

| Param | Value |
| ------------- | ------------- |
| JS heap | 9 930 340 |
| Nodes | 78 |
| Listeners | 22 |

A slice somewhere in the end

01-end

| Param | Value |
| ------------- | ------------- |
| JS heap | 9 930 160 |
| Nodes | 78 |
| Listeners | 22 |


Case 2

Running for 400 seconds.

ENABLE_RENDERER = true
ENABLE_AUTOTICK = true

Results overview

02-main

A slice somewhere in the beginning

02-start

| Param | Value |
| ------------- | ------------- |
| JS heap | 10 476 604 |
| Nodes | 78 |
| Listeners | 26 |

A slice somewhere in the end

02-end

| Param | Value |
| ------------- | ------------- |
| JS heap | 16 668 252 |
| Nodes | 78 |
| Listeners | 26 |


Case 3

Running for 1500 seconds.

ENABLE_RENDERER = true
ENABLE_AUTOTICK = true

Results overview

03-main

A slice somewhere in the beginning

03-start

| Param | Value |
| ------------- | ------------- |
| JS heap | 10 813 088 |
| Nodes | 78 |
| Listeners | 26 |

A slice somewhere in the end

03-end

| Param | Value |
| ------------- | ------------- |
| JS heap | 36 903 848 |
| Nodes | 78 |
| Listeners | 26 |


Observation

Well, JS Heap keeps going up forever if ENABLE_RENDERER === true. In Case 3 which was run for 25 minutes it increased more that 3 fold. In Case 1 where ENABLE_RENDERER === false memory stays in control excellently.


Bug?

A bug? Undesirable Chrome GC behaviour? Or I am missing the correct way to dispose WebGLRenderer object?


Three.js version
  • [ ] Dev
  • [x] r113
Browser
  • [ ] All of them
  • [x] Chrome
  • [ ] Firefox
  • [ ] Internet Explorer
OS
  • [ ] All of them
  • [ ] Windows
  • [x] macOS
  • [ ] Linux
  • [ ] Android
  • [ ] iOS
Hardware Requirements (graphics card, VR Device, ...)

Most helpful comment

Just something that might be helpful for debugging this that I recently found out about -- you can run chrome with the following flags to enable more precise memory information via window.performance.memory and expose a function for manually triggering garbage collection as window.gc():

chrome.exe --js-flags="--expose-gc" --enable-precise-memory-info

And I commend you for your thorough test cases!

All 9 comments

Just something that might be helpful for debugging this that I recently found out about -- you can run chrome with the following flags to enable more precise memory information via window.performance.memory and expose a function for manually triggering garbage collection as window.gc():

chrome.exe --js-flags="--expose-gc" --enable-precise-memory-info

And I commend you for your thorough test cases!

I guess because normally people have a single renderer for the entire life time of the webpage (which itself may be under 400 seconds), they never noticed any leaks, @kroko

@gkjohnson 馃憤 it was run using --enable-precise-memory-info. instead of --js-flags="--expose-gc" and window.gc() I simply use _Collect garbage_ icon found in Performance tab when needed 馃槈

@makc yeah. nowadays _webpages_ can be also GUIs for memory sensitive devices, SPAs where all (routing) or parts of "view" (_split_ pages) get attached and detached (think React/Preact), window.location.reload() should be avoided as a hack to solve creeping memory. safekeeping three.js singleton in global state is possible, but I rather don't.

everything lead to results as described in OP. now on continuing my tests i start to think it is Heisenbug. 馃う鈥嶁檪

Rerun with this code, added framebuffer construction and destruction to hit more Three.js stuff.

'use strict';

import {
  REVISION as ThreeREVISION,
  WebGLRenderer as ThreeWebGLRenderer,
  WebGLRenderTarget as ThreeWebGLRenderTarget,
  // ==============================
  // Texture Constants
  // -----
  // Wrapping
  ClampToEdgeWrapping as ThreeClampToEdgeWrapping,
  // -----
  // Filters
  LinearFilter as ThreeLinearFilter,
  // -----
  // Types
  FloatType as ThreeFloatType,
  // -----
  // Formats
  RGBAFormat as ThreeRGBAFormat,
  // -----
  // Encoding
  LinearEncoding as ThreeLinearEncoding
} from 'three';

// Setting ENABLE_RENDERER to
// true: has WebGLRenderer creation and destruction in the cycle
// false: does not have WebGLRenderer creation and destruction in the cycle
// useful for A/B test
const ENABLE_RENDERER = true;

// Setting ENABLE_AUTOTICK to
// true: enable automatic cycling
// false: cycling is done by mouse clicking
// useful for heap snapshot stepping
const ENABLE_AUTOTICK = true;

const instance = {
  alive: false,
  canvasElStatic: document.querySelector('.canvasStatic'),
  canvasElOnFly: null
};

function setup () {
  console.log('setup()');
  createLargeScopedMemBlock();
  initCanvasDynamic();
  ENABLE_RENDERER && initRenderer();
  ENABLE_RENDERER && initRenderTargets();
}

function shutdown () {
  console.log('shutdown()');
  ENABLE_RENDERER && destroyRenderTargets();
  ENABLE_RENDERER && destroyRenderer();
  destroyCanvasDynamic();
}

function createLargeScopedMemBlock () {
  // create mem usage in order to tickle GC to fire
  // some large string
  new Array(1e6).join('x');
  // numbaaas
  const numArray = [];
  for (let i = 0; i < 1e6; ++i) {
    numArray.push(Math.random());
  }
}

function initCanvasDynamic () {
  console.log('initCanvasDynamic()');
  instance.canvasElOnFly = document.createElement('canvas');
  instance.canvasElOnFly.className = 'canvasOnFly';
  document.body.appendChild(instance.canvasElOnFly);
}

function destroyCanvasDynamic () {
  console.log('destroyCanvasDynamic()');
  instance.canvasElOnFly.parentNode.removeChild(instance.canvasElOnFly);
}

function initRenderer () {
  console.log('initRenderer()');
  instance.rendererObj = new ThreeWebGLRenderer({
    canvas: instance.canvasElStatic,
    // canvas: instance.canvasElOnFly,
    context: null,
    precision: 'highp',
    alpha: false,
    premultipliedAlpha: true,
    antialias: false,
    stencil: false,
    preserveDrawingBuffer: false,
    powerPreference: 'default',
    failIfMajorPerformanceCaveat: false,
    depth: false,
    logarithmicDepthBuffer: false
  });

  instance.rendererObj.autoClear = false;
  instance.rendererObj.debug.checkShaderErrors = true;
  instance.rendererObj.sortObjects = false;
}

function destroyRenderer () {
  console.log('destroyRenderer()');
  instance.rendererObj.renderLists.dispose();
  instance.rendererObj.dispose();
}

function initRenderTargets () {
  console.log('initRenderTargets()');

  const frameBufferOptionsStCompat = {
    wrapS: ThreeClampToEdgeWrapping,
    wrapT: ThreeClampToEdgeWrapping,
    magFilter: ThreeLinearFilter,
    minFilter: ThreeLinearFilter,
    format: ThreeRGBAFormat,
    type: ThreeFloatType,
    anisotropy: 1, // instance.rendererObj.getMaxAnisotropy(),
    encoding: ThreeLinearEncoding,
    depthBuffer: false,
    stencilBuffer: false
  };

  instance.renderTargetsObj = {
    renderTargetsBuferAObjPing: new ThreeWebGLRenderTarget(
      512,
      512,
      frameBufferOptionsStCompat
    ),
    renderTargetsBuferAObjPong: new ThreeWebGLRenderTarget(
      512,
      512,
      frameBufferOptionsStCompat
    )
  };

  instance.renderTargetsObj.renderTargetsBuferAObjPing.setSize(1024, 1024);
  instance.renderTargetsObj.renderTargetsBuferAObjPong.setSize(1024, 1024);
}

function destroyRenderTargets () {
  console.log('destroyRenderTargets()');
  instance.renderTargetsObj.renderTargetsBuferAObjPing.dispose();
  instance.renderTargetsObj.renderTargetsBuferAObjPong.dispose();
}

function tick () {
  console.log('TICK');
  instance.alive = !instance.alive;
  if (instance.alive) {
    setup();
  }
  else {
    shutdown();
  }
}

// use setTimeout instead of setInterval
function cycle () {
  window.clearTimeout(instance.timeoutObj);
  instance.timeoutObj = window.setTimeout(() => {
    tick();
    cycle();
  }, 500);
}

console.log('Three REVISION', ThreeREVISION);
if (ENABLE_AUTOTICK) {
  cycle();
}
else {
  document.body.addEventListener('click', tick, false);
}


Case 4

Running for 1800 seconds.
As timeout is set to 500ms that means 1800 disposals.

ENABLE_RENDERER = true
ENABLE_AUTOTICK = true

Results overview

04-main

A slice somewhere in the beginning

04-start

| Param | Value |
| ------------- | ------------- |
| JS heap | 8 947 492 |
| Nodes | 78 |
| Listeners | 10 |

A slice somewhere in the end

04-end

| Param | Value |
| ------------- | ------------- |
| JS heap | 9 320 696 |
| Nodes | 78 |
| Listeners | 10 |

Heisenbug?

No leaking 馃槅 馃槶

Three.js developer tools Chrome extension is the culprit!

Three.js Developer Tools Chrome extension was enabled in cases 1-3. By enabled I mean installed and enabled in Chrome, however code in no way was using it though.
Once this extension is disabled (case 4), memory usage is stable.

Just to note, three.js hooks into the API whether any other APIs get called manually or not as of a few versions ago -- which instruments renderers and scenes and stores them -- a quick test could be to see if changing the storage data structure to a WeakMap does the trick

Note there could be other things holding references. In that case, you might be able to put __THREE_DEVTOOLS__ = null at the top of your script to disable it on a specific page -- does this only happen when thousands of renderers are created?

@kroko

parts of "view" (split pages) get attached and detached (think React/Preact),

it is really fine, just let your renderer sit as a const in the module, and not as a property in the component - problem solved

@kroko here is sample code for react:

const renderer = new WebGLRenderer (...);
...
export class Scene3D extends Component {
    constructor (props) {
        super (props);
        this.wrapper = React.createRef ();
    }
    componentDidMount () {
        this.wrapper.current.insertBefore (renderer.domElement, this.wrapper.current.firstChild);
        resize ();
    }
    render () {
        return (
            <div className="scene-3d" ref={this.wrapper}>
                <...whatever overlays you want here...>
            </div>
        )
    }
}

@jsantell answered in correct repo.

@makc Yeah, many possibilities for how to architecture it. Given that I am better versed in C/C++ than JavaScript has led to behavioural issues 馃槅 - when I do React stuff I behave as if classes were not just syntax sugar, I like my stuff scoped. /offt Anyways, I usually make Three.js to be part of the _class_ with construction on componentDidMount() only after first meaningful paint render(), it may also depend on some props (i.e., glTF model is passed to component) and it uses React managed DOM canvas (using React.createRef() as you do), disposals are made on componentWillUnmount(). Const in module or part of class - the backstory is that I observed mem leakage in my app, and started puling out parts till I got to very beginning of the chain, namely WebGLRenderer, which seemingly leaked, hence the ticket and example code. As it currently stands, WebGLRenderer does not actually leak (JS Heap going from 8947492 to 9320696 after 1800 disposals is great).

Was this page helpful?
0 / 5 - 0 ratings

Related issues

filharvey picture filharvey  路  3Comments

jack-jun picture jack-jun  路  3Comments

jlaquinte picture jlaquinte  路  3Comments

boyravikumar picture boyravikumar  路  3Comments

seep picture seep  路  3Comments