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

Textures and Material Instances

The previous chapter’s material had no bind group at all — nothing to give the shader beyond raw vertex data. Sampling a texture means changing three things: the shader gains a @group, the material declares what that group contains, and something has to supply an actual texture + sampler for that group at draw time. That third piece is a new concept: a material instance.

This chapter’s full code is the wgpu_showcase example verified alongside this book — run it yourself with cargo run from examples/wgpu_showcase.

Why materials and instances are separate

A GPUMaterial is a pipeline — compiled once, describing what shape of bind group a shader expects (a texture at binding 0, a sampler at binding 1). It says nothing about which texture. That’s deliberate: the same brick-wall material should be reusable for a floor and a crate without recompiling a pipeline for each — only the bound texture differs. A GPUMaterialInstance is that missing piece: a concrete bind group, built by resolving a material’s declared entries against actual assets.

The shader, now with a texture

const SHADER: &str = r#"
struct VOut {
    @builtin(position) clip_pos: vec4<f32>,
    @location(0) uv: vec2<f32>,
};

@vertex
fn vs_main(@location(0) pos: vec3<f32>, @location(1) uv: vec2<f32>) -> VOut {
    var out: VOut;
    out.clip_pos = vec4<f32>(pos, 1.0);
    out.uv = uv;
    return out;
}

@group(0) @binding(0) var albedo: texture_2d<f32>;
@group(0) @binding(1) var albedo_sampler: sampler;

@fragment
fn fs_main(in: VOut) -> @location(0) vec4<f32> {
    return textureSample(albedo, albedo_sampler, in.uv);
}
"#;

Declaring the bind group on the material

BindingEntry/BindingKind describe the shape of @group(0) — a texture at binding 0, a sampler at binding 1, both fragment-visible:

use pebble::wgpu::binding::{BindingEntry, BindingKind};

fn material_entries() -> Vec<BindingEntry> {
    vec![
        BindingEntry {
            name: "albedo",
            binding: 0,
            kind: BindingKind::texture_2d(wgpu::ShaderStages::FRAGMENT),
        },
        BindingEntry {
            name: "albedo_sampler",
            binding: 1,
            kind: BindingKind::sampler(wgpu::ShaderStages::FRAGMENT),
        },
    ]
}

Visibility is explicit on every entry rather than inferred — BindingKind is shared between materials and compute passes, and build_material panics if any entry here were accidentally COMPUTE-visible instead of catching the mistake deep inside a wgpu validation error. name is purely a diagnostic label matched against instance params below — it has no effect on the actual binding, which is entirely positional (binding: N).

With entries non-empty, MaterialDescriptor also needs own_group: Some(0) (the default) instead of None — this is what tells build_material these entries occupy @group(0) in the pipeline layout, rather than there being no bind group at all.

Loading a texture

use pebble::wgpu::textures::TextureDescriptor;

let brick = textures.insert(
    "brick",
    TextureDescriptor::from_file("../assets/textures/brick.png").with_mips(),
);

Assets<TextureDescriptor> and its ProcessedAssets<GPUTexture> counterpart are registered automatically by WGPUPlugin, same as mesh and material — decoding and uploading happen on AssetSync like any other asset.

Binding it: the material instance

use pebble::wgpu::{
    instance::{BindingInstanceEntry, MaterialInstanceDescriptor},
    samplers::SamplerKind,
};

let brick_instance = instances.insert(
    "brick_instance",
    MaterialInstanceDescriptor::new(
        material.id,
        vec![
            ("albedo", BindingInstanceEntry::Texture(brick.id)),
            ("albedo_sampler", BindingInstanceEntry::Sampler(SamplerKind::LinearRepeat)),
        ],
    ),
);

Each (name, BindingInstanceEntry) pair is matched against the material’s own BindingEntry::names to find the right @binding(N) — the names here ("albedo", "albedo_sampler") must match the ones in material_entries() above, or the instance fails to upload. MaterialInstanceDescriptor::new takes RawAssetHandles, not typed Handle<T>s — that’s material.id/brick.id, unwrapping the typed handles. This is the one place RawAssetHandle shows up directly (see Chapter 6): an instance crosses between the material’s ProcessedAssets<GPUMaterial> and the texture’s ProcessedAssets<GPUTexture>, two different Ts that no single typed Handle<T> could refer to at once.

SamplerKind::LinearRepeat pulls from a small global cache of common sampler configurations (GlobalSamplers, set up automatically by WGPUPlugin) rather than creating a new wgpu::Sampler per instance — samplers are cheap to share and there’s rarely a reason not to.

Spawning and rendering

commands.spawn((quad, brick_instance)); // Handle<MeshDescriptor>, Handle<MaterialInstanceDescriptor>
use pebble::wgpu::instance::GPUMaterialInstance;

fn render(
    mut frame: ResMut<CurrentFrame<WGPUBackend>>,
    materials: Res<ProcessedAssets<GPUMaterial>>,
    meshes: Res<ProcessedAssets<GPUMesh>>,
    instances: Res<ProcessedAssets<GPUMaterialInstance>>,
    mut query: Query<(&Handle<MeshDescriptor>, &Handle<MaterialInstanceDescriptor>)>,
) {
    let Some(mut active) = frame.active() else { return };
    let mut pass = active.render_context([0.05, 0.05, 0.08, 1.0]);

    for (mesh_handle, instance_handle) in query.iter() {
        let Some(mesh) = meshes.get(mesh_handle.id) else { continue };
        let Some(instance) = instances.get(instance_handle.id) else { continue };
        let Some(material) = materials.get(instance.target) else { continue };

        pass.set_pipeline(&material.pipeline);
        pass.set_bind_group(0, Some(&instance.bind_group), &[]);
        pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
        pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
        pass.draw_indexed(0..mesh.index_count, 0, 0..1);
    }
}

Two changes from Chapter 8’s render: an extra Res<ProcessedAssets<GPUMaterialInstance>>, an extra pass.set_bind_group(0, ...) call, and the query now looks up the material through the instance (instance.target, a RawAssetHandle) instead of holding a material handle on the entity directly. The entity itself only needs to know its mesh and its instance — the instance already knows which material it belongs to.

Run wgpu_showcase and you get a brick-textured quad. The uniform/storage buffer variants of BindingInstanceEntry — for a per-instance color tint, say — follow the exact same (name, entry) shape, just with Uniform(bytes)/Storage(bytes) instead of Texture(handle).