Per-instance data can arrive two ways. A vertex buffer with stepMode: 'instance' (First Cover Quad) feeds attributes, converted by the input assembler from compact formats such as uint16 or unorm8x4. A storage buffer indexed by instance_index holds any struct, runtime-sized, readable in both stages and writable by compute shaders, which is what Indirect Draws and Bundles needs to cull and sort on the GPU. Instance vertex buffers are limited to vertex formats and a 2,048-byte stride, and a shader sees only its own record; a storage buffer is any host-shareable struct, runtime-sized and randomly accessible (a book can read its neighbor's price). Uniform arrays suit small fixed counts (64 KiB by default); storage buffers suit data shared with compute.
<!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="330"></canvas>
<canvas id="labels" width="600" height="330"></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 COUNT = 24;
const code = /* wgsl */ `
struct Book { price: f32, base: f32, rating: f32, stock: f32 } // any host-shareable struct
@group(0) @binding(0) var<storage, read_write> books: array<Book>; // runtime-sized
@group(0) @binding(1) var<uniform> time: f32;
@compute @workgroup_size(64) fn reprice(@builtin(global_invocation_id) id: vec3u) {
if (id.x >= arrayLength(&books)) { return; } // compute writes the records
let b = books[id.x];
books[id.x].price = b.base * (0.8 + 0.2 * sin(time * 1.3 + f32(id.x) * 0.9));
}
@group(0) @binding(0) var<storage> shelf: array<Book>; // vertex stage: read-only
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));
let me = shelf[i];
let left = shelf[max(i, 1u) - 1]; // random access: the neighbour's record
let bargain = i > 0 && me.price < left.price * 0.9;
let h = me.price / 40 * 1.3;
let color = select(vec3f(0.55, 0.62, 0.72), vec3f(0.16, 0.56, 0.30), bargain);
return Out(vec4f(-0.96 + f32(i) * (1.92 / ${COUNT}) + q.x * 0.065, -0.7 + q.y * h, 0, 1), color);
}
@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 data = new Float32Array(COUNT * 4);
for (let i = 0; i < COUNT; i++) { const base = 12 + ((i * 37) % 28); data.set([base, base, 4 + (i % 10) / 10, 1], i * 4); }
const books = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(books, 0, data);
const time = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const module = device.createShaderModule({ code });
const compute = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'reprice' } });
const render = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module, entryPoint: 'vs' }, fragment: { module, entryPoint: 'fs', targets: [{ format }] } });
const computeGroup = device.createBindGroup({ layout: compute.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: books } }, { binding: 1, resource: { buffer: time } }] });
const renderGroup = device.createBindGroup({ layout: render.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: books } }] });
ink.font = '12px system-ui, sans-serif'; ink.fillStyle = '#222';
ink.fillText('Each frame a compute pass rewrites the prices; each instance reads its own record and its left', 12, 20);
ink.fillText('neighbour\'s, and turns green when it costs 10% less. An instance vertex buffer could not do either.', 12, 38);
ink.fillStyle = '#555';
ink.fillText('vertex buffer (stepMode instance): compact formats, 2,048-byte stride, own record only', 12, 300);
ink.fillText('storage buffer: any struct, runtime-sized, random access, writable by compute', 12, 318);
function frame(now) {
device.queue.writeBuffer(time, 0, new Float32Array([now / 1000]));
const encoder = device.createCommandEncoder();
const cp = encoder.beginComputePass();
cp.setPipeline(compute); cp.setBindGroup(0, computeGroup); cp.dispatchWorkgroups(1); cp.end();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(render); pass.setBindGroup(0, renderGroup); pass.draw(4, COUNT);
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>