device.createPipelineLayout({ bindGroupLayouts }) lists one bind group layout per group; the array index is the n of @group(n) and of setBindGroup(n, ...). A bound group stays bound when you switch pipelines, as long as both pipeline layouts agree for that group, so order groups from least to most often changed: group 0 (camera, catalog) is set once per pass and only the last group changes per draw, as in Catalog Buffer Layout. Layouts compare by content: a group made from a separately created identical layout worked.
An entry may be null to leave a group unused. Chrome 154 1 also has the newer immediates, per-draw constants like Vulkan's push constants: with immediateSize: 4 in the layout, var<immediate> percentOff: f32; and pass.setImmediates(0, new Float32Array([20])), a dispatch computed 31.60 for book 2 with no buffer at all (maxImmediateSize is 64 bytes here).
<!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 header = /* wgsl */ `
@group(0) @binding(0) var<uniform> camera: vec4f; // pan x, zoom, time, -
@group(1) @binding(0) var<uniform> object: vec4f; // x, y, width, height
fn place(q: vec2f) -> vec4f {
let p = (object.xy + q * object.zw - vec2f(camera.x, 0)) * camera.y;
return vec4f(p, 0, 1);
}`;
const booksCode = header + /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f { return place(vec2f(f32(v & 1), f32(v >> 1))); }
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.08 + object.x * 0.2, 0.40, 0.75 - object.x * 0.2, 1); }`;
const planksCode = header + /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f { return place(vec2f(f32(v & 1), f32(v >> 1))); }
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
let grain = 0.9 + 0.1 * sin(p.x * 0.35 + sin(p.y * 0.8) * 3); // a wood pattern
return vec4f(vec3f(0.55, 0.38, 0.22) * grain, 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 V = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
const entry = { binding: 0, visibility: V, buffer: { type: 'uniform' } };
// Array index = @group number. The planks get a separately created but identical
// camera layout: layouts compare by content, so group 0 stays bound across pipelines.
const cameraLayout = device.createBindGroupLayout({ entries: [entry] });
const cameraLayoutCopy = device.createBindGroupLayout({ entries: [entry] });
const objectLayout = device.createBindGroupLayout({ entries: [entry] });
const make = (code, groups) => {
const module = device.createShaderModule({ code });
return device.createRenderPipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: groups }),
primitive: { topology: 'triangle-strip' }, vertex: { module }, fragment: { module, targets: [{ format }] } });
};
const books = make(booksCode, [cameraLayout, objectLayout]);
const planks = make(planksCode, [cameraLayoutCopy, objectLayout]);
const uniform = (data) => {
const b = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(b, 0, new Float32Array(data));
return b;
};
const group = (layout, buffer) => device.createBindGroup({ layout, entries: [{ binding: 0, resource: { buffer } }] });
const cameraBuffer = uniform([0, 1, 0, 0]);
const cameraGroup = group(cameraLayout, cameraBuffer);
const bookGroups = [], plankGroups = [];
for (let shelf = 0; shelf < 2; shelf++) {
plankGroups.push(group(objectLayout, uniform([-1.2, -0.11 - shelf * 0.85, 2.4, 0.06])));
for (let i = 0; i < 7; i++) bookGroups.push(group(objectLayout, uniform([-1.05 + i * 0.3, -0.05 - shelf * 0.85, 0.2, 0.55 + (i * 5 % 4) * 0.07])));
}
function frame(now) {
device.queue.writeBuffer(cameraBuffer, 0, new Float32Array([0.25 * Math.sin(now / 1500), 0.85, now / 1000, 0]));
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.setBindGroup(0, cameraGroup); // set once per pass...
pass.setPipeline(planks);
for (const g of plankGroups) { pass.setBindGroup(1, g); pass.draw(4); }
pass.setPipeline(books); // ...and still bound after switching pipelines
for (const g of bookGroups) { pass.setBindGroup(1, g); pass.draw(4); }
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
['bindGroupLayouts: [cameraLayout, objectLayout] -> @group(0), @group(1)',
'setBindGroup(0, camera) once; setPipeline(planks); setPipeline(books)',
'group 0 survives both switches; only setBindGroup(1, ...) runs per draw'].forEach((t, i) => ink.fillText(t, 10, 20 + i * 17));
requestAnimationFrame(frame);
}
main();
</script>