Physics (oxide-physics) — Stage 9
Oxide's physics is a feature-gated module (oxide-physics) built on
rapier3d, added to an app through the Stage-5 module
system. It is a comprehensive rigid-body system — collision, scene queries,
joints, and a kinematic character controller — not a thin wrapper. This document
grows piece by piece as Stage 9 lands; it currently covers the data model and
module wiring (piece 1).
Design: the ECS is the source of truth
A physics object is described by two plain, serializable, reflected components on a scene entity:
RigidBody— how it moves (or that it doesn't).Collider— what shape it is, its material, and theLayerMaskfiltering of what it collides with.
The rapier simulation world is a transient resource rebuilt from these
components, never the authoritative store. That has a deliberate payoff: the
Stage-8.7 play-mode snapshot captures these components like any
other, so Play runs the simulation, Stop reverts the authored components,
and the next Play rebuilds the rapier world fresh — with no special-casing.
The Transform is the authoritative pose; the simulation writes it
back each fixed step (a later piece).
Adding physics to an app
use oxide_engine::app::{App, DefaultModules};
use oxide_physics::PhysicsModule;
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(PhysicsModule); // registers RigidBody/Collider + PhysicsSettings
PhysicsModule registers the component types for reflection (so they are
dual-editable from the inspector and from scripts/RON, and captured by the play
snapshot) and inserts a PhysicsSettings resource holding the global gravity
vector. Like every module it can be enabled, disabled, or removed as a unit, so a
game that never uses physics never compiles it in.
RigidBody
The dynamics half of a physics object — attach alongside a Collider.
| Field | Meaning |
|---|---|
kind |
Dynamic (simulated), Kinematic (game-moved, unaffected by forces), or Static (immovable world geometry) |
mass |
Mass in kg; 0 derives it from the collider's density |
linear_damping / angular_damping |
Velocity drag (0 = none) |
gravity_scale |
Per-body gravity multiplier (1 normal, 0 floats) |
ccd |
Continuous collision detection for fast bodies (off by default) |
use oxide_physics::{RigidBody, RigidBodyKind};
let dynamic = RigidBody::default(); // a fully-simulated body
let floor = RigidBody::static_body(); // immovable
let platform = RigidBody::kinematic(); // moved by the game
Collider
The shape + material half — attach on its own for static geometry, or with a
RigidBody for a moving body. The shape is a flat selector plus dimension fields
(mirroring MeshRenderer's PrimitiveShape), so the inspector renders a clean
combo + drag-values:
shape |
Dimensions used |
|---|---|
Box |
half_extents (per-axis half sizes) |
Sphere |
radius |
Capsule |
radius + half_height (axis = local +Y) |
Cylinder |
radius + half_height (axis = local +Y) |
Material/filter fields: friction, restitution, density, sensor (a trigger
that reports overlap without resolving contact), and the membership / filter
LayerMasks. Two colliders interact only when each one's
membership intersects the other's filter, so collision groups, triggers, and
(later) scene queries all use the engine's one shared filtering primitive.
use oxide_engine::math::Vec3;
use oxide_engine::layer::LayerMask;
use oxide_physics::Collider;
let ground = Collider::cuboid(Vec3::new(10.0, 0.5, 10.0));
let ball = Collider::ball(0.5);
let trigger = Collider::cuboid(Vec3::splat(1.0)).as_sensor()
.with_layers(LayerMask::layer(0), LayerMask::layer(1)); // only fires for layer 1
Convex-hull and triangle-mesh colliders (which need mesh data) are a later Stage-9 piece.
Simulation
PhysicsModule installs a PhysicsWorld resource — the rapier simulation plus
an entity ↔ body map — and a FixedUpdate system that, each fixed step:
- syncs the rapier world to the scene (inserts a body+collider for each new physics entity, removes bodies for despawned ones, pushes kinematic targets),
- steps rapier by one
fixed_delta, then - writes back each moved body's pose onto its entity's
Transform.
So physics runs whenever the app advances its fixed timestep — including the editor's Play mode, which is the first real consumer (Play drops a body, Stop reverts it). Nothing extra is wired: the play snapshot already captures the components.
# use oxide_engine::app::{App, DefaultModules};
# use oxide_engine::math::{Transform, Vec3};
# use oxide_physics::{PhysicsModule, RigidBody, Collider};
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(PhysicsModule);
let ball = app.scene.spawn("ball", Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)));
app.scene.world_mut().insert_one(ball, RigidBody::default()).unwrap();
app.scene.world_mut().insert_one(ball, Collider::ball(0.5)).unwrap();
for _ in 0..120 { app.step(); } // one fixed tick each
// the ball has fallen; its Transform.translation.y is now lower
Forces & control
PhysicsWorld exposes by-entity control so game code never touches rapier
handles: set_linear_velocity / linear_velocity, apply_impulse,
apply_force, apply_torque_impulse, and wake. Reach the resource with
app.get_resource_mut::<PhysicsWorld>().
Collision & trigger events
When two colliders start or stop touching, the step records a CollisionEvent
{ a, b, started, sensor }. started distinguishes enter (true) from
exit (false); sensor distinguishes a trigger overlap (one collider is
a sensor — nothing was resolved, things pass through) from a solid contact.
Events accumulate across every fixed sub-step and are cleared at the first step
of each frame, so a system reading after Update sees them all:
# use oxide_physics::PhysicsWorld;
# fn read(app: &oxide_engine::app::App, player: oxide_engine::scene::Entity) {
let physics = app.get_resource::<PhysicsWorld>().unwrap();
for ev in physics.trigger_events() {
if let Some(other) = ev.other(player) {
if ev.started { /* player entered a trigger zone */ }
}
}
# }
"Stay" (ongoing overlap) is not an event — query the current state with
is_intersecting(a, b) or intersecting_pairs(). Collision filtering uses the
collider membership/filter LayerMasks: two colliders interact only when
each one's membership intersects the other's filter, so a trigger can be made to
fire only for, say, the Player layer.
Scene queries
PhysicsWorld answers spatial questions against the simulated colliders, all
filtered by a LayerMask (pass LayerMask::ALL to hit anything):
| Query | Returns |
|---|---|
raycast(origin, dir, max_distance, mask) |
first RayHit { entity, toi, point, normal } |
sphere_cast(origin, radius, dir, max_distance, mask) |
first hit of a swept sphere (a "thick raycast") |
overlap_sphere(center, radius, mask) |
every entity whose collider overlaps the sphere |
point_overlap(point, mask) |
every entity whose collider contains the point |
# use oxide_engine::math::Vec3;
# use oxide_engine::layer::LayerMask;
# use oxide_physics::PhysicsWorld;
# fn pick(physics: &PhysicsWorld) {
if let Some(hit) = physics.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL) {
// hit.entity / hit.point / hit.normal
}
# }
During simulation the step keeps the query world in sync. To query the edited
scene outside Play (e.g. the editor's raycast probe), build a transient world and
sync it once: sync_to_scene(scene) rebuilds the rapier world from the scene's
Collider/RigidBody components and refreshes the query pipeline without
stepping, so a following raycast/overlap_* reflects the current colliders.
# use oxide_engine::math::Vec3;
# use oxide_engine::layer::LayerMask;
# use oxide_engine::scene::Scene;
# use oxide_physics::PhysicsWorld;
# fn probe(scene: &Scene) {
let mut world = PhysicsWorld::new();
world.sync_to_scene(scene); // query-able, no Play / no step
let _ = world.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL);
# }
Joints / constraints
add_joint(a, b, kind, anchor_a, anchor_b) connects two entities' bodies and
returns a JointId (remove_joint undoes it). Both bodies must already be in
the simulation (true after the first step in which their components exist). The
JointKinds and the DOF they leave:
| Kind | Constraint |
|---|---|
Fixed |
a rigid weld — zero relative DOF |
Spherical |
ball-and-socket — anchors stay coincident, free rotation (3 rot. DOF) |
Revolute { axis } |
hinge — 1 rotational DOF about axis |
Prismatic { axis } |
slider — 1 translational DOF along axis |
Joints live in the PhysicsWorld (not the ECS), so — unlike components — they
are not captured by the play snapshot; game/setup code recreates them on each
Play. Editor-authored joint components await a serializable entity-reference
type (backlog).
Character controller
A CharacterController component is a kinematic capsule — it never reacts to
forces. The game asks it to move and the controller resolves that against the
world (move-and-slide along walls, auto-step small ledges, snap to ground on
slopes, refuse slopes steeper than max_slope_degrees), reporting whether the
character ended up grounded. The entity carries only a CharacterController (no
RigidBody/Collider), so it is never simulated and never self-collides.
# use oxide_engine::math::Vec3;
# use oxide_physics::PhysicsWorld;
# fn tick(app: &mut oxide_engine::app::App, player: oxide_engine::scene::Entity, dt: f32) {
let desired = Vec3::new(input_x, -9.81 * dt, input_z); // move + gravity
let movement = {
let physics = app.get_resource::<PhysicsWorld>().unwrap();
physics.move_character(&app.scene, player, desired, dt).unwrap()
};
let mut t = app.scene.local_transform(player).unwrap();
t.translation += movement.translation; // apply the resolved move
app.scene.set_local_transform(player, t);
// movement.grounded → e.g. allow jump
# let (input_x, input_z) = (0.0, 0.0);
# }
Tune the capsule (radius, half_height) and the rules (max_slope_degrees,
step_offset, snap_to_ground, skin_width) on the component. This is the
basis reused by the Stage-15 prototyping kit's character.
Limitations (current)
Transform::scale is not yet applied to collider dimensions (author the shape at
its true size), and bodies are simulated in world space — keep physics bodies at
the scene root (or under unscaled parents) for now. Both are lifted in later
pieces.
Roadmap (Stage 9 pieces)
- Component data model + module wiring —
RigidBody,Collider,PhysicsModule,PhysicsSettings. ✅ - Rapier-backed simulation — build the world from components, step on
FixedUpdate, write transforms back, by-entity forces/velocities. ✅ - Collision groups/masks via
LayerMask, sensors, collision/trigger events (enter/exit + stay queries). ✅ - Scene queries — raycast, sphere-cast, sphere/point overlap, all
LayerMask-filtered. ✅ - Joints/constraints — fixed, spherical, revolute, prismatic (programmatic API). ✅
- Kinematic character controller — capsule move-and-slide, step offset, slope limit, grounded. ✅
- Examples —
physics_stack(boxes settle + ball lands) andcharacter_capsule(walk/climb/jump/wall), headless console demos. ✅ (this piece) - Editor: (8a) RigidBody/Collider/CharacterController are addable and
editable in the inspector (no per-type code), and
PhysicsModuleruns in Play — drop a body, watch it fall, Stop reverts it (ondev, awaiting eye-check). (8b) collider shape wireframe gizmos in the viewport — box, sphere, capsule, and cylinder outlines, green for solid colliders and amber for sensors (triggers), with the selected entity drawn thicker. The outline mirrors the simulation, which ignoresTransform::scale, so it shows the exact shape rapier builds (translation + rotation only). Toggle it from View ▸ Show Colliders (on by default). (8c) raycast debug viz — a View ▸ Raycast Probe toggle (off by default, a debug aid to verify the raycast API and inspect colliders): with it on, a viewport click casts the editor camera→cursor ray against the edited scene's colliders (viaPhysicsWorld::sync_to_scene, so it needs no Play) and freezes the ray into the world. The viewport then redraws it every frame — the ray in cyan, plus on a hit a magenta dot at the surface point and a short whisker along the surface normal — so orbiting the camera reveals it as a real 3D line (a ray cast from the live camera is otherwise just a point in that same camera's view). ✅
Stage 9 is complete — all pieces are on main.