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.
Uniforms vs Vertex Data
Vertex buffers give each vertex its own value. Uniforms give every vertex the same value.
| Vertex buffer | Uniform buffer | |
|---|---|---|
| Value changes | Per vertex | Per draw call |
| Shader input | @location(n) parameter | var<uniform> global |
| Connected with | setVertexBuffer() | setBindGroup() |
| Example | Corner positions | Time, 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 »
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);
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 type | Size | Alignment |
|---|---|---|
f32, i32, u32 | 4 bytes | 4 |
vec2f | 8 bytes | 8 |
vec3f | 12 bytes | 16 |
vec4f | 16 bytes | 16 |
mat4x4f | 64 bytes | 16 |
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:
| Bytes | Float index | Field |
|---|---|---|
| 0–7 | 0, 1 | offset |
| 8–11 | 2 | scale |
| 12–15 | 3 | padding: color must start at a multiple of 16 |
| 16–27 | 4, 5, 6 | color |
| 28–31 | 7 | padding: the struct size rounds up to 32 |
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 »
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_DST2. 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_DSTusage. - Connect the buffer with
device.createBindGroup()andpass.setBindGroup(). Withlayout: "auto", get the layout frompipeline.getBindGroupLayout(0). - Animate by calling
writeBuffereach frame insiderequestAnimationFrame. - Struct fields follow alignment rules.
vec3faligns to 16 bytes, so leave padding.
Official documentation
Go deeper with the specifications and reference documentation:
- developer.mozilla.org
MDN: GPUDevice.createBindGroup()
Binding buffers, textures and samplers to a shader.
- developer.mozilla.org
MDN: GPUDevice.createBindGroupLayout()
Describing bindings explicitly so pipelines can share bind groups.
- developer.mozilla.org
MDN: GPURenderPassEncoder.setBindGroup()
Selecting which bind group a draw uses, with dynamic offsets.
- w3.org
WGSL specification: alignment and size
The exact alignment and padding rules for uniform structs.