Skip to content

Commit c0f17be

Browse files
committed
[add] collision end events
1 parent 4957fb8 commit c0f17be

3 files changed

Lines changed: 239 additions & 3 deletions

File tree

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ pub struct PhysicsBackend2D {
264264
rigid_body_slots_2d: Vec<RigidBodySlot2D>,
265265
collider_slots_2d: Vec<ColliderSlot2D>,
266266
active_body_pairs_2d: HashSet<BodyPairKey2D>,
267+
active_body_pair_order_2d: Vec<BodyPairKey2D>,
267268
queued_collision_events_2d: Vec<CollisionEvent2DBackend>,
268269
}
269270

@@ -305,6 +306,7 @@ impl PhysicsBackend2D {
305306
rigid_body_slots_2d: Vec::new(),
306307
collider_slots_2d: Vec::new(),
307308
active_body_pairs_2d: HashSet::new(),
309+
active_body_pair_order_2d: Vec::new(),
308310
queued_collision_events_2d: Vec::new(),
309311
};
310312
}
@@ -1291,8 +1293,9 @@ impl PhysicsBackend2D {
12911293
/// The public API reports one event per body pair, not one event per collider
12921294
/// pair. This pass aggregates Rapier collider contacts by owning bodies,
12931295
/// 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+
/// resulting active set against the previous step to detect both newly
1297+
/// started and newly ended contacts without emitting collider-pair
1298+
/// duplicates for compound bodies.
12961299
///
12971300
/// # Returns
12981301
/// Returns `()` after appending any newly-started events to the backend
@@ -1352,8 +1355,28 @@ impl PhysicsBackend2D {
13521355
});
13531356
}
13541357

1358+
for body_pair_key in self.active_body_pair_order_2d.iter().copied() {
1359+
if current_body_pair_contacts.contains_key(&body_pair_key) {
1360+
continue;
1361+
}
1362+
1363+
self
1364+
.queued_collision_events_2d
1365+
.push(CollisionEvent2DBackend {
1366+
kind: CollisionEventKind2DBackend::Ended,
1367+
body_a_slot_index: body_pair_key.body_a_slot_index,
1368+
body_a_slot_generation: body_pair_key.body_a_slot_generation,
1369+
body_b_slot_index: body_pair_key.body_b_slot_index,
1370+
body_b_slot_generation: body_pair_key.body_b_slot_generation,
1371+
contact_point: None,
1372+
normal: None,
1373+
penetration: None,
1374+
});
1375+
}
1376+
13551377
self.active_body_pairs_2d =
13561378
current_body_pair_contacts.keys().copied().collect();
1379+
self.active_body_pair_order_2d = current_body_pair_order;
13571380

