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:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+299
View File
@@ -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