WebGPU Instancing

Instancing draws many copies of the same shape with a single draw call.

Grass, stars, particles, trees and crowds are all made of thousands of similar objects. Instancing lets the GPU draw them all at once, each copy in its own place and colour.

400 rotating cubes, drawn with one draw call.

One Draw Call, Many Copies

You have used pass.draw(vertexCount) before. It takes a second argument: the instance count.

pass.draw(VERTICES_PER_SQUARE, COLUMNS * ROWS);

This runs the vertex shader for all 6 vertices of a square, once for every copy. Each copy is called an instance.

The full form is pass.draw(vertexCount, instanceCount, firstVertex, firstInstance). The last three default to 1, 0 and 0.

The instance_index Built-in

If every copy were drawn in the same place, you would see only one. The vertex shader needs to know which copy it is working on.

That is @builtin(instance_index). It is 0 for the first copy, 1 for the second, and so on. All vertices of the same copy get the same value.

Example

Place 144 squares in a 16×9 grid, using only instance_index:

@vertex
fn vertexMain(
  @builtin(vertex_index) vertexIndex: u32,
  @builtin(instance_index) instanceIndex: u32
) -> VertexOutput {
  let corners = array(
    vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0),
    vec2f(0.0, 0.0), vec2f(1.0, 1.0), vec2f(0.0, 1.0)
  );
  let column = instanceIndex % COLUMNS;
  let row = instanceIndex / COLUMNS;
  let cellSize = vec2f(2.0 / f32(COLUMNS), 2.0 / f32(ROWS));
  let margin = 0.1;
  let corner = corners[vertexIndex] * (1.0 - 2.0 * margin) + margin;
  let cell = vec2f(f32(column), f32(row));
  var output: VertexOutput;
  output.position = vec4f((cell + corner) * cellSize - 1.0, 0.0, 1.0);
  output.color = vec3f(f32(column) / f32(COLUMNS - 1u), f32(row) / f32(ROWS - 1u), 0.8);
  return output;
}
Edit & Run »

COLUMNS and ROWS are WGSL constants (16 and 9), written into the shader text from JavaScript. The column is the remainder after dividing by 16, and the row is the whole-number result. Row 0 is at the bottom, because clip-space y points up.

Note instanceIndex / COLUMNS is integer division, because both values are u32. So 20u / 16u is 1u, not 1.25.

Per-Instance Data in a Vertex Buffer

Working out everything from instance_index only goes so far. Usually each instance has its own data: a position, a colour, a size.

You can put that data in a vertex buffer with stepMode: "instance". Then the GPU moves to the next element once per instance, not once per vertex:

buffers: [{
  arrayStride: FLOATS_PER_INSTANCE * Float32Array.BYTES_PER_ELEMENT,
  stepMode: "instance",
  attributes: [
    { shaderLocation: 0, offset: 0, format: "float32x2" },
    { shaderLocation: 1, offset: 8, format: "float32x3" }
  ]
}]

Each instance uses 5 floats: an x and y offset, then red, green and blue.

stepModeThe buffer moves to the next element…
"vertex"For every vertex. The default.
"instance"For every instance

Example

10,000 animated squares, each with its own offset and colour:

@vertex
fn vertexMain(
  @builtin(vertex_index) vertexIndex: u32,
  @location(0) offset: vec2f,
  @location(1) color: vec3f
) -> VertexOutput {
  let corners = array(
    vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
    vec2f(-1.0, -1.0), vec2f(1.0, 1.0), vec2f(-1.0, 1.0)
  );
  let wave = vec2f(
    sin(uniforms.time + offset.y * 6.0),
    cos(uniforms.time * 1.3 + offset.x * 6.0)
  ) * 0.04;
  let corner = corners[vertexIndex] * QUAD_SIZE * vec2f(1.0 / uniforms.aspect, 1.0);
  var output: VertexOutput;
  output.position = vec4f(offset + wave + corner, 0.0, 1.0);
  output.color = color;
  return output;
}
Edit & Run »

The corner comes from vertex_index, and changes for every vertex. The offset and colour come from the instance buffer, and change for every instance. The whole scene is one call:

