Update (Oct 23): latest improvements applied. Here’s the route as it runs now:
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 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, 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: it uses plain defaults (clones,
PBRMetallicRoughnessMaterial, a PCFShadowGenerator, nothing frozen, compatibility mode on). - An independent run, endel/webgpu-webgl-benchmarks (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 (_draw is the same in 9.29.0 and on current master), compatibility mode:
| PlayCanvas PR (their result) | Babylon today |
|---|---|
| #9380 + #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: 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: 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: 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). |
| #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 sameGPURenderPipelineis already bound. - B. Skip
setVertexBuffer()andsetIndexBuffer()when buffer, offset and format did not change, like the WebGL path does. - C. Skip
setBindGroup(i, g)whengis already bound ati, 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 manyGPUBindGroupobjects. - 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 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
- Would you welcome A, B and C as separate small PRs? Is there one you would rather not have?
- Would you consider D, or the bind group split, for core?
- 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
// 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;
};