feat(physics): add ConvexHull collider with GJK support function

Add ConvexHull struct storing vertices with a support function that
returns the farthest point in a given direction, enabling GJK/EPA
collision detection. Update all Collider match arms across the physics
crate (collision, raycast, integrator, solver) to handle the new variant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 15:49:38 +09:00
parent 1b5da4d0d5
commit 0f08c65a1e
6 changed files with 125 additions and 10 deletions

View File

@@ -1,10 +1,37 @@
use voltex_math::{Vec3, AABB};
#[derive(Debug, Clone, Copy)]
/// A convex hull collider defined by a set of vertices.
#[derive(Debug, Clone)]
pub struct ConvexHull {
pub vertices: Vec<Vec3>,
}
impl ConvexHull {
pub fn new(vertices: Vec<Vec3>) -> Self {
ConvexHull { vertices }
}
/// Support function for GJK: returns the vertex farthest in the given direction.
pub fn support(&self, direction: Vec3) -> Vec3 {
let mut best = self.vertices[0];
let mut best_dot = best.dot(direction);
for &v in &self.vertices[1..] {
let d = v.dot(direction);
if d > best_dot {
best_dot = d;
best = v;
}
}
best
}
}
#[derive(Debug, Clone)]
pub enum Collider {
Sphere { radius: f32 },
Box { half_extents: Vec3 },
Capsule { radius: f32, half_height: f32 },
ConvexHull(ConvexHull),
}
impl Collider {
@@ -21,6 +48,16 @@ impl Collider {
let r = Vec3::new(*radius, *half_height + *radius, *radius);
AABB::new(position - r, position + r)
}
Collider::ConvexHull(hull) => {
let mut min = position + hull.vertices[0];
let mut max = min;
for &v in &hull.vertices[1..] {
let p = position + v;
min = Vec3::new(min.x.min(p.x), min.y.min(p.y), min.z.min(p.z));
max = Vec3::new(max.x.max(p.x), max.y.max(p.y), max.z.max(p.z));
}
AABB::new(min, max)
}
}
}
}
@@ -52,4 +89,45 @@ mod tests {
assert_eq!(aabb.min, Vec3::new(0.5, 0.5, 2.5));
assert_eq!(aabb.max, Vec3::new(1.5, 3.5, 3.5));
}
#[test]
fn test_convex_hull_support() {
let hull = ConvexHull::new(vec![
Vec3::new(-1.0, -1.0, -1.0),
Vec3::new(1.0, -1.0, -1.0),
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, -1.0, 1.0),
]);
// Support in +Y direction should return the top vertex
let s = hull.support(Vec3::new(0.0, 1.0, 0.0));
assert!((s.y - 1.0).abs() < 1e-6);
}
#[test]
fn test_convex_hull_support_negative() {
let hull = ConvexHull::new(vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
]);
let s = hull.support(Vec3::new(-1.0, 0.0, 0.0));
assert!((s.x - 0.0).abs() < 1e-6); // origin is farthest in -X
}
#[test]
fn test_convex_hull_in_collider() {
// Test that ConvexHull variant works with existing collision system
let hull = ConvexHull::new(vec![
Vec3::new(-1.0, -1.0, -1.0),
Vec3::new(1.0, -1.0, -1.0),
Vec3::new(1.0, 1.0, -1.0),
Vec3::new(-1.0, 1.0, -1.0),
Vec3::new(-1.0, -1.0, 1.0),
Vec3::new(1.0, -1.0, 1.0),
Vec3::new(1.0, 1.0, 1.0),
Vec3::new(-1.0, 1.0, 1.0),
]);
// Just verify construction works
assert_eq!(hull.vertices.len(), 8);
}
}

View File

@@ -13,7 +13,7 @@ pub fn detect_collisions(world: &World) -> Vec<ContactPoint> {
let pairs_data: Vec<(Entity, Vec3, Collider)> = world
.query2::<Transform, Collider>()
.into_iter()
.map(|(e, t, c)| (e, t.position, *c))
.map(|(e, t, c)| (e, t.position, c.clone()))
.collect();
if pairs_data.len() < 2 {
@@ -34,7 +34,7 @@ pub fn detect_collisions(world: &World) -> Vec<ContactPoint> {
let mut contacts = Vec::new();
let lookup = |entity: Entity| -> Option<(Vec3, Collider)> {
pairs_data.iter().find(|(e, _, _)| *e == entity).map(|(_, p, c)| (*p, *c))
pairs_data.iter().find(|(e, _, _)| *e == entity).map(|(_, p, c)| (*p, c.clone()))
};
for (ea, eb) in broad_pairs {
@@ -55,8 +55,9 @@ pub fn detect_collisions(world: &World) -> Vec<ContactPoint> {
(Collider::Box { half_extents: ha }, Collider::Box { half_extents: hb }) => {
narrow::box_vs_box(pos_a, *ha, pos_b, *hb)
}
// Any combination involving Capsule uses GJK/EPA
(Collider::Capsule { .. }, _) | (_, Collider::Capsule { .. }) => {
// Any combination involving Capsule or ConvexHull uses GJK/EPA
(Collider::Capsule { .. }, _) | (_, Collider::Capsule { .. })
| (Collider::ConvexHull(_), _) | (_, Collider::ConvexHull(_)) => {
gjk::gjk_epa(&col_a, pos_a, &col_b, pos_b)
}
};

View File

@@ -30,6 +30,9 @@ fn support(collider: &Collider, position: Vec3, direction: Vec3) -> Vec3 {
}
base + direction * (*radius / len)
}
Collider::ConvexHull(hull) => {
position + hull.support(direction)
}
}
}

View File

@@ -32,6 +32,25 @@ pub fn inertia_tensor(collider: &Collider, mass: f32) -> Vec3 {
let iz = ix;
Vec3::new(ix, iy, iz)
}
Collider::ConvexHull(hull) => {
// Approximate using AABB of the hull vertices
let mut min = hull.vertices[0];
let mut max = min;
for &v in &hull.vertices[1..] {
min = Vec3::new(min.x.min(v.x), min.y.min(v.y), min.z.min(v.z));
max = Vec3::new(max.x.max(v.x), max.y.max(v.y), max.z.max(v.z));
}
let size = max - min;
let w = size.x;
let h = size.y;
let d = size.z;
let factor = mass / 12.0;
Vec3::new(
factor * (h * h + d * d),
factor * (w * w + d * d),
factor * (w * w + h * h),
)
}
}
}

