Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Bind Groups and Layouts

A bind group layout

BindGroupLayoutBuilder — one BindingKind per entry, visibility always explicit:

let layout = BindGroupLayoutBuilder::new()
    .label("camera_layout")
    .entry("camera", 0, BindingKind::uniform_buffer(ShaderStages::VERTEX))
    .build(&backend);

BindingKind constructors, one per resource shape: texture_2d/texture_2d_array/texture_cubemap, storage_texture, sampler/comparison_sampler, uniform_buffer/dynamic_uniform_buffer, storage_buffer_read_only/storage_buffer_read_write/dynamic_storage_buffer — every one takes ShaderStages explicitly, nothing defaulted. Visibility is explicit rather than inferred because BindingKind is shared between materials and compute passes — a material entry can be FRAGMENT/VERTEX/VERTEX_FRAGMENT, a compute entry must be exactly COMPUTE, and building a material or compute pipeline panics if an entry’s visibility doesn’t fit, catching the mistake immediately instead of deep inside a wgpu validation error.

Building a layout by hand this way is mostly for resources outside the material/compute system (a camera — see Custom GPU Resources); Material/Compute build their own layout internally from a GroupEntry::Own entry’s BindingEntrys.

.build() panics on a duplicate @binding(N) — a shader/layout mismatch fails loudly here instead of at draw time.

A bind group

BindGroupBuilder — one resource per binding, against an already-built layout. Each resource kind has its own method, taking the matching opaque type directly:

let bind_group = BindGroupBuilder::new(&layout)
    .label("camera_bind_group")
    .buffer(&camera_buffer)          // &Buffer, @binding(0), call order
    .build(&backend);

// The other resource kinds, same call-order-assigns-@binding(N) shape:
BindGroupBuilder::new(&layout)
    .texture_2d(&brick_texture)      // &GPUTexture
    .texture_array(&atlas)           // &GPUTextureArray
    .texture_cubemap(&sky)           // &GPUCubemap
    .texture_view(&shadow_map)       // &TextureView — a render target, sampled back (see below)
    .sampler(&sampler)               // &Sampler, from GlobalSamplers::get
    .dynamic_buffer(&dynamic)        // &DynamicBuffer
    .build(&backend);

texture_view/texture_view_at is how a render target sampled back in a later pass gets bound — a shadow map read in the lighting pass, a post-process input, an offscreen pass fed into a full-screen quad. It takes the same opaque TextureView that RenderTargetTextureBuilder::build/GPUCubemap::face_attachment hand back — build it with both RENDER_ATTACHMENT (to render into it) and TEXTURE_BINDING (to sample it) usage:

let shadow_map = RenderTargetTextureBuilder::new(2048, 2048, TextureFormat::Depth32Float)
    .usage(TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING)
    .build(&backend);

// Sampling a depth texture (rather than a color one) as `texture_depth_2d` in
// WGSL needs `TextureSampleType::Depth` in the layout entry, and typically a
// comparison sampler (`textureSampleCompare`) rather than a regular one —
// spell the full BindingKind::Texture variant out rather than reaching for
// `texture_2d`, which defaults to a filterable float sample type:
BindingEntry {
    name: "shadow_map",
    binding: 0,
    kind: BindingKind::Texture {
        visibility: ShaderStages::FRAGMENT,
        sample_type: TextureSampleType::Depth,
        view_dimension: TextureViewDimension::D2,
        multisampled: false,
    },
};
BindingKind::comparison_sampler(ShaderStages::FRAGMENT); // paired sampler entry

If your bindings aren’t contiguous from 0 (looked up by name, as material/compute instances are — see Materials), use the _at variants instead (.buffer_at(2, &buf), .texture_2d_at(0, &tex), …).

Pipeline layouts (multiple bind groups)

A material/compute’s whole pipeline layout — its own bind group plus anything external — is one call: .entries(Vec<GroupEntry>). Position in that list is the @group(N) index, so there’s no separate group number to keep in sync with the shader by hand — the first element is @group(0), the second @group(1), and so on:

use pebble::wgpu::layout::GroupEntry;

MaterialBuilder::new(SHADER)
    .entries(vec![
        GroupEntry::Global("camera"),         // @group(0): pulled from the global pool by name
        GroupEntry::Own(material_entries()),  // @group(1): this material's own texture/sampler
    ])
    // ...
    .build_asset("lit", &mut materials);

GroupEntry has three variants:

  • Own(Vec<BindingEntry>) — this material/compute’s own bind group entries, built into a fresh layout internally. At most one of these is allowed per .entries(...) list — build_material/build_compute panics on a second one, since there’s only one instance-bindable group per material/compute (the one a MaterialInstance/ComputeInstance binds concrete resources against).
  • Global(&'static str) — a layout looked up by name in the GlobalLayoutPool resource. Resolved lazily, at upload time — the material/compute doesn’t need name to already be registered while .entries(...) is being called, only by the time it actually uploads (if it’s not there yet, upload quietly retries next tick, same as any other unmet dependency). This is the normal way to reach a camera, lights, or anything else shared across many materials.
  • Layout(BindGroupLayout) — an already-built layout occupying this position directly, for anything that isn’t going through the pool (built by hand — see Custom GPU Resources). Clone because the same layout might be wired into more than one material/compute pass this way.

build_material/build_compute also panics if .entries(...) needs more bind groups than the device’s max_bind_groups allows (wgpu guarantees only 4, @group(0..=3)) — turning that mistake into an immediate, specific error instead of an opaque wgpu validation failure at draw time.

A pool of shared layouts

GlobalLayoutPool is a named name -> BindGroupLayout registry for layouts shared across many materials/compute passes (a camera, lights, …) — inserted empty as a resource by WGPUPlugin, so it’s always there, and pulled in automatically as a Deps by every material/compute’s Asset::upload. Register into it from wherever the layout becomes ready — typically a follow-up once-system with ResMut<GlobalLayoutPool>, as soon as the resource that owns the layout has been inserted:

fn register_camera_layout(camera: Res<Camera>, mut pool: ResMut<GlobalLayoutPool>) -> Option<()> {
    pool.register("camera", camera.bind_group_layout.clone());
    Some(())
}

Order relative to whatever setup system builds the material doesn’t matter — GroupEntry::Global("camera") doesn’t need "camera" to be registered yet when .entries(...) is called, only by the time the material actually uploads, so setup doesn’t even need Res<Camera> as a dependency anymore (only whatever renders with the camera’s bind group still does). A material that doesn’t need every registered global just doesn’t reference it by name, so its pipeline layout doesn’t carry a group it never uses. See Custom GPU Resources for a worked example wiring a camera’s layout into @group(1).