13581381
return;
13591382
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ impl PhysicsWorld2D {
158158
/// until gameplay code asks for them. Draining here keeps the simulation step
159159
/// free of user callbacks and makes event consumption explicit, which is
160160
/// easier to integrate into fixed-update loops than re-entrant callback
161-
/// dispatch during contact resolution.
161+
/// dispatch during contact resolution. Events remain queued across multiple
162+
/// `step()` calls until this method drains them.
162163
///
163164
/// # Returns
164165
/// Returns an iterator over the queued collision events, draining the queue

crates/lambda-rs/tests/physics_2d/collision_events.rs

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,52 @@ fn build_ball(world: &mut PhysicsWorld2D) -> RigidBody2D {
5555
return ball;
5656
}
5757

58+
/// Creates a static body with two overlapping circle colliders.
59+
///
60+
/// # Arguments
61+
/// - `world`: The world that will own the compound body.
62+
///
63+
/// # Returns
64+
/// Returns the created compound rigid body handle.
65+
fn build_compound_circle_body(world: &mut PhysicsWorld2D) -> RigidBody2D {
66+
let body = RigidBody2DBuilder::new(RigidBodyType::Static)
67+
.build(world)
68+
.unwrap();
69+
70+
Collider2DBuilder::circle(0.5)
71+
.with_offset(-0.25, 0.0)
72+
.build(world, body)
73+
.unwrap();
74+
Collider2DBuilder::circle(0.5)
75+
.with_offset(0.25, 0.0)
76+
.build(world, body)
77+
.unwrap();
78+
79+
return body;
80+
}
81+
82+
/// Creates a dynamic ball already positioned in overlap.
83+
///
84+
/// # Arguments
85+
/// - `world`: The world that will own the body.
86+
/// - `position`: The initial body position in meters.
87+
///
88+
/// # Returns
89+
/// Returns the created rigid body handle.
90+
fn build_overlapping_ball(
91+
world: &mut PhysicsWorld2D,
92+
position: [f32; 2],
93+
) -> RigidBody2D {
94+
let ball = RigidBody2DBuilder::new(RigidBodyType::Dynamic)
95+
.with_position(position[0], position[1])
96+
.build(world)
97+
.unwrap();
98+
99+
Collider2DBuilder::circle(0.5).build(world, ball).unwrap();
100+
101+
return ball;
102+
}
103+
58104
/// Steps until at least one collision event is produced.
59105
///
60106
/// # Arguments
@@ -79,6 +125,33 @@ fn step_until_collision_events(
79125
panic!("expected collision events within {max_steps} steps");
80126
}
81127

128+
/// Steps until a collision event of the requested kind is produced.
129+
///
130+
/// # Arguments
131+
/// - `world`: The world to step.
132+
/// - `kind`: The event kind to wait for.
133+
/// - `max_steps`: The maximum number of steps to attempt.
134+
///
135+
/// # Returns
136+
/// Returns all drained events from the first step that produced the requested
137+
/// event kind.
138+
fn step_until_collision_event_kind(
139+
world: &mut PhysicsWorld2D,
140+
kind: CollisionEventKind,
141+
max_steps: u32,
142+
) -> Vec<CollisionEvent> {
143+
for _ in 0..max_steps {
144+
world.step();
145+
146+
let events: Vec<CollisionEvent> = world.collision_events().collect();
147+
if events.iter().any(|event| event.kind == kind) {
148+
return events;
149+
}
150+
}
151+
152+
panic!("expected {kind:?} event within {max_steps} steps");
153+
}
154+
82155
/// Ensures first contact emits a single `Started` event.
83156
#[test]
84157
fn physics_2d_collision_events_first_contact_emits_started() {
@@ -169,3 +242,142 @@ fn physics_2d_collision_events_started_includes_contact_data() {
169242

170243
return;
171244
}
245+
246+
/// Ensures separation emits one `Ended` event.
247+
#[test]
248+
fn physics_2d_collision_events_separation_emits_ended() {
249+
let mut world = PhysicsWorld2DBuilder::new()
250+
.with_gravity(0.0, 0.0)
251+
.build()
252+
.unwrap();
253+
254+
let ground = build_ground(&mut world);
255+
let ball = build_overlapping_ball(&mut world, [0.0, 0.0]);
256+
257+
let started_events =
258+
step_until_collision_event_kind(&mut world, CollisionEventKind::Started, 1);
259+
assert_eq!(
260+
started_events
261+
.iter()
262+
.filter(|event| event.kind == CollisionEventKind::Started)
263+
.count(),
264+
1,
265+
);
266+
267+
ball.set_position(&mut world, 0.0, 4.0).unwrap();
268+
let ended_events =
269+
step_until_collision_event_kind(&mut world, CollisionEventKind::Ended, 1);
270+
let ended_event = ended_events
271+
.into_iter()
272+
.find(|event| event.kind == CollisionEventKind::Ended)
273+
.unwrap();
274+
275+
assert_eq!(ended_event.body_a, ground);
276+
assert_eq!(ended_event.body_b, ball);
277+
278+
return;
279+
}
280+
281+
/// Ensures `Ended` omits contact payload fields.
282+
#[test]
283+
fn physics_2d_collision_events_ended_has_no_contact_payload() {
284+
let mut world = PhysicsWorld2DBuilder::new()
285+
.with_gravity(0.0, 0.0)
286+
.build()
287+
.unwrap();
288+
289+
build_ground(&mut world);
290+
let ball = build_overlapping_ball(&mut world, [0.0, 0.0]);
291+
292+
step_until_collision_event_kind(&mut world, CollisionEventKind::Started, 1);
293+
ball.set_position(&mut world, 0.0, 4.0).unwrap();
294+
295+
let ended_event =
296+
step_until_collision_event_kind(&mut world, CollisionEventKind::Ended, 1)
297+
.into_iter()
298+
.find(|event| event.kind == CollisionEventKind::Ended)
299+
.unwrap();
300+
301+
assert_eq!(ended_event.contact_point, None);
302+
assert_eq!(ended_event.normal, None);
303+
assert_eq!(ended_event.penetration, None);
304+
305+
return;
306+
}
307+
308+
/// Ensures compound colliders still emit one event per body pair.
309+
#[test]
310+
fn physics_2d_collision_events_compound_colliders_emit_one_body_pair_event() {
311+
let mut world = PhysicsWorld2DBuilder::new()
312+
.with_gravity(0.0, 0.0)
313+
.build()
314+
.unwrap();
315+
316+
let compound_body = build_compound_circle_body(&mut world);
317+
let ball = build_overlapping_ball(&mut world, [0.0, 0.0]);
318+
319+
let started_events =
320+
step_until_collision_event_kind(&mut world, CollisionEventKind::Started, 1);
321+
322+
assert_eq!(
323+
started_events
324+
.iter()
325+
.filter(|event| event.kind == CollisionEventKind::Started)
326+
.count(),
327+
1,
328+
);
329+
assert_eq!(
330+
started_events
331+
.iter()
332+
.find(|event| event.kind == CollisionEventKind::Started)
333+
.unwrap()
334+
.body_a,
335+
compound_body,
336+
);
337+
assert_eq!(
338+
started_events
339+
.iter()
340+
.find(|event| event.kind == CollisionEventKind::Started)
341+
.unwrap()
342+
.body_b,
343+
ball,
344+
);
345+
346+
ball.set_position(&mut world, 3.0, 0.0).unwrap();
347+
let ended_events =
348+
step_until_collision_event_kind(&mut world, CollisionEventKind::Ended, 1);
349+
350+
assert_eq!(
351+
ended_events
352+
.iter()
353+
.filter(|event| event.kind == CollisionEventKind::Ended)
354+
.count(),
355+
1,
356+
);
357+
358+
return;
359+
}
360+
361+
/// Ensures queued events survive multiple steps until drained.
362+
#[test]
363+
fn physics_2d_collision_events_preserve_queue_across_multiple_steps() {
364+
let mut world = PhysicsWorld2DBuilder::new()
365+
.with_gravity(0.0, 0.0)
366+
.build()
367+
.unwrap();
368+
369+
build_ground(&mut world);
370+
let ball = build_overlapping_ball(&mut world, [0.0, 0.0]);
371+
372+
world.step();
373+
ball.set_position(&mut world, 0.0, 4.0).unwrap();
374+
world.step();
375+
376+
let events: Vec<CollisionEvent> = world.collision_events().collect();
377+
378+
assert_eq!(events.len(), 2);
379+
assert_eq!(events[0].kind, CollisionEventKind::Started);
380+
assert_eq!(events[1].kind, CollisionEventKind::Ended);
381+
382+
return;
383+
}

0 commit comments

Comments
 (0)