diff --git a/crates/lambda-rs-platform/src/physics/mod.rs b/crates/lambda-rs-platform/src/physics/mod.rs index 93ebaddd..0113f63c 100644 --- a/crates/lambda-rs-platform/src/physics/mod.rs +++ b/crates/lambda-rs-platform/src/physics/mod.rs @@ -8,7 +8,10 @@ pub mod rapier2d; pub use rapier2d::{ Collider2DBackendError, + CollisionEvent2DBackend, + CollisionEventKind2DBackend, PhysicsBackend2D, + RaycastHit2DBackend, RigidBody2DBackendError, RigidBodyType2D, }; diff --git a/crates/lambda-rs-platform/src/physics/rapier2d.rs b/crates/lambda-rs-platform/src/physics/rapier2d.rs index 7f53b460..c74fc5a3 100644 --- a/crates/lambda-rs-platform/src/physics/rapier2d.rs +++ b/crates/lambda-rs-platform/src/physics/rapier2d.rs @@ -5,6 +5,10 @@ //! of the platform layer. use std::{ + collections::{ + HashMap, + HashSet, + }, error::Error, fmt, }; @@ -103,6 +107,51 @@ impl fmt::Display for Collider2DBackendError { impl Error for Collider2DBackendError {} +/// Backend-agnostic data describing the nearest 2D raycast hit. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RaycastHit2DBackend { + /// The hit rigid body's slot index. + pub body_slot_index: u32, + /// The hit rigid body's slot generation. + pub body_slot_generation: u32, + /// The world-space hit point. + pub point: [f32; 2], + /// The world-space unit hit normal. + pub normal: [f32; 2], + /// The non-negative hit distance in meters. + pub distance: f32, +} + +/// Indicates whether a backend collision pair started or ended contact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CollisionEventKind2DBackend { + /// The body pair started touching during the current backend step. + Started, + /// The body pair stopped touching during the current backend step. + Ended, +} + +/// Backend-agnostic data describing one 2D collision event. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CollisionEvent2DBackend { + /// The transition kind for the body pair. + pub kind: CollisionEventKind2DBackend, + /// The first rigid body's slot index. + pub body_a_slot_index: u32, + /// The first rigid body's slot generation. + pub body_a_slot_generation: u32, + /// The second rigid body's slot index. + pub body_b_slot_index: u32, + /// The second rigid body's slot generation. + pub body_b_slot_generation: u32, + /// The representative world-space contact point, when available. + pub contact_point: Option<[f32; 2]>, + /// The representative world-space contact normal, when available. + pub normal: Option<[f32; 2]>, + /// The representative penetration depth, when available. + pub penetration: Option, +} + /// The fallback mass applied to dynamic bodies before density colliders exist. const DYNAMIC_BODY_FALLBACK_MASS_KG: f32 = 1.0; @@ -150,6 +199,10 @@ struct RigidBodySlot2D { struct ColliderSlot2D { /// The handle to the Rapier collider stored in the `ColliderSet`. rapier_handle: ColliderHandle, + /// The parent rigid body slot index that owns this collider. + parent_slot_index: u32, + /// The parent rigid body slot generation that owns this collider. + parent_slot_generation: u32, /// A monotonically increasing counter used to validate stale handles. generation: u32, } @@ -169,6 +222,30 @@ struct ColliderAttachmentMassPlan2D { should_remove_fallback_mass: bool, } +/// A normalized body-pair key used for backend collision tracking. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct BodyPairKey2D { + /// The first body slot index. + body_a_slot_index: u32, + /// The first body slot generation. + body_a_slot_generation: u32, + /// The second body slot index. + body_b_slot_index: u32, + /// The second body slot generation. + body_b_slot_generation: u32, +} + +/// The representative contact selected for a body pair during one step. +#[derive(Debug, Clone, Copy, PartialEq)] +struct BodyPairContact2D { + /// The representative world-space contact point. + point: [f32; 2], + /// The representative world-space normal from body A toward body B. + normal: [f32; 2], + /// The non-negative penetration depth. + penetration: f32, +} + /// A 2D physics backend powered by `rapier2d`. /// /// This type is an internal implementation detail used by `lambda-rs`. @@ -186,6 +263,10 @@ pub struct PhysicsBackend2D { pipeline: PhysicsPipeline, rigid_body_slots_2d: Vec, collider_slots_2d: Vec, + collider_parent_slots_2d: HashMap, + active_body_pairs_2d: HashSet, + active_body_pair_order_2d: Vec, + queued_collision_events_2d: Vec, } impl PhysicsBackend2D { @@ -225,6 +306,10 @@ impl PhysicsBackend2D { pipeline: PhysicsPipeline::new(), rigid_body_slots_2d: Vec::new(), collider_slots_2d: Vec::new(), + collider_parent_slots_2d: HashMap::new(), + active_body_pairs_2d: HashSet::new(), + active_body_pair_order_2d: Vec::new(), + queued_collision_events_2d: Vec::new(), }; } @@ -304,6 +389,8 @@ impl PhysicsBackend2D { /// - `density`: The density in kg/m². /// - `friction`: The friction coefficient (unitless). /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// - `collision_group`: The collider membership bitfield. + /// - `collision_mask`: The collider interaction mask bitfield. /// /// # Returns /// Returns a `(slot_index, slot_generation)` pair for the created collider. @@ -322,6 +409,8 @@ impl PhysicsBackend2D { density: f32, friction: f32, restitution: f32, + collision_group: u32, + collision_mask: u32, ) -> Result<(u32, u32), Collider2DBackendError> { return self.attach_collider_2d( parent_slot_index, @@ -332,6 +421,8 @@ impl PhysicsBackend2D { density, friction, restitution, + collision_group, + collision_mask, ); } @@ -350,6 +441,8 @@ impl PhysicsBackend2D { /// - `density`: The density in kg/m². /// - `friction`: The friction coefficient (unitless). /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// - `collision_group`: The collider membership bitfield. + /// - `collision_mask`: The collider interaction mask bitfield. /// /// # Returns /// Returns a `(slot_index, slot_generation)` pair for the created collider. @@ -369,6 +462,8 @@ impl PhysicsBackend2D { density: f32, friction: f32, restitution: f32, + collision_group: u32, + collision_mask: u32, ) -> Result<(u32, u32), Collider2DBackendError> { return self.attach_collider_2d( parent_slot_index, @@ -379,6 +474,8 @@ impl PhysicsBackend2D { density, friction, restitution, + collision_group, + collision_mask, ); } @@ -398,6 +495,8 @@ impl PhysicsBackend2D { /// - `density`: The density in kg/m². /// - `friction`: The friction coefficient (unitless). /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// - `collision_group`: The collider membership bitfield. + /// - `collision_mask`: The collider interaction mask bitfield. /// /// # Returns /// Returns a `(slot_index, slot_generation)` pair for the created collider. @@ -417,6 +516,8 @@ impl PhysicsBackend2D { density: f32, friction: f32, restitution: f32, + collision_group: u32, + collision_mask: u32, ) -> Result<(u32, u32), Collider2DBackendError> { let rapier_builder = if half_height == 0.0 { ColliderBuilder::ball(radius) @@ -433,6 +534,8 @@ impl PhysicsBackend2D { density, friction, restitution, + collision_group, + collision_mask, ); } @@ -452,6 +555,8 @@ impl PhysicsBackend2D { /// - `density`: The density in kg/m². /// - `friction`: The friction coefficient (unitless). /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// - `collision_group`: The collider membership bitfield. + /// - `collision_mask`: The collider interaction mask bitfield. /// /// # Returns /// Returns a `(slot_index, slot_generation)` pair for the created collider. @@ -472,6 +577,8 @@ impl PhysicsBackend2D { density: f32, friction: f32, restitution: f32, + collision_group: u32, + collision_mask: u32, ) -> Result<(u32, u32), Collider2DBackendError> { let rapier_vertices: Vec = vertices .iter() @@ -493,6 +600,8 @@ impl PhysicsBackend2D { density, friction, restitution, + collision_group, + collision_mask, ); } @@ -596,6 +705,174 @@ impl PhysicsBackend2D { .is_ok(); } + /// Returns all rigid bodies whose colliders contain the provided point. + /// + /// This walks the live collider set instead of Rapier's query pipeline + /// because gameplay queries are expected to work immediately after collider + /// creation. Before the world steps, broad-phase acceleration structures are + /// not guaranteed to be synchronized with newly attached colliders. + /// + /// # Arguments + /// - `point`: The world-space point to test. + /// + /// # Returns + /// Returns backend rigid body slot pairs for each matching collider. + pub fn query_point_2d(&self, point: [f32; 2]) -> Vec<(u32, u32)> { + if validate_position(point[0], point[1]).is_err() { + return Vec::new(); + } + + let point = Vector::new(point[0], point[1]); + let mut body_slots = Vec::new(); + + for (collider_handle, collider) in self.colliders.iter() { + if !collider.shape().contains_point(collider.position(), point) { + continue; + } + + let Some(body_slot) = + self.query_hit_to_parent_body_slot_2d(collider_handle) + else { + continue; + }; + + body_slots.push(body_slot); + } + + return body_slots; + } + + /// Returns all rigid bodies whose colliders overlap the provided AABB. + /// + /// This performs exact shape-vs-shape tests over the live collider set for + /// the same reason as `query_point_2d()`: overlap queries need to be correct + /// before the first simulation step, when broad-phase data may still be + /// stale. Using exact tests here also avoids broad-phase false positives in + /// the backend result. + /// + /// # Arguments + /// - `min`: The minimum world-space corner of the query box. + /// - `max`: The maximum world-space corner of the query box. + /// + /// # Returns + /// Returns backend rigid body slot pairs for each matching collider. + pub fn query_aabb_2d(&self, min: [f32; 2], max: [f32; 2]) -> Vec<(u32, u32)> { + if validate_position(min[0], min[1]).is_err() + || validate_position(max[0], max[1]).is_err() + { + return Vec::new(); + } + + let half_extents = + Vector::new((max[0] - min[0]) * 0.5, (max[1] - min[1]) * 0.5); + let center = Vector::new((min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5); + let query_shape = Cuboid::new(half_extents); + let query_pose = Pose::from_translation(center); + let query_dispatcher = self.narrow_phase.query_dispatcher(); + let mut body_slots = Vec::new(); + + for (collider_handle, collider) in self.colliders.iter() { + // Express the query box in the collider's local frame because Parry's + // exact intersection test compares one shape pose relative to the other. + let shape_to_collider = query_pose.inv_mul(collider.position()); + let intersects = query_dispatcher.intersection_test( + &shape_to_collider, + &query_shape, + collider.shape(), + ); + + if intersects != Ok(true) { + continue; + } + + let Some(body_slot) = + self.query_hit_to_parent_body_slot_2d(collider_handle) + else { + continue; + }; + + body_slots.push(body_slot); + } + + return body_slots; + } + + /// Returns the nearest rigid body hit by the provided finite ray segment. + /// + /// This iterates the live collider set directly instead of using Rapier's + /// broad-phase query pipeline because raycasts are expected to see colliders + /// that were just created or attached earlier in the frame. Keeping queries + /// on the live collider set makes the result match gameplay expectations even + /// before the world has advanced. + /// + /// # Arguments + /// - `origin`: The world-space ray origin. + /// - `dir`: The world-space ray direction. + /// - `max_dist`: The maximum query distance in meters. + /// + /// # Returns + /// Returns the nearest hit data when any live collider intersects the ray. + pub fn raycast_2d( + &self, + origin: [f32; 2], + dir: [f32; 2], + max_dist: f32, + ) -> Option { + if validate_position(origin[0], origin[1]).is_err() + || validate_velocity(dir[0], dir[1]).is_err() + || !max_dist.is_finite() + || max_dist <= 0.0 + { + return None; + } + + let normalized_dir = normalize_query_vector_2d(dir)?; + let ray = Ray::new( + Vector::new(origin[0], origin[1]), + Vector::new(normalized_dir[0], normalized_dir[1]), + ); + let mut nearest_hit = None; + + for (collider_handle, collider) in self.colliders.iter() { + // Resolve the public body handle data up front so the final hit payload + // stays backend-agnostic and does not expose Rapier collider handles. + let Some(body_slot) = + self.query_hit_to_parent_body_slot_2d(collider_handle) + else { + continue; + }; + + let Some(hit) = + cast_live_collider_raycast_hit_2d(collider, &ray, max_dist) + else { + continue; + }; + let hit_point = ray.point_at(hit.time_of_impact); + let candidate = RaycastHit2DBackend { + body_slot_index: body_slot.0, + body_slot_generation: body_slot.1, + point: [hit_point.x, hit_point.y], + normal: [hit.normal.x, hit.normal.y], + distance: hit.time_of_impact, + }; + + // The public API only returns the nearest hit, so keep the first minimum + // distance we observe while scanning the live collider set. + if nearest_hit + .as_ref() + .is_some_and(|nearest: &RaycastHit2DBackend| { + candidate.distance >= nearest.distance + }) + { + continue; + } + + nearest_hit = Some(candidate); + } + + return nearest_hit; + } + /// Sets the current position for the referenced body. /// /// # Arguments @@ -837,9 +1114,24 @@ impl PhysicsBackend2D { &(), ); + self.collect_collision_events_2d(); + return; } + /// Drains backend collision events queued by prior step calls. + /// + /// The backend accumulates transition events across substeps so + /// `PhysicsWorld2D` can expose one post-step drain point without exposing + /// Rapier's event machinery or borrowing backend internals through the + /// public iterator. + /// + /// # Returns + /// Returns all queued backend collision events in insertion order. + pub fn drain_collision_events_2d(&mut self) -> Vec { + return std::mem::take(&mut self.queued_collision_events_2d); + } + /// Returns an immutable reference to a rigid body slot after validation. /// /// # Arguments @@ -998,6 +1290,101 @@ impl PhysicsBackend2D { return; } + /// Collects body-pair collision transitions from the current narrow phase. + /// + /// The public API reports one event per body pair, not one event per collider + /// pair. This pass aggregates Rapier collider contacts by owning bodies, + /// keeps the deepest active contact seen for each body pair, and compares the + /// resulting active set against the previous step to detect both newly + /// started and newly ended contacts without emitting collider-pair + /// duplicates for compound bodies. + /// + /// # Returns + /// Returns `()` after appending any newly-started or newly-ended events to + /// the backend queue. + fn collect_collision_events_2d(&mut self) { + let mut current_body_pair_contacts: HashMap< + BodyPairKey2D, + BodyPairContact2D, + > = HashMap::new(); + let mut current_body_pair_order = Vec::new(); + + for contact_pair in self.narrow_phase.contact_pairs() { + if !contact_pair.has_any_active_contact() { + continue; + } + + let Some((body_pair_key, body_pair_contact)) = + self.body_pair_contact_from_contact_pair_2d(contact_pair) + else { + continue; + }; + + if let Some(existing_contact) = + current_body_pair_contacts.get_mut(&body_pair_key) + { + if body_pair_contact.penetration > existing_contact.penetration { + *existing_contact = body_pair_contact; + } + + continue; + } + + current_body_pair_order.push(body_pair_key); + current_body_pair_contacts.insert(body_pair_key, body_pair_contact); + } + + for body_pair_key in current_body_pair_order.iter().copied() { + if self.active_body_pairs_2d.contains(&body_pair_key) { + continue; + } + + let Some(contact) = current_body_pair_contacts.get(&body_pair_key) else { + continue; + }; + + self + .queued_collision_events_2d + .push(CollisionEvent2DBackend { + kind: CollisionEventKind2DBackend::Started, + body_a_slot_index: body_pair_key.body_a_slot_index, + body_a_slot_generation: body_pair_key.body_a_slot_generation, + body_b_slot_index: body_pair_key.body_b_slot_index, + body_b_slot_generation: body_pair_key.body_b_slot_generation, + contact_point: Some(contact.point), + normal: Some(contact.normal), + penetration: Some(contact.penetration), + }); + } + + // Check for ended contacts by looking for body pairs that were active in the + // previous step but are missing from the current step. + for body_pair_key in self.active_body_pair_order_2d.iter().copied() { + if current_body_pair_contacts.contains_key(&body_pair_key) { + continue; + } + + self + .queued_collision_events_2d + .push(CollisionEvent2DBackend { + kind: CollisionEventKind2DBackend::Ended, + body_a_slot_index: body_pair_key.body_a_slot_index, + body_a_slot_generation: body_pair_key.body_a_slot_generation, + body_b_slot_index: body_pair_key.body_b_slot_index, + body_b_slot_generation: body_pair_key.body_b_slot_generation, + contact_point: None, + normal: None, + penetration: None, + }); + } + + self.active_body_pairs_2d = + current_body_pair_contacts.keys().copied().collect(); + self.active_body_pair_order_2d = current_body_pair_order; + + return; + } + /// Attaches a prepared Rapier collider builder to a parent rigid body. /// /// This helper applies the shared local transform and material properties, @@ -1018,6 +1405,8 @@ impl PhysicsBackend2D { /// - `density`: The requested density in kg/m². /// - `friction`: The friction coefficient (unitless). /// - `restitution`: The restitution coefficient in `[0.0, 1.0]`. + /// - `collision_group`: The collider membership bitfield. + /// - `collision_mask`: The collider interaction mask bitfield. /// /// # Returns /// Returns a `(slot_index, slot_generation)` pair for the created collider. @@ -1035,6 +1424,8 @@ impl PhysicsBackend2D { density: f32, friction: f32, restitution: f32, + collision_group: u32, + collision_mask: u32, ) -> Result<(u32, u32), Collider2DBackendError> { let (rapier_parent_handle, rapier_density) = self .prepare_parent_body_for_collider_attachment_2d( @@ -1042,6 +1433,8 @@ impl PhysicsBackend2D { parent_slot_generation, density, )?; + let interaction_groups = + build_collision_groups_2d(collision_group, collision_mask); let rapier_collider = rapier_builder .translation(Vector::new(local_offset[0], local_offset[1])) @@ -1051,6 +1444,8 @@ impl PhysicsBackend2D { .friction_combine_rule(CoefficientCombineRule::Multiply) .restitution(restitution) .restitution_combine_rule(CoefficientCombineRule::Max) + .collision_groups(interaction_groups) + .solver_groups(interaction_groups) .build(); let rapier_handle = self.colliders.insert_with_parent( @@ -1069,8 +1464,13 @@ impl PhysicsBackend2D { let slot_generation = 1; self.collider_slots_2d.push(ColliderSlot2D { rapier_handle, + parent_slot_index, + parent_slot_generation, generation: slot_generation, }); + self + .collider_parent_slots_2d + .insert(rapier_handle, (parent_slot_index, parent_slot_generation)); return Ok((slot_index, slot_generation)); } @@ -1179,21 +1579,96 @@ impl PhysicsBackend2D { /// Validates that collider slots reference live Rapier colliders. /// /// This debug-only validation reads slot fields to prevent stale-handle - /// regressions during backend refactors. + /// regressions during backend refactors. The direct collider-handle lookup + /// map is validated alongside the slot table because queries and contact + /// collection rely on O(1) parent resolution in hot paths. /// /// # Returns /// Returns `()` after completing validation. fn debug_validate_collider_slots_2d(&self) { + debug_assert_eq!( + self.collider_slots_2d.len(), + self.collider_parent_slots_2d.len(), + "collider parent lookup map diverged from collider slot table" + ); + for slot in self.collider_slots_2d.iter() { debug_assert!(slot.generation > 0, "collider slot generation is zero"); debug_assert!( self.colliders.get(slot.rapier_handle).is_some(), "collider slot references missing Rapier collider" ); + debug_assert!( + self + .rigid_body_slot_2d( + slot.parent_slot_index, + slot.parent_slot_generation + ) + .is_ok(), + "collider slot references missing parent rigid body slot" + ); + debug_assert_eq!( + self + .collider_parent_slots_2d + .get(&slot.rapier_handle) + .copied(), + Some((slot.parent_slot_index, slot.parent_slot_generation)), + "collider parent lookup map references wrong parent rigid body slot" + ); } return; } + + /// Resolves a query hit collider back to its owning rigid body slot. + /// + /// # Arguments + /// - `collider_handle`: The Rapier collider handle returned by a query. + /// + /// # Returns + /// Returns the owning backend rigid body slot pair when the collider slot is + /// still tracked by the backend. + fn query_hit_to_parent_body_slot_2d( + &self, + collider_handle: ColliderHandle, + ) -> Option<(u32, u32)> { + return self.collider_parent_slots_2d.get(&collider_handle).copied(); + } + + /// Resolves one Rapier contact pair into a normalized body-pair contact. + /// + /// Rapier stores contacts per collider pair. The public API is body-oriented, + /// so this helper maps each collider pair back to its owning bodies, discards + /// self-contacts, normalizes body ordering for stable deduplication, and + /// returns the deepest active solver contact for that body pair. + /// + /// # Arguments + /// - `contact_pair`: The Rapier contact pair to inspect. + /// + /// # Returns + /// Returns the normalized body-pair key and representative contact when the + /// pair belongs to two distinct tracked bodies and has at least one active + /// solver contact. + fn body_pair_contact_from_contact_pair_2d( + &self, + contact_pair: &ContactPair, + ) -> Option<(BodyPairKey2D, BodyPairContact2D)> { + let body_a = + self.query_hit_to_parent_body_slot_2d(contact_pair.collider1)?; + let body_b = + self.query_hit_to_parent_body_slot_2d(contact_pair.collider2)?; + + if body_a == body_b { + return None; + } + + let (body_pair_key, should_flip_normal) = + normalize_body_pair_key_2d(body_a, body_b); + let representative_contact = + representative_contact_from_pair_2d(contact_pair, should_flip_normal)?; + + return Some((body_pair_key, representative_contact)); + } } /// Builds a Rapier rigid body builder with `lambda-rs` invariants applied. @@ -1251,6 +1726,67 @@ fn build_rapier_rigid_body( } } +/// Converts public collision filter bitfields into Rapier interaction groups. +/// +/// # Arguments +/// - `collision_group`: The collider membership bitfield. +/// - `collision_mask`: The collider interaction mask bitfield. +/// +/// # Returns +/// Returns Rapier interaction groups using AND-based matching. +fn build_collision_groups_2d( + collision_group: u32, + collision_mask: u32, +) -> InteractionGroups { + return InteractionGroups::new( + Group::from_bits_retain(collision_group), + Group::from_bits_retain(collision_mask), + InteractionTestMode::And, + ); +} + +/// Normalizes a body-pair key into a stable `body_a`/`body_b` ordering. +/// +/// Stable ordering lets the backend deduplicate collider contacts that belong +/// to the same bodies and keeps the public event stream from oscillating based +/// on Rapier's internal pair ordering. The returned boolean tells callers +/// whether normals reported from collider/body 1 toward collider/body 2 must be +/// flipped to match the normalized body ordering. +/// +/// # Arguments +/// - `body_a`: The first raw backend body slot pair. +/// - `body_b`: The second raw backend body slot pair. +/// +/// # Returns +/// Returns the normalized body-pair key and whether contact normals should be +/// flipped to point from normalized body A toward normalized body B. +fn normalize_body_pair_key_2d( + body_a: (u32, u32), + body_b: (u32, u32), +) -> (BodyPairKey2D, bool) { + if body_a <= body_b { + return ( + BodyPairKey2D { + body_a_slot_index: body_a.0, + body_a_slot_generation: body_a.1, + body_b_slot_index: body_b.0, + body_b_slot_generation: body_b.1, + }, + false, + ); + } + + return ( + BodyPairKey2D { + body_a_slot_index: body_b.0, + body_a_slot_generation: body_b.1, + body_b_slot_index: body_a.0, + body_b_slot_generation: body_a.1, + }, + true, + ); +} + /// Validates a 2D position. /// /// # Arguments @@ -1310,6 +1846,127 @@ fn validate_velocity(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> { return Ok(()); } +/// Normalizes a finite 2D query vector. +/// +/// Query directions are normalized inside the backend so geometric helpers can +/// treat Rapier's `time_of_impact` as world-space distance. Keeping that +/// normalization in one helper avoids subtle drift between different query +/// paths and keeps zero-length rejection consistent. +/// +/// # Arguments +/// - `vector`: The vector to normalize. +/// +/// # Returns +/// Returns the normalized vector when the input has non-zero finite length. +fn normalize_query_vector_2d(vector: [f32; 2]) -> Option<[f32; 2]> { + let length = vector[0].hypot(vector[1]); + + if !length.is_finite() || length <= 0.0 { + return None; + } + + return Some([vector[0] / length, vector[1] / length]); +} + +/// Selects the deepest active solver contact from one Rapier contact pair. +/// +/// Rapier's collider pair may expose several manifolds and several active +/// solver contacts per manifold. The public start event only needs one +/// representative contact, so this helper keeps the active solver contact with +/// the greatest penetration depth. Solver contacts are used instead of raw +/// tracked contacts because they already provide world-space points and reflect +/// the contacts that actually participated in collision resolution this step. +/// +/// # Arguments +/// - `contact_pair`: The Rapier contact pair to inspect. +/// - `should_flip_normal`: Whether the selected normal should be inverted to +/// point from normalized body A toward normalized body B. +/// +/// # Returns +/// Returns the deepest active solver contact for the pair, if one exists. +fn representative_contact_from_pair_2d( + contact_pair: &ContactPair, + should_flip_normal: bool, +) -> Option { + let mut representative_contact = None; + + for manifold in &contact_pair.manifolds { + let mut normal = [manifold.data.normal.x, manifold.data.normal.y]; + + if should_flip_normal { + normal = [-normal[0], -normal[1]]; + } + + for solver_contact in &manifold.data.solver_contacts { + let penetration = (-solver_contact.dist).max(0.0); + let candidate_contact = BodyPairContact2D { + point: [solver_contact.point.x, solver_contact.point.y], + normal, + penetration, + }; + + if representative_contact.as_ref().is_some_and( + |existing: &BodyPairContact2D| { + candidate_contact.penetration <= existing.penetration + }, + ) { + continue; + } + + representative_contact = Some(candidate_contact); + } + } + + return representative_contact; +} + +/// Casts a ray against one live collider and normalizes the reported normal. +/// +/// When the origin lies inside a collider, Parry may report a zero normal for +/// the solid hit at distance `0.0`. In that case, this helper performs one +/// non-solid cast along the same ray to recover a stable outward exit normal +/// while preserving the `0.0` contact distance expected by the public API. +/// +/// # Arguments +/// - `collider`: The live Rapier collider to test. +/// - `ray`: The normalized query ray. +/// - `max_dist`: The maximum query distance in meters. +/// +/// # Returns +/// Returns normalized hit data when the collider intersects the finite ray. +fn cast_live_collider_raycast_hit_2d( + collider: &Collider, + ray: &Ray, + max_dist: f32, +) -> Option { + // Use a solid cast so callers starting inside geometry receive an immediate + // hit at distance `0.0` instead of only the exit point. + let mut hit = collider.shape().cast_ray_and_get_normal( + collider.position(), + ray, + max_dist, + true, + )?; + + let normalized_normal = + normalize_query_vector_2d([hit.normal.x, hit.normal.y]).or_else(|| { + // Some inside hits report a zero normal. A follow-up non-solid cast + // recovers the exit-face normal without changing the public `0.0` + // distance contract for origin-inside queries. + let exit_hit = collider.shape().cast_ray_and_get_normal( + collider.position(), + ray, + max_dist, + false, + )?; + + return normalize_query_vector_2d([exit_hit.normal.x, exit_hit.normal.y]); + })?; + + hit.normal = Vector::new(normalized_normal[0], normalized_normal[1]); + return Some(hit); +} + /// Validates a 2D force vector. /// /// # Arguments diff --git a/crates/lambda-rs/src/physics/collider_2d.rs b/crates/lambda-rs/src/physics/collider_2d.rs index 7d673698..82217415 100644 --- a/crates/lambda-rs/src/physics/collider_2d.rs +++ b/crates/lambda-rs/src/physics/collider_2d.rs @@ -13,6 +13,7 @@ use std::{ use lambda_platform::physics::Collider2DBackendError; use super::{ + CollisionFilter, PhysicsWorld2D, RigidBody2D, RigidBody2DError, @@ -124,6 +125,7 @@ pub struct Collider2DBuilder { local_offset: [f32; 2], local_rotation: f32, material: ColliderMaterial2D, + collision_filter: CollisionFilter, } impl Collider2DBuilder { @@ -234,6 +236,18 @@ impl Collider2DBuilder { return self; } + /// Sets the collision filtering configuration for the collider. + /// + /// # Arguments + /// - `filter`: The collision filter to assign to the collider. + /// + /// # Returns + /// Returns the updated builder. + pub fn with_collision_filter(mut self, filter: CollisionFilter) -> Self { + self.collision_filter = filter; + return self; + } + /// Sets the collider density, in kg/m². /// /// When attaching a collider with `density > 0.0` to a dynamic body that did @@ -331,6 +345,8 @@ impl Collider2DBuilder { self.material.density(), self.material.friction(), self.material.restitution(), + self.collision_filter.group, + self.collision_filter.mask, ) .map_err(map_backend_error)?, ColliderShape2D::Rectangle { @@ -348,6 +364,8 @@ impl Collider2DBuilder { self.material.density(), self.material.friction(), self.material.restitution(), + self.collision_filter.group, + self.collision_filter.mask, ) .map_err(map_backend_error)?, ColliderShape2D::Capsule { @@ -365,6 +383,8 @@ impl Collider2DBuilder { self.material.density(), self.material.friction(), self.material.restitution(), + self.collision_filter.group, + self.collision_filter.mask, ) .map_err(map_backend_error)?, ColliderShape2D::ConvexPolygon { vertices } => world @@ -378,6 +398,8 @@ impl Collider2DBuilder { self.material.density(), self.material.friction(), self.material.restitution(), + self.collision_filter.group, + self.collision_filter.mask, ) .map_err(map_backend_error)?, }; @@ -396,6 +418,7 @@ impl Collider2DBuilder { local_offset: [DEFAULT_LOCAL_OFFSET_X, DEFAULT_LOCAL_OFFSET_Y], local_rotation: DEFAULT_LOCAL_ROTATION_RADIANS, material: ColliderMaterial2D::default(), + collision_filter: CollisionFilter::default(), }; } } @@ -954,4 +977,29 @@ mod tests { return; } + + /// Uses a filter that collides with all groups unless overridden. + #[test] + fn builder_defaults_collision_filter_to_all_groups() { + let builder = Collider2DBuilder::circle(1.0); + + assert_eq!(builder.collision_filter, CollisionFilter::default()); + + return; + } + + /// Stores a caller-provided collision filter on the builder. + #[test] + fn builder_overrides_collision_filter() { + let filter = CollisionFilter { + group: 0b0001, + mask: 0b0110, + }; + + let builder = Collider2DBuilder::circle(1.0).with_collision_filter(filter); + + assert_eq!(builder.collision_filter, filter); + + return; + } } diff --git a/crates/lambda-rs/src/physics/mod.rs b/crates/lambda-rs/src/physics/mod.rs index 0cc9d847..9ee5642e 100644 --- a/crates/lambda-rs/src/physics/mod.rs +++ b/crates/lambda-rs/src/physics/mod.rs @@ -4,15 +4,23 @@ //! API is backend-agnostic and does not expose vendor types. use std::{ + cell::RefCell, + collections::HashSet, error::Error, fmt, + mem, sync::atomic::{ AtomicU64, Ordering, }, }; -use lambda_platform::physics::PhysicsBackend2D; +use lambda_platform::physics::{ + CollisionEvent2DBackend, + CollisionEventKind2DBackend, + PhysicsBackend2D, + RaycastHit2DBackend, +}; mod collider_2d; mod rigid_body_2d; @@ -32,6 +40,8 @@ pub use rigid_body_2d::{ RigidBodyType, }; +const DEFAULT_COLLISION_FILTER_GROUP: u32 = u32::MAX; +const DEFAULT_COLLISION_FILTER_MASK: u32 = u32::MAX; const DEFAULT_GRAVITY_X: f32 = 0.0; const DEFAULT_GRAVITY_Y: f32 = -9.81; const DEFAULT_TIMESTEP_SECONDS: f32 = 1.0 / 60.0; @@ -39,6 +49,69 @@ const DEFAULT_SUBSTEPS: u32 = 1; static NEXT_WORLD_ID: AtomicU64 = AtomicU64::new(1); +/// Indicates whether a collision pair started or ended contact this step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CollisionEventKind { + /// The two bodies started touching during the most recent simulation step. + Started, + /// The two bodies stopped touching during the most recent simulation step. + Ended, +} + +/// Describes a body pair collision observed during simulation stepping. +/// +/// The body pair is unordered. `body_a` and `body_b` identify the two bodies +/// involved in the event, but their positions are not stable or semantically +/// meaningful across runs, backends, or separate events. Callers MUST treat +/// the pair as unordered and MUST NOT rely on one body always appearing in the +/// same field. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CollisionEvent { + /// The event transition kind for this body pair. + pub kind: CollisionEventKind, + /// One body participating in the unordered collision pair. + pub body_a: RigidBody2D, + /// The other body participating in the unordered collision pair. + pub body_b: RigidBody2D, + /// The representative contact point, when available. + pub contact_point: Option<[f32; 2]>, + /// The representative contact normal, when available. + pub normal: Option<[f32; 2]>, + /// The representative penetration depth, when available. + pub penetration: Option, +} + +/// Configures collider collision group and mask bitfields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CollisionFilter { + /// The membership bitfield for this collider. + pub group: u32, + /// The bitfield describing which groups this collider can collide with. + pub mask: u32, +} + +impl Default for CollisionFilter { + fn default() -> Self { + return Self { + group: DEFAULT_COLLISION_FILTER_GROUP, + mask: DEFAULT_COLLISION_FILTER_MASK, + }; + } +} + +/// Describes the nearest body hit by a ray query. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RaycastHit { + /// The rigid body hit by the ray. + pub body: RigidBody2D, + /// The world-space hit position. + pub point: [f32; 2], + /// The world-space hit normal. + pub normal: [f32; 2], + /// The non-negative distance from the origin to the hit point. + pub distance: f32, +} + /// A 2D physics simulation world. pub struct PhysicsWorld2D { world_id: u64, @@ -46,6 +119,7 @@ pub struct PhysicsWorld2D { timestep_seconds: f32, substeps: u32, backend: PhysicsBackend2D, + queued_collision_events: RefCell>, } impl PhysicsWorld2D { @@ -62,6 +136,8 @@ impl PhysicsWorld2D { .step_with_timestep_seconds(substep_timestep_seconds); } + let backend_events = self.backend.drain_collision_events_2d(); + self.queue_backend_collision_events(backend_events); self.backend.clear_rigid_body_forces_2d(); return; @@ -82,6 +158,221 @@ impl PhysicsWorld2D { pub fn timestep_seconds(&self) -> f32 { return self.timestep_seconds; } + + /// Drains collision events collected by prior `step()` calls. + /// + /// Collision events are produced while the world advances and then buffered + /// until gameplay code asks for them. Draining here keeps the simulation step + /// free of user callbacks and makes event consumption explicit, which is + /// easier to integrate into fixed-update loops than re-entrant callback + /// dispatch during contact resolution. Events remain queued across multiple + /// `step()` calls until this method drains them. + /// + /// # Returns + /// Returns an iterator over the queued collision events, draining the queue + /// as part of iteration creation. + pub fn collision_events(&self) -> impl Iterator { + let queued_events: Vec = + mem::take(&mut *self.queued_collision_events.borrow_mut()); + return queued_events.into_iter(); + } + + /// Returns all bodies whose colliders contain the provided point. + /// + /// Point queries are intended for gameplay checks that can be called freely + /// during update code. Treating invalid floating-point input as a miss keeps + /// the public API simple for callers and avoids forcing game code to thread + /// backend validation errors through every query site. + /// + /// # Arguments + /// - `point`: The world-space point to test. + /// + /// # Returns + /// Returns a vector of matching rigid body handles. + pub fn query_point(&self, point: [f32; 2]) -> Vec { + if !is_valid_query_point(point) { + return Vec::new(); + } + + // Backend queries operate in collider space, but the public API reports + // owning bodies. Rebuild body handles and collapse duplicate hits from + // compound colliders before returning the result. + let body_slots = self.backend.query_point_2d(point); + return self.deduplicate_query_body_hits(body_slots); + } + + /// Returns all bodies whose colliders overlap the provided axis-aligned box. + /// + /// AABB queries are meant to be tolerant of how gameplay code produces + /// bounds. The world normalizes `min` and `max` before delegating so callers + /// can pass corners in either order without adding their own pre-processing. + /// Invalid floating-point inputs are treated as a miss for the same reason as + /// `query_point()`: query call sites stay straightforward and do not need to + /// handle backend-specific error types. + /// + /// # Arguments + /// - `min`: The minimum world-space corner of the query box. + /// - `max`: The maximum world-space corner of the query box. + /// + /// # Returns + /// Returns a vector of matching rigid body handles. + pub fn query_aabb(&self, min: [f32; 2], max: [f32; 2]) -> Vec { + if !is_valid_query_point(min) || !is_valid_query_point(max) { + return Vec::new(); + } + + // Normalize the query box so callers do not need to sort the bounds first. + let normalized_min = [min[0].min(max[0]), min[1].min(max[1])]; + let normalized_max = [min[0].max(max[0]), min[1].max(max[1])]; + let body_slots = self.backend.query_aabb_2d(normalized_min, normalized_max); + + return self.deduplicate_query_body_hits(body_slots); + } + + /// Returns the nearest rigid body hit by the provided ray. + /// + /// Raycasts are exposed as a lightweight gameplay query rather than a + /// fallible backend operation. Inputs that cannot represent a meaningful + /// finite segment are treated as a miss, and the backend hit is rebound to + /// this world's public body handles before returning so the high-level API + /// stays vendor-free. + /// + /// # Arguments + /// - `origin`: The ray origin in world space. + /// - `dir`: The ray direction in world space. + /// - `max_dist`: The maximum query distance. + /// + /// # Returns + /// Returns the nearest hit, if one exists. + pub fn raycast( + &self, + origin: [f32; 2], + dir: [f32; 2], + max_dist: f32, + ) -> Option { + if !is_valid_query_point(origin) + || !is_valid_query_direction(dir) + || !max_dist.is_finite() + || max_dist <= 0.0 + { + return None; + } + + // The backend performs the geometry query and returns backend-neutral hit + // data, which we then bind back to this world's public rigid body handles. + let hit = self.backend.raycast_2d(origin, dir, max_dist)?; + return Some(self.map_backend_raycast_hit(hit)); + } + + /// Rebuilds and deduplicates rigid body handles from backend query hits. + /// + /// # Arguments + /// - `body_slots`: Backend `(slot_index, slot_generation)` pairs. + /// + /// # Returns + /// Returns one `RigidBody2D` handle per unique body, preserving first-hit + /// order from the backend query. + fn deduplicate_query_body_hits( + &self, + body_slots: Vec<(u32, u32)>, + ) -> Vec { + let mut seen_bodies = HashSet::new(); + let mut bodies = Vec::new(); + + for (slot_index, slot_generation) in body_slots { + let body = RigidBody2D::from_backend_slot( + self.world_id, + slot_index, + slot_generation, + ); + + // Spatial queries match colliders internally, but the public surface is + // body-oriented. Compound bodies therefore collapse to one handle. + if seen_bodies.insert(body) { + bodies.push(body); + } + } + + return bodies; + } + + /// Rebuilds a public raycast hit from backend slot and geometry data. + /// + /// # Arguments + /// - `hit`: The backend hit payload. + /// + /// # Returns + /// Returns a backend-agnostic `RaycastHit`. + fn map_backend_raycast_hit(&self, hit: RaycastHit2DBackend) -> RaycastHit { + return RaycastHit { + body: RigidBody2D::from_backend_slot( + self.world_id, + hit.body_slot_index, + hit.body_slot_generation, + ), + point: hit.point, + normal: hit.normal, + distance: hit.distance, + }; + } + + /// Appends backend collision events to the public drain queue. + /// + /// The backend reports only world-local slot data. This helper rebinds those + /// slots to world-scoped public handles and stores the results until + /// `collision_events()` drains them. + /// + /// # Arguments + /// - `backend_events`: The backend events to convert and queue. + /// + /// # Returns + /// Returns `()` after queueing the converted events. + fn queue_backend_collision_events( + &self, + backend_events: Vec, + ) { + let mapped_events = backend_events + .into_iter() + .map(|event| self.map_backend_collision_event(event)); + self + .queued_collision_events + .borrow_mut() + .extend(mapped_events); + + return; + } + + /// Rebuilds a public collision event from backend slot and contact data. + /// + /// # Arguments + /// - `event`: The backend event payload. + /// + /// # Returns + /// Returns a backend-agnostic public collision event. + fn map_backend_collision_event( + &self, + event: CollisionEvent2DBackend, + ) -> CollisionEvent { + return CollisionEvent { + kind: match event.kind { + CollisionEventKind2DBackend::Started => CollisionEventKind::Started, + CollisionEventKind2DBackend::Ended => CollisionEventKind::Ended, + }, + body_a: RigidBody2D::from_backend_slot( + self.world_id, + event.body_a_slot_index, + event.body_a_slot_generation, + ), + body_b: RigidBody2D::from_backend_slot( + self.world_id, + event.body_b_slot_index, + event.body_b_slot_generation, + ), + contact_point: event.contact_point, + normal: event.normal, + penetration: event.penetration, + }; + } } /// Builder for `PhysicsWorld2D`. @@ -171,6 +462,7 @@ impl PhysicsWorld2DBuilder { timestep_seconds: self.timestep_seconds, substeps: self.substeps, backend, + queued_collision_events: RefCell::new(Vec::new()), }); } } @@ -246,6 +538,32 @@ fn validate_gravity(gravity: [f32; 2]) -> Result<(), PhysicsWorld2DError> { return Ok(()); } +/// Returns whether a query point contains only finite coordinates. +/// +/// # Arguments +/// - `point`: The point to validate. +/// +/// # Returns +/// Returns `true` when both coordinates are finite. +fn is_valid_query_point(point: [f32; 2]) -> bool { + return point[0].is_finite() && point[1].is_finite(); +} + +/// Returns whether a ray/query direction has finite non-zero length. +/// +/// # Arguments +/// - `direction`: The query direction to validate. +/// +/// # Returns +/// Returns `true` when both components are finite and the vector is non-zero. +fn is_valid_query_direction(direction: [f32; 2]) -> bool { + if !direction[0].is_finite() || !direction[1].is_finite() { + return false; + } + + return direction[0].hypot(direction[1]) > 0.0; +} + /// Allocates a non-zero unique world identifier. fn allocate_world_id() -> u64 { loop { @@ -404,4 +722,28 @@ mod tests { return; } + + /// Exposes stable defaults for collider collision filtering. + #[test] + fn collision_filter_default_collides_with_all_groups() { + let filter = CollisionFilter::default(); + + assert_eq!(filter.group, u32::MAX); + assert_eq!(filter.mask, u32::MAX); + + return; + } + + /// Returns empty query and event results for an empty world. + #[test] + fn empty_world_collision_queries_return_no_results() { + let world = PhysicsWorld2DBuilder::new().build().unwrap(); + + assert_eq!(world.collision_events().count(), 0); + assert!(world.query_point([0.0, 0.0]).is_empty()); + assert!(world.query_aabb([-1.0, -1.0], [1.0, 1.0]).is_empty()); + assert_eq!(world.raycast([0.0, 0.0], [1.0, 0.0], 10.0), None); + + return; + } } diff --git a/crates/lambda-rs/src/physics/rigid_body_2d.rs b/crates/lambda-rs/src/physics/rigid_body_2d.rs index 064d8ff9..99aaf143 100644 --- a/crates/lambda-rs/src/physics/rigid_body_2d.rs +++ b/crates/lambda-rs/src/physics/rigid_body_2d.rs @@ -44,6 +44,30 @@ pub struct RigidBody2D { } impl RigidBody2D { + /// Creates a world-scoped rigid body handle from backend slot identifiers. + /// + /// This helper is an internal implementation detail used by query and event + /// APIs that reconstruct public handles from backend state. + /// + /// # Arguments + /// - `world_id`: The owning physics world identifier. + /// - `slot_index`: The backend body slot index. + /// - `slot_generation`: The backend body slot generation counter. + /// + /// # Returns + /// Returns a `RigidBody2D` handle bound to the provided world. + pub(super) fn from_backend_slot( + world_id: u64, + slot_index: u32, + slot_generation: u32, + ) -> Self { + return Self { + world_id, + slot_index, + slot_generation, + }; + } + /// Returns the backend slot identifiers for this handle. /// /// This function is an internal implementation detail used by other diff --git a/crates/lambda-rs/tests/physics_2d/collision_events.rs b/crates/lambda-rs/tests/physics_2d/collision_events.rs new file mode 100644 index 00000000..d394195e --- /dev/null +++ b/crates/lambda-rs/tests/physics_2d/collision_events.rs @@ -0,0 +1,403 @@ +//! 2D collision event integration tests. +//! +//! These tests validate post-step collision event collection through the +//! public `lambda-rs` 2D physics API. + +use lambda::physics::{ + Collider2DBuilder, + CollisionEvent, + CollisionEventKind, + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RigidBody2D, + RigidBody2DBuilder, + RigidBodyType, +}; + +const MAX_STEP_COUNT_UNTIL_CONTACT: u32 = 240; +const STEADY_CONTACT_STEP_COUNT: u32 = 30; + +/// Creates a static ground body. +/// +/// # Arguments +/// - `world`: The world that will own the ground body. +/// +/// # Returns +/// Returns the created ground rigid body handle. +fn build_ground(world: &mut PhysicsWorld2D) -> RigidBody2D { + let ground = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, -1.0) + .build(world) + .unwrap(); + + Collider2DBuilder::rectangle(10.0, 0.5) + .build(world, ground) + .unwrap(); + + return ground; +} + +/// Creates a falling dynamic ball body. +/// +/// # Arguments +/// - `world`: The world that will own the ball body. +/// +/// # Returns +/// Returns the created ball rigid body handle. +fn build_ball(world: &mut PhysicsWorld2D) -> RigidBody2D { + let ball = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 3.0) + .build(world) + .unwrap(); + + Collider2DBuilder::circle(0.5).build(world, ball).unwrap(); + + return ball; +} + +/// Creates a static body with two overlapping circle colliders. +/// +/// # Arguments +/// - `world`: The world that will own the compound body. +/// +/// # Returns +/// Returns the created compound rigid body handle. +fn build_compound_circle_body(world: &mut PhysicsWorld2D) -> RigidBody2D { + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(world) + .unwrap(); + + Collider2DBuilder::circle(0.5) + .with_offset(-0.25, 0.0) + .build(world, body) + .unwrap(); + Collider2DBuilder::circle(0.5) + .with_offset(0.25, 0.0) + .build(world, body) + .unwrap(); + + return body; +} + +/// Creates a dynamic ball already positioned in overlap. +/// +/// # Arguments +/// - `world`: The world that will own the body. +/// - `position`: The initial body position in meters. +/// +/// # Returns +/// Returns the created rigid body handle. +fn build_overlapping_ball( + world: &mut PhysicsWorld2D, + position: [f32; 2], +) -> RigidBody2D { + let ball = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(position[0], position[1]) + .build(world) + .unwrap(); + + Collider2DBuilder::circle(0.5).build(world, ball).unwrap(); + + return ball; +} + +/// Steps until at least one collision event is produced. +/// +/// # Arguments +/// - `world`: The world to step. +/// - `max_steps`: The maximum number of steps to attempt. +/// +/// # Returns +/// Returns the drained events from the first step that produced any events. +fn step_until_collision_events( + world: &mut PhysicsWorld2D, + max_steps: u32, +) -> Vec { + for _ in 0..max_steps { + world.step(); + + let events: Vec = world.collision_events().collect(); + if !events.is_empty() { + return events; + } + } + + panic!("expected collision events within {max_steps} steps"); +} + +/// Steps until a collision event of the requested kind is produced. +/// +/// # Arguments +/// - `world`: The world to step. +/// - `kind`: The event kind to wait for. +/// - `max_steps`: The maximum number of steps to attempt. +/// +/// # Returns +/// Returns all drained events from the first step that produced the requested +/// event kind. +fn step_until_collision_event_kind( + world: &mut PhysicsWorld2D, + kind: CollisionEventKind, + max_steps: u32, +) -> Vec { + for _ in 0..max_steps { + world.step(); + + let events: Vec = world.collision_events().collect(); + if events.iter().any(|event| event.kind == kind) { + return events; + } + } + + panic!("expected {kind:?} event within {max_steps} steps"); +} + +/// Asserts that an event references the expected body pair regardless of order. +/// +/// # Arguments +/// - `event`: The collision event to validate. +/// - `expected_body_a`: One expected body in the pair. +/// - `expected_body_b`: The other expected body in the pair. +/// +/// # Returns +/// Returns `()` after verifying the unordered body pair. +fn assert_event_body_pair_unordered( + event: &CollisionEvent, + expected_body_a: RigidBody2D, + expected_body_b: RigidBody2D, +) { + let matches_expected_order = + event.body_a == expected_body_a && event.body_b == expected_body_b; + let matches_reverse_order = + event.body_a == expected_body_b && event.body_b == expected_body_a; + + assert!( + matches_expected_order || matches_reverse_order, + "expected unordered body pair ({expected_body_a:?}, \ + {expected_body_b:?}), got ({:?}, {:?})", + event.body_a, + event.body_b, + ); + + return; +} + +/// Ensures first contact emits a single `Started` event. +#[test] +fn physics_2d_collision_events_first_contact_emits_started() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + let ground = build_ground(&mut world); + let ball = build_ball(&mut world); + let events = + step_until_collision_events(&mut world, MAX_STEP_COUNT_UNTIL_CONTACT); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, CollisionEventKind::Started); + assert_event_body_pair_unordered(&events[0], ground, ball); + + return; +} + +/// Ensures steady-state contact does not re-emit `Started` every step. +#[test] +fn physics_2d_collision_events_steady_contact_emits_no_extra_started() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + build_ground(&mut world); + build_ball(&mut world); + step_until_collision_events(&mut world, MAX_STEP_COUNT_UNTIL_CONTACT); + + for _ in 0..STEADY_CONTACT_STEP_COUNT { + world.step(); + assert!( + world + .collision_events() + .all(|event| event.kind != CollisionEventKind::Started), + "steady-state contact emitted an unexpected Started event", + ); + } + + return; +} + +/// Ensures draining the queue leaves the next read empty. +#[test] +fn physics_2d_collision_events_queue_drains_after_read() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + build_ground(&mut world); + build_ball(&mut world); + + let events = + step_until_collision_events(&mut world, MAX_STEP_COUNT_UNTIL_CONTACT); + + assert_eq!(events.len(), 1); + assert_eq!(world.collision_events().count(), 0); + + return; +} + +/// Ensures `Started` includes representative contact data. +#[test] +fn physics_2d_collision_events_started_includes_contact_data() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + build_ground(&mut world); + build_ball(&mut world); + + let events = + step_until_collision_events(&mut world, MAX_STEP_COUNT_UNTIL_CONTACT); + let started_event = events + .into_iter() + .find(|event| event.kind == CollisionEventKind::Started) + .unwrap(); + + let contact_point = started_event.contact_point.unwrap(); + let normal = started_event.normal.unwrap(); + let penetration = started_event.penetration.unwrap(); + + assert!(contact_point[0].is_finite()); + assert!(contact_point[1].is_finite()); + assert!(normal[0].is_finite()); + assert!(normal[1].is_finite()); + assert!(penetration.is_finite()); + assert!(penetration >= 0.0); + assert!( + (normal[0] * normal[0] + normal[1] * normal[1] - 1.0).abs() <= 1.0e-4, + "expected a unit normal, got {:?}", + normal, + ); + + return; +} + +/// Ensures separation emits one `Ended` event. +#[test] +fn physics_2d_collision_events_separation_emits_ended() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let ground = build_ground(&mut world); + let ball = build_overlapping_ball(&mut world, [0.0, 0.0]); + + let started_events = + step_until_collision_event_kind(&mut world, CollisionEventKind::Started, 1); + assert_eq!( + started_events + .iter() + .filter(|event| event.kind == CollisionEventKind::Started) + .count(), + 1, + ); + + ball.set_position(&mut world, 0.0, 4.0).unwrap(); + let ended_events = + step_until_collision_event_kind(&mut world, CollisionEventKind::Ended, 1); + let ended_event = ended_events + .into_iter() + .find(|event| event.kind == CollisionEventKind::Ended) + .unwrap(); + + assert_event_body_pair_unordered(&ended_event, ground, ball); + + return; +} + +/// Ensures `Ended` omits contact payload fields. +#[test] +fn physics_2d_collision_events_ended_has_no_contact_payload() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + build_ground(&mut world); + let ball = build_overlapping_ball(&mut world, [0.0, 0.0]); + + step_until_collision_event_kind(&mut world, CollisionEventKind::Started, 1); + ball.set_position(&mut world, 0.0, 4.0).unwrap(); + + let ended_event = + step_until_collision_event_kind(&mut world, CollisionEventKind::Ended, 1) + .into_iter() + .find(|event| event.kind == CollisionEventKind::Ended) + .unwrap(); + + assert_eq!(ended_event.contact_point, None); + assert_eq!(ended_event.normal, None); + assert_eq!(ended_event.penetration, None); + + return; +} + +/// Ensures compound colliders still emit one event per body pair. +#[test] +fn physics_2d_collision_events_compound_colliders_emit_one_body_pair_event() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let compound_body = build_compound_circle_body(&mut world); + let ball = build_overlapping_ball(&mut world, [0.0, 0.0]); + + let started_events = + step_until_collision_event_kind(&mut world, CollisionEventKind::Started, 1); + + assert_eq!( + started_events + .iter() + .filter(|event| event.kind == CollisionEventKind::Started) + .count(), + 1, + ); + assert_event_body_pair_unordered( + started_events + .iter() + .find(|event| event.kind == CollisionEventKind::Started) + .unwrap(), + compound_body, + ball, + ); + + ball.set_position(&mut world, 3.0, 0.0).unwrap(); + let ended_events = + step_until_collision_event_kind(&mut world, CollisionEventKind::Ended, 1); + + assert_eq!( + ended_events + .iter() + .filter(|event| event.kind == CollisionEventKind::Ended) + .count(), + 1, + ); + + return; +} + +/// Ensures queued events survive multiple steps until drained. +#[test] +fn physics_2d_collision_events_preserve_queue_across_multiple_steps() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + build_ground(&mut world); + let ball = build_overlapping_ball(&mut world, [0.0, 0.0]); + + world.step(); + ball.set_position(&mut world, 0.0, 4.0).unwrap(); + world.step(); + + let events: Vec = world.collision_events().collect(); + + assert_eq!(events.len(), 2); + assert_eq!(events[0].kind, CollisionEventKind::Started); + assert_eq!(events[1].kind, CollisionEventKind::Ended); + + return; +} diff --git a/crates/lambda-rs/tests/physics_2d/collision_filters.rs b/crates/lambda-rs/tests/physics_2d/collision_filters.rs new file mode 100644 index 00000000..77c04250 --- /dev/null +++ b/crates/lambda-rs/tests/physics_2d/collision_filters.rs @@ -0,0 +1,148 @@ +//! 2D collision filter integration tests. +//! +//! These tests validate that collider group and mask settings affect physical +//! contact generation through the public API. + +use lambda::physics::{ + Collider2DBuilder, + CollisionFilter, + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RigidBody2D, + RigidBody2DBuilder, + RigidBodyType, +}; + +const DEFAULT_STEP_COUNT: u32 = 240; + +/// Steps a world forward for the given number of fixed timesteps. +/// +/// # Arguments +/// - `world`: The world to step. +/// - `steps`: The number of steps to execute. +/// +/// # Returns +/// Returns `()` after stepping the world. +fn step_world(world: &mut PhysicsWorld2D, steps: u32) { + for _ in 0..steps { + world.step(); + } + + return; +} + +/// Creates a static ground body with the provided collision filter. +/// +/// # Arguments +/// - `world`: The world that will own the body. +/// - `filter`: The collision filter to apply to the ground collider. +/// +/// # Returns +/// Returns the created rigid body handle. +fn build_ground( + world: &mut PhysicsWorld2D, + filter: CollisionFilter, +) -> RigidBody2D { + let ground = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, -1.0) + .build(world) + .unwrap(); + + Collider2DBuilder::rectangle(20.0, 0.5) + .with_collision_filter(filter) + .build(world, ground) + .unwrap(); + + return ground; +} + +/// Creates a dynamic ball body with the provided collision filter. +/// +/// # Arguments +/// - `world`: The world that will own the body. +/// - `filter`: The collision filter to apply to the ball collider. +/// +/// # Returns +/// Returns the created rigid body handle. +fn build_ball( + world: &mut PhysicsWorld2D, + filter: CollisionFilter, +) -> RigidBody2D { + let ball = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, 4.0) + .build(world) + .unwrap(); + + Collider2DBuilder::circle(0.5) + .with_collision_filter(filter) + .build(world, ball) + .unwrap(); + + return ball; +} + +/// Allows collisions when both colliders' group and mask settings match. +#[test] +fn physics_2d_matching_collision_filters_allow_contact() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + build_ground( + &mut world, + CollisionFilter { + group: 0b0001, + mask: 0b0010, + }, + ); + let ball = build_ball( + &mut world, + CollisionFilter { + group: 0b0010, + mask: 0b0001, + }, + ); + + step_world(&mut world, DEFAULT_STEP_COUNT); + + let position = ball.position(&world).unwrap(); + + assert!( + position[1] > -0.25, + "ball did not collide with the ground: y={}", + position[1], + ); + + return; +} + +/// Prevents collisions when the colliders' groups and masks do not overlap. +#[test] +fn physics_2d_mismatched_collision_filters_prevent_contact() { + let mut world = PhysicsWorld2DBuilder::new().build().unwrap(); + + build_ground( + &mut world, + CollisionFilter { + group: 0b0001, + mask: 0b0001, + }, + ); + let ball = build_ball( + &mut world, + CollisionFilter { + group: 0b0010, + mask: 0b0010, + }, + ); + + step_world(&mut world, DEFAULT_STEP_COUNT); + + let position = ball.position(&world).unwrap(); + + assert!( + position[1] < -5.0, + "ball unexpectedly collided with the ground: y={}", + position[1], + ); + + return; +} diff --git a/crates/lambda-rs/tests/physics_2d/mod.rs b/crates/lambda-rs/tests/physics_2d/mod.rs index 88bfd791..3475c9ba 100644 --- a/crates/lambda-rs/tests/physics_2d/mod.rs +++ b/crates/lambda-rs/tests/physics_2d/mod.rs @@ -4,8 +4,11 @@ //! surface, including cross-crate wiring through `lambda-rs-platform`. mod colliders; +mod collision_events; +mod collision_filters; mod compound_colliders; mod materials; +mod queries; use lambda::physics::{ PhysicsWorld2DBuilder, diff --git a/crates/lambda-rs/tests/physics_2d/queries.rs b/crates/lambda-rs/tests/physics_2d/queries.rs new file mode 100644 index 00000000..912fa7e6 --- /dev/null +++ b/crates/lambda-rs/tests/physics_2d/queries.rs @@ -0,0 +1,298 @@ +//! Spatial query integration tests. +//! +//! These tests validate point queries, overlap queries, and raycasts through +//! the public `lambda-rs` 2D physics API. + +use lambda::physics::{ + Collider2DBuilder, + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RaycastHit, + RigidBody2D, + RigidBody2DBuilder, + RigidBodyType, +}; + +const FLOAT_TOLERANCE: f32 = 1.0e-5; + +/// Builds a static rectangle body with one rectangle collider. +/// +/// # Arguments +/// - `world`: The physics world to mutate. +/// - `position`: The body position in meters. +/// - `half_width`: The rectangle half-width in meters. +/// - `half_height`: The rectangle half-height in meters. +/// +/// # Returns +/// Returns the created rigid body handle. +fn build_static_rectangle( + world: &mut PhysicsWorld2D, + position: [f32; 2], + half_width: f32, + half_height: f32, +) -> RigidBody2D { + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(position[0], position[1]) + .build(world) + .unwrap(); + + Collider2DBuilder::rectangle(half_width, half_height) + .build(world, body) + .unwrap(); + + return body; +} + +/// Builds a static circle body with one circle collider. +/// +/// # Arguments +/// - `world`: The physics world to mutate. +/// - `position`: The body position in meters. +/// - `radius`: The circle radius in meters. +/// +/// # Returns +/// Returns the created rigid body handle. +fn build_static_circle( + world: &mut PhysicsWorld2D, + position: [f32; 2], + radius: f32, +) -> RigidBody2D { + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(position[0], position[1]) + .build(world) + .unwrap(); + + Collider2DBuilder::circle(radius) + .build(world, body) + .unwrap(); + + return body; +} + +/// Builds a static body with two overlapping circle colliders. +/// +/// # Arguments +/// - `world`: The physics world to mutate. +/// +/// # Returns +/// Returns the created rigid body handle. +fn build_compound_circle_body(world: &mut PhysicsWorld2D) -> RigidBody2D { + let body = RigidBody2DBuilder::new(RigidBodyType::Static) + .build(world) + .unwrap(); + + Collider2DBuilder::circle(0.5) + .with_offset(-0.25, 0.0) + .build(world, body) + .unwrap(); + Collider2DBuilder::circle(0.5) + .with_offset(0.25, 0.0) + .build(world, body) + .unwrap(); + + return body; +} + +/// Ensures point queries include interior and boundary hits. +#[test] +fn physics_2d_queries_point_hits_interior_and_boundary() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let rectangle = build_static_rectangle(&mut world, [0.0, 0.0], 1.0, 1.0); + + assert_eq!(world.query_point([0.0, 0.0]), vec![rectangle]); + assert_eq!(world.query_point([1.0, 0.0]), vec![rectangle]); + + return; +} + +/// Ensures point queries miss when the point lies outside all colliders. +#[test] +fn physics_2d_queries_point_misses_outside_geometry() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + build_static_rectangle(&mut world, [0.0, 0.0], 1.0, 1.0); + + assert!(world.query_point([1.1, 0.0]).is_empty()); + + return; +} + +/// Ensures AABB queries return all overlapping bodies. +#[test] +fn physics_2d_queries_aabb_hits_overlapping_bodies() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let rectangle = build_static_rectangle(&mut world, [-2.0, 0.0], 0.75, 1.0); + let circle = build_static_circle(&mut world, [2.0, 0.0], 0.75); + + let hits = world.query_aabb([-3.0, -1.0], [3.0, 1.0]); + + assert_eq!(hits.len(), 2); + assert!(hits.contains(&rectangle)); + assert!(hits.contains(&circle)); + + return; +} + +/// Ensures AABB queries normalize inverted bounds before testing. +#[test] +fn physics_2d_queries_aabb_accepts_inverted_bounds() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let circle = build_static_circle(&mut world, [1.0, 0.0], 0.5); + let hits = world.query_aabb([2.0, 1.0], [0.0, -1.0]); + + assert_eq!(hits, vec![circle]); + + return; +} + +/// Ensures compound collider point hits are deduplicated to one body handle. +#[test] +fn physics_2d_queries_compound_colliders_return_one_body_handle() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let body = build_compound_circle_body(&mut world); + let hits = world.query_point([0.0, 0.0]); + + assert_eq!(hits, vec![body]); + + return; +} + +/// Ensures raycasts return the nearest hit body along the segment. +#[test] +fn physics_2d_queries_raycast_returns_nearest_hit() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let near_circle = build_static_circle(&mut world, [2.0, 0.0], 0.5); + build_static_rectangle(&mut world, [5.0, 0.0], 0.5, 0.5); + + let hit = world.raycast([0.0, 0.0], [1.0, 0.0], 10.0).unwrap(); + + assert_eq!(hit.body, near_circle); + assert_point_approximately_eq(hit.point, [1.5, 0.0]); + assert_f32_approximately_eq(hit.distance, 1.5); + + return; +} + +/// Ensures raycast distances are reported in world meters. +#[test] +fn physics_2d_queries_raycast_distance_uses_world_units() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let circle = build_static_circle(&mut world, [5.0, 0.0], 1.0); + let hit = world.raycast([0.0, 0.0], [2.0, 0.0], 10.0).unwrap(); + + assert_eq!(hit.body, circle); + assert_point_approximately_eq(hit.point, [4.0, 0.0]); + assert_f32_approximately_eq(hit.distance, 4.0); + + return; +} + +/// Ensures raycast normals remain unit length. +#[test] +fn physics_2d_queries_raycast_returns_unit_normal() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + build_static_circle(&mut world, [4.0, 1.0], 1.0); + let hit = world.raycast([0.0, 1.0], [1.0, 0.0], 10.0).unwrap(); + + assert_unit_normal(hit); + + return; +} + +/// Ensures solid raycasts report zero distance when starting inside a collider. +#[test] +fn physics_2d_queries_raycast_from_inside_reports_zero_distance() { + let mut world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, 0.0) + .build() + .unwrap(); + + let rectangle = build_static_rectangle(&mut world, [0.0, 0.0], 1.0, 1.0); + let hit = world.raycast([0.0, 0.0], [1.0, 0.0], 10.0).unwrap(); + + assert_eq!(hit.body, rectangle); + assert_point_approximately_eq(hit.point, [0.0, 0.0]); + assert_f32_approximately_eq(hit.distance, 0.0); + assert_unit_normal(hit); + + return; +} + +/// Asserts that two scalar values match within floating-point tolerance. +/// +/// # Arguments +/// - `actual`: The computed scalar value. +/// - `expected`: The expected scalar value. +/// +/// # Returns +/// Returns `()` after validating the scalar difference. +fn assert_f32_approximately_eq(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= FLOAT_TOLERANCE, + "expected approximately {expected}, got {actual}", + ); + + return; +} + +/// Asserts that two world-space points match within floating-point tolerance. +/// +/// # Arguments +/// - `actual`: The computed point. +/// - `expected`: The expected point. +/// +/// # Returns +/// Returns `()` after validating both coordinates. +fn assert_point_approximately_eq(actual: [f32; 2], expected: [f32; 2]) { + assert_f32_approximately_eq(actual[0], expected[0]); + assert_f32_approximately_eq(actual[1], expected[1]); + + return; +} + +/// Asserts that a raycast hit normal has unit length within tolerance. +/// +/// # Arguments +/// - `hit`: The raycast hit to validate. +/// +/// # Returns +/// Returns `()` after validating the hit normal length. +fn assert_unit_normal(hit: RaycastHit) { + let normal_length = + (hit.normal[0] * hit.normal[0] + hit.normal[1] * hit.normal[1]).sqrt(); + + assert!((normal_length - 1.0).abs() <= FLOAT_TOLERANCE); + + return; +} diff --git a/demos/physics/Cargo.toml b/demos/physics/Cargo.toml index 732a11f4..548b1141 100644 --- a/demos/physics/Cargo.toml +++ b/demos/physics/Cargo.toml @@ -29,3 +29,8 @@ required-features = ["physics-2d"] name = "physics_colliders_2d" path = "src/bin/physics_colliders_2d.rs" required-features = ["physics-2d"] + +[[bin]] +name = "physics_collision_events_2d" +path = "src/bin/physics_collision_events_2d.rs" +required-features = ["physics-2d"] diff --git a/demos/physics/src/bin/physics_collision_events_2d.rs b/demos/physics/src/bin/physics_collision_events_2d.rs new file mode 100644 index 00000000..680bf358 --- /dev/null +++ b/demos/physics/src/bin/physics_collision_events_2d.rs @@ -0,0 +1,664 @@ +#![allow(clippy::needless_return)] + +//! Demo: Render a 2D collision pair and log collision events. +//! +//! This demo keeps the scene intentionally small so the collision event stream +//! is easy to inspect: +//! - A dynamic ball falls onto a static floor. +//! - `CollisionEventKind::Started` is logged when contact begins. +//! - `CollisionEventKind::Ended` is logged after Space launches the ball away. +//! - The ball tint switches while floor contact is active. +//! +//! Controls: +//! - Space: launch the ball upward after it has settled on the floor + +use std::ops::Range; + +use lambda::{ + component::Component, + events::{ + EventMask, + Key, + VirtualKey, + WindowEvent, + }, + physics::{ + Collider2DBuilder, + CollisionEvent, + CollisionEventKind, + PhysicsWorld2D, + PhysicsWorld2DBuilder, + RigidBody2D, + RigidBody2DBuilder, + RigidBodyType, + }, + render::{ + bind::{ + BindGroupBuilder, + BindGroupLayoutBuilder, + BindingVisibility, + }, + buffer::{ + Buffer, + BufferBuilder, + Properties, + Usage, + }, + command::RenderCommand, + mesh::{ + Mesh, + MeshBuilder, + }, + pipeline::{ + CullingMode, + RenderPipelineBuilder, + }, + render_pass::RenderPassBuilder, + shader::{ + Shader, + ShaderBuilder, + ShaderKind, + VirtualShader, + }, + vertex::{ + ColorFormat, + VertexAttribute, + VertexBuilder, + VertexElement, + }, + viewport::ViewportBuilder, + ResourceId, + }, + runtime::start_runtime, + runtimes::{ + application::ComponentResult, + ApplicationRuntimeBuilder, + }, +}; + +const WINDOW_WIDTH: u32 = 1200; +const WINDOW_HEIGHT: u32 = 600; + +const FLOOR_HALF_WIDTH: f32 = 0.88; +const FLOOR_HALF_HEIGHT: f32 = 0.05; +const FLOOR_Y: f32 = -0.82; + +const BALL_RADIUS: f32 = 0.08; +const BALL_START_Y: f32 = 0.42; +const BALL_LAUNCH_IMPULSE_Y: f32 = 1.45; + +const VERTEX_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 vertex_position; +layout (location = 1) in vec3 vertex_normal; +layout (location = 2) in vec3 vertex_color; + +layout (location = 0) out vec3 frag_color; + +layout (set = 0, binding = 0) uniform ContactDemoGlobals { + vec4 offset_rotation; + vec4 tint; +} globals; + +void main() { + float radians = globals.offset_rotation.z; + float cosine = cos(radians); + float sine = sin(radians); + + mat2 rotation = mat2( + cosine, -sine, + sine, cosine + ); + + vec2 rotated = rotation * vertex_position.xy; + vec2 translated = rotated + globals.offset_rotation.xy; + + gl_Position = vec4(translated, vertex_position.z, 1.0); + frag_color = vertex_color * globals.tint.xyz; +} + +"#; + +const FRAGMENT_SHADER_SOURCE: &str = r#" +#version 450 + +layout (location = 0) in vec3 frag_color; +layout (location = 0) out vec4 fragment_color; + +void main() { + fragment_color = vec4(frag_color, 1.0); +} + +"#; + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +struct ContactDemoUniform { + offset_rotation: [f32; 4], + tint: [f32; 4], +} + +unsafe impl lambda::pod::PlainOldData for ContactDemoUniform {} + +struct RenderBody { + body: RigidBody2D, + vertices: Range, + tint_idle: [f32; 4], + tint_contact: [f32; 4], + highlights_contact: bool, + uniform_buffer: Buffer, + bind_group_id: ResourceId, +} + +pub struct CollisionEvents2DDemo { + physics_world: PhysicsWorld2D, + physics_accumulator_seconds: f32, + pending_launch_impulse: bool, + + ball_body: RigidBody2D, + floor_body: RigidBody2D, + ball_contact_active: bool, + + vertex_shader: Shader, + fragment_shader: Shader, + mesh: Option, + render_pipeline_id: Option, + render_pass_id: Option, + bodies: Vec, + + width: u32, + height: u32, +} + +impl CollisionEvents2DDemo { + fn push_vertex(mesh_builder: MeshBuilder, x: f32, y: f32) -> MeshBuilder { + return mesh_builder.with_vertex( + VertexBuilder::new() + .with_position([x, y, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 1.0]) + .build(), + ); + } + + fn append_rectangle( + mesh_builder: MeshBuilder, + vertex_count: &mut u32, + half_width: f32, + half_height: f32, + ) -> (MeshBuilder, Range) { + let start = *vertex_count; + + let left = -half_width; + let right = half_width; + let bottom = -half_height; + let top = half_height; + + let mesh_builder = Self::push_vertex(mesh_builder, left, bottom); + let mesh_builder = Self::push_vertex(mesh_builder, right, bottom); + let mesh_builder = Self::push_vertex(mesh_builder, right, top); + + let mesh_builder = Self::push_vertex(mesh_builder, left, bottom); + let mesh_builder = Self::push_vertex(mesh_builder, right, top); + let mesh_builder = Self::push_vertex(mesh_builder, left, top); + + *vertex_count += 6; + let end = *vertex_count; + return (mesh_builder, start..end); + } + + fn append_circle( + mesh_builder: MeshBuilder, + vertex_count: &mut u32, + radius: f32, + segments: u32, + ) -> (MeshBuilder, Range) { + let start = *vertex_count; + let mut mesh_builder = mesh_builder; + + for index in 0..segments { + let t0 = index as f32 / segments as f32; + let t1 = (index + 1) as f32 / segments as f32; + + let angle0 = t0 * std::f32::consts::TAU; + let angle1 = t1 * std::f32::consts::TAU; + + let x0 = angle0.cos() * radius; + let y0 = angle0.sin() * radius; + let x1 = angle1.cos() * radius; + let y1 = angle1.sin() * radius; + + mesh_builder = Self::push_vertex(mesh_builder, 0.0, 0.0); + mesh_builder = Self::push_vertex(mesh_builder, x0, y0); + mesh_builder = Self::push_vertex(mesh_builder, x1, y1); + } + + *vertex_count += 3 * segments; + let end = *vertex_count; + return (mesh_builder, start..end); + } + + fn is_ball_floor_event(&self, event: CollisionEvent) -> bool { + let is_direct_pair = + event.body_a == self.ball_body && event.body_b == self.floor_body; + let is_swapped_pair = + event.body_a == self.floor_body && event.body_b == self.ball_body; + return is_direct_pair || is_swapped_pair; + } + + fn log_ball_floor_event(&mut self, event: CollisionEvent) { + match event.kind { + CollisionEventKind::Started => { + self.ball_contact_active = true; + + match (event.contact_point, event.normal, event.penetration) { + (Some(point), Some(normal), Some(penetration)) => { + println!( + "Collision Started: point=({:.3}, {:.3}) normal=({:.3}, {:.3}) penetration={:.4}", + point[0], + point[1], + normal[0], + normal[1], + penetration, + ); + } + _ => { + println!("Collision Started: contact data unavailable"); + } + } + } + CollisionEventKind::Ended => { + self.ball_contact_active = false; + println!("Collision Ended: ball left the floor"); + } + } + + return; + } +} + +impl Component for CollisionEvents2DDemo { + fn on_attach( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Result { + println!( + "Controls: wait for the ball to settle on the floor, then press Space to launch it." + ); + + let render_pass = RenderPassBuilder::new() + .with_label("physics-collision-events-2d-pass") + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + ); + + let layout = BindGroupLayoutBuilder::new() + .with_uniform(0, BindingVisibility::Vertex) + .build(render_context.gpu()); + + let attributes = vec![ + VertexAttribute { + location: 0, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 0, + }, + }, + VertexAttribute { + location: 1, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 12, + }, + }, + VertexAttribute { + location: 2, + offset: 0, + element: VertexElement { + format: ColorFormat::Rgb32Sfloat, + offset: 24, + }, + }, + ]; + + let mut mesh_builder = + MeshBuilder::new().with_attributes(attributes.clone()); + let mut vertex_count = 0_u32; + + let (updated_mesh_builder, floor_vertices) = Self::append_rectangle( + mesh_builder, + &mut vertex_count, + FLOOR_HALF_WIDTH, + FLOOR_HALF_HEIGHT, + ); + mesh_builder = updated_mesh_builder; + + let (updated_mesh_builder, ball_vertices) = + Self::append_circle(mesh_builder, &mut vertex_count, BALL_RADIUS, 32); + mesh_builder = updated_mesh_builder; + + let mesh = mesh_builder.build(); + + let render_bodies = [ + ( + self.floor_body, + floor_vertices, + [0.22, 0.22, 0.24, 1.0], + [0.22, 0.22, 0.24, 1.0], + false, + ), + ( + self.ball_body, + ball_vertices, + [0.22, 0.55, 0.95, 1.0], + [0.95, 0.28, 0.22, 1.0], + true, + ), + ]; + + let mut bodies = Vec::with_capacity(render_bodies.len()); + + for (body, vertices, tint_idle, tint_contact, highlights_contact) in + render_bodies + { + let position = body + .position(&self.physics_world) + .map_err(|error| error.to_string())?; + let rotation = body + .rotation(&self.physics_world) + .map_err(|error| error.to_string())?; + + let initial_uniform = ContactDemoUniform { + offset_rotation: [position[0], position[1], rotation, 0.0], + tint: tint_idle, + }; + + let uniform_buffer = BufferBuilder::new() + .with_length(std::mem::size_of::()) + .with_usage(Usage::UNIFORM) + .with_properties(Properties::CPU_VISIBLE) + .with_label("collision-events-demo-globals") + .build(render_context.gpu(), vec![initial_uniform]) + .map_err(|error| error.to_string())?; + + let bind_group = BindGroupBuilder::new() + .with_layout(&layout) + .with_uniform(0, &uniform_buffer, 0, None) + .build(render_context.gpu()); + + bodies.push(RenderBody { + body, + vertices, + tint_idle, + tint_contact, + highlights_contact, + uniform_buffer, + bind_group_id: render_context.attach_bind_group(bind_group), + }); + } + + let pipeline = RenderPipelineBuilder::new() + .with_label("physics-collision-events-2d-pipeline") + .with_culling(CullingMode::None) + .with_layouts(&[&layout]) + .with_buffer( + BufferBuilder::build_from_mesh(&mesh, render_context.gpu()) + .map_err(|error| error.to_string())?, + mesh.attributes().to_vec(), + ) + .build( + render_context.gpu(), + render_context.surface_format(), + render_context.depth_format(), + &render_pass, + &self.vertex_shader, + Some(&self.fragment_shader), + ); + + self.render_pass_id = Some(render_context.attach_render_pass(render_pass)); + self.render_pipeline_id = Some(render_context.attach_pipeline(pipeline)); + self.mesh = Some(mesh); + self.bodies = bodies; + + return Ok(ComponentResult::Success); + } + + fn on_detach( + &mut self, + _render_context: &mut lambda::render::RenderContext, + ) -> Result { + return Ok(ComponentResult::Success); + } + + fn event_mask(&self) -> EventMask { + return EventMask::WINDOW | EventMask::KEYBOARD; + } + + fn on_window_event(&mut self, event: &WindowEvent) -> Result<(), String> { + match event { + WindowEvent::Resize { width, height } => { + self.width = *width; + self.height = *height; + } + WindowEvent::Close => {} + } + + return Ok(()); + } + + fn on_keyboard_event(&mut self, event: &Key) -> Result<(), String> { + let Key::Pressed { virtual_key, .. } = event else { + return Ok(()); + }; + + if virtual_key != &Some(VirtualKey::Space) { + return Ok(()); + } + + if !self.ball_contact_active { + println!("Space ignored: wait until the ball is resting on the floor"); + return Ok(()); + } + + self.pending_launch_impulse = true; + return Ok(()); + } + + fn on_update( + &mut self, + last_frame: &std::time::Duration, + ) -> Result { + self.physics_accumulator_seconds += last_frame.as_secs_f32(); + + let timestep_seconds = self.physics_world.timestep_seconds(); + + while self.physics_accumulator_seconds >= timestep_seconds { + if self.pending_launch_impulse { + self + .ball_body + .set_velocity(&mut self.physics_world, 0.0, 0.0) + .map_err(|error| error.to_string())?; + self + .ball_body + .apply_impulse(&mut self.physics_world, 0.0, BALL_LAUNCH_IMPULSE_Y) + .map_err(|error| error.to_string())?; + self.pending_launch_impulse = false; + println!("Launch impulse applied"); + } + + self.physics_world.step(); + + for event in self.physics_world.collision_events() { + if self.is_ball_floor_event(event) { + self.log_ball_floor_event(event); + } + } + + self.physics_accumulator_seconds -= timestep_seconds; + } + + return Ok(ComponentResult::Success); + } + + fn on_render( + &mut self, + render_context: &mut lambda::render::RenderContext, + ) -> Vec { + let viewport = ViewportBuilder::new().build(self.width, self.height); + + for body in self.bodies.iter() { + let position = body + .body + .position(&self.physics_world) + .expect("RigidBody2D position failed"); + let rotation = body + .body + .rotation(&self.physics_world) + .expect("RigidBody2D rotation failed"); + + let tint = if body.highlights_contact && self.ball_contact_active { + body.tint_contact + } else { + body.tint_idle + }; + + let uniform = ContactDemoUniform { + offset_rotation: [position[0], position[1], rotation, 0.0], + tint, + }; + + body + .uniform_buffer + .write_value(render_context.gpu(), 0, &uniform); + } + + let render_pass = self.render_pass_id.expect("render pass missing"); + let render_pipeline = + self.render_pipeline_id.expect("render pipeline missing"); + + let mut commands = vec![ + RenderCommand::BeginRenderPass { + render_pass, + viewport: viewport.clone(), + }, + RenderCommand::SetPipeline { + pipeline: render_pipeline, + }, + RenderCommand::SetViewports { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::SetScissors { + start_at: 0, + viewports: vec![viewport.clone()], + }, + RenderCommand::BindVertexBuffer { + pipeline: render_pipeline, + buffer: 0, + }, + ]; + + for body in self.bodies.iter() { + commands.push(RenderCommand::SetBindGroup { + set: 0, + group: body.bind_group_id, + dynamic_offsets: Vec::new(), + }); + commands.push(RenderCommand::Draw { + vertices: body.vertices.clone(), + instances: 0..1, + }); + } + + commands.push(RenderCommand::EndRenderPass); + return commands; + } +} + +impl Default for CollisionEvents2DDemo { + fn default() -> Self { + let mut physics_world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, -3.2) + .with_substeps(4) + .build() + .expect("Failed to create PhysicsWorld2D"); + + let floor_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, FLOOR_Y) + .build(&mut physics_world) + .expect("Failed to create floor body"); + + Collider2DBuilder::rectangle(FLOOR_HALF_WIDTH, FLOOR_HALF_HEIGHT) + .with_density(0.0) + .with_friction(0.8) + .with_restitution(0.0) + .build(&mut physics_world, floor_body) + .expect("Failed to create floor collider"); + + let ball_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, BALL_START_Y) + .build(&mut physics_world) + .expect("Failed to create ball body"); + + Collider2DBuilder::circle(BALL_RADIUS) + .with_density(100.0) + .with_friction(0.45) + .with_restitution(0.0) + .build(&mut physics_world, ball_body) + .expect("Failed to create ball collider"); + + let mut shader_builder = ShaderBuilder::new(); + let vertex_shader = shader_builder.build(VirtualShader::Source { + source: VERTEX_SHADER_SOURCE.to_string(), + kind: ShaderKind::Vertex, + entry_point: "main".to_string(), + name: "physics-collision-events-2d".to_string(), + }); + let fragment_shader = shader_builder.build(VirtualShader::Source { + source: FRAGMENT_SHADER_SOURCE.to_string(), + kind: ShaderKind::Fragment, + entry_point: "main".to_string(), + name: "physics-collision-events-2d".to_string(), + }); + + return Self { + physics_world, + physics_accumulator_seconds: 0.0, + pending_launch_impulse: false, + + ball_body, + floor_body, + ball_contact_active: false, + + vertex_shader, + fragment_shader, + mesh: None, + render_pipeline_id: None, + render_pass_id: None, + bodies: Vec::new(), + + width: WINDOW_WIDTH, + height: WINDOW_HEIGHT, + }; + } +} + +fn main() { + let runtime = ApplicationRuntimeBuilder::new("Physics: 2D Collision Events") + .with_window_configured_as(move |window_builder| { + return window_builder + .with_dimensions(WINDOW_WIDTH, WINDOW_HEIGHT) + .with_name("Physics: 2D Collision Events"); + }) + .with_component(move |runtime, demo: CollisionEvents2DDemo| { + return (runtime, demo); + }) + .build(); + + start_runtime(runtime); +} diff --git a/docs/features.md b/docs/features.md index 446bb28e..bede59e7 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,13 +3,13 @@ title: "Cargo Features Overview" document_id: "features-2025-11-17" status: "living" created: "2025-11-17T23:59:00Z" -last_updated: "2026-03-14T22:54:24Z" -version: "0.1.17" +last_updated: "2026-03-25T16:39:52Z" +version: "0.1.18" engine_workspace_version: "2023.1.30" wgpu_version: "26.0.1" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "23dc1cbe0b87e772e92071ad170dfb70ced36f88" +repo_commit: "f3c56aaa0985993cc7e751865913e7a2ef27040e" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["guide", "features", "validation", "cargo", "audio", "physics"] @@ -17,8 +17,8 @@ tags: ["guide", "features", "validation", "cargo", "audio", "physics"] ## Overview This document enumerates the primary Cargo features exposed by the workspace -relevant to rendering, validation, and audio behavior. It defines defaults, -relationships, and expected behavior in debug and release builds. +relevant to rendering, validation, audio, and physics behavior. It defines +defaults, relationships, and expected behavior in debug and release builds. ## Table of Contents - [Overview](#overview) @@ -91,11 +91,16 @@ Physics - `physics-2d` (umbrella, disabled by default): enables the 2D physics world APIs (for example, `lambda::physics::PhysicsWorld2D`, `lambda::physics::RigidBody2D`, `lambda::physics::Collider2D`, and - `lambda::physics::Collider2DBuilder`). This feature enables the platform - physics backend via `lambda-rs-platform/physics-2d` (currently backed by - `rapier2d`). Expected runtime cost depends on simulation workload, collider - count, and contact density; no runtime cost is incurred unless a physics - world is constructed, populated, and stepped. + `lambda::physics::Collider2DBuilder`). This feature also covers collision + event delivery (`PhysicsWorld2D::collision_events()`), per-collider + collision filtering (`CollisionFilter`, + `Collider2DBuilder::with_collision_filter()`), and read-only spatial queries + (`PhysicsWorld2D::{query_point,query_aabb,raycast}`). This feature enables + the platform physics backend via `lambda-rs-platform/physics-2d` (currently + backed by `rapier2d`). Expected runtime cost depends on simulation workload, + collider count, contact density, event buffering, and query frequency; no + runtime cost is incurred unless a physics world is constructed, populated, + stepped, or queried. Render validation @@ -175,6 +180,8 @@ Physics `rapier2d` directly via this crate. ## Changelog +- 0.1.18 (2026-03-25): Expand `physics-2d` documentation to include collision + events, collision filtering, and spatial queries. - 0.1.17 (2026-03-14): Update `physics-2d` coverage to include 2D colliders. - 0.1.16 (2026-02-13): Document 2D rigid bodies under `physics-2d` and update metadata. diff --git a/docs/specs/README.md b/docs/specs/README.md index c4e9c583..2c4c3489 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -3,9 +3,13 @@ title: "Specifications Index" document_id: "specs-index-2026-02-07" status: "living" created: "2026-02-07T00:00:00Z" -last_updated: "2026-02-17T23:08:44Z" -version: "0.1.4" -repo_commit: "43c91a76dec71326cc255ebb6fb6c6402e95735c" +last_updated: "2026-03-25T16:39:52Z" +version: "0.1.5" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "f3c56aaa0985993cc7e751865913e7a2ef27040e" owners: ["lambda-sh"] reviewers: ["engine"] tags: ["index", "specs", "docs"] @@ -36,7 +40,13 @@ tags: ["index", "specs", "docs"] - 2D Physics World — [physics/physics-world-2d.md](physics/physics-world-2d.md) - 2D Rigid Bodies — [physics/rigid-bodies-2d.md](physics/rigid-bodies-2d.md) - 2D Colliders — [physics/colliders-2d.md](physics/colliders-2d.md) +- 2D Collision Queries and Events — [physics/collision-queries-and-events-2d.md](physics/collision-queries-and-events-2d.md) ## Templates - Specification template — [_spec-template.md](_spec-template.md) + +## Changelog + +- 0.1.5 (2026-03-25): Add the 2D collision queries and events specification + and align metadata with the long-lived documentation requirements. diff --git a/docs/specs/physics/collision-queries-and-events-2d.md b/docs/specs/physics/collision-queries-and-events-2d.md new file mode 100644 index 00000000..a10c34eb --- /dev/null +++ b/docs/specs/physics/collision-queries-and-events-2d.md @@ -0,0 +1,458 @@ +--- +title: "2D Collision Queries and Events" +document_id: "collision-queries-events-2d-2026-03-25" +status: "draft" +created: "2026-03-25T16:39:52Z" +last_updated: "2026-03-25T16:39:52Z" +version: "0.1.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "26.0.1" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "f3c56aaa0985993cc7e751865913e7a2ef27040e" +owners: ["lambda-sh"] +reviewers: ["engine"] +tags: ["spec", "physics", "2d", "lambda-rs", "platform"] +--- + +# 2D Collision Queries and Events + +## Table of Contents + +- [Summary](#summary) +- [Scope](#scope) +- [Terminology](#terminology) +- [Architecture Overview](#architecture-overview) +- [Design](#design) + - [API Surface](#api-surface) + - [lambda-rs Public API](#lambda-rs-public-api) + - [Behavior](#behavior) + - [Validation and Errors](#validation-and-errors) + - [Cargo Features](#cargo-features) +- [Constraints and Rules](#constraints-and-rules) +- [Performance Considerations](#performance-considerations) +- [Requirements Checklist](#requirements-checklist) +- [Verification and Testing](#verification-and-testing) +- [Compatibility and Migration](#compatibility-and-migration) +- [Changelog](#changelog) + +## Summary + +- Introduce collision event delivery for `PhysicsWorld2D` so applications can + respond when two bodies begin or end contact. +- Introduce read-only spatial queries for point overlap, axis-aligned bounding + box (AABB) overlap, and raycasts without advancing the simulation. +- Introduce per-collider collision filtering through `CollisionFilter` + group/mask bitmasks. +- Keep the public API backend-agnostic while allowing + `lambda-rs-platform` to translate backend contact and query results into the + stable `lambda-rs` contract. + +Rationale +- 2D rigid bodies and colliders exist, but gameplay code still lacks a stable + way to detect contacts and inspect the world outside of solver stepping. +- Collision events and read-only queries provide the minimum interaction layer + required for common gameplay systems such as pickups, hit scans, grounded + checks, and scripted physics responses. + +## Scope + +### Goals + +- Provide collision start and end notifications after `PhysicsWorld2D::step()`. +- Provide representative contact information for collision start events. +- Provide per-collider collision filtering via layers and masks. +- Provide point overlap queries that return owning `RigidBody2D` handles. +- Provide AABB overlap queries that return owning `RigidBody2D` handles. +- Provide raycasts that return the nearest hit body and hit information. +- Support collision and query inspection without requiring a simulation step in + the same frame. + +### Non-Goals + +- Trigger volumes, sensors, and overlap-only colliders. +- Arbitrary user callback registration or invocation during stepping. +- Query-side filtering rules beyond the built-in geometry tests. +- Complex filtering expressions, tag systems, or scriptable predicates. +- Public exposure of backend/vendor contact, query, or shape types. + +## Terminology + +- Collision event: a notification emitted when a pair of bodies transitions + into or out of contact. +- Contact point: a representative world-space point on the collision manifold + for a collision start event. +- Normal: a unit-length world-space vector pointing from `body_a` toward + `body_b` for the representative collision contact. +- Penetration: the representative overlap depth in meters for a collision + start event. +- Collision filter: a pair of bitmasks (`group`, `mask`) used to determine + whether two colliders may generate contacts. +- Overlap query: a read-only test that returns all bodies whose colliders + intersect a geometric region. +- Raycast: a read-only intersection test along a finite ray segment. +- AABB: axis-aligned bounding box. + +## Architecture Overview + +Dependencies +- This work item depends on the following specifications: + - `docs/specs/physics/physics-world-2d.md` + - `docs/specs/physics/rigid-bodies-2d.md` + - `docs/specs/physics/colliders-2d.md` + +Crate boundaries +- Crate `lambda` (package: `lambda-rs`) + - MUST expose collision events, filters, and query APIs through the public + `physics` module. + - MUST NOT expose backend/vendor types. +- Crate `lambda_platform` (package: `lambda-rs-platform`) + - MUST own backend event collection, contact extraction, filter mapping, and + spatial query execution. + - MUST translate backend-specific results into the public types defined by + this specification. + +Data flow + +```text +application + └── lambda::physics::PhysicsWorld2D + ├── post-step collision event queue + ├── query_point / query_aabb / raycast + └── lambda_platform::physics::PhysicsBackend2D (internal) + └── vendor crate (for example, rapier2d) +``` + +## Design + +### API Surface + +Module layout (new or extended) + +- `crates/lambda-rs/src/physics/mod.rs` + - Re-export `CollisionEvent`, `CollisionEventKind`, `CollisionFilter`, and + `RaycastHit`. + - Expose new `PhysicsWorld2D` query and event entry points. +- `crates/lambda-rs/src/physics/collider_2d.rs` + - Extend `Collider2DBuilder` with collision filter configuration. +- `crates/lambda-rs-platform/src/physics/mod.rs` + - Add internal query, event, and filter support for the active 2D physics + backend. + +### lambda-rs Public API + +Public entry points (draft) + +```rust +/// The type of collision transition represented by a `CollisionEvent`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CollisionEventKind { + Started, + Ended, +} + +/// Per-collider collision filter bitmasks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CollisionFilter { + pub group: u32, + pub mask: u32, +} + +/// A collision transition between two bodies. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CollisionEvent { + pub kind: CollisionEventKind, + pub body_a: RigidBody2D, + pub body_b: RigidBody2D, + pub contact_point: Option<[f32; 2]>, + pub normal: Option<[f32; 2]>, + pub penetration: Option, +} + +/// The nearest ray intersection against colliders in a `PhysicsWorld2D`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RaycastHit { + pub body: RigidBody2D, + pub point: [f32; 2], + pub normal: [f32; 2], + pub distance: f32, +} + +impl PhysicsWorld2D { + /// Returns and drains collision events collected by prior `step()` calls. + pub fn collision_events(&self) -> impl Iterator; + + /// Returns all bodies whose colliders contain `point`. + pub fn query_point(&self, point: [f32; 2]) -> Vec; + + /// Returns all bodies whose colliders overlap the given AABB. + pub fn query_aabb( + &self, + min: [f32; 2], + max: [f32; 2], + ) -> Vec; + + /// Returns the nearest hit on the finite ray segment. + pub fn raycast( + &self, + origin: [f32; 2], + dir: [f32; 2], + max_dist: f32, + ) -> Option; +} + +impl Collider2DBuilder { + /// Sets the collision filter used for collider-vs-collider contact tests. + pub fn with_collision_filter(self, filter: CollisionFilter) -> Self; +} +``` + +Notes +- `collision_events()` uses post-step iteration rather than callback + registration. +- `CollisionEvent` is body-scoped rather than collider-scoped. Multiple + colliders on the same pair of bodies MUST be coalesced into one body-pair + event stream. + +### Behavior + +Collision filter defaults +- `CollisionFilter` MUST default to: + - `group = u32::MAX` + - `mask = u32::MAX` +- The default filter MUST preserve the pre-filter behavior where all colliders + are eligible to collide. + +Collision filter rule +- Two colliders MUST be eligible to generate contacts only when: + - `(a.group & b.mask) != 0` + - `(b.group & a.mask) != 0` +- If either condition fails, the pair MUST NOT generate solver contacts and + MUST NOT contribute to collision start or end events. +- Filtering is defined per collider. A body with multiple colliders MAY collide + with another body through one collider pair while another pair is filtered + out. + +Event collection and delivery +- `PhysicsWorld2D::step()` MUST collect collision transitions that occur during + the step. +- `PhysicsWorld2D::collision_events()` MUST drain all currently queued events. + Calling it again before the next queued event is added MUST yield an empty + iterator. +- If `step()` is called multiple times before `collision_events()`, the world + MUST retain all queued events until they are drained. +- The event queue MUST contain at most one `Started` event when a body pair + transitions from not touching to touching. +- The event queue MUST contain at most one `Ended` event when a body pair + transitions from touching to not touching. +- `body_a` and `body_b` identify an unordered pair. Consumers MUST NOT depend + on their ordering. + +Body-pair aggregation +- When multiple collider pairs connect the same two bodies, event generation + MUST be body-pair scoped: + - `Started` is emitted when the first eligible collider pair begins contact. + - `Ended` is emitted when the last eligible collider pair stops contact. +- A body MUST NOT generate collision events against itself. + +Representative contact data +- `CollisionEventKind::Started` MUST populate: + - `contact_point` + - `normal` + - `penetration` +- `CollisionEventKind::Ended` MUST set those fields to `None`. +- For `Started` events with multiple candidate contacts between the same body + pair, the implementation MUST report one representative contact using the + deepest penetration from that step. +- `normal` MUST be unit length. +- `penetration` MUST be `>= 0.0`. +- Contact positions, normals, and penetration values MUST be reported in + world-space meters. + +Event ordering +- Event order MUST be stable for a single run of a single backend when the + same simulation inputs are replayed. +- Cross-platform or cross-backend deterministic ordering is NOT required. + +Spatial query semantics +- `query_point`, `query_aabb`, and `raycast` MUST be read-only and MUST NOT + advance the simulation. +- Spatial queries MUST operate on the current collider transforms in the + world, including changes caused by prior `step()` calls and direct rigid body + mutation. +- Query results MUST return bodies, not colliders. +- If more than one collider on the same body matches a query, that body MUST + appear only once in the returned result. + +Point queries +- `query_point()` MUST return every body with at least one collider containing + the given point. +- Points on the collider boundary MUST count as hits. + +AABB queries +- `query_aabb()` MUST return every body with at least one collider overlapping + the query box. +- The implementation MUST accept `min` and `max` in any order by normalizing + them to component-wise minimum/maximum bounds before executing the query. +- Boundary-touching overlaps MUST count as hits. + +Raycasts +- `raycast()` MUST test the finite segment starting at `origin` and extending + in direction `dir` for `max_dist` meters. +- `dir` MUST NOT be required to be pre-normalized by the application. +- The implementation MUST normalize `dir` internally before computing the hit + distance and normal. +- `raycast()` MUST return the nearest hit only. +- `RaycastHit::distance` MUST be measured in meters from `origin` along the + finite ray segment and MUST be in `[0.0, max_dist]`. +- `RaycastHit::normal` MUST be unit length. +- If the ray origin lies inside a collider, the query MUST return a hit with + `distance = 0.0`. + +Filtering and queries +- The collision filter defined by `CollisionFilter` applies only to + collider-vs-collider contact generation and event generation. +- `query_point`, `query_aabb`, and `raycast` MUST ignore collision filter + masks in this work item because the query APIs do not accept query-side + filter inputs. + +Intersection tests without simulation +- Applications MUST be able to use `query_point`, `query_aabb`, and + `raycast` without calling `step()` in the same frame. +- This work item MUST NOT introduce a separate boolean-only intersection API. + The spatial query set above satisfies the non-simulation intersection + requirement. + +### Validation and Errors + +Validation principles +- Public APIs in this work item MUST NOT panic on invalid query inputs. +- The query methods remain infallible in the public API surface. Invalid + inputs MUST therefore produce empty results or `None` rather than public + error types. + +Input validation (normative) +- `query_point()`: + - If either point component is non-finite, the result MUST be empty. +- `query_aabb()`: + - If any bound component is non-finite, the result MUST be empty. +- `raycast()`: + - If any `origin` or `dir` component is non-finite, the result MUST be + `None`. + - If `dir` has zero length, the result MUST be `None`. + - If `max_dist` is non-finite or `<= 0.0`, the result MUST be `None`. + +Filter validation +- All `u32` bit patterns for `group` and `mask` MUST be accepted. +- `with_collision_filter()` MUST store the provided filter verbatim. + +Backend translation +- `lambda-rs-platform` MUST convert backend contact/query results into + backend-agnostic public types. +- Backend-specific query misses, invalid-shape rejections, or internal cache + states MUST NOT leak into the `lambda-rs` public API. + +### Cargo Features + +- All public APIs in this work item MUST be gated under the existing umbrella + feature `physics-2d` (crate: `lambda-rs`). +- The platform backend support MUST remain enabled through + `lambda-rs-platform/physics-2d`. +- No additional feature flags are introduced by this specification. +- `docs/features.md` MUST describe that `physics-2d` now covers: + - collision events + - collision filtering + - point/AABB/raycast queries + +## Constraints and Rules + +- Public APIs MUST remain backend-agnostic and MUST NOT expose vendor types. +- This work item MUST NOT add trigger volumes or sensor callbacks. +- This work item MUST NOT require applications to register callbacks into the + world. +- Query APIs MUST be safe to call between simulation steps. +- Event delivery MUST remain post-step and pull-based for this iteration. + +## Performance Considerations + +Recommendations +- Applications SHOULD drain `collision_events()` once per simulation step. + - Rationale: Avoids unnecessary event queue growth. +- Point and AABB queries SHOULD use the backend broad phase when available. + - Rationale: Keeps query cost proportional to candidate overlap count rather + than total collider count. +- Raycasts SHOULD stop at the first confirmed nearest hit. + - Rationale: Preserves the expected cost model for common hit-scan gameplay. +- Body-pair event aggregation SHOULD avoid duplicate allocations for compound + collider contacts. + - Rationale: Compound bodies can otherwise multiply transient event cost. + +## Requirements Checklist + +Functionality +- [ ] Collision start and end events are exposed through `collision_events()`. +- [ ] Collision start events include representative contact data. +- [ ] Collision filters prevent masked pairs from generating contacts. +- [ ] `query_point()` returns bodies containing the point. +- [ ] `query_aabb()` returns bodies overlapping the AABB. +- [ ] `raycast()` returns the nearest hit with point, normal, and distance. + +API Surface +- [ ] Public query and event APIs are exposed through `lambda::physics`. +- [ ] `Collider2DBuilder` supports `with_collision_filter()`. +- [ ] Public APIs remain backend-agnostic and vendor-free. + +Validation and Errors +- [ ] Invalid query inputs return empty results or `None` without panicking. +- [ ] Collision filter inputs accept all `u32` bitmasks. +- [ ] Event payload semantics for `Started` and `Ended` are documented. + +Documentation and Examples +- [ ] `docs/features.md` reflects the expanded `physics-2d` behavior. +- [ ] The spec index lists this document under Physics. + +## Verification and Testing + +Unit tests (crate: `lambda-rs`) +- Verify that masked collider pairs do not generate events or physical + contacts. +- Verify that the default filter allows collisions. +- Verify that `collision_events()` drains the queue and does not duplicate + steady-state contacts across multiple steps. +- Verify invalid query inputs return empty results or `None`. + +Integration tests (crate: `lambda-rs`) +- Integration entrypoint: `crates/lambda-rs/tests/integration.rs`. +- Feature-specific physics tests: `crates/lambda-rs/tests/physics_2d/`. +- Event coverage: + - Two bodies first touch and emit one `Started` event. + - Two bodies separate after contact and emit one `Ended` event. + - `Started` includes representative contact point, normal, and penetration. +- Query coverage: + - `query_point()` hits an interior point and misses an exterior point. + - `query_aabb()` returns all overlapping bodies and deduplicates compound + colliders on the same body. + - `raycast()` returns the nearest hit with correct distance ordering. + - `raycast()` returns `distance = 0.0` when starting inside a collider. +- Commands: + - `cargo test -p lambda-rs --features physics-2d -- --nocapture` + - `cargo test --workspace` + +Manual verification +- Optional demo coverage MAY visualize: + - contact start/end notifications + - point/AABB query selections + - raycast hit normals + +## Compatibility and Migration + +- This work item adds APIs under the existing `physics-2d` feature and is + additive for current users. +- Existing applications that do not enable `physics-2d` are unaffected. +- Existing collider construction code remains source-compatible because the + default collision filter preserves current collision behavior. + +## Changelog + +- 2026-03-25 0.1.0: Initial draft defining collision events, filters, and + spatial queries for 2D physics. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index b028e241..22452eaa 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -3,13 +3,13 @@ title: "Tutorials Index" document_id: "tutorials-index-2025-10-17" status: "living" created: "2025-10-17T00:20:00Z" -last_updated: "2026-02-13T00:00:00Z" -version: "0.9.0" +last_updated: "2026-04-01T00:00:00Z" +version: "0.10.0" engine_workspace_version: "2023.1.30" wgpu_version: "28.0.0" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "6a3b507eedddc39f568ed73cfadf34011d57b9a3" +repo_commit: "7273183d923e78273b77b7f924bc8d6abc734cb9" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["index", "tutorials", "docs"] @@ -45,11 +45,13 @@ Browse all tutorials under `rendering/`. - Physics 2D: Falling Quad (Kinematic) — [physics/basics/falling-quad-kinematic.md](physics/basics/falling-quad-kinematic.md) - Physics 2D: Rigid Bodies (No Collisions) — [physics/basics/rigid-bodies-2d.md](physics/basics/rigid-bodies-2d.md) +- Physics 2D: Collision Events — [physics/basics/collision-events-2d.md](physics/basics/collision-events-2d.md) Browse all tutorials under `physics/`. Changelog +- 0.10.0 (2026-04-01): Add the 2D collision events physics tutorial. - 0.9.0 (2026-02-13): Add rigid bodies physics tutorial. - 0.8.0 (2026-02-07): Add physics tutorial section and first physics demo. - 0.7.1 (2026-02-07): Group tutorials by feature area in the index. diff --git a/docs/tutorials/physics/basics/collision-events-2d.md b/docs/tutorials/physics/basics/collision-events-2d.md new file mode 100644 index 00000000..10a87697 --- /dev/null +++ b/docs/tutorials/physics/basics/collision-events-2d.md @@ -0,0 +1,513 @@ +--- +title: "Physics 2D: Collision Events" +document_id: "physics-collision-events-2d-2026-04-01" +status: "draft" +created: "2026-04-01T00:00:00Z" +last_updated: "2026-04-01T00:00:00Z" +version: "0.2.0" +engine_workspace_version: "2023.1.30" +wgpu_version: "28.0.0" +shader_backend_default: "naga" +winit_version: "0.29.10" +repo_commit: "7273183d923e78273b77b7f924bc8d6abc734cb9" +owners: ["lambda-sh"] +reviewers: ["engine", "rendering"] +tags: ["tutorial", "physics", "2d", "collision-events", "fixed-timestep"] +--- + +## Overview + +This tutorial builds the `physics_collision_events_2d` demo that now exists in +`demos/physics/src/bin/physics_collision_events_2d.rs`. The finished example +creates a static floor and a dynamic ball, advances a 2D physics world on a +fixed timestep, drains `PhysicsWorld2D::collision_events()` after each step, +and changes the ball tint while contact with the floor is active. + +The tutorial focuses on the gameplay-facing side of the API. The rendering +path stays intentionally small and only exists to make the collision state +visible without adding UI or text rendering. + +## Table of Contents + +- [Overview](#overview) +- [Goals](#goals) +- [Prerequisites](#prerequisites) +- [Implementation Steps](#implementation-steps) + - [Step 1 — Register the Demo Binary](#step-1) + - [Step 2 — Define Constants, Shaders, and Uniforms](#step-2) + - [Step 3 — Add Render and Gameplay State](#step-3) + - [Step 4 — Add Geometry and Event Helpers](#step-4) + - [Step 5 — Build the Default Physics Scene](#step-5) + - [Step 6 — Attach Resources and Process Events](#step-6) + - [Step 7 — Render the Bodies and Start the Runtime](#step-7) +- [Validation](#validation) +- [Notes](#notes) +- [Conclusion](#conclusion) +- [Exercises](#exercises) +- [Changelog](#changelog) + +## Goals + +- Build a dedicated 2D collision-events demo binary. +- Show a fixed-timestep update loop that drains collision events immediately + after `PhysicsWorld2D::step()`. +- Demonstrate how to track contact state for one body pair without inferring it + from transforms. +- Log representative `Started` contact data and handle `Ended` without contact + payloads. +- Make the state transition visible by tinting the ball while floor contact is + active. + +## Prerequisites + +- The workspace builds with `cargo build --workspace`. +- The demos crate is available as `lambda-demos-physics`. +- The `physics-2d` feature is enabled for the physics demos crate. +- You are comfortable reading a `Component` implementation and a small amount + of render setup code. + +## Implementation Steps + +### Step 1 — Register the Demo Binary + +Add a new binary entry to `demos/physics/Cargo.toml`. Keeping collision events +in a dedicated binary prevents the broader collider demo from becoming a second +physics tutorial with competing goals. + +```toml +[[bin]] +name = "physics_collision_events_2d" +path = "src/bin/physics_collision_events_2d.rs" +required-features = ["physics-2d"] +``` + +This step gives Cargo a focused entry point for the tutorial. From this point +on, you can build and run the example independently from the other physics +demos. + +### Step 2 — Define Constants, Shaders, and Uniforms + +Create `demos/physics/src/bin/physics_collision_events_2d.rs` and start with +the constants that define the scene and the small shader pair used to draw it. +The demo only needs a floor, a ball, and one uniform block with translation, +rotation, and tint. + +```rust +const WINDOW_WIDTH: u32 = 1200; +const WINDOW_HEIGHT: u32 = 600; + +const FLOOR_HALF_WIDTH: f32 = 0.88; +const FLOOR_HALF_HEIGHT: f32 = 0.05; +const FLOOR_Y: f32 = -0.82; + +const BALL_RADIUS: f32 = 0.08; +const BALL_START_Y: f32 = 0.42; +const BALL_LAUNCH_IMPULSE_Y: f32 = 1.45; +``` + +Use a uniform type that matches the shader contract: + +```rust +#[repr(C)] +#[derive(Debug, Clone, Copy)] +struct ContactDemoUniform { + offset_rotation: [f32; 4], + tint: [f32; 4], +} + +unsafe impl lambda::pod::PlainOldData for ContactDemoUniform {} +``` + +The vertex shader should rotate and translate the mesh in clip space, and the +fragment shader should output the tinted color. The real demo uses inline GLSL +strings named `VERTEX_SHADER_SOURCE` and `FRAGMENT_SHADER_SOURCE`. + +After this step, the file has the immutable scene dimensions and the minimal +GPU contract needed to draw collision state. + +### Step 3 — Add Render and Gameplay State + +Define the render record for each body and the main component state. The key +idea is to keep collision-derived state explicit. The tutorial is about +responding to event transitions, so `ball_contact_active` should live alongside +the physics handles rather than being recomputed from positions. + +```rust +struct RenderBody { + body: RigidBody2D, + vertices: Range, + tint_idle: [f32; 4], + tint_contact: [f32; 4], + highlights_contact: bool, + uniform_buffer: Buffer, + bind_group_id: ResourceId, +} + +pub struct CollisionEvents2DDemo { + physics_world: PhysicsWorld2D, + physics_accumulator_seconds: f32, + pending_launch_impulse: bool, + + ball_body: RigidBody2D, + floor_body: RigidBody2D, + ball_contact_active: bool, + + vertex_shader: Shader, + fragment_shader: Shader, + mesh: Option, + render_pipeline_id: Option, + render_pass_id: Option, + bodies: Vec, + + width: u32, + height: u32, +} +``` + +This step separates the three responsibilities in the demo: +- `physics_world`, body handles, and impulse state drive simulation. +- `ball_contact_active` stores gameplay state derived from events. +- `bodies`, shaders, and pipeline handles support rendering. + +### Step 4 — Add Geometry and Event Helpers + +Add small helpers for mesh construction and collision-event handling. The demo +uses one combined mesh, so helper functions keep the geometry code small and +make the tutorial easier to follow. + +Build triangles with these helpers: + +```rust +fn push_vertex(mesh_builder: MeshBuilder, x: f32, y: f32) -> MeshBuilder { + return mesh_builder.with_vertex( + VertexBuilder::new() + .with_position([x, y, 0.0]) + .with_normal([0.0, 0.0, 1.0]) + .with_color([1.0, 1.0, 1.0]) + .build(), + ); +} + +fn append_rectangle( + mesh_builder: MeshBuilder, + vertex_count: &mut u32, + half_width: f32, + half_height: f32, +) -> (MeshBuilder, Range) { /* ... */ } + +fn append_circle( + mesh_builder: MeshBuilder, + vertex_count: &mut u32, + radius: f32, + segments: u32, +) -> (MeshBuilder, Range) { /* ... */ } +``` + +Then add the event-specific helpers: + +```rust +fn is_ball_floor_event(&self, event: CollisionEvent) -> bool { + let is_direct_pair = + event.body_a == self.ball_body && event.body_b == self.floor_body; + let is_swapped_pair = + event.body_a == self.floor_body && event.body_b == self.ball_body; + + return is_direct_pair || is_swapped_pair; +} + +fn log_ball_floor_event(&mut self, event: CollisionEvent) { + match event.kind { + CollisionEventKind::Started => { + self.ball_contact_active = true; + // Print contact data when present. + } + CollisionEventKind::Ended => { + self.ball_contact_active = false; + println!("Collision Ended: ball left the floor"); + } + } +} +``` + +The real implementation prints either a fully formatted `Started` message with +contact point, normal, and penetration, or a fallback line when the contact +payload is unavailable. + +After this step, the file has the local helpers that make the rest of the +component implementation short and readable. + +### Step 5 — Build the Default Physics Scene + +Implement `Default` for the component. This keeps `main()` small and makes the +physics setup explicit in one place. The scene uses one static floor and one +dynamic ball because a single body pair produces the cleanest event stream. + +```rust +impl Default for CollisionEvents2DDemo { + fn default() -> Self { + let mut physics_world = PhysicsWorld2DBuilder::new() + .with_gravity(0.0, -3.2) + .with_substeps(4) + .build() + .expect("Failed to create PhysicsWorld2D"); + + let floor_body = RigidBody2DBuilder::new(RigidBodyType::Static) + .with_position(0.0, FLOOR_Y) + .build(&mut physics_world) + .expect("Failed to create floor body"); + + Collider2DBuilder::rectangle(FLOOR_HALF_WIDTH, FLOOR_HALF_HEIGHT) + .with_density(0.0) + .with_friction(0.8) + .with_restitution(0.0) + .build(&mut physics_world, floor_body) + .expect("Failed to create floor collider"); + + let ball_body = RigidBody2DBuilder::new(RigidBodyType::Dynamic) + .with_position(0.0, BALL_START_Y) + .build(&mut physics_world) + .expect("Failed to create ball body"); + + Collider2DBuilder::circle(BALL_RADIUS) + .with_density(100.0) + .with_friction(0.45) + .with_restitution(0.0) + .build(&mut physics_world, ball_body) + .expect("Failed to create ball collider"); + + // Build the inline GLSL shaders here as well. + } +} +``` + +The demo also constructs the vertex and fragment shaders in `default()`, then +initializes the remaining render fields to `None` or empty collections. + +After this step, the simulation is reproducible before any rendering code runs. +The ball starts above the floor, settles into contact, and is ready to produce +its first `Started` event. + +### Step 6 — Attach Resources and Process Events + +Implement the component lifecycle and fixed-update loop. `on_attach()` should +build the render pass, bind group layout, combined mesh, per-body uniform +buffers, and render pipeline. The implementation uses one mesh for both bodies +and stores the floor and ball vertex ranges separately. + +Create the two render entries like this: + +```rust +let render_bodies = [ + ( + self.floor_body, + floor_vertices, + [0.22, 0.22, 0.24, 1.0], + [0.22, 0.22, 0.24, 1.0], + false, + ), + ( + self.ball_body, + ball_vertices, + [0.22, 0.55, 0.95, 1.0], + [0.95, 0.28, 0.22, 1.0], + true, + ), +]; +``` + +Handle keyboard input in `on_keyboard_event()` and only arm the launch when +the ball is already touching the floor: + +```rust +fn on_keyboard_event(&mut self, event: &Key) -> Result<(), String> { + let Key::Pressed { virtual_key, .. } = event else { + return Ok(()); + }; + + if virtual_key != &Some(VirtualKey::Space) { + return Ok(()); + } + + if !self.ball_contact_active { + println!("Space ignored: wait until the ball is resting on the floor"); + return Ok(()); + } + + self.pending_launch_impulse = true; + return Ok(()); +} +``` + +Drive physics and collision events from `on_update()`: + +```rust +fn on_update( + &mut self, + last_frame: &std::time::Duration, +) -> Result { + self.physics_accumulator_seconds += last_frame.as_secs_f32(); + + let timestep_seconds = self.physics_world.timestep_seconds(); + + while self.physics_accumulator_seconds >= timestep_seconds { + if self.pending_launch_impulse { + self + .ball_body + .set_velocity(&mut self.physics_world, 0.0, 0.0) + .map_err(|error| error.to_string())?; + self + .ball_body + .apply_impulse( + &mut self.physics_world, + 0.0, + BALL_LAUNCH_IMPULSE_Y, + ) + .map_err(|error| error.to_string())?; + self.pending_launch_impulse = false; + println!("Launch impulse applied"); + } + + self.physics_world.step(); + + for event in self.physics_world.collision_events() { + if self.is_ball_floor_event(event) { + self.log_ball_floor_event(event); + } + } + + self.physics_accumulator_seconds -= timestep_seconds; + } + + return Ok(ComponentResult::Success); +} +``` + +Resetting the ball velocity before applying the launch impulse keeps the +separation readable. Without that reset, the jump height depends on the exact +velocity the solver produced while the ball was settling. + +After this step, the demo has its core behavior. The ball falls, emits a +single `Started` event when contact begins, ignores `Space` until grounded, +and emits `Ended` when the launch separates the pair. + +### Step 7 — Render the Bodies and Start the Runtime + +Implement `on_render()` so each body reads its current transform from +`PhysicsWorld2D`, writes a fresh `ContactDemoUniform`, and draws its vertex +range. The ball is the only body that switches tint when +`ball_contact_active` is true. + +```rust +let tint = if body.highlights_contact && self.ball_contact_active { + body.tint_contact +} else { + body.tint_idle +}; + +let uniform = ContactDemoUniform { + offset_rotation: [position[0], position[1], rotation, 0.0], + tint, +}; + +body + .uniform_buffer + .write_value(render_context.gpu(), 0, &uniform); +``` + +Finish the file with a small `main()` that creates the runtime and window: + +```rust +fn main() { + let runtime = ApplicationRuntimeBuilder::new( + "Physics: 2D Collision Events", + ) + .with_window_configured_as(move |window_builder| { + return window_builder + .with_dimensions(WINDOW_WIDTH, WINDOW_HEIGHT) + .with_name("Physics: 2D Collision Events"); + }) + .with_component(move |runtime, demo: CollisionEvents2DDemo| { + return (runtime, demo); + }) + .build(); + + start_runtime(runtime); +} +``` + +After this step, the tutorial matches the checked-in demo. You can build and +run the binary and observe the floor-ball collision events in the terminal and +on screen. + +## Validation + +Build the demo: + +```bash +cargo build -p lambda-demos-physics --bin physics_collision_events_2d +``` + +Run the demo: + +```bash +cargo run -p lambda-demos-physics --bin physics_collision_events_2d +``` + +Expected behavior: + +- The terminal prints the controls hint when the component attaches. +- The ball falls onto the floor under gravity and eventually turns orange-red. +- The first contact prints `Collision Started:` with point, normal, and + penetration values when the backend provides them. +- Pressing `Space` before the ball settles prints + `Space ignored: wait until the ball is resting on the floor`. +- Pressing `Space` after the ball settles prints `Launch impulse applied`. +- When the ball leaves the floor, the terminal prints + `Collision Ended: ball left the floor`. +- When the ball lands again, a new `Collision Started:` line appears rather + than one line every frame. + +## Notes + +- The demo MUST drain `collision_events()` inside the fixed-update loop after + `PhysicsWorld2D::step()`. Draining later makes the event timing harder to + reason about. +- `CollisionEventKind::Started` SHOULD be treated as the place where contact + data is available. The demo prints a fallback message if the payload is not + present. +- `CollisionEventKind::Ended` MUST be handled without assuming a contact + point, normal, or penetration value exists. +- The tutorial SHOULD keep the scene to one body pair. That makes event + transitions easy to inspect while validating the API. +- The render path MAY stay minimal. The purpose of this demo is to show how + gameplay code reacts to collision events, not to demonstrate advanced + rendering patterns. + +## Conclusion + +You now have a complete collision-events reference demo that matches the code +checked into the repository. The example shows the intended pattern for +gameplay integration: use a fixed timestep, step the world, drain transition +events immediately, and derive simple game state from those transitions. + +Because the scene stays small, the tutorial also serves as a clean starting +point for later experiments with multiple bodies, collision filters, and query +APIs. + +## Exercises + +- Add a second ball and maintain separate contact state for each ball-floor + pair. +- Add a wall collider and print separate messages for ball-wall contact. +- Replace the terminal logging with a small on-screen event history. +- Add a second collider to the ball body and confirm the demo still reacts to + one body-pair event stream. +- Change the launch impulse based on how long the ball has been grounded. +- Extend the demo with a point query that highlights the ball when the mouse is + over it. + +## Changelog + +- 0.2.0 (2026-04-01): Rewrite the tutorial to match the implemented + `physics_collision_events_2d` demo and document the real build sequence. +- 0.1.0 (2026-04-01): Initial tutorial for `physics_collision_events_2d`.