Introduction
Pebble is a low-level Rust game engine: an ECS, a wgpu-backed renderer, and an asset pipeline, wired together by a builder-style App. It deliberately stops there — no scene graph, no physics, no built-in skeletal animation. You get GPU primitives (buffers, textures, materials, compute pipelines) and an ECS to drive them, and you build whatever higher-level systems your game needs on top.
This book is organized by feature, not by tutorial chapter — jump to whatever you’re trying to do. Each page assumes you’ve read Apps and Plugins and Systems and Stages first, since almost everything else builds on those two.
For the full API reference, generate rustdoc locally:
cargo doc --open
Getting Started
Add pebble to your Cargo.toml:
[dependencies]
pebble-engine = "1.0"
It’s imported as pebble:
use pebble::app::App;
use pebble::graphics::GraphicsPlugin;
fn main() {
App::new()
.with_logging()
.add_plugin(GraphicsPlugin::new())
.run();
}
GraphicsPlugin opens a window, acquires a GPU backend, and registers every built-in asset type. That’s enough to compile and run — an empty window that clears to black every frame. From here:
- Apps and Plugins and Systems and Stages — how to add your own logic.
- The Asset Pipeline and Handles — load a mesh and texture.
- Recording a Render Pass — draw something.
Running on the web
Pebble also builds for wasm32-unknown-unknown. See Running on the Web.
Apps and Plugins
App is the central object: it owns the ECS world, every resource, and every system, organized into stages. It’s built by chaining methods that take self by value and return Self, so a typical setup reads as one expression:
use pebble::app::App;
use pebble::ecs::system::SystemStage;
use pebble::graphics::GraphicsPlugin;
App::new()
.with_logging()
.add_plugin(GraphicsPlugin::new())
.add_system(SystemStage::Ready, setup)
.add_system(SystemStage::Update, my_game_logic)
.run();
App::new() gives you a completely empty app — no window, no GPU backend, no Time. Everything is opt-in via .add_plugin(...).
Plugins
A Plugin is a composable unit of app setup — it inserts resources, registers systems, or adds further plugins:
use pebble::app::App;
use pebble::ecs::plugin::Plugin;
struct MyPlugin;
impl Plugin for MyPlugin {
fn build(self, app: App) -> App {
app.insert_resource(MyResource::default())
.add_system(SystemStage::Update, my_system)
}
}
Any FnOnce(App) -> App also implements Plugin, so a plain closure works without a named type — handy for one-off setup you don’t intend to reuse.
Plugins composing other plugins
“Adds further plugins of its own” is how pebble’s own GraphicsPlugin is actually built — it’s not a special case, just three smaller plugins bundled behind one name, with a DeviceFeatures value threaded through to BackendPlugin:
pub struct GraphicsPlugin {
features: DeviceFeatures,
}
impl GraphicsPlugin {
pub fn new() -> Self {
Self { features: DeviceFeatures::empty() }
}
pub fn with_features(features: DeviceFeatures) -> Self {
Self { features }
}
}
impl Plugin for GraphicsPlugin {
fn build(self, app: App) -> App {
app.add_plugin(WindowPlugin::default())
.add_plugin(BackendPlugin::with_features(self.features))
.add_plugin(BuiltinAssetsPlugin)
}
}
Your own plugins can do the same — group a handful of related plugins/systems your project always wants together under one name, so call sites stay a one-liner instead of repeating the same five .add_plugin(...) calls in every example/binary.
A closure is the lighter-weight version of this for one-off, non-reusable setup — useful for something conditional you’d otherwise have to hand-roll a whole struct for:
fn debug_plugin(app: App) -> App {
app.add_system(SystemStage::PostRender, print_frame_time)
}
let app = App::new().add_plugin(GraphicsPlugin::new());
let app = if cfg!(debug_assertions) { app.add_plugin(debug_plugin) } else { app };
GPU device features
DeviceFeatures is a bitflag set of optional GPU capabilities (ADDRESS_MODE_CLAMP_TO_BORDER, TIMESTAMP_QUERY, PIPELINE_STATISTICS_QUERY, INDIRECT_FIRST_INSTANCE, TEXTURE_COMPRESSION_BC/ETC2/ASTC, POLYGON_MODE_LINE, FLOAT32_FILTERABLE, SHADER_F16, SUBGROUP, RAY_QUERY, MESH_SHADER) requested when the GPU device is created. GraphicsPlugin::new() requests none of them — combine flags with | and pass them to with_features:
App::new()
.add_plugin(GraphicsPlugin::with_features(
DeviceFeatures::SUBGROUP | DeviceFeatures::SHADER_F16,
))
.run();
Only request a feature the adapter actually supports — request_device panics otherwise. Once the backend is up, Read<Backend>::features() reports what was actually granted:
fn check(backend: Read<Backend>) {
if backend.features().contains(DeviceFeatures::SUBGROUP) {
// safe to dispatch a compute shader using subgroup ops
}
}
Because features default to none, anything built into pebble that depends on one — like the NearestClampBorder sampler needing ADDRESS_MODE_CLAMP_TO_BORDER, see Samplers — has to handle the “not enabled” case itself rather than assuming it’s there.
GPU limits
DeviceLimits reports the numeric limits the device was actually granted — max texture size, bind groups, buffer sizes, vertex attributes, compute workgroup sizes, and so on. Unlike DeviceFeatures, there’s nothing to request: limits aren’t opt-in, so this is read-only, via Read<Backend>::limits():
fn check(backend: Read<Backend>) {
let limits = backend.limits();
if width > limits.max_texture_dimension_2d {
tracing::error!("texture too large for this device");
}
}
Like DeviceFeatures, DeviceLimits mirrors a curated subset of wgpu::Limits — the fields most code actually reads (textures, bind groups, buffers, vertex layout, compute workgroups). It omits the mesh-shader/ray-tracing-specific limits that pair with DeviceFeatures::MESH_SHADER/RAY_QUERY.
Key methods
insert_resource<T>/remove_resource<T>— see Resources.add_plugin<P: Plugin>— runs the plugin’sbuild.add_system(stage, system)— see Systems and Stages.add_event::<T>()— see Events.add_observer(fn)— see Observers.set_runner(fn)— overrides how the main loop is driven;WindowPluginuses this to hand control towinit’s event loop instead of the default headless polling loop.with_logging()— initializestracing_subscribersotracing::info!/warn!/error!calls throughout the engine actually print.run()— consumes the app and starts it.
Systems and Stages
A system is a plain function whose parameters are all SystemParams — Read<T>/Write<T>, Query<Q>, Local<T>, Commands, EventReader<T>/EventWriter<T>, and tuples of these. Pebble infers everything from the function signature; there’s no registration macro or trait to implement:
fn my_system(time: Read<Time>, mut player: Query<(&mut Position, &Velocity)>) {
for (pos, vel) in player.iter() {
pos.0 += vel.0 * time.delta_seconds();
}
}
Register it on a stage:
app.add_system(SystemStage::Update, my_system)
Stages
Stages run in this order, every tick:
| Stage | Runs |
|---|---|
Startup | Once, before anything else — no GPU backend yet, pure CPU setup. |
Ready | Once, automatically, the first tick the GPU backend is ready. For one-time setup that needs Backend/Assets<T> — building your first mesh, material, etc. |
AssetSync | Uploads CPU-side assets to the GPU, retrying until dependencies are met. |
PreUpdate | Before main game logic — input, timers, event aging. |
Update | Main game logic. |
PostUpdate | After main game logic. |
PreRender | Acquires the frame. |
Render | Issue draw calls. |
PostRender | Submit and present. |
Startup and Ready each run exactly once and are then removed from their app’s schedule — registering a second system on either later just adds to what runs that one time, it doesn’t create a second occurrence.
Why Ready instead of Startup for GPU setup
Startup runs before the GPU backend has had a single tick to be acquired, so Read<Backend> there always panics. Ready runs the moment the backend actually exists — a plain Read<Backend> there is always safe, no Option guard needed:
fn setup(backend: Read<Backend>, mut meshes: Write<Assets<Mesh>>) {
Mesh::new(vertices, indices).build_asset("player", &mut meshes);
}
app.add_system(SystemStage::Ready, setup)
Built-in plugins that own a Ready system (GraphicsPlugin‘s init_global_samplers, for instance) register it with .priority(ENGINE_READY_PRIORITY) — a constant equal to i32::MAX. That guarantees engine setup always wins the tie-break against your own Ready systems, which default to priority 0, so engine resources it builds (GlobalSamplers, etc.) are already present for any user Ready-stage system that reads them the same tick — regardless of add-order. Give your own plugin’s Ready systems this same priority if other users’ code (or your own later setup) needs to depend on them without an explicit .after(...).
System ordering
Systems on the same stage normally run in the order they were added. Three tools change that, and can be combined:
.after(...)/.before(...)— pin a system relative to another one known to the stage. The other system doesn’t need to be added yet; only its type is used, resolved when the schedule’s order is next computed..priority(n: i32)— break ties between systems that have noafter/beforerelationship to each other; higher runs first. Defaults to0. An explicitafter/beforeconstraint always wins over priority — priority only decides among systems the schedule would otherwise be free to run in any order..chain()— called on a tuple of systems, e.g.(a, b, c).chain(), forces them to run in exactly that relative order. Register the result withadd_systems(plural), notadd_system. A chain can itself take.after(...)/.before(...)/.priority(...), applied to the whole chain —.after/.beforeonly need to constrain the chain’s first/last system respectively, since the rest already transitively depend on it;.priorityapplies to every member, since each competes for its own slot as it individually becomes eligible to run.
app.add_system(SystemStage::Update, spawn_enemies)
.add_system(SystemStage::Update, move_enemies.after(spawn_enemies))
.add_system(SystemStage::Update, render.after(move_enemies))
.add_system(SystemStage::Update, hud.priority(10))
.add_systems(
SystemStage::Update,
(physics_step, resolve_collisions).chain().before(render),
);
Local state
Local<T> gives a system its own private, per-system T that persists between ticks — a frame counter, a “have I already fired” flag. Two different systems, even with the same T, never share one:
fn count_ticks(mut ticks: Local<u32>) {
*ticks += 1;
}
Resources
A resource is a singleton value keyed by type — one Time, one Assets<Mesh>, one of whatever app-wide state you define. Insert one from the App builder:
app.insert_resource(Score(0))
or from within a system, deferred, via Commands.
Reading and writing
Take Read<T> or Write<T> as a system parameter:
fn print_score(score: Read<Score>) {
println!("{}", score.0);
}
fn add_point(mut score: Write<Score>) {
score.0 += 1;
}
Both borrow-check at runtime, not compile time — two systems that both take Write<T> for the same T will panic if they somehow ran concurrently, but pebble’s scheduler runs systems one at a time within a stage, so in practice this only bites if you hold a borrow across an await point or similar, which the API doesn’t allow you to do anyway.
Read/Write panic at fetch time if T isn’t present. For a resource that might not exist yet — a GPU backend before it’s acquired, a plugin that’s optional — take Option<Read<T>>/Option<Write<T>> instead:
fn maybe_render(backend: Option<Read<Backend>>) {
let Some(backend) = backend else { return };
// ...
}
In practice you rarely need the Option form for Backend specifically — see SystemStage::Ready, which exists precisely so ordinary Read<Backend> is safe from Ready onward.
A worked example: a resource that only needs Backend
If a resource of your own needs nothing but Backend to build — no other resource, no per-tick retry — don’t reach for Option<Read<Backend>> at all. Register the system on SystemStage::Ready instead: it runs exactly once, automatically, the first tick Backend exists, so a plain Read<Backend> inside it is always safe, and there’s no “already built?” guard to write because it physically can’t run twice.
Pebble’s own built-in samplers work this way — init_global_samplers is registered on Ready:
fn init_global_samplers(backend: Read<Backend>, mut commands: Commands) {
let samplers = /* build every SamplerKind against backend */;
commands.insert_resource(GlobalSamplers { samplers });
}
It’s registered with .priority(ENGINE_READY_PRIORITY) (see Systems and Stages), so it always runs before other Ready-stage systems regardless of add-order. GlobalSamplers ends up with the exact same guarantee Backend itself has — present from Ready onward, so downstream systems (any other Ready-stage system, and anything in AssetSync/PreUpdate/Update/… since those run later in the tick) can just take Read<GlobalSamplers>, no Option needed.
When you actually need the Option-guard-and-retry shape
Reach for Option<Read<T>> when a resource depends on something that isn’t guaranteed by a fixed one-shot stage — most commonly, another resource that’s itself built asynchronously (an uploaded asset, a resource inserted from AssetSync), or state that can legitimately not exist yet for reasons Ready doesn’t capture:
fn maybe_render(backend: Option<Read<Backend>>) {
let Some(backend) = backend else { return };
// ...
}
For a resource that needs to wait on something else and shouldn’t be rebuilt once it exists, use two guards doing two different jobs — one gating when the work can happen, one gating against doing it more than once:
fn init_thing(dep: Option<Read<SomeAsyncResource>>, existing: Option<Read<Thing>>, mut commands: Commands) {
if existing.is_some() {
return; // already built — don't redo it every tick forever
}
let Some(dep) = dep else {
return; // dependency not ready yet — no-op and try again next tick
};
commands.insert_resource(Thing::from(&*dep));
}
This is the same “return None, retry next tick” shape the whole asset pipeline is built on — resources that depend on other resources just apply it by hand instead of through Asset::upload. Reach for it only when your dependency isn’t itself covered by a one-shot stage like Ready.
Queries, Commands, and Entities
Pebble’s ECS world is hecs underneath. Query/Commands themselves stay unopinionated about it — you write plain Rust tuples of components — but entity IDs and hecs::CommandBuffer (which Commands Derefs to) do genuinely surface the moment you need to work with entities directly, as this page’s examples show. Entity itself is re-exported as pebble::ecs::Entity, so you don’t need hecs as your own dependency just to spell that one type — but a couple of more advanced patterns below (anything touching &hecs::World directly) do still need it.
Queries
Query<Q> fetches components matching Q from every entity that has them:
fn move_things(mut q: Query<(&mut Position, &Velocity)>, time: Read<Time>) {
for (pos, vel) in q.iter() {
pos.0 += vel.0 * time.delta_seconds();
}
}
Other methods:
get(entity)— fetch one known entity directly,Noneif it doesn’t exist or doesn’t matchQ.with::<R>()/without::<R>()— narrow to entities that also have (or don’t have) component(s)R, withoutRjoining the yielded items. Chainable.single()/get_single()— expect exactly one matching entity (the player, the active camera).singlepanics if that’s not true;get_singlereturnsNoneinstead.
Getting entity IDs out of a query
q.iter() yields only whatever’s in Q — a Query<(&Health,)> iterates &Health values with no way to know which entity each one came from. Entity implements hecs::Query in its own right, so the fix is to ask for it explicitly, as one more element of the tuple:
use pebble::ecs::Entity;
fn find_low_health(mut q: Query<(Entity, &Health)>) {
for (entity, health) in q.iter() {
if health.0 < 10 {
// pass `entity` to commands.despawn(...), store it in a resource,
// whatever you need
}
}
}
Commands
Commands queues entity spawns/despawns and resource mutations, applied once the current stage finishes running — not immediately. It Derefs to hecs::CommandBuffer for entity operations:
fn spawn_enemy(mut commands: Commands) {
commands.spawn((Position(Vec2::ZERO), Enemy));
}
It also has its own methods, independent of hecs::CommandBuffer:
insert_resource(value)/remove_resource::<T>()— deferred resource mutation, same effect asApp::insert_resourcebut callable from inside a system.trigger(event)— see Observers.
Deferring to end-of-stage means a spawn queued by one system in Update is visible to a Query in PostUpdate that same tick, but not to another Update system that runs after it in the same stage.
Getting the Entity back from a deferred spawn
CommandBuffer::spawn doesn’t return an Entity — it can’t, since the actual spawn hasn’t happened yet, it’s just queued. If you need the ID right away (to store in a resource, hand to another system this same tick, etc.), reserve one up front with hecs::World::reserve_entity() — available via the &hecs::World system parameter — then queue an insert against that already-known ID instead of a spawn:
fn spawn_and_track(world: &hecs::World, mut commands: Commands, mut tracker: Write<SpawnedEnemies>) {
let entity = world.reserve_entity();
commands.insert(entity, (Position(Vec2::ZERO), Enemy));
tracker.0.push(entity); // valid to use immediately, even though the World
// doesn't actually have this entity until commands sync
}
Putting it together: a full lifecycle
Spawn, react to state each tick, and clean up — the whole loop an enemy goes through, using only what’s on this page:
fn spawn_wave(world: &hecs::World, mut commands: Commands) {
for _ in 0..5 {
let entity = world.reserve_entity();
commands.insert(entity, (Position(random_spawn_point()), Health(100), Enemy));
}
}
fn apply_damage(mut q: Query<(&mut Health, &RecentHit)>) {
for (health, hit) in q.iter() {
health.0 -= hit.amount;
}
}
fn despawn_dead(mut q: Query<(Entity, &Health)>, mut commands: Commands) {
for (entity, health) in q.iter() {
if health.0 <= 0 {
commands.despawn(entity);
}
}
}
Register all three on SystemStage::Update, in that order — apply_damage runs against components already in the World this tick (including anything spawned in a previous tick’s Update, since that spawn’s commands already synced), and despawn_dead sees the health values apply_damage just wrote, since they’re plain mutations through &mut Health, not deferred through Commands. If despawn_dead should also notify other systems when an enemy dies — awarding score, playing a sound — see Observers for the natural next step: commands.trigger(EnemyDied { .. }) right alongside the despawn.
Events
Events are for many-to-many, poll-when-convenient communication — damage dealt, an item picked up, anything a handful of unrelated systems might want to react to without tight coupling. If you instead want guaranteed same-tick reactions, see Observers.
Register the event type on the app:
app.add_event::<Damage>()
This is idempotent — safe to call from two different plugins that both want Damage.
Sending and reading
struct Damage(u32);
fn deal_damage(mut writer: EventWriter<Damage>) {
writer.send(Damage(10));
}
fn log_damage(mut reader: EventReader<Damage>) {
for event in reader.iter() {
println!("took {} damage", event.0);
}
}
Events<T> is double-buffered: an event sent during tick N is visible to readers for the rest of N and all of N + 1, then dropped. Each EventReader keeps its own read cursor (like Local), so a reader sees every event exactly once no matter when it runs relative to the writer, and multiple readers of the same type don’t interfere with each other.
For a resource/event type that might not be registered — e.g. an optional plugin’s event — take Option<EventReader<T>>/Option<EventWriter<T>> instead.
Signaling that an async result is ready
A particularly useful case: a system polling a Promise<T> (a GPU readback, say) and another system that needs to react once the result lands. Sending an event when the Promise resolves is usually a better fit than publishing the result as a resource — see Promise: handing the result to a dependent system via an Event for the full walkthrough, including why send beats Commands::insert_resource for same-tick delivery.
Observers
Observers are pebble’s other communication primitive, alongside Events — subscription-based instead of polled, and dispatched the same tick they’re triggered, not next tick.
Register one on the app:
struct Ping(u32);
fn on_ping(trigger: Trigger<Ping>, mut score: Write<Score>) {
score.0 += trigger.0;
}
app.add_observer(on_ping)
An observer function’s first parameter is always Trigger<E> — it Derefs straight to E, so trigger.0/trigger.some_field reads through directly. Every other parameter is an ordinary SystemParam, same as a ordinary system.
Fire one via Commands::trigger:
fn fire(mut commands: Commands) {
commands.trigger(Ping(3));
}
Triggers are queued the same way entity spawns are, and dispatched at the same point in the tick: once the current stage finishes running and commands sync — so an observer registered for Ping runs before the end of the stage that triggered it, not one tick later. Multiple observers can be registered for the same event type; all of them run.
Why multiple observers on one trigger is the actual point
The useful case isn’t one observer — it’s several unrelated ones reacting to the same moment without knowing about each other. Say an enemy dies: something should add score, something should roll loot, something should kick off a screen shake. Those three concerns have no reason to live in the same system, or even know the others exist:
struct EnemyDied { position: glam::Vec3, value: u32 }
fn award_score(trigger: Trigger<EnemyDied>, mut score: Write<Score>) {
score.0 += trigger.value;
}
fn spawn_loot(trigger: Trigger<EnemyDied>, mut commands: Commands) {
commands.spawn((Position(trigger.position), Loot));
}
fn trigger_screen_shake(trigger: Trigger<EnemyDied>, mut shake: Write<ScreenShake>) {
shake.0 = 0.3;
}
app.add_observer(award_score)
.add_observer(spawn_loot)
.add_observer(trigger_screen_shake)
The system that actually detects the death doesn’t call any of these directly — it just triggers the fact that happened:
use pebble::ecs::Entity;
fn check_deaths(mut q: Query<(Entity, &Health, &Value, &Position)>, mut commands: Commands) {
for (entity, health, value, pos) in q.iter() {
if health.0 <= 0 {
commands.trigger(EnemyDied { position: pos.0, value: value.0 });
commands.despawn(entity);
}
}
}
(See Queries, Commands, and Entities for why Entity has to be named explicitly in the query to get it back out.)
Add a fourth reaction later (an achievement check, a kill-streak counter) by registering one more observer — check_deaths never changes. This is the same shape Events gives you for many-to-many communication, but with a guarantee events don’t make: every one of these three observers has already run, and their effects (like award_score’s mutation) are visible, before the stage that triggered the death finishes — so a UI system later in that same stage already sees the updated score, not one tick stale.
Events vs. Observers
Use Events when several systems might poll for something whenever convenient, and it’s fine if a couple ticks pass before someone reads it. Use Observers when you need a guaranteed, same-tick reaction — the trigger and its handling should feel like one atomic step, as in the death example above.
Promise
This page goes deeper than most — Promise<T> trips people up more than anything else in the engine, usually because the mechanism (why it exists, why it isn’t just a resource, why storing it is your job) isn’t obvious from the API alone. Read this once and the rest of the engine’s async-ish bits (GPU backend acquisition, Buffer::read) fall out for free.
The problem
Systems are synchronous functions that run once per tick and are expected to return quickly. There’s no .await inside a system, and nothing in the scheduler will pause one system while it waits on I/O — if a system blocked, every other system in that stage would freeze with it for that tick.
But some things genuinely take time and can’t be forced into “just compute it right now”: acquiring a GPU device from the OS, waiting for a GPU-to-CPU buffer copy to land, decoding a file on a background thread. These operations start on one tick and finish on some later, unpredictable tick. Promise<T> exists to bridge that gap — a way for a system to say “start this, and check back on it every tick without blocking.”
What it actually is
Strip away the ECS integration and Promise<T> is nothing more than a labeled oneshot channel:
let (fulfiller, promise) = Promise::new();
Fulfiller<T> is the producer half — hand it off to whatever does the actual work (a background thread, an async task, a GPU driver callback), and it calls fulfiller.fulfill(value) exactly once when the result is ready. Promise<T> is the consumer half — you keep it, and call .poll() on it whenever you want to check in. poll() never blocks; it’s cheap enough to call every single tick until it resolves:
match promise.poll() {
PromiseState::Pending => {} // not yet — check again next tick
PromiseState::Ready(value) => { /* ... */ } // resolved — this only ever happens once
PromiseState::Disconnected => { /* ... */ } // the Fulfiller was dropped without fulfilling — never resolving
}
Disconnected isn’t a failure path you can usually recover from — it means whatever was supposed to produce the value gave up (panicked, was cancelled) without calling fulfill. Treat it like Ready in the sense that it’s also a terminal state: stop polling.
Why it’s not a resource
Every other piece of shared state in pebble — Time, Assets<T>, Backend — is a resource: one instance, globally addressable by type, registered once. A Promise<T> doesn’t fit that shape. It isn’t persistent app state; it’s a handle to one specific pending operation that exists for a few ticks and then is gone. If Promise<T> auto-registered itself as a resource, you’d only ever be able to have one in-flight Promise<SomeType> at a time for the entire app, which is far too restrictive — you might want to kick off several unrelated GPU readbacks in the same tick, for instance.
So instead, Promise<T> is just a plain value, same as an i32 or a String. Nothing about it is special-cased by the engine. That also means the engine does not automatically keep it alive between ticks — which is the part that actually confuses people, so it gets its own section.
The real challenge: giving it a home
A system function’s local variables do not persist between ticks. The scheduler calls your function fresh, every tick:
fn broken(/* no persistent parameter */) {
let (fulfiller, promise) = Promise::new(); // remade from scratch, every single tick
// by the time this function is called again next tick, `promise` is already gone —
// you can never see it resolve
}
To poll something across multiple ticks, it has to live somewhere that survives between calls of your function. Pebble doesn’t have one special place for this — you choose, based on who needs to see the Promise:
- Only one system needs to poll it → a
Local<Option<Promise<T>>>. Private to that one system, persists across its ticks, nobody else can see or interfere with it. - Multiple systems need to see the same pending operation → wrap it in a field on your own resource, and insert/remove that resource as the operation starts/finishes.
- It belongs to one specific entity (a per-entity async load, say) → a field on a component.
In every case the shape is the same: wrap it in Option<Promise<T>>, start it as None, set it to Some(promise) when you kick off the operation, and set it back to None once poll() returns Ready or Disconnected — otherwise you’d keep matching against an already-resolved (and by then meaningless) channel forever.
Walkthrough 1: Local, one consumer
This is the shape to reach for by default — used internally by Buffer::read, where only the system that kicked off the readback cares about the result:
fn readback(buffer: Read<SomeBuffer>, mut pending: Local<Option<Promise<Vec<u8>>>>) {
// kick it off once
if pending.is_none() {
*pending = Some(buffer.0.read());
}
// check in every tick after that
if let Some(p) = pending.as_ref() {
match p.poll() {
PromiseState::Ready(bytes) => {
// do something with `bytes`
*pending = None; // done — stop polling
}
PromiseState::Pending => {}
PromiseState::Disconnected => {
*pending = None; // gave up — stop polling
}
}
}
}
One system, one Local, nobody else involved. This is the pattern for the vast majority of Promise usage.
Walkthrough 2: a resource, multiple systems
Sometimes more than one system needs visibility into the same pending operation — this is exactly the shape pebble itself uses internally to acquire the GPU backend (src/graphics/render.rs), and it’s worth reading end to end because it shows why you’d reach for a resource instead of a Local.
Three systems are involved, all running every tick until the backend is ready:
// the resource that gives every interested system a shared view of the same Promise
pub struct GPUReceiver {
promise: Promise<Backend>,
}
System 1 — kicks the operation off, exactly once:
fn obtain_gpu(mut commands: Commands, receiver: Option<Read<GPUReceiver>>, /* ... */) {
if receiver.is_some() {
return; // already in flight — don't start a second one
}
let (fulfiller, promise) = Promise::new();
// ...spawn the actual async GPU initialization, which eventually calls
// fulfiller.fulfill(backend) from wherever it finishes...
commands.insert_resource(GPUReceiver { promise });
}
Because the Promise now lives in a resource rather than that system’s own Local, checking receiver.is_some() from any system tells you whether one is already in flight — that’s the whole reason this needed to be a resource instead of a Local: obtain_gpu itself needs to see the state that poll_gpu (a different system) is updating.
System 2 — polls it every tick:
fn poll_gpu(mut commands: Commands, receiver: Option<Read<GPUReceiver>>, /* ... */) {
let Some(receiver) = receiver else { return; };
match receiver.promise.poll() {
PromiseState::Ready(backend) => commands.insert_resource(backend), // now Backend exists as its own resource
PromiseState::Pending => {}
PromiseState::Disconnected => { /* log and give up */ }
}
}
System 3 — cleans up the now-useless GPUReceiver once the real Backend resource exists:
fn clean_up_gpu_acquisition_resources(mut commands: Commands, ready: Read<BackendReady>, receiver: Option<Read<GPUReceiver>>) {
if ready.0 && receiver.is_some() {
commands.remove_resource::<GPUReceiver>();
}
}
Notice there’s no Option<Promise<T>> here at all — the resource itself (Option<Read<GPUReceiver>>, i.e. whether the resource exists) plays the role that Option played in the Local version. Once the backend is ready, the whole GPUReceiver resource is removed rather than cleared to None internally.
Walkthrough 3: handing the result to a dependent system via an Event
A common shape: System A dispatches a compute pass, reads the result back, and System B needs that result — but B has no other reason to run except “there’s new data from A.” Wrapping the result in a resource works, but an Event is usually the better fit: a readback finishing is a discrete occurrence, not persistent state, and B’s whole job is “react when one happens,” not “hold onto a value forever.”
struct ReadbackDone(Vec<u8>);
app.add_event::<ReadbackDone>()
// System A: still owns its own pending Promise privately, in a Local — nothing
// changes about *that* part. The only difference from Walkthrough 1 is what
// happens once it resolves.
fn compute_and_readback(
backend: Read<Backend>,
buffer: Read<SomeBuffer>,
mut pending: Local<Option<Promise<Vec<u8>>>>,
mut writer: EventWriter<ReadbackDone>,
) {
if pending.is_none() {
backend.dispatch_compute(|pass| { /* ... */ });
*pending = Some(buffer.0.read());
}
if let Some(p) = pending.as_ref() {
match p.poll() {
PromiseState::Ready(bytes) => {
writer.send(ReadbackDone(bytes));
*pending = None;
}
PromiseState::Pending => {}
PromiseState::Disconnected => { *pending = None; }
}
}
}
// System B: always scheduled, effectively a no-op on ticks with nothing new
fn use_result(mut reader: EventReader<ReadbackDone>) {
for event in reader.iter() {
// ... use event.0 ...
}
}
Two things worth knowing about the timing here, since they’re easy to get backwards:
EventWriter::sendisn’t deferred. UnlikeCommands::insert_resource— which is queued and only takes effect once the whole stage finishes and its commands flush —sendmutates the underlyingEvents<T>immediately. That means B doesn’t need to be pushed to a later stage than A to see the same-tick result; being registered after A within the same stage is already enough, since a stage’s systems run one after another against the same live resources.- Events expire; resources don’t. A sent event is visible for the rest of the tick it was sent, plus the following tick, then it’s aged out for good — see Events. That’s a non-issue for a system like
use_resultabove, which is unconditionally scheduled and callsreader.iter()every single tick regardless of whether anything’s there (pebble has no conditional system scheduling — a registered system always runs, so it can never “miss its turn” and fail to drain the queue). It only becomes a real risk if you ever gate the consuming logic on something that could skip calling.iter()for a tick or more — in that case, reach for the resource-based shape in Walkthrough 2 instead, since a resource simply waits for you rather than expiring.
A note on threads
Promise<T> carries an unsafe impl Sync, which is worth understanding rather than just trusting. The underlying oneshot::Receiver<T> is Send but not Sync — it uses a raw pointer internally, and the crate only guarantees safety for a single consumer touching it, not concurrent access through a shared reference. But Local<T>/resource storage both require their contents to be Send + Sync, regardless of whether anything is actually reading it concurrently.
This is safe in pebble specifically because the scheduler runs one system at a time, on a single thread, never concurrently — a Promise stored in a Local or resource is genuinely never touched by two systems at once, even though the type system can’t see that guarantee on its own. This unsafe impl is pebble asserting a fact about its own scheduler, not a general claim that Promise<T> is safe to share across real threads.
Choosing where to put it: quick reference
| Situation | Storage |
|---|---|
| One system starts it and polls it | Local<Option<Promise<T>>> |
| Several systems need to observe the same pending operation, for as long as it takes | A field on your own resource, inserted/removed via Commands |
| One or more other, unconditionally-scheduled systems just need to react once when it resolves | Keep the Promise in a Local (Walkthrough 1), and send an Event once it’s Ready (Walkthrough 3) |
| The pending operation belongs to one entity | A field on a component |
Time
TimePlugin inserts a Time resource and ticks it every frame on SystemStage::PreUpdate:
app.add_plugin(TimePlugin)
fn move_player(time: Read<Time>, mut pos: Write<Position>) {
pos.0 += velocity * time.delta_seconds();
}
delta()/delta_seconds()— time since the previous tick.elapsed()/elapsed_seconds()— total time since the app started.fps()—1.0 / delta_seconds(), or0.0on the very first tick.
GraphicsPlugin does not add TimePlugin for you — add it explicitly if you need Time.
The Asset Pipeline and Handles
Every GPU resource in pebble — meshes, textures, materials, computes — follows the same unified CPU→GPU asset model. Understanding this once means every other rendering page in this book looks the same.
The pattern
- Build a CPU-side description (
Mesh::new(...),Texture::from_file(...),Material::new(...), …). - Call
.build_asset(name, &mut assets)to insert it into that type’sAssets<T>and get back aHandle<T>. - Some time later — usually the very next
AssetSyncstage — the asset finishes uploading to the GPU. Until then, lookups on the handle returnNone.
fn setup(backend: Read<Backend>, mut meshes: Write<Assets<Mesh>>) {
let handle: Handle<Mesh> = Mesh::new(vertices, indices).build_asset("player", &mut meshes);
// stash `handle` in a component or resource
}
fn use_it(meshes: Read<Assets<Mesh>>, handle: Read<Handle<Mesh>>) {
if let Some(gpu_mesh) = meshes.get(*handle) {
// ready to draw
}
}
Handle<T> is Copy, Eq, Hash — cheap to store in a component or use as a map key. A stale handle (its entry removed) just yields None, never panics.
Assets<T>
insert(name, source)— as above, via each type’sbuild_asset.get(handle)— the GPU-side processed value, once ready.get_source(handle)/get_source_mut(handle)— the CPU-side source (e.g. a mesh’s raw vertices, for building a collision shape from the same data).is_ready(handle)— whether the upload has finished.mark_dirty(handle)— re-queues an entry for re-upload, e.g. after mutating it viaget_source_mut.get_by_name/get_source_by_name/get_handle_by_name— same lookups, keyed by the name string instead of a handle.iter()— every currently-loaded asset of this type, source data included, ready or not.
Why uploads retry instead of failing
Asset<B>::upload returns Option<Self::Processed> — None just means “try again next tick,” not an error. This is what lets you build a mesh before the GPU backend exists, or a material that depends on a shared bind group layout that hasn’t been registered yet, without hand-rolled ordering: the asset system keeps retrying every AssetSync tick until every dependency (declared via Asset::Deps) is available, then uploads once and stops.
Defining your own asset type
AssetSource/Asset<B> are the two traits every built-in type implements. The asset! macro is a pure syntax transform that writes both at once:
asset!(MyThing => GPUMyThing, |self, backend: &Backend| {
Some(GPUMyThing { /* ... */ })
});
// or, with dependencies on other resources:
asset!(MyThing => GPUMyThing, deps: [SomePool], |self, backend: &Backend, deps| {
Some(GPUMyThing { /* ... */ })
});
Then register it the same way every built-in type is registered:
app.add_plugin(AssetPlugin::<Backend, MyThing>::new())
Windowing and Input
WindowPlugin opens an OS window via winit and inserts two resources: Window and Input. GraphicsPlugin adds it for you; add it directly only if you’re assembling your own windowing/backend setup:
app.add_plugin(WindowPlugin::new(WindowConfig {
title: "My Game".into(),
width: 1280,
height: 720,
}))
WindowConfig::default() gives you "Pebble" at 1280x720.
WindowPlugin also installs the app’s runner (via App::set_runner) — it hands control to winit’s own event loop instead of the default headless polling loop, and works unmodified on both native and wasm32-unknown-unknown.
Input
Input gives keyboard/mouse state for the current tick, backed by winit_input_helper:
fn handle_input(input: Read<Input>) {
if input.key_pressed(KeyCode::Space) {
// this tick only
}
if input.key_held(KeyCode::KeyW) {
// every tick it's down
}
let (dx, dy) = input.mouse_diff();
if input.close_requested() {
// decide yourself whether/how to exit
}
}
key_pressed/mouse_pressedare edge-triggered;key_held/mouse_heldare level-triggered.cursor()— position in window coordinates,Noneif outside the window.cursor_diff()— cursor movement since last tick, clamped to the window.mouse_diff()— raw mouse motion since last tick, not clamped — useful for a look/orbit camera.scroll_diff(),resolution(),close_requested().
KeyCode/MouseButton mirror winit’s own types — no winit type appears in the public API.
Window Control
Window, inserted by WindowPlugin, gives runtime control over the OS window — no raw winit type appears in its public API:
fn adjust_window(window: Read<Window>) {
window.set_title("Paused");
window.set_inner_size(1920, 1080);
window.set_fullscreen(true);
window.set_cursor_visible(false);
window.set_cursor_grab(CursorGrabMode::Locked);
}
Other methods: inner_size(), set_resizable, set_visible, set_minimized, set_maximized, set_decorations, focus, is_fullscreen, set_cursor_icon, request_redraw.
set_fullscreen(true) uses borderless fullscreen. set_cursor_grab takes a CursorGrabMode (None/Confined/Locked) and returns whether the platform honored it.
Buffers
Buffer is an opaque GPU buffer — no raw wgpu type appears in its public API. Build one with BufferBuilder:
let vertex_buffer = BufferBuilder::with_data(bytemuck::cast_slice(&vertices))
.with_label("Vertex Buffer")
.with_usage(BufferUsages::VERTEX)
.build(&backend);
let empty = BufferBuilder::empty(1024)
.with_usage(BufferUsages::STORAGE | BufferUsages::COPY_DST)
.build(&backend);
.with_uniform()/.with_storage() are shortcuts for the common UNIFORM | COPY_DST and STORAGE | COPY_DST usage combos.
write(data)— overwrites the buffer’s contents from the start.write_at(offset, data)— overwrites starting at a byte offset.size()— the buffer’s size in bytes.
Reading a buffer back
read() copies the buffer’s contents back to the CPU, returning a Promise<Vec<u8>> — poll it each tick until it resolves. Requires BufferUsages::COPY_SRC:
fn kick_off(buffer: Read<SomeBuffer>, mut promise: Local<Option<Promise<Vec<u8>>>>) {
*promise = Some(buffer.0.read());
}
fn check(mut promise: Local<Option<Promise<Vec<u8>>>>) {
if let Some(p) = promise.as_ref() {
if let PromiseState::Ready(bytes) = p.poll() {
// ...
*promise = None;
}
}
}
Dynamic buffers
DynamicBuffer holds many fixed-size elements, each individually writable and bindable at an aligned offset — for per-object uniform data, for example. Build one with DynamicBufferBuilder; the per-element stride is rounded up to the device’s required offset alignment for you:
let per_object = DynamicBufferBuilder::uniform(std::mem::size_of::<ObjectData>() as u64, 100)
.build(&backend);
per_object.write_element(object_index, bytemuck::bytes_of(&data));
Bind a specific element with BindGroupBuilder::with_dynamic_buffer, then pass its byte offset (index * stride()) in the offsets slice when you set_bind_group during a render/compute pass — see Bind Groups and Layouts and Recording a Render Pass.
Material/Compute also take an existing Buffer/DynamicBuffer directly, via .with_buffer(name, buffer)/.with_dynamic_buffer(name, buffer) — see Materials.
Bind Groups and Layouts
A Material/Compute declares its own bind group (group 0) and supplies its values in the same builder chain — see Materials/Compute Pipelines for the everyday API (.texture(...)/.uniform_value(...)/etc., or .with_entry(...) + a value-only call for anything those don’t produce). This page covers the mechanics underneath: what an entry actually is, and how to share or supply a layout beyond group 0.
Declaring an entry by hand
.with_entry(name, kind)/.with_entry_at(name, binding, kind) push straight into the same accumulator the streamlined calls do — binding indices auto-increment unless you pin one explicitly with with_entry_at:
Material::new(SHADER_SOURCE)
.with_entry("albedo", BindingKind::texture_2d(ShaderStages::FRAGMENT))
.with_texture("albedo", albedo_handle)
.with_entry_at("camera", 0, BindingKind::uniform_buffer(ShaderStages::VERTEX))
.with_uniform_value("camera", &camera_data)
BindingKind constructors cover the common cases: 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.
Sharing a layout across pipelines
GlobalLayoutPool, inserted as a resource by BuiltinAssetsPlugin, lets unrelated materials/computes share one bind group layout instead of each declaring their own — e.g. a camera uniform every material binds the same way. .with_extra_group(...) appends it as group 1 (and up, one call per group, in call order) — group 0 is always the material/compute’s own entries:
fn register_camera_layout(backend: Read<Backend>, mut pool: Write<GlobalLayoutPool>) {
let layout = BindGroupLayoutBuilder::new()
.with_entry("camera", 0, BindingKind::uniform_buffer(ShaderStages::VERTEX))
.build(&backend);
pool.register("camera", layout);
}
Material::new(SHADER_SOURCE)
.texture("albedo", albedo_handle)
.with_extra_group(GroupEntry::Global("camera"))
A pipeline can also take a pre-built BindGroupLayout directly via GroupEntry::Layout(layout) — for a standalone layout that was never registered under a name. Note this opts a Material/Compute out of pipeline sharing: an inline layout has no name to structurally compare against another one, so it always compiles its own pipeline.
Building the actual bind group
Once a Material/Compute uploads, it produces a BindGroup internally — you don’t usually build one by hand. If you are (e.g. for Custom GPU Resources), BindGroupBuilder matches values to slots in binding order, or explicitly via the _at(binding, ...) variant:
let bind_group = BindGroupBuilder::new(&layout)
.with_texture_2d(&gpu_texture)
.with_sampler(&sampler)
.with_buffer(&camera_buffer)
.build(&backend);
Also available: with_texture_array, with_texture_cubemap, with_texture_view, with_dynamic_buffer.
Reach for BindGroupBuilder directly only when you’re not going through the asset pipeline at all — see Custom GPU Resources. Material/Compute themselves resolve their own named values (.with_texture(...), .with_uniform_value(...), etc.) against their own declared entries the same way, by name, at upload time — that resolution isn’t part of the public API, since there’s no longer a generic pipeline type it needs to work against.
Materials
A Material is a render pipeline asset and its own bind group values in one — WGSL shader source, the fixed-function state (vertex layouts, cull mode, depth, targets) needed to compile it, and the textures/samplers/uniforms it renders with. It follows the same asset pipeline pattern as everything else, and produces one Handle<Material>:
fn setup(backend: Read<Backend>, mut materials: Write<Assets<Material>>, mut textures: Write<Assets<Texture>>) {
let albedo = Texture::from_file("albedo.png").build_asset("albedo", &mut textures);
let handle = Material::new(SHADER_SOURCE)
.with_label("unlit")
.with_vertex_layouts(vec![Vertex::layout()])
.texture("albedo", albedo)
.sampler("albedo_sampler", SamplerKind::LinearRepeat)
.with_targets(vec![ColorTargetState {
format: backend.surface_format(),
blend: Some(BlendState::ALPHA_BLENDING),
write_mask: ColorWrites::ALL,
}])
.build_asset("unlit", &mut materials);
}
Defaults: vs_main/fs_main entry points, back-face culling, fill mode, no depth testing, sample count 1. Override with with_vertex_entry/without_vertex_entry, with_cull_mode/without_cull_mode, with_depth/without_depth, with_polygon_mode, with_sample_count.
ColorTargetState::DEFAULT_TARGET is a ready-made single opaque Rgba8Unorm target, if you don’t need custom blending. DepthStencilState::DEFAULT is likewise a ready-made Depth32Float/Less/depth-write-enabled state, for the common opaque-3D case. Both section types (ColorTargetState, BlendState/BlendComponent, DepthStencilState) also implement plain Default (matching these same values), for ..Default::default() struct-update syntax or generic code — e.g. ColorTargetState { format: my_format, ..Default::default() } to override just the format.
Material::standard: presets for the common case
If most of your materials are ordinary opaque 3D geometry using the built-in Vertex type, Material::standard(shader_source) saves retyping the same three calls every time — it’s Material::new(shader_source) pre-chained with .with_vertex_layouts(vec![Vertex::layout()]), a single opaque target in the actual surface format, and .with_depth(DepthStencilState::DEFAULT):
let handle = Material::standard(SHADER_SOURCE)
.with_label("unlit")
.texture("albedo", albedo)
.build_asset("unlit", &mut materials);
The surface format isn’t a hardcoded guess (the real one varies by platform/backend — Bgra8Unorm is common on Windows/DX12, not the Rgba8Unorm DEFAULT_TARGET assumes), and it isn’t looked up when you call standard() either — standard() takes no Backend reference, same as new. It’s resolved against the real Backend at upload time instead, the same way a Texture’s MipLevels gets resolved against the texture’s actual size only once that’s known, rather than at construction.
It’s still a plain builder underneath — chain .with_vertex_layouts(...)/.with_targets(...)/.with_depth(...)/.without_depth() afterwards to override any one of the three for a material that doesn’t fit the common case (a custom vertex type, a blended target, no depth test). For anything more different than that, start from Material::new instead.
Bind group values: streamlined vs. manual
.texture(name, handle) above does two things in one call: it declares a fragment-visible texture_2d<f32> entry at the next auto-assigned binding index, and binds handle to it. The full streamlined set: .texture/.texture_array/.cubemap (a Handle, resolved at upload time), .sampler, .uniform/.storage (raw bytes), .uniform_value/.storage_value (a typed value — see below).
That covers the common case — one bind group (group 0), everything fragment-visible, binding indices in call order. Drop to the manual, two-step form when you need something it doesn’t produce:
Material::standard(SHADER_SOURCE)
// vertex-visible, and pinned to binding 0 explicitly
.with_entry_at("camera", 0, BindingKind::uniform_buffer(ShaderStages::VERTEX))
.with_uniform_value("camera", &camera_data)
// same idea for anything else with a non-default sample type, dynamic
// offset, etc. — see Bind Groups and Layouts
.build_asset("unlit", &mut materials)
.with_entry/.with_entry_at declare the entry; the value-only counterparts (.with_texture, .with_sampler, .with_uniform/.with_storage, .with_uniform_value/.with_storage_value, .with_buffer/.with_dynamic_buffer) bind the value against whatever was declared — see Bind Groups and Layouts for the full picture of how entries and values match up by name.
Names must match between an entry and its value — a mismatch fails to build silently (the upload retries forever, since the binding lookup returns None). The uploaded GPUMaterial gives you .update(name, data) (or .update_value(name, &value) for a typed value) to overwrite a bound uniform/storage buffer in place, without rebuilding the whole bind group — handy for a per-frame value like a camera matrix.
Every value type, and how to add it
Every kind of value a Material’s bind group can hold, and every way to add one — streamlined one-call, manual .with_entry/.with_entry_at + a value-only call, and (where one exists) the #[derive(MaterialParams)] field attribute:
| Value | WGSL type | Streamlined | Manual: declare + bind | Derive attribute |
|---|---|---|---|---|
| Texture | texture_2d<f32> | .texture(name, handle) | BindingKind::texture_2d(vis) + .with_texture(name, handle) | #[texture(N)] |
| Texture array | texture_2d_array<f32> | .texture_array(name, handle) | BindingKind::texture_2d_array(vis) + .with_texture_array(name, handle) | #[texture_array(N)] |
| Cubemap | texture_cube<f32> | .cubemap(name, handle) | BindingKind::texture_cubemap(vis) + .with_cubemap(name, handle) | #[cubemap(N)] |
| Pre-built view (one mip, a standalone render target) | texture_2d<f32> | — | BindingKind::texture_2d(vis) + .with_texture_view(name, view) | — |
| Storage texture | texture_storage_2d<format, access> | — | BindingKind::storage_texture(vis, format, access, dim) + .with_texture_view(name, view) (same resource kind as a plain view — the layout entry is what makes it a storage texture) | — |
| Sampler | sampler | .sampler(name, kind) | BindingKind::sampler(vis) + .with_sampler(name, kind) | #[sampler(N)] |
Comparison sampler (shadow maps: SamplerKind::CompareLess) | sampler_comparison | — | BindingKind::comparison_sampler(vis) + .with_sampler(name, SamplerKind::CompareLess) — .sampler(...) always declares a plain (non-comparison) sampler, so this one needs the manual form | — |
| Uniform, raw bytes | uniform<...> | .uniform(name, bytes) | BindingKind::uniform_buffer(vis) + .with_uniform(name, bytes) | — |
Uniform, typed (encase) | uniform<...> | .uniform_value(name, &val) | BindingKind::uniform_buffer(vis) + .with_uniform_value(name, &val) | #[uniform(N)] |
| Storage, raw bytes | storage<...> | .storage(name, bytes) (read-only) | BindingKind::storage_buffer_read_only/_read_write(vis) + .with_storage(name, bytes) | — |
Storage, typed (encase) | storage<...> | .storage_value(name, &val) (read-only) | same BindingKinds + .with_storage_value(name, &val) | #[storage(N)] |
An existing Buffer you already built (e.g. a compute pass’s output) | uniform<...>/storage<...> | — | matching BindingKind::uniform_buffer/storage_buffer_*(vis) + .with_buffer(name, buffer) — no buffer is created, so buffer must already carry the matching BufferUsages::UNIFORM/::STORAGE | — |
An existing DynamicBuffer | uniform<...>/storage<...> (dynamic offset) | — | BindingKind::dynamic_uniform_buffer/dynamic_storage_buffer(vis, elem_size) + .with_dynamic_buffer(name, buffer) | — |
| A whole extra bind group (group 1+), shared or standalone | — | — | .with_extra_group(GroupEntry::Global("name")) / GroupEntry::Layout(layout) — see Bind Groups and Layouts | #[layout("name")] / #[layout(param)] |
The rows with no derive attribute (comparison samplers, pre-built views/storage textures, an existing Buffer/DynamicBuffer) aren’t a limitation on combining with #[derive(MaterialParams)] — .into_material(...) hands back an ordinary Material, so chain the manual .with_entry(...) + value call onto the result exactly as you would without the derive.
Many uniform combinations, one shader
Compiling a wgpu::RenderPipeline is the expensive part of building a Material — building its bind group is cheap. So several Materials that share the same shader and fixed-function state (vertex layout, cull/depth/targets/polygon mode/sample count, and bind group shape) automatically compile just once and share the result — MaterialPipelineCache, a resource BuiltinAssetsPlugin inserts, handles this for you. In practice: “the same shader, several different uniform values” is just several ordinary Materials, not a separate instance concept to manage:
let red = Material::standard(ENEMY_SHADER)
.texture("sprite", sheet)
.uniform_value("tint", &Tint { color: [1.0, 0.2, 0.2, 1.0] })
.build_asset("enemy_red", &mut materials);
let green = Material::standard(ENEMY_SHADER) // same shader + shape as `red` → shares its compiled pipeline
.texture("sprite", sheet)
.uniform_value("tint", &Tint { color: [0.2, 1.0, 0.2, 1.0] })
.build_asset("enemy_green", &mut materials);
One caveat: a Material using GroupEntry::Layout(...) (an inline pre-built layout, rather than the streamlined calls or GroupEntry::Global) opts out of this cache — it always compiles its own pipeline, same as every Material did before the cache existed.
Typed uniforms with encase
.uniform_value/.storage_value (and GPUMaterial::update_value) take any type implementing encase::ShaderType — usually a #[derive(encase::ShaderType)] struct — instead of a hand-packed Vec<u8>, and lay it out with the correct WGSL alignment for you:
#[derive(encase::ShaderType)]
struct Tint {
color: [f32; 4],
}
Material::standard(SHADER_SOURCE).uniform_value("tint", &Tint { color: [1.0, 0.2, 0.2, 1.0] })
glam’s vector/matrix types (Vec2/Vec3/Vec4/Mat2/Mat3/Mat4) already implement ShaderType (via glam’s own encase feature, which pebble enables) — use them directly in a uniform struct’s fields.
Building the params struct with #[derive(MaterialParams)]
For the common case, you don’t have to write the .texture(...)/.uniform_value(...) chain by hand at all — see Material/Compute Params for a struct-derived version of everything on this page.
Material/Compute Params (derive)
#[derive(MaterialParams)]/#[derive(ComputeParams)] (from pebble-derive, re-exported from Material/Compute’s own modules) turn a plain struct’s fields into the .texture(...)/.uniform_value(...)/etc. chain from Materials/Compute Pipelines — so the struct’s shape is the bind group, instead of a chain you write and keep in sync with it by hand:
use pebble::graphics::pipeline::material::{Material, MaterialParams};
#[derive(MaterialParams)]
struct EnemyMaterialParams {
#[uniform(0)]
tint: Vec4,
#[uniform(0)]
emissive: f32, // same index as `tint` → packed into one generated buffer together
#[texture(1)]
albedo: Handle<Texture>,
#[sampler(2)]
sampler: SamplerKind,
}
let mat = EnemyMaterialParams { tint: RED, emissive: 2.0, albedo, sampler: SamplerKind::LinearRepeat }
.into_material(Material::standard(ENEMY_SHADER))
.build_asset("enemy_red", &mut materials);
The derive generates one method, into_material(self, base: Material, ...) -> Material (into_compute/Compute for #[derive(ComputeParams)]) — it doesn’t replace Material/Compute, it just writes the builder chain for you and hands back the same Material you’d have built by hand, ready for .build_asset(...).
Full example: the struct and its shader, side by side
The struct above declares three bindings in group 0: tint+emissive packed into one uniform buffer at binding 0 (same index, so one WGSL var<uniform>), albedo at binding 1, sampler at binding 2. The WGSL has to declare the same shape — the derive doesn’t generate your shader, only the Rust-side wiring:
// enemy.wgsl
struct Params {
tint: vec4<f32>,
emissive: f32,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var albedo: texture_2d<f32>;
@group(0) @binding(2) var albedo_sampler: sampler;
// matches Vertex::layout() — position/tex_coords/normal/tangent,
// what Material::standard() wires up for you
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) tex_coords: vec2<f32>,
@location(2) normal: vec3<f32>,
@location(3) tangent: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) tex_coords: vec2<f32>,
}
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4<f32>(in.position, 1.0);
out.tex_coords = in.tex_coords;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let base = textureSample(albedo, albedo_sampler, in.tex_coords);
return base * params.tint + vec4<f32>(vec3<f32>(params.emissive), 0.0);
}
use pebble::graphics::pipeline::{material::{Material, MaterialParams}, samplers::SamplerKind, textures::Texture};
const ENEMY_SHADER: &str = include_str!("enemy.wgsl");
#[derive(MaterialParams)]
struct EnemyMaterialParams {
#[uniform(0)]
tint: Vec4,
#[uniform(0)]
emissive: f32,
#[texture(1)]
albedo: Handle<Texture>,
#[sampler(2)]
sampler: SamplerKind,
}
fn setup(mut materials: Write<Assets<Material>>, mut textures: Write<Assets<Texture>>) {
let albedo = Texture::from_file("enemy.png").build_asset("enemy_albedo", &mut textures);
let red = EnemyMaterialParams {
tint: Vec4::new(1.0, 0.3, 0.3, 1.0),
emissive: 0.0,
albedo,
sampler: SamplerKind::LinearRepeat,
}
.into_material(Material::standard(ENEMY_SHADER)) // Vertex::layout() + Depth32Float + real surface format
.build_asset("enemy_red", &mut materials);
}
Material::standard(ENEMY_SHADER) is what supplies the vertex layout the shader’s VertexInput assumes (see Materials) — the derive only ever touches the bind-group side (group 0 here), never the vertex/fixed-function state, so .standard(...)/.new(...) and .with_vertex_layouts(...)/.with_depth(...)/etc. still work exactly as described there.
Field attributes
#[uniform(N)], #[storage(N)], #[texture(N)], #[texture_array(N)], #[cubemap(N)], #[sampler(N)] — every field needs exactly one. N is the real WGSL @binding(N) index, same as .with_entry_at’s.
Grouping. Several #[uniform(N)]/#[storage(N)] fields sharing the same N pack into one generated buffer together (named after the first field in the group) — matching how a WGSL uniform/storage block is one binding no matter how many members it has. Every other kind needs a binding to itself; two fields sharing a #[texture(N)]/etc. index is a compile error.
Type checking. #[texture(N)]/#[texture_array(N)]/#[cubemap(N)]/#[sampler(N)] fields are checked against the shape they’re expected to be, on a best-effort basis: a field type that’s recognizably wrong (#[texture(1)] foo: Handle<Cubemap>) is a clear compile error pointing at the field; anything not confidently recognized (a type alias, an unusual path) is silently left to rustc’s own type error at the generated call site, same as if the check didn’t exist. #[uniform]/#[storage] fields can be any type implementing encase::ShaderType (see Materials) — there’s no fixed shape to check there.
Combining with manual bindings
Not every value type has an attribute — a comparison sampler, a pre-built texture view/storage texture, or an existing Buffer/DynamicBuffer you bind directly have no #[...] form (see the full value type table). For those, add them by hand alongside the derived ones: .into_material(...)/.into_compute(...) return an ordinary Material/Compute, so .with_entry(...) + a value call just chain onto the result, same as if the derive weren’t there at all:
#[derive(MaterialParams)]
struct EnemyMaterialParams {
#[texture(0)]
albedo: Handle<Texture>,
#[sampler(1)]
albedo_sampler: SamplerKind,
}
let mat = EnemyMaterialParams { albedo, albedo_sampler: SamplerKind::LinearRepeat }
.into_material(Material::standard(SHADER)) // claims bindings 0 and 1
// manual entries appended after — auto-assigned indices pick up at 2
.with_entry("shadow_map", BindingKind::texture_2d(ShaderStages::FRAGMENT))
.with_texture_view("shadow_map", shadow_view)
.with_entry("shadow_sampler", BindingKind::comparison_sampler(ShaderStages::FRAGMENT))
.with_sampler("shadow_sampler", SamplerKind::CompareLess)
.build_asset("enemy", &mut materials);
Order matters for auto-assigned indices. #[uniform(N)]/etc. fields always claim their literal N — same as .with_entry_at — regardless of where .into_material(...) sits in the chain. But a manual .with_entry(name, kind) (or a streamlined call like .texture(...)) auto-assigns the next free index, tracked on the same Material/Compute you’re building. Chain manual auto-indexed entries after .into_material(...)/.into_compute(...) so they pick up after the struct’s own indices; chaining them onto base before passing it in risks colliding with a low N the struct declares (a fresh Material::standard(...) starts auto-assignment at 0, same as a struct’s first #[texture(0)]). If you do want manual entries declared first, pin them with .with_entry_at(name, N, kind) at an index above the struct’s highest one instead of relying on auto-assignment.
Collisions aren’t silent. Two entries (derived or manual) landing on the same binding index panics with a clear message (binding N assigned more than once building bind group layout...) the first time the material/compute actually builds its pipeline — not at derive-macro compile time, since the derive has no visibility into what a caller chains on afterward. Names need to stay unique the same way — a derived field and a manual entry sharing a name silently resolve to whichever one was declared first (see Materials on how names and values match up), rather than erroring.
Visibility
Defaults to FRAGMENT for MaterialParams, always exactly COMPUTE for ComputeParams (a compute bind group entry can’t be anything else — #[derive(ComputeParams)] rejects any override attempt with a compile error). Override per field on a MaterialParams struct with a second attribute argument:
#[derive(MaterialParams)]
struct SkinnedMaterialParams {
#[uniform(0, vertex)]
joint_matrices: JointMatrices,
#[texture(1, vertex_fragment)]
displacement_map: Handle<Texture>,
#[texture(2)] // no override — stays FRAGMENT
albedo: Handle<Texture>,
}
vertex, fragment, or vertex_fragment. Every field sharing a grouped #[uniform(N)]/#[storage(N)] index must agree on the same visibility (explicit or all-default) — a compile error otherwise.
Optional textures
A #[texture(N)]/#[texture_array(N)]/#[cubemap(N)] field typed Option<Handle<T>> instead of Handle<T> binds a fallback texture when the value is None — the WGSL binding always exists regardless of whether a given instance has a value, so into_material/into_compute gains one extra {field}_fallback: Handle<T> parameter per optional field:
#[derive(MaterialParams)]
struct EnemyMaterialParams {
#[texture(0)]
albedo: Option<Handle<Texture>>,
}
let mat = EnemyMaterialParams { albedo: enemy.custom_skin } // Option<Handle<Texture>>
.into_material(Material::standard(SHADER), default_skin_texture) // used only if albedo is None
.build_asset("enemy", &mut materials);
#[layout(...)]: a bind group beyond your own
Repeatable struct attribute, appends group 1 and up (beyond the struct’s own group 0), in the order written:
#[layout("name")]— alwaysGroupEntry::Global("name")(see Bind Groups and Layouts), no extra parameter.#[layout(param)]— the caller supplies theGroupEntryat the call site instead — any variant, includingGroupEntry::Layout(...)for a standalone layout that was never registered inGlobalLayoutPool.into_material/into_computegains oneGroupEntry-typed parameter perparamoccurrence, namedextra_group_0,extra_group_1, … in declaration order among theparamoccurrences specifically (fixed#[layout("name")]entries don’t consume a slot).
#[derive(MaterialParams)]
#[layout("day_night")] // always the shared "day_night" layout
struct TerrainMaterialParams {
#[texture(0)]
albedo: Handle<Texture>,
}
#[derive(MaterialParams)]
#[layout(param)] // caller decides — could be Global or a one-off Layout
struct EnemyMaterialParams {
#[texture(0)]
albedo: Handle<Texture>,
}
let terrain = TerrainMaterialParams { albedo }.into_material(Material::standard(SHADER));
let enemy = EnemyMaterialParams { albedo }.into_material(Material::standard(SHADER), GroupEntry::Global("lighting"));
Parameter order
On the generated method: base, then one {field}_fallback per optional-texture-kind field (ascending binding index), then one extra_group_N per #[layout(param)] (declaration order).
ComputeParams
Identical shape, targeting Compute’s streamlined methods instead — .into_compute(self, base: Compute, ...) -> Compute, visibility always COMPUTE, #[storage(N)] defaults to read-write (a compute pass binding a storage buffer usually means to write it — use .with_entry/.with_storage by hand for a read-only one):
use pebble::graphics::pipeline::compute::{Compute, ComputeParams};
#[derive(ComputeParams)]
struct BlurParams {
#[uniform(0)]
radius: f32,
#[texture(1)]
src: Handle<Texture>,
}
Note that, unlike .storage(name, Vec<u8>) on Compute itself, a #[storage(N)]/#[uniform(N)] field always goes through the typed encase path (same as .storage_value/.uniform_value) — its type needs to implement encase::ShaderType, not be raw bytes.
Meshes and Vertices
Mesh<V> is a vertex + index buffer asset, generic over vertex type — V defaults to the built-in Vertex (position, UV, normal, tangent), but any bytemuck::Pod struct works:
fn setup(backend: Read<Backend>, mut meshes: Write<Assets<Mesh>>) {
let vertices = vec![/* Vertex { .. } */];
let indices = vec![0, 1, 2, /* ... */];
let handle = Mesh::new(vertices, indices).build_asset("cube", &mut meshes);
}
The uploaded GPUMesh has public vertex_buffer/index_buffer (both plain Buffer) and index_count, ready to pass to set_vertex_buffer/set_index_buffer/draw_indexed — see Recording a Render Pass.
CPU-side access
vertices()/indices() return the CPU-side source data — e.g. for building a collision mesh from the same data used to render it. release_cpu_data() frees that copy once you’re done reading it, but unlike other asset types, a released mesh can never be re-uploaded — if the GPU backend is ever lost and recreated afterward, this mesh logs an error and simply stays not-ready forever. Only call it if that’s acceptable for this particular mesh.
Custom vertex types
#[repr(C)]
#[derive(Copy, Clone, Default, bytemuck::Pod, bytemuck::Zeroable)]
struct MyVertex {
position: glam::Vec3,
color: glam::Vec4,
}
impl MyVertex {
fn layout() -> VertexBufferLayout {
VertexBufferLayout {
array_stride: std::mem::size_of::<MyVertex>() as u64,
step_mode: VertexStepMode::Vertex,
attributes: vec![
VertexAttribute { format: VertexFormat::Float32x3, offset: 0, shader_location: 0 },
VertexAttribute { format: VertexFormat::Float32x4, offset: 12, shader_location: 1 },
],
}
}
}
Pass MyVertex::layout() to Material::with_vertex_layouts (see Materials) so the pipeline’s vertex stage matches.
Instancing
InstanceVertex carries just a model matrix, for instanced draws — bind it alongside a regular vertex buffer at VertexStepMode::Instance, then pass an instance range to draw/draw_indexed instead of 0..1.
Textures
Three related asset types share the same construction pattern: Texture (2D), TextureArray (2D array, one file/buffer per layer), and Cubemap (six equal-size faces, wgpu’s +X, -X, +Y, -Y, +Z, -Z order). This page covers Texture; the other two differ only in shape.
fn setup(backend: Read<Backend>, mut textures: Write<Assets<Texture>>) {
let from_disk = Texture::from_file("assets/albedo.png")
.with_mips()
.build_asset("albedo", &mut textures);
let from_pixels = Texture::from_data(256, 256, TextureFormat::Rgba8Unorm, pixel_bytes)
.build_asset("noise", &mut textures);
let render_target = Texture::empty(1920, 1080, TextureFormat::Rgba16Float)
.build_asset("bloom_buffer", &mut textures);
}
from_file(path)— decoded from disk on upload. Supports the regular 8/16/32-bit unorm and float formats, not block-compressed or multi-planar ones.from_data(width, height, format, bytes)— from an in-memory buffer you already have.empty(width, height, format)— no source data at all: a render target (post-processing, shadow maps), or something you’llwrite()yourself..with_mips()— generates a full GPU-side mip chain..with_mip_count(n)generates exactlynlevels instead (e.g. for a PBR prefilter pass).
CPU-side access
data() returns the CPU-side pixels — only ever Some for a from_data() texture, since from_file() re-decodes from disk on each upload rather than keeping a copy. release_cpu_data() frees that copy; unlike Mesh, a released texture can still be re-uploaded later, it just comes back empty instead of with its original contents.
Writing into a texture at runtime
The uploaded GPUTexture has write(mip_level, pixels) to overwrite one mip level — for a texture you’re streaming or rendering into from the CPU side — and get_view(mip_level) for binding a specific level (e.g. as a render target during mip generation).
Texture arrays and cubemaps
TextureArray::from_files(vec!["a.png", "b.png", "c.png"]).build_asset("atlas", &mut arrays);
Cubemap::from_files(1024, [px, nx, py, ny, pz, nz]).build_asset("sky", &mut cubemaps);
GPUTextureArray::write_layer/GPUCubemap::write_face mirror GPUTexture::write, with an extra layer/face index. get_view similarly takes a layer or face alongside the mip level.
Samplers
Pebble doesn’t build a sampler per texture — instead, BuiltinAssetsPlugin (part of GraphicsPlugin) builds a fixed set of ready-made samplers once at startup, inserted as GlobalSamplers:
fn bind(samplers: Read<GlobalSamplers>) -> &Sampler {
samplers.get(SamplerKind::LinearClamp)
}
SamplerKind variants:
| Variant | Filter | Address mode |
|---|---|---|
LinearRepeat | linear, mipped | repeat |
LinearClamp | linear, mipped | clamp to edge |
LinearClampNoMip | linear, no mip sampling | clamp to edge |
Nearest | nearest | repeat |
NearestClampBorder | nearest | clamp to border (falls back to clamp-to-edge on wasm, where border color isn’t supported) |
LinearClampBorder | linear, mipped | clamp to border (falls back to clamp-to-edge on wasm, where border color isn’t supported) |
CompareLess | linear | clamp to edge, with a Less depth comparison — for shadow map PCF |
Pass a SamplerKind to Material/Compute’s .sampler(name, kind) (streamlined) or .with_sampler(name, kind) (value-only, see Materials) or BindGroupBuilder::with_sampler directly.
NearestClampBorder/LinearClampBorder and device features
On native, NearestClampBorder and LinearClampBorder need the ADDRESS_MODE_CLAMP_TO_BORDER device feature — GraphicsPlugin::new() doesn’t request it by default. If it wasn’t requested (and granted), both fall back to plain ClampToEdge addressing (with a tracing::warn! at startup) instead of failing GPU validation. Request the feature explicitly if you need an actual border color:
App::new()
.add_plugin(GraphicsPlugin::with_features(DeviceFeatures::ADDRESS_MODE_CLAMP_TO_BORDER))
.run();
wasm builds don’t need this — both fall back to clamp-to-edge there regardless of requested features, since WebGPU has no border-color support.
Either way, the border color itself is fixed to opaque white (wgpu::SamplerBorderColor::OpaqueWhite) — wgpu doesn’t expose an arbitrary custom border color like desktop GL’s glTexParameterfv(GL_TEXTURE_BORDER_COLOR, ...).
Recording a Render Pass
BackendPlugin (part of GraphicsPlugin) acquires the swapchain frame on SystemStage::PreRender and submits/presents it on PostRender. Your own drawing goes on SystemStage::Render, in between.
fn draw(mut frame: Write<CurrentFrame>) {
let Some(mut active) = frame.active() else { return }; // no frame this tick — skip
let pass = PassBuilder::new()
.with_target(ColorTargetBuilder::new().with_clear([0.1, 0.1, 0.1, 1.0]).build())
.build();
let mut render_pass = active.begin_pass(pass);
render_pass.set_pipeline(&material.pipeline);
render_pass.set_bind_group(0, &instance.bind_group, &[]);
render_pass.set_vertex_buffer(0, &mesh.vertex_buffer);
render_pass.set_index_buffer(&mesh.index_buffer, IndexFormat::Uint32);
render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
}
CurrentFrame::active() returns None when the surface couldn’t be acquired this tick (occluded, mid-resize, etc.) — always check it and skip rendering rather than unwrapping.
The material/instance/mesh lookups above are simplified for the example — in practice each is an Assets<T>::get(handle) call that returns Option, so a real draw system chains a few of those first. See Helper Macros for bind_mat!/draw_mesh!, which collapse that chain (plus set_pipeline/set_bind_group/draw_indexed) to two lines.
Targets
ColorTargetBuilder/DepthTargetBuilder/PassBuilder describe what to render into:
- An unattached color target (
ColorTargetBuilder::new()with no.with_attachment(...)) falls back to the swapchain’s own view — the common case for drawing directly to the screen. .with_attachment(&texture_view)points a color target at your own texture instead, e.g. for post-processing — see Textures for building aTextureViewrender target.- A depth target always points at your own texture; there’s no swapchain depth buffer. Zero color targets plus a depth target is a valid, depth-only pass (a shadow map).
During the pass
RenderPass mirrors wgpu’s own API closely: set_pipeline, set_bind_group, set_vertex_buffer, set_index_buffer, draw, draw_indexed, and their indirect variants draw_indirect/draw_indexed_indirect. For the indirect draws, DrawIndirectArgs/DrawIndexedIndirectArgs give the exact byte layout the GPU expects — write one via bytemuck::bytes_of (or its own .as_bytes()) into a buffer built with BufferUsages::INDIRECT.
Helper Macros
A small set of #[macro_export] macros that collapse the boilerplate that shows up constantly around asset lookups and drawing. They’re plain macro_rules!, exported at the crate root — pebble::or_return!, pebble::bind_mat!, etc. — no prelude needed.
or_return!
Systems return (), so the ? operator isn’t available the way it would be in a function returning Option/Result. or_return! collapses the let Some(x) = expr else { return }; pattern that fills the gap:
fn draw(materials: Read<Assets<Material>>) {
let material = or_return!(materials.get(handle));
// ...
}
Pass a second argument to return something other than ():
let material = or_return!(materials.get(handle), return None);
The other three macros are built on top of or_return!, so they inherit this same early-return-when-not-ready behavior — an asset that hasn’t finished uploading yet is a normal, common case (a couple frames on load), not a bug to unwrap() past.
bind_mat! / bind_comp!
The lookup-then-bind every draw call repeats: materials.get(handle), then set_pipeline + set_bind_group(0, ...):
fn draw(
mut frame: Write<CurrentFrame>,
materials: Read<Assets<Material>>,
) {
let Some(mut active) = frame.active() else { return };
let pass = PassBuilder::new().build();
let mut render_pass = active.begin_pass(pass);
bind_mat!(render_pass, materials, material_handle);
}
It evaluates to the looked-up &GPUMaterial, so grab it with let if you also need .update(name, data)/.update_value(name, &value) on it (e.g. a per-frame camera uniform):
let material = bind_mat!(render_pass, materials, material_handle);
material.update("camera", bytemuck::bytes_of(&camera_data));
bind_comp! is the same thing for a ComputePass + Compute instead of a RenderPass + Material.
There’s no combined “bind and draw” macro — a draw call sometimes needs more than one bind group (material at group 0, something else at group 1) before drawing, so bind_mat!/bind_comp! stay composable rather than folded into one rigid macro.
draw_mesh!
Sets the vertex/index buffers and calls draw_indexed for a mesh handle, defaulting the instance range to 0..1:
draw_mesh!(render_pass, meshes, mesh_handle);
draw_mesh!(render_pass, meshes, mesh_handle, 0..enemy_count); // instanced draw
Putting it together
The Recording a Render Pass example, using the full suite:
fn draw(
mut frame: Write<CurrentFrame>,
materials: Read<Assets<Material>>,
meshes: Read<Assets<Mesh>>,
) {
let Some(mut active) = frame.active() else { return }; // no frame this tick — skip
let pass = PassBuilder::new()
.with_target(ColorTargetBuilder::new().with_clear([0.1, 0.1, 0.1, 1.0]).build())
.build();
let mut render_pass = active.begin_pass(pass);
bind_mat!(render_pass, materials, material_handle);
draw_mesh!(render_pass, meshes, mesh_handle);
}
Five lines of lookups and wiring down to two, with the same not-ready-yet handling as the manual version.
Compute Pipelines
Compute is the compute-pipeline counterpart to Material — WGSL shader source plus its bind group entries and the buffers/textures it dispatches with, all in one asset, same pattern:
fn setup(mut computes: Write<Assets<Compute>>) {
Compute::new(SHADER_SOURCE)
.storage("data", initial_bytes) // read-write by default (compute usually writes what it binds)
.build_asset("particles", &mut computes);
}
.storage/.texture/.texture_array/.cubemap/.sampler/.uniform/.uniform_value/.storage_value all work the same as Material’s — see Materials — except visibility is always exactly ShaderStages::COMPUTE; there’s no visibility to choose. with_entry_point overrides the default cs_main.
Need an explicit binding index, or a bind group entry the streamlined calls don’t produce (a non-default sample type, a dynamic-offset buffer)? Drop to .with_entry/.with_entry_at plus the matching value-only call, same two-step pattern as Material:
Compute::new(SHADER_SOURCE)
.with_entry_at("data", 0, BindingKind::storage_buffer_read_write(ShaderStages::COMPUTE))
.with_storage("data", initial_bytes)
.build_asset("particles", &mut computes);
To bind a buffer you already have — e.g. chaining compute passes, where one pass’s output storage buffer is the next pass’s input — use .with_buffer(name, existing_buffer) instead: it binds existing_buffer as-is, no new buffer is created. existing_buffer must already carry usage flags matching how name was declared (BufferUsages::UNIFORM or ::STORAGE):
Compute::new(SHADER_SOURCE)
.with_buffer("data", previous_pass_output.clone())
.build_asset("second_pass", &mut computes);
Same pipeline sharing as Material — several Computes using the same shader and bind group shape compile once and share the result, via ComputePipelineCache.
Dispatching
Unlike rendering, compute work isn’t tied to the frame lifecycle — it doesn’t need a swapchain frame to exist, so it dispatches immediately via Backend::dispatch_compute, in its own command encoder, submitted right away:
fn simulate(backend: Read<Backend>, computes: Read<Assets<Compute>>) {
backend.dispatch_compute(|pass| {
bind_comp!(pass, computes, compute_handle);
pass.dispatch_workgroups(64, 1, 1);
});
}
ComputePass also has dispatch_workgroups_indirect(buffer, offset). To read a result back afterward, use Buffer::read on the underlying storage buffer and poll the returned Promise — the GPU work itself is already submitted by the time dispatch_compute returns, but reading it back is still async.
#[derive(ComputeParams)]
Same idea as #[derive(MaterialParams)] — a plain struct’s fields become the .storage(...)/.texture(...)/etc. chain, minus the visibility question (always COMPUTE).
Custom GPU Resources
Pebble is deliberately low-level — the built-in asset types (Mesh, Texture, Material, Compute, …) cover the common cases, but nothing stops you from working with Buffer/BindGroup/TextureView directly, or defining an entirely new asset type. This page is about the escape hatches.
Your own asset type
The usual path — see The Asset Pipeline and Handles for the full explanation of the asset! macro and why uploads retry instead of failing:
asset!(MyThing => GPUMyThing, |self, backend: &Backend| {
Some(GPUMyThing { /* ... */ })
});
app.add_plugin(AssetPlugin::<Backend, MyThing>::new())
Building a pipeline outside Assets<T>
build_material/build_compute — the same functions the asset upload path calls internally — are public, for callers assembling their own wiring around a Material/Compute description without going through the usual asset flow:
let (pipeline, layout) = build_material(&backend, &material_desc, &layout_pool)
.expect("all dependencies must already be registered");
A custom pipeline type of your own
There’s no generic BindGroupTarget/BindingInstance<T> extension point to plug a new pipeline type into — Material/Compute each resolve their own named bind group values directly (via the internal, crate-private params::build_bind_group), rather than through a trait a third pipeline type could also implement. Building a genuinely new kind of pipeline (neither a render pipeline nor a compute pipeline) means following the same shape Material/Compute do rather than extending them: your own CPU-side descriptor struct, an upload() that compiles a wgpu pipeline (mirroring build_material/build_compute above) and then builds a BindGroup directly via BindGroupBuilder (see Bind Groups and Layouts) against whatever entries your own type declares.
Raw buffers, textures, and bind groups
For anything that doesn’t need the asset system’s retry/dependency machinery at all — a one-off buffer, a render target texture — build them directly with BufferBuilder and BindGroupBuilder. A standalone render target is just an ordinary Texture::empty(...) (.with_sample_count(...)/.with_extra_usage(...) for MSAA or extra usage flags) — see Textures, Buffers, and Bind Groups and Layouts.
Running on the Web
Pebble builds for wasm32-unknown-unknown — WindowPlugin’s runner and the whole rendering pipeline are written to work unmodified on both native and web. A few things differ:
- The canvas is inserted automatically.
winitdoesn’t do this on its own;WindowPluginasks it to (with_append(true)) so a window actually shows up in the page without hand-rolled DOM code. - The event loop is non-blocking. Natively,
winit’srunblocks forever; on wasm it usesEventLoopExtWebSys::spawn, which registers the loop with the browser and returns immediately. This is handled insideWindowPlugin— you don’t need to branch on it yourself. - The default headless loop busy-polls instead of sleeping. There’s no real OS thread to sleep on wasm, so if you never register a windowing plugin (uncommon), the fallback loop just spins. In practice this path is only reached before a windowing plugin’s runner takes over.
- No
ADDRESS_MODE_CLAMP_TO_BORDER. The GPU backend requests this feature only on native;SamplerKind::NearestClampBorderfalls back to clamp-to-edge on wasm accordingly.
Building:
cargo build --target wasm32-unknown-unknown
You’ll still need your own bundling/serving setup (wasm-bindgen, trunk, or similar) — pebble doesn’t ship one.
Further Reading
- Full API reference:
cargo doc --openin the pebble repository, or the published docs on docs.rs. - wgpu — pebble’s GPU backend. Understanding wgpu’s own concepts (bind groups, pipelines, command encoders) makes the Rendering: Building Blocks and Rendering: Drawing sections click faster, since most of pebble’s rendering types mirror wgpu’s own closely: https://wgpu.rs
- hecs — pebble’s ECS world underneath
Query/Commands: https://docs.rs/hecs - Source and issues: https://github.com/Akihiro120/pebble
This book covers release’s current feature set. If something here doesn’t match what you see in the source, the source wins — please open an issue.