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

17 KiB

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 작성

// 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 모듈 등록
pub mod ray;
pub use ray::Ray;
  • Step 3: 테스트 실행

Run: cargo test -p voltex_math Expected: 37 PASS (기존 35 + 2)

  • Step 4: 커밋
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 작성

// 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<f32> {
    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.

pub mod ray;
  • Step 3: 테스트 실행

Run: cargo test -p voltex_physics Expected: 46 PASS (기존 36 + 10)

  • Step 4: 커밋
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 작성

// 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<RayHit> {
    // 1. Gather entities with Transform + Collider
    let entities: Vec<(Entity, Vec3, Collider)> = world
        .query2::<Transform, Collider>()
        .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<RayHit> = 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 모듈 등록
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: 커밋
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 아래에:

### 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 미뤄진 항목 추가
## Phase 5-3

- **Ray vs Plane, Triangle, Mesh** — 콜라이더 기반만 지원. 메시 레벨 레이캐스트 미구현.
- **raycast_all (다중 hit)** — 가장 가까운 hit만 반환.
- **BVH 조기 종료 최적화** — 모든 리프 검사 후 최소 t 선택. front-to-back 순회 미구현.
  • Step 3: 커밋
git add docs/STATUS.md docs/DEFERRED.md
git commit -m "docs: add Phase 5-3 raycasting status and deferred items"