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

Part I covered the ECS core without ever touching a GPU. This chapter is the bridge into Part II: how a CPU-side description (mesh data, a texture descriptor) becomes a GPU-side object (a vertex buffer, a wgpu::Texture) on its own schedule, without you writing upload/retry logic by hand.

Asset<B>: describing a conversion

B is the backend type — for everything in this book, pebble::wgpu::backend::WGPUBackend. Asset<B> describes one conversion: a Source type (what you author) becomes Self (what gets used at render time):

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

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

upload returning None means “not ready yet, retry next tick” — the same convention as .once() from Chapter 2, but per-asset instead of per-system. Deps names extra resources upload needs beyond the backend itself (a shared bind group layout, a camera — see Camera, Depth, and Lazy 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.

B doesn’t have to be a graphics backend at all — B = () works for a pure CPU-to-CPU transform (decompression, format conversion), and any other service type works for audio, networking, or whatever else fits the same “raw data in, processed value out, maybe needs something else to exist first” shape.

AssetPlugin: wiring it up

You rarely implement upload by hand for the built-in wgpu types (WGPUPlugin already does it — see the next chapter) — but registering AssetPlugin::<B, T>::new() is what turns an Asset<B> impl into a working pipeline:

  • Assets<T::Source> — stores raw CPU data, tracks which entries are dirty.
  • ProcessedAssets<T> — stores the converted (GPU-side) results, indexed by the same handles as the source.
  • A sync system on AssetSync 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 ProcessedAssets<T> whenever upload first succeeds.

Handle<T>: a typed reference

Assets<T>::insert(name, value) returns a Handle<T> — a small, Copy, typed key into that store:

let quad: Handle<MeshDescriptor> = meshes.insert("quad", MeshDescriptor { /* ... */ });

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,” useful as a placeholder before an asset exists yet.

Internally, Handle<T> wraps an untyped RawAssetHandle — you’ll see RawAssetHandle directly (via a handle’s .id field) whenever code needs to cross between a source type’s Assets<T> and a differently-typed ProcessedAssets<U>, since a single Handle<T> can’t type-correctly refer to both sides of that conversion at once. Your First Triangle shows exactly where this comes up.

LazyResource<B>: exactly one, constructed on demand

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.

impl LazyResource<WGPUBackend> for DepthTexture {
    type Deps<'a> = ();

    fn construct<'a>(backend: &WGPUBackend, _deps: &()) -> Option<Self> {
        let texture = backend.device.create_texture(/* Depth16Unorm, ... */);
        let view = texture.create_view(&Default::default());
        Some(DepthTexture { texture, view })
    }
}

Register with LazyResourcePlugin::<WGPUBackend, DepthTexture>::new(). It adds a system to AssetSyncDeps that waits for the backend (and any Deps) to exist, calls construct exactly once, inserts the result as an ordinary Res<DepthTexture>, and never runs again. Everywhere else in the app, a DepthTexture just looks like any other resource — the “wait for it to become constructible” logic lives entirely in this one plugin, not scattered across every system that needs it.

If you need more than one instance of something (multiple textures, multiple materials), that’s Asset<B> + Handle<T> from earlier in this chapter, not LazyResource — 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 a LazyResource. AssetSync runs plain assets (mesh, texture — no cross-asset dependency); AssetSyncDeps runs LazyResources and anything depending on another ProcessedAssets<T>. Both are re-run to convergence every tick (see Chapter 2’s stage table), 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.