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}