Bottlenecks timed raw WebGL draw calls. An engine adds its own JavaScript per draw (culling, defines checks, uniforms, binding), so this test draws 1,000 books several ways and averages the CPU time per frame:
async function measure(engine, mode) {
const scene = new BABYLON.Scene(engine);
new BABYLON.ArcRotateCamera('camera', -Math.PI / 2, 1.2, 60, BABYLON.Vector3.Zero(), scene);
const book = BABYLON.MeshBuilder.CreateBox('book', { width: 0.3, depth: 0.7 }, scene);
const m = new Float32Array(16 * 1000); // thin-instance matrices
for (let i = 0; i < 1000; i++) { // a 40 x 25 wall
const at = new BABYLON.Vector3((i % 40) - 20, Math.floor(i / 40) - 12, 0);
if (mode === 'thin') BABYLON.Matrix.Translation(at.x, at.y, 0).copyToArray(m, i * 16);
else if (i === 0) book.position = at;
else (mode === 'instances' ? book.createInstance() : book.clone()).position = at;
}
if (mode === 'thin') book.thinInstanceSetBuffer('matrix', m, 16);
await scene.whenReadyAsync();
if (mode === 'snapshot') { // WebGPU only
scene.meshes.forEach((mesh) => mesh.freezeWorldMatrix()); scene.freezeActiveMeshes();
Object.assign(engine, { snapshotRenderingMode: BABYLON.Constants.SNAPSHOTRENDERING_FAST,
snapshotRendering: true }); // record once, then replay
}
const stats = new BABYLON.SceneInstrumentation(scene);
stats.captureFrameTime = true; // CPU time, frame start to end
engine.runRenderLoop(() => scene.render());
await new Promise((resolve) => setTimeout(resolve, 3000));
engine.stopRenderLoop(); engine.snapshotRendering = false; scene.dispose();
return stats.frameTimeCounter.lastSecAverage; // the last second's average
}
const gl = new BABYLON.Engine(view);
const second = document.body.appendChild(document.createElement('canvas'));
const gpu = new BABYLON.WebGPUEngine(second);
await gpu.initAsync();
for (const [engine, mode] of [[gl, 'meshes'], [gl, 'instances'], [gl, 'thin'],
[gpu, 'meshes'], [gpu, 'instances'], [gpu, 'snapshot']]) {
const ms = (await measure(engine, mode)).toFixed(1);
console.log(`${engine.isWebGPU ? 'WebGPU' : 'WebGL2'} ${mode}: ${ms} ms`);
}| 1,000 books drawn as | Draw calls | CPU time per frame |
|---|---|---|
| WebGL 2, separate meshes | 1,000 | 1.00 (4.1 to 4.8 ms) |
| WebGL 2, instances | 1 | 0.31 to 0.37 |
| WebGL 2, thin instances | 1 | 0.04 to 0.05 |
| WebGPU, separate meshes | 1,000 | 1.42 to 2.02 |
| WebGPU, instances | 1 | 0.35 to 0.42 |
| WebGPU, fast snapshot rendering | 1,000, replayed | 0.05 to 0.07 |
Read the ratios, not the milliseconds. Per draw, the WebGPU path spent 1.4 to 2 times the JavaScript of the WebGL path; instances still update 1,000 matrices a frame. Fast snapshots replay render bundles (Recording a GPURenderBundle) at a twentieth of the cost, ideal for a static shop. Cut draws first (Many Objects), then snapshot.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 280px; display: block; touch-action: none; outline: none; }
#info { max-width: 600px; margin: 0; padding: 4px 8px; font: 12px monospace; white-space: pre-wrap; }
</style>
<canvas id="view"></canvas>
<pre id="info">Measuring…</pre>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true);
const info = document.getElementById('info');
const results = [];
let scene = null;
function build(mode) { // a 40 x 25 wall of books
scene?.dispose();
scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#f7f3ecff');
new BABYLON.ArcRotateCamera('camera', -Math.PI / 2, 1.2, 60, BABYLON.Vector3.Zero(), scene);
new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.6), scene);
const book = BABYLON.MeshBuilder.CreateBox('book', { width: 0.3, depth: 0.7 }, scene);
book.material = new BABYLON.StandardMaterial('cover', scene);
book.material.diffuseColor = BABYLON.Color3.FromHexString({ meshes: '#b5452f', instances: '#e09a10', thin: '#3f7d3a' }[mode]);
const m = new Float32Array(16 * 1000); // thin-instance matrices
for (let i = 0; i < 1000; i++) {
const at = new BABYLON.Vector3((i % 40) - 20, Math.floor(i / 40) - 12, 0);
if (mode === 'thin') BABYLON.Matrix.Translation(at.x, at.y, 0).copyToArray(m, i * 16);
else if (i === 0) book.position = at;
else (mode === 'instances' ? book.createInstance('b' + i) : book.clone('b' + i)).position = at;
}
if (mode === 'thin') book.thinInstanceSetBuffer('matrix', m, 16);
const stats = new BABYLON.SceneInstrumentation(scene);
stats.captureFrameTime = true; // CPU time, frame start to end
return stats;
}
const MODES = ['meshes', 'instances', 'thin'];
let mode = 0, stats = build(MODES[0]), started = performance.now();
engine.runRenderLoop(() => {
scene.render();
if (performance.now() - started > 2000) { // two seconds per mode, then the next
results[mode] = `${MODES[mode].padEnd(10)} ${String(stats.drawCallsCounter.current).padStart(5)} draw calls ` +
`${stats.frameTimeCounter.lastSecAverage.toFixed(2)} ms per frame`;
mode = (mode + 1) % MODES.length;
stats = build(MODES[mode]);
started = performance.now();
}
info.textContent = `now: ${MODES[mode]}\n` + results.filter(Boolean).join('\n');
});
addEventListener('resize', () => engine.resize());
</script>