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

Introduction

This book teaches Pebble, a modular ECS framework for building render engines in Rust, the same way Learn Wgpu teaches wgpu: by building something real, one small step at a time, explaining each new piece only once it’s actually needed.

Pebble is not a renderer. It’s the plumbing around one: an application loop, a plugin system, a resource/entity store (built on hecs), and a GPU asset pipeline that turns CPU-side descriptions into GPU-side objects on their own schedule. It ships a ready-made wgpu backend (pebble::wgpu) so you don’t have to write one yourself to get started, but nothing about the framework requires it — the same App, systems, and asset pipeline work with a hand-rolled Backend implementation for Metal, Vulkan, or anything else.

This book uses the built-in pebble::wgpu module throughout, because it’s the fastest path to something on screen and doesn’t require understanding wgpu pipeline internals up front. Where to Go From Here points at what changes if you outgrow it and want to own the graphics backend directly.

What you’ll build

By the end of Part II you’ll have a window, a textured quad rendered through the asset pipeline, an orbiting camera with a depth buffer, and a compute pass — the same arc as learn-wgpu’s early chapters, adapted to how Pebble structures things: as Plugins, Systems, and Assets instead of one big run() function.

How to read this

  • Part I covers the ECS core — the parts of Pebble that have nothing to do with graphics. If you already know an ECS framework (Bevy, hecs directly, specs), skim it for Pebble’s specific vocabulary (Res/ResMut, SystemStage, .once(), run_if) and move on.
  • Part II is the hands-on rendering tutorial, building up one example project chapter by chapter.
  • Part III covers running the result on the web and where to go next.

Every code sample in this book uses real, current Pebble APIs — checked against the crate this book ships alongside. Where a sample is illustrative rather than copy-pasteable (mainly in Camera, Depth, and Lazy Resources and Compute Pipelines), the text says so explicitly and points at the closest full working example in examples/.

Prerequisites

  • Comfortable reading Rust — generics, traits, closures. Pebble leans on all three.
  • No prior wgpu or graphics-API experience assumed, though some familiarity helps. Concepts (bind groups, pipelines, shader stages) are introduced as they come up, not explained from first principles — for that depth, learn-wgpu itself is the better reference, and Pebble’s wgpu module is a thin, opinionated layer directly on top of what it teaches.

A note on where this book lives

This book is source-controlled at book/ in the same repository as the engine, and rebuilds automatically on every push to main. If a chapter is wrong or stale relative to the code, that’s a real bug — open an issue or a PR.

Getting Started

Adding the dependency

[dependencies]
pebble-engine = "0.12"

The crate is named pebble-engine on crates.io, but the library itself is pebble — everything in this book is use pebble::....

The prelude

Almost every type used in this book — App, Res/ResMut, Query, Commands, Handle<T>, Events/EventReader/EventWriter, BackgroundTasks — is re-exported from pebble::prelude:

use pebble::prelude::*;

The wgpu module (materials, meshes, textures, the backend itself) is deliberately not in the prelude — you’ll import from pebble::wgpu::{...} explicitly starting in Opening a Window. Keeping it separate means a project that never touches pebble::wgpu (a headless simulation, a server) doesn’t drag wgpu in as a dependency’s dependency in spirit, even though it’s still compiled in today — the split exists so that boundary is at least visible in every import list.

The shape of every Pebble program

Every Pebble application, no matter how small, has the same three-part shape:

fn main() {
    App::new()
        .add_plugin(/* ... */)   // 1. register capabilities
        .add_system(/* ... */)   // 2. register behavior
        .build()                 // 3. wire it all together, validate, go
        .run();
}
  1. Plugins add capabilities — a window, a graphics backend, an asset type, your own game-specific setup. App::add_plugin just queues them; nothing runs yet.
  2. Systems are plain functions registered against a stage (SystemStage::Update, SystemStage::Render, …) that determines when they run each tick.
  3. build() runs every queued plugin’s registration logic, checks that every system’s resource requirements can eventually be satisfied, and settles as much of the asset pipeline as it can synchronously. run() hands control to whichever runner the window plugin installed — normally an infinite loop calling App::update() once per frame.

Nothing renders yet with just this shape — that needs a window and a backend, which is where Opening a Window picks up. Part I first covers what App, plugins, and systems actually are, using a headless example with no window at all, so the ECS vocabulary is settled before graphics enters the picture.

Running the examples alongside this book

Pebble’s repository ships six runnable examples, ordered by complexity, in examples/. This book leans most heavily on ecs_basics (Part I, no window needed) and wgpu_showcase (Part II, the built-in wgpu module) — both are good to have open in another tab as you read:

git clone https://github.com/Akihiro120/pebble
cd pebble/examples/wgpu_showcase
cargo run

The App and the Plugin System

App owns everything: the ECS world, resources, the registered systems, and the runner that drives the main loop. You build one by chaining calls, then hand it off:

use pebble::prelude::*;

fn main() {
    App::new()
        .add_plugin(MyWindowPlugin)
        .add_plugin(MyBackendPlugin)
        .add_plugin(MyGamePlugin)
        .build()
        .run();
}

Plugins are the unit of composition

A Plugin is anything implementing one method:

pub trait Plugin {
    fn build(&self, app: &mut App);
}

That’s the entire extension point. Windowing, the graphics backend, every asset type, and your own game-specific setup are all just plugins. build receives &mut App and can add resources, register systems, or queue further plugins — plugins can register other plugins, and App::build() keeps draining the queue (up to a hard limit of 64 passes, to catch an accidental registration cycle) until nothing new shows up.

Here’s a minimal one, from the orbit_camera example:

struct TimePlugin;

