gizmo_physics_core/collision.rs
1use gizmo_core::entity::Entity;
2use gizmo_math::Vec3;
3
4// ============================================================================
5// ContactPoint
6// ============================================================================
7
8/// A single contact point between two colliding bodies.
9///
10/// `normal` always points **from body A toward body B** (the separating
11/// direction for body A). Both `local_point_*` fields are populated by the
12/// dispatcher for warm-starting the constraint solver across frames.
13#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Default)]
14pub struct ContactPoint {
15 /// World-space contact position (midpoint on the contact surface).
16 pub point: Vec3,
17 /// Contact normal, pointing from A to B (unit vector).
18 pub normal: Vec3,
19 /// Penetration depth (always ≥ 0).
20 pub penetration: f32,
21 /// `point` expressed in body A's local space (set by the dispatcher).
22 pub local_point_a: Vec3,
23 /// `point` expressed in body B's local space (set by the dispatcher).
24 pub local_point_b: Vec3,
25 /// Accumulated normal impulse — reused for warm-starting.
26 pub normal_impulse: f32,
27 /// Accumulated tangential impulse — reused for warm-starting.
28 pub tangent_impulse: Vec3,
29}
30
31// ============================================================================
32// ContactManifold
33// ============================================================================
34
35/// Up to four contact points between a pair of bodies, along with the
36/// combined material properties needed by the constraint solver.
37///
38/// # Contact limit & point selection
39///
40/// Physics engines conventionally cap manifolds at **4 points** because that
41/// is the minimum required to fully constrain a convex face-face contact.
42/// When a 5th point would be added we keep the configuration that maximises
43/// the contact area while retaining the deepest point:
44///
45/// 1. Always keep the deepest point (most important for penetration resolution).
46/// 2. Fill the remaining 3 slots by greedily maximising the minimum distance
47/// to any already-selected point (farthest-point heuristic — O(n) per slot).
48///
49/// This gives a good approximation of the convex hull of the contact patch
50/// without an expensive full hull computation.
51#[derive(Debug, Clone)]
52pub struct ContactManifold {
53 pub entity_a: Entity,
54 pub entity_b: Entity,
55 /// At most 4 contact points.
56 pub contacts: Vec<ContactPoint>,
57 /// Combined dynamic friction coefficient (geometric mean of both materials).
58 pub friction: f32,
59 /// Combined static friction coefficient.
60 pub static_friction: f32,
61 /// Combined coefficient of restitution (max of both materials).
62 pub restitution: f32,
63 /// Number of consecutive physics frames this manifold has been alive.
64 /// Incremented by the pipeline each frame; reset when the collision ends.
65 pub lifetime: u32,
66}
67
68impl ContactManifold {
69 /// Create a new manifold. Entity order is normalised (lower id → entity_a)
70 /// so that cache lookups with either ordering always hit.
71 pub fn new(entity_a: Entity, entity_b: Entity) -> Self {
72 let (entity_a, entity_b) = if entity_a.id() <= entity_b.id() {
73 (entity_a, entity_b)
74 } else {
75 (entity_b, entity_a)
76 };
77 Self {
78 entity_a,
79 entity_b,
80 contacts: Vec::with_capacity(4),
81 // Sensible defaults; overwritten by the pipeline using
82 // PhysicsMaterial::combine before the solver runs.
83 friction: 0.5,
84 static_friction: 0.5,
85 restitution: 0.3,
86 lifetime: 0,
87 }
88 }
89
90 /// Add `contact` to the manifold, warm-starting from any existing point
91 /// that is within `MERGE_RADIUS` in world space.
92 ///
93 /// If the manifold is already at capacity (4 points) and no merge occurs,
94 /// the 5-point set is reduced back to 4 using the area-maximisation
95 /// heuristic described in the type-level docs.
96 pub fn add_contact(&mut self, contact: ContactPoint) {
97 const MERGE_RADIUS_SQ: f32 = 0.02 * 0.02;
98
99 // ── Warm-start merge ─────────────────────────────────────────────
100 for existing in &mut self.contacts {
101 if (existing.point - contact.point).length_squared() < MERGE_RADIUS_SQ {
102 // Update geometry but preserve accumulated impulses.
103 let saved_normal = existing.normal_impulse;
104 let saved_tangent = existing.tangent_impulse;
105 *existing = contact;
106 existing.normal_impulse = saved_normal;
107 existing.tangent_impulse = saved_tangent;
108 return;
109 }
110 }
111
112 // ── Fast path: still room ────────────────────────────────────────
113 if self.contacts.len() < 4 {
114 self.contacts.push(contact);
115 return;
116 }
117
118 // ── Reduce 5 → 4 with area-maximisation heuristic ────────────────
119 // Build a temporary 5-element array on the stack.
120 let mut pool = [ContactPoint::default(); 5];
121 pool[..4].copy_from_slice(&self.contacts);
122 pool[4] = contact;
123
124 self.contacts.clear();
125 self.contacts.extend_from_slice(&select_4_contacts(&pool));
126 }
127
128 /// Remove all contact points (does **not** reset `lifetime`).
129 pub fn clear(&mut self) {
130 self.contacts.clear();
131 }
132
133 /// Returns `true` if the manifold has not been refreshed within
134 /// `max_lifetime` frames — i.e. the collision pair has separated.
135 pub fn is_stale(&self, max_lifetime: u32) -> bool {
136 self.lifetime > max_lifetime
137 }
138}
139
140// ============================================================================
141// 4-point selection
142// ============================================================================
143
144/// Reduce `pool` (exactly 5 elements) to the 4 points that maximise the
145/// contact area:
146///
147/// 1. Pick the deepest point (index of maximum `penetration`).
148/// 2. Pick the point farthest from #1.
149/// 3. Pick the point farthest from the line #1–#2.
150/// 4. Pick the point that maximises the triangle area of the remaining set.
151///
152/// This is equivalent to a greedy farthest-point sampling and runs in O(1)
153/// (fixed pool size of 5).
154fn select_4_contacts(pool: &[ContactPoint; 5]) -> [ContactPoint; 4] {
155 // Step 1 — deepest point.
156 let i0 = (0..5)
157 .max_by(|&a, &b| pool[a].penetration.total_cmp(&pool[b].penetration))
158 .unwrap();
159
160 // Step 2 — farthest from i0.
161 let p0 = pool[i0].point;
162 let i1 = (0..5)
163 .filter(|&i| i != i0)
164 .max_by(|&a, &b| {
165 (pool[a].point - p0)
166 .length_squared()
167 .total_cmp(&(pool[b].point - p0).length_squared())
168 })
169 .unwrap();
170
171 // Step 3 — farthest from the line p0–p1.
172 let p1 = pool[i1].point;
173 let seg = (p1 - p0).normalize_or_zero();
174 let i2 = (0..5)
175 .filter(|&i| i != i0 && i != i1)
176 .max_by(|&a, &b| {
177 dist_sq_to_line(pool[a].point, p0, seg).total_cmp(&dist_sq_to_line(
178 pool[b].point,
179 p0,
180 seg,
181 ))
182 })
183 .unwrap();
184
185 // Step 4 — the remaining point that maximises the area of the
186 // quadrilateral formed by the 4 selected points.
187 //
188 // When the contact patch is coplanar (common case: face-on-face) the
189 // volume-based heuristic degenerates to zero. Instead, compute the
190 // sum of triangle areas from the candidate to every pair of
191 // already-selected points. This always picks the point that keeps
192 // the contact patch as spread-out as possible.
193 let p2 = pool[i2].point;
194 let i3 = (0..5)
195 .filter(|&i| i != i0 && i != i1 && i != i2)
196 .max_by(|&a, &b| {
197 let score = |idx: usize| -> f32 {
198 let q = pool[idx].point;
199 // Sum of cross-product magnitudes gives a good proxy for
200 // how much area the candidate adds to the patch.
201 (q - p0).cross(q - p1).length_squared()
202 + (q - p1).cross(q - p2).length_squared()
203 + (q - p2).cross(q - p0).length_squared()
204 };
205 score(a).total_cmp(&score(b))
206 })
207 .unwrap();
208
209 [pool[i0], pool[i1], pool[i2], pool[i3]]
210}
211
212/// Squared distance from `point` to the infinite line through `origin` along
213/// unit direction `dir`.
214#[inline]
215fn dist_sq_to_line(point: Vec3, origin: Vec3, dir: Vec3) -> f32 {
216 let d = point - origin;
217 let along = dir * d.dot(dir);
218 (d - along).length_squared()
219}
220
221// ============================================================================
222// Event types
223// ============================================================================
224
225/// Whether a collision pair has just begun, is ongoing, or has ended.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
227pub enum CollisionEventType {
228 /// First frame the pair is in contact.
229 Started,
230 /// Pair was already in contact last frame.
231 Persisting,
232 /// Pair is no longer in contact.
233 Ended,
234}
235
236/// Emitted every physics step for each solid collision pair.
237#[derive(Debug, Clone)]
238pub struct CollisionEvent {
239 pub entity_a: Entity,
240 pub entity_b: Entity,
241 pub event_type: CollisionEventType,
242 /// Solved contact points (populated after constraint resolution).
243 pub contact_points: arrayvec::ArrayVec<ContactPoint, 4>,
244}
245
246/// Emitted for trigger (non-solid) collider overlaps.
247#[derive(Debug, Clone)]
248pub struct TriggerEvent {
249 /// The entity whose collider has `is_trigger = true`.
250 pub trigger_entity: Entity,
251 pub other_entity: Entity,
252 pub event_type: CollisionEventType,
253}
254
255/// Emitted when a rigid body's fracture threshold is exceeded.
256#[derive(Debug, Clone, Copy)]
257pub struct FractureEvent {
258 pub entity: Entity,
259 pub impact_point: Vec3,
260 pub impact_force: f32,
261}
262
263// ============================================================================
264// Tests
265// ============================================================================
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 fn make_entity(id: u32) -> Entity {
272 Entity::new(id, 0)
273 }
274
275 fn pt(x: f32, y: f32, pen: f32) -> ContactPoint {
276 ContactPoint {
277 point: Vec3::new(x, y, 0.0),
278 normal: Vec3::Y,
279 penetration: pen,
280 ..Default::default()
281 }
282 }
283
284 // ── Entity ordering ───────────────────────────────────────────────────
285
286 #[test]
287 fn manifold_normalises_entity_order() {
288 let e_high = make_entity(10);
289 let e_low = make_entity(5);
290 let m = ContactManifold::new(e_high, e_low);
291 assert_eq!(m.entity_a.id(), 5);
292 assert_eq!(m.entity_b.id(), 10);
293 }
294
295 #[test]
296 fn manifold_same_order_when_already_sorted() {
297 let e1 = make_entity(1);
298 let e2 = make_entity(2);
299 let m = ContactManifold::new(e1, e2);
300 assert_eq!(m.entity_a.id(), 1);
301 assert_eq!(m.entity_b.id(), 2);
302 }
303
304 // ── Warm-start merge ──────────────────────────────────────────────────
305
306 #[test]
307 fn warm_start_preserves_impulses_on_merge() {
308 let mut m = ContactManifold::new(make_entity(1), make_entity(2));
309
310 let mut first = pt(1.0, 0.0, 0.1);
311 first.normal_impulse = 5.0;
312 first.tangent_impulse = Vec3::new(1.0, 0.0, 0.0);
313 m.add_contact(first);
314
315 // New contact is within the merge radius with updated geometry.
316 let updated = pt(1.001, 0.0, 0.2);
317 m.add_contact(updated);
318
319 assert_eq!(m.contacts.len(), 1, "near-duplicate should merge, not add");
320 assert_eq!(
321 m.contacts[0].normal_impulse, 5.0,
322 "accumulated normal impulse must be preserved"
323 );
324 assert_eq!(
325 m.contacts[0].tangent_impulse,
326 Vec3::new(1.0, 0.0, 0.0),
327 "accumulated tangent impulse must be preserved"
328 );
329 assert!(
330 (m.contacts[0].penetration - 0.2).abs() < 1e-6,
331 "geometry (penetration) must be updated"
332 );
333 }
334
335 // ── Contact capacity & area maximisation ──────────────────────────────
336
337 #[test]
338 fn contact_limit_enforced_at_4() {
339 let mut m = ContactManifold::new(make_entity(1), make_entity(2));
340 // 4 well-separated, equal-depth contacts.
341 m.add_contact(pt(0.0, 0.0, 1.0));
342 m.add_contact(pt(10.0, 0.0, 1.0));
343 m.add_contact(pt(0.0, 10.0, 1.0));
344 m.add_contact(pt(10.0, 10.0, 1.0));
345 assert_eq!(m.contacts.len(), 4);
346
347 // 5th point — shallow, near point #0; should be the one dropped.
348 m.add_contact(pt(0.5, 0.5, 0.1));
349 assert_eq!(m.contacts.len(), 4, "must stay at 4 contacts");
350
351 // The shallow interloper should not survive.
352 assert!(
353 !m.contacts
354 .iter()
355 .any(|c| (c.penetration - 0.1).abs() < 1e-6),
356 "shallowest near-duplicate contact should be dropped"
357 );
358 }
359
360 #[test]
361 fn deepest_contact_always_retained() {
362 let mut m = ContactManifold::new(make_entity(1), make_entity(2));
363 m.add_contact(pt(0.0, 0.0, 0.5));
364 m.add_contact(pt(1.0, 0.0, 0.5));
365 m.add_contact(pt(0.0, 1.0, 0.5));
366 m.add_contact(pt(1.0, 1.0, 0.5));
367
368 // Add a new point with extreme penetration.
369 m.add_contact(pt(0.5, 0.5, 99.0));
370
371 assert!(
372 m.contacts
373 .iter()
374 .any(|c| (c.penetration - 99.0).abs() < 1e-6),
375 "deepest contact must always be retained"
376 );
377 }
378
379 // ── Staleness ─────────────────────────────────────────────────────────
380
381 #[test]
382 fn is_stale_respects_lifetime() {
383 let mut m = ContactManifold::new(make_entity(1), make_entity(2));
384 assert!(!m.is_stale(3));
385 m.lifetime = 4;
386 assert!(m.is_stale(3));
387 m.lifetime = 3;
388 assert!(!m.is_stale(3));
389 }
390
391 // ── Clear ─────────────────────────────────────────────────────────────
392
393 #[test]
394 fn clear_removes_contacts_but_not_lifetime() {
395 let mut m = ContactManifold::new(make_entity(1), make_entity(2));
396 m.add_contact(pt(0.0, 0.0, 1.0));
397 m.lifetime = 7;
398 m.clear();
399 assert!(m.contacts.is_empty(), "contacts should be cleared");
400 assert_eq!(m.lifetime, 7, "lifetime must not be touched by clear()");
401 }
402
403 // ── select_4_contacts ─────────────────────────────────────────────────
404
405 #[test]
406 fn select_4_keeps_deepest_and_maximises_spread() {
407 // Arrange 5 points: 4 at corners of a 10×10 square (depth 1.0)
408 // and one very deep point at the centre.
409 let pool = [
410 pt(0.0, 0.0, 1.0),
411 pt(10.0, 0.0, 1.0),
412 pt(0.0, 10.0, 1.0),
413 pt(10.0, 10.0, 1.0),
414 pt(5.0, 5.0, 5.0), // deepest, at centre
415 ];
416 let result = select_4_contacts(&pool);
417
418 // The deepest point (centre, pen=5.0) must be in the result.
419 assert!(
420 result.iter().any(|c| (c.penetration - 5.0).abs() < 1e-6),
421 "deepest point must be selected"
422 );
423 // All 4 must be distinct (no duplicates).
424 for i in 0..4 {
425 for j in (i + 1)..4 {
426 assert_ne!(
427 result[i].point, result[j].point,
428 "selected contacts must be distinct"
429 );
430 }
431 }
432 }
433}