# Phase 5-3: Raycasting 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:** BVH 가속 레이캐스트 — Ray를 쏘아 가장 가까운 콜라이더 entity를 찾는다 **Architecture:** `voltex_math`에 Ray 타입 추가, `voltex_physics`에 기하 교차 함수 3개(AABB, Sphere, Box)와 ECS 통합 raycast 함수. 기존 BvhTree를 재사용하여 broad phase 가속. **Tech Stack:** Rust, voltex_math (Vec3, AABB, Ray), voltex_ecs (World, Entity, Transform), voltex_physics (Collider, BvhTree) **Spec:** `docs/superpowers/specs/2026-03-25-phase5-3-raycasting.md` --- ## File Structure ### voltex_math (수정) - `crates/voltex_math/src/ray.rs` — Ray 타입 (Create) - `crates/voltex_math/src/lib.rs` — Ray 모듈 등록 (Modify) ### voltex_physics (추가) - `crates/voltex_physics/src/ray.rs` — ray_vs_aabb, ray_vs_sphere, ray_vs_box (Create) - `crates/voltex_physics/src/raycast.rs` — RayHit, raycast (Create) - `crates/voltex_physics/src/lib.rs` — 새 모듈 등록 (Modify) --- ## Task 1: Ray 타입 (voltex_math) **Files:** - Create: `crates/voltex_math/src/ray.rs` - Modify: `crates/voltex_math/src/lib.rs` - [ ] **Step 1: ray.rs 작성** ```rust // crates/voltex_math/src/ray.rs use crate::Vec3; #[derive(Debug, Clone, Copy)] pub struct Ray { pub origin: Vec3, pub direction: Vec3, } impl Ray { /// Create a ray. Direction is normalized. pub fn new(origin: Vec3, direction: Vec3) -> Self { Self { origin, direction: direction.normalize(), } } /// Point along the ray at parameter t. pub fn at(&self, t: f32) -> Vec3 { self.origin + self.direction * t } } #[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_new_normalizes_direction() { let r = Ray::new(Vec3::ZERO, Vec3::new(3.0, 0.0, 0.0)); assert!(approx_vec(r.direction, Vec3::X)); } #[test] fn test_at() { let r = Ray::new(Vec3::new(1.0, 2.0, 3.0), Vec3::X); let p = r.at(5.0); assert!(approx_vec(p, Vec3::new(6.0, 2.0, 3.0))); } } ``` - [ ] **Step 2: lib.rs에 ray 모듈 등록** ```rust pub mod ray; pub use ray::Ray; ``` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_math` Expected: 37 PASS (기존 35 + 2) - [ ] **Step 4: 커밋** ```bash git add crates/voltex_math/src/ray.rs crates/voltex_math/src/lib.rs git commit -m "feat(math): add Ray type with direction normalization" ``` --- ## Task 2: 기하 교차 함수 (ray_vs_aabb, ray_vs_sphere, ray_vs_box) **Files:** - Create: `crates/voltex_physics/src/ray.rs` - Modify: `crates/voltex_physics/src/lib.rs` - [ ] **Step 1: ray.rs 작성** ```rust // crates/voltex_physics/src/ray.rs use voltex_math::{Vec3, Ray, AABB}; /// Ray vs AABB (slab method). Returns t of nearest intersection, or None. /// If ray starts inside AABB, returns Some(0.0). pub fn ray_vs_aabb(ray: &Ray, aabb: &AABB) -> Option { let mut t_min = f32::NEG_INFINITY; let mut t_max = f32::INFINITY; let o = [ray.origin.x, ray.origin.y, ray.origin.z]; let d = [ray.direction.x, ray.direction.y, ray.direction.z]; let bmin = [aabb.min.x, aabb.min.y, aabb.min.z]; let bmax = [aabb.max.x, aabb.max.y, aabb.max.z]; for i in 0..3 { if d[i].abs() < 1e-8 { // Ray parallel to slab if o[i] < bmin[i] || o[i] > bmax[i] { return None; } } else { let inv_d = 1.0 / d[i]; let mut t1 = (bmin[i] - o[i]) * inv_d; let mut t2 = (bmax[i] - o[i]) * inv_d; if t1 > t2 { std::mem::swap(&mut t1, &mut t2); } t_min = t_min.max(t1); t_max = t_max.min(t2); if t_min > t_max { return None; } } } if t_max < 0.0 { return None; // AABB is behind ray } Some(t_min.max(0.0)) } /// Ray vs Sphere. Returns (t, normal) or None. pub fn ray_vs_sphere(ray: &Ray, center: Vec3, radius: f32) -> Option<(f32, Vec3)> { let oc = ray.origin - center; let a = ray.direction.dot(ray.direction); // 1.0 if normalized let b = 2.0 * oc.dot(ray.direction); let c = oc.dot(oc) - radius * radius; let discriminant = b * b - 4.0 * a * c; if discriminant < 0.0 { return None; } let sqrt_d = discriminant.sqrt(); let mut t = (-b - sqrt_d) / (2.0 * a); if t < 0.0 { t = (-b + sqrt_d) / (2.0 * a); // try far intersection if t < 0.0 { return None; // both behind ray } } let point = ray.at(t); let normal = (point - center).normalize(); Some((t, normal)) } /// Ray vs axis-aligned Box. Returns (t, normal) or None. pub fn ray_vs_box(ray: &Ray, center: Vec3, half_extents: Vec3) -> Option<(f32, Vec3)> { let aabb = AABB::from_center_half_extents(center, half_extents); let t = ray_vs_aabb(ray, &aabb)?; // Compute normal from the face where the ray enters if t == 0.0 { // Ray starts inside box — return t=0 with normal opposite to ray direction // Find closest face let bmin = aabb.min; let bmax = aabb.max; let p = ray.origin; let faces: [(f32, Vec3); 6] = [ (p.x - bmin.x, Vec3::new(-1.0, 0.0, 0.0)), (bmax.x - p.x, Vec3::new(1.0, 0.0, 0.0)), (p.y - bmin.y, Vec3::new(0.0, -1.0, 0.0)), (bmax.y - p.y, Vec3::new(0.0, 1.0, 0.0)), (p.z - bmin.z, Vec3::new(0.0, 0.0, -1.0)), (bmax.z - p.z, Vec3::new(0.0, 0.0, 1.0)), ]; let mut min_dist = f32::INFINITY; let mut normal = Vec3::Y; for (dist, n) in &faces { if *dist < min_dist { min_dist = *dist; normal = *n; } } return Some((0.0, normal)); } // Find which face was hit by checking the hit point let hit = ray.at(t); let rel = hit - center; let hx = half_extents.x; let hy = half_extents.y; let hz = half_extents.z; let normal = if (rel.x - hx).abs() < 1e-4 { Vec3::X } else if (rel.x + hx).abs() < 1e-4 { Vec3::new(-1.0, 0.0, 0.0) } else if (rel.y - hy).abs() < 1e-4 { Vec3::Y } else if (rel.y + hy).abs() < 1e-4 { Vec3::new(0.0, -1.0, 0.0) } else if (rel.z - hz).abs() < 1e-4 { Vec3::Z } else { Vec3::new(0.0, 0.0, -1.0) }; Some((t, normal)) } #[cfg(test)] mod tests { use super::*; fn approx(a: f32, b: f32) -> bool { (a - b).abs() < 1e-4 } fn approx_vec(a: Vec3, b: Vec3) -> bool { approx(a.x, b.x) && approx(a.y, b.y) && approx(a.z, b.z) } // ray_vs_aabb tests #[test] fn test_aabb_hit() { let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X); let aabb = AABB::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::ONE); let t = ray_vs_aabb(&ray, &aabb).unwrap(); assert!(approx(t, 4.0)); } #[test] fn test_aabb_miss() { let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X); let aabb = AABB::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::ONE); assert!(ray_vs_aabb(&ray, &aabb).is_none()); } #[test] fn test_aabb_inside() { let ray = Ray::new(Vec3::ZERO, Vec3::X); let aabb = AABB::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::ONE); let t = ray_vs_aabb(&ray, &aabb).unwrap(); assert!(approx(t, 0.0)); } // ray_vs_sphere tests #[test] fn test_sphere_hit() { let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X); let (t, normal) = ray_vs_sphere(&ray, Vec3::ZERO, 1.0).unwrap(); assert!(approx(t, 4.0)); assert!(approx_vec(normal, Vec3::new(-1.0, 0.0, 0.0))); } #[test] fn test_sphere_miss() { let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X); assert!(ray_vs_sphere(&ray, Vec3::ZERO, 1.0).is_none()); } #[test] fn test_sphere_tangent() { let ray = Ray::new(Vec3::new(-5.0, 1.0, 0.0), Vec3::X); let result = ray_vs_sphere(&ray, Vec3::ZERO, 1.0); // Tangent: discriminant ≈ 0, should still hit assert!(result.is_some()); } #[test] fn test_sphere_inside() { let ray = Ray::new(Vec3::ZERO, Vec3::X); let (t, _normal) = ray_vs_sphere(&ray, Vec3::ZERO, 2.0).unwrap(); // Should return far intersection assert!(approx(t, 2.0)); } // ray_vs_box tests #[test] fn test_box_hit_face() { let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X); let (t, normal) = ray_vs_box(&ray, Vec3::ZERO, Vec3::ONE).unwrap(); assert!(approx(t, 4.0)); assert!(approx_vec(normal, Vec3::new(-1.0, 0.0, 0.0))); } #[test] fn test_box_miss() { let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X); assert!(ray_vs_box(&ray, Vec3::ZERO, Vec3::ONE).is_none()); } #[test] fn test_box_inside() { let ray = Ray::new(Vec3::ZERO, Vec3::X); let (t, _normal) = ray_vs_box(&ray, Vec3::ZERO, Vec3::ONE).unwrap(); assert!(approx(t, 0.0)); } } ``` - [ ] **Step 2: lib.rs에 ray 모듈 등록** NOTE: voltex_physics already has modules. The new module name `ray` should not conflict. ```rust pub mod ray; ``` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_physics` Expected: 46 PASS (기존 36 + 10) - [ ] **Step 4: 커밋** ```bash git add crates/voltex_physics/src/ray.rs crates/voltex_physics/src/lib.rs git commit -m "feat(physics): add ray intersection tests (AABB, sphere, box)" ``` --- ## Task 3: ECS 레이캐스트 통합 **Files:** - Create: `crates/voltex_physics/src/raycast.rs` - Modify: `crates/voltex_physics/src/lib.rs` - [ ] **Step 1: raycast.rs 작성** ```rust // crates/voltex_physics/src/raycast.rs use voltex_ecs::{World, Entity}; use voltex_ecs::Transform; use voltex_math::{Vec3, Ray}; use crate::collider::Collider; use crate::bvh::BvhTree; use crate::ray as ray_tests; #[derive(Debug, Clone, Copy)] pub struct RayHit { pub entity: Entity, pub t: f32, pub point: Vec3, pub normal: Vec3, } /// Cast a ray into the world. Returns the closest hit within max_dist, or None. pub fn raycast(world: &World, ray: &Ray, max_dist: f32) -> Option { // 1. Gather entities with Transform + Collider let entities: Vec<(Entity, Vec3, Collider)> = world .query2::() .into_iter() .map(|(e, t, c)| (e, t.position, *c)) .collect(); if entities.is_empty() { return None; } // 2. Build BVH let bvh_entries: Vec<(Entity, voltex_math::AABB)> = entities .iter() .map(|(e, pos, col)| (*e, col.aabb(*pos))) .collect(); let bvh = BvhTree::build(&bvh_entries); // 3. Test all leaves (broad + narrow) let mut closest: Option = None; for (entity, pos, collider) in &entities { let aabb = collider.aabb(*pos); // Broad phase: ray vs AABB let aabb_t = match ray_tests::ray_vs_aabb(ray, &aabb) { Some(t) if t <= max_dist => t, _ => continue, }; // Early skip if we already have a closer hit if let Some(ref hit) = closest { if aabb_t >= hit.t { continue; } } // Narrow phase: ray vs collider let result = match collider { Collider::Sphere { radius } => { ray_tests::ray_vs_sphere(ray, *pos, *radius) } Collider::Box { half_extents } => { ray_tests::ray_vs_box(ray, *pos, *half_extents) } }; if let Some((t, normal)) = result { if t <= max_dist { if closest.is_none() || t < closest.as_ref().unwrap().t { closest = Some(RayHit { entity: *entity, t, point: ray.at(t), normal, }); } } } } closest } #[cfg(test)] mod tests { use super::*; use voltex_ecs::World; use voltex_ecs::Transform; use voltex_math::Vec3; use crate::Collider; fn approx(a: f32, b: f32) -> bool { (a - b).abs() < 1e-3 } #[test] fn test_empty_world() { let world = World::new(); let ray = Ray::new(Vec3::ZERO, Vec3::X); assert!(raycast(&world, &ray, 100.0).is_none()); } #[test] fn test_hit_sphere() { let mut world = World::new(); let e = world.spawn(); world.add(e, Transform::from_position(Vec3::new(5.0, 0.0, 0.0))); world.add(e, Collider::Sphere { radius: 1.0 }); let ray = Ray::new(Vec3::ZERO, Vec3::X); let hit = raycast(&world, &ray, 100.0).unwrap(); assert_eq!(hit.entity, e); assert!(approx(hit.t, 4.0)); // 5.0 - 1.0 radius assert!(approx(hit.point.x, 4.0)); } #[test] fn test_closest_of_multiple() { let mut world = World::new(); let far = world.spawn(); world.add(far, Transform::from_position(Vec3::new(10.0, 0.0, 0.0))); world.add(far, Collider::Sphere { radius: 1.0 }); let near = world.spawn(); world.add(near, Transform::from_position(Vec3::new(3.0, 0.0, 0.0))); world.add(near, Collider::Sphere { radius: 1.0 }); let ray = Ray::new(Vec3::ZERO, Vec3::X); let hit = raycast(&world, &ray, 100.0).unwrap(); assert_eq!(hit.entity, near); assert!(approx(hit.t, 2.0)); // 3.0 - 1.0 } #[test] fn test_max_dist() { let mut world = World::new(); let e = world.spawn(); world.add(e, Transform::from_position(Vec3::new(50.0, 0.0, 0.0))); world.add(e, Collider::Sphere { radius: 1.0 }); let ray = Ray::new(Vec3::ZERO, Vec3::X); assert!(raycast(&world, &ray, 10.0).is_none()); // too far } #[test] fn test_hit_box() { let mut world = World::new(); let e = world.spawn(); world.add(e, Transform::from_position(Vec3::new(5.0, 0.0, 0.0))); world.add(e, Collider::Box { half_extents: Vec3::ONE }); let ray = Ray::new(Vec3::ZERO, Vec3::X); let hit = raycast(&world, &ray, 100.0).unwrap(); assert_eq!(hit.entity, e); assert!(approx(hit.t, 4.0)); // 5.0 - 1.0 half_extent } #[test] fn test_mixed_sphere_box() { let mut world = World::new(); let sphere = world.spawn(); world.add(sphere, Transform::from_position(Vec3::new(10.0, 0.0, 0.0))); world.add(sphere, Collider::Sphere { radius: 1.0 }); let box_e = world.spawn(); world.add(box_e, Transform::from_position(Vec3::new(3.0, 0.0, 0.0))); world.add(box_e, Collider::Box { half_extents: Vec3::ONE }); let ray = Ray::new(Vec3::ZERO, Vec3::X); let hit = raycast(&world, &ray, 100.0).unwrap(); assert_eq!(hit.entity, box_e); // box is closer assert!(approx(hit.t, 2.0)); // 3.0 - 1.0 } } ``` - [ ] **Step 2: lib.rs에 raycast 모듈 등록** ```rust pub mod raycast; pub use raycast::{RayHit, raycast}; ``` - [ ] **Step 3: 테스트 실행** Run: `cargo test -p voltex_physics` Expected: 52 PASS (46 + 6) - [ ] **Step 4: 전체 workspace 테스트** Run: `cargo test --workspace` Expected: all pass - [ ] **Step 5: 커밋** ```bash git add crates/voltex_physics/src/raycast.rs crates/voltex_physics/src/lib.rs git commit -m "feat(physics): add BVH-accelerated raycast with ECS integration" ``` --- ## Task 4: 문서 업데이트 **Files:** - Modify: `docs/STATUS.md` - Modify: `docs/DEFERRED.md` - [ ] **Step 1: STATUS.md에 Phase 5-3 추가** Phase 5-2 아래에: ```markdown ### Phase 5-3: Raycasting - voltex_math: Ray type (origin, direction, at) - voltex_physics: ray_vs_aabb, ray_vs_sphere, ray_vs_box - voltex_physics: raycast(world, ray, max_dist) BVH-accelerated ECS integration ``` 테스트 수 업데이트. 다음을 Phase 6 (오디오)로 변경. - [ ] **Step 2: DEFERRED.md에 Phase 5-3 미뤄진 항목 추가** ```markdown ## Phase 5-3 - **Ray vs Plane, Triangle, Mesh** — 콜라이더 기반만 지원. 메시 레벨 레이캐스트 미구현. - **raycast_all (다중 hit)** — 가장 가까운 hit만 반환. - **BVH 조기 종료 최적화** — 모든 리프 검사 후 최소 t 선택. front-to-back 순회 미구현. ``` - [ ] **Step 3: 커밋** ```bash git add docs/STATUS.md docs/DEFERRED.md git commit -m "docs: add Phase 5-3 raycasting status and deferred items" ```