WebGPU: the per-effect "leftOver" UBO pool + bind-group cache grow with draw order until the device is lost

I kept getting crashes that didn’t seem to be caused by the typical sources. Eventually i traced it back to the bind group cache. It’s like it was creating a new index for every combination of materials, etc. -and rather than looking back at previously cached identical examples -it would just make more copies, so panning around a sim with occlusion meant it would restack everything over and over. for a big sim - boom, crash. I’m proposing a solution. This worked for me. I’ve popped it into a PR.

Playground below.

TL;DR

On WebGPU, each compiled effect has one UniformBuffer for its “leftover” uniforms (webgpuPipelineContext.ts, new UniformBuffer(engine, undefined, undefined, "leftOver-" + this._name)). Every material using that effect writes its own values into that shared buffer, once per mesh per frame. Because WebGPU sets trackUbosInFrame: true, each differing write takes the next GPU buffer in the buffer’s pool (_createNewBuffer → _rebuild → _buffers.push). Which pooled buffer a mesh gets is therefore its position in draw order. The bind-group cache keys on that buffer’s uniqueId. Move the camera, draw order changes, the same mesh now binds a different buffer, and that (mesh, buffer) pair is a brand new cache entry — forever, since WebGPUCacheBindGroups has no eviction. Key space ≈ meshes × pool depth. In a static 5,000-prim scene this reaches the descriptor limit in minutes: device.lost, D3D12 E_OUTOFMEMORY.

The fix is small: give each draw context a stable slot in the pool instead of taking the next free one. Then a given draw call always binds the same buffer for a given effect; the pool is bounded by the number of draw contexts; bind groups and fast-path bundles stay valid. Field result on the crashing scene: bind groups created 7,796 in 13 minutes, then 0 in the next 110 s (stock: +27,701 in 5 s), pool flat, no loss. PR: WebGPU: key the leftover uniform buffer slots on the draw context, not on draw order by increasinglyHuman · Pull Request #18880 · BabylonJS/Babylon.js · GitHub

The mechanism, in source

  1. Materials/uniformBuffer.ts — with trackUbosInFrame, updateUniform() calls _createNewBuffer() on the first differing write after an update(); _createNewBuffer() advances _bufferIndex or calls _rebuild(), which does this._buffers.push(...). _buffers is emptied only in the constructor and in _rebuildAfterContextLost(). The pool never shrinks, and its depth is the high-water mark of distinct values flushed in one frame.

  2. _checkNewFrame() resets _bufferIndex to 0 each frame, so slot = the order in which materials flush this frame = draw order.

  3. Engines/webgpuEngine.pure.ts _draw(): webgpuPipelineContext.uniformBuffer.update() then bindUniformBufferBase(uniformBuffer.getBuffer(), 0, LeftOvertUBOName) → drawContext.setBuffer(name, buffer).

  4. WebGPU/webgpuCacheBindGroups.ts keys the cache on the buffers’ uniqueIds (among other things). No delete, no prune, no size cap. A cached bind group pins the buffers it references (spec §3.1.2), so the pooled buffers can’t be collected either.

So every (draw context, pooled buffer) pair the camera ever produces becomes a permanent bind group. Nothing in the scene has to change for this to grow — only the order.

Measured (Chrome, WebGPU, D3D12; Babylon 9.17.1)

Probe = walk engine._uniformBuffers, sum _buffers.length (GPU buffers in the pools), note the deepest one.

scene UBOs pooled GPU buffers deepest pool after ~2 min of walking
small plaza 876 1,027 24 flat
5,371-prim OAR import 11,738 12,903 233 (leftOver-pbr+pbr) 14,565 / 689 — reproduced twice (12,974→14,499, 246→662)

UniformBuffer object count stayed flat at ~11,735 the whole time — the growth is inside them. Bind groups created: +27,701 in 5 s at 3,422 active meshes; the two device losses in the log were at 234,253 and 247,398 total bind groups (≈ 1M descriptors at ~4 per group, the D3D12 shader-visible heap limit). Creation tracks active meshes, not time: 792 meshes = +10 per 5 s; 2,571 = 1,908 in one frame.

Ruled out by measurement: lights (every light-related key distinct: 1 throughout), object churn (every create/dispose count flat — it’s a static scene), and “just reset the cache”: a guard calling ResetCache() at 60k kept the count between 5k and 77k and the device was lost sooner. GPUBindGroup has no destroy(); dropping references only hands the descriptors to GC, and at ~2,400 allocations/s the heap wins. disableCacheBindGroups is the same trap with a bigger constant.

Why “reuse your meshes” doesn’t cover it

In thread 62697 (GreasedLine dispose churn) the conclusion was that the cache doesn’t grow if the app reuses its objects. This scene reuses everything — nothing is created or disposed while it grows. The growth comes from draw order alone. Babylon-Lite already made the other choice: per-object persistent bind groups and queue.writeBuffer in place (pbr-renderable.ts), no per-frame slot rotation, no global cache. This PR moves the classic engine to the same invariant for the one buffer that has it wrong.

The fix — owner-keyed slots

UniformBuffer.update(owner?): when the buffer tracks per-frame GPU buffers and an owner (the draw context) is given, the slot is looked up in a WeakMap<drawContext, index> instead of taken in arrival order. The slot’s CPU shadow is compared to the pending values and the upload is skipped when the GPU already holds them. Two details keep it correct:

  • Same owner, same frame, different values (the GPU hasn’t consumed the first flush yet): spill to a slot nobody owns rather than overwrite. Never observed in the field (window.__uboOverflow stayed 0 over 13 minutes) because every pass has its own draw wrapper, but the guard costs one compare.

  • Disposed contexts release their slot (WebGPUDrawContext.dispose() → uniformBuffer._releaseOwnerSlot(this)); the next new owner takes it, so churny scenes don’t grow the pool either.

WebGPUEngine.useOwnerKeyedUniformBufferSlots = true (default) passes this._currentDrawContext from _draw(); set it to false for A/B. WebGL is untouched (no trackUbosInFrame). Unit test: 40 owners × 200 frames in shuffled order → pool stays at 40, zero uploads once values are on the GPU, same-frame spill and release/reuse covered.

Before / after, same scene, same route (third floor of the OAR house = the consistent crash spot)

stock 9.17.1 owner-keyed slots
deepest pool over the walk 233 → 689 in 2 min, never flattens 1,340 → 2,109, then flat (one step per newly-seen area; it is now the count of draw contexts on that effect)
bind groups created +27,701 in 5 s 7,796 total after 13 min; +0 in the next 110 s
uploads one per differing flush skipped when the slot already holds the values
the crash spot device.lost no loss, incl. 5 NPCs spawned and combat

Let me add @Evgeni_Popov to the thread. I answered on the github thread (my ai did…) so I will wait for popov checks :slight_smile: