WebGPU Vertex Buffers

A vertex buffer is a block of GPU memory that holds the corners of your shapes.

So far, the corners were written inside the shader. Real programs load shapes from JavaScript, so the data must travel to the GPU. Vertex buffers are how it travels.

A hexagon built from 7 vertices and an index buffer.

Why Use Buffers?

In the last lessons, the corners lived in an array(...) inside the vertex shader. That works for one triangle, but:

  • You must rewrite the shader to change the shape.
  • Models from files can have thousands of corners.
  • One shader should be able to draw many different shapes.

The fix is to keep the shader general, and send the corners in a buffer.

Step 1: Put the Data in a Float32Array

The GPU reads raw bytes, not JavaScript arrays. A Float32Array stores numbers as 32-bit floats, which is exactly what the GPU expects.

const vertices = new Float32Array([
   0.0,  0.6,
  -0.6, -0.5,
   0.6, -0.5
]);

Each pair of numbers is one corner: an x and a y. Each float takes 4 bytes, so this array is 24 bytes long.

Step 2: Create the Buffer

Use device.createBuffer() to reserve memory on the GPU. You give it a size in bytes and a usage:

const vertexBuffer = device.createBuffer({
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
});

The usage flags say what the buffer is for. They are combined with |:

FlagMeaning
GPUBufferUsage.VERTEXThe buffer can be used as a vertex buffer
GPUBufferUsage.INDEXThe buffer can be used as an index buffer
GPUBufferUsage.UNIFORMThe buffer can be used as a uniform buffer (next lesson)
GPUBufferUsage.COPY_DSTYou can copy data into the buffer

Step 3: Write the Data

device.queue.writeBuffer() copies your array into the buffer. The 0 is the byte offset to start writing at.

device.queue.writeBuffer(vertexBuffer, 0, vertices);
Note writeBuffer only works because the buffer has the COPY_DST usage. Without it, WebGPU reports a validation error.

Step 4: Describe the Layout

The buffer is just bytes. The GPU needs to know how to cut them into vertices. A vertex buffer layout tells it:

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [
    { shaderLocation: 0, offset: 0, format: "float32x2" }
  ]
};
  • arrayStride is the number of bytes from one vertex to the next. Two floats are 2 × 4 = 8 bytes.
  • attributes lists the values inside each vertex.
  • shaderLocation is the @location number the value goes to in the shader.
  • offset is where the value starts, in bytes, inside one vertex.
  • format is the type and count of the numbers.

Vertex Formats

These are the formats you will use most:

FormatSizeWGSL typeTypical use
float324 bytesf32A single value, like size
float32x28 bytesvec2f2D position, texture coordinate
float32x312 bytesvec3f3D position, RGB colour, normal
float32x416 bytesvec4fRGBA colour
uint324 bytesu32An ID or index
unorm8x44 bytesvec4fCompact RGBA colour, 0–255 read as 0.0–1.0

Step 5: Read It in the Shader

In the vertex shader, the value arrives as a parameter marked @location(0). You no longer need vertex_index.

@vertex
fn vertexMain(@location(0) position: vec2f) -> @builtin(position) vec4f {
  return vec4f(position, 0.0, 1.0);
}

Step 6: Use It in the Pipeline and Pass

Add the layout to the pipeline's vertex.buffers list. Then, in the render pass, attach the buffer with setVertexBuffer() before you draw.

Example

Draw a triangle from a vertex buffer:

const FLOATS_PER_VERTEX = 2;
const BYTES_PER_FLOAT = 4;

const pipeline = device.createRenderPipeline({
  layout: "auto",
  vertex: {
    module: shaderModule,
    entryPoint: "vertexMain",
    buffers: [{
      arrayStride: FLOATS_PER_VERTEX * BYTES_PER_FLOAT,
      attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }]
    }]
  },
  fragment: { module: shaderModule, entryPoint: "fragmentMain", targets: [{ format }] }
});

pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / FLOATS_PER_VERTEX);
Edit & Run »
Tip The first number in setVertexBuffer(0, ...) is the slot: the position of the layout in the buffers list. It is not the same as shaderLocation.

Interleaving Position and Colour

A vertex can carry more than a position. You can put the colour right after it, in the same buffer. Storing different values side by side like this is called interleaving.

const vertices = new Float32Array([
   0.0,  0.7,   1.0, 0.3, 0.3,
  -0.7, -0.6,   0.3, 1.0, 0.4,
   0.7, -0.6,   0.3, 0.5, 1.0
]);

Each vertex is now 5 floats: x, y, red, green, blue. That is 20 bytes. The colour starts after the two position floats, at byte 8.

Example

Read position and colour from one interleaved buffer:

const FLOATS_PER_VERTEX = 5;
const BYTES_PER_FLOAT = 4;

const vertexBufferLayout = {
  arrayStride: FLOATS_PER_VERTEX * BYTES_PER_FLOAT,
  attributes: [
    { shaderLocation: 0, offset: 0, format: "float32x2" },
    { shaderLocation: 1, offset: 2 * BYTES_PER_FLOAT, format: "float32x3" }
  ]
};
Edit & Run »

In the shader, group the inputs in a struct. Each field's @location matches a shaderLocation in the layout:

struct VertexInput {
  @location(0) position: vec2f,
  @location(1) color: vec3f,
}

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
  var output: VertexOutput;
  output.position = vec4f(input.position, 0.0, 1.0);
  output.color = input.color;
  return output;
}

Index Buffers

A square is two triangles, which is 6 vertices. But a square only has 4 corners. Two corners are stored twice.

An index buffer fixes this. You store each corner once, then list which corners make each triangle:

const vertices = new Float32Array([
  -0.5, -0.5,
   0.5, -0.5,
   0.5,  0.5,
  -0.5,  0.5
]);

const indices = new Uint16Array([
  0, 1, 2,
  0, 2, 3
]);

The numbers in indices are vertex numbers. Triangle one uses corners 0, 1 and 2. Triangle two uses corners 0, 2 and 3.

Create the index buffer with the INDEX usage. Then call setIndexBuffer() and draw with drawIndexed() instead of draw():

Example

Draw a hexagon from 7 vertices and 18 indices:

const indexBuffer = device.createBuffer({
  size: indices.byteLength,
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(indexBuffer, 0, indices);

pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setIndexBuffer(indexBuffer, "uint16");
pass.drawIndexed(indices.length);
Edit & Run »

The hexagon has one centre vertex and six corner vertices. Each of its six triangles uses the centre, so without an index buffer the centre would be stored six times.

Warning writeBuffer needs a byte size that is a multiple of 4. A Uint16Array with an odd number of indices is 2 bytes short. Add one extra 0 at the end to pad it, and still pass the real count to drawIndexed().
Note "uint16" indices go up to 65 535. For bigger models, use a Uint32Array and "uint32".

Exercises

1. Each vertex has a 3D position (3 floats) and an RGBA colour (4 floats). What is the arrayStride?

Show answer

7 floats × 4 bytes = 28 bytes.

2. In that same layout, what is the offset and format of the colour?

Show answer
{ shaderLocation: 1, offset: 12, format: "float32x4" }

3. Which usage flag is missing? device.createBuffer({ size: 24, usage: GPUBufferUsage.VERTEX }), followed by writeBuffer.

Show answer

GPUBufferUsage.COPY_DST. You need it to copy data into the buffer.

4. Write the index list for a square whose corners are stored in the order bottom-left, bottom-right, top-right, top-left.

Show answer
const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);

Summary

  • Vertex data goes in a Float32Array, then into a GPU buffer made with device.createBuffer().
  • device.queue.writeBuffer() copies data into a buffer with the COPY_DST usage.
  • The vertex buffer layout sets arrayStride and each attribute's shaderLocation, offset and format.
  • The shader reads each attribute through a matching @location input.
  • Index buffers let triangles share corners. Draw them with setIndexBuffer() and drawIndexed().

Official documentation