Vertex Formats

Vertex Formats and Packing Attributes

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)").

Packed vertex formats: float32x2 position and unorm8x4 colour in a 12-byte stream, snorm8x4 normals lighting a fan of spinesHTMLLive
<!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>