Hi everyone !
I’ve been tracking a memory leak in our app for a while and finally managed to isolate it. Posting here in case it’s a known issue or if there’s a better workaround than what I’ve found.
I use PBRCustomMaterial to manage UV randomization while using instances.
The problem
Every time you create a PBRCustomMaterial and later dispose it, two entries remain permanently in Effect.ShadersStore:
custompbr_<uniqueId>VertexShader
custompbr_<uniqueId>PixelShader
dispose(true, true) correctly destroys the JS object and the compiled WebGL program, but it never touches these GLSL source strings. Since the key is built from uniqueId — which is unique and never reused — these entries can never be requested again after disposal. They just sit there forever.
In our case we create and dispose PBRCustomMaterial instances dynamically (temporary visual states on meshes), so this accumulates fast. We measured around 5-6 MB of leaked GLSL strings after normal usage.
Reproduction
Here’s a playground: Babylon.js Playground
Click the button a few times and watch ShadersStore entries grow by 2 on every click, while Materials in scene stays at 1. The material is properly disposed each time.
I found the leak by proxying the store:
const store = Effect.ShadersStore;
(Effect as any).ShadersStore = new Proxy(store, {
set(target, key, value) {
console.warn('Added to ShadersStore:', key);
console.trace();
return Reflect.set(target, key, value);
}
});
The trace lands at Builder @ pbrCustomMaterial.ts → _prepareEffect @ pbrBaseMaterial.ts.
Why it happens
PBRCustomMaterial uses uniqueId as its shader cache key, unlike PBRMaterial which keys on active defines and can share shaders between instances with identical configurations. So with PBRCustomMaterial, every single instance unconditionally writes its own entries — and since dispose() doesn’t know about them, they’re never cleaned up.
Also worth noting: PBRMaterial.clone() returns a PBRMaterial, not a PBRCustomMaterial. So anyone needing a typed clone is forced to call new PBRCustomMaterial() manually and a custom “clone” method, which makes this pattern pretty hard to avoid.
If this is not an expected behavior, I thought about 2 possible fixes, which are complementary, but any other idea would be appreciated !
Two possible fixes (I guess)
Option 1 — fix dispose()
Since uniqueId is unique by construction, no other material can ever share this shader key. Deleting it in dispose() should be completely safe:
public override dispose(forceDisposeEffect?: boolean, forceDisposeTextures?: boolean): void {
const key = "custompbr_" + this.uniqueId;
delete Effect.ShadersStore[key + "VertexShader"];
delete Effect.ShadersStore[key + "PixelShader"];
super.dispose(forceDisposeEffect, forceDisposeTextures);
}
Option 2 — fix clone() to return a PBRCustomMaterial
If clone() returned a proper PBRCustomMaterial — copying CustomParts, _customUniform, and _customAttributes — the cloned instance could reuse the same shader key as its source, avoiding the leak at creation time rather than at disposal. (This would also remove the need for the kind of custom clone method we had to write)
I guess both option 1 and 2 could get implemented to avoid leak in all cases.
Thanks for looking into this!