impl Plugin for TimePlugin {
    fn build(&self, app: &mut App) {
        app.add_resource(Time {
            time: Instant::now(),
            last_time: Instant::now(),
            delta_time: 0.0,
        })
        .add_system(SystemStage::PreUpdate, update_delta_time);
    }
}

Nothing here is special-cased by the framework — TimePlugin uses exactly the two calls (add_resource, add_system) available to any other code with a &mut App. Writing your own plugins is how you organize a real project: one plugin per subsystem (input, camera, physics, UI), each self-contained, added to App in main in whatever order makes sense.

What build() actually does

Three things, in order:

  1. Runs every queued plugin’s build, repeating until no plugin queues another.
  2. Validates resource requirements. Every system’s declared dependencies (covered next chapter) are checked against what plugins have declared they’ll eventually provide. A system requiring a resource that nothing will ever insert is a near-certain bug — build() panics immediately, naming every offending system and resource, rather than letting it surface as a runtime panic several ticks into run().
  3. Settles the asset pipeline as far as it can go synchronously (covered in The Asset Pipeline and Handles) — so resources that don’t need to wait for anything asynchronous (a GPU backend arriving on another thread, say) are ready the moment build() returns, not one tick later.

run() is deliberately thin: it hands self to whatever runner is currently installed and does nothing else. The default runner just loops app.update() forever; a window plugin normally replaces it with one that also pumps the OS event loop (see Opening a Window).

There is no “Startup” stage

Frameworks with a fixed set of lifecycle stages usually have a dedicated Startup one. Pebble doesn’t — anything that should run once is an ordinary system wrapped in .once(), covered in the next chapter. This keeps the mental model to one thing (“systems run on stages, every tick”) instead of two (“systems run on stages, except the ones that only run at the start, which are different”).

Systems, Stages, and Resources

Systems are plain functions

A system is any function whose parameters are all SystemParams. Pebble inspects the signature, fetches each parameter, and calls the function — no registration macro, no trait to implement by hand:

fn move_system(
    time: Res<Time>,              // immutable resource borrow
    mut rb: ResMut<RigidBodies>,  // mutable resource borrow
    mut q: Query<&mut Transform>, // ECS query
    mut cmd: Commands,            // deferred world mutations
) {
    // ...
}

app.add_system(SystemStage::Update, move_system);

Query and Commands are covered in the next chapter. This chapter is about the other two: resources, and when a system runs at all.

Stages: when a system runs

Every system is registered against a SystemStage, which determines its place in the fixed per-tick order:

StagePurpose
PreUpdateBefore main logic (input, time, draining channels)
UpdateMain game logic
PostUpdateAfter main logic
PreRenderPrepare render data, poll the backend
AssetSyncUpload CPU assets to the GPU backend
AssetSyncDepsUpload assets that depend on other GPU assets
RenderIssue draw calls
PostRenderPresent the frame

AssetSync/AssetSyncDeps are special: they run to convergence (repeated until a full pass produces nothing new) at the very front of every tick, and again after every other stage — so newly queued asset work is drained immediately instead of waiting for next tick’s front pass. The Asset Pipeline and Handles covers why they’re split into two.

Within one stage, systems run in the order they were registered, unless you impose an explicit ordering constraint — not covered in this book; see the System trait’s docs for before/after.

Resources: singleton state

A resource is any hecs::Component type with exactly one instance, stored in the ECS world rather than on an entity:

app.add_resource(MyConfig { volume: 0.8 });

fn my_system(config: Res<MyConfig>) {
    println!("{}", config.volume);
}

Res<T>/ResMut<T> borrow it immutably/mutably for the duration of the system call — the same borrow-checking rules as RefCell apply across the whole tick, so two systems in the same stage both wanting ResMut<T> is fine (they run sequentially), but you can’t stash a Res<T> guard somewhere and read it later.

What happens when a resource isn’t there yet

A bare Res<T>/ResMut<T> is a hard requirement: before a system with one runs, Pebble checks that T actually exists. What happens if it doesn’t depends on whether anything has declared it will eventually provide T:

  • Something declared it (a LazyResource plugin, an async graphics backend) — the system is silently skipped this pass and retried next tick. No error; this is the expected shape of “constructed asynchronously.”
  • Nothing declared itApp panics immediately, naming both the system and the missing resource, with a hint pointing at the fix (usually a missing app.add_resource(...) or a missing plugin).

This is why build() runs its own pre-flight pass (see previous chapter): it applies exactly this check to every system in every stage before run() starts, so a missing-resource mistake becomes one clear panic at startup instead of a surprise several ticks in.

When a resource is legitimately optional — not “not ready yet,” but “may never exist, and that’s fine” — use Option<Res<T>> instead. It never panics or waits; the system just receives None and can skip its own work:

fn maybe_render(backend: Option<Res<WGPUBackend>>) {
    let Some(backend) = backend else { return }; // backend not ready yet, try again next tick
    // ...
}

Run once

There’s no dedicated “Startup” stage (see the end of the previous chapter) — instead, .once() turns “have I already done this” into the system’s own return value:

fn spawn_scene(mut commands: Commands, config: Option<Res<MyConfig>>) -> Option<()> {
    let config = config?; // not ready yet — try again next tick
    commands.spawn(/* ... */);
    Some(()) // done — never runs again
}

app.add_system(SystemStage::PreUpdate, spawn_scene.once());

Return None to mean “call me again next tick”; return Some(()) to mean “done” — the system is retired permanently, no matter how many ticks that took. It composes with the hard-requirement check above: a bare Res<T> parameter inside a .once() system is still checked (wait if declared, panic if not) before the function body ever runs.

Run conditions

.run_if::<C>() gates a system (or a whole tuple passed to add_systems) behind a RunCondition, re-checked every tick — its SystemParams are only fetched, and its body only runs, when the condition holds:

app.add_systems(
    SystemStage::Update,
    expensive_diagnostic.run_if::<ResourceExists<DebugOverlay>>(),
);

Built-in conditions: ResourceExists<T>, plus And<A, B>/Or<A, B> for combining conditions; implement RunCondition yourself for anything else. A system wrapped in .run_if is fully exempt from the hard-requirement panic described above — the condition is trusted to gate correctly, so a bare Res<T> inside it is never checked independently.

Queries, Commands, and Entities

Components and entities

An entity is just an id; components are plain Rust values attached to it. Spawn one with Commands:

fn spawn_entities(mut commands: Commands) -> Option<()> {
    commands.spawn((
        Name("wanderer"),
        Position { x: 0.0, y: 0.0 },
        Velocity { dx: 1.0, dy: 2.0 },
    ));
    Some(())
}

app.add_system(SystemStage::PreUpdate, spawn_entities.once());

Any tuple of types works as the component set — no registration step, no marker trait to implement. A component with no data at all (struct Asleep;) is a common, useful pattern purely for tagging entities so a query can filter by “has this,” regardless of what else it carries.

Querying

Query<Q> fetches every entity matching the component set Q. Iterate it with .iter():

fn move_entities(mut query: Query<(&Name, &mut Position, &Velocity)>) {
    for (name, pos, vel) in query.iter() {
        pos.x += vel.dx;
        pos.y += vel.dy;
        println!("{} moved to ({:.1}, {:.1})", name.0, pos.x, pos.y);
    }
}

Mixing &T (read) and &mut T (write) in the same query is fine — hecs (the ECS crate Pebble builds on) borrow-checks it per-component at runtime, so this only panics if some other system concurrently holds a conflicting borrow, not merely because the query itself asks for both.

Include Entity directly in Q when you need the id back alongside the components — it’s a query term like any other, not a separate mechanism:

fn despawn_far_away(mut commands: Commands, mut query: Query<(Entity, &Name, &Position)>) {
    for (entity, name, pos) in query.iter() {
        if pos.x.hypot(pos.y) > 10.0 {
            commands.despawn(entity);
        }
    }
}

Two more helpers for the cases where you don’t want the whole result set:

  • query.get(entity) — look up one known entity directly, without scanning the rest of the query.
  • query.single() / query.get_single() — expect exactly one match (the player, the active camera). single panics if that’s not true; get_single returns None instead.

Commands: deferred mutation

despawn_far_away above calls commands.despawn(entity) while a query over the same world is still borrowed — safe only because Commands doesn’t touch the world immediately. Every Commands call (spawn, despawn, insert_resource, …) queues an operation into a command buffer, which is flushed once the current stage finishes running every system in it. This is also why a resource inserted via commands.insert_resource(...) isn’t visible to a system later in the same stage — only from the next stage (or the next tick) onward; use App::add_resource directly (outside of a system) or ResMut/direct mutation inside a system when you need it visible immediately.

Why the split?

Querying and mutating the same world simultaneously is exactly the kind of aliasing Rust’s borrow checker exists to prevent — Commands sidesteps it by not aliasing at all: it just appends “do this later” to a buffer, and the actual mutation happens at a single well-defined point (the end of the stage) where nothing else is borrowed. It’s the same trick most ECS frameworks use, under whatever name they give it (Bevy calls its version Commands too).

Events

Resources are good for “the current state of X.” Events are for “something happened” — damage was dealt, a file finished loading, a button was clicked. Events<T> is a double-buffered queue: an event sent during tick N stays visible to every reader for the rest of N and all of N + 1, then is dropped.

Sending and reading

struct Damage(u32);

app.add_event::<Damage>();

fn deal_damage(mut writer: EventWriter<Damage>) {
    writer.send(Damage(5));
}

fn on_damage(mut reader: EventReader<Damage>) {
    for event in reader.iter() {
        println!("took {} damage", event.0);
    }
}

app.add_event::<T>() does two things: inserts the Events<T> resource, and registers the per-tick aging step that gives the two-tick guarantee above. An EventWriter<T>/EventReader<T> used before this call panics with a hint pointing back at add_event — same hard-requirement mechanism as Res<T> from the previous chapter, just checking for Events<T> specifically.

Why two ticks?

Systems run in a fixed order within a tick, so a reader registered before the writer in the stage order would never see a same-tick send if events only lived for the tick they were sent in — it already ran by the time the writer fires. Keeping an event visible through the next tick as well means every reader sees every event exactly once, regardless of where in the pipeline it happens to run relative to the writer. Each EventReader<T> tracks its own read cursor privately (the same way Local<T> persists per-system state), so multiple independent readers of the same event type never interfere with each other.

Option<EventReader<T>> / Option<EventWriter<T>>

Exactly like Option<Res<T>>: use these when a system should just skip its event-related work if T hasn’t been registered yet, instead of hard-panicking:

fn maybe_log_damage(reader: Option<EventReader<Damage>>) {
    let Some(mut reader) = reader else { return };
    for event in reader.iter() {
        // ...
    }
}

This matters most for library-ish code — a plugin that optionally reacts to an event type the host application may or may not have registered, without forcing that application to always register it.

Events whose payload arrives from a background task — a downloaded file, a GPU readback — use a different constructor, add_async_event, covered in the next chapter alongside the rest of Pebble’s async story.

Async Systems and Background Tasks

Some work shouldn’t block a frame: decoding a large file, a network fetch, a GPU→CPU buffer readback. BackgroundTasksPlugin::new(worker_count) registers a small worker pool (Res<BackgroundTasks>) for exactly this, with four ways to use it depending on what you need back:

