Skip to content

Commit 4957fb8

Browse files
committed
[add] physics collision start events.
1 parent f62670b commit 4957fb8

5 files changed

Lines changed: 533 additions & 3 deletions

File tree

crates/lambda-rs-platform/src/physics/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ pub mod rapier2d;
88

99
pub use rapier2d::{
1010
Collider2DBackendError,
11+
CollisionEvent2DBackend,
12+
CollisionEventKind2DBackend,
1113
PhysicsBackend2D,
1214
RaycastHit2DBackend,
1315
RigidBody2DBackendError,

crates/lambda-rs-platform/src/physics/rapier2d.rs

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
//! of the platform layer.
66
77
use std::{
8+
collections::{
9+
HashMap,
10+
HashSet,
11+
},
812
error::Error,
913
fmt,
1014
};
@@ -118,6 +122,36 @@ pub struct RaycastHit2DBackend {
118122
pub distance: f32,
119123
}
120124

125+
/// Indicates whether a backend collision pair started or ended contact.
126+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127+
pub enum CollisionEventKind2DBackend {
128+
/// The body pair started touching during the current backend step.
129+
Started,
130+
/// The body pair stopped touching during the current backend step.
131+
Ended,
132+
}
133+
134+
/// Backend-agnostic data describing one 2D collision event.
135+
#[derive(Debug, Clone, Copy, PartialEq)]
136+
pub struct CollisionEvent2DBackend {
137+
/// The transition kind for the body pair.
138+
pub kind: CollisionEventKind2DBackend,
139+
/// The first rigid body's slot index.
140+
pub body_a_slot_index: u32,
141+
/// The first rigid body's slot generation.
142+
pub body_a_slot_generation: u32,
143+
/// The second rigid body's slot index.
144+
pub body_b_slot_index: u32,
145+
/// The second rigid body's slot generation.
146+
pub body_b_slot_generation: u32,
147+
/// The representative world-space contact point, when available.
148+
pub contact_point: Option<[f32; 2]>,
149+
/// The representative world-space contact normal, when available.
150+
pub normal: Option<[f32; 2]>,
151+
/// The representative penetration depth, when available.
152+
pub penetration: Option<f32>,
153+
}
154+
121155
/// The fallback mass applied to dynamic bodies before density colliders exist.
122156
const DYNAMIC_BODY_FALLBACK_MASS_KG: f32 = 1.0;
123157

@@ -188,6 +222,30 @@ struct ColliderAttachmentMassPlan2D {
188222
should_remove_fallback_mass: bool,
189223
}
190224

225+
/// A normalized body-pair key used for backend collision tracking.
226+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
227+
struct BodyPairKey2D {
228+
/// The first body slot index.
229+
body_a_slot_index: u32,
230+
/// The first body slot generation.
231+
body_a_slot_generation: u32,
232+
/// The second body slot index.
233+
body_b_slot_index: u32,
234+
/// The second body slot generation.
235+
body_b_slot_generation: u32,
236+
}
237+
238+
/// The representative contact selected for a body pair during one step.
239+
#[derive(Debug, Clone, Copy, PartialEq)]
240+
struct BodyPairContact2D {
241+
/// The representative world-space contact point.
242+
point: [f32; 2],
243+
/// The representative world-space normal from body A toward body B.
244+
normal: [f32; 2],
245+
/// The non-negative penetration depth.
246+
penetration: f32,
247+
}
248+
191249
/// A 2D physics backend powered by `rapier2d`.
192250
///
193251
/// This type is an internal implementation detail used by `lambda-rs`.
@@ -205,6 +263,8 @@ pub struct PhysicsBackend2D {
205263
pipeline: PhysicsPipeline,
206264
rigid_body_slots_2d: Vec<RigidBodySlot2D>,
207265
collider_slots_2d: Vec<ColliderSlot2D>,
266+
active_body_pairs_2d: HashSet<BodyPairKey2D>,
267+
queued_collision_events_2d: Vec<CollisionEvent2DBackend>,
208268
}
209269

210270
impl PhysicsBackend2D {
@@ -244,6 +304,8 @@ impl PhysicsBackend2D {
244304
pipeline: PhysicsPipeline::new(),
245305
rigid_body_slots_2d: Vec::new(),
246306
collider_slots_2d: Vec::new(),
307+
active_body_pairs_2d: HashSet::new(),
308+
queued_collision_events_2d: Vec::new(),
247309
};
248310
}
249311

@@ -1048,9 +1110,24 @@ impl PhysicsBackend2D {
10481110
&(),
10491111
);
10501112

1113+
self.collect_collision_events_2d();
1114+
10511115
return;
10521116
}
10531117

