PBRCustomMaterial: ShadersStore memory leak on dispose()

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!

Thanks for the detailed repro and analysis! I confirmed this is a leak: PBRCustomMaterial stores generated shader source under per-instance custompbr_* keys, and those entries were not removed when the material was disposed.

I’ve opened a PR with a fix that removes the generated vertex/pixel shader source entries from Effect.ShadersStore during PBRCustomMaterial.dispose(...), with a unit regression test covering the cleanup:

Thanks a lot @Evgeni_Popov for your (really) prompt fix ! :clap: