# Physics (`oxide-physics`) — Stage 9 Oxide's physics is a **feature-gated module** (`oxide-physics`) built on [`rapier3d`](https://rapier.rs), 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`](#rigidbody) — *how* it moves (or that it doesn't). - [`Collider`](#collider) — *what shape* it is, its material, and the [`LayerMask`](layers.md) 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](play-mode.md) 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`](scene.md) is the authoritative pose; the simulation writes it back each fixed step (a later piece). ## Adding physics to an app ```rust 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) | ```rust 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` [`LayerMask`](layers.md)s. 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. ```rust 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`](modules.md), 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. ```rust # 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::()`. ### 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: ```rust # use oxide_physics::PhysicsWorld; # fn read(app: &oxide_engine::app::App, player: oxide_engine::scene::Entity) { let physics = app.get_resource::().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` `LayerMask`s: 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 | ```rust # 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. ```rust # 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 `JointKind`s 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. ```rust # 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::().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 wiring** — `RigidBody`, `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. **Examples** — `physics_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`](#scene-queries), 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`. [`PhysicsSettings`]: #adding-physics-to-an-app [`PhysicsWorld`]: #simulation