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

**URL:** <https://forum.babylonjs.com/t/webgpu-the-per-effect-leftover-ubo-pool-bind-group-cache-grow-with-draw-order-until-the-device-is-lost/64046>\
**Category:** Bugs\
**Created:** [September 4, 2026, 5:08am UTC](https://forum.babylonjs.com/t/webgpu-the-per-effect-leftover-ubo-pool-bind-group-cache-grow-with-draw-order-until-the-device-is-lost/64046 "2026-09-04T05:08:51Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![increasinglyHuman](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/increasinglyhuman/32/51340_2.png) [@increasinglyHuman](https://forum.babylonjs.com/u/increasinglyHuman)\
**Post date:** [September 4, 2026, 5:08am UTC](https://forum.babylonjs.com/t/webgpu-the-per-effect-leftover-ubo-pool-bind-group-cache-grow-with-draw-order-until-the-device-is-lost/64046/1 "2026-09-04T05:08:51Z")

</div>

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.

> **[Babylon.js Playground](https://playground.babylonjs.com/#ASGPJY%235)**
>
> Babylon.js playground is a live editor for Babylon.js WebGL and WebGPU 3D scenes

### **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](https://github.com/BabylonJS/Babylon.js/pull/18880)

### **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’ `uniqueId`s (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 |

---

<div class="post-metadata">

**Author:** ![sebavan](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/sebavan/32/57_2.png) [@sebavan](https://forum.babylonjs.com/u/sebavan)\
**Post date:** [September 4, 2026, 5:27pm UTC](https://forum.babylonjs.com/t/webgpu-the-per-effect-leftover-ubo-pool-bind-group-cache-grow-with-draw-order-until-the-device-is-lost/64046/2 "2026-09-04T17:27:58Z")

</div>

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