pass.setVertexBuffer(0, instanceBuffer);
pass.draw(VERTICES_PER_QUAD, INSTANCE_COUNT);
Tip A pipeline can use several vertex buffers at once. A common setup is buffer 0 with stepMode: "vertex" for the shape, and buffer 1 with stepMode: "instance" for the copies.

Per-Instance Data in a Storage Buffer

Another way is a storage buffer. It holds an array of structs, and the shader picks one with instance_index:

struct CubeInstance {
  position: vec3f,
  spinOffset: f32,
  color: vec3f,
}

@group(0) @binding(1) var<storage, read> instances: array<CubeInstance>;

Create the buffer with STORAGE usage, and add it to the bind group:

const instanceBuffer = device.createBuffer({
  size: instanceData.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});

Example

A field of 400 spinning cubes. Each cube reads its position, colour and timing from the storage buffer:

@vertex
fn vertexMain(
  @builtin(instance_index) instanceIndex: u32,
  @location(0) position: vec3f,
  @location(1) brightness: f32
) -> VertexOutput {
  let instance = instances[instanceIndex];
  let angle = uniforms.time + instance.spinOffset;
  let rotated = rotateX(rotateY(position, angle), angle * 0.7);
  let bob = vec3f(0.0, 0.4 * sin(angle), 0.0);
  let worldPosition = rotated + instance.position + bob;
  var output: VertexOutput;
  output.position = uniforms.viewProjection * vec4f(worldPosition, 1.0);
  output.color = instance.color * brightness;
  return output;
}
Edit & Run »
Warning Storage buffers follow WGSL alignment rules. A vec3f takes 12 bytes but starts on a 16-byte boundary. Here, spinOffset fills the gap after position, and the struct is padded to 32 bytes (8 floats) in total. Your JavaScript data must match this layout exactly.

The cubes example also uses the depth buffer from Lesson 7, so near cubes hide far ones.

Why Instancing Is Fast

Every draw call has a cost on the CPU. JavaScript calls into the browser, the browser checks your commands, and the driver prepares the GPU.

  • Without instancing: 10,000 objects means 10,000 draw calls, and usually 10,000 bind group changes. The CPU becomes the bottleneck.
  • With instancing: one draw call. The CPU work is the same for 10 objects or 100,000. The GPU does the rest in parallel.

The shape data is also stored only once. Only the small per-instance data is repeated.

Comparing the Approaches

ApproachPer-instance dataGood forLimits
instance_index onlyNone. Everything is calculated.Grids and simple patternsEvery instance must follow a formula
Vertex buffer, stepMode: "instance"Attributes at @location(n)A few values per instance: offset, colour, sizeOnly simple types (numbers and vectors). Limited number of attributes.
Storage bufferarray<Struct> read with instances[instanceIndex]Large or complex data, matrices, data written by a compute shaderMust follow WGSL alignment rules
Many draw callsOne uniform buffer or bind group per objectA handful of very different objectsSlow for thousands of objects

Exercises

1. What does pass.draw(36, 50) do?

Show answer

It draws 50 instances of a 36-vertex shape, for example 50 cubes. The vertex shader runs 36 × 50 = 1,800 times.

2. In the grid example, which instance index is the square in column 3, row 2?

Show answer

row * COLUMNS + column = 2 × 16 + 3 = 35

3. In the 10,000 squares example, what happens if you remove stepMode: "instance"?

Show answer

The buffer steps once per vertex instead. All instances read only the first 6 elements, and each of the 6 corners gets a different offset and colour. You see two large, random triangles, drawn 10,000 times in the same place, instead of 10,000 squares.

4. Make every second square in the grid example half as bright.

Show answer
let brightness = select(1.0, 0.5, instanceIndex % 2u == 1u);
output.color = output.color * brightness;

Summary

  • pass.draw(vertexCount, instanceCount) draws many copies with one call.
  • @builtin(instance_index) tells the vertex shader which copy it is drawing.
  • Per-instance data can live in a vertex buffer with stepMode: "instance", or in a storage buffer indexed by instance_index.
  • Instancing is fast because the CPU makes one draw call instead of thousands.

Official documentation