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.
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 |:
| Flag | Meaning |
|---|---|
GPUBufferUsage.VERTEX | The buffer can be used as a vertex buffer |
GPUBufferUsage.INDEX | The buffer can be used as an index buffer |
GPUBufferUsage.UNIFORM | The buffer can be used as a uniform buffer (next lesson) |
GPUBufferUsage.COPY_DST | You 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);
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
@locationnumber 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:
| Format | Size | WGSL type | Typical use |
|---|---|---|---|
float32 | 4 bytes | f32 | A single value, like size |
float32x2 | 8 bytes | vec2f | 2D position, texture coordinate |
float32x3 | 12 bytes | vec3f | 3D position, RGB colour, normal |
float32x4 | 16 bytes | vec4f | RGBA colour |
uint32 | 4 bytes | u32 | An ID or index |
unorm8x4 | 4 bytes | vec4f | Compact 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 »
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.
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().
"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 withdevice.createBuffer(). device.queue.writeBuffer()copies data into a buffer with theCOPY_DSTusage.- The vertex buffer layout sets
arrayStrideand each attribute'sshaderLocation,offsetandformat. - The shader reads each attribute through a matching
@locationinput. - Index buffers let triangles share corners. Draw them with
setIndexBuffer()anddrawIndexed().
Official documentation
Go deeper with the specifications and reference documentation:
- developer.mozilla.org
MDN: GPUDevice.createBuffer()
Buffer sizes, usage flags and mapping at creation.
- developer.mozilla.org
MDN: GPUQueue.writeBuffer()
Uploading data from JavaScript typed arrays to a GPU buffer.
- developer.mozilla.org
MDN: GPURenderPassEncoder.drawIndexed()
Drawing with an index buffer, including base vertex and instance offsets.
- w3.org
WebGPU specification: vertex state
Vertex buffer layouts, attributes, formats, offsets and strides.