I want…UseResult delivery
A blocking closure run off-thread, native onlyBackgroundTasks::spawn_blockingpoll the returned TaskHandle<T> yourself
A future (async/.await) run off-thread, web-compatibleBackgroundTasks::spawn_asyncpoll the returned TaskHandle<T> yourself
A whole system that’s fire-and-forget async, no result needed.detach()nothing — genuinely fire-and-forget
A future whose result should show up as an ordinary eventAsyncEventWriter<T>automatic — arrives on EventReader<T>

spawn_blocking is the odd one out and named for it: there’s no OS thread to block in a browser tab, so it’s native-only. Everything else in this table works identically on native and web.

The friendliest option: AsyncEventWriter<T>

For the common case — “run this in the background, deliver the result as an event once it’s done” — AsyncEventWriter<T> combines spawn_async with the event system from the previous chapter, so consuming the result is completely ordinary:

struct ReadbackDone(Vec<u8>);

app.add_async_event::<ReadbackDone>();

fn start_readback(events: AsyncEventWriter<ReadbackDone>, backend: Res<WGPUBackend>) {
    let future = backend.readback_buffer(&buf);
    events.spawn(async move { ReadbackDone(future.await) });
}

fn on_readback(mut reader: EventReader<ReadbackDone>) {
    for event in reader.iter() {
        // event.0 is the Vec<u8> read back from the GPU
    }
}

It sits next to EventWriter<T> in the same vocabulary — EventWriter::send enqueues an event now, AsyncEventWriter::spawn enqueues one once the future resolves. Register the type with app.add_async_event::<T>(), not add_event — using the wrong one produces a hint telling you exactly that.

Fire-and-forget systems: .detach()

A whole system can be async without any of the above, if you genuinely don’t need the result back:

fn save_screenshot(tasks: Res<BackgroundTasks>) -> impl Future<Output = ()> + Send + 'static {
    let tasks = tasks.clone();
    async move {
        // ... write to disk ...
    }
}

app.add_system(SystemStage::Update, save_screenshot.detach());

The system runs synchronously as usual — its SystemParams are fetched normally — but instead of doing the work directly, it returns a future, which the scheduler hands to spawn_async and moves on from immediately. A real async fn can’t be used directly here: its returned future borrows every parameter, so it’s never 'static on its own. Extract the owned pieces you need in the ordinary function body, then move only those into the async move block you return.

Fetching a file over HTTP

Same shape as the readback example above — wrap the fetch in a future, spawn it, read the result off an EventReader in a later system. Only the body of the future differs between native and web:

struct FileLoaded(Result<Vec<u8>, String>);

app.add_async_event::<FileLoaded>();

fn start_download(events: AsyncEventWriter<FileLoaded>) {
    events.spawn(async move {
        FileLoaded(fetch_url("https://example.com/data.bin").await)
    });
}

fn on_file_loaded(mut reader: EventReader<FileLoaded>) {
    for FileLoaded(result) in reader.iter() {
        match result {
            Ok(bytes) => { /* ... */ }
            Err(e) => tracing::error!("download failed: {e}"),
        }
    }
}

#[cfg(target_arch = "wasm32")]
async fn fetch_url(url: &str) -> Result<Vec<u8>, String> {
    use wasm_bindgen::JsCast;
    use wasm_bindgen_futures::JsFuture;
    let window = web_sys::window().unwrap();
    let resp: web_sys::Response = JsFuture::from(window.fetch_with_str(url))
        .await.map_err(|e| format!("{e:?}"))?.dyn_into().unwrap();
    let buf = JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?)
        .await.map_err(|e| format!("{e:?}"))?;
    Ok(js_sys::Uint8Array::new(&buf).to_vec())
}

#[cfg(not(target_arch = "wasm32"))]
async fn fetch_url(url: &str) -> Result<Vec<u8>, String> {
    reqwest::get(url).await.map_err(|e| e.to_string())?
        .bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string())
}

The #[cfg] split lives entirely inside fetch_url — everything above it (the event, the spawn call, the reader) is identical on both platforms.

Getting a JS event into the scheduler

Going the other direction — a browser event (a button click, a custom postMessage) reaching your systems — doesn’t go through BackgroundTasks at all, since there’s no future to await; the callback fires synchronously whenever the browser decides to call it. The pattern is a plain channel, filled by a wasm_bindgen closure registered on the DOM element, drained by an ordinary system into an EventWriter:

#[derive(Clone)]
struct ButtonClicks(crossbeam_channel::Sender<()>, crossbeam_channel::Receiver<()>);

struct ButtonClicked;
app.add_event::<ButtonClicked>();

#[cfg(target_arch = "wasm32")]
fn setup_button_listener(app: &mut App) {
    let (tx, rx) = crossbeam_channel::unbounded();
    app.add_resource(ButtonClicks(tx.clone(), rx));

    let button = web_sys::window().unwrap().document().unwrap()
        .get_element_by_id("my-button").unwrap();
    let closure = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
        let _ = tx.send(());
    }).into_js_value();
    button.add_event_listener_with_callback("click", closure.unchecked_ref()).unwrap();
}

fn drain_button_clicks(clicks: Res<ButtonClicks>, mut writer: EventWriter<ButtonClicked>) {
    while clicks.1.try_recv().is_ok() {
        writer.send(ButtonClicked);
    }
}

fn on_click(mut reader: EventReader<ButtonClicked>) {
    for _ in reader.iter() { /* ... */ }
}

This is the same shape pebble::wgpu::window::WinitWindow already uses internally for the browser’s resize event — a Closure capturing a Sender, registered once at startup, drained by a system every tick. Your gameplay code only ever sees EventReader<ButtonClicked>; nothing downstream needs to know the event originated from outside the ECS at all.

Web support at a glance

APINativeWeb (wasm32)
BackgroundTasks::spawn_blocking❌ (queues a job that never runs)
BackgroundTasks::spawn_async / .detach() / AsyncEventWriter<T>
WGPUBackend::readback_buffer

