Draw Call Cost

The Cost of a Draw Call, Revisited

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:

CPU time per frame for 1,000 books: meshes, instances, thin instances, snapshotsJavaScript
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`);
}
Three runs on the shared GTX 1650 machine, relative to WebGL 2 meshes (the timer has 0.1 ms steps)
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.

A wall of 1,000 books timed as separate meshes, instances and thin instances, with CPU time per frame for eachHTMLLive
<!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>