Files
Oxide/physics/src/world.rs
T
Homer Simpson f56a1eea3b 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>
2026-07-05 20:41:02 +02:00

1498 lines
55 KiB
Rust

//! 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<Entity> {
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<Entity, RigidBodyHandle>,
body_to_entity: HashMap<RigidBodyHandle, Entity>,
entity_to_collider: HashMap<Entity, ColliderHandle>,
collider_to_entity: HashMap<ColliderHandle, Entity>,
/// Collision/trigger transitions for the current frame (accumulated across
/// every fixed sub-step, cleared at the first step of each frame).
events: Vec<CollisionEvent>,
/// 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<RigidBodyHandle> {
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<Item = &CollisionEvent> {
self.events.iter().filter(|e| e.sensor)
}
/// The solid-contact transitions from the current frame.
pub fn contact_events(&self) -> impl Iterator<Item = &CollisionEvent> {
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<RayHit> {
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<RayHit> {
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<Entity> {
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<Entity> {
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<JointId> {
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<CharacterMovement> {
let cc = *scene.get::<CharacterController>(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::<PhysicsSettings>()
.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<RigidBody>)> = 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<Entity> = present.iter().map(|(e, _, _)| *e).collect();
let stale: Vec<Entity> = 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::<PhysicsWorld>() 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::<PhysicsWorld>().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::<PhysicsWorld>().unwrap().body_count(), 1);
app.scene
.despawn(ball, oxide_engine::scene::DespawnPolicy::Recursive);
app.step();
assert_eq!(app.get_resource::<PhysicsWorld>().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::<PhysicsWorld>().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::<PhysicsWorld>().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::<PhysicsWorld>()
.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::<PhysicsWorld>().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::<PhysicsWorld>().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::<PhysicsWorld>().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::<PhysicsWorld>().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::<PhysicsWorld>()
.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::<PhysicsWorld>()
.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::<PhysicsWorld>()
.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::<PhysicsWorld>().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::<PhysicsWorld>().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::<PhysicsWorld>()
.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}");
}
}