1use super::aabb::{Aabb, Hit, sweep_aabb};
4use super::collider_set::ColliderSet;
5use crate::math::Vec2;
6
7const MAX_SLIDE_PASSES: u32 = 2;
11
12#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct CollisionConfig {
19 pub up: Vec2,
23 pub skin: f32,
27 pub snap_distance: f32,
29 pub normal_threshold: f32,
32 pub block_epsilon: f32,
36}
37
38impl CollisionConfig {
39 #[must_use]
47 pub fn new(
48 up: Vec2,
49 skin: f32,
50 snap_distance: f32,
51 normal_threshold: f32,
52 block_epsilon: f32,
53 ) -> Self {
54 assert!(
55 up.is_finite() && up.length_squared() > 0.0,
56 "CollisionConfig: up must be a finite non-zero direction, got {up}"
57 );
58 assert!(
59 normal_threshold > 0.0 && normal_threshold < 1.0,
60 "CollisionConfig: normal threshold must be in (0, 1), got {normal_threshold}"
61 );
62 Self {
63 up: up.normalize(),
64 skin,
65 snap_distance,
66 normal_threshold,
67 block_epsilon,
68 }
69 }
70
71 #[must_use]
73 pub fn is_floor(&self, normal: Vec2) -> bool {
74 normal.dot(self.up) > self.normal_threshold
75 }
76
77 #[must_use]
79 pub fn is_ceiling(&self, normal: Vec2) -> bool {
80 normal.dot(self.up) < -self.normal_threshold
81 }
82}
83
84#[allow(clippy::struct_excessive_bools)]
91#[derive(Debug, Clone, Copy, Default, PartialEq)]
92pub struct Contacts {
93 pub floor: bool,
95 pub ceiling: bool,
97 pub wall: bool,
99 pub last_floor_normal: Vec2,
101 pub last_ceiling_normal: Vec2,
103 pub desired: Vec2,
105 pub effective: Vec2,
107 pub down_blocked: bool,
110 pub up_blocked: bool,
112}
113
114#[must_use]
129pub fn move_and_collide(
130 body: Aabb,
131 delta: Vec2,
132 set: &ColliderSet,
133 config: &CollisionConfig,
134) -> (Vec2, Contacts) {
135 let mut contacts = Contacts {
136 desired: delta,
137 ..Contacts::default()
138 };
139 if !delta.is_finite() {
140 return (Vec2::ZERO, contacts);
141 }
142
143 let mut body = body;
144 let mut remaining = delta;
145 let mut total = Vec2::ZERO;
146
147 for _ in 0..MAX_SLIDE_PASSES {
148 if remaining.abs_diff_eq(Vec2::ZERO, f32::EPSILON) {
149 break;
150 }
151
152 let Some(hit) = earliest_hit(&body, remaining, set, config, |_| true) else {
153 total += remaining;
155 break;
156 };
157
158 let move_to = remaining * hit.t;
159 let safe = back_off(move_to, hit.normal, config.skin);
160 total += safe;
161 body = body.translated(safe);
162 classify(&mut contacts, hit.normal, config);
163
164 let leftover = remaining - move_to;
166 remaining = leftover - hit.normal * leftover.dot(hit.normal);
167 }
168
169 contacts.effective = total;
170
171 let desired_up = delta.dot(config.up);
174 let effective_up = total.dot(config.up);
175 contacts.down_blocked = desired_up < 0.0 && effective_up > desired_up + config.block_epsilon;
176 contacts.up_blocked = desired_up > 0.0 && effective_up < desired_up - config.block_epsilon;
177
178 (total, contacts)
179}
180
181#[must_use]
189pub fn snap_to_ground(body: Aabb, set: &ColliderSet, config: &CollisionConfig) -> (Vec2, Contacts) {
190 let probe = -config.up * config.snap_distance;
191 let mut contacts = Contacts {
192 desired: probe,
193 ..Contacts::default()
194 };
195
196 let Some(hit) = earliest_hit(&body, probe, set, config, |normal| config.is_floor(normal))
197 else {
198 return (Vec2::ZERO, contacts);
199 };
200
201 let safe = back_off(probe * hit.t, hit.normal, config.skin);
202 contacts.effective = safe;
203 classify(&mut contacts, hit.normal, config);
204 (safe, contacts)
205}
206
207fn earliest_hit(
210 body: &Aabb,
211 delta: Vec2,
212 set: &ColliderSet,
213 config: &CollisionConfig,
214 accept: impl Fn(Vec2) -> bool,
215) -> Option<Hit> {
216 let mut best: Option<Hit> = None;
217 for index in set.query(&swept_region(body, delta)) {
218 let Some(collider) = set.get(index) else {
219 continue;
220 };
221 if collider.flags.sensor || !collider.flags.solid {
222 continue;
223 }
224 let Some(hit) = sweep_aabb(body, delta, &collider.aabb) else {
225 continue;
226 };
227 if !accept(hit.normal) {
228 continue;
229 }
230 if collider.flags.one_way && !one_way_blocks(hit.normal, delta, config) {
231 continue;
232 }
233 if best.is_none_or(|previous| hit.t < previous.t) {
234 best = Some(hit);
235 }
236 }
237 best
238}
239
240fn one_way_blocks(normal: Vec2, motion: Vec2, config: &CollisionConfig) -> bool {
243 config.is_floor(normal) && motion.dot(config.up) < 0.0
244}
245
246fn classify(contacts: &mut Contacts, normal: Vec2, config: &CollisionConfig) {
248 if config.is_floor(normal) {
249 contacts.floor = true;
250 contacts.last_floor_normal = normal;
251 } else if config.is_ceiling(normal) {
252 contacts.ceiling = true;
253 contacts.last_ceiling_normal = normal;
254 } else {
255 contacts.wall = true;
256 }
257}
258
259fn back_off(move_to: Vec2, normal: Vec2, skin: f32) -> Vec2 {
262 if normal.x.abs() > normal.y.abs() {
265 Vec2::new(reduce_toward_zero(move_to.x, skin), move_to.y)
266 } else {
267 Vec2::new(move_to.x, reduce_toward_zero(move_to.y, skin))
268 }
269}
270
271fn reduce_toward_zero(value: f32, amount: f32) -> f32 {
275 if value >= 0.0 {
276 (value - amount).max(0.0)
277 } else {
278 (value + amount).min(0.0)
279 }
280}
281
282fn swept_region(body: &Aabb, delta: Vec2) -> Aabb {
284 let start_min = body.min();
285 let start_max = body.max();
286 Aabb::from_min_max(
287 start_min.min(start_min + delta),
288 start_max.max(start_max + delta),
289 )
290}
291
292#[cfg(test)]
293mod tests {
294 use super::super::collider_set::ColliderFlags;
295 use super::*;
296 use wasm_bindgen_test::wasm_bindgen_test;
297
298 #[wasm_bindgen_test(unsupported = test)]
302 fn contacts_record_the_request_and_classification_is_strict() {
303 let config = CollisionConfig::new(Vec2::Y, 0.01, 0.1, 0.7, 1e-4);
304 let set = ColliderSet::new(1.0);
305 let body = Aabb::new(Vec2::ZERO, Vec2::splat(0.5));
306 let (_, contacts) = move_and_collide(body, Vec2::new(1.5, -2.0), &set, &config);
307 assert_eq!(contacts.desired, Vec2::new(1.5, -2.0));
308 let (_, contacts) = snap_to_ground(body, &set, &config);
309 assert_eq!(contacts.desired, Vec2::new(0.0, -0.1));
310
311 assert!(
312 !config.is_floor(Vec2::new(0.3, 0.7)),
313 "exactly at the threshold"
314 );
315 assert!(config.is_floor(Vec2::new(0.0, 0.7001)));
316 assert!(
317 !config.is_ceiling(Vec2::new(0.3, -0.7)),
318 "exactly at the threshold"
319 );
320 assert!(config.is_ceiling(Vec2::new(0.0, -0.7001)));
321 }
322
323 #[wasm_bindgen_test(unsupported = test)]
327 fn blocked_flags_need_a_shortfall_beyond_the_epsilon() {
328 let config = CollisionConfig::new(Vec2::Y, 0.0, 0.1, 0.7, 0.0);
329 let mut set = ColliderSet::new(1.0);
330 set.insert(
332 Aabb::new(Vec2::new(0.0, -3.0), Vec2::splat(1.0)),
333 ColliderFlags::SOLID,
334 );
335 set.insert(
336 Aabb::new(Vec2::new(0.0, 3.0), Vec2::splat(1.0)),
337 ColliderFlags::SOLID,
338 );
339 let body = Aabb::new(Vec2::ZERO, Vec2::splat(1.0));
340 let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config);
341 assert_eq!(moved, Vec2::new(0.0, -1.0));
342 assert!(contacts.floor);
343 assert!(!contacts.down_blocked, "landed exactly where asked");
344 let (_, contacts) = move_and_collide(body, Vec2::new(0.0, -5.0), &set, &config);
345 assert!(contacts.down_blocked);
346 let (moved, contacts) = move_and_collide(body, Vec2::new(0.0, 1.0), &set, &config);
347 assert_eq!(moved, Vec2::new(0.0, 1.0));
348 assert!(contacts.ceiling);
349 assert!(!contacts.up_blocked, "rose exactly as far as asked");
350 let (_, contacts) = move_and_collide(body, Vec2::new(0.0, 5.0), &set, &config);
351 assert!(contacts.up_blocked);
352 }
353
354 const SKIN: f32 = 0.01;
355 const SNAP: f32 = 2.0 / 32.0;
357
358 fn aabb(cx: f32, cy: f32, hx: f32, hy: f32) -> Aabb {
359 Aabb::new(Vec2::new(cx, cy), Vec2::new(hx, hy))
360 }
361
362 fn config(up: Vec2) -> CollisionConfig {
363 CollisionConfig::new(up, SKIN, SNAP, 0.7, 0.0001)
364 }
365
366 fn set_with(colliders: &[(Aabb, ColliderFlags)]) -> ColliderSet {
367 let mut set = ColliderSet::new(1.0);
368 for &(aabb, flags) in colliders {
369 set.insert(aabb, flags);
370 }
371 set
372 }
373
374 #[wasm_bindgen_test(unsupported = test)]
378 fn landing_rests_one_skin_above_the_floor() {
379 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
380 let body = aabb(0.0, 2.0, 0.5, 0.5);
381 let (effective, contacts) =
382 move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
383
384 let rest = 2.0 + effective.y;
386 assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
387 assert!(contacts.floor && contacts.down_blocked);
388 assert!(!contacts.ceiling && !contacts.wall);
389 assert_eq!(contacts.last_floor_normal, Vec2::Y);
390 }
391
392 #[wasm_bindgen_test(unsupported = test)]
396 fn head_bump_stops_below_the_ceiling() {
397 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
398 let body = aabb(0.0, -2.0, 0.5, 0.5);
399 let (effective, contacts) =
400 move_and_collide(body, Vec2::new(0.0, 3.0), &set, &config(Vec2::Y));
401
402 let rest = -2.0 + effective.y;
403 assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
404 assert!(contacts.ceiling && contacts.up_blocked);
405 assert!(!contacts.floor);
406 assert_eq!(contacts.last_ceiling_normal, Vec2::NEG_Y);
407 }
408
409 #[wasm_bindgen_test(unsupported = test)]
413 fn wall_blocks_one_axis_and_the_slide_keeps_the_other() {
414 let set = set_with(&[(aabb(2.0, 0.0, 0.5, 5.0), ColliderFlags::SOLID)]);
415 let body = aabb(0.0, 0.0, 0.5, 0.5);
416 let (effective, contacts) =
417 move_and_collide(body, Vec2::new(3.0, 1.0), &set, &config(Vec2::Y));
418
419 assert!((effective.x - 0.99).abs() < 1e-4, "x was {}", effective.x);
421 assert!((effective.y - 1.0).abs() < 1e-4, "y was {}", effective.y);
422 assert!(contacts.wall);
423 assert!(!contacts.floor && !contacts.ceiling);
424 }
425
426 #[wasm_bindgen_test(unsupported = test)]
429 fn landing_while_running_keeps_the_horizontal_travel() {
430 let set = set_with(&[(aabb(0.0, 0.0, 10.0, 0.5), ColliderFlags::SOLID)]);
431 let body = aabb(0.0, 2.0, 0.5, 0.5);
432 let (effective, contacts) =
433 move_and_collide(body, Vec2::new(2.0, -3.0), &set, &config(Vec2::Y));
434
435 assert!(contacts.floor);
436 assert!((effective.x - 2.0).abs() < 1e-4, "x was {}", effective.x);
437 }
438
439 #[wasm_bindgen_test(unsupported = test)]
443 fn the_earliest_contact_wins_over_query_order() {
444 let set = set_with(&[
445 (aabb(0.0, -4.0, 5.0, 0.5), ColliderFlags::SOLID),
446 (aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID),
447 ]);
448 let body = aabb(0.0, 2.0, 0.5, 0.5);
449 let (effective, contacts) =
450 move_and_collide(body, Vec2::new(0.0, -6.0), &set, &config(Vec2::Y));
451
452 let rest = 2.0 + effective.y;
453 assert!((rest - (1.0 + SKIN)).abs() < 1e-4, "rested at {rest}");
454 assert!(contacts.floor);
455 }
456
457 #[wasm_bindgen_test(unsupported = test)]
462 fn contact_exactly_at_the_end_of_the_delta_is_reported() {
463 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
464 let body = aabb(0.0, 2.0, 0.5, 0.5);
466 let (effective, contacts) =
467 move_and_collide(body, Vec2::new(0.0, -1.0), &set, &config(Vec2::Y));
468
469 assert!(contacts.floor && contacts.down_blocked);
470 assert!(
471 (effective.y - (-1.0 + SKIN)).abs() < 1e-4,
472 "y was {}",
473 effective.y
474 );
475 }
476
477 #[wasm_bindgen_test(unsupported = test)]
481 fn the_skin_back_off_never_reverses_the_move() {
482 let set = set_with(&[(aabb(0.0, 0.0, 0.5, 0.5), ColliderFlags::SOLID)]);
483 let body = aabb(-1.003, 0.0, 0.5, 0.5);
484 let (effective, contacts) =
485 move_and_collide(body, Vec2::new(0.006, 0.0), &set, &config(Vec2::Y));
486
487 assert!(contacts.wall);
488 assert!(effective.x >= 0.0, "moved backwards: {}", effective.x);
489 assert!(effective.x <= 0.003, "moved past contact: {}", effective.x);
490 }
491
492 #[wasm_bindgen_test(unsupported = test)]
495 fn sensors_and_non_solid_colliders_do_not_block() {
496 let set = set_with(&[
497 (aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SENSOR),
498 (
499 aabb(0.0, -1.5, 5.0, 0.5),
500 ColliderFlags {
501 solid: false,
502 one_way: false,
503 sensor: false,
504 },
505 ),
506 ]);
507 let body = aabb(0.0, 2.0, 0.5, 0.5);
508 let (effective, contacts) =
509 move_and_collide(body, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
510
511 assert!((effective.y + 3.0).abs() < 1e-6, "y was {}", effective.y);
512 assert!(!contacts.floor && !contacts.wall && !contacts.down_blocked);
513 }
514
515 #[wasm_bindgen_test(unsupported = test)]
518 fn one_way_blocks_only_a_landing_from_above() {
519 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
520
521 let rising = aabb(0.0, -2.0, 0.5, 0.5);
522 let (effective, contacts) =
523 move_and_collide(rising, Vec2::new(0.0, 4.0), &set, &config(Vec2::Y));
524 assert!(!contacts.ceiling && !contacts.up_blocked);
525 assert!((effective.y - 4.0).abs() < 1e-6, "y was {}", effective.y);
526
527 let falling = aabb(0.0, 2.0, 0.5, 0.5);
528 let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, -3.0), &set, &config(Vec2::Y));
529 assert!(contacts.floor && contacts.down_blocked);
530 }
531
532 #[wasm_bindgen_test(unsupported = test)]
536 fn non_finite_delta_moves_nothing() {
537 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
538 let body = aabb(0.0, 2.0, 0.5, 0.5);
539 for delta in [Vec2::new(f32::NAN, -1.0), Vec2::new(0.0, f32::NEG_INFINITY)] {
540 let (effective, contacts) = move_and_collide(body, delta, &set, &config(Vec2::Y));
541 assert_eq!(effective, Vec2::ZERO);
542 assert!(!contacts.floor && !contacts.down_blocked);
543 }
544 }
545
546 #[wasm_bindgen_test(unsupported = test)]
549 fn snap_reaches_exactly_as_far_as_it_is_configured_to() {
550 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
551 let body = aabb(0.0, 1.0 + 1.0 / 32.0, 0.5, 0.5);
552
553 let (snap, contacts) = snap_to_ground(body, &set, &config(Vec2::Y));
554 assert!(contacts.floor, "a 2 px probe must bridge a 1 px gap");
555 assert!(snap.y < 0.0, "snap pulls the body toward the floor");
556 assert_eq!(contacts.last_floor_normal, Vec2::Y);
557
558 let short = CollisionConfig::new(Vec2::Y, SKIN, 0.5 / 32.0, 0.7, 0.0001);
559 let (snap, contacts) = snap_to_ground(body, &set, &short);
560 assert!(!contacts.floor, "a half-pixel probe must not reach");
561 assert_eq!(snap, Vec2::ZERO);
562 }
563
564 #[wasm_bindgen_test(unsupported = test)]
570 fn snap_grounds_on_a_one_way_top_but_not_from_under_it() {
571 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
572
573 let above = aabb(0.0, 0.75 + 1.0 / 32.0, 0.5, 0.5);
575 let (snap, contacts) = snap_to_ground(above, &set, &config(Vec2::Y));
576 assert!(contacts.floor, "a one-way top still grounds a landing");
577 assert!(snap.y < 0.0);
578
579 let below = aabb(0.0, -1.0, 0.5, 0.5);
581 let (snap, contacts) = snap_to_ground(below, &set, &config(Vec2::Y));
582 assert!(!contacts.floor);
583 assert_eq!(snap, Vec2::ZERO);
584 }
585
586 #[wasm_bindgen_test(unsupported = test)]
590 fn y_down_worlds_classify_floors_and_ceilings_the_same() {
591 let config = config(Vec2::NEG_Y);
592 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.5), ColliderFlags::SOLID)]);
593
594 let above = aabb(0.0, -2.0, 0.5, 0.5);
596 let (effective, contacts) = move_and_collide(above, Vec2::new(0.0, 3.0), &set, &config);
597 let rest = -2.0 + effective.y;
598 assert!((rest - (-1.0 - SKIN)).abs() < 1e-4, "rested at {rest}");
599 assert!(contacts.floor && contacts.down_blocked);
600 assert!(!contacts.ceiling && !contacts.up_blocked);
601 assert_eq!(contacts.last_floor_normal, Vec2::NEG_Y);
602
603 let below = aabb(0.0, 2.0, 0.5, 0.5);
604 let (_, contacts) = move_and_collide(below, Vec2::new(0.0, -3.0), &set, &config);
605 assert!(contacts.ceiling && contacts.up_blocked);
606 assert!(!contacts.floor && !contacts.down_blocked);
607 assert_eq!(contacts.last_ceiling_normal, Vec2::Y);
608 }
609
610 #[wasm_bindgen_test(unsupported = test)]
614 fn y_down_worlds_mirror_one_way_and_snapping() {
615 let config = config(Vec2::NEG_Y);
616 let set = set_with(&[(aabb(0.0, 0.0, 5.0, 0.25), ColliderFlags::ONE_WAY)]);
617
618 let rising = aabb(0.0, 2.0, 0.5, 0.5);
619 let (effective, contacts) = move_and_collide(rising, Vec2::new(0.0, -4.0), &set, &config);
620 assert!(!contacts.ceiling);
621 assert!((effective.y + 4.0).abs() < 1e-6, "y was {}", effective.y);
622
623 let falling = aabb(0.0, -2.0, 0.5, 0.5);
624 let (_, contacts) = move_and_collide(falling, Vec2::new(0.0, 3.0), &set, &config);
625 assert!(contacts.floor && contacts.down_blocked);
626
627 let hovering = aabb(0.0, -0.75 - 1.0 / 32.0, 0.5, 0.5);
630 let (snap, contacts) = snap_to_ground(hovering, &set, &config);
631 assert!(contacts.floor, "the probe must run along -up, toward +y");
632 assert!(snap.y > 0.0, "snap pulls the body toward the platform");
633 }
634}