alice_physics/world_character.rs
1//! Move-and-slide of a [`CharacterController`] against the geometry a
2//! [`PhysicsWorld`] collides with.
3//!
4//! [`CharacterController::move_and_slide`] takes a slice of bodies and SDF
5//! colliders and sees each static body as a sphere of the character's radius; it
6//! does not see the world's static colliders (planes, height fields, triangle
7//! meshes) or the bodies' true shapes. [`PhysicsWorld::move_character`] sweeps the
8//! controller's capsule with [`PhysicsWorld::cast_capsule`], so it meets the same
9//! geometry the world collides with: body shapes, compound children, static
10//! colliders and (`std`) SDF colliders.
11//!
12//! # The capsule
13//!
14//! Centre [`CharacterController::position`], total height
15//! [`CharacterConfig::height`] (hemispheres included) and radius
16//! [`CharacterConfig::radius`]: the segment runs from
17//! `position − (height/2 − radius)·Y` to `position + (height/2 − radius)·Y`
18//! (a sphere when `height ≤ 2·radius`). `+Y` is up, as in
19//! [`CharacterController::move_and_slide`].
20//!
21//! # One move
22//!
23//! The displacement plus [`CharacterController::platform_velocity`] (as in
24//! [`CharacterController::move_and_slide`]) is swept in up to
25//! [`CharacterConfig::max_slides`] steps:
26//!
27//! 1. Sweep the capsule along the rest of the displacement (`len + skin`). No hit:
28//! move the whole rest and stop.
29//! 2. Hit at distance `t` with normal `n`: move along the direction until the
30//! capsule is [`CharacterConfig::skin_width`] from the surface along `n`,
31//! `t − skin / (−direction·n)` (at least 0). The capsule only moves along the
32//! swept path, which the cast found clear, so a move never makes it overlap.
33//! 3. Project the rest (`len` minus the distance moved, along the direction)
34//! onto the contact plane:
35//! `rest − n (rest · n)`. A surface steeper than
36//! [`CharacterConfig::max_slope_angle`] whose projection would climb (`y > 0`)
37//! is treated as a vertical wall: the rest is projected onto its horizontal
38//! normal instead, so a steep slope cannot be walked up.
39//!
40//! The rest left after the last step is dropped. In a corner the projections run
41//! from one wall into the other; each such step moves 0 (the capsule already is
42//! a skin width from the wall it runs into), so the capsule stays put.
43//!
44//! # Steps
45//!
46//! When the controller was grounded before the move, its step height is positive,
47//! and the sweep met a surface steeper than the slope limit, the move is tried
48//! again raised: up by the step height (less if a ceiling is nearer), the
49//! horizontal part of the displacement swept from there, then down by the rise
50//! plus two skin widths, stopping a skin width above the surface. The raised result is taken when it lands on a walkable
51//! surface and gets more than a skin width further along the horizontal direction
52//! than the plain sweep.
53//!
54//! # Ground
55//!
56//! After the move a downward sweep of [`CharacterConfig::ground_probe_distance`]
57//! plus a skin width looks for ground: grounded when it hits a surface whose
58//! normal makes at most [`CharacterConfig::max_slope_angle`] with `+Y`. The body
59//! it belongs to (if any) becomes [`CharacterController::ground_body_index`] and
60//! its velocity [`CharacterController::platform_velocity`].
61//!
62//! # Starting overlap
63//!
64//! Before the move, a capsule that starts overlapping colliders is pushed out:
65//!
66//! 1. Find every collider the capsule overlaps and how far: the distance `δ` from
67//! the capsule's segment to the collider (the queries of
68//! [`PhysicsWorld::overlap_sphere`], on the segment) is below the radius, the
69//! penetration is `r − δ` and the direction the collider's normal at the
70//! nearest point.
71//! 2. Push the capsule along that normal by the penetration plus
72//! [`CharacterConfig::skin_width`], the deepest overlap first (ties by target:
73//! bodies, then static colliders, then SDF colliders, each by index), so it
74//! ends a skin width clear of that surface, as a move leaves it.
75//! 3. Repeat until nothing overlaps, at most [`CharacterConfig::max_slides`]
76//! pushes.
77//!
78//! The move of § One move then starts from the freed position. A plane is
79//! two-sided: a capsule below it is pushed down.
80//!
81//! The capsule is **not** freed, and keeps its position, when its segment itself
82//! meets a solid (an overlap deeper than the radius: there is no distance and so
83//! no push-out direction), when overlaps remain after
84//! [`CharacterConfig::max_slides`] pushes, or when the pushes would add up to more
85//! than [`CharacterConfig::radius`] `+` [`CharacterConfig::height`] (so a capsule
86//! deep inside a large body is not flung out of it). It then starts the move where
87//! it is, and [`PhysicsWorld::cast_capsule`] reports a cast that starts overlapping
88//! at `t = 0` with normal `−direction`: there is no surface normal to slide along,
89//! the move goes 0 along the direction and its projection onto the plane normal
90//! to the direction is 0, so the controller does not move
91//! ([`MoveResult::position`] is the old position); such a contact is not ground,
92//! and no step is tried from inside (the upward sweep of § Steps also starts
93//! overlapping).
94//!
95//! # Configuration
96//!
97//! A [`CharacterConfig`] with a negative [`CharacterConfig::radius`],
98//! [`CharacterConfig::skin_width`] or [`CharacterConfig::height`] describes no
99//! capsule: the move is refused, the controller is left unchanged and the
100//! returned [`MoveResult`] is its current state. [`CharacterConfig::max_slides`]
101//! of 0 is taken as 1 (one sweep, no slide). A height below `2·radius` is a
102//! sphere of the radius.
103//!
104//! # Excluding the character's own body
105//!
106//! [`PhysicsWorld::move_character_with_filter`] takes a [`RayFilter`]: pass
107//! [`RayFilter::excluding_body`] when the character also has a body in the world.
108//!
109//! Author: Moroya Sakamoto
110
111use crate::character::{CharacterConfig, CharacterController, MoveResult};
112use crate::math::{Fix128, Vec3Fix};
113use crate::shape_raycast::RayFilter;
114use crate::solver::PhysicsWorld;
115use crate::world_shape_query::{Penetration, WorldShapeHit};
116
117/// The controller's capsule and the constants a move uses.
118#[derive(Clone, Copy)]
119struct Sweep<'a> {
120 world: &'a PhysicsWorld,
121 filter: &'a RayFilter,
122 /// Half the segment length, `height/2 − radius` (at least 0).
123 half: Fix128,
124 radius: Fix128,
125 skin: Fix128,
126 /// `cos(max_slope_angle)`: a normal with `n·Y` at least this is walkable.
127 walkable: Fix128,
128 max_slides: usize,
129}
130
131/// Result of [`Sweep::slide`].
132#[derive(Clone, Copy)]
133struct Slid {
134 position: Vec3Fix,
135 /// A surface steeper than the slope limit was met.
136 steep: bool,
137}
138
139impl Sweep<'_> {
140 /// The capsule centred at `center` swept along `direction` for `max_t`.
141 fn cast(self, center: Vec3Fix, direction: Vec3Fix, max_t: Fix128) -> Option<WorldShapeHit> {
142 let off = Vec3Fix::new(Fix128::ZERO, self.half, Fix128::ZERO);
143 self.world.cast_capsule(
144 center - off,
145 center + off,
146 self.radius,
147 direction,
148 max_t,
149 self.filter,
150 )
151 }
152
153 /// How far to move along the unit `dir` toward a contact at distance `t` with
154 /// normal `n` to stop a skin width from the surface along `n`:
155 /// `t − skin / (−dir·n)`, at least 0 (`t − skin` when `n` does not face `dir`).
156 fn short_of(self, t: Fix128, dir: Vec3Fix, n: Vec3Fix) -> Fix128 {
157 let facing = -dir.dot(n);
158 let back = if facing > Fix128::ZERO {
159 self.skin / facing
160 } else {
161 self.skin
162 };
163 if t > back {
164 t - back
165 } else {
166 Fix128::ZERO
167 }
168 }
169
170 fn is_walkable(self, n: Vec3Fix) -> bool {
171 n.y >= self.walkable
172 }
173
174 /// Move-and-slide of `rest` from `position` (module doc, steps 1-3).
175 fn slide(self, mut position: Vec3Fix, mut rest: Vec3Fix) -> Slid {
176 let mut steep = false;
177 for _ in 0..self.max_slides {
178 let Some(dir) = rest.try_normalize() else {
179 break;
180 };
181 let len = rest.length();
182 let Some(hit) = self.cast(position, rest, len + self.skin) else {
183 position = position + rest;
184 break;
185 };
186 let n = hit.normal;
187 let travel = self.short_of(hit.t, dir, n);
188 position = position + dir * travel;
189 let left = dir * (len - travel);
190 let walkable = self.is_walkable(n);
191 steep |= !walkable;
192 let mut next = project(left, n);
193 if !walkable && n.y > Fix128::ZERO && next.y > Fix128::ZERO {
194 if let Some(wall) = Vec3Fix::new(n.x, Fix128::ZERO, n.z).try_normalize() {
195 next = project(left, wall);
196 }
197 }
198 rest = next;
199 }
200 Slid { position, steep }
201 }
202
203 /// The raised move of the module doc § Steps, if it is taken.
204 fn step(
205 self,
206 start: Vec3Fix,
207 total: Vec3Fix,
208 step_height: Fix128,
209 plain: Vec3Fix,
210 ) -> Option<Vec3Fix> {
211 let horizontal = Vec3Fix::new(total.x, Fix128::ZERO, total.z);
212 let along = horizontal.try_normalize()?;
213 let rise = match self.cast(start, Vec3Fix::UNIT_Y, step_height + self.skin) {
214 Some(hit) if hit.t > self.skin => hit.t - self.skin,
215 Some(_) => return None,
216 None => step_height,
217 };
218 let raised = start + Vec3Fix::new(Fix128::ZERO, rise, Fix128::ZERO);
219 let across = self.slide(raised, horizontal).position;
220 let down = -Vec3Fix::UNIT_Y;
221 let hit = self.cast(across, down, rise + self.skin + self.skin)?;
222 if !self.is_walkable(hit.normal) {
223 return None;
224 }
225 let landed = across + down * self.short_of(hit.t, down, hit.normal);
226 let progress = |p: Vec3Fix| (p - start).dot(along);
227 (progress(landed) > progress(plain) + self.skin).then_some(landed)
228 }
229
230 /// The capsule at `position` pushed out of the colliders it starts overlapping
231 /// (module doc § Starting overlap): `None` when it cannot be freed.
232 fn depenetrate(self, position: Vec3Fix, max_push: Fix128) -> Option<Vec3Fix> {
233 let off = Vec3Fix::new(Fix128::ZERO, self.half, Fix128::ZERO);
234 let mut p = position;
235 let mut pushed = Fix128::ZERO;
236 for _ in 0..=self.max_slides {
237 let overlaps =
238 self.world
239 .capsule_penetrations(p - off, p + off, self.radius, self.filter)?;
240 // Deepest first, ties by target (the list is sorted by target and only
241 // a strictly deeper one replaces the first).
242 let Some(deepest) =
243 overlaps
244 .iter()
245 .fold(None, |best: Option<&Penetration>, o| match best {
246 Some(b) if b.depth >= o.depth => Some(b),
247 _ => Some(o),
248 })
249 else {
250 return Some(p);
251 };
252 let step = deepest.depth + self.skin;
253 pushed = pushed + step;
254 if pushed > max_push {
255 return None;
256 }
257 p = p + deepest.normal * step;
258 }
259 None
260 }
261
262 /// The walkable ground under a capsule at `position` (module doc § Ground).
263 fn ground(self, position: Vec3Fix, probe: Fix128) -> Option<WorldShapeHit> {
264 let down = -Vec3Fix::UNIT_Y;
265 let hit = self.cast(position, down, probe + self.skin)?;
266 (!starts_inside(&hit, down) && self.is_walkable(hit.normal)).then_some(hit)
267 }
268}
269
270/// `v` without its component along the unit `n`.
271fn project(v: Vec3Fix, n: Vec3Fix) -> Vec3Fix {
272 v - n * v.dot(n)
273}
274
275/// A cast that started overlapping: `t = 0`, normal `−direction` (the
276/// [`crate::world_shape_query`] convention). `direction` is the vector passed to
277/// the cast, normalized here the same way the cast normalizes it.
278fn starts_inside(hit: &WorldShapeHit, direction: Vec3Fix) -> bool {
279 hit.t.is_zero() && direction.try_normalize().is_some_and(|d| hit.normal == -d)
280}
281
282impl PhysicsWorld {
283 /// Move `ctrl` by `displacement`, sliding along everything the world collides
284 /// with (see [`crate::world_character`]), with the default [`RayFilter`].
285 ///
286 /// Updates the controller's position, grounded state, ground body and platform
287 /// velocity as [`CharacterController::move_and_slide`] does, and returns them.
288 pub fn move_character(
289 &self,
290 ctrl: &mut CharacterController,
291 displacement: Vec3Fix,
292 ) -> MoveResult {
293 self.move_character_with_filter(ctrl, displacement, &RayFilter::default())
294 }
295
296 /// [`PhysicsWorld::move_character`] seeing only what `filter` sees (for
297 /// instance [`RayFilter::excluding_body`] for the character's own body).
298 pub fn move_character_with_filter(
299 &self,
300 ctrl: &mut CharacterController,
301 displacement: Vec3Fix,
302 filter: &RayFilter,
303 ) -> MoveResult {
304 let config: CharacterConfig = ctrl.config;
305 // A negative radius, skin width or height describes no capsule: refused
306 // (module doc § Configuration).
307 if config.radius.is_negative()
308 || config.skin_width.is_negative()
309 || config.height.is_negative()
310 {
311 return MoveResult {
312 position: ctrl.position,
313 grounded: ctrl.grounded,
314 velocity: ctrl.velocity,
315 platform_velocity: ctrl.platform_velocity,
316 };
317 }
318 let half = config.height.half() - config.radius;
319 let sweep = Sweep {
320 world: self,
321 filter,
322 half: if half.is_negative() {
323 Fix128::ZERO
324 } else {
325 half
326 },
327 radius: config.radius,
328 skin: config.skin_width,
329 walkable: config.max_slope_angle.cos(),
330 max_slides: config.max_slides.max(1),
331 };
332 // A capsule that cannot be freed keeps its position, and the sweep from
333 // inside then blocks the move (module doc § Starting overlap).
334 let start = sweep
335 .depenetrate(ctrl.position, config.radius + config.height)
336 .unwrap_or(ctrl.position);
337 let total = displacement + ctrl.platform_velocity;
338 let plain = sweep.slide(start, total);
339 let mut position = plain.position;
340 if ctrl.grounded && plain.steep && config.step_height > Fix128::ZERO {
341 if let Some(stepped) = sweep.step(start, total, config.step_height, position) {
342 position = stepped;
343 }
344 }
345
346 let ground = sweep.ground(position, config.ground_probe_distance);
347 let ground_body = ground.and_then(|h| h.body);
348 let platform_velocity = ground_body
349 .and_then(|i| self.bodies.get(i))
350 .map_or(Vec3Fix::ZERO, |b| b.velocity);
351
352 ctrl.position = position;
353 ctrl.grounded = ground.is_some();
354 ctrl.ground_body_index = ground_body;
355 ctrl.platform_velocity = platform_velocity;
356 ctrl.velocity = displacement;
357
358 MoveResult {
359 position,
360 grounded: ctrl.grounded,
361 velocity: displacement,
362 platform_velocity,
363 }
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 //! Closed-form unit tests of the move: every expected position is written
370 //! from the geometry by hand (the closed form is in a comment next to each
371 //! assertion); none calls the code under test.
372 //!
373 //! Default config: radius `r = 0.3`, height `h = 1.8` (segment half `0.6`),
374 //! skin `s = 0.01`, step height `0.3`, slope limit `0.785` rad
375 //! (`cos = 0.7074`), 4 slides. A capsule standing a skin width above the
376 //! floor `y = 0` has its centre at `h/2 + s = 0.91`.
377
378 use super::*;
379 use crate::plane_collider::PlaneCollider;
380 use crate::shape::Shape;
381 use crate::solver::{PhysicsConfig, RigidBody};
382 use crate::static_collider::StaticCollider;
383
384 const TOL: f64 = 1e-9;
385 const ITER: f64 = 1e-7;
386
387 fn fx(v: f64) -> Fix128 {
388 Fix128::from_f64(v)
389 }
390
391 fn v3(x: f64, y: f64, z: f64) -> Vec3Fix {
392 Vec3Fix::new(fx(x), fx(y), fx(z))
393 }
394
395 fn world() -> PhysicsWorld {
396 PhysicsWorld::new(PhysicsConfig::default())
397 }
398
399 fn plane(w: &mut PhysicsWorld, normal: Vec3Fix, offset: f64) -> usize {
400 w.add_static_collider(StaticCollider::Plane(PlaneCollider::new(
401 normal,
402 fx(offset),
403 )))
404 }
405
406 #[track_caller]
407 fn assert_pos(got: Vec3Fix, want: [f64; 3], tol: f64) {
408 let g = [got.x.to_f64(), got.y.to_f64(), got.z.to_f64()];
409 for k in 0..3 {
410 assert!(
411 (g[k] - want[k]).abs() < tol,
412 "position {g:?} but the closed form is {want:?}"
413 );
414 }
415 }
416
417 #[test]
418 fn a_negative_radius_skin_or_height_refuses_the_move() {
419 let w = world();
420 for k in 0..3 {
421 let mut config = CharacterConfig::default();
422 match k {
423 0 => config.radius = fx(-0.3),
424 1 => config.skin_width = fx(-0.01),
425 _ => config.height = fx(-1.8),
426 }
427 let mut ctrl = CharacterController::new(v3(1.0, 2.0, 3.0), config);
428 ctrl.velocity = v3(4.0, 0.0, 0.0);
429 ctrl.grounded = true;
430 ctrl.platform_velocity = v3(0.5, 0.0, 0.0);
431 let r = w.move_character(&mut ctrl, v3(5.0, 0.0, 0.0));
432 // oracle: refused, the result is the controller's current state.
433 assert_eq!(r.position, v3(1.0, 2.0, 3.0));
434 assert!(r.grounded);
435 assert_eq!(r.velocity, v3(4.0, 0.0, 0.0));
436 assert_eq!(r.platform_velocity, v3(0.5, 0.0, 0.0));
437 assert_eq!(ctrl.position, v3(1.0, 2.0, 3.0));
438 assert_eq!(ctrl.velocity, v3(4.0, 0.0, 0.0));
439 }
440 }
441
442 #[test]
443 fn a_free_move_takes_the_whole_displacement() {
444 let w = world();
445 let mut ctrl = CharacterController::new_default(v3(1.0, 5.0, -2.0));
446 let r = w.move_character(&mut ctrl, v3(3.0, -1.0, 2.0));
447 // oracle: nothing in the way: start + displacement, nothing under it.
448 assert_pos(r.position, [4.0, 4.0, 0.0], TOL);
449 assert!(!r.grounded);
450 assert_eq!(r.velocity, v3(3.0, -1.0, 2.0));
451 assert_eq!(r.platform_velocity, Vec3Fix::ZERO);
452 assert_eq!(ctrl.position, r.position);
453 assert_eq!(ctrl.ground_body_index, None);
454 }
455
456 #[test]
457 fn walking_down_into_the_floor_keeps_the_tangential_part() {
458 let mut w = world();
459 plane(&mut w, Vec3Fix::UNIT_Y, 0.0);
460 let mut ctrl = CharacterController::new_default(v3(0.0, 0.91, 0.0));
461 let r = w.move_character(&mut ctrl, v3(1.0, -0.5, 2.0));
462 // oracle: starting a skin width above the plane, D − n (D·n) with
463 // n = +Y: (1, 0, 2) added, the height stays 0.91; the probe finds the
464 // floor 0.01 below (a static collider: no ground body).
465 assert_pos(r.position, [1.0, 0.91, 2.0], TOL);
466 assert!(r.grounded);
467 assert!(ctrl.grounded);
468 assert_eq!(ctrl.ground_body_index, None);
469 assert_eq!(r.platform_velocity, Vec3Fix::ZERO);
470 }
471
472 #[test]
473 fn a_wall_removes_the_normal_component() {
474 let mut w = world();
475 // The wall x = 2 (normal −X, offset −2).
476 plane(&mut w, -Vec3Fix::UNIT_X, -2.0);
477 let mut ctrl = CharacterController::new_default(Vec3Fix::ZERO);
478 let r = w.move_character(&mut ctrl, v3(3.0, 0.0, 1.0));
479 // oracle: the capsule stops a skin width from the wall, x = 2 − r − s =
480 // 1.69; the tangential part of the whole displacement is kept, z = 1.
481 assert_pos(r.position, [1.69, 0.0, 1.0], TOL);
482 assert!(!r.grounded);
483
484 // max_slides 0 is one sweep without a slide: the move along the unit
485 // direction (3, 0, 1)/√10 stops at x = 1.69, z = 1.69/3.
486 let config = CharacterConfig {
487 max_slides: 0,
488 ..CharacterConfig::default()
489 };
490 let mut ctrl = CharacterController::new(Vec3Fix::ZERO, config);
491 let r = w.move_character(&mut ctrl, v3(3.0, 0.0, 1.0));
492 assert_pos(r.position, [1.69, 0.0, 1.69 / 3.0], TOL);
493 }
494
495 #[test]
496 fn a_steep_slope_is_a_wall_that_is_not_climbed() {
497 let mut w = world();
498 // The plane through the origin with normal n = (−√3/2, 1/2, 0), 60° from
499 // horizontal: steeper than the slope limit (n·Y = 0.5 < 0.7074).
500 let s3 = 3f64.sqrt();
501 plane(&mut w, v3(-s3, 1.0, 0.0), 0.0);
502 let mut ctrl = CharacterController::new_default(v3(-5.0, 0.0, 0.0));
503 let r = w.move_character(&mut ctrl, v3(10.0, 0.0, 0.0));
504 // oracle: the lower end (x, −0.6) has n·a = −√3/2·x − 0.3; the capsule is
505 // a skin width from the plane when that is r + s = 0.31, x = −1.22/√3.
506 // The rest projected on the plane would climb (y > 0), so it is projected
507 // on the horizontal normal −X instead: 0. No walkable ground.
508 assert_pos(r.position, [-1.22 / s3, 0.0, 0.0], TOL);
509 assert!(!r.grounded);
510 }
511
512 #[test]
513 fn a_low_step_is_climbed_when_grounded() {
514 let mut w = world();
515 // A box step: half extents (1, 0.1, 5) at (3, 0.1, 0), top y = 0.2,
516 // front face x = 2 (below the 0.3 step height).
517 let b = w
518 .add_shaped_body(
519 &Shape::Box {
520 half_extents: v3(1.0, 0.1, 5.0),
521 },
522 Fix128::ONE,
523 v3(3.0, 0.1, 0.0),
524 )
525 .expect("valid shape");
526 let mut ctrl = CharacterController::new_default(v3(0.0, 0.91, 0.0));
527 ctrl.grounded = true;
528 let r = w.move_character(&mut ctrl, v3(2.5, 0.0, 0.0));
529 // oracle: raised by the step height, swept to x = 2.5 over the step,
530 // lowered to a skin width above its top: y = 0.2 + 0.91.
531 assert_pos(r.position, [2.5, 1.11, 0.0], ITER);
532 assert!(r.grounded);
533 assert_eq!(ctrl.ground_body_index, Some(b));
534
535 // Not grounded before the move: no step. The lower hemisphere (centre
536 // y = 0.31) meets the step's edge (2, 0.2), 0.11 below it, when its
537 // centre is √(0.3² − 0.11²) = √0.0779 short of x = 2, normal
538 // n = (−√0.0779, 0.11)/0.3; it backs off s/(−d·n) = 0.003/√0.0779, and
539 // the rest, which would climb the steep normal, is cancelled.
540 let mut ctrl = CharacterController::new_default(v3(0.0, 0.91, 0.0));
541 let r = w.move_character(&mut ctrl, v3(2.5, 0.0, 0.0));
542 let q = 0.0779f64.sqrt();
543 assert_pos(r.position, [2.0 - q - 0.003 / q, 0.91, 0.0], ITER);
544 }
545
546 #[test]
547 fn a_moving_platform_becomes_ground_and_carries_the_next_move() {
548 let mut w = world();
549 // A box platform: half extents (5, 0.5, 5) at the origin, top y = 0.5,
550 // moving at (2, 0, 0).
551 let b = w
552 .add_shaped_body(
553 &Shape::Box {
554 half_extents: v3(5.0, 0.5, 5.0),
555 },
556 Fix128::ONE,
557 Vec3Fix::ZERO,
558 )
559 .expect("valid shape");
560 w.bodies[b].velocity = v3(2.0, 0.0, 0.0);
561 let mut ctrl = CharacterController::new_default(v3(0.0, 1.41, 0.0));
562 let r = w.move_character(&mut ctrl, Vec3Fix::ZERO);
563 // oracle: standing a skin width above the top (0.5 + 0.91): no move,
564 // the platform is the ground and its velocity is inherited.
565 assert_pos(r.position, [0.0, 1.41, 0.0], TOL);
566 assert!(r.grounded);
567 assert_eq!(ctrl.ground_body_index, Some(b));
568 assert_eq!(r.platform_velocity, v3(2.0, 0.0, 0.0));
569 // oracle: the next move adds the platform velocity: x = 0 + 2.
570 let r = w.move_character(&mut ctrl, Vec3Fix::ZERO);
571 assert_pos(r.position, [2.0, 1.41, 0.0], ITER);
572 assert_eq!(r.velocity, Vec3Fix::ZERO);
573 }
574
575 #[test]
576 fn a_capsule_sunk_into_the_floor_is_pushed_out_first() {
577 let mut w = world();
578 plane(&mut w, Vec3Fix::UNIT_Y, 0.0);
579 let mut ctrl = CharacterController::new_default(v3(0.0, 0.8, 0.0));
580 let r = w.move_character(&mut ctrl, Vec3Fix::ZERO);
581 // oracle: the segment's lowest point y = 0.2 is 0.1 deeper than r: pushed
582 // up by 0.1 + s, centre 0.91.
583 assert_pos(r.position, [0.0, 0.91, 0.0], TOL);
584 assert!(r.grounded);
585 }
586
587 #[test]
588 fn a_capsule_whose_segment_crosses_a_solid_stays_put() {
589 let mut w = world();
590 plane(&mut w, Vec3Fix::UNIT_Y, 0.0);
591 // The segment runs from y = −0.1 to 1.1 and crosses the plane.
592 let mut ctrl = CharacterController::new_default(v3(0.0, 0.5, 0.0));
593 let r = w.move_character(&mut ctrl, v3(1.0, 0.0, 0.0));
594 // oracle: not freed; the cast starts overlapping (t = 0, normal
595 // −direction): the move is 0 and its projection is 0; not ground.
596 assert_pos(r.position, [0.0, 0.5, 0.0], TOL);
597 assert!(!r.grounded);
598 }
599
600 #[test]
601 fn a_capsule_between_walls_closer_than_its_diameter_is_not_freed() {
602 let mut w = world();
603 // Walls x = −0.25 and x = 0.25: a capsule of radius 0.3 at x = 0
604 // overlaps both by 0.05, and every push out of one goes into the other.
605 plane(&mut w, Vec3Fix::UNIT_X, -0.25);
606 plane(&mut w, Vec3Fix::UNIT_X, 0.25);
607 for max_slides in [4, 100] {
608 // 4 slides: overlaps remain after the pushes; 100 slides: the pushes
609 // (0.06, then 0.12 each) add up past r + h = 2.1.
610 let config = CharacterConfig {
611 max_slides,
612 ..CharacterConfig::default()
613 };
614 let mut ctrl = CharacterController::new(Vec3Fix::ZERO, config);
615 let r = w.move_character(&mut ctrl, v3(0.0, 0.0, 1.0));
616 // oracle: not freed, the cast from inside blocks the move.
617 assert_pos(r.position, [0.0, 0.0, 0.0], TOL);
618 }
619 }
620
621 #[test]
622 fn a_height_below_the_diameter_is_a_sphere() {
623 let mut w = world();
624 plane(&mut w, Vec3Fix::UNIT_Y, 0.0);
625 let config = CharacterConfig {
626 radius: fx(0.5),
627 height: fx(0.4),
628 ..CharacterConfig::default()
629 };
630 let mut ctrl = CharacterController::new(v3(0.0, 2.0, 0.0), config);
631 let r = w.move_character(&mut ctrl, v3(0.0, -5.0, 0.0));
632 // oracle: a sphere of radius 0.5 falls onto y = 0 and stops a skin width
633 // above it: centre y = 0.5 + 0.01.
634 assert_pos(r.position, [0.0, 0.51, 0.0], TOL);
635 assert!(r.grounded);
636 }
637
638 #[test]
639 fn the_filter_excludes_the_characters_own_body() {
640 let mut w = world();
641 // The character's own body: a sphere of radius 0.5 at its centre.
642 let own = w.add_body_with_radius(RigidBody::new_static(Vec3Fix::ZERO), fx(0.5));
643 let mut ctrl = CharacterController::new_default(Vec3Fix::ZERO);
644 let r = w.move_character(&mut ctrl, v3(1.0, 0.0, 0.0));
645 // oracle: the segment crosses its own body: not freed, no move.
646 assert_pos(r.position, [0.0, 0.0, 0.0], TOL);
647 let r = w.move_character_with_filter(
648 &mut ctrl,
649 v3(1.0, 0.0, 0.0),
650 &RayFilter::default().excluding_body(own),
651 );
652 // oracle: with its body excluded nothing is in the way: x = 1.
653 assert_pos(r.position, [1.0, 0.0, 0.0], TOL);
654 }
655
656 #[test]
657 fn short_of_and_project_closed_forms() {
658 let w = world();
659 let filter = RayFilter::default();
660 let sweep = Sweep {
661 world: &w,
662 filter: &filter,
663 half: fx(0.6),
664 radius: fx(0.3),
665 skin: fx(0.01),
666 walkable: fx(0.5),
667 max_slides: 4,
668 };
669 let d = Vec3Fix::UNIT_X;
670 // oracle: head-on (−d·n = 1): t − s = 0.99.
671 assert_pos(
672 Vec3Fix::new(
673 sweep.short_of(Fix128::ONE, d, -d),
674 Fix128::ZERO,
675 Fix128::ZERO,
676 ),
677 [0.99, 0.0, 0.0],
678 TOL,
679 );
680 // oracle: at 60° (−d·n = 1/2): t − s/(1/2) = 1 − 0.02.
681 let n = v3(-0.5, 0.75f64.sqrt(), 0.0);
682 assert_pos(
683 Vec3Fix::new(
684 sweep.short_of(Fix128::ONE, d, n),
685 Fix128::ZERO,
686 Fix128::ZERO,
687 ),
688 [0.98, 0.0, 0.0],
689 TOL,
690 );
691 // oracle: a normal not facing d backs off s; never below 0.
692 assert_pos(
693 Vec3Fix::new(
694 sweep.short_of(Fix128::ONE, d, d),
695 Fix128::ZERO,
696 Fix128::ZERO,
697 ),
698 [0.99, 0.0, 0.0],
699 TOL,
700 );
701 assert_eq!(sweep.short_of(fx(0.005), d, -d), Fix128::ZERO);
702 // oracle: walkable when n·Y ≥ 0.5.
703 assert!(sweep.is_walkable(Vec3Fix::UNIT_Y));
704 assert!(!sweep.is_walkable(v3(0.9, 0.4359, 0.0)));
705 // oracle: v − n (v·n): (3, 4, 5) without its Y part.
706 assert_eq!(
707 project(v3(3.0, 4.0, 5.0), Vec3Fix::UNIT_Y),
708 v3(3.0, 0.0, 5.0)
709 );
710 }
711}