Files
game_engine/docs/superpowers/plans/2026-03-25-phase5-1-collision-detection.md
2026-03-25 11:37:16 +09:00

32 KiB

Phase 5-1: Collision Detection Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Sphere + Box 콜라이더 충돌 감지 시스템 구현 (BVH broad phase + 전용 narrow phase)

Architecture: voltex_math에 AABB 추가, voltex_physics crate 신규 생성. Broad phase는 BVH 트리(중앙값 분할), narrow phase는 Sphere/Box 조합별 전용 함수. ECS 통합으로 detect_collisions(world) API 제공.

Tech Stack: Rust, voltex_math (Vec3), voltex_ecs (World, Entity, Transform, SparseSet)

Spec: docs/superpowers/specs/2026-03-25-phase5-1-collision-detection.md


File Structure

voltex_math (수정)

  • crates/voltex_math/src/aabb.rs — AABB 타입 (Create)
  • crates/voltex_math/src/lib.rs — AABB 모듈 등록 (Modify)

voltex_physics (신규)

  • crates/voltex_physics/Cargo.toml — crate 설정 (Create)
  • crates/voltex_physics/src/lib.rs — public exports (Create)
  • crates/voltex_physics/src/collider.rs — Collider enum + aabb() (Create)
  • crates/voltex_physics/src/contact.rs — ContactPoint (Create)
  • crates/voltex_physics/src/narrow.rs — 충돌 테스트 함수 3개 (Create)
  • crates/voltex_physics/src/bvh.rs — BvhTree (Create)
  • crates/voltex_physics/src/collision.rs — detect_collisions ECS 통합 (Create)

Workspace (수정)

  • Cargo.toml — workspace members에 voltex_physics 추가 (Modify)

Task 1: AABB 타입 (voltex_math)

Files:

  • Create: crates/voltex_math/src/aabb.rs

  • Modify: crates/voltex_math/src/lib.rs

  • Step 1: aabb.rs 생성 — 테스트부터 작성

// crates/voltex_math/src/aabb.rs
use crate::Vec3;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AABB {
    pub min: Vec3,
    pub max: Vec3,
}

impl AABB {
    pub fn new(min: Vec3, max: Vec3) -> Self {
        Self { min, max }
    }

    pub fn from_center_half_extents(center: Vec3, half: Vec3) -> Self {
        Self {
            min: center - half,
            max: center + half,
        }
    }

    pub fn center(&self) -> Vec3 {
        (self.min + self.max) * 0.5
    }

    pub fn half_extents(&self) -> Vec3 {
        (self.max - self.min) * 0.5
    }

    pub fn contains_point(&self, p: Vec3) -> bool {
        p.x >= self.min.x && p.x <= self.max.x
            && p.y >= self.min.y && p.y <= self.max.y
            && p.z >= self.min.z && p.z <= self.max.z
    }

    pub fn intersects(&self, other: &AABB) -> bool {
        self.min.x <= other.max.x && self.max.x >= other.min.x
            && self.min.y <= other.max.y && self.max.y >= other.min.y
            && self.min.z <= other.max.z && self.max.z >= other.min.z
    }

    pub fn merged(&self, other: &AABB) -> AABB {
        AABB {
            min: Vec3::new(
                self.min.x.min(other.min.x),
                self.min.y.min(other.min.y),
                self.min.z.min(other.min.z),
            ),
            max: Vec3::new(
                self.max.x.max(other.max.x),
                self.max.y.max(other.max.y),
                self.max.z.max(other.max.z),
            ),
        }
    }

    pub fn surface_area(&self) -> f32 {
        let d = self.max - self.min;
        2.0 * (d.x * d.y + d.y * d.z + d.z * d.x)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_new_and_accessors() {
        let a = AABB::new(Vec3::new(-1.0, -2.0, -3.0), Vec3::new(1.0, 2.0, 3.0));
        let c = a.center();
        assert!((c.x).abs() < 1e-6);
        assert!((c.y).abs() < 1e-6);
        assert!((c.z).abs() < 1e-6);
        let h = a.half_extents();
        assert!((h.x - 1.0).abs() < 1e-6);
        assert!((h.y - 2.0).abs() < 1e-6);
        assert!((h.z - 3.0).abs() < 1e-6);
    }

    #[test]
    fn test_from_center_half_extents() {
        let a = AABB::from_center_half_extents(Vec3::new(5.0, 5.0, 5.0), Vec3::new(1.0, 1.0, 1.0));
        assert_eq!(a.min, Vec3::new(4.0, 4.0, 4.0));
        assert_eq!(a.max, Vec3::new(6.0, 6.0, 6.0));
    }

    #[test]
    fn test_contains_point() {
        let a = AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0));
        assert!(a.contains_point(Vec3::new(1.0, 1.0, 1.0)));  // inside
        assert!(a.contains_point(Vec3::ZERO));                  // boundary
        assert!(!a.contains_point(Vec3::new(3.0, 1.0, 1.0)));  // outside
    }

    #[test]
    fn test_intersects() {
        let a = AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0));
        let b = AABB::new(Vec3::new(1.0, 1.0, 1.0), Vec3::new(3.0, 3.0, 3.0));
        assert!(a.intersects(&b));  // overlap

        let c = AABB::new(Vec3::new(5.0, 5.0, 5.0), Vec3::new(6.0, 6.0, 6.0));
        assert!(!a.intersects(&c)); // separated

        let d = AABB::new(Vec3::new(2.0, 0.0, 0.0), Vec3::new(3.0, 2.0, 2.0));
        assert!(a.intersects(&d));  // touching edge
    }

    #[test]
    fn test_merged() {
        let a = AABB::new(Vec3::ZERO, Vec3::ONE);
        let b = AABB::new(Vec3::new(2.0, 2.0, 2.0), Vec3::new(3.0, 3.0, 3.0));
        let m = a.merged(&b);
        assert_eq!(m.min, Vec3::ZERO);
        assert_eq!(m.max, Vec3::new(3.0, 3.0, 3.0));
    }

    #[test]
    fn test_surface_area() {
        // 2x2x2 box: area = 2*(4+4+4) = 24
        let a = AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0));
        assert!((a.surface_area() - 24.0).abs() < 1e-6);
    }
}
  • Step 2: lib.rs에 aabb 모듈 등록

crates/voltex_math/src/lib.rs에 추가:

pub mod aabb;
pub use aabb::AABB;
  • Step 3: 테스트 실행

Run: cargo test -p voltex_math Expected: 기존 29개 + AABB 6개 = 35개 PASS

  • Step 4: 커밋
git add crates/voltex_math/src/aabb.rs crates/voltex_math/src/lib.rs
git commit -m "feat(math): add AABB type with intersection, merge, and containment"

Task 2: voltex_physics crate 설정 + Collider 타입

Files:

  • Create: crates/voltex_physics/Cargo.toml

  • Create: crates/voltex_physics/src/lib.rs

  • Create: crates/voltex_physics/src/contact.rs

  • Create: crates/voltex_physics/src/collider.rs

  • Modify: Cargo.toml (workspace)

  • Step 1: Cargo.toml 생성

# crates/voltex_physics/Cargo.toml
[package]
name = "voltex_physics"
version = "0.1.0"
edition = "2021"

[dependencies]
voltex_math.workspace = true
voltex_ecs.workspace = true
  • Step 2: workspace에 추가

Cargo.toml workspace members에 추가:

"crates/voltex_physics",

workspace.dependencies에 추가:

voltex_physics = { path = "crates/voltex_physics" }
  • Step 3: contact.rs 작성
// crates/voltex_physics/src/contact.rs
use voltex_ecs::Entity;
use voltex_math::Vec3;

#[derive(Debug, Clone, Copy)]
pub struct ContactPoint {
    pub entity_a: Entity,
    pub entity_b: Entity,
    pub normal: Vec3,
    pub depth: f32,
    pub point_on_a: Vec3,
    pub point_on_b: Vec3,
}
  • Step 4: collider.rs 작성 (테스트 포함)
// crates/voltex_physics/src/collider.rs
use voltex_math::{Vec3, AABB};

#[derive(Debug, Clone, Copy)]
pub enum Collider {
    Sphere { radius: f32 },
    Box { half_extents: Vec3 },
}

