Recording a GPURenderBundle

A GPURenderBundle is a recorded list of render commands. device.createRenderBundleEncoder({ colorFormats, depthStencilFormat, sampleCount }) returns an encoder with the drawing half of a render pass: setPipeline(), setBindGroup(), setVertexBuffer(), setIndexBuffer(), the four draw calls and setImmediates(). It has no setViewport(), setScissorRect(), setBlendConstant(), setStencilReference(), occlusion queries or nested executeBundles() (all undefined on the encoder), because those belong to the pass. finish() validates everything once and returns the bundle; pass.executeBundles([bundle, ...]) runs it inside any render pass whose attachment formats and sample count match the descriptor. Recording Instanced Shelf's three shelves takes the same calls as drawing them:

The three shelves of Section 4.14.10 recorded as a bundleJavaScript
const recorder = device.createRenderBundleEncoder({ colorFormats: [format] });  // as the pass
recorder.setPipeline(pipeline), recorder.setBindGroup(0, scene);
for (let i = 0; i < 3; i++) {
  recorder.setBindGroup(1, shelfGroup, [i * 256]), recorder.draw(6, 7);
}
const shelvesBundle = recorder.finish({ label: 'three shelves' });
// every frame, in a pass whose one color attachment has that format:
pass.executeBundles([shelvesBundle]);

Swapped into the 4.14.10 page in place of its draw loop, the bundle drew the identical three shelves. A bundle recorded for rgba8unorm and executed in a bgra8unorm pass failed: "Attachment state of renderBundles[0] ... is not compatible".

Three shelves recorded once into a GPURenderBundle and replayed with executeBundles(), plus the format-mismatch errorHTMLLive
<!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 */ `
struct Book { price: f32, rating: f32, inStock: f32 }
struct Shelf { y: f32, maxPrice: f32, minRating: f32 }
@group(0) @binding(0) var<storage> books: array<Book>;
@group(1) @binding(0) var<uniform> shelf: Shelf;
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec3f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
  let q = vec2f(f32(v & 1), f32(v >> 1));
  if (i == 6) { return Out(vec4f(q.x * 1.46 - 0.46, shelf.y - 0.3 + q.y * 0.05, 0, 1), vec3f(0.55, 0.38, 0.22)); }
  let b = books[i];
  let on = b.inStock > 0 && b.price < shelf.maxPrice && b.rating >= shelf.minRating;
  let tints = array(vec3f(.12, .37, .55), vec3f(.36, .25, .60), vec3f(.88, .60, .06), vec3f(.71, .27, .18), vec3f(.25, .49, .23), vec3f(.16, .62, .56));
  return Out(vec4f(-0.42 + f32(i) * 0.24 + q.x * 0.2, shelf.y - 0.25 + q.y * 0.46, 0, 1), mix(vec3f(0.93, 0.91, 0.87), tints[i], select(0.2, 1.0, on)));
}
@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 { STORAGE, UNIFORM, COPY_DST } = GPUBufferUsage;
  const SLOT = device.limits.minUniformBufferOffsetAlignment;
  const books = device.createBuffer({ size: 72, usage: STORAGE | COPY_DST });
  device.queue.writeBuffer(books, 0, new Float32Array([14.99, 4.6, 1, 39.50, 4.3, 1, 24.00, 4.8, 0, 18.75, 4.1, 1, 16.20, 4.5, 1, 21.30, 4.4, 1]));
  const shelfData = new Float32Array(3 * SLOT / 4);
  [[0.7, 1e9, 0], [0.12, 20, 0], [-0.46, 1e9, 4.5]].forEach((s, i) => shelfData.set(s, i * SLOT / 4));
  const shelves = device.createBuffer({ size: shelfData.byteLength, usage: UNIFORM | COPY_DST });
  device.queue.writeBuffer(shelves, 0, shelfData);

  const V = GPUShaderStage.VERTEX;
  const sceneLayout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: V, buffer: { type: 'read-only-storage' } }] });
  const shelfLayout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: V, buffer: { hasDynamicOffset: true } }] });
  const module = device.createShaderModule({ code });
  const pipelineFor = (targetFormat) => device.createRenderPipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [sceneLayout, shelfLayout] }),
    primitive: { topology: 'triangle-strip' }, vertex: { module }, fragment: { module, targets: [{ format: targetFormat }] } });
  const scene = device.createBindGroup({ layout: sceneLayout, entries: [{ binding: 0, resource: { buffer: books } }] });
  const shelfGroup = device.createBindGroup({ layout: shelfLayout, entries: [{ binding: 0, resource: { buffer: shelves, size: 12 } }] });

  // Recording takes the same calls as drawing; finish() validates everything once.
  function record(targetFormat) {
    const recorder = device.createRenderBundleEncoder({ colorFormats: [targetFormat] });   // as the pass
    recorder.setPipeline(pipelineFor(targetFormat));
    recorder.setBindGroup(0, scene);
    for (let i = 0; i < 3; i++) { recorder.setBindGroup(1, shelfGroup, [i * SLOT]); recorder.draw(4, 7); }
    return recorder.finish({ label: 'three shelves' });
  }
  const shelvesBundle = record(format);
  const encoderMembers = ['setViewport', 'setScissorRect', 'setBlendConstant', 'executeBundles']
    .filter((name) => !(name in device.createRenderBundleEncoder({ colorFormats: [format] })));

  // A bundle recorded for another format cannot run in this pass.
  const other = format === 'bgra8unorm' ? 'rgba8unorm' : 'bgra8unorm';
  const wrongBundle = record(other);
  device.pushErrorScope('validation');
  const scratch = device.createTexture({ size: [4, 4], format, usage: GPUTextureUsage.RENDER_ATTACHMENT });
  const bad = device.createCommandEncoder();
  const badPass = bad.beginRenderPass({ colorAttachments: [{ view: scratch.createView(), loadOp: 'clear', storeOp: 'store' }] });
  badPass.executeBundles([wrongBundle]);
  badPass.end();
  bad.finish();
  const mismatch = await device.popErrorScope();

  ink.font = 'bold 14px system-ui, sans-serif'; ink.fillStyle = '#222';
  ['All books', 'Under $20', 'Rated 4.5+'].forEach((t, i) => ink.fillText(t, 14, 54 + i * 99));
  ink.font = '11px ui-monospace, monospace'; ink.fillStyle = '#1f4f8a';
  ink.fillText('every frame:', 14, 74); ink.fillText('executeBundles(', 14, 88); ink.fillText('  [shelvesBundle])', 14, 102);
  ink.fillStyle = '#555';
  ink.fillText(`not on a bundle encoder: ${encoderMembers.join(', ')}`, 14, 314);
  ink.fillStyle = '#8a2b2b';
  ink.fillText(`${other} bundle in a ${format} pass: ${mismatch ? mismatch.message.split('\n')[0] : 'accepted'}`.slice(0, 94), 14, 332);

  function frame() {
    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.executeBundles([shelvesBundle]);             // replays the recorded draws
    pass.end();
    device.queue.submit([encoder.finish()]);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
main();
</script>