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 f56a1eea3b
128 changed files with 40493 additions and 2 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "oxide-physics"
description = "Oxide 3D game engine — physics module (rapier3d)"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
oxide-engine = { path = "../engine" }
glam.workspace = true
hecs.workspace = true
serde.workspace = true
ron.workspace = true
thiserror.workspace = true
log.workspace = true
# The physics backend. rapier3d bundles its own nalgebra (`rapier3d::na`); the
# module converts at the boundary so the rest of the engine stays on `glam`.
rapier3d = "0.33"
[dev-dependencies]
ron.workspace = true
+159
View File
@@ -0,0 +1,159 @@
//! The [`RigidBody`] component and its [`RigidBodyKind`].
//!
//! A rigid body is the dynamics half of a physics object: it says *how* an
//! entity moves under the simulation (or that it does not). The *shape* it
//! collides with is a separate [`Collider`](crate::Collider) component, so the
//! two concerns compose — a body with no collider is a point mass, a collider
//! with no body is static world geometry.
//!
//! Like every Oxide component these are plain serializable data with
//! `#[derive(Reflect)]`, so they are editable from the inspector and from
//! scripts with no per-type editor code. The simulation reads them when it
//! builds the rapier world; the
//! authored fields here are inputs, never live simulation state (velocities and
//! poses live on the [`Transform`](oxide_engine::math::Transform) and inside
//! rapier).
use oxide_engine::reflect::{Reflect, ReflectEnum};
use serde::{Deserialize, Serialize};
/// How a [`RigidBody`] participates in the simulation.
///
/// Mirrors rapier's body types, named for engine users:
/// - [`Dynamic`](Self::Dynamic) — fully simulated; moved by gravity, forces,
/// and contacts.
/// - [`Kinematic`](Self::Kinematic) — moved only by the game (its
/// [`Transform`](oxide_engine::math::Transform)); pushes dynamic bodies but is
/// never pushed back. The basis for the character controller (Stage 9 later
/// piece) and for moving platforms.
/// - [`Static`](Self::Static) — never moves; immovable world geometry (floors,
/// walls). The cheapest body — no integration cost.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ReflectEnum)]
pub enum RigidBodyKind {
/// Fully simulated — gravity, forces, and contacts move it.
#[default]
Dynamic,
/// Moved only by the game; affects dynamics but is not affected by them.
Kinematic,
/// Immovable world geometry.
Static,
}
impl RigidBodyKind {
/// Every kind, for editor menus.
pub const ALL: [RigidBodyKind; 3] = [
RigidBodyKind::Dynamic,
RigidBodyKind::Kinematic,
RigidBodyKind::Static,
];
/// A human-readable label.
pub fn label(self) -> &'static str {
match self {
RigidBodyKind::Dynamic => "Dynamic",
RigidBodyKind::Kinematic => "Kinematic",
RigidBodyKind::Static => "Static",
}
}
}
/// Component: the dynamics of a physics object.
///
/// Attach alongside a [`Collider`](crate::Collider) to make an entity take part
/// in the simulation. The fields are authoring inputs consumed when the rapier
/// body is created; runtime velocity/pose are not stored here (the
/// [`Transform`](oxide_engine::math::Transform) is the authoritative pose, which
/// the simulation writes back each step).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
pub struct RigidBody {
/// How the body participates in the simulation.
pub kind: RigidBodyKind,
/// Mass in kilograms. Ignored for non-dynamic bodies. `0` means "derive the
/// mass from the collider's [`density`](crate::Collider::density)".
pub mass: f32,
/// Linear velocity damping (drag), `0` = none. Slows translation over time.
pub linear_damping: f32,
/// Angular velocity damping, `0` = none. Slows rotation over time.
pub angular_damping: f32,
/// Per-body multiplier on global gravity (`1` = normal, `0` = floats,
/// negative = anti-gravity). Lets some bodies ignore gravity individually.
pub gravity_scale: f32,
/// Enable continuous collision detection — prevents fast bodies tunnelling
/// through thin geometry, at extra cost. Off by default.
pub ccd: bool,
}
impl Default for RigidBody {
fn default() -> Self {
Self {
kind: RigidBodyKind::Dynamic,
// 0 = derive mass from collider density (rapier's default behaviour).
mass: 0.0,
linear_damping: 0.0,
angular_damping: 0.0,
gravity_scale: 1.0,
ccd: false,
}
}
}
impl RigidBody {
/// A static (immovable) body — convenience for world geometry.
pub fn static_body() -> Self {
Self {
kind: RigidBodyKind::Static,
..Self::default()
}
}
/// A kinematic body — moved by the game, not by the simulation.
pub fn kinematic() -> Self {
Self {
kind: RigidBodyKind::Kinematic,
..Self::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_a_dynamic_body() {
let b = RigidBody::default();
assert_eq!(b.kind, RigidBodyKind::Dynamic);
assert_eq!(b.gravity_scale, 1.0);
assert!(!b.ccd);
}
#[test]
fn constructors_set_the_kind() {
assert_eq!(RigidBody::static_body().kind, RigidBodyKind::Static);
assert_eq!(RigidBody::kinematic().kind, RigidBodyKind::Kinematic);
}
#[test]
fn round_trips_through_ron() {
let b = RigidBody {
kind: RigidBodyKind::Kinematic,
mass: 2.5,
linear_damping: 0.1,
angular_damping: 0.2,
gravity_scale: 0.0,
ccd: true,
};
let ron = ron::to_string(&b).unwrap();
let back: RigidBody = ron::from_str(&ron).unwrap();
assert_eq!(b, back);
}
#[test]
fn kind_variants_are_reflected_for_a_combo() {
// The editor lists these in a dropdown via ReflectEnum.
assert_eq!(
RigidBodyKind::variants(),
&["Dynamic", "Kinematic", "Static"]
);
}
}
+101
View File
@@ -0,0 +1,101 @@
//! The [`CharacterController`] component — a kinematic capsule character.
//!
//! A character controller is *not* a rigid body: it never reacts to forces.
//! Instead the game asks it to move by some desired translation each frame and
//! the controller resolves that against the world's colliders — sliding along
//! walls, stepping up small ledges, sticking to the ground on slopes, and
//! refusing to climb anything too steep — then reports whether the character
//! ended up grounded. It is the basis for player/NPC movement and is reused by
//! the Stage-15 prototyping kit.
//!
//! The character is a **capsule** (radius + cylindrical half-height along `+Y`).
//! An entity with this component is moved through
//! [`PhysicsWorld::move_character`](crate::PhysicsWorld::move_character); it does
//! **not** carry a [`RigidBody`](crate::RigidBody)/[`Collider`](crate::Collider)
//! (it isn't simulated), so it never appears in the body set and never
//! self-collides.
use oxide_engine::math::Vec3;
use oxide_engine::reflect::Reflect;
use serde::{Deserialize, Serialize};
/// Component: a kinematic capsule character moved by move-and-slide.
///
/// Tune the capsule shape (`radius`/`half_height`) and the movement rules
/// (`max_slope_degrees`, `step_offset`, `snap_to_ground`, `skin_width`). Pass an
/// entity carrying this to
/// [`PhysicsWorld::move_character`](crate::PhysicsWorld::move_character).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
pub struct CharacterController {
/// Capsule radius.
pub radius: f32,
/// Capsule cylindrical half-height (the straight segment between the two
/// hemispherical caps), along local `+Y`.
pub half_height: f32,
/// Steepest ground slope (degrees from horizontal) the character will walk
/// up; steeper surfaces are treated as walls.
pub max_slope_degrees: f32,
/// Tallest step/ledge the character will automatically climb. `0` disables
/// auto-stepping.
pub step_offset: f32,
/// Distance below the feet within which the character snaps down to stay
/// grounded (prevents bouncing down stairs/slopes). `0` disables snapping.
pub snap_to_ground: f32,
/// A small collision-detection margin kept around the capsule.
pub skin_width: f32,
}
impl Default for CharacterController {
fn default() -> Self {
Self {
radius: 0.3,
half_height: 0.6,
max_slope_degrees: 45.0,
step_offset: 0.3,
snap_to_ground: 0.2,
skin_width: 0.01,
}
}
}
impl CharacterController {
/// The total half-height of the capsule (cylinder half-height + one radius),
/// i.e. the distance from the capsule center to either tip.
pub fn total_half_height(&self) -> f32 {
self.half_height + self.radius
}
/// The local-space offset from the capsule center down to the feet (the
/// bottom tip), useful for placing/grounding the character.
pub fn foot_offset(&self) -> Vec3 {
Vec3::new(0.0, -self.total_half_height(), 0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_a_reasonable_humanoid_capsule() {
let c = CharacterController::default();
assert_eq!(c.radius, 0.3);
assert!((c.total_half_height() - 0.9).abs() < 1e-6);
assert!((c.foot_offset() - Vec3::new(0.0, -0.9, 0.0)).length() < 1e-6);
}
#[test]
fn round_trips_through_ron() {
let c = CharacterController {
radius: 0.4,
half_height: 1.0,
max_slope_degrees: 50.0,
step_offset: 0.0,
snap_to_ground: 0.0,
skin_width: 0.02,
};
let ron = ron::to_string(&c).unwrap();
let back: CharacterController = ron::from_str(&ron).unwrap();
assert_eq!(c, back);
}
}
+210
View File
@@ -0,0 +1,210 @@
//! The [`Collider`] component and its [`ColliderShape`].
//!
//! A collider is the *shape* half of a physics object: the geometry the
//! simulation tests for contact, plus the material (friction/restitution) and
//! the [`LayerMask`] filtering that decides *what it collides with*. Paired with
//! a [`RigidBody`](crate::RigidBody) it becomes a moving body; on its own it is
//! static world geometry.
//!
//! The shape is modelled as a flat selector + dimension fields rather than a
//! data-carrying enum, mirroring [`MeshRenderer`]'s `PrimitiveShape` — that
//! keeps every field an individually-reflectable scalar so the inspector renders
//! a clean combo + drag-values with no per-type editor code. Convex-hull and
//! triangle-mesh colliders (which need mesh data) are a later Stage-9 piece.
//!
//! [`MeshRenderer`]: oxide_engine::render::MeshRenderer
use oxide_engine::layer::LayerMask;
use oxide_engine::math::Vec3;
use oxide_engine::reflect::{Reflect, ReflectEnum};
use serde::{Deserialize, Serialize};
/// The primitive shape of a [`Collider`].
///
/// Which dimension fields on the [`Collider`] are read depends on the shape:
/// - [`Box`](Self::Box) — `half_extents` (per-axis half sizes).
/// - [`Sphere`](Self::Sphere) — `radius`.
/// - [`Capsule`](Self::Capsule) — `radius` + `half_height` (the cylindrical
/// segment half-length, capped by hemispheres; axis is local `+Y`).
/// - [`Cylinder`](Self::Cylinder) — `radius` + `half_height` (axis is local
/// `+Y`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ReflectEnum)]
pub enum ColliderShape {
/// Axis-aligned box; uses `half_extents`.
#[default]
Box,
/// Sphere; uses `radius`.
Sphere,
/// Capsule along local `+Y`; uses `radius` + `half_height`.
Capsule,
/// Cylinder along local `+Y`; uses `radius` + `half_height`.
Cylinder,
}
impl ColliderShape {
/// Every shape, for editor menus.
pub const ALL: [ColliderShape; 4] = [
ColliderShape::Box,
ColliderShape::Sphere,
ColliderShape::Capsule,
ColliderShape::Cylinder,
];
/// A human-readable label.
pub fn label(self) -> &'static str {
match self {
ColliderShape::Box => "Box",
ColliderShape::Sphere => "Sphere",
ColliderShape::Capsule => "Capsule",
ColliderShape::Cylinder => "Cylinder",
}
}
}
/// Component: the collision shape and material of a physics object.
///
/// Attach on its own for static world geometry, or alongside a
/// [`RigidBody`](crate::RigidBody) for a moving body. `membership`/`filter` are
/// the engine's shared [`LayerMask`] primitive (Stage 5), so collision groups,
/// triggers, and scene queries all filter the same way.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)]
pub struct Collider {
/// The primitive shape; selects which dimension fields below are used.
pub shape: ColliderShape,
/// Half-extents for [`ColliderShape::Box`] (per-axis half sizes).
pub half_extents: Vec3,
/// Radius for sphere / capsule / cylinder shapes.
pub radius: f32,
/// Half-height for capsule / cylinder shapes (along local `+Y`).
pub half_height: f32,
/// Coulomb friction coefficient (`0` = frictionless, ~`0.5` typical).
pub friction: f32,
/// Bounciness, `0` = inelastic, `1` = fully elastic.
pub restitution: f32,
/// Mass density (kg/m³-ish); used to derive a dynamic body's mass when its
/// [`mass`](crate::RigidBody::mass) is `0`.
pub density: f32,
/// When `true` the collider is a **sensor (trigger)**: it reports overlap
/// (enter/stay/exit events) without resolving contact, so things pass
/// through it.
pub sensor: bool,
/// The layers this collider **belongs to** — what it *is*, for the other
/// side's filter to select.
pub membership: LayerMask,
/// The layers this collider **collides with** — what it *cares about*. Two
/// colliders interact only when each one's membership intersects the
/// other's filter.
pub filter: LayerMask,
}
impl Default for Collider {
fn default() -> Self {
Self {
shape: ColliderShape::Box,
// A unit cube (matches the default MeshRenderer primitive).
half_extents: Vec3::splat(0.5),
radius: 0.5,
half_height: 0.5,
friction: 0.5,
restitution: 0.0,
density: 1.0,
sensor: false,
// Belong to layer 0 ("Default") and collide with everything, so a
// freshly-added collider interacts out of the box.
membership: LayerMask::layer(0),
filter: LayerMask::ALL,
}
}
}
impl Collider {
/// A box collider with the given per-axis half-extents.
pub fn cuboid(half_extents: Vec3) -> Self {
Self {
shape: ColliderShape::Box,
half_extents,
..Self::default()
}
}
/// A sphere collider of the given radius.
pub fn ball(radius: f32) -> Self {
Self {
shape: ColliderShape::Sphere,
radius,
..Self::default()
}
}
/// A capsule collider (radius + cylindrical half-height along `+Y`).
pub fn capsule(radius: f32, half_height: f32) -> Self {
Self {
shape: ColliderShape::Capsule,
radius,
half_height,
..Self::default()
}
}
/// This collider made a sensor (trigger).
pub fn as_sensor(mut self) -> Self {
self.sensor = true;
self
}
/// This collider on the given membership/filter masks.
pub fn with_layers(mut self, membership: LayerMask, filter: LayerMask) -> Self {
self.membership = membership;
self.filter = filter;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_a_unit_box_that_collides_with_everything() {
let c = Collider::default();
assert_eq!(c.shape, ColliderShape::Box);
assert_eq!(c.half_extents, Vec3::splat(0.5));
assert!(!c.sensor);
assert_eq!(c.membership, LayerMask::layer(0));
assert_eq!(c.filter, LayerMask::ALL);
}
#[test]
fn constructors_pick_the_shape() {
assert_eq!(Collider::ball(2.0).shape, ColliderShape::Sphere);
assert_eq!(Collider::ball(2.0).radius, 2.0);
assert_eq!(Collider::capsule(0.3, 0.8).shape, ColliderShape::Capsule);
assert!(Collider::default().as_sensor().sensor);
}
#[test]
fn with_layers_sets_masks() {
let c = Collider::default().with_layers(LayerMask::layer(2), LayerMask::layer(3));
assert_eq!(c.membership, LayerMask::layer(2));
assert_eq!(c.filter, LayerMask::layer(3));
}
#[test]
fn round_trips_through_ron() {
let c = Collider::capsule(0.4, 1.0).as_sensor().with_layers(
LayerMask::layer(1),
LayerMask::layer(2).union(LayerMask::layer(5)),
);
let ron = ron::to_string(&c).unwrap();
let back: Collider = ron::from_str(&ron).unwrap();
assert_eq!(c, back);
}
#[test]
fn shape_variants_are_reflected_for_a_combo() {
assert_eq!(
ColliderShape::variants(),
&["Box", "Sphere", "Capsule", "Cylinder"]
);
}
}
+38
View File
@@ -0,0 +1,38 @@
//! Oxide Engine — physics module (`oxide-physics`).
//!
//! Stage 9 adds rigid-body physics — collision, queries, constraints, and a
//! character controller — as a **feature-gated module** built on
//! [`rapier3d`](https://rapier.rs), not a thin wrapper. It plugs into the engine
//! through the Stage-5 module system: add [`PhysicsModule`] to an
//! [`App`](oxide_engine::app::App) and physics components become live.
//!
//! The design keeps the **ECS as the source of truth**: an entity describes its
//! physics with two serializable, reflected components —
//! - [`RigidBody`] — *how* it moves (dynamic / kinematic / static, mass,
//! damping, gravity scale, CCD), and
//! - [`Collider`] — *what 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, so play-mode snapshot/restore (Stage 8.7) works for free: Stop
//! reverts the authored components and the next Play rebuilds the world fresh.
//!
//! [`LayerMask`]: oxide_engine::layer::LayerMask
//!
//! This first piece lands the component data model and the module wiring; the
//! rapier-backed simulation, scene queries, joints, and the character controller
//! follow in subsequent pieces.
#![deny(warnings)]
mod body;
mod character;
mod collider;
mod module;
mod world;
pub use body::{RigidBody, RigidBodyKind};
pub use character::CharacterController;
pub use collider::{Collider, ColliderShape};
pub use module::{PhysicsModule, PhysicsSettings, DEFAULT_GRAVITY};
pub use world::{CharacterMovement, CollisionEvent, JointId, JointKind, PhysicsWorld, RayHit};
+110
View File
@@ -0,0 +1,110 @@
//! The [`PhysicsModule`] — the Stage-9 entry point that wires physics into an
//! [`App`].
//!
//! Following the engine's module convention, everything physics contributes is
//! registered here so it can be enabled, disabled, or removed as a unit (and so
//! an exported game that never touches physics never compiles it in). This piece
//! registers the component *types* for reflection (making [`RigidBody`] and
//! [`Collider`] dual-editable and captured by the play-mode snapshot) and
//! installs the global [`PhysicsSettings`]. The simulation system itself is
//! added in a later piece.
use oxide_engine::app::{App, Module, Schedule};
use oxide_engine::math::Vec3;
use crate::world::step_physics;
use crate::{CharacterController, Collider, PhysicsWorld, RigidBody};
/// Earth-like gravity (m/s²) along `-Y` — the default for a new physics world.
pub const DEFAULT_GRAVITY: Vec3 = Vec3::new(0.0, -9.81, 0.0);
/// Project-wide physics tuning, stored as an [`App`] resource.
///
/// Kept tiny and serializable so it can grow into a settings page; for now it
/// carries the global gravity vector the simulation integrates dynamic bodies
/// against (each body can still scale it via
/// [`RigidBody::gravity_scale`](crate::RigidBody::gravity_scale)).
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PhysicsSettings {
/// Global gravity acceleration applied to dynamic bodies.
pub gravity: Vec3,
}
impl Default for PhysicsSettings {
fn default() -> Self {
Self {
gravity: DEFAULT_GRAVITY,
}
}
}
/// The physics module. Add it after [`DefaultModules`] to give an app a
/// rapier-backed simulation.
///
/// [`DefaultModules`]: oxide_engine::app::DefaultModules
///
/// ```
/// use oxide_engine::app::App;
/// use oxide_physics::PhysicsModule;
///
/// let mut app = App::new();
/// app.add_module(PhysicsModule);
/// assert!(app.has_module("physics"));
/// assert!(app.types.is_registered("RigidBody"));
/// ```
pub struct PhysicsModule;
impl Module for PhysicsModule {
fn name(&self) -> &'static str {
"physics"
}
fn build(&self, app: &mut App) {
// Register the component types for reflection so they round-trip through
// RON (scripts/AI) and are captured by the play-mode scene snapshot.
app.register_type::<RigidBody>("RigidBody");
app.register_type::<Collider>("Collider");
app.register_type::<CharacterController>("CharacterController");
// The global gravity setting; the simulation reads it each fixed step.
app.insert_resource(PhysicsSettings::default());
// The transient rapier world (rebuilt from components) and the
// fixed-timestep system that syncs, steps, and writes poses back.
app.insert_resource(PhysicsWorld::new());
app.add_system(Schedule::FixedUpdate, step_physics);
}
}
#[cfg(test)]
mod tests {
use super::*;
use oxide_engine::app::DefaultModules;
#[test]
fn module_registers_component_types() {
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(PhysicsModule);
assert!(app.has_module("physics"));
assert!(app.types.is_registered("RigidBody"));
assert!(app.types.is_registered("Collider"));
assert_eq!(
app.get_resource::<PhysicsSettings>().map(|s| s.gravity),
Some(DEFAULT_GRAVITY)
);
}
#[test]
fn removing_the_module_drops_its_types() {
let mut app = App::new();
app.add_module(PhysicsModule);
assert!(app.types.is_registered("Collider"));
assert!(app.remove_module("physics"));
assert!(!app.has_module("physics"));
assert!(!app.types.is_registered("RigidBody"));
assert!(!app.types.is_registered("Collider"));
}
}
+1497
View File
File diff suppressed because it is too large Load Diff