Using WebGPU Dynamic Uniform Buffers to Avoid Rebuilding Bind Groups
Dynamic uniform buffers let you bind a single GPUBuffer with variable offsets, avoiding per‑draw bind group recreation. This guide explains the mechanism, shows a concrete example, covers limits, and highlights common pitfalls.
30 Sept 2025, 09:56 UTC

Why Dynamic Uniform Buffers Matter
When rendering many objects that share a pipeline but have per‑draw data (model matrices, colors, light parameters), the naive approach is to create a new GPUBindGroup for each object. This incurs allocation, validation, and GPU‑side bookkeeping on every frame, which can quickly become a bottleneck, especially on mobile GPUs or when drawing hundreds of objects.
WebGPU’s dynamic uniform buffers let you keep a single GPUBuffer and change the offset used for each draw call. The driver re‑uses the same bind group, only swapping the offset, which reduces CPU overhead and can improve cache locality on the GPU.
How It Works
1. Create a buffer that holds all per‑object data. The buffer is created once and reused for every frame.
2. Define a bind group layout that marks the binding as dynamic:
const layout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: { type: 'uniform-buffer', dynamic: true }
}]
});
3. Create a bind group with the buffer but no offset (offset is supplied later during rendering):
const bindGroup = device.createBindGroup({
layout,
entries: [{ binding: 0, resource: { buffer } }]
});
4. During command encoding, set the bind group and pass the per‑draw offset:
// offset must be a multiple of device.limits.minUniformBufferOffsetAlignment
const offset = perObjectIndex * perObjectStride;
pass.setBindGroup(0, bindGroup, [offset]);
5. In the shader, declare the uniform buffer normally:
@binding(0) @group(0) var uModel : mat4x4;
The shader automatically reads from the buffer at the supplied offset, so no changes are needed compared to a static uniform buffer.
Concrete Worked Example
Below is a minimal example that renders two triangles with different colors using a dynamic uniform buffer. Each triangle has a 4‑float color and a 4x4 model matrix. The buffer layout interleaves these values for each object.
// 1. Create device and limits
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const minAlign = device.limits.minUniformBufferOffsetAlignment;
const perObjectStride = 64 + 16; // 64 bytes for mat4, 16 for vec4 color
const alignedStride = Math.ceil(perObjectStride / minAlign) * minAlign;
// 2. Allocate buffer for two objects
const bufferSize = alignedStride * 2;
const uniformBuffer = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
// 3. Write data (CPU side)
const data = new Float32Array(bufferSize / 4);
// Object 0
data.set([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1], 0); // identity mat4
data.set([1,0,0,1], 16); // red
// Object 1
data.set([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1], 20); // identity
data.set([0,0,1,1], 36); // blue
device.queue.writeBuffer(uniformBuffer, 0, data.buffer, data.byteOffset, data.byteLength);
// 4. Bind group layout and bind group
const layout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: { type: 'uniform-buffer', dynamic: true }
}]
});
const bindGroup = device.createBindGroup({
layout,
entries: [{ binding: 0, resource: { buffer } }]
});
// 5. Render loop (simplified)
function render() {
const commandEncoder = device.createCommandEncoder();
const pass = commandEncoder.beginRenderPass({
colorAttachments: [{ view: ctx.getCurrentTexture().createView(), loadOp: 'clear', storeOp: 'store' }]
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup, [0]); // first object
pass.draw(3, 1, 0, 0);
pass.setBindGroup(0, bindGroup, [alignedStride]); // second object
pass.draw(3, 1, 0, 0);
pass.end();
device.queue.submit([commandEncoder.finish()]);
}
Running this code will display a red triangle on the left and a blue triangle on the right, proving that the offset correctly selects the per‑object slice.
Limits and Practical Checks
| Limit | Description |
|---|---|
| minUniformBufferOffsetAlignment | Smallest allowed offset, typically 256 bytes on desktop GPUs. |
| maxDynamicUniformBufferRange | Maximum size of a single dynamic uniform buffer slice. If your per‑object data exceeds this, split the buffer or use a storage buffer. |
| maxUniformBufferBindingSize | Maximum total size of a uniform buffer regardless of dynamic usage. |
To verify support, run:
const device = await adapter.requestDevice();
console.log('maxDynamicUniformBufferRange:', device.limits.maxDynamicUniformBufferRange);
If the value is 0, the browser does not support dynamic uniform buffers. Current stable support is in Chrome 125+ and Edge 125+. Safari and Firefox are still experimental.
Common Pitfalls
- Offset misalignment: Offsets not a multiple of
minUniformBufferOffsetAlignmentcause command failure or driver stalls. Always align using the formula shown above. - Shader type mismatch: Declaring the binding as
storage-bufferin the shader while the layout saysuniform-buffertriggers validation errors. - Exceeding maxDynamicUniformBufferRange: Large per‑object data sets (e.g., many lights) may exceed the limit; consider using a storage buffer or splitting data across multiple dynamic buffers.
- Driver fallback: Some drivers silently copy data each frame for dynamic uniform buffers, negating performance gains. Benchmark your target platform to confirm benefits.
When to Use Dynamic Uniform Buffers
Use them when:
- You have many objects with small per‑draw data (≤ 256 bytes).
- You want to avoid the overhead of creating thousands of bind groups per frame.
- Target platforms support the necessary limits (check
maxDynamicUniformBufferRange).
They are less useful for heavy per‑object data or when you need random access to many buffers, where a storage buffer may be preferable.
Conclusion
Dynamic uniform buffers are a powerful feature of WebGPU that can reduce CPU overhead and improve rendering performance for per‑draw data. By correctly aligning offsets, respecting limits, and verifying browser support, developers can safely adopt this pattern in real‑world applications.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.