pebble-engine 0.3.1

A modular, ECS-style graphics/app framework for Rust.
Documentation

Pebble

Examples Check Crates.io docs.rs License

A modular ECS framework for building render engines in Rust. Pebble provides the application loop, plugin system, resource management, and a GPU asset pipeline — but makes no rendering decisions for you. Batching, depth, post-processing, shaders, and draw calls are all yours to own.

[!WARNING] Pebble is built primarily for my own projects. It is shared publicly and you are free to use it, but expect breaking changes without notice. My own use cases drive priorities over external feature requests.


Design philosophy

Most graphics frameworks force you into their renderer. Pebble does the opposite: it gives you the plumbing and gets out of the way.

  • Bring your own graphics API. Implement the Backend + FrameOperations traits for wgpu, Metal, Vulkan, or anything else.
  • Bring your own windowing. Implement WindowProvider + WindowRunner for winit, SDL2, or a headless context.
  • Bring your own assets. Implement Asset<B> to describe how a CPU-side value becomes a GPU-side value. Pebble handles the dirty queue, retry logic, and dependency ordering automatically.
  • Compose with plugins. Everything — windowing, the backend, asset types, game logic — is a Plugin. Your engine is just a list of plugins wired to an App.

Core concepts

App and plugins

App::new()
    .add_plugin(MyWindowPlugin)
    .add_plugin(MyBackendPlugin)
    .add_plugin(MyGamePlugin)
    .build()
    .run();

build() runs all plugin registrations, executes startup systems, and validates that every declared resource dependency has a provider. run() hands the app to the runner installed by your window plugin.

Systems and stages

Systems are plain Rust functions. Parameters are declared in the function signature and fetched automatically:

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

Systems are registered at a SystemStage that determines when they run each frame:

Stage Purpose
Startup Once at startup, before the loop
AssetSync Upload CPU assets to the GPU backend
AssetSyncDeps Upload assets that depend on other GPU assets
PreUpdate Before main logic (e.g. input, time)
Update Main game logic
PostUpdate After main logic
PreRender Prepare render data, poll backend
Render Issue draw calls
PostRender Present the frame

Resources

Resources are singleton values stored in the ECS world. Any hecs::Component type can be a resource:

app.add_resource(MyConfig {});

// In a system:
fn my_system(config: Res<MyConfig>) {}

Option<Res<T>> is used when a resource may not exist yet — the system receives None and can skip its work gracefully. This is the standard way to wait for things like the GPU backend, which arrives asynchronously after startup.

The asset pipeline

The Asset<B> trait describes how a CPU-side source type is converted to a processed type using backend B:

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

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

B is generic — it need not be a GPU backend. Use B = () for CPU-to-CPU transforms (decompression, format conversion), or any other service type for audio, networking, etc.

Registering AssetPlugin::<B, T>::new() wires up the full pipeline automatically:

  • Assets<T::Source> — stores raw CPU data, tracks a dirty queue.
  • ProcessedAssets<T> — stores the converted results, indexed by the same handles.
  • A sync system on AssetSync that drains the dirty queue each tick, calling T::upload for each pending entry.

If upload returns None the handle is re-queued for the next tick. If a Deps resource is missing the whole sync system waits until it appears. No manual ordering or callbacks needed.

Lazy resources

LazyResource<B> complements Asset<B> for resources that have exactly one instance in the whole app and need a device to be constructed, but don't come from authored data and don't need a Handle or a pool entry.

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

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

Register with LazyResourcePlugin. The plugin adds a system to AssetSyncDeps that waits for B and all Deps to be present as resources, calls construct once, inserts the result via Res<T>, and never runs again. If construct returns None it retries next tick.

.add_plugin(LazyResourcePlugin::<WGPUBackend, DepthTexture>::new())

Good candidates: depth textures, camera uniform buffers, shared bind group layouts — anything that is one-of-a-kind and needs a backend before it can exist. If you need more than one instance of something, use Asset<B> + Handle instead.


Quick start

Add to Cargo.toml:

[dependencies]
pebble-engine = "0.2"

The minimal application — clear the screen to a colour:

use pebble::prelude::*;

fn main() {
    App::new()
        .add_plugin(WindowPlugin::<MyWindow>::new(WindowConfig {
            title: "Hello Pebble",
            width: 800,
            height: 600,
        }))
        .add_plugin(GraphicsPlugin::<MyBackend, MyWindow>::new())
        .add_plugin(RenderPlugin::<MyBackend>::new())
        .add_system(SystemStage::Render, render)
        .build()
        .run();
}

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

MyWindow implements WindowProvider + WindowRunner. MyBackend implements Backend + FrameOperations. See the examples below for complete, runnable implementations using wgpu and winit.


Examples

The examples are standalone crates that share a examples/common crate providing a wgpu + winit backend implementation. They are ordered by complexity and each has a step-by-step README.

Example Description
clear_screen Minimal app: open a window and clear it each frame
hello_triangle Draw a triangle using the asset pipeline
textured_quad Texture mapping and asset-to-asset dependencies
orbit_camera 3D camera, depth buffer, lazy resources, and the full plugin system

Run any example from its directory:

cd examples/hello_triangle
cargo run

Compiled shaders (SPIR-V) are pre-built in examples/assets/shaders/compiled/. If you modify the GLSL sources, recompile them with python3 examples/compile_shaders.py.


Implementing a backend

To use Pebble with your own graphics API, implement two traits:

FrameOperations — represents one acquired frame:

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

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

Backend — manages the swapchain and device:

impl Backend for MyBackend {
    type Frame = MyFrame;

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

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

init always delivers the backend through an InitSender, whether you do it synchronously (call sender.send before returning) or asynchronously (spawn a thread/task and call sender.send when ready). The framework polls the channel each PreRender tick until the backend arrives.


License

Licensed under either of

at your option.