From 0f08c65a1ebb6c42d56b0ad25b6c38a9faa5377f Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 26 Mar 2026 15:49:38 +0900 Subject: [PATCH] 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) --- crates/voltex_physics/src/collider.rs | 80 ++++++++++++++++++++++++- crates/voltex_physics/src/collision.rs | 9 +-- crates/voltex_physics/src/gjk.rs | 3 + crates/voltex_physics/src/integrator.rs | 19 ++++++ crates/voltex_physics/src/raycast.rs | 14 ++++- crates/voltex_physics/src/solver.rs | 10 +++- 6 files changed, 125 insertions(+), 10 deletions(-) diff --git a/crates/voltex_physics/src/collider.rs b/crates/voltex_physics/src/collider.rs index 668d8a0..e66abf8 100644 --- a/crates/voltex_physics/src/collider.rs +++ b/crates/voltex_physics/src/collider.rs @@ -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, +} + +impl ConvexHull { + pub fn new(vertices: Vec) -> 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); + } } diff --git a/crates/voltex_physics/src/collision.rs b/crates/voltex_physics/src/collision.rs index 55d0091..0c0228e 100644 --- a/crates/voltex_physics/src/collision.rs +++ b/crates/voltex_physics/src/collision.rs @@ -13,7 +13,7 @@ pub fn detect_collisions(world: &World) -> Vec { let pairs_data: Vec<(Entity, Vec3, Collider)> = world .query2::() .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 { 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 { (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) } }; diff --git a/crates/voltex_physics/src/gjk.rs b/crates/voltex_physics/src/gjk.rs index 2580e05..ef459e0 100644 --- a/crates/voltex_physics/src/gjk.rs +++ b/crates/voltex_physics/src/gjk.rs @@ -30,6 +30,9 @@ fn support(collider: &Collider, position: Vec3, direction: Vec3) -> Vec3 { } base + direction * (*radius / len) } + Collider::ConvexHull(hull) => { + position + hull.support(direction) + } } } diff --git a/crates/voltex_physics/src/integrator.rs b/crates/voltex_physics/src/integrator.rs index 98a3fb0..d271c1c 100644 --- a/crates/voltex_physics/src/integrator.rs +++ b/crates/voltex_physics/src/integrator.rs @@ -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), + ) + } } } diff --git a/crates/voltex_physics/src/raycast.rs b/crates/voltex_physics/src/raycast.rs index 5474726..13ffd3a 100644 --- a/crates/voltex_physics/src/raycast.rs +++ b/crates/voltex_physics/src/raycast.rs @@ -17,7 +17,7 @@ pub fn raycast(world: &World, ray: &Ray, max_dist: f32) -> Option { let entities: Vec<(Entity, Vec3, Collider)> = world .query2::() .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 { 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 { let entities: Vec<(Entity, Vec3, Collider)> = world .query2::() .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 { 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 { diff --git a/crates/voltex_physics/src/solver.rs b/crates/voltex_physics/src/solver.rs index d06f36a..15b8f91 100644 --- a/crates/voltex_physics/src/solver.rs +++ b/crates/voltex_physics/src/solver.rs @@ -23,8 +23,8 @@ pub fn resolve_collisions(world: &mut World, contacts: &[ContactPoint], iteratio for contact in contacts { let rb_a = world.get::(contact.entity_a).copied(); let rb_b = world.get::(contact.entity_b).copied(); - let col_a = world.get::(contact.entity_a).copied(); - let col_b = world.get::(contact.entity_b).copied(); + let col_a = world.get::(contact.entity_a).cloned(); + let col_b = world.get::(contact.entity_b).cloned(); let pos_a = world.get::(contact.entity_a).map(|t| t.position); let pos_b = world.get::(contact.entity_b).map(|t| t.position); @@ -205,7 +205,7 @@ fn apply_ccd(world: &mut World, config: &PhysicsConfig) { .query3::() .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