Skip to content

WebGPURenderer: Textures never removes dispose listeners - #34368

Merged
Mugen87 merged 7 commits into
mrdoob:devfrom
wcandillon:fix-webgpu-renderer-dispose-leak
Sep 9, 2026
Merged

Mugen87 merged 7 commits into
mrdoob:devfrom
wcandillon:fix-webgpu-renderer-dispose-leak

Conversation

@wcandillon

@wcandillon wcandillon commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

This is a follow up from #34327 which was nicely found and reported in RN WebGPU: wcandillon/react-native-webgpu#445

this is the reproduction (use ?lib=cdn to try against the current release).

repro-renderer-leak.html

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

📦 Bundle size

Full ESM build, minified and gzipped.

Before After Diff
Core 389.18
102.13
389.18
102.13
+0 B
+0 B
WebGL 373.88
87.94
373.88
87.94
+0 B
+0 B
WebGPU 708.56
195.08
709.17
195.22
+612 B
+139 B
WebGPU Nodes 706.55
194.76
707.16
194.91
+612 B
+141 B

🌳 Bundle size after tree-shaking

Minimal build including a renderer, camera, empty scene, and dependencies.

Before After Diff
WebGL 518.47
125.12
518.47
125.12
+0 B
+0 B
WebGPU 762.22
204.64
762.83
204.77
+612 B
+131 B
WebGPU Nodes 710.62
191.86
711.23
191.99
+612 B
+135 B

@wcandillon
wcandillon force-pushed the fix-webgpu-renderer-dispose-leak branch from 1dd1f54 to d25de02 Compare August 25, 2026 10:48
Comment thread src/renderers/common/Textures.js
Comment thread src/renderers/common/Textures.js
@Mugen87

Mugen87 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

The ordering in Renderer.dispose() is not correct anymore.

this.info.dispose();
this._animation.dispose();
this._objects.dispose();
this._geometries.dispose();
this._pipelines.dispose();
this._nodes.dispose();
this._bindings.dispose();
this._renderLists.dispose();
this._renderContexts.dispose();
this._textures.dispose();
for ( const canvasTarget of this._frameBufferTargets.keys() ) {
canvasTarget.dispose();
}
await this.backend.dispose();

You would have to move this._textures.dispose(); after the for loop and this.info.dispose(); must come last.

for ( const canvasTarget of this._frameBufferTargets.keys() ) {

   canvasTarget.dispose();

}

this._textures.dispose();
this.info.dispose();

Comment thread src/renderers/common/Textures.js Outdated

renderTarget.addEventListener( 'dispose', renderTargetData.onDispose );

this._disposeListeners.set( renderTarget, renderTargetData.onDispose );

@Mugen87 Mugen87 Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear: The tracking you are adding here is no ideal solution. Also add the same comment like in Geoemtries.js:

// see #34368 why tracking separate remove listeners is required right now
// TODO: Re-evaluate how onDispose() is managed in this component

The problem is if an app never uses texture.dispose(), the new strong references stored in the Map prevent any GC that was previously possible with the WeakMap only approach. So a user error means the texture with its expensive image is stored for the renderer's lifetime now.

Comment thread src/renderers/common/Textures.js Outdated
* @private
* @type {Map<(Texture|RenderTarget),Function>}
*/
this._disposeListeners = new Map();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, but would this work as well?

this._tracked = new Set(); // Set<WeakRef<Texture|RenderTarget>>
...
this._tracked.add( new WeakRef( texture ) );
...

dispose() {
	for ( const ref of this._tracked ) {
		const object = ref.deref();
		if ( object !== undefined && this.has( object ) ) object.removeEventListener( 'dispose', this.get( object ).onDispose );
	}
	this._tracked.clear();
    this._htmlTextures.clear();
	super.dispose();
}

- Track textures and render targets with a Set of WeakRefs instead of a
  strong Map so undisposed objects can still be garbage collected.
- Textures.dispose() now runs _destroyRenderTarget()/_destroyTexture()
  for each tracked object and clears _htmlTextures.
- Dispose info last in Renderer.dispose() so the destroy calls above do
  not decrement counters that were already reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Mugen87 Mugen87 added this to the r187 milestone Aug 25, 2026
@Mugen87

Mugen87 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

This would be the first time we use WeakRef in the repo. It should be well supported in browsers (see https://caniuse.com/mdn-javascript_builtins_weakref) but I'm unsure with node/bundlers. Moving to the r187 milestone so we have enough time to test this on dev.

Comment thread src/renderers/common/Textures.js
If a texture or render target is garbage collected without an explicit
dispose(), the registry removes its stale WeakRef from the tracking set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wcandillon

Copy link
Copy Markdown
Contributor Author

Haha by uncovering this issue, it looks like I may have pushed the use of FinalizationRegistry (which is fair?) but FinalizationRegistry is not an API supported in React Native.
I think we can do a polyfill for it, just wanted to note irony of the situation. 😆

@Mugen87
Mugen87 merged commit 5570931 into mrdoob:dev Sep 9, 2026
10 checks passed
@Mugen87

Mugen87 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

FYI: I've adapted this approach in Geometries as well which means we no longer track the listeners with a Map.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Sponsor
SponsoredKunjungi sekarang
Promo