Bind Groups and Layouts

How shaders access resources

Shaders need data: transformation matrices, textures, computed results. WebGPU's binding model connects shader code to GPU resources through bind groups and layouts. Understanding this system is essential for anything beyond hardcoded vertex colors.

The Binding Model

In WGSL, you declare external resources using @group and @binding attributes:

@group(0) @binding(0) var<uniform> mvp: mat4x4f;
@group(0) @binding(1) var diffuseTexture: texture_2d<f32>;
@group(0) @binding(2) var texSampler: sampler;
wgsl

But how does the shader know which actual buffer or texture to use? That connection happens through bind groups.

Interactive: The layout → group → shader relationship

Click a section to learn more. Layouts define structure, groups fill it, shaders consume it.

The flow works in three parts. A bind group layout declares what types of resources go in each binding slot—"slot 0 is a uniform buffer, slot 1 is a texture." A bind group fills those slots with actual resources—"slot 0 gets this buffer, slot 1 gets that texture." The shader then accesses resources by their group and binding indices.

This indirection has purpose. Layouts can be shared across multiple bind groups. You create one layout for "material resources," then create many bind groups conforming to that layout—one for each material. At render time, you swap bind groups without touching the pipeline.

Bind Group Layouts

A bind group layout declares the structure of a bind group:

const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
      buffer: { type: 'uniform' }
    },
    {
      binding: 1,
      visibility: GPUShaderStage.FRAGMENT,
      texture: { sampleType: 'float' }
    },
    {
      binding: 2,
      visibility: GPUShaderStage.FRAGMENT,
      sampler: { type: 'filtering' }
    }
  ]
});
javascript

Each entry specifies a binding index, which shader stages can access it, and what type of resource it holds. The visibility field uses bitwise flags—a resource might be visible to just the fragment shader, or to both vertex and fragment, or to compute.

The resource type determines what can be bound to that slot. Buffer bindings specify buffer: { type: 'uniform' } or buffer: { type: 'storage' }. Texture bindings specify texture: { sampleType: 'float' }. Sampler bindings specify sampler: { type: 'filtering' }.

Creating Bind Groups

Once you have a layout, you create bind groups that conform to it:

const bindGroup = device.createBindGroup({
  layout: bindGroupLayout,
  entries: [
    {
      binding: 0,
      resource: { buffer: uniformBuffer }
    },
    {
      binding: 1,
      resource: texture.createView()
    },
    {
      binding: 2,
      resource: sampler
    }
  ]
});
javascript

Each entry provides the actual GPU resource for that binding slot. The types must match—if the layout says binding 0 is a buffer, you must provide a buffer. If it says binding 1 is a texture, you must provide a texture view.

Interactive: Build bindings and see generated code

Bindings

@group()
@binding(0)
Uniform Buffer

Layout Code (JavaScript)

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

Shader Code (WGSL)

@group(0) @binding(0) var<uniform> uniforms: MyUniforms;

Add bindings to see how they map to JavaScript layout code and WGSL declarations. Toggle V/F/C for shader stage visibility.

The bind group is immutable after creation. To change which buffer is bound, you create a new bind group. This might seem wasteful, but it enables important optimizations—the driver can validate bindings once at creation rather than on every draw call.

Resource Types

WebGPU supports several types of bindable resources:

Interactive: Explore different resource types

Uniform Buffer

Read-only data accessible to shaders

Common uses: Transformation matrices, time, resolution

Layout Entry

{
  binding: 0,
  visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
  buffer: { type: 'uniform' }
}

Bind Group Entry

{
  binding: 0,
  resource: { buffer: uniformBuffer }
}

WGSL Declaration