The rule of thumb: if it’s a future, it runs everywhere. If it’s a blocking closure, it’s native-only — there’s no thread to block on in a browser tab. Running on the Web covers the rest of what’s platform-specific once graphics enter the picture.

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.

Opening a Window

Everything up to this point has run headless, no window at all — Part I’s examples are all tested that way (ecs_basics sets its own runner and calls app.update() in a plain loop). Real graphics needs three things: a window, a GPU device, and a render loop tied to the two. pebble::wgpu::backend::WGPUPlugin sets up all three at once.

WGPUPlugin

use pebble::prelude::*;
use pebble::wgpu::backend::WGPUPlugin;

fn main() {
    App::new()
        .add_plugin(WGPUPlugin::new(WindowConfig {
            title: "My Game".to_string(),
            width: 1280,
            height: 720,
        }))
        .add_system(SystemStage::Render, render)
        .build()
        .run();
}

fn render(mut frame: ResMut<CurrentFrame<WGPUBackend>>) {
    if let Some(mut active) = frame.active() {
        let mut _pass = active.render_context([0.05, 0.05, 0.08, 1.0]);
        // draw calls go here
    }
}

WGPUPlugin is a convenience bundle — under the hood it registers a window plugin, the graphics backend, the render loop, and every asset pipeline this book’s Part II uses (mesh, material, material instance, texture, texture array, cubemap, compute, sampler). One plugin instead of one add_plugin call per asset type.

Why Option-shaped access to the frame

frame.active() returns Option<ActiveFrame<...>>, not the frame directly. Backend initialization is asynchronous (creating a wgpu::Device is itself an async operation, and on native it may also be handed off to a background thread) — for the first several ticks after build(), there simply isn’t a frame yet. render just does nothing on those ticks; there’s no error to handle, because nothing has gone wrong. This is the same Option<Res<T>> pattern from Chapter 2, applied to the one resource (CurrentFrame<B>) that’s guaranteed to start out absent in every graphical app.

render_context(clear_color) is a shortcut for the common case: one color attachment, cleared to clear_color, no depth buffer. Camera, Depth, and Lazy Resources uses the more general begin_pass once a depth attachment enters the picture.

What you should see

Run this and you get a window, cleared every frame to a dark blue-gray — nothing drawn yet, because nothing has been given to draw. That’s Your First Triangle.

Your First Triangle

Time to draw something. This chapter builds a mesh and a material from scratch and gets a solid-colored triangle on screen — no texture yet, that’s the next chapter.

The shader

MaterialDescriptor takes one WGSL string covering both the vertex and fragment stage:

const SHADER: &str = r#"
@vertex
fn vs_main(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
    return vec4<f32>(pos, 1.0);
}

@fragment
fn fs_main() -> @location(0) vec4<f32> {
    return vec4<f32>(1.0, 0.5, 0.2, 1.0);
}
"#;

@location(0) on the vertex input has to line up with the vertex buffer layout you give MaterialDescriptor.vertex_layouts — that’s Vertex::layout(), covered next.

Vertex data

pebble::wgpu::mesh::Vertex is a fixed layout: position, texture coordinates, normal, and tangent — everything a lit, textured mesh needs. This shader only reads position, but the vertex buffer still needs all four fields populated, since the buffer’s layout is fixed regardless of what any one shader chooses to read:

use pebble::wgpu::mesh::Vertex;

fn triangle_vertices() -> Vec<Vertex> {
    let uv = glam::Vec2::ZERO;
    let normal = glam::Vec3::Z;
    let tangent = glam::Vec4::new(1.0, 0.0, 0.0, 1.0);
    vec![
        Vertex::new(glam::Vec3::new(0.0, 0.6, 0.0), uv, normal, tangent),
        Vertex::new(glam::Vec3::new(-0.6, -0.6, 0.0), uv, normal, tangent),
        Vertex::new(glam::Vec3::new(0.6, -0.6, 0.0), uv, normal, tangent),
    ]
}

Setup: inserting the mesh and material

Everything from here on happens in a .once() system — see Chapter 2 if you skipped Part I. Res<WGPUBackend> (the built-in backend, not your own) is a hard requirement here purely so setup waits until the GPU device exists before reading backend.config.format; nothing in setup needs to touch the device directly.

use pebble::wgpu::{
    backend::WGPUBackend,
    material::MaterialDescriptor,
    mesh::MeshDescriptor,
};

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<MeshDescriptor>>,
    mut materials: ResMut<Assets<MaterialDescriptor<'static>>>,
    backend: Res<WGPUBackend>,
) -> Option<()> {
    let triangle = meshes.insert(
        "triangle",
        MeshDescriptor {
            vertices: triangle_vertices(),
            indices: vec![0, 1, 2],
        },
    );

    let material = materials.insert(
        "solid_orange",
        MaterialDescriptor {
            label: Some("solid-orange"),
            shader_source: SHADER,
            vertex_entry: Some("vs_main"),
            fragment_entry: Some("fs_main"),
            vertex_layouts: vec![Vertex::layout()],
            entries: vec![],   // no textures/uniforms yet — see the next chapter
            own_group: None,   // ...so there's no bind group at all for this material
            targets: vec![wgpu::ColorTargetState {
                format: backend.config.format,
                blend: None,
                write_mask: Default::default(),
            }],
            ..Default::default()
        },
    );

    commands.spawn((triangle, material));

    Some(())
}

meshes.insert/materials.insert return Handle<MeshDescriptor>/Handle<MaterialDescriptor> — spawning an entity with both handles as components is how the render system (next) finds them again. entries: vec![] plus own_group: None together mean “this material has no bind group at all” — valid, since the shader above doesn’t declare a @group either. The moment a shader wants a texture or a uniform, both of those need to change; that’s the whole subject of the next chapter.

