# WebGPU: CPU cost per draw call in 9.29/9.30, and which small PRs would you welcome?

**URL:** <https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298>\
**Category:** Feature requests\
**Created:** [October 13, 2026, 10:41pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298 "2026-10-13T22:41:58Z")\
**Posts on this page:** 14\
**Page:** 1

<div class="post-metadata">

**Author:** ![Enrique\_Velasquez](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/enrique_velasquez/32/50550_2.png) [@Enrique\_Velasquez](https://forum.babylonjs.com/u/Enrique_Velasquez)\
**Post date:** [October 13, 2026, 10:41pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/1 "2026-10-13T22:41:58Z")

</div>

**Update (Oct 23): latest improvements applied.** Here’s the route as it runs now:

[![](https://i.ytimg.com/vi/ciLyOPkH8nU/hqdefault.jpg "Babylon.js 9.31 WebGPU: our streamed open-world route, live in the browser") ](https://www.youtube.com/watch?v=ciLyOPkH8nU)

It’s a streamed cell world of about 1 × 1 km: 3D Tiles 1.1, 64 cells of 128 m and 16 of 256 m, plus HLOD. Measured at the start of the drive, at 1424 × 896, it has about 510 enabled meshes, 60.9k instances culled on the GPU in 465 groups, about 510 draw calls per frame and 317 world textures. The video is rendered at native 1080p and reaches the screen at 56.8 distinct frames per second, with a worst gap of 50 ms.

* * *

Hi everyone, hi @Evgeni_Popov,

Our scenes are CPU-bound on draw submission, so we read two public benchmarks from the last weeks closely. We did not find them discussed here yet. In [64212](https://forum.babylonjs.com/t/webgpu-the-bind-group-cache-keeps-entries-for-disposed-meshes-and-textures-forever-also-after-engine-dispose/64212/10) you mentioned that the current WebGPU path already has more CPU-side overhead than WebGL, partly because of the extra bookkeeping and caches. Below are the numbers that show how large that gap is, what we read in the source, and four small PRs we could send.

**The numbers (published, not ours)**

[web-engines-compare](https://github.com/mvaligursky/web-engines-compare/blob/92c450af301dc0e40974f5539044982fb1cad751/results/2026-10-01/README.md), results of 2026-10-01, Chrome 154, every engine at its defaults: 1K-20K boxes cloned from 100 meshes, 100 PBR materials with 4 textures each, one directional light with one shadow map, so 2 draws per box. CPU time per draw call in µs, lower is better:

| Engine | M4 Max WebGPU | M4 Max WebGL2 | i7-10700F WebGPU | i7-10700F WebGL2 | Pixel 8 Pro WebGPU | Pixel 8 Pro WebGL2 |
| --- | --- | --- | --- | --- | --- | --- |
| Babylon.js 9.29.0 | 4.40 | 2.35 | 7.34 | 4.83 | 9.25 | 5.93 |
| PlayCanvas 2.23.0 | 0.43 | 0.75 | 1.22 | 1.66 | 1.63 | 2.16 |
| three.js r186 | 2.10 | 0.89 | 4.68 | 2.10 | 5.66 | 2.43 |

- The author, Martin Valigursky, works on PlayCanvas. The code is public. We read the [Babylon adapter](https://github.com/mvaligursky/web-engines-compare/blob/92c450af301dc0e40974f5539044982fb1cad751/adapters/babylon.js): it uses plain defaults (clones, `PBRMetallicRoughnessMaterial`, a PCF `ShadowGenerator`, nothing frozen, compatibility mode on).
- An independent run, [endel/webgpu-webgl-benchmarks](https://github.com/endel/webgpu-webgl-benchmarks/tree/25723d75c516488854794b8494a0a847bc9c933f) (Babylon 9.26.0, PlayCanvas 2.22.2, three.js r186, Chrome 151, Apple M1 Pro): 5,000 separate animated meshes on WebGPU cost 12 ms of CPU per frame in PlayCanvas, 24 ms in Babylon.js and 29 ms in three.js.
- Babylon’s own WebGL2 path is 1.5-1.9x cheaper per draw than its WebGPU path on the same scene. PlayCanvas got 3.1-4.0x faster on WebGPU between 2.22.6 and 2.23.0 (same test, same browser).

**PlayCanvas 2.23 recipe vs Babylon 9.30**

From reading [9.30.0](https://github.com/BabylonJS/Babylon.js/blob/9.30.0/packages/dev/core/src/Engines/webgpuEngine.pure.ts#L3840-L3983) (`_draw` is the same in 9.29.0 and on current master), compatibility mode:

| PlayCanvas PR (their result) | Babylon today |
| --- | --- |
| [#9380](https://github.com/playcanvas/engine/pull/9380) + [#9492](https://github.com/playcanvas/engine/pull/9492): keep the bound pipeline, look it up only when a state changes (#9492: -18% CPU frame time at 20K draws) | The lookup is skipped when nothing changed (`_isDirty`), but the six state setters run on every draw, and `setPipeline()` is called on every draw (L3935). |
| [#9496](https://github.com/playcanvas/engine/pull/9496): skip vertex and index buffer binds the pass already has (draw call cost 2.8 to 0.9 ms per frame, best case) | `setIndexBuffer()` and every `setVertexBuffer()` run on every draw (L3938-3954). The WebGL path already skips them (`bindBuffers`, `_bindIndexBufferWithCache`). |
| [#9413](https://github.com/playcanvas/engine/pull/9413): bind the material bind group only at a material switch (7.6% when the material changes every draw, neutral when sorted) | A clean draw does no bind group lookup, but every group is set on every draw (L3957-3959). With `_SimplifiedKnownBindings`, group 0 is the Scene UBO and group 1 holds lights, material, Mesh UBO and textures, so group 1 is unique per mesh. Each cache leaf also creates its own group 0. |
| [#9614](https://github.com/playcanvas/engine/pull/9614): matrices in a persistent storage buffer, indexed by first instance (-34% to -44% static, -23% to -24% moving) | Partly there: the Mesh UBO is persistent, shared by the main and shadow passes, and only uploaded when it changes. But it lives in group 1, which makes each draw’s bind group unique per mesh (and grows the bind group cache per mesh, see [64212](https://forum.babylonjs.com/t/webgpu-the-bind-group-cache-keeps-entries-for-disposed-meshes-and-textures-forever-also-after-engine-dispose/64212)). |
| [#9482](https://github.com/playcanvas/engine/pull/9482): light uniforms uploaded once per pass (per-draw UBO 528 B to 128 B) | Already done: `Light._bindLight` refreshes a light’s data once per render id. `BindLights` still runs on every main-pass draw. Freezing the material skips it only on WebGL2, and only while the material, effect and visibility stay the same from one draw to the next; on WebGPU it runs even then (the WebGPU engine sets `needToAlwaysBindUniformBuffers`). |

**Small PRs we could send**

No API change. A, B and the `setBindGroup` part of C go in `_draw`. The bound state is tracked per encoder and reset at each render pass, at each bundle encoder, after `executeBundles` (WebGPU clears the pass state there) and after a scissored clear (in compatibility mode its clear quad binds its own pipeline and bind groups on the same pass). So they only skip calls on an encoder that is reused across draws: the render pass in compatibility mode, and the bundle encoder while a snapshot is being recorded. Outside recording, non-compatibility mode replays each draw’s cached bundle or encodes the draw into a new bundle of its own, so they change nothing there. The group 0 part of C goes in `WebGPUCacheBindGroups` and D in `WebGPUCacheRenderPipeline`. Both caches also serve non-compatibility mode and snapshot recording, and the clear quad has its own `WebGPUCacheRenderPipeline`, so those two PRs would come with the before/after and the pixel diff in those modes too, and with a scissored clear.

- **A.** Skip `setPipeline()` when the same `GPURenderPipeline` is already bound.
- **B.** Skip `setVertexBuffer()` and `setIndexBuffer()` when buffer, offset and format did not change, like the WebGL path does.
- **C.** Skip `setBindGroup(i, g)` when `g` is already bound at `i`, and reuse the group 0 bind group (Scene UBO only) per effect instead of creating one per cache leaf. Then meshes of one material skip the group 0 rebind, and the cache should keep about half as many `GPUBindGroup` objects.
- **D.** Mark the pipeline dirty from the state setters instead of recomputing all states on every draw (the #9492 idea).

Each PR would come with a Playground, counters of the skipped calls, a before/after on the benchmark scene (several runs per build, alternating the build order, on a quiet machine), and a pixel diff. We saw [#18562](https://github.com/BabylonJS/Babylon.js/pull/18562) by matthargett: it diffed this same state inside a new command-stream layer for native hosts and was closed in June, for now, because of overhead on the web path. These would be a few comparisons with no new layer, and we would drop any PR that shows no gain on the web.

Bigger changes, only if you think they belong in core and not only in Babylon Lite: split group 1 so that material and lights are bound once per material switch, and an opt-in storage buffer for world matrices.

**Questions**

1. Would you welcome A, B and C as separate small PRs? Is there one you would rather not have?
2. Would you consider D, or the bind group split, for core?
3. Is there a scene you want the before/after on, besides web-engines-compare?

**What we are not claiming**

- We have not measured A-D. They only cut repeated encoder calls and pipeline state checks during draw submission; they do not touch the per-mesh work above `_draw`. So we expect a few percent, not the 5.7-10x gap. PlayCanvas saw the same: #9380 alone showed no measurable CPU gain, and #9496’s best case is one mesh drawn many times in a row.
- We expect most of the per-draw time to be above `_draw` (active mesh evaluation, render list, `bindForSubMesh`, shadow map rendering). We are not proposing anything there yet.
- We do not claim the benchmark is neutral. Its author works on PlayCanvas; its code is public and its Babylon adapter looks fair to us.
- We know about snapshot rendering and non-compatibility mode, and we plan to use them where they fit. Snapshot rendering replays a recorded frame, so the set of draws is fixed while it is on. This post is about the default path, which is what most apps and this benchmark run.

**Probe** (public APIs only). Paste it into the Playground, pick WebGPU or WebGL2 in the toolbar and read the console: draws per frame, CPU ms per frame and µs per draw.

_Credit and disclosure: this post, the source reading and the probe were prepared by Memo, the AI of our studio 53ON (Claude with MemoriaBios, our long-term memory), and reviewed by me._

> **Playground code**
>
> ```javascript
> // Per-draw CPU cost probe for Babylon.js, public APIs only.
> // Same scene shape as the draw-calls test of github.com/mvaligursky/web-engines-compare:
> // N boxes cloned from 100 meshes, 100 PBR materials with 4 textures each,
> // one directional light with a PCF shadow map, so about 2 draws per box.
> // Pick WebGPU or WebGL2 in the Playground toolbar, run, and read the console.
> const N = 4000; // boxes
> const MESHES = 100;
> const MATERIALS = 100;
> const TEX = 32; // texture size
> const GRID = 142; // boxes per row
> 
> var createScene = function () {
> const scene = new BABYLON.Scene(engine);
> scene.useRightHandedSystem = true;
> scene.clearColor = new BABYLON.Color4(0.035, 0.035, 0.06, 1);
> 
> // mulberry32, so every run builds the same scene
> let seed = 12345;
> const rnd = () => {
> seed = (seed + 0x6d2b79f5) | 0;
> let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
> t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
> return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
> };
> 
> const camera = new BABYLON.FreeCamera("camera", new BABYLON.Vector3(0, 0, 180), scene);
> camera.fov = Math.PI / 4;
> camera.minZ = 1;
> camera.maxZ = 600;
> camera.setTarget(BABYLON.Vector3.Zero());
> 
> const dir = new BABYLON.Vector3(-0.35, -0.45, -0.82).normalize();
> const light = new BABYLON.DirectionalLight("light", dir, scene);
> light.intensity = 3.4;
> light.position = dir.scale(-100);
> const shadows = new BABYLON.ShadowGenerator(2048, light);
> shadows.usePercentageCloserFiltering = true;
> 
> const planeMat = new BABYLON.PBRMetallicRoughnessMaterial("planeMat", scene);
> planeMat.baseColor = new BABYLON.Color3(0.55, 0.55, 0.55).toLinearSpace();
> planeMat.metallic = 0;
> planeMat.roughness = 0.9;
> const plane = BABYLON.MeshBuilder.CreatePlane("plane", { size: GRID + 8, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, scene);
> plane.material = planeMat;
> plane.position.z = -1;
> plane.receiveShadows = true;
> 
> const makeTexture = (linear, fill) => {
> const data = new Uint8Array(TEX * TEX * 4);
> for (let i = 0; i < data.length; i += 4) {
> fill(data, i);
> }
> const t = new BABYLON.RawTexture(data, TEX, TEX, BABYLON.Constants.TEXTUREFORMAT_RGBA, scene, true, false, BABYLON.Texture.TRILINEAR_SAMPLINGMODE);
> t.gammaSpace = !linear;
> return t;
> };
> const noise = (d, i) => {
> d[i] = d[i + 1] = d[i + 2] = 128 + rnd() * 127;
> d[i + 3] = 255;
> };
> const flatNormal = (d, i) => {
> d[i] = 128;
> d[i + 1] = 128;
> d[i + 2] = 255;
> d[i + 3] = 255;
> };
> 
> const materials = [];
> for (let m = 0; m < MATERIALS; m++) {
> const mat = new BABYLON.PBRMetallicRoughnessMaterial("mat" + m, scene);
> mat.baseColor = BABYLON.Color3.FromHSV((m * 222.5) % 360, 0.6, 0.8).toLinearSpace();
> mat.metallic = Math.round(rnd() * 4) / 4;
> mat.roughness = 0.25 + rnd() * 0.7;
> mat.baseTexture = makeTexture(false, noise);
> mat.metallicRoughnessTexture = makeTexture(true, noise);
> mat.normalTexture = makeTexture(true, flatNormal);
> mat.emissiveColor = mat.baseColor.scale(0.25);
> mat.emissiveTexture = makeTexture(false, noise);
> materials.push(mat);
> }
> 
> const masters = [];
> for (let k = 0; k < MESHES; k++) {
> const s = () => 0.42 * (0.6 + rnd() * 0.4);
> const master = BABYLON.MeshBuilder.CreateBox("mesh" + k, { width: s(), height: s(), depth: s() }, scene);
> master.setEnabled(false);
> masters.push(master);
> }
> for (let i = 0; i < N; i++) {
> // a clone shares the geometry of its master but is its own draw call
> const cube = masters[Math.floor(rnd() * MESHES)].clone("cube" + i);
> cube.setEnabled(true);
> cube.material = materials[i % MATERIALS];
> cube.position.set((i % GRID) - (GRID - 1) / 2, (GRID - 1) / 2 - Math.floor(i / GRID), 0);
> shadows.addShadowCaster(cube, false);
> }
> 
> // CPU time from beginFrame to endFrame (scene.render() plus the submit), like the benchmark.
> // GPU execution is not included. SceneInstrumentation resets the draw call counter every frame.
> const instrumentation = new BABYLON.SceneInstrumentation(scene);
> let t0 = 0;
> let warmup = 120;
> let frames = 0;
> let sumMs = 0;
> let sumDraws = 0;
> const onBegin = engine.onBeginFrameObservable.add(() => {
> t0 = performance.now();
> });
> const onEnd = engine.onEndFrameObservable.add(() => {
> const ms = performance.now() - t0;
> const draws = instrumentation.drawCallsCounter.current;
> if (warmup > 0 || draws < 2 * N) {
> // skip warm-up frames and frames where shaders are still compiling
> warmup--;
> return;
> }
> frames++;
> sumMs += ms;
> sumDraws += draws;
> if (frames === 300) {
> console.log(
> `${engine.isWebGPU ? "WebGPU" : "WebGL2"}, Babylon ${BABYLON.Engine.Version}, ${N} boxes: ` +
> `${Math.round(sumDraws / frames)} draws/frame, ${(sumMs / frames).toFixed(2)} ms CPU/frame, ` +
> `${((1000 * sumMs) / sumDraws).toFixed(2)} us/draw`
> );
> frames = 0;
> sumMs = 0;
> sumDraws = 0;
> }
> });
> scene.onDisposeObservable.add(() => {
> engine.onBeginFrameObservable.remove(onBegin);
> engine.onEndFrameObservable.remove(onEnd);
> instrumentation.dispose();
> });
> 
> return scene;
> };
> 
> ```

---

<div class="post-metadata">

**Author:** ![Evgeni\_Popov](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/evgeni_popov/32/4432_2.png) [@Evgeni\_Popov](https://forum.babylonjs.com/u/Evgeni_Popov)\
**Post date:** [October 14, 2026, 10:23am UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/2 "2026-10-14T10:23:36Z")

</div>

Thanks for the detailed write-up, and especially for distinguishing the published measurements from the proposed optimizations that have not been measured yet. Your description of the current draw path is broadly correct.

In principle, yes: small, focused PRs that reduce redundant work would be welcome, provided the before/after measurements show a worthwhile CPU improvement without regressions. Reducing the number of WebGPU calls is useful evidence, but is not by itself proof of a performance improvement, as the results of the pipeline-binding PR you linked also illustrate.

## A, B and C

**A and B look like reasonable candidates to start with.** Please keep the state tracking lightweight and allocation-free on the draw path. For buffer bindings, the comparisons need to cover the actual GPU buffer, binding slot, offset and index format, rather than only the Babylon buffer object.

For **C** , I would separate skipping redundant `setBindGroup` calls from sharing the Scene bind group. These are independent changes with different correctness and performance considerations, and separating them would make the results easier to assess.

Sharing group 0 is worth investigating, but “per effect” alone is not a sufficient cache key: the actual Scene buffer and compatible bind-group layout also matter. Please account for multiple scenes/cameras, buffer replacement, custom shader layouts and device recreation. The expected reduction in bind-group objects should be measured for the workload rather than assumed to apply to every shader configuration.

The invalidation boundaries you listed are important, including new render passes, new bundle encoders, `executeBundles` and the scissored clear quad. Please cover those paths, along with non-compatibility mode and snapshot recording/replay, so an optimization of the default path does not introduce regressions elsewhere.

## D and the larger changes

I would consider **D** , but as a separate change. Babylon already avoids the pipeline-cache lookup when the derived state is unchanged, so the opportunity here is to avoid unnecessary state derivation, not to remove a lookup that currently happens on every draw.

One detail to be careful with: `_setVertexState()` also builds the vertex-buffer list used by `_draw()`. Different meshes can use the same pipeline layout while requiring different buffers. Reusing the pipeline must not leave the previous mesh’s buffer list selected. Blend-state arrays are also mutated in place, so reference comparisons alone will not reliably detect changes.

A bind-group split or persistent storage for world matrices would need a separate design discussion. I would not rule them out for core in principle, but they affect shader processing, resource ownership and interactions with instancing and custom shaders. They should not be bundled into these smaller PRs.

## Measurements and scenes

`web-engines-compare` is a useful starting point. However, its CPU time per draw is the whole frame’s CPU time divided by the draw count, including culling, sorting, material binding and shadow preparation. It should not be interpreted as the isolated cost of `_draw()`, and we still need profiling to establish where most of the difference comes from.

**Please also benchmark with `engine.compatibilityMode = false`, alongside the default `true` configuration, and report the two modes separately.** Non-compatibility mode can reuse per-draw render bundles, bypassing much of the repeated command encoding. This would help establish how much of the CPU cost can already be avoided with the existing fast path, both in the benchmark and in your application. It is not a substitute for measuring improvements to the default path: keep compatibility mode enabled as the primary baseline for the encoder-call elision proposals, and distinguish bundle creation from steady-state reuse in the non-compatibility results.

Draw order also matters for B: PlayCanvas’s default opaque sort groups by material and then mesh, whereas Babylon’s comparator does not group these meshes by geometry within a material. The same set of geometries therefore does not imply the same sequence of buffer switches.

Besides the default benchmark configuration, the most useful cases would be:

- One shared geometry versus many geometries, with material-grouped and alternating-material draws.
- Shadows on and off, and static versus moving meshes.
- A representative application scene with mixed materials and render-target/post-process transitions, rather than only the repeated-box case.

The alternating build order and pixel comparisons you proposed are good. Please collect detailed call counters separately from the timing runs so the instrumentation does not distort a small gain, and report run-to-run variation as well as the average.

One correction to the supplied probe: its fixed camera distance does not fit the grid in a narrow or portrait canvas. Some boxes will be culled, and the `draws < 2 * N` guard can then reject every frame without printing a result. Please use aspect-aware camera fitting, as the benchmark does, and report incomplete draw counts explicitly.

So, yes to investigating these improvements, with isolated PRs and measured gains. I would keep the encoder-call elision, Scene-group sharing, pipeline dirty tracking and larger binding redesign as separate steps.

---

<div class="post-metadata">

**Author:** ![Enrique\_Velasquez](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/enrique_velasquez/32/50550_2.png) [@Enrique\_Velasquez](https://forum.babylonjs.com/u/Enrique_Velasquez)\
**Post date:** [October 15, 2026, 12:34pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/3 "2026-10-15T12:34:38Z")

</div>

Thanks @Evgeni_Popov, we followed your plan and measured before writing any code. Short version: in our measurements A and B would save very little, and the profile pointed somewhere else. Details below; please tell us if you read the numbers differently.

**1. Base matrix** (9.30.0, 4,000 boxes, 9 samples per cell, timing and counters in separate runs). µs per draw = frame CPU / draws, mean ± SD over the samples; NCM is `engine.compatibilityMode = false`:

| scene | draws | WebGPU compat | WebGPU NCM | WebGL2 |
| --- | --- | --- | --- | --- |
| default | 8,001 | 8.49 ± 0.87 | 8.05 ± 1.53 | 6.19 ± 0.62 |
| shared geometry | 8,001 | 8.02 ± 1.20 | 7.89 ± 1.77 | 5.05 ± 0.33 |
| grouped materials | 8,001 | 9.07 ± 1.21 | 7.70 ± 1.03 | 5.67 ± 0.73 |
| no shadows | 4,001 | 10.96 ± 2.14 | 8.09 ± 1.35 | 6.16 ± 1.51 |
| moving | 8,001 | 11.04 ± 1.70 | 8.66 ± 0.77 | 7.04 ± 1.60 |

The machine was not idle during these runs, hence the spread. Portrait 500×900 with the aspect-aware framing you suggested: 8,001 draws, 0 incomplete frames.

Counters (compat): `setPipeline` is repeated on 7,998 / 8,001 draws in every scene; index / vertex buffer repeats only show up with shared geometry (~1 % otherwise).

NCM: 2 `executeBundles` per frame and 0 bundles created inside the timing windows, so these are steady-state reuse numbers (we did not time bundle creation). It is 2–26 % faster than compat depending on the scene: 5 % in the default one, and within the spread for default and shared geometry.

**2. CPU profile** (V8, compat, default scene, 12 s): `setPipeline` stays under 1.2 % self, `setBindGroup` 5.1 %, `setVertexBuffer` 2.9 %, `setIndexBuffer` 1.8 %. The big items are per-mesh scene work: `_setDefaultAutoExtendShadowProjectionMatrix` 9.6 %, `_getInstancesRenderList` 7.8 %, `_updateOwnerKeyed` 6.4 %. So A has a ceiling around 1 %, and B only pays off with shared geometry.

**3. A real app scene** (our streaming world: ~710 draws per frame, thin instances, a texture streamer, driven along a fixed route in real time at 1423×889). All encoder calls together are ~5 % of frame CPU. NCM measured 28.9 ms per frame against 32.2 ms in compat, but a repeat compat run drifted to 43.7 ms (another full-screen window was open), so we cannot call that one yet; we will repeat it on an idle machine.

What did stand out: `Material._markAllSubMeshesAsTexturesDirty` at 6.4 % self time (its body is the loop of `_markAllSubMeshesAsDirty`). Every texture swap on a material walks every mesh, submesh and draw wrapper of the scene, although `material.meshMap` already knows which meshes use it (`useMaterialMeshMap` is on by default). A streaming world swaps textures all the time, so this grows with scene size × swaps.

**4. Proposal, instead of A/B:** walk `meshMap` in `_markAllSubMeshesAsDirty` (and in `markDirty` / `resetDrawCache`, which have the same loop), keeping the exact inner checks (`getMaterial(true, passId) === this`, the material context):

- meshes that render this material through a `MultiMaterial`: also walk the `meshMap` of the scene’s multi-materials that contain it;
- instances that act as regular meshes (`_actAsRegularMesh`) render with their own submeshes: walk `mesh.instances` too;
- the scene’s default material (meshes with no material) and `useMaterialMeshMap: false`: keep today’s full scan;
- per-pass materials are already in `meshMap` (`setMaterialForRenderPass` registers them).

Equivalence, written as a unit test in the repo (`Materials/babylon.material.markDirtyMeshMap.test.ts`, NullEngine), six cases: meshes and their instances, multi-materials, a per-pass material, the default material, an unused material, and a mesh removed from the scene. On current master five pass and one fails: the mesh removed from the scene but still holding the material, which the change now marks too (what you want when it comes back). With the change all six pass; a walk over `meshMap` alone fails three of them (instances, multi-materials, default material). The Materials suite stays green (417/417) and lint reports the same warnings as master. The full unit run shows two failures, both unrelated: a KHR\_interactivity loader test that passes on re-run, and a UMD rollup test that times out at 5 s on clean master too.

Measured in our world (9.30.0, compat, 849 meshes, 243 materials, same route in real time, idle machine, full-screen window), swapping the method live and alternating 3 times in one session:

| pass | method | ms inside `_markAllSubMeshesAsDirty` per frame | frame CPU mean | p95 |
| --- | --- | --- | --- | --- |
| 1 | full scan | 1.323 | 27.66 | 46.2 |
| 2 | meshMap | 0.027 | 28.30 | 44.8 |
| 3 | full scan | 1.182 | 26.26 | 43.3 |
| 4 | meshMap | 0.020 | 25.06 | 35.5 |
| 5 | full scan | 1.200 | 26.21 | 44.3 |
| 6 | meshMap | 0.020 | 25.01 | 35.5 |

Same ~11.4k mark calls per pass. The marking itself drops from 1.24 to 0.02 ms per frame (−98 %). The frame mean moves by −0.6 ms on average, which is inside the run-to-run noise (the first pair even goes the other way); the last two pairs show −1.2 ms and a p95 about 8 ms lower, since bursts of texture swaps no longer walk the whole scene. It scales with meshes × swaps, so it matters more for big streamed scenes than this number suggests.

The change and its test are ready on a branch. If that sounds reasonable, I’ll open the PR. Critique is welcome: if you know a case this walk misses, or would rather see `markDirty` / `resetDrawCache` in a separate PR, we’ll adjust.

_Prepared by Memo, the AI of our studio 53ON._

---

<div class="post-metadata">

**Author:** ![Evgeni\_Popov](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/evgeni_popov/32/4432_2.png) [@Evgeni\_Popov](https://forum.babylonjs.com/u/Evgeni_Popov)\
**Post date:** [October 15, 2026, 1:19pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/4 "2026-10-15T13:19:27Z")

</div>

Thanks for following up with measurements and for being explicit about the noise and the limits of the results. I agree with prioritizing the material-invalidation work for your streaming workload rather than starting with A/B. The reduction in time spent in the marking routine is promising, while your qualification of the whole-frame improvement is appropriate.

The non-compatibility results are useful too, particularly the separate counters showing steady-state bundle reuse. They do not establish a large gain in the default/shared-geometry cases given the spread, but they help distinguish command-encoding overhead from the scene and material work that remains. I would also treat the V8 self-time profile as prioritization evidence rather than a strict upper bound on what A could save.

**Please open a focused PR for the material-invalidation change.** Before relying on `meshMap` as the candidate set, though, there is a correctness issue in the existing bookkeeping that the optimization needs to account for.

## The map is not currently a complete usage index

`_setMaterial()` and `setMaterialForRenderPass()` both write the same `meshMap[mesh.uniqueId]` entry. Removing one assignment clears that entry without checking whether another assignment still uses the material. For example:

```auto
mesh.material = material;
mesh.setMaterialForRenderPass(passId, material);
mesh.setMaterialForRenderPass(passId, undefined);

```

After this sequence, `mesh.material` is still `material`, but its map entry is `undefined`. The current full scan can still find and dirty the main-pass draw wrapper; a traversal based only on this map would miss the mesh. Keeping the inner material/context checks is necessary, but cannot recover a mesh omitted from the candidate set.

The same problem occurs when:

- The material is assigned to two render passes and one override is removed or replaced.
- The regular material is changed while a render-pass override still references the old material.

This bookkeeping issue predates your proposal, but the new invalidation path would depend on an invariant it does not currently guarantee. Please include these assignment/removal transitions in the regression cases, not just initial per-pass assignment, and either maintain complete membership across those transitions or retain a correct fallback for the affected cases.

The instance, MultiMaterial and default-material cases you identified are also relevant. Please retain the existing pass-specific material resolution, material-context checks and dirty-blocking behavior. An empty map does not necessarily mean the material is unused, as the example above demonstrates.

## Scope and behavior

For the first PR, I would focus on `_markAllSubMeshesAsDirty()`, which is the path supported by your streaming measurements. I would leave `markDirty()` and `resetDrawCache()` for follow-ups unless including them is necessary for the implementation. They have different effects on draw wrappers and deserve their own behavioral coverage rather than being changed only because their loops look similar.

The removed-from-scene case is not strictly an equivalence test: the current implementation only visits `scene.meshes`, while the proposed traversal also reaches a detached mesh through the map. Invalidating that mesh before it is re-added may be desirable, but it is a behavior change. I would keep the optimization behavior-preserving initially, or discuss and cover that change explicitly, including asset-container remove/add cycles, rather than treating a failure on current master as proof of equivalence.

## Evidence for the PR

The reported reduction from about 1.24 ms to 0.02 ms in the marking routine is a useful motivation for reviewing the change. It is not a 98% frame-time improvement, and your post already makes that distinction. Likewise, the lower p95 values are encouraging, but a live route driven in real time can encounter different streaming work between runs even with similar call totals.

Please include a small reproducible benchmark with a fixed scene and texture-swap sequence, with timing instrumentation separated from detailed counters, alongside the application results. Cover material reassignment/disposal and some streaming churn as well: map entries can remain present with `undefined` values, so traversal cost should not be inferred solely from the current number of live users. Also keep the disabled-map path and the ordinary no-change rendering path free of unnecessary new work.

That would make this a worthwhile, well-bounded optimization to review. The main prerequisite is establishing that the optimized traversal reaches every draw wrapper the current scene scan would invalidate.

---

<div class="post-metadata">

**Author:** ![Enrique\_Velasquez](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/enrique_velasquez/32/50550_2.png) [@Enrique\_Velasquez](https://forum.babylonjs.com/u/Enrique_Velasquez)\
**Post date:** [October 15, 2026, 9:09pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/5 "2026-10-15T21:09:22Z")

</div>

Thanks @Evgeni_Popov, that review changed the design. The PR is here: [Material: skip submeshes that cannot resolve to the material when marking dirty by enriqxxx · Pull Request #19004 · BabylonJS/Babylon.js · GitHub](https://github.com/BabylonJS/Babylon.js/pull/19004)

You were right about the bookkeeping, and there is a second gap: `GaussianSplattingMeshBase` stores its material without `_setMaterial`, so its meshes never enter `meshMap`. Our earlier walk would have missed them too. So the first PR does not use `meshMap` at all:

- it keeps the `scene.meshes` walk with the same per-pass resolution, material-context checks and dirty blocking;
- before resolving the passes of a submesh, it skips it if its rendering mesh cannot resolve to the material in any pass (its material and its render pass materials are neither this material nor a multi-material, and it has a material, so the default material cannot apply). The check reads the same slots as `SubMesh.getMaterial`;
- only `_markAllSubMeshesAsDirty()`; `markDirty()` and `resetDrawCache()` are untouched.

Because it walks the same meshes, detached meshes and asset container cycles behave as today. The disabled-map path and dead map entries don’t matter, and the rendering path is untouched.

**Regression cases** (NullEngine): the transitions you listed (pass material set then removed, two passes with one removed, pass material replaced, mesh material changed while a pass keeps the old one), multi-materials as mesh and pass material, instances, the default material, a mesh that keeps its material outside `meshMap`, and removal / asset-container cycles. All nine pass on current master unchanged and with the change. A tenth case checks the optimization itself (fails on master by design).

**Reproducible benchmark** (in the PR description): fixed scene, fixed sequence of 2,000 texture swaps, timing without instrumentation, counters in a separate pass, plus streaming churn and material reassignment/disposal. A texture swap costs ~27 µs on the branch against ~132 µs on master, with identical marking calls and marked draw wrappers. `scene.render()` with no change stays within noise.

**In our streaming world** (same route as before, alternated live 3 times): the marking goes from 2.54 to 0.74 ms per frame (−71 %) with the same number of calls; frame CPU is lower in every pair (−1.7 ms on average, −4.4 %) and p95 about 11 ms lower. The machine was busier than for the numbers in #3 (master marks at 2.54 ms per frame here against 1.24 then), so compare within the session; the table is in the PR. The NCM repeat on an idle machine that we promised in #3 is still pending; we’ll post it when it is done.

This is about 5× in the benchmark, smaller than the `meshMap` walk would give (~85× on the marking loop alone), but it is exact by construction. If you want the O(users) walk later, it needs `meshMap` to be complete for every mesh type and a decision on meshes outside the scene; we can do that as a separate step. Critique welcome, as always.

_Prepared by Memo, the AI of our studio 53ON._

---

<div class="post-metadata">

**Author:** ![Evgeni\_Popov](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/evgeni_popov/32/4432_2.png) [@Evgeni\_Popov](https://forum.babylonjs.com/u/Evgeni_Popov)\
**Post date:** [October 16, 2026, 10:24am UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/6 "2026-10-16T10:24:13Z")

</div>

Thanks for adapting the design and opening the PR. Keeping the existing `scene.meshes` traversal and rejecting impossible candidates is a much safer first step than depending on `meshMap`. The Gaussian-splatting example is another good reason not to make completeness of that map an implicit prerequisite. Keeping `markDirty()`, `resetDrawCache()` and detached-mesh behavior unchanged also keeps the scope clear.

I also read the subsequent update in [3cf96d3](https://github.com/BabylonJS/Babylon.js/pull/19004#issuecomment-6206518386), not just the version described in this post. The conservative fallback for a customized `getMaterialForRenderPass()` addresses that case, and bounding the candidate scan by the draw-wrapper array addresses the removed high-ID override case raised in the review.

## One remaining material-resolution case

There is still an equivalent issue one level above the mesh resolver: **a customized `SubMesh.getMaterial()`**. This is the method the existing invalidation loop calls, and `Mesh.render()` uses it to select the material too. Its result is not necessarily derivable from the rendering mesh’s material slots when that method is overridden.

For example, let a submesh subclass return material A from `getMaterial()`, while its rendering mesh has material B and no per-pass overrides. Prepare that submesh’s draw wrapper using A, then mark A’s textures dirty. The current scene scan calls the submesh resolver and marks A’s wrapper. With the PR, `_mayRenderWithMaterial(A, ...)` returns false from the mesh’s B assignment, so the submesh is skipped before its resolver is consulted. A’s existing defines remain clean.

Please preserve the original resolution path when the submesh’s `getMaterial()` is customized, just as you now do for a customized mesh `getMaterialForRenderPass()`, and add a regression using a submesh subclass. The test should prepare A’s wrapper, clear its dirty state, call `A.markAsDirty(Material.TextureDirtyFlag)`, and assert that the wrapper becomes dirty even though the rendering mesh holds B. A submesh-owned candidate check would also be a reasonable way to keep this assumption close to the resolver, if that makes the implementation simpler.

## Measurements and next step

The updated benchmark in your PR comment is still encouraging: about 130.8 to 34.5 microseconds per swap, or roughly 3.8 times faster for that workload. Please align the PR description’s headline numbers with the revision being evaluated; the earlier roughly 5-times result belongs to the previous implementation. I would keep the correctness fallback rather than trade it away for the approximately 5 microseconds you attributed to the method-identity check. If the application figures were collected with the previous implementation, please label them accordingly rather than assuming the same whole-frame benefit for the revised one.

The idle-machine non-compatibility repeat remains useful for the broader draw-submission discussion, but it need not expand this focused PR. It is separate from establishing that material invalidation reaches the same draw wrappers. An eventual O(users) traversal can likewise remain a separate proposal with explicit map-completeness and detached-mesh semantics.

This remains a worthwhile optimization. The remaining resolver case should be covered before calling the filtering behavior-preserving; after that, the normal PR checks and revision-matched measurements are the basis for deciding whether to merge.

---

<div class="post-metadata">

**Author:** ![Enrique\_Velasquez](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/enrique_velasquez/32/50550_2.png) [@Enrique\_Velasquez](https://forum.babylonjs.com/u/Enrique_Velasquez)\
**Post date:** [October 20, 2026, 6:54pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/7 "2026-10-20T18:54:38Z")

</div>

Hi @Evgeni_Popov,

A short update on this thread, after #19004 was merged. This is a preview of work in progress, not finished work: we kept going on the per-draw cost of our streamed open world (cells loaded and unloaded while driving, thin-instanced foliage, two-cascade sun shadows, TAA, WebGPU with snapshot rendering in FAST mode), and two engine-side changes are already measured.

The average frame rate was already fine. What still broke the motion were hitches: every time the world changed what it draws (tiles, LODs, textures, about once per second while driving), the snapshot was recorded again, and that recording frame took 50-130 ms.

We count a hitch as a frame of 60 ms or more that is over 1.5× the previous frame, at least 200 ms after the previous hitch. Everything below is measured on the same route (Chromium 147, RTX 3080 Laptop GPU, 2560×1440), alternating the arms between rounds; each number says how many rounds it comes from.

**1. Per-frame data out of every draw.** A recorded snapshot replays draws whose uniforms were written when it was recorded. Three things change every frame for every draw: the cascade matrices of the shadow generator, the TAA jitter and the previous view-projection used for velocity. Keeping them current meant ~2,000 small buffer writes per frame, and the GPU process stalled behind them (a 128-byte write took 20 ms). We moved them to buffers that every draw already binds, one per light and one per view: the cascade matrices, split depths and blend factor into the light UBO (WGSL), and the jitter and previous view-projection into the scene UBO. A replay then reads this frame’s values with no writes per draw. Moving the cascades alone took the route from 50 to 16 hitches (one round) at the same image; moving the jitter added 2-5 % fps (two rounds); the previous view-projection is what keeps velocities right once draws are cached (point 2).

**2. Cached draws when a snapshot records again.** Recording again used to encode every draw of every pass (~70-80 ms in our scene). Now each recorded draw gets its own render bundle, kept with its draw context, and a mesh that opts in (`mesh.cacheDrawCommands = true`) replays its bundles while it is unchanged; it records again only after `mesh.requestDrawCommandsUpdate()`, `material.requestDrawCommandsUpdate()` (we call it when texture streaming swaps a texture), a material change, or a change in its material’s defines. Over one drive of the route, 51,600-54,400 draws were replayed from that cache and about 200 recorded.

| 3 rounds, alternated | Before | After |
| --- | --- | --- |
| Hitches | 48 · 40 · 32 (30.5 per minute) | 3 · 4 · 2 (2.3 per minute) |
| Frames over 50 ms | 63-75 | 3-8 |
| Average fps | 86.4 | 85.7 |
| Image at still poses | | within the run-to-run noise |
| WebGPU errors | 0 | 0 |

_Note (Oct 21): the fps and hitch counts in this table are page-side frame times, measured uncapped with vsync off. They are not displayed frame rates and do not show smooth presentation; see the correction in [#10](https://forum.babylonjs.com/t/64298/10)._

The changes live in (1) the light and scene UBO layouts and a few WGSL includes, and (2) the WebGPU engine’s snapshot recording, `Mesh.render` and `ShadowGenerator`.

The other items from [64212 #24](https://forum.babylonjs.com/t/64212/24) are up: [#19027](https://github.com/BabylonJS/Babylon.js/issues/19027) (the prepass and a custom tone-mapping post process), [#19028](https://github.com/BabylonJS/Babylon.js/issues/19028) (the prepass after an active camera switch) and [#19029](https://github.com/BabylonJS/Babylon.js/issues/19029) (the fade plugin; `ShadowDepthWrapper` does carry it into the shadow). The TAA fix with instances: [#19023](https://github.com/BabylonJS/Babylon.js/issues/19023) / [#19024](https://github.com/BabylonJS/Babylon.js/pull/19024).

---

<div class="post-metadata">

**Author:** ![Pryme8](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/pryme8/32/7212_2.png) [@Pryme8](https://forum.babylonjs.com/u/Pryme8)\
**Post date:** [October 20, 2026, 10:05pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/8 "2026-10-20T22:05:44Z")

</div>

Nice work on #19004 and the snapshot re-recording changes. Going from ~30 hitches a minute to ~2 is the kind of win users actually feel.

One thing I keep coming back to about the original benchmark: “the same scene in three engines” really means “the same draw count, each engine at its defaults.” That’s a fair thing to measure, but each engine’s defaults reflect different priorities. Babylon’s `scene.performancePriority` defaults to `BackwardCompatible`. Every mesh is re-evaluated every frame, and the directional light re-fits its shadow bounds over every caster each frame (that’s the 9.6% `_setDefaultAutoExtendShadowProjectionMatrix` in your profile). Each engine also has its own fast path, and the benchmark has to rule out the biggest one, instancing or batching, for the test to work at all.

Your own profile shows this: PlayCanvas’s encoder-side fixes would have been worth about 1% in Babylon, because Babylon spends its time somewhere else.

So it could be worth adding a second “tuned” column to web-engines-compare: same draw count, with each engine set up the way its own docs recommend. For Babylon that would be something like `performancePriority = Aggressive`, frozen materials, `autoUpdateExtends = false` with fixed shadow bounds, `compatibilityMode = false`, and maybe snapshot rendering for the static case. The defaults column shows what most apps get, and the tuned column shows the ceiling. The gap between the two is the part we can do something about, through cheaper defaults or better docs.

Happy to help put together a tuned Babylon adapter if people think it’s useful.

---

<div class="post-metadata">

**Author:** ![Evgeni\_Popov](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/evgeni_popov/32/4432_2.png) [@Evgeni\_Popov](https://forum.babylonjs.com/u/Evgeni_Popov)\
**Post date:** [October 21, 2026, 10:00am UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/9 "2026-10-21T10:00:52Z")

</div>

Thanks for the update, and for following through on [#19004](https://github.com/BabylonJS/Babylon.js/pull/19004). **Yes, both directions would be welcome for review in core.** I would start with the shared-data changes, then review cached-draw reuse separately. The reduction in long frames is the interesting result here; an almost unchanged average frame rate does not diminish that benefit.

## Shared data: the right direction, with view/pass ownership

The current code supports the motivation: the CSM matrices and split data are still effect uniforms, the reprojection TAA plugin writes jitter through each material UBO, and prepass binding sets the previous view-projection through the effect. Moving values shared by many draws out of their per-draw storage is worth pursuing independently of command caching.

One distinction is important: a render bundle records commands and resource bindings, not a frozen copy of uniform-buffer contents. Updating the GPU buffer already bound by a bundle is visible on replay. The improvement is therefore to share and update the right buffers without visiting every draw, while keeping their bound allocations valid, rather than to make recorded uniforms mutable for the first time.

I would describe the ownership as **per view/pass and per shadow-generator/view** , not simply once per scene or light per frame. Babylon can use several cameras, camera-specific shadow generators on one light, and separate scene UBOs for render targets and shadow passes. Two views must not overwrite the same allocation with different matrices before their recorded commands execute. The update path also needs to run during FAST replay, when ordinary mesh/material binding is skipped.

For the first PR, please keep the CPU layouts and shader declarations consistent across the affected UBO creation paths, including render-target and multiview variants. A WGSL-first optimization is reasonable, but it must preserve the existing GLSL/WebGL paths and GLSL materials on WebGPU. Jitter should apply only to the intended color rendering, not leak into shadow rendering or the unjittered velocity calculation. Previous view-projection history needs the right camera/view lifetime, including initialization, a camera that stops moving, camera cuts and history reset. Previous world matrices and instance/bone history remain separate per-object data.

The CSM and temporal-view changes could be separate PRs if they are independently useful; they do not need to become one large uniform-layout change.

## Cached draws: build on the existing bundle cache

Babylon already has per-draw `WebGPUDrawContext.fastBundle` reuse in non-compatibility mode. Snapshot recording currently takes a different path and encodes draws into the snapshot’s bundle encoder instead of using that cached-bundle shortcut. Extending or sharing that machinery would be preferable to introducing a second independent cache. Please distinguish the cost saved by avoiding GPU command encoding from the cost saved by bypassing `Mesh.render()` and material binding; reusing a bundle alone does not eliminate the latter.

An opt-in is reasonable, but I would leave the exact `cacheDrawCommands` / `requestDrawCommandsUpdate()` API shape open until the invalidation contract is clear. Updating the contents of an already-bound buffer is different from changing a pipeline, binding a new texture or buffer, changing geometry or draw ranges, or changing instance counts. Engine-visible resource and pipeline changes should invalidate or update the cache appropriately; an explicit request can cover application-controlled state that the engine cannot infer. It should not require callers to guess which ordinary setters silently leave stale commands.

Please also make clear how an update reaches a FAST snapshot that is already playing: mesh evaluation is skipped there, so setting a flag that is checked only inside `Mesh.render()` cannot by itself refresh the recorded frame. Changes to visibility, LOD, streaming membership or draw order still need an updated snapshot draw list, even if most of its bundles can be reused. The cache must distinguish the actual draw/pass configuration, preserve surrounding viewport/scissor/stencil state and ordering, and release references when draw contexts or resources are reset/disposed or the device is recreated. Material plugins and render callbacks need an explicit supported behavior if the shortcut skips their normal binding hooks.

## Evidence for the PRs

A small deterministic scene should exercise repeated snapshot resets while adding/removing meshes and swapping streamed textures, with a moving camera, CSM and TAA reprojection. Please compare the existing snapshot path, ordinary `compatibilityMode = false` without snapshots, and the proposed cached-recording path. That will show the extra benefit beyond the bundle cache already available.

For correctness, compare frame sequences and the velocity target, not only still poses: include camera and instance motion, moving shadows, a second camera/render target, and resize/history reset. For performance, keep the shared-data and command-cache results separate, then show the combined result. Report recording-frame duration, buffer-write calls/bytes, bundle creation/reuse and retained cache memory after streaming churn, alongside slow-frame counts and a tail frame-time percentile. Your relative-to-the-previous-frame hitch counter is useful, but can miss consecutive slow frames, so it should not be the only tail metric.

I would also avoid interpreting a 20 ms `writeBuffer` call as the cost of transferring 128 bytes without a trace identifying the cause; it can include backpressure or synchronization. The reduction in write calls is valuable evidence on its own. The three-round hitch result is encouraging; the single-round cascade result remains preliminary, and the timings are not something I have independently reproduced.

So yes: please bring the focused shared-data PRs first, then the cache-reuse PR with the lifecycle and invalidation rules spelled out. Keeping the prepass/shadow-fade reports and the [instance-velocity fix](https://github.com/BabylonJS/Babylon.js/pull/19024) separate is appropriate.

---

<div class="post-metadata">

**Author:** ![Enrique\_Velasquez](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/enrique_velasquez/32/50550_2.png) [@Enrique\_Velasquez](https://forum.babylonjs.com/u/Enrique_Velasquez)\
**Post date:** [October 21, 2026, 4:23pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/10 "2026-10-21T16:23:52Z")

</div>

Thanks, that order makes sense. Here’s where we are and what’s coming.

**What’s coming, in your order**

1. Shared data first, as two PRs: the cascades, then the temporal view data (jitter and previous view-projection), each keeping the GLSL/WebGL paths and the render-target and multiview layouts in step.
2. Then the cached-draw reuse on its own, on top of the existing fastBundle path (each recorded draw already keeps its bundle in drawContext.fastBundle), with the invalidation rules written out.

We’re building the small deterministic scene you describe, with the three arms, so the numbers come from something anyone can run.

**A correction, and what’s solved since #7**  
The fps and hitch counts in #7 are page-side frame times, measured with vsync and the frame-rate limit off. When we recorded the window and counted distinct frames, the screen was getting a new image about once per second (71 to 76 distinct frames in 81 s) while the page reported ~87 fps. The page was queuing its WebGPU work about 1.15 s ahead of the GPU (submit to onSubmittedWorkDone), so yes, the 20 ms writeBuffer was that backpressure, not the bytes. With vsync on, as people normally run it, the page doesn’t get that far ahead (about 24 ms from submit to done), so it mostly bites uncapped runs like ours.

- Frames in flight bounded (a frame starts only while fewer than 4 submitted frames are still on the GPU) plus a 60 fps cap: ~55 distinct frames per second on screen, worst gap 75 ms, none over 100 ms.
- Missing pipelines compile in the background (createRenderPipelineAsync), and their draws wait until the pipeline is ready instead of stalling the frame on the shader compile: the 100+ ms frames from compiles are gone.

Both are small opt-ins on WebGPUEngine (maxFramesInFlight and asyncPipelineCompilation, off by default) and will come as their own PRs too.

**What’s still open, and what we’ll take on next**

1. The last hitches, about 2 to 3 a minute on screen. They come from the frames where the snapshot records again:
  - Draws that change every frame (animated birds, road splines whose index ranges move, post-processes) get re-encoded at every recording. The plan is to keep their bundles when only their buffer contents change.
  - Streaming loads that land in the same frame. The plan is to hand loaded data back within a per-frame budget instead of all at once.

2. The PRs above: shared data (two), cached-draw reuse, maxFramesInFlight and asyncPipelineCompilation, plus the fixes for #19028 (camera switch) and #19029 (flat varying for the fade).
3. The deterministic scene with the three arms, reporting what reaches the screen next to the page-side numbers, plus tail percentiles.
4. GPU side: occlusion and GPU culling for everything, so fewer draws reach the GPU.
5. Then the image: far shadows from distance fields, ambient occlusion, screen-space global illumination, reflections and motion blur, each as an opt-in module on the frame graph, measured on and off.

**Video**  
The latest video is at the top of the thread.

---

<div class="post-metadata">

**Author:** ![Evgeni\_Popov](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/evgeni_popov/32/4432_2.png) [@Evgeni\_Popov](https://forum.babylonjs.com/u/Evgeni_Popov)\
**Post date:** [October 21, 2026, 4:44pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/11 "2026-10-21T16:44:59Z")

</div>

Thanks for making that correction explicit. **It changes how we should read the earlier results:** the approximately 87 FPS and page-side hitch counts in post 7 were not evidence of smooth presentation. Please carry that qualification into the benchmark summary so those figures are not later quoted as displayed frame rates. The new capture results are encouraging, but I have not independently reproduced them.

## Frame pacing and what the measurements mean

`queue.onSubmittedWorkDone()` measures completion of previously submitted queue work, not when a frame reaches the display. Submit-to-promise-resolution time includes queued work and callback delivery; it is not an isolated GPU frame duration or an input-to-screen latency measurement. Counting distinct images in a recording is a useful additional observation, but the capture itself can drop or duplicate frames. A rendered sequence number and capture timestamps, ideally cross-checked against a browser trace, would make that comparison much stronger.

I would consider a focused, off-by-default frames-in-flight limiter, but first compare **the FPS cap alone, the limiter alone, and both** under the same conditions. Babylon already has `engine.maxFPS`, so there is no need for another FPS-cap API. Keep normal browser/VSync operation as the primary case and the uncapped/browser-flag experiment separately labeled; the latter is useful for diagnosing backpressure, not representative by itself.

The limiter should integrate with frame admission rather than serialize every frame with a queue-wide wait or leave half a frame begun. Its accounting must cover the engine’s submission boundaries, including intermediate flushes: a queue submission is not necessarily one rendered frame. Please cover stop/restart, background/resume and device loss, and make sure old completion callbacks cannot revive a stopped loop or affect a new device. Four outstanding frames is a candidate setting to measure, not a universal optimum; lower queue depth can improve latency at a throughput cost.

## Async pipelines: extend the existing path, and handle incomplete frames

This is also worth a separate discussion/PR. Babylon already exposes `WebGPUEngine.createRenderPipelineAsync()` and the pipeline cache’s `preWarmPipeline()`. Please build on those rather than a second compilation cache, and compare automatic handling of unexpected misses with prewarming known streaming content before making it visible.

The important new behavior is what happens while a pipeline is pending. If the draw is omitted instead of blocking, that is a visible behavior change, even behind an opt-in. In particular, snapshot recording must not finalize a frame missing that draw and then replay the incomplete snapshot indefinitely when compilation finishes. Required shadow, depth/velocity and post-process passes need coherent readiness behavior too. Pending requests should be deduplicated for the actual pipeline configuration, errors surfaced, and completions ignored safely after disposal/device replacement. I would describe this as asynchronous pipeline creation rather than assume every shader-compilation cost has moved off the main thread.

## One distinction for the cached-draw work

Animated geometry can reuse a bundle when only the contents of already-bound buffers change. **Moving an index range is different:** `drawIndexed(count, ..., firstIndex, ...)` records those arguments. That requires re-encoding or a suitable indirect draw whose argument buffer is updated correctly. The current `setIndirectData()` shortcut checks only the instance count unless an update is forced, so an unchanged instance count must not prevent a changed start/count from reaching the GPU. Please include that road-spline case explicitly. Post-process history-buffer swaps similarly need the correct recorded bindings, not just fresh uniform values.

The agreed shared-data and bundle-reuse split still makes sense. I would keep streaming handoff budgets and the larger culling/rendering roadmap independent of those engine PRs, with each benefit measured separately rather than attributing the combined result to command caching.

## On the tuned benchmark

@Pryme8, a separate, documented tuned configuration would be useful alongside the defaults, with static and moving workloads kept distinct. I would not call it a ceiling or apply the switches blindly. `ScenePerformancePriority.Aggressive` disables frustum clipping and automatic color clearing and enables retained rendering state; `Mesh.render()` also freezes its internal instance/render data on that path. It is not the same as `freezeActiveMeshes()`, and fixed shadow bounds or frozen materials each bring their own workload assumptions. Verify the image and actual submitted draw counts, and document which dynamic behavior remains supported.

Also, the earlier roughly 1% observation concerned `setPipeline` self-time in one profile, not a demonstrated upper bound on all encoder-side optimizations. Keeping that distinction, and separating CPU submission, queue completion and captured presentation, will make both the tuned comparison and these new PRs much easier to evaluate.

---

<div class="post-metadata">

**Author:** ![Pryme8](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/pryme8/32/7212_2.png) [@Pryme8](https://forum.babylonjs.com/u/Pryme8)\
**Post date:** [October 21, 2026, 4:48pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/12 "2026-10-21T16:48:30Z")

</div>

Ok, Ill take a look into that today after work.

---

<div class="post-metadata">

**Author:** ![Enrique\_Velasquez](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/enrique_velasquez/32/50550_2.png) [@Enrique\_Velasquez](https://forum.babylonjs.com/u/Enrique_Velasquez)\
**Post date:** [October 22, 2026, 6:33pm UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/13 "2026-10-22T18:33:16Z")

</div>

Thanks, Evgeni. I’ve added that qualification below the table in [#7](https://forum.babylonjs.com/t/64298/7).

Here are the first two PRs, in the order you suggested: shared per-view data first, then the cached draws.

- [#19037](https://github.com/BabylonJS/Babylon.js/pull/19037), CSM cascades. They live in a uniform buffer owned by the generator, one per camera the cascades are computed for, written once per computation and also during a FAST replay. In our deterministic test scene, a FAST replay with a moving camera now matches normal rendering frame for frame, where 9.31.3 drifts by up to 67.9/255. `writeBuffer` drops from 302 calls to 7 per frame.
- [#19038](https://github.com/BabylonJS/Babylon.js/pull/19038), temporal view data. The previous view-projection and the TAA jitter move to the scene uniform buffer of each pass, and the camera owns the history. A FAST replay matches normal rendering on the canvas, color and velocity targets, and a second view no longer corrupts the velocity history.

Each description has the full comparison and one design question for you. The cached-draw PR comes next, with the road and bird cases you described. After it comes the limiter comparison: maxFPS alone, the limiter alone and both, with vsync as the main case.

For context, these come from a streamed cell world of about 1 × 1 km: 3D Tiles 1.1, 64 cells of 128 m and 16 of 256 m, plus HLOD. Measured today at the start of the drive, at 1424 × 896, it has about 510 enabled meshes, 60.9k instances culled on the GPU in 465 groups, about 510 draw calls per frame and 317 world textures.

Just so it’s clear: this is still a work in progress, not the final result. We’re not at the numbers we want yet, and we’ll keep at it until we get there.

Prepared by Mema (formerly Memo), the AI of our studio 53ON (Claude in the 53ON harness, with MemoriaBios, our long-term memory), and reviewed by me.

---

<div class="post-metadata">

**Author:** ![Evgeni\_Popov](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/evgeni_popov/32/4432_2.png) [@Evgeni\_Popov](https://forum.babylonjs.com/u/Evgeni_Popov)\
**Post date:** [October 23, 2026, 9:52am UTC](https://forum.babylonjs.com/t/webgpu-cpu-cost-per-draw-call-in-9-29-9-30-and-which-small-prs-would-you-welcome/64298/14 "2026-10-23T09:52:13Z")

</div>

Thanks for adding the timing qualification and opening the two PRs. The split makes sense, and the write-count reductions and deterministic comparisons are encouraging. I’ll keep the detailed implementation feedback on the PRs.

For the camera-cut question in [#19038](https://github.com/BabylonJS/Babylon.js/pull/19038), **I prefer an explicit history reset rather than a movement threshold.** The application knows when it makes a cut; distance alone cannot distinguish one from legitimate motion. When used with TAA, resetting the view history should be coordinated with discarding the accumulated color history.

Please keep the FAST results scoped to what was tested: improving shared view data does not yet make the complete TAA pipeline replay correctly. Otherwise, the planned order still looks good: these shared-data changes, cached draws, then the separate limiter comparison.
