Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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’s build.
  • 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; WindowPlugin uses this to hand control to winit’s event loop instead of the default headless polling loop.
  • with_logging() — initializes tracing_subscriber so tracing::info!/warn!/error! calls throughout the engine actually print.
  • run() — consumes the app and starts it.