..Default::default() fills in the rest: cull_mode: Some(Face::Back), no depth testing, fill polygon mode. Camera, Depth, and Lazy Resources is where depth stops being None.

Rendering

use pebble::wgpu::mesh::GPUMesh;
use pebble::wgpu::material::GPUMaterial;

fn render(
    mut frame: ResMut<CurrentFrame<WGPUBackend>>,
    materials: Res<ProcessedAssets<GPUMaterial>>,
    meshes: Res<ProcessedAssets<GPUMesh>>,
    mut query: Query<(&Handle<MeshDescriptor>, &Handle<MaterialDescriptor<'static>>)>,
) {
    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, material_handle) in query.iter() {
        let Some(mesh) = meshes.get(mesh_handle.id) else { continue };
        let Some(material) = materials.get(material_handle.id) else { continue };

        pass.set_pipeline(&material.pipeline);
        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 things worth noticing:

  • Res<ProcessedAssets<GPUMesh>>, not Assets<MeshDescriptor>. render reads the uploaded GPU-side objects (built by the asset pipeline from Chapter 6), not the CPU-side descriptors that produced them.
  • meshes.get/materials.get return Option, and a miss just continues. The asset might genuinely not be uploaded yet on the very first few frames — same “not ready yet, not an error” shape as every other async boundary in this book.

Run it, and you get an orange triangle over a dark background. Everything from here (Chapter 9 onward) is additive on top of this same skeleton: a bind group for a texture, a camera bind group, a depth attachment.

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).

Camera, Depth, and Lazy Resources

This chapter is illustrative rather than copy-pasteable: there’s no built-in pebble::wgpu camera or depth type (there isn’t meant to be — a camera’s uniform layout is yours to define), so the code below follows the exact same LazyResource/extra_layouts mechanism as the previous two chapters, adapted from the fully working, tested orbit_camera example. That example targets a hand-rolled Backend rather than pebble::wgpu::backend::WGPUBackend, but every API used below — LazyResource, MaterialDescriptor::extra_layouts, begin_pass — is identical either way. Read orbit_camera’s README for the complete, runnable version.

A depth buffer and a camera are both things Chapter 6 called out as good LazyResource candidates: exactly one instance, needs the GPU device to exist before it can be built, not authored data.

The depth texture

use pebble::wgpu::backend::WGPUBackend;

struct DepthTexture {
    texture: wgpu::Texture,
    view: wgpu::TextureView,
}

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

    fn construct<'a>(backend: &WGPUBackend, _deps: &()) -> Option<Self> {
        let texture = backend.device.create_texture(&wgpu::TextureDescriptor {
            label: Some("depth"),
            size: wgpu::Extent3d { width: backend.config.width, height: backend.config.height, depth_or_array_layers: 1 },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Depth16Unorm,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            view_formats: &[],
        });
        let view = texture.create_view(&Default::default());
        Some(DepthTexture { texture, view })
    }
}
.add_plugin(LazyResourcePlugin::<WGPUBackend, DepthTexture>::new())

The camera

A camera needs a uniform buffer (the view/projection matrices), a bind group layout describing that buffer, and a bind group binding the two together — all built once the device exists:

struct Camera {
    buffer: wgpu::Buffer,
    bind_group_layout: wgpu::BindGroupLayout,
    bind_group: wgpu::BindGroup,
}

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

    fn construct<'a>(backend: &WGPUBackend, _deps: &()) -> Option<Self> {
        let buffer = backend.device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("camera"),
            size: std::mem::size_of::<[[f32; 4]; 4]>() as u64 * 2, // view + projection
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        let bind_group_layout = backend.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("camera_layout"),
            entries: &[wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::VERTEX,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            }],
        });

        let bind_group = backend.device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("camera_bind_group"),
            layout: &bind_group_layout,
            entries: &[wgpu::BindGroupEntry { binding: 0, resource: buffer.as_entire_binding() }],
        });

        Some(Camera { buffer, bind_group_layout, bind_group })
    }
}
.add_plugin(LazyResourcePlugin::<WGPUBackend, Camera>::new())

Updating it every frame is an ordinary Update-stage system, writing fresh matrices via queue.write_buffer — nothing new relative to Part I.

Wiring the camera into the material’s pipeline layout

The quad material from Chapter 9 occupies @group(0) for its own texture/sampler bind group. The camera needs its own group too — MaterialDescriptor::extra_layouts is exactly for bind group layouts that exist outside a material’s own entries:

use pebble::wgpu::layout::OwnedGroupLayout;

fn setup(
    // ...
    camera: Res<Camera>,
    depth: Res<DepthTexture>,
) -> Option<()> {
    let material = materials.insert("lit", MaterialDescriptor {
        // ... shader_source, vertex_layouts, entries (albedo/sampler at @group(0)) as before ...
        extra_layouts: vec![OwnedGroupLayout { group: 1, layout: camera.bind_group_layout.clone() }],
        depth: Some(wgpu::DepthStencilState {
            format: wgpu::TextureFormat::Depth16Unorm,
            depth_write_enabled: true,
            depth_compare: wgpu::CompareFunction::Less,
            stencil: wgpu::StencilState::default(),
            bias: wgpu::DepthBiasState::default(),
        }),
        ..Default::default()
    });
    // ...
    Some(())
}

own_group (the material’s own texture/sampler entries, Some(0)) plus every group in extra_layouts must cover 0..=max exactly once — assemble_bind_group_layouts panics on a gap or a collision, turning a mismatched @group(N) in the shader into an immediate, specific error instead of an opaque wgpu validation failure at draw time.

setup requiring Res<Camera>/Res<DepthTexture> (hard requirements, from Chapter 2) is what makes this correct without any manual waiting: setup itself won’t run until both lazy resources exist, so by the time it builds MaterialDescriptor, camera.bind_group_layout is guaranteed to be real.

