1
Physics
Jaroslav Beneš edited this page 2026-08-08 18:11:58 +02:00

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:

  • RigidBodyhow it moves (or that it doesn't).
  • Colliderwhat shape it is, its material, and the LayerMask filtering 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:

  1. syncs the rapier world to the scene (inserts a body+collider for each new physics entity, removes bodies for despawned ones, pushes kinematic targets),
  2. steps rapier by one fixed_delta, then
  3. 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)

  1. Component data model + module wiringRigidBody, Collider, PhysicsModule, PhysicsSettings.
  2. Rapier-backed simulation — build the world from components, step on FixedUpdate, write transforms back, by-entity forces/velocities.
  3. Collision groups/masks via LayerMask, sensors, collision/trigger events (enter/exit + stay queries).
  4. Scene queries — raycast, sphere-cast, sphere/point overlap, all LayerMask-filtered.
  5. Joints/constraints — fixed, spherical, revolute, prismatic (programmatic API).
  6. Kinematic character controller — capsule move-and-slide, step offset, slope limit, grounded.
  7. Examplesphysics_stack (boxes settle + ball lands) and character_capsule (walk/climb/jump/wall), headless console demos. (this piece)
  8. Editor: (8a) RigidBody/Collider/CharacterController are addable and editable in the inspector (no per-type code), and PhysicsModule runs in Play — drop a body, watch it fall, Stop reverts it (on dev, 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 ignores Transform::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 (via PhysicsWorld::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.