When Bundles Help

When Render Bundles Help and When They Do Not

Bundles save JavaScript and validation work, not GPU work. Timed on the shared machine (median of 20 frames, the same tiny triangles as Bind Group Trade-offs), the JavaScript cost of a pass fell to the timer's floor with a bundle, while the time to completion barely moved:

Direct draws versus a replayed bundle (Chrome 154 1 , GTX 1650)
Draws per pass Direct: encode and submit Bundle: encode and submit Recording the bundle once
1,000 0.1 ms 0.1 ms 0.2 ms
10,000 0.4 ms 0.1 ms 0.7-1.1 ms
10,000, bind group changed per draw 1.2 ms 0.1 ms 1.5-3.3 ms

Completion took 4.9-5.8 ms either way for 10,000 draws. Bundles help when many draws repeat unchanged frame after frame (static scenery, UI, instanced crowds of meshes) and the CPU is the bottleneck, as on phones. They do not help with a few draws, with scenes whose draw list changes (culling on the CPU, sorting by depth) or with GPU-bound frames. Babylon.js 89,603 's WebGPU-only snapshot rendering (Babylon.js) records one frame's draw calls and replays them, and its documentation likewise reports the gain on the JavaScript side only.

JavaScript cost of direct draws versus a replayed bundle: the book's numbers and a live measurement with 3,000 small drawsHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="360"></canvas>
  <canvas id="labels" width="600" height="360"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

const DRAWS = 3000;
// The book: encode and submit, median of 20 frames (Chrome 154, GTX 1650).
const book = [['1,000 draws', 0.1, 0.1], ['10,000 draws', 0.4, 0.1], ['10,000, group changed per draw', 1.2, 0.1]];
const code = /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> @builtin(position) vec4f {
  let a = f32(v) * 2.094;
  let c = vec2f(f32(i % 60) / 30 - 0.98, 0.55 - f32(i / 60) / 50 * 0.5);   // one tiny triangle per draw
  return vec4f(c + vec2f(cos(a), sin(a)) * 0.012, 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.55, 0.70, 0.88, 1); }`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const device = await adapter.requestDevice();
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });
  const module = device.createShaderModule({ code });
  const pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module }, fragment: { module, targets: [{ format }] } });

  const t0 = performance.now();
  const recorder = device.createRenderBundleEncoder({ colorFormats: [format] });
  recorder.setPipeline(pipeline);
  for (let i = 0; i < DRAWS; i++) recorder.draw(3, 1, 0, i);    // separate draws, as a real scene has
  const bundle = recorder.finish();
  const recordMs = performance.now() - t0;

  const samples = { direct: [], bundle: [] };
  let frames = 0;
  function frame() {
    const useBundle = frames % 2 === 1;
    const start = performance.now();
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
    if (useBundle) pass.executeBundles([bundle]);
    else { pass.setPipeline(pipeline); for (let i = 0; i < DRAWS; i++) pass.draw(3, 1, 0, i); }
    pass.end();
    device.queue.submit([encoder.finish()]);
    (useBundle ? samples.bundle : samples.direct).push(performance.now() - start);
    if (++frames % 20 === 0) drawChart();
    requestAnimationFrame(frame);
  }
  const median = (a) => [...a].sort((x, y) => x - y)[Math.floor(a.length / 2)] ?? 0;
  function drawChart() {
    ink.clearRect(0, 0, 600, 360);
    ink.fillStyle = 'rgba(247,244,238,0.92)'; ink.fillRect(0, 190, 600, 170);
    const rows = [...book, [`this browser: ${DRAWS.toLocaleString('en-US')} draws`, median(samples.direct), median(samples.bundle)]];
    const max = Math.max(1.3, ...rows.map((r) => r[1]));
    rows.forEach(([name, direct, replay], i) => {
      const y = 204 + i * 36;
      ink.font = '12px system-ui, sans-serif'; ink.fillStyle = '#222'; ink.textAlign = 'right'; ink.fillText(name, 222, y + 14);
      ink.fillStyle = '#e0990f'; ink.fillRect(230, y, Math.max(direct / max * 300, 2), 12);
      ink.fillStyle = '#1f5f8b'; ink.fillRect(230, y + 14, Math.max(replay / max * 300, 2), 12);
      ink.fillStyle = '#333'; ink.textAlign = 'left';
      ink.fillText(`${direct.toFixed(1)} / ${replay.toFixed(1)} ms`, 236 + Math.max(direct, replay) / max * 300, y + 18);
    });
    ink.font = '11.5px system-ui, sans-serif';
    ink.fillStyle = '#e0990f'; ink.fillRect(12, 342, 12, 12); ink.fillStyle = '#333'; ink.fillText('direct draws: encode + submit', 28, 352);
    ink.fillStyle = '#1f5f8b'; ink.fillRect(230, 342, 12, 12); ink.fillStyle = '#333'; ink.fillText(`replayed bundle (recorded once in ${recordMs.toFixed(1)} ms)`, 246, 352);
    ink.fillStyle = '#222'; ink.font = 'bold 12.5px system-ui, sans-serif';
    ink.fillText('Bundles save JavaScript and validation work, not GPU work.', 12, 196 - 12);
  }
  requestAnimationFrame(frame);
}
main();
</script>