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

The Asset Pipeline and Handles

How a CPU-side description (mesh data, a texture descriptor) becomes a GPU-side object (a vertex buffer, an uploaded texture) on its own schedule, without you writing upload/retry logic by hand.

AssetSource and Asset<B>: describing a conversion

B is the backend type — for the rest of this book, pebble::wgpu::backend::WGPUBackend. Two traits work together: AssetSource declares the GPU-side output type, and Asset<B> implements the conversion — both are implemented on the CPU-side source type, not the GPU result:

use pebble::assets::upload::{Asset, AssetSource};

struct Mesh { vertices: Vec<Vertex>, indices: Vec<u32> }
struct GPUMesh { vertex_buffer: Buffer, index_buffer: Buffer, index_count: u32 }

impl AssetSource for Mesh {
    type Processed = GPUMesh;  // the GPU-side result type
}

impl Asset<WGPUBackend> for Mesh {
    type Deps<'a> = ();   // no extra dependencies

    fn upload<'a>(&self, backend: &WGPUBackend, _deps: &()) -> Option<GPUMesh> {
        // create GPU buffers from self's data
        Some(GPUMesh { /* ... */ })
    }
}

upload returning None means “not ready yet, retry next tick.” Deps names extra resources upload needs beyond the backend itself (a shared bind group layout, a camera — see Custom GPU Resources for a real one); if a Deps resource isn’t present yet, the whole upload is skipped and retried, same as a missing Res<T> on an ordinary system.

Assets<T>: unified CPU+GPU storage

Assets<T> stores both the CPU source (T) and its GPU-side result (T::Processed) per entry — one resource, both sides. .get(handle) returns Option<&T::Processed> — the uploaded GPU object, or None if it hasn’t finished uploading yet:

fn render(meshes: Res<Assets<Mesh>>, mut query: Query<&Handle<Mesh>>) {
    for handle in query.iter() {
        let Some(gpu_mesh) = meshes.get(*handle) else { continue }; // Option<&GPUMesh>
        // gpu_mesh.vertex_buffer, gpu_mesh.index_count, ...
    }
}

There is no separate ProcessedAssets<GPUType> resource — Res<Assets<Mesh>> gives you both sides. Insert CPU data with assets.insert(name, value) or via a builder’s .build_asset(name, &mut assets).

AssetPlugin: wiring it up

AssetPlugin::<B, T>::new() — where T is the CPU source type — registers the upload pipeline:

  • Assets<T> — the unified store (source + processed), inserted automatically.
  • A sync system that drains the dirty queue every tick, calling T::upload for each pending entry, re-queuing anything that returned None.

No manual ordering, no callbacks — insert source data, and the processed value shows up in Assets<T> whenever upload first succeeds.

WGPUPlugin already registers AssetPlugin for every built-in type (Mesh, Texture, Material, etc.) — you only need to call it yourself for custom asset types you define.

Handle<T>: a typed reference

Assets<T>::insert(name, value) (or, from a builder, .build_asset(name, &mut assets), which calls it for you) returns a Handle<T> — a small, Copy, typed key into that store:

let quad: Handle<Mesh> = MeshBuilder::new(vertices, indices).build_asset("quad", &mut meshes);

A Handle<T> doesn’t keep anything alive on its own; it’s just a lookup key, cheap to store on a component or clone around. Handle::default() is the null handle — the same sentinel every lookup already treats as “not present.”

Internally, Handle<T> wraps an untyped RawAssetHandle. You’ll see RawAssetHandle directly when code needs to cross from one Assets<T> into another — for example, a material instance stores its parent material as a raw handle, and the render system reconstructs the typed handle for lookup:

// instance.target is a RawAssetHandle — reconstruct a typed Handle for lookup:
let material = materials.get(Handle::<Material>::new(instance.target))?;

One-off GPU resources: startup systems

Some things aren’t authored data at all — there’s exactly one of them in the whole app, and they just need a backend to exist before they can be constructed. A depth texture is the canonical example: not loaded from a file, not one of many, but genuinely can’t exist before the GPU device does.

Use an ordinary startup system that inserts the resource via Commands::insert_resource. Returning Option<()> makes it a once-system (see Systems and Stages) that retries if WGPUBackend isn’t ready yet:

fn init_depth_texture(mut commands: Commands, backend: Res<WGPUBackend>) -> Option<()> {
    let view = RenderTargetTextureBuilder::new(
        backend.surface_width(),
        backend.surface_height(),
        TextureFormat::Depth16Unorm,
    )
    .with_label("depth")
    .with_usage(TextureUsages::RENDER_ATTACHMENT)
    .build(&backend);
    commands.insert_resource(DepthTexture { view });
    Some(())
}
.add_system(SystemStage::Startup, init_depth_texture)

Everywhere else in the app, Res<DepthTexture> just looks like any other resource. If you need more than one instance of something (multiple textures, multiple materials), that’s Asset<B> + Handle<T> from earlier on this page, not a startup system — the dividing line is exactly “one of, ever” vs. “a pool of, addressed by handle.”

Why two AssetSync stages?

Some assets depend on other assets — a material instance needs its material to already be uploaded, a material might need a camera bind group layout that’s itself inserted by a startup system. AssetSync runs plain assets (mesh, texture — no cross-asset dependency); AssetSyncDeps runs anything depending on another asset. Both are re-run to convergence every tick (see Systems and Stages), so a multi-level dependency chain resolves itself over however many ticks it takes, with each level just declaring what it needs via Deps and trusting the framework to sequence it correctly.