Hi everyone, hi @Evgeni_Popov,
This follows up on Potential memory leak in WebGPUCacheBindGroups? from March. In that topic you wrote:
We assumed that the cache would not grow indefinitely and that bind groups would be reused between meshes. We could implement mechanisms to regularly clean the cache from unused entries, but this would add an extra load to a mechanism that is already heavier than WebGL.
Below are a minimal repro and numbers for both cases: disposed meshes and disposed textures. There is also one case that reusing objects cannot avoid: the cache survives engine.dispose().
What the code does
The file is packages/dev/core/src/Engines/WebGPU/webgpuCacheBindGroups.ts. It is byte-identical in 9.29.0 and master (4236dc62).
WebGPUCacheBindGroups._Cacheis one static trie (L42). Its keys areDataBuffer.uniqueId + 2^20(L110), sampler hash codes (L120) andInternalTexture.uniqueId + 2^35(L130).- Nothing removes entries except
ResetCache()(L63-72). The engine calls it only from_restoreEngineAfterContextLost(webgpuEngine.pure.ts L1288 on master). _releaseBuffer(L1932),_releaseTexture(L2406) anddispose()(L4118) never touch the cache.- Unique ids are never reused (
InternalTexture._Counter++,DataBuffer._Counter++). Once a buffer or texture is disposed, no lookup can ever hit an entry keyed on it again. Each such entry keeps its trie nodes, its bind group array and itsGPUBindGroupobjects alive.
Repro (Playground, select the WebGPU engine): https://playground.babylonjs.com/#ZGU9DE#0 (the code is also below)
MODE = "meshes": 30 boxes share one StandardMaterial and one texture. Every 3 frames each box is disposed and replaced by a new box with the same material. This is the GreasedLine pattern from 62697.MODE = "textures": each box has its own StandardMaterial. ItsdiffuseTextureis replaced by a new 4x4 RawTexture, and the old texture is disposed.- In both modes, the number of live meshes and textures stays constant.
- Check without internals: let it run to the end, click “Collect garbage” in the DevTools Memory panel, then type
queryObjects(GPUBindGroup)in the console. It reports 60 objects at the start and 20,060 after 10,000 swaps. WithCONTROL_DISABLE_CACHE = true(engine.disableCacheBindGroups), it stays at 60. - The console also prints the cache size. That part reads the internal
_Cachefor diagnostics only; the repro does not need it.
Expected: after a GC, memory stays flat. The cache holds no entries for disposed buffers or textures, and nothing is left after engine.dispose().
Actual: each disposed mesh (through its mesh uniform buffer) or texture leaves one cache entry behind, forever. In this scene each entry holds 2 GPUBindGroup objects.
Numbers. Windows 11, Chrome 147 (Chrome for Testing 147.0.7727.15), NVIDIA Ampere GPU, D3D12. Babylon is the Playground “Latest” build (it reports 9.29.0; it is the rolling CDN build of 2026-10-02). An automated harness ran the snippet (this version, plus two earlier versions with the same scene and swap logic). It read the V8 heap after two forced GCs (DevTools protocol) and counted GPUBindGroup objects with Runtime.queryObjects. Each point is 2-6 runs, and the spread between runs was 0.4 MB or less.
Disposed meshes (MODE = "meshes"), cache on:
| Disposed meshes | Cache entries | Cache nodes | Live GPUBindGroup |
V8 heap after GC |
|---|---|---|---|---|
| 0 | 30 | 152 | 60 | 11.1-11.2 MB |
| 1,000 | 1,030 | 5,152 | 2,060 | 12.5-12.8 MB |
| 5,000 | 5,030 | 25,152 | 10,060 | 14.8-15.2 MB |
| 10,000 | 10,030 | 50,152 | 20,060 | 17.8-18.2 MB |
- With the cache disabled: 0 entries, 60
GPUBindGroup, and the heap stays at 12.1-12.4 MB at 10,000. - Growth from 1,000 to 10,000: about 615-630 B of V8 heap per disposed mesh, plus about 145 B of Blink heap (the
GPUBindGroupwrappers). With the cache disabled it is 15-35 B.
Disposed textures (MODE = "textures"), cache on:
| Disposed textures | Cache entries | Live GPUBindGroup |
V8 heap after GC |
|---|---|---|---|
| 0 | 30 | 60 | 11.7-11.8 MB |
| 1,000 | 1,030 | 2,060 | 12.3-12.6 MB |
| 5,000 | 5,030 | 10,060 | 13.3-13.4 MB |
| 10,000 | 10,030 | 20,060 | 14.4-14.5 MB |
| 20,000 | 20,030 | 40,060 | 16.7-16.8 MB |
- With the cache disabled: the heap stays at 12.2-12.3 MB, with 60
GPUBindGroup. - Growth: about 225-250 B of V8 heap per disposed texture, plus about 135-145 B of Blink heap.
- The textures themselves are freed: live
InternalTextureobjects stay at 30. What is retained is the trie nodes, the bind group arrays and theGPUBindGroupobjects. WebGPUCacheBindGroups.ResetCache()(the workaround from 62697) frees all of it: the heap drops by 4.5 MB at 20,000, andGPUBindGroupgoes back to 60.
After scene.dispose() + engine.dispose() (set DISPOSE_ENGINE_AT_END = true)
| Cache entries left | Live GPUBindGroup |
V8 heap after GC | |
|---|---|---|---|
| Meshes, cache on | 10,030 (50,152 nodes) | 20,060 | 16.6-16.9 MB |
| Meshes, cache disabled | 0 | 0 | 10.8-10.9 MB |
| Textures, cache on | 10,030 | 20,060 | 13.0-13.1 MB |
| Textures, cache disabled | 0 | 0 | 10.8-10.9 MB |
The device is destroyed at that point, so these entries can never be used again. In a single-page app that mounts and unmounts engines, they add up with every mount.
What we are not claiming. We do not claim anything about GPU memory. Our GPU-process memory readings moved by about ±30 MB with no trend, so they can neither confirm nor rule out the native-memory side reported in 64046. PR #18880 fixed the leftover-UBO growth, and its description says it “does not introduce bind-group cache eviction”, so this case is still open.
We hit this in a streaming app where textures switch resolution tiers: the cache grew by about 8.8k entries per round trip of our test route. Part of that churn is ours (we created a new Texture for every tier switch), and we are fixing it on our side by reusing textures, as you advised in 62697.
Possible directions. We are happy to make the PR, whichever you prefer:
- Reset the static cache when the last WebGPUEngine is disposed. It is a few lines with no per-frame cost, and it could go together with #18927 (engine dispose order).
- Remove the entries of released resources, with no change to
getBindGroups. A release only marks the key, and only for resources that actually created a cache node. A batched purge runs at the end of the frame.- We prototyped it as a runtime emulation on the stock build. It kept the cache flat: 50-270 entries instead of 10,030-20,030, with the heap as flat as with the cache disabled.
- The open point is your “extra load” concern. Our first version walks the whole trie. A purge took about 0.2 ms with 30 draws and about 2 ms with 1,500 live draws, measured while another browser was running, so these timings are rough.
- Before a PR we would bound that cost (an adaptive threshold or a small reverse index) and measure it on a quiet machine against stock, including a static scene that must pay nothing.
Would you take a PR for (1)? For (2), is there an approach you would prefer, or a cost budget it has to meet?
Credit and disclosure: this report, the repro and the measurements were prepared by Memo, the AI of our studio 53ON (Claude with MemoriaBios, our long-term memory), and reviewed by me: code, numbers and text.
Repro code (same as the Playground)
// Babylon.js Playground repro (WebGPU only):
// the bind group cache keeps the entries of disposed meshes and textures forever.
//
// How to run: paste into https://playground.babylonjs.com, select the WebGPU engine, press Run,
// and open the DevTools console.
//
// The repro uses public API only. The cacheDiagnostics() helper below reads internal fields
// (WebGPUCacheBindGroups._Cache and .Statistics) only to print counts. The repro does not depend on it.
//
// MODE "meshes": NUM_OBJECTS boxes share ONE StandardMaterial and ONE texture. Every FRAMES_PER_SWAP frames,
// each box is disposed and replaced by a new box with the same material.
// This is the create/dispose pattern from forum topic 62697 (GreasedLines sharing a material).
// MODE "textures": each box has its own StandardMaterial. Every FRAMES_PER_SWAP frames, its diffuseTexture is
// replaced by a new 4x4 RawTexture and the old texture is disposed.
// In both modes the number of live meshes and textures stays constant.
//
// Expected: after a GC, memory stays flat, and the cache keeps nothing for disposed buffers or textures.
// Actual (9.29.0, master 4236dc62): every disposed mesh (its mesh uniform buffer) or texture leaves
// one cache entry behind (2 GPUBindGroup objects in this scene). Nothing ever removes these entries.
//
// Check it without internals: let the run finish, click "Collect garbage" in the DevTools Memory panel, then type
// queryObjects(GPUBindGroup) in the console. It reports 60 objects at the start and 20,060 after 10,000 swaps.
// Control run: set CONTROL_DISABLE_CACHE = true (engine.disableCacheBindGroups). The count then stays at 60.
// DISPOSE_ENGINE_AT_END = true also disposes the scene and the engine after the last checkpoint: the static
// cache keeps all its entries anyway. Reload the Playground after that run.
const MODE = "meshes"; // "meshes" or "textures"
const CONTROL_DISABLE_CACHE = false; // true = control run, the bind group cache is bypassed
const DISPOSE_ENGINE_AT_END = false; // true = dispose scene + engine at the end and print what the cache still holds
const NUM_OBJECTS = 30;
const FRAMES_PER_SWAP = 3;
const CHECKPOINTS = [0, 1000, 5000, 10000]; // number of disposed meshes (or textures)
var createScene = function () {
const scene = new BABYLON.Scene(engine);
const cols = 6;
const rows = Math.ceil(NUM_OBJECTS / cols);
const camera = new BABYLON.ArcRotateCamera("camera", -Math.PI / 2, Math.PI / 2.5, 16, BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
if (!engine.isWebGPU) {
console.warn("[repro] This repro needs the WebGPU engine.");
return scene;
}
engine.disableCacheBindGroups = CONTROL_DISABLE_CACHE;
const pixels = new Uint8Array(4 * 4 * 4);
const newTexture = (seed) => {
pixels.fill(64 + (seed % 128));
return BABYLON.RawTexture.CreateRGBATexture(pixels, 4, 4, scene, false, false, BABYLON.Texture.NEAREST_SAMPLINGMODE);
};
let sharedMaterial = null;
if (MODE === "meshes") {
sharedMaterial = new BABYLON.StandardMaterial("shared", scene);
sharedMaterial.diffuseTexture = newTexture(0);
}
const makeBox = (i) => {
const box = BABYLON.MeshBuilder.CreateBox("box" + i, { size: 0.8 }, scene);
box.position.set(((i % cols) - (cols - 1) / 2) * 1.2, (Math.floor(i / cols) - (rows - 1) / 2) * 1.2, 0); // all boxes in view
if (sharedMaterial) {
box.material = sharedMaterial;
} else {
const material = new BABYLON.StandardMaterial("mat" + i, scene);
material.diffuseTexture = newTexture(i);
box.material = material;
}
return box;
};
const boxes = [];
for (let i = 0; i < NUM_OBJECTS; i++) {
boxes.push(makeBox(i));
}
let swaps = 0;
const swap = (i) => {
if (MODE === "meshes") {
boxes[i].dispose(); // the shared material and its texture are kept
boxes[i] = makeBox(i);
} else {
const material = boxes[i].material;
const previous = material.diffuseTexture;
material.diffuseTexture = newTexture(swaps);
previous.dispose();
}
swaps++;
};
// Diagnostics only (internal fields, not needed for the repro): count the cache nodes and entries.
const cacheDiagnostics = () => {
const cache = BABYLON.WebGPUCacheBindGroups;
if (!cache || !cache._Cache) {
return { cacheEntries: "n/a" };
}
let nodes = 0;
let entries = 0;
const walk = (node) => {
if (node.bindGroups) {
entries++;
}
for (const key in node.values) {
nodes++;
walk(node.values[key]);
}
};
walk(cache._Cache);
return { cacheNodes: nodes, cacheEntries: entries, bindGroupsCreatedTotal: cache.Statistics.totalCreated };
};
const report = (label) => {
const row = {
mode: MODE,
cacheDisabled: CONTROL_DISABLE_CACHE,
swaps,
liveMeshes: scene.meshes.length,
liveTextures: scene.textures.length,
heapMB: performance.memory ? +(performance.memory.usedJSHeapSize / 1048576).toFixed(2) : "n/a",
...cacheDiagnostics(),
};
console.log("[repro] " + label + " " + JSON.stringify(row));
};
let state = "waiting"; // waiting -> settle -> pause -> run -> settle -> ... -> done
let next = 0;
let frame = 0;
let settleFrames = 0;
const checkpoint = () => {
report("checkpoint");
next++;
state = "pause";
// short pause so the console can be read; nothing is created or disposed meanwhile
setTimeout(() => {
if (next < CHECKPOINTS.length) {
state = "run";
return;
}
state = "done";
if (DISPOSE_ENGINE_AT_END) {
scene.dispose();
engine.dispose();
report("after scene.dispose() + engine.dispose()");
}
console.log("[repro] done");
}, 1000);
};
scene.executeWhenReady(() => (state = "settle"));
scene.onBeforeRenderObservable.add(() => {
if (state === "run") {
frame++;
for (let i = 0; i < NUM_OBJECTS && swaps < CHECKPOINTS[next]; i++) {
if ((frame + i) % FRAMES_PER_SWAP === 0) {
swap(i);
}
}
if (swaps === CHECKPOINTS[next]) {
state = "settle";
settleFrames = 0;
}
} else if (state === "settle" && ++settleFrames >= FRAMES_PER_SWAP) {
// the last created meshes/textures have been drawn at least once
checkpoint();
}
});
return scene;
};