Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+299
@@ -0,0 +1,299 @@
|
||||
# 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::<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:
|
||||
|
||||
```rust
|
||||
# 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` `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::<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 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
|
||||
Reference in New Issue
Block a user