WebGPU Uniforms & Bind Groups

A uniform is a value that is the same for every vertex and every pixel in one draw call.

Colours, the time, the screen size and camera settings are all good uniforms. You send them through a uniform buffer, and connect that buffer to the shader with a bind group.

A triangle rotated by a time uniform that changes every frame.

Uniforms vs Vertex Data

Vertex buffers give each vertex its own value. Uniforms give every vertex the same value.

Vertex bufferUniform buffer
Value changesPer vertexPer draw call
Shader input@location(n) parametervar<uniform> global
Connected withsetVertexBuffer()setBindGroup()
ExampleCorner positionsTime, colour, camera

Declaring a Uniform in WGSL

A uniform is a global variable outside any function:

@group(0) @binding(0) var<uniform> color: vec4f;

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return color;
}
  • var<uniform> says the value comes from a uniform buffer.
  • @group(0) picks the bind group: a set of resources you attach together.
  • @binding(0) picks the slot inside that group.

Every vertex and fragment shader in the module can read it. No shader can change it.

Creating a Uniform Buffer

A uniform buffer is made like a vertex buffer, but with the UNIFORM usage:

const colorValue = new Float32Array([1.0, 0.55, 0.2, 1.0]);

const uniformBuffer = device.createBuffer({
  size: colorValue.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
device.queue.writeBuffer(uniformBuffer, 0, colorValue);

Creating a Bind Group

The shader only knows "group 0, binding 0". A bind group tells WebGPU which buffer that is.

A bind group must match a bind group layout. With layout: "auto", WebGPU builds the layout from your shader. You get it with pipeline.getBindGroupLayout(0), where 0 is the group number.

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [
    { binding: 0, resource: { buffer: uniformBuffer } }
  ]
});

Each entry's binding number matches a @binding(n) in the shader.

Using the Bind Group

In the render pass, call setBindGroup() after setPipeline() and before you draw:

Example

Colour a triangle with a uniform:

pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
Edit & Run »
Tip In the Playground, change the numbers in colorValue. The shader stays the same, but the colour changes. That is the power of uniforms.

Updating Uniforms Every Frame

To animate, write a new value into the uniform buffer, then draw again. Do this every frame with requestAnimationFrame().

The browser passes the time in milliseconds to your function. Turn it into seconds and send it to the shader:

Example

Spin a triangle with a time uniform:

const MILLISECONDS_PER_SECOND = 1000;
const frameValues = new Float32Array(2);

function render(timeInMilliseconds) {
  frameValues[0] = timeInMilliseconds / MILLISECONDS_PER_SECOND;
  frameValues[1] = canvas.width / canvas.height;
  device.queue.writeBuffer(uniformBuffer, 0, frameValues);

  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      clearValue: { r: 0.05, g: 0.06, b: 0.07, a: 1 },
      loadOp: "clear",
      storeOp: "store"
    }]
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.draw(3);
  pass.end();
  device.queue.submit([encoder.finish()]);

  requestAnimationFrame(render);
}

requestAnimationFrame(render);
Edit & Run »

The shader gets the time and the canvas aspect ratio in one struct. It rotates each corner by the time, then divides x by the aspect ratio so the triangle is not stretched:

struct Frame {
  time: f32,
  aspect: f32,
}

@group(0) @binding(0) var<uniform> frame: Frame;

Inside vertexMain, the corner is rotated and corrected:

let angle = frame.time;
let rotated = vec2f(
  corner.x * cos(angle) - corner.y * sin(angle),
  corner.x * sin(angle) + corner.y * cos(angle)
);
output.position = vec4f(rotated.x / frame.aspect, rotated.y, 0.0, 1.0);
Note You must call context.getCurrentTexture() again every frame. The canvas gives you a fresh texture each time.

Struct Memory Layout

When a uniform is a struct, each field must start at a byte offset that is a multiple of its alignment. The gaps that this leaves are called padding.

WGSL typeSizeAlignment
f32, i32, u324 bytes4
vec2f8 bytes8
vec3f12 bytes16
vec4f16 bytes16
mat4x4f64 bytes16

The whole struct's size is also rounded up to a multiple of its largest alignment.

Look at this struct:

struct Shape {
  offset: vec2f,
  scale: f32,
  color: vec3f,
}

Here is where each field lives in memory:

BytesFloat indexField
0–70, 1offset
8–112scale
12–153padding: color must start at a multiple of 16
16–274, 5, 6color
28–317padding: the struct size rounds up to 32
Warning A vec3f is 12 bytes but aligns to 16. If you pack your floats tightly in JavaScript, every value after a gap lands in the wrong place, and your shader reads garbage. Always leave the padding slots empty in your Float32Array.

Example

Fill a padded struct and draw three triangles, each with its own bind group:

const SHAPE_SIZE_IN_BYTES = 32;
const OFFSET_INDEX = 0;
const SCALE_INDEX = 2;
const COLOR_INDEX = 4;

function createShapeBindGroup(shape) {
  const shapeValues = new Float32Array(SHAPE_SIZE_IN_BYTES / Float32Array.BYTES_PER_ELEMENT);
  shapeValues.set(shape.offset, OFFSET_INDEX);
  shapeValues[SCALE_INDEX] = shape.scale;
  shapeValues.set(shape.color, COLOR_INDEX);

  const uniformBuffer = device.createBuffer({
    size: SHAPE_SIZE_IN_BYTES,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
  });
  device.queue.writeBuffer(uniformBuffer, 0, shapeValues);

  return device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: uniformBuffer } }]
  });
}

for (const bindGroup of bindGroups) {
  pass.setBindGroup(0, bindGroup);
  pass.draw(3);
}
Edit & Run »
Tip To avoid padding, put the biggest types first and fill gaps with small ones. For example, a vec3f followed by an f32 fits exactly in 16 bytes.

Exercises

1. Which usage flags does a uniform buffer need if you update it with writeBuffer?

Show answer
GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST

2. Declare a uniform called brightness of type f32 at group 0, binding 1.

Show answer
@group(0) @binding(1) var<uniform> brightness: f32;

3. What is the byte offset of tint in struct Light { strength: f32, tint: vec3f }? What is the size of the struct?

Show answer

tint starts at byte 16, because vec3f aligns to 16. The struct is 32 bytes.

4. In the animation example, make the triangle spin twice as fast without changing the shader.

Show answer
frameValues[0] = 2 * timeInMilliseconds / MILLISECONDS_PER_SECOND;

Summary

  • A uniform is the same for every vertex and pixel in one draw.
  • Declare it with @group(0) @binding(0) var<uniform>.
  • Store it in a buffer with the UNIFORM | COPY_DST usage.
  • Connect the buffer with device.createBindGroup() and pass.setBindGroup(). With layout: "auto", get the layout from pipeline.getBindGroupLayout(0).
  • Animate by calling writeBuffer each frame inside requestAnimationFrame.
  • Struct fields follow alignment rules. vec3f aligns to 16 bytes, so leave padding.

Official documentation