alice_physics/sdf_character.rs
1//! SDF-swept kinematic character controller.
2//!
3//! Alternative to [`crate::character`] (trimesh / capsule move-and-slide)
4//! that treats the world as a signed distance field. The character is a
5//! vertical capsule (`radius`, `height`); its position advances by a
6//! sequence of penetration-resolving surface projections instead of
7//! swept-shape collision against a triangle soup.
8//!
9//! The controller is deterministic in the sense that it uses `f32`
10//! throughout (matching [`crate::sdf_collider::SdfField`]), so it can
11//! be driven by ALICE-SDF's compiled fields for level geometry with a
12//! stable evaluation cost.
13//!
14//! # Move-and-slide algorithm
15//!
16//! 1. Advance the tentative position by the full desired displacement.
17//! 2. Sample the SDF at the character's centre. If the sample is
18//! smaller than `radius`, the character is penetrating.
19//! 3. Push the character out along the surface normal by
20//! `radius − sample`, rounding up to
21//! `radius − sample + skin_width` so the next query starts safely
22//! outside the field, and capping the step at `max_push`.
23//! 4. Repeat steps 2-3 until either the sample is `>= radius` or the
24//! iteration budget is exhausted.
25//! 5. Return the corrected position (regardless of whether all
26//! iterations converged; a caller wanting hard guarantees can
27//! inspect [`MoveOutcome::converged`]).
28//!
29//! # Fields that are not exact distance fields
30//!
31// LIMITATION(COV-MBD-113): Step 3 treats the sample as a true distance.
32//! Step 3 treats the sample as a true distance. For a field with
33//! `|∇f| = L > 1` — gyroid walls and many other implicit surfaces are in
34//! that class — the sample **overstates** the penetration by up to `L`,
35//! so the full push can clear the free gap and land inside the next
36//! sheet of geometry, i.e. the character tunnels through a wall.
37//!
38//! ⚠️ [`MoveOutcome::converged`] does **not** rule this out. The hop
39//! length depends on the penetration depth, so the loop keeps jumping
40//! until one hop happens to land in a gap and then reports success — in
41//! a pocket the character never legally reached. On the periodic-sheet
42//! field of `tests/analytic_sdf_character_up_axis.rs` it converges on
43//! iteration 5, two and a half periods away, having crossed three solid
44//! sheets.
45//!
46//! [`SdfCharacter::max_push`] caps one iteration's step for exactly that
47//! case: with the cap below the free gap's width the character walks out
48//! into the adjacent pocket instead of jumping past it. It defaults to
49//! [`f32::INFINITY`], so an exact distance field keeps the original
50//! single-step arithmetic bit for bit.
51//!
52//! Two more consequences of an inexact field, both opt-in for the same
53//! bit-compatibility reason:
54//!
55//! - **The normal can face the centre of the world.** Geometry buried in
56//! the ground reports that at the seam, and pushing along it drives the
57//! character underground. [`SdfCharacter::min_up_alignment`] rejects
58//! those normals and pushes along [`SdfCharacter::up`] instead, at the
59//! same magnitude. Default [`f32::NEG_INFINITY`] never substitutes.
60//! - **A push can land deeper than it started.** The run therefore
61//! records the least-penetrating sample it saw in
62//! [`MoveOutcome::best_position`] / [`MoveOutcome::best_distance`],
63//! including the position produced by the final push. A caller that
64//! must never return a worse position than it was handed uses those
65//! when [`MoveOutcome::converged`] is `false`. This is reporting only —
66//! [`MoveOutcome::position`] keeps its old value.
67//!
68//! # Ground detection
69//!
70//! [`SdfCharacter::ground_contact`] probes a short distance along
71//! `−up` and reports the sampled distance together with the surface
72//! normal and its alignment to `up`;
73//! [`SdfCharacter::is_grounded`] thresholds that alignment against
74//! `ground_up_threshold`.
75//!
76//! [`SdfCharacter::up`] defaults to `+Y`, which is what a level with a
77//! single gravity direction wants. A sphere world's up axis is
78//! **radial** (`p / |p|`) and differs per position, so those consumers
79//! set `up` every frame — with a fixed `+Y` probe a character standing
80//! on the equator of a planet reports "not grounded" while standing on
81//! the ground.
82//!
83//! # One frame
84//!
85//! [`SdfCharacter::velocity`] plus [`SdfCharacter::apply_gravity`] and
86//! [`SdfCharacter::step`] cover a whole frame, so a ballistic fall and
87//! its landing live here rather than in each consumer:
88//!
89//! ```
90//! # use alice_physics::sdf_character::SdfCharacter;
91//! # use alice_physics::sdf_collider::ClosureSdf;
92//! # let field = ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0));
93//! let mut ch = SdfCharacter::new([0.0, 4.0, 0.0], 0.35, 1.8);
94//! let dt = 1.0 / 60.0;
95//! for _ in 0..240 {
96//! ch.apply_gravity([0.0, -9.81, 0.0], dt);
97//! // `control` is the frame's own locomotion, already scaled by dt.
98//! ch.step(&field, dt, [0.0, 0.0, 0.0]);
99//! }
100//! // Resting on the surface, with the fall absorbed by the contact.
101//! assert!((ch.position[1] - (0.35 + ch.skin_width)).abs() < 1.0e-3);
102//! assert!(ch.velocity[1].abs() < 1.0e-6);
103//! ```
104//!
105//! [`SdfCharacter::step`] removes only the velocity component pointing
106//! **into** what was hit, so the character slides along a wall rather
107//! than sticking to it, and a jump that brushes the floor keeps its
108//! upward speed.
109
110use crate::character_state::CharacterStateContext;
111use crate::sdf_collider::SdfField;
112
113/// What the ground probe found below the character.
114///
115/// Returned by [`SdfCharacter::ground_contact`].
116#[derive(Debug, Clone, Copy, PartialEq)]
117pub struct GroundContact {
118 /// SDF value at the probe point. Negative when the probe is inside
119 /// the surface, which is the common case for a character resting on
120 /// the ground.
121 pub distance: f32,
122 /// Unit surface normal at the probe point, as the field reports it.
123 pub normal: [f32; 3],
124 /// `normal · up` with `up` normalized — the quantity
125 /// [`SdfCharacter::is_grounded`] compares against
126 /// `ground_up_threshold`. `1.0` is a surface square to the up axis.
127 pub up_alignment: f32,
128}
129
130/// Result of an SDF move-and-slide call.
131#[derive(Debug, Clone, Copy, PartialEq)]
132pub struct MoveOutcome {
133 /// Final character centre after penetration resolution.
134 pub position: [f32; 3],
135 /// True when the last sampled distance was already `>= radius` (no
136 /// residual penetration).
137 pub converged: bool,
138 /// Number of penetration-resolution iterations that ran (0 when
139 /// the initial displacement was already collision-free).
140 pub iterations: usize,
141 /// Position of the least-penetrating sample the run saw, including
142 /// the starting one.
143 ///
144 /// Equal to [`Self::position`] whenever the run converged, and
145 /// whenever the samples improved monotonically. They differ on
146 /// fields that are not exact distance fields, where a push can land
147 /// **deeper** than where it started — see the module docs. A caller
148 /// that must never hand back a worse position than it was given uses
149 /// this field when [`Self::converged`] is `false`.
150 pub best_position: [f32; 3],
151 /// The sampled distance at [`Self::best_position`].
152 pub best_distance: f32,
153}
154
155impl MoveOutcome {
156 /// The position a caller should adopt: [`Self::position`] when the
157 /// resolution converged, [`Self::best_position`] otherwise.
158 ///
159 /// Reading [`Self::position`] unconditionally hands back a point that
160 /// can be **deeper than the one the caller passed in** on a field that
161 /// is not an exact distance field — see the module docs. This is the
162 /// safe read, in one call, so consumers do not each rewrite the same
163 /// conditional.
164 #[must_use]
165 pub const fn resolved_position(&self) -> [f32; 3] {
166 if self.converged {
167 self.position
168 } else {
169 self.best_position
170 }
171 }
172}
173
174/// Vertical-capsule character driven by SDF-swept move-and-slide.
175#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct SdfCharacter {
177 /// Centre of the character.
178 pub position: [f32; 3],
179 /// Capsule radius (m).
180 pub radius: f32,
181 /// Total capsule height (m). Currently unused by the MVP move
182 /// algorithm but preserved for downstream systems that need the
183 /// character AABB.
184 pub height: f32,
185 /// Maximum penetration-resolution iterations. Typical value: 8.
186 pub max_iterations: usize,
187 /// Small offset added on top of the pushed-out distance so the
188 /// next SDF query is safely outside the surface. Typical: 1e-4 m.
189 pub skin_width: f32,
190 /// SDF distance below the character considered "grounded". Typical
191 /// value: 0.05 m.
192 pub ground_probe_epsilon: f32,
193 /// Minimum component of the ground normal along [`Self::up`] for the
194 /// character to be considered standing on that surface. Typical:
195 /// 0.7 (≈ 45° slope).
196 pub ground_up_threshold: f32,
197 /// Up axis the ground probe walks against, in world space. Need not
198 /// be normalized (it is normalized on use); a zero or non-finite
199 /// axis falls back to `+Y`.
200 ///
201 /// Defaults to `+Y`. A sphere world sets this to the radial
202 /// direction `p / |p|` each frame.
203 pub up: [f32; 3],
204 /// Upper bound on one penetration-resolution step (m).
205 ///
206 /// Defaults to [`f32::INFINITY`] — no clamping, which keeps the
207 /// arithmetic of an exact distance field unchanged. Set it for
208 /// fields whose gradient magnitude exceeds 1 (gyroid walls and
209 /// similar), where an unclamped push can jump over the free gap into
210 /// the next sheet of geometry; see the module docs.
211 pub max_push: f32,
212 /// Minimum `normal · up` for the field's normal to be used as the
213 /// push direction. Below it the push runs along [`Self::up`] instead,
214 /// at the same magnitude.
215 ///
216 /// Defaults to [`f32::NEG_INFINITY`] — the normal is always taken, so
217 /// the original arithmetic is untouched (and [`Self::up`] is not even
218 /// normalized, so the default path costs nothing).
219 ///
220 /// Geometry buried in the ground produces normals that face the
221 /// centre of the world at the seam, and pushing along one drives the
222 /// character underground rather than out of the surface. `-0.2` keeps
223 /// sideways pushes (a wall has `normal · up = 0`) while rejecting
224 /// those.
225 pub min_up_alignment: f32,
226 /// Kinematic velocity (m/s, world space), integrated by
227 /// [`Self::apply_gravity`] and consumed by [`Self::step`].
228 ///
229 /// Defaults to zero. [`crate::character::CharacterController`] has
230 /// carried a velocity since 1.0; this is the SDF controller's
231 /// equivalent, so a ballistic fall and its landing are expressed by
232 /// the controller rather than by each consumer.
233 pub velocity: [f32; 3],
234}
235
236impl Default for SdfCharacter {
237 fn default() -> Self {
238 Self {
239 position: [0.0, 0.0, 0.0],
240 radius: 0.35,
241 height: 1.8,
242 max_iterations: 8,
243 skin_width: 1.0e-4,
244 ground_probe_epsilon: 5.0e-2,
245 ground_up_threshold: 0.7,
246 up: [0.0, 1.0, 0.0],
247 max_push: f32::INFINITY,
248 min_up_alignment: f32::NEG_INFINITY,
249 velocity: [0.0, 0.0, 0.0],
250 }
251 }
252}
253
254impl SdfCharacter {
255 /// Constructor with sensible defaults.
256 #[must_use]
257 pub fn new(position: [f32; 3], radius: f32, height: f32) -> Self {
258 Self {
259 position,
260 radius,
261 height,
262 ..Self::default()
263 }
264 }
265
266 /// Move the character by `displacement` and resolve any resulting
267 /// SDF penetration.
268 ///
269 /// Returns the outcome without mutating `self`. Callers who want
270 /// the character to remember the new position should assign
271 /// `outcome.position` back to `self.position`.
272 #[must_use]
273 pub fn move_and_slide<F: SdfField + ?Sized>(
274 &self,
275 field: &F,
276 displacement: [f32; 3],
277 ) -> MoveOutcome {
278 self.resolve(field, displacement, false).0
279 }
280
281 /// [`Self::move_and_slide`], optionally resolving walkable contacts
282 /// along [`Self::up`].
283 ///
284 /// With `walkable_along_up`, a contact whose normal `n` satisfies
285 /// `n·up ≥ ground_up_threshold` is pushed along `up` by
286 /// `(radius − d + skin_width) / (n·up)` instead of along `n` — the
287 /// distance that clears a plane with normal `n` when moving along `up`.
288 /// Standing on a walkable slope then produces no sideways push, so
289 /// gravity does not walk the character downhill; steeper contacts keep
290 /// the normal push and slide.
291 fn resolve<F: SdfField + ?Sized>(
292 &self,
293 field: &F,
294 displacement: [f32; 3],
295 walkable_along_up: bool,
296 ) -> (MoveOutcome, [f32; 3]) {
297 // Sum of the pushes as applied (direction × magnitude), kept apart
298 // from `pos`: differencing two positions of magnitude `|p|` loses
299 // the direction of a push much smaller than `ulp(|p|)`.
300 let mut pushed = [0.0_f32; 3];
301 let mut pos = [
302 self.position[0] + displacement[0],
303 self.position[1] + displacement[1],
304 self.position[2] + displacement[2],
305 ];
306 let mut iterations = 0;
307 let mut converged = false;
308 // `min_up_alignment` is `NEG_INFINITY` by default; skipping the
309 // normalization entirely in that case keeps the default path free
310 // of the `sqrt` as well as of the substitution.
311 let guard_up = if self.min_up_alignment.is_finite() {
312 Some(self.up_unit())
313 } else {
314 None
315 };
316 let walk_up = if walkable_along_up {
317 Some(self.up_unit())
318 } else {
319 None
320 };
321 let mut best_position = pos;
322 let mut best_distance = f32::NEG_INFINITY;
323 while iterations < self.max_iterations {
324 let d = field.distance(pos[0], pos[1], pos[2]);
325 if d > best_distance {
326 best_distance = d;
327 best_position = pos;
328 }
329 if d >= self.radius {
330 converged = true;
331 break;
332 }
333 let (mut nx, mut ny, mut nz) = field.normal(pos[0], pos[1], pos[2]);
334 if let Some(up) = guard_up {
335 // A normal facing the centre of the world would push the
336 // character further in; run along `up` at the same
337 // magnitude instead.
338 if nx * up[0] + ny * up[1] + nz * up[2] < self.min_up_alignment {
339 [nx, ny, nz] = up;
340 }
341 }
342 // `max_push` is `INFINITY` by default, and `min` with
343 // `INFINITY` returns the left operand unchanged, so an exact
344 // distance field keeps the original arithmetic bit for bit.
345 let mut push = self.radius - d + self.skin_width;
346 if let Some(up) = walk_up {
347 let along = nx * up[0] + ny * up[1] + nz * up[2];
348 if along >= self.ground_up_threshold && along > 0.0 {
349 push /= along;
350 [nx, ny, nz] = up;
351 }
352 }
353 let push = crate::det_math::min(push, self.max_push);
354 pos[0] += nx * push;
355 pos[1] += ny * push;
356 pos[2] += nz * push;
357 pushed[0] += nx * push;
358 pushed[1] += ny * push;
359 pushed[2] += nz * push;
360 iterations += 1;
361 }
362 if !converged {
363 // The loop only samples at its top, so the position produced
364 // by the last push has not been measured yet. Without this
365 // the budget-exhausted case would compare `best` against one
366 // sample too few — and that final push is exactly the one
367 // that can have made things worse.
368 let d = field.distance(pos[0], pos[1], pos[2]);
369 if d > best_distance {
370 best_distance = d;
371 best_position = pos;
372 }
373 }
374 (
375 MoveOutcome {
376 position: pos,
377 converged,
378 iterations,
379 best_position,
380 best_distance,
381 },
382 pushed,
383 )
384 }
385
386 /// Integrate an acceleration into [`Self::velocity`] (`v += a·dt`).
387 ///
388 /// Unconditional: gating it on whether the character is standing is
389 /// the caller's decision (a hovering or flying mode overrides the
390 /// radial velocity outright rather than accumulating).
391 pub fn apply_gravity(&mut self, gravity: [f32; 3], dt: f32) {
392 self.velocity[0] += gravity[0] * dt;
393 self.velocity[1] += gravity[1] * dt;
394 self.velocity[2] += gravity[2] * dt;
395 }
396
397 /// Advance one frame: integrate the velocity, resolve the resulting
398 /// penetration, adopt the resulting position, and remove the part of
399 /// the velocity that points into whatever was hit.
400 ///
401 /// `control` is the caller's own displacement for this frame (tangent
402 /// locomotion, a teleport nudge, …) and is added to `velocity · dt`.
403 ///
404 /// The position adopted is [`MoveOutcome::position`] when the
405 /// resolution converged and [`MoveOutcome::best_position`] otherwise,
406 /// so a frame can never end deeper than it started on a field that is
407 /// not an exact distance field.
408 ///
409 /// # Contact response
410 ///
411 /// The resolution's net correction gives the contact direction. Only
412 /// the velocity component pointing **into** the surface is removed,
413 /// which is the inelastic kinematic law: landing stops the fall and
414 /// leaves the tangential motion, so the character slides along a wall
415 /// instead of sticking to it. Velocity already pointing away from the
416 /// surface is untouched, so brushing the floor mid-jump does not eat
417 /// the jump.
418 pub fn step<F: SdfField + ?Sized>(
419 &mut self,
420 field: &F,
421 dt: f32,
422 control: [f32; 3],
423 ) -> MoveOutcome {
424 self.step_resolved(field, dt, control, false)
425 }
426
427 /// Integrate an acceleration of constant magnitude `g` toward `center`
428 /// into [`Self::velocity`] — the gravity of a sphere world, see
429 /// [`crate::spherical_terrain::central_gravity`].
430 pub fn apply_central_gravity(&mut self, center: [f32; 3], g: f32, dt: f32) {
431 let a = crate::spherical_terrain::central_gravity(center, g, self.position);
432 self.apply_gravity(a, dt);
433 }
434
435 /// Advance one frame on a sphere world centred at `center`.
436 ///
437 /// 1. [`Self::up`] becomes the radial direction `(p − c) / |p − c|`.
438 /// 2. The tangential part of `tangent_velocity` moves the character
439 /// along the great circle: the angle travelled is `|v_t|·dt / r`, so
440 /// the arc length is exactly `|v_t|·dt` and the distance from the
441 /// centre is unchanged. A straight step of the same length would
442 /// leave the sphere and shorten the arc to `r·atan(|v_t|·dt / r)`.
443 /// The radial part of `tangent_velocity` is ignored.
444 /// 3. [`Self::velocity`] is carried along by the same rotation, so a
445 /// radial velocity (a jump, a fall) stays radial at the new position.
446 /// 4. As [`Self::step`] with no `control`, except that a walkable
447 /// contact (`n·up ≥ ground_up_threshold`) is resolved along `up`:
448 /// a character standing on a walkable slope does not creep downhill
449 /// under gravity, and walking onto a rising slope lifts it.
450 ///
451 /// 5. Standing on walkable ground within `skin_width` of it and not
452 /// moving away, the character is held at the top of the skin band
453 /// (no bobbing when one frame's fall is shorter than the skin).
454 ///
455 /// A character at the centre itself has no up axis; the arc move is
456 /// skipped and steps 4 and 5 still run.
457 pub fn step_on_sphere<F: SdfField + ?Sized>(
458 &mut self,
459 field: &F,
460 center: [f32; 3],
461 dt: f32,
462 tangent_velocity: [f32; 3],
463 ) -> MoveOutcome {
464 let rel = [
465 self.position[0] - center[0],
466 self.position[1] - center[1],
467 self.position[2] - center[2],
468 ];
469 let r = crate::det_math::sqrt(rel[0] * rel[0] + rel[1] * rel[1] + rel[2] * rel[2]);
470 if r > 0.0 && r.is_finite() {
471 let up = [rel[0] / r, rel[1] / r, rel[2] / r];
472 self.up = up;
473 let radial = tangent_velocity[0] * up[0]
474 + tangent_velocity[1] * up[1]
475 + tangent_velocity[2] * up[2];
476 let vt = [
477 tangent_velocity[0] - up[0] * radial,
478 tangent_velocity[1] - up[1] * radial,
479 tangent_velocity[2] - up[2] * radial,
480 ];
481 let speed = crate::det_math::sqrt(vt[0] * vt[0] + vt[1] * vt[1] + vt[2] * vt[2]);
482 let angle = speed * dt / r;
483 if angle > 0.0 && angle.is_finite() {
484 let t = [vt[0] / speed, vt[1] / speed, vt[2] / speed];
485 let (sin, cos) = crate::det_math::sin_cos(angle);
486 let new_up = [
487 up[0] * cos + t[0] * sin,
488 up[1] * cos + t[1] * sin,
489 up[2] * cos + t[2] * sin,
490 ];
491 let new_t = [
492 t[0] * cos - up[0] * sin,
493 t[1] * cos - up[1] * sin,
494 t[2] * cos - up[2] * sin,
495 ];
496 self.position = [
497 center[0] + new_up[0] * r,
498 center[1] + new_up[1] * r,
499 center[2] + new_up[2] * r,
500 ];
501 // Rotate the velocity by the same angle about up × t: the
502 // components along up and t turn with the frame, the one
503 // along the axis is unchanged.
504 let v = self.velocity;
505 let v_up = v[0] * up[0] + v[1] * up[1] + v[2] * up[2];
506 let v_t = v[0] * t[0] + v[1] * t[1] + v[2] * t[2];
507 for i in 0..3 {
508 let axis_part = v[i] - up[i] * v_up - t[i] * v_t;
509 self.velocity[i] = axis_part + new_up[i] * v_up + new_t[i] * v_t;
510 }
511 self.up = new_up;
512 }
513 }
514 let outcome = self.step_resolved(field, dt, [0.0; 3], true);
515 self.snap_to_walkable_ground(field);
516 outcome
517 }
518
519 /// Ground snap for [`Self::step_on_sphere`].
520 ///
521 /// A resolution leaves the character `skin_width` clear of the surface,
522 /// and the next frame's fall only counts as contact once it eats through
523 /// that skin. When one frame's fall is shorter than the skin, the
524 /// character free-falls across it every few frames and bobs by up to
525 /// `skin_width`. Standing on walkable ground (`n·up ≥ ground_up_threshold`)
526 /// inside the skin band and not moving away from it, the character is
527 /// lifted back to the top of the band and its velocity toward the ground
528 /// is removed, so it rests at one height. A character moving away (a
529 /// jump) is left alone, and the snap only ever lifts.
530 fn snap_to_walkable_ground<F: SdfField + ?Sized>(&mut self, field: &F) {
531 let [x, y, z] = self.position;
532 let d = field.distance(x, y, z);
533 let band = self.radius + self.skin_width;
534 if d >= band || d.is_nan() {
535 return;
536 }
537 let up = self.up_unit();
538 let (nx, ny, nz) = field.normal(x, y, z);
539 let along = nx * up[0] + ny * up[1] + nz * up[2];
540 let v_up = self.velocity[0] * up[0] + self.velocity[1] * up[1] + self.velocity[2] * up[2];
541 if along >= self.ground_up_threshold && along > 0.0 && v_up <= 0.0 {
542 let lift = (band - d) / along;
543 for ((p, v), u) in self.position.iter_mut().zip(&mut self.velocity).zip(up) {
544 *p += u * lift;
545 *v -= u * v_up;
546 }
547 }
548 }
549
550 /// Body of [`Self::step`] and [`Self::step_on_sphere`].
551 fn step_resolved<F: SdfField + ?Sized>(
552 &mut self,
553 field: &F,
554 dt: f32,
555 control: [f32; 3],
556 walkable_along_up: bool,
557 ) -> MoveOutcome {
558 let displacement = [
559 self.velocity[0] * dt + control[0],
560 self.velocity[1] * dt + control[1],
561 self.velocity[2] * dt + control[2],
562 ];
563 let unresolved = [
564 self.position[0] + displacement[0],
565 self.position[1] + displacement[1],
566 self.position[2] + displacement[2],
567 ];
568 let (outcome, pushed) = self.resolve(field, displacement, walkable_along_up);
569 let adopted = outcome.resolved_position();
570 self.position = adopted;
571
572 // `step` keeps the position difference (its historical arithmetic);
573 // the sphere step uses the exact sum of pushes when the resolution
574 // converged, because on a planet `|p|` is large and the per-frame
575 // push is small, so the difference has lost the contact direction.
576 let correction = if walkable_along_up && outcome.converged {
577 pushed
578 } else {
579 [
580 adopted[0] - unresolved[0],
581 adopted[1] - unresolved[1],
582 adopted[2] - unresolved[2],
583 ]
584 };
585 let len = crate::det_math::sqrt(
586 correction[0] * correction[0]
587 + correction[1] * correction[1]
588 + correction[2] * correction[2],
589 );
590 // No correction means no contact, so the velocity is untouched.
591 // The threshold is `skin_width` because a converged resolution
592 // always overshoots by at least that much.
593 if len > self.skin_width {
594 let n = [
595 correction[0] / len,
596 correction[1] / len,
597 correction[2] / len,
598 ];
599 let into = self.velocity[0] * n[0] + self.velocity[1] * n[1] + self.velocity[2] * n[2];
600 if into < 0.0 {
601 self.velocity[0] -= n[0] * into;
602 self.velocity[1] -= n[1] * into;
603 self.velocity[2] -= n[2] * into;
604 }
605 }
606 outcome
607 }
608
609 /// [`Self::up`] normalized, falling back to `+Y` for a zero or
610 /// non-finite axis so the probe can never produce `NaN` coordinates.
611 ///
612 /// `+Y` (the default) normalizes to itself exactly: the length is
613 /// `1.0` and dividing by `1.0` is exact in IEEE-754.
614 ///
615 /// The length goes through [`crate::det_math::sqrt`] rather than
616 /// `f32::sqrt` — this module is not `std`-gated, and the platform
617 /// libm is not cross-platform bit-exact.
618 #[must_use]
619 fn up_unit(&self) -> [f32; 3] {
620 let [x, y, z] = self.up;
621 let len = crate::det_math::sqrt(x * x + y * y + z * z);
622 if len > 0.0 && len.is_finite() {
623 [x / len, y / len, z / len]
624 } else {
625 [0.0, 1.0, 0.0]
626 }
627 }
628
629 /// Probe `radius + ground_probe_epsilon` along `−up` and report what
630 /// the field says there.
631 ///
632 /// `None` when the probe point is farther than
633 /// `ground_probe_epsilon` from any surface (the character is in the
634 /// air). The alignment test itself is left to the caller, or to
635 /// [`Self::is_grounded`].
636 #[must_use]
637 pub fn ground_contact<F: SdfField + ?Sized>(&self, field: &F) -> Option<GroundContact> {
638 let up = self.up_unit();
639 let reach = self.radius + self.ground_probe_epsilon;
640 let probe = [
641 self.position[0] - up[0] * reach,
642 self.position[1] - up[1] * reach,
643 self.position[2] - up[2] * reach,
644 ];
645 let distance = field.distance(probe[0], probe[1], probe[2]);
646 if distance > self.ground_probe_epsilon {
647 return None;
648 }
649 let (nx, ny, nz) = field.normal(probe[0], probe[1], probe[2]);
650 Some(GroundContact {
651 distance,
652 normal: [nx, ny, nz],
653 up_alignment: nx * up[0] + ny * up[1] + nz * up[2],
654 })
655 }
656
657 /// Build the per-tick sensor readings for [`crate::character_state::transition`]
658 /// from the ground probe.
659 ///
660 /// `is_grounded` is "the probe found a surface" (any contact from
661 /// [`Self::ground_contact`], not the [`Self::ground_up_threshold`]
662 /// test), and `slope_radians` is the angle between the contact normal
663 /// and [`Self::up`], `acos(up_alignment)`. The state machine, not
664 /// [`Self::is_grounded`], then decides between standing and sliding with
665 /// `max_walkable_slope`, so a steep contact yields `Sliding` instead of
666 /// being reported as airborne. With no contact the slope is `0` and
667 /// `is_grounded` is `false`.
668 ///
669 /// `in_water`, `crouch_requested` and `jump_pressed` are the caller's own
670 /// inputs and are copied through.
671 #[must_use]
672 pub fn locomotion_context<F: SdfField + ?Sized>(
673 &self,
674 field: &F,
675 in_water: bool,
676 crouch_requested: bool,
677 jump_pressed: bool,
678 max_walkable_slope: f32,
679 ) -> CharacterStateContext {
680 let (is_grounded, slope_radians) = match self.ground_contact(field) {
681 Some(c) => (true, crate::det_math::acos(c.up_alignment.clamp(-1.0, 1.0))),
682 None => (false, 0.0),
683 };
684 CharacterStateContext {
685 is_grounded,
686 slope_radians,
687 in_water,
688 crouch_requested,
689 jump_pressed,
690 max_walkable_slope,
691 }
692 }
693
694 /// `true` when the character stands on a surface whose normal points
695 /// predominantly along [`Self::up`].
696 #[must_use]
697 pub fn is_grounded<F: SdfField + ?Sized>(&self, field: &F) -> bool {
698 self.ground_contact(field)
699 .is_some_and(|c| c.up_alignment >= self.ground_up_threshold)
700 }
701}
702
703// ============================================================================
704// The world's SDF colliders as one field
705// ============================================================================
706
707/// The union of a [`PhysicsWorld`](crate::solver::PhysicsWorld)'s SDF colliders
708/// ([`PhysicsWorld::sdf_colliders`](crate::solver::PhysicsWorld::sdf_colliders))
709/// as one world-space [`SdfField`], returned by
710/// [`PhysicsWorld::sdf_field`](crate::solver::PhysicsWorld::sdf_field).
711///
712/// A world point is queried in each collider's local space through its
713/// [`SdfFrame`](crate::sdf_collider::SdfFrame) (as
714/// [`crate::sdf_collider::collide_point_sdf`] does): the distance is the
715/// minimum over the colliders of `scale · field(local point)`, and the normal
716/// is the nearest collider's local normal rotated to world space (the first
717/// collider in index order on a tie, as [`crate::sdf_collider::SdfUnion`]
718/// takes its first field). With no colliders the distance is `+∞` and the
719/// normal `+Y`.
720///
721/// Colliders are taken as stored: a collider attached to a body follows the
722/// body only after [`crate::sdf_collider::sync_dynamic_sdf_colliders`].
723#[cfg(feature = "std")]
724#[derive(Clone, Copy)]
725pub struct WorldSdfField<'a> {
726 colliders: &'a [crate::sdf_collider::SdfCollider],
727}
728
729#[cfg(feature = "std")]
730impl WorldSdfField<'_> {
731 /// The nearest collider at a world point and its world distance.
732 fn nearest(&self, x: f32, y: f32, z: f32) -> Option<(usize, (f32, f32, f32), f32)> {
733 let p = crate::math::Vec3Fix::from_f32(x, y, z);
734 let mut best: Option<(usize, (f32, f32, f32), f32)> = None;
735 for (i, c) in self.colliders.iter().enumerate() {
736 let (lx, ly, lz) = c.world_to_local(p);
737 let d = c.field.distance(lx, ly, lz) * c.frame().scale_f32();
738 if best.is_none_or(|(_, _, b)| d < b) {
739 best = Some((i, (lx, ly, lz), d));
740 }
741 }
742 best
743 }
744}
745
746#[cfg(feature = "std")]
747impl SdfField for WorldSdfField<'_> {
748 fn distance(&self, x: f32, y: f32, z: f32) -> f32 {
749 self.nearest(x, y, z).map_or(f32::INFINITY, |(_, _, d)| d)
750 }
751
752 fn normal(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
753 self.distance_and_normal(x, y, z).1
754 }
755
756 fn distance_and_normal(&self, x: f32, y: f32, z: f32) -> (f32, (f32, f32, f32)) {
757 match self.nearest(x, y, z) {
758 Some((i, (lx, ly, lz), d)) => {
759 let c = &self.colliders[i];
760 let (nx, ny, nz) = c.field.normal(lx, ly, lz);
761 (d, c.local_normal_to_world(nx, ny, nz).to_f32())
762 }
763 None => (f32::INFINITY, (0.0, 1.0, 0.0)),
764 }
765 }
766}
767
768#[cfg(feature = "std")]
769impl crate::solver::PhysicsWorld {
770 /// The world's SDF colliders as one field ([`WorldSdfField`]), for any
771 /// [`SdfCharacter`] query: [`SdfCharacter::move_and_slide`],
772 /// [`SdfCharacter::step_on_sphere`], [`SdfCharacter::ground_contact`], ….
773 #[must_use]
774 pub fn sdf_field(&self) -> WorldSdfField<'_> {
775 WorldSdfField {
776 colliders: &self.sdf_colliders,
777 }
778 }
779
780 /// Advance `ch` one frame against the world's SDF colliders:
781 /// [`SdfCharacter::step`] on [`PhysicsWorld::sdf_field`](crate::solver::PhysicsWorld::sdf_field),
782 /// with the same arguments and result. `dt` is passed through unchanged, as
783 /// [`SdfCharacter::step`] takes it (`dt = 0` moves by `control` only).
784 ///
785 /// The character sees only the SDF colliders, not bodies or static
786 /// colliders; for a capsule against everything the world collides with see
787 /// [`PhysicsWorld::move_character`](crate::solver::PhysicsWorld::move_character).
788 pub fn move_sdf_character(
789 &self,
790 ch: &mut SdfCharacter,
791 dt: f32,
792 control: [f32; 3],
793 ) -> MoveOutcome {
794 ch.step(&self.sdf_field(), dt, control)
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801 use crate::sdf_collider::ClosureSdf;
802
803 fn ground_plane() -> ClosureSdf {
804 // Infinite plane at y = 0, positive above.
805 ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0))
806 }
807
808 fn unit_sphere() -> ClosureSdf {
809 ClosureSdf::new(
810 |x, y, z| (x * x + y * y + z * z).sqrt() - 1.0,
811 |x, y, z| {
812 let len = (x * x + y * y + z * z).sqrt().max(1.0e-6);
813 (x / len, y / len, z / len)
814 },
815 )
816 }
817
818 #[test]
819 fn move_in_free_space_leaves_character_unchanged() {
820 let field = ground_plane();
821 let character = SdfCharacter::new([0.0, 2.0, 0.0], 0.35, 1.8);
822 let outcome = character.move_and_slide(&field, [1.0, 0.0, 0.0]);
823 assert!(outcome.converged);
824 assert_eq!(outcome.iterations, 0);
825 assert!((outcome.position[0] - 1.0).abs() < 1.0e-6);
826 assert!((outcome.position[1] - 2.0).abs() < 1.0e-6);
827 }
828
829 #[test]
830 fn character_pushed_out_of_plane() {
831 let field = ground_plane();
832 // Character radius 0.35 sitting with centre at y = 0.1 →
833 // penetrating the ground.
834 let character = SdfCharacter::new([0.0, 0.1, 0.0], 0.35, 1.8);
835 let outcome = character.move_and_slide(&field, [0.0, 0.0, 0.0]);
836 assert!(outcome.converged);
837 // Final centre should be at y ≥ radius.
838 assert!(
839 outcome.position[1] >= character.radius,
840 "y = {} < radius {}",
841 outcome.position[1],
842 character.radius
843 );
844 }
845
846 #[test]
847 fn character_pushed_off_sphere_surface() {
848 let field = unit_sphere();
849 // Character penetrating a unit sphere at the +x pole.
850 let character = SdfCharacter::new([0.9, 0.0, 0.0], 0.35, 1.8);
851 let outcome = character.move_and_slide(&field, [0.0, 0.0, 0.0]);
852 // After resolution the centre should sit sphere radius +
853 // character radius outward.
854 let dist = (outcome.position[0] * outcome.position[0]
855 + outcome.position[1] * outcome.position[1]
856 + outcome.position[2] * outcome.position[2])
857 .sqrt();
858 assert!(
859 dist >= 1.0 + character.radius - 1.0e-3,
860 "expected distance ≥ 1.35, got {dist}"
861 );
862 }
863
864 #[test]
865 fn is_grounded_true_on_plane() {
866 let field = ground_plane();
867 let character = SdfCharacter::new([0.0, 0.35, 0.0], 0.35, 1.8);
868 assert!(character.is_grounded(&field));
869 }
870
871 #[test]
872 fn is_grounded_false_in_air() {
873 let field = ground_plane();
874 let character = SdfCharacter::new([0.0, 5.0, 0.0], 0.35, 1.8);
875 assert!(!character.is_grounded(&field));
876 }
877
878 #[test]
879 fn is_grounded_false_on_steep_slope() {
880 // A "slope" field: surface at y = x (45°), gradient tilted.
881 let slope = ClosureSdf::new(
882 |x, y, _z| (y - x) / 2.0_f32.sqrt(),
883 |_x, _y, _z| (-1.0 / 2.0_f32.sqrt(), 1.0 / 2.0_f32.sqrt(), 0.0),
884 );
885 // ny = 1/√2 ≈ 0.707, at the default ground_up_threshold = 0.7 the
886 // slope should be borderline; make the threshold stricter.
887 let mut character = SdfCharacter::new([0.5, 0.85, 0.0], 0.35, 1.8);
888 character.ground_up_threshold = 0.9;
889 assert!(!character.is_grounded(&slope));
890 }
891
892 #[test]
893 fn default_config_is_reasonable() {
894 let c = SdfCharacter::default();
895 assert!(c.radius > 0.0);
896 assert!(c.height > c.radius);
897 assert!(c.max_iterations > 0);
898 }
899
900 #[test]
901 fn zero_iterations_when_no_penetration_after_move() {
902 let field = ground_plane();
903 let character = SdfCharacter::new([0.0, 5.0, 0.0], 0.35, 1.8);
904 let outcome = character.move_and_slide(&field, [1.0, 0.0, 0.0]);
905 assert_eq!(outcome.iterations, 0);
906 assert!(outcome.converged);
907 }
908
909 fn sphere_of(r: f32) -> ClosureSdf {
910 ClosureSdf::new(
911 move |x, y, z| (x * x + y * y + z * z).sqrt() - r,
912 |x, y, z| {
913 let len = (x * x + y * y + z * z).sqrt().max(1.0e-6);
914 (x / len, y / len, z / len)
915 },
916 )
917 }
918
919 /// oracle: `resolved_position` is the position when the resolution
920 /// converged and the best position found otherwise.
921 #[test]
922 fn resolved_position_falls_back_to_the_best() {
923 let mut o = MoveOutcome {
924 position: [1.0, 2.0, 3.0],
925 converged: true,
926 iterations: 1,
927 best_position: [4.0, 5.0, 6.0],
928 best_distance: 0.5,
929 };
930 assert_eq!(o.resolved_position(), [1.0, 2.0, 3.0]);
931 o.converged = false;
932 assert_eq!(o.resolved_position(), [4.0, 5.0, 6.0]);
933 }
934
935 /// oracle: on a planet of radius 10 a character of radius 1/2 standing at
936 /// the north pole `(0, 10.5, 0)` that walks along `+x` at
937 /// `v = r·(π/2)/dt` (`r = 10.5`, `dt = 1`) is carried a quarter of a great
938 /// circle to `(10.5, 0, 0)` (snapped onto the skin band, within 1e-3), and
939 /// its up axis turns to `+x`. Central gravity `g = 2` for `dt = 1/2` adds
940 /// `−g·dt` along the radius: velocity `(−1, 0, 0)`.
941 #[test]
942 fn step_on_sphere_moves_a_quarter_great_circle() {
943 let planet = sphere_of(10.0);
944 let mut ch = SdfCharacter::new([0.0, 10.5, 0.0], 0.5, 1.8);
945 let v = 10.5 * core::f32::consts::FRAC_PI_2;
946 let _ = ch.step_on_sphere(&planet, [0.0; 3], 1.0, [v, 0.0, 0.0]);
947 let [x, y, z] = ch.position;
948 assert!(
949 (x - 10.5).abs() < 1e-3 && y.abs() < 1e-3 && z.abs() < 1e-3,
950 "{:?}",
951 ch.position
952 );
953 assert!(
954 (ch.up[0] - 1.0).abs() < 1e-5 && ch.up[1].abs() < 1e-5,
955 "{:?}",
956 ch.up
957 );
958 ch.velocity = [0.0; 3];
959 ch.apply_central_gravity([0.0; 3], 2.0, 0.5);
960 assert!(
961 (ch.velocity[0] + 1.0).abs() < 1e-5 && ch.velocity[1].abs() < 1e-5,
962 "{:?}",
963 ch.velocity
964 );
965 }
966
967 /// oracle: a character at the centre has no up axis: the arc move is
968 /// skipped and, far from the surface of a planet of radius 10 (distance
969 /// 10 inside, so `|d| ≥ band`) with no velocity, it stays where it is.
970 #[test]
971 fn step_on_sphere_at_the_centre_stays() {
972 let planet = sphere_of(10.0);
973 let mut ch = SdfCharacter::new([0.0; 3], 0.5, 1.8);
974 let _ = ch.step_on_sphere(&planet, [0.0; 3], 1.0, [1.0, 0.0, 0.0]);
975 assert_eq!(ch.position, [0.0; 3]);
976 }
977
978 /// oracle: the world's field is the nearest of its SDF colliders: with a
979 /// ground plane at `y = 0` the distance at `(0, 3, 0)` is 3 and the
980 /// normal `+y`; without colliders it is `+∞` with normal `+y`. Moving a
981 /// character of radius 1/2 by `(0, −1, 0)` from `y = 1` through the
982 /// world pushes it back out to `y ≥ 1/2`.
983 #[cfg(feature = "std")]
984 #[test]
985 fn world_sdf_field_and_character_move() {
986 let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig::default());
987 let empty = world.sdf_field();
988 assert_eq!(empty.distance(0.0, 3.0, 0.0), f32::INFINITY);
989 assert_eq!(
990 empty.distance_and_normal(0.0, 3.0, 0.0),
991 (f32::INFINITY, (0.0, 1.0, 0.0))
992 );
993 world.add_sdf_collider(crate::sdf_collider::SdfCollider::new_static(
994 Box::new(ground_plane()),
995 crate::math::Vec3Fix::ZERO,
996 crate::math::QuatFix::IDENTITY,
997 ));
998 let field = world.sdf_field();
999 assert!((field.distance(0.0, 3.0, 0.0) - 3.0).abs() < 1e-6);
1000 let (nx, ny, nz) = field.normal(0.0, 3.0, 0.0);
1001 assert!(nx.abs() < 1e-6 && (ny - 1.0).abs() < 1e-6 && nz.abs() < 1e-6);
1002 let mut ch = SdfCharacter::new([0.0, 1.0, 0.0], 0.5, 1.8);
1003 let out = world.move_sdf_character(&mut ch, 1.0, [0.0, -1.0, 0.0]);
1004 assert!(out.converged);
1005 assert!(ch.position[1] >= 0.5 - 1e-4, "{:?}", ch.position);
1006 }
1007}