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.