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