1118+
/// Drains backend collision events queued by prior step calls.
1119+
///
1120+
/// The backend accumulates transition events across substeps so
1121+
/// `PhysicsWorld2D` can expose one post-step drain point without exposing
1122+
/// Rapier's event machinery or borrowing backend internals through the
1123+
/// public iterator.
1124+
///
1125+
/// # Returns
1126+
/// Returns all queued backend collision events in insertion order.
1127+
pub fn drain_collision_events_2d(&mut self) -> Vec<CollisionEvent2DBackend> {
1128+
return std::mem::take(&mut self.queued_collision_events_2d);
1129+
}
1130+
10541131
/// Returns an immutable reference to a rigid body slot after validation.
10551132
///
10561133
/// # Arguments
@@ -1209,6 +1286,78 @@ impl PhysicsBackend2D {
12091286
return;
12101287
}
12111288

1289+
/// Collects body-pair collision transitions from the current narrow phase.
1290+
///
1291+
/// The public API reports one event per body pair, not one event per collider
1292+
/// pair. This pass aggregates Rapier collider contacts by owning bodies,
1293+
/// keeps the deepest active contact seen for each body pair, and compares the
1294+
/// resulting active set against the previous step to detect newly-started
1295+
/// contacts without repeating `Started` every frame.
1296+
///
1297+
/// # Returns
1298+
/// Returns `()` after appending any newly-started events to the backend
1299+
/// queue.
1300+
fn collect_collision_events_2d(&mut self) {
1301+
let mut current_body_pair_contacts: HashMap<
1302+
BodyPairKey2D,
1303+
BodyPairContact2D,
1304+
> = HashMap::new();
1305+
let mut current_body_pair_order = Vec::new();
1306+
1307+
for contact_pair in self.narrow_phase.contact_pairs() {
1308+
if !contact_pair.has_any_active_contact() {
1309+
continue;
1310+
}
1311+
1312+
let Some((body_pair_key, body_pair_contact)) =
1313+
self.body_pair_contact_from_contact_pair_2d(contact_pair)
1314+
else {
1315+
continue;
1316+
};
1317+
1318+
if let Some(existing_contact) =
1319+
current_body_pair_contacts.get_mut(&body_pair_key)
1320+
{
1321+
if body_pair_contact.penetration > existing_contact.penetration {
1322+
*existing_contact = body_pair_contact;
1323+
}
1324+
1325+
continue;
1326+
}
1327+
1328+
current_body_pair_order.push(body_pair_key);
1329+
current_body_pair_contacts.insert(body_pair_key, body_pair_contact);
1330+
}
1331+
1332+
for body_pair_key in current_body_pair_order.iter().copied() {
1333+
if self.active_body_pairs_2d.contains(&body_pair_key) {
1334+
continue;
1335+
}
1336+
1337+
let Some(contact) = current_body_pair_contacts.get(&body_pair_key) else {
1338+
continue;
1339+
};
1340+
1341+
self
1342+
.queued_collision_events_2d
1343+
.push(CollisionEvent2DBackend {
1344+
kind: CollisionEventKind2DBackend::Started,
1345+
body_a_slot_index: body_pair_key.body_a_slot_index,
1346+
body_a_slot_generation: body_pair_key.body_a_slot_generation,
1347+
body_b_slot_index: body_pair_key.body_b_slot_index,
1348+
body_b_slot_generation: body_pair_key.body_b_slot_generation,
1349+
contact_point: Some(contact.point),
1350+
normal: Some(contact.normal),
1351+
penetration: Some(contact.penetration),
1352+
});
1353+
}
1354+
1355+
self.active_body_pairs_2d =
1356+
current_body_pair_contacts.keys().copied().collect();
1357+
1358+
return;
1359+
}
1360+
12121361
/// Attaches a prepared Rapier collider builder to a parent rigid body.
12131362
///
12141363
/// This helper applies the shared local transform and material properties,
@@ -1424,6 +1573,7 @@ impl PhysicsBackend2D {
14241573

14251574
return;
14261575
}
1576+
14271577
/// Resolves a query hit collider back to its owning rigid body slot.
14281578
///
14291579
/// # Arguments
@@ -1446,6 +1596,41 @@ impl PhysicsBackend2D {
14461596
collider_slot.parent_slot_generation,
14471597
));
14481598
}
1599+
1600+
/// Resolves one Rapier contact pair into a normalized body-pair contact.
1601+
///
1602+
/// Rapier stores contacts per collider pair. The public API is body-oriented,
1603+
/// so this helper maps each collider pair back to its owning bodies, discards
1604+
/// self-contacts, normalizes body ordering for stable deduplication, and
1605+
/// returns the deepest active solver contact for that body pair.
1606+
///
1607+
/// # Arguments
1608+
/// - `contact_pair`: The Rapier contact pair to inspect.
1609+
///
1610+
/// # Returns
1611+
/// Returns the normalized body-pair key and representative contact when the
1612+
/// pair belongs to two distinct tracked bodies and has at least one active
1613+
/// solver contact.
1614+
fn body_pair_contact_from_contact_pair_2d(
1615+
&self,
1616+
contact_pair: &ContactPair,
1617+
) -> Option<(BodyPairKey2D, BodyPairContact2D)> {
1618+
let body_a =
1619+
self.query_hit_to_parent_body_slot_2d(contact_pair.collider1)?;
1620+
let body_b =
1621+
self.query_hit_to_parent_body_slot_2d(contact_pair.collider2)?;
1622+
1623+
if body_a == body_b {
1624+
return None;
1625+
}
1626+
1627+
let (body_pair_key, should_flip_normal) =
1628+
normalize_body_pair_key_2d(body_a, body_b);
1629+
let representative_contact =
1630+
representative_contact_from_pair_2d(contact_pair, should_flip_normal)?;
1631+
1632+
return Some((body_pair_key, representative_contact));
1633+
}
14491634
}
14501635

