f56a1eea3b
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>
1498 lines
55 KiB
Rust
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}");
|
|
}
|
|
}
|