Ponko
Ponko is a general-purpose 2D/3D game engine in Rust, with C and C++ bindings. It aims to combine serious game-engine capabilities, measured performance, clear code-first APIs and practical AI-assisted development. Large simulations are a strength, alongside ordinary 2D and 3D games.
You do not need a large visual editor. Start with Game::new(), setup code and
systems; use external asset tools and the optional runtime inspector when they
help. Modular internals should support an integrated experience: common things
work immediately, advanced things can be customized, and neither requires
understanding the whole engine.
Read the project vision, architecture, and roadmap for direction, current limitations and planned work. The Bevy and Unity comparison explains the tradeoffs; Ponko does not claim feature parity or overall superiority.
Start small, scale when needed
A small game can keep state in a closure. This example loads assets/crab.png,
moves it with the arrow keys, and quits on Escape:
use ponko::*;
fn main() {
Game::new("My Game", 800, 600).run(|ctx| {
let crab = ctx.load_texture("crab.png");
let mut pos = vec2(400.0, 300.0);
move |ctx: &mut Ctx| {
if ctx.key_down(Key::Right) { pos.x += 200.0 * ctx.dt(); }
if ctx.key_down(Key::Left) { pos.x -= 200.0 * ctx.dt(); }
if ctx.key_pressed(Key::Escape) { ctx.quit(); }
ctx.draw(&crab, pos);
}
});
}
static void my_game() {
crabby::texture crab{};
crabby::vec2 pos{400, 300};
crabby::game("My Game", 800, 600)
.system([&](crabby::ctx& ctx) {
if (ctx.key_down(CRABBY_KEY_RIGHT)) pos.x += 200 * ctx.dt();
if (ctx.key_down(CRABBY_KEY_LEFT)) pos.x -= 200 * ctx.dt();
if (ctx.key_pressed(CRABBY_KEY_ESCAPE)) ctx.quit();
ctx.draw(crab, pos);
})
.run([&](crabby::ctx& ctx) { crab = ctx.load_texture("crab.png"); });
}
The C++ wrapper retains the crabby namespace and crabby.hpp header during
the rename. Continue with Getting started for installation
and the paired API examples.
Grow into entities and systems, fixed-step simulation and parallel queries as needed. A headless run advances simulation without a window or GPU. Configure a minimal build with Cargo features when those dependencies should also be omitted. Parallel query iteration exists today; automatic dependency-aware parallel system scheduling is planned.
Current tools and future goals
Ponko includes a wgpu 2D/3D renderer, physics integrations, scenes/prefabs, audio, UI, networking and native bindings. Feature availability and platform limits vary; use the relevant chapters and roadmap before relying on a complete workflow. Renderer restructuring and stronger ECS scheduling are gradual improvements to existing systems, not replacements already delivered.
AI-agent tools can launch a game, inspect reflected state, step exact ticks, read logs and measure performance. For visual checks, launch a windowed instance and request a screenshot. A useful loop is: edit, launch, run a fixed scenario, inspect state and timings, correct, and repeat with evidence. Broader automated acceptance workflows and reliable autonomous creation of complete games remain goals.
How this book is laid out
- Guide: practical chapters for game code, assets, rendering, simulation and tools.
- Reference: benchmarks, public APIs, compatibility and contribution guidance.
Every page is searchable: press S or click the magnifying glass. Use
performance measurements to understand specific workloads;
benchmark results do not establish a general engine ranking.
Status and compatibility
Formerly Crabby Engine, Ponko currently has package version 0.0.1. Source folders
retain crabby_* names. The guide follows main; installed releases may differ.
STABILITY.md
remains authoritative for published API/ABI, MSRV, saved-world, MCP and
determinism commitments. The rename does not weaken those promises.
Help improve the engine through Contributing.
Ponko vs Bevy vs Unity
Last reviewed: 2026-11-08. Ponko is the engine previously named Crabby Engine. This comparison describes the checked-in implementation and the linked official documentation; it does not claim feature parity or general performance superiority. Product versions and tool availability change.
Ponko aims to make a capable 2D/3D engine accessible through a small, code-first public API and integrated runtime tools. Its differentiator is that combination of choices. Code-first development, ECS, headless runs, modularity and remote inspection are also available in other engines.
The programming model
Ponko uses Rust functions or closures taking &mut Ctx. The same engine has
a C ABI and C++17 wrapper. Ordinary games start with Game::new, optional
builder settings, and run; no plugin framework or large visual editor is
required. See getting started for a complete game and
C and C++ for the equivalent native API.
Bevy is also a code-first Rust engine. Its App, systems and plugins form
its application model. DefaultPlugins already supplies ordinary engine
facilities; developers do not have to assemble those individually. It also
supports selecting or replacing plugins. See the official
plugin guide.
Unity’s usual workflow combines C# scripts, components, assets, scenes and its visual Editor. This is often useful for teams that author scenes and content visually. Unity also provides an Entities package for data-oriented work, so its GameObject workflow is not its only programming model. The Unity manual and Entities concepts describe those alternatives.
ECS and parallelism
Ponko stores component columns in archetypes and gives entities generational
IDs. Typed queries, optional components, with/without filtering, runtime
component registration and parallel query iteration exist today in
crates/crabby_ecs. A small fixed-step simulation can be checked without a GPU:
#![allow(unused)]
fn main() {
use ponko::*;
struct Pos(f32);
fn fall(ctx: &mut Ctx) {
let dt = ctx.dt();
ctx.world.query::<&mut Pos>().for_each(|p| p.0 -= 10.0 * dt);
}
#[test]
fn things_fall_ten_units_per_second() {
let mut world = Game::new("test", 800, 600)
.sim_system(fall)
.run_headless(60, |ctx| {
ctx.world.spawn((Pos(100.0),));
});
let y = world.query::<&Pos>().iter().next().unwrap().0;
assert!((y - 90.0).abs() < 1e-3);
}
}
struct Pos { float y; };
static void things_fall_ten_units_per_second() {
crabby::entity thing = 0;
auto world = crabby::game("test", 800, 600)
.sim_system([](crabby::ctx& ctx) {
const float dt = ctx.dt();
ctx.world().each([dt](Pos& p) { p.y -= 10 * dt; });
}, "fall")
.run_headless(60, [&](crabby::ctx& ctx) { thing = ctx.world().spawn(Pos{100}); });
const float y = world.get<Pos>(thing).y;
if (std::fabs(y - 90) > 1e-3f) { std::fprintf(stderr, "fell to %f\n", double(y)); std::abort(); }
}
This uses the existing Rust and C++ headless APIs. Parallelizing entities
within a query is different from running
independent systems concurrently: Ponko’s system loops currently run in
registration order. Arbitrary &mut Ctx callbacks remain exclusive.
Bevy’s ECS uses typed system parameters to express access, supports table and sparse-set component storage, and schedules compatible systems in parallel. Explicit ordering still matters. See Bevy ECS and its system scheduling tutorial. Ponko’s optional access-aware scheduler is planned, following access metadata #137, benchmarks #132 and barrier contracts #146, before parallel scheduling #138. It must preserve the simple existing workflow and cannot safely be achieved by sharing unrestricted mutable contexts between threads.
Unity Entities supplies ECS systems and archetype/chunk storage; Unity’s Job System and Burst provide parallel and optimized code paths. Comparing Ponko’s ECS against Mono GameObject callbacks does not measure Unity Entities/Jobs/Burst.
Rendering and customization
Ponko’s custom wgpu renderer already draws 2D sprites and 3D meshes with materials, lighting, shadows and post-processing. WGSL surface shaders, full-screen effects and compute workloads have existing APIs. Large static instance batches use GPU culling on supported devices, with device-dependent fallbacks. See 3D, profiling and the source modules listed in ARCHITECTURE.md.
Those features do not establish graphics parity. Resource and pipeline
ownership, history management and pass coordination remain tightly coupled
in src/render/gpu3d.rs. The roadmap calls for measured subsystem refactoring
#136, explicit internal
pass dependencies #147,
pipeline caching #152 and
capability contracts #151.
A general safe custom-pass API #155
is planned; current shader hooks are not full renderer replaceability.
Bevy also uses wgpu. Its renderer exposes extraction, preparation and rendering infrastructure plus render-graph and material extension points; see bevy_render. Unity provides multiple render pipelines and documented customization, including URP and HDRP. Choose from actual visual requirements, target hardware, tooling and maintenance cost. A shared graphics API or one favorable scene is not an overall renderer comparison.
AI development, inspection and evidence
Ponko’s ponko-mcp server exposes tools for launching examples, inspecting
reflected entities/resources, modifying scenes, stepping paused simulations,
reading logs, capturing windowed screenshots and requesting performance
reports. The agent guide documents their real names and inputs.
The optional inspector and control endpoint complement ordinary debugging.
Headless mode cannot produce a rendered screenshot.
For example: launch a seeded reproduction paused, capture a checkpoint, run a fixed tick count, inspect entity state, change code, rebuild and rerun. For a visual change, also capture a windowed frame at the same camera and quality settings. State inspection, successful compilation and screenshots answer different questions; none substitutes for all the others.
Bevy’s official Remote Protocol allows inspection and modification of ECS data through JSON-RPC, including queries and schema discovery. This is real runtime tooling; Ponko does not have an exclusive claim to remote ECS inspection. BRP and MCP are different protocols, so client integration must be checked rather than assumed.
Unity provides an official AI Assistant and MCP integration. Its current
CLI migration guidance
says the in-editor Assistant MCP server is deprecated and replaced by the
CLI’s unity mcp mode. This is a tool migration, not removal of Unity’s
agent support. Follow the current Unity setup and access requirements.
Ponko’s structured shader diagnostics #153, schedule/query inspection #145 and expanded GPU reporting #157 are planned improvements to existing tooling. Reliable autonomous completion of arbitrary games is a goal to investigate, not a present capability.
Multiplayer, determinism and servers
Ponko includes UDP lockstep, snapshots/replication and message-oriented
network APIs, with optional Steam integration. These are building blocks;
a shipped game’s authority, prediction, recovery and hosting behavior still
need game-specific validation. Headless Game::run_headless runs fixed
simulation ticks without rendering. Disabling default features removes the
window/GPU path; enabling only what a server needs is supported today.
Read networking, headless execution and determinism before choosing a synchronization model. Reproducibility assumes matching builds, seeds and inputs. Cross-platform physics determinism requires the appropriate feature; floating-point transcendentals can differ between platforms. These are not blanket bit-identical-world guarantees.
Bevy supports headless applications through its plugin selection; networking is an integration decision that should be evaluated against the chosen library and release. Its ECS scheduler does not by itself guarantee cross-platform deterministic gameplay. Unity has both desktop headless and Dedicated Server workflows and an official multiplayer stack covering netcode and player connections. Headless operation and multiplayer are not unique to Ponko.
Assets, portability and ecosystem
Ponko has direct asset loading, scenes/prefabs, glTF model loading and a Unity text-scene importer with FBX-related processing. External tools such as Blender remain appropriate for content creation. Importing supported scene data does not run arbitrary Unity scripts, packages or shaders. Budgeted texture/mesh residency and asynchronous uploads are future work #154; asset lifecycle stress diagnostics #161 remain open. Do not infer a complete production asset pipeline from a successful import of one asset.
Ponko CI covers Linux, macOS and Windows and builds the browser target. WebGPU/WebGL2 fallback support does not mean that every desktop feature works on every browser. Mobile and consoles are not currently validated release targets. STABILITY.md is the compatibility contract; a crate-name/version reset does not authorize breaking its existing C ABI, saved-world or MCP contracts.
Bevy has a broader public contributor/plugin ecosystem; Unity has a much longer production history, commercial content/tooling ecosystem and broader platform workflows. These maturity assessments are practical judgments, not benchmark results. Check the maintained Bevy ecosystem directory, Unity platform documentation and required third-party integrations for your actual project.
Choosing an engine
Choose Ponko when its compact Rust/C++ interface, integrated simulation and runtime-control workflow fit your project, and you are willing to validate missing production requirements and contribute to an early engine.
Choose Bevy when you want a Rust ECS-centered ecosystem with automatic compatible-system scheduling and more existing plugin integrations. Both engines support code-first development and useful defaults.
Choose Unity when visual content authoring, established production tools, team experience, commercial integrations or supported deployment targets are decisive. Its modular packages and high-performance data-oriented path should be evaluated alongside its familiar GameObject workflow.
Use the performance chapter as a record of specific reproducible workloads, not a league table of engines. Match workloads, resolution, quality, build configuration and target hardware before drawing conclusions. Consult the roadmap for unresolved foundations; it is directional and has no promised dates.
Getting started
You need a recent stable Rust (rustup update stable). On Linux, also the
headers for sound and gamepads: sudo apt-get install libasound2-dev libudev-dev
(Debian, Ubuntu).
Run the examples
Clone the repository and run any example:
git clone https://github.com/ponkoengine/ponko
cd ponko
cargo run --release --example boids
| Example | What it shows |
|---|---|
window | The smallest game: a window, input, one sprite |
sprites | Drawing many sprites with tint, rotation and scale |
ecs | Entities, components and systems, with sim controls |
bunnymark / bunnymark_ecs | Sprite throughput, immediate mode vs ECS |
boids | Flocking with a spatial grid on all cores |
ants | Ant colonies: pheromone trails, foraging, growth; up to millions of ants |
headless | A million agents with no window or GPU |
examples/cpp/boids.cpp | The boids example in C++ (build with CMake, see C and C++) |
Always use --release for anything with many entities; debug builds are
10–50× slower.
Your first game
Add the engine to a new project:
cargo new my_game && cd my_game
cargo add ponko
Then in src/main.rs (Rust) or main.cpp (C++, see C and C++
for building):
use ponko::*;
fn main() {
Game::new("My Game", 800, 600).run(|ctx| {
let crab = ctx.load_texture("crab.png");
let mut pos = vec2(400.0, 300.0);
move |ctx: &mut Ctx| {
if ctx.key_down(Key::Right) { pos.x += 200.0 * ctx.dt(); }
if ctx.key_down(Key::Left) { pos.x -= 200.0 * ctx.dt(); }
if ctx.key_pressed(Key::Escape) { ctx.quit(); }
ctx.draw(&crab, pos);
}
});
}
static void my_game() {
crabby::texture crab{};
crabby::vec2 pos{400, 300};
crabby::game("My Game", 800, 600)
.system([&](crabby::ctx& ctx) {
if (ctx.key_down(CRABBY_KEY_RIGHT)) pos.x += 200 * ctx.dt();
if (ctx.key_down(CRABBY_KEY_LEFT)) pos.x -= 200 * ctx.dt();
if (ctx.key_pressed(CRABBY_KEY_ESCAPE)) ctx.quit();
ctx.draw(crab, pos);
})
.run([&](crabby::ctx& ctx) { crab = ctx.load_texture("crab.png"); });
}
setup runs once and returns a closure that runs every frame. In C++, the
per-frame code is a frame system and the state lives in variables it
captures. Variables you
create in setup and move into the closure are your game state. For small
games that’s all you need; when things grow, move to
entities and systems.
Everything comes in with use ponko::*;: Game, Ctx, Vec2,
Color, Key, the ECS types and the built-in components.
Entities, components, queries
The game world lives in ctx.world, an entity-component system (ECS).
- An entity is an id (
Entity). It has no data of its own. - A component is any
Send + Sync + 'staticRust type. No derive needed. In C++ it is any plain struct (trivially copyable), found by its type: nothing to register. - A query visits every entity that has a given set of components.
Spawning
#![allow(unused)]
fn main() {
struct Health(f32);
struct Enemy;
let e: Entity = ctx.world.spawn((Transform2D::at(vec2(10.0, 20.0)), Health(100.0), Enemy));
// Many at once is much faster than a loop of `spawn`:
ctx.world.spawn_batch((0..100_000).map(|i| (Transform2D::at(vec2(i as f32, 0.0)), Health(10.0))));
}
crabby::entity e = world.spawn(transform_at({10, 20}), Health{100}, Enemy{});
// Many at once:
for (int i = 0; i < 100000; ++i)
world.spawn(transform_at({float(i), 0}), Health{10});
A tuple of components is a bundle; bundles go up to 12 components. C++
passes the components straight to spawn.
struct Health { float value; };
struct Enemy {};
Reading and changing one entity
#![allow(unused)]
fn main() {
if let Some(h) = ctx.world.get_mut::<Health>(e) {
h.0 -= 5.0;
}
ctx.world.insert(e, Enemy); // add or replace a component
ctx.world.remove::<Enemy>(e); // returns the removed value
ctx.world.despawn(e);
assert!(!ctx.world.is_alive(e)); // old handles stay dead, even if the slot is reused
}
Health h = world.get<Health>(e);
world.set(e, Health{h.value - 5});
world.set(e, Enemy{}); // add or replace a component
if (world.has<Enemy>(e)) world.remove<Enemy>(e);
world.despawn(e);
assert(!world.alive(e)); // old handles stay dead, even if the slot is reused
assert(!world.try_get<Health>(e));
Queries
#![allow(unused)]
fn main() {
ctx.world
.query::<(&mut Transform2D, &Health)>()
.without::<Enemy>()
.for_each(|(t, h)| t.position.y += h.0);
}
world.each([](transform2d& t, const Health& hp) {
t.position.y += hp.value;
});
In C++, each reads the components to visit from your function’s
parameters: T& to change one, const T& to read it, and a first
crabby::entity parameter gets the entity’s id. world.each_par is the C++
par_for_each: your function runs on all cores at once, so it must only
touch its own entity.
For code that works on whole arrays, world.query<A, B>(fn) hands you one
chunk (archetype) at a time, with an array per component:
world.query<transform2d, Health>([](auto chunk) {
auto [t, hp] = chunk.columns(); // one array per component
for (std::size_t i = 0; i < chunk.size(); ++i)
t[i].position.y += hp[i].value;
});
each compiles to the same loop, so it’s as fast. With query_par, size a
buffer with world.count<A, B>() and write row i of a chunk to slot
chunk.first() + i to fill it from all cores: rows are numbered the same
way in every query.
What you can ask for (Rust):
| In the query | You get |
|---|---|
&T | read access to T (entity must have it) |
&mut T | write access to T |
Option<&T>, Option<&mut T> | the component if present; doesn’t filter |
Entity | the entity’s id |
| a tuple of the above | all of them, up to 8 |
Filters with::<T>() and without::<T>() narrow the match without fetching
data. Asking for the same component twice with &mut panics.
Ways to run a query:
for_each(f): the fastest loop; use this by default.par_for_each(f): same, split over all CPU cores. Use it for per-entity work on many entities.par_collect_into(&mut vec, f): fillsvecwithfof every match, on all cores, infor_eachorder. The vector keeps its memory, so doing it every tick allocates nothing.iter(): a normal iterator, for when you needmap/filter/collect. It is several times slower thanfor_eachon big worlds.count(),get(entity).
Commands: changing the world while you iterate
You can’t spawn or despawn inside a query loop, because the query is borrowing
the world. Record the change in ctx.commands instead:
#![allow(unused)]
fn main() {
fn die(ctx: &mut Ctx) {
let cmds = &mut ctx.commands;
ctx.world.query::<(Entity, &Health)>().for_each(|(e, h)| {
if h.0 <= 0.0 { cmds.despawn(e); }
});
}
}
static void die(crabby::ctx& ctx) {
ctx.world().each([&](crabby::entity e, const Health& hp) {
if (hp.value <= 0) ctx.despawn_later(e);
});
}
The engine applies commands after every system, so the next system sees them.
Resources
A resource is one global value of a type, stored in the world and found by its type:
#![allow(unused)]
fn main() {
struct Score(u32);
ctx.world.insert_resource(Score(0));
ctx.world.resource_mut::<Score>().0 += 1;
}
struct Score { std::uint32_t points; };
[[maybe_unused]] static void score_point(crabby::ctx& ctx) {
auto world = ctx.world();
Score score = world.try_resource<Score>().value_or(Score{0});
score.points += 1;
world.set_resource(score);
}
C++ resources are plain structs, copied in and out: resource<T>() (throws
if the world has none), try_resource<T>(), set_resource(value) and
remove_resource<T>(). Read them before an each loop, not inside it (the
world is borrowed while the loop runs). In C: crabby_resource_set,
crabby_resource_get and crabby_resource_remove, by name.
Sorting for cache locality
world.sort_by::<T>(key) reorders entities that have T by a u64 key.
Entity ids stay valid. The built-in spatial grid uses it to keep entities
that are close in space close in memory; see Spatial queries.
Systems and the frame
A system is a function fn(&mut Ctx) (or a closure); in C++, anything
callable as void(crabby::ctx&), with an optional name for timings and tools. You add systems to
the Game and they run in the order you added them.
#![allow(unused)]
fn main() {
Game::new("Sim", 1280, 720)
.sim_system(steer) // fixed rate: game logic
.sim_system(movement)
.system(camera) // once per frame: input, camera, UI
.run(|ctx| { /* spawn things */ });
}
crabby::game("Sim", 1280, 720)
.sim_system(steer, "steer") // fixed rate: game logic
.sim_system(movement, "movement")
.system(camera_system, "camera") // once per frame: input, camera, UI
.run([](crabby::ctx&) { /* spawn things */ });
Two kinds of systems
sim_system | system | |
|---|---|---|
| Runs | at the fixed rate, 0 or more times per frame | once per rendered frame |
ctx.dt() | the fixed step (1/60 s by default) | real time since last frame |
| Use for | gameplay, physics, AI: anything that must behave the same at any FPS | input, camera, debug UI |
| In headless runs | runs | doesn’t run |
Change the rate with .fixed_hz(120). In C++, sim_system(f, name, every)
also takes the interval of sim_system_every.
One frame, in order
- Simulation systems,
ctx.fixed_steps()times (each tick runs all of them once). - Frame systems.
- The closure returned from
setup, if any. - With
3d, animator and particle updates. - Late systems (
Game::late_system), then built-in draw systems such asdraw_sprites. - Spatial audio update, then rendering.
Commands are applied after each system. The setup-returned closure is appended to the frame-system list, so it runs before animator updates and late systems. Headless execution runs simulation systems only.
Parallel queries and future scheduling
Systems currently execute sequentially, taking exclusive &mut Ctx access.
A system can use the ECS’s parallel query methods to divide its entity work
across threads; this is not automatic parallel execution of different systems.
Optional declared-access scheduling is planned in
#137 and
#138, after safety and
performance baselines. Event visibility, deferred commands and barriers must
be explicit (#146). Existing
unannotated sim_system callbacks must remain usable and exclusive.
See the architecture
for current subsystem boundaries.
Systems with state
A system can be a closure that owns data. Build it with a function that returns the closure:
#![allow(unused)]
fn main() {
fn spawner(every: u64) -> impl FnMut(&mut Ctx) {
let mut spawned = 0;
move |ctx| {
if ctx.sim_tick() % every == 0 {
ctx.commands.spawn((Transform2D::default(),));
spawned += 1;
}
}
}
Game::new("t", 800, 600).sim_system(spawner(30));
}
struct Spawner {
std::uint64_t every;
int spawned = 0;
void operator()(crabby::ctx& ctx) {
if (ctx.sim_tick() % every == 0) {
ctx.spawn_later(crabby::transform_at({0, 0}));
++spawned;
}
}
};
// crabby::game("t", 800, 600).sim_system(Spawner{30}, "spawner");
Replacing built-ins
Everything the engine does by itself is an ordinary system, and
remove_system takes it out (in C++, by name):
#![allow(unused)]
fn main() {
Game::new("t", 800, 600)
.remove_system(draw_sprites) // draw your own way
.system(my_draw);
}
crabby::game("t", 800, 600)
.remove_system("ponko::ecs::draw_sprites") // draw your own way
.system(my_draw, "my_draw");
Timings
ctx.system_timings() lists every system with its last and average time in
milliseconds, so a game can show its own profiler overlay. For a full
timeline, build with --features profiling and open
puffin_viewer on 127.0.0.1:8585.
Input: keyboard, mouse, gamepads
Input is read from ctx in any system. Every kind works the same way:
_down while held, _pressed on the frame it went down, _released on the
frame it came up. A tap shorter than a frame still shows up as pressed and
released.
Keyboard
#![allow(unused)]
fn main() {
use ponko::*;
fn update(ctx: &mut Ctx, pos: &mut Vec2) {
if ctx.key_down(Key::Right) { pos.x += 200.0 * ctx.dt(); }
if ctx.key_pressed(Key::Space) { println!("jump"); }
}
}
Keys are named by their position on a US keyboard: Key::W is the key
above S on every layout, so WASD controls stay in place for AZERTY and
Dvorak players too. In C++: ctx.key_down(CRABBY_KEY_RIGHT).
Mouse
ctx.mouse_pos(): in window pixels;ctx.mouse_world(): through the camera, in world units.ctx.mouse_down(MouseButton::Left)(and_pressed,_released).ctx.mouse_delta()since the last frame, andctx.mouse_wheel().ctx.mouse_over_ui():trueover a UI panel or the inspector, so a click on a button doesn’t also act in the world.
Mouse look
For a first-person or orbit camera, lock the cursor and read raw motion:
ctx.set_cursor_locked(true)hides the cursor and holds it in the window (where the platform can’t hold it in place, it’s kept inside the window instead);falselets it go. It takes effect after the frame and comes back when the window regains focus.ctx.mouse_motion(): how far the mouse moved this frame in raw device units (+y is down), even while the cursor is locked and can’t move.mouse_delta()stops at the window’s edge; this doesn’t.
C++: ctx.set_cursor_locked(true), ctx.mouse_motion(). See
3D physics for a walker that uses both.
Gamepads
Xbox, PlayStation, Switch and most other controllers work, plugged in or
over Bluetooth, and can come and go while the game runs.
ctx.gamepads() lists the connected ones. Each keeps its slot while
connected (pad.slot(): 0 is player one), and a pad plugged in later takes
the lowest free slot.
#![allow(unused)]
fn main() {
fn move_players(ctx: &mut Ctx) {
let dt = ctx.dt();
// Read the pads first: the query below borrows the world.
let mut sticks = [Vec2::ZERO; MAX_GAMEPADS];
let mut spins = [false; MAX_GAMEPADS];
let mut grow = [0.0; MAX_GAMEPADS];
for &pad in ctx.gamepads() {
sticks[pad.slot()] = ctx.gamepad_stick(pad, GamepadStick::Left);
spins[pad.slot()] = ctx.gamepad_pressed(pad, GamepadButton::South);
grow[pad.slot()] = ctx.gamepad_axis(pad, GamepadAxis::RightTrigger);
}
for (t, player) in ctx.world.query::<(&mut Transform2D, &mut Player)>().iter() {
let Some(slot) = player.slot else { continue };
t.position += sticks[slot] * SPEED * dt;
if spins[slot] {
player.spin = std::f32::consts::TAU;
}
t.scale = Vec2::splat(1.0 + 2.0 * grow[slot]);
}
}
}
struct Player { std::uint32_t slot; float spin; }; // one per connected pad
static void move_players(crabby::ctx& ctx) {
const float dt = ctx.dt();
// Read the pads first: the loop below borrows the world.
crabby::vec2 sticks[CRABBY_MAX_GAMEPADS] = {};
bool spins[CRABBY_MAX_GAMEPADS] = {};
float grow[CRABBY_MAX_GAMEPADS] = {};
for (crabby::gamepad pad : ctx.gamepads()) {
sticks[pad.slot] = ctx.gamepad_stick(pad, crabby::stick::left);
spins[pad.slot] = ctx.gamepad_pressed(pad, CRABBY_GAMEPAD_SOUTH);
grow[pad.slot] = ctx.gamepad_axis(pad, CRABBY_GAMEPAD_RIGHT_TRIGGER_AXIS);
}
ctx.world().each([&](crabby::transform2d& t, Player& player) {
t.position += sticks[player.slot] * 400 * dt;
if (spins[player.slot]) player.spin = 6.2831853f;
t.scale = crabby::vec2{1, 1} * (1 + 2 * grow[player.slot]);
});
}
Buttons are named by position, so a game’s controls mean the same on every brand:
GamepadButton | Xbox | PlayStation | Nintendo |
|---|---|---|---|
South | A | Cross | B |
East | B | Circle | A |
North | Y | Triangle | X |
West | X | Square | Y |
LeftBumper / RightBumper | LB / RB | L1 / R1 | L / R |
LeftTrigger / RightTrigger | LT / RT | L2 / R2 | ZL / ZR |
Select / Start | View / Menu | Share / Options | Minus / Plus |
LeftStick / RightStick | stick clicks | L3 / R3 | stick clicks |
DPadUp, DPadDown, DPadLeft, DPadRight | d-pad | d-pad | d-pad |
Sticks and triggers are axes:
ctx.gamepad_stick(pad, GamepadStick::Left)is aVec2from-1to1, y down like screen coordinates, so adding it to a position moves that way. Small wobbles around the center read as zero.ctx.gamepad_axis(pad, GamepadAxis::RightTrigger)goes from0(released) to1(fully pulled): use it for throttles and charge-ups. The triggers also count as buttons once pulled past halfway.
examples/gamepad.rs gives every connected pad its own crab:
cargo run --example gamepad.
Gamepads need the gamepad cargo feature, on by default. Without it, or in
headless runs, ctx.gamepads() is empty. On Linux, building
it needs the udev headers (sudo apt-get install libudev-dev).
Named input actions
Bind physical inputs to names once, then query those names in game systems. An action is a button, a scalar axis, or a vector axis. Several sources can control the same action; this makes keyboard and controller support share the same game logic. Configuration and rebinding may allocate. Event handling, frame resets and action queries allocate nothing.
fn main() {
let pad = Gamepad::from_slot(0).unwrap();
let mut bindings = ActionMap::default();
bindings.set(
"jump",
Action::Button(vec![
ButtonBinding::Key(Key::Space),
ButtonBinding::Gamepad {
pad,
button: GamepadButton::South,
},
]),
);
bindings.set(
"move",
Action::Axis2(vec![
Axis2Binding::Keys {
left: Key::A,
right: Key::D,
up: Key::W,
down: Key::S,
},
Axis2Binding::Gamepad {
pad,
stick: GamepadStick::Left,
deadzone: 0.2,
},
]),
);
bindings.set(
"throttle",
Action::Axis(vec![AxisBinding::Gamepad {
pad,
axis: GamepadAxis::RightTrigger,
deadzone: 0.1,
}]),
);
let saved = bindings.to_json().unwrap();
let restored = ActionMap::from_json(&saved).unwrap();
Game::new("actions", 1, 1).run_headless(0, |ctx| {
ctx.set_actions(restored);
ctx.feed_input(InputEvent::Key(Key::Space, true));
assert!(ctx.action_pressed("jump") && ctx.action_down("jump"));
let movement = ctx.action_axis2("move");
let throttle = ctx.action_axis("throttle");
assert_eq!((movement, throttle), (Vec2::ZERO, 0.0));
ctx.feed_input(InputEvent::Key(Key::Space, false));
assert!(ctx.action_released("jump"));
let mut changed = ctx.actions().clone();
changed.set("jump", Action::Button(vec![ButtonBinding::Key(Key::Enter)]));
ctx.set_actions(changed);
assert!(!ctx.action_pressed("jump"));
});
}
static void action_map_snippet() {
crabby::action_map bindings;
bindings.set("jump", crabby::action::button({
crabby::action_binding::key(CRABBY_KEY_SPACE),
crabby::action_binding::button({0}, CRABBY_GAMEPAD_SOUTH)}));
bindings.set("move", crabby::action::axis2({
crabby::action_binding::keys2(CRABBY_KEY_A, CRABBY_KEY_D, CRABBY_KEY_W, CRABBY_KEY_S),
crabby::action_binding::stick({0}, crabby::stick::left, 0.2f)}));
bindings.set("throttle", crabby::action::axis({
crabby::action_binding::axis({0}, CRABBY_GAMEPAD_RIGHT_TRIGGER_AXIS, 0.1f)}));
const std::string saved = bindings.to_json();
auto restored = crabby::action_map::from_json(saved);
crabby::game("actions", 1, 1).run_headless(0, [&](crabby::ctx& ctx) {
ctx.set_actions(restored);
ctx.feed_input(crabby::input_event::key(CRABBY_KEY_SPACE, true));
if (!ctx.action_pressed("jump") || !ctx.action_down("jump")) throw std::runtime_error("missing action press");
const crabby::vec2 move = ctx.action_axis2("move");
const float throttle = ctx.action_axis("throttle");
if (move.x != 0 || move.y != 0 || throttle != 0) throw std::runtime_error("unexpected axis input");
ctx.feed_input(crabby::input_event::key(CRABBY_KEY_SPACE, false));
if (!ctx.action_released("jump")) throw std::runtime_error("missing action release");
auto changed = ctx.actions();
changed.set("jump", crabby::action::button({crabby::action_binding::key(CRABBY_KEY_ENTER)}));
ctx.set_actions(changed);
if (ctx.action_pressed("jump")) throw std::runtime_error("rebinding generated a press");
});
}
feed_input supplies events manually for headless tests and replay. Live
keyboard, mouse and gamepad input updates the same action state automatically.
Frame edges clear after each frame (after each tick in headless runs).
| Action | Rust sources | C++ sources |
|---|---|---|
Action::Button / action::button | ButtonBinding::Key, Mouse, Gamepad | action_binding::key, mouse, button |
Action::Axis / action::axis | AxisBinding::Keys, Gamepad | action_binding::keys, axis |
Action::Axis2 / action::axis2 | Axis2Binding::Keys, Gamepad | action_binding::keys2, stick |
For example, add ButtonBinding::Mouse(MouseButton::Left) or
action_binding::mouse(CRABBY_MOUSE_LEFT) to a button action. A scalar key pair
takes the negative key followed by the positive key. Four-key vectors take
left, right, up, down, so WASD is A, D, W, S. Opposing keys cancel and keyboard
diagonals are normalized. Vectors use screen coordinates: positive Y is down.
Buttons combine with OR. action_pressed fires when the first source becomes
held; action_released fires when the last held source releases. Pressing a
second source or releasing one of two held sources produces neither edge.
A press and release within one frame preserves both edges.
Scalar and vector axes choose the source with the largest absolute value or
vector length; the first binding wins ties. Sources do not add together.
Sticks use a radial deadzone; scalar gamepad axes use a component deadzone.
Inside it the value is zero, and outside it the remaining range is rescaled
to reach one. A deadzone must be finite and in 0..1; invalid values disable
that source. Non-finite analog input reads zero. Disconnection releases the
pad’s buttons and centers its axes. An unconnected slot starts at zero.
Use action_down, action_pressed, action_released, action_axis and
action_axis2 on Ctx / ctx. Missing names and queries of the wrong kind
return false or zero. Names are case-sensitive. Queries borrow their names
and allocate nothing.
To rebind, copy ctx.actions(), call set or remove, then install it with
ctx.set_actions. Rust uses .clone() to copy the map; C++ returns an owned
map. Installation resets action edges and uses currently held inputs as the
baseline. Rebinding a held key therefore does not generate a new press.
ActionMap::to_json / action_map::to_json save bindings only, with the same
serde JSON format in both languages. Write that string to your settings file
using normal file I/O. Read it back with ActionMap::from_json /
action_map::from_json, then install the result. Invalid JSON fails before
changing active bindings. Rust returns Result; C++ throws the usual engine
exception. Button state is never saved.
The C API uses an owned CrabbyActionMap: create/free, set/remove,
load/save JSON, then crabby_ctx_set_actions. crabby_action_map_set takes
a kind (0 button, 1 scalar, 2 vector) and an array of CrabbyActionBinding.
CrabbyActionSource documents each source’s fields; unused fields are zero.
Save follows the standard two-call buffer convention: query the length,
allocate length + 1 bytes, then save again. Context queries are named
crabby_ctx_action_down, pressed, released, axis and axis2.
This map has no chords, action contexts, smoothing, interactive binding UI, or device-specific profiles. Each gamepad binding names a stable controller slot, the same slot used by the raw gamepad API.
Events: messages between systems
A system often needs to tell other systems that something happened: a unit died, a building finished, the player scored. Send an event and let whoever cares read it, instead of having systems call each other or poll flags.
#![allow(unused)]
fn main() {
struct Health(i32);
struct Died {
who: Entity,
}
/// Sends one `Died` per entity out of health, from inside a query.
fn deaths(ctx: &mut Ctx) {
ctx.world.query::<(Entity, &Health)>().for_each(|(e, h)| {
if h.0 <= 0 {
ctx.events.send(Died { who: e });
}
});
}
/// Any later system (or this one next tick) reads them, once each.
fn funerals(ctx: &mut Ctx) {
for d in ctx.events.read::<Died>() {
ctx.world.despawn(d.who);
}
}
}
struct Hp { int value; };
struct Died { crabby::entity who; };
// Sends one Died per entity out of health, from inside a loop.
static void deaths(crabby::ctx& ctx) {
ctx.world().each([&](crabby::entity e, const Hp& hp) {
if (hp.value <= 0) ctx.send(Died{e});
});
}
// Any later system (or this one next tick) reads them, once each.
static void funerals(crabby::ctx& ctx) {
for (const Died& d : ctx.read<Died>()) ctx.despawn_later(d.who);
}
Any type can be an event: a Rust type, or a plain C++ struct. In C, register the event struct like a component and pass its id. Events can be at most 16-byte aligned.
Who sees what
Every system has its own read position. read returns the events it hasn’t
seen yet, oldest first, and the next read returns only newer ones. So:
- A system after the sender, in the same tick, sees the events right away.
- A system before the sender sees them on its next run, the next tick.
- A system that runs every tick (or every frame) sees every event exactly
once. With
sim_system_every(n, ..), it sees everything sent since its last run. - Events from
setupreach the first tick.
Within simulation systems this is deterministic: the same sends in the same order give the same reads, whatever the frame rate.
Events are dropped once every system has had its chance: when they’re at
least one frame old and older than the slowest sim_system_every. You
never clear them yourself. peek shows everything still kept without
moving the read position.
Sending from queries
ctx.events is a separate field from ctx.world, so you can send inside a
query and read while changing the world (as above). C++ ctx.send works
inside world().each but not each_par, which runs on many threads:
collect there, then send.
Tools and agents see events
Reflect an event type and the control endpoint and the MCP server can collect it: an agent subscribes, runs ticks, and gets every event with the tick it was sent in, as JSON. In C and C++, events whose type is reflected (described with its fields) show up the same way.
#![allow(unused)]
fn main() {
#[derive(serde::Serialize)]
struct Scored {
points: u32,
}
fn setup_tools(ctx: &mut Ctx) {
// Tools (the control endpoint, MCP) can now collect Scored events as JSON.
ctx.events.reflect::<Scored>();
}
}
// Described with its fields: tools can collect Died events as JSON.
ctx.world().reflect<Died>({crabby::field("who", &Died::who)});
Collecting costs nothing until a tool subscribes.
Cost
Sending is a push onto a vector, and reading returns a slice of it: about
4 ns per event sent, read and dropped (100 000 events per tick cost
0.43 ms, cargo run --release -p ponko_bench -- events). Steady state
allocates nothing.
Events aren’t saved with the world (world.save); a save between ticks
drops the ones in flight.
The simulation clock
Simulation systems run on their own clock, separate from the frame rate. The game can pause, step and fast-forward the sim while rendering and input keep running normally.
#![allow(unused)]
fn main() {
fn controls(ctx: &mut Ctx) {
if ctx.key_pressed(Key::Space) {
if ctx.is_paused() { ctx.resume() } else { ctx.pause() }
}
if ctx.key_pressed(Key::Period) {
ctx.step(); // one tick while paused
}
if ctx.key_pressed(Key::Num4) {
ctx.set_sim_speed(64.0); // fast-forward
}
}
Game::new("Colony", 1280, 720).sim_system(ants).system(controls);
}
static void controls(crabby::ctx& ctx) {
if (ctx.key_pressed(CRABBY_KEY_SPACE)) {
if (ctx.is_paused()) ctx.resume(); else ctx.pause();
}
if (ctx.key_pressed(CRABBY_KEY_PERIOD))
ctx.step(); // one tick while paused
if (ctx.key_pressed(CRABBY_KEY_NUM_4))
ctx.set_sim_speed(64); // fast-forward
}
// crabby::game("Colony", 1280, 720).sim_system(ants).system(controls);
Put controls in a frame system: frame systems keep running while the sim is
paused.
What the clock guarantees
- Ticks are always the same length.
ctx.dt()in a sim system is the fixed step (1 / fixed_hz), at every speed. Speed only changes how many ticks run per frame, so a run at 64× gives the same result as one at 1×. - The game stays responsive. At most 64 ticks run per frame. If the sim can’t keep up, it runs as fast as it can instead of freezing the window.
ctx.sim_tick()counts ticks;ctx.sim_time()is simulated seconds as anf64(no drift after hours of simulated time).
Doing work less often
Not everything needs to run 60 times a second. There are two tools, and they combine.
A whole system at a lower rate:
#![allow(unused)]
fn main() {
Game::new("Colony", 1280, 720)
.sim_system(movement) // every tick
.sim_system_every(10, plan_routes) // 6 times a second
.sim_system_every(10, grow_food) // also 6/s, but on other ticks
}
crabby::game("Colony", 1280, 720)
.sim_system(movement, "movement") // every tick
.sim_system(plan_routes, "plan_routes", 10) // 6 times a second
.sim_system(grow_food, "grow_food", 10); // also 6/s, but on other ticks
Inside plan_routes, ctx.dt() is 10 ticks’ worth of time, so rates stay
right. Systems with the same interval are spread over different ticks, so
their cost doesn’t pile up on one frame.
Entities taking turns: each tick, only 1/n of the entities do the expensive part.
#![allow(unused)]
fn main() {
fn think(ctx: &mut Ctx) {
let turn = ctx.turns(8); // each ant thinks every 8th tick
ctx.world.query::<(Entity, &mut Brain)>().par_for_each(|(e, brain)| {
if turn.includes(e) {
brain.decide(turn.dt()); // time since this ant's last turn
}
});
}
}
struct Brain { std::uint32_t plans; };
static void think(crabby::ctx& ctx) {
const crabby::turns turn = ctx.turns(8); // each ant thinks every 8th tick
ctx.world().each_par([&](crabby::entity e, Brain& brain) {
if (turn.includes(e))
brain.plans += 1; // turn.dt(): time since this ant's last turn
});
}
This keeps the per-tick cost flat instead of spiking every 8th tick, and
works in par_for_each (C++: each_par).
Smooth motion between ticks
If the sim rate is lower than the frame rate, ctx.fixed_alpha() (0..1)
tells a frame system how far it is between the last tick and the next, for
interpolating positions when drawing.
Spatial queries
“Who is near me?” is the core question of most simulations. Checking every pair is O(n²); a spatial grid answers it by looking only at nearby cells.
The built-in grid
Turn it on with a cell size near your usual query radius:
#![allow(unused)]
fn main() {
Game::new("Boids", 1280, 720)
.spatial_grid(24.0)
.sim_system(flock);
}
At the start of every tick, every entity with a Transform2D is indexed into
ctx.spatial. Query it from any sim system:
#![allow(unused)]
fn main() {
struct Neighbors(usize);
fn count_neighbors(ctx: &mut Ctx) {
let grid = &ctx.spatial;
ctx.world
.query::<(Entity, &Transform2D, &mut Neighbors)>()
.par_for_each(|(me, t, n)| {
n.0 = 0;
grid.for_each_in_radius(t.position, 10.0, |o| {
if o.entity != me { n.0 += 1; }
});
});
}
}
// crabby::game("Boids", 1280, 720).spatial_grid(24).sim_system(count_neighbors);
static void count_neighbors(crabby::ctx& ctx) {
const auto grid = ctx.spatial_view();
ctx.world().each_par([&](crabby::entity me, const crabby::transform2d& t, Neighbors& n) {
n.count = 0;
grid.for_each_in_radius(t.position, 10, [&](const crabby::grid_hit& o) {
if (crabby::bits(o.entity) != me) ++n.count;
});
});
}
The grid is read-only while you query the world, so this runs on all cores.
In C++, ctx.spatial_view() gives a view that walks the grid inline (no
call into the engine per query); ctx.spatial_radius(center, r, hits)
fills a vector instead, which is simpler but slower in hot loops.
| Method | Visits |
|---|---|
for_each_in_radius(center, r, f) | entries within r of center |
for_each_in_rect(min, max, f) | entries inside the rectangle |
count_in_radius(center, r) | how many entries are within r |
nearest(center, max_r, accept) | the closest entry accept agrees to |
Each visit gives a GridEntry with entity and position.
Every 32 ticks (SPATIAL_SORT_EVERY) the built-in system also reorders
entities by grid cell (World::sort_by with spatial_key), so neighbors in
space are neighbors in memory. With a million boids this cut a tick from
62 ms to 41 ms.
Your own grid, with data
The built-in grid stores only entity and position. When neighbors need more
data, for example a boid’s velocity, keep your own SpatialGrid<T> inside
the system and fill it with rebuild_with_data:
#![allow(unused)]
fn main() {
/// The flocking system. It owns a grid that stores each boid's velocity, so
/// neighbors' velocities can be read while the world is busy with the query.
fn flocking(bounds: Vec2) -> impl FnMut(&mut Ctx) {
let mut grid = SpatialGrid::with_data(RADIUS);
move |ctx: &mut Ctx| {
let dt = ctx.dt();
// Keep boids that are close in space close in memory, so neighbor
// lookups hit the cache (1M boids: 62 -> 40 ms/tick).
if ctx.sim_tick().is_multiple_of(SPATIAL_SORT_EVERY) {
ctx.world.sort_by::<Transform2D>(|t| spatial_key(t.position, RADIUS));
}
grid.rebuild_with_data(
ctx.world
.query::<(Entity, &Transform2D, &Vel)>()
.iter()
.map(|(e, t, v)| (e, t.position, v.0)),
);
let grid = &grid;
ctx.world
.query::<(Entity, &mut Transform2D, &mut Vel)>()
.par_for_each(|(me, t, v)| {
let (mut away, mut heading, mut center, mut n) = (Vec2::ZERO, Vec2::ZERO, Vec2::ZERO, 0.0);
grid.for_each_in_radius(t.position, RADIUS, |o| {
if o.entity == me {
return;
}
let d = t.position - o.position;
away += d / d.length_squared().max(1.0);
heading += o.data;
center += o.position;
n += 1.0;
});
let mut steer = away * 900.0;
if n > 0.0 {
steer += (heading / n - v.0) * 1.5 + (center / n - t.position) * 1.2;
}
let speed = (v.0 + steer * dt).length().clamp(MIN_SPEED, MAX_SPEED);
v.0 = (v.0 + steer * dt).normalize_or(Vec2::X) * speed;
t.position = (t.position + v.0 * dt).rem_euclid(bounds);
t.rotation = v.0.to_angle();
});
}
}
}
// The flocking system. Its grid stores each boid's index into `velocities`,
// so neighbors' velocities can be read while the query borrows the world.
struct Flocking {
explicit Flocking(vec2 world_size) : bounds(world_size) {}
vec2 bounds;
std::shared_ptr<crabby::grid> grid = std::make_shared<crabby::grid>(RADIUS);
std::shared_ptr<std::vector<crabby::grid_item>> items = std::make_shared<std::vector<crabby::grid_item>>();
std::shared_ptr<std::vector<vec2>> velocities = std::make_shared<std::vector<vec2>>();
void operator()(crabby::ctx& ctx) {
auto world = ctx.world();
const float dt = ctx.dt();
// Keep boids that are close in space close in memory (cache hits).
if (ctx.sim_tick() % SPATIAL_SORT_EVERY == 0) world.sort_spatial(RADIUS);
items->clear();
velocities->clear();
world.each([&](crabby::entity e, const crabby::transform2d& t, const Vel& v) {
items->push_back({crabby::entity_of(e), t.position, std::uint32_t(velocities->size())});
velocities->push_back(v.v);
});
grid->rebuild(*items);
const auto view = grid->view();
const auto& vels = *velocities;
const vec2 b = bounds;
world.each_par([&](crabby::entity me, crabby::transform2d& t, Vel& v) {
vec2 away{0, 0}, heading{0, 0}, center{0, 0};
float n = 0;
view.for_each_in_radius(t.position, RADIUS, [&](const crabby::grid_item& o) {
if (crabby::bits(o.entity) == me) return;
const vec2 d = t.position - o.position;
away += d / std::max(crabby::length_squared(d), 1.0f);
heading += vels[o.data];
center += o.position;
n += 1;
});
vec2 steer = away * 900.0f;
if (n > 0) steer += (heading / n - v.v) * 1.5f + (center / n - t.position) * 1.2f;
const vec2 next = v.v + steer * dt;
const float speed = std::min(std::max(crabby::length(next), MIN_SPEED), MAX_SPEED);
v.v = crabby::normalize_or(next, {1, 0}) * speed;
t.position = crabby::rem_euclid(t.position + v.v * dt, b);
t.rotation = crabby::to_angle(v.v);
});
}
};
C++ uses a crabby::grid, whose items carry a uint32_t of your own (here
an index into a velocity array), and walks it with grid->view().
Choosing a cell size
- About your query radius is a good start: a radius query then reads a 3×3 block of cells.
- Much smaller cells mean more cells to visit; much larger means more entries per cell to reject.
- Queries much larger than the world fall back to a plain scan, so huge radii stay correct and don’t get slow.
Pathfinding with flow fields
When thousands of units head for the same place (a rally point, the player, the nearest food), searching a path for each of them is wasteful. A flow field does one search from the goal outward and stores, for every cell of the map, which way to step. Each unit then just looks up the cell it stands on: one array read, the same cost for 10 units as for a million.
Two types do this:
NavGrid: the map. A cost per cell:1is normal ground, up to255is slower (mud, shallow water),NavGrid::WALL(0) can’t be entered.FlowField: the answer.compute(&nav, &goals)fills it; thendirection_at(pos)gives a unit vector toward the nearest goal anddistance_at(pos)how far away it is.
The map
#![allow(unused)]
fn main() {
let mut nav = NavGrid::new(100, 80, 4.0); // 100 × 80 cells, 4 world units each
for y in 0..60 {
nav.set_cost(40, y, NavGrid::WALL);
}
for x in 50..70 {
for y in 20..40 {
nav.set_cost(x, y, 6); // mud: six times slower
}
}
let mut flow = FlowField::new();
flow.compute(&nav, &[vec2(390.0, 10.0)]); // or several goals: the nearest wins
}
static void make_walls(crabby::nav_grid& nav) {
// 1 is normal ground, higher is slower (mud), nav_grid::wall blocks.
for (std::uint32_t y = 0; y < 60; ++y) nav.set_cost(40, y, crabby::nav_grid::wall);
for (std::uint32_t x = 50; x < 70; ++x)
for (std::uint32_t y = 20; y < 40; ++y) nav.set_cost(x, y, 6);
}
crabby::nav_grid nav(100, 80, 4.0f); // 100 x 80 cells, 4 world units each
make_walls(nav);
crabby::flow_field flow;
flow.compute(nav, crabby::vec2{390, 10}); // or several goals: the nearest wins
nav.origin (C++: set_origin) moves the grid: it is where cell (0, 0)’s
top-left corner is in the world. nav.cell_at(pos), nav.center_of(cell)
and nav.walkable(pos) convert between world points and cells. To fill a
whole map at once (from a tilemap, an image, a level file), write
nav.costs_mut() (C++: nav.costs()), one byte per cell, row by row.
Paths move in 8 directions. Diagonal steps cost √2 times a straight one, and never squeeze between two walls that touch at a corner. Slow ground counts as longer: a unit walks around a mud pit when the way around is shorter than wading through.
Steering
direction_at only reads, so any number of threads can use one field at
once. Look it up from a parallel query:
#![allow(unused)]
fn main() {
/// Every agent steers along the field; mud slows it down, walls stop it.
fn steer(ctx: &mut Ctx) {
let dt = ctx.dt();
let random = ctx.random();
// Taken out for the query, which borrows the world, then put back.
let paths = ctx.world.remove_resource::<Paths>().expect("paths");
ctx.world
.query::<(Entity, &mut Transform2D, &mut Agent)>()
.par_for_each(|(e, t, a)| {
let mut rng = random.of(e);
let cell = paths.nav.cell_at(t.position);
let slow = cell.and_then(|c| paths.nav.cost(c.x, c.y)).unwrap_or(1).max(1) as f32;
let want = paths.flow.direction_at(t.position) * (SPEED / slow);
a.vel = a.vel.lerp(want, 0.15) + rng.unit_vec2() * 3.0;
// Slide along walls instead of walking into them.
let next = t.position + a.vel * dt;
if paths.nav.walkable(next) {
t.position = next;
} else if paths.nav.walkable(vec2(next.x, t.position.y)) {
t.position.x = next.x;
} else if paths.nav.walkable(vec2(t.position.x, next.y)) {
t.position.y = next.y;
}
});
ctx.world.insert_resource(paths);
}
}
static void steer(crabby::ctx& ctx, const crabby::flow_field& flow) {
const float step = 40 * ctx.dt();
const crabby::flow_view view = flow.view(); // inline lookups, safe from every thread
ctx.world().each_par([&](crabby::transform2d& t) {
t.position += view.direction_at(t.position) * step;
});
}
In C++, flow.view() gives a flow_view whose direction_at is inline,
with no call into the engine per lookup. Take a new view after every
compute.
The direction is zero at a goal, on a wall, outside the grid and where no
goal can be reached; distance_at returns nothing (None, C++
std::nullopt) in the last case. Moving a unit straight along the
direction can clip a wall corner, so the example slides along walls with
nav.walkable instead of stepping onto them.
When the goal moves
Call compute again: the field reuses its memory, so recomputing every
time the player clicks or a target moves is fine. For many different
destinations (each squad to its own target), keep one FlowField per
destination, not one per unit.
Measured in release on a MacBook Pro (M4 Max) (cargo run --release -p ponko_bench -- flow):
| What | Time |
|---|---|
compute, 256 × 256 cells | 1.25 ms |
compute, 1024 × 1024 cells | 13.0 ms |
1 million direction_at lookups, parallel | 0.29 ms |
The flow example (cargo run --release --example flow) has 20 000 units
finding their way around walls and through mud to wherever you click; H
shows the distance field. Headless with a million units
(HEADLESS=1 AGENTS=1000000 cargo run --release --example flow) a tick
takes 3.2 ms, steering and all.
For a whole game built on flow fields, see the battle example: two
armies, one field each, rerouted when you click a new rally point
(cargo run --release --example battle).
Attachments: things that follow things
Health bars over units, turrets on tanks, selection rings, a carried flag:
entities that should stay at some place relative to another entity. Give
them an Attached component and add the built-in follow_attachments
system after the systems that move things:
#![allow(unused)]
fn main() {
struct Tank {
speed: f32,
}
fn drive(ctx: &mut Ctx) {
ctx.world
.query::<(&mut Transform2D, &Tank)>()
.for_each(|(t, tank)| t.position.x += tank.speed);
}
#[test]
fn turrets_ride_on_tanks() {
let mut turret = None;
let world = Game::new("tanks", 800, 600)
.sim_system(drive)
.sim_system(follow_attachments) // after the systems that move things
.run_headless(10, |ctx| {
let tank = ctx.world.spawn((Transform2D::at(vec2(0.0, 0.0)), Tank { speed: 1.0 }));
let on_top = Attached::to(tank).offset(vec2(0.0, -4.0));
turret = Some(ctx.world.spawn((Transform2D::default(), on_top)));
});
assert_eq!(position(&world, turret.unwrap()), vec2(10.0, -4.0));
}
}
struct Tank { float speed; };
static void turrets_ride_on_tanks() {
crabby::entity turret = 0;
auto world = crabby::game("tanks", 800, 600)
.sim_system([](crabby::ctx& ctx) {
ctx.world().each([](crabby::transform2d& t, const Tank& tank) { t.position.x += tank.speed; });
}, "drive")
.follow_attachments() // after the systems that move things
.run_headless(10, [&](crabby::ctx& ctx) {
auto w = ctx.world();
const auto tank = w.spawn(crabby::transform_at({0, 0}), Tank{1});
turret = w.spawn(crabby::transform_at({0, 0}), crabby::attach_to(tank, {0, -4}));
});
const auto at = world.get<crabby::transform2d>(turret).position;
if (at != crabby::vec2{10, -4}) { std::fprintf(stderr, "turret at %f,%f\n", double(at.x), double(at.y)); std::abort(); }
}
Every tick, each attached entity’s Transform2D gets:
- position: the followed entity’s position plus
offset, turned by its rotation and stretched by its scale (so flipping a sprite with a negative x scale mirrors what hangs off it); - rotation: the followed entity’s rotation plus
rotation.
Its own scale is left alone, so a small icon stays small on a big unit.
| Rust | C++ | Meaning |
|---|---|---|
Attached::to(e) | crabby::attach_to(e) | right on top of e |
.offset(v) | attach_to(e, v) | v away, in e’s space |
.rotated(r) | attach_to(e, v, r) | turned r radians more |
Chains and cleanup
Attachments can be attached to attachments (an arm on a body, a hand on the arm): each link is placed in its parent’s space, from the root down, all in one tick, whatever order the entities were spawned in.
When the entity at the top of a chain is despawned, everything attached to it (directly or down the chain) is despawned in the same tick. A chain that loops back on itself, or is more than 64 links deep, is treated as broken and despawned too.
Cost
Placing runs on all cores and allocates nothing in steady state: about
0.7 ms per tick for 100,000 attachments (release, M-series Mac,
cargo run --release -p ponko_bench -- attach). Entities without
Attached cost nothing.
Attachments are a sim system, so they’re deterministic, saved with the world
(Attached is reflected) and visible to tools and agents like any other
component.
Physics: bodies that fall, stack and bounce
Crates that tumble, balls that bounce, doors that push things, triggers that
notice who walked in: 2D rigid-body physics, built on
rapier. It’s behind the physics feature:
ponko = { version = "0.0.1", features = ["physics"] }
(The C library, ponko_ffi, has it on by default.) For 3D bodies, terrain
and a walking character, see 3D physics.
Turn it on with gravity in pixels per second² (y is down) and add the systems that react to it:
#![allow(unused)]
fn main() {
Game::new("Physics", W as u32, H as u32)
.physics(vec2(0.0, 980.0)) // pixels/s², y is down
.sim_system(ring_the_bell)
}
auto world = crabby::game("Physics", 1000, 700)
.physics({0, 980}) // pixels/s², y is down
.sim_system(ring_the_bell, "bell")
The physics step is a simulation system, placed where you call physics:
systems added before it move things, systems added after it see the result.
Bodies and colliders
An entity becomes a physics body with three components: a Transform2D, a
RigidBody (how it moves) and a Collider (its shape, in pixels):
#![allow(unused)]
fn main() {
fn build_level(ctx: &mut Ctx) {
// Fixed bodies never move: the ground and two walls.
for (at, size) in [
(vec2(W / 2.0, H - 10.0), vec2(W, 20.0)),
(vec2(10.0, H / 2.0), vec2(20.0, H)),
(vec2(W - 10.0, H / 2.0), vec2(20.0, H)),
] {
ctx.world
.spawn((Transform2D::at(at), RigidBody::fixed(), Collider::rect(size)));
}
// A bouncy ball and a crate, both dynamic: gravity and collisions move them.
ctx.world.spawn((
Transform2D::at(vec2(300.0, 100.0)),
RigidBody::dynamic(),
Collider::circle(16.0).restitution(0.8),
));
ctx.world.spawn((
Transform2D::at(vec2(320.0, 0.0)),
RigidBody::dynamic(),
Collider::rect(vec2(40.0, 40.0)).friction(0.9),
));
// A ramp: any convex shape of up to 8 corners, around its position.
ctx.world.spawn((
Transform2D::at(vec2(150.0, H - 80.0)),
RigidBody::fixed(),
Collider::polygon(&[vec2(-140.0, 60.0), vec2(140.0, 60.0), vec2(-140.0, -60.0)]),
));
// A sensor reports overlaps but doesn't push.
ctx.world.spawn((
Transform2D::at(vec2(800.0, H - 60.0)),
RigidBody::fixed(),
Collider::rect(vec2(120.0, 80.0)).sensor(),
Bell { rang: 0.0 },
));
}
}
struct Bell { float rang; };
static void build_level(crabby::world_view world) {
// Fixed bodies never move: the ground.
world.spawn(crabby::transform_at({500, 690}), crabby::fixed_body(), crabby::rect_collider({1000, 20}));
// A bouncy ball and a crate, both dynamic: gravity and collisions move them.
auto ball = crabby::circle_collider(16);
ball.restitution = 0.8f;
world.spawn(crabby::transform_at({300, 100}), crabby::dynamic_body(), ball);
auto box = crabby::rect_collider({40, 40});
box.friction = 0.9f;
world.spawn(crabby::transform_at({320, 0}), crabby::dynamic_body(), box);
// A ramp: any convex shape of up to 8 corners, around its position.
world.spawn(crabby::transform_at({150, 620}), crabby::fixed_body(),
crabby::polygon_collider({{-140, 60}, {140, 60}, {-140, -60}}));
// A sensor reports overlaps but doesn't push.
auto bucket = crabby::rect_collider({120, 80});
bucket.sensor = true;
world.spawn(crabby::transform_at({800, 640}), crabby::fixed_body(), bucket, Bell{0});
}
| Rust | C++ | Meaning |
|---|---|---|
RigidBody::dynamic() | crabby::dynamic_body() | moved by gravity and collisions |
RigidBody::fixed() | crabby::fixed_body() | never moves: ground, walls |
RigidBody::kinematic() | crabby::kinematic_body() | moved only by you; pushes, isn’t pushed |
Collider::circle(r) | crabby::circle_collider(r) | |
Collider::rect(size) | crabby::rect_collider(size) | |
Collider::capsule(h, r) | crabby::capsule_collider(h, r) | vertical, good for characters |
Collider::polygon(&points) | crabby::polygon_collider({points...}) | convex, 3 to 8 corners: ramps, rocks, ships |
.density(d), .friction(f), .restitution(r) | fields of the same name | mass per pixel², 0 ice..1 rubber, bounciness |
.sensor() | .sensor = true | reports overlaps, doesn’t push |
.offset(v) | .offset = v | shape moved from the entity’s position |
A polygon is the convex hull of its corners, given around the entity’s
position in any order; collider.shape.points() reads them back (C++:
world.collider_points(e)). For a concave shape, such as an L, split it
into convex pieces on separate bodies.
A rigid body also has gravity_scale, linear_damping, angular_damping,
lock_rotation (characters that shouldn’t tip over) and ccd (continuous
collision detection, so bullets don’t pass through thin walls).
Transform2D stays the source of truth. After each step, dynamic bodies
write their new position and rotation into it, so sprites, attachments, the
spatial grid and everything else just follow. Set a transform yourself and
the body teleports there. Change a RigidBody or Collider and the body is
rebuilt; despawn the entity (or remove either component) and it’s gone.
Add a Velocity component to read a body’s speed after every step, or to
set it (a jump, a thrown crate, a moving platform):
#![allow(unused)]
fn main() {
ctx.world.insert(player, Velocity::linear(vec2(0.0, -400.0))); // jump: y is down
}
In C++, crabby::velocity is a plain struct, so it also works as a query
column. Bodies and colliders need converting, so like sprites they’re read
and written with get/set, not queried.
Collisions
When two colliders start or stop touching, the step sends a
CollisionStarted or CollisionStopped event. sensor tells overlaps with
a sensor apart from real contacts:
#![allow(unused)]
fn main() {
fn ring_the_bell(ctx: &mut Ctx) {
let mut rang = false;
for c in ctx.events.read::<CollisionStarted>() {
rang |= c.sensor && [c.a, c.b].iter().any(|&e| ctx.world.has::<Bell>(e));
}
let dt = ctx.dt();
for bell in ctx.world.query::<&mut Bell>().iter() {
bell.rang = if rang { 1.0 } else { (bell.rang - dt * 2.0).max(0.0) };
}
}
}
static void ring_the_bell(crabby::ctx& ctx) {
auto world = ctx.world();
bool rang = false;
for (const crabby::collision& c : ctx.collisions_started())
rang |= c.sensor && (world.has<Bell>(crabby::bits(c.a)) || world.has<Bell>(crabby::bits(c.b)));
world.each([&](Bell& bell) { bell.rang = rang ? 1.0f : std::max(0.0f, bell.rang - ctx.dt() * 2); });
}
Like all events, each system sees each collision once.
Rays, overlaps and pushes
ctx.physics answers questions about the world as of the last step, and
gives bodies a push:
#![allow(unused)]
fn main() {
fn blast(ctx: &mut Ctx) {
if !ctx.key_pressed(Key::Space) {
return;
}
let center = ctx.mouse_world();
let mut near = Vec::new();
ctx.physics.entities_in_circle(center, 200.0, &mut near);
for e in near {
let Some(t) = ctx.world.get::<Transform2D>(e) else {
continue;
};
let away = (t.position - center).normalize_or(Vec2::NEG_Y);
if let Some(mass) = ctx.physics.mass(e) {
ctx.physics.apply_impulse(e, away * mass * 600.0);
}
}
}
/// What's under the mouse, straight down: a laser from the cursor.
fn laser(ctx: &mut Ctx) -> Option<RayHit> {
ctx.physics.cast_ray(ctx.mouse_world(), vec2(0.0, 1.0), H)
}
}
static void blast(crabby::ctx& ctx, crabby::vec2 center) {
std::vector<CrabbyEntity> near;
ctx.entities_in_circle(center, 200, near);
for (CrabbyEntity found : near) {
const crabby::entity e = crabby::bits(found);
const crabby::vec2 away = crabby::normalize_or(ctx.world().get<crabby::transform2d>(e).position - center, {0, -1});
if (auto mass = ctx.mass(e)) ctx.apply_impulse(e, away * *mass * 600.0f);
}
}
static std::optional<crabby::ray_hit> laser(crabby::ctx& ctx, crabby::vec2 from) {
return ctx.cast_ray(from, {0, 1}, 700); // what's straight below
}
cast_ray(origin, direction, max_distance): the first collider hit, with the point, the surface normal and the distance.entities_at(point, &mut out)andentities_in_circle(center, r, &mut out): everything there.outis reused, so no allocation per call.apply_impulse(e, impulse): a one-off push in mass × pixels/s (multiply bymass(e)to get a speed change).set_gravity(g): change the pull for everyone.
New bodies join at the next step, so a query in the tick you spawned them won’t see them yet.
Determinism and cost
The same start gives the same result, tick after tick, on one machine: the
physics step runs inside the simulation clock with a fixed
time step. For bit-identical results across different CPUs and operating
systems (lockstep multiplayer, replays), use the physics-determinism
feature instead, which turns on rapier’s cross-platform determinism at a
small speed cost.
Bodies cost only where they exist: entities without a RigidBody are never
looked at, so the million-entity paths stay as fast as before. Compared with
calling rapier directly, Crabby’s syncing adds about 3–8%
(cargo run --release -p ponko_bench -- physics):
| Boxes falling into a pile, 300 ticks | Crabby | rapier alone |
|---|---|---|
| 1,000 | 35.1 ms | 32.6 ms |
| 10,000 | ~1.9 s | ~1.8 s |
Try it: cargo run --release --example physics --features physics. Left
click drops crates, right click balls, Space blasts things away.
Multiplayer: deterministic lockstep
Lockstep is how RTS and simulation games do multiplayer: every player runs the whole simulation, and only inputs cross the network. As long as everyone applies the same inputs in the same order to the same start, everyone has the same world, whether there are 10 units or 100,000. A tick costs the same bandwidth either way: about 50 bytes per player.
Crabby’s simulation is built for it already: a fixed clock, seeded randomness per (seed, tick, system, entity), and deterministic queries and physics. Lockstep adds the session: 2–8 players, plain UDP, no server to run.
Host and join
One player hosts, the others join by address:
#![allow(unused)]
fn main() {
let net = match args.first().map(String::as_str) {
Some("host") => Net::host(format!("0.0.0.0:{}", args.get(1).map_or("7777", |p| p)), 2),
Some("join") => Net::join(args.get(1).map_or("127.0.0.1:7777", |a| a)),
_ => {
eprintln!("usage: lockstep host [port] | join <host:port> | steam | steam-join");
return;
}
}
.expect("could not open a socket");
play(net);
}
fn play(net: Net) {
Game::new("Lockstep", 800, 600)
.lockstep(net)
.system(read_keys) // frame system: input out
.sim_system(move_crabs) // sim system: everyone's input in
.system(draw)
.run(|ctx| {
ctx.world.reflect::<Crab>(); // compared between peers
for player in 0..2 {
ctx.world.spawn((
Transform2D::at(vec2(250.0 + 300.0 * player as f32, 300.0)),
Crab {
player,
push: Vec2::ZERO,
},
));
}
});
}
crabby::game game("RTS", 1280, 720);
if (hosting)
game.lockstep_host("0.0.0.0:7777", 2); // 2 players, 3 ticks input delay
else
game.lockstep_join("192.168.1.20:7777");
game.sim_system(send_orders).sim_system(obey_orders).run([](crabby::ctx&) {});
The host is player 0; the others get 1, 2, … in the order they join. The game starts when everyone is in. Until then, and whenever a player’s input is late, the simulation waits: frames (drawing, UI) go on, ticks don’t.
Inputs out, inputs in
A player’s input is whatever you send with ctx.net.set_input(&value): a
serde type in Rust (a plain struct in C++, up to 1024 bytes). Set it in a
frame system from the keys and mouse; the session sends it. Then
simulation systems read every player’s input for the tick and apply
it:
#![allow(unused)]
fn main() {
/// What one player does in one tick: all that crosses the network.
#[derive(Serialize, Deserialize, Default)]
struct Pad {
dir: Vec2,
shove: bool,
}
/// Reads this player's keys every frame; the session sends them.
fn read_keys(ctx: &mut Ctx) {
let mut pad = Pad::default();
for (key, d) in [
(Key::Left, -Vec2::X),
(Key::Right, Vec2::X),
(Key::Up, -Vec2::Y),
(Key::Down, Vec2::Y),
] {
if ctx.key_down(key) {
pad.dir += d;
}
}
pad.shove = ctx.key_down(Key::Space);
ctx.net.set_input(&pad);
}
/// The simulation: every peer runs it on every player's input, in the
/// same order, so every peer ends up with the same world.
fn move_crabs(ctx: &mut Ctx) {
let pads: Vec<Pad> = (0..ctx.net.players())
.map(|p| ctx.net.input::<Pad>(p).unwrap_or_default())
.collect();
let dt = ctx.dt();
let crabs: Vec<(Entity, Vec2, u32)> = ctx
.world
.query::<(Entity, &Transform2D, &Crab)>()
.iter()
.map(|(e, t, c)| (e, t.position, c.player))
.collect();
for &(e, at, player) in &crabs {
let pad = &pads[player as usize];
if pad.shove {
for &(other, other_at, _) in &crabs {
if other != e && at.distance(other_at) < 120.0 {
ctx.world.get_mut::<Crab>(other).unwrap().push += (other_at - at).normalize_or_zero() * 900.0;
}
}
}
let crab = *ctx.world.get::<Crab>(e).unwrap();
let t = ctx.world.get_mut::<Transform2D>(e).unwrap();
t.position += (pad.dir.normalize_or_zero() * 250.0 + crab.push) * dt;
t.position = t.position.clamp(vec2(40.0, 40.0), vec2(760.0, 560.0));
ctx.world.get_mut::<Crab>(e).unwrap().push = crab.push * 0.9;
}
}
}
struct Order { float x, y; bool go; }; // what one player did this tick
struct Owner { std::uint32_t player; };
static void send_orders(crabby::ctx& ctx) {
// Usually from the mouse, in a frame system; offline it just works too.
ctx.set_input(Order{100, 50, ctx.sim_tick() % 30 == 0});
}
static void obey_orders(crabby::ctx& ctx) {
// Every peer applies every player's input, in the same order.
std::vector<std::optional<Order>> orders;
for (std::uint32_t p = 0; p < ctx.players(); ++p) orders.push_back(ctx.input<Order>(p));
ctx.world().each([&](crabby::transform2d& t, const Owner& owner) {
if (owner.player < orders.size() && orders[owner.player] && orders[owner.player]->go)
t.position = {orders[owner.player]->x, orders[owner.player]->y};
});
}
The last input set before a tick goes into it and is then cleared, so set
it every frame for held keys. Without a session, ctx.net is offline: one
player whose input applies on the next tick. The same code runs alone and
online, so you can build the single-player game first.
Input delay
Input set at tick t is used at tick t + input_delay on every machine
(default 3 ticks, 50 ms at 60 Hz). That’s the time it has to reach the
others; as long as the round trip is shorter, nobody ever waits. A longer
delay hides a worse connection but makes controls feel laggier. Set it on
the host: Net::host(addr, players)?.input_delay(5).
Packets get lost; nobody notices. Every packet repeats all inputs the other side hasn’t acknowledged yet, so the next one fills the gap.
Staying in sync
Lockstep only works if the simulation really is deterministic. The rules:
- Change the world only in simulation systems, driven by inputs. Frame systems may read it and draw it, never change it.
- Same start: the same setup and the same
Game::seedeverywhere (a joining player with another seed is refused). - No outside state: no wall clock, no
HashMapiteration order, no thread-dependent results. Usectx.random()for randomness;par_for_eachand the spatial grid are deterministic. - Physics across platforms: rapier gives the same result on the same
platform. Players on different OSes or CPUs need the
physics-determinismfeature.
When something slips through, Crabby tells you. Every checksum_every
ticks (default 60) the peers compare a hash of every
reflected component and resource; on a mismatch,
ctx.net.desync() reports the tick, the player and which components
differ, so you know where to look:
crabby lockstep: desync with player 1 at tick 119: Unit differ
Only reflected components are compared, so reflect the ones your simulation changes.
Status
ctx.net.state() says where the session is: Connecting, Running, or
Lost (a player was silent for longer than the timeout, 10 s by default,
or the host refused us; ctx.net.error() says why). ctx.net.player() and
players() say who’s who; stats() counts packets and bytes sent.
Over Steam
With the steam feature, Steam carries the packets: players meet in a
friends-only Steam lobby, invites come from the Steam overlay, and Steam’s
relays get through any router, with no port forwarding and no addresses
to type. The lockstep itself is the same, so nothing else in the game changes.
ponko = { version = "0.0.1", features = ["steam"] }
The host makes the lobby and invites; a friend either launches the game
from the invite (Steam passes +connect_lobby) or accepts it while the
game waits:
#![allow(unused)]
fn main() {
/// Steam: the host creates a friends-only lobby and invites from the
/// overlay; the friend accepts in Steam. No addresses, no port forwarding.
#[cfg(feature = "steam")]
fn steam_session(args: &[String]) -> Option<Net> {
let mode = args.first().map(String::as_str);
let invited = steam_lobby_from_args(); // launched by accepting an invite
if !matches!(mode, Some("steam" | "steam-join")) && invited.is_none() {
return None;
}
// 480 is Valve's test app; a real game uses its own app id.
let client = steamworks::Client::init_app(480).expect("Steam must be running and logged in");
if let Some(lobby) = invited {
return Some(Net::steam_join(&client, lobby));
}
if mode == Some("steam") {
return Some(Net::steam_host(&client, 2));
}
println!("waiting for you to accept a Steam invite...");
let lobby = steam_wait_for_invite(&client, std::time::Duration::from_secs(300))?;
Some(Net::steam_join(&client, lobby))
}
}
// ponko_ffi built with --features steam; 480 is Valve's test app.
crabby::game game("RTS", 1280, 720);
if (std::uint64_t lobby = crabby::steam_lobby_from_args()) // launched from an invite
game.lockstep_steam_join(480, lobby);
else if (hosting)
game.lockstep_steam_host(480, 2); // then ctx.steam_invite()
else
game.lockstep_steam_join(480, crabby::steam_wait_for_invite(480, 120000));
While connecting, ctx.net.steam_lobby() is the lobby, and
ctx.net.steam_invite() opens the overlay’s invite dialog (the example
does that on the I key). steamworks is re-exported, so the same Client
also gives you achievements, stats and the rest of Steam.
Shipping: the game needs Steam running, its Steam app id (480, Valve’s
test app “Spacewar”, works for development) and Valve’s libsteam_api
library next to the executable (libsteam_api.dylib, libsteam_api.so
or steam_api64.dll). Cargo puts it there for cargo run, examples and
tests; when you package the game, copy it from target/<profile>/. For C
and C++, build ponko_ffi with --features steam.
Try it with two Steam accounts on two machines:
cargo run --example lockstep --features steam -- steam on one, press I
to invite, and cargo run --example lockstep --features steam -- steam-join
on the other, then accept the invite.
Testing
Net::loopback(players) connects sessions in memory, so a test can run a
whole match headless, one thread per player, and check that every world
ends identical:
#![allow(unused)]
fn main() {
#[test]
fn two_peers_run_ten_thousand_ticks_bit_identical() {
// `play` runs each session in its own thread and returns every world.
assert_identical(&play(Net::loopback(2), 10_000, 200, nothing));
}
}
Cost
Measured with cargo run --release -p ponko_bench -- net: 10,000 ticks of
a small input-driven sim take 2 ms offline and 8.5 ms as two loopback
peers in two threads (under 1 µs extra per tick, mostly waiting for the
other thread). The guest sends about 48 bytes per tick. At 60 ticks per
second that’s under 3 KB/s per player, whatever the size of the world.
Try it: cargo run --example lockstep -- host 7777 in one terminal and
cargo run --example lockstep -- join 127.0.0.1:7777 in another.
Application messages (Rust)
For client/server games with their own packet format, own a NetMessages
endpoint and call poll() once per frame. This is independent of ctx.net:
there is no simulation barrier and the engine does not synchronize worlds.
A host accepts up to max_clients remote clients. Joining clients address
PeerId::HOST; hosts use the identifiers from MessageEvent::Connected.
The host can relay messages explicitly, or each P2P participant can host an
endpoint and join the others. Peer identifiers belong to one endpoint.
Lane::Reliable delivers complete messages once, in order, while the
connection survives. Lane::Unreliable never retries and discards messages
older than the most recently delivered message on that peer’s lane.
Ordering is independent in each direction. Renet handles fragmentation and
reassembly for both lanes. Each application payload is limited to 1 MiB;
an unreliable fragmented message is lost if any fragment is missing.
#![allow(unused)]
fn main() {
// The game owns the endpoint. In a game, call poll and drain events from
// a frame system, including frames where the simulation is paused.
let mut host = NetMessages::host("127.0.0.1:0", 8)?;
let mut client = NetMessages::join(host.local_addr().unwrap())?;
let deadline = Instant::now() + Duration::from_secs(5);
let mut received = false;
while Instant::now() < deadline && !received {
client.poll()?;
let mut send_hello = false;
for event in client.events() {
match event {
MessageEvent::Connected(_) => {
send_hello = true;
}
MessageEvent::Disconnected(_, reason) => eprintln!("{reason}"),
_ => {}
}
}
if send_hello {
client.send(PeerId::HOST, Lane::Reliable, b"hello")?;
}
host.poll()?;
// Collect actions separately because events() borrows the endpoint.
let mut reply_to = None;
for event in host.events() {
if let MessageEvent::Message { peer, bytes, .. } = event {
println!("received: {}", String::from_utf8_lossy(&bytes));
reply_to = Some(peer);
received = true;
}
}
if let Some(peer) = reply_to {
host.send_serde(peer, Lane::Unreliable, &42u32)?;
}
std::thread::sleep(Duration::from_millis(1));
}
}
// The game owns the endpoint. In a game, poll it and read its events in
// a frame system, including frames where the simulation is paused.
auto host = crabby::net_messages::host("127.0.0.1:0", 8);
auto client = crabby::net_messages::join(("127.0.0.1:" + std::to_string(host.local_port())).c_str());
bool received = false;
for (int frame = 0; frame < 5000 && !received; frame++) {
client.poll();
while (auto e = client.next_event()) {
if (e->kind == CRABBY_MESSAGE_CONNECTED) client.send(0, CRABBY_LANE_RELIABLE, std::string("hello"));
if (e->kind == CRABBY_MESSAGE_DISCONNECTED) std::fprintf(stderr, "%s\n", e->reason.c_str());
}
host.poll();
while (auto e = host.next_event()) {
if (e->kind != CRABBY_MESSAGE) continue;
std::printf("received: %.*s\n", int(e->bytes.size()), reinterpret_cast<const char*>(e->bytes.data()));
host.send(e->peer, CRABBY_LANE_UNRELIABLE, std::string("42"));
received = true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
Run the self-contained UDP example with
cargo run --example net_messages --no-default-features.
send(peer, lane, bytes) copies the payload into a bounded channel queue;
send_serde encodes JSON and MessageEvent::decode::<T>() decodes it.
send_to_all targets connected peers and can partially succeed if a later
peer’s queue is full. The application remains responsible for its packet
schema, authority and version negotiation. The replication
helpers below cover the common pieces
without dictating a format.
Drain events() after each poll, then apply any sends after dropping the
iterator (it borrows the endpoint). Events include connections, complete
messages, and disconnections with diagnostic reasons. The default silence
and connection timeout is ten seconds; configure it with .timeout(duration).
disconnect(peer) discards pending messages, sends three best-effort leave
notifications and reports a local event. If those notifications are lost,
the remote detects the timeout. Dropping an endpoint is detected by timeout.
A full host refuses new connections with a disconnection event.
Each Renet lane has a 4 MiB budget per direction per peer; a full send queue
returns WouldBlock so the application can retry or drop its update.
Incoming event storage is bounded to about 5 MiB and 4096 message events,
with a small allowance for connection events during a poll. Polling returns
WouldBlock if events have not been drained; continued receive pressure can
exhaust a reliable receive channel and disconnect that peer. Polling handles
at most 256 incoming datagrams per call. stats() counts datagrams and bytes,
including retransmissions and control traffic. Buffers are reused for framing,
but owned messages and Renet packet batches allocate; this API does not promise
allocation-free frames.
loopback(n) builds an in-memory host and clients, intentionally reversing
packet arrival to exercise reordering. loopback_lossy(n, drop_one_in) also
drops every Nth datagram. poll_elapsed(duration) advances an explicit clock
for deterministic tests without sleeping.
UDP sessions use nonblocking std::net::UdpSocket on the calling thread;
there is no async runtime or worker thread. They are unauthenticated and
unencrypted. Session nonces reject delayed traffic from earlier connections,
but do not authenticate users. Use a trusted network or a secure tunnel;
public Internet authentication, encryption and admission policy belong in a
secure transport. This message API currently supports UDP and memory only.
With feature steam, NetMessages uses Steam Networking Messages on a
separate channel from lockstep. Steam sends unreliable packets without Nagle;
renet supplies delivery, ordering and fragmentation. Each transport packet is
at most 1,500 bytes, below Steam’s message limit. Lane::ReliableUnordered
(CRABBY_LANE_RELIABLE_UNORDERED in C++) retries without ordering messages.
Create one message endpoint per Steam client. The host creates a friends-only
lobby (1–249 clients plus host); join with its lobby id. Poll every frame and
drain the same events as UDP. Peer ids remain session-local; steam_peer
returns the authenticated Steam identity. Hosts do not automatically forward
client messages. The protocol metadata is checked before joining the message
handshake; applications should check their own game version before accepting
application messages. Host migration requires creating a new endpoint.
#![allow(unused)]
fn main() {
use ponko::*;
let client = steamworks::Client::init_app(480).unwrap();
let lobby_id = steam_lobby_from_args();
let mut net = match lobby_id {
Some(id) => NetMessages::steam_join(&client, id)?,
None => NetMessages::steam_host(&client, 3)?,
};
net.poll()?;
if lobby_id.is_none() {
if let Some(lobby) = net.steam_lobby().filter(|l| l.id().is_some()) {
lobby.set_metadata("version", "1")?;
lobby.invite()?;
}
}
Ok::<(), std::io::Error>(())
}
auto net = lobby_id ? crabby::net_messages::steam_join(480, lobby_id)
: crabby::net_messages::steam_host(480, 3);
net.poll(); // every frame; drain net.next_event() afterwards
if (!lobby_id && net.steam_lobby_id()) {
net.steam_set_metadata("version", "1");
net.steam_invite(); // normally in response to the player's invite button
}
For a lobby without a message endpoint, use SteamLobby. Visibility can be
friends-only, public or private; capacity includes the owner (1–250).
Creation and joining are asynchronous: poll runs callbacks and reports
errors, and id remains absent until ready. Metadata writes require ownership;
reads use Steam’s callback-updated cache. Drop/free leaves the lobby, including
when a pending request completes after the handle was dropped. The existing
steam_lobby_from_args and steam_wait_for_invite helpers cover overlay invites
accepted before launch or while running. Steam is initialized only by the
application (or the C API’s first call with an app id), never by fake relay tests.
#![allow(unused)]
fn main() {
use ponko::*;
let client = steamworks::Client::init_app(480).unwrap();
let mut lobby = SteamLobby::create(&client, SteamLobbyVisibility::Public, 4)?;
lobby.poll()?;
if lobby.id().is_some() {
lobby.set_metadata("version", "1")?;
let members = lobby.members();
let version = lobby.metadata("version");
lobby.invite()?;
lobby.leave()?;
}
Ok::<(), std::io::Error>(())
}
auto lobby = crabby::steam_lobby::create(480, crabby::steam_lobby_visibility::public_lobby, 4);
lobby.poll(); // each frame until lobby.id() != 0
if (lobby.id()) {
lobby.set_metadata("version", "1");
auto members = lobby.members(); // Steam ids
auto version = lobby.metadata("version");
(void)members; (void)version;
lobby.invite();
lobby.leave();
}
The protocol layer uses Renet, whose
transport-independent channels provide retransmission, ordered delivery and
fragmentation without Bevy or an async runtime. renet_steam was evaluated,
but its Steamworks 0.12.2 dependency differs from this engine’s 0.13.1, and
the engine instead adapts the existing renet packet protocol directly.
Replicating state over messages
Client/server and co-op games usually don’t run lockstep: each player owns
their own motion (and the vehicle they drive), the host owns the rest, and
state travels as snapshots on an unreliable lane. The engine moves the bytes
(NetMessages) and gives you small helpers for the parts every such game
rewrites, without imposing a wire format: they take decoded values and
return decisions. You keep your own packet codec. None of them touches the
network, so they’re plain data structures that work (and test) without a
session, in Rust and from C and C++.
Showing remote things smoothly: snapshot interpolation
Packets arrive unevenly, out of order, twice, or not at all; drawing each one as it comes makes remote entities jitter. Instead, buffer the snapshots and draw the entity a little in the past (100 ms by default), always between two snapshots you already have.
A SnapshotStream is one remote entity’s buffer plus an estimate of the
sender’s clock. For each decoded packet, push the sender’s timestamp, your
own clock and the state; every frame, sample your clock:
#![allow(unused)]
fn main() {
// One stream per remote entity: it holds the snapshots received and learns
// the sender's clock from their timestamps. The wire format is yours.
let mut car = SnapshotStream::<Pose>::new(); // shown 100 ms in the past
let mut newest = LatestWins::new(); // drops duplicate and late packets
for n in 0u16..20 {
// In a game this is a decoded packet from `NetMessages::events()`:
let (entity, stamp) = (7u64, UpdateStamp { epoch: 0, seq: n });
let sender_time = 5000.0 + f64::from(n) * 0.05; // on the sender's clock
let arrived = f64::from(n) * 0.05 + 0.03; // on yours, e.g. ctx.time()
let pose = Pose::at(vec3(n as f32, 0.0, 0.0)).moving(vec3(20.0, 0.0, 0.0));
if newest.accept(entity, stamp) {
car.push(sender_time, arrived, pose);
}
}
// Every frame: where to draw it. Between snapshots it is interpolated;
// when packets are late it is extrapolated for 250 ms, then held.
let shown = car.sample(1.0).unwrap();
println!("{:?} at {:?}", shown.state, shown.value.position);
}
// One stream per remote entity: it holds the snapshots received and learns
// the sender's clock from their timestamps. The wire format is yours.
crabby::snapshot_stream car; // shown 100 ms in the past
crabby::latest_wins newest; // drops duplicate and late packets
for (std::uint16_t n = 0; n < 20; n++) {
// In a game this is a decoded packet from net_messages::next_event():
std::uint64_t entity = 7;
double sender_time = 5000.0 + n * 0.05; // on the sender's clock
double arrived = n * 0.05 + 0.03; // on yours, e.g. ctx.time()
auto pose = crabby::moving(crabby::pose_at({float(n), 0, 0}), {20, 0, 0});
if (newest.accept(entity, crabby::update_stamp{0, n})) car.push(sender_time, arrived, pose);
}
// Every frame: where to draw it. Between snapshots it is interpolated;
// when packets are late it is extrapolated for 250 ms, then held.
auto shown = car.sample(1.0);
std::printf("x = %.1f\n", shown->value.position.x);
- Pose: a position, a rotation and an optional velocity. Position is
interpolated linearly, or along a cubic Hermite curve when both
neighbouring snapshots carry a velocity (smoother through turns, and at low
send rates). Rotation uses slerp. Rust’s
SnapshotStream<T>andSnapshotBuffer<T>work for any state you implementInterpolatefor (f32,Vec2,Vec3andQuatalready are); C and C++ have the pose. - Late packets: when the buffer runs dry, the entity is extrapolated
from the newest snapshot (by its velocity, or the speed between the last two
snapshots) for at most 250 ms, then held where the prediction ended,
never snapped back.
samplesays which one you got (Interpolated,Extrapolated,Held), which is handy for showing a “connection problem”. Rotation is held, not predicted. - Out of order and duplicates: snapshots are stored by timestamp, so a
late one still lands in the right place and a second copy is ignored
(
pushtells you which). - The clock: timestamps are on the sender’s clock, which is none of
yours.
ClockSynclearns the offset from the packets alone, no round trips: it follows the fastest of the recent packets (the 4th smallest of the last 32, so jitter and late packets don’t move it, and neither do a few freakishly fast ones) and smooths that, so it also follows clock drift. A clock that jumps (the sender restarted) is noticed after 8 packets and the estimate starts over. Use any clock you like for yours (ctx.time()), the same one forpushandsample. - Teleports:
clear()the stream when something respawns or teleports, or it slides across the map.reset()on a new connection. - Tuning:
SnapshotConfighasdelay(make it more than the gap between your packets plus their jitter),max_extrapolationandcapacity(snapshots kept; at least twicedelay× your send rate).
With the 3d feature, the NetPose component holds a stream and the system
apply_net_poses sets the Transform3D position and rotation of every entity
that has one, from ctx.time(); add it with Game::system so entities move
every frame:
game.system(apply_net_poses);
// from the message handler:
// world.get_mut::<NetPose>(entity).unwrap().stream.push(sender_time, ctx.time(), pose);
Who may update what: authority epochs and sequence numbers
With mixed authority, a handoff (the guest takes the wheel of a car the host
was driving) races with updates still in flight from the old owner. An
Authority is an owner peer and an epoch; a handoff bumps the epoch, and
updates carry the epoch they were sent under, so a late packet from the
previous owner is recognised and dropped (AuthorityCheck::Stale) instead of
yanking the car back:
#![allow(unused)]
fn main() {
let (host, guest) = (PeerId::HOST, PeerId::from_bits(1));
let mut vehicle = Authority::new(host); // the host owns it...
let epoch = vehicle.handoff(guest); // ...until the guest takes the wheel.
// Announce (guest, epoch) on a reliable lane; every other peer calls
// `vehicle.adopt(guest, epoch)` when it arrives. State updates carry the
// epoch they were sent under, and the receiver checks it:
assert_eq!(vehicle.check(host, 0), AuthorityCheck::Stale); // late, from before the handoff
assert_eq!(vehicle.check(guest, epoch), AuthorityCheck::Accept);
assert!(seq_newer(2, 65535)); // sequence numbers wrap around
}
const std::uint64_t host = 0, guest = 1;
crabby::authority vehicle(host); // the host owns it...
std::uint16_t epoch = vehicle.handoff(guest); // ...until the guest takes the wheel.
// Announce (guest, epoch) on a reliable lane; every other peer calls
// vehicle.adopt(guest, epoch) when it arrives. State updates carry the
// epoch they were sent under, and the receiver checks it:
bool late = vehicle.check(host, 0) == crabby::authority_check::stale; // from before the handoff
bool current = vehicle.check(guest, epoch) == crabby::authority_check::accept;
bool wraps = crabby::seq_newer(2, 65535); // sequence numbers wrap around
Whoever decides (usually the host) calls handoff and announces
(owner, epoch) on a reliable lane; the others adopt it. An update that
overtakes its announcement is Ahead: drop it, or hold it until the
announcement arrives. If two peers hand the same entity off at once, adopt
breaks the tie in favour of the lower peer id, so everyone ends up with the
same owner.
For unreliable state updates, number them with a wrapping 16-bit sequence.
seq_newer(a, b) compares with wrap-around (65535 is followed by 0), and
LatestWins is the per-entity filter: it accepts an update only if its
UpdateStamp { epoch, seq } is newer than everything accepted before for the
same entity, which drops duplicates, reordered packets and updates from an old
epoch in one call (see newest.accept(...) in the snapshot example above).
Forget an entity with forget when it’s gone, and after a long silence (half
the sequence range, 32,768 updates, is the limit for an unambiguous
comparison).
Who needs what: interest by distance
Sending every entity to every peer doesn’t scale. An Interest decides, per
peer, which entities to send and how often, from the position of that
peer’s viewer(s) (the avatar, the camera) and the entities’ positions. The
rings are InterestConfig::tiers; by default entities within 40 units are
sent every tick, within 120 units every 4th tick, and beyond that not at all.
#![allow(unused)]
fn main() {
// Near entities every tick, mid ones every 4th, far ones never.
let mut interest = Interest::new(InterestConfig::default());
let viewers = [InterestViewer {
peer: PeerId::HOST,
position: vec3(0.0, 0.0, 0.0),
}];
let world = [
InterestEntity {
id: 1,
position: vec3(10.0, 0.0, 0.0),
},
InterestEntity {
id: 2,
position: vec3(500.0, 0.0, 0.0),
},
];
let mut sent = 0;
for tick in 0..8u64 {
interest.update(tick, &viewers, &world);
for change in interest.changes() {
println!("{:?} {:?} for peer {:?}", change.kind, change.entity, change.peer);
}
for entry in interest.due(PeerId::HOST) {
sent += 1; // send entity `entry.entity` to this peer now
let _ = entry;
}
}
}
// Near entities every tick, mid ones every 4th, far ones never.
crabby::interest interest;
const std::uint64_t peer = 0; // from CRABBY_MESSAGE_CONNECTED; the host is 0
std::vector<crabby::interest_viewer> viewers{{peer, {0, 0, 0}}};
std::vector<crabby::interest_entity> world{{1, {10, 0, 0}}, {2, {500, 0, 0}}};
int sent = 0;
for (std::uint64_t tick = 0; tick < 8; tick++) {
interest.update(tick, viewers, world);
for (auto& change : interest.changes())
std::printf("entity %llu %s\n", (unsigned long long)change.entity,
change.kind == CRABBY_INTEREST_ENTERED ? "entered" : "left");
for (auto& entry : interest.entries(peer))
if (entry.send) sent++; // send entity entry.entity to this peer now
}
Call update once per send tick with all viewers and entities. Then
entries (Rust: due for just the ones to send now) lists what each peer is
interested in, with the ring (tier), the distance and a send flag, and
changes reports entities that entered (spawn them on that peer) or
left (despawn them, also when the entity is gone from the world). An
entity that has just entered always has send set.
- Hysteresis (5 units by default): an entity moves to a closer ring only once it is that far inside it, and to a farther ring (or out of interest) only once it is that far outside. One standing on a boundary doesn’t flicker in and out, and doesn’t cost a spawn and a despawn every other tick.
- Staggering: the slow rings send on different ticks for different
entities (by id), so a ring of 1,000 entities every 4th tick is 250 sends
per tick, not 1,000 every 4th. Turn it off with
stagger: false. - Scale: the entities go into a spatial grid first, so a peer only looks
at those near its viewers, and the buffers are reused. One
updatein a release build on a laptop: 1,000 entities and 4 peers took 28 µs, 5,000 entities and 8 peers 65 µs, 20,000 entities and 8 peers 272 µs (about 350 entries in range in total); with everything in range of everyone (5,000 entities, 8 peers, 29,000 entries) 1.4 ms, about 50 ns per entry. A peer may have several viewers (split screen); an entity counts by its nearest. Positions are 3D; for a flat world, use a constant height. - Peers that have no viewer in an
updateare forgotten, with their entities reported as left; callremove_peerwhen one disconnects.
Testing it
Everything above is pure data, so tests need no sockets: feed push with
recorded or simulated packets and a made-up clock. The engine’s own tests also
run two NetMessages::loopback_lossy endpoints with extra delay and jitter,
exchanging snapshots built with these helpers
(tests/net_replication.rs), and every helper has tests for jitter,
reordering, packet loss, the extrapolation cap, handoff races, sequence
wrap-around and interest hysteresis.
Drawing
There are two ways to put things on screen. Both end up in the same batched renderer.
Sprites as components
Give an entity a Transform2D and a Sprite, and the built-in
draw_sprites system draws it every frame:
#![allow(unused)]
fn main() {
let crab = ctx.load_texture("crab.png");
ctx.world.spawn((
Transform2D::at(vec2(400.0, 300.0)).rotated(0.3).scaled(vec2(2.0, 2.0)),
Sprite::new(crab).with_color(Color::RED).with_z(1.0),
));
}
auto crab = ctx.load_texture("crab.png");
auto world = ctx.world();
world.spawn(crabby::transform_at({400, 300}, 0.3f, {2, 2}),
crabby::sprite_of(crab, crabby::colors::red, 1));
Transform2D:position,rotation(radians, clockwise on screen),scale(negative flips).Sprite:texture,color(tint),z(higher is on top),pivot((0.5, 0.5)is the center).
Immediate drawing
For UI, effects and quick prototypes, draw directly every frame:
#![allow(unused)]
fn main() {
ctx.clear(Color::hex(0x203040));
ctx.draw(&crab, vec2(100.0, 100.0));
ctx.draw_ex(&crab, vec2(200.0, 100.0), DrawParams {
rotation: 0.5,
scale: vec2(2.0, 2.0),
color: Color::RED,
..Default::default()
});
}
ctx.clear(crabby::rgb(0.125f, 0.19f, 0.25f));
ctx.draw(crab, {100, 100});
auto params = crabby::draw_defaults();
params.rotation = 0.5f;
params.scale = {2, 2};
params.color = crabby::colors::red;
ctx.draw(crab, {200, 100}, params);
Coordinates are world units, which are window pixels ((0, 0) at the
top-left) until you move the camera; see Camera, text and UI.
Textures
-
ctx.load_texture("path.png")loads an image file. -
ctx.create_texture(w, h, &rgba)makes one from pixels. -
ctx.update_texture(&tex, &rgba)replaces a texture’s pixels, for images that change while the game runs (a minimap, a heat map, a simulated field). The ants example draws its pheromone field this way every frame.
Texture is a small Copy handle; store it in components freely.
std::vector<std::uint8_t> rgba(64 * 64 * 4, 255);
auto heat = ctx.create_texture(64, 64, rgba.data(), rgba.size());
rgba[0] = 0; // ...change pixels...
ctx.update_texture(heat, rgba.data(), rgba.size());
In C++, Sprite values are crabby::sprite (sprite_of(texture, tint, z)).
They can be spawned, read and set, but not used as query columns, because
the engine stores sprites in a Rust layout. Transform2D can.
Hot reload
Save an image in your editor and the running game shows the new version
within a quarter of a second: textures loaded with load_texture are
reloaded when their file changes, under the same handle, so every sprite
using them updates by itself. Fonts (load_font) and sounds (load_sound)
reload the same way, and so do data files. A file that
can’t be read or decoded mid-save keeps the old version. It’s on by default
in debug builds and off in release builds; Game::hot_reload(bool) or
CRABBY_HOT_RELOAD=1/0 changes that.
If you computed something from an image (a level read from its pixels, a
collision mask), redo it when the texture shows up in reloaded_textures:
#![allow(unused)]
fn main() {
Game::new("Art test", 1280, 720)
.hot_reload(true) // the default in debug builds
.run(|ctx| {
let level = ctx.load_texture("level.png");
move |ctx: &mut Ctx| {
if ctx.reloaded_textures().contains(&level) {
println!("level.png changed: rebuilding the map");
}
}
});
}
crabby::texture level{};
crabby::game("Art test", 1280, 720)
.hot_reload(true) // the default in debug builds of the engine
.system([&](crabby::ctx& ctx) {
for (crabby::texture t : ctx.reloaded_textures())
if (t.id == level.id) std::puts("level.png changed: rebuilding the map");
}, "watch_level")
.run([&](crabby::ctx& ctx) { level = ctx.load_texture("level.png"); });
An image that changed size isn’t reloaded, because sprites keep the size
their texture had; the console says so, and a restart picks it up. A file
caught halfway through saving fails to decode and is tried again on the
next save. Checking costs one file stat per loaded texture, four times a
second.
Screenshots
#![allow(unused)]
fn main() {
if ctx.key_pressed(Key::F12) {
ctx.save_screenshot("shot.png");
}
}
if (ctx.key_pressed(CRABBY_KEY_F12)) ctx.save_screenshot("shot.png");
save_screenshot saves the next frame as a PNG once it
has been drawn, even while the window is hidden. Headless runs have no
frames, so it does nothing there.
Camera, text and UI
Big worlds need a camera, and games need words on screen. Everything here is drawn with sprites, so it batches with the rest of the frame.
World space and screen space
There are two kinds of coordinates:
- World units: sprites,
ctx.draw, and the shapes below. They go through the camera. - Window pixels: UI and text, drawn on top of the world.
(0, 0)is the window’s top-left. SetDrawParams { screen: true, .. }to draw a sprite in window pixels (an icon in a HUD).
The default camera shows world point (0, 0) at the top-left at zoom 1, so
until you move it, world units are window pixels.
The camera
ctx.camera is a Camera2D (C++:
ctx.camera() / ctx.set_camera(c) with the same two fields):
position (the world point at the window’s top-left) and zoom (window
pixels per world unit). Its methods do the usual jobs:
| Want to | Call |
|---|---|
| follow something | ctx.camera.look_at(hero) every frame |
| zoom toward the mouse | ctx.camera.zoom_at(ctx.mouse_pos(), 1.1) |
| know what the mouse points at | ctx.mouse_world() |
| put a label on a unit | ctx.camera.world_to_screen(unit) |
| skip work for things off screen | ctx.camera.visible_rect().contains(p) |
For looking around a big world, add the ready-made system:
#![allow(unused)]
fn main() {
Game::new("Colony", 1280, 720)
.system(camera_controls) // right/middle drag pans, wheel zooms
}
crabby::game("Colony", 1280, 720)
.camera_controls() // right/middle drag pans, wheel zooms
The ants example starts zoomed out to fit every colony:
#![allow(unused)]
fn main() {
let fit = ctx.window_size() / bounds;
ctx.camera.zoom = fit.x.min(fit.y);
ctx.camera.look_at(bounds / 2.0);
}
const crabby::vec2 window = ctx.window_size();
auto cam = ctx.camera();
cam.zoom = std::min(window.x / bounds.x, window.y / bounds.y);
cam.position = bounds / 2 - window / (2 * cam.zoom); // look at the middle
ctx.set_camera(cam);
Shapes
In world units, like sprites:
#![allow(unused)]
fn main() {
ctx.draw_rect(Rect::new(0.0, 0.0, 200.0, 20.0), Color::hex(0x3a5a40));
ctx.draw_line(from, to, 2.0, Color::RED);
ctx.draw_circle(tower, 120.0, Color::BLUE.with_alpha(0.2));
}
ctx.draw_rect(crabby::rect_at({0, 0}, {200, 20}), crabby::rgb(0.23f, 0.35f, 0.25f));
ctx.draw_line(from, to, 2, crabby::colors::red);
ctx.draw_circle(tower, 120, crabby::rgba(0, 0, 1, 0.2f));
C++ rectangles are {min, max}; crabby::rect_at(min, size) makes one
from a corner and a size, like Rust’s Rect::new.
Text
#![allow(unused)]
fn main() {
ctx.draw_text("Hello", vec2(20.0, 20.0), 24.0, Color::WHITE);
let size = ctx.measure_text("Paused", 32.0); // for centering
let title = ctx.load_font("fonts/Title.ttf"); // TrueType or OpenType
ctx.draw_text_ex("Colony", vec2(20.0, 60.0), TextParams { font: title, size: 40.0, ..Default::default() });
}
ctx.draw_text("Hello", {20, 20}, 24, crabby::colors::white);
const crabby::vec2 size = ctx.measure_text("Paused", 32); // for centering
const crabby::font title = ctx.load_font("fonts/Title.ttf"); // TrueType or OpenType
auto params = crabby::text_defaults();
params.font = title;
params.size = 40;
ctx.draw_text("Colony", {20, 60}, params);
In C++, draw_text(text, pos, params) is draw_text_ex, with
crabby::text_defaults() to start from.
sizeis the line height in window pixels;\nstarts a new line.- The built-in font is Ubuntu Light (Ubuntu Font Licence).
- Glyphs are rasterized at the screen’s real pixel size the first time they’re drawn and kept in an atlas texture, so text is sharp on Retina screens and costs only a lookup per glyph after that.
measure_textworks headless too, so layout code can be tested.
UI
A few immediate-mode widgets, drawn in window pixels. Call them every frame:
#![allow(unused)]
fn main() {
fn hud(ctx: &mut Ctx) {
let panel = Rect::new(12.0, 12.0, 230.0, 172.0);
ctx.ui_panel(panel);
let mut at = panel.min + ctx.ui_style.padding;
let (zoom, dropped) = (ctx.camera.zoom, ctx.world.resource::<Scene>().dropped);
let lines = format!("zoom {zoom:.2}x\ncrabs dropped: {dropped}\n{:.0} FPS", ctx.fps());
at.y += ctx.ui_label(at, &lines).y + 8.0;
let follow = ctx.world.resource::<Scene>().follow;
let label = if follow { "Stop following" } else { "Follow orbiter" };
if ctx.ui_button(Rect::new(at.x, at.y, 210.0, 30.0), label) {
ctx.world.resource_mut::<Scene>().follow = !follow;
}
at.y += 38.0;
if ctx.ui_button(Rect::new(at.x, at.y, 210.0, 30.0), "Reset camera") {
ctx.camera.zoom = 1.0;
ctx.camera.position = Vec2::ZERO;
ctx.world.resource_mut::<Scene>().follow = false;
}
}
}
struct Hud {
int dropped = 0;
bool follow = false;
void operator()(crabby::ctx& ctx) {
const crabby::rect panel = crabby::rect_at({12, 12}, {230, 172});
ctx.ui_panel(panel);
const float pad = ctx.ui_style().padding;
crabby::vec2 at = panel.min + crabby::vec2{pad, pad};
char lines[128];
std::snprintf(lines, sizeof lines, "zoom %.2fx\ncrabs dropped: %d\n%.0f FPS",
double(ctx.camera().zoom), dropped, double(ctx.fps()));
at.y += ctx.ui_label(at, lines).y + 8;
if (ctx.ui_button(crabby::rect_at(at, {210, 30}), follow ? "Stop following" : "Follow orbiter"))
follow = !follow;
at.y += 38;
if (ctx.ui_button(crabby::rect_at(at, {210, 30}), "Reset camera")) {
ctx.set_camera({{0, 0}, 1});
follow = false;
}
}
};
// crabby::game("UI", 1280, 720).camera_controls().system(Hud{}, "hud");
ui_panel(rect): a background.ui_label(pos, text): text in the style’s size and color; returns the space it took, so you can stack lines.ui_button(rect, label):trueon the frame it’s clicked.ctx.ui_stylesets colors, text size and padding (C++:ui_style(),set_ui_style(s)).ctx.mouse_over_ui()says whether the mouse is over UI, so a click on a button doesn’t also act on the world behind it.camera_controlschecks it before zooming.
The whole scene from cargo run --release --example ui:
#![allow(unused)]
fn main() {
fn scene(ctx: &mut Ctx) {
let t = ctx.time() as f32;
// The world: a grid of tiles, a few shapes, a crab in orbit.
for y in 0..20 {
for x in 0..20 {
let shade = if (x + y) % 2 == 0 { 0.16 } else { 0.19 };
let tile = Rect::new(x as f32 * 64.0, y as f32 * 64.0, 64.0, 64.0);
ctx.draw_rect(tile, Color::rgb(shade, shade + 0.03, shade));
}
}
let center = vec2(640.0, 640.0);
ctx.draw_circle(center, 180.0, Color::hex(0x3a86ff).with_alpha(0.15));
let orbit = center + Vec2::from_angle(t * 0.6) * 180.0;
ctx.draw_line(center, orbit, 3.0, Color::hex(0xffbe0b));
let crab = ctx.world.resource::<Scene>().crab;
ctx.draw_ex(
&crab,
orbit,
DrawParams {
rotation: t,
..Default::default()
},
);
// A label that sticks to the crab: world position -> window pixels.
let above = ctx.camera.world_to_screen(orbit) + vec2(-28.0, -40.0);
ctx.draw_text("orbiter", above, 16.0, Color::hex(0xffbe0b));
if ctx.world.resource::<Scene>().follow {
ctx.camera.look_at(orbit);
}
if ctx.mouse_pressed(MouseButton::Left) && !ctx.mouse_over_ui() {
let at = ctx.mouse_world();
ctx.world.spawn((Transform2D::at(at), Sprite::new(crab)));
let plop = ctx.world.resource::<Scene>().plop;
let speed = 0.8 + ctx.random().stream(0).f32() * 0.4;
ctx.play_sound_ex(
&plop,
SoundParams {
speed,
..Default::default()
},
);
ctx.world.resource_mut::<Scene>().dropped += 1;
}
}
}
For agents
The control endpoint has camera.get and camera.set, and ponko-mcp
has a set_camera tool. Combined with screenshot, an agent can look at
any part of a running world.
Retained game UI
Build a tree once in ctx.ui_retained, then update its text, values and styles.
The engine handles layout/input before frame systems and draws the tree above
scene sprites. The immediate-mode ui_panel, ui_label and ui_button APIs
remain available.
In C++ the tree is ctx.ui_retained(), a crabby::ui::tree; layouts and paints are
built by chaining (ui::layout().column().size(...), ui::paint().wrap()). In C it
is the crabby_ctx_ui_* functions with a flat CrabbyUiLayout and CrabbyUiPaint
(see C and C++ and below).
The tree is owned by Ui, independently of ECS entities. Stable UiId handles
identify widgets and events. Removing a subtree invalidates its IDs permanently.
Taffy supplies flexbox, grid, percentage sizes, min/max constraints, padding,
margin, gap and absolute positioning through the ui_layout re-export. It owns
neither the renderer nor the event loop. Crabby owns interaction and appearance.
A shop and inventory grid
The runnable Rust example is cargo run --example ui_shop. Build it without
opening a window with cargo build --example ui_shop.
#![allow(unused)]
fn main() {
let ui = &mut ctx.ui_retained;
let root = ui.add(
UiWidget::Panel,
Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
flex_direction: FlexDirection::Column,
padding: ui_layout::Rect::length(24.0),
gap: Size::length(12.0),
..Default::default()
},
);
let title = ui.add_child(root, UiWidget::Label, fixed(400.0, 32.0));
ui.set_text(title, "General store — choose an item");
let search = ui.add_child(root, UiWidget::TextInput, fixed(360.0, 36.0));
ui.set_text(search, "Your order note");
let scroll = ui.add_child(
root,
UiWidget::Scroll,
Style {
size: Size {
width: percent(1.0),
height: length(360.0),
},
min_size: Size::length(0.0),
flex_shrink: 1.0,
..Default::default()
},
);
let grid = ui.add_child(
scroll,
UiWidget::Panel,
Style {
display: Display::Grid,
size: Size {
width: percent(0.95),
height: auto(),
},
grid_template_columns: vec![fr(1.0), fr(1.0), fr(1.0)],
grid_auto_rows: vec![length(76.0)],
gap: Size::length(8.0),
flex_shrink: 0.0,
..Default::default()
},
);
let mut items = Vec::new();
for i in 0..36 {
let item = ui.add_child(
grid,
UiWidget::Button,
Style {
min_size: Size::length(0.0),
padding: ui_layout::Rect::length(8.0),
..Default::default()
},
);
ui.set_text(item, format!("Item {}\n{} coins", i + 1, (i + 1) * 3));
ui.set_paint(
item,
UiPaint {
wrap: true,
border_width: 1.0,
..Default::default()
},
);
items.push(item);
}
let gift = ui.add_child(root, UiWidget::Toggle, fixed(220.0, 28.0));
ui.set_text(gift, "Gift wrap");
let buy = ui.add_child(root, UiWidget::Button, fixed(220.0, 40.0));
ui.set_text(buy, "Buy selected item");
ui.set_enabled(buy, false);
ui.push_screen(root, false);
}
auto tree = ctx.ui_retained();
auto fixed = [](float width, float height) { return ui::layout().size(width, height).shrink(0); };
auto root = tree.add_panel(ui::none, ui::layout()
.column().size(ui::percent(1), ui::percent(1)).padding(24).gap(12));
shop.title = tree.add_label(root, "General store — choose an item", fixed(400, 32));
tree.add_text_input(root, "Your order note", fixed(360, 36));
auto scroll = tree.add_scroll(root, ui::layout()
.size(ui::percent(1), ui::points(360)).min_size(ui::points(0), ui::points(0)));
auto grid = tree.add_panel(scroll, ui::layout()
.grid({ui::fr(1), ui::fr(1), ui::fr(1)}).auto_rows({ui::track_points(76)})
.size(ui::percent(0.95f), ui::automatic()).gap(8).shrink(0));
for (int i = 0; i < 36; ++i) {
auto label = "Item " + std::to_string(i + 1) + "\n" + std::to_string((i + 1) * 3) + " coins";
auto item = tree.add_button(grid, label.c_str(),
ui::layout().min_size(ui::points(0), ui::points(0)).padding(8));
tree.set_paint(item, ui::paint().wrap().border_width(1));
shop.items.push_back(item);
}
tree.add_toggle(root, "Gift wrap", false, fixed(220, 28));
shop.buy = tree.add_button(root, "Buy selected item", fixed(220, 40));
tree.set_enabled(shop.buy, false);
tree.push_screen(root);
A row/column is a Panel with Taffy’s flex_direction. An inventory/list is a
panel with grid/column layout whose children are buttons, labels or images.
Keep scroll content from shrinking (flex_shrink: 0.0); give the scroll view a
bounded size. UiWidget::Scroll clips descendants and supports wheel scrolling,
content dragging and a vertical scrollbar. set_scroll uses unscaled layout
units and clamps offsets during layout. Nested scroll views intersect clips.
Navigation scrolls the focused widget into view.
UiPaint supplies background colors for normal/hover/pressed/disabled states,
borders, font, line height, text color, wrapping and horizontal alignment.
Set texture for an image or textured panel, and slice to the source border
size in texture pixels for nine-slice backgrounds. UiWidget::Toggle uses
zero/one values; Slider uses normalized 0..1 values. Read them with value
after a Changed event. Changes made with setters do not emit user events.
Named grid areas and repeat tracks
Template areas name rectangular regions. A row is a space-separated list of names;
. (or multiple dots) leaves a cell unnamed. All rows must have equal widths and
each name must fill a rectangle. C/C++ rejects invalid templates with a status
message. Names are UTF-8 identifiers using letters, digits, _ and -, without
CSS escapes; reserved CSS keywords and names starting with digits are rejected.
#![allow(unused)]
fn main() {
use ponko::{Ui, UiWidget, ui_layout::*};
use ponko::ui_layout::style_helpers::*;
let mut tree = Ui::default();
let dashboard = tree.add(UiWidget::Panel, Style {
display: Display::Grid,
size: Size { width: length(600.0), height: length(400.0) },
grid_template_columns: vec![repeat(2, vec![fr(1.0)])],
grid_template_rows: vec![length(40.0), fr(1.0)],
grid_template_areas: Some(GridTemplateAreas {
row_count: 2, column_count: 2,
areas: vec![
GridTemplateArea { name: "header".into(), row_start: 1, row_end: 2, column_start: 1, column_end: 3 },
GridTemplateArea { name: "side".into(), row_start: 2, row_end: 3, column_start: 1, column_end: 2 },
GridTemplateArea { name: "main".into(), row_start: 2, row_end: 3, column_start: 2, column_end: 3 },
],
}),
..Style::default()
});
let placement = GridPlacement::NamedLine("main".into(), 0);
let main = tree.add_child(dashboard, UiWidget::Label, Style {
grid_column: Line { start: placement.clone(), end: placement.clone() },
grid_row: Line { start: placement.clone(), end: placement },
..Style::default()
});
tree.set_text(main, "Content");
}
auto dashboard = tree.add_panel(ui::none, ui::layout()
.grid({ui::repeat(2, ui::fr(1))})
.grid_rows({ui::track_points(40), ui::fr(1)})
.areas({"header header", "side main"}).size(600, 400));
tree.add_label(dashboard, "Dashboard", ui::layout().area("header"));
tree.add_label(dashboard, "Navigation", ui::layout().area("side"));
tree.add_label(dashboard, "Content", ui::layout().area("main"));
C keeps CrabbyUiTrack and the original flat track arrays unchanged. For repeats,
use CrabbyUiTemplateTrack arrays in grid_template_columns/grid_template_rows;
a nonzero component count overrides the corresponding original track array.
repetition is 0 for a single track, 1 for count repetitions of tracks,
2 for auto-fill, or 3 for auto-fit. Repeat fragments cannot contain repeats.
C++ accepts ui::repeat(3, ui::fr(1)), multi-track fragments such as
ui::repeat(2, {ui::track_points(40), ui::fr(1)}), and
ui::auto_fill(ui::track_points(100)) / ui::auto_fit(ui::track_points(100)).
Automatic repeats require fixed point or percentage tracks throughout the axis
and at most one automatic repeat per axis, following taffy’s supported subset.
column_names({"left", "middle split", "right"}) and row_names(...) name
line boundaries. With repeats these are component boundaries, rather than every
expanded track; use ui::repeat(3, ui::fr(1)).names({"start", "end"}) for names inside
a repeat fragment. Supply exactly one more name set than repeated tracks.
tree.set_grid_named_line(node, 0, false, "middle", 1) places a column start at
the first matching line (axis 1 selects rows, true selects the end). Negative
occurrences count backwards; zero also resolves implicit area start/end names.
Unknown names follow taffy’s implicit-line placement rules. Named spans are
currently Rust-only. Nested repeats are not supported by taffy.
C can use the layout descriptor fields or change existing nodes with
crabby_ctx_ui_set_grid_template_areas, crabby_ctx_ui_set_grid_line_names,
crabby_ctx_ui_set_grid_area, and crabby_ctx_ui_set_grid_named_line.
Each takes the same screen or world-panel target as other UI calls. Inputs
are copied during the call. Passing zero area/name rows clears those definitions;
an empty area name restores automatic placement. set_layout replaces the whole
style, including areas and names. The C layout struct grew, so rebuild native
callers against the regenerated header.
Screen stack and modal dialogs
#![allow(unused)]
fn main() {
// A detached screen can be reused after it is popped.
let dialog = ui.add(
UiWidget::Panel,
Style {
size: Size {
width: length(360.0),
height: length(180.0),
},
margin: ui_layout::Rect {
left: auto(),
right: auto(),
top: length(180.0),
bottom: auto(),
},
padding: ui_layout::Rect::length(20.0),
flex_direction: FlexDirection::Column,
gap: Size::length(12.0),
..Default::default()
},
);
let message = ui.add_child(dialog, UiWidget::Label, fixed(320.0, 40.0));
let confirm = ui.add_child(dialog, UiWidget::Button, fixed(240.0, 36.0));
ui.set_text(confirm, "Confirm purchase");
let cancel = ui.add_child(dialog, UiWidget::Button, fixed(240.0, 30.0));
ui.set_text(cancel, "Back");
}
// A detached screen can be reused after it is popped.
shop.dialog = tree.add_panel(ui::none, ui::layout()
.size(360, 180).column().padding(20).gap(12)
.margin(ui::points(180), ui::automatic(), ui::automatic(), ui::automatic()));
shop.message = tree.add_label(shop.dialog, "", fixed(320, 40));
shop.confirm = tree.add_button(shop.dialog, "Confirm purchase", fixed(240, 36));
shop.cancel = tree.add_button(shop.dialog, "Back", fixed(240, 30));
Push a detached root with push_screen(root, modal). All screens render in stack
order; only the top receives input, even when it is not modal. A modal screen
dims earlier screens and owns pointer input outside its rectangle too. Pop with
pop_screen(); nodes remain available for reuse. Escape/gamepad East pops the
top screen and emits ScreenPopped, consuming the rest of that input snapshot.
The previous screen’s focus is restored when possible. Even the last screen can
be popped; game code decides whether to reopen it.
Events, navigation and ownership
#![allow(unused)]
fn main() {
move |ctx: &mut Ctx| {
// UI input/layout ran before this closure. Drain first, then mutate.
let mut clicked = None;
for event in ctx.ui_retained.events() {
if let UiEvent::Clicked(id) = event {
clicked = Some(id);
}
}
if let Some(id) = clicked {
if let Some(index) = items.iter().position(|&item| item == id) {
selected = Some(index);
ctx.ui_retained.set_enabled(buy, true);
ctx.ui_retained.set_text(buy, format!("Buy item {}", index + 1));
} else if id == buy {
ctx.ui_retained
.set_text(message, format!("Buy item {}?", selected.unwrap() + 1));
ctx.ui_retained.push_screen(dialog, true);
} else if id == confirm || id == cancel {
ctx.ui_retained.pop_screen();
if id == confirm {
ctx.ui_retained.set_text(title, "Purchase complete!");
}
}
}
// Tab/arrows or d-pad navigate; Enter/South confirms; Escape/East
// pops the top screen. A modal screen also owns the outside pointer.
if !ctx.mouse_over_ui() { /* world pointer actions */ }
if !ctx.ui_wants_keyboard() { /* world keyboard/gamepad actions */ }
}
}
static void shop_events(crabby::ctx& ctx, Shop& shop) {
auto tree = ctx.ui_retained();
// The UI ran before this system. Take the events first, then change the tree.
for (const ui::event& e : tree.events()) {
if (e.kind != ui::event_kind::clicked) continue;
auto item = std::find(shop.items.begin(), shop.items.end(), e.node);
if (item != shop.items.end()) {
shop.selected = int(item - shop.items.begin());
tree.set_enabled(shop.buy, true);
tree.set_text(shop.buy, ("Buy item " + std::to_string(shop.selected + 1)).c_str());
} else if (e.node == shop.buy) {
tree.set_text(shop.message, ("Buy item " + std::to_string(shop.selected + 1) + "?").c_str());
tree.push_screen(shop.dialog, true);
} else if (e.node == shop.confirm || e.node == shop.cancel) {
tree.pop_screen();
if (e.node == shop.confirm) tree.set_text(shop.title, "Purchase complete!");
}
}
// Tab/arrows or d-pad navigate; Enter/South confirms; Escape/East pops the
// top screen. A modal screen also owns the pointer outside it.
if (!tree.wants_pointer()) { /* world pointer actions */ }
if (!tree.wants_keyboard()) { /* world keyboard/gamepad actions */ }
}
events() drains Clicked, Changed, Submitted and ScreenPopped events
(C++: tree.events() or tree.next_event(); C: crabby_ctx_ui_next_event, one
tagged CrabbyUiEvent at a time, CRABBY_NOT_FOUND when none are left).
Unread events expire at the next UI frame. Event IDs avoid callbacks borrowing
the context and game state simultaneously. Drain events before mutating the tree.
Tab/Shift-Tab traverses enabled controls. Arrows/d-pad use spatial navigation;
Enter, Space or gamepad South activates the focused control. Text inputs reserve
left/right, Home/End, Backspace/Delete, Shift-selection and Ctrl/Cmd-A for editing;
Enter submits. Clicking/dragging selects text. Text is UTF-8 with scalar-boundary
editing; grapheme-cluster editing, clipboard and IME composition are not provided.
Native committed Unicode is available to both UI and games as ctx.typed_text().
Custom hosts/tests can supply it through ctx.ui_insert_text().
ctx.mouse_over_ui() includes immediate and retained UI, including captured
drags. ctx.ui_wants_keyboard() reports retained keyboard/gamepad ownership
while any screen is open. These queries do not erase raw game input: game code
uses them to suppress world actions. UI screen changes cancel pointer capture,
so releasing a dialog button cannot activate an underlying screen.
Scale and headless tests
Layout and pointer coordinates use logical window units. DPI already belongs
to the window/text renderer; set_scale(1.25) adds a user preference on top,
scaling geometry, text and hit testing together. Percentage dimensions resolve
against the available logical viewport, so roots can adapt to resolution.
Ui::frame(viewport, &UiInput) runs without a GPU and allows deterministic tests
of layout, events and editing. Ui::layout resolves geometry without processing
input. Standalone Ui measurements use the built-in font; context-owned UI also
measures loaded custom fonts. The engine keeps existing sprite batching and adds
per-batch scissor rectangles; world sprites and immediate UI remain unclipped.
UI on surfaces in the world
A computer screen, a shop terminal or a sign with buttons is a UiPanel3D: a
component with its own Ui that the engine draws every frame into a render
target, which a glowing, alpha-blended quad shows. Build panel.ui just like the
screen’s tree; its widgets are laid out in the target’s pixels, so a 600 x 400
panel has a 600 x 400 viewport. ctx.create_ui_panel(width, height, size) makes
the render target, the material and the quad (size is in meters, in the
entity’s XY plane, facing +Z) and returns the panel and the Model to spawn
with it, next to a Transform3D. The quad is an ordinary model: move and
rotate the entity as usual.
In C++ the entity comes first: ctx.create_ui_panel(entity, width, height, size)
gives it the panel and the model, and ctx.panel_ui(entity) is the panel’s
ui::tree, with every call of the screen’s tree.
#![allow(unused)]
fn main() {
// A 1.2 x 0.8 m terminal, 600 x 400 pixels, with a button.
let (mut panel, model) = ctx.create_ui_panel(600, 400, vec2(1.2, 0.8));
let root = panel.ui.add(
UiWidget::Panel,
Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
flex_direction: FlexDirection::Column,
padding: ui_layout::Rect::length(20.0),
gap: Size::length(12.0),
..Default::default()
},
);
let buy = panel.ui.add_child(
root,
UiWidget::Button,
Style {
size: Size {
width: length(200.0),
height: length(60.0),
},
flex_shrink: 0.0,
..Default::default()
},
);
panel.ui.set_text(buy, "Buy");
panel.ui.push_screen(root, false);
ctx.world
.spawn((Transform3D::at(vec3(0.0, 1.5, -2.0)), model, panel, Terminal { buy }));
}
// A 1.2 x 0.8 m terminal, 600 x 400 pixels, with a button.
auto terminal = ctx.world().spawn(crabby::transform3d_at({0, 1.5f, -2}));
ctx.create_ui_panel(terminal, 600, 400, {1.2f, 0.8f});
auto panel = ctx.panel_ui(terminal);
auto root = panel.add_panel(ui::none, ui::layout()
.column().size(ui::percent(1), ui::percent(1)).padding(20).gap(12));
auto buy = panel.add_button(root, "Buy", ui::layout().size(200, 60).shrink(0));
panel.push_screen(root);
The mouse uses the nearest panel the camera ray through it hits, at the pixel
it hits, unless the screen’s own UI wants the pointer. Clicking a panel also
gives it the keyboard (typing, Tab, Enter) until another is clicked; the others
only see the pointer. panel.pointer() is where the aim is on it this frame, in
its pixels (None when it is off the panel), and panel.interactive = false
turns its input off. Events, focus and the screen stack belong to each panel’s
own Ui: read them from it.
When the cursor is locked (a first-person game) or the aim isn’t the mouse (a
controller in VR), give panels a ray yourself with ctx.set_ui_pointer_ray,
for example from the camera along its forward direction. None goes back to the
mouse.
#![allow(unused)]
fn main() {
fn terminal(ctx: &mut Ctx) {
// Aim with the middle of the screen instead of the mouse (for a locked
// cursor, or a controller in VR).
let (eye, forward) = (ctx.camera3d.position, ctx.camera3d.forward());
ctx.set_ui_pointer_ray(Some((eye, forward)));
ctx.world
.query::<(&mut UiPanel3D, &Terminal)>()
.for_each(|(panel, terminal)| {
while let Some(event) = panel.ui.next_event() {
if event == UiEvent::Clicked(terminal.buy) {
panel.ui.set_text(terminal.buy, "Bought!");
}
}
});
}
}
// Aim with the middle of the screen instead of the mouse (for a locked
// cursor, or a controller in VR).
auto eye = ctx.camera3d();
ctx.set_ui_pointer_ray(eye.position, crabby::forward(crabby::transform3d_at(eye.position, eye.rotation)));
for (const ui::event& e : panel.events())
if (e.kind == ui::event_kind::clicked && e.node == buy) panel.set_text(buy, "Bought!");
if (auto pixel = ctx.ui_panel_pointer(terminal)) {
// The aim is over the panel, at this pixel of its 600 x 400.
(void)pixel;
}
Panels are drawn in a pass of their own before the 3D frame that shows them, so
the quad is never a frame behind its contents. Headless runs build and lay out
panels (and Ui::frame drives them by hand, like the screen’s tree) but draw
nothing and give them no mouse.
From C
The C API mirrors the Rust one function by function. Nodes are uint64_t ids
(CRABBY_UI_NONE for “no node”); a removed node’s id fails with
CRABBY_NOT_FOUND instead of panicking, and a call that fails changes nothing.
CrabbyUiLayout column = crabby_ui_layout_default();
column.flex_direction = CRABBY_UI_FLEX_COLUMN;
column.width = (CrabbyUiLength){CRABBY_UI_LENGTH_PERCENT, 1.0f};
column.padding.top = column.padding.right = column.padding.bottom = column.padding.left =
(CrabbyUiLength){CRABBY_UI_LENGTH_POINTS, 24.0f};
uint64_t root, buy;
crabby_ctx_ui_add_panel(ctx, CRABBY_UI_SCREEN, CRABBY_UI_NONE, &column, &root);
crabby_ctx_ui_add_button(ctx, CRABBY_UI_SCREEN, root, "Buy", NULL, &buy); /* NULL layout: default */
crabby_ctx_ui_push_screen(ctx, CRABBY_UI_SCREEN, root, false);
CrabbyUiEvent event;
while (crabby_ctx_ui_next_event(ctx, CRABBY_UI_SCREEN, &event) == CRABBY_OK)
if (event.kind == CRABBY_UI_EVENT_CLICKED && event.id == buy) { /* ... */ }
Retained tree operations take a target after the context: CRABBY_UI_SCREEN
for the screen’s own UI, or the entity of a world-space panel to build and read
that panel’s UI with the same calls. Node ids are local to the UI that made them;
the same numeric id can exist in several trees. crabby_ctx_ui_insert_text and
crabby_ctx_ui_wants_keyboard operate on context-wide input and have no target.
Panels are made with crabby_ctx_create_ui_panel(ctx, entity, width, height, size, &texture),
which gives the entity (it needs a CrabbyTransform3D) the panel and its model;
crabby_ctx_ui_panel_pointer, crabby_ctx_set_ui_panel_interactive,
crabby_ctx_remove_ui_panel, crabby_ctx_set_ui_pointer_ray and
crabby_ctx_clear_ui_pointer_ray do the rest.
CrabbyTexture texture;
crabby_ctx_create_ui_panel(ctx, terminal, 600, 400, (CrabbyVec2){1.2f, 0.8f}, &texture);
uint64_t buy;
crabby_ctx_ui_add_button(ctx, terminal, CRABBY_UI_NONE, "Buy", NULL, &buy);
crabby_ctx_ui_push_screen(ctx, terminal, buy, false);
CrabbyUiLayout is the Taffy Style flattened: display, position, flex
direction, wrap, grow, shrink and basis, the alignment properties, width, height,
min/max sizes, margin, padding, inset, gap, aspect ratio, grid tracks (arrays with
a count, read only during the call) and grid item placement. Lengths are a kind
(automatic, points or a fraction of the parent) and a value. Enum-valued fields
are uint32_t holding the constants of the matching CrabbyUi* enum, and an
unknown value is an error. CrabbyUiPaint is UiPaint; fonts come from
crabby_ctx_load_font. Text is read back into a buffer you size with
crabby_ctx_ui_text (call once with capacity 0 for the length).
crabby_ctx_ui_frame runs a frame by hand with a CrabbyUiInput, like
Ui::frame, which is how the C and C++ tests click buttons without a window.
This chapter’s Rust and C++ examples build the same shop: the C++ one is
examples/cpp/guide.cpp, compiled and run headless in CI.
Sprite sheets, animation and tilemaps
Sprite sheets
A sprite sheet is one texture holding many frames of the same size: an animation strip, a tileset, a set of icons. Frames are numbered left to right, top to bottom.
#![allow(unused)]
fn main() {
let tex = ctx.load_texture("units.png");
let sheet = SpriteSheet::new(tex, 32, 32); // 32 × 32 frames
ctx.world.spawn((Transform2D::at(pos), sheet.sprite(5))); // frame 5
}
auto tex = ctx.load_texture("units.png");
auto sheet = crabby::sheet_of(tex, 32, 32); // 32 x 32 frames
world.spawn(crabby::transform_at(pos), crabby::sprite_of(sheet, 5)); // frame 5
Under the hood a sprite has a region, the part of its texture to show in
texture pixels. sheet.sprite(i) is Sprite::new(tex).with_region(sheet.frame(i)),
and ctx.draw_ex takes the same region in DrawParams.
For pixel art, turn smoothing off so pixels stay square:
#![allow(unused)]
fn main() {
ctx.set_texture_filter(&tex, TextureFilter::Nearest);
}
ctx.set_texture_filter(tex, crabby::texture_filter::nearest);
Animation
Add an Animation next to the Sprite; the built-in animate_sprites
system (on by default, all cores) advances it every frame:
#![allow(unused)]
fn main() {
ctx.world.spawn((
Transform2D::at(pos),
sheet.sprite(0),
Animation::new(sheet, 0..8, 12.0), // frames 0-7 at 12 fps, looping
));
let boom = Animation::new(sheet, 8..16, 24.0).once(); // stops on the last frame
}
world.spawn(crabby::transform_at(pos), crabby::sprite_of(sheet, 0),
crabby::animation_of(sheet, 0, 8, 12)); // frames 0-7 at 12 fps
auto boom = crabby::animation_of(sheet, 8, 8, 24);
boom.looping = false; // stops on the last frame; crabby::finished(boom) says when
animation.finished() tells you when a once() animation is done, so you
can despawn the explosion. In C++, crabby::animation is a plain struct, so
an each can take crabby::animation& like crabby::transform2d&.
Tilemaps
A TileMap is a grid of sheet frames on an entity with a Transform2D
(its position is the map’s top-left corner). The built-in draw_tilemaps
system draws only the tiles the camera can see, so a map can be huge: the
tiles example has a million tiles, and zoomed out to 380 000 visible tiles it
still runs at over 500 FPS.
#![allow(unused)]
fn main() {
fn setup(ctx: &mut Ctx, side: u32) {
let tiles = tileset(ctx);
let mut map = TileMap::new(tiles, side, side);
for y in 0..side {
for x in 0..side {
map.set(x, y, Some(terrain(x as f32, y as f32)));
}
}
ctx.world.spawn((Transform2D::default(), map));
let blob = creature(ctx);
let mut rng = Rng::new(7);
let world = (side * TILE) as f32;
ctx.world.spawn_batch((0..20_000).map(|_| {
let at = vec2(rng.range(0.0, world), rng.range(0.0, world));
let mut walk = Animation::new(blob, 0..8, rng.range(6.0, 12.0));
walk.time = rng.f32(); // out of step with each other
(
Transform2D::at(at),
blob.sprite(0),
walk,
Wander(rng.unit_vec2() * 20.0),
)
}));
ctx.camera.zoom = 2.0;
ctx.camera.look_at(vec2(world, world) / 2.0);
}
}
static crabby::entity make_map(crabby::world_view world, crabby::sprite_sheet tiles, std::uint32_t side) {
const crabby::entity map = world.spawn(crabby::transform_at({0, 0}));
auto t = world.add_tilemap(map, tiles, side, side); // the transform is its top-left corner
for (std::uint32_t y = 0; y < side; ++y)
for (std::uint32_t x = 0; x < side; ++x)
t.set(x, y, (x * 7 + y * 3) % 5 == 0 ? STONE : GRASS);
return map;
}
Changing tiles is writing into the map. In C++, world.tilemap(e) gives the
engine’s tiles directly (set, get, tiles() for bulk writes), with no copy:
#![allow(unused)]
fn main() {
fn paint(ctx: &mut Ctx) {
if !ctx.mouse_down(MouseButton::Left) || ctx.mouse_over_ui() {
return;
}
let at = ctx.mouse_world();
ctx.world.query::<(&Transform2D, &mut TileMap)>().for_each(|(t, map)| {
if let Some(tile) = map.tile_at(t.position, at) {
map.set(tile.x, tile.y, Some(STONE));
}
});
}
}
static void paint(crabby::ctx& ctx, crabby::entity map) {
if (!ctx.mouse_down(CRABBY_MOUSE_LEFT) || ctx.mouse_over_ui()) return;
auto world = ctx.world();
const crabby::vec2 origin = world.get<crabby::transform2d>(map).position;
auto tiles = world.tilemap(map);
if (auto tile = tiles.tile_at(origin, ctx.mouse_world())) tiles.set((*tile)[0], (*tile)[1], STONE);
}
TileMap::EMPTY(orset(x, y, None)) leaves a hole.tile_sizedefaults to the sheet’s frame size; set it to scale tiles.- Maps are drawn before sprites, so sprites with the same
zare on top.
cargo run --release --example tiles, or MAP=4000 for 16 million tiles.
3D: meshes, materials and the sun
The same engine draws 3D: the same ECS, systems, clock and tools, with
lit meshes under the 2D layer. Turn it on in Cargo.toml:
[dependencies]
ponko = { version = "0.0.1", features = ["3d"] }
Games that leave it off pay nothing for it. The C library (ponko_ffi)
always has it.
A 3D entity has a Transform3D (where it is, which way it faces, its
size) and a Model (which mesh to draw, and a tint). Here’s a village of
houses that turn slowly:
#![allow(unused)]
fn main() {
/// Turns around its own Y axis, this many radians a second.
struct Spin(f32);
fn village(ctx: &mut Ctx) {
let grass = ctx.create_material(MaterialDesc {
roughness: 0.9,
..MaterialDesc::color(Color::hex(0x5E8C4A))
});
let ground = ctx.create_mesh(&MeshData::plane(100.0), grass);
ctx.world.spawn((Transform3D::default(), Model::new(ground)));
let house = ctx.load_mesh("models/house.glb"); // its walls and roof keep their materials
for i in 0..5 {
let at = vec3(i as f32 * 4.0 - 8.0, 0.0, -6.0);
ctx.world.spawn((Transform3D::at(at), Model::new(house), Spin(0.5)));
}
ctx.camera3d.position = vec3(0.0, 6.0, 12.0);
ctx.camera3d.look_at(vec3(0.0, 0.0, -4.0));
ctx.lighting.sun_direction = vec3(-0.8, -0.6, -0.4); // late afternoon: long shadows
}
fn spin(ctx: &mut Ctx) {
let dt = ctx.dt();
ctx.world.query::<(&mut Transform3D, &Spin)>().for_each(|(t, s)| {
t.rotation *= Quat::from_rotation_y(s.0 * dt);
});
}
}
/// Turns around its own Y axis, this many radians a second.
struct Spin { float speed; };
static void village(crabby::ctx& ctx) {
auto grass_look = crabby::material_of(crabby::rgb(.37f, .55f, .29f));
grass_look.roughness = 0.9f;
auto grass = ctx.create_material(grass_look);
auto ground = ctx.create_mesh(crabby::mesh_data::plane(100), grass);
ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(ground));
auto house = ctx.load_mesh("models/house.glb"); // its walls and roof keep their materials
// The house's walls, drawn with a shader of our own: keep what they
// were made from (textures, colors) and change the shader.
auto weathered = ctx.create_shader("weathered.wgsl",
"fn surface(s: Surface) -> Surface { var o = s; o.albedo *= 0.8; o.roughness = 0.95; return o; }");
for (auto [walls, desc] : ctx.loaded_materials("walls")) {
desc.has_shader = true;
desc.shader = weathered;
ctx.update_material(walls, desc);
}
for (int i = 0; i < 5; ++i) {
crabby::vec3 at{float(i) * 4 - 8, 0, -6};
ctx.world().spawn(crabby::transform3d_at(at), crabby::model_of(house), Spin{0.5f});
}
auto camera = ctx.camera3d();
camera.position = {0, 6, 12};
ctx.set_camera3d(camera);
ctx.camera3d_look_at({0, 0, -4});
auto light = ctx.lighting();
light.sun_direction = {-0.8f, -0.6f, -0.4f}; // late afternoon: long shadows
ctx.set_lighting(light);
}
static void spin(crabby::ctx& ctx) {
const float dt = ctx.dt();
ctx.world().each([&](crabby::transform3d& t, const Spin& s) {
t.rotation *= crabby::quat_from_rotation_y(s.speed * dt);
});
}
Try the demo: cargo run --release --example 3d --features 3d. Drag
with the left mouse button to orbit and use the wheel to zoom.
CUBES=100000 fills the field with that many spinning cubes.
Space
3D space is right-handed with +Y up. The default camera looks along -Z.
Units are yours to choose; meters work well. A Transform3D has a
position, a rotation (a quaternion, Quat) and a scale, and is
built the same way as a 2D one:
#![allow(unused)]
fn main() {
Transform3D::at(vec3(0.0, 1.0, -5.0))
.rotated(Quat::from_rotation_y(0.5))
.scaled(2.0)
}
t.looking_at(target) turns it to face a point, and t.forward() is the
way it faces (its -Z axis). C++ has crabby::transform3d_at(position, rotation, scale), crabby::looking_at, crabby::forward, and * on
quaternions and vectors.
Meshes and materials
A material says how a surface looks: its color (and an optional
texture), how rough it is (0 is a mirror, 1 is chalk), how metallic
(0 for most things, 1 for bare metal) and the light it gives off
(emissive). Lighting is physically based, as in glTF, so models made in
Blender look the same here.
A mesh is triangles with materials. There are three ways to get one:
- Shapes:
MeshData::cube(size),plane(size)andsphere(radius, segments), thenctx.create_mesh(&data, material). - Your own triangles: fill a
MeshDatawith positions, indices (three per triangle, counter-clockwise from the front) and optionally normals, texture coordinates andcolors(one per corner, multiplying the material’s color). Normals you leave out are computed. A second set of texture coordinates,uvs1(glTF’sTEXCOORD_1), is there for your own shaders to read asv.uv1ands.uv1: texture layers, masks, lightmaps.create_meshchecks that the data fits together. - glTF files:
ctx.load_mesh("models/house.glb")loads a.glb(or a.gltfwith its files) fromassets/, with every mesh in its scene, its materials with all their maps (base color, normal, occlusion, roughness and metalness, emissive), vertex colors, transparency and double-sidedness. Loading the same file again returns the same mesh.
Meshes and materials are cheap handles: make each one once, at startup,
and spawn as many models of it as you like. A Model tint
(Model::new(mesh).with_color(c)) multiplies the mesh’s colors, so a
thousand cubes can come in a thousand colors from one mesh.
Skinned meshes
A skinned mesh bends with a Skeleton: each corner follows up to four
joints with weights that add up to one. Load the mesh and skeleton from
the same glTF, then edit pose to move joints relative to their parents.
find looks up a joint by name. joint_matrix gives its current matrix
in the entity’s space; multiply by the entity transform for world space.
#![allow(unused)]
fn main() {
let mesh = ctx.load_mesh("models/character.glb");
let skeleton = ctx.load_skeleton("models/character.glb");
let hand = skeleton.find("hand_r").expect("model needs hand_r");
let character = ctx.world.spawn((Transform3D::default(), Model::new(mesh), skeleton));
let skeleton = ctx.world.get_mut::<Skeleton>(character).unwrap();
skeleton.pose[hand].rotation = Quat::from_rotation_y(0.3);
let local_hand = skeleton.joint_matrix(hand);
let transform = ctx.world.get::<Transform3D>(character).unwrap();
let world_hand = (transform.matrix() * local_hand).transform_point3(Vec3::ZERO);
println!("Hand at {world_hand}");
}
auto mesh = ctx.load_mesh("models/character.glb");
auto character = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(mesh));
ctx.load_skeleton(character, "models/character.glb");
auto hand = ctx.find_joint(character, "hand_r").value();
auto pose = ctx.joint_pose(character, hand);
pose.rotation = crabby::quat_from_rotation_y(0.3f);
ctx.set_joint_pose(character, hand, pose);
auto m = ctx.joint_matrix(character, hand); // model space, column-major
std::printf("Hand at %g %g %g (model space)\n", m[12], m[13], m[14]);
Each pose starts at rest. reset_pose() restores it; joints() exposes
the joint names, parents and rest transforms. A system can pose the skeleton
directly, or sample the animation clips described below:
#![allow(unused)]
fn main() {
fn sway(ctx: &mut Ctx) {
let time = ctx.time() as f32;
ctx.world.query::<(&mut Skeleton, &Sway)>().for_each(|(s, phase)| {
for (i, pose) in s.pose.iter_mut().enumerate() {
pose.rotation = Quat::from_rotation_z((time * 1.5 + phase.0 - i as f32 * 0.45).sin() * 0.16);
}
});
ctx.world.query::<(&mut Skeleton, &Bend)>().for_each(|(s, bend)| {
bend.0.sample_blended(time.rem_euclid(bend.0.duration()), 0.5, s);
});
ctx.world.query::<&mut Animator>().for_each(|a| {
a.set_bool(0, ((time / 3.0) as u32).is_multiple_of(2));
});
if ctx.key_pressed(Key::Escape) {
ctx.quit();
}
}
}
float time = float(ctx.time());
for (std::size_t i = 0; i < ctx.joint_count(tentacle); i++) {
auto pose = ctx.joint_pose(tentacle, i);
pose.rotation = crabby::quat_from_rotation_z(std::sin(time * 1.5f - i * 0.45f) * 0.16f);
ctx.set_joint_pose(tentacle, i, pose);
}
AttachToJoint keeps a rigid object on a joint, with an offset in that
joint’s space. It updates the object’s Transform3D just before drawing,
so systems see the previous frame’s attachment transform; use
joint_matrix when you need the current pose during a system.
SkinOf instead bends another mesh with the owner’s skeleton and draws
it at the owner’s transform. Use it for clothes and interchangeable parts:
joints bind by name, even when the part was made with a separate skeleton.
The part needs no transform of its own.
#![allow(unused)]
fn main() {
ctx.world.spawn((Model::new(band), SkinOf(tentacle)));
let tip = skeleton.find("joint_7").unwrap();
ctx.world.spawn((
Transform3D::default(),
Model::new(ball),
AttachToJoint {
skeleton: tentacle,
joint: tip,
offset: Transform3D::at(vec3(0.0, 0.12, 0.0)),
},
));
}
// Clothes: a mesh bound to the same joint names, posed by the tentacle.
auto band = ctx.create_skinned_mesh(tube(2.2f, 2.5f, 0.25f), gold, tentacle);
ctx.set_skin_of(ctx.world().spawn(crabby::model_of(band)), tentacle);
// A ball riding the tip, a little above it.
auto tip = *ctx.find_joint(tentacle, "joint_7");
auto rider = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(ball));
ctx.attach_to_joint(rider, tentacle, tip, crabby::transform3d_at({0, 0.12f, 0}));
You can also make everything in code. Put parents before their children:
#![allow(unused)]
fn main() {
fn chain() -> Skeleton {
Skeleton::new(
(0..JOINTS)
.map(|i| Joint {
name: format!("joint_{i}"),
parent: i.checked_sub(1),
rest: Transform3D::at(Vec3::Y * if i == 0 { 0.0 } else { STEP }),
})
.collect(),
)
}
}
constexpr int chain_joints = 8;
constexpr float chain_step = 0.5f;
static std::vector<crabby::joint> chain() {
std::vector<crabby::joint> joints;
for (int i = 0; i < chain_joints; i++)
joints.push_back({"joint_" + std::to_string(i), i - 1, crabby::transform3d_at({0, i == 0 ? 0 : chain_step, 0})});
return joints;
}
Fill MeshData::joints and weights with one four-element array per
corner. Here a subdivided cylinder blends between its two nearest joints:
#![allow(unused)]
fn main() {
fn tube(bottom: f32, top: f32, radius: f32) -> MeshData {
const SLICES: usize = 12;
const ROWS: usize = 28;
let mut data = MeshData::default();
for row in 0..=ROWS {
let y = bottom + (top - bottom) * row as f32 / ROWS as f32;
let along = (y / STEP).clamp(0.0, (JOINTS - 1) as f32);
let lower = (along.floor() as usize).min(JOINTS - 2);
let blend = along - lower as f32;
for slice in 0..SLICES {
let angle = std::f32::consts::TAU * slice as f32 / SLICES as f32;
data.positions.push(vec3(radius * angle.cos(), y, radius * angle.sin()));
data.joints.push([lower as u16, (lower + 1) as u16, 0, 0]);
data.weights.push([1.0 - blend, blend, 0.0, 0.0]);
}
}
for row in 0..ROWS {
for slice in 0..SLICES {
let a = (row * SLICES + slice) as u32;
let b = (row * SLICES + (slice + 1) % SLICES) as u32;
let c = a + SLICES as u32;
let d = b + SLICES as u32;
data.indices.extend([a, c, b, b, c, d]);
}
}
for slice in 1..SLICES - 1 {
let last = (ROWS * SLICES) as u32;
data.indices.extend([0, slice as u32, (slice + 1) as u32]);
data.indices
.extend([last, last + (slice + 1) as u32, last + slice as u32]);
}
data
}
}
static crabby::mesh_data tube(float bottom, float top, float radius) {
const int slices = 12, rows = 28;
std::vector<crabby::vec3> positions;
std::vector<std::uint32_t> indices;
std::vector<std::array<std::uint16_t, 4>> joints;
std::vector<std::array<float, 4>> weights;
for (int row = 0; row <= rows; row++) {
float y = bottom + (top - bottom) * row / rows;
float along = std::clamp(y / chain_step, 0.0f, float(chain_joints - 1));
int lower = std::min(int(along), chain_joints - 2);
float blend = along - lower;
for (int slice = 0; slice < slices; slice++) {
float angle = 6.2831853f * slice / slices;
positions.push_back({radius * std::cos(angle), y, radius * std::sin(angle)});
joints.push_back({std::uint16_t(lower), std::uint16_t(lower + 1), 0, 0});
weights.push_back({1 - blend, blend, 0, 0});
}
}
for (std::uint32_t row = 0; row < rows; row++)
for (std::uint32_t slice = 0; slice < slices; slice++) {
std::uint32_t a = row * slices + slice, b = row * slices + (slice + 1) % slices;
indices.insert(indices.end(), {a, a + slices, b, b, a + slices, b + slices});
}
crabby::mesh_data data(positions, indices);
data.set_skin(joints, weights);
return data;
}
Pass the rest skeleton to create_skinned_mesh; it records the bind pose:
#![allow(unused)]
fn main() {
let skeleton = chain();
let mesh = ctx.create_skinned_mesh(&tube(0.0, 3.5, 0.22), green, &skeleton);
// A separate skeleton with matching names demonstrates name binding.
let band = ctx.create_skinned_mesh(&tube(2.2, 2.5, 0.25), gold, &chain());
}
auto tentacle = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}));
ctx.set_skeleton(tentacle, chain());
auto mesh = ctx.create_skinned_mesh(tube(0, 3.5f, 0.22f), green, tentacle);
ctx.world().set(tentacle, crabby::model_of(mesh));
Try cargo run --example skinning --features 3d for three swaying
tentacles, a band sharing one skeleton and a sphere riding its tip.
Skinning runs on the GPU in a compute pass before any drawing pass uses
the mesh, including shadows and other cameras. WebGL2 uses a CPU fallback.
Custom material shaders need nothing special: they receive the deformed
geometry.
Animation clips
ctx.load_clips(path) loads every animation in a skinned glTF file.
ctx.load_clip(path, name) selects one, and lists available names if missing.
The try_load_clips and try_load_clip variants return Result instead.
Clips share their data when cloned and are cached with the file. Morph-weight
channels are currently ignored; translation, rotation and scale support Step,
Linear and glTF CubicSpline interpolation.
Sample clips into a skeleton each frame. Time clamps to the clip’s endpoints; use the remainder for looping, guarding zero-duration clips. For a crossfade, sample the first clip and blend toward the second with a weight from 0 to 1:
#![allow(unused)]
fn main() {
let mut skeleton = ctx.load_skeleton("models/character.glb");
let walk = ctx.load_clip("models/character.glb", "walk");
let run = ctx.load_clip("models/character.glb", "run");
let time = ctx.time() as f32;
let loop_time = |clip: &AnimationClip| {
if clip.duration() > 0.0 {
time.rem_euclid(clip.duration())
} else {
0.0
}
};
skeleton.reset_pose();
walk.sample(loop_time(&walk), &mut skeleton);
run.sample_blended(loop_time(&run), 0.5, &mut skeleton);
// Keep clips and the skeleton as components/resources for subsequent frames.
}
auto walk = ctx.load_clip("models/character.glb", "walk");
auto run = ctx.load_clip("models/character.glb", "run");
float t = float(ctx.time());
ctx.reset_pose(character);
ctx.sample_clip(walk, character, std::fmod(t, walk.duration()));
ctx.sample_clip(run, character, std::fmod(t, run.duration()), 0.5f);
Only animated properties change; reset the pose first when each frame should start at rest. Blending uses vector lerp and shortest-path quaternion slerp. Clips bind directly to their source skeleton’s joint indices. On another skeleton, they match names (the first match wins), caching that mapping on the first sample; later samples and skeleton clones allocate nothing. Missing joints are skipped. Sample once during setup to prepare a different skeleton’s binding before gameplay. Each clip retains these mappings and their shared joint lists until the clip is dropped.
Clips can also be built in code; this short bend is applied over the procedural sway in the skinning example:
#![allow(unused)]
fn main() {
fn bend_clip(skeleton: &Skeleton) -> AnimationClip {
let rotation = AnimationTrack::new(
vec![0.0, 1.0, 2.0],
[0.0, 0.3, 0.0]
.map(|angle| Vec4::from_array(Quat::from_rotation_z(angle).to_array()))
.to_vec(),
Interpolation::Linear,
);
AnimationClip::new(
"bend",
skeleton,
vec![JointAnimation {
joint: 1,
name: "joint_1".into(),
translation: None,
rotation: Some(rotation),
scale: None,
}],
)
}
}
C++ can’t build clips in code yet: load them from glTF files with ctx.load_clip.
AnimationTrack stores vector values as xyz with w ignored and quaternion
values as xyzw. Cubic tracks have three values per key: incoming tangent,
value, outgoing tangent. Tangents are derivatives per second; cubic rotation
results are normalized. You can keep time and fade weights in your own
components, or use an animator.
Animation state machines
AnimationGraph shares an immutable definition through Arc; each Animator
keeps its own parameters, state and clocks. State and parameter indices follow
vector order. This two-state graph switches between code-made clips:
#![allow(unused)]
fn main() {
fn bend_animator(skeleton: &Skeleton) -> Animator {
let idle = AnimationClip::new("idle", skeleton, vec![]);
let bend = bend_clip(skeleton);
let states = vec![
AnimationState::new(Motion::Clip(idle)),
AnimationState::new(Motion::Clip(bend)),
];
let transition = |from, to, active| AnimationTransition {
from: Some(from),
to,
conditions: vec![AnimationCondition::Bool(0, active)],
exit_time: None,
duration: 0.12,
offset: 0.0,
};
let graph = AnimationGraph::new(
vec![AnimationParameter::Bool(false)],
states,
vec![transition(0, 1, true), transition(1, 0, false)],
0,
);
Animator::new(graph)
}
}
crabby::animation_graph_builder b;
auto moving = b.add_bool();
auto idle = b.add_state(crabby::motion::of(ctx.load_clip("models/character.glb", "idle")));
auto walking = b.add_state(crabby::motion::of(walk));
b.add_condition(b.add_transition(idle, walking, 0.12f), CRABBY_CONDITION_BOOL, moving, 1);
b.add_condition(b.add_transition(walking, idle, 0.12f), CRABBY_CONDITION_BOOL, moving, 0);
auto graph = b.build(idle); // shared: give it to every character
ctx.set_animator(character, graph);
Spawn an Animator and Skeleton on the same entity for automatic playback.
The engine advances every animator each frame after your frame systems and
before your draw systems. So frame systems see last frame’s pose, and draw
systems this frame’s. run_headless has no frames; there, or to pose one at
another point, set manual = true and call update yourself:
#![allow(unused)]
fn main() {
let mut skeleton = chain();
let mut animator = bend_animator(&skeleton);
animator.manual = true;
animator.set_bool(0, true);
animator.update(0.016, &mut skeleton);
// Or spawn (Animator, Skeleton) together for automatic updates.
animator.crossfade(0, 0.12, 0.0);
animator.update(0.016, &mut skeleton);
for name in animator.drain_events() {
println!("Animation event: {name}");
}
}
ctx.animator_set_bool(character, moving, true);
// Or pose it yourself instead of every frame:
ctx.animator_set_manual(character, true);
ctx.animator_update(character, 0.016f);
ctx.animator_crossfade(character, idle, 0.12f);
while (auto name = ctx.animator_next_event(character)) std::printf("Animation event: %s\n", name->c_str());
Motion::Blend1D linearly blends neighbouring sorted thresholds on a float
parameter, clamping beyond the ends. Motion::Blend2D uses normalized inverse
squared distance on two floats; an exact child point selects exactly that
child (first wins for coincident points). Trees can contain clips or nested
trees through MotionChild, whose positive time_scale multiplies seconds.
A tree’s duration is the longest child duration divided by its time scale.
Each leaf loops at its own duration for looping states; clamped states hold
leaves at their ends. State speed can also be multiplied by a float parameter;
negative parameter values pause playback. Missing tracks use the rest pose.
Rotations use incremental shortest-path slerp in child order.
Transitions are tested in vector order, first match wins, at most once per
update. All conditions must pass: float greater/less, bool equality, or a set
trigger. Only a selected transition consumes its triggers. An exit time is a
minimum on the source’s unwrapped normalized clock, checked against the end of
the update; an any-state transition ignores it and skips its current target.
The target starts at offset and advances for the update’s full dt.
play and crossfade queue commands for the next update (last command wins),
which override graph transitions for that update.
Fades last a fixed number of seconds, independent of playback speed. Both
states advance during an ordinary fade. An interruption starts from the last
displayed mixed pose and freezes that pose as its source, preserving continuity.
Only the destination state tests transitions and emits events during a fade.
Events use normalized times in [0, 1); zero fires on a loop wrap, not initial
entry. Crossed events, including multiple loop wraps, arrive in time order via
drain_events. The reusable buffer retains at most 64 events between drains;
excess events are dropped. Sampling buffers allocate on first update or when
the skeleton layout changes; warm up clips on the target layout before gameplay.
Layers and root motion
A layer plays its own graph over the base animation, with its own
parameters, clock and events: a wave over a walk, or an additive lean. Its
weight fades it in and out, and a JointMask limits it to part of the
skeleton (JointMask::descendants picks a joint and everything under it).
#![allow(unused)]
fn main() {
let mut animator = bend_animator(skeleton);
let waving = AnimationGraph::new(vec![], vec![AnimationState::new(Motion::Clip(wave))], vec![], 0);
let mut layer = AnimationLayer::new(waving);
layer.mask = JointMask::descendants(skeleton, "joint_3"); // that joint and its children
layer.weight = 0.8;
let wave = animator.add_layer(layer);
animator.layer_mut(wave).weight = 0.5; // fade it any time; its graph has its own parameters
// The clip's own steps move the entity, instead of sliding in place.
animator.root_joint = 0;
animator.apply_root_motion = true;
}
crabby::animation_graph_builder upper;
upper.add_state(crabby::motion::of(ctx.load_clip("models/character.glb", "wave")));
auto waving = upper.build(0);
// The wave plays on the spine and everything under it, over the walk.
auto wave = ctx.animator_add_layer(character, waving, crabby::layer_blend::override_pose, "spine");
ctx.animator_set_layer_weight(character, wave, 0.8f);
// The walk's own steps move the character, instead of sliding in place.
ctx.animator_set_root_motion(character, crabby::root_motion::apply, *ctx.find_joint(character, "hips"));
Layers apply in the order they were added, each on the pose below it.
LayerBlend::Override blends toward the layer’s pose by its weight.
LayerBlend::Additive adds the layer’s difference from a reference pose:
reference_pose if you set one, otherwise the layer’s current motion at
time zero. Layers update even at weight zero, so they stay in time.
With root motion, the root joint’s horizontal movement and turning (yaw)
are taken out of the pose; height, pitch and roll stay in it.
root_motion() returns what the latest update took out, in the entity’s
own axes. Whole loops count, faster states move further, and crossfades blend
the two states’ movement. apply_root_motion also moves the entity’s
Transform3D by it during the engine’s automatic updates;
extract_root_motion alone leaves moving the entity to you, for example to
drive a physics character instead. Pick a root joint at the top of the
hierarchy (often the hips): its axes are the ones extraction uses.
IK and look-at
Register procedural corrections with Game::late_system: it runs once per
frame after automatic animators and before drawing, in registration order,
with frame dt and system timings. It does not run in headless simulations.
two_bone_ik rotates a direct three-joint chain toward a world target and
uses a world-space pole direction to choose its bend. Unreachable targets
extend to full reach; weight zero preserves the pose. look_at_joint aims a
joint-local axis, with a correction angle limit in radians and a blend weight.
Apply it parent first with separate weights to spread aim over a chain.
Entity non-uniform scale is removed before solving. Exact two-bone reach
requires uniform positive joint scales; non-uniform joint scale and shear
are approximate. Zero-length bones and singular transforms are ignored.
ctx.draw_skeleton uses projected 2D lines over the scene: bones are shown
through surfaces, without depth testing.
#![allow(unused)]
fn main() {
// Register this with Game::new(...).late_system(procedural_pose).
#[allow(dead_code)]
fn procedural_pose(ctx: &mut Ctx) {
let target = vec3(0.7, 0.6, 0.2);
ctx.world
.query::<(&Transform3D, &mut Skeleton)>()
.for_each(|(world, skeleton)| {
two_bone_ik(skeleton, world, [0, 1, 2], target, Vec3::Z, 1.0);
// Apply parent first when distributing aim across spine/neck/head.
look_at_joint(skeleton, world, 5, Vec3::Y, target, 0.3, 0.2);
look_at_joint(skeleton, world, 6, Vec3::Y, target, 0.5, 0.5);
look_at_joint(skeleton, world, 7, Vec3::Y, target, 0.8, 1.0);
});
}
#[allow(dead_code)]
fn debug_bones(ctx: &mut Ctx, character: Entity) {
ctx.draw_skeleton(character, Color::RED);
}
}
// Register this with crabby::game(...).late_system(procedural_pose).
[[maybe_unused]] static void procedural_pose(crabby::ctx& ctx, crabby::entity character) {
crabby::vec3 target{0.7f, 0.6f, 0.2f};
ctx.two_bone_ik(character, 0, 1, 2, target, {0, 0, 1});
// Apply parent first when distributing aim across spine/neck/head.
ctx.look_at_joint(character, 5, {0, 1, 0}, target, 0.3f, 0.2f);
ctx.look_at_joint(character, 6, {0, 1, 0}, target, 0.5f, 0.5f);
ctx.look_at_joint(character, 7, {0, 1, 0}, target, 0.8f, 1.0f);
}
[[maybe_unused]] static void debug_bones(crabby::ctx& ctx, crabby::entity character) {
ctx.draw_skeleton(character, crabby::colors::red);
}
Morph targets
A morph target (blend shape) is a named shape a mesh can blend toward: an offset for each corner, scaled by a weight. Blend a few to make poses that a model can slide between: a face between a smile and a blink, sliders for nose length and jaw width, a dent where something hit.
Each entry of MeshData::morph_targets has a name, one position offset
per corner and, optionally, one normal offset per corner so the lighting
follows the shape. ctx.morph_target(mesh, name) finds a target’s index.
A MorphWeights component on a model says how much of each target to
show: 0 for none, 1 for all of it (values outside that range exaggerate
or invert the shape). A model without one shows its mesh’s own weights,
which a glTF file can set per mesh. glTF targets are named from the file’s
targetNames extras, and meshes of one file that use the same name share
one target.
#![allow(unused)]
fn main() {
// System or setup: everything here runs headless too.
fn morph(ctx: &mut Ctx) {
// One target, named to find it again: at full weight its third corner
// sinks 0.4 into the sheet. Normals are left out, so the shading keeps
// its smooth look; give one offset per corner to bend the light along.
let mut sheet = MeshData::plane(1.0);
let mut dent = vec![Vec3::ZERO; sheet.positions.len()];
dent[2] = vec3(0.0, 0.0, -0.4);
sheet.morph_targets.push(MorphTarget {
name: "dent".into(),
positions: dent,
normals: Vec::new(),
});
let mut halfway = sheet.morph_targets[0].clone();
halfway.name = "dent_half".into();
halfway.positions[2] = vec3(0.1, 0.0, -0.15);
sheet.morph_targets.push(halfway);
sheet.morph_frames = vec![
MorphFrame {
channel: 0,
target: 1,
weight: 0.5,
},
MorphFrame {
channel: 0,
target: 0,
weight: 1.0,
},
];
let material = ctx.default_material();
let mesh = ctx.create_mesh(&sheet, material);
let dent = ctx.morph_target(mesh, "dent").unwrap();
let mut weights = MorphWeights::default();
weights.set(dent, 0.5); // halfway dented
let mut skeleton = Skeleton::new(vec![]);
let expression = AnimationClip::new("expression", &skeleton, vec![]).with_morph_tracks(vec![MorphAnimation {
name: "dent".into(),
track: AnimationTrack::new(vec![0.0, 1.0], vec![Vec4::ZERO, Vec4::X], Interpolation::Linear),
}]);
expression.sample(0.5, &mut skeleton);
ctx.world
.spawn((Transform3D::default(), Model::new(mesh), weights, skeleton));
ctx.write_animated_morphs();
}
}
// System or setup: everything here runs headless too.
static void morphed_sheet(crabby::ctx& ctx) {
// One target, named to find it again: at full weight its third corner
// sinks 0.4 into the sheet. Leave the normals empty to keep the
// shading, or give one offset per corner to bend the light along.
crabby::morph_target dent{"dent", std::vector<crabby::vec3>(4), {}};
dent.positions[2] = {0, 0, -0.4f};
auto sheet = crabby::mesh_data::plane(1.0);
sheet.add_morph_target(dent);
auto halfway = dent;
halfway.name = "dent_half";
halfway.positions[2] = {0.1f, 0, -0.15f};
sheet.add_morph_target(halfway);
sheet.add_morph_frame(0, 1, 0.5f);
sheet.add_morph_frame(0, 0, 1.0f);
auto mesh = ctx.create_mesh(sheet, ctx.default_material());
auto at = ctx.morph_target(mesh, "dent").value();
auto panel = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(mesh));
ctx.set_morph_weight(panel, at, 0.5f); // authored halfway frame
ctx.set_skeleton(panel, {}); // morph-only clips need no joints
crabby::clip expression("expression");
expression.set_morph_track("dent", {0, 1}, {0, 1});
ctx.sample_clip(expression, panel, 0.5f);
}
Morph targets combine with skinning: the shape is added first, then the
joints bend the result. A mesh with morph targets is shaped on the GPU in
the same compute pass as skinning, and WebGL2 uses the same CPU fallback.
glTF weights channels animate named targets with STEP, LINEAR or
CUBICSPLINE interpolation, including morph-only files without joints.
AnimationClip::with_morph_tracks adds scalar tracks in the x component;
C++ uses clip::set_morph_track. One means full weight. The animator
blends these tracks through blend trees, crossfades and override layers.
Additive layers add their weighted morph values (zero reference), independently
of the layer’s joint reference pose. Joint masks affect joints only: morph
tracks on a masked layer still run. Clips leave unanimated weights untouched.
Automatic animators write MorphWeights on their model and SkinOf
attachments. After manual Rust sampling, call ctx.write_animated_morphs();
C++ sample_clip and animator_update write the component immediately.
Skeleton::write_morph_weights can instead write to a caller-owned weight list.
Partial overrides use the model’s initial weights as their baseline; explicit
changes to its weight component establish a new baseline.
Unity/FBX channels can have several authored frames. Importing preserves every
frame, including position and normal deltas. MeshData::morph_frames (C++
mesh_data::add_morph_frame) links existing targets to a controlling channel
index and a positive frame weight. Set only the channel’s weight: halfway may
be its own authored shape, rather than half the final deltas. Evaluation is
piecewise linear, interpolating the first frame from zero and extrapolating
outside the authored range using the end segments. The CPU expands each
channel into at most two active target weights for the existing GPU/CPU
skinning paths. Ordinary glTF targets need no frame metadata.
The Unity importer carries mappings in mesh extras crabbyMorphFrames
(channel, target, weight indices/weight), alongside standard glTF targets.
Plain YAML blendShape.<name> curves on SkinnedMeshRenderer use 0–100 units
and export sampled weight channels, just like imported transform curves;
Hermite curves are sampled at the clip rate plus source key times. All-step
curves retain STEP interpolation; mixed interpolation is approximated by
linear samples. Animation extras crabbyAnimatedTargets identify the sparse
Unity channels so unrelated target weights remain untouched. Compressed or
binary Unity clips remain unsupported.
Camera
ctx.camera3d is the 3D camera. It has a position and rotation, and
look_at(target) turns it to face a point. Its fov_y is the vertical
field of view in radians (60° by default), and near and far bound
what’s drawn. ctx.camera3d.screen_ray(mouse) gives the ray under a
window point, for picking things with the mouse.
C++: ctx.camera3d() and ctx.set_camera3d(c) read and write it,
ctx.camera3d_look_at(target) turns it, and ctx.screen_ray(point)
returns the ray.
Light and shadows
ctx.lighting holds the sun and the sky:
sun_direction: which way sunlight travels. Straight down is noon; a low angle gives long shadows.sun_colorandsun_intensity: the sun’s color and brightness.sky_colorandground_color: soft light from above and below, scaled byambient_intensity. They (or the atmosphere) are also what sky IBL lights surfaces with.equator_color(Noneby default): light from the horizon. With it the ambient goes from the ground color below, through this on the horizon, to the sky color above, so walls facing sideways get a color of their own (a three-color gradient, as other engines’ “trilight” ambient). Like the other two, it’s only used without an atmosphere.
#![allow(unused)]
fn main() {
// Without an atmosphere: a blue sky, a green-grey horizon and brown ground.
ctx.sky.atmosphere = false;
ctx.lighting.sky_color = Color::hex(0x8FB3D9);
ctx.lighting.equator_color = Some(Color::hex(0x8C9A86));
ctx.lighting.ground_color = Color::hex(0x4D4135);
}
/// Without an atmosphere: a blue sky, a green-grey horizon and brown ground.
static void trilight(crabby::ctx& ctx) {
auto light = ctx.lighting();
light.sky_color = crabby::rgb(0.56f, 0.70f, 0.85f);
light.has_equator = true;
light.equator_color = crabby::rgb(0.55f, 0.60f, 0.53f);
light.ground_color = crabby::rgb(0.30f, 0.25f, 0.21f);
ctx.set_lighting(light);
}
shadowsandshadow_distance: the sun casts soft shadows up to that far from the camera (60 units by default), fading out at the edge.shadow_cascades(1 to 4, default 4): how many shadow maps share that distance. Each covers a stretch further out than the one before, so shadows stay sharp near the camera even when they reach hundreds of units; neighbouring cascades blend where they meet. Fewer are cheaper: each one draws its shadow casters again.shadow_resolution(256 to 4096, default 2048): the width of each cascade’s shadow map. The maps take4 × resolution² × cascadesbytes of GPU memory (64 MB by default). Change it while the game runs and the maps are made again at the new size.shadow_refresh(1 to 8, default 2): how often, in frames, the far half of the cascades is drawn again. The far cascades take turns, so at 2 each one is drawn every other frame and the far shadows cost half as much. Each is drawn into a box a little bigger than it needs (texels about 20% coarser), so the camera can move a while before it’s drawn again early; so is a cascade whose box the camera leaves, and every cascade when the sun moves. What moves out there casts a shadow that lags by up to that many frames. 1 draws every cascade every frame, in a tight box.shadow_filter(1 to 3, default 3): how soft the sun’s shadow edges are. Each lit pixel looks the shadow up once (1: a smooth 2×2 texel edge), four times (2: 3×3) or nine times (3: 4×4). On a large, high-resolution screen the lookups are a good part of drawing every lit pixel, so 1 or 2 is a cheap win on weaker GPUs.
The shadow maps follow the camera and move in whole texels, so shadow edges stay still while the camera moves.
Only casters whose shadow can land on something the camera sees (out to
shadow_distance) go into the cascades: a tree behind the camera with
the sun in front of it is skipped. Casters smaller than one of a
cascade’s texels are left out of it too, so pebbles cast in the near
cascades and not in the far ones, where their shadow wouldn’t show.
Every model casts a shadow, and each one costs as much in the shadow maps
as on screen. For small things that crowd the ground, such as grass and
pebbles, turn it off per mesh with ctx.set_mesh_shadows(mesh, false):
#![allow(unused)]
fn main() {
/// A meadow: thousands of grass tufts, too small for their shadows to show.
fn plant_meadow(ctx: &mut Ctx, tuft: Mesh) {
ctx.set_mesh_shadows(tuft, false); // no shadow map work for grass
for i in 0..1000 {
let at = vec3((i % 40) as f32 * 0.5, 0.0, (i / 40) as f32 * 0.5);
ctx.world.spawn((Transform3D::at(at), Model::new(tuft)));
}
}
}
/// A meadow: thousands of grass tufts, too small for their shadows to show.
static void plant_meadow(crabby::ctx& ctx, crabby::mesh tuft) {
ctx.set_mesh_shadows(tuft, false); // no shadow map work for grass
for (int i = 0; i < 1000; ++i) {
crabby::vec3 at{(i % 40) * 0.5f, 0, (i / 40) * 0.5f};
ctx.world().spawn(crabby::transform3d_at(at), crabby::model_of(tuft));
}
}
Per entity, a ShadowMesh picks what goes into the shadow maps instead of
the model: a simplified copy or a billboard for a detailed model far away
(a shadow there covers a few texels, so a tenth of the triangles casts the
same one), or ShadowMesh(None) for no shadow at all. An entity with a
ShadowMesh and no Model casts only a shadow: one simple stand-in for
the shadows of many small pieces, such as a fence, that cast none
themselves. On a TerrainTile, ShadowMesh(None) turns the ground’s own
shadow off. Scene files write it as "ShadowMesh": { "mesh": "file.glb" }
or { "mesh": null }.
#![allow(unused)]
fn main() {
/// A detailed tree with a cheap shadow, a flat sign with none, and a stand-in
/// shadow for a row of fence pieces that cast none themselves.
fn yard(ctx: &mut Ctx, tree: Mesh, blob: Mesh, sign: Mesh, fence: Mesh, fence_shadow: Mesh) {
ctx.world
.spawn((Transform3D::default(), Model::new(tree), ShadowMesh(Some(blob))));
ctx.world
.spawn((Transform3D::at(vec3(4.0, 0.0, 0.0)), Model::new(sign), ShadowMesh(None)));
for i in 0..10 {
let at = vec3(i as f32, 0.0, 6.0);
ctx.world
.spawn((Transform3D::at(at), Model::new(fence), ShadowMesh(None)));
}
// Only a shadow: no Model.
ctx.world
.spawn((Transform3D::at(vec3(0.0, 0.0, 6.0)), ShadowMesh(Some(fence_shadow))));
}
}
/// A detailed tree with a cheap shadow, a flat sign with none, and a stand-in
/// shadow for a row of fence pieces that cast none themselves.
static void yard(crabby::ctx& ctx, crabby::mesh tree, crabby::mesh blob, crabby::mesh sign,
crabby::mesh fence, crabby::mesh fence_shadow) {
auto t = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(tree));
ctx.set_shadow_mesh(t, blob);
auto s = ctx.world().spawn(crabby::transform3d_at({4, 0, 0}), crabby::model_of(sign));
ctx.set_no_shadow(s);
for (int i = 0; i < 10; ++i) {
auto f = ctx.world().spawn(crabby::transform3d_at({float(i), 0, 6}), crabby::model_of(fence));
ctx.set_no_shadow(f);
}
// Only a shadow: no model.
auto only = ctx.world().spawn(crabby::transform3d_at({0, 0, 6}));
ctx.set_shadow_mesh(only, fence_shadow);
}
Shadows are often where a big 3D scene’s GPU time goes: every caster is drawn once per shadow cascade it falls in, also when it’s behind the camera. The profiling tools count the triangles drawn into the shadow maps separately from the camera’s.
Normal maps, ORM maps and glow
Besides texture, a MaterialDesc takes the maps that glTF models come
with:
normal_map: surface detail (bumps, grooves, bricks) as tangent-space normals, OpenGL style.normal_strengthscales it.orm_map: ambient occlusion in red, roughness in green and metallic in blue, multiplyingroughnessandmetallic.emissive_map: multipliesemissive.
A texture gets mipmaps (smaller copies of itself) the first time a material uses it, so it stays smooth far away and at a slant instead of shimmering. Sprites drawing the same texture still use it at full size.
Materials are solid by default (Blend::Opaque), whatever their alpha.
blend: Blend::Cutout cuts out the pixels whose alpha is below 0.5
(leaves, fences, grass cards), in their shadows too. The edge of the cut
is smoothed with the multisampling (alpha to coverage): the alpha is
sharpened to a ramp about a pixel wide around 0.5, and each pixel keeps as
many of its samples as that says, so leaves and wire fences don’t stair-step
or shimmer. Shadows keep the hard cut. It costs more, as the
material’s shader then also runs for hidden pixels and shadows, so keep it
for materials with holes. blend: Blend::Transparent makes a material
see-through by its alpha (glass, water). Transparent models are drawn after everything solid and
cast no shadows; they aren’t sorted, so overlapping ones can show in the
wrong order. double_sided: true draws the back of each triangle too
(leaves, cloth). ctx.update_material(material, desc) changes a material
while the game runs.
Point and spot lights
Attach a PointLight3D or SpotLight3D to an entity with a Transform3D.
Point lights shine in every direction; spots aim along the transform’s local
−Z (forward()). Transform scale never changes their range. Color is sRGB
(alpha ignored); intensity is linear radiant intensity. Cone angles are half
angles in radians, with 0 <= inner_angle <= outer_angle < pi/2.
#![allow(unused)]
fn main() {
use ponko::*;
fn lamps(ctx: &mut Ctx) {
ctx.world.spawn((Transform3D::at(vec3(0.0, 3.0, 0.0)),
PointLight3D { color: Color::rgb(1.0, 0.3, 0.1), intensity: 30.0, range: 12.0,
..Default::default() }));
let aim = Transform3D::at(vec3(4.0, 3.0, 0.0)).looking_at(Vec3::ZERO);
ctx.world.spawn((aim,
SpotLight3D { intensity: 60.0, range: 20.0, inner_angle: 0.2,
outer_angle: 0.5, shadows: true, ..Default::default() }));
}
}
static void punctual_lights(crabby::ctx& ctx) {
ctx.world().spawn(crabby::transform3d_at({0, 3, 0}),
crabby::point_light3d{crabby::rgb(1, .3f, .1f), 30, 12, false});
auto aim = crabby::looking_at(crabby::transform3d_at({4, 3, 0}), {0, 0, 0});
ctx.world().spawn(aim,
crabby::spot_light3d{crabby::colors::white, 60, 20, .2f, .5f, true}); // casts shadows
}
Both types use the sun’s Cook–Torrance GGX diffuse/specular model.
Distance attenuation is max(1 - (distance/range)^4, 0)^2 / max(distance^2, 1):
finite near the source, smoothly zero at the range. Spots also smoothstep from
the inner cone to the outer cone; equal angles give a hard edge.
Nonfinite or nonpositive intensity/range and invalid cone angles disable a light.
The CPU culls lights conservatively and bins them into 16×9 screen tiles with 24 logarithmic depth slices, independently for each camera (including target and reflection cameras). Pixels loop over their cluster’s list, with no fixed per-cluster cap or silent overflow. CPU lists and GPU textures retain capacity and grow only at new high-water marks. Allocation-free steady state refers to the engine’s light collection and packing; the GPU driver’s upload machinery is outside that guarantee. Packed textures also work on WebGL2. Adapter texture limits bound the maximum number of visible lights and cluster references; exceeding them reports an explicit capacity error.
Shadows from point and spot lights
Set shadows: true on a light (the spot light above has it) to have
models block it. A spot light takes one shadow view, looking down its cone,
and a point light six, one per cube face. Shadowed lights nearest the
camera get theirs first, up to light_shadow_views views a frame (12 by
default, at most MAX_LIGHT_SHADOW_VIEWS, 24); any left over shine
unshadowed. So keep shadows for the lights that need them: a desk lamp,
headlights, a torch, a muzzle flash.
Every view is a square tile of one depth atlas, packed again each frame,
so many small far lights fit beside a few large near ones. A light’s tiles
are as wide as it looks big: one whose reach fills the view (or that the
camera stands in) gets light_shadow_resolution texels (default
LIGHT_SHADOW_SIZE, 1024), one twice as far away half that, down to 64.
Set a light’s shadow_resolution to pick its size yourself (64 to 2048).
Sizes are powers of two; when the views don’t all fit the atlas, the
largest shrink first, and only if even the smallest don’t fit are the
farthest lights left unshadowed.
#![allow(unused)]
fn main() {
/// A street at night: a sharp shadow for the lamp beside the player,
/// rough ones for the street lights down the road.
fn street_lights(ctx: &mut Ctx) {
ctx.lighting.light_shadow_atlas = 8192; // room for more shadow views
ctx.lighting.light_shadow_views = 24; // as many as there can be
let lamp = PointLight3D {
color: Color::rgb(1.0, 0.8, 0.6),
intensity: 40.0,
range: 12.0,
shadows: true,
shadow_resolution: 2048, // always sharp
};
ctx.world.spawn((Transform3D::at(vec3(0.0, 3.0, 0.0)), lamp));
for i in 0..18 {
let x = -20.0 + 2.5 * i as f32;
let at = Transform3D::at(vec3(x, 5.0, -30.0)).looking_at(vec3(x, 0.0, -29.9));
// Sized by how big they look: smaller the farther they are.
let street_light = SpotLight3D {
intensity: 60.0,
range: 8.0,
shadows: true,
..Default::default()
};
ctx.world.spawn((at, street_light));
}
}
}
/// A street at night: a sharp shadow for the lamp beside the player,
/// rough ones for the street lights down the road.
static void street_lights(crabby::ctx& ctx) {
auto light = ctx.lighting();
light.light_shadow_atlas = 8192; // room for more shadow views
light.light_shadow_views = 24; // as many as there can be
ctx.set_lighting(light);
crabby::point_light3d lamp{crabby::rgb(1, .8f, .6f), 40, 12, true};
lamp.shadow_resolution = 2048; // always sharp
ctx.world().spawn(crabby::transform3d_at({0, 3, 0}), lamp);
for (int i = 0; i < 18; i++) {
float x = -20 + 2.5f * i;
auto at = crabby::looking_at(crabby::transform3d_at({x, 5, -30}), {x, 0, -29.9f});
// Sized by how big they look: smaller the farther they are.
ctx.world().spawn(at, crabby::spot_light3d{crabby::colors::white, 60, 8, .3f, .5f, true});
}
}
Light shadows use the same caster meshes as the sun (ShadowMesh, mesh
shadows off), are filtered over 3×3 texels (kept inside each light’s tile,
so they never pick up a neighbor’s), and are pushed a little off the
surface against shadow acne, like the sun’s. Lighting::shadows off turns
them off too. Only the main camera sees them: target and reflection
cameras get the light unshadowed. The atlas is made the first time a light
casts shadows.
light_shadow_atlas (1024 to 8192, a power of two, default
LIGHT_SHADOW_ATLAS_SIZE, 4096) is the atlas’s width and height: it takes
4 × size² bytes (64 MB by default) and holds sixteen 1024² views, or 256
of 256². A budget of 0 views turns point and spot light shadows off and
frees it; a new atlas size makes it again.
Try cargo run --release --features 3d --example lights (LIGHTS=1000).
The scene has colored points and spots, with shadows and bloom disabled.
A windowless release benchmark of 0, 100 and 1,000 lights is available with
cargo test --release --features 3d --lib offscreen_light_benchmark -- --ignored --nocapture.
It reports ctx.render_stats() CPU and GPU milliseconds after warmup at 1280×720.
Custom shaders
A material can have its own shader, in WGSL. It doesn’t have to do everything: it defines one or both of two functions, and the engine does the rest (lighting, shadows, the depth prepass, and transparency):
fn vertex(v: Vertex) -> Vertexmoves vertices, in world space: wind in grass, waves on water.v.originis where the model is, so each model can sway or vary on its own.fn surface(s: Surface) -> Surfacechanges each pixel: itsalbedo,alpha,normal,roughness,metallic,occlusionandemissive, filled in from the material first. It can reads.world,s.uv,s.uv1(the mesh’s second texture coordinates),s.color(the model’s tint times the vertex color),s.view(towards the camera) ands.origin(where the model is: a height above its base, a color per model).
#![allow(unused)]
fn main() {
/// Grass that sways in the wind: the higher a vertex, the more it moves.
const WIND: &str = "
fn vertex(v: Vertex) -> Vertex {
var out = v;
let sway = sin(frame.time.x * 2.0 + v.position.x) * material.custom[0].x;
out.position.x += sway * v.uv.y * v.uv.y;
return out;
}";
fn meadow(ctx: &mut Ctx) {
let wind = ctx.create_shader("wind", WIND);
let grass = ctx.create_material(MaterialDesc {
shader: Some(wind),
double_sided: true,
custom: [vec4(0.2, 0.0, 0.0, 0.0), Vec4::ZERO, Vec4::ZERO, Vec4::ZERO],
..MaterialDesc::color(Color::hex(0x7FA83C))
});
let blade = ctx.create_mesh(&MeshData::plane(0.5), grass);
ctx.world.spawn((Transform3D::default(), Model::new(blade)));
}
}
/// Grass that sways in the wind: the higher a vertex, the more it moves.
static const char* wind_shader = R"(
fn vertex(v: Vertex) -> Vertex {
var out = v;
let sway = sin(frame.time.x * 2.0 + v.position.x) * material.custom[0].x;
out.position.x += sway * v.uv.y * v.uv.y;
return out;
})";
static void meadow(crabby::ctx& ctx) {
auto look = crabby::material_of(crabby::rgb(.5f, .66f, .24f));
look.has_shader = true;
look.shader = ctx.create_shader("wind", wind_shader);
look.double_sided = true;
look.custom[0] = 0.2f;
auto grass = ctx.create_material(look);
auto blade = ctx.create_mesh(crabby::mesh_data::plane(0.5f), grass);
ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(blade));
}
A shader can read:
frame.time.x: seconds since the game started (frame.time.y: since the last frame),frame.camera.xyz,frame.sun_dir.xyz,frame.sun_color.rgb,frame.sky.rgbandframe.ground.rgb;material.custom[0]to[3]: fourvec4s fromMaterialDesc::custom;custom_texture0andcustom_texture1(fromMaterialDesc::custom_textures), and the material’s own textures, withmaterial_sampler;custom_array0tocustom_array2(texture_2d_array<f32>, fromMaterialDesc::custom_arrays): texture arrays, many same-size images in one, picked by layer:textureSample(custom_array0, material_sampler, s.uv, 2). Make one withctx.create_texture_arrayorctx.load_texture_array, asTextureKind::Color(painted colors) orTextureKind::Data(normal maps, masks and weights, read as stored);- helpers:
hash2,noise2(smooth value noise) andfbm2(five octaves of it).
Transparent materials can also see what is behind them, for water,
glass and heat haze. With s.screen (the pixel’s position on screen, in
pixels):
scene_color(screen)is the lit color of the opaque scene there;scene_position(screen)is the world position of the opaque surface there, andscene_distance(screen)its distance from the camera.
Offsetting screen bends what shows through (refraction), and comparing
scene_distance(s.screen) with the surface’s own distance gives how
deep the water is. A frame with such a shader copies the scene once
before its transparent models are drawn; frames without one don’t pay
for it.
ctx.load_shader("shaders/water.wgsl") loads one from assets/; with
hot reload on, saving the file changes the running game. A shader that
doesn’t compile stops the game at load with the compiler’s message,
pointing at the line; on reload, the message is printed and the old
shader stays. cargo run --release --example shaders --features 3d shows
a lake, grass in the wind and a pattern made in a shader.
Custom material hooks can call punctual_light(s: Surface) -> vec3<f32>.
It returns linear HDR diffuse plus specular using the surface’s world position,
normal, view direction, albedo, metallic and roughness. The built-in shade
already adds it; adding it to emissive adds the light a second time. For stylized
uses, copy the surface, change its BRDF fields, and evaluate that copy. The
lower-level direct_light(s, direction_to_light, radiance) evaluates the same
BRDF for one light. sunlight(world, normal) returns sun shadow visibility
(screen effects and compute passes can call it as well).
It includes light shadows for lights that cast them.
Sky and fog
ctx.sky draws a physical sky behind the 3D world, lit by the sun in
ctx.lighting: blue by day, orange and red when the sun is low, dark blue
with stars at night. The same air lights the scene: the sun turns orange
and dims as it sets, and the sky’s light (bluish in the shade) replaces
Lighting::sky_color and ground_color. One world unit counts as a
meter.
atmosphere: on by default. Off, the background isctx.clear’s color and the light comes fromLighting’s colors.haze: dust and water in the air. More gives a paler sky, a bigger glow around the sun and redder sunsets.sun_size(degrees across),starsandnight_light(moonlight and starlight, when the sun is down).fog_density,fog_heightandfog_falloff: height fog, thickest atfog_heightand thinning out above it. It takes the sky’s color behind it (orfog_colorwithout an atmosphere) and covers the sky near the horizon too.fog_startandfog_end: distance fog, none closer thanfog_startand all fog pastfog_end, whatever the height. Off whilefog_endisn’t pastfog_start(the default). It takes the same color as the height fog, and leaves the sky alone.fog_sun_glowandfog_sun_focus: sunlight the fog scatters toward the camera, so both kinds glow when looking toward the sun; the focus (0 to under 1) draws the glow in close around it.
Sky::sun_direction_at(hour) gives the sun’s direction for a time of
day, for a day and night cycle: it rises in the east (+X) at 6, is
highest at noon and sets in the west at 18.
#![allow(unused)]
fn main() {
/// A misty morning: the sun low in the east, fog lying in the valleys.
fn misty_morning(ctx: &mut Ctx) {
ctx.lighting.sun_direction = Sky::sun_direction_at(7.5);
ctx.sky.haze = 2.0; // a paler sky, a bigger glow around the sun
ctx.sky.fog_density = 0.03; // thick near the ground
ctx.sky.fog_height = 0.0;
ctx.sky.fog_falloff = 10.0; // and thin ten meters up
}
}
/// A misty morning: the sun low in the east, fog lying in the valleys.
static void misty_morning(crabby::ctx& ctx) {
auto light = ctx.lighting();
light.sun_direction = crabby::sun_direction_at(7.5f);
ctx.set_lighting(light);
auto sky = ctx.sky();
sky.haze = 2; // a paler sky, a bigger glow around the sun
sky.fog_density = 0.03f; // thick near the ground
sky.fog_height = 0;
sky.fog_falloff = 10; // and thin ten meters up
ctx.set_sky(sky);
}
For a big open world, distance fog hides where the streamed world ends:
#![allow(unused)]
fn main() {
/// A big open world fading out far away, glowing toward the sun.
fn far_haze(ctx: &mut Ctx) {
ctx.sky.fog_start = 500.0; // clear up to half a kilometre
ctx.sky.fog_end = 1200.0; // and gone in the haze past 1.2 km
ctx.sky.fog_sun_glow = 0.06; // the haze glows toward the sun
ctx.sky.fog_sun_focus = 0.7; // most of all close around it
}
}
/// A big open world fading out far away, glowing toward the sun.
static void far_haze(crabby::ctx& ctx) {
auto sky = ctx.sky();
sky.fog_start = 500; // clear up to half a kilometre
sky.fog_end = 1200; // and gone in the haze past 1.2 km
sky.fog_sun_glow = 0.06f; // the haze glows toward the sun
sky.fog_sun_focus = 0.7f; // most of all close around it
ctx.set_sky(sky);
}
Light shafts let the sun’s shadows reach into the fog: where the mist is in a tree’s shade it stops glowing, so beams of sunlight show between the trunks. They need fog with a sun glow and shadows, and reach as far as the shadows do. Each pixel samples the shadow map 16 times along its view ray, with a different offset every frame; temporal antialiasing smooths that grain away.
#![allow(unused)]
fn main() {
/// Beams of morning sun through the trees.
fn sunbeams(ctx: &mut Ctx) {
ctx.sky.fog_density = 0.03; // low mist to light up
ctx.sky.fog_sun_glow = 0.1; // glowing in the sun
ctx.sky.light_shafts = 1.0; // but not in the shade
ctx.lighting.shadows = true; // shafts follow the sun's shadows
ctx.post.anti_aliasing = AntiAliasing::Taa; // and smooth out their grain
}
}
/// Beams of morning sun through the trees.
static void sunbeams(crabby::ctx& ctx) {
auto sky = ctx.sky();
sky.fog_density = 0.03f; // low mist to light up
sky.fog_sun_glow = 0.1f; // glowing in the sun
sky.light_shafts = 1; // but not in the shade
ctx.set_sky(sky);
auto light = ctx.lighting();
light.shadows = true; // shafts follow the sun's shadows
ctx.set_lighting(light);
auto post = ctx.post();
post.anti_aliasing = uint32_t(crabby::anti_aliasing::taa); // smooth their grain
ctx.set_post(post);
}
The sky costs two small passes a frame: one works it out for every
direction into a 256x128 texture, one draws it where nothing else was.
In the shaders example, the left and right arrows turn the time of day
and F turns fog on and off.
Screen effects
A screen effect is a shader run over the whole lit 3D frame, for looks that belong to the picture rather than to one surface: under water, heat haze, a dream blur, sun shafts, outlines. It defines one function:
fn effect(p: Pixel) -> vec3<f32>gives the new light at each pixel, fromp.color(the light there so far, brighter than white where the scene is bright),p.worldandp.distance(the nearest solid surface and how far it is from the camera),p.sky(nothing solid there),p.screen(in pixels) andp.uv(0 to 1 across the screen).
It can read other pixels with scene_color(screen) and
scene_position(screen), and everything a material shader reads:
frame, the noise helpers, and material.custom and the textures of the
material it’s given. That includes the sun’s shadow maps:
sunlight(world, normal) says how much sun reaches a point (0 in shadow,
1 lit), so an effect can march along the view ray and add light only where
the sun gets through, for sun shafts in fog or dust. Compute passes can
call it too. Make one with ctx.create_effect or
ctx.load_effect (hot reloaded, like material shaders), and run it by
adding a ScreenEffect to ctx.effects. Effects run in that order, each
on what the one before left, after the transparent models and before
bloom and the post-processing below. p.world is the nearest solid
surface: transparent models don’t count.
#![allow(unused)]
fn main() {
/// Under water, light fades to deep blue with distance (and the sky above
/// is gone). `material.custom[0].x` is the water's height.
const UNDERWATER: &str = "
fn effect(p: Pixel) -> vec3<f32> {
if frame.camera.y > material.custom[0].x {
return p.color; // above the water: nothing changes
}
let deep = vec3<f32>(0.02, 0.12, 0.18);
let fade = 1.0 - exp(-p.distance * 0.08);
return mix(p.color * vec3<f32>(0.6, 0.9, 1.0), deep, fade);
}";
fn dive(ctx: &mut Ctx) {
let underwater = ctx.create_effect("underwater.wgsl", UNDERWATER);
let lake = ctx.create_material(MaterialDesc {
custom: [vec4(2.0, 0.0, 0.0, 0.0), Vec4::ZERO, Vec4::ZERO, Vec4::ZERO], // water at y = 2
..Default::default()
});
ctx.effects.push(ScreenEffect::new(underwater).with_material(lake));
}
}
/// Under water, light fades to deep blue with distance (and the sky above
/// is gone). `material.custom[0].x` is the water's height.
static const char* underwater_effect = R"(
fn effect(p: Pixel) -> vec3<f32> {
if frame.camera.y > material.custom[0].x {
return p.color; // above the water: nothing changes
}
let deep = vec3<f32>(0.02, 0.12, 0.18);
let fade = 1.0 - exp(-p.distance * 0.08);
return mix(p.color * vec3<f32>(0.6, 0.9, 1.0), deep, fade);
})";
static void dive(crabby::ctx& ctx) {
auto underwater = ctx.create_effect("underwater.wgsl", underwater_effect);
auto desc = crabby::material_of(crabby::colors::white);
desc.custom[0] = 2; // water at y = 2
auto lake = ctx.create_material(desc);
auto list = ctx.effects();
list.push_back(crabby::screen_effect_of(underwater, lake));
ctx.set_effects(list);
}
Each effect costs one full-screen draw: effects take turns between two frame buffers, each reading one and writing the other, so the frame is never copied. A game with no effects doesn’t pay for them.
Render targets
A render target is a texture a second camera draws the world into, each
frame, before the main view: for mirrors, water that reflects the hills,
a security camera’s monitor, a portal. ctx.create_render_target(w, h)
makes one, and a TargetCamera in ctx.target_cameras draws into it.
Materials then use it like any other texture. It holds the lit scene with
its sky and fog, in the same HDR light as the frame before tone mapping
(so put it in emissive, not albedo, to show it as it is). Target
cameras use the main view’s shadows and don’t draw transparent models.
TargetCamera::reflection(camera, target, level) is the camera for a
level mirror at height level: camera mirrored below it, drawing only
what’s above it (its clip_plane, which tilts the camera’s near plane
onto the mirror, so it costs nothing). Read it at the screen position
flipped upside down, and update it every frame as the camera moves:
#![allow(unused)]
fn main() {
/// A still pond: its color is the world mirrored in it, which a target
/// camera below the surface draws into `custom_texture0`, upside down.
const MIRROR: &str = "
fn surface(s: Surface) -> Surface {
var out = s;
let uv = s.screen * frame.screen.zw;
out.emissive = textureSample(custom_texture0, material_sampler, vec2<f32>(uv.x, 1.0 - uv.y)).rgb * 0.9;
out.albedo = vec3<f32>(0.0);
return out;
}";
fn pond(ctx: &mut Ctx) {
let size = ctx.window_size();
let reflection = ctx.create_render_target(size.x as u32 / 2, size.y as u32 / 2);
let mirror = ctx.create_shader("mirror", MIRROR);
let water = ctx.create_material(MaterialDesc {
shader: Some(mirror),
custom_textures: [Some(reflection), None],
..Default::default()
});
let surface = ctx.create_mesh(&MeshData::plane(20.0), water);
ctx.world.spawn((Transform3D::default(), Model::new(surface)));
ctx.target_cameras
.push(TargetCamera::reflection(ctx.camera3d, reflection, 0.0));
}
/// Every frame: the mirrored camera follows the real one.
fn follow_reflections(ctx: &mut Ctx) {
for t in &mut ctx.target_cameras {
*t = TargetCamera::reflection(ctx.camera3d, t.target, 0.0);
}
}
}
/// A still pond: its color is the world mirrored in it, which a target
/// camera below the surface draws into `custom_texture0`, upside down.
static const char* mirror_shader = R"(
fn surface(s: Surface) -> Surface {
var out = s;
let uv = s.screen * frame.screen.zw;
out.emissive = textureSample(custom_texture0, material_sampler, vec2<f32>(uv.x, 1.0 - uv.y)).rgb * 0.9;
out.albedo = vec3<f32>(0.0);
return out;
})";
static void pond(crabby::ctx& ctx) {
auto size = ctx.window_size();
auto reflection = ctx.create_render_target(std::uint32_t(size.x / 2), std::uint32_t(size.y / 2));
auto look = crabby::material_of(crabby::colors::white);
look.has_shader = true;
look.shader = ctx.create_shader("mirror", mirror_shader);
look.has_custom_textures[0] = true;
look.custom_textures[0] = reflection;
auto surface = ctx.create_mesh(crabby::mesh_data::plane(20), ctx.create_material(look));
ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(surface));
ctx.set_target_cameras({ctx.reflection_camera(ctx.camera3d(), reflection, 0)});
}
/// Every frame: the mirrored camera follows the real one.
static void follow_reflections(crabby::ctx& ctx) {
auto list = ctx.target_cameras();
for (auto& t : list) t = ctx.reflection_camera(ctx.camera3d(), t.target, 0);
ctx.set_target_cameras(list);
}
Each target camera draws its view again (a depth prepass and the lit
pass), so keep targets small: half the window is plenty for a rippled
lake. examples/terrain.rs has a lake with waves that reflects the hills.
Compute passes
A compute pass runs a shader on the GPU once per texel of a render target, every frame, before anything is drawn: for things that live in a texture and change over time, such as ripples, footprints and tyre tracks in snow or mud, wet ground drying, spreading fire, or fog of war. It defines one function:
fn compute(c: Cell) -> vec4<f32>gives the texel’s new value, from what it holds now (c.value, all 0 at first), where it is (c.pixel,c.size, andc.uvfrom 0 to 1) and its neighbours:cell_at(pixel)reads any texel as it was before this pass.
It also reads frame (frame.time.y is the frame’s seconds) and the
values and textures of the material it’s given, as screen effects do, so
the game steers it with ctx.update_material. Make one with
ctx.create_compute or ctx.load_compute (hot reloaded), and run it by
adding a ComputePass to ctx.compute_passes. Passes run in that order;
the same pass twice steps the simulation twice a frame. Materials read the
target as a texture, in the same frame:
#![allow(unused)]
fn main() {
/// Ripples: each texel moves toward the average of its neighbours,
/// overshooting like water. Red is the height now, green the height a
/// frame ago; `material.custom[0]` drops a stone (uv x, uv y, strength).
const RIPPLES: &str = "
fn compute(c: Cell) -> vec4<f32> {
let around = cell_at(c.pixel + vec2<i32>(1, 0)).x + cell_at(c.pixel - vec2<i32>(1, 0)).x
+ cell_at(c.pixel + vec2<i32>(0, 1)).x + cell_at(c.pixel - vec2<i32>(0, 1)).x;
var height = (around * 0.5 - c.value.y) * 0.985;
let drop = material.custom[0];
if drop.z > 0.0 && distance(c.uv, drop.xy) * f32(c.size.x) < 3.0 {
height -= drop.z;
}
return vec4<f32>(height, c.value.x, 0.0, 0.0);
}";
/// The simulated heights, for a water material to read, and the material
/// that drops stones into them.
fn ripples(ctx: &mut Ctx) -> (Texture, Material) {
let heights = ctx.create_render_target(256, 256);
let ripples = ctx.create_compute("ripples", RIPPLES);
let stones = ctx.create_material(MaterialDesc::default());
ctx.compute_passes
.push(ComputePass::new(ripples, heights).with_material(stones));
(heights, stones)
}
/// Drops a stone at `uv` (0 to 1 across the water) this frame; `strength`
/// 0 drops none.
fn drop_stone(ctx: &mut Ctx, stones: Material, uv: Vec2, strength: f32) {
let stone = uv.extend(strength).extend(0.0);
ctx.update_material(
stones,
MaterialDesc {
custom: [stone, Vec4::ZERO, Vec4::ZERO, Vec4::ZERO],
..Default::default()
},
);
}
}
/// Ripples: each texel moves toward the average of its neighbours,
/// overshooting like water. Red is the height now, green the height a
/// frame ago; `material.custom[0]` drops a stone (uv x, uv y, strength).
static const char* ripples_compute = R"(
fn compute(c: Cell) -> vec4<f32> {
let around = cell_at(c.pixel + vec2<i32>(1, 0)).x + cell_at(c.pixel - vec2<i32>(1, 0)).x
+ cell_at(c.pixel + vec2<i32>(0, 1)).x + cell_at(c.pixel - vec2<i32>(0, 1)).x;
var height = (around * 0.5 - c.value.y) * 0.985;
let drop = material.custom[0];
if drop.z > 0.0 && distance(c.uv, drop.xy) * f32(c.size.x) < 3.0 {
height -= drop.z;
}
return vec4<f32>(height, c.value.x, 0.0, 0.0);
})";
struct ripples_state { crabby::texture heights; crabby::material stones; };
/// The simulated heights, for a water material to read, and the material
/// that drops stones into them.
static ripples_state ripples(crabby::ctx& ctx) {
auto heights = ctx.create_render_target(256, 256);
auto ripples = ctx.create_compute("ripples", ripples_compute);
auto stones = ctx.create_material(crabby::material_of(crabby::colors::white));
auto list = ctx.compute_passes();
list.push_back(crabby::compute_pass_of(ripples, heights, stones));
ctx.set_compute_passes(list);
return {heights, stones};
}
/// Drops a stone at `uv` (0 to 1 across the water) this frame; `strength`
/// 0 drops none.
static void drop_stone(crabby::ctx& ctx, crabby::material stones, crabby::vec2 uv, float strength) {
auto desc = crabby::material_of(crabby::colors::white);
desc.custom[0] = uv.x;
desc.custom[1] = uv.y;
desc.custom[2] = strength;
ctx.update_material(stones, desc);
}
examples/compute.rs rains on a pond with this, and bends the water’s
normals by the heights. Values are 16-bit floats, four per texel. A pass
costs one GPU dispatch and one copy of its target: about 0.04 ms of GPU
time for 512 x 512 on an M4 Max. Browsers without WebGPU (WebGL2) can’t run compute
passes: making one there returns an error.
Post-processing
The 3D frame is lit in HDR: light can be brighter than white. On its way
to the screen, ctx.post decides how it looks:
exposure: brightness in stops (+1 doubles it).bloomandbloom_threshold: light brighter than the threshold glows into its surroundings (0 turns bloom off).temperatureandtint: white balance, cooler or warmer, greener or pinker.contrastandsaturation: 1 keeps them as they are.tonemap: how bright light rolls off to white.Tonemap::Aces(the default) is film-like,AgXkeeps the hue of bright colors,Neutralstays closest to the materials’ own colors.lut: a color lookup table for grading in an image editor. Start fromPost::neutral_lut(32), save it as a PNG, paint over it, and load it withctx.load_texture.vignette: darker corners.
#![allow(unused)]
fn main() {
/// A warm, glowing evening look.
fn golden_hour(ctx: &mut Ctx) {
ctx.post.exposure = 0.3; // a third of a stop brighter
ctx.post.bloom = 0.2; // highlights glow
ctx.post.temperature = 0.4; // warmer
ctx.post.saturation = 1.1;
ctx.post.vignette = 0.3; // darker corners
ctx.post.tonemap = Tonemap::AgX;
}
}
/// A warm, glowing evening look.
static void golden_hour(crabby::ctx& ctx) {
auto post = ctx.post();
post.ao_enabled = true;
post.ao_half_resolution = true;
post.ao_temporal = true;
post.ao_radius = 1.5f; // world units
post.ao_intensity = 1.0f;
post.ao_slices = 3;
post.ao_steps = 6;
post.contact_shadows = 0.3f; // small, sharp sun shadows under feet and props
post.anti_aliasing = uint32_t(crabby::anti_aliasing::smaa);
post.exposure = 0.3f; // a third of a stop brighter
post.bloom = 0.2f; // highlights glow
post.temperature = 0.4f; // warmer
post.saturation = 1.1f;
post.vignette = 0.3f; // darker corners
post.tonemap = uint32_t(crabby::tonemap::agx);
ctx.set_post(post);
}
Lens effects sell speed or a camera look:
chromatic_aberration: red, green and blue drift apart toward the edges, 0 to 1.lens_distortion: bends the picture, -1 to 1. Above 0 bulges the middle out (barrel), below 0 pinches it in (pincushion).lens_scale: zooms in after the bend (above 1) to hide the edges it pulls in.
#![allow(unused)]
fn main() {
/// The rush of speed: colors split at the edges, the middle pinches in.
fn speed_lens(ctx: &mut Ctx, speed: f32) {
let rush = (speed / 40.0).clamp(0.0, 1.0);
ctx.post.chromatic_aberration = 0.8 * rush;
ctx.post.lens_distortion = -0.26 * rush;
ctx.post.lens_scale = 1.0 + 0.04 * rush; // hide the pulled-in edges
}
}
/// The rush of speed: colors split at the edges, the middle pinches in.
static void speed_lens(crabby::ctx& ctx, float speed) {
float rush = std::clamp(speed / 40.0f, 0.0f, 1.0f);
auto post = ctx.post();
post.chromatic_aberration = 0.8f * rush;
post.lens_distortion = -0.26f * rush;
post.lens_scale = 1.0f + 0.04f * rush; // hide the pulled-in edges
ctx.set_post(post);
}
Sprites, text and UI drawn on top are not changed. Nor is the
background color from ctx.clear.
Ambient occlusion and post antialiasing
ctx.post.ambient_occlusion enables GTAO: screen-space horizon integration
with normals reconstructed from depth, followed by a depth-aware bilateral
filter. Only ambient/indirect light is occluded; direct sun, punctual lights
and emissive surfaces retain their light. Radius is in world units. Start
with radius 1, intensity 1, 3 slices and 6 steps per direction. Rust clamps
slices to 1–8 and steps to 1–16; the C API rejects values outside these ranges.
AO defaults off to preserve existing scene output and render tests.
ctx.post.contact_shadows adds contact shadows in the same pass: each
pixel steps toward the sun across the depth buffer, up to that many world
units, and loses the sun if something on screen sits in front of a step.
They catch the small, sharp shadows the shadow map is too coarse for:
under feet, pebbles and cups, and in creases. 0.3 suits people and props;
0 (the default) is off. Like AO they only see what is on screen.
ctx.post.anti_aliasing chooses AntiAliasing::None (default), Fxaa,
Smaa (SMAA 1x) or Taa (temporal, below). FXAA and SMAA run on
tone-mapped LDR before sprites and UI,
in addition to the configured geometry MSAA. FXAA is cheaper; SMAA searches
edges and uses the reference area/search lookup textures (MIT licensed,
notices in src/render/smaa/).
#![allow(unused)]
fn main() {
ctx.post.ao_half_resolution = true;
ctx.post.ao_temporal = true;
ctx.post.ambient_occlusion = AmbientOcclusion {
enabled: true,
radius: 1.5,
intensity: 1.0,
slices: 3,
steps: 6,
};
ctx.post.contact_shadows = 0.3; // small, sharp sun shadows under feet and props
ctx.post.anti_aliasing = AntiAliasing::Smaa;
}
post.ao_enabled = true;
post.ao_half_resolution = true;
post.ao_temporal = true;
post.ao_radius = 1.5f; // world units
post.ao_intensity = 1.0f;
post.ao_slices = 3;
post.ao_steps = 6;
post.contact_shadows = 0.3f; // small, sharp sun shadows under feet and props
post.anti_aliasing = uint32_t(crabby::anti_aliasing::smaa);
ctx.set_post(post);
Post::ao_half_resolution traces at half width and height and resolves to
full resolution with depth-aware weights. Contact shadows remain sharp:
their march runs at full resolution during the resolve in this mode.
Post::ao_temporal rotates slice directions over eight frames and reprojects
history using the previous camera matrix, independently of TAA. Depth mismatch,
sky and offscreen reprojections reject history; a local clamp limits trails.
It uses the same object motion vectors as TAA (below), so moving models
reproject with their own motion.
Both switches default off. High uses half-resolution temporal AO; Ultra does too
with more samples. Lower presets leave AO off.
GTAO uses one RG8 trace target (full or half resolution), one full-resolution output and two fragment passes. Temporal mode adds two full-resolution RGBA16F history targets; spatial mode allocates no history. With SSR the full-resolution output is the first mip of the shared image; its reflection mip is untouched. Enabling it also enables the solid depth prepass on GPUs that normally skip that prepass. It is skipped on WebGL2/downlevel devices without compute capabilities, matching the engine’s optional advanced-effects fallback. FXAA and SMAA use fragment passes, including on WebGL2: FXAA adds one LDR intermediate and one pass, SMAA adds three passes and edge/weight targets. Targets are reused until resize. No timing claim is made without a measured scene. Screen-space AO cannot see offscreen or hidden occluders and can halo at silhouettes. Target cameras and transparent surfaces do not receive AO. History resets on resize, mode changes and AO sample/radius/intensity changes. Use fewer slices/steps with temporal mode to offset reprojection and bandwidth costs.
Offscreen release measurement on Apple M4 Max (Metal), 1920×1080, a floor and
2,501 boxes, no contact shadows or TAA: full-resolution 4 slices × 8 steps
2.35 ms, half-resolution 4 × 8 0.76 ms, half-resolution temporal 2 × 4
0.36 ms for the two AO passes combined. These are means of the final 20
of 80 frames per mode from the engine GPU timestamp profiler, excluding scene
rendering. They measure cost, not equal image quality; results depend on the
scene and GPU. Reproduce with
cargo test --release --features 3d --lib ao_gpu_timings_1080p -- --ignored --nocapture.
Temporal antialiasing
AntiAliasing::Taa (anti_aliasing::taa in C++) spreads the work of
antialiasing over frames. Each frame the camera is nudged by a sub-pixel
amount (a 16 position Halton sequence), so every frame samples the picture
at slightly different spots; the lit HDR frame is then blended with the
last result, moved along each pixel’s motion. It runs on the HDR frame
before bloom, screen effects and tone mapping, so bright edges do not
flicker and bloom stays steady, and it also smooths shading and thin
details that FXAA and SMAA cannot see. The geometry MSAA stays on under it.
Motion comes from two places. Anything that did not move relative to the
world is found from the depth buffer and the last frame’s camera. Models
whose transform changed since the last frame (tracked per entity, while
TAA or temporal AO is on) are drawn again, right after the depth prepass,
into a small two-channel motion image: how much further the model moved on
screen than the camera alone explains, worked out from the model matrix it
had the frame before. Only models that moved are drawn there, so a still
scene costs nothing extra. Both TAA and temporal AO (ctx.post.ao_temporal)
add it to the camera’s motion, taking it from the nearest surface in each
3x3 block of pixels so the edges of moving models follow them. Where the
history disagrees with the colors around a pixel (something moved into
view, a light changed), it is clipped to them, so there are no trails
behind moving models. The result is softened a little, so
ctx.post.taa_sharpness (default 0.25, 0 to 1) sharpens it back.
The history starts over by itself on window resizes, when TAA is turned
on, and when the camera jumps far or turns sharply in one frame. After a
camera cut the engine cannot see (a teleport that keeps the angle, a
switch to a new scene), or after teleporting an object across the screen,
call ctx.reset_temporal_history(). It resets temporal AO too.
What the motion image does not see: skinned and morphing meshes move as a
whole with their entity (the last frame’s pose is not kept), a material’s
vertex function is evaluated at this frame’s time for both frames, and
meshes drawn from a GPU buffer (set_mesh_gpu_buffer) move only with the
camera. These rely on the color clipping above.
#![allow(unused)]
fn main() {
ctx.post.anti_aliasing = AntiAliasing::Taa;
ctx.post.taa_sharpness = 0.3; // it softens the image a little
// After teleporting the camera (or an object), forget the old view:
ctx.camera3d.position = vec3(400.0, 3.0, 0.0);
ctx.reset_temporal_history();
}
/// Temporal antialiasing, and a camera cut.
static void temporal_antialiasing(crabby::ctx& ctx) {
auto post = ctx.post();
post.anti_aliasing = uint32_t(crabby::anti_aliasing::taa);
post.taa_sharpness = 0.3f; // it softens the image a little
ctx.set_post(post);
// After teleporting the camera (or an object), forget the old view:
ctx.reset_temporal_history();
}
What to expect:
- Cost. Two passes: one draws the motion of the models that moved (depth-tested against the opaque pass, so it costs their vertices and nothing else; with nothing moved it only clears), one resolves the frame. Memory: three full-size HDR images (the lit frame and two histories, 8 bytes a pixel each), a motion image (8 bytes a pixel) and its multisampled partner (8 bytes a sample, discarded after use on tiled GPUs); the opaque pass also keeps its depth for the motion pass. With TAA off none of this is allocated or drawn.
- Skinned models move with their model matrix only: a limb that bends between frames is not followed, and may smear slightly at its edge.
- Particles, trails and transparent surfaces have no motion of their
own. They reproject with the opaque surface behind them, so a still
one is clean and a fast one can ghost a little before the clipping
catches it. Custom
vertexfunctions that move vertices (wind, waves) are likewise not part of the motion. - Meshes drawn from a GPU buffer (see “GPU buffers”) move rigidly with their entity: the model matrix of the last frame and of this one are applied to the same buffer elements. What the buffer does to each element between frames (a particle’s own motion) is not tracked, so a fast particle can ghost a little like the particles above.
- LOD groups have no motion of their own either: they suit scenery
that stays put. A moving object is best drawn as a
Model. - Jitter moves only the 3D camera. Shadow maps, sprites and UI, picking rays and anything you read back on the CPU use the unjittered camera; GTAO and screen effects see the same jittered depth as the frame, so they agree with it.
- Resolution scale. The resolve takes its input and output sizes separately, so a lower-resolution render can later be resolved up to the window’s size; today both are the window’s size.
- WebGL2 cannot run it (it needs the compute-class devices that
ambient occlusion needs, and reads the multisampled depth), so there
Taadraws with FXAA instead, without any error.
Screen-space reflections
ctx.post.ssr (0 to 1; 0, the default, is off) makes glossy surfaces
reflect what is on screen: wet streets, polished floors, still water. Each
pixel follows its reflected view ray across the depth buffer, in 32
jittered steps that start short, then halves the step that went behind
something six times to find the hit. The ray reaches up to
ctx.post.ssr_max_distance world units (default 30). The opaque pass uses
what it hit in place of the sky or reflection probe the surface would
reflect otherwise, with the surface’s own Fresnel, so a dielectric floor
reflects mostly at grazing angles and a metal one everywhere. Reflections
stay at full strength up to roughness 0.4, then fade out by 0.7. Mirrors
stay sharp; glossy floors use progressively coarser,
depth-aware reflection mips, with interpolation between levels.
Where the ray finds nothing (it leaves the screen, passes behind something, or runs out of length) the environment’s reflection stays. The reflection also fades out near the screen’s edges and over the last 40% of the ray, so it never cuts off hard.
#![allow(unused)]
fn main() {
// Wet streets and polished floors reflect what's on screen.
ctx.post.ssr = 1.0;
ctx.post.ssr_max_distance = 40.0; // world units along the reflected ray
ctx.post.anti_aliasing = AntiAliasing::Taa; // smooths their grain
// Glossy surfaces blur it: full strength up to roughness 0.4, gone by 0.7.
let wet = MaterialDesc {
roughness: 0.1,
..MaterialDesc::color(Color::rgb(0.2, 0.2, 0.22))
};
}
/// Wet streets and polished floors reflect what's on screen.
static void screen_reflections(crabby::ctx& ctx) {
auto post = ctx.post();
post.ssr = 1.0f;
post.ssr_max_distance = 40.0f; // world units along the reflected ray
post.anti_aliasing = uint32_t(crabby::anti_aliasing::taa); // smooths their grain
ctx.set_post(post);
// Glossy surfaces blur it: full strength up to roughness 0.4, gone by 0.7.
auto wet = crabby::material_of(crabby::rgb(.2f, .2f, .22f));
wet.roughness = 0.1f;
(void)wet;
}
What to expect:
- Cost. A half-resolution full-screen pass after the depth prepass
(each of its pixels stands for a 2x2 block), and a copy of the opaque
image after the opaque pass. One compute dispatch builds up to four
smaller, depth-aware reflection levels from the trace, starting at
quarter resolution; it reads the depth only where there is something to
filter. The GPU profiler shows them as
screen reflections(the trace) andreflection blur(on tile-based GPUs the latter overlaps the start of the opaque pass, which then carries most of its time). A release offscreen test (reflection_filter_gpu_cost, ignored by default), at 1920x1080 on an Apple M4 Max with a glossy floor and an emissive box, measured about 0.28 ms for the trace and 0.05 ms of frame time for the levels (frames with and without them, alternated); a scene measurement, not a bound. The High and Ultra presets turn SSR on. Memory: the copy (8 bytes a pixel) and one image the opaque pass reads both the reflections and GTAO from (8 bytes a pixel, plus a quarter of that for the half-size reflections, plus under 1.4 bytes per full-size pixel for the filter levels and the image they are made in). Turning them on also turns on the solid depth prepass on GPUs that skip it. Withssrat 0 none of this is allocated or drawn. - Blur limits. The footprint grows with roughness, up to about 32 full-size pixels; it does not track ray-hit distance or sample a GGX cone. Depth-plane rejection reduces bleeding across receiver edges, but there is no normal buffer and coarse mips can still lose thin details. Confidence-premultiplied filtering fades misses toward the environment.
- Last frame’s colors. The reflections are mixed in while the frame is lit, so the colors come from the last frame’s image, at the point the hit was then. The first frame after they are turned on, after a resize or after a camera cut reflects only the environment, and a moving object’s reflection trails it by a frame.
- Only what is on screen is reflected: not what is behind the camera, off to the side or hidden behind something else. A reflection fades out as what it shows leaves the screen.
- Normals come from the depth buffer, since the engine keeps no normal image: the reflection follows a surface’s shape, but not its normal map.
- Grain. The steps are jittered per pixel and per frame. TAA averages that away; without it, reflections show some noise along their edges, and the half resolution makes those edges a little blocky.
- Transparent surfaces and target cameras don’t get them; nor does WebGL2 (like GTAO, they need a compute-class device).
Graphics quality
Games let players trade looks for speed: Low, Medium, High,
Ultra and Custom (some setting changed by hand). GraphicsQuality
gathers the settings that decide how heavy the 3D picture is, and
ctx.set_graphics_quality switches them while the game runs, with no
restart. A new game starts with the engine’s defaults, which are none of
the presets.
#![allow(unused)]
fn main() {
// An options menu: pick a preset ...
ctx.set_graphics_quality(GraphicsQuality::medium());
// ... then change single settings on top of it.
let mut quality = ctx.graphics_quality();
quality.render_scale = 0.8; // draw the 3D scene at 80% and scale it up
quality.sharpen = 0.3;
quality.shadow_resolution = 4096;
ctx.set_graphics_quality(quality);
// The settings are the ordinary ones in ctx.post and ctx.lighting.
ctx.post.ambient_occlusion.radius = 2.0;
let level = ctx.graphics_quality().level(); // QualityLevel::Custom
}
/// An options menu: pick a preset, then change single settings on top of it.
static void options_menu(crabby::ctx& ctx) {
ctx.set_graphics_quality(crabby::graphics_quality_preset(crabby::quality_level::medium));
auto quality = ctx.graphics_quality();
quality.render_scale = 0.8f; // draw the 3D scene at 80% and scale it up
quality.sharpen = 0.3f;
quality.shadow_resolution = 4096;
ctx.set_graphics_quality(quality);
// The settings are the ordinary ones in ctx.post() and ctx.lighting().
auto level = crabby::graphics_quality_level(ctx.graphics_quality()); // quality_level::custom
(void)level;
}
| low | medium | high | ultra | |
|---|---|---|---|---|
render_scale | 0.67 | 1 | 1 | 1 |
sharpen | 0.5 | 0 | 0 | 0 |
shadow_distance | 30 | 50 | 80 | 120 |
shadow_cascades | 2 | 3 | 4 | 4 |
shadow_resolution | 1024 | 2048 | 2048 | 4096 |
shadow_refresh | 4 | 2 | 2 | 1 |
shadow_filter | 1 | 2 | 3 | 3 |
light_shadow_views | 0 | 4 | 8 | 24 |
light_shadow_resolution | 512 | 512 | 1024 | 2048 |
light_shadow_atlas | 1024 | 2048 | 4096 | 8192 |
| ambient occlusion (slices, steps) | off | off | 2, 4 | 4, 8 |
| AO half resolution / temporal | off / off | off / off | on / on | on / on |
ssr (screen-space reflections) | 0 | 0 | 1 | 1 |
gi (global illumination) | off | off | off | on |
anti_aliasing | none | FXAA | SMAA | SMAA |
The struct holds nothing of its own. Applying it writes the matching
fields of ctx.post and ctx.lighting (so everything else there, such
as the AO radius, the tone map or the sun, stays as the game set it), and
ctx.graphics_quality() reads them back. level() says which preset the
settings are, or Custom. Rust clamps values to their ranges; the C API
refuses them.
Render scale. ctx.post.render_scale (0.25 to 2) draws the 3D scene
at that fraction of the window’s size in each direction, then scales it to
the window, under the sprites, text and UI, which stay at the window’s own
resolution. Everything in the 3D frame follows the scaled size: the
multisampled color and depth, bloom, ambient occlusion, antialiasing and
screen effects, so the cost of a pixel-bound scene drops about with the
square of the scale. The scaling pass is bilinear when the scene is
smaller than the window; above 1 (supersampling) it averages four taps.
sharpen (0 to 1) adds an adaptive contrast filter after the scaling,
which brings back some of the edge contrast a low scale loses (the idea of
AMD’s CAS, in the same pass). At a scale of exactly 1 there is no extra
target and no extra pass.
What switching costs. Nothing is drawn from stale targets: the next frame makes what it needs first.
- A new
render_scale, or turning AO or FXAA on or off, makes the frame’s targets again (one frame of allocation, no more). - A new
shadow_resolutionorshadow_cascadesmakes the sun’s shadow map again;light_shadow_atlasthe lights’ atlas. Turning every shadow off frees both. - Turning SMAA off frees its targets.
- Turning global illumination on makes its probes (they start dark and fill in over the next frames); turning it off frees them.
- Changing the AO’s
radius,intensity,slicesorsteps, thesharpenamount or the shadow distance allocates nothing.
The old GPU memory is freed once the commands that used it have run. MSAA
(Game::msaa) is chosen when the game starts and is not part of the
presets.
Terrain
Ground that goes on for kilometers is made of tiles: squares of
heightfield terrain, each from a grid of heights.
ctx.create_terrain_tile(&heights, size, material) makes one from n × n
heights, row after row along +Z, and you spawn it with a Transform3D
at its corner instead of a Model. Neighbouring tiles share their edge
heights, so a tile of 65 × 65 heights 2 m apart is 128 m wide and the
next one starts 128 m further on. A NaN height cuts a hole, for a cave
mouth or a tunnel.
Each tile is drawn more coarsely as the camera moves away: every height
up to lod_distance (by default the tile’s size), then every 2nd height
to twice that, every 4th to four times that, and every 8th beyond. The
lighting doesn’t jump between levels, because the normals always come
from every height. A short skirt hangs down around each tile’s edge, so
there are no cracks where tiles of different detail meet.
#![allow(unused)]
fn main() {
/// Rolling hills: 3 x 3 tiles of 128 m, each from 65 x 65 heights 2 m
/// apart, sharing their edges, with a pond-sized hole in the middle tile.
/// Grass in the valleys, dirt higher up and rock on the tops.
fn hills(ctx: &mut Ctx) {
let height = |x: f32, z: f32| (x * 0.02).sin() * 6.0 + (z * 0.03).cos() * 4.0;
let ground = hill_ground(ctx, height);
let (n, size) = (65, 128.0);
for tz in -1..=1 {
for tx in -1..=1 {
let corner = vec3(tx as f32 * size, 0.0, tz as f32 * size);
let heights: Vec<f32> = (0..n * n)
.map(|i| {
let (x, z) = ((i % n) as f32 * 2.0 + corner.x, (i / n) as f32 * 2.0 + corner.z);
let hole = tx == 0 && tz == 0 && (vec2(x, z) - vec2(64.0, 64.0)).length() < 6.0;
if hole { f32::NAN } else { height(x, z) }
})
.collect();
let tile = ctx.create_terrain_tile(&heights, size, ground);
ctx.world.spawn((Transform3D::at(corner), tile));
}
}
ctx.camera3d.position = vec3(64.0, 30.0, 220.0);
ctx.camera3d.look_at(vec3(64.0, 0.0, 64.0));
}
}
/// Rolling hills: 3 x 3 tiles of 128 m, each from 65 x 65 heights 2 m
/// apart, sharing their edges, with a pond-sized hole in the middle tile.
/// Grass in the valleys, dirt higher up and rock on the tops.
static void hills(crabby::ctx& ctx) {
auto ground = hill_ground(ctx);
const int n = 65;
const float size = 128;
for (int tz = -1; tz <= 1; ++tz)
for (int tx = -1; tx <= 1; ++tx) {
crabby::vec3 corner{float(tx) * size, 0, float(tz) * size};
std::vector<float> heights(n * n);
for (int i = 0; i < n * n; ++i) {
float x = float(i % n) * 2 + corner.x, z = float(i / n) * 2 + corner.z;
bool hole = tx == 0 && tz == 0 && std::hypot(x - 64, z - 64) < 6;
heights[i] = hole ? NAN : hill_height(x, z);
}
auto tile = ctx.create_terrain_tile(heights, size, ground);
ctx.world().spawn(crabby::transform3d_at(corner), tile);
}
auto cam = ctx.camera3d();
cam.position = {64, 30, 220};
ctx.set_camera3d(cam);
ctx.camera3d_look_at({64, 0, 64});
}
The ground’s look comes from ctx.create_terrain_material: up to 8
layers (grass, dirt, rock, …) as one texture array, and a weight map
saying how much of each shows where. The weight map is a texture array of
TextureKind::Data too, laid over the ground from weights_corner
across weights_size world units: its first image’s red, green, blue and
alpha are layers 0 to 3, and a second image holds layers 4 to 7. One
weight map can cover many tiles. The layers’ textures are lined up with
the world, not with each tile, so they run on across tile edges, each
repeating every layer_size units. normals adds a normal map for each
layer.
#![allow(unused)]
fn main() {
/// Three ground layers, speckled so they don't look flat (in a game,
/// `ctx.load_texture_array` from image files), and one weight map over
/// all nine tiles from the heights: red grass, green dirt, blue rock.
fn hill_ground(ctx: &mut Ctx, height: impl Fn(f32, f32) -> f32) -> Material {
let speckled = |base: [u8; 3]| -> Vec<u8> {
(0..64 * 64u32)
.flat_map(|i| {
let shade = (i.wrapping_mul(2_654_435_761) >> 27) as u8; // 0..31
[
base[0].saturating_add(shade),
base[1].saturating_add(shade),
base[2].saturating_add(shade),
255,
]
})
.collect()
};
let (grass, dirt, rock) = (
speckled([70, 120, 50]),
speckled([110, 85, 60]),
speckled([120, 120, 115]),
);
let layers = ctx.create_texture_array(64, 64, &[&grass, &dirt, &rock], TextureKind::Color);
let (cells, area) = (96, 384.0); // 4 m a pixel, from (-128, -128)
let weights: Vec<u8> = (0..cells * cells)
.flat_map(|i| {
let x = (i % cells) as f32 * area / cells as f32 - 128.0;
let z = (i / cells) as f32 * area / cells as f32 - 128.0;
let up = ((height(x, z) + 10.0) / 20.0).clamp(0.0, 1.0); // 0 low, 1 high
let rock = ((up - 0.7) * 4.0).clamp(0.0, 1.0);
let dirt = (1.0 - (up - 0.5).abs() * 4.0).clamp(0.0, 1.0);
let grass = 1.0 - rock.max(dirt);
[grass, dirt, rock, 0.0].map(|w| (w * 255.0) as u8)
})
.collect();
let weights = ctx.create_texture_array(cells, cells, &[&weights], TextureKind::Data);
let desc = TerrainMaterialDesc {
layer_size: [6.0, 4.0, 8.0, 4.0, 4.0, 4.0, 4.0, 4.0],
..TerrainMaterialDesc::new(layers, weights, vec2(-128.0, -128.0), vec2(area, area))
};
ctx.create_terrain_material(&desc)
}
}
static float hill_height(float x, float z) { return std::sin(x * 0.02f) * 6 + std::cos(z * 0.03f) * 4; }
/// Three ground layers, speckled so they don't look flat (in a game,
/// ctx.load_texture_array from image files), and one weight map over
/// all nine tiles from the heights: red grass, green dirt, blue rock.
static crabby::material hill_ground(crabby::ctx& ctx) {
std::vector<std::uint8_t> pixels;
for (auto base : {std::array<int, 3>{70, 120, 50}, {110, 85, 60}, {120, 120, 115}})
for (std::uint32_t i = 0; i < 64 * 64; ++i) {
int shade = int((i * 2654435761u) >> 27); // 0..31
for (int c : base) pixels.push_back(std::uint8_t(std::min(c + shade, 255)));
pixels.push_back(255);
}
auto layers = ctx.create_texture_array(64, 64, pixels, crabby::texture_kind::color);
const std::uint32_t cells = 96;
const float area = 384; // 4 m a pixel, from (-128, -128)
std::vector<std::uint8_t> weights;
for (std::uint32_t i = 0; i < cells * cells; ++i) {
float x = float(i % cells) * area / float(cells) - 128, z = float(i / cells) * area / float(cells) - 128;
float up = std::clamp((hill_height(x, z) + 10) / 20, 0.0f, 1.0f); // 0 low, 1 high
float rock = std::clamp((up - 0.7f) * 4, 0.0f, 1.0f);
float dirt = std::clamp(1 - std::abs(up - 0.5f) * 4, 0.0f, 1.0f);
float grass = 1 - std::max(rock, dirt);
for (float w : {grass, dirt, rock, 0.0f}) weights.push_back(std::uint8_t(w * 255));
}
auto weight_map = ctx.create_texture_array(cells, cells, weights, crabby::texture_kind::data);
auto desc = crabby::terrain_material_of(layers, weight_map, {-128, -128}, {area, area});
const float sizes[] = {6, 4, 8};
std::copy(std::begin(sizes), std::end(sizes), desc.layer_size);
return ctx.create_terrain_material(desc);
}
On steep ground the layers are also laid on from the sides (triplanar mapping), so cliffs don’t stretch them. Flat ground skips the side lookups, so the terrain costs no more than before.
MeshData::heightfield(&heights, size, step) gives the same triangles as
plain mesh data, for one detail level of your own.
To find the ground under a point on the CPU, to stand a tree, a
character or a camera on it, make a Heightfield from the same heights.
height_at(x, z), counted from the tile’s corner, follows the triangles
the tile is drawn with, so what it finds is exactly what you see. It
gives nothing off the tile or over a hole. normal_at gives the way the
ground faces there. TerrainTileData::heightfield() keeps one for a
streamed tile.
#![allow(unused)]
fn main() {
/// Trees standing on a 128 m tile: each at the ground's height under it,
/// none over a hole.
fn plant_trees(ctx: &mut Ctx, heights: &[f32], corner: Vec3, tree: Mesh) {
let ground = Heightfield::new(heights, 128.0);
for i in 0..20 {
let (x, z) = ((i * 37 % 128) as f32, (i * 71 % 128) as f32);
if let Some(y) = ground.height_at(x, z) {
ctx.world
.spawn((Transform3D::at(corner + vec3(x, y, z)), Model::new(tree)));
}
}
}
}
/// Trees standing on a 128 m tile: each at the ground's height under it,
/// none over a hole.
static void plant_trees(crabby::ctx& ctx, const std::vector<float>& heights, crabby::vec3 corner, crabby::mesh tree) {
crabby::heightfield ground(heights, 128);
for (int i = 0; i < 20; ++i) {
float x = float(i * 37 % 128), z = float(i * 71 % 128);
if (auto y = ground.height_at(x, z))
ctx.world().spawn(crabby::transform3d_at({corner.x + x, corner.y + *y, corner.z + z}), crabby::model_of(tree));
}
}
To walk and drive on the tiles, give each tile’s entity a
TerrainCollider from the same heights: it matches the drawn ground, turned
and scaled with the tile, holes and all (see
3D physics).
Levels of detail
A LodGroup draws cheaper meshes as an object shrinks on screen, and none
once it’s small enough not to matter. It goes on an entity with a
Transform3D in place of a Model. Its levels go from the most detailed
down, each with the fraction of the screen’s height the object must cover
for it to show (as in Unity, so the choice doesn’t change with the window
size), and the object’s size, the height that counts. LodLevel::at_distance
and LodGroup::distance_at convert between distances and those fractions
for games that think in metres.
#![allow(unused)]
fn main() {
// A 2 m object: the sphere until it's 25% of the screen's height,
// the cube down to 3%, then nothing. The sphere crossfades into the
// cube over the top quarter of its range.
let group = LodGroup::new(
2.0,
vec![
LodLevel::new(0.25, vec![LodRenderer::new(high)]).with_fade(0.25),
LodLevel::new(0.03, vec![LodRenderer::new(low)]),
],
);
let e = ctx.world.spawn((Transform3D::default(), group));
// Lower quality: drop detail at 70% of the distance.
ctx.lod.bias = 0.7;
}
/// A 2 m object: the sphere until it's 25% of the screen's height, the cube
/// down to 3%, then nothing. The sphere crossfades into the cube over the top
/// quarter of its range.
static crabby::entity lod_object(crabby::ctx& ctx, crabby::mesh high, crabby::mesh low) {
auto e = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}));
crabby::lod_level near_level{0.25f, 0.25f, {crabby::lod_renderer_of(high)}};
crabby::lod_level far_level{0.03f, 0, {crabby::lod_renderer_of(low)}};
ctx.set_lod_group(e, 2, {near_level, far_level});
// Lower quality: drop detail at 70% of the distance.
ctx.set_lod({0.7f, true});
return e;
}
- Crossfades. A level with a
fade_widthhands over to the next one across the top of its range instead of all at once. For that stretch both levels draw through matching dither patterns, so every pixel shows one or the other and no hole opens between them.ctx.lod.crossfade = falseturns every fade into a plain switch, which costs less. - Quality.
ctx.lod.biasscales every group’s screen height: 2 keeps detail twice as far away, 0.5 drops it at half the distance. It is the setting a graphics quality menu changes. - Shadows. The shadow maps use the level the main camera picked, crossfade
included.
with_shadow(LodShadow::Casters(...))gives a level other meshes to cast with (a simple stand-in, or the detailed meshes while a billboard shows); an empty list casts no shadow. - Other cameras. Cameras drawing into render targets pick their own levels.
- Decals. A
ReceivesDecalson the group’s entity applies to every level, as it does to aModel. - Scene files. A group is saved as
"LodGroup": { "size": 8, "levels": [{ "screen_height": 0.3, "renderers": [{ "mesh": "tree.glb" }] }] }. Its meshes are loaded with the scene, and a group with a mesh made in code isn’t saved.
Picking runs in parallel across all groups each frame, and the meshes it picks are drawn instanced with everything else, so thousands of groups cost about what their visible meshes do.
Occlusion culling
In a town or a forest, most of what’s in view is behind something: a house, a hill, a trunk. With occlusion culling on, those models aren’t drawn.
#![allow(unused)]
fn main() {
// A town or a forest: skip what houses, hills and trunks hide.
ctx.camera3d.occlusion_culling = true;
}
/// A town or a forest: skip what houses, hills and trunks hide.
static void cull_hidden(crabby::ctx& ctx) {
ctx.set_occlusion_culling(true);
}
- How. After the depth pass, a compute pass shrinks the depth to a grid of tiles (about 160 across) holding each tile’s farthest depth. The CPU reads it back without waiting for the GPU, so the grid it culls with is one to three frames old. A model is skipped when its bounding box, seen from where the camera was then, lies behind that depth everywhere it covers. The test is a handful of lookups per model, done on all cores with the rest of the culling.
- Late by a frame. Something that comes out from behind a wall can show up a frame or two late. Turning the camera doesn’t cause this (the grid is tested from the view it was drawn from). Moving it can, so the box is grown by how far the camera moved since that frame. A cut to a new place is safe: nothing far from the old view is culled.
- Shadows. Hidden models still cast shadows into the view.
- What counts as an occluder. Everything solid that the camera drew, including cut-out leaves (only their opaque texels). Transparent surfaces and particles hide nothing.
- Cost and limits. Making the grid reads each depth sample once, and
testing a model costs far less than drawing it.
ctx.render_stats().occludedcounts the models skipped last frame. Needs compute shaders: on WebGL2 nothing is culled. Cameras that draw into render targets don’t cull.
Streaming big worlds
A world bigger than memory loads around the player and frees what’s far behind. The engine gives three pieces for this, and the game decides what a “cell” holds: terrain, trees, houses, or all of them.
CellStreamer::new(cell_size, load_radius, keep_radius)keeps a grid of square cells on the ground. Each frame,cells.update(position, max_new)works out which cells came withinload_radius(at mostmax_newof them, nearest first, so the work spreads over frames) and which went beyondkeep_radius. Read them withto_load()andto_drop(). The gap between the two radii stops a cell at the edge from loading and dropping over and over as the camera moves back and forth.Job::spawn(|| ...)runs work on a worker thread, andjob.take()hands its result over once it’s done, without waiting. Build the data there (heights,MeshData, decoded files) and keep GPU work on the game’s thread.TerrainTileData::new(&heights, size)builds every detail level of a tile, andctx.upload_terrain_tile(&data, material)is then the quick part. In C++, usestd::asyncfor the work.ctx.free_mesh(mesh)andctx.free_terrain_tile(tile)give the GPU memory back. Despawn the entity that showed it too. The next mesh made takes the freed id, so streaming in and out forever doesn’t grow the engine’s tables; drop the old handle when you free it, since it would then name the new mesh. To hide a model for a while (a plant beyond its draw distance), showMesh::NONE(C++crabby::mesh_none) instead: it draws nothing and is never another mesh, while a freed handle is. Reloading an evicted glTF keeps its existing materials and textures, including custom shader edits, so repeated travel does not allocate a fresh material set every time geometry comes back.- A
FarTerraindraws the rest of the world beyond the loaded tiles, from a coarse grid of heights you give it (the world’s heights every 32 m, say): a few large meshes with the terrain material. When a tile is shown,far.cut(cell)cuts the far ground away under it, andfar.uncut(cell)fills it in again when the tile goes;far.update(ctx)rebuilds only the meshes around a changed cell. The far ground never overlaps a tile, so nothing fights over depth, and a skirt hangs down from every cut edge, beside the tile’s own skirt, so there’s no crack where coarse and fine ground meet. Its grid must line up with the cells: the cell size a whole number of its spacing, its corner on its grid.
#![allow(unused)]
fn main() {
/// Ground that loads around the camera: tiles 128 m wide within 600 m,
/// built on worker threads, freed past 800 m, and coarse far ground
/// beyond them.
struct Ground {
cells: CellStreamer,
tiles: std::collections::HashMap<(i32, i32), Tile>,
material: Material,
far: FarTerrain,
}
enum Tile {
Building(Job<TerrainTileData>),
Live(Entity, TerrainTile),
}
fn hill(x: f32, z: f32) -> f32 {
(x * 0.01).sin() * (z * 0.013).cos() * 20.0
}
impl Ground {
fn new(material: Material) -> Self {
// The far ground: 8 km around the start, a height every 32 m.
let n = 257;
let heights: Vec<f32> = (0..n * n)
.map(|i| hill(-4096.0 + (i % n) as f32 * 32.0, -4096.0 + (i / n) as f32 * 32.0))
.collect();
Self {
cells: CellStreamer::new(128.0, 600.0, 800.0),
tiles: Default::default(),
material,
far: FarTerrain::new(&heights, n, vec2(-4096.0, -4096.0), 32.0, 128.0, material),
}
}
/// Every frame: start up to 4 tiles, drop the far ones, and put up to
/// 2 finished ones in the world.
fn update(&mut self, ctx: &mut Ctx) {
self.cells.update(ctx.camera3d.position, 4);
for &cell in self.cells.to_load() {
let corner = self.cells.corner(cell);
let job = Job::spawn(move || {
// 65 x 65 heights, 2 m apart.
let heights: Vec<f32> = (0..65 * 65)
.map(|i| hill(corner.x + (i % 65) as f32 * 2.0, corner.z + (i / 65) as f32 * 2.0))
.collect();
TerrainTileData::new(&heights, 128.0)
});
self.tiles.insert(cell, Tile::Building(job));
}
for cell in self.cells.to_drop() {
if let Some(Tile::Live(entity, tile)) = self.tiles.remove(cell) {
ctx.world.despawn(entity);
ctx.free_terrain_tile(tile);
self.far.uncut(*cell);
}
}
let mut uploads = 0;
for (&cell, tile) in &mut self.tiles {
if let Tile::Building(job) = tile
&& uploads < 2
&& let Some(data) = job.take()
{
let made = ctx.upload_terrain_tile(&data, self.material);
let entity = ctx.world.spawn((Transform3D::at(self.cells.corner(cell)), made));
*tile = Tile::Live(entity, made);
// Shown now: cut the far ground away under it.
self.far.cut(cell);
uploads += 1;
}
}
self.far.update(ctx);
}
}
}
/// Ground that loads around the camera: tiles 128 m wide within 600 m,
/// built on worker threads, freed past 800 m, and coarse far ground
/// beyond them.
static float hill(float x, float z) { return std::sin(x * 0.01f) * std::cos(z * 0.013f) * 20.0f; }
/// The far ground: 8 km around the start, a height every 32 m.
static std::vector<float> far_heights() {
std::vector<float> heights(257 * 257);
for (std::size_t i = 0; i < heights.size(); ++i)
heights[i] = hill(-4096 + float(i % 257) * 32, -4096 + float(i / 257) * 32);
return heights;
}
struct Ground {
struct Tile {
std::future<crabby::terrain_tile_data> building;
crabby::entity entity = 0; // once built (`building` is then spent)
crabby::terrain_tile tile{};
};
crabby::cell_streamer cells{128, 600, 800};
std::map<std::pair<int, int>, Tile> tiles;
crabby::material material;
crabby::far_terrain far;
explicit Ground(crabby::material m) : material(m), far(far_heights(), 257, {-4096, -4096}, 32, 128, m) {}
/// Every frame: start up to 4 tiles, drop the far ones, and put up to
/// 2 finished ones in the world.
void update(crabby::ctx& ctx) {
cells.update(ctx.camera3d().position, 4);
for (auto cell : cells.to_load()) {
auto corner = cells.corner(cell);
tiles[{cell.x, cell.z}].building = std::async(std::launch::async, [corner] {
// 65 x 65 heights, 2 m apart.
std::vector<float> heights(65 * 65);
for (std::size_t i = 0; i < heights.size(); ++i)
heights[i] = hill(corner.x + float(i % 65) * 2, corner.z + float(i / 65) * 2);
return crabby::terrain_tile_data(heights, 128);
});
}
for (auto cell : cells.to_drop()) {
auto found = tiles.find({cell.x, cell.z});
if (found == tiles.end()) continue;
if (!found->second.building.valid()) {
ctx.world().despawn(found->second.entity);
ctx.free_terrain_tile(found->second.tile);
far.uncut(cell);
}
tiles.erase(found); // a tile still building is waited for here
}
int uploads = 0;
for (auto& [cell, t] : tiles) {
if (uploads == 2) break;
if (!t.building.valid() || t.building.wait_for(std::chrono::seconds(0)) != std::future_status::ready) continue;
t.tile = ctx.upload_terrain_tile(t.building.get(), material);
t.entity = ctx.world().spawn(crabby::transform3d_at(cells.corner({cell.first, cell.second})), t.tile);
far.cut({cell.first, cell.second}); // shown now: cut the far ground away under it
++uploads;
}
far.update(ctx);
}
};
Cut a cell only once its tile is in the world, not when it starts
loading, so there’s never a gap. Without far terrain, hide where the world
ends with fog (ctx.sky.fog_density) a little inside the load radius, so
new tiles appear behind it, not at an edge. The streaming example flies
over endless hills this way. Physics needn’t reach as far as the drawing:
collider streaming
keeps the ground solid only near the player. On an M4 Max,
uploading a 256 m tile of 129 × 129 heights takes 0.5 ms of the game’s
frame (0.75 ms at worst). Building it on a worker takes longer, but it
doesn’t hold up the frame. Far terrain for 16 km sampled every 32 m (64
meshes) builds in 4 ms the first time; cutting or filling a cell after that
rebuilds a mesh or a few, 0.13 ms on average and 0.25 ms at worst, before
the GPU upload.
Mixing with 2D
3D draws first and everything 2D draws on top: sprites, shapes, text, UI
and the inspector. A 3D game can have a 2D HUD with no extra work.
ctx.clear(color) sets the background behind both.
Cost
Models with the same mesh are drawn together in one instanced draw call.
A depth prepass finds the nearest surface of every pixel first, so each
pixel is lit only once, however many models overlap it.
Models outside the camera’s view are skipped, except when they cast a
shadow into it. Edges are smoothed with 4x multisampling;
Game::msaa(2) takes 2 samples instead, about a third less GPU time
for the 3D passes on a large screen.
On GPUs that draw in tiles (Apple’s), solid surfaces skip the prepass: those GPUs already drop hidden solid fragments before lighting them, and only cut-out ones (leaves, grass) gain from being laid down first.
On an M4 Max MacBook Pro, 100,000 spinning cubes with shadows run at
402 FPS, fullscreen with vsync off: 2.5 ms of CPU a frame, in 28 draw
calls. The same scene in Unity 6, made of GameObjects, runs at 54 FPS
(the comparison). ctx.models_drawn() counts the
models drawn last frame, and ctx.render_stats() gives the frame’s draw
calls and CPU time.
Not yet
The parts that are deliberately still ahead:
- tried and tested 3D in the browser (it builds for the web, but no one has run it there yet).
Particles
Add a ParticleEmitter3D and Transform3D to an entity. Each emitter owns a
preallocated CPU pool; frame systems run before particles advance. Rate emission
and queued bursts share the hard max_particles cap. Excess emission is discarded.
The same seed and frame steps reproduce an emitter’s simulation independently of
other emitters. This is a visual frame simulation, not a fixed-tick lockstep effect.
#![allow(unused)]
fn main() {
fn setup(ctx: &mut Ctx) {
ctx.camera3d.position = vec3(0.0, 2.0, 7.0);
ctx.camera3d.look_at(vec3(0.0, 1.0, 0.0));
ctx.sky.atmosphere = false;
let emitter = ParticleEmitter3D::new(ParticleSettings3D {
rate: 120.0,
burst: 40,
lifetime: (0.7, 1.4),
speed: (2.0, 4.0),
shape: ParticleShape3D::Cone { angle: 0.45 },
gravity: vec3(0.0, -3.0, 0.0),
drag: 0.2,
size: (0.05, 0.13),
rotation: (0.0, std::f32::consts::TAU),
size_over_life: ParticleCurve::new(vec![(0.0, 0.5), (0.2, 1.0), (1.0, 0.0)]),
color_over_life: ParticleGradient::new(vec![
(0.0, Color::rgba(1.0, 0.7, 0.2, 1.0)),
(1.0, Color::rgba(1.0, 0.1, 0.0, 0.0)),
]),
blend: ParticleBlend3D::Additive,
max_particles: 512,
seed: 42,
..Default::default()
});
ctx.world.spawn((Transform3D::default(), emitter));
}
}
auto fire = crabby::particle_settings()
.rate(120)
.burst(40)
.lifetime(0.7f, 1.4f)
.speed(2, 4)
.cone(0.45f)
.gravity({0, -3, 0})
.drag(0.2f)
.size(0.05f, 0.13f)
.rotation(0, 6.2831853f)
.size_over_life({{0, 0.5f}, {0.2f, 1}, {1, 0}})
.color_over_life({{0, {1, 0.7f, 0.2f, 1}}, {1, {1, 0.1f, 0, 0}}})
.blend(crabby::particle_blend::additive)
.max_particles(512)
.seed(42);
auto e = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}));
ctx.set_particle_emitter3d(e, fire);
ctx.particle_burst(e, 20); // more at any time
Run the example with cargo run --features 3d --example particles.
ParticleShape3D supports point, sphere, cone (along local +Y), and box emission.
ParticleCurve and ParticleGradient use ordered keys at normalized ages 0–1,
linearly interpolate, and clamp at their endpoints. Settings are immutable after
creation; replace the component to change them or reset its seed.
Set looping: false and duration to stop continuous emission after that many
seconds. burst is an initial, non-repeating burst; set rate: 0.0 for a pure
one-shot. Queue another burst at any time with
ctx.world.get_mut::<ParticleEmitter3D>(entity).unwrap().burst(20)
(C++: ctx.particle_burst(entity, 20)).
Expired particles release pool slots without reallocating. Bursts and rate
particles spawn at the end of the current frame; existing particles advance first.
World-space particles keep their spawn transform. With ParticleSpace3D::Local,
existing particles follow the entity, gravity uses its coordinates, and the largest
absolute scale component scales their square billboards. World-space speed uses
rotation without scale. Supply texture: Some(texture) for sprites; sheet: (columns, rows) animates a row-major sprite sheet over each particle’s lifetime. Without a
texture, particles are white quads tinted by their gradient.
Particles draw after opaque surfaces, depth tested without depth writes, one
instanced draw per live emitter. Emitter centers are sorted by camera-forward depth back to front, and
alpha particles are also sorted within each emitter. Overlapping emitters cannot
interleave their particles perfectly. Additive particles skip internal sorting.
These unlit billboards pass through HDR post processing. This first implementation
renders particles in the main camera only. Soft intersections are omitted: the
combined opaque/transparent pass does not expose depth for simultaneous sampling.
run_headless skips the visual frame phase, so emitters remain idle without GPU work.
Ribbons and lines
Two more components draw camera-facing strips in the same pass as particles, with the same blending (alpha or additive) and textures:
Trail3Dleaves a ribbon behind a moving entity: tracers, sword swings, streaks behind cars or projectiles. Give an entity aTransform3Dand a trail; the engine records where it went and draws the ribbon through those points, so the ribbon stays where the entity was.Line3Ddraws a line through points: beams, cables, ropes, paths.
#![allow(unused)]
fn main() {
// A glowing tracer behind a flying ball: a quarter second long, thin at
// the end and fading out.
let mut tracer = Trail3D::new(0.25, 0.12);
tracer.color = ParticleGradient::new(vec![
(0.0, Color::rgb(1.0, 0.8, 0.3)),
(1.0, Color::rgba(1.0, 0.3, 0.0, 0.0)),
]);
tracer.blend = ParticleBlend3D::Additive;
let white = ctx.default_material();
let ball = ctx.create_mesh(&MeshData::sphere(0.15, 16), white);
ctx.world
.spawn((Transform3D::default(), Model::new(ball), tracer, Orbiting));
// After a teleport: ctx.world.get_mut::<Trail3D>(entity).unwrap().clear();
}
// A glowing tracer behind a flying ball: a quarter second long, thin at
// the end and fading out.
auto tracer = crabby::trail3d(0.25f, 0.12f)
.color_over_age({{0, {1, 0.8f, 0.3f, 1}}, {1, {1, 0.3f, 0, 0}}})
.blend(crabby::particle_blend::additive);
auto ball = ctx.create_mesh(crabby::mesh_data::sphere(0.15f, 16), white);
auto flyer = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(ball));
ctx.set_trail3d(flyer, tracer);
// After a teleport: ctx.clear_trail3d(flyer);
A trail’s lifetime is how long each point lasts. width is a
ParticleCurve and color a ParticleGradient, both over the ribbon’s age:
0 at the newest end (the entity), 1 at the oldest. Trail3D::new(lifetime, width) narrows from width to 0 and fades to transparent. A new point is
kept only after the entity moved min_distance meters, up to max_points
(the oldest go first), though the ribbon always reaches the entity. Set
emitting to false and the ribbon fades out; clear() forgets every point
(C++: ctx.clear_trail3d(entity)), which you want after a teleport so no
ribbon spans the jump. With a texture, u runs along the ribbon (a point’s
age over lifetime) and v across. point_count() says how many points it
keeps (C++: ctx.trail3d_point_count(entity)).
#![allow(unused)]
fn main() {
// A laser beam 5 m long, from the entity forward.
let beam = Line3D {
blend: ParticleBlend3D::Additive,
..Line3D::new(vec![Vec3::ZERO, vec3(0.0, 0.0, -5.0)], 0.03, Color::RED)
};
ctx.world.spawn((Transform3D::at(vec3(-4.0, 1.5, 3.0)), beam));
}
// A laser beam 5 m long, from the entity forward.
auto beam = crabby::line3d({{0, 0, 0}, {0, 0, -5}}, 0.03f, crabby::colors::red)
.blend(crabby::particle_blend::additive);
auto emitter = ctx.world().spawn(crabby::transform3d_at({-4, 1.5f, 3}));
ctx.set_line3d(emitter, beam);
A line’s points are in its entity’s space, so the entity moves, turns and
scales it (width is multiplied by the largest scale component). Change the
points any time: the strip is rebuilt every frame. texture_length is the
meters of line per repeat of the texture; 0 stretches it once over the
whole line. In C++ call ctx.set_line3d(entity, line) again to change a
line.
Like particles, trails and lines are only for show: run_headless skips the
visual frame phase, so trails record nothing there.
Decals
There are two kinds. Mesh decals are built once on the CPU and drawn as a mesh; projected decals are boxes the GPU paints onto whatever is inside them each frame. The next section helps choose.
Mesh decals
MeshData::decal(projector, lift) cuts the part of a mesh inside a box out as
a mesh of its own, with an image projected straight onto it: bullet holes,
scorch marks, splats, signs and tyre marks. projector places the box in the
mesh’s space; it maps the unit cube (-0.5 to 0.5 on each axis) onto the box,
so its scale is the decal’s size and depth. The image is projected along the
box’s -Z (looking from +Z) with its top along +Y, and only faces turned
toward +Z get it. Every corner is lifted lift meters along its normal so
the decal sits on the surface without flickering; a few millimeters is
typical. The result is empty when the box misses the mesh.
#![allow(unused)]
fn main() {
// A 1 m scorch mark on the floor at (1, 0, -1): the box is 1 x 1 m wide
// and 0.4 m deep, its +Z turned up, so the image is projected down.
let up = Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2);
let projector = Mat4::from_scale_rotation_translation(vec3(1.0, 1.0, 0.4), up, vec3(1.0, 0.0, -1.0));
let mark = floor_data.decal(projector, 0.003);
if !mark.indices.is_empty() {
let scorch = ctx.create_material(MaterialDesc {
blend: Blend::Transparent,
..MaterialDesc::color(Color::rgba(0.05, 0.04, 0.03, 0.8))
});
let mark = ctx.create_mesh(&mark, scorch);
// Drawn where the floor is.
ctx.world.spawn((Transform3D::default(), Model::new(mark)));
}
// A mesh from a glTF file works the same: ctx.load_mesh_data("models/wall.glb")?.decal(..)
}
// A 1 m scorch mark on the floor at (1, 0, -1): the box is 1 x 1 m wide
// and 0.4 m deep, its +Z turned up, so the image is projected down.
// The projector's 16 floats go column after column.
std::array<float, 16> projector = {1, 0, 0, 0, 0, 0, -1, 0, 0, 0.4f, 0, 0, 1, 0, -1, 1};
auto mark = floor_data.decal(projector, 0.003f);
if (mark.view().index_count > 0) {
auto look = crabby::material_of({0.05f, 0.04f, 0.03f, 0.8f});
look.blend = 1; // transparent
auto scorch = ctx.create_mesh(mark, ctx.create_material(look));
// Drawn where the floor is.
ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(scorch));
}
// A mesh from a glTF file works the same: ctx.load_mesh_data("models/wall.glb").decal(..)
Draw the decal with its own material (usually transparent or cut out) and the
same transform as the mesh it was cut from. To cut decals from a model, read
its triangles with ctx.load_mesh_data(path), which loads a glTF file’s mesh
(all its parts in one, placed as in the file) onto the CPU without making
anything on the GPU. It loads the file on each call, so keep the result.
In C++ the projector is std::array<float, 16> (column after column).
Projected decals
A Decal3D is a box in the world whose image the renderer paints onto the
opaque surfaces inside it, every frame, with no mesh built. Footprints, blood,
tyre marks, bullet holes and dirt can number in the hundreds or thousands, and
they follow whatever is under them: a decal stays put while a model, a skinned
character or a door moves through its box, and a decal parented to a moving
model (see attachments) carries its mark along.
The entity’s Transform3D places the box (its scale multiplies size). As
with mesh decals the image is projected along the box’s -Z (looking from
+Z), its top along +Y. In the example the box’s +Z is turned to point up, so
the image lands on the ground below.
#![allow(unused)]
fn main() {
use ponko::*;
fn tyre_marks(ctx: &mut Ctx) {
// One texture array holds every decal image and normal map. Make it with
// TextureKind::Data: decals decode the color layers from sRGB themselves.
let images = ctx.load_texture_array(&["tyre.png", "tyre_n.png"], TextureKind::Data);
ctx.set_decal_textures(Some(images));
let mark = Decal3D {
size: vec3(1.5, 3.0, 0.5), // width, length, depth
image: 0,
normal_image: 1,
roughness: 0.9,
max_distance: 40.0,
..Default::default()
};
// Looking down: the box's +Z turned to point up.
let down = Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2);
ctx.world.spawn((Transform3D::at(vec3(4.0, 0.1, 2.0)).rotated(down), mark));
// A character that no decal lands on:
ctx.world.spawn((Transform3D::default(), ReceivesDecals::NONE));
}
}
static void projected_decals(crabby::ctx& ctx) {
// Images and normal maps share one texture array: layer 0 is a tyre mark,
// layer 1 its normal map. Make it with texture_kind::data.
std::vector<std::uint8_t> pixels(2 * 2 * 4 * 2, 255);
ctx.set_decal_textures(ctx.create_texture_array(2, 2, pixels, crabby::texture_kind::data));
auto mark = crabby::decal3d_default();
mark.size = {1.5f, 3.0f, 0.5f}; // width, length, depth
mark.image = 0;
mark.normal_image = 1;
mark.roughness = 0.9f;
mark.max_distance = 40;
// Looking down: the box's +Z turned to point up.
auto at = crabby::transform3d_at({4, 0.1f, 2});
at.rotation = crabby::quat_from_rotation_x(-1.5707964f);
auto tyre = ctx.world().spawn(at, mark);
(void)tyre;
// A character that no decal lands on:
ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::receives_decals{0});
}
What a Decal3D sets:
image,normal_image,uv_min,uv_max: the layer of the decal texture array with the color image and the one with a tangent-space normal map (Decal3D::NO_IMAGEfor none; a decal without an image is a flatcolorrectangle), and the part of the layer to use, so a layer can be an atlas. Leave a few pixels of padding between atlas entries: mipmaps blend them.color,opacity: a tint on the image, and how much of it shows.roughness,metallic: the surface’s values under the decal; negative leaves them. Wet blood is smooth, dust rough, dirt on metal not metallic.angle_fade_start,angle_fade_end: the decal fades out on surfaces turned away from its projection, so it doesn’t stretch over a wall it grazes. Fully visible up to about 57°, gone at about 83° by default.depth_fade: how much of the box’s depth the decal fades over at each end, so it doesn’t end in a hard slice where the ground rises or falls.max_distance: past it the decal isn’t drawn (it fades over the last quarter): the cheapest way to keep a level full of marks light.order: decals with a higher order draw over lower ones.layersandReceivesDecals: a decal shows on a model when itslayersand the model’sReceivesDecalsshare a bit (16 layers). Models without the component take every decal;ReceivesDecals::NONEopts a character or a first-person weapon out.
Projected decals are blended into the surface before lighting. They are lit like the surface they land on, so they darken in shadow, take the sun, point lights and reflections, and a normal map or lowered roughness catches the light. Transparent materials, particles and the sky don’t get decals.
How it works and what it costs. The renderer already sorts point and spot
lights into a grid of 16 x 9 screen cells with 24 slices of depth. Decals
share that grid: each frame the decals the camera sees (at most
MAX_DECALS, 4096; beyond that the nearest are kept) are listed per cell, and
a pixel blends the decals in its cell. A cell holds at most
MAX_DECALS_PER_CLUSTER (64), keeping the highest orders. Pixels outside
every decal pay one cell lookup. Collecting 4096 decals took 0.33 ms of CPU in
a release build, and an offscreen 1280 x 720 frame with 1000 to 4000 mostly
separate decals added roughly 1 to 3 ms (readback included) on a Metal GPU, so cost grows with
how many decals overlap each pixel, not with the geometry under them. Each
decal uses 128 bytes of GPU memory; the images are the texture array you set.
WebGL2. The mesh shader needs one more sampled texture for the decal images
than WebGL2 (and GPUs left at the default limit) allow. Native builds ask the
adapter for more; where that fails, ctx.supports_decals() is false and decals
are skipped cleanly: nothing else changes and no shader errors.
Mesh decals or projected decals? Use a projected decal for the many small marks that appear and disappear while the game runs (footprints, blood, bullet holes), for anything on moving or skinned models, on terrain, or on geometry you can’t cut (any model in the scene): nothing is built, so spawning one is just spawning an entity. Use a mesh decal when the mark is permanent and the GPU has no room for projected ones (WebGL2), when the decal needs a material of its own (a glowing sign, an animated shader, glass), when it must be transparent or sorted like a transparent surface, or when it should follow one mesh exactly (a sign painted on a wall) and cost nothing per pixel.
Text in the world (SDF)
Text3D draws signs, labels and name tags on an entity’s Transform3D.
Its glyph atlas uses signed distance fields (SDF), so one set of glyphs
serves different sizes and distances, with outlines and soft edges.
Screen-space text stays bitmap for pixel-exact drawing; SDF is for text
in the world.
#![allow(unused)]
fn main() {
use ponko::*;
Game::new("World text", 1, 1).run_headless(0, |ctx| {
let label = Text3D {
size: 0.3,
outline: Color::BLACK,
outline_width: 0.06,
billboard: true,
..Text3D::new("OPEN")
};
ctx.world.spawn((Transform3D::at(vec3(0.0, 2.5, 0.0)), label));
});
}
static void world_text_snippet() {
crabby::game("World text", 1, 1).run_headless(0, [](crabby::ctx& ctx) {
auto e = ctx.world().spawn(crabby::transform3d_at({0, 2.5f, 0}));
crabby::text3d label("OPEN");
label.size = .3f;
label.outline = {0, 0, 0, 1};
label.outline_width = .06f;
label.billboard = true;
ctx.set_text3d(e, label);
});
}
Line height (size) is in meters, multiplied by the transform’s scale.
The block is centered on the entity in its XY plane, read from +Z;
billboard turns it toward the camera. Newlines start lines, aligned
left, center (default), or right inside the block. font uses any loaded
font, with the built-in font as default. outline_width and softness
are fractions of a line, clamped to about 0.12. World text is transparent,
depth-tested against solid models, and casts no shadows.
In C++, read an optional owned copy with ctx.text3d(e), replace it with
ctx.set_text3d(e, label), or remove it with ctx.remove_text3d(e).
The C equivalents use CrabbyText3D settings initialized by
crabby_text3d_default(), a copied UTF-8 C string, and
crabby_ctx_text3d_text for a caller-owned output buffer. Query its byte
length with capacity zero, then allocate length plus one for the NUL.
Unknown fonts and alignment values are rejected before changing text.
Sky IBL and local reflections
Ambient light uses diffuse irradiance and a GGX-prefiltered environment with
roughness levels and a split-sum BRDF lookup. The diffuse part comes from
order-2 spherical harmonics of the environment (nine coefficients, projected on
the GPU from every texel of the cube), so it is smooth, with no sampling noise,
and looked up from a small cube like before. The procedural sky is baked when
its lighting or camera altitude changes. Without an atmosphere, the existing
sky and ground colors supply the environment. ambient_intensity scales the
sky environment; direct sun lighting is unchanged. Smooth and metallic
surfaces now show the sky in their reflections instead of a flat tint, and
rough ones are lit by the sky’s direction and color, not just up and down.
A reflection probe captures six 128² linear HDR views only when requested. Place its entity inside a room, then request a bake after creating the room. The capture includes opaque geometry, emissive materials, sky and existing lights; transparent objects are omitted, and where nothing is drawn the sky shows. Captures use sky IBL to avoid recursive local reflections. Moving a probe or changing the scene requires another bake. A bake asked for while the window is hidden happens on the next frame that is drawn.
#![allow(unused)]
fn main() {
use ponko::*;
fn setup(ctx: &mut Ctx) -> Result<(), String> {
let volume = ReflectionProbe3D {
size: vec3(12.0, 4.0, 8.0),
..Default::default()
};
let probe = ctx.world.spawn((Transform3D::at(vec3(0.0, 2.0, 0.0)), volume));
ctx.bake_reflection_probe(probe)?;
// To use a file instead: ctx.load_reflection_probe(probe, "room.exr")?;
Ok(()) }
}
static void reflection_probes(crabby::ctx& ctx) {
auto volume = crabby::reflection_probe3d_default();
volume.size = {12, 4, 8};
auto probe = ctx.world().spawn(crabby::transform3d_at({0, 2, 0}), volume);
ctx.bake_reflection_probe(probe);
// To use a file instead: ctx.load_reflection_probe(probe, "room.exr");
}
ReflectionProbe3D defaults to a 10-unit axis-aligned box, a 1-unit fade
inside its boundary, intensity 1 and box projection enabled. Transform scale
and rotation do not change the box. Box projection intersects the reflection
ray with its walls before sampling; disable it for distant environments.
Where volumes overlap, the two smallest that reach a pixel are used: a room
inside a bigger one wins in its interior, and fades into the bigger one (or the
sky) across its blend_distance. Sky lighting fills any remaining weight;
outside all volumes, only the sky contributes.
Unbaked probes have no influence. content is context-owned: leave it zero
until baking or loading assigns it.
A context supports four resident probe environments. Repeated bakes reuse the same slot; attempting to allocate a fifth returns an error. A probe can instead load a linear HDR or EXR 2:1 equirectangular image (+Y at the top, +X at the center). Loading and runtime capture use the same prefilter. Images must have finite radiance; negative values clamp to zero and values above 65504 clamp to the half-float range.
The renderer stores cube faces and roughness levels in a 2D RGBA16F atlas, using the existing sky binding. No extra sampled-texture binding, compute pass or cube array is required: the same render-pass path supports WebGL2. The atlas uses 12 MiB, the scratch cube 0.75 MiB, and each allocated capture’s six targets 0.75 MiB (plus retained multisample/depth target-camera resources). The BRDF lookup is generated once per renderer. Baking costs six scene views plus 64-sample diffuse/specular filtering; it is synchronous GPU work on the next rendered frame, so request bakes during loading or at infrequent scene changes.
GPU buffers and particles
A GpuBuffer holds a fixed-length array of vec4<f32> elements on the GPU.
Create it with a nonzero length and either no initial data (zeroed) or one
value per element. write_gpu_buffer replaces a checked range before the next
frame; it is useful for setup and occasional input, without per-particle CPU work.
A buffer compute shader defines fn update(i: u32), reading and writing
items[i]. The engine dispatches one invocation per element in workgroups of
64, checking the last group’s bounds. frame and material have the same
uniforms as texture compute, including frame.time.y (delta seconds).
ComputePass::for_buffer and texture passes share one ordered list; each
finishes before the next begins, before drawing. Invocations within a pass
have no ordering: avoid reading values other invocations write.
#![allow(unused)]
fn main() {
fn gpu_particles(ctx: &mut Ctx) -> GpuBuffer {
let items = ctx.create_gpu_buffer(128, &[]);
let update = ctx.create_compute("particles", "fn update(i: u32) { items[i].y += frame.time.y; }");
ctx.compute_passes.push(ComputePass::for_buffer(update, items));
let shader = ctx.create_shader(
"particles",
"fn vertex(v: Vertex) -> Vertex { var o = v; o.position += items[v.instance_index].xyz; return o; }",
);
let material = ctx.create_material(MaterialDesc {
shader: Some(shader),
..Default::default()
});
let mesh = ctx.create_mesh(&MeshData::cube(0.05), material);
ctx.set_mesh_gpu_buffer(mesh, items, 128);
// The emitter's extent around its entity, for culling.
ctx.set_mesh_gpu_bounds(mesh, Vec3::ZERO, 20.0);
ctx.world.spawn((Transform3D::default(), Model::new(mesh)));
items
}
}
static void gpu_particles(crabby::ctx& ctx) {
auto items = ctx.create_gpu_buffer(128);
auto update = ctx.create_compute("particles", "fn update(i: u32) { items[i].y += frame.time.y; }");
ctx.set_compute_passes({crabby::buffer_compute_pass(update, items)});
auto shader = ctx.create_shader("particles", "fn vertex(v: Vertex) -> Vertex { var o = v; o.position += items[v.instance_index].xyz; return o; }");
auto desc = crabby::material_of(crabby::colors::white);
desc.has_shader = true;
desc.shader = shader;
auto material = ctx.create_material(desc);
auto mesh = ctx.create_mesh(crabby::mesh_data::cube(0.05f), material);
ctx.set_mesh_gpu_buffer(mesh, items, 128);
// The emitter's extent around its entity, for culling.
ctx.set_mesh_gpu_bounds(mesh, {0, 0, 0}, 20);
ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(mesh));
}
set_mesh_gpu_buffer binds the same storage as read-only items in the mesh’s
material shader and draws count instances. Read items[v.instance_index]
in vertex. Every instance is drawn at its entity’s transform and tint, like a
normal model: v.position arrives already placed there (v.origin is the
entity’s position), and the shader moves it by what it reads from items, in
world axes. A particle emitter therefore follows its entity. The engine
uploads one instance record per entity, shared by all count instances, and
draws once per entity, so use one entity per emitter.
The entity is culled by the mesh’s bounds, which don’t know where the shader
puts the instances. set_mesh_gpu_bounds(mesh, center, radius) grows them to
also hold a sphere around the entity (in the entity’s space) that covers the
whole simulation; call it again to replace the sphere. Disable occlusion
culling for shader-displaced geometry. Depth and shadow passes use the same
vertex shader. With temporal antialiasing or temporal AO, a moving entity
also writes its motion vector, so the whole effect reprojects with it instead
of trailing; per-element motion inside the buffer is not tracked (see the
motion limits under temporal antialiasing). Buffers cost no CPU work per frame.
free_gpu_buffer releases a buffer’s GPU memory, for effects that come and
go. Its compute passes are removed from the context’s list and meshes drawing
from it draw nothing until bound to another buffer. The next buffer made
reuses the freed id, like freed meshes do, so drop the handle after freeing it.
On devices without storage compute (including WebGL2), buffer updates and buffer-driven draws are skipped without a panic. Headless games retain handles and validate writes, so setup code can be tested without a GPU.
GPU-driven instances
For large static populations, create a persistent instance batch instead of one entity per object. The mesh supplies its materials. Transforms upload when the batch is created or updated; steady frames upload only view and occlusion data. World-space cells reject groups before individual GPU sphere tests. Visible indices drive the normal material, depth and shadow passes through indirect indexed draws. Static instances use camera motion for TAA; they do not need an object motion pass.
#![allow(unused)]
fn main() {
use ponko::*;
fn persistent_instances(ctx: &mut Ctx, mesh: Mesh) {
let transforms = (0..100_000)
.map(|i| Mat4::from_translation(vec3((i % 1000) as f32, 0.0, (i / 1000) as f32)))
.collect::<Vec<_>>();
let batch = ctx.create_instance_batch(
mesh,
&transforms,
InstanceBatchOptions {
max_distance: 500.0,
..Default::default()
},
);
assert_eq!(ctx.instance_batch_len(batch), Some(100_000));
assert!(ctx.update_instance_batch(batch, &[Mat4::IDENTITY]));
ctx.free_instance_batch(batch);
assert_eq!(ctx.instance_batch_len(batch), None);
assert!(!ctx.update_instance_batch(batch, &[]));
ctx.free_instance_batch(batch);
}
}
void persistent_instances(crabby::ctx& ctx, crabby::mesh mesh) {
std::vector<std::array<float,16>> transforms;
for (unsigned i=0; i<100000; ++i) {
transforms.push_back({1,0,0,0, 0,1,0,0, 0,0,1,0,
float(i%1000),0,float(i/1000),1});
}
auto batch = ctx.create_instance_batch(mesh, transforms, {32.0f,500.0f,true,crabby::colors::white});
ctx.update_instance_batch(batch, transforms);
ctx.free_instance_batch(batch);
}
Use one batch per streamed region, and free_instance_batch when unloading it.
update_instance_batch replaces all transforms and rebuilds cells. Handles are
not recycled. Options set cell width, maximum draw distance, tint and shadow
casting. Matrices must be finite and affine. Empty batches are valid. A batch holds at most 16,777,216 instances and must fit the device storage-buffer limit. Instance
ordering within a batch is unspecified. Materials should be opaque or cutout;
transparent instances are not depth sorted. Meshes must outlive their batches.
Devices without compute use persistent vertex buffers and CPU cell culling;
they conservatively draw all instances in visible cells. This fallback avoids
per-frame transform uploads too. The GPU path keeps independent visibility and indirect-argument buffers for the
main camera, sun cascades, target cameras (including oblique reflection views),
and packed point/spot shadow views. cast_shadows controls both sun and local
shadows. Distance culling uses the target camera or light position; sun cascades
use the main camera. Only the main view uses occlusion culling.
View slots grow to the peak concurrent view count and are reused across frames;
steady frames allocate no batch buffers or CPU visibility lists. Each GPU slot
costs 112 * instance_count + 16 + 20 * mesh_part_count + 128 bytes, plus bind
groups. Four sun slots are reserved; a point light adds six slots, a spot light
one, and each target camera one. CPU fallback slots reserve one range per cell.
Transform/cell data is retained until a batch is updated or freed, so budget
batch sizes against the number of simultaneous views. Updates are explicit; animated, skinned and moving objects should
continue to use models.
Global illumination
ctx.lighting.gi turns on bounced light: sunlight, sky light and lamp
light that hits a surface and lights others from there. A red wall in the
sun tints the floor beside it red, a room lit through a window is lit all
over, and a closed room goes dark instead of taking the sky’s ambient
light, all following the sun through a day and night. It is off by
default.
#![allow(unused)]
fn main() {
// Sunlight bounces off walls and floors onto other surfaces.
ctx.lighting.gi = GlobalIllumination {
enabled: true,
extent: vec3(48.0, 16.0, 48.0), // the probe grid around the camera
spacing: 2.0, // a probe every 2 world units
probes_per_frame: 4, // how many look around each frame
intensity: 1.0,
};
}
/// Sunlight bounces off walls and floors onto other surfaces.
static void bounced_light(crabby::ctx& ctx) {
auto light = ctx.lighting();
light.gi_enabled = true;
light.gi_extent = {48, 16, 48}; // the probe grid around the camera
light.gi_spacing = 2.0f; // a probe every 2 world units
light.gi_probes_per_frame = 4; // how many look around each frame
light.gi_intensity = 1.0f;
ctx.set_lighting(light);
}
How it works: a grid of light probes moves with the camera, extent
across, one every spacing world units (the default 32 x 16 x 32 at 2
units is 17 x 9 x 17 probes). Each frame probes_per_frame of them look
around: each draws six small (16x16) views of what is near it, lit as
usual (the sun with its shadows, point and spot lights, the sky) and with
the probes’ own light as the ambient light, so light bounces once more
each time a probe looks again. Each probe keeps how much light reaches it
from every direction, and how far away the surfaces around it are. A
surface then takes its ambient light from the eight probes around it,
each counted only as far as it can see the surface (from those
distances), which keeps light from leaking through walls. The probes
that look first are the new ones, nearest first (the grid moves a whole
spacing at a time, and keeps the probes that are still inside it), then
the ones that looked longest ago, nearer ones sooner.
Inside the grid the bounced light replaces the sky and ground ambient light (and reflection probes’ diffuse light); past its edges it fades back to that over one spacing. Where it is darker than the sky’s light would be (indoors), reflections of the sky darken with it.
What to expect:
- Cost. Per frame: one more pass over the world to find what is near
the grid, a 1024² shadow map of that,
probes_per_frametimes six small views, a compute pass, and eight probe lookups in every lit pixel. In a test scene of 900 buildings at 1280x720 on an Apple M4 Max, the defaults added about 0.5 ms of GPU time and 0.1 ms of CPU time a frame; 16 probes a frame added about 1 to 1.5 ms of GPU time. The Ultra preset turns it on. Memory: about 2.5 kB per probe (6.5 MB for the default grid) and 2 MB for the views and their shadow map. - Lag. Probes take turns, so bounced light catches up with a change (a door opening, a lamp turned on, the camera arriving somewhere new) over a few seconds at the defaults: 17 x 9 x 17 probes at 4 a frame is a full round in about 10 seconds at 60 frames a second, nearest probes first. Slow changes like the sun’s are followed smoothly. More probes a frame catch up faster.
- Coarse light. It is diffuse light at the probes’ spacing: it can’t tell apart spaces much smaller than that, and walls thinner than about a tenth of the spacing may leak. Small things within a cell don’t get their own bounced light, which is what ambient occlusion is for.
- What the probes see. Their views draw opaque surfaces near the grid (up to four spacings past it), not transparent ones or particles, with the coarser levels of detail, and not instance batches. Surfaces further away are sky to them. Point and spot lights light the views but cast no shadows in them.
- Where it runs. It needs compute shaders and an 18th texture slot in material shaders, which every desktop and laptop GPU has (Vulkan, Metal, DirectX 12). Elsewhere, as on WebGL2, it is skipped and the ambient light is as without it.
3D physics: bodies, terrain, characters and vehicles
Crates that tumble down a hill, a first-person character that walks up
slopes and stairs, a car that drives over the terrain, rays that find what
the player is looking at: 3D
rigid-body physics on rapier, designed like the
2D physics. It’s behind the physics3d feature (which turns
on 3d):
ponko = { version = "0.0.1", features = ["physics3d"] }
(The C library, ponko_ffi, has it on by default.)
Turn it on with gravity in m/s² (+Y is up):
#![allow(unused)]
fn main() {
Game::new("Crabby Engine: 3D physics", 1280, 720)
.physics3d(vec3(0.0, -9.81, 0.0)) // m/s², +Y up; 50 steps a second
.sim_system(walk)
.system(eyes)
}
auto world = crabby::game("3D physics", 1280, 720)
.physics3d({0, -9.81f, 0}) // m/s², +Y up; 50 steps a second
.sim_system(walk, "walk")
The physics step is a simulation system, placed where you call physics3d.
It runs 50 times a second unless you chose a rate with fixed_hz, and
between steps every body is drawn blended between its last two poses,
so motion stays smooth at any frame rate. Simulation systems always see the
stepped pose; to read a body’s exact physics position from a frame system,
use ctx.physics3d.position(e).
Bodies, colliders and terrain
An entity becomes a body with a Transform3D, a RigidBody3D and a
Collider3D (in meters). Terrain gets a heightfield made from the same
heights, size and corner as its terrain tile, so what you walk on
is what you see:
#![allow(unused)]
fn main() {
fn build_world(ctx: &mut Ctx) {
// Ground: a terrain tile and a heightfield from the same heights,
// both at the tile's corner.
let corner = vec3(-SIZE / 2.0, 0.0, -SIZE / 2.0);
let step = SIZE / (N - 1) as f32;
let heights: Vec<f32> = (0..N * N)
.map(|i| height(corner.x + (i % N) as f32 * step, corner.z + (i / N) as f32 * step))
.collect();
let ground = ctx.physics3d.heightfield(&heights, SIZE);
let look = grass(ctx);
let tile = ctx.create_terrain_tile(&heights, SIZE, look);
ctx.world.spawn((
Transform3D::at(corner),
tile,
RigidBody3D::fixed(),
Collider3D::mesh(ground),
));
// Stairs: fixed boxes 0.25 m higher each.
let stone = ctx.create_material(MaterialDesc::color(Color::hex(0x9A948A)));
let block = ctx.create_mesh(&MeshData::cube(1.0), stone);
for i in 0..8 {
let size = vec3(2.0, 0.25 * (i + 1) as f32, 0.6);
let at = vec3(6.0, height(6.0, 3.0 + i as f32 * 0.6), 3.0 + i as f32 * 0.6);
ctx.world.spawn((
Transform3D {
scale: size,
..Transform3D::at(at + Vec3::Y * size.y / 2.0)
},
Model::new(block),
RigidBody3D::fixed(),
Collider3D::cuboid(size),
));
}
// Crates: dynamic, light enough to push.
let wood = ctx.create_material(MaterialDesc::color(Color::hex(0xB07A43)));
let crate_mesh = ctx.create_mesh(&MeshData::cube(1.0), wood);
for i in 0..6 {
let (x, z) = (-4.0 + i as f32 * 1.5, -6.0);
ctx.world.spawn((
Transform3D::at(vec3(x, height(x, z) + 2.0 + i as f32, z)),
Model::new(crate_mesh),
RigidBody3D::dynamic(),
Collider3D::cuboid(Vec3::ONE).density(150.0).friction(0.7),
Velocity3D::default(),
));
}
// The walker: a capsule 1.8 m tall, its transform at its feet.
let mut walker = Character3D::new(1.8, 0.3);
walker.step_height = 0.3;
let id = ctx
.world
.spawn((Transform3D::at(vec3(0.0, height(0.0, 0.0) + 1.0, 0.0)), walker));
ctx.world.insert_resource(Player {
id,
yaw: 0.0,
pitch: 0.0,
});
}
}
struct Walker { crabby::entity id; }; // a resource: who walks
static void build_world3d(crabby::ctx& ctx) {
auto world = ctx.world();
// Ground: a heightfield from the same heights as the terrain tile it
// stands for (here a gentle slope), at the tile's corner.
const int n = 33;
std::vector<float> heights(n * n);
for (int i = 0; i < n * n; ++i) heights[i] = float(i % n) * 0.1f;
auto ground = ctx.heightfield_collider(heights, 64);
world.spawn(crabby::transform3d_at({-32, 0, -32}), crabby::fixed_body3d(), crabby::mesh_collider(ground));
// A crate that falls, slides and can be pushed.
auto crate = crabby::box_collider({1, 1, 1});
crate.density = 200; // kg per m³
world.spawn(crabby::transform3d_at({4, 6, 0}), crabby::dynamic_body3d(), crate, crabby::velocity3d{});
// The walker: a capsule 1.8 m tall, its transform at its feet.
auto walker = crabby::character(1.8f, 0.3f);
walker.step_height = 0.3f; // climbs stairs this high
const crabby::entity me = world.spawn(crabby::transform3d_at({0, 4, 0}), walker);
world.set_resource(Walker{me});
}
| Rust | C++ | Meaning |
|---|---|---|
RigidBody3D::dynamic() | crabby::dynamic_body3d() | moved by gravity and collisions |
RigidBody3D::fixed() | crabby::fixed_body3d() | never moves: ground, walls, buildings |
RigidBody3D::kinematic() | crabby::kinematic_body3d() | moved only by you; pushes, isn’t pushed |
Collider3D::sphere(r) | crabby::sphere_collider(r) | |
Collider3D::cuboid(size) | crabby::box_collider(size) | full width, height, depth |
Collider3D::capsule(h, r) | crabby::capsule_collider3d(h, r) | upright; h between the centers of its ends |
Collider3D::mesh(m) | crabby::mesh_collider(m) | a heightfield, convex hull or triangle mesh |
.density(d), .friction(f), .restitution(r) | fields of the same name | kg/m³ (default 1000), 0 ice..1 rubber, bounciness |
.sensor() | .sensor = true | reports overlaps, doesn’t push |
.offset(v), .groups(g) | .offset, .groups | shape moved from the entity; who it touches |
RigidBody3D::dynamic().mass(kg) | crabby::with_mass(body, kg) | a mass of its own instead of density × volume |
.center_of_mass(v) | crabby::with_center_of_mass(body, v) | center of mass in the body’s frame (low for cars) |
Collision meshes are made once and shared by any number of colliders:
ctx.physics3d.heightfield(&heights, size)(C++ctx.heightfield_collider): n × n heights, row after row along +Z, NaN for a hole you can fall through. Put it on a fixed body at the tile’s corner.ctx.physics3d.convex_hull(&points): the hull around a point cloud, for rocks and props that move.ctx.physics3d.trimesh(&vertices, &triangles): any triangle mesh, for static buildings and level geometry (it has no inside, so give it to fixed bodies).ctx.physics3d.free_mesh(m)lets go of one; colliders still using it keep it until they’re removed.
Each of these has a twin in CollisionMeshData (C++
crabby::collision_mesh_data) that builds the mesh on any thread, for
streamed worlds: build it in the
same Job as the rest of a cell, then ctx.physics3d.add_mesh(&data) (C++
ctx.add_collision_mesh) on the game’s thread, which only shares it.
A terrain tile is simplest to make solid with a TerrainCollider on
the tile’s own entity: TerrainCollider::new(mesh) (C++
crabby::solid_terrain(mesh)) with the heightfield of the tile’s heights.
It sits at the tile’s Transform3D, turned and scaled with it (a tile
drawn twice as high is solid twice as high), so it matches the drawn ground
triangle for triangle, holes included. Despawning the tile removes it; to
edit the ground (a crater), make a new heightfield, put it in the
component’s mesh and free the old one: the collider changes at the next
step. Its friction, restitution and groups work as on a Collider3D.
In a scene file, a mesh collider can name a mesh file instead, and
ctx.load_scene makes the mesh (the Unity importer writes
them this way):
"Collider3D": { "shape": { "Mesh": { "file": "town/meshes/wall.glb", "scale": [1, 1, 1], "convex": false } } }
Transform3D stays the source of truth, as in 2D: after each step,
dynamic bodies write their pose into it; set a transform yourself and the
body teleports there. A Velocity3D component reads and sets a body’s
speed (in C++ a plain struct and a query column). Physics ignores a
transform’s scale: size the collider instead.
Collision groups decide who touches whom. A collider is in the groups
of its memberships bits and touches colliders whose memberships share a
bit with its filter; both sides must agree. CollisionGroups::ALL (the
default) touches everything:
#![allow(unused)]
fn main() {
const GROUND: u32 = 1;
const DEBRIS: u32 = 2;
// Debris lands on the ground but passes through other debris.
let debris = CollisionGroups::new(DEBRIS, GROUND);
}
Collisions send CollisionStarted and CollisionStopped events, the same
ones as in 2D (C++: ctx.collisions_started()).
A walking character
Character3D is a kinematic capsule that walks: it slides along walls,
climbs slopes up to max_slope, steps up ledges up to step_height, stays
on the ground going downhill (snap_distance), pushes light dynamic bodies
(as if it weighed push_mass) and sets off sensors. Its Transform3D
position is at its feet. Set velocity (x and z to walk, y to jump)
and read grounded; the step adds gravity while it’s in the air:
#![allow(unused)]
fn main() {
/// WASD walks the way the walker faces, Shift runs, Space jumps (only
/// from the ground). A simulation system: it runs before each step.
fn walk(ctx: &mut Ctx) {
let keys = [Key::W, Key::A, Key::S, Key::D, Key::LeftShift].map(|k| ctx.key_down(k));
let jump = ctx.key_down(Key::Space);
let player = ctx.world.resource::<Player>();
let (id, yaw) = (player.id, player.yaw);
let Some(c) = ctx.world.get_mut::<Character3D>(id) else {
return;
};
let forward = vec3(-yaw.sin(), 0.0, -yaw.cos());
let right = vec3(yaw.cos(), 0.0, -yaw.sin());
let axis = |plus: bool, minus: bool| plus as i32 as f32 - minus as i32 as f32;
let wish = (forward * axis(keys[0], keys[2]) + right * axis(keys[3], keys[1])).normalize_or_zero();
let speed = if keys[4] { 8.0 } else { 4.0 };
c.velocity.x = wish.x * speed;
c.velocity.z = wish.z * speed;
if c.grounded && jump {
c.velocity.y = 5.0;
}
}
}
/// WASD walks, space jumps (only from the ground).
static void walk(crabby::ctx& ctx) {
auto world = ctx.world();
const crabby::entity me = world.resource<Walker>().id;
auto c = world.get<crabby::character3d>(me);
float x = float(ctx.key_down(CRABBY_KEY_D)) - float(ctx.key_down(CRABBY_KEY_A));
float z = float(ctx.key_down(CRABBY_KEY_S)) - float(ctx.key_down(CRABBY_KEY_W));
c.velocity.x = x * 4; // m/s
c.velocity.z = z * 4;
if (c.grounded && ctx.key_pressed(CRABBY_KEY_SPACE)) c.velocity.y = 5;
world.set(me, c);
}
| Field | Default | |
|---|---|---|
height, radius | 1.8, 0.3 | feet to head; the capsule’s width |
max_slope | 45° | steeper ground is a wall |
step_height | 0.35 m | 0: no stepping |
snap_distance | 0.3 m | how far down it follows the ground |
push_mass | 80 kg | 0: doesn’t push |
gravity_scale | 1 | 0: floats |
groups | all | what it walks on and bumps into |
For mouse look, lock the cursor and turn the head by the raw mouse motion (input) in a frame system:
#![allow(unused)]
fn main() {
/// Every frame: the mouse turns the head (raw motion, so it works while the
/// cursor is locked) and the camera sits at the walker's eyes. A click
/// locks the mouse, Escape frees it.
fn eyes(ctx: &mut Ctx) {
if ctx.mouse_pressed(MouseButton::Left) {
if ctx.cursor_locked() {
shove(ctx);
} else {
ctx.set_cursor_locked(true);
}
} else if ctx.key_pressed(Key::Escape) {
if ctx.cursor_locked() {
ctx.set_cursor_locked(false);
} else {
ctx.quit();
}
}
let turn = if ctx.cursor_locked() {
ctx.mouse_motion() * 0.003
} else {
Vec2::ZERO
};
let p = ctx.world.resource_mut::<Player>();
p.yaw -= turn.x;
p.pitch = (p.pitch - turn.y).clamp(-1.5, 1.5);
let (id, yaw, pitch) = (p.id, p.yaw, p.pitch);
if let Some(t) = ctx.world.get::<Transform3D>(id) {
ctx.camera3d.position = t.position + Vec3::Y * 1.65;
ctx.camera3d.rotation = Quat::from_rotation_y(yaw) * Quat::from_rotation_x(pitch);
}
}
}
Vehicles
Vehicle3D turns a dynamic body into a car (or a truck, a buggy, anything
on up to 8 wheels) with raycast wheels, the classic wheel-collider
model: each physics step, every wheel casts a ray down from its mount,
pushes the chassis up with a spring and a damper, spins by the torques you
give it and the road’s grip, and pushes the chassis along through its
tyre’s friction curves. A car tuned for that model elsewhere keeps its
numbers here. The chassis faces -Z; give it a mass and a low center of
mass, or it rolls over in turns:
#![allow(unused)]
fn main() {
/// A 1200 kg box car facing -Z, its center of mass low between the axles,
/// front wheels first.
fn spawn_car(ctx: &mut Ctx, at: Vec3) -> Entity {
let mut car = Vehicle3D::new();
for (x, z) in [(-0.8, -1.3), (0.8, -1.3), (-0.8, 1.3), (0.8, 1.3)] {
let mut wheel = Wheel3D::new(vec3(x, -0.3, z), 0.35);
wheel.sideways_friction.stiffness = 1.3;
car = car.with_wheel(wheel);
}
ctx.world.spawn((
Transform3D::at(at),
RigidBody3D::dynamic()
.mass(1200.0)
.center_of_mass(vec3(0.0, -0.35, 0.0)),
Collider3D::cuboid(vec3(1.8, 0.6, 4.2)),
Velocity3D::default(),
car,
))
}
}
/// A 1200 kg box car facing -Z, its center of mass low between the axles,
/// front wheels first.
static crabby::entity spawn_car(crabby::ctx& ctx, crabby::vec3 at) {
std::vector<crabby::wheel3d> wheels;
for (float z : {-1.3f, 1.3f})
for (float x : {-0.8f, 0.8f}) {
auto w = crabby::wheel({x, -0.3f, z}, 0.35f);
w.sideways_friction.stiffness = 1.3f;
wheels.push_back(w);
}
auto body = crabby::with_center_of_mass(crabby::with_mass(crabby::dynamic_body3d(), 1200), {0, -0.35f, 0});
return ctx.world().spawn(crabby::transform3d_at(at), body, crabby::box_collider({1.8f, 0.6f, 4.2f}),
crabby::velocity3d{}, crabby::vehicle({wheels[0], wheels[1], wheels[2], wheels[3]}));
}
Everything that makes a car a particular car is the game’s: the
engine and its gears, which wheels drive and steer, the brakes and assists.
Set each wheel’s motor_torque (N·m, positive forward), brake_torque
and steer_angle (radians, positive steers right) in a simulation system
that runs before the physics step:
#![allow(unused)]
fn main() {
/// The game's half of driving, before each physics step: rear-wheel
/// drive, brakes on all four, steering that eases in and tightens less at
/// speed.
fn drive(ctx: &mut Ctx) {
let controls = *ctx.world.resource::<Controls>();
let dt = ctx.dt();
let car = ctx.world.resource::<Player>().car;
let forward = forward_speed(&ctx.world, car);
let wanted = controls.steer * 0.6 / (1.0 + forward.abs() * 0.08);
let player = ctx.world.resource_mut::<Player>();
player.steer += (wanted - player.steer).clamp(-2.0 * dt, 2.0 * dt);
let steer = player.steer;
let Some(vehicle) = ctx.world.get_mut::<Vehicle3D>(car) else {
return;
};
// Pressing against the way it rolls brakes; from (almost) still it drives.
let braking = controls.throttle * forward < 0.0 && forward.abs() > 0.5;
for (i, wheel) in vehicle.wheels_mut().iter_mut().enumerate() {
let front = i < 2;
wheel.steer_angle = if front { steer } else { 0.0 };
wheel.motor_torque = if front || braking {
0.0
} else {
controls.throttle * 900.0
};
wheel.brake_torque = if braking {
3000.0
} else if controls.handbrake && !front {
5000.0
} else {
0.0
};
}
}
}
/// Before each physics step: W drives the rear wheels, S brakes all four,
/// A and D steer the front ones.
static void drive(crabby::ctx& ctx) {
auto world = ctx.world();
const crabby::entity car = world.resource<Driven>().car;
auto v = world.get<crabby::vehicle3d>(car);
const float throttle = float(ctx.key_down(CRABBY_KEY_W));
const bool brake = ctx.key_down(CRABBY_KEY_S);
const float steer = 0.5f * (float(ctx.key_down(CRABBY_KEY_D)) - float(ctx.key_down(CRABBY_KEY_A)));
for (std::uint32_t i = 0; i < v.wheel_count; ++i) {
const bool front = i < 2;
v.wheels[i].steer_angle = front ? steer : 0; // radians, positive right
v.wheels[i].motor_torque = front ? 0 : throttle * 900; // N·m
v.wheels[i].brake_torque = brake ? 3000 : 0;
}
world.set(car, v);
}
Wheel3D field | Default | |
|---|---|---|
position | the wheel’s center at full compression, in the chassis’s frame | |
radius, mass | 0.5 m, 20 kg | the mass sets how fast torque spins it up |
suspension_distance | 0.3 m | how far the suspension travels, straight down the chassis’s -Y |
spring, damper | 35 000 N/m, 4 500 N·s/m | |
target_position | 0.5 | where it rests under the car’s weight: 0 compressed, 1 extended |
force_app_point_distance | 0 | forces act this far up from the resting wheel’s bottom; higher rolls less |
damping_rate | 0.25 | slows the spin, N·m per rad/s |
forward_friction, sideways_friction | FrictionCurve::FORWARD, ::SIDEWAYS | grip for slip (below) |
The suspension holds each wheel’s share of the weight (its sprung_mass,
from where the wheels are around the center of mass) at target_position,
whatever the spring rate, so a car at rest sits at its designed height;
the spring and damper act on what’s beyond that: bumps, braking, turns.
A tyre’s grip follows a friction curve of its slip: it rises to a peak
(extremum_slip, extremum_value), falls to a lower sliding grip
(asymptote_slip, asymptote_value) and stays there, scaled by
stiffness (lower it for wet or icy ground) and by the load on the wheel.
Forward slip compares the tyre’s speed with the road’s (0 rolling, +
spinning, -1 locked); sideways slip is the angle between where the wheel
points and where it goes, in radians.
After each step, every wheel says what happened: grounded,
contact_point and contact_normal, compression and
suspension_force, angular_velocity (and rpm()), forward_slip and
sideways_slip (for skid marks and tyre sounds). To draw a wheel, its
pose(chassis) (C++ crabby::wheel_pose) is its center and turn: steer,
spin and suspension travel. Pass the chassis’s Transform3D from a frame
system and the wheels move as smoothly as the chassis:
#![allow(unused)]
fn main() {
/// Every frame: each wheel drawn where its car's last step put it (steer,
/// spin and suspension travel), following the chassis's smoothed pose.
fn place_wheels(ctx: &mut Ctx) {
let player = ctx.world.resource::<Player>();
let car = player.car;
let (Some(&vehicle), Some(&chassis)) = (ctx.world.get::<Vehicle3D>(car), ctx.world.get::<Transform3D>(car)) else {
return;
};
for i in 0..player.wheels.len() {
let e = ctx.world.resource::<Player>().wheels[i];
let pose = vehicle.wheels()[i].pose(&chassis);
if let Some(t) = ctx.world.get_mut::<Transform3D>(e) {
*t = Transform3D { scale: t.scale, ..pose };
}
}
}
}
The wheels are rays, so a car can’t tunnel through the ground however
fast it goes (a test drives one over a heightfield at 30 m/s), and they
see only what Vehicle3D::groups touches. The road is pushed back too:
a car on a dynamic raft pushes the raft. C++ sees the component as
crabby::vehicle3d, a plain struct (and a query column): the wheels are
wheels[0..wheel_count].
Cost: a car on heightfield terrain adds about 2 µs to a physics step,
its chassis body included (cargo run --release -p ponko_bench -- vehicles, M4 Max):
| Cars driving, 250 steps | Total | Per step, over the terrain alone |
|---|---|---|
| 1 | 1.9 ms | 5 µs |
| 10 | 5.9 ms | 22 µs |
| 100 | 46.7 ms | 185 µs |
Try it: cargo run --release --example vehicle --features physics3d.
Rays, shape casts and overlaps
ctx.physics3d answers questions about the world as of the last step:
#![allow(unused)]
fn main() {
/// Left click shoves whatever is in front of the eye, up to 30 m away.
fn shove(ctx: &mut Ctx) {
let me = ctx.world.resource::<Player>().id;
let (eye, forward) = (ctx.camera3d.position, ctx.camera3d.rotation * Vec3::NEG_Z);
let filter = QueryFilter3D::default().exclude(me);
if let Some(hit) = ctx.physics3d.cast_ray(eye, forward, 30.0, filter)
&& let Some(mass) = ctx.physics3d.mass(hit.entity)
{
ctx.physics3d
.apply_impulse_at(hit.entity, forward * mass * 6.0, hit.point);
}
}
}
/// What the walker is looking at, up to 50 m away (not itself).
static std::optional<crabby::hit3d> look(crabby::ctx& ctx, crabby::vec3 eye, crabby::vec3 forward) {
const crabby::entity me = ctx.world().resource<Walker>().id;
return ctx.cast_ray3d(eye, forward, 50, crabby::filter3d().exclude(me));
}
cast_ray(origin, direction, max_distance, filter): the closest hit, with the entity, point, surface normal and distance.cast_ray_all(.., &mut out): every hit, nearest first.cast_sphereandcast_capsule: sweep a shape (a wheel, a character) and get the first thing it would touch.overlap_sphere,overlap_boxandoverlap_capsule: every entity inside, intoout.apply_impulse(e, impulse)andapply_impulse_at(e, impulse, point): a push in kg × m/s (multiply bymass(e)for a speed change); at a point it also spins the body.
Every query takes a QueryFilter3D (C++ crabby::filter3d):
.exclude(e) looks through one entity (the one asking), .groups(g) sees
what a collider in those groups would touch, and .with_sensors() sees
sensors too (they’re skipped by default). Lists go into a Vec you keep,
so queries don’t allocate.
New bodies join at the next step, so a query in the tick you spawned them won’t see them yet.
Cost
Bodies cost only where they exist, and steady-state steps add no
allocations of their own on top of rapier’s. Compared with calling rapier
directly, the engine’s syncing adds 13–25%
(cargo run --release -p ponko_bench -- physics3d, M4 Max):
| Boxes falling into a pit, 250 ticks | Crabby | rapier alone |
|---|---|---|
| 1,000 | 30.4 ms | 24.5 ms |
| 10,000 | 440 ms | 389 ms |
Sleeping bodies are skipped when positions are written back, and the pass that hands
velocity from heavier to lighter bodies (dominance) only runs when some
body has a non-zero dominance group.
Characters cost more than bodies: each one sweeps its capsule against the world several times a step (walking, stepping, snapping to the ground). Those sweeps only read the world, so from 8 characters up they run on all cores; the results are applied one after another in a fixed order, so a crowd moves exactly the same way on every run. Walking over heightfield terrain:
| Characters, 250 ticks | Time | Per step |
|---|---|---|
| 100 | 218 ms | 0.9 ms |
| 1,000 | 2.45 s | 9.8 ms |
A thousand is about the limit of a 50 Hz step. Characters also sweep
against each other; if a crowd doesn’t need to, give them a group that
leaves itself out of its filter (groups = CollisionGroups::new(CROWD, !CROWD)),
and each sweep only meets the level.
For bigger crowds, move them yourself, with a ray down to find the ground.
Level geometry as a triangle mesh is somewhat cheaper than a heightfield.
Try it: cargo run --release --example physics3d --features physics3d.
Big worlds: solid only near the player
A world that streams in kilometers of terrain and thousands of buildings
doesn’t need all of them solid: only the ground near the player and the
vehicles. ctx.physics3d.stream_colliders(Some(max_new)) (C++
ctx.stream_colliders(max_new)) turns that on. From then, the colliders of
fixed bodies and TerrainColliders are only in the physics world near
entities with a ColliderFocus (C++ crabby::focus_colliders(radius, keep_radius)):
- A collider within a focus’s
radius(measured on the ground, to the nearest point of the collider) comes in, nearest first, at mostmax_newa step, so a big area doesn’t arrive in one step. - It stays until it’s beyond
keep_radiusfrom every focus. The gap between the two stops colliders at the edge from coming and going as the player walks back and forth. - A collider never goes while a moving body (dynamic, kinematic or a character) is over it, so a crate left behind doesn’t fall through the ground. Bodies far from every focus find no new ground, though: give anything that travels far on its own, like a car, its own focus, a bigger one if it’s fast.
- Ray casts and other queries only see colliders that are in.
ctx.physics3d.is_collider_active(e)says whethere’s is. - Drawing doesn’t change: the world is still drawn out to the streamer’s
load radius.
stream_colliders(None)brings every collider back.
#![allow(unused)]
fn main() {
/// A streamed tile, built on a worker: its meshes and its collider, from
/// the same heights.
fn build_tile(heights: Vec<f32>) -> Job<(TerrainTileData, CollisionMeshData)> {
Job::spawn(move || {
let drawn = TerrainTileData::new(&heights, 128.0);
let solid = CollisionMeshData::heightfield(&heights, 128.0);
(drawn, solid)
})
}
/// Once built, on the game's thread: drawn and solid. Free both when the
/// tile goes (`ctx.free_terrain_tile`, `ctx.physics3d.free_mesh`).
fn place_tile(ctx: &mut Ctx, corner: Vec3, built: (TerrainTileData, CollisionMeshData), ground: Material) -> Entity {
let (drawn, solid) = built;
let tile = ctx.upload_terrain_tile(&drawn, ground);
let mesh = ctx.physics3d.add_mesh(&solid);
ctx.world
.spawn((Transform3D::at(corner), tile, TerrainCollider::new(mesh)))
}
/// Drawn out to the streamer's 600 m, but solid only near the player and
/// the car (further ahead of the car: it's faster), 4 new colliders a step.
fn solid_near(ctx: &mut Ctx, player: Entity, car: Entity) {
ctx.physics3d.stream_colliders(Some(4));
ctx.world.insert(player, ColliderFocus::new(150.0, 200.0));
ctx.world.insert(car, ColliderFocus::new(300.0, 350.0));
}
}
/// A streamed tile, built on a worker: its meshes and its collider, from
/// the same heights.
struct BuiltTile {
crabby::terrain_tile_data drawn;
crabby::collision_mesh_data solid;
};
static std::future<BuiltTile> build_tile(std::vector<float> heights) {
return std::async(std::launch::async, [heights = std::move(heights)] {
return BuiltTile{crabby::terrain_tile_data(heights, 128), crabby::collision_mesh_data::heightfield(heights, 128)};
});
}
/// Once built, on the game's thread: drawn and solid. Free both when the
/// tile goes (ctx.free_terrain_tile, ctx.free_collision_mesh).
static crabby::entity place_tile(crabby::ctx& ctx, crabby::vec3 corner, const BuiltTile& built, crabby::material ground) {
auto tile = ctx.upload_terrain_tile(built.drawn, ground);
auto mesh = ctx.add_collision_mesh(built.solid);
return ctx.world().spawn(crabby::transform3d_at(corner), tile, crabby::solid_terrain(mesh));
}
/// Drawn out to the streamer's 600 m, but solid only near the player and
/// the car (further ahead of the car: it's faster), 4 new colliders a step.
static void solid_near(crabby::ctx& ctx, crabby::entity player, crabby::entity car) {
ctx.stream_colliders(4);
ctx.world().set(player, crabby::focus_colliders(150, 200));
ctx.world().set(car, crabby::focus_colliders(300, 350));
}
Bringing a collider in or taking it out is cheap: the mesh was built on the worker, and the collider only joins or leaves rapier’s broad phase. Checking which colliders to bring in looks at every static collider each step: with 10,000 fixed boxes, 100 balls and a moving focus, a step takes about 0.2 ms with streaming on or off (M4 Max, release). Building a 129 × 129 heightfield takes about 0.05 ms on the worker, and adding it on the game’s thread under a microsecond.
Joints
A Joint3D component on a separate entity connects two body entities. Both
endpoints need Transform3D, RigidBody3D and Collider3D. Connections join
at the next physics step; missing endpoints are retried. Despawning the
connection, removing its component, or removing either endpoint’s body removes
the Rapier impulse joint on the next step. Several connections can share a body.
Changing the component rebuilds the joint and wakes both bodies.
#![allow(unused)]
fn main() {
let a = ctx
.world
.spawn((Transform3D::default(), RigidBody3D::fixed(), Collider3D::sphere(0.1)));
let b = ctx.world.spawn((
Transform3D::at(vec3(0.0, -1.0, 0.0)),
RigidBody3D::dynamic(),
Collider3D::sphere(0.2),
));
let mut joint = Joint3D::spherical(a, b)
.motion(JointAxis3D::AngularX, JointMotion3D::Limited(-0.785, 0.785))
.swing_cone(0.524)
.drive(JointAxis3D::AngularX, JointDrive3D::spring(0.0, 100.0, 20.0));
joint.frame_b.position = Vec3::Y;
joint.contacts_enabled = false;
let connection = ctx.world.spawn((joint,));
// Despawn `connection` to detach; either body's despawn also removes the joint.
}
auto a = world.spawn(crabby::transform3d_at({0, 0, 0}), crabby::fixed_body3d(), crabby::sphere_collider(0.1f));
auto b = world.spawn(crabby::transform3d_at({0, -1, 0}), crabby::dynamic_body3d(), crabby::sphere_collider(0.2f));
auto joint = crabby::spherical_joint3d(a, b);
joint = crabby::with_limits(joint, crabby::joint_axis::angular_x, -0.785f, 0.785f);
joint.swing_cone = 0.524f;
joint = crabby::with_drive(joint, crabby::joint_axis::angular_x, crabby::spring_drive(0, 100, 20));
joint.frame_b.position = {0, 1, 0};
joint.contacts_enabled = false;
auto connection = world.spawn(joint);
// Despawn `connection` to detach; either body's despawn also removes the joint.
Both snippets are compiled and stepped headless (the Rust one by the
physics3d_joint_guide test). In C++ the axes are crabby::joint_axis::*
indices into axes and drives, and swing_cone is negative for none.
fixed(a, b) locks all axes; spherical(a, b) locks translation;
revolute(a, b) leaves only rotation around local X free;
prismatic(a, b) leaves only translation along local X free.
generic(a, b) starts with all six axes free. Use .motion(axis, Free),
.motion(axis, Locked) or .motion(axis, Limited(min, max)) to configure
each axis. Names are qualified by JointMotion3D; axes by JointAxis3D.
Distances are meters and angles radians. Limits must be finite and ordered;
angular ranges must span less than a full turn.
frame_a and frame_b are anchor poses in each body’s local space (scale
ignored). Rotate these frames to orient a hinge or slider. The frames coincide
at the joint’s rest pose. For spherical joints, .swing_cone(angle) couples
local Y/Z angular motion into a circular cone around X. Local X remains the
independent twist axis. The cone overrides the Y/Z limits and drives; it is
not two independent rectangular angular limits.
.drive(axis, JointDrive3D::velocity(speed, damping)) sets a velocity motor.
JointDrive3D::spring(target, stiffness, damping) drives toward an angle or
position. Set its max_force to cap force/torque. Drives use Rapier’s default
acceleration model; locked axes ignore drives. For an arbitrary target rotation,
express the target in the joint’s local angular coordinates and drive the
unlocked angular axes, or rotate the rest anchor frame. These coordinates are
Rapier’s joint coordinates, not a general quaternion orientation servo.
Joined bodies do not collide by default; set contacts_enabled = true to
restore contact response. Contact dominance does not make joints one-way.
There is no automatic break-force option: Rapier does not expose a simple
break threshold on impulse joints. Despawn the connection to detach explicitly.
ctx.physics3d.joint_count() reports live connections after the last step.
Ragdolls
ctx.start_ragdoll(entity, &bones) hands a skinned character’s skeleton
over to physics: each RagdollBone names a joint and gets a dynamic capsule
running to the joint’s first child (a sphere for a joint without children).
Each capsule is held to the nearest chosen bone above it by a spherical
joint, limited to a cone of .swing(radians) (default 0.7) and a
.twist(radians) either way (default 0.3). Pick the bones that matter
(hips, spine, head, upper and lower limbs). Joints you leave out keep their
pose relative to the bone above them, so hands and fingers ride along.
The bodies start where the skeleton is posed now and are linked to physics
at once, so you can push them in the same frame: a hit, an explosion. From
then on the bodies pose the skeleton after every step (Ragdoll3D on the
entity holds them; body(joint) finds one) and its Animator waits.
#![allow(unused)]
fn main() {
// A hit knocks `enemy` over: physics takes over its skeleton.
ctx.start_ragdoll(
enemy,
&[
RagdollBone::new("hips", 0.12),
RagdollBone::new("spine", 0.1).swing(0.5),
RagdollBone::new("head", 0.1),
RagdollBone::new("arm", 0.05).swing(1.2),
],
)
.expect("the skeleton has these joints");
let spine = ctx.world.get::<Skeleton>(enemy).unwrap().find("spine").unwrap();
let body = ctx.world.get::<Ragdoll3D>(enemy).unwrap().body(spine).unwrap();
ctx.physics3d.apply_impulse(body, hit * 40.0);
// When it gets up: bodies gone, half a second back to the animation.
ctx.stop_ragdoll(enemy, 0.5);
}
// A hit knocks `enemy` over: physics takes over its skeleton.
ctx.start_ragdoll(enemy, {
crabby::ragdoll_bone("hips", 0.12f),
crabby::ragdoll_bone("spine", 0.1f).swing(0.5f),
crabby::ragdoll_bone("head", 0.1f),
crabby::ragdoll_bone("arm", 0.05f).swing(1.2f),
});
auto spine = ctx.ragdoll_body(enemy, *ctx.find_joint(enemy, "spine"));
ctx.apply_impulse3d(*spine, {hit.x * 40, hit.y * 40, hit.z * 40});
// When it gets up: bodies gone, half a second back to the animation.
ctx.stop_ragdoll(enemy, 0.5f);
ctx.stop_ragdoll(entity, seconds) removes the bodies and joints and blends
the skeleton from where it lies back to what the animator plays, over
seconds (0 snaps). The entity’s Transform3D doesn’t move by itself: set
it to where the ragdoll lies (its root bone’s body) first if the animation
should start from there. The bodies are entities of their own, so stop the
ragdoll before despawning the character. Starting again replaces a running
ragdoll; a bad call (an unknown or repeated joint, a radius that isn’t
positive) returns an error and leaves the running one alone. Bodies of
one ragdoll don’t collide with the bones they’re jointed to, but do with
the rest of the world and the rest of the body.
Contact modification
ContactModifier3D changes how one body’s contacts behave. It is a component:
put it on an entity that has a collider.
- One-way surfaces.
ContactModifier3D::one_way(up)makes a body solid only from the sideuppoints to. A ball thrown up from below passes through the platform and lands on top of it.upis in the body’s own frame, so a tilted platform turns it with it.one_way_angle(radians, default 1.2, about 70 degrees) is how far a contact’s direction may lean fromupand still count as “from above”, so a steep slope of the surface still holds; narrow it to make only the flat top solid. Something already partly inside the platform keeps passing until it is out, so it is never pushed up from within. - Conveyors.
ContactModifier3D::conveyor(velocity)makes the surface carry whatever touches it atvelocity, in metres per second. The velocity is in the body’s frame, so a belt on a turning body turns its direction too. Only the part along the surface counts; the part into it is ignored. It works whether the belt or the thing on it comes first in the pair. - A custom hook.
ContactModifier3D::hook()sends the body’s contacts to the function you set withctx.physics3d.set_contact_hook(...)(ctx.set_contact_hook3d(...)in C++; a null function clears it). The hook gets aContactPair3D: the two entities (aandb), the contactnormal()and its points, and it canignore()the whole pair, drop oneremove_point(i), changeset_frictionandset_restitution, orset_surface_velocityin world space.
#![allow(unused)]
fn main() {
// A platform to jump up through: solid only from above.
ctx.world.spawn((
Transform3D::at(vec3(0.0, 3.0, 0.0)),
RigidBody3D::fixed(),
Collider3D::cuboid(vec3(4.0, 0.2, 4.0)),
ContactModifier3D::one_way(Vec3::Y),
));
// A belt carrying whatever stands on it along +X at 2 m/s.
ctx.world.spawn((
Transform3D::at(vec3(10.0, 0.0, 0.0)),
RigidBody3D::fixed(),
Collider3D::cuboid(vec3(8.0, 0.2, 1.0)),
ContactModifier3D::conveyor(vec3(2.0, 0.0, 0.0)),
));
// A hook for the rest: the ghost walks through the hooked wall.
let ghost = ctx.world.spawn((
Transform3D::at(vec3(-10.0, 1.0, 0.0)),
RigidBody3D::dynamic(),
Collider3D::sphere(0.5),
));
ctx.world.spawn((
Transform3D::at(vec3(-10.0, 1.0, 3.0)),
RigidBody3D::fixed(),
Collider3D::cuboid(vec3(4.0, 2.0, 0.2)),
ContactModifier3D::hook(),
));
ctx.physics3d.set_contact_hook(move |pair| {
if pair.a == ghost || pair.b == ghost {
pair.ignore();
}
});
}
// A platform to jump up through: solid only from above.
world.spawn(crabby::transform3d_at({0, 3, 0}), crabby::fixed_body3d(), crabby::box_collider({4, 0.2f, 4}),
crabby::one_way_modifier3d({0, 1, 0}));
// A belt carrying whatever stands on it along +X at 2 m/s.
world.spawn(crabby::transform3d_at({10, 0, 0}), crabby::fixed_body3d(), crabby::box_collider({8, 0.2f, 1}),
crabby::conveyor_modifier3d({2, 0, 0}));
// A hook for the rest: the ghost walks through the hooked wall.
auto ghost = world.spawn(crabby::transform3d_at({-10, 1, 0}), crabby::dynamic_body3d(), crabby::sphere_collider(0.5f));
world.spawn(crabby::transform3d_at({-10, 1, 3}), crabby::fixed_body3d(), crabby::box_collider({4, 2, 0.2f}),
crabby::hook_modifier3d());
ctx.set_contact_hook3d([ghost](crabby::contact_pair3d& pair) {
if (pair.a() == ghost || pair.b() == ghost) pair.ignore();
});
The hook runs during the physics step and sees only the pair, so capture
whatever else it needs (an entity, a flag) when you set it; it can’t reach
the world. Its changes last for one step: the next step builds the contacts
again and calls the hook again. Keep it short, since it runs for every
contact of every hooked body, every step. In C++ an exception thrown by the
hook ends the run after that step and comes out of run or run_headless.
Modified bodies cost a little: their contacts are rebuilt every step instead
of being kept while the bodies rest. Bodies without a ContactModifier3D
are not affected, and removing the component makes a body plain again.
One-way pushing
A higher RigidBody3D::dominance group pushes lower groups without receiving
a contact impulse back. Equal groups react normally. Both bodies remain dynamic,
so the higher body still responds to gravity, fixed walls and equal-group
contacts. Groups default to zero and range from -128 to 127 (C++: crabby::with_dominance):
#![allow(unused)]
fn main() {
let pusher = RigidBody3D::dynamic().dominance(1);
let pushed = RigidBody3D::dynamic(); // group 0
// Both remain dynamic: gravity, walls and equal-group bodies still affect them.
ctx.world
.spawn((Transform3D::at(Vec3::X * 3.0), pusher, Collider3D::cuboid(Vec3::ONE)));
ctx.world
.spawn((Transform3D::at(Vec3::X * 4.0), pushed, Collider3D::cuboid(Vec3::ONE)));
}
auto pusher = crabby::with_dominance(crabby::dynamic_body3d(), 1);
auto pushed = crabby::dynamic_body3d(); // group 0
// Both remain dynamic: gravity, walls and equal-group bodies still affect them.
world.spawn(crabby::transform3d_at({3, 0, 0}), pusher, crabby::box_collider({1, 1, 1}));
world.spawn(crabby::transform3d_at({4, 0, 0}), pushed, crabby::box_collider({1, 1, 1}));
This combines Rapier’s built-in dominance with a normal-velocity correction on the lower body. In Rapier 0.36.1, the dominance solver treats the higher side as stationary: it prevents pushback but does not transfer a moving pusher’s velocity. After solving, the engine applies a normal impulse only to the lower body, using its effective mass and inertia at the manifold’s average contact point. Contact hooks expose no normal target velocity or per-side inverse-mass control, so changing contact points alone does not solve this. A headless regression test compares equal-mass dynamic boxes: the faster higher-group box keeps its velocity while accelerating the other; equal groups exchange momentum. The setting is a body-wide ordering, not an arbitrary per-pair rule; it deliberately violates equal-and-opposite contact reaction. It does not suppress forces from joints, explicit impulses, or vehicle suspension code. The correction is a single post-solve normal impulse per manifold, not a full second constraint solve: complex stacks and simultaneous joint/contact constraints can need smaller steps. It does not transfer tangential surface velocity or reproduce full restitution against the moving surface. Fixed and kinematic bodies retain their usual dominance over dynamic bodies regardless of group.
3D navigation meshes
Enable navmesh for pure Rust Recast baking and Detour pathfinding. It implies
3d, but needs no window, GPU, physics, Bevy or async runtime. Servers can build
with --no-default-features --features navmesh. The existing 2D NavGrid and
FlowField are independent.
Gather static geometry as world-space NavSource triangle soups, or use
NavSource::from_mesh with a MeshData and Transform3D. Include solid walls,
ceilings and platforms, not only floors. Floors face up with counter-clockwise
winding. Sources are copied: later mesh edits require another bake.
#![allow(unused)]
fn main() {
fn navigation() -> NavMesh3D {
let floor = NavSource::from_mesh(&MeshData::plane(30.0), Transform3D::default());
let wall = NavSource::from_mesh(&MeshData::cube(4.0), Transform3D::at(vec3(0.0, 2.0, 0.0)));
NavMesh3D::bake(&[floor, wall], NavBakeSettings::default()).unwrap()
}
}
crabby::nav_sources sources;
sources.add_mesh(crabby::mesh_data::plane(30), crabby::transform3d_at({0, 0, 0}));
sources.add_mesh(crabby::mesh_data::cube(4), crabby::transform3d_at({0, 2, 0}));
auto settings = crabby::nav_bake_defaults();
settings.carving = true; // keep layers so carving obstacles can cut holes
ctx.bake_navmesh(sources, settings);
Settings use world units, and max_slope uses degrees. Recast voxelizes triangles,
filters low ceilings and ledges, erodes for the agent radius, partitions regions,
and builds tiled polygons with detail heights. Radius and height round up to
cells; climb rounds down. Smaller cells improve accuracy and cost more baking
work and memory. Bake uses bounded backend budgets and returns NavBakeError
on invalid inputs or resource exhaustion. A scene with no walkable surface may
produce an empty mesh.
Large levels
The level is baked as a grid of square tiles, tile_size cells (default 32,
6.4 m at the default cell size) on a side, and a navmesh holds at most 16,384
tiles. When a level would need more, the baker grows tile_size for you, up to
255 cells, so kilometre-wide terrain bakes with the default settings: 0.8 km
square at the default cell size keeps 32-cell tiles, and the grown tiles reach
about 6.5 km. Beyond that, raise cell_size; the error says which limit was hit
and which setting to change. Bake time grows with the number of cells, about a
second for a 500 m square at the defaults, so use a coarser cell_size and
cell_height (for example 0.5 and 0.25) for the largest levels. Smaller tiles
make carving cheaper, which is why the default is not larger.
Sample explicitly when accepting a distant click or spawn position. Sampling
uses Euclidean distance, including vertical distance. find_path accepts endpoints
close to the surface (two cells horizontally and climb plus two cell heights
vertically) and returns string-pulled world-space corners. Disconnected or truncated
routes return None, never a partial success. Queries currently cap the search and
corridor at 8192 nodes/polygons; an oversized route can therefore return None.
Agent route preparation also caps surface samples at one million; an oversized
route leaves the agent stopped.
#![allow(unused)]
fn main() {
let nav = navigation();
let start = nav.sample_position(vec3(-8.0, 1.0, 0.0), 2.0).unwrap();
let destination = vec3(8.0, 0.0, 0.0);
let corners = nav.find_path(start, destination).unwrap();
assert!(corners.len() >= 3);
}
start = ctx.navmesh_sample_position({-8, 1, 0}, 2).value();
auto corners = ctx.navmesh_find_path(start, destination);
if (corners.size() < 3) std::abort();
Register step_navmesh3d with Game::sim_system (C++: game::navmesh3d()), like an exposed physics step.
There is no automatic navigation update. Insert a NavMesh3D resource, then spawn
entities with Transform3D and NavAgent3D. This works in run_headless and obeys
the simulation clock. Put destination/obstacle systems before the navigation step.
#![allow(unused)]
fn main() {
let mut world = Game::new("Navigation server", 1, 1)
.fixed_hz(60)
.sim_system(step_navmesh3d)
.run_headless(600, |ctx| {
ctx.world.insert_resource(nav);
let mut agent = NavAgent3D::default();
agent.destination = Some(destination);
ctx.world.spawn((Transform3D::at(start), agent));
// World-space AABB; move its center or despawn it to reopen polygons.
ctx.world
.spawn((NavObstacle3D::new(vec3(0.0, 1.0, 8.0), vec3(1.0, 1.0, 1.0)),));
});
let position = world.query::<&Transform3D>().iter().next().unwrap().position;
assert!(position.distance(destination) < 0.3);
}
.navmesh3d() // moves agents every simulation tick
.run_headless(600, [&](crabby::ctx& ctx) {
auto agent = ctx.world().spawn(crabby::transform3d_at(start));
ctx.set_nav_agent(agent, 3);
ctx.set_nav_avoidance(agent, true, 2.0f, 3.6f);
ctx.set_nav_destination(agent, destination);
// A world-space box; move it or remove it to reopen polygons.
ctx.set_nav_obstacle(ctx.world().spawn(), {0, 1, 8}, {1, 1, 1});
The follower accelerates up to its speed and follows a surface-sampled path. Its transform represents its feet. It has no gravity, physical collision response, turning inertia. Crowd avoidance is opt-in (see below). Do not also attach a physics body or character controller that writes the same transform. Use a separate presentation offset if your model origin is above its feet. Bake for the largest agent radius and height; components with radius larger than the bake radius stop.
NavObstacle3D is a world-space axis-aligned box component. The sim system gathers
all boxes each tick. Changed, moved or removed boxes invalidate cached paths, as
does replacing the mesh resource, changing a destination or teleporting an agent.
Unreachable agents wait until one of these changes. An agent trapped inside a
newly blocked polygon stops; it is not automatically pushed out.
Dynamic boxes block entire overlapping polygon bounds, expanded by agent radius
and vertical clearance. This is conservative: a small obstacle may close a much
larger region, especially with large tiles. Opt-in carving instead cuts new polygon edges from cached heightfield layers.
Non-carving changes scan all polygons; no spatial obstacle
index is maintained. Use geometry in the bake for permanent walls and holes.
For manual queries without the sim system, call set_obstacles; when registered,
the system owns the obstacle list and replaces it from ECS components.
With avoidance disabled, no path queries or heap allocations occur in steady agent movement; replans build
paths and sample their heights. Baking and obstacle scans are synchronous, so
schedule large bakes outside latency-sensitive ticks. Ordered source geometry,
settings and updates are deterministic; the backend enables libm for consistent
math. This does not establish cross-platform determinism for the rest of a game.
The backend is navmesh-rs, a Rust port of Recast/Detour. It avoids a native C++
build and keeps generation and 3D queries together. Landmass would still require
a baker, Polyanya requires extra 3D surface handling, and oxidized_navigation is
a Bevy plugin. The C API and C++ wrapper expose baking, queries, agents, avoidance and obstacles.
Crowd avoidance
Set NavAgent3D::avoidance to opt into the backend’s DetourCrowd manager.
The default is false so existing path following and arrival behaviour stay intact.
Only opted-in agents participate, including agents whose destination is None.
Do not mix opted-out followers into a crowd and expect them to collide.
#![allow(unused)]
fn main() {
use ponko::*;
let mut agent = NavAgent3D::default();
agent.avoidance = true;
agent.avoidance_weight = 2.0;
agent.neighbour_distance = 3.6;
agent.destination = Some(Vec3::X * 5.0);
}
ctx.set_nav_avoidance(agent, true, 2.0f, 3.6f);
Crowd agents retain radius, maximum speed and acceleration. The nonnegative
avoidance_weight controls separation steering, rather than right of way.
neighbour_distance must be positive; choose it to cover approaching traffic.
The C equivalent is crabby_ctx_set_nav_avoidance(ctx, entity, enabled, weight, neighbour_distance). Invalid C parameters return an error; invalid Rust agent
parameters stop that agent and remove it from the crowd. Crowd radii must be
positive and no larger than the bake radius.
The backend uses a proximity grid, keeps the six nearest neighbours and samples velocities against their predicted motion and local navmesh walls, with a 2.5 s time horizon. Nearest-neighbour and sampling fixes are enabled. An internal 30% radius margin reduces penetration from its iterative overlap correction. Movement uses connected surface corridors, including height, rather than projecting onto a globally nearest floor. Ticks are subdivided toward 60 Hz, up to 64 substeps.
This is approximate local avoidance, not a collision solver or a guarantee of progress in arbitrarily dense traffic. Large time steps, excessive speeds, initial overlaps, narrow passages and opposing bottlenecks can cause penetration or stalling. Stopped agents can be displaced by overlap correction. Several agents sharing one destination settle in a region around it: nonzero discs cannot all arrive at one exact point. Give agents distinct reachable targets when precise arrival is required. Moving boxes invalidate crowd corridors. Carving is available as described above; there is no predictive box velocity.
Slots and corridors persist across ticks. Capacity grows in powers of two up to 16383 agents; excess agents wait. Mesh or obstacle changes rebuild the crowd, and teleports recreate the affected slot. The backend currently allocates query scratch during updates; avoid assuming allocation-free crowd simulation. The original follower retains its allocation-free steady path. Baking, full-route validation on a new target, and crowd updates are synchronous and headless.
Dynamic carving
Set carve = true to cut real holes using DetourTileCache. The default remains
conservative polygon blocking. Carving needs the heightfield layers, which cost
bake time and memory, so bake with NavBakeSettings::carving set to true
(carving in the C struct). Without it, a carve obstacle blocks like an
ordinary one. A carving bake builds the mesh from the layers alone, so it costs
about the same as a normal bake. Tile size is configured in bake settings (8–255
cells, default 32). Static heightfield layers are retained using the raw codec;
updates never rasterize source triangles again.
#![allow(unused)]
fn main() {
use ponko::*;
fn add(ctx: &mut Ctx) {
// Bake with layers: NavBakeSettings { carving: true, ..Default::default() }.
let mut obstacle = NavObstacle3D::new(vec3(6.0, 1.0, 6.0), Vec3::ONE);
obstacle.carve = true;
ctx.world.spawn((obstacle,));
// step_navmesh3d gathers components and rebuilds one cached layer per tick.
let nav = ctx.world.resource_mut::<NavMesh3D>();
let steps = nav.update_carving(0).unwrap();
let pending = nav.carving_pending();
let _ = (steps, pending);
}
}
ctx.set_nav_carve_obstacle(ctx.world().spawn(), {6, 1, 6}, {1, 1, 1});
// Simulation gathers components and rebuilds one cached layer per tick.
// After a tick, optional extra work reports steps and pending updates:
auto carving = ctx.navmesh_update_carving(0);
(void)carving;
For manual Rust meshes, set_obstacles queues changes and update_carving(budget)
advances at most that many layer rebuilds. C/C++ components are gathered by the
simulation before extra manual updates can advance them. A zero budget only
queries pending work. Changes arriving during rebuilds are retained. Movement
under half a cell from the last applied position does not rebuild; size changes
do. Ordered obstacle lists supply identity, so reordering can cause extra work.
Each edit is submitted separately. Only layers touched by changed obstacles are rebuilt; moving an obstacle touches its old and new bounds. All agent paths and crowd corridors are invalidated after tile updates, conservatively including agents whose paths do not cross those tiles. Paths see each tile as it completes, so carving is eventually consistent rather than atomic across tiles.
The backend accepts at most eight layers per obstacle and 256 obstacle handles;
large boxes or deeply stacked worlds can exceed this. Manual updates return an
error rather than silently truncating a hole. Simulation stops navigation on a
cache error until the obstacle is corrected; a manual update reports the error. Cylinders are not exposed. Agents
already enclosed by a hole stop rather than being ejected. Budget units count
cache update calls (at most one layer each); edits touching no layers can still
consume one unit. The simulation uses one unit per tick. Rebuilds allocate backend
scratch and refresh polygon bounds across the mesh; the budget bounds tile count,
not wall-clock duration. A carving bake keeps every tile’s heightfield layers in
memory. A carving mesh is built from those layers, so its polygons have no detail
mesh heights; ordinary bakes keep the detail mesh. A carve obstacle that fully
cuts a corridor leaves the two sides unconnected: find_path returns None and
agents wait until the hole is removed.
Sound
#![allow(unused)]
fn main() {
let hit = ctx.load_sound("hit.ogg"); // OGG Vorbis, WAV, MP3 or FLAC from assets/
ctx.play_sound(&hit);
let music = ctx.load_sound("theme.ogg");
let song = ctx.play_sound_ex(&music, SoundParams { volume: 0.5, looping: true, ..Default::default() });
ctx.stop_sound(song);
}
const crabby::sound hit = ctx.load_sound("hit.ogg"); // OGG Vorbis, WAV, MP3 or FLAC from assets/
ctx.play_sound(hit);
const crabby::sound music = ctx.load_sound("theme.ogg");
auto params = crabby::sound_defaults();
params.volume = 0.5f;
params.looping = true;
const crabby::playing song = ctx.play_sound(music, params);
ctx.stop_sound(song);
SoundParams { volume, speed, looping }:speedalso changes the pitch, and a little random variation keeps repeated effects from sounding mechanical.ctx.create_sound(sample_rate, &samples)makes a sound from generated mono samples (-1..1). Theuiexample’s click is made this way:
#![allow(unused)]
fn main() {
let samples: Vec<f32> = (0..4410) // 0.1 s at 44.1 kHz
.map(|i| 0.3 * (i as f32 * 880.0 * std::f32::consts::TAU / 44100.0).sin())
.collect();
let click = ctx.create_sound(44100, &samples);
}
std::vector<float> samples(4410); // 0.1 s at 44.1 kHz
for (std::size_t i = 0; i < samples.size(); ++i)
samples[i] = 0.3f * std::sin(float(i) * 880.0f * 6.2831853f / 44100.0f);
const crabby::sound click = ctx.create_sound(44100, samples.data(), samples.size());
The audio device opens the first time a sound plays. Headless runs, and machines without audio, are silent but run the same code. For servers you can drop the audio library entirely:
ponko = { version = "0.0.1", default-features = false }
The sound API stays; it just makes no noise. The C library has the same
switch: build ponko_ffi with --no-default-features. Linux builds with audio need
the ALSA headers (libasound2-dev on Debian and Ubuntu).
Sound in 3D
With the 3d feature, pair AudioSource3D with Transform3D, or play a
one-off sound at a point with ctx.play_sound_at. Every frame, after your
systems, the engine moves each sound to its entity and sets its volume,
panning and doppler from where the listener is. The listener follows
ctx.camera3d; to hear from somewhere else, such as the player in a
third-person game, call ctx.set_audio_listener(Some(..)).
Positions use world units; doppler assumes metres and takes velocities
from frame to frame. Set doppler: 0.0 to turn it off, for example for
something that teleports.
fn setup(ctx: &mut Ctx) {
let samples: Vec<f32> = (0..44100)
.map(|i| (i as f32 * 220.0 * std::f32::consts::TAU / 44100.0).sin() * 0.1)
.collect();
let hum = ctx.create_sound(44100, &samples);
let settings = AudioSettings3D {
sound: SoundParams {
volume: 0.4,
looping: true,
..Default::default()
},
min_distance: 2.0,
max_distance: 60.0,
rolloff: AudioRolloff::Linear,
..Default::default()
};
ctx.world
.spawn((Transform3D::at(vec3(4.0, 0.0, 0.0)), AudioSource3D::new(hum, settings)));
ctx.play_sound_at(&hum, vec3(-4.0, 0.0, 0.0), AudioSettings3D::default());
}
fn main() {
// The sounds follow their entities and the listener (ctx.camera3d) by themselves.
Game::new("Spatial audio", 800, 600).run(setup);
}
static void sound3d(crabby::ctx& ctx) {
float samples[4410];
for (int i = 0; i < 4410; i++) samples[i] = 0.1f * std::sin(i * 220.0f * 6.2831853f / 44100.0f);
auto hum = ctx.create_sound(44100, samples, 4410);
auto settings = crabby::audio_settings3d_defaults();
settings.sound.volume = 0.4f;
settings.sound.looping = true;
settings.min_distance = 2;
settings.max_distance = 60;
settings.rolloff = CRABBY_ROLLOFF_LINEAR;
auto e = ctx.world().spawn(crabby::transform3d_at({4, 0, 0}));
ctx.set_audio_source3d(e, hum, settings);
ctx.play_sound_at(hum, {-4, 0, 0});
}
Build this example with cargo build --example audio3d --features 3d.
Only running it opens a window and plays audio. Procedural clips already use
create_sound; file-backed clips use load_sound.
AudioSettings3D controls volume, pitch (sound.speed), looping, minimum and
maximum distance, and logarithmic, linear or keyed rolloff. Keyed curves use
sorted (normalized_distance, gain) pairs in a static slice. All curves are
silent at the maximum distance and full volume inside the minimum distance.
Stereo panning follows the listener’s right axis. Kira applies gain, panning
and playback-rate changes with smoothing; this is stereo positional audio,
not an HRTF simulation. Prefer mono source clips for localized effects.
ctx.play_sound_at starts a voice at a fixed world position. Save its handle
to stop it; removing an entity source stops its voice on the next audio update.
Headless execution never opens an audio device, and disabling audio keeps
the same APIs silent. No allocation occurs during updates unless a new voice
starts. load_sound decodes clips into memory; load_stream keeps long files streamed.
Buses, low-pass and reverb
Ordinary playback uses the sfx bus. Create other named buses once with
audio_bus, save their handles, and pass one to play_sound_on or to
AudioSettings3D::bus. Handles belong to the context that created them.
Volume is a linear gain; muting preserves the configured gain. Bus changes
apply to active voices as well as future playback.
#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn buses_and_effects(ctx: &mut Ctx, sound: Sound) {
let music = ctx.audio_bus("music"); // Save this handle; names are looked up once.
ctx.set_audio_bus_volume(music, 0.6);
let voice = ctx.play_sound_on(
&sound,
SoundParams {
looping: true,
..Default::default()
},
music,
AudioEffects {
low_pass_hz: 4_000.0,
reverb: 0.2,
},
);
ctx.set_sound_effects(
voice,
AudioEffects {
low_pass_hz: 800.0,
reverb: 0.5,
},
);
ctx.mute_audio_bus(music, true);
ctx.mute_audio_bus(music, false); // Restores the configured gain.
let settings = AudioSettings3D {
bus: music,
effects: AudioEffects {
low_pass_hz: 2_000.0,
reverb: 0.3,
},
..Default::default()
};
ctx.play_sound_at(&sound, vec3(3.0, 0.0, 0.0), settings);
}
}
static void buses_and_effects(crabby::ctx& ctx, crabby::sound sound) {
auto music = ctx.audio_bus("music"); // Save this handle; names are looked up once.
ctx.set_audio_bus_volume(music, 0.6f);
auto params = crabby::sound_defaults();
params.looping = true;
auto voice = ctx.play_sound_on(sound, music, params, {4000, 0.2f});
ctx.set_sound_effects(voice, {800, 0.5f});
ctx.mute_audio_bus(music, true);
ctx.mute_audio_bus(music, false); // Restores the configured gain.
auto settings = crabby::audio_settings3d_defaults();
settings.bus = music;
settings.effects = {2000, 0.3f};
ctx.play_sound_at(sound, {3, 0, 0}, settings);
}
Each voice uses a Kira low-pass filter on its own child track. The cutoff
is clamped to 20–20,000 Hz. set_sound_effects changes cutoff and reverb
send while a voice is playing, including positional voices. Reverb is a
single shared, fully wet Kira send track; the send amount is linear 0–1.
The send follows voice attenuation, filtering and bus gain. Muting a bus
stops its new reverb input; existing shared reverb tails decay naturally.
Nonfinite cutoff/send values use defaults. No custom DSP is used. Starting
a voice allocates its track and decoder; changing parameters and updating
existing spatial voices does not allocate engine-side buffers each frame.
Streaming music and ambience
#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn streaming_music(ctx: &mut Ctx) {
let music = ctx.audio_bus("music");
let song = ctx.load_stream("music.ogg");
let voice = ctx.play_sound_on(
&song,
SoundParams {
looping: true,
..Default::default()
},
music,
AudioEffects::default(),
);
ctx.stop_sound(voice);
}
}
[[maybe_unused]] static void streaming_music(crabby::ctx& ctx) {
auto music = ctx.audio_bus("music");
auto song = ctx.load_stream("music.ogg");
auto params = crabby::sound_defaults();
params.looping = true;
ctx.play_sound_on(song, music, params);
}
load_stream reads the header and keeps the native filesystem path rather
than decoding the entire clip. Each playback opens a separate decoder;
Kira performs buffered decoding on its worker thread. The returned Sound
works with 2D playback, positional playback, buses, effects and looping.
The file must remain available until playback ends. Streaming files are
not hot reloaded and currently require native filesystem assets (web builds
should use load_sound for prefetched assets). With audio disabled, stream
handles are silent and report zero duration. Headless playback never creates
tracks, decoder workers or an audio device. These additions currently have
Rust APIs only; C and C++ bindings are deferred.
Data files: tune without restarting
Keep the numbers you tweak (speeds, damage, costs, wave sizes) in a JSON
file in assets/ instead of in code. With hot reload
on (the default in debug builds), saving the file changes them in the
running game, within a quarter of a second, so you can tune a
fight while watching it.
#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct Tuning {
speed: f32,
damage: f32,
}
fn load_tuning(ctx: &mut Ctx, path: &str) {
ctx.load_resource::<Tuning>(path); // e.g. "tuning.json": {"speed": 30, "damage": 2}
}
fn step_size(ctx: &Ctx) -> f32 {
// The file's current values, every tick.
ctx.world.resource::<Tuning>().speed * ctx.dt()
}
}
struct Tuning { float speed; float damage; };
[[maybe_unused]] static void load_tuning(crabby::ctx& ctx) {
ctx.world().reflect_resource<Tuning>({crabby::field("speed", &Tuning::speed), crabby::field("damage", &Tuning::damage)});
ctx.load_json<Tuning>("tuning.json"); // {"speed": 30, "damage": 2}
// Systems read ctx.world().resource<Tuning>(): the file's current values.
}
In Rust the file becomes a resource: any type that implements
serde::Deserialize, replaced whole on each reload (use #[serde(default)]
for optional fields). In C and C++ the resource is a plain struct described
with reflect_resource, and the file’s fields merge into it, so the file
may list only some of them (C: crabby_ctx_load_resource_json). C++ can
also load a file into a component of one entity: ctx.load_json<T>(path, e).
- Loading fails with a clear message: a missing file lists the folders searched, and bad JSON says the line and column.
- A file that doesn’t parse while the game runs (often one caught halfway through saving) keeps the old values and prints why; the next good save applies.
ctx.reloaded_files()names the files reloaded this frame, textures included, for work that depends on them (rebuilding a map, say). In C++,ctx.was_reloaded("map.json")(C:crabby_ctx_was_reloaded).- Headless runs reload too, so an agent or a test can change a file between ticks.
Scenes and prefabs
A scene is a whole world kept in a file: a level, a menu, a test
setup. A prefab is one entity’s components, kept in a file and spawned
as many times as you like: a tree, an enemy, a building. Both live in
assets/, both are JSON (so they diff well in git), and the editor,
agents and your own code write them like any other file.
#![allow(unused)]
fn main() {
fn beach(ctx: &mut Ctx) -> Result<(), String> {
ctx.world.reflect::<Palm>(); // components in files must be reflected
ctx.load_scene("levels/beach.json")?; // the level: its entities, ids kept
for i in 0..3 {
// three more palms, all alike, in a row
ctx.spawn_prefab("prefabs/palm.json", vec2(400.0 + i as f32 * 64.0, 400.0))?;
}
Ok(())
}
}
struct Palm { float height; };
static void beach(crabby::ctx& ctx) {
ctx.world().reflect<Palm>({crabby::field("height", &Palm::height)}); // components in files must be reflected
ctx.load_scene("levels/beach.json"); // the level: its entities, ids kept
for (int i = 0; i < 3; ++i) // three more palms, all alike, in a row
ctx.spawn_prefab("prefabs/palm.json", {400.0f + float(i) * 64, 400});
}
load_scenereplaces the world with the scene’s entities, which keep their ids, so components that refer to other entities still point at the right ones. Resources that are reflected load too.spawn_prefabputs the prefab’s position at the point you give and keeps its saved rotation and scale. A prefab without aTransform2Dgets one. The file is read once, then kept in memory, so spawning a thousand trees reads it once.- Only reflected components go in files: reflect your
types before loading or saving.
Transform2D,Spriteand the physics components are reflected already. A sprite names its image ("texture": "crab.png"), and loading the scene or prefab loads it. With 3D on,Transform3DandModelare reflected too: a model names its mesh’s glTF file ("mesh": "tree.glb"), loaded the same way. Scenes can also come from Unity (Importing from Unity). - Errors say what’s wrong and change nothing: a missing file lists the folders searched, and a component that isn’t reflected is named.
Saving
The inspector’s editor saves and loads scenes and places prefabs with the mouse. From code:
save_scene(path) writes the world (its reflected components and
resources) and save_prefab(entity, path) writes one entity, without its
position. Both write over the file in assets/ if it’s there, else create
it (and its folders). Web builds can’t save files.
#![allow(unused)]
fn main() {
ctx.save_scene("levels/beach.json")?;
ctx.save_prefab(palm, "prefabs/palm.json")?;
}
C++: ctx.save_scene(path) and ctx.save_prefab(e, path), which throw on
errors like the rest of the wrapper. C: crabby_ctx_load_scene,
crabby_ctx_save_scene, crabby_ctx_spawn_prefab and
crabby_ctx_save_prefab.
The files
A scene is the save format of world.save():
{
"format": "crabby-world",
"version": 1,
"entities": [
{ "entity": 0, "components": { "Transform2D": { "position": [0.0, 400.0] }, "Palm": { "height": 3.0 } } },
{ "entity": 1, "components": { "Transform2D": { "position": [200.0, 380.0] }, "Palm": { "height": 5.0 } } }
]
}
A prefab lists one entity’s components:
{
"format": "crabby-prefab",
"version": 1,
"components": {
"Transform2D": { "rotation": 0.1 },
"Palm": { "height": 4.0 }
}
}
Fields left out take their defaults (here the palm’s position and scale). Prefabs are single entities for now: a prefab can’t hold children.
Importing from Unity
ponko_unity reads a Unity project and turns its scenes and prefabs into
Crabby scenes: every drawn mesh becomes a .glb with its
materials, its textures become PNGs, and the scene is a crabby-world
file of Transform3D and Model entities that load_scene loads. It
runs once, as a tool, and writes into your game’s assets/.
cargo run --release -p ponko_unity --example import -- \
MyUnityGame my_game/assets/town town/ Assets/Scenes/Town.unity
That writes my_game/assets/town/Town.json (load it with
ctx.load_scene("town/Town.json")), the meshes in town/meshes/ and their
textures in town/meshes/textures/, then says what it left out and which
shaders the materials used. Give it several scenes or prefabs at once and
they share their meshes and textures; ONE=all.json puts all their
entities in one scene.
To look around what came out:
SCENE=town/Town.json cargo run --release --features 3d --example scene_viewer
Unity’s +Z is Crabby’s -Z: everything is mirrored along Z on the way, so a scene looks the same, and positions and rotations come out in Crabby’s space.
What comes over
- Meshes from mesh assets (
.asset), FBX and OBJ files (scaled and oriented as Unity’s model importer does), and Unity’s built-in Cube, Plane and Quad. Each submesh keeps its material. A model imported with Bake Axis Conversion has its file’s axes (Z-up, say) turned into Unity’s in the mesh itself, as Unity does, so parts placed one by one in a prefab line up. - Materials of URP’s Lit, Simple Lit and Unlit shaders, and of other shaders that use the same property names: base color and map, normal map, metallic and smoothness (packed into glTF’s occlusion, roughness and metalness image), emission, tiling, cutout, transparency and two-sided, plus occlusion. A custom shader is drawn as plain PBR by the same rules; the report lists the shaders used, so you know which need a Crabby shader of their own.
- Textures from image files, at most as large as their import settings allow, and from texture assets saved uncompressed.
- Prefabs inside prefabs, with their overrides, baked into one flat list of entities placed in the world.
- Colliders, if you ask (
importer.colliders(true)): box, sphere, capsule and mesh colliders become entities with a fixedRigidBody3Dand aCollider3D(3D physics), the object’s scale worked into the shape. A mesh collider’s mesh is written as a.glband named in the scene by file;ctx.load_scenemakes a triangle mesh of it (a convex hull for a convex one), once per mesh and scale, and saving the scene names the file again. Triggers are left out.
Vertex colors are kept only for shaders you name (see below): URP’s shaders ignore them, so most meshes are drawn as Unity draws them without.
Renderers keep their Cast Shadows setting: Off gets a ShadowMesh of
none, and Shadows Only (often a simplified stand-in for the shadows of the
pieces around it) becomes a ShadowMesh with no Model, drawn only into
the shadow maps.
Levels of detail
A Unity LODGroup comes over as a LodGroup on one entity placed
at the group’s object. Every level keeps its renderers (each placed
relative to the group), its screen-relative height as the level’s
screen_height, and its fadeTransitionWidth as fade_width when the group’s
Fade Mode is on. The group’s m_Size is the size and its reference point
the center. A renderer’s Cast Shadows setting becomes the level’s shadow:
a level with a renderer set to Off or Shadows Only casts the shadow of
the right meshes, not the ones it shows.
#![allow(unused)]
fn main() {
import.lod_groups(false); // LOD 0 only, the far levels left out
}
Animated cross-fading (a fade over seconds) isn’t supported: those groups fade over a band of screen heights instead, and the report counts them. Skinned renderers can’t be part of a group: one in LOD 0 stays an entity of its own, and the ones in far levels are left out (also counted).
FBX and uncompressed Unity .asset skinned meshes export inverse bind
matrices and up to four influences per vertex (weights below 0.001 are
dropped, then the largest four are normalized). Mesh assets support legacy
bone weights and VertexData blend channels. Renderer bone references use
the flattened scene’s names, hierarchy and overridden rest transforms when
all joints resolve; otherwise the source skeleton is retained (mesh assets
use unnamed bind-pose joints). Left out, and counted in the report: skinned renderers in far levels of detail, inactive objects and disabled renderers, the built-in
Sphere, Capsule and Cylinder, compressed meshes, and meshes or textures
it can’t find or read.
Your own shaders for imported materials
Materials of the project’s own shaders come over as plain PBR. To draw them with a Crabby shader of your own, tell the importer which shaders those are:
#![allow(unused)]
fn main() {
import.custom_shader("Assets/Shaders/Wall.shader"); // as the report lists it
}
Their meshes then keep their vertex colors and UV set 1 (which custom
shaders often read; in Crabby’s shaders they’re s.color and s.uv1,
UV set 1 unflipped, as Unity has it), and the importer writes
materials.json next to the scenes: each such material by name, with its
shader and every number, color and texture it sets, the textures written
as PNGs. In the game, loaded_materials(name) gives every material loaded
from glTF files under that name, with what it was made from, so you can
keep that and change the shader:
#![allow(unused)]
fn main() {
// The house's walls, drawn with a shader of our own: keep what they
// were made from (textures, colors) and change the shader.
let weathered = ctx.create_shader(
"weathered.wgsl",
"fn surface(s: Surface) -> Surface { var o = s; o.albedo *= 0.8; o.roughness = 0.95; return o; }",
);
for (walls, desc) in ctx.loaded_materials("walls") {
ctx.update_material(
walls,
MaterialDesc {
shader: Some(weathered),
..desc
},
);
}
}
// The house's walls, drawn with a shader of our own: keep what they
// were made from (textures, colors) and change the shader.
auto weathered = ctx.create_shader("weathered.wgsl",
"fn surface(s: Surface) -> Surface { var o = s; o.albedo *= 0.8; o.roughness = 0.95; return o; }");
for (auto [walls, desc] : ctx.loaded_materials("walls")) {
desc.has_shader = true;
desc.shader = weathered;
ctx.update_material(walls, desc);
}
Call it after the scene (or model) is loaded: models loaded later aren’t
in it yet. C: crabby_ctx_loaded_materials.
A shader that reads something else per vertex, such as a second UV set, can have it put in the vertex colors instead, which is what Crabby’s shaders get per vertex besides one UV set:
#![allow(unused)]
fn main() {
// A river shader with its flow in UV set 1: speed up to 4, foam 0 to 1.
import.vertex_colors("Assets/Shaders/River.shader", |mesh, v| {
let [speed, foam] = mesh.uvs[1].get(v).copied().unwrap_or_default();
[speed / 4.0, foam, 0.0, 1.0]
});
}
Each channel keeps 8 bits, from 0 to 1, so scale values into that range and back in the shader.
Model animation
Skinned FBX models export their takes as glTF animations targeting the same nodes as the skin. Local translation, quaternion rotation and scale are sampled at the file’s frame rate, including clip endpoints and source key times. Constant tracks use STEP interpolation; other sampled tracks use LINEAR. The axis conversion, import scale and Z mirror match the rest pose.
The model’s .fbx.meta clipAnimations entries select a take (takeName)
and slice firstFrame through lastFrame into named clips. Each clip starts
at zero seconds. Without entries, every take exports under its take name.
Loop flags are preserved as animation extras.loop; the player chooses
whether to repeat playback. Renderer bone references preserve joint order,
and m_RootBone supplies the glTF skeleton root. Missing bone references
retain the unresolved model bones while applying the resolved scene bones.
Standalone .anim clips (text or binary serialized) also import:
#![allow(unused)]
fn main() {
let clips = [ponko_unity::AnimationClip::read("Assets/Walk.anim")?];
import.animation_clips(clips); // before importing scenes / exporting cached meshes
}
Clips target each exported skin’s root (empty path) and child names along
slash-separated Transform paths. Unresolved or ambiguous paths are reported.
AnimationClip::append_to_skin also attaches clips directly to a MeshSkin
in Unity space. The importer uses the same glTF writer and Z mirror as FBX.
Quaternion, position, scale and Euler curves use cubic Hermite sampling at
m_SampleRate, including keys and endpoints; Euler rotations use Unity’s
Z-X-Y order. Clip settings select start/stop times (output starts at zero)
and preserve the loop flag. Infinite tangents evaluate as steps; fully step
tracks use glTF STEP. Mixed step/smooth tracks use sampled LINEAR, so jumps
between samples are approximated. Weighted tangents are approximated as
unweighted Hermite and reported.
Which curves a clip uses
A clip saved by the editor usually carries two copies of its animation: the
curves you authored (m_PositionCurves, m_RotationCurves, m_ScaleCurves,
m_EulerCurves, the editor’s per-property m_EditorCurves such as
m_LocalPosition.x and m_EulerEditorCurves, and blend-shape float curves)
and the runtime data Unity bakes from them (m_MuscleClip with
m_ClipBindingConstant). When there are authored curves, the importer uses
them and ignores the baked copy. Properties that a per-property curve leaves
out keep their rest pose: a clip that only animates m_LocalPosition.x
leaves y and z alone. Where plain transform curves and per-property curves
describe the same target, the plain ones win.
A clip without authored curves is decoded from the baked data:
- Streamed, dense and constant curves: cubic keys per time,
samples at a fixed rate (linear between frames), and single values. Their
channels are bound to targets through the clip’s
genericBindings: the CRC-32 of a transform’s slash-separated path (below the node the clip is applied to) and an attribute (position, rotation, scale or Euler angles). Hash and node must match exactly; unmatched or ambiguous paths are reported. - Compressed rotation curves (
m_Compressed, bit-packed keys). - Blend shapes in baked data are only identified by a hash of their
name. The importer matches it against the blend shapes of the mesh being
exported (
AnimationClip::with_blend_shape_namesdoes the same for your own use); unmatched ones are reported.
Binary serialized clips (Unity’s Mixed or Binary asset serialization,
2022 or newer, saved by the editor with type trees) read into the same
fields, so they behave like their text twins. AnimationClip::read picks
the format; parse takes text and parse_binary bytes.
What does not import: humanoid clips whose curves are muscles and root motion need retargeting, so a clip with only those is an error, and one that mixes them with transform curves imports the transform curves and reports the rest. Float curves on anything but blend shapes, object reference curves, weighted tangents (approximated), animation events and Animator controllers are skipped and reported or not imported yet. The compressed-curve tangent layout and the baked decoders are checked against synthetic data built from the formats’ public descriptions, not against every Unity version’s output.
Terrain
Unity terrain (TerrainData, text or binary) reads as a
UnityTerrain: its heights in world units, already in Crabby’s row order,
ready for a terrain tile, and the layers painted on it with
their weight maps.
#![allow(unused)]
fn main() {
let file = ponko_unity::UnityFile::read("MyUnityGame/Assets/Terrain/Hills.asset")?;
let data = file.of_class(156).next().expect("a TerrainData");
let hills = ponko_unity::UnityTerrain::from_object(data)?;
// In the game (or written to a file the game reads):
let tile = ctx.create_terrain_tile(&hills.heights, hills.size, ground);
let [x, y, z] = hills.corner([0.0, 0.0, 0.0]); // the Unity Terrain's position
ctx.world.spawn((Transform3D::at(vec3(x, y, z)), tile));
}
The weight maps are Texture2Ds in the same file: read them with
UnityTexture and their rows already line up with the tile.
From C and C++
The importer is a Rust tool: run it once, as above. What it writes are
ordinary scene, glTF and PNG files, which C and C++ games load with
ctx.load_scene(path) (C: crabby_ctx_load_scene) like any other.
Changing code while the game runs
Edit a system, save, and the running game uses the new code from the next frame on. Nothing restarts: every entity, resource, the camera and the tick count stay where they were. With a battle half fought, you can retune how units pick targets and watch the same fight continue.
This uses subsecond, the hot-patching
engine from the Dioxus project, run by Dioxus’s CLI dx.
Setup
Install the CLI once:
cargo install [email protected]
(dx is also the name of a Deno tool; if that one comes first on your
PATH, call this one as ~/.cargo/bin/dx.)
Give your game a hotpatch feature that turns on the engine’s:
[dependencies]
ponko = "0.0.1"
[features]
hotpatch = ["ponko/hotpatch"]
Then start the game through dx instead of cargo run:
dx serve --hot-patch --features hotpatch
Edit any system in your game’s crate and save. The console says
Hot-patching: src/main.rs took 349ms, then ponko: code patched,
and the system runs its new code.
Try it
examples/hotpatch.rs spins a few crabs and prints the first one’s
rotation every second:
#![allow(unused)]
fn main() {
fn spin(ctx: &mut Ctx) {
let dt = ctx.dt();
for t in ctx.world.query::<&mut Transform2D>().iter() {
t.rotation += -6.0 * dt;
}
}
}
Change += 1.5 to -= 3.0 and save. The printed rotation turns around
from wherever it had got to:
tick 4200 rotation 66.40
tick 4260 rotation 63.40
tick 4320 rotation 60.40
What can change
| Change | Patched live? |
|---|---|
| A system’s body, and any function it calls | yes |
| Constants and literals in them (speeds, colors, ranges) | yes |
| New helper functions called from a system | yes |
Adding or removing systems (.sim_system(...) in main) | no: main already ran; restart |
| A component’s or resource’s fields (its size or layout) | no: restart (the old values in memory have the old layout) |
| Code outside your game’s crate (the engine, other crates in a workspace) | no: only the crate with main is patched |
Code that changes a type’s layout and runs anyway can crash, so restart after changing struct fields. For tuning numbers without code at all, see data files; for looking at and editing the running world, the inspector.
Cost
Without the hotpatch feature, systems are plain calls, as before. With
it, each system call looks itself up in a table, which is negligible (one
lookup per system per frame, not per entity), and only in debug builds;
release builds call directly even with the feature on.
The engine’s own systems (draw_sprites, camera_controls, …) can’t be
patched, since they’re outside your crate.
C and C++
Not available: hot patching works on Rust code compiled by dx. C and C++
games can keep their game data in data files, which
reload when saved.
Headless runs and testing
Game::run_headless(ticks, setup) (C++: crabby::game::run_headless) runs the simulation with no window and no
GPU, as fast as the CPU allows, then hands you the World.
Use it for:
- tests of game logic in plain
cargo test, including on CI machines without a GPU; - dedicated servers and batch runs;
- benchmarks of the simulation alone.
#![allow(unused)]
fn main() {
use ponko::*;
struct Pos(f32);
fn fall(ctx: &mut Ctx) {
let dt = ctx.dt();
ctx.world.query::<&mut Pos>().for_each(|p| p.0 -= 10.0 * dt);
}
#[test]
fn things_fall_ten_units_per_second() {
let mut world = Game::new("test", 800, 600)
.sim_system(fall)
.run_headless(60, |ctx| {
ctx.world.spawn((Pos(100.0),));
});
let y = world.query::<&Pos>().iter().next().unwrap().0;
assert!((y - 90.0).abs() < 1e-3);
}
}
struct Pos { float y; };
static void things_fall_ten_units_per_second() {
crabby::entity thing = 0;
auto world = crabby::game("test", 800, 600)
.sim_system([](crabby::ctx& ctx) {
const float dt = ctx.dt();
ctx.world().each([dt](Pos& p) { p.y -= 10 * dt; });
}, "fall")
.run_headless(60, [&](crabby::ctx& ctx) { thing = ctx.world().spawn(Pos{100}); });
const float y = world.get<Pos>(thing).y;
if (std::fabs(y - 90) > 1e-3f) { std::fprintf(stderr, "fell to %f\n", double(y)); std::abort(); }
}
What runs
setupruns once. If it returns a frame closure, that closure is dropped.- Each tick runs every sim system once. Frame systems and drawing don’t run, because there are no frames.
- Pause and sim speed are ignored;
ctx.quit()stops early. - Textures load for their sizes but nothing is uploaded.
The same game can run windowed or headless: the boids example does both
(HEADLESS=1 cargo run --release --example boids), and
examples/headless.rs
simulates a million agents this way.
Builds without a window
A simulation server or a CI job that only runs headless doesn’t need winit
and wgpu. Turn the window feature off and keep the rest:
# Cargo.toml
[dependencies]
ponko = { version = "0.0.1", default-features = false, features = ["audio"] }
# Build the C library without a window
cargo build --release -p ponko_ffi \
--no-default-features --features audio
That build has 82 crates instead of 142, builds from clean in 7 s instead of
15 s (release, M-series Mac) and leaves half the build output. Everything in
this chapter works the same. Drawing calls do nothing, and Game::run
(C: crabby_game_run) stops with a message that says to use run_headless.
Web builds: games in the browser
The engine includes browser-build tooling. Hosted playable demos are not yet deployed on ponko.dev. Build the public battle, flow, and ants examples locally using the instructions below.
Building your game
Once:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.130 # the wasm-bindgen version in your Cargo.lock
Then, from your game’s folder, with
web/build.sh
and web/index.html
copied from the engine’s repository:
web/build.sh --bin my_game # -> target/web/my_game/
python3 -m http.server -d target/web/my_game 8000
and open http://localhost:8000. In the engine’s repository,
web/build.sh battle builds an example.
The output folder is a static site: put it on any web host (GitHub Pages,
itch.io, your own server). It holds the page, the game (game_bg.wasm, about
3 MB compressed) and a copy of assets/.
What’s different in a browser
Assets are fetched before setup. There’s no file system, so the build
script lists your assets/ folder in assets/index.json, and the engine
downloads every file in it before running your setup. load_texture and
the other loading functions then work as usual.
One thread. Browsers can run WebAssembly on several threads only on
specially configured servers, so web builds use one. Parallel queries
(par_for_each) still work, just on a single core: the battle’s 20,000
units take about 4.5 ms per tick in Chrome, against 0.5 ms native on all
cores of the same machine. Plan for tens of thousands of units on the web,
rather than millions.
Measure time with the engine’s Instant. std::time::Instant::now()
panics in browsers. ponko::Instant is the same type on desktop and
works on the web:
#![allow(unused)]
fn main() {
use ponko::*;
let start = Instant::now();
// ... work ...
let ms = start.elapsed().as_secs_f64() * 1000.0;
let _ = ms;
}
Settings come from the address, not the environment. There are no
environment variables in a browser. The inspector reads ?inspector=1
(on), ?inspector=open (on and open) or ?inspector=0 instead of
CRABBY_INSPECTOR.
The page is yours. The engine draws into <canvas id="crabby"> if the
page has one, and adds a canvas otherwise. web/index.html makes the canvas
fill the page; change it to put the game anywhere. Browsers only allow
sound once the player has clicked or pressed a key, so sounds played before
that (music at startup, say) may stay silent.
Not available on the web: the control endpoint and
MCP tools, hot reload of files, hot
patching of code, and anything that writes files
(ctx.save_screenshot, saving the world to a path). println! output goes
nowhere; panics appear in the browser’s developer console.
C and C++
Not yet: web builds work for Rust games. C and C++ games would need to be compiled with Emscripten, which the engine doesn’t support so far.
C and C++
Games can be written in C++ (or plain C) as well as Rust. They use the same engine: the same ECS, game loop, renderer, spatial grid and control endpoint for agents. Every chapter of this guide shows the C++ version of its examples next to the Rust one.
include/crabby.hpp: the C++17 wrapper, header-only.crabby::game,crabby::ctxandcrabby::world_viewmirror Rust’sGame,CtxandWorld.include/crabby.h: the C API underneath, generated from theponko_fficrate with cbindgen.- One shared library,
ponko_ffi(libponko_ffi.dylib/.so,ponko_ffi.dll).
Loops over entities run in your C++ code, over whole component arrays,
so a million entities never mean a million calls across the language
boundary, and each_par runs them on all cores.
Build and run the examples
From the repository root, with Rust, CMake, a C11 compiler and a C++17 compiler:
cargo build -p ponko_ffi --release
cmake -S examples/cpp -B target/native -DCMAKE_BUILD_TYPE=Release -DCRABBY_LIBRARY_DIR="$PWD/target/release"
cmake --build target/native --config Release
ctest --test-dir target/native -C Release --output-on-failure
./target/native/crabby_cpp_boids # window
HEADLESS=1 BOIDS=1000000 TICKS=300 ./target/native/crabby_cpp_boids
./target/native/crabby_cpp_battle # the battle demo
HEADLESS=1 UNITS=1000000 TICKS=600 ./target/native/crabby_cpp_battle
In PowerShell, replace $PWD with the repository’s absolute path, using
forward slashes; Visual Studio builds use --config Release and put programs
in target/native/Release/.
For your own game, copy the recipe in examples/cpp/CMakeLists.txt: include
the two headers, link ponko_ffi, ship its shared library next to your
executable, and put your textures in an assets/ folder next to it (the
example copies the repository’s assets/). crabby_abi_version() returns 2;
rebuild against the matching header when you update the engine.
A C++ game
static void my_game() {
crabby::texture crab{};
crabby::vec2 pos{400, 300};
crabby::game("My Game", 800, 600)
.system([&](crabby::ctx& ctx) {
if (ctx.key_down(CRABBY_KEY_RIGHT)) pos.x += 200 * ctx.dt();
if (ctx.key_down(CRABBY_KEY_LEFT)) pos.x -= 200 * ctx.dt();
if (ctx.key_pressed(CRABBY_KEY_ESCAPE)) ctx.quit();
ctx.draw(crab, pos);
})
.run([&](crabby::ctx& ctx) { crab = ctx.load_texture("crab.png"); });
}
- Systems are anything callable as
void(crabby::ctx&). Lambdas can capture your game state by reference: the game runs insiderun(), so it outlives nothing it captures. run(setup)opens the window;run_headless(ticks, setup)runs the simulation alone and returns the world (see Headless runs).- Errors become exceptions. An exception thrown by your system stops the game
and comes out of
run(); it never crosses into the engine.
Components
C++ components are plain structs: standard-layout, trivially copyable and
trivially destructible. No std::string, std::vector or owning pointers in
components; store ids or indices and keep other objects outside the ECS.
struct Position { float x, y; };
struct Velocity { float x, y; };
There’s nothing to register: calls find a component by its C++ type, and
the engine learns about the type the first time it sees one. It’s named
after the type, namespaces included (game::Health), which is how tools and
saves show it; world.reflect<T>("Name", fields) picks another name.
The built-ins are C++ types too: crabby::transform2d is Transform2D
(the same bytes as Rust), crabby::attached is Attached,
crabby::animation is Animation, crabby::velocity is Velocity, and
crabby::sprite is Sprite (spawn, get and set, but not a query parameter,
since the engine stores sprites in a Rust layout; the compiler tells you).
Physics bodies and colliders, crabby::rigid_body and
crabby::collider, work like sprites.
crabby::world world;
auto player = world.spawn(Position{0, 0}, Velocity{1, 2});
world.each([](Position& p, const Velocity& v) {
p.x += v.x;
p.y += v.y;
});
auto result = world.get<Position>(player);
Queries and threads
world.each(fn) calls your function for every entity with the components
it takes (see Queries); world.query<A, B>(fn) hands you
whole arrays instead. While a loop runs, the world and context are busy:
other world or context calls throw (C: return CRABBY_BUSY), except
- commands:
despawn_later,spawn_later,set_later,remove_later, applied when the system returns (ineachandquery, not in the_parversions); - events:
sendandread(also not in the_parversions; see Events); - spatial queries, and the clock values
dt(),sim_tick(),random().
each_par and query_par split archetypes into slices and run your function
on all cores at once. It must only touch its own entity and read shared
data; that is the same rule as Rust’s par_for_each.
each is as fast as a hand-written loop over the arrays: 1M entities,
position += velocity, take 0.15 ms either way
(examples/cpp/bench.cpp).
Everything else stays on the thread that runs the game.
Speed
The battle demo is a whole game, written twice: examples/battle.rs and
examples/cpp/battle.cpp. Both have flow fields, per-team grids, combat on
all cores, reinforcements and rounds. Same seed, same map, same armies
(the C++ rng gives the same numbers as Rust’s), release builds,
HEADLESS=1 UNITS=… TICKS=1200:
| Units | Rust | C++ |
|---|---|---|
| 20k | 0.57 ms/tick | 0.57 ms/tick |
| 200k | 4.04 ms/tick | 4.24 ms/tick |
| 1M | 18.85 ms/tick | 19.97 ms/tick |
The hot loops use views (grid.view(), flow.view()) that the header reads
inline, so nothing crosses into the engine per unit. The gap for big armies
is in rebuilding the per-team grids: Rust feeds them straight from the
units, C++ copies each team’s units into an array first.
HEADLESS=1 BOIDS=… ./crabby_cpp_boids and HEADLESS=1 BOIDS=… cargo run --release --example boids run the same flocking, release builds, on the
development Mac (arm64, 2026-11-03):
| Boids | Rust | C++ |
|---|---|---|
| 100k | 3.5 ms/tick | 3.9 ms/tick |
| 1M | 31.3 ms/tick | 34.5 ms/tick |
The remaining gap is mostly the C++ version copying velocities into its own
array each tick before rebuilding its grid. Plain column updates (one million
p += v, cargo run --release --example ffi_bench vs crabby_cpp_bench,
which times each and query) run at the same speed in both: about 0.15 ms.
Grid queries in hot loops should use views (ctx.spatial_view(),
grid.view()): the header walks the grid inline, as Rust does. Through the
query functions, C++ boids took 41.5 ms/tick at 1M.
Plain C
C uses the same functions without the wrapper: status codes instead of
exceptions, a user pointer instead of captures.
CrabbyWorld* world = crabby_world_new();
if (!world) { fprintf(stderr, "%s\n", crabby_last_error()); return 1; }
uint32_t position, velocity;
check(crabby_component_register(world, "Position", sizeof(Position), _Alignof(Position), &position));
check(crabby_component_register(world, "Velocity", sizeof(Velocity), _Alignof(Velocity), &velocity));
Position p = {0, 0};
Velocity v = {1, 2};
CrabbyValue values[] = {{position, &p, sizeof(p)}, {velocity, &v, sizeof(v)}};
uint64_t entity;
check(crabby_spawn(world, values, 2, &entity));
uint32_t columns[] = {position, velocity};
check(crabby_query(world, columns, 2, update, NULL));
check(crabby_get(world, entity, position, &p, sizeof(p)));
check(crabby_world_free(world));
static void update(const CrabbyChunk* chunk, void* user) {
(void)user;
Position* positions = chunk->columns[0];
const Velocity* velocities = chunk->columns[1];
for (size_t i = 0; i < chunk->count; ++i) {
positions[i].x += velocities[i].x;
positions[i].y += velocities[i].y;
}
}
A headless game in C:
typedef struct { uint32_t position, velocity; uint64_t agent; } Game;
static void setup(CrabbyCtx* ctx, void* user) {
Game* g = user;
CrabbyWorld* world = crabby_ctx_world(ctx);
check(crabby_component_register(world, "Position", sizeof(Position), _Alignof(Position), &g->position));
check(crabby_component_register(world, "Velocity", sizeof(Velocity), _Alignof(Velocity), &g->velocity));
Position p = {0, 0};
Velocity v = {1, 2};
CrabbyValue values[] = {{g->position, &p, sizeof(p)}, {g->velocity, &v, sizeof(v)}};
check(crabby_spawn(world, values, 2, &g->agent));
}
static void movement_chunk(const CrabbyChunk* chunk, void* user) {
float dt = *(const float*)user;
Position* positions = chunk->columns[0];
const Velocity* velocities = chunk->columns[1];
for (size_t i = 0; i < chunk->count; ++i) {
positions[i].x += velocities[i].x * dt;
positions[i].y += velocities[i].y * dt;
}
}
static void movement(CrabbyCtx* ctx, void* user) {
Game* g = user;
float dt = crabby_ctx_dt(ctx);
uint32_t columns[] = {g->position, g->velocity};
check(crabby_query(crabby_ctx_world(ctx), columns, 2, movement_chunk, &dt));
}
Game g = {0};
CrabbyGame* game = crabby_game_new("C headless", 1, 1);
check(crabby_game_sim_system(game, "movement", 1, movement, &g));
CrabbyWorld* sim = NULL;
check(crabby_game_run_headless(game, 60, setup, &g, &sim)); // frees `game`
Position final;
check(crabby_get(sim, g.agent, g.position, &final, sizeof(final)));
check(crabby_world_free(sim));
Every call returns CrabbyStatus; on failure crabby_last_error() has the
message (per thread, until the next failed call). Null arguments, wrong
sizes, unknown ids, duplicate query columns and busy worlds are rejected.
Pointers you pass must still be valid: the library can’t recognize a
dangling pointer or a buffer smaller than you say. Callbacks must return
normally; never let a C++ exception or longjmp cross them.
Still Rust-only
C and C++ resources and components are plain structs (no std::string
or std::vector inside), copied in and out. Everything else in this guide
works from C++, and each chapter shows both.
Reflection: components and resources as JSON
Reflection lets code that doesn’t know your types read and change them by name: the agent bridge (MCP), the inspector, and save/load all use it. It’s opt-in per type and built on serde.
#![allow(unused)]
fn main() {
use ponko::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Health { current: f32, max: f32 }
Game::new("Arena", 1280, 720).run(|ctx| {
ctx.world.reflect::<Health>();
let e = ctx.world.spawn((Transform2D::default(), Health { current: 10.0, max: 10.0 }));
// Read: {"current": 10.0, "max": 10.0}
let hp = ctx.world.get_json(e, "Health").unwrap();
// Write only some fields; the rest keep their values.
ctx.world.set_json(e, "Health", json!({ "current": 3.0 })).unwrap();
});
}
Add serde = { version = "1", features = ["derive"] } to your Cargo.toml.
C and C++ components
C++ has no serde, so a C++ component lists its fields with
world.reflect<T>. From then on it is a reflected component like any other:
agents read and write it through ponko-mcp, and saves include it, under
the type’s name (Unit, or game::Unit in a namespace) or the one you pass
first: world.reflect<Unit>("Soldier", {...}).
struct Unit {
float current;
float max;
crabby::vec2 home;
crabby::entity target; // entity ids are uint64_t
bool angry;
};
static void reflect_units(crabby::world_view world) {
// Tools and agents (ponko-mcp) now see {"current", "max", "home", "target", "angry"}.
world.reflect<Unit>({
crabby::field("current", &Unit::current),
crabby::field("max", &Unit::max),
crabby::field("home", &Unit::home),
crabby::field("target", &Unit::target),
crabby::field("angry", &Unit::angry),
});
}
Fields can be float, double, the fixed-width integers, bool,
crabby::vec2 ([x, y] in JSON) and crabby::color ({"r", "g", "b", "a"}).
Entities are uint64_t, which is how Rust saves an Entity too. Bytes
no field covers (padding, fields you leave out) are zero in values made
from JSON, and editing a field leaves them alone.
In C, crabby_component_reflect(world, id, fields, count) takes the same
list as CrabbyField { name, kind, offset }, with offsetof for the
offset. The Rust side of this is World::reflect_fields, for components
that are registered at runtime and have no Rust type.
Resources
Game-wide state (a score, the current round, rules an agent might want to tweak) lives in resources. Reflect them the same way:
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
struct Score { red: u32, blue: u32 }
world.reflect_resource::<Score>();
world.insert_resource(Score { red: 0, blue: 0 });
world.resource_json("Score")?; // {"red": 0, "blue": 0}
world.set_resource_json("Score", json!({ "blue": 3 }))?; // only blue changes
}
struct Teams { std::uint32_t red, blue; };
static void reflect_teams(crabby::world_view world) {
world.reflect_resource<Teams>({crabby::field("red", &Teams::red), crabby::field("blue", &Teams::blue)});
world.set_resource(Teams{0, 0}); // agents: get_resource "Teams" -> {"blue": 0, "red": 0}
}
Reflected resources show up in ponko-mcp (list_resources,
get_resource, set_resource) and are part of world saves.
Resources hold whatever Rust type you like, so the ones that can’t be JSON
(a nav grid, a GPU handle) just stay unreflected. A C++ resource is saved
under its type name with namespaces (game::Score); C names it in
crabby_resource_reflect.
Names
A component’s name is its type name without the module path:
my_game::units::Health is "Health". Reflecting two different types with
the same short name panics and names both, so a name always means one type.
Transform2D, Sprite and Attached are reflected in every game already.
A sprite’s texture is written as the name of its image in assets/
("texture": "crab.png"), and setting one loads the image. Sprites of
textures made in code (create_texture) have no file to name, so they’re
left out of saves.
The methods
All of them come from WorldReflect, which use ponko::*; brings in.
| Method | Does |
|---|---|
reflect::<T>() | makes T visible to reflection |
reflected_components() | names of all reflected components |
get_json(e, name) | one component as JSON |
set_json(e, name, value) | writes it; adds it if missing; objects merge field by field |
remove_json(e, name) | removes it; Ok(true) if it was there |
entity_json(e) | all reflected components of e, keyed by name |
spawn_json(value) | spawns from the entity_json format; on error spawns nothing |
reflect_resource::<R>() | makes resource R visible to reflection |
reflected_resources() | names of all reflected resources |
resource_json(name) | one resource as JSON |
set_resource_json(name, value) | writes it; inserts it if missing; objects merge field by field |
entity_json and spawn_json round-trip, so a snapshot of an entity can be
copied, saved, or sent over the wire:
#![allow(unused)]
fn main() {
let snapshot = world.entity_json(e)?; // {"Transform2D": {...}, "Health": {...}}
let copy = world.spawn_json(snapshot)?;
}
Components that aren’t reflected are left out of entity_json.
Errors
Every call returns a ReflectError that says what went wrong:
UnknownComponent, NoSuchEntity, MissingComponent, UnknownResource,
MissingResource, or BadValue (with serde’s message, such as
invalid type: string "seven", expected struct Health).
Tips
#[serde(default)]on a struct letsset_jsonadd the component from a partial value.Transform2Dhas it.- Reflection is for tools, not hot loops: it goes through JSON. Use queries inside systems.
The inspector: look inside a running game
Press the backquote key (`, left of 1) in a debug build and a panel
opens on the right of the window. It shows the game as it runs:
- Sim controls: play, pause, step one tick, and the simulation speed
(drag the
1.00×box). The tick, frame rate and entity count are below, and a graph of the last 240 frame times (the line marks 60 fps; red bars are slow frames). - The selected entity: each component it has. Reflected components are editable in place: drag numbers, tick booleans, type text (applied when you press Enter or click away). Remove takes a component off, Duplicate copies the entity, Despawn removes it, Look at moves the camera to it. A yellow ring marks it in the world.
- Entities: every entity, with its components. Pick a component under With to list only entities that have it. Click a row to select it, or press Pick and then click a unit in the world.
- Resources: reflected resources, editable the same way.
- Systems: each system’s average time per frame, slowest bars longest.
Sim systems are marked
(sim).
Only reflected components and resources can be read and
edited; others show up by name, marked not reflected. The same reflection
is what agents and save files use, so one reflect call covers all three.
Editing: build levels in the game
To try it: cargo run --example editor, press `, then Pause.
Pause, and the inspector is an editor. Clicks and the editor’s keys in the world go to it, not to the game:
| Do | To |
|---|---|
| Click | Select the sprite under the mouse (the top one), or the nearest entity |
| Drag | Move the selected entity |
| Right or middle drag, wheel | Pan and zoom the camera |
| Delete (or Backspace) | Despawn the selected entity |
| Ctrl+D (⌘D) | Duplicate it, a little down and to the right |
| Ctrl+Z (⌘Z) | Undo: moves, edits, added and removed components, spawns, despawns |
| Esc | Stop placing a prefab |
The Edit section has Undo, New entity (a bare transform in the
middle of the screen), and two rows for files in assets/:
- Scene: save the world to a scene file, or load one (loading clears undo).
- Prefab: type a name and Save the selected entity as a prefab.
Prefabs in
assets/prefabs/are listed below: click one, then click in the world to place it (hold Shift to place several).
Below a selected entity’s components, Add component lists the
reflected components it doesn’t have. A component is added with its
default fields, so types whose fields all have #[serde(default)] add
cleanly; others say which field they need.
Play runs the game and remembers the world; Stop pauses and puts the world back as it was at Play, so you can try a level and return to it. Keep forgets the remembered world. Only reflected components are put back: anything that isn’t reflected isn’t in the snapshot either.
Undoing a despawn brings the entity back as a new entity: handles never come back to life, so other components that pointed at it still point at nothing.
Turning it on and off
The inspector is on by default in debug builds and off in release builds. To ship it in a release build for playtesters:
#![allow(unused)]
fn main() {
Game::new("Playtest", 1280, 720)
.inspector(true) // also in release builds: the ` key opens it
.run(|ctx| {
ctx.world.reflect::<Health>(); // editable in the inspector
});
}
crabby::game("Playtest", 1280, 720)
.inspector(true) // also in release builds: the ` key opens it
.run([](crabby::ctx& ctx) { reflect_units(ctx.world()); });
The environment variable CRABBY_INSPECTOR overrides the game’s choice:
1 turns it on, 0 turns it off, and open turns it on and opens it at
start.
While the inspector is closed it costs nothing: egui doesn’t run and no
input goes to it. While it’s open, clicks, keys and the mouse wheel over
the panel stay in the panel, and ctx.mouse_over_ui() is true there,
so a game that checks it doesn’t also act on clicks in the panel. The
panel isn’t in screenshots taken with ctx.save_screenshot or by agents.
The overlay is built with egui, behind
the inspector cargo feature (on by default). Games that don’t want egui
in their build turn default features off:
ponko = { version = "0.0.1", default-features = false, features = ["window", "audio"] }
C and C++ games get the inspector from ponko_ffi’s inspector feature,
also on by default.
Randomness, determinism and saving
A simulation that comes out the same every time it runs with the same inputs is easy to debug, test and compare: replay a bug, check a balance change against the old version, let an agent run experiments. Crabby simulations are reproducible by default.
Random numbers
Get random numbers in systems from ctx.random():
#![allow(unused)]
fn main() {
fn wander(ctx: &mut Ctx) {
let random = ctx.random();
ctx.world.query::<(Entity, &mut Heading)>().par_for_each(|(e, h)| {
h.0 += random.of(e).range(-0.2, 0.2);
});
}
}
struct Heading { float radians; };
static void wander(crabby::ctx& ctx) {
const crabby::random random = ctx.random();
ctx.world().each_par([&](crabby::entity e, Heading& h) {
h.radians += random.of(e).range(-0.2f, 0.2f);
});
}
random.of(entity)is that entity’s own stream for this tick and system. It doesn’t depend on which thread runs the entity or in what order, so it’s safe inpar_for_each.random.stream(n)is for numbers that don’t belong to an entity (where to spawn food, which event fires).- Both give an
Rngwithf32(),range(lo, hi),below(n),chance(p),angle(),unit_vec2()andu64().
For setup code and tests, Rng::new(seed) makes a generator directly.
C++ has the same generator in the header (crabby::rng, crabby::random),
bit for bit: a C++ system gets exactly the numbers its Rust twin would.
Seeds
Every game has a seed, DEFAULT_SEED unless you set one:
#![allow(unused)]
fn main() {
Game::new("Colony", 1280, 720).seed(42)
}
Same seed, same simulation. For a different world each run, seed from the clock.
What keeps runs the same
The engine guarantees, for a fixed seed:
- systems run in the order they were added, every tick;
- queries visit entities in the same order;
par_for_eachgives the same results as a plain loop, as long as each entity only writes its own components (the borrow rules make you do that);- the spatial grid, spatial sorting and commands are deterministic;
sim_system_everyandctx.turnsdepend only on the tick.
The engine’s own tests run a simulation with all of these at once (threads, grid queries, sorting, reduced rates, spawning, despawning) and check that two runs give byte-identical saves.
Things that break determinism, and what to do instead:
| Don’t | Do |
|---|---|
rand::thread_rng(), time-based randomness in systems | ctx.random() |
frame systems (system) changing simulation state | do it in a sim_system |
iterating a HashMap to decide things | BTreeMap, or sort first |
| reading wall-clock time in sim systems | ctx.sim_tick(), ctx.sim_time() |
Floating point is deterministic on one machine and build. Across CPU
architectures, transcendental functions (sin, exp) can differ in the
last bit.
Saving and loading the world
With reflection, the whole world saves to JSON:
#![allow(unused)]
fn main() {
let save = ctx.world.save(); // serde_json::Value
std::fs::write("save.json", save.to_string())?;
let save: Value = serde_json::from_str(&std::fs::read_to_string("save.json")?)?;
ctx.world.load(&save)?;
}
const std::string save = world.save(); // JSON text; write it to a file
// ...
world.load(save); // every entity back, with its id
- Ids are kept. Loading restores every entity with the id it had, so
components that refer to other entities (
struct Target(Entity)) still point at the right ones.Entityitself saves as its 64-bit id. - All or nothing.
loadchecks every component name and value first; if anything is wrong, the world is left as it was and you get an error saying what. - Only reflected components are saved. The save’s
"not_saved"lists the other components by name and count, so nothing goes missing silently. Reflected resources are saved too (under"resources"); other resources and the sim clock aren’t. C++ components are saved once their fields are described (see Reflection).
Agents can do the same through ponko-mcp (save_world, load_world):
checkpoint, try something, roll back.
Tools and agents: the control endpoint
A running game, windowed or headless, can open a control endpoint that
tools use to look inside it and drive it: AI agents (through
ponko-mcp), the inspector, test scripts. Several instances can run
at once; each gets its own endpoint.
Turning it on
It’s off by default, so shipped games never open a port. Any of these turns it
on (C and C++ games too: game.control_endpoint(true) or the same
environment variables):
#![allow(unused)]
fn main() {
Game::new("Colony", 1280, 720).control_endpoint(true)
}
CRABBY_CONTROL=1 cargo run --release --example boids
CRABBY_CONTROL=1 CRABBY_START_PAUSED=1 HEADLESS=1 cargo run --release --example boids
The environment variable works for every game without code changes.
CRABBY_START_PAUSED=1 starts the simulation paused, so a tool can set
things up and step it tick by tick.
Finding instances
Each instance writes a small JSON file to ~/.crabby/instances/
(%LOCALAPPDATA%\crabby\instances on Windows, or $CRABBY_INSTANCES_DIR)
and removes it on exit:
{
"name": "Crabby Engine: boids",
"pid": 41213,
"port": 50113,
"token": "9f1c…",
"headless": false,
"cwd": "/home/me/my_game",
"exe": "/home/me/my_game/target/release/my_game",
"started": 1793642400,
"engine_version": "1.0.0"
}
A file whose pid is no longer running is stale (the game crashed); tools
skip it.
Security
- The endpoint listens on
127.0.0.1only. - Every request must carry the instance’s
token. The registry file is readable only by your user (mode0600on Unix).
Protocol
TCP, one JSON object per line in each direction.
{"id": 1, "token": "9f1c…", "method": "entities.list", "params": {"with": ["Transform2D"], "limit": 2}}
{"id": 1, "result": {"total": 20000, "offset": 0, "entities": [{"entity": 4294967296, "components": {"Transform2D": {"position": [10.5, 3.0], "rotation": 0.0, "scale": [0.5, 0.5]}}, "other_components": ["Sprite", "Vel"]}, …]}}
Errors come back as {"id": 1, "error": "entity 999 does not exist"}.
Requests are applied by the game loop between frames (between ticks when headless), so they never race the simulation or each other. A paused headless game waits for requests and runs only the ticks you step.
| Method | Params | Returns |
|---|---|---|
methods | every method with a description | |
info | pid, headless, tick, sim time, paused, speed, fps, entity count, render stats (sprites, draw calls, CPU and GPU ms) | |
world.summary | entity count, reflected components and resources, archetypes and sizes | |
entities.list | with?, where?, limit? (100), offset? | entities with reflected components as JSON, names of the others |
entities.stats | fields, with?, where? | {entities, fields: {path: {count, min, max, mean} or {count, values}}} (see below) |
entity.get | entity | {component: value} |
entity.spawn | components | {entity} |
entity.despawn | entity | true if it existed |
component.set | entity, component, value | the new value (object fields merge) |
component.remove | entity, component | true if it had it |
resources.list | {resource: value} for every reflected resource (null if it’s not in the world) | |
resource.get | resource | its value |
resource.set | resource, value | the new value (object fields merge; inserted if missing) |
world.save | path | writes a save; entity and resource counts, bytes, tick |
world.load | path | replaces every entity from a save (same ids; checked first) |
scene.save / scene.load | path | a scene in assets/ (loading also loads sprite images) |
prefab.spawn | path, at | spawns a prefab at [x, y]; returns entity |
prefab.save | entity, path | saves an entity as a prefab in assets/ |
sim.pause / sim.resume | ||
sim.step | ticks? (1) | ticks queued |
sim.speed | speed | |
systems.timings | each system’s last and average ms | |
events.list | reflected event types and how many are kept now | |
events.subscribe | types? (all), capacity? (1000) | starts collecting new events between frames; the newest capacity are kept |
events.take | {events: [{tick, type, data}], dropped} since the last take | |
events.unsubscribe | true if there was a subscription | |
logs.get | lines? (100), since? | {lines, next, missed}: the game’s recent stdout and stderr (see below) |
perf.get | meshes? (0) | where frame time goes, over the last few seconds: fps; avg, p50, p95, p99 and max ms; CPU in systems, render and display wait; GPU ms in total and per pass; draw calls and 3D models and triangles (camera and shadows); the slowest systems; with meshes, the meshes drawing the most triangles (see Profiling a frame) |
perf.trace | path? (a temp file), frames? (120) | records the next frames as a Chrome/Perfetto trace; returns the path, written once the frames are done |
perf.hud | on | shows or hides the performance overlay |
camera.get | position, zoom, center, visible rect | |
camera.set | position?, zoom?, look_at? | move the camera; returns camera.get |
screenshot | path?, max_size? | save the next frame as a PNG (windowed games only); returns path, width, height |
game.quit |
Component values use reflection: call
world.reflect::<T>() for a component, world.reflect_resource::<R>() for
a resource, to make it readable and writable.
Entity ids are the numbers from Entity::to_bits.
Questions about many entities
A simulation with a million units is too big to list. entities.stats
answers questions about all of them at once, from reflected fields:
{"method": "entities.stats", "params": {
"fields": ["Unit.hp", "Transform2D.position", "Unit.team"],
"where": ["Unit.hp < 5"]}}
gives, for the matching entities, count, min, max and mean of
numbers (per axis for vectors like positions) and counts per value for
names and enums ("values": {"Red": 120, "Blue": 98}). A field path is a
component name, then fields, array elements by number:
Transform2D.position.0 is x. A bare component name (Team) is its whole
value.
where takes conditions on field paths with ==, !=, <, <=, >,
>= and a JSON value (a bare word is a string: Unit.team == Red). It
filters entities.list too. On the battle demo, stats over 1M units take
about 0.3 s (release); the game is paused for that long.
Logs
While the endpoint is open, the engine keeps the game’s last 5,000 lines of
stdout and stderr: println!, C and C++ printf and std::cout, panic
messages and the engine’s own notes. The terminal still shows everything.
So a tool can read the output of a game you started yourself, from a
terminal or an IDE. logs.get is answered right away, not between frames,
so it works while a frame is stuck too.
Without since you get the newest lines. Pass the next of one answer as
since in the next request to get only what’s new; missed counts lines
that were dropped before you read them. This works on macOS, Linux and
Windows. A Windows game built as a GUI program (no console) has no output to
keep; logs.get then says it isn’t captured.
Trying it by hand
CRABBY_CONTROL=1 cargo run --release --example boids &
f=$(ls ~/.crabby/instances/*.json | head -1)
port=$(jq .port "$f"); token=$(jq -r .token "$f")
echo "{\"id\":1,\"token\":\"$token\",\"method\":\"info\"}" | nc -q1 127.0.0.1 "$port"
Working with AI agents (MCP)
ponko-mcp is a Model Context Protocol
server. It lets an AI coding agent (Claude Code, Cursor, Codex, …) start
your game, look inside it while it runs, change it, and step the simulation,
instead of guessing from source code and print statements.
It builds on the control endpoint and reflection.
Setup
In this repository it’s already configured: .mcp.json registers the server
for any MCP client that reads project config (Claude Code asks once to
approve it).
For your own game, install the binary and add it to your agent:
cargo install ponko_mcp
claude mcp add ponko -- ponko-mcp # Claude Code
claude mcp add ponko-view -- ponko-mcp --read-only
Other clients take the same command (ponko-mcp, stdio transport) in their
MCP config.
Then make the components you want agents to see reflected:
#![allow(unused)]
fn main() {
ctx.world.reflect::<Health>();
}
What an agent can do
| Tool | Changes the game | What it’s for |
|---|---|---|
list_instances | find running games | |
launch | ✓ | build and start a game or example; headless and paused by default |
instance_info | tick, sim time, paused, speed, fps, entity count, render CPU/GPU ms | |
world_summary | archetypes, entity counts, reflected components | |
list_entities | entities with component values; filter by component names and where conditions (Health.current < 5), paged | |
get_entity | one entity’s components | |
entity_stats | min, max, mean and value counts over every matching entity: “how many units per team, how hurt are they?” (details) | |
list_resources / get_resource | game-wide state: scores, rules, the current round | |
system_timings | which systems are slow | |
perf_report | why frames are slow: fps and frame-time percentiles, CPU vs GPU, GPU time per pass, triangles drawn for the camera and the shadows, the heaviest meshes (details) | |
record_trace | the next N frames as a Perfetto trace, to find hitches | |
perf_hud | show the performance overlay, e.g. before a screenshot | |
list_events | event types the game shows tools, and how many are in flight | |
subscribe_events / take_events | collect events while ticks run, then see what happened (each with its tick) | |
read_logs | recent stdout/stderr of any running game, also one you started yourself (logs); pass next back as since for only new lines | |
screenshot | the next frame of a windowed game, as an image the agent can look at | |
set_camera | move or zoom the camera (look_at, zoom, position), e.g. before a screenshot | |
run_ticks | ✓ | run exactly N ticks of a paused sim and wait |
watch | ✓ | run N ticks and sample every K: tick, resources, entity counts, time per tick |
pause / resume / set_speed | ✓ | sim clock |
spawn_entity / despawn_entity | ✓ | |
set_component / remove_component | ✓ | change values; object fields merge |
set_resource | ✓ | change game-wide state; object fields merge |
save_world | checkpoint the world to a file | |
load_world | ✓ | roll back to a checkpoint (same entity ids) |
save_scene / load_scene | ✓ | write or read a scene in the game’s assets/ |
spawn_prefab / save_prefab | ✓ | place a prefab at a point, or save an entity as one |
quit_instance | ✓ |
--read-only hides every tool marked ✓, for agents that should only watch.
Several agents, several games
Every game with the endpoint on registers itself in ~/.crabby/instances/,
and every ponko-mcp reads that registry. So:
- each agent runs its own
ponko-mcp, and all of them see all games; - every tool takes
instance(the id, pid, port or name fromlist_instances), optional when only one game runs; - the game applies requests between ticks, one at a time, so agents never race the simulation or each other.
A typical split: one agent edits gameplay code and relaunches the game, while a read-only agent watches system timings in a long-running instance.
A session
An agent asked to “find out why boids clump at the edges” might do:
launchwithexample: "boids". The game builds, starts headless and paused, and returns{"id": "6257-57560", "name": "boids", ...}.world_summary: one archetype[Transform2D, Sprite, Vel]with 20,000 entities.run_tickswithticks: 600(10 simulated seconds), thenentity_statswithfields: ["Transform2D.position"]andwhere: ["Transform2D.position.0 < 20"]to count how many are near the left border (andlist_entitieswith the samewhereto look at some).- Edit the steering code,
quit_instance,launchagain, and compare. system_timingsto check the fix didn’t make the tick slower.
Or, without relaunching: save_world at tick 0, set_component to try a
different value on some boids, run_ticks 600, look, then load_world to
roll back and try the next value.
Because the sim only advances on run_ticks, every run is repeatable and the
agent can check results at exact ticks.
To see how a run develops, watch does it in one call. In the battle
example, with its Battle stats reflected:
{"ticks": 3600, "every": 600, "resources": ["Score"], "count": ["Unit"]}
returns seven samples (before, then every 10 simulated seconds), each with
the tick, the Score resource, how many entities have a Unit, and the
wall-clock time per tick, so “who’s winning, and does it get slower as
the armies meet?” is one answer.
To check how things look, launch with headless: false and call
screenshot. It works while the window is hidden behind others, and is
scaled down to 1024 pixels by default (max_size) to keep it cheap to look
at. A screenshot of the ants example after 40 seconds showed food carriers
looping instead of going home; giving them a sense of where home is raised
deliveries in that time from 328 to 1,747.
Making your game agent-friendly
- Reflect the components that matter for gameplay, the resources that
hold game-wide state (
world.reflect_resource::<Score>()), so an agent can ask “who’s winning?” without counting entities, and the events (ctx.events.reflect::<Died>()), so it can see what happened, not only the state afterwards. - Read sizes and seeds from environment variables (like the examples’
BOIDSandHEADLESS), solaunchcan set them throughenv. - Support headless: put game logic in sim systems; it then runs without a window or GPU, which is faster for agents and works on CI.
A reproducible development loop
Start with a small reproduction and record the engine revision, example or
game command, features, seed, input sequence and expected state. Use launch
with a controlled environment, inspect the reflected state, then run_ticks
to a fixed tick and record get_entity/get_resource results. Save a
checkpoint when useful; only reflected/serialized game data can be inspected
or restored through these tools.
After editing code, compile, run the relevant tests and relaunch the same
reproduction. Compare the same state and ticks. For a visual change, use a
windowed instance and record camera, resolution, quality and screenshots;
headless success does not verify rendering. Collect a perf_report or
record_trace when the change affects performance, and preserve comparable
before/after workloads. A screenshot alone does not establish simulation
correctness or a performance improvement.
These tools exist today. A general gameplay-input recording/replay service, structured scheduler inspection, richer shader diagnostics and broader GPU/asset memory reports are not implied by their presence. Follow #145, #153 and #157 for the relevant planned extensions. Reliable autonomous creation of complete games remains a long-term objective, with human review and reproducible evidence still required. See the project vision.
Performance and benchmarks
Numbers from a MacBook Pro (M4 Max), release builds. Reproduce them yourself; every number here has a command next to it.
These are historical measurements made under the Crabby name, retained without rerunning them for the Ponko documentation update. They are specific workload results, not current-version guarantees or proof of overall engine superiority. See the engine comparison and baseline issues #132 and #134 for measurement scope.
ECS iteration
cargo run --release -p ponko_bench
| Benchmark (1M entities) | Crabby | bevy_ecs | hecs |
|---|---|---|---|
iterate (&mut Pos, &Vel) | 0.15 ms | 0.36 ms | 1.77 ms |
par_for_each on a heavy per-entity workload, 10M entities: 8.1 ms on all
cores vs 57.6 ms on one.
Headless simulation
AGENTS=1000000 cargo run --release --example headless
| Agents | ms per tick |
|---|---|
| 1M | 1.0 |
| 10M | 8.7 |
Boids (neighbor search)
HEADLESS=1 BOIDS=<n> cargo run --release --example boids
| Boids | ms per tick |
|---|---|
| 20k | 0.9 |
| 100k | 3.7 |
| 1M | ~33 |
Ant colonies (whole game loop)
HEADLESS=1 ANTS=<n> cargo run --release --example ants: steering with
pheromone sensing, trail laying, foraging, colony growth.
| Ants | ms per tick |
|---|---|
| 5k | 0.22 |
| 50k | 1.2 |
| 1M | 15 |
Flow fields (pathfinding)
cargo run --release -p ponko_bench -- flow for the field itself;
HEADLESS=1 AGENTS=<n> cargo run --release --example flow for units
following it around walls and mud, with a new goal every 2 seconds.
| What | Time |
|---|---|
compute, 256 × 256 cells | 1.25 ms |
compute, 1024 × 1024 cells | 13.0 ms |
1M direction_at lookups, parallel | 0.29 ms |
| 20k units, per tick | 0.14 ms |
| 1M units, per tick | 3.2 ms |
Battle (the flagship demo)
HEADLESS=1 UNITS=<n> TICKS=1200 cargo run --release --example battle:
two armies march along flow fields, keep their spacing, find enemies with
per-team spatial grids, fight, and rebuild from their bases. The map grows
with the army, so the crowd density stays the same.
| Units | Map (cells) | ms per tick |
|---|---|---|
| 20k | 400 × 225 | 0.57 |
| 200k | 1264 × 711 | 4.0 |
| 1M | 2828 × 1590 | 18.9 |
With a window, 20k units run at the display’s 120 FPS with 1.2 ms of simulation per frame.
How it stays fast, in case your game needs the same tricks:
- Marching units look for enemies only on their turn (
ctx.turns(4)), units in a fight every tick. - Separation uses a fine grid of friends, every other tick; enemy search a coarse grid of enemies, at melee reach first and full sight only if nothing is in reach.
- The four grids are rebuilt at the same time (
rayon::scope). - Once a second, units are sorted by
spatial_key, so neighbors on the map are neighbors in memory.
Events and attachments
cargo run --release -p ponko_bench -- events and -- attach:
| What | Per tick |
|---|---|
| 100k events sent, read by another system and dropped | 0.42 ms (4.2 ns per event) |
| 100k attachments following 100k moving entities | 0.66 ms |
Both allocate nothing once running: events reuse their buffers, and attachments are placed in parallel into scratch kept between ticks.
Rendering a million sprites
CRABS=1000000 SCALE=<s> cargo run --release --example bunnymark_ecs:
1M bouncing crabs, drawn by the built-in draw_sprites. The example prints
the render stats described below.
| Crab size on screen | FPS | render CPU | GPU |
|---|---|---|---|
1.6 px (SCALE=0.05) | 120 (display cap) | 3.3 ms | 2.4 ms |
8 px (SCALE=0.25) | 99 | 3.3 ms | 8.6 ms |
32 px (SCALE=1) | 15 | 2.8 ms | 71 ms |
The 32 px row is limited by the GPU filling pixels: on a Retina screen a million 32 px crabs are about 4 billion blended pixels per frame. Fewer or smaller sprites on screen is the only fix for that; the cost per sprite is small (about 1.6 ms of GPU time per million).
What keeps the CPU side small:
draw_spritesbuilds the instance data on all cores (1M sprites: 1.1 ms, was 3.4 ms on one core).- Each sprite is 36 bytes on the GPU (was 52): the per-frame upload for 1M sprites is 36 MB. (A 28-byte version without a texture region measured the same render CPU time; the region is needed for text and atlases.)
Is it the CPU or the GPU?
ctx.render_stats() tells you what drawing the last frame cost:
#![allow(unused)]
fn main() {
use ponko::*;
fn report(ctx: &mut Ctx) {
let r = ctx.render_stats();
println!(
"{} sprites in {} draw calls: {:.2} ms CPU, GPU {:?} ms",
r.sprites, r.draw_calls, r.cpu_ms, r.gpu_ms
);
}
}
gpu_ms is measured on the GPU with timestamp queries, so the frame rate
the window shows (which the compositor can cap or smooth) can’t fool it.
If gpu_ms is close to the frame time, the GPU is the limit: draw fewer
or smaller sprites. If cpu_ms plus your systems’ time
(ctx.system_timings()) is close to it, the CPU is. Agents
get the same numbers from the control endpoint’s info method.
Profiling a frame
(The profiling chapter has the rest: your own timing scopes, counting allocations, headless performance tests with budgets, and what to do about what you find.)
A frame takes about as long as the slower of the CPU (systems, then recording draws) and the GPU. The profiler shows which, and where the time goes, in a running game:
- The overlay (
ctx.set_perf_hud(true), orCRABBY_HUD=1): fps, frame-time percentiles, CPU and GPU time, and the slowest GPU passes. ctx.frame_stats(): the same numbers over the last few seconds: fps, average, p50, p95, p99 and worst frame time, CPU time in systems, in rendering and waiting on the display, and GPU time. Display wait includes pacing; use measured GPU/CPU work to identify the bottleneck.ctx.gpu_passes(): GPU time per pass (each shadow cascade, depth prepass, opaque, transparent, screen effects, bloom, post), measured with timestamp queries. On GPUs that draw in tiles, passes overlap, so each is counted from the end of the one before.ctx.render_stats(): draw calls; 3D models and triangles drawn for the camera and, separately, for the shadow maps; and the render CPU time split into culling (collect_ms), recording (record_ms, all cores) and submitting.ctx.mesh_costs(): per mesh, how many models and triangles it drew for the camera and the shadows last frame, heaviest first.- Traces:
ctx.record_trace(path, frames)(orCRABBY_TRACE=path) writes the next frames as a Chrome trace: every system, rendering, display waits and the GPU passes on a timeline. Open it in Perfetto to see what’s in a hitch.
#![allow(unused)]
fn main() {
/// F3 shows the performance overlay; F4 records the next 300 frames as a
/// trace to open in Perfetto. Slow stretches are logged for investigation.
fn perf_keys(ctx: &mut Ctx) {
if ctx.key_pressed(Key::F3) {
let on = !ctx.perf_hud();
ctx.set_perf_hud(on);
}
if ctx.key_pressed(Key::F4) {
ctx.record_trace("trace.json", 300);
}
let stats = ctx.frame_stats();
if stats.frames > 0 && stats.p99_ms > 20.0 && ctx.frame().is_multiple_of(600) {
// Display wait includes pacing; inspect measured CPU/GPU work separately.
println!("slow: p99 {:.1} ms, gpu {:?} ms", stats.p99_ms, stats.gpu_ms);
for pass in ctx.gpu_passes() {
println!(" {}: {:.2} ms", pass.name, pass.ms);
}
}
}
}
/// F3 shows the performance overlay; F4 records the next 300 frames as a
/// trace to open in Perfetto. Slow stretches are logged for investigation.
static void perf_keys(crabby::ctx& ctx) {
static bool hud = false;
if (ctx.key_pressed(CRABBY_KEY_F3)) ctx.set_perf_hud(hud = !hud);
if (ctx.key_pressed(CRABBY_KEY_F4)) ctx.record_trace("trace.json", 300);
auto stats = ctx.frame_stats();
if (stats.frames > 0 && stats.p99_ms > 20 && ctx.frame() % 600 == 0) {
// Display wait includes pacing; inspect measured CPU/GPU work separately.
std::printf("slow: p99 %.1f ms, gpu %.2f ms\n", stats.p99_ms, stats.gpu_ms);
}
}
Benchmark runs
CRABBY_BENCH=<frames> turns any windowed game into a benchmark: after
CRABBY_BENCH_WARMUP frames (240), it measures that many with vsync off,
prints a report and quits. The report has the frame-time percentiles, the
CPU split, the GPU passes, the slowest systems and the meshes drawing the
most triangles; CRABBY_BENCH_OUT=file.json also writes it as JSON, and
CRABBY_BENCH_MESHES=file.csv writes every mesh’s counts. Frames are
drawn offscreen, so the display’s refresh rate neither caps nor stalls
them (CRABBY_BENCH_PRESENT=1 shows them instead).
CRABBY_BENCH=600 CRABBY_BENCH_OUT=before.json cargo run --release
Run it at a fixed camera before and after a change, and compare.
From an agent
The control endpoint has the same numbers (perf.get,
perf.trace, perf.hud), and the MCP server offers them as
perf_report, record_trace and perf_hud: an agent can ask a running
game why it’s slow, change something, and ask again.
What usually helps in 3D
- Shadows: every caster is drawn again for each shadow cascade it
falls in, also behind the camera. Compare the camera’s triangles with
the shadows’ in
render_stats. Turn shadows off for small things (set_mesh_shadows), and give detailed models a simplerShadowMesh, especially far away. - Distant detail: swap far models for simpler meshes or billboards.
mesh_costsshows which meshes cost the most. - Pixels: on a large Retina screen,
Game::msaa(2)costs about a third less GPU time for the 3D passes than the default 4x.
3D: Crabby vs Unity
The same 3D scene in Crabby and in Unity 6, built the way a normal Unity
game is: one GameObject per cube with a MeshRenderer and a script whose
Update() turns it. Both scenes have:
- N spinning, tinted cubes;
- 12 physically based spheres and a ground;
- a sun with soft shadows (one 2048² shadow map, 60 units);
- sky and ground light and 4x MSAA.
Both run fullscreen at the display’s native resolution with vsync off, on the M4 Max. Each number is the median of three 10-second runs, after a 3-second warmup.
Crabby (left) and Unity 6 (right), 100,000 cubes. Unity’s axes are left-handed, so it sees the field mirrored.
| Spinning cubes | Unity 6 (URP) | Crabby | Crabby’s lead |
|---|---|---|---|
| 1,000 | 728 FPS | 750 FPS | about even |
| 10,000 | 398 FPS | 724 FPS | 1.8x |
| 100,000 | 54 FPS | 402 FPS | 7.5x |
CPU time per frame:
| Spinning cubes | Unity main thread | Crabby (everything but waiting for the display) |
|---|---|---|
| 1,000 | 0.65 ms | 0.17 ms |
| 10,000 | 2.51 ms | 0.43 ms |
| 100,000 | 18.7 ms | 2.48 ms |
Unity’s time goes to the per-object work every GameObject brings: 100k
Update() calls, transform syncing, and culling and batching 100k
renderers. Crabby’s goes to one query that turns every cube and one pass
that culls them, groups them by mesh and uploads them (28 draw calls).
To be fair to Unity:
- SSAO, post-processing and HDR were disabled in this historical comparison. The current engine has additional rendering features.
- The cubes share 64 tinted materials. Giving each its own color, as Crabby does for free, would break Unity’s batching.
- Crabby’s fullscreen frame is 3% smaller: macOS keeps the strip by the camera notch, so it’s 3456 x 2168 against Unity’s 3456 x 2234.
- The Unity player uses Mono, Unity’s default script backend. IL2CPP
would make the
Update()calls somewhat faster. - Each run started 10 seconds after the last one ended, so neither engine inherited the other’s fullscreen exit.
- Unity Entities/DOTS, Jobs and Burst were not measured. The result applies to this GameObject/Mono workload; its performance gap cannot be generalized to Unity’s optimized data-oriented workflows.
Run it yourself:
- Crabby:
CUBES=100000 BENCH_SECONDS=10 cargo run --release --features 3d --example 3d(fullscreen;BENCH_WINDOWED=1keeps the window). - Unity: the project and the steps are in
bench/unity.
On macOS, a game in a window is shown at most once per display refresh
even with vsync off. To measure past 120 FPS, run fullscreen
(Game::fullscreen(true)).
Memory
Speed is half of scale; the other half is what each entity costs. The
headless example prints it: 1M agents with Transform2D, Sprite and a
small Agent component take 76 bytes per entity allocated, spare
capacity included.
From code, world.allocated_bytes() is the total for all component data,
and Archetype::bytes_per_entity() is what one more entity of that exact
component set costs. Agents get both from the control endpoint’s
world.summary (the MCP world_summary tool).
Rules the engine follows
- No heap allocation per frame in steady state on hot paths.
- Components live in tightly packed columns per archetype, so
for_eachcompiles to plain loops the compiler can vectorize. - Performance claims in pull requests come with numbers from release builds.
Tips
- Use
--release. Always. - Prefer
for_each/par_for_eachoveriter()for big queries. - Spawn with
spawn_batch. - Run expensive logic at a reduced rate with
sim_system_every(n, system), or spread per-entity work over ticks withctx.turns(n)(see The simulation clock).
C++
The battle demo in C++ runs at the same speed as the Rust one for 20k units (0.57 ms/tick) and 6% slower for a million (19.97 against 18.85 ms/tick). C++ boids run 1M boids at 34.5 ms/tick headless, against 31.3 ms/tick for the Rust version; plain column updates are the same speed in both (about 0.15 ms per million).
Performance and profiling
Fast code starts with measuring. This chapter is the loop to follow when a game is slow, or before you’ve decided whether it is: measure, read the report, fix the one biggest thing, measure again. Everything here is built in; you need no outside tools. The performance chapter has the engine’s own benchmark numbers and the frame-level tools (the overlay, GPU passes, traces).
1. Measure
A frame takes about as long as the slower of the CPU (your systems, then recording the draws) and the GPU. Three things are on offer, from cheapest to most detailed:
| You want | Use |
|---|---|
| a quick look while playing | the overlay: CRABBY_HUD=1, or ctx.set_perf_hud(true) |
| to know which system or part of it is slow, and what allocates | scopes and ctx.perf_report() |
| a number for a test or CI that fails when things get slower | Game::profile_headless and a Budget |
The overlay and CRABBY_BENCH switch the deeper profiling on by themselves.
In your own code, ctx.set_profiling(true) does. While it is off, a scope
costs a single atomic load and the allocation counter a single load per
allocation, so leaving scopes in shipped code is fine.
Time your own code: scopes
Every system is timed automatically. To see inside one, wrap the parts you suspect:
#![allow(unused)]
fn main() {
/// Time the parts of your own code you suspect.
fn steering(ctx: &mut Ctx) {
profile_scope!("steering");
{
profile_scope!("steering/neighbors");
std::hint::black_box((0..2000).map(|i| (i as f32).sqrt()).sum::<f32>());
}
ctx.profile("steering/avoid", |ctx| {
ctx.world.query::<&mut Transform2D>().for_each(|t| t.position.x += 1.0);
});
}
}
// In one .cpp file, at global scope: count C++ `new` calls in the reports.
CRABBY_COUNT_CPP_ALLOCATIONS
/// Time the parts of your own code you suspect.
static void steering(crabby::ctx& ctx) {
CRABBY_PROFILE_SCOPE("steering");
{
CRABBY_PROFILE_SCOPE("steering/neighbors");
volatile float x = 0;
for (int i = 0; i < 2000; ++i) x = x + std::sqrt(float(i));
}
(void)ctx;
}
profile_scope!("name") (C++: CRABBY_PROFILE_SCOPE("name")) times the rest
of the block. ctx.profile("name", |ctx| ...) times a closure. Scopes nest
(times are inclusive), they work on any thread, and times from several threads
add up. A scope named "steering/neighbors" sorts next to its parent in the
report.
Count allocations
An allocation in code that runs every frame is a common reason for stutter.
To count them, install the counting allocator once, in your main.rs:
#![allow(unused)]
fn main() {
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
}
// Put this once at global scope in one .cpp file (see the scopes example above):
CRABBY_COUNT_CPP_ALLOCATIONS
Counting happens only while profiling is on. The report then says how many
allocations each frame and each system makes. In C and C++ the engine’s own
allocations are counted too; CRABBY_COUNT_CPP_ALLOCATIONS adds your new
calls. (A C game can call crabby_note_allocation(bytes) from its own
allocator.)
In a running game
#![allow(unused)]
fn main() {
/// F5 starts timing your scopes and counting allocations; F6 prints where
/// the last half minute went.
fn perf_keys(ctx: &mut Ctx) {
if ctx.key_pressed(Key::F5) {
let on = !ctx.profiling();
ctx.set_profiling(on);
}
if ctx.key_pressed(Key::F6) {
println!("{}", ctx.perf_report());
}
}
}
/// F5 starts timing your scopes and counting allocations; F6 prints where
/// the last half minute went.
static void perf_report_keys(crabby::ctx& ctx) {
static bool on = false;
if (ctx.key_pressed(CRABBY_KEY_F5)) ctx.set_profiling(on = !on);
if (ctx.key_pressed(CRABBY_KEY_F6)) std::printf("%s", ctx.perf_report().text().c_str());
}
Or without changing the game: CRABBY_BENCH=600 cargo run --release plays a
fixed run offscreen and prints the same report at the end
(CRABBY_BENCH_REPORT=now.json also saves it); the
MCP server’s perf_report tool takes profiling=true for an
agent to do the same on a running game.
In a test or CI, with no window
#![allow(unused)]
fn main() {
/// Run it headless for 200 ticks (after 20 warm-up ticks), then read the
/// report and fail the test if it's too slow. Keep time limits generous:
/// machines differ.
#[test]
fn steering_stays_within_budget() {
let (_world, report) = Game::new("bench", 1, 1)
.sim_system(steering)
.profile_headless(20, 200, |ctx| {
for _ in 0..100 {
ctx.world.spawn((Transform2D::default(),));
}
});
println!("{report}");
report.assert_within(
&Budget::new()
.frame_p99_ms(50.0)
.system("steering", 10.0)
.scope("steering/neighbors", 10.0),
);
}
}
/// Run the game headless for 200 ticks (after 20 warm-up ticks) and keep it
/// within budget. Call it from a test or a CI job; keep time limits generous.
static void steering_stays_within_budget() {
auto run = crabby::game("bench", 1, 1)
.sim_system(steering, "steering")
.profile_headless(20, 200, [](crabby::ctx&) {});
std::printf("%s", run.report.text().c_str());
std::string broken = run.report.check(R"({
"frame_p99_ms": 50,
"systems": {"steering": 10},
"scopes": {"steering/neighbors": 10}
})");
if (!broken.empty()) throw std::runtime_error("over budget:\n" + broken);
}
profile_headless(warmup, ticks, setup) runs the simulation like
run_headless, but times every tick and returns the report with the world.
The first warmup ticks aren’t counted: buffers grow and caches fill.
2. Read the report
println!("{report}") prints this (shortened):
== performance report: 200 frames, 100 entities ==
frame ms: avg 0.052 p50 0.049 p95 0.061 p99 0.090 max 0.412
allocations per frame: 3.0 (0.2 KB), at most 3
-- systems (avg ms, max ms, allocs/frame) --
0.047 0.390 3.0 my_game::steering
-- scopes (avg ms, max ms, calls/frame) --
0.040 0.380 1.0 steering
0.030 0.350 1.0 steering/neighbors
-- slowest frames --
frame 143: 0.41 ms, 3 allocations [my_game::steering 0.39, steering 0.38, steering/neighbors 0.35]
-- warnings --
! `my_game::steering` allocates 3 times per frame
-> Keep the buffer between frames instead: a Vec in a resource ...
frame ms: the average says how fast it is, p99 and max say how smooth. A p99 much higher than p50 is stutter: the average hides it.- systems: where the CPU time goes, slowest first. A system’s time covers everything it did, scopes and allocations included. Scopes sit inside systems; don’t add them up with the systems.
- allocs/frame: anything above zero in per-frame code deserves a look. Counts, unlike times, are the same on every machine.
- slowest frames: what was running in the worst frames. If these share a system or scope, that’s your hitch.
- render (windowed games): CPU time in systems and drawing, GPU time, draw calls and triangles for the camera and the shadows, and GPU time per pass. Display wait is idle time, not work.
- warnings: the report spots common problems (an allocating system, one system taking over half the time, spikes, GPU or CPU bound, heavy shadows, thousands of draw calls) and says what to try.
The same report is data: report.systems, report.scopes,
report.frame_ms.p99, report.allocs and so on (C++: parse
report.json()).
3. Budgets and comparing runs
A budget turns “it should stay fast” into a failing test. Limits are optional; anything you leave out isn’t checked:
#![allow(unused)]
fn main() {
use ponko::*;
let report = PerfReport::default();
let budget = Budget::new()
.frame_p99_ms(4.0)
.allocs_per_frame(0.0)
.system("steering", 0.5)
.scope("steering/neighbors", 0.3);
report.assert_within(&budget); // panics, listing what broke
}
std::string broken = report.check(R"({
"frame_p99_ms": 4, "allocs_per_frame": 0,
"systems": {"steering": 0.5},
"scopes": {"steering/neighbors": 0.3}
})");
if (!broken.empty()) throw std::runtime_error(broken);
Timings differ between machines and runs, so give time limits headroom (twice
what you see on your machine), or use them to catch only big regressions.
Allocation counts are exact: allocs_per_frame(0.0) on a system that
should never allocate is the budget to rely on.
To catch regressions without picking numbers, keep a report from a good run and compare against it:
#![allow(unused)]
fn main() {
use ponko::*;
let (_w, report) = Game::new("t", 1, 1).profile_headless(0, 5, |_| {});
report.save("target/perf-now.json").unwrap();
if let Ok(before) = PerfReport::load("perf-baseline.json") {
for slower in report.regressions(&before, 0.25) {
println!("slower: {slower}"); // e.g. system `my_game::steering` (ms): 0.047 -> 0.081
}
}
}
(C++: perf_report::save, load and regressions.) A value counts when it
grew by more than the tolerance and by a little in absolute terms, so noise in
tiny numbers is ignored.
4. Common fixes
Allocates every frame. Keep buffers between frames: a Vec held in a
resource (or captured by the system), clear()ed and refilled. Avoid
format!, to_string, collect, Box and Vec::new + push in code that
runs each tick. C++: reuse a std::vector and reserve.
One system dominates. Wrap its halves in scopes to see which. Then, in
order of effort: iterate with for_each / par_for_each instead of
iter() (ECS); do the work for a quarter of the entities per tick
with ctx.turns(4); run it less often with sim_system_every
(The simulation clock); use a spatial grid or flow field
instead of checking every pair (Spatial queries).
Spikes. Look at slowest frames: the same system each time usually means
work that sometimes does a lot (a path search, a rebuild, spawning a wave).
Spread it over ticks, or do it ahead of time. Allocation bursts, asset loads
and spawning thousands of entities at once show up the same way.
ctx.record_trace(path, frames) shows a slow frame on a timeline in
Perfetto.
GPU bound (GPU time close to the frame time, display wait large). The
pass list says which pass. Shadows: fewer casters, a simpler
ShadowMesh, set_mesh_shadows(false) for small things. Pixels:
Game::msaa(2), fewer or smaller things drawn. Triangles: simpler meshes far
away. Many draw calls: pack sprites into an atlas, share meshes and
materials.
CPU bound in drawing. Compare draw calls and models with what’s really visible; cull and merge what you can.
When something is still slow after this: sample <pid> 5 on macOS or a
profiler for the native code, or --features profiling with
puffin_viewer for a live
flame graph of the systems.
What isn’t measured yet
- GPU memory use and per-mesh memory.
- Rust allocations on other threads are counted for the whole process, so a system is charged for what rayon workers allocate during it.
- Scopes are process-wide: two games running side by side in one process (only tests do that) would see each other’s scopes.
API reference
Every public item has rustdoc with an example. Build the reference locally:
cargo doc --workspace --no-deps --open
The crates:
-
ponko: the engine. Start here;use ponko::*;brings in everything a game needs. -
ponko_ecs: the ECS on its own, including the C-compatible path (register_component,for_each_chunk) for other languages. -
ponko_ffi: the C ABI and safety contracts. The C and C++ guide covers building, examples and the C++ wrapper.
Contributing
Start with CONTRIBUTING.md.
The detailed maintainer/agent integration checklist is in
AGENTS.md.
In short:
- Work on a branch, open a pull request to
main, wait for CI. - Add relevant tests for changed behavior and helpful nearby API comments. You do not need a doctest for every item or unrelated local toolchains.
- Performance claims need release-build numbers in the PR.
- Rust-only changes are welcome; guide chapters and C/C++ bindings are not required from every contributor. Maintainers coordinate that coverage and own follow-ups. Preserve existing compatibility and describe behavior and tests.
Editing this book
The book is plain Markdown in book/src/, built with
mdBook:
cargo install mdbook --locked
mdbook serve book --open
Every page has an edit button (top right) that opens the file on GitHub.
License
Ponko is dual licensed under MIT or Apache-2.0, at your option. Contributions are accepted under the same terms.