14511636
/// Builds a Rapier rigid body builder with `lambda-rs` invariants applied.
@@ -1522,6 +1707,48 @@ fn build_collision_groups_2d(
15221707
);
15231708
}
15241709

1710+
/// Normalizes a body-pair key into a stable `body_a`/`body_b` ordering.
1711+
///
1712+
/// Stable ordering lets the backend deduplicate collider contacts that belong
1713+
/// to the same bodies and keeps the public event stream from oscillating based
1714+
/// on Rapier's internal pair ordering. The returned boolean tells callers
1715+
/// whether normals reported from collider/body 1 toward collider/body 2 must be
1716+
/// flipped to match the normalized body ordering.
1717+
///
1718+
/// # Arguments
1719+
/// - `body_a`: The first raw backend body slot pair.
1720+
/// - `body_b`: The second raw backend body slot pair.
1721+
///
1722+
/// # Returns
1723+
/// Returns the normalized body-pair key and whether contact normals should be
1724+
/// flipped to point from normalized body A toward normalized body B.
1725+
fn normalize_body_pair_key_2d(
1726+
body_a: (u32, u32),
1727+
body_b: (u32, u32),
1728+
) -> (BodyPairKey2D, bool) {
1729+
if body_a <= body_b {
1730+
return (
1731+
BodyPairKey2D {
1732+
body_a_slot_index: body_a.0,
1733+
body_a_slot_generation: body_a.1,
1734+
body_b_slot_index: body_b.0,
1735+
body_b_slot_generation: body_b.1,
1736+
},
1737+
false,
1738+
);
1739+
}
1740+
1741+
return (
1742+
BodyPairKey2D {
1743+
body_a_slot_index: body_b.0,
1744+
body_a_slot_generation: body_b.1,
1745+
body_b_slot_index: body_a.0,
1746+
body_b_slot_generation: body_a.1,
1747+
},
1748+
true,
1749+
);
1750+
}
1751+
15251752
/// Validates a 2D position.
15261753
///
15271754
/// # Arguments
@@ -1601,6 +1828,58 @@ fn normalize_query_vector_2d(vector: [f32; 2]) -> Option<[f32; 2]> {
16011828
return Some([vector[0] / length, vector[1] / length]);
16021829
}
16031830

1831+
/// Selects the deepest active solver contact from one Rapier contact pair.
1832+
///
1833+
/// Rapier's collider pair may expose several manifolds and several active
1834+
/// solver contacts per manifold. The public start event only needs one
1835+
/// representative contact, so this helper keeps the active solver contact with
1836+
/// the greatest penetration depth. Solver contacts are used instead of raw
1837+
/// tracked contacts because they already provide world-space points and reflect
1838+
/// the contacts that actually participated in collision resolution this step.
1839+
///
1840+
/// # Arguments
1841+
/// - `contact_pair`: The Rapier contact pair to inspect.
1842+
/// - `should_flip_normal`: Whether the selected normal should be inverted to
1843+
/// point from normalized body A toward normalized body B.
1844+
///
1845+
/// # Returns
1846+
/// Returns the deepest active solver contact for the pair, if one exists.
1847+
fn representative_contact_from_pair_2d(
1848+
contact_pair: &ContactPair,
1849+
should_flip_normal: bool,
1850+
) -> Option<BodyPairContact2D> {
1851+
let mut representative_contact = None;
1852+
1853+
for manifold in &contact_pair.manifolds {
1854+
let mut normal = [manifold.data.normal.x, manifold.data.normal.y];
1855+
1856+
if should_flip_normal {
1857+
normal = [-normal[0], -normal[1]];
1858+
}
1859+
1860+
for solver_contact in &manifold.data.solver_contacts {
1861+
let penetration = (-solver_contact.dist).max(0.0);
1862+
let candidate_contact = BodyPairContact2D {
1863+
point: [solver_contact.point.x, solver_contact.point.y],
1864+
normal,
1865+
penetration,
1866+
};
1867+
1868+
if representative_contact.as_ref().is_some_and(
1869+
|existing: &BodyPairContact2D| {
1870+
candidate_contact.penetration <= existing.penetration
1871+
},
1872+
) {
1873+
continue;
1874+
}
1875+
1876+
representative_contact = Some(candidate_contact);
1877+
}
1878+
}
1879+
1880+
return representative_contact;
1881+
}
1882+
16041883
/// Casts a ray against one live collider and normalizes the reported normal.
16051884
///
16061885
/// When the origin lies inside a collider, Parry may report a zero normal for

0 commit comments

Comments
 (0)