Rendering with a depth attachment

render_context (used in every earlier chapter) is a shortcut for “one color attachment, no depth.” A depth pass uses begin_pass directly:

use pebble::prelude::{ColorTarget, DepthTarget, Pass};

fn render(
    mut frame: ResMut<CurrentFrame<WGPUBackend>>,
    camera: Res<Camera>,
    depth: Res<DepthTexture>,
    // ... materials, meshes, instances as before ...
) {
    let Some(mut active) = frame.active() else { return };
    let mut pass = active.begin_pass(Pass {
        colors: &[ColorTarget::default([0.2, 0.3, 0.3, 1.0])],
        depth: Some(DepthTarget::new(&depth.view, 1.0)),
    });

    pass.set_bind_group(1, Some(&camera.bind_group), &[]); // group 1: shared across every draw

    for /* ... */ {
        pass.set_pipeline(&material.pipeline);
        pass.set_bind_group(0, Some(&instance.bind_group), &[]); // group 0: per-instance
        // set_vertex_buffer / set_index_buffer / draw_indexed as before
    }
}

DepthTarget::new(view, 1.0) clears the depth buffer to the far plane (1.0) at the start of the pass — a fragment only writes if its depth compares Less than what’s already there, so nearer geometry always wins regardless of draw order. Bind group 1 (the camera) is set once per pass, outside the loop, since every draw shares the same view/projection; bind group 0 (the material instance) is set per-draw, inside it.

Compute Pipelines

Compute passes reuse almost everything from Chapters 6–9: ComputeDescriptor mirrors MaterialDescriptor, ComputeInstanceDescriptor mirrors MaterialInstanceDescriptor, and both share the same BindingKind/BindingEntry vocabulary — the only real difference is shader stage.

This chapter builds a compute pass that doubles every number in a buffer, entirely off the render loop — dispatch happens from an ordinary system, not SystemStage::Render.

The shader

const COMPUTE_SHADER: &str = r#"
@group(0) @binding(0) var<storage, read_write> data: array<f32>;

@compute @workgroup_size(64)
fn cs_main(@builtin(global_invocation_id) id: vec3<u32>) {
    data[id.x] = data[id.x] * 2.0;
}
"#;

Declaring the binding

Compute entries use the same BindingKind constructors as a material’s — storage_buffer_read_write this time, visible to exactly the compute stage:

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

fn compute_entries() -> Vec<BindingEntry> {
    vec![BindingEntry {
        name: "data",
        binding: 0,
        kind: BindingKind::storage_buffer_read_write(wgpu::ShaderStages::COMPUTE),
    }]
}

build_compute panics if an entry here isn’t visible to exactly COMPUTE — reusing a material’s FRAGMENT-visible entry by mistake fails loudly here instead of misbehaving silently.

Setup: the pass and its buffer

use pebble::wgpu::{
    compute::ComputeDescriptor,
    instance::{BindingInstanceEntry, ComputeInstanceDescriptor},
};

fn setup(
    mut commands: Commands,
    mut computes: ResMut<Assets<ComputeDescriptor<'static>>>,
    mut instances: ResMut<Assets<ComputeInstanceDescriptor>>,
) -> Option<()> {
    let pass = computes.insert(
        "double",
        ComputeDescriptor {
            label: Some("double"),
            shader_source: COMPUTE_SHADER,
            entry_point: Some("cs_main"),
            entries: compute_entries(),
            ..Default::default()
        },
    );

    let numbers: Vec<f32> = (0..64).map(|i| i as f32).collect();
    let bytes = bytemuck::cast_slice(&numbers).to_vec();

    let instance = instances.insert(
        "double_instance",
        ComputeInstanceDescriptor::new(pass.id, vec![("data", BindingInstanceEntry::Storage(bytes))]),
    );

    commands.spawn((instance,));
    Some(())
}

BindingInstanceEntry::Storage(bytes) allocates and owns the storage buffer itself, sized from the initial bytes — the same instance mechanism from Chapter 9, just with Storage instead of Texture. Nothing here is compute-specific: ComputeInstanceDescriptor is a type alias for the exact same generic GPUBindingInstance<T> that backs MaterialInstanceDescriptor, with T = GPUCompute instead of T = GPUMaterial.

Dispatching

There’s no FrameOperations-mediated path for compute — a render pass is tied to an acquired frame, but a compute pass isn’t tied to a frame at all, so dispatch happens directly against backend.device/backend.queue, from whatever system decides it’s time to run:

use pebble::wgpu::compute::GPUCompute;
use pebble::wgpu::instance::GPUComputeInstance;

fn dispatch(
    backend: Res<WGPUBackend>,
    computes: Res<ProcessedAssets<GPUCompute>>,
    instances: Res<ProcessedAssets<GPUComputeInstance>>,
    mut query: Query<&Handle<ComputeInstanceDescriptor>>,
) {
    for instance_handle in query.iter() {
        let Some(instance) = instances.get(instance_handle.id) else { continue };
        let Some(pass) = computes.get(instance.target) else { continue };

        let mut encoder = backend.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("double-encoder"),
        });
        {
            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
                label: Some("double-pass"),
                timestamp_writes: None,
            });
            compute_pass.set_pipeline(&pass.pipeline);
            compute_pass.set_bind_group(0, Some(&instance.bind_group), &[]);
            compute_pass.dispatch_workgroups(1, 1, 1);
        }
        backend.queue.submit(Some(encoder.finish()));
    }
}

64 elements, one workgroup of 64 threads (matching @workgroup_size(64) in the shader), so a single dispatch_workgroups(1, 1, 1) covers the whole buffer.

Reading the result back

