Organising WGSL bind groups
A bind-group layout that survives a growing pipeline count — including how I got it wrong first.
WebGPU exposes shader resources through bind groups. I put everything in @group(0)
without thinking about it, and it fell apart somewhere past the fifth pipeline.
What went wrong
With everything in one group, any single differing resource forces an entirely new bind group.
The camera matrix (shared by every pipeline), material data (per pipeline) and model matrix (per draw) all lived in the same group — so drawing one object meant rebuilding a bind group that included the camera matrix all over again.
Split by update frequency
The fix is simple: give each update frequency its own group.
| Group | Contents | Updated |
|---|---|---|
@group(0) |
camera, time, viewport | once per frame |
@group(1) |
material, textures | on pipeline switch |
@group(2) |
model matrix, instance data | per draw |
@group(0) @binding(0) var<uniform> frame: FrameUniforms;
@group(1) @binding(0) var<uniform> material: MaterialUniforms;
@group(1) @binding(1) var albedo: texture_2d<f32>;
@group(2) @binding(0) var<uniform> model: ModelUniforms;
Now setBindGroup(0, ...) is called once at the top of the frame. Only group 2 gets
rebuilt inside the draw loop.
The spec guarantees at least four bind groups, so a design that fits in three runs everywhere.
Share the layouts
GPUBindGroupLayout objects are reusable across pipelines. Every pipeline should receive
the same group 0 layout.
const frameLayout = device.createBindGroupLayout({ entries: [...] });
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [frameLayout, materialLayout, modelLayout],
}),
// ...
});
layout: 'auto' is convenient for the first pipeline, but it generates a distinct layout
object per pipeline, which makes bind groups unshareable. Switching to explicit layouts as
soon as you write the second pipeline saves time overall.
Dynamic offsets, or not
Per-draw model matrices can also live in one large uniform buffer, switched by dynamic offset.
pass.setBindGroup(2, modelBindGroup, [i * alignedSize]);
That collapses bind group creation to a single object, which helps — but offsets must be
multiples of minUniformBufferOffsetAlignment, typically 256 bytes. Spending 256 bytes on a
64-byte matrix is a real cost, so while draw counts are low, separate bind groups read better.