impl Collider {
    pub fn aabb(&self, position: Vec3) -> AABB {
        match self {
            Collider::Sphere { radius } => {
                let r = Vec3::new(*radius, *radius, *radius);
                AABB::new(position - r, position + r)
            }
            Collider::Box { half_extents } => {
                AABB::new(position - *half_extents, position + *half_extents)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sphere_aabb() {
        let c = Collider::Sphere { radius: 2.0 };
        let aabb = c.aabb(Vec3::new(1.0, 0.0, 0.0));
        assert_eq!(aabb.min, Vec3::new(-1.0, -2.0, -2.0));
        assert_eq!(aabb.max, Vec3::new(3.0, 2.0, 2.0));
    }

    #[test]
    fn test_box_aabb() {
        let c = Collider::Box { half_extents: Vec3::new(1.0, 2.0, 3.0) };
        let aabb = c.aabb(Vec3::ZERO);
        assert_eq!(aabb.min, Vec3::new(-1.0, -2.0, -3.0));
        assert_eq!(aabb.max, Vec3::new(1.0, 2.0, 3.0));
    }
}
  • Step 5: lib.rs 작성
// crates/voltex_physics/src/lib.rs
pub mod collider;
pub mod contact;

pub use collider::Collider;
pub use contact::ContactPoint;
  • Step 6: 테스트 실행

Run: cargo test -p voltex_physics Expected: 2개 PASS (sphere_aabb, box_aabb)

  • Step 7: 커밋
git add crates/voltex_physics/ Cargo.toml
git commit -m "feat(physics): add voltex_physics crate with Collider and ContactPoint"

Task 3: Narrow Phase — sphere_vs_sphere

Files:

  • Create: crates/voltex_physics/src/narrow.rs

  • Modify: crates/voltex_physics/src/lib.rs

  • Step 1: narrow.rs 작성 — 테스트부터

// crates/voltex_physics/src/narrow.rs
use voltex_math::Vec3;

/// Returns (normal A→B, depth, point_on_a, point_on_b) or None if no collision.
pub fn sphere_vs_sphere(
    pos_a: Vec3, radius_a: f32,
    pos_b: Vec3, radius_b: f32,
) -> Option<(Vec3, f32, Vec3, Vec3)> {
    let diff = pos_b - pos_a;
    let dist_sq = diff.length_squared();
    let sum_r = radius_a + radius_b;

    if dist_sq > sum_r * sum_r {
        return None;
    }

    let dist = dist_sq.sqrt();
    let normal = if dist > 1e-8 {
        diff * (1.0 / dist)
    } else {
        Vec3::Y // arbitrary fallback for coincident centers
    };

    let depth = sum_r - dist;
    let point_on_a = pos_a + normal * radius_a;
    let point_on_b = pos_b - normal * radius_b;

    Some((normal, depth, point_on_a, point_on_b))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn approx(a: f32, b: f32) -> bool {
        (a - b).abs() < 1e-5
    }

    fn approx_vec(a: Vec3, b: Vec3) -> bool {
        approx(a.x, b.x) && approx(a.y, b.y) && approx(a.z, b.z)
    }

    #[test]
    fn test_sphere_sphere_separated() {
        let r = sphere_vs_sphere(
            Vec3::ZERO, 1.0,
            Vec3::new(5.0, 0.0, 0.0), 1.0,
        );
        assert!(r.is_none());
    }

    #[test]
    fn test_sphere_sphere_overlapping() {
        let r = sphere_vs_sphere(
            Vec3::ZERO, 1.0,
            Vec3::new(1.5, 0.0, 0.0), 1.0,
        );
        let (normal, depth, pa, pb) = r.unwrap();
        assert!(approx_vec(normal, Vec3::X));
        assert!(approx(depth, 0.5));
        assert!(approx_vec(pa, Vec3::new(1.0, 0.0, 0.0)));
        assert!(approx_vec(pb, Vec3::new(0.5, 0.0, 0.0)));
    }

    #[test]
    fn test_sphere_sphere_touching() {
        let r = sphere_vs_sphere(
            Vec3::ZERO, 1.0,
            Vec3::new(2.0, 0.0, 0.0), 1.0,
        );
        let (normal, depth, _pa, _pb) = r.unwrap();
        assert!(approx_vec(normal, Vec3::X));
        assert!(approx(depth, 0.0));
    }

    #[test]
    fn test_sphere_sphere_coincident() {
        let r = sphere_vs_sphere(
            Vec3::ZERO, 1.0,
            Vec3::ZERO, 1.0,
        );
        let (_normal, depth, _pa, _pb) = r.unwrap();
        assert!(approx(depth, 2.0));
    }
}
  • Step 2: lib.rs에 narrow 모듈 등록
pub mod narrow;
  • Step 3: 테스트 실행

Run: cargo test -p voltex_physics Expected: 6개 PASS (collider 2 + narrow 4)

  • Step 4: 커밋
git add crates/voltex_physics/src/narrow.rs crates/voltex_physics/src/lib.rs
git commit -m "feat(physics): add sphere_vs_sphere narrow phase"

Task 4: Narrow Phase — sphere_vs_box

Files:

  • Modify: crates/voltex_physics/src/narrow.rs

  • Step 1: sphere_vs_box 함수 + 테스트 추가

narrow.rs에 추가:

pub fn sphere_vs_box(
    sphere_pos: Vec3, radius: f32,
    box_pos: Vec3, half_extents: Vec3,
) -> Option<(Vec3, f32, Vec3, Vec3)> {
    // Closest point on box to sphere center
    let bmin = box_pos - half_extents;
    let bmax = box_pos + half_extents;

    let closest = Vec3::new(
        sphere_pos.x.clamp(bmin.x, bmax.x),
        sphere_pos.y.clamp(bmin.y, bmax.y),
        sphere_pos.z.clamp(bmin.z, bmax.z),
    );

    let diff = sphere_pos - closest;
    let dist_sq = diff.length_squared();

    // Sphere center outside box
    if dist_sq > 1e-8 {
        let dist = dist_sq.sqrt();
        if dist > radius {
            return None;
        }
        let normal = diff * (-1.0 / dist); // points from sphere toward box
        // Convention: normal A→B means sphere→box
        let depth = radius - dist;
        let point_on_a = sphere_pos - normal * radius; // sphere surface (note: normal is sphere→box, so subtract)

        // Actually: normal should be A(sphere)→B(box)
        // normal = (box - sphere) direction = -diff.normalize()
        let point_on_b = closest;
        return Some((normal, depth, point_on_a, point_on_b));
    }

    // Sphere center inside box — find nearest face
    let dx_min = sphere_pos.x - bmin.x;
    let dx_max = bmax.x - sphere_pos.x;
    let dy_min = sphere_pos.y - bmin.y;
    let dy_max = bmax.y - sphere_pos.y;
    let dz_min = sphere_pos.z - bmin.z;
    let dz_max = bmax.z - sphere_pos.z;

    let mut min_dist = dx_min;
    let mut normal = Vec3::new(-1.0, 0.0, 0.0);
    let mut closest_face = Vec3::new(bmin.x, sphere_pos.y, sphere_pos.z);

    if dx_max < min_dist {
        min_dist = dx_max;
        normal = Vec3::new(1.0, 0.0, 0.0);
        closest_face = Vec3::new(bmax.x, sphere_pos.y, sphere_pos.z);
    }
    if dy_min < min_dist {
        min_dist = dy_min;
        normal = Vec3::new(0.0, -1.0, 0.0);
        closest_face = Vec3::new(sphere_pos.x, bmin.y, sphere_pos.z);
    }
    if dy_max < min_dist {
        min_dist = dy_max;
        normal = Vec3::new(0.0, 1.0, 0.0);
        closest_face = Vec3::new(sphere_pos.x, bmax.y, sphere_pos.z);
    }
    if dz_min < min_dist {
        min_dist = dz_min;
        normal = Vec3::new(0.0, 0.0, -1.0);
        closest_face = Vec3::new(sphere_pos.x, sphere_pos.y, bmin.z);
    }
    if dz_max < min_dist {
        min_dist = dz_max;
        normal = Vec3::new(0.0, 0.0, 1.0);
        closest_face = Vec3::new(sphere_pos.x, sphere_pos.y, bmax.z);
    }

    let depth = min_dist + radius;
    let point_on_a = sphere_pos + normal * radius;
    let point_on_b = closest_face;

    Some((normal, depth, point_on_a, point_on_b))
}

테스트 추가 (tests 모듈 안):

    #[test]
    fn test_sphere_box_separated() {
        let r = sphere_vs_box(
            Vec3::new(5.0, 0.0, 0.0), 1.0,
            Vec3::ZERO, Vec3::ONE,
        );
        assert!(r.is_none());
    }

    #[test]
    fn test_sphere_box_face_overlap() {
        // sphere at x=1.5, radius=1, box at origin half=1 → face contact on +x
        let r = sphere_vs_box(
            Vec3::new(1.5, 0.0, 0.0), 1.0,
            Vec3::ZERO, Vec3::ONE,
        );
        let (normal, depth, _pa, pb) = r.unwrap();
        assert!(approx(normal.x, -1.0)); // sphere→box: -X direction
        assert!(approx(depth, 0.5));
        assert!(approx(pb.x, 1.0));
    }

    #[test]
    fn test_sphere_box_center_inside() {
        // sphere center at box center
        let r = sphere_vs_box(
            Vec3::ZERO, 0.5,
            Vec3::ZERO, Vec3::ONE,
        );
        assert!(r.is_some());
        let (_normal, depth, _pa, _pb) = r.unwrap();
        assert!(depth > 0.0);
    }
  • Step 2: 테스트 실행

Run: cargo test -p voltex_physics Expected: 9개 PASS

  • Step 3: 커밋
git add crates/voltex_physics/src/narrow.rs
git commit -m "feat(physics): add sphere_vs_box narrow phase"

Task 5: Narrow Phase — box_vs_box (SAT)

Files:

  • Modify: crates/voltex_physics/src/narrow.rs

  • Step 1: box_vs_box 함수 + 테스트 추가

narrow.rs에 추가:

pub fn box_vs_box(
    pos_a: Vec3, half_a: Vec3,
    pos_b: Vec3, half_b: Vec3,
) -> Option<(Vec3, f32, Vec3, Vec3)> {
    // AABB vs AABB using SAT on 3 axes
    let diff = pos_b - pos_a;

    let overlap_x = (half_a.x + half_b.x) - diff.x.abs();
    if overlap_x < 0.0 { return None; }

    let overlap_y = (half_a.y + half_b.y) - diff.y.abs();
    if overlap_y < 0.0 { return None; }

    let overlap_z = (half_a.z + half_b.z) - diff.z.abs();
    if overlap_z < 0.0 { return None; }

    // Find minimum overlap axis
    let (normal, depth) = if overlap_x <= overlap_y && overlap_x <= overlap_z {
        let sign = if diff.x >= 0.0 { 1.0 } else { -1.0 };
        (Vec3::new(sign, 0.0, 0.0), overlap_x)
    } else if overlap_y <= overlap_z {
        let sign = if diff.y >= 0.0 { 1.0 } else { -1.0 };
        (Vec3::new(0.0, sign, 0.0), overlap_y)
    } else {
        let sign = if diff.z >= 0.0 { 1.0 } else { -1.0 };
        (Vec3::new(0.0, 0.0, sign), overlap_z)
    };

    let point_on_a = pos_a + Vec3::new(
        normal.x * half_a.x,
        normal.y * half_a.y,
        normal.z * half_a.z,
    );
    let point_on_b = pos_b - Vec3::new(
        normal.x * half_b.x,
        normal.y * half_b.y,
        normal.z * half_b.z,
    );

    Some((normal, depth, point_on_a, point_on_b))
}

테스트 추가:

    #[test]
    fn test_box_box_separated() {
        let r = box_vs_box(
            Vec3::ZERO, Vec3::ONE,
            Vec3::new(5.0, 0.0, 0.0), Vec3::ONE,
        );
        assert!(r.is_none());
    }

    #[test]
    fn test_box_box_overlapping() {
        let r = box_vs_box(
            Vec3::ZERO, Vec3::ONE,
            Vec3::new(1.5, 0.0, 0.0), Vec3::ONE,
        );
        let (normal, depth, _pa, _pb) = r.unwrap();
        assert!(approx_vec(normal, Vec3::X));
        assert!(approx(depth, 0.5));
    }

    #[test]
    fn test_box_box_touching() {
        let r = box_vs_box(
            Vec3::ZERO, Vec3::ONE,
            Vec3::new(2.0, 0.0, 0.0), Vec3::ONE,
        );
        let (_normal, depth, _pa, _pb) = r.unwrap();
        assert!(approx(depth, 0.0));
    }

    #[test]
    fn test_box_box_y_axis() {
        let r = box_vs_box(
            Vec3::ZERO, Vec3::ONE,
            Vec3::new(0.0, 1.5, 0.0), Vec3::ONE,
        );
        let (normal, depth, _pa, _pb) = r.unwrap();
        assert!(approx_vec(normal, Vec3::Y));
        assert!(approx(depth, 0.5));
    }
  • Step 2: 테스트 실행

Run: cargo test -p voltex_physics Expected: 13개 PASS

  • Step 3: 커밋
git add crates/voltex_physics/src/narrow.rs
git commit -m "feat(physics): add box_vs_box narrow phase (SAT)"

Task 6: BVH Tree (Broad Phase)

Files:

  • Create: crates/voltex_physics/src/bvh.rs

  • Modify: crates/voltex_physics/src/lib.rs

  • Step 1: bvh.rs 작성

// crates/voltex_physics/src/bvh.rs
use voltex_ecs::Entity;
use voltex_math::AABB;

#[derive(Debug)]
enum BvhNode {
    Leaf { entity: Entity, aabb: AABB },
    Internal { aabb: AABB, left: usize, right: usize },
}

#[derive(Debug)]
pub struct BvhTree {
    nodes: Vec<BvhNode>,
}

impl BvhTree {
    /// Build BVH from entity-AABB pairs using median split on longest axis.
    pub fn build(entries: &[(Entity, AABB)]) -> Self {
        let mut tree = BvhTree { nodes: Vec::new() };
        if !entries.is_empty() {
            let mut sorted: Vec<(Entity, AABB)> = entries.to_vec();
            tree.build_recursive(&mut sorted);
        }
        tree
    }

    fn build_recursive(&mut self, entries: &mut [(Entity, AABB)]) -> usize {
        if entries.len() == 1 {
            let idx = self.nodes.len();
            self.nodes.push(BvhNode::Leaf {
                entity: entries[0].0,
                aabb: entries[0].1,
            });
            return idx;
        }

        // Compute bounding AABB
        let mut combined = entries[0].1;
        for e in entries.iter().skip(1) {
            combined = combined.merged(&e.1);
        }

        // Find longest axis
        let extent = combined.max - combined.min;
        let axis = if extent.x >= extent.y && extent.x >= extent.z {
            0 // x
        } else if extent.y >= extent.z {
            1 // y
        } else {
            2 // z
        };

        // Sort by axis center
        entries.sort_by(|a, b| {
            let ca = a.1.center();
            let cb = b.1.center();
            let va = match axis { 0 => ca.x, 1 => ca.y, _ => ca.z };
            let vb = match axis { 0 => cb.x, 1 => cb.y, _ => cb.z };
            va.partial_cmp(&vb).unwrap()
        });

        let mid = entries.len() / 2;
        let (left_entries, right_entries) = entries.split_at_mut(mid);

        let left = self.build_recursive(left_entries);
        let right = self.build_recursive(right_entries);

        let idx = self.nodes.len();
        self.nodes.push(BvhNode::Internal {
            aabb: combined,
            left,
            right,
        });
        idx
    }

    /// Return all pairs of entities whose AABBs overlap.
    pub fn query_pairs(&self) -> Vec<(Entity, Entity)> {
        let mut pairs = Vec::new();
        if self.nodes.is_empty() {
            return pairs;
        }
        let root = self.nodes.len() - 1;
        self.collect_leaves_and_test(root, &mut pairs);
        pairs
    }

    fn collect_leaves_and_test(&self, node_idx: usize, pairs: &mut Vec<(Entity, Entity)>) {
        // Collect all leaves, then test all pairs.
        // For small counts this is fine; optimize later if needed.
        let mut leaves = Vec::new();
        self.collect_leaves(node_idx, &mut leaves);
        for i in 0..leaves.len() {
            for j in (i + 1)..leaves.len() {
                let (ea, aabb_a) = leaves[i];
                let (eb, aabb_b) = leaves[j];
                if aabb_a.intersects(&aabb_b) {
                    pairs.push((ea, eb));
                }
            }
        }
    }

    fn collect_leaves(&self, node_idx: usize, out: &mut Vec<(Entity, AABB)>) {
        match &self.nodes[node_idx] {
            BvhNode::Leaf { entity, aabb } => {
                out.push((*entity, *aabb));
            }
            BvhNode::Internal { left, right, .. } => {
                self.collect_leaves(*left, out);
                self.collect_leaves(*right, out);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use voltex_math::Vec3;

    fn make_entity(id: u32) -> Entity {
        Entity { id, generation: 0 }
    }

    #[test]
    fn test_build_empty() {
        let tree = BvhTree::build(&[]);
        assert!(tree.query_pairs().is_empty());
    }

    #[test]
    fn test_build_single() {
        let entries = vec![
            (make_entity(0), AABB::new(Vec3::ZERO, Vec3::ONE)),
        ];
        let tree = BvhTree::build(&entries);
        assert!(tree.query_pairs().is_empty());
    }

    #[test]
    fn test_overlapping_pair() {
        let entries = vec![
            (make_entity(0), AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0))),
            (make_entity(1), AABB::new(Vec3::ONE, Vec3::new(3.0, 3.0, 3.0))),
        ];
        let tree = BvhTree::build(&entries);
        let pairs = tree.query_pairs();
        assert_eq!(pairs.len(), 1);
    }

    #[test]
    fn test_separated_pair() {
        let entries = vec![
            (make_entity(0), AABB::new(Vec3::ZERO, Vec3::ONE)),
            (make_entity(1), AABB::new(Vec3::new(5.0, 5.0, 5.0), Vec3::new(6.0, 6.0, 6.0))),
        ];
        let tree = BvhTree::build(&entries);
        assert!(tree.query_pairs().is_empty());
    }

    #[test]
    fn test_multiple_entities() {
        let entries = vec![
            (make_entity(0), AABB::new(Vec3::ZERO, Vec3::new(2.0, 2.0, 2.0))),
            (make_entity(1), AABB::new(Vec3::ONE, Vec3::new(3.0, 3.0, 3.0))),
            (make_entity(2), AABB::new(Vec3::new(10.0, 10.0, 10.0), Vec3::new(11.0, 11.0, 11.0))),
        ];
        let tree = BvhTree::build(&entries);
        let pairs = tree.query_pairs();
        // Only 0-1 overlap, 2 is far away
        assert_eq!(pairs.len(), 1);
        let (a, b) = pairs[0];
        assert!((a.id == 0 && b.id == 1) || (a.id == 1 && b.id == 0));
    }
}
  • Step 2: lib.rs에 bvh 모듈 등록
pub mod bvh;
pub use bvh::BvhTree;
  • Step 3: 테스트 실행

Run: cargo test -p voltex_physics Expected: 18개 PASS (collider 2 + narrow 11 + bvh 5)

  • Step 4: 커밋
git add crates/voltex_physics/src/bvh.rs crates/voltex_physics/src/lib.rs
git commit -m "feat(physics): add BVH tree for broad phase collision detection"

Task 7: ECS Integration — detect_collisions

Files:

  • Create: crates/voltex_physics/src/collision.rs

  • Modify: crates/voltex_physics/src/lib.rs

  • Step 1: collision.rs 작성

// crates/voltex_physics/src/collision.rs
use voltex_ecs::{World, Entity};
use voltex_ecs::Transform;
use voltex_math::Vec3;

use crate::collider::Collider;
use crate::contact::ContactPoint;
use crate::bvh::BvhTree;
use crate::narrow;

/// Detect all collisions among entities that have Transform + Collider.
pub fn detect_collisions(world: &World) -> Vec<ContactPoint> {
    // 1. Gather entities with Transform + Collider
    let pairs_data: Vec<(Entity, Vec3, Collider)> = world
        .query2::<Transform, Collider>()
        .iter()
        .map(|(e, t, c)| (*e, t.position, *c))
        .collect();

    if pairs_data.len() < 2 {
        return Vec::new();
    }

    // 2. Build AABBs
    let entries: Vec<(Entity, voltex_math::AABB)> = pairs_data
        .iter()
        .map(|(e, pos, col)| (*e, col.aabb(*pos)))
        .collect();

    // 3. Broad phase
    let bvh = BvhTree::build(&entries);
    let broad_pairs = bvh.query_pairs();

    // 4. Narrow phase
    let mut contacts = Vec::new();

    // Build a lookup: entity_id -> (position, collider)
    // Using a simple linear scan since entity count is expected to be moderate
    let lookup = |entity: Entity| -> Option<(Vec3, Collider)> {
        pairs_data.iter().find(|(e, _, _)| *e == entity).map(|(_, p, c)| (*p, *c))
    };

    for (ea, eb) in broad_pairs {
        let (pos_a, col_a) = match lookup(ea) { Some(v) => v, None => continue };
        let (pos_b, col_b) = match lookup(eb) { Some(v) => v, None => continue };

        let result = match (&col_a, &col_b) {
            (Collider::Sphere { radius: ra }, Collider::Sphere { radius: rb }) => {
                narrow::sphere_vs_sphere(pos_a, *ra, pos_b, *rb)
            }
            (Collider::Sphere { radius }, Collider::Box { half_extents }) => {
                narrow::sphere_vs_box(pos_a, *radius, pos_b, *half_extents)
            }
            (Collider::Box { half_extents }, Collider::Sphere { radius }) => {
                // Swap order: sphere is A
                narrow::sphere_vs_box(pos_b, *radius, pos_a, *half_extents)
                    .map(|(n, d, pa, pb)| (-n, d, pb, pa)) // flip normal and points
            }
            (Collider::Box { half_extents: ha }, Collider::Box { half_extents: hb }) => {
                narrow::box_vs_box(pos_a, *ha, pos_b, *hb)
            }
        };

        if let Some((normal, depth, point_on_a, point_on_b)) = result {
            contacts.push(ContactPoint {
                entity_a: ea,
                entity_b: eb,
                normal,
                depth,
                point_on_a,
                point_on_b,
            });
        }
    }

    contacts
}

#[cfg(test)]
mod tests {
    use super::*;
    use voltex_ecs::World;
    use voltex_ecs::Transform;
    use voltex_math::Vec3;
    use crate::Collider;

    #[test]
    fn test_no_colliders() {
        let world = World::new();
        let contacts = detect_collisions(&world);
        assert!(contacts.is_empty());
    }

    #[test]
    fn test_single_entity() {
        let mut world = World::new();
        let e = world.spawn();
        world.add(e, Transform::from_position(Vec3::ZERO));
        world.add(e, Collider::Sphere { radius: 1.0 });
        let contacts = detect_collisions(&world);
        assert!(contacts.is_empty());
    }

    #[test]
    fn test_two_spheres_colliding() {
        let mut world = World::new();
        let a = world.spawn();
        world.add(a, Transform::from_position(Vec3::ZERO));
        world.add(a, Collider::Sphere { radius: 1.0 });

        let b = world.spawn();
        world.add(b, Transform::from_position(Vec3::new(1.5, 0.0, 0.0)));
        world.add(b, Collider::Sphere { radius: 1.0 });

        let contacts = detect_collisions(&world);
        assert_eq!(contacts.len(), 1);
        assert!((contacts[0].depth - 0.5).abs() < 1e-5);
    }

    #[test]
    fn test_two_spheres_separated() {
        let mut world = World::new();
        let a = world.spawn();
        world.add(a, Transform::from_position(Vec3::ZERO));
        world.add(a, Collider::Sphere { radius: 1.0 });

        let b = world.spawn();
        world.add(b, Transform::from_position(Vec3::new(10.0, 0.0, 0.0)));
        world.add(b, Collider::Sphere { radius: 1.0 });

        let contacts = detect_collisions(&world);
        assert!(contacts.is_empty());
    }

    #[test]
    fn test_sphere_vs_box_collision() {
        let mut world = World::new();
        let a = world.spawn();
        world.add(a, Transform::from_position(Vec3::ZERO));
        world.add(a, Collider::Sphere { radius: 1.0 });

        let b = world.spawn();
        world.add(b, Transform::from_position(Vec3::new(1.5, 0.0, 0.0)));
        world.add(b, Collider::Box { half_extents: Vec3::ONE });

        let contacts = detect_collisions(&world);
        assert_eq!(contacts.len(), 1);
        assert!(contacts[0].depth > 0.0);
    }

    #[test]
    fn test_box_vs_box_collision() {
        let mut world = World::new();
        let a = world.spawn();
        world.add(a, Transform::from_position(Vec3::ZERO));
        world.add(a, Collider::Box { half_extents: Vec3::ONE });

        let b = world.spawn();
        world.add(b, Transform::from_position(Vec3::new(1.5, 0.0, 0.0)));
        world.add(b, Collider::Box { half_extents: Vec3::ONE });

        let contacts = detect_collisions(&world);
        assert_eq!(contacts.len(), 1);
        assert!((contacts[0].depth - 0.5).abs() < 1e-5);
    }

    #[test]
    fn test_three_entities_mixed() {
        let mut world = World::new();

        let a = world.spawn();
        world.add(a, Transform::from_position(Vec3::ZERO));
        world.add(a, Collider::Sphere { radius: 1.0 });

        let b = world.spawn();
        world.add(b, Transform::from_position(Vec3::new(1.5, 0.0, 0.0)));
        world.add(b, Collider::Sphere { radius: 1.0 });

        let c = world.spawn();
        world.add(c, Transform::from_position(Vec3::new(100.0, 0.0, 0.0)));
        world.add(c, Collider::Box { half_extents: Vec3::ONE });

        let contacts = detect_collisions(&world);
        assert_eq!(contacts.len(), 1); // only a-b collide
    }
}
  • Step 2: lib.rs에 collision 모듈 등록
pub mod collision;
pub use collision::detect_collisions;
  • Step 3: 테스트 실행

Run: cargo test -p voltex_physics Expected: 25개 PASS (collider 2 + narrow 11 + bvh 5 + collision 7)

  • Step 4: 전체 workspace 테스트

Run: cargo test --workspace Expected: 기존 105 + AABB 6 + physics 25 = 136개 전부 PASS

  • Step 5: 커밋
git add crates/voltex_physics/src/collision.rs crates/voltex_physics/src/lib.rs
git commit -m "feat(physics): add detect_collisions ECS integration"

Task 8: 문서 업데이트 + 최종 커밋

Files:

  • Modify: docs/STATUS.md

  • Modify: docs/DEFERRED.md

  • Modify: CLAUDE.md

  • Step 1: STATUS.md에 Phase 5-1 추가

Phase 4c 아래에 추가:

### Phase 5-1: Collision Detection
- voltex_math: AABB type
- voltex_physics: Collider (Sphere, Box), ContactPoint
- voltex_physics: BVH broad phase (median split)
- voltex_physics: Narrow phase (sphere-sphere, sphere-box, box-box SAT)
- voltex_physics: detect_collisions ECS integration

crate 구조에 voltex_physics 추가. 테스트 수 업데이트. 다음 항목을 Phase 5-2 (리지드바디)로 변경.

  • Step 2: DEFERRED.md에 Phase 5-1 미뤄진 항목 추가
## Phase 5-1

- **Capsule, Convex Hull 콜라이더** — Sphere + Box만 구현. 추후 GJK/EPA와 함께 추가.
- **OBB (회전된 박스) 충돌** — 축 정렬 AABB만 지원. OBB는 GJK/EPA로 대체 예정.
- **Incremental BVH 업데이트** — 매 프레임 전체 rebuild. 성능 이슈 시 incremental update 적용.
- **연속 충돌 감지 (CCD)** — 이산 충돌만. 빠른 물체의 터널링 미처리.
  • Step 3: 커밋
git add docs/STATUS.md docs/DEFERRED.md CLAUDE.md
git commit -m "docs: add Phase 5-1 collision detection status and deferred items"