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}