A GPUVertexFormat names the bytes in the buffer and how the shader sees them: float32x3 (12 bytes) arrives as a vec3f for positions, snorm8x4 (4 bytes) as a vec4f from -1 to 1 for packed normals, unorm8x4 as a 0 to 1 vec4f for colors, and uint16 or uint8 as a u32. There are no three-component 8- or 16-bit formats, and the single-component ones are newer additions that Chrome 154 1 accepts.
Packing cuts memory traffic: the book record above is 8 bytes where three float32s would take 12. The rules were tested on this machine: an attribute offset must be a multiple of the format's size, capped at 4 ("Attribute offset (1) in not a multiple of 2", Chrome's typo included); arrayStride must be a multiple of 4 ("Vertex buffer arrayStride (6) is not a multiple of 4"); and the base type must match (float32 or unorm16 into a u32 input failed with "Attribute base type (Float ...) does not match the shader's base type (Uint)").
<!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) color: vec3f, @location(1) normal: vec3f }
@vertex fn vs(@location(0) position: vec2f, // float32x2: 8 bytes
@location(1) color: vec4f, // unorm8x4: bytes 0-255 arrive as 0-1
@location(2) normal: vec4f) -> Out { // snorm8x4: bytes -127..127 arrive as -1..1
return Out(vec4f(position, 0, 1), color.rgb, normal.xyz);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
let light = max(dot(normalize(in.normal), normalize(vec3f(-0.5, 0.4, 0.75))), 0.0);
return vec4f(in.color * (0.3 + 0.7 * light), 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 });
// Twelve spines, each a quad whose normal tilts across the fan: 12 bytes per vertex.
const SPINES = 12, bytes = new ArrayBuffer(SPINES * 6 * 12), dv = new DataView(bytes);
const quad = [[0, 0], [1, 0], [0, 1], [1, 0], [1, 1], [0, 1]];
const palette = [[31, 95, 139], [92, 64, 153], [224, 153, 15], [64, 125, 59], [181, 69, 46], [41, 158, 143]];
let o = 0;
for (let s = 0; s < SPINES; s++) {
const angle = (s / (SPINES - 1) - 0.5) * 1.6; // normals fan from left to right
for (const [qx, qy] of quad) {
dv.setFloat32(o, -0.9 + s * 0.15 + qx * 0.13, true); dv.setFloat32(o + 4, -0.55 + qy * (0.9 + (s % 3) * 0.1), true);
palette[s % 6].forEach((c, k) => dv.setUint8(o + 8 + k, c)); dv.setUint8(o + 11, 255);
o += 12;
}
}
// Normals live in a second, 4-byte-per-vertex stream (snorm8x4)
const normals = new Int8Array(SPINES * 6 * 4);
for (let s = 0; s < SPINES; s++) {
const a = (s / (SPINES - 1) - 0.5) * 1.6;
for (let v = 0; v < 6; v++) normals.set([Math.round(Math.sin(a) * 127), 0, Math.round(Math.cos(a) * 127), 0], (s * 6 + v) * 4);
}
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 vertexBuffer = upload(bytes), normalBuffer = upload(normals);
const module = device.createShaderModule({ code });
const layout = (colorOffset) => [
{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' },
{ shaderLocation: 1, offset: colorOffset, format: 'unorm8x4' }] },
{ arrayStride: 4, attributes: [{ shaderLocation: 2, offset: 0, format: 'snorm8x4' }] }];
// A misaligned attribute: offset 6 for a 4-byte format fails pipeline creation.
device.pushErrorScope('validation');
device.createRenderPipeline({ layout: 'auto', vertex: { module, buffers: layout(6) }, fragment: { module, targets: [{ format }] } });
const misaligned = await device.popErrorScope();
const pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module, buffers: layout(8) }, fragment: { module, targets: [{ format }] } });
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, vertexBuffer);
pass.setVertexBuffer(1, normalBuffer);
pass.draw(SPINES * 6);
pass.end();
device.queue.submit([encoder.finish()]);
const parts = [['float32x2 position', 8, '#3d7fc4'], ['unorm8x4 colour', 4, '#d98a2b'], ['snorm8x4 normal', 4, '#5aa36b']];
let x = 12;
for (const [name, n, color] of parts) {
ink.fillStyle = color; ink.fillRect(x, 10, n * 24 - 3, 22);
ink.fillStyle = '#fff'; ink.font = '11px system-ui, sans-serif'; ink.fillText(name, x + 4, 25);
x += n * 24;
}
ink.fillStyle = '#444'; ink.fillText('16 bytes (40 as float32s)', x + 6, 25);
ink.fillStyle = '#8a2b2b'; ink.font = '11.5px ui-monospace, monospace';
ink.fillText('unorm8x4 at offset 6:', 12, 312);
ink.fillText(misaligned ? misaligned.message.split('\n')[0].slice(0, 80) : 'accepted', 12, 330);
}
main();
</script>