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:
| 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.
<!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>