Pebble
A low-level, ECS-style application/graphics framework for Rust, built directly on hecs, wgpu, and winit. Pebble gives you the app loop, a plugin system, resource management, and a CPU→GPU asset pipeline — it deliberately makes no decisions about how you render, animate, or simulate anything. There is no built-in skeletal animation, no model loader, no collision system. Pebble's job is to make sure the low-level primitives (a mesh with any vertex layout you want, a material with any shader you write, a compute pass, GPU→CPU readback) are flexible enough that you can build those things yourself without fighting the engine.
[!WARNING] Pebble is under active development and its public API is not stable. Expect breaking changes without notice.
Design philosophy
- Low-level by design, not by omission.
Meshdoesn't force a vertex format — it's generic over anybytemuck::Podtype, so a custom vertex struct (position + joint indices + weights, for example) works exactly the same way the built-in one does.Material/Computetake raw WGSL you write yourself, with an explicit vertex layout and bind-group layout — nothing about the shading model is assumed. - You build the game-specific systems. Skeletal animation, model loading, collision — none of it is in the engine. It's all buildable today against the existing low-level API (see the
Mesh/Material/ComputeInstanceprimitives below); the engine's job stops at giving you the primitives to do it with, not doing it for you. - Compose with plugins. Windowing, the GPU backend, asset types, your own game logic — all of it is a
Plugin. AnAppis built by chaining.add_plugin(...)calls. - Async work resolves like a value, not a callback. GPU backend acquisition, buffer readback, and compute results all use the same
Promise<T>/PromiseStateshape — poll it each tick until it'sReady.
Core concepts
App and plugins
App is consumed and returned by each builder call — a plugin's build takes App by value and hands back a (possibly modified) App:
new
.add_plugin // windowing (winit) + GPU backend (wgpu) + the built-in asset types
.add_plugin
.add_system
.add_system
.run;
run() hands control to whatever runner is installed — GraphicsPlugin installs one that drives the app from winit's own event loop, so you don't write your own loop.
Systems and stages
Systems are plain functions; parameters are fetched automatically from their type:
Systems are registered on a SystemStage, run in this fixed order every tick:
| Stage | Purpose |
|---|---|
Startup |
Runs once, before anything else — before the GPU backend exists. Pure CPU-only bootstrapping. |
Ready |
Runs once, automatically, the first tick the GPU backend is ready. Where one-time setup that needs Backend or the asset system belongs — building meshes/materials/computes, anything that isn't meant to happen every tick. A plain Read<Backend> here is always safe; no Option guard needed. |
AssetSync |
Uploads CPU-side assets to the GPU, retrying automatically until their dependencies are met. |
PreUpdate |
Before main game logic (input polling, event aging, timers). |
Update |
Main game logic. |
PostUpdate |
After main game logic. |
PreRender |
Acquire the frame. |
Render |
Issue draw calls. |
PostRender |
Submit and present. |
Startup and Ready are both genuinely one-shot: registering a system there doesn't need a Local<bool> "have I already run" guard — the stage itself is removed from the schedule the instant it runs once.
Every stage from Ready onward only ever runs once the GPU backend exists — App::update() runs a separate gpu_schedules set (used internally for backend acquisition) for as long as the backend isn't ready yet, and doesn't touch the regular stages at all until it is. So a bare Read<Backend>/Read<Assets<T>> anywhere in Ready or later is always sound.
Resources
Resources are singleton values, fetched by type:
app.insert_resource;
Option<Read<T>>/Option<Write<T>> are for a resource that might not exist yet (or might never) — the system gets None and can skip gracefully instead of panicking.
Queries
Query<Q> wraps hecs queries — no hecs::* type appears in its public surface:
query.get(entity) fetches one known entity directly. query.with::<R>()/query.without::<R>() narrow by component presence (chainable — each returns another Query). query.single()/query.get_single() expect exactly one match (panicking / None respectively if that's not true).
Commands
Commands defers entity spawns and resource inserts/removes until the end of the current stage (Derefs to hecs::CommandBuffer for spawning, plus insert_resource/remove_resource/trigger):
Events — polling
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, so a reader sees it exactly once regardless of when it runs relative to the writer:
app.;
add_event::<T>() is idempotent — registering the same type twice from two different plugins is a no-op, not a double-registration bug.
Observers — subscription
For "run this specific reaction the instant something happens" instead of polling a buffer: add_observer registers a callback, Commands::trigger dispatches to every observer registered for that type. Observers run at the end of the current stage — same tick, not deferred to some later poll:
app.add_observer
.add_observer;
Multiple add_observer::<E>() calls for the same E all fire independently.
Promise — one-off async results
Promise<T>/Fulfiller<T> is a oneshot result you poll each tick — used for GPU backend acquisition, Buffer::read(), and compute dispatch readback:
Not a resource, not registered anywhere — it's a plain value you store wherever fits (a Local, a field on your own resource/component).
Time
Not registered automatically — add it if you need it:
app.add_plugin; // Read<Time>: delta()/delta_seconds(), elapsed()/elapsed_seconds(), fps()
Backed by web_time, so it's correct on wasm32-unknown-unknown too. Pebble doesn't ship audio or gamepad support — this is a graphics engine; the asset pipeline makes it straightforward to wire up your own crate for either (e.g. rodio, gilrs) as a plugin.
Windowing and input
GraphicsPlugin opens a window (winit) and inserts Window/Input as resources — no raw winit type is ever exposed:
Confirmed functional on both native and wasm32-unknown-unknown (verified with a real headless-Chrome screenshot, not just a compile check) — the canvas is attached to the page automatically and the event loop uses the correct non-blocking entry point on web.
The asset pipeline
Assets<T> holds both a value's CPU-side source and its GPU-side processed form together (the "unified" model) — no separate CPU/GPU stores to keep in sync yourself:
AssetPlugin::<Backend, MyThing>::new() wires the retry-until-ready upload loop into AssetSync automatically. If upload returns None (a dependency isn't ready yet), it's retried next tick — no manual ordering.
Writing those two trait impls by hand is enough boilerplate that there's a macro for it — expands to exactly the same code:
asset!;
// with dependencies — bare types, wrapped in Read<'a, _> (or a tuple of
// them for more than one) automatically
asset!;
Every built-in asset type (Texture, Mesh, Material, ...) uses the trait impls directly — the macro only exists to make a new, user-defined asset type cheaper to write.
CPU-side source data (a Mesh's vertices/indices, a Texture's pixels) stays reachable via Assets<T>::get_source/get_source_by_name — e.g. to build a collision mesh from the same vertex data used to upload the render mesh — and can be explicitly released (release_cpu_data()) once you're done reading it, if you don't want the CPU copy sitting in memory forever. Assets<T>::iter() enumerates everything currently loaded.
GPU resource builders
Each of Texture/TextureArray/Cubemap/Mesh/Material/Compute/MaterialInstance/ComputeInstance is its own builder — no separate XBuilder type, chained with_* calls terminating in .build_asset(name, &mut assets):
let material = new
.with_vertex_layouts
.with_entries
.with_targets
.build_asset;
Mesh<V>is generic over anyV: bytemuck::Pod— the built-inVertex(position/uv/normal/tangent) is just the default; a custom vertex type (joint indices/weights for skinning, say) works identically.Texture/TextureArray/Cubemapshare the same construction options (from_file/from_data/empty), the samewith_mips()/with_mip_count(n)GPU-side mip generation, and matchingget_view(..., mip_level)accessors. An empty texture can be used as a render target (post-processing, shadow maps) — confirmed via a dedicated pure-logic test, sinceRENDER_ATTACHMENTusage is granted whenever there's no source data, independent of mip count.Material/Computetake raw WGSL directly — no forced shading model.MaterialInstance/ComputeInstancebind concrete textures/buffers/samplers into a material or compute's bind group, and can be updated at runtime (instance.update("name", &bytes)) — this is how you drive a live GPU buffer (joint matrices for skinning, simulation state for a compute pass) from a system each tick.ComputePass+Backend::dispatch_compute(...)run a compute pipeline immediately, in its own command encoder — not deferred to any render stage. Read the result back with the sameBuffer::read() -> Promise<Vec<u8>>used everywhere else.
Quick start
[]
= { = "https://github.com/Akihiro120/pebble", = "release" }
use ;
This opens a window and drives a real GPU render loop (acquire/submit/present) with nothing drawn yet — add systems on Ready to build your meshes/materials, and on Render to issue draw calls against them (see Core concepts above).
Web/wasm
Pebble targets wasm32-unknown-unknown alongside native. cargo check --target wasm32-unknown-unknown builds clean, and windowing + GPU rendering have been verified functional in an actual browser (not just compiling) — the canvas is attached to the page automatically (winit's with_append(true)), and the event loop uses the non-blocking wasm entry point rather than the one that blocks forever natively.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.