//! The [`PhysicsWorld`] resource and the fixed-timestep simulation step. //! //! This is the bridge between the engine's ECS (the source of truth) and the //! rapier simulation. The rapier state lives in a [`PhysicsWorld`] resource //! rebuilt from the [`RigidBody`]/[`Collider`] components; each fixed step the //! [`step_physics`] system: //! //! 1. **syncs** — inserts a rapier body+collider for every new physics entity, //! removes bodies for despawned ones, and pushes kinematic targets, //! 2. **steps** the rapier pipeline by one [`fixed_delta`](oxide_engine::app::Time::fixed_delta), //! 3. **writes back** each moved body's pose onto its entity's //! [`Transform`](oxide_engine::math::Transform). //! //! Because the ECS components are authoritative and rapier is transient, the //! Stage-8.7 play-mode snapshot captures physics for free: Stop restores the //! authored components and the next Play rebuilds the world. //! //! ## Conversions & limitations (this piece) //! //! rapier 0.33 uses its own (glam-backed) math types; this module converts at //! the boundary by components so the engine stays on its own `glam`. Two //! simplifications land with the first simulation piece and are lifted later: //! **`Transform::scale` is ignored** (the collider uses its authored //! dimensions), and **non-root bodies** write back through their parent's world //! transform but author their shapes in world space — keep physics bodies at the //! scene root or unscaled for now. use std::collections::{HashMap, HashSet}; use oxide_engine::app::App; use oxide_engine::math::{Quat, Transform, Vec3}; use oxide_engine::scene::{Entity, Scene}; use rapier3d::control::{CharacterAutostep, CharacterLength, KinematicCharacterController}; use rapier3d::math::{Pose, Rotation, Vector}; use rapier3d::parry::query::ShapeCastOptions; use rapier3d::parry::shape::{Ball, Capsule}; use rapier3d::prelude::{ ActiveEvents, ChannelEventCollector, ColliderBuilder, ColliderHandle, CollisionEvent as RapierCollisionEvent, FixedJointBuilder, Group, ImpulseJointHandle, InteractionGroups, InteractionTestMode, PhysicsWorld as RapierWorld, PrismaticJointBuilder, QueryFilter, Ray, RevoluteJointBuilder, RigidBodyBuilder, RigidBodyHandle, SphericalJointBuilder, }; use oxide_engine::layer::LayerMask; use crate::{ CharacterController, Collider, ColliderShape, PhysicsSettings, RigidBody, RigidBodyKind, DEFAULT_GRAVITY, }; /// The collision-resolved result of a [`PhysicsWorld::move_character`] call. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CharacterMovement { /// The actual translation to apply, after sliding along walls, stepping up /// ledges, and snapping to the ground (may be shorter than the request). pub translation: Vec3, /// Whether the character is standing on walkable ground after the move. pub grounded: bool, } /// Which kind of constraint a joint imposes between two bodies. /// /// Each anchors a point on body A to a point on body B and removes some degrees /// of freedom: /// - [`Fixed`](Self::Fixed) — welds the bodies: no relative motion at all. /// - [`Spherical`](Self::Spherical) — ball-and-socket: the anchor points stay /// coincident, but the bodies may rotate freely about it (3 rotational DOF). /// - [`Revolute`](Self::Revolute) — hinge: like spherical but rotation is locked /// to a single `axis` (1 rotational DOF). /// - [`Prismatic`](Self::Prismatic) — slider: the bodies may only translate /// relative to each other along `axis` (1 translational DOF). #[derive(Debug, Clone, Copy, PartialEq)] pub enum JointKind { /// A rigid weld — zero relative DOF. Fixed, /// A ball-and-socket joint — 3 rotational DOF about the anchor. Spherical, /// A hinge about `axis` (in body A's local frame). Revolute { axis: Vec3 }, /// A slider along `axis` (in body A's local frame). Prismatic { axis: Vec3 }, } /// An opaque handle to a joint created with [`PhysicsWorld::add_joint`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct JointId(ImpulseJointHandle); /// The result of a successful scene query (raycast or shape-cast). #[derive(Debug, Clone, Copy, PartialEq)] pub struct RayHit { /// The entity whose collider was hit. pub entity: Entity, /// Distance along the (normalized) ray/sweep direction to the hit. pub toi: f32, /// The world-space hit point. pub point: Vec3, /// The world-space surface normal at the hit. pub normal: Vec3, } /// One collision/overlap transition surfaced to game code for the current frame. /// /// Reported when two colliders **start** or **stop** touching. A `sensor` event /// is a trigger overlap (one collider is a sensor — no contact was resolved); a /// non-`sensor` event is a solid contact. The pair is unordered. "Stay" (ongoing /// overlap) is not an event — query it with /// [`PhysicsWorld::is_intersecting`] / [`PhysicsWorld::intersecting_pairs`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CollisionEvent { /// One of the two entities involved. pub a: Entity, /// The other entity. pub b: Entity, /// `true` = the pair began touching (enter); `false` = stopped (exit). pub started: bool, /// `true` = a sensor/trigger overlap; `false` = a solid contact. pub sensor: bool, } impl CollisionEvent { /// The other entity in the pair, given one of them (or `None` if `entity` /// is not part of this event). pub fn other(&self, entity: Entity) -> Option { if self.a == entity { Some(self.b) } else if self.b == entity { Some(self.a) } else { None } } } // --- glam <-> rapier conversions (by component; the crates' glam versions // differ, so we never rely on type identity) --------------------------- fn to_vec(v: Vec3) -> Vector { Vector::new(v.x, v.y, v.z) } fn from_vec(v: Vector) -> Vec3 { Vec3::new(v.x, v.y, v.z) } fn to_quat(q: Quat) -> Rotation { Rotation::from_xyzw(q.x, q.y, q.z, q.w) } fn from_quat(q: Rotation) -> Quat { Quat::from_xyzw(q.x, q.y, q.z, q.w) } fn to_pose(t: &Transform) -> Pose { Pose::from_parts(to_vec(t.translation), to_quat(t.rotation)) } /// The rapier simulation, plus the entity ↔ handle mapping. /// /// Installed as an [`App`] resource by /// [`PhysicsModule`](crate::PhysicsModule); driven each fixed step by /// [`step_physics`]. Public control methods (forces, velocities, sleep/wake) /// operate by [`Entity`] so game code never handles rapier types. pub struct PhysicsWorld { world: RapierWorld, entity_to_body: HashMap, body_to_entity: HashMap, entity_to_collider: HashMap, collider_to_entity: HashMap, /// Collision/trigger transitions for the current frame (accumulated across /// every fixed sub-step, cleared at the first step of each frame). events: Vec, /// The frame the `events` buffer was last cleared for. events_frame: u64, } impl Default for PhysicsWorld { fn default() -> Self { Self::new() } } impl PhysicsWorld { /// A new, empty physics world. pub fn new() -> Self { Self { world: RapierWorld::new(), entity_to_body: HashMap::new(), body_to_entity: HashMap::new(), entity_to_collider: HashMap::new(), collider_to_entity: HashMap::new(), events: Vec::new(), events_frame: 0, } } /// The rapier body handle backing `entity`, if it is in the simulation. /// Exposed for the joint system (a later piece) and advanced callers. pub fn body_handle(&self, entity: Entity) -> Option { self.entity_to_body.get(&entity).copied() } /// The number of bodies currently in the simulation. pub fn body_count(&self) -> usize { self.entity_to_body.len() } // --- Collision / trigger events ---------------------------------------- /// Every collision/trigger transition (enter/exit) from the current frame. pub fn collision_events(&self) -> &[CollisionEvent] { &self.events } /// The trigger (sensor) overlap transitions from the current frame. pub fn trigger_events(&self) -> impl Iterator { self.events.iter().filter(|e| e.sensor) } /// The solid-contact transitions from the current frame. pub fn contact_events(&self) -> impl Iterator { self.events.iter().filter(|e| !e.sensor) } /// Whether two entities' colliders are currently intersecting (the "stay" /// state between an enter and an exit event). Works for both sensor overlaps /// and solid contacts. pub fn is_intersecting(&self, a: Entity, b: Entity) -> bool { let (Some(&ha), Some(&hb)) = ( self.entity_to_collider.get(&a), self.entity_to_collider.get(&b), ) else { return false; }; self.world.intersection_pair(ha, hb).unwrap_or(false) || self .world .contact_pair(ha, hb) .is_some_and(|p| p.has_any_active_contact()) } /// All entity pairs whose sensor colliders are currently overlapping. pub fn intersecting_pairs(&self) -> Vec<(Entity, Entity)> { self.world .intersection_pairs() .filter(|(_, _, _, _, intersecting)| *intersecting) .filter_map(|(h1, _, h2, _, _)| { Some(( *self.collider_to_entity.get(&h1)?, *self.collider_to_entity.get(&h2)?, )) }) .collect() } // --- Scene queries ----------------------------------------------------- /// The [`InteractionGroups`] for a query that should hit colliders on any /// layer in `mask`. The query "belongs to" every layer and filters by /// `mask`, so it selects colliders whose membership intersects `mask`. fn query_groups(mask: LayerMask) -> InteractionGroups { InteractionGroups::new( Group::all(), Group::from_bits_retain(mask.bits()), InteractionTestMode::And, ) } /// Casts a ray and returns the first collider hit on a layer in `mask`. /// /// `dir` need not be normalized; `max_distance` is measured in world units. /// Pass [`LayerMask::ALL`] to hit anything. pub fn raycast( &self, origin: Vec3, dir: Vec3, max_distance: f32, mask: LayerMask, ) -> Option { let dir = dir.normalize_or_zero(); if dir == Vec3::ZERO { return None; } let ray = Ray::new(to_vec(origin), to_vec(dir)); let filter = QueryFilter::new().groups(Self::query_groups(mask)); let (handle, intersection) = self.world .cast_ray_and_get_normal(&ray, max_distance, true, filter)?; let entity = *self.collider_to_entity.get(&handle)?; Some(RayHit { entity, toi: intersection.time_of_impact, point: origin + dir * intersection.time_of_impact, normal: from_vec(intersection.normal), }) } /// Sweeps a sphere of `radius` from `origin` along `dir` and returns the /// first collider hit on a layer in `mask` (a "thick raycast"). pub fn sphere_cast( &self, origin: Vec3, radius: f32, dir: Vec3, max_distance: f32, mask: LayerMask, ) -> Option { let dir = dir.normalize_or_zero(); if dir == Vec3::ZERO { return None; } let shape = Ball::new(radius); let shape_pos = Pose::from_parts(to_vec(origin), to_quat(Quat::IDENTITY)); let options = ShapeCastOptions::with_max_time_of_impact(max_distance); let filter = QueryFilter::new().groups(Self::query_groups(mask)); let (handle, hit) = self.world .cast_shape(&shape_pos, to_vec(dir), &shape, options, filter)?; let entity = *self.collider_to_entity.get(&handle)?; Some(RayHit { entity, toi: hit.time_of_impact, point: from_vec(hit.witness1), normal: from_vec(hit.normal1), }) } /// Every entity whose collider overlaps a sphere at `center` (on a layer in /// `mask`) — an overlap/proximity query. pub fn overlap_sphere(&self, center: Vec3, radius: f32, mask: LayerMask) -> Vec { let shape = Ball::new(radius); let pose = Pose::from_parts(to_vec(center), to_quat(Quat::IDENTITY)); let filter = QueryFilter::new().groups(Self::query_groups(mask)); self.world .intersect_shape(pose, &shape, filter) .filter_map(|(h, _)| self.collider_to_entity.get(&h).copied()) .collect() } /// Every entity whose collider contains `point` (on a layer in `mask`) — a /// point/containment query. pub fn point_overlap(&self, point: Vec3, mask: LayerMask) -> Vec { let filter = QueryFilter::new().groups(Self::query_groups(mask)); self.world .intersect_point(to_vec(point), filter) .filter_map(|(h, _)| self.collider_to_entity.get(&h).copied()) .collect() } // --- Joints / constraints ---------------------------------------------- /// Connects two entities' bodies with a joint and returns its handle. /// /// `anchor_a` / `anchor_b` are the attachment points in each body's local /// frame. Both entities must already be in the simulation (they are after /// the first step in which their components exist); returns `None` if either /// has no body yet. Joints created this way live in the [`PhysicsWorld`] /// (not the ECS), so they are recreated by game/setup code on each Play /// rather than restored from the scene snapshot. pub fn add_joint( &mut self, a: Entity, b: Entity, kind: JointKind, anchor_a: Vec3, anchor_b: Vec3, ) -> Option { let ha = self.entity_to_body.get(&a).copied()?; let hb = self.entity_to_body.get(&b).copied()?; let (aa, ab) = (to_vec(anchor_a), to_vec(anchor_b)); let handle = match kind { JointKind::Fixed => self.world.insert_impulse_joint( ha, hb, FixedJointBuilder::new().local_anchor1(aa).local_anchor2(ab), ), JointKind::Spherical => self.world.insert_impulse_joint( ha, hb, SphericalJointBuilder::new() .local_anchor1(aa) .local_anchor2(ab), ), JointKind::Revolute { axis } => self.world.insert_impulse_joint( ha, hb, RevoluteJointBuilder::new(to_vec(axis.normalize_or_zero())) .local_anchor1(aa) .local_anchor2(ab), ), JointKind::Prismatic { axis } => self.world.insert_impulse_joint( ha, hb, PrismaticJointBuilder::new(to_vec(axis.normalize_or_zero())) .local_anchor1(aa) .local_anchor2(ab), ), }; Some(JointId(handle)) } /// Removes a previously created joint. No-op if it was already removed. pub fn remove_joint(&mut self, joint: JointId) { self.world.remove_impulse_joint(joint.0); } /// The number of joints currently in the simulation. pub fn joint_count(&self) -> usize { self.world.impulse_joints().count() } // --- Character controller ---------------------------------------------- /// Resolves a desired move for a kinematic capsule character against the /// world, returning the collision-corrected translation and grounded state. /// /// `entity` must carry a [`CharacterController`] component; its capsule is /// taken from there and its start pose from its world /// [`Transform`](oxide_engine::math::Transform). The character is **not** in /// the body set, so it never self-collides. The caller applies the returned /// [`translation`](CharacterMovement::translation) to the entity (typically /// `scene.set_local_transform`). Returns `None` if the entity has no /// controller. pub fn move_character( &self, scene: &Scene, entity: Entity, desired: Vec3, dt: f32, ) -> Option { let cc = *scene.get::(entity)?; let world_t = scene.world_transform(entity)?; let shape = Capsule::new_y(cc.half_height, cc.radius); let mut controller = KinematicCharacterController { offset: CharacterLength::Absolute(cc.skin_width.max(1.0e-3)), max_slope_climb_angle: cc.max_slope_degrees.to_radians(), ..KinematicCharacterController::default() }; controller.autostep = (cc.step_offset > 0.0).then_some(CharacterAutostep { max_height: CharacterLength::Absolute(cc.step_offset), min_width: CharacterLength::Absolute(cc.radius * 0.5), include_dynamic_bodies: false, }); controller.snap_to_ground = (cc.snap_to_ground > 0.0).then_some(CharacterLength::Absolute(cc.snap_to_ground)); let pose = to_pose(&world_t); let queries = self.world.query_pipeline(); let movement = controller.move_shape(dt, &queries, &shape, &pose, to_vec(desired), |_| {}); Some(CharacterMovement { translation: from_vec(movement.translation), grounded: movement.grounded, }) } // --- Forces & control (by entity) -------------------------------------- /// Sets a body's linear velocity (m/s), waking it. pub fn set_linear_velocity(&mut self, entity: Entity, v: Vec3) { if let Some(b) = self.body_mut(entity) { b.set_linvel(to_vec(v), true); } } /// The body's current linear velocity, or zero if it has none. pub fn linear_velocity(&self, entity: Entity) -> Vec3 { self.body(entity) .map(|b| from_vec(b.linvel())) .unwrap_or(Vec3::ZERO) } /// Applies a one-shot impulse (mass·velocity) at the body's center, waking it. pub fn apply_impulse(&mut self, entity: Entity, impulse: Vec3) { if let Some(b) = self.body_mut(entity) { b.apply_impulse(to_vec(impulse), true); } } /// Applies a continuous force at the body's center (cleared each step), waking it. pub fn apply_force(&mut self, entity: Entity, force: Vec3) { if let Some(b) = self.body_mut(entity) { b.add_force(to_vec(force), true); } } /// Applies a one-shot angular impulse, waking the body. pub fn apply_torque_impulse(&mut self, entity: Entity, torque: Vec3) { if let Some(b) = self.body_mut(entity) { b.apply_torque_impulse(to_vec(torque), true); } } /// Wakes (or, with `false`, allows to sleep) a body. pub fn wake(&mut self, entity: Entity, strong: bool) { if let Some(h) = self.entity_to_body.get(&entity).copied() { self.world.wake_up(h, strong); } } fn body(&self, entity: Entity) -> Option<&rapier3d::dynamics::RigidBody> { self.entity_to_body .get(&entity) .and_then(|h| self.world.bodies.get(*h)) } fn body_mut(&mut self, entity: Entity) -> Option<&mut rapier3d::dynamics::RigidBody> { match self.entity_to_body.get(&entity).copied() { Some(h) => self.world.bodies.get_mut(h), None => None, } } // --- The fixed step ---------------------------------------------------- /// Syncs the rapier world to the scene, steps once, and writes poses back. fn sync_and_step(&mut self, app: &mut App) { let gravity = app .get_resource::() .map(|s| s.gravity) .unwrap_or(DEFAULT_GRAVITY); self.world.gravity = to_vec(gravity); self.world.integration_parameters.dt = app.time.fixed_delta; // Clear the event buffer once per frame, then accumulate across every // fixed sub-step so a consumer reading after Update sees them all. if self.events_frame != app.time.frame { self.events.clear(); self.events_frame = app.time.frame; } self.sync_bodies(&app.scene); self.step_and_collect_events(); self.write_back(&mut app.scene); } /// Steps the rapier pipeline, draining collision/trigger events into the /// frame buffer (translated from collider handles to entities). fn step_and_collect_events(&mut self) { let (collision_tx, collision_rx) = std::sync::mpsc::channel(); let (force_tx, _force_rx) = std::sync::mpsc::channel(); let collector = ChannelEventCollector::new(collision_tx, force_tx); self.world.step_with_events(&(), &collector); drop(collector); // close the senders so the iterator terminates for ev in collision_rx.try_iter() { let started = ev.started(); let sensor = ev.sensor(); let (h1, h2) = match ev { RapierCollisionEvent::Started(a, b, _) | RapierCollisionEvent::Stopped(a, b, _) => { (a, b) } }; if let (Some(&a), Some(&b)) = ( self.collider_to_entity.get(&h1), self.collider_to_entity.get(&h2), ) { self.events.push(CollisionEvent { a, b, started, sensor, }); } } } /// Rebuilds the rapier world from `scene`'s physics components and refreshes /// the query pipeline, leaving it ready for scene queries **without /// advancing the simulation**. /// /// The simulation step runs the same sync internally; expose it so tools /// (notably the editor's raycast probe) can query the *edited* scene outside /// Play. rapier's query pipeline reads from the broad phase, which only learns /// about colliders during a pipeline step — so after syncing we run one /// **`dt = 0`** step: it registers every collider's AABB for queries while /// integrating nothing, so authored positions are preserved. After this call /// [`raycast`](Self::raycast) / [`overlap_sphere`](Self::overlap_sphere) and /// the other queries reflect the current colliders. pub fn sync_to_scene(&mut self, scene: &Scene) { self.sync_bodies(scene); // Refresh the broad phase (and thus the query pipeline) without moving // anything: a zero-length step integrates no motion but rebuilds the // spatial acceleration structure the queries read from. self.world.integration_parameters.dt = 0.0; self.world.step(); } /// Inserts/removes/updates rapier bodies to match the scene's physics entities. fn sync_bodies(&mut self, scene: &Scene) { // Snapshot the physics entities (collider required, body optional) so we // can drop the query borrow before resolving world transforms. let present: Vec<(Entity, Collider, Option)> = scene .world() .query::<(&Collider, Option<&RigidBody>)>() .iter() .map(|(e, (c, rb))| (e, *c, rb.copied())) .collect(); // Remove bodies whose entity lost its collider or was despawned. let live: HashSet = present.iter().map(|(e, _, _)| *e).collect(); let stale: Vec = self .entity_to_body .keys() .copied() .filter(|e| !live.contains(e)) .collect(); for e in stale { if let Some(h) = self.entity_to_body.remove(&e) { self.body_to_entity.remove(&h); self.world.remove_body(h); } if let Some(ch) = self.entity_to_collider.remove(&e) { self.collider_to_entity.remove(&ch); } } for (e, col, rb) in &present { let world_t = scene.world_transform(*e).unwrap_or(Transform::IDENTITY); match self.entity_to_body.get(e).copied() { Some(h) => { // A kinematic body is driven by its ECS transform: push the // target so the step integrates toward it. if matches!(rb.map(|r| r.kind), Some(RigidBodyKind::Kinematic)) { if let Some(b) = self.world.bodies.get_mut(h) { b.set_next_kinematic_position(to_pose(&world_t)); } } } None => self.insert_body(*e, col, rb.as_ref(), &world_t), } } } /// Builds and inserts a rapier body + collider for a new physics entity. fn insert_body(&mut self, e: Entity, col: &Collider, rb: Option<&RigidBody>, t: &Transform) { let kind = rb.map(|r| r.kind).unwrap_or(RigidBodyKind::Static); let mut body = match kind { RigidBodyKind::Dynamic => RigidBodyBuilder::dynamic(), RigidBodyKind::Kinematic => RigidBodyBuilder::kinematic_position_based(), RigidBodyKind::Static => RigidBodyBuilder::fixed(), } .pose(to_pose(t)) .user_data(e.to_bits().get() as u128); let explicit_mass = rb.map(|r| r.mass > 0.0).unwrap_or(false); if let Some(r) = rb { body = body .linear_damping(r.linear_damping) .angular_damping(r.angular_damping) .gravity_scale(r.gravity_scale) .ccd_enabled(r.ccd); if r.mass > 0.0 { body = body.additional_mass(r.mass); } } // When an explicit mass is set, zero the collider density so the body's // mass is exactly the authored value (density would otherwise add to it). let density = if explicit_mass { 0.0 } else { col.density }; let collider = build_shape(col) .friction(col.friction) .restitution(col.restitution) .density(density) .sensor(col.sensor) // Emit started/stopped events for this collider (off by default). .active_events(ActiveEvents::COLLISION_EVENTS) .collision_groups(InteractionGroups::new( Group::from_bits_retain(col.membership.bits()), Group::from_bits_retain(col.filter.bits()), InteractionTestMode::And, )); let (handle, collider_handle) = self.world.insert(body.build(), collider.build()); self.entity_to_body.insert(e, handle); self.body_to_entity.insert(handle, e); self.entity_to_collider.insert(e, collider_handle); self.collider_to_entity.insert(collider_handle, e); } /// Writes each moved body's pose back onto its entity's local transform. fn write_back(&self, scene: &mut Scene) { for (&e, &h) in &self.entity_to_body { let Some(body) = self.world.bodies.get(h) else { continue; }; // Static bodies never move; skip the work (and the float churn). if body.is_fixed() { continue; } let pos = body.position(); let world_t = Transform { translation: from_vec(pos.translation), rotation: from_quat(pos.rotation), scale: Vec3::ONE, }; // Convert world → local through the parent (root: local == world). // Only translation/rotation are driven; the entity keeps its scale. let mut local = scene.local_transform(e).unwrap_or(Transform::IDENTITY); let new_local = match scene.parent(e).and_then(|p| scene.world_transform(p)) { Some(parent_world) => parent_world.inverse().mul_transform(&world_t), None => world_t, }; local.translation = new_local.translation; local.rotation = new_local.rotation; scene.set_local_transform(e, local); } } } /// Builds the rapier collider shape from an Oxide [`Collider`]. fn build_shape(col: &Collider) -> ColliderBuilder { match col.shape { ColliderShape::Box => { ColliderBuilder::cuboid(col.half_extents.x, col.half_extents.y, col.half_extents.z) } ColliderShape::Sphere => ColliderBuilder::ball(col.radius), ColliderShape::Capsule => ColliderBuilder::capsule_y(col.half_height, col.radius), ColliderShape::Cylinder => ColliderBuilder::cylinder(col.half_height, col.radius), } } /// The [`Schedule::FixedUpdate`](oxide_engine::app::Schedule::FixedUpdate) /// system that advances the simulation one fixed step. Takes the /// [`PhysicsWorld`] out of the app so it can borrow the scene mutably, then /// re-inserts it. pub(crate) fn step_physics(app: &mut App) { let Some(mut world) = app.remove_resource::() else { return; }; world.sync_and_step(app); app.insert_resource(world); } #[cfg(test)] mod tests { use super::*; use crate::PhysicsModule; use oxide_engine::app::{App, DefaultModules}; use oxide_engine::math::Transform; /// Builds an app with physics and a fixed 60 Hz step, returning it ready to /// `step()`. fn physics_app() -> App { let mut app = App::new(); app.add_modules(DefaultModules); app.add_module(PhysicsModule); app } fn spawn_dynamic_ball(app: &mut App, y: f32) -> Entity { let e = app .scene .spawn("ball", Transform::from_translation(Vec3::new(0.0, y, 0.0))); app.scene .world_mut() .insert_one(e, RigidBody::default()) .unwrap(); app.scene .world_mut() .insert_one(e, Collider::ball(0.5)) .unwrap(); e } fn spawn_static_floor(app: &mut App) -> Entity { let e = app .scene .spawn("floor", Transform::from_translation(Vec3::ZERO)); app.scene .world_mut() .insert_one(e, RigidBody::static_body()) .unwrap(); app.scene .world_mut() .insert_one(e, Collider::cuboid(Vec3::new(10.0, 0.5, 10.0))) .unwrap(); e } fn y_of(app: &App, e: Entity) -> f32 { app.scene.local_transform(e).unwrap().translation.y } #[test] fn a_dropped_ball_falls() { let mut app = physics_app(); let ball = spawn_dynamic_ball(&mut app, 5.0); let start = y_of(&app, ball); app.step(); app.step(); assert!(y_of(&app, ball) < start, "ball should fall under gravity"); } #[test] fn a_dropped_ball_lands_on_the_floor_and_rests() { let mut app = physics_app(); spawn_static_floor(&mut app); let ball = spawn_dynamic_ball(&mut app, 5.0); // ~3 s of simulation: plenty to fall 4.5 m and settle. for _ in 0..180 { app.step(); } let y = y_of(&app, ball); // Floor top is at y=0.5, ball radius 0.5 → resting center ≈ 1.0. assert!( (y - 1.0).abs() < 0.1, "ball should rest on the floor at ~y=1.0, got {y}" ); // And it should be (nearly) at rest. let world = app.get_resource::().unwrap(); assert!( world.linear_velocity(ball).length() < 0.05, "ball should have come to rest" ); } #[test] fn stacked_boxes_stay_stacked_without_jitter() { let mut app = physics_app(); spawn_static_floor(&mut app); // Three unit boxes stacked: centers at 1.0, 2.0, 3.0 (floor top = 0.5, // half-extent 0.5 → resting centers ≈ 1.0, 2.0, 3.0). let mut boxes = Vec::new(); for i in 0..3 { let y = 1.0 + i as f32; let e = app .scene .spawn("box", Transform::from_translation(Vec3::new(0.0, y, 0.0))); app.scene .world_mut() .insert_one(e, RigidBody::default()) .unwrap(); app.scene .world_mut() .insert_one(e, Collider::cuboid(Vec3::splat(0.5))) .unwrap(); boxes.push((e, y)); } for _ in 0..240 { app.step(); } // Each box should remain near its starting height (stable contact, no // collapse, no explosion). for (e, y0) in boxes { let y = y_of(&app, e); assert!( (y - y0).abs() < 0.15, "box should stay stacked near y={y0}, got {y}" ); } } #[test] fn a_static_floor_does_not_move() { let mut app = physics_app(); let floor = spawn_static_floor(&mut app); for _ in 0..60 { app.step(); } assert!(y_of(&app, floor).abs() < 1e-5, "static body must not move"); } #[test] fn gravity_scale_zero_floats() { let mut app = physics_app(); let e = app.scene.spawn( "floater", Transform::from_translation(Vec3::new(0.0, 3.0, 0.0)), ); app.scene .world_mut() .insert_one( e, RigidBody { gravity_scale: 0.0, ..RigidBody::default() }, ) .unwrap(); app.scene .world_mut() .insert_one(e, Collider::ball(0.5)) .unwrap(); for _ in 0..60 { app.step(); } assert!( (y_of(&app, e) - 3.0).abs() < 1e-3, "a zero-gravity body should not fall" ); } #[test] fn a_kinematic_body_ignores_gravity() { let mut app = physics_app(); let e = app.scene.spawn( "platform", Transform::from_translation(Vec3::new(0.0, 2.0, 0.0)), ); app.scene .world_mut() .insert_one(e, RigidBody::kinematic()) .unwrap(); app.scene .world_mut() .insert_one(e, Collider::cuboid(Vec3::splat(0.5))) .unwrap(); for _ in 0..60 { app.step(); } assert!( (y_of(&app, e) - 2.0).abs() < 1e-4, "a kinematic body is not moved by forces" ); } #[test] fn despawning_an_entity_removes_its_body() { let mut app = physics_app(); let ball = spawn_dynamic_ball(&mut app, 5.0); app.step(); assert_eq!(app.get_resource::().unwrap().body_count(), 1); app.scene .despawn(ball, oxide_engine::scene::DespawnPolicy::Recursive); app.step(); assert_eq!(app.get_resource::().unwrap().body_count(), 0); } /// Spawns a dynamic ball on the given layer (membership = filter = layer). fn spawn_layered_ball(app: &mut App, pos: Vec3, layer: u32) -> Entity { let e = app.scene.spawn("ball", Transform::from_translation(pos)); app.scene .world_mut() .insert_one(e, RigidBody::default()) .unwrap(); let mask = oxide_engine::layer::LayerMask::layer(layer); app.scene .world_mut() .insert_one(e, Collider::ball(0.5).with_layers(mask, mask)) .unwrap(); e } #[test] fn matching_layers_collide_but_mismatched_layers_pass_through() { // Two balls dropped onto a static floor, side by side but overlapping in // x; if they collide they push apart, if they don't they stay overlapped. fn final_separation(layer_a: u32, layer_b: u32) -> f32 { let mut app = physics_app(); spawn_static_floor(&mut app); let a = spawn_layered_ball(&mut app, Vec3::new(-0.2, 1.0, 0.0), layer_a); let b = spawn_layered_ball(&mut app, Vec3::new(0.2, 1.0, 0.0), layer_b); for _ in 0..120 { app.step(); } let xa = app.scene.local_transform(a).unwrap().translation.x; let xb = app.scene.local_transform(b).unwrap().translation.x; (xa - xb).abs() } // Same layer → they collide and push apart (separation grows past ~1.0, // the sum of radii). assert!( final_separation(1, 1) > 0.9, "same-layer balls should collide and separate" ); // Different, non-matching layers → they ignore each other and stay // roughly where they started (separation ≈ 0.4). assert!( final_separation(1, 2) < 0.6, "mismatched-layer balls should pass through each other" ); } #[test] fn a_sensor_fires_a_trigger_event_and_does_not_block() { let mut app = physics_app(); // A static sensor box straddling y=2.5..3.5 (center y=3, half 0.5). let sensor = app.scene.spawn( "trigger", Transform::from_translation(Vec3::new(0.0, 3.0, 0.0)), ); app.scene .world_mut() .insert_one(sensor, RigidBody::static_body()) .unwrap(); app.scene .world_mut() .insert_one(sensor, Collider::cuboid(Vec3::splat(0.5)).as_sensor()) .unwrap(); // A ball dropped from above the sensor. let ball = spawn_dynamic_ball(&mut app, 5.0); let mut saw_enter = false; let mut saw_exit = false; for _ in 0..180 { app.step(); let world = app.get_resource::().unwrap(); for ev in world.trigger_events() { assert!(ev.sensor); assert_eq!(ev.other(sensor), Some(ball)); if ev.started { saw_enter = true; } else { saw_exit = true; } } } assert!(saw_enter, "ball should have entered the sensor"); assert!( saw_exit, "ball should have passed through and exited the sensor" ); // And it passed through — it is now well below the sensor. assert!( y_of(&app, ball) < 2.0, "a sensor must not block the ball, got y={}", y_of(&app, ball) ); } #[test] fn contact_events_fire_when_a_ball_lands() { let mut app = physics_app(); let floor = spawn_static_floor(&mut app); let ball = spawn_dynamic_ball(&mut app, 3.0); let mut saw_contact = false; for _ in 0..120 { app.step(); let world = app.get_resource::().unwrap(); if world .contact_events() .any(|e| e.started && e.other(ball) == Some(floor)) { saw_contact = true; } } assert!(saw_contact, "the ball landing should fire a contact event"); // While resting it should report as intersecting the floor ("stay"). assert!(app .get_resource::() .unwrap() .is_intersecting(ball, floor)); } #[test] fn a_raycast_hits_the_floor_and_reports_point_and_normal() { let mut app = physics_app(); spawn_static_floor(&mut app); // top at y=0.5 let floor = app.scene.roots()[0]; app.step(); // register bodies let world = app.get_resource::().unwrap(); // Cast straight down from above. let hit = world .raycast( Vec3::new(0.0, 5.0, 0.0), Vec3::new(0.0, -1.0, 0.0), 10.0, LayerMask::ALL, ) .expect("ray should hit the floor"); assert_eq!(hit.entity, floor); // Floor top is y=0.5, so the ray travels ~4.5 m. assert!((hit.toi - 4.5).abs() < 0.05, "toi={}", hit.toi); assert!((hit.point.y - 0.5).abs() < 0.05, "point.y={}", hit.point.y); // Up-facing surface. assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal); } #[test] fn sync_to_scene_makes_the_edited_scene_queryable_without_a_step() { // The editor's raycast probe queries the *edited* scene outside Play, so // it needs a query-able world built straight from components — no step. let mut app = physics_app(); let floor = spawn_static_floor(&mut app); // top at y=0.5 let mut world = PhysicsWorld::new(); world.sync_to_scene(&app.scene); assert_eq!(world.body_count(), 1, "sync should register the floor body"); let hit = world .raycast( Vec3::new(0.0, 5.0, 0.0), Vec3::new(0.0, -1.0, 0.0), 10.0, LayerMask::ALL, ) .expect("ray should hit the floor right after sync, with no step"); assert_eq!(hit.entity, floor); assert!((hit.point.y - 0.5).abs() < 0.05, "point.y={}", hit.point.y); assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal); } #[test] fn a_raycast_respects_the_layer_mask() { let mut app = physics_app(); // Floor on layer 3 only. let e = app .scene .spawn("floor", Transform::from_translation(Vec3::ZERO)); app.scene .world_mut() .insert_one(e, RigidBody::static_body()) .unwrap(); let mask = LayerMask::layer(3); app.scene .world_mut() .insert_one( e, Collider::cuboid(Vec3::new(10.0, 0.5, 10.0)).with_layers(mask, mask), ) .unwrap(); app.step(); let world = app.get_resource::().unwrap(); let down = Vec3::new(0.0, -1.0, 0.0); // A query on layer 3 hits it; a query on layer 1 misses. assert!(world .raycast(Vec3::new(0.0, 5.0, 0.0), down, 10.0, LayerMask::layer(3)) .is_some()); assert!(world .raycast(Vec3::new(0.0, 5.0, 0.0), down, 10.0, LayerMask::layer(1)) .is_none()); } #[test] fn a_sphere_cast_hits_a_wall() { let mut app = physics_app(); // A vertical wall at x=2. let e = app.scene.spawn( "wall", Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)), ); app.scene .world_mut() .insert_one(e, RigidBody::static_body()) .unwrap(); app.scene .world_mut() .insert_one(e, Collider::cuboid(Vec3::new(0.5, 5.0, 5.0))) .unwrap(); app.step(); let world = app.get_resource::().unwrap(); let hit = world .sphere_cast( Vec3::ZERO, 0.5, Vec3::new(1.0, 0.0, 0.0), 10.0, LayerMask::ALL, ) .expect("sphere should hit the wall"); assert_eq!(hit.entity, e); // Wall face at x=1.5, sphere radius 0.5 → contact when center at x=1.0. assert!((hit.toi - 1.0).abs() < 0.05, "toi={}", hit.toi); } #[test] fn overlap_and_point_queries_find_colliders() { let mut app = physics_app(); let e = app .scene .spawn("box", Transform::from_translation(Vec3::new(0.0, 0.0, 0.0))); app.scene .world_mut() .insert_one(e, RigidBody::static_body()) .unwrap(); app.scene .world_mut() .insert_one(e, Collider::cuboid(Vec3::splat(1.0))) .unwrap(); app.step(); let world = app.get_resource::().unwrap(); // A sphere overlapping the box. assert_eq!( world.overlap_sphere(Vec3::new(0.5, 0.0, 0.0), 0.5, LayerMask::ALL), vec![e] ); // A sphere far away overlaps nothing. assert!(world .overlap_sphere(Vec3::new(10.0, 0.0, 0.0), 0.5, LayerMask::ALL) .is_empty()); // The origin is inside the box. assert_eq!(world.point_overlap(Vec3::ZERO, LayerMask::ALL), vec![e]); // A point outside is not. assert!(world .point_overlap(Vec3::new(5.0, 0.0, 0.0), LayerMask::ALL) .is_empty()); } /// Spawns a small free-floating dynamic body (tiny ball, no gravity unless /// asked) for joint tests, returning its entity. fn spawn_joint_body(app: &mut App, pos: Vec3, kind: RigidBodyKind) -> Entity { let e = app.scene.spawn("jb", Transform::from_translation(pos)); let rb = RigidBody { kind, ..RigidBody::default() }; app.scene.world_mut().insert_one(e, rb).unwrap(); app.scene .world_mut() .insert_one(e, Collider::ball(0.1)) .unwrap(); e } fn pos_of(app: &App, e: Entity) -> Vec3 { app.scene.local_transform(e).unwrap().translation } #[test] fn a_fixed_joint_welds_a_body_to_a_static_anchor() { let mut app = physics_app(); let anchor = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static); let body = spawn_joint_body(&mut app, Vec3::new(0.0, -2.0, 0.0), RigidBodyKind::Dynamic); app.step(); // register bodies // Anchor A's local point (0,-2,0) welded to B's origin. app.get_resource_mut::() .unwrap() .add_joint( anchor, body, JointKind::Fixed, Vec3::new(0.0, -2.0, 0.0), Vec3::ZERO, ) .expect("both bodies are registered"); for _ in 0..120 { app.step(); } // A fixed joint to a static anchor fully constrains B: it does not fall. let p = pos_of(&app, body); assert!( (p - Vec3::new(0.0, -2.0, 0.0)).length() < 0.05, "fixed-jointed body should stay put, got {p:?}" ); } #[test] fn a_revolute_joint_keeps_a_pendulum_at_constant_radius() { let mut app = physics_app(); let pivot = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static); let bob = spawn_joint_body(&mut app, Vec3::new(1.0, 0.0, 0.0), RigidBodyKind::Dynamic); app.step(); // Hinge about Z at the world origin: A's anchor at its origin, B's anchor // one unit back so the two coincide at the start. app.get_resource_mut::() .unwrap() .add_joint( pivot, bob, JointKind::Revolute { axis: Vec3::Z }, Vec3::ZERO, Vec3::new(-1.0, 0.0, 0.0), ) .unwrap(); let mut swung_down = false; for _ in 0..240 { app.step(); let p = pos_of(&app, bob); // The revolute joint pins B to a circle of radius 1 about the origin. assert!( (p.length() - 1.0).abs() < 0.1, "pendulum radius should stay ~1, got {} at {p:?}", p.length() ); if p.y < -0.5 { swung_down = true; } } assert!( swung_down, "the pendulum should swing downward under gravity" ); } #[test] fn a_prismatic_joint_only_slides_along_its_axis() { let mut app = physics_app(); let anchor = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static); let slider = spawn_joint_body(&mut app, Vec3::new(0.0, -1.0, 0.0), RigidBodyKind::Dynamic); app.step(); // Slide only along Y. app.get_resource_mut::() .unwrap() .add_joint( anchor, slider, JointKind::Prismatic { axis: Vec3::Y }, Vec3::ZERO, Vec3::ZERO, ) .unwrap(); for _ in 0..120 { app.step(); } let p = pos_of(&app, slider); // Gravity slides it down along Y; x/z stay pinned. assert!(p.y < -1.0, "slider should fall along Y, got {p:?}"); assert!( p.x.abs() < 1e-3 && p.z.abs() < 1e-3, "off-axis drift: {p:?}" ); } #[test] fn joints_can_be_removed() { let mut app = physics_app(); let a = spawn_joint_body(&mut app, Vec3::ZERO, RigidBodyKind::Static); let b = spawn_joint_body(&mut app, Vec3::new(0.0, -1.0, 0.0), RigidBodyKind::Dynamic); app.step(); let world = app.get_resource_mut::().unwrap(); let j = world .add_joint( a, b, JointKind::Fixed, Vec3::new(0.0, -1.0, 0.0), Vec3::ZERO, ) .unwrap(); assert_eq!(world.joint_count(), 1); world.remove_joint(j); assert_eq!(world.joint_count(), 0); // With the joint gone, B falls freely. for _ in 0..60 { app.step(); } assert!(pos_of(&app, b).y < -1.5, "freed body should fall"); } /// Spawns a capsule character at `pos` and returns its entity. fn spawn_character(app: &mut App, pos: Vec3) -> Entity { let e = app.scene.spawn("player", Transform::from_translation(pos)); app.scene .world_mut() .insert_one(e, CharacterController::default()) .unwrap(); e } /// Moves the character one step (gravity + `horizontal`) and applies the /// resolved translation, returning whether it ended grounded. fn move_character_step(app: &mut App, e: Entity, horizontal: Vec3, dt: f32) -> bool { let desired = horizontal + Vec3::new(0.0, -9.81 * dt, 0.0); let movement = { let world = app.get_resource::().unwrap(); world.move_character(&app.scene, e, desired, dt).unwrap() }; let mut t = app.scene.local_transform(e).unwrap(); t.translation += movement.translation; app.scene.set_local_transform(e, t); movement.grounded } #[test] fn a_character_settles_on_the_floor_and_reports_grounded() { let mut app = physics_app(); spawn_static_floor(&mut app); // top at y=0.5 // Capsule total half-height 0.9 → rests with center at y≈1.4. Start above. let player = spawn_character(&mut app, Vec3::new(0.0, 2.0, 0.0)); app.step(); // register the floor body let mut grounded = false; for _ in 0..120 { grounded = move_character_step(&mut app, player, Vec3::ZERO, 1.0 / 60.0); } let y = pos_of(&app, player).y; assert!(grounded, "character should be grounded on the floor"); assert!( (y - 1.4).abs() < 0.1, "character should rest at ~y=1.4, got {y}" ); } #[test] fn a_character_is_blocked_by_a_wall() { let mut app = physics_app(); spawn_static_floor(&mut app); // A wall whose near face is at x=1.5. let wall = app.scene.spawn( "wall", Transform::from_translation(Vec3::new(2.0, 2.0, 0.0)), ); app.scene .world_mut() .insert_one(wall, RigidBody::static_body()) .unwrap(); app.scene .world_mut() .insert_one(wall, Collider::cuboid(Vec3::new(0.5, 2.0, 5.0))) .unwrap(); let player = spawn_character(&mut app, Vec3::new(0.0, 1.4, 0.0)); app.step(); // Walk hard into the wall. for _ in 0..180 { move_character_step(&mut app, player, Vec3::new(0.1, 0.0, 0.0), 1.0 / 60.0); } let x = pos_of(&app, player).x; // Capsule radius 0.3 → stops with center near x=1.2; never past the face. assert!( x < 1.3, "character should be blocked before the wall, got x={x}" ); } #[test] fn a_character_climbs_a_low_step_but_not_a_high_one() { // Returns the character's final height after walking +X into a step // whose top is at `step_top` (the floor surface is at y=0.5, so the // climb height is `step_top - 0.5`). 50 steps end the walk on top of a // climbable step (which spans x in [0.5, 3.5]) without running off the // floor's edge. fn final_height(step_top: f32) -> f32 { let mut app = physics_app(); spawn_static_floor(&mut app); // top at 0.5 let step = app.scene.spawn( "step", Transform::from_translation(Vec3::new(2.0, step_top / 2.0, 0.0)), ); app.scene .world_mut() .insert_one(step, RigidBody::static_body()) .unwrap(); app.scene .world_mut() .insert_one(step, Collider::cuboid(Vec3::new(1.5, step_top / 2.0, 5.0))) .unwrap(); let player = spawn_character(&mut app, Vec3::new(-0.5, 1.4, 0.0)); app.step(); for _ in 0..50 { move_character_step(&mut app, player, Vec3::new(0.08, 0.0, 0.0), 1.0 / 60.0); } pos_of(&app, player).y } // step_offset defaults to 0.3. A step rising 0.2 above the floor (top // 0.7) is climbable → the character ends up on it (center ≈ 0.7+0.9=1.6). let climbed = final_height(0.7); assert!( climbed > 1.5, "should climb the low step, ended at y={climbed}" ); // A step rising 0.4 above the floor (top 0.9) exceeds the offset → // blocked; the character stays at floor height (center ≈ 1.4). let blocked = final_height(0.9); assert!( blocked < 1.5, "should not climb the high step, ended at y={blocked}" ); } #[test] fn an_impulse_launches_a_floating_body() { let mut app = physics_app(); let e = app .scene .spawn("proj", Transform::from_translation(Vec3::ZERO)); app.scene .world_mut() .insert_one( e, RigidBody { gravity_scale: 0.0, ..RigidBody::default() }, ) .unwrap(); app.scene .world_mut() .insert_one(e, Collider::ball(0.5)) .unwrap(); // Register the body first. app.step(); app.get_resource_mut::() .unwrap() .apply_impulse(e, Vec3::new(0.0, 0.0, 5.0)); for _ in 0..30 { app.step(); } let z = app.scene.local_transform(e).unwrap().translation.z; assert!(z > 0.5, "impulse should move the body along +Z, got {z}"); } }