The storage buffer now holds doubled values on the GPU — getting them back to the CPU is exactly the async readback pattern from Chapter 5: WGPUBackend::readback_buffer returns a future, AsyncEventWriter<T> delivers its result as an ordinary event once it resolves. The one addition here is finding the right wgpu::Buffer to read from — GPUBindingInstance::update’s docs note the same buffers are addressable by name; a small accessor on your own code (or extending GPUComputeInstance usage to keep the handle around) gets you the &wgpu::Buffer to pass to readback_buffer. Nothing about the readback itself differs from the GPU→CPU example already covered.

Running on the Web

Everything built in Part II runs on wasm32-unknown-unknown as well as native, with no code changes to the application logic itself — pebble::wgpu already branches internally wherever the two platforms genuinely differ (GPU backend selection, buffer-mapping driven by the browser’s microtask queue instead of a worker thread, and so on).

cargo build --target wasm32-unknown-unknown

The canvas

pebble::wgpu::window::WinitWindow looks for a canvas by a fixed element id and renders into it — add one to your HTML:

<canvas id="wgpu_canvas"></canvas>

Bundling

Pulling in web-sys/wasm-bindgen/wasm-bindgen-futures (already wasm32-only dependencies of the crate — you don’t add them yourself) and bundling with wasm-bindgen, trunk, or wasm-pack is up to your own build setup; Pebble doesn’t prescribe one. A minimal trunk-based index.html needs nothing beyond the canvas above and trunk’s usual <link data-trunk rel="rust" /> tag.

What actually changes on web

Only one API from this book behaves differently, and Chapter 5 already covered exactly why:

APINativeWeb
BackgroundTasks::spawn_blocking❌ — queues a job that never runs, there’s no OS thread to block
BackgroundTasks::spawn_async / .detach() / AsyncEventWriter<T>✅ — driven by the browser’s microtask queue
WGPUBackend::readback_buffer

If your project never calls spawn_blocking directly, the same binary logic works unmodified on both targets — the render loop, the asset pipeline, materials, textures, camera, compute, all of it. The #[cfg(target_arch = "wasm32")] splits you will need are the ones you write yourself for genuinely browser-only integration — reading a DOM element, listening for a JS event — exactly the pattern in Getting a JS event into the scheduler.

This book’s own deploy pipeline

If you’re curious what a real build-and-deploy setup looks like end to end: this book itself is built by mdbook and published to GitHub Pages via a GitHub Actions workflow in the same repository (.github/workflows/deploy-book.yml) — not a wasm/Pebble deploy specifically, but the same “push to main, CI builds it, CI publishes it” shape applies whether the artifact is a book or a trunk build --release output directory.

Where to Go From Here

Owning the graphics backend

Everything in Part II went through pebble::wgpu — a ready-made Backend/FrameOperations implementation plus the descriptor-based material/mesh/texture layer on top of it. None of that is required by the framework itself. App, systems, resources, events, the asset pipeline — everything from Part I — work identically against a Backend you write by hand, for Metal, Vulkan, D3D12, or a second, differently-configured wgpu setup of your own.

Two traits, both covered conceptually in Opening a Window and Camera, Depth, and Lazy Resources without you writing them yourself:

FrameOperations — one acquired frame:

impl FrameOperations for MyFrame {
    type Context<'a> = MyRenderPass<'a>;  // what you draw with
    type Attachment = MyTextureView;
    type DepthAttachment = MyTextureView;

    fn begin(&mut self, pass: Pass<'_, Self>) -> Self::Context<'_> { /* ... */ }
}

Backend — the swapchain and device:

impl Backend for MyBackend {
    type Frame = MyFrame;

    fn init(handle: impl GPUSurfaceHandle, width: u32, height: u32, sender: InitSender<Self>) {
        // create device/swapchain synchronously or on a thread, then:
        sender.send(MyBackend { /* ... */ });
    }

    fn acquire(&mut self) -> Result<Self::Frame, AcquireError> { /* ... */ }
    fn present(&mut self, frame: Self::Frame) { /* ... */ }
}

init always delivers the backend through an InitSender — synchronously (sender.send before returning) or asynchronously (spawn a thread, call sender.send once it’s ready). App polls the channel every PreRender tick until the backend arrives — which is exactly the same “resource that arrives asynchronously” shape from Chapter 2, applied to the one resource every graphical app needs most.

Once you have Backend/FrameOperations, Asset<B> (Chapter 6) works against your own types exactly as it does against WGPUBackend — write your own Mesh/Material/Texture types implementing Asset<MyBackend> in place of reaching for pebble::wgpu’s. examples/hello_triangle, examples/textured_quad, and examples/orbit_camera do exactly this against a hand-rolled wgpu backend in examples/common — read them once you want to see the pattern applied for real, end to end.

The examples, ordered by complexity

ExampleWhat it adds
ecs_basicsPart I’s material, no window
clear_screenA window, a hand-rolled Backend, nothing drawn
hello_triangleA hand-rolled Asset pipeline, a triangle
textured_quadTexture loading, asset-to-asset dependencies
orbit_cameraCustom plugins, LazyResource, depth buffer, camera — the source for Chapter 10
wgpu_showcaseChapters 7–9’s exact code, running

Further reading

  • API docs: docs.rs/pebble-engine — every type and method this book covers, plus the ones it didn’t have room for (dynamic uniform/storage buffers, texture arrays, cubemaps, Query::single/get_single, ordering constraints between systems in the same stage).
  • The Readme: a denser, single-page version of Part I and the pebble::wgpu overview — good as a quick reference once you’ve read this book once.
  • learn-wgpu: for wgpu concepts this book takes as given — bind groups, pipelines, shader stages — sotrh.github.io/learn-wgpu covers them from first principles.

Pebble is under active development — expect the API to keep moving. If something in this book drifts out of date, it’s a bug in the book, not a reason to distrust it wholesale: open an issue.