struct Uniforms {
  mvp: mat4x4f,
  time: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;

Uniform buffers hold read-only data shared across shader invocations. They are optimized for small, frequently-read data like transformation matrices or shader parameters. Size is limited (typically 64KB), but access is fast.

Storage buffers can be read and written from shaders. They support much larger sizes (gigabytes) and are the primary way to pass data to compute shaders. Access is slightly slower than uniform buffers, but the flexibility is worth it.

Textures hold image data. You bind texture views, not textures directly—a view specifies which mip levels and array layers to expose. Textures can be 1D, 2D, 3D, cube maps, or arrays.

Samplers configure how textures are sampled: filtering mode (nearest vs linear), address mode (clamp vs repeat), and comparison mode for shadow mapping. Samplers are separate from textures, so you can reuse one texture with different sampling configurations.

Storage textures can be written to from compute shaders. They are useful for image processing, procedural texture generation, and render targets that compute shaders will write directly.

Pipeline Layouts

A pipeline layout combines multiple bind group layouts:

const pipelineLayout = device.createPipelineLayout({
  bindGroupLayouts: [
    perFrameLayout,    // group 0
    perMaterialLayout, // group 1
    perObjectLayout,   // group 2
  ]
});
javascript

The order matters—it determines which layout corresponds to which @group index in the shader. Group 0 uses the first layout, group 1 uses the second, and so on.

Interactive: Organize resources across multiple groups

@group(0)Per-frame data
  • Camera matrices
  • Time/globals
@group(1)Per-material data
  • Textures
  • Material properties
const pipelineLayout = device.createPipelineLayout({
  bindGroupLayouts: [
    bindGroupLayout0, // Group 0: Per-frame data
    bindGroupLayout1, // Group 1: Per-material data
  ]
});

const pipeline = device.createRenderPipeline({
  layout: pipelineLayout,
  // ... other config
});

// At render time:
pass.setBindGroup(0, bindGroup0);
pass.setBindGroup(1, bindGroup1);
Why multiple groups?

Group 0 changes once per frame (camera). Group 1 changes per material. Group 2 changes per object. By organizing bindings this way, you minimize how many bind groups you need to switch between draw calls.

Why use multiple groups? Frequency of change. Group 0 might hold per-frame data (camera, time) that changes once per frame. Group 1 might hold per-material data (textures, material properties) that changes when you switch materials. Group 2 might hold per-object data (model matrix) that changes every draw call.

By organizing bindings this way, you minimize bind group switches. When drawing many objects with the same material, you set group 0 once, set group 1 when the material changes, and only update group 2 for each object. This is significantly faster than switching all bindings every draw call.

In the Shader

WGSL declarations must match the layout exactly:

// Group 0: per-frame data
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
@group(0) @binding(1) var<uniform> time: f32;
 
// Group 1: per-material data
@group(1) @binding(0) var baseTexture: texture_2d<f32>;
@group(1) @binding(1) var normalTexture: texture_2d<f32>;
@group(1) @binding(2) var texSampler: sampler;
 
// Group 2: per-object data
@group(2) @binding(0) var<uniform> model: mat4x4f;
wgsl

The var<uniform> syntax declares a uniform variable. The var<storage, read_write> syntax declares a read-write storage variable. Plain var (without angle brackets) declares texture and sampler variables.

Type mismatches between the layout and shader code cause pipeline creation to fail. If the layout says binding 0 is a uniform buffer but the shader declares it as a texture, validation fails. WebGPU catches these errors at pipeline creation time rather than letting them cause mysterious runtime issues.

Automatic Layout Inference

For simple cases, you can let WebGPU infer the layout from the shader:

const pipeline = device.createRenderPipeline({
  layout: 'auto',
  // ... shader and other config
});
 
// Get the inferred layout
const bindGroupLayout = pipeline.getBindGroupLayout(0);
javascript

This is convenient for prototyping. The downside is that you cannot share layouts across pipelines—each pipeline gets its own inferred layout. For production code with many pipelines and materials, explicit layouts are usually better.

Binding at Render Time

During a render pass, you set bind groups with setBindGroup:

pass.setPipeline(pipeline);
pass.setBindGroup(0, frameBindGroup);
pass.setBindGroup(1, materialBindGroup);
pass.setBindGroup(2, objectBindGroup);
pass.draw(vertexCount);
 
// Switch to different object
pass.setBindGroup(2, anotherObjectBindGroup);
pass.draw(vertexCount);
 
// Switch material
pass.setBindGroup(1, differentMaterialBindGroup);
pass.setBindGroup(2, thirdObjectBindGroup);
pass.draw(vertexCount);
javascript

Bind groups persist until changed. Setting group 2 does not affect groups 0 and 1. This enables the frequency-based organization described earlier.

Key Takeaways

  • Bind group layouts declare the structure: what types of resources go in each slot
  • Bind groups fill the structure with actual GPU resources
  • Shader @group and @binding attributes must match the pipeline layout exactly
  • Multiple bind groups let you organize by update frequency—per-frame, per-material, per-object
  • Pipeline layouts combine multiple bind group layouts into a complete binding configuration
  • Resource types include uniform buffers, storage buffers, textures, samplers, and storage textures