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

1156 lines
32 KiB
Markdown

# 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 생성 — 테스트부터 작성**
```rust
// 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`에 추가:
```rust
pub mod aabb;
pub use aabb::AABB;
```
- [ ] **Step 3: 테스트 실행**
Run: `cargo test -p voltex_math`
Expected: 기존 29개 + AABB 6개 = 35개 PASS
- [ ] **Step 4: 커밋**
```bash
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 생성**
```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에 추가:
```toml
"crates/voltex_physics",
```
workspace.dependencies에 추가:
```toml
voltex_physics = { path = "crates/voltex_physics" }
```
- [ ] **Step 3: contact.rs 작성**
```rust
// 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 작성 (테스트 포함)**
```rust
// 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 작성**
```rust
// 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: 커밋**
```bash
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 작성 — 테스트부터**
```rust
// 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 모듈 등록**
```rust
pub mod narrow;
```
- [ ] **Step 3: 테스트 실행**
Run: `cargo test -p voltex_physics`
Expected: 6개 PASS (collider 2 + narrow 4)
- [ ] **Step 4: 커밋**
```bash
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`에 추가:
```rust
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 모듈 안):
```rust
#[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: 커밋**
```bash
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`에 추가:
```rust
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))
}
```
테스트 추가:
```rust
#[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: 커밋**
```bash
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 작성**
```rust
// 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 모듈 등록**
```rust
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: 커밋**
```bash
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 작성**
```rust
// 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 모듈 등록**
```rust
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: 커밋**
```bash
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 아래에 추가:
```markdown
### 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 미뤄진 항목 추가**
```markdown
## Phase 5-1
- **Capsule, Convex Hull 콜라이더** — Sphere + Box만 구현. 추후 GJK/EPA와 함께 추가.
- **OBB (회전된 박스) 충돌** — 축 정렬 AABB만 지원. OBB는 GJK/EPA로 대체 예정.
- **Incremental BVH 업데이트** — 매 프레임 전체 rebuild. 성능 이슈 시 incremental update 적용.
- **연속 충돌 감지 (CCD)** — 이산 충돌만. 빠른 물체의 터널링 미처리.
```
- [ ] **Step 3: 커밋**
```bash
git add docs/STATUS.md docs/DEFERRED.md CLAUDE.md
git commit -m "docs: add Phase 5-1 collision detection status and deferred items"
```