View File

@@ -17,7 +17,7 @@ pub fn raycast(world: &World, ray: &Ray, max_dist: f32) -> Option<RayHit> {
let entities: Vec<(Entity, Vec3, Collider)> = world
.query2::<Transform, Collider>()
.into_iter()
.map(|(e, t, c)| (e, t.position, *c))
.map(|(e, t, c)| (e, t.position, c.clone()))
.collect();
if entities.is_empty() {
@@ -53,6 +53,11 @@ pub fn raycast(world: &World, ray: &Ray, max_dist: f32) -> Option<RayHit> {
Collider::Capsule { radius, half_height } => {
ray_tests::ray_vs_capsule(ray, *pos, *radius, *half_height)
}
Collider::ConvexHull(_) => {
// Use AABB test as approximation for convex hull raycasting
let aabb = collider.aabb(*pos);
ray_tests::ray_vs_aabb(ray, &aabb).map(|t| (t, Vec3::Y))
}
};
if let Some((t, normal)) = result {
@@ -77,7 +82,7 @@ pub fn raycast_all(world: &World, ray: &Ray, max_dist: f32) -> Vec<RayHit> {
let entities: Vec<(Entity, Vec3, Collider)> = world
.query2::<Transform, Collider>()
.into_iter()
.map(|(e, t, c)| (e, t.position, *c))
.map(|(e, t, c)| (e, t.position, c.clone()))
.collect();
if entities.is_empty() {
@@ -106,6 +111,11 @@ pub fn raycast_all(world: &World, ray: &Ray, max_dist: f32) -> Vec<RayHit> {
Collider::Capsule { radius, half_height } => {
ray_tests::ray_vs_capsule(ray, *pos, *radius, *half_height)
}
Collider::ConvexHull(_) => {
// Use AABB test as approximation for convex hull raycasting
let aabb = collider.aabb(*pos);
ray_tests::ray_vs_aabb(ray, &aabb).map(|t| (t, Vec3::Y))
}
};
if let Some((t, normal)) = result {

View File

@@ -23,8 +23,8 @@ pub fn resolve_collisions(world: &mut World, contacts: &[ContactPoint], iteratio
for contact in contacts {
let rb_a = world.get::<RigidBody>(contact.entity_a).copied();
let rb_b = world.get::<RigidBody>(contact.entity_b).copied();
let col_a = world.get::<Collider>(contact.entity_a).copied();
let col_b = world.get::<Collider>(contact.entity_b).copied();
let col_a = world.get::<Collider>(contact.entity_a).cloned();
let col_b = world.get::<Collider>(contact.entity_b).cloned();
let pos_a = world.get::<Transform>(contact.entity_a).map(|t| t.position);
let pos_b = world.get::<Transform>(contact.entity_b).map(|t| t.position);
@@ -205,7 +205,7 @@ fn apply_ccd(world: &mut World, config: &PhysicsConfig) {
.query3::<Transform, RigidBody, Collider>()
.into_iter()
.filter(|(_, _, rb, _)| !rb.is_static() && !rb.is_sleeping)
.map(|(e, t, rb, c)| (e, t.position, rb.velocity, *c))
.map(|(e, t, rb, c)| (e, t.position, rb.velocity, c.clone()))
.collect();
let all_colliders: Vec<(Entity, voltex_math::AABB)> = world
@@ -222,6 +222,10 @@ fn apply_ccd(world: &mut World, config: &PhysicsConfig) {
Collider::Sphere { radius } => *radius,
Collider::Box { half_extents } => half_extents.x.min(half_extents.y).min(half_extents.z),
Collider::Capsule { radius, .. } => *radius,
Collider::ConvexHull(hull) => {
// Use minimum distance from origin to any vertex as approximate radius
hull.vertices.iter().map(|v| v.length()).fold(f32::MAX, f32::min)
}
};
// Only apply CCD if displacement > collider radius