Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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});
}
RustC++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 namekg/m³ (default 1000), 0 ice..1 rubber, bounciness
.sensor().sensor = truereports overlaps, doesn’t push
.offset(v), .groups(g).offset, .groupsshape 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);
}
FieldDefault
height, radius1.8, 0.3feet to head; the capsule’s width
max_slope45°steeper ground is a wall
step_height0.35 m0: no stepping
snap_distance0.3 mhow far down it follows the ground
push_mass80 kg0: doesn’t push
gravity_scale10: floats
groupsallwhat 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 fieldDefault
positionthe wheel’s center at full compression, in the chassis’s frame
radius, mass0.5 m, 20 kgthe mass sets how fast torque spins it up
suspension_distance0.3 mhow far the suspension travels, straight down the chassis’s -Y
spring, damper35 000 N/m, 4 500 N·s/m
target_position0.5where it rests under the car’s weight: 0 compressed, 1 extended
force_app_point_distance0forces act this far up from the resting wheel’s bottom; higher rolls less
damping_rate0.25slows the spin, N·m per rad/s
forward_friction, sideways_frictionFrictionCurve::FORWARD, ::SIDEWAYSgrip 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 stepsTotalPer step, over the terrain alone
11.9 ms5 µs
105.9 ms22 µs
10046.7 ms185 µ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_sphere and cast_capsule: sweep a shape (a wheel, a character) and get the first thing it would touch.
  • overlap_sphere, overlap_box and overlap_capsule: every entity inside, into out.
  • apply_impulse(e, impulse) and apply_impulse_at(e, impulse, point): a push in kg × m/s (multiply by mass(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 ticksCrabbyrapier alone
1,00030.4 ms24.5 ms
10,000440 ms389 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 ticksTimePer step
100218 ms0.9 ms
1,0002.45 s9.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 most max_new a step, so a big area doesn’t arrive in one step.
  • It stays until it’s beyond keep_radius from 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 whether e’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 side up points to. A ball thrown up from below passes through the platform and lands on top of it. up is 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 from up and 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 at velocity, 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 with ctx.physics3d.set_contact_hook(...) (ctx.set_contact_hook3d(...) in C++; a null function clears it). The hook gets a ContactPair3D: the two entities (a and b), the contact normal() and its points, and it can ignore() the whole pair, drop one remove_point(i), change set_friction and set_restitution, or set_surface_velocity in 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.