Skip to content

Commit 5bef382

Browse files
New plane solver (#905)
Opening as a draft because it still needs a docs pass, benchmarking to ensure it's actually faster/scales better than the existing algorithm and removing the old code if so. # Objective The current plane solver is O(n^3) in the number of collision planes. It's not likely to break the bank but it'd be preferable to bring this down to O(n^2) or lower ## Solution - I figured out a GJK-like algorithm for solving the underlying quadratic programming problem, and implemented it. Steps of the algorithm are O(n), and it should terminate in at most n steps, though I've never seen the step number exceed 4. I've tried to construct sets of normals that would take 5+ steps to converge and the arrangements of planes it would take seem very specific. Blog post fully explaining the details is in progress ## Testing - Tested so far in the 3d move-and-slide example, still need to test in 2d as well - ~~I'm intending to benchmark the new method against the existing one for some arrangements of maybe 1 to 10 normals? to try and establish scalings empirically~~ - ~~I think there are a few more micro-optimisations I could squeeze out, like imposing a specific ordering of the normals in `SimplicialCone::Wedge(n1, n2)` relative to the new search direction, which would save a few comparisons and dot products in the relevant branch of `SimplicialCone::project_point`.~~ - The `SimplicialCone` enum exists to keep the algorithm allocation-free (as opposed to keeping the current simplex points in a vec. Alternatively the first couple of iterations of the loop could be manually unrolled, which would get rid of the match and might speed things up a bit --------- Co-authored-by: Joona Aalto <jondolf.dev@gmail.com>
1 parent a383159 commit 5bef382

File tree

5 files changed

+542
-92
lines changed

5 files changed

+542
-92
lines changed

crates/avian3d/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,8 @@ required-features = ["3d", "default-collider", "diagnostic_ui"]
212212
[[example]]
213213
name = "debugdump_3d"
214214
required-features = ["3d", "debug-plugin"]
215+
216+
[[bench]]
217+
name = "velocity_projection"
218+
required-features = ["3d", "default-collider", "test"]
219+
harness = false
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use bevy_math::{Dir3, Vec3};
2+
use core::hint::black_box;
3+
use criterion::{BenchmarkId, Criterion, PlotConfiguration, criterion_group, criterion_main};
4+
5+
use avian3d::{
6+
character_controller::move_and_slide::{
7+
project_velocity, project_velocity_bruteforce, test::QuasiRandomDirection,
8+
},
9+
math::PI,
10+
};
11+
12+
fn bench_velocity_projection(c: &mut Criterion) {
13+
let mut group = c.benchmark_group("Velocity Projection");
14+
let normals = &[
15+
Dir3::Z,
16+
Dir3::from_xyz(2.0, 0.0, 1.0).unwrap(),
17+
Dir3::from_xyz(-2.0, 0.0, 1.0).unwrap(),
18+
Dir3::from_xyz(0.0, 2.0, 1.0).unwrap(),
19+
Dir3::from_xyz(0.0, -2.0, 1.0).unwrap(),
20+
Dir3::from_xyz(1.5, 1.5, 1.0).unwrap(),
21+
Dir3::from_xyz(1.5, -1.5, 1.0).unwrap(),
22+
Dir3::from_xyz(-1.5, 1.5, 1.0).unwrap(),
23+
Dir3::from_xyz(-1.5, -1.5, 1.0).unwrap(),
24+
Dir3::from_xyz(1.0, 1.75, 1.0).unwrap(),
25+
Dir3::from_xyz(1.0, -1.75, 1.0).unwrap(),
26+
Dir3::from_xyz(-1.0, 1.75, 1.0).unwrap(),
27+
Dir3::from_xyz(-1.0, -1.75, 1.0).unwrap(),
28+
Dir3::from_xyz(1.75, 1.0, 1.0).unwrap(),
29+
Dir3::from_xyz(1.75, -1.0, 1.0).unwrap(),
30+
Dir3::from_xyz(-1.75, 1.0, 1.0).unwrap(),
31+
Dir3::from_xyz(-1.75, -1.0, 1.0).unwrap(),
32+
];
33+
34+
// Performance is not the same for every input velocity,
35+
// so ensure we're sampling the sphere evenly.
36+
let mut velocities = QuasiRandomDirection::default();
37+
38+
for n in 1..=normals.len() {
39+
velocities.reset();
40+
group.bench_with_input(
41+
BenchmarkId::new("brute-force", n),
42+
&normals[..n],
43+
|b, norms| {
44+
b.iter_batched(
45+
|| velocities.next().unwrap(),
46+
|v| project_velocity_bruteforce(black_box(v), black_box(norms)),
47+
criterion::BatchSize::SmallInput,
48+
)
49+
},
50+
);
51+
velocities.reset();
52+
group.bench_with_input(BenchmarkId::new("gjk", n), &normals[..n], |b, norms| {
53+
b.iter_batched(
54+
|| velocities.next().unwrap(),
55+
|v| project_velocity(black_box(v), black_box(norms)),
56+
criterion::BatchSize::SmallInput,
57+
)
58+
});
59+
}
60+
group
61+
.plot_config(PlotConfiguration::default().summary_scale(criterion::AxisScale::Logarithmic));
62+
}
63+
64+
criterion_group!(benches, bench_velocity_projection);
65+
criterion_main!(benches);

src/character_controller/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Utilities for implementing character controllers.
22
33
pub mod move_and_slide;
4+
mod velocity_project;
45

56
/// Re-exports common types related to character controller functionality.
67
pub mod prelude {

src/character_controller/move_and_slide.rs

Lines changed: 3 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
//!
33
//! See the documentation of [`MoveAndSlide`] for more information.
44
5+
pub use super::velocity_project::*;
6+
57
use crate::{collision::collider::contact_query::contact_manifolds, prelude::*};
68
use bevy::{ecs::system::SystemParam, prelude::*};
79
use core::time::Duration;
@@ -1082,97 +1084,6 @@ impl<'w, 's> MoveAndSlide<'w, 's> {
10821084
/// does not try to continue moving into colliding geometry.
10831085
#[must_use]
10841086
pub fn project_velocity(v: Vector, normals: &[Dir]) -> Vector {
1085-
if normals.is_empty() {
1086-
return v;
1087-
}
1088-
1089-
// The halfspaces defined by the contact normals form a polyhedral cone.
1090-
// We want to find the closest point to v that lies inside this cone.
1091-
//
1092-
// There are three cases to consider:
1093-
// 1. v is already inside the cone -> return v
1094-
// 2. v is outside the cone
1095-
// a. Project v onto each plane and check if the projection is inside the cone
1096-
// b. Project v onto each edge (intersection of two planes) and check if the projection is inside the cone
1097-
// 3. If no valid projection is found, return the apex of the cone (the origin)
1098-
1099-
// Case 1: Check if v is inside the cone
1100-
if normals
1101-
.iter()
1102-
.all(|normal| normal.adjust_precision().dot(v) >= -DOT_EPSILON)
1103-
{
1104-
return v;
1105-
}
1106-
1107-
// Best candidate so far
1108-
let mut best_projection = Vector::ZERO;
1109-
let mut best_distance_sq = Scalar::INFINITY;
1110-
1111-
// Helper to test halfspace validity
1112-
let is_valid = |projection: Vector| {
1113-
normals
1114-
.iter()
1115-
.all(|n| projection.dot(n.adjust_precision()) >= -DOT_EPSILON)
1116-
};
1117-
1118-
// Case 2a: Face projections (single-plane active set)
1119-
for n in normals {
1120-
let n = n.adjust_precision();
1121-
let n_dot_v = n.dot(v);
1122-
if n_dot_v < -DOT_EPSILON {
1123-
// Project v onto the plane defined by n:
1124-
// projection = v - (v · n) n
1125-
let projection = v - n_dot_v * n;
1126-
1127-
// Check if better than previous best and valid
1128-
let distance_sq = v.distance_squared(projection);
1129-
if distance_sq < best_distance_sq && is_valid(projection) {
1130-
best_distance_sq = distance_sq;
1131-
best_projection = projection;
1132-
}
1133-
}
1134-
}
1135-
1136-
// Case 2b: Edge projections (two-plane active set)
1137-
// TODO: Can we optimize this from O(n^3) to O(n^2)?
1138-
#[cfg(feature = "3d")]
1139-
{
1140-
let n = normals.len();
1141-
for i in 0..n {
1142-
let ni = normals[i].adjust_precision();
1143-
for nj in normals
1144-
.iter()
1145-
.take(n)
1146-
.skip(i + 1)
1147-
.map(|n| n.adjust_precision())
1148-
{
1149-
// Compute edge direction e = ni x nj
1150-
let e = ni.cross(nj);
1151-
let e_length_sq = e.length_squared();
1152-
if e_length_sq < DOT_EPSILON {
1153-
// Nearly parallel edge
1154-
continue;
1155-
}
1156-
1157-
// Project v onto the line spanned by e:
1158-
// projection = ((v · e) / |e|²) e
1159-
let projection = e * (v.dot(e) / e_length_sq);
1160-
1161-
// Check if better than previous best and valid
1162-
let distance_sq = v.distance_squared(projection);
1163-
if distance_sq < best_distance_sq && is_valid(projection) {
1164-
best_distance_sq = distance_sq;
1165-
best_projection = projection;
1166-
}
1167-
}
1168-
}
1169-
}
1170-
1171-
// Case 3: If no candidate is found, the projection is at the apex (the origin)
1172-
if best_distance_sq.is_infinite() {
1173-
Vector::ZERO
1174-
} else {
1175-
best_projection
1176-
}
1087+
project_velocity(v, normals)
11771088
}
11781089
}

0 commit comments

Comments
 (0)