Skip to main content

avian3d/character_controller/
velocity_project.rs

1use crate::prelude::*;
2
3/// Needed to improve stability when `n.dot(dir)` happens to be very close to zero.
4const DOT_EPSILON: f32 = 0.005;
5
6/// Projects input velocity `v` onto the planes defined by the given `normals`.
7/// This ensures that `velocity` does not point into any of the planes, but along them.
8///
9/// This is often used after [`MoveAndSlide::cast_move`] to ensure a character moved that way
10/// does not try to continue moving into colliding geometry.
11///
12/// This is a brute-force implementation that tests all possible projections.
13/// Consider using [`project_velocity`] for better performance on larger sets of normals.
14#[must_use]
15pub fn project_velocity_bruteforce(v: Vector, normals: &[Dir]) -> Vector {
16    // NOTE: This brute-force method is primarily intended for testing and validation purposes.
17    //       A more complex but faster `project_velocity` function can be found after this one.
18
19    if normals.is_empty() {
20        return v;
21    }
22
23    // The halfspaces defined by the contact normals form a polyhedral cone.
24    // We want to find the closest point to v that lies inside this cone.
25    //
26    // There are three cases to consider:
27    // 1. v is already inside the cone -> return v
28    // 2. v is outside the cone
29    //    a. Project v onto each plane and check if the projection is inside the cone
30    //    b. Project v onto each edge (intersection of two planes) and check if the projection is inside the cone
31    // 3. If no valid projection is found, return the apex of the cone (the origin)
32
33    // Case 1: Check if v is inside the cone
34    if normals.iter().all(|normal| normal.dot(v) >= -DOT_EPSILON) {
35        return v;
36    }
37
38    // Best candidate so far
39    let mut best_projection = Vector::ZERO;
40    let mut best_distance_sq = f32::INFINITY;
41
42    // Helper to test halfspace validity
43    let is_valid = |projection: Vector| normals.iter().all(|n| projection.dot(**n) >= -DOT_EPSILON);
44
45    // Case 2a: Face projections (single-plane active set)
46    for n in normals {
47        let n_dot_v = n.dot(v);
48        if n_dot_v < -DOT_EPSILON {
49            // Project v onto the plane defined by n:
50            // projection = v - (v · n) n
51            let projection = v - n_dot_v * *n;
52
53            // Check if better than previous best and valid
54            let distance_sq = v.distance_squared(projection);
55            if distance_sq < best_distance_sq && is_valid(projection) {
56                best_distance_sq = distance_sq;
57                best_projection = projection;
58            }
59        }
60    }
61
62    // Case 2b: Edge projections (two-plane active set)
63    #[cfg(feature = "3d")]
64    {
65        let n = normals.len();
66        for i in 0..n {
67            let ni = normals[i];
68            for nj in normals.iter().take(n).skip(i + 1) {
69                // Compute edge direction e = ni x nj
70                let e = ni.cross(**nj);
71                let e_length_sq = e.length_squared();
72                if e_length_sq < DOT_EPSILON {
73                    // Nearly parallel edge
74                    continue;
75                }
76
77                // Project v onto the line spanned by e:
78                // projection = ((v · e) / |e|²) e
79                let projection = e * (v.dot(e) / e_length_sq);
80
81                // Check if better than previous best and valid
82                let distance_sq = v.distance_squared(projection);
83                if distance_sq < best_distance_sq && is_valid(projection) {
84                    best_distance_sq = distance_sq;
85                    best_projection = projection;
86                }
87            }
88        }
89    }
90
91    // Case 3: If no candidate is found, the projection is at the apex (the origin)
92    if best_distance_sq.is_infinite() {
93        Vector::ZERO
94    } else {
95        best_projection
96    }
97}
98
99/// Projects input velocity `v` onto the planes defined by the given `normals`.
100/// This ensures that `velocity` does not point into any of the planes, but along them.
101///
102/// This is often used after [`MoveAndSlide::cast_move`] to ensure a character moved that way
103/// does not try to continue moving into colliding geometry.
104///
105/// This function uses a GJK-like algorithm to solve the dual problem to velocity projection,
106/// from which obtaining the solution of the primal problem is simple.
107/// See <https://benspiers.co.uk/Games/Velocity-Projection> for the full mathematical details.
108#[must_use]
109pub fn project_velocity(v: Vector, normals: &[Dir]) -> Vector {
110    -project_onto_conical_hull(-v, normals)
111}
112
113/// A geometric object similar to a simplex, with the first point at the origin
114/// and the others at an arbitrarily large distance away from it ("at infinity").
115///
116/// The relevant property of the "arbitrarily large" distances is that the closest
117/// point to any test point is the origin, and the closest line segment, triangle
118/// or higher simplex to any test point always includes the origin.
119///
120/// The non-origin points are therefore all represented by unit vectors
121/// rather than actual points. When running a GJK-like algorithm for this shape,
122/// the relevant simplices are:
123///  - Point: the origin
124///  - Line segment: a ray extending in some direction from the origin
125///  - Triangle: a wedge spanning the area between two of the above rays
126#[cfg_attr(
127    feature = "3d",
128    doc = " - Tetrahedron: a \"solid wedge\" spanning the volume between three rays"
129)]
130///
131/// The last of these is not directly represented as a variant of [`SimplicialCone`],
132/// as the projection method always prunes its output down to a simplex of non-full
133/// dimensionality.
134#[derive(Debug)]
135enum SimplicialCone {
136    /// A simplicial cone consisting only of a single point, the origin.
137    Origin,
138    /// A simplicial cone consisting of two points, the origin and a point at infinity,
139    /// which together form a semi-infinite ray, the analog to a line segment.
140    Ray(#[cfg(feature = "3d")] Dir),
141    #[cfg(feature = "3d")]
142    /// A simplicial cone consisting of three points, the origin and two points at infinity.
143    /// It forms a wedge between two rays, the analog to a triangle.
144    Wedge(Dir, Dir),
145}
146
147/// Projects the input point `x0` onto the convex cone defined by the given `normals`.
148/// This runs a variant of GJK, specialised for point vs. convex cone computation.
149#[must_use]
150fn project_onto_conical_hull(x0: Vector, normals: &[Dir]) -> Vector {
151    // The current simplicial cone.
152    let mut maybe_cone = Some(SimplicialCone::Origin);
153
154    // The search vector is the vector pointing from the closest point
155    // on the current simplicial cone to x0.
156    let mut search_vector = x0;
157    let mut n_iters = 0;
158
159    while let Some(cone) = maybe_cone {
160        // If the search vector is near-zero, the current optimal point
161        // is very close to the input, so we terminate.
162        if search_vector.length_squared() < DOT_EPSILON * DOT_EPSILON {
163            break;
164        }
165
166        // Find the normal that best improves the projection (maximises the dot product).
167        let n_dots = normals.iter().map(|n| n.dot(search_vector));
168
169        let Some((best_idx, best_dot)) = n_dots
170            .enumerate()
171            .max_by(|(_, dot1), (_, dot2)| f32::total_cmp(dot1, dot2))
172        else {
173            // No normals provided.
174            break;
175        };
176
177        // None of the possible directions can improve on the current optimal point.
178        if best_dot <= DOT_EPSILON {
179            break;
180        }
181
182        let best_normal = normals[best_idx];
183
184        // Update the simplicial cone with the new normal,
185        // and get the new search vector.
186        (maybe_cone, search_vector) = cone.project_point(x0, best_normal);
187
188        n_iters += 1;
189        if n_iters >= 10 {
190            break;
191        }
192    }
193
194    search_vector
195}
196
197impl SimplicialCone {
198    /// Updates the simplicial cone by adding a new direction vector to it.
199    /// Returns a new search vector `x0 - x`, where `x` is the point closest to `x0`
200    /// on the updated simplicial cone.
201    ///
202    /// If possible, the n+1-simplex is pruned back down to a n-simplex.
203    /// Otherwise, the n+1-simplex is returned.
204    ///
205    /// If `x0` is found to be contained within a maximum-dimensionality simplicial cone,
206    /// [`None`] is returned in place of the updated simplex, and the accompanying search vector
207    /// is zero. This avoids having to maintain and handle a variant of [`SimplicialCone`] to
208    /// represent a full-dimensional simplicial cone.
209    ///
210    /// This method makes various assumptions about its inputs that match the implementation of
211    /// [`project_onto_conical_hull`]. For example, the dot product of `new_direction`
212    /// and the search direction from `self` to `x0` should not be negative.
213    fn project_point(self, x0: Vector, new_direction: Dir) -> (Option<SimplicialCone>, Vector) {
214        // See https://benspiers.co.uk/Games/Velocity-Projection
215
216        let new_direction_vec = new_direction;
217
218        match self {
219            SimplicialCone::Origin => {
220                // New simplex is a ray between the origin and new_direction,
221                // closest point is on the resulting ray.
222
223                // The preconditions of this method imply that dot >= 0.0.
224                let dot = new_direction_vec.dot(x0);
225                #[cfg(feature = "2d")]
226                let ray = Self::Ray();
227                #[cfg(feature = "3d")]
228                let ray = Self::Ray(new_direction);
229                (Some(ray), x0 - dot * new_direction_vec)
230            }
231            #[cfg(feature = "2d")]
232            // Preconditions imply that:
233            // - dot product between new_direction and old search_direction is > 0.0
234            //   - i.e. x0 and new_direction are in the same half-disk relative to current ray
235            // - dot product between new_direction and x0 is smaller than between the current ray and x0
236            //   - i.e. new_direction can't fall between current ray and x0
237            //   - the two rays must be on either side of x0
238            // Therefore can deduce that x0 falls within the wedge, so we can just return (None, ZERO)
239            // without any more checking.
240            SimplicialCone::Ray() => (None, Vector::ZERO),
241            #[cfg(feature = "3d")]
242            SimplicialCone::Ray(previous_direction) => {
243                let cross = new_direction_vec.cross(*previous_direction);
244                let dot = x0.dot(cross);
245                let new_search_vector = dot * cross / cross.length_squared();
246
247                // Orient Wedge(n1, n2) to guarantee that dot(x0, n1 x n2) is nonnegative.
248                let new_cone = if dot > 0.0 {
249                    Self::Wedge(new_direction, previous_direction)
250                } else {
251                    Self::Wedge(previous_direction, new_direction)
252                };
253
254                (Some(new_cone), new_search_vector)
255            }
256            #[cfg(feature = "3d")]
257            SimplicialCone::Wedge(n1, n2) => {
258                // According to preconditions, dot product between new_direction and old search_direction is > 0.0
259                // - therefore `new_direction` falls on the same side of the plane spanned by `n1`, `n2` as `x0` does
260                // - Wedge(n1, n2) is oriented such that dot(x0, n1 x n2) is nonnegative, so n1 x n2 points towards
261                //   new_direction on the new simplicial cone as well, towards the interior of (n1, n2, new_direction)
262                // - The cross products below are in the opposite order to the cycle (n1, n2, new_direction),
263                //   so they point away from the interior of the solid wedge
264
265                // cross1 points away from n2
266                let cross1 = n1.cross(*new_direction_vec);
267                let cross1_sq = cross1.length_squared();
268
269                // Distance of x0 from the wedge facet (n1, new_direction)
270                // is positive if away from n2, negative if towards n2.
271                let dot1 = x0.dot(cross1);
272                let inside1 = dot1 <= 0.0;
273
274                // cross2 points away from n1
275                let cross2 = new_direction_vec.cross(*n2);
276                let cross2_sq = cross2.length_squared();
277
278                // Distance of x0 away from the wedge facet (new_direction, n2)
279                // is positive if away from n1, negative if towards n1.
280                let dot2 = x0.dot(cross2);
281                let inside2 = dot2 <= 0.0;
282
283                if inside1 & inside2 {
284                    // x0 is on the "inside" side of (n1, new_direction) and of (new_direction, n2).
285                    // The previous iteration of this method will have established that it's on
286                    // the current "inside" side of (n2, n1) as well, so x0 is inside the overall
287                    // solid wedge, and our work here is done.
288                    (None, Vector::ZERO)
289                } else if dot1 * dot1.abs() * cross2_sq > dot2 * dot2.abs() * cross1_sq {
290                    // The above is `if dot1 / cross1.length() > dot2 / cross2.length()`,
291                    // but written to avoid square roots.
292
293                    // Again careful here to orient the Wedges such that dot(x0, n1 x n2) is nonnegative,
294                    // and n1 x n2 faces outwards from the current solid wedge
295                    // (inwards for the next iteration, because the next new_direction will be on that side)
296                    (
297                        Some(Self::Wedge(n1, new_direction)),
298                        dot1 * cross1 / cross1_sq,
299                    )
300                } else {
301                    (
302                        Some(Self::Wedge(new_direction, n2)),
303                        dot2 * cross2 / cross2_sq,
304                    )
305                }
306            }
307        }
308    }
309}
310
311#[cfg(any(test, feature = "test"))]
312pub mod test {
313    //! Tests for velocity projection, notably the [`QuasiRandomDirection`] type used
314    //! in testing and benchmarking functions on uniformly distributed input directions.
315    //!
316    //! This is used because the velocity projection edge cases may show up for relatively small
317    //! subsets of input directions, both in terms of correctness and performance.
318
319    #[cfg(test)]
320    use super::DOT_EPSILON;
321    use crate::prelude::*;
322    use core::f32::consts::PI;
323
324    /// Tests that `project_velocity` agrees with `project_velocity_bruteforce`.
325    #[test]
326    fn check_agreement() {
327        #[cfg(feature = "2d")]
328        let normals = &[
329            Dir::Y,
330            Dir::from_xy(2.0, 1.0).unwrap(),
331            Dir::from_xy(-2.0, 1.0).unwrap(),
332            Dir::from_xy(1.0, 1.0).unwrap(),
333            Dir::from_xy(-1.0, 1.0).unwrap(),
334        ];
335
336        #[cfg(feature = "3d")]
337        let normals = &[
338            Dir::Z,
339            Dir::from_xyz(2.0, 0.0, 1.0).unwrap(),
340            Dir::from_xyz(-2.0, 0.0, 1.0).unwrap(),
341            Dir::from_xyz(0.0, 2.0, 1.0).unwrap(),
342            Dir::from_xyz(0.0, -2.0, 1.0).unwrap(),
343            Dir::from_xyz(1.5, 1.5, 1.0).unwrap(),
344            Dir::from_xyz(1.5, -1.5, 1.0).unwrap(),
345            Dir::from_xyz(-1.5, 1.5, 1.0).unwrap(),
346            Dir::from_xyz(-1.5, -1.5, 1.0).unwrap(),
347            Dir::from_xyz(1.0, 1.75, 1.0).unwrap(),
348            Dir::from_xyz(1.0, -1.75, 1.0).unwrap(),
349            Dir::from_xyz(-1.0, 1.75, 1.0).unwrap(),
350            Dir::from_xyz(-1.0, -1.75, 1.0).unwrap(),
351            Dir::from_xyz(1.75, 1.0, 1.0).unwrap(),
352            Dir::from_xyz(1.75, -1.0, 1.0).unwrap(),
353            Dir::from_xyz(-1.75, 1.0, 1.0).unwrap(),
354            Dir::from_xyz(-1.75, -1.0, 1.0).unwrap(),
355        ];
356
357        for n in 1..=normals.len() {
358            let selected_normals = &normals[..n];
359            let velocities = QuasiRandomDirection::default();
360            let mut worst_result = (f32::NEG_INFINITY, Vector::ZERO);
361            for vel in velocities.take(1000) {
362                let new_result = super::project_velocity(vel, selected_normals);
363                for (k, normal) in selected_normals.iter().enumerate() {
364                    let intrusion = -new_result.dot(**normal);
365                    assert!(
366                        intrusion <= DOT_EPSILON,
367                        "velocity still points into constraint plane after projection: \
368                        input {vel} against {n} normals has intrusion \
369                        {intrusion} against normal {k}"
370                    );
371                }
372                let old_result = super::project_velocity_bruteforce(vel, selected_normals);
373
374                // Measure of quality of output: we're trying to minimise |output - input|.
375                // How much worse than the brute-force method do we do?
376                let amount_worse = (new_result - vel).length() - (old_result - vel).length();
377                assert!(
378                    amount_worse <= DOT_EPSILON,
379                    "velocity projection result was {amount_worse} > {DOT_EPSILON} worse than \
380                    result from brute-force method when projecting input {vel} against {n} normals"
381                );
382
383                if amount_worse >= worst_result.0 {
384                    worst_result = (amount_worse, vel);
385                }
386            }
387            let (worst_error, bad_input) = worst_result;
388
389            eprintln!(
390                "For {n} normals, worst case new method is {worst_error} worse than brute-force for input {bad_input}",
391            );
392        }
393    }
394
395    /// Iterator that produces a fixed sequence of unit vectors,
396    /// uniformly distributed around the unit sphere.
397    ///
398    /// The produced sequence of directions is open, meaning it
399    /// continues indefinitely without repeating, at least up to
400    /// the limits of floating-point precision.
401    #[derive(Default)]
402    pub struct QuasiRandomDirection {
403        #[cfg(feature = "3d")]
404        i: f32,
405        j: f32,
406    }
407
408    #[cfg(feature = "3d")]
409    impl QuasiRandomDirection {
410        #[allow(clippy::excessive_precision)]
411        const PLASTIC: f32 = 1.32471795724475;
412        const INV_PLASTIC: f32 = 1.0 / Self::PLASTIC;
413        const INV_PLASTIC_SQ: f32 = Self::INV_PLASTIC * Self::INV_PLASTIC;
414    }
415
416    #[cfg(feature = "2d")]
417    impl QuasiRandomDirection {
418        const INV_GOLDEN: f32 = 1.0 / core::f32::consts::GOLDEN_RATIO;
419    }
420
421    impl QuasiRandomDirection {
422        /// Resets the internal state of the direction generator.
423        ///
424        /// Directions returned by [`Self::next()`] after this method is called
425        /// will match the sequence produced by a new instance.
426        pub fn reset(&mut self) {
427            *self = Self::default();
428        }
429    }
430
431    impl Iterator for QuasiRandomDirection {
432        type Item = Vector;
433
434        fn next(&mut self) -> Option<Self::Item> {
435            let phi = 2.0 * PI * self.j;
436            let x = phi.cos();
437            let y = phi.sin();
438            #[cfg(feature = "3d")]
439            {
440                let z = 2.0 * self.i - 1.0;
441                let rho = (1.0 - z * z).sqrt();
442                self.i = (self.i + Self::INV_PLASTIC) % 1.0;
443                self.j = (self.j + Self::INV_PLASTIC_SQ) % 1.0;
444                Some(Vector::new(rho * x, rho * y, z))
445            }
446            #[cfg(feature = "2d")]
447            {
448                self.j = (self.j + Self::INV_GOLDEN) % 1.0;
449                Some(Vector::new(x, y))
450            }
451        }
452    }
453}