ferravex_physics/character/
mod.rs

1//! Kinematic upright capsules and planar boxes, shared by games and prediction.
2//! Positive Y is up. Positions are feet/sole centers. Move terrain first, then motors.
3mod geometry;
4mod motion;
5use ferravex_math::{Quat, Vec2, Vec3};
6pub use motion::{
7    Character, CharacterInput, CharacterStep, JumpState, Motion, Support, move_and_slide,
8};
9
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub enum CharacterShape {
12    Capsule {
13        radius: f32,
14        height: f32,
15    },
16    /// Axis-aligned planar rectangle, half extents in XY; Z remains zero.
17    Box2 {
18        half: Vec2,
19    },
20}
21impl CharacterShape {
22    pub fn valid(self) -> bool {
23        match self {
24            Self::Capsule { radius, height } => {
25                radius.is_finite()
26                    && height.is_finite()
27                    && radius >= 0.001
28                    && height >= radius * 2.
29                    && height <= 1000.
30            }
31            Self::Box2 { half } => {
32                half.is_finite() && half.min_element() >= 0.001 && half.max_element() <= 1000.
33            }
34        }
35    }
36    pub fn half_extents(self) -> Vec3 {
37        match self {
38            Self::Capsule { radius, height } => Vec3::new(radius, height * 0.5, radius),
39            Self::Box2 { half } => Vec3::new(half.x, half.y, 0.),
40        }
41    }
42    fn planar(self) -> bool {
43        matches!(self, Self::Box2 { .. })
44    }
45    fn center(self, feet: Vec3) -> Vec3 {
46        feet + Vec3::Y * self.half_extents().y
47    }
48    pub fn bounds(self, feet: Vec3) -> QueryBounds {
49        let center = self.center(feet);
50        let half = self.half_extents();
51        QueryBounds {
52            min: center - half,
53            max: center + half,
54        }
55    }
56}
57
58#[derive(Clone, Copy, Debug)]
59pub struct QueryBounds {
60    pub min: Vec3,
61    pub max: Vec3,
62}
63impl QueryBounds {
64    pub fn intersects(self, other: Self) -> bool {
65        (0..3).all(|i| self.min[i] <= other.max[i] && self.max[i] >= other.min[i])
66    }
67    pub fn expanded(self, amount: f32) -> Self {
68        Self {
69            min: self.min - Vec3::splat(amount),
70            max: self.max + Vec3::splat(amount),
71        }
72    }
73    fn union(self, other: Self) -> Self {
74        Self {
75            min: self.min.min(other.min),
76            max: self.max.max(other.max),
77        }
78    }
79}
80#[derive(Clone, Copy, Debug)]
81pub enum ObstacleShape {
82    Box {
83        half: Vec3,
84    },
85    Ball {
86        radius: f32,
87    },
88    Capsule {
89        a: Vec3,
90        b: Vec3,
91        radius: f32,
92    },
93    Cylinder {
94        half_height: f32,
95        radius: f32,
96    },
97    /// An infinite solid half-space, with local outward unit normal.
98    Plane {
99        normal: Vec3,
100    },
101}
102#[derive(Clone, Copy, Debug)]
103pub struct Obstacle {
104    /// Stable, unique identity within a terrain. Reuse requires a new generation/id.
105    pub id: u64,
106    pub shape: ObstacleShape,
107    pub position: Vec3,
108    pub rotation: Quat,
109    pub membership: u32,
110    pub sensor: bool,
111}
112impl Obstacle {
113    pub fn cuboid(id: u64, position: Vec3, half: Vec3) -> Self {
114        Self {
115            id,
116            position,
117            shape: ObstacleShape::Box { half },
118            rotation: Quat::IDENTITY,
119            membership: u32::MAX,
120            sensor: false,
121        }
122    }
123    pub fn valid(self) -> bool {
124        if !bounded(self.position)
125            || !self.rotation.is_finite()
126            || (self.rotation.length_squared() - 1.).abs() > 0.001
127        {
128            return false;
129        }
130        let radius_valid = |r: f32| r.is_finite() && (0.001..=1000.).contains(&r);
131        match self.shape {
132            ObstacleShape::Box { half } => {
133                half.is_finite() && half.min_element() > 0. && half.max_element() <= 10000.
134            }
135            ObstacleShape::Ball { radius } => radius_valid(radius),
136            ObstacleShape::Capsule { a, b, radius } => {
137                bounded(a) && bounded(b) && (a - b).length() <= 2000. && radius_valid(radius)
138            }
139            ObstacleShape::Cylinder {
140                half_height,
141                radius,
142            } => radius_valid(half_height) && radius_valid(radius),
143            ObstacleShape::Plane { normal } => {
144                normal.is_finite() && (normal.length_squared() - 1.).abs() < 0.001
145            }
146        }
147    }
148    pub fn bounds(self) -> QueryBounds {
149        let (center, half) = match self.shape {
150            ObstacleShape::Box { half } => (
151                self.position,
152                (self.rotation * Vec3::X).abs() * half.x
153                    + (self.rotation * Vec3::Y).abs() * half.y
154                    + (self.rotation * Vec3::Z).abs() * half.z,
155            ),
156            ObstacleShape::Ball { radius } => (self.position, Vec3::splat(radius)),
157            ObstacleShape::Capsule { a, b, radius } => {
158                let a = self.position + self.rotation * a;
159                let b = self.position + self.rotation * b;
160                ((a + b) * 0.5, (a - b).abs() * 0.5 + Vec3::splat(radius))
161            }
162            ObstacleShape::Cylinder {
163                half_height,
164                radius,
165            } => {
166                let y = self.rotation * Vec3::Y;
167                (
168                    self.position,
169                    Vec3::new(
170                        (1. - y.x * y.x).max(0.).sqrt(),
171                        (1. - y.y * y.y).max(0.).sqrt(),
172                        (1. - y.z * y.z).max(0.).sqrt(),
173                    ) * radius
174                        + y.abs() * half_height,
175                )
176            }
177            ObstacleShape::Plane { .. } => (Vec3::ZERO, Vec3::splat(2e6)),
178        };
179        QueryBounds {
180            min: center - half,
181            max: center + half,
182        }
183    }
184}
185/// The visitor receives current poses. Implementations may use their broadphase;
186/// they must visit every obstacle intersecting bounds, in deterministic order.
187/// Character queries allocate no scratch buffers. Sensors/layers are filtered by the motor.
188pub trait Terrain {
189    fn visit(&self, bounds: QueryBounds, visitor: &mut dyn FnMut(Obstacle));
190    /// Current pose for a previously reported support; None releases a removed platform.
191    fn obstacle(&self, id: u64) -> Option<Obstacle>;
192}
193impl Terrain for [Obstacle] {
194    fn visit(&self, bounds: QueryBounds, visitor: &mut dyn FnMut(Obstacle)) {
195        for &o in self {
196            if !o.valid() || bounds.intersects(o.bounds()) {
197                visitor(o);
198            }
199        }
200    }
201    fn obstacle(&self, id: u64) -> Option<Obstacle> {
202        self.iter().find(|o| o.id == id).copied()
203    }
204}
205#[derive(Clone, Copy, Debug)]
206pub struct CharacterSettings {
207    pub skin: f32,
208    pub ground_snap: f32,
209    pub step_height: f32,
210    pub min_ground_normal: f32,
211    pub max_iterations: u32,
212    pub max_candidates: usize,
213    pub max_recovery: f32,
214    pub membership_mask: u32,
215    pub speed: f32,
216    pub ground_acceleration: f32,
217    pub air_acceleration: f32,
218    pub gravity: f32,
219    pub terminal_speed: f32,
220    pub jump_speed: f32,
221    pub coyote_time: f32,
222    pub jump_buffer: f32,
223}
224impl Default for CharacterSettings {
225    fn default() -> Self {
226        Self {
227            skin: 0.002,
228            ground_snap: 0.08,
229            step_height: 0.25,
230            min_ground_normal: 0.7,
231            max_iterations: 8,
232            max_candidates: 16384,
233            max_recovery: 1.,
234            membership_mask: u32::MAX,
235            speed: 7.,
236            ground_acceleration: 55.,
237            air_acceleration: 14.,
238            gravity: 24.,
239            terminal_speed: 50.,
240            jump_speed: 8.6,
241            coyote_time: 0.1,
242            jump_buffer: 0.12,
243        }
244    }
245}
246impl CharacterSettings {
247    pub fn valid(self) -> bool {
248        let nonnegative = |v: f32, max: f32| v.is_finite() && (0. ..=max).contains(&v);
249        self.skin.is_finite()
250            && (1e-5..=1.).contains(&self.skin)
251            && nonnegative(self.ground_snap, 100.)
252            && nonnegative(self.step_height, 100.)
253            && self.min_ground_normal.is_finite()
254            && (0.01..=1.).contains(&self.min_ground_normal)
255            && (1..=16).contains(&self.max_iterations)
256            && (1..=1_000_000).contains(&self.max_candidates)
257            && nonnegative(self.max_recovery, 1000.)
258            && nonnegative(self.speed, 100000.)
259            && nonnegative(self.ground_acceleration, 1e6)
260            && nonnegative(self.air_acceleration, 1e6)
261            && nonnegative(self.gravity, 1e6)
262            && nonnegative(self.terminal_speed, 100000.)
263            && nonnegative(self.jump_speed, 100000.)
264            && nonnegative(self.coyote_time, 0.5)
265            && nonnegative(self.jump_buffer, 0.5)
266    }
267}
268#[derive(Clone, Copy, Debug, PartialEq, Eq)]
269pub enum CharacterError {
270    InvalidInput,
271    InvalidObstacle,
272    QueryBudget,
273    SweepBudget,
274    UnresolvedPenetration,
275}
276impl std::fmt::Display for CharacterError {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        write!(f, "character controller: {self:?}")
279    }
280}
281impl std::error::Error for CharacterError {}
282fn bounded(p: Vec3) -> bool {
283    p.is_finite() && p.abs().max_element() <= 1e6
284}
285
286// The rigid world adapter reads current poses and excludes the character's own
287// body through the query mask/sensor flag when applications also store a proxy.
288impl<B: crate::body::Access> Terrain for crate::world::World<B> {
289    fn visit(&self, bounds: QueryBounds, visitor: &mut dyn FnMut(Obstacle)) {
290        for (handle, collider) in self.colliders.iter() {
291            if let Some(o) = self.character_obstacle(handle, collider)
292                && bounds.intersects(o.bounds())
293            {
294                visitor(o);
295            }
296        }
297    }
298    fn obstacle(&self, id: u64) -> Option<Obstacle> {
299        let handle = crate::ColliderHandle {
300            index: (id & 0xffff_ffff) as usize,
301            generation: (id >> 32) as u32,
302        };
303        self.character_obstacle(handle, self.colliders.get(handle)?)
304    }
305}
306impl<B: crate::body::Access> crate::world::World<B> {
307    fn character_obstacle(
308        &self,
309        handle: crate::ColliderHandle,
310        collider: &crate::Collider,
311    ) -> Option<Obstacle> {
312        let core = self.bodies.get(collider.parent?)?.core();
313        let shape = match collider.shape {
314            crate::body::Shape::Ball(radius) => ObstacleShape::Ball { radius },
315            crate::body::Shape::Box(half) => ObstacleShape::Box { half },
316            #[cfg(feature = "dim2")]
317            crate::body::Shape::Capsule { a, b, radius } => ObstacleShape::Capsule { a, b, radius },
318            #[cfg(feature = "dim3")]
319            crate::body::Shape::Cylinder {
320                half_height,
321                radius,
322            } => ObstacleShape::Cylinder {
323                half_height,
324                radius,
325            },
326        };
327        let pose = collider.pose(core);
328        Some(Obstacle {
329            id: (u64::from(handle.generation) << 32) | handle.index as u64,
330            shape,
331            position: pose.position,
332            rotation: pose.rotation,
333            membership: collider.membership,
334            sensor: collider.sensor,
335        })
336    }
337}
338
339#[cfg(test)]
340mod tests;