A bundle holds references, not copies: the buffers and textures it binds can change contents every frame, and each replay sees the new data. Only the command list is frozen. So the camera, per-object uniforms and even the draw counts (through indirect draws) stay live, while pipelines, bind groups and the draw sequence stay fixed. Record static scenery once at load time and replay it each frame, next to regular commands for the parts that change. Executing bundles resets the pass's pipeline, bind groups and buffers, so re-set them afterwards: a draw() right after executeBundles() failed with "No pipeline set". Bundles must be re-recorded when a bind group has to be replaced, for example when a resized canvas needs a new depth texture view bound in a group.
<!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="340"></canvas>
<canvas id="labels" width="600" height="340"></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 code = /* wgsl */ `
@group(0) @binding(0) var<uniform> camera: vec4f; // time, sway, -, -: rewritten every frame
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec3f }
@vertex fn scenery(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let q = vec2f(f32(v & 1), f32(v >> 1));
let row = f32(i / 12); let k = f32(i % 12);
let sway = sin(camera.x + k * 0.5 + row) * 0.05 * q.y;
let p = vec2f(-0.95 + k * 0.16 + q.x * 0.12 + sway, 0.2 - row * 0.55 + q.y * (0.35 + 0.05 * (k % 3)));
return Out(vec4f(p, 0, 1), mix(vec3f(0.08, 0.40, 0.75), vec3f(0.30, 0.60, 0.85), (k % 4) / 3));
}
@vertex fn highlight(@builtin(vertex_index) v: u32) -> Out { // the part that changes: drawn directly
let q = vec2f(f32(v & 1), f32(v >> 1));
let k = floor(fract(camera.x * 0.1) * 12);
return Out(vec4f(-0.965 + k * 0.16 + q.x * 0.15, 0.17 + q.y * 0.52, 0, 1), vec3f(0.95, 0.70, 0.20));
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return vec4f(in.color, 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 layout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } }] });
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [layout] });
const make = (entryPoint) => device.createRenderPipeline({ layout: pipelineLayout, primitive: { topology: 'triangle-strip' },
vertex: { module, entryPoint }, fragment: { module, entryPoint: 'fs', targets: [{ format }] } });
const sceneryPipeline = make('scenery'), highlightPipeline = make('highlight');
const camera = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const group = device.createBindGroup({ layout, entries: [{ binding: 0, resource: { buffer: camera } }] });
// Recorded once at load time: it holds a reference to 'camera', not a copy of its contents.
const recorder = device.createRenderBundleEncoder({ colorFormats: [format] });
recorder.setPipeline(sceneryPipeline);
recorder.setBindGroup(0, group);
recorder.draw(4, 24);
const scenery = recorder.finish();
// Executing bundles resets the pass state: a draw right after it has no pipeline.
device.pushErrorScope('validation');
const scratch = device.createTexture({ size: [4, 4], format, usage: GPUTextureUsage.RENDER_ATTACHMENT });
const bad = device.createCommandEncoder();
const bp = bad.beginRenderPass({ colorAttachments: [{ view: scratch.createView(), loadOp: 'clear', storeOp: 'store' }] });
bp.setPipeline(highlightPipeline); bp.setBindGroup(0, group);
bp.executeBundles([scenery]);
bp.draw(4); // pipeline and group were reset
bp.end(); bad.finish();
const reset = await device.popErrorScope();
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText('bundle (recorded once): 24 spines reading the camera buffer', 12, 20);
ink.fillText('per frame: writeBuffer(camera), executeBundles(), then setPipeline + setBindGroup + draw', 12, 38);
ink.fillStyle = '#8a2b2b'; ink.font = '11px ui-monospace, monospace';
ink.fillText(`draw() right after executeBundles(): ${reset ? reset.message.split('\n')[0] : 'accepted'}`.slice(0, 94), 12, 330);
function frame(now) {
device.queue.writeBuffer(camera, 0, new Float32Array([now / 600, 0, 0, 0])); // live data under a frozen command list
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' }] });
pass.setPipeline(highlightPipeline); // regular commands before...
pass.setBindGroup(0, group);
pass.draw(4);
pass.executeBundles([scenery]); // ...the static scenery...
pass.end(); // (anything drawn after it would need state set again)
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>