Under the Hood
Webgpu

Bind groups: how resources reach the shader

A render pipeline bakes in shaders and fixed state but not the actual data those shaders run against, so WebGPU groups the buffers, textures, and samplers a shader needs into bind groups validated once against a fixed bind group layout, letting an entire set of resources bind in a single call instead of WebGL's one-uniform-at-a-time approach.

Bind groups: how resources reach the shader

The render pipeline lesson bundled up everything fixed about a draw — shaders, vertex layout, blend state — into one object, but a shader is never self-contained. It needs a camera matrix to place vertices, a texture to sample for color, a sampler describing how to filter that texture, maybe an array of light positions. In WebGL those arrived one at a time: a call to set this uniform, another to bind that texture to a slot, another to set the sampler, every one of them per draw, every one of them state the driver had to re-check. WebGPU groups them instead. A bind group is a set of concrete resources bound together as one object, checked against a bind group layout that declares the shape those resources must have, and swapped into a draw with a single call. This lesson is the part of the pipeline's layout field the last lesson left unopened, and the JavaScript-side counterpart to the @group/@binding attributes lesson 5 put on WGSL variables.

The WGSL side: @group and @binding name a slot, not a value

Recall from the WGSL lesson that a resource variable is declared with two numeric attributes ahead of its type:

@group(0) @binding(0)
var<uniform> camera: Camera;

@group(1) @binding(0)
var albedoTexture: texture_2d<f32>;

@group(1) @binding(1)
var albedoSampler: sampler;

The group index picks which bind group, of however many the pipeline declares, a resource comes from at draw time. The binding index picks which slot inside that group. Neither number refers to a JavaScript object yet — the shader is only declaring "there will be a uniform buffer here, a texture there, a sampler there," and leaving the actual instances to be supplied later, at runtime, from the JavaScript side. That indirection is the entire point: a pipeline compiled once, referencing numbered slots rather than concrete resources, can draw many different objects by plugging different buffers and textures into the same slots each time.

The layout: declaring the shape, once, up front

Before you can supply real resources you declare their shape:

const cameraBindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
      buffer: { type: "uniform" },
    },
  ],
});

Each entry names a binding number matching the WGSL side, a visibility bitmask saying which shader stages are allowed to see that binding, and a resource-type descriptor (buffer, texture, or sampler, each with its own sub-options) describing what kind of thing will eventually fill the slot. visibility matters for more than documentation: telling the browser a binding is vertex-only lets the backend skip making it available to the fragment stage at all, and it's one more piece of information validated at creation time rather than guessed at draw time. This is the exact same "settle it once, up front" move the render pipeline made with shaders and blend state — a GPUBindGroupLayout is validated a single time, when it's created, not re-derived on every draw.

The bind group: supplying the actual resources

A bind group is the layout, filled in with real GPU objects:

const cameraBindGroup = device.createBindGroup({
  layout: cameraBindGroupLayout,
  entries: [
    {
      binding: 0,
      resource: { buffer: cameraUniformBuffer },
    },
  ],
});

createBindGroup checks that every entry matches what the layout promised — right resource type, right binding numbers, nothing missing — and produces an object that can be handed to the GPU as a unit. One layout can back many bind groups: a single "per-material" layout describing a texture-plus-sampler shape can be reused to create a distinct bind group for every material in a scene, each pointing at different actual textures, all validated against the same shape exactly once.

Pipeline compatibility: the pipeline knows the shape, the bind group supplies the instances

Lesson 4 mentioned a pipeline's layout field can be "auto" (WebGPU infers bind group layouts from the WGSL source) or an explicit GPUPipelineLayout built from device.createPipelineLayout({ bindGroupLayouts: [...] }). Either way, the pipeline ends up holding a specific expected shape for every group index it uses. When you later call pass.setBindGroup(index, group), the browser confirms the group you're passing is compatible with — the same shape as — what the pipeline expects at that index. The pipeline is the contract; the bind group is what fulfills it. A mismatch (wrong buffer type, wrong visibility, a binding the shader doesn't declare) is caught as an error at creation or bind time, not silently ignored the way a stray WebGL uniform call could be.

Binding at draw time: one call for a whole set

pass.setBindGroup(0, cameraBindGroup);
pass.setBindGroup(1, materialBindGroup);
pass.draw(vertexCount);

Contrast this with the WebGL sequence it replaces: a call to set a matrix uniform, a call to make a texture unit active, a call to bind a texture to it, a call to tell the sampler uniform which unit to read — four or five separate calls, each one poking the driver, every draw. setBindGroup swaps an entire validated set of resources into the pipeline in one call. It's the same shape as the render-pipeline story from lesson 4 — expensive checking happens once, at createBindGroupLayout/createBindGroup time, so the per-draw cost is just "here's the group index, here's the group."

Frequency of change: group by how often things actually change

Because a bind group index is rebound independently of the others, the natural design is to organize groups by how often their contents change, not by what they conceptually "belong to." Put the resources that stay fixed for an entire frame — the camera and view-projection matrix, maybe the current time — in group 0, bind it once, and leave it bound while you draw everything in the frame. Put resources that change per material — textures, material constants — in a higher group, rebound only when the material actually switches between draws. Put resources that change per object — a transform, an instance color — in the highest group, rebound on every single draw. Most draws in a frame then only touch the highest-frequency group; the low-frequency ones are already sitting bound from before. It's the same instinct as the merge/instance/share-material techniques from the draw-calls lesson — minimize the amount of re-binding work between draws, rather than eliminating draws outright.

Dynamic offsets: one bind group, many slices of one buffer

When many objects' per-object data is packed contiguously into a single large buffer, you don't need a separate bind group per object either. Mark that binding hasDynamicOffset: true in the layout, then pass an offset at draw time:

pass.setBindGroup(2, perObjectBindGroup, [objectIndex * alignedStride]);

The bind group itself doesn't change between draws — only the byte offset into the same underlying buffer does. That's one validated bind group reused across every object, instead of one bind group allocated per object, at the cost of having to pack your per-object data into one buffer with correctly aligned strides ahead of time.

The code, end to end

const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
      buffer: { type: "uniform" },
    },
  ],
});

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

const pipeline = device.createRenderPipeline({
  layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
  // ...vertex, fragment, primitive, depthStencil from lesson 4
});
@group(0) @binding(0)
var<uniform> camera: Camera;

@vertex
fn vs_main(@location(0) position: vec3f) -> @builtin(position) vec4f {
  return camera.viewProjection * vec4f(position, 1.0);
}
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(vertexCount);

Where this goes next

You now have every piece needed to describe one draw: a pipeline with baked-in state, and bind groups supplying the resources that pipeline's shaders expect. The one thing still missing is how a draw actually gets sent to the GPU at all. Command encoders and the queue is where setPipeline and setBindGroup calls like the ones above get recorded into a real command buffer and submitted — the mechanism that makes issuing many draws cheap on the CPU side.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. In WGSL, what do the group index and binding index on a resource variable actually refer to, and what don't they refer to yet?
  2. What does a GPUBindGroupLayout entry declare, and why does declaring visibility per binding help validation happen once instead of per draw?
  3. Explain the relationship between a bind group layout and a bind group: which one is the contract, and which one fulfills it?
  4. How does the pipeline's layout field connect to bind groups created later, and what happens if a bind group's shape doesn't match what the pipeline expects?
  5. Contrast pass.setBindGroup with the sequence of WebGL calls it replaces. Where does the WebGL sequence spend time that WebGPU no longer does?
  6. Explain the frequency-of-change design pattern for bind groups: why does per-frame data belong in a lower group index than per-object data?
  7. What problem do dynamic offsets solve, and how do they let one bind group serve many objects instead of allocating one bind group per object?