Buffers and Bind Groups's shelf computed corners from vertex_index and read books from storage. Vertex buffers are the fixed-function alternative: vertex.buffers describes up to 8 buffers (maxVertexBuffers), each with an arrayStride, a stepMode and attributes that map byte offsets to @location inputs. 'vertex' advances per vertex; 'instance' advances per instance, so one buffer of corners and one of books draw a whole shelf. The layout from First Cover Quad:
vertex: { module, buffers: [
{ arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] },
{ arrayStride: 8, stepMode: 'instance', attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32' }, // left edge
{ shaderLocation: 2, offset: 4, format: 'uint16' }, // page count
{ shaderLocation: 3, offset: 6, format: 'uint8' }] }] } // atlas cellpass.setVertexBuffer(slot, buffer) binds each, the slot being its index in buffers. A shader input with no matching attribute fails pipeline creation ("Vertex attribute slot 1 used in ... is not present in the VertexState"). Vertex buffers suit data read once per vertex, in order; storage buffers suit random access. Vertex buffers also work where storage in the vertex stage is limited: the specification's maxStorageBuffersInVertexStage limit can be lower than the per-stage default on small devices.
<!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 Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f, @location(1) @interpolate(flat) cell: u32 }
@vertex fn vs(@location(0) corner: vec2f, // buffer 0, stepMode 'vertex'
@location(1) left: f32, // buffer 1, stepMode 'instance': float32
@location(2) pages: u32, // uint16 page count
@location(3) cell: vec2u) -> Out { // uint8x2: atlas cell, spare byte
let height = f32(pages) / 450 * 1.4;
return Out(vec4f(left + corner.x * 0.26, -0.75 + corner.y * height, 0, 1), corner, cell.x);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
let tints = array(vec3f(.12, .37, .55), vec3f(.36, .25, .60), vec3f(.88, .60, .06),
vec3f(.25, .49, .23), vec3f(.71, .27, .18), vec3f(.16, .62, .56));
let band = step(0.72, in.uv.y) * step(in.uv.y, 0.8);
let shade = 0.75 + 0.25 * smoothstep(0.0, 0.15, in.uv.x);
return vec4f(mix(tints[in.cell] * shade, vec3f(0.98, 0.95, 0.86), band), 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 });
// Buffer 0: six corners of a quad, 8 bytes each.
const corners = new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]);
// Buffer 1: one 8-byte record per book: float32 left edge, uint16 pages, uint8 cell, 1 spare byte.
const pages = [312, 428, 256, 198, 344, 176];
const records = new ArrayBuffer(8 * 6), view = new DataView(records);
pages.forEach((p, i) => {
view.setFloat32(i * 8, -0.9 + i * 0.3, true);
view.setUint16(i * 8 + 4, p, true);
view.setUint8(i * 8 + 6, (i * 5) % 6);
});
const upload = (data) => {
const b = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(b, 0, data);
return b;
};
const cornerBuffer = upload(corners), bookBuffer = upload(records);
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', fragment: { module, targets: [{ format }] },
vertex: { module, buffers: [
{ arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] },
{ arrayStride: 8, stepMode: 'instance', attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32' }, // left edge
{ shaderLocation: 2, offset: 4, format: 'uint16' }, // page count
{ shaderLocation: 3, offset: 6, format: 'uint8x2' }] }] } }); // atlas cell (+ spare)
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(pipeline);
pass.setVertexBuffer(0, cornerBuffer); // slot = index in vertex.buffers
pass.setVertexBuffer(1, bookBuffer);
pass.draw(6, 6); // 6 corners x 6 instances
pass.end();
device.queue.submit([encoder.finish()]);
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText("buffer 0: arrayStride 8, stepMode 'vertex' -> 6 corners", 12, 20);
ink.fillText("buffer 1: arrayStride 8, stepMode 'instance' -> 6 books", 12, 38);
// Byte map of one instance record
const parts = [['float32 left', 4, '#3d7fc4'], ['uint16 pages', 2, '#d98a2b'], ['u8 cell', 1, '#5aa36b'], ['spare', 1, '#bbb']];
let x = 12;
for (const [name, bytes, color] of parts) {
ink.fillStyle = color; ink.fillRect(x, 300, bytes * 70 - 3, 22);
ink.fillStyle = '#fff'; ink.font = '11px system-ui, sans-serif'; ink.fillText(name, x + 4, 315);
x += bytes * 70;
}
ink.fillStyle = '#444'; ink.fillText('one 8-byte instance record (three float32s would take 12)', x + 8, 315);
ink.textAlign = 'center';
pages.forEach((p, i) => ink.fillText(`${p} p.`, 75 + i * 90, 280));
}
main();
</script>