diff --git a/Cargo.lock b/Cargo.lock index 828ebbd..4031fe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,6 +180,13 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "audio_demo" +version = "0.1.0" +dependencies = [ + "voltex_audio", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -2026,6 +2033,13 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "voltex_asset" version = "0.1.0" +[[package]] +name = "voltex_audio" +version = "0.1.0" +dependencies = [ + "voltex_math", +] + [[package]] name = "voltex_ecs" version = "0.1.0" @@ -2037,6 +2051,14 @@ dependencies = [ name = "voltex_math" version = "0.1.0" +[[package]] +name = "voltex_physics" +version = "0.1.0" +dependencies = [ + "voltex_ecs", + "voltex_math", +] + [[package]] name = "voltex_platform" version = "0.1.0" diff --git a/docs/superpowers/plans/2026-03-25-phase5-1-collision-detection.md b/docs/superpowers/plans/2026-03-25-phase5-1-collision-detection.md new file mode 100644 index 0000000..a080d2d --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase5-1-collision-detection.md @@ -0,0 +1,1155 @@ +# 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, +} + +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 { + // 1. Gather entities with Transform + Collider + let pairs_data: Vec<(Entity, Vec3, Collider)> = world + .query2::() + .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" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase5-2-rigidbody.md b/docs/superpowers/plans/2026-03-25-phase5-2-rigidbody.md new file mode 100644 index 0000000..a879510 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase5-2-rigidbody.md @@ -0,0 +1,614 @@ +# Phase 5-2: Rigid Body Simulation 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:** 리지드바디 시뮬레이션 — 물체가 중력으로 떨어지고, 충돌 시 임펄스로 튕기는 기본 물리 + +**Architecture:** `voltex_physics`에 RigidBody 컴포넌트, Semi-implicit Euler 적분, 임펄스 기반 충돌 응답 추가. ECS의 collect-compute-apply 패턴으로 borrow 문제 회피. `physics_step()`이 적분 → 충돌 감지 → 충돌 응답을 순차 실행. + +**Tech Stack:** Rust, voltex_math (Vec3), voltex_ecs (World, Entity, Transform, get_mut) + +**Spec:** `docs/superpowers/specs/2026-03-25-phase5-2-rigidbody.md` + +--- + +## File Structure + +### voltex_physics (수정/추가) +- `crates/voltex_physics/src/rigid_body.rs` — RigidBody 컴포넌트 + PhysicsConfig (Create) +- `crates/voltex_physics/src/integrator.rs` — integrate 함수 (Create) +- `crates/voltex_physics/src/solver.rs` — resolve_collisions + physics_step (Create) +- `crates/voltex_physics/src/lib.rs` — 새 모듈 등록 (Modify) + +--- + +## Task 1: RigidBody 컴포넌트 + PhysicsConfig + +**Files:** +- Create: `crates/voltex_physics/src/rigid_body.rs` +- Modify: `crates/voltex_physics/src/lib.rs` + +- [ ] **Step 1: rigid_body.rs 작성** + +```rust +// crates/voltex_physics/src/rigid_body.rs +use voltex_math::Vec3; + +#[derive(Debug, Clone, Copy)] +pub struct RigidBody { + pub velocity: Vec3, + pub angular_velocity: Vec3, + pub mass: f32, + pub restitution: f32, + pub gravity_scale: f32, +} + +impl RigidBody { + /// Create a dynamic rigid body with given mass. + pub fn dynamic(mass: f32) -> Self { + Self { + velocity: Vec3::ZERO, + angular_velocity: Vec3::ZERO, + mass, + restitution: 0.3, + gravity_scale: 1.0, + } + } + + /// Create a static rigid body (infinite mass, immovable). + pub fn statik() -> Self { + Self { + velocity: Vec3::ZERO, + angular_velocity: Vec3::ZERO, + mass: 0.0, + restitution: 0.3, + gravity_scale: 0.0, + } + } + + pub fn inv_mass(&self) -> f32 { + if self.mass == 0.0 { 0.0 } else { 1.0 / self.mass } + } + + pub fn is_static(&self) -> bool { + self.mass == 0.0 + } +} + +pub struct PhysicsConfig { + pub gravity: Vec3, + pub fixed_dt: f32, +} + +impl Default for PhysicsConfig { + fn default() -> Self { + Self { + gravity: Vec3::new(0.0, -9.81, 0.0), + fixed_dt: 1.0 / 60.0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dynamic_body() { + let rb = RigidBody::dynamic(2.0); + assert_eq!(rb.mass, 2.0); + assert!(!rb.is_static()); + assert!((rb.inv_mass() - 0.5).abs() < 1e-6); + assert_eq!(rb.velocity, Vec3::ZERO); + assert_eq!(rb.restitution, 0.3); + assert_eq!(rb.gravity_scale, 1.0); + } + + #[test] + fn test_static_body() { + let rb = RigidBody::statik(); + assert_eq!(rb.mass, 0.0); + assert!(rb.is_static()); + assert_eq!(rb.inv_mass(), 0.0); + assert_eq!(rb.gravity_scale, 0.0); + } + + #[test] + fn test_physics_config_default() { + let cfg = PhysicsConfig::default(); + assert!((cfg.gravity.y - (-9.81)).abs() < 1e-6); + assert!((cfg.fixed_dt - 1.0 / 60.0).abs() < 1e-6); + } +} +``` + +- [ ] **Step 2: lib.rs에 모듈 등록** + +`crates/voltex_physics/src/lib.rs`에 추가: +```rust +pub mod rigid_body; +pub use rigid_body::{RigidBody, PhysicsConfig}; +``` + +- [ ] **Step 3: 테스트 실행** + +Run: `cargo test -p voltex_physics` +Expected: 기존 25 + 3 = 28개 PASS + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_physics/src/rigid_body.rs crates/voltex_physics/src/lib.rs +git commit -m "feat(physics): add RigidBody component and PhysicsConfig" +``` + +--- + +## Task 2: Semi-implicit Euler 적분 + +**Files:** +- Create: `crates/voltex_physics/src/integrator.rs` +- Modify: `crates/voltex_physics/src/lib.rs` + +- [ ] **Step 1: integrator.rs 작성** + +NOTE: ECS borrow 제약으로 collect-compute-apply 패턴 사용. +`world.query2()` returns `Vec<(Entity, &A, &B)>` (immutable refs). +변경사항은 별도로 수집 후 `world.get_mut()`으로 적용. + +```rust +// crates/voltex_physics/src/integrator.rs +use voltex_ecs::World; +use voltex_ecs::Transform; +use voltex_math::Vec3; +use crate::rigid_body::{RigidBody, PhysicsConfig}; + +/// Apply gravity and integrate velocity/position using Semi-implicit Euler. +/// Only affects dynamic bodies (mass > 0). +pub fn integrate(world: &mut World, config: &PhysicsConfig) { + // 1. Collect: read entities with both Transform and RigidBody + let updates: Vec<(voltex_ecs::Entity, Vec3, Vec3)> = world + .query2::() + .into_iter() + .filter(|(_, _, rb)| !rb.is_static()) + .map(|(entity, transform, rb)| { + // Semi-implicit Euler: + // v' = v + gravity * gravity_scale * dt + // x' = x + v' * dt + let new_velocity = rb.velocity + config.gravity * rb.gravity_scale * config.fixed_dt; + let new_position = transform.position + new_velocity * config.fixed_dt; + (entity, new_velocity, new_position) + }) + .collect(); + + // 2. Apply: write back + for (entity, new_velocity, new_position) in updates { + if let Some(rb) = world.get_mut::(entity) { + rb.velocity = new_velocity; + } + if let Some(t) = world.get_mut::(entity) { + t.position = new_position; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use voltex_ecs::World; + use voltex_ecs::Transform; + use voltex_math::Vec3; + use crate::RigidBody; + + fn approx(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-4 + } + + #[test] + fn test_gravity_fall() { + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Transform::from_position(Vec3::new(0.0, 10.0, 0.0))); + world.add(e, RigidBody::dynamic(1.0)); + + let config = PhysicsConfig::default(); + integrate(&mut world, &config); + + let rb = world.get::(e).unwrap(); + let t = world.get::(e).unwrap(); + + // After 1 step: v = 0 + (-9.81) * (1/60) = -0.1635 + let expected_vy = -9.81 * config.fixed_dt; + assert!(approx(rb.velocity.y, expected_vy)); + + // Position: 10 + (-0.1635) * (1/60) = 10 - 0.002725 + let expected_py = 10.0 + expected_vy * config.fixed_dt; + assert!(approx(t.position.y, expected_py)); + } + + #[test] + fn test_static_unchanged() { + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Transform::from_position(Vec3::new(0.0, 5.0, 0.0))); + world.add(e, RigidBody::statik()); + + let config = PhysicsConfig::default(); + integrate(&mut world, &config); + + let t = world.get::(e).unwrap(); + assert!(approx(t.position.y, 5.0)); + + let rb = world.get::(e).unwrap(); + assert!(approx(rb.velocity.y, 0.0)); + } + + #[test] + fn test_initial_velocity() { + let mut world = World::new(); + let e = world.spawn(); + world.add(e, Transform::from_position(Vec3::ZERO)); + let mut rb = RigidBody::dynamic(1.0); + rb.velocity = Vec3::new(5.0, 0.0, 0.0); + rb.gravity_scale = 0.0; // no gravity for this test + world.add(e, rb); + + let config = PhysicsConfig::default(); + integrate(&mut world, &config); + + let t = world.get::(e).unwrap(); + let expected_x = 5.0 * config.fixed_dt; + assert!(approx(t.position.x, expected_x)); + } +} +``` + +- [ ] **Step 2: lib.rs에 모듈 등록** + +```rust +pub mod integrator; +pub use integrator::integrate; +``` + +- [ ] **Step 3: 테스트 실행** + +Run: `cargo test -p voltex_physics` +Expected: 31개 PASS (28 + 3) + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_physics/src/integrator.rs crates/voltex_physics/src/lib.rs +git commit -m "feat(physics): add Semi-implicit Euler integration" +``` + +--- + +## Task 3: 임펄스 기반 충돌 응답 + physics_step + +**Files:** +- Create: `crates/voltex_physics/src/solver.rs` +- Modify: `crates/voltex_physics/src/lib.rs` + +- [ ] **Step 1: solver.rs 작성** + +```rust +// crates/voltex_physics/src/solver.rs +use voltex_ecs::{World, Entity}; +use voltex_ecs::Transform; +use voltex_math::Vec3; + +use crate::contact::ContactPoint; +use crate::rigid_body::{RigidBody, PhysicsConfig}; +use crate::collision::detect_collisions; +use crate::integrator::integrate; + +const POSITION_SLOP: f32 = 0.01; +const POSITION_PERCENT: f32 = 0.4; + +/// Resolve collisions using impulse-based response + positional correction. +pub fn resolve_collisions(world: &mut World, contacts: &[ContactPoint]) { + // Collect impulse + position corrections + let mut velocity_changes: Vec<(Entity, Vec3)> = Vec::new(); + let mut position_changes: Vec<(Entity, Vec3)> = Vec::new(); + + for contact in contacts { + let rb_a = world.get::(contact.entity_a).copied(); + let rb_b = world.get::(contact.entity_b).copied(); + + let (rb_a, rb_b) = match (rb_a, rb_b) { + (Some(a), Some(b)) => (a, b), + _ => continue, + }; + + let inv_mass_a = rb_a.inv_mass(); + let inv_mass_b = rb_b.inv_mass(); + let inv_mass_sum = inv_mass_a + inv_mass_b; + + // Both static — skip + if inv_mass_sum == 0.0 { + continue; + } + + // Relative velocity (A relative to B) + let v_rel = rb_a.velocity - rb_b.velocity; + let v_rel_n = v_rel.dot(contact.normal); + + // Already separating — skip impulse + if v_rel_n > 0.0 { + // Still apply position correction if penetrating + } else { + // Impulse + let e = rb_a.restitution.min(rb_b.restitution); + let j = -(1.0 + e) * v_rel_n / inv_mass_sum; + + velocity_changes.push((contact.entity_a, contact.normal * (j * inv_mass_a))); + velocity_changes.push((contact.entity_b, contact.normal * (-j * inv_mass_b))); + } + + // Positional correction (Baumgarte) + let correction_mag = (contact.depth - POSITION_SLOP).max(0.0) * POSITION_PERCENT / inv_mass_sum; + if correction_mag > 0.0 { + let correction = contact.normal * correction_mag; + position_changes.push((contact.entity_a, correction * (-inv_mass_a))); + position_changes.push((contact.entity_b, correction * inv_mass_b)); + } + } + + // Apply velocity changes + for (entity, dv) in velocity_changes { + if let Some(rb) = world.get_mut::(entity) { + rb.velocity = rb.velocity + dv; + } + } + + // Apply position corrections + for (entity, dp) in position_changes { + if let Some(t) = world.get_mut::(entity) { + t.position = t.position + dp; + } + } +} + +/// Run one physics simulation step: integrate → detect → resolve. +pub fn physics_step(world: &mut World, config: &PhysicsConfig) { + integrate(world, config); + let contacts = detect_collisions(world); + resolve_collisions(world, &contacts); +} + +#[cfg(test)] +mod tests { + use super::*; + use voltex_ecs::World; + use voltex_ecs::Transform; + use voltex_math::Vec3; + use crate::{Collider, RigidBody}; + use crate::contact::ContactPoint; + use crate::collision::detect_collisions; + + fn approx(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 + } + + #[test] + fn test_two_dynamic_spheres_head_on() { + let mut world = World::new(); + + let a = world.spawn(); + world.add(a, Transform::from_position(Vec3::new(-0.5, 0.0, 0.0))); + world.add(a, Collider::Sphere { radius: 1.0 }); + let mut rb_a = RigidBody::dynamic(1.0); + rb_a.velocity = Vec3::new(1.0, 0.0, 0.0); + rb_a.restitution = 1.0; // perfect elastic + rb_a.gravity_scale = 0.0; + world.add(a, rb_a); + + let b = world.spawn(); + world.add(b, Transform::from_position(Vec3::new(0.5, 0.0, 0.0))); + world.add(b, Collider::Sphere { radius: 1.0 }); + let mut rb_b = RigidBody::dynamic(1.0); + rb_b.velocity = Vec3::new(-1.0, 0.0, 0.0); + rb_b.restitution = 1.0; + rb_b.gravity_scale = 0.0; + world.add(b, rb_b); + + let contacts = detect_collisions(&world); + assert_eq!(contacts.len(), 1); + + resolve_collisions(&mut world, &contacts); + + let va = world.get::(a).unwrap().velocity; + let vb = world.get::(b).unwrap().velocity; + + // Equal mass, perfect elastic: velocities swap + assert!(approx(va.x, -1.0)); + assert!(approx(vb.x, 1.0)); + } + + #[test] + fn test_dynamic_vs_static_floor() { + let mut world = World::new(); + + // Dynamic sphere above floor + let ball = world.spawn(); + world.add(ball, Transform::from_position(Vec3::new(0.0, 0.5, 0.0))); + world.add(ball, Collider::Sphere { radius: 1.0 }); + let mut rb = RigidBody::dynamic(1.0); + rb.velocity = Vec3::new(0.0, -2.0, 0.0); + rb.restitution = 1.0; + rb.gravity_scale = 0.0; + world.add(ball, rb); + + // Static floor + let floor = world.spawn(); + world.add(floor, Transform::from_position(Vec3::new(0.0, -1.0, 0.0))); + world.add(floor, Collider::Box { half_extents: Vec3::new(10.0, 1.0, 10.0) }); + world.add(floor, RigidBody::statik()); + + let contacts = detect_collisions(&world); + assert_eq!(contacts.len(), 1); + + resolve_collisions(&mut world, &contacts); + + let ball_rb = world.get::(ball).unwrap(); + let floor_rb = world.get::(floor).unwrap(); + + // Ball should bounce up + assert!(ball_rb.velocity.y > 0.0); + // Floor should not move + assert!(approx(floor_rb.velocity.y, 0.0)); + } + + #[test] + fn test_position_correction() { + let mut world = World::new(); + + // Two overlapping spheres + let a = world.spawn(); + world.add(a, Transform::from_position(Vec3::ZERO)); + world.add(a, Collider::Sphere { radius: 1.0 }); + let mut rb_a = RigidBody::dynamic(1.0); + rb_a.gravity_scale = 0.0; + world.add(a, rb_a); + + let b = world.spawn(); + world.add(b, Transform::from_position(Vec3::new(1.0, 0.0, 0.0))); + world.add(b, Collider::Sphere { radius: 1.0 }); + let mut rb_b = RigidBody::dynamic(1.0); + rb_b.gravity_scale = 0.0; + world.add(b, rb_b); + + let contacts = detect_collisions(&world); + assert_eq!(contacts.len(), 1); + assert!(contacts[0].depth > POSITION_SLOP); + + resolve_collisions(&mut world, &contacts); + + let pa = world.get::(a).unwrap().position; + let pb = world.get::(b).unwrap().position; + + // Bodies should have moved apart + let dist = (pb - pa).length(); + assert!(dist > 1.0); // was 1.0, should be slightly more now + } + + #[test] + fn test_physics_step_ball_drop() { + let mut world = World::new(); + + // Ball high up + let ball = world.spawn(); + world.add(ball, Transform::from_position(Vec3::new(0.0, 5.0, 0.0))); + world.add(ball, Collider::Sphere { radius: 0.5 }); + world.add(ball, RigidBody::dynamic(1.0)); + + // Static floor + let floor = world.spawn(); + world.add(floor, Transform::from_position(Vec3::new(0.0, -1.0, 0.0))); + world.add(floor, Collider::Box { half_extents: Vec3::new(10.0, 1.0, 10.0) }); + world.add(floor, RigidBody::statik()); + + let config = PhysicsConfig::default(); + + // Run several steps — ball should fall + for _ in 0..10 { + physics_step(&mut world, &config); + } + + let t = world.get::(ball).unwrap(); + // Ball should have moved down from y=5 + assert!(t.position.y < 5.0); + // But should still be above the floor + assert!(t.position.y > -1.0); + } + + #[test] + fn test_both_static_no_response() { + 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 }); + world.add(a, RigidBody::statik()); + + let b = world.spawn(); + world.add(b, Transform::from_position(Vec3::new(0.5, 0.0, 0.0))); + world.add(b, Collider::Sphere { radius: 1.0 }); + world.add(b, RigidBody::statik()); + + let contacts = detect_collisions(&world); + resolve_collisions(&mut world, &contacts); + + // Both should remain at their positions + let pa = world.get::(a).unwrap().position; + let pb = world.get::(b).unwrap().position; + assert!(approx(pa.x, 0.0)); + assert!(approx(pb.x, 0.5)); + } +} +``` + +- [ ] **Step 2: lib.rs에 모듈 등록** + +```rust +pub mod solver; +pub use solver::{resolve_collisions, physics_step}; +``` + +- [ ] **Step 3: 테스트 실행** + +Run: `cargo test -p voltex_physics` +Expected: 36개 PASS (28 + 3 + 5) + +- [ ] **Step 4: 전체 workspace 테스트** + +Run: `cargo test --workspace` +Expected: 기존 136 + 11 = 147개 전부 PASS + +- [ ] **Step 5: 커밋** + +```bash +git add crates/voltex_physics/src/solver.rs crates/voltex_physics/src/lib.rs +git commit -m "feat(physics): add impulse collision response and physics_step" +``` + +--- + +## Task 4: 문서 업데이트 + +**Files:** +- Modify: `docs/STATUS.md` +- Modify: `docs/DEFERRED.md` + +- [ ] **Step 1: STATUS.md에 Phase 5-2 추가** + +Phase 5-1 아래에 추가: +```markdown +### Phase 5-2: Rigid Body Simulation +- voltex_physics: RigidBody (mass, velocity, restitution), PhysicsConfig +- voltex_physics: Semi-implicit Euler integration +- voltex_physics: Impulse-based collision response + positional correction (Baumgarte) +- voltex_physics: physics_step (integrate → detect → resolve) +``` + +테스트 수 업데이트 (voltex_physics: 36). +다음 항목을 Phase 5-3 (레이캐스팅)으로 변경. + +- [ ] **Step 2: DEFERRED.md에 Phase 5-2 미뤄진 항목 추가** + +```markdown +## Phase 5-2 + +- **각속도/회전 물리** — angular_velocity 필드만 존재, 적분 미구현. 관성 텐서 필요. +- **마찰 (Coulomb)** — 미구현. 물체가 미끄러짐 없이 반발만. +- **Sequential Impulse 솔버** — 단일 반복 충돌 응답만. 다중 물체 쌓기 불안정. +- **Sleep/Island 시스템** — 정지 물체 최적화 미구현. +``` + +- [ ] **Step 3: 커밋** + +```bash +git add docs/STATUS.md docs/DEFERRED.md +git commit -m "docs: add Phase 5-2 rigid body simulation status and deferred items" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase5-3-raycasting.md b/docs/superpowers/plans/2026-03-25-phase5-3-raycasting.md new file mode 100644 index 0000000..e6ab587 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase5-3-raycasting.md @@ -0,0 +1,607 @@ +# 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" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase6-1-audio.md b/docs/superpowers/plans/2026-03-25-phase6-1-audio.md new file mode 100644 index 0000000..2488392 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase6-1-audio.md @@ -0,0 +1,1191 @@ +# Phase 6-1: Audio System Foundation 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:** WAV 파일을 로드하고 WASAPI를 통해 소리를 재생하는 기본 오디오 시스템 + +**Architecture:** `voltex_audio` crate 신규 생성. WAV 파서와 믹싱 로직은 순수 함수로 테스트 가능하게 구현. WASAPI FFI는 별도 모듈. mpsc channel로 메인↔오디오 스레드 통신. AudioSystem이 public API 제공. + +**Tech Stack:** Rust, Windows WASAPI (COM FFI), std::sync::mpsc, std::thread + +**Spec:** `docs/superpowers/specs/2026-03-25-phase6-1-audio.md` + +--- + +## File Structure + +### voltex_audio (신규) +- `crates/voltex_audio/Cargo.toml` — crate 설정 (Create) +- `crates/voltex_audio/src/lib.rs` — public exports (Create) +- `crates/voltex_audio/src/audio_clip.rs` — AudioClip 타입 (Create) +- `crates/voltex_audio/src/wav.rs` — WAV 파서 (Create) +- `crates/voltex_audio/src/mixing.rs` — 믹싱 순수 함수 (Create) +- `crates/voltex_audio/src/wasapi.rs` — WASAPI FFI 바인딩 (Create) +- `crates/voltex_audio/src/audio_system.rs` — AudioSystem API + 오디오 스레드 (Create) + +### Workspace (수정) +- `Cargo.toml` — workspace members + dependencies (Modify) + +### Example (신규) +- `examples/audio_demo/Cargo.toml` (Create) +- `examples/audio_demo/src/main.rs` (Create) + +--- + +## Task 1: Crate 설정 + AudioClip 타입 + +**Files:** +- Create: `crates/voltex_audio/Cargo.toml` +- Create: `crates/voltex_audio/src/lib.rs` +- Create: `crates/voltex_audio/src/audio_clip.rs` +- Modify: `Cargo.toml` (workspace) + +- [ ] **Step 1: Cargo.toml 생성** + +```toml +# crates/voltex_audio/Cargo.toml +[package] +name = "voltex_audio" +version = "0.1.0" +edition = "2021" + +[dependencies] +``` + +- [ ] **Step 2: workspace에 추가** + +`Cargo.toml` workspace members에 `"crates/voltex_audio"` 추가. +workspace.dependencies에 `voltex_audio = { path = "crates/voltex_audio" }` 추가. + +- [ ] **Step 3: audio_clip.rs 작성** + +```rust +// crates/voltex_audio/src/audio_clip.rs + +/// Decoded audio data with interleaved f32 samples normalized to -1.0..1.0. +#[derive(Clone)] +pub struct AudioClip { + pub samples: Vec, + pub sample_rate: u32, + pub channels: u16, +} + +impl AudioClip { + pub fn new(samples: Vec, sample_rate: u32, channels: u16) -> Self { + Self { samples, sample_rate, channels } + } + + /// Number of sample frames (total samples / channels). + pub fn frame_count(&self) -> usize { + if self.channels == 0 { 0 } else { self.samples.len() / self.channels as usize } + } + + /// Duration in seconds. + pub fn duration(&self) -> f32 { + if self.sample_rate == 0 { 0.0 } else { self.frame_count() as f32 / self.sample_rate as f32 } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mono_clip() { + let clip = AudioClip::new(vec![0.0; 44100], 44100, 1); + assert_eq!(clip.frame_count(), 44100); + assert!((clip.duration() - 1.0).abs() < 1e-5); + } + + #[test] + fn test_stereo_clip() { + let clip = AudioClip::new(vec![0.0; 88200], 44100, 2); + assert_eq!(clip.frame_count(), 44100); + assert!((clip.duration() - 1.0).abs() < 1e-5); + } +} +``` + +- [ ] **Step 4: lib.rs 작성** + +```rust +// crates/voltex_audio/src/lib.rs +pub mod audio_clip; +pub use audio_clip::AudioClip; +``` + +- [ ] **Step 5: 테스트 실행** + +Run: `cargo test -p voltex_audio` +Expected: 2 PASS + +- [ ] **Step 6: 커밋** + +```bash +git add crates/voltex_audio/ Cargo.toml +git commit -m "feat(audio): add voltex_audio crate with AudioClip type" +``` + +--- + +## Task 2: WAV 파서 + +**Files:** +- Create: `crates/voltex_audio/src/wav.rs` +- Modify: `crates/voltex_audio/src/lib.rs` + +- [ ] **Step 1: wav.rs 작성** + +```rust +// crates/voltex_audio/src/wav.rs +use crate::audio_clip::AudioClip; + +/// Parse a WAV file from raw bytes. Supports PCM 16-bit mono/stereo only. +pub fn parse_wav(data: &[u8]) -> Result { + if data.len() < 44 { + return Err("WAV too short".into()); + } + + // RIFF header + if &data[0..4] != b"RIFF" { + return Err("Missing RIFF header".into()); + } + if &data[8..12] != b"WAVE" { + return Err("Missing WAVE identifier".into()); + } + + // Find fmt chunk + let (fmt_offset, _fmt_size) = find_chunk(data, b"fmt ") + .ok_or("Missing fmt chunk")?; + + let format_tag = read_u16_le(data, fmt_offset); + if format_tag != 1 { + return Err(format!("Unsupported format tag: {} (only PCM=1)", format_tag)); + } + + let channels = read_u16_le(data, fmt_offset + 2); + if channels != 1 && channels != 2 { + return Err(format!("Unsupported channel count: {}", channels)); + } + + let sample_rate = read_u32_le(data, fmt_offset + 4); + let bits_per_sample = read_u16_le(data, fmt_offset + 14); + if bits_per_sample != 16 { + return Err(format!("Unsupported bits per sample: {} (only 16)", bits_per_sample)); + } + + // Find data chunk + let (data_offset, data_size) = find_chunk(data, b"data") + .ok_or("Missing data chunk")?; + + let num_samples = data_size / 2; // 16-bit = 2 bytes per sample + let mut samples = Vec::with_capacity(num_samples); + + for i in 0..num_samples { + let offset = data_offset + i * 2; + if offset + 2 > data.len() { + break; + } + let raw = read_i16_le(data, offset); + samples.push(raw as f32 / 32768.0); + } + + Ok(AudioClip::new(samples, sample_rate, channels)) +} + +fn find_chunk(data: &[u8], id: &[u8; 4]) -> Option<(usize, usize)> { + let mut offset = 12; // skip RIFF header + while offset + 8 <= data.len() { + let chunk_id = &data[offset..offset + 4]; + let chunk_size = read_u32_le(data, offset + 4) as usize; + if chunk_id == id { + return Some((offset + 8, chunk_size)); + } + offset += 8 + chunk_size; + // Chunks are 2-byte aligned + if chunk_size % 2 != 0 { + offset += 1; + } + } + None +} + +fn read_u16_le(data: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([data[offset], data[offset + 1]]) +} + +fn read_u32_le(data: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]) +} + +fn read_i16_le(data: &[u8], offset: usize) -> i16 { + i16::from_le_bytes([data[offset], data[offset + 1]]) +} + +/// Generate a WAV file in memory (PCM 16-bit mono). Useful for testing. +pub fn generate_wav_bytes(samples_f32: &[f32], sample_rate: u32) -> Vec { + let num_samples = samples_f32.len(); + let data_size = num_samples * 2; // 16-bit + let file_size = 36 + data_size; + + let mut buf = Vec::with_capacity(file_size + 8); + + // RIFF header + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&(file_size as u32).to_le_bytes()); + buf.extend_from_slice(b"WAVE"); + + // fmt chunk + buf.extend_from_slice(b"fmt "); + buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size + buf.extend_from_slice(&1u16.to_le_bytes()); // PCM + buf.extend_from_slice(&1u16.to_le_bytes()); // mono + buf.extend_from_slice(&sample_rate.to_le_bytes()); + buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate + buf.extend_from_slice(&2u16.to_le_bytes()); // block align + buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample + + // data chunk + buf.extend_from_slice(b"data"); + buf.extend_from_slice(&(data_size as u32).to_le_bytes()); + for &s in samples_f32 { + let clamped = s.clamp(-1.0, 1.0); + let i16_val = (clamped * 32767.0) as i16; + buf.extend_from_slice(&i16_val.to_le_bytes()); + } + + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_wav() { + let samples = vec![0.0, 0.5, -0.5, 1.0]; + let wav_data = generate_wav_bytes(&samples, 44100); + let clip = parse_wav(&wav_data).unwrap(); + assert_eq!(clip.sample_rate, 44100); + assert_eq!(clip.channels, 1); + assert_eq!(clip.samples.len(), 4); + } + + #[test] + fn test_sample_conversion_accuracy() { + let samples = vec![1.0, -1.0, 0.0]; + let wav_data = generate_wav_bytes(&samples, 44100); + let clip = parse_wav(&wav_data).unwrap(); + // 32767/32768 ≈ 0.99997 + assert!((clip.samples[0] - 32767.0 / 32768.0).abs() < 1e-3); + assert!((clip.samples[1] - (-1.0)).abs() < 1e-3); + assert!((clip.samples[2]).abs() < 1e-3); + } + + #[test] + fn test_invalid_riff() { + let data = vec![0u8; 44]; + assert!(parse_wav(&data).is_err()); + } + + #[test] + fn test_too_short() { + let data = vec![0u8; 10]; + assert!(parse_wav(&data).is_err()); + } + + #[test] + fn test_roundtrip() { + let original = vec![0.25, -0.25, 0.5, -0.5]; + let wav_data = generate_wav_bytes(&original, 22050); + let clip = parse_wav(&wav_data).unwrap(); + assert_eq!(clip.sample_rate, 22050); + assert_eq!(clip.channels, 1); + assert_eq!(clip.samples.len(), 4); + for (a, b) in original.iter().zip(clip.samples.iter()) { + assert!((a - b).abs() < 0.001); + } + } +} +``` + +- [ ] **Step 2: lib.rs에 wav 모듈 등록** + +```rust +pub mod wav; +pub use wav::{parse_wav, generate_wav_bytes}; +``` + +- [ ] **Step 3: 테스트 실행** + +Run: `cargo test -p voltex_audio` +Expected: 7 PASS (2 clip + 5 wav) + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_audio/src/wav.rs crates/voltex_audio/src/lib.rs +git commit -m "feat(audio): add WAV parser with PCM 16-bit support" +``` + +--- + +## Task 3: 믹싱 순수 함수 + +**Files:** +- Create: `crates/voltex_audio/src/mixing.rs` +- Modify: `crates/voltex_audio/src/lib.rs` + +- [ ] **Step 1: mixing.rs 작성** + +```rust +// crates/voltex_audio/src/mixing.rs +use crate::audio_clip::AudioClip; + +/// A currently playing sound instance. +pub struct PlayingSound { + pub clip_index: usize, + pub position: usize, // sample frame position + pub volume: f32, + pub looping: bool, +} + +/// Mix active sounds into an interleaved f32 output buffer. +/// `output` has `frames * device_channels` elements. +/// Returns indices of sounds that finished (non-looping, reached end). +pub fn mix_sounds( + output: &mut [f32], + playing: &mut Vec, + clips: &[AudioClip], + device_sample_rate: u32, + device_channels: u16, + frames: usize, +) { + // Zero output + for s in output.iter_mut() { + *s = 0.0; + } + + let mut finished = Vec::new(); + + for (idx, sound) in playing.iter_mut().enumerate() { + if sound.clip_index >= clips.len() { + finished.push(idx); + continue; + } + let clip = &clips[sound.clip_index]; + let clip_frames = clip.frame_count(); + if clip_frames == 0 { + finished.push(idx); + continue; + } + + let rate_ratio = clip.sample_rate as f64 / device_sample_rate as f64; + + for frame in 0..frames { + let clip_frame_f = sound.position as f64 + frame as f64 * rate_ratio; + let clip_frame = clip_frame_f as usize; + + if clip_frame >= clip_frames { + if sound.looping { + sound.position = 0; + // Continue from beginning for remaining frames + // (simplified: just stop for this buffer, will resume next call) + break; + } else { + finished.push(idx); + break; + } + } + + let out_offset = frame * device_channels as usize; + + for ch in 0..device_channels as usize { + let clip_ch = if ch < clip.channels as usize { ch } else { 0 }; + let sample_idx = clip_frame * clip.channels as usize + clip_ch; + if sample_idx < clip.samples.len() { + let sample = clip.samples[sample_idx] * sound.volume; + if out_offset + ch < output.len() { + output[out_offset + ch] += sample; + } + } + } + } + + // Advance position + let advanced = (frames as f64 * rate_ratio) as usize; + sound.position += advanced; + + // Handle looping wrap + if sound.position >= clip_frames && sound.looping { + sound.position %= clip_frames; + } + } + + // Remove finished sounds (reverse order to preserve indices) + finished.sort_unstable(); + finished.dedup(); + for &idx in finished.iter().rev() { + if idx < playing.len() { + playing.remove(idx); + } + } + + // Clamp output + for s in output.iter_mut() { + *s = s.clamp(-1.0, 1.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AudioClip; + + #[test] + fn test_single_sound_volume() { + let clip = AudioClip::new(vec![0.5, 0.5, 0.5, 0.5], 44100, 1); + let mut playing = vec![PlayingSound { + clip_index: 0, position: 0, volume: 0.5, looping: false, + }]; + let mut output = vec![0.0; 4]; + mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 4); + for s in &output { + assert!((*s - 0.25).abs() < 1e-5); // 0.5 * 0.5 + } + } + + #[test] + fn test_two_sounds_sum() { + let clip = AudioClip::new(vec![0.4; 4], 44100, 1); + let mut playing = vec![ + PlayingSound { clip_index: 0, position: 0, volume: 1.0, looping: false }, + PlayingSound { clip_index: 0, position: 0, volume: 1.0, looping: false }, + ]; + let mut output = vec![0.0; 4]; + mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 4); + for s in &output { + assert!((*s - 0.8).abs() < 1e-5); // 0.4 + 0.4 + } + } + + #[test] + fn test_clipping() { + let clip = AudioClip::new(vec![0.9; 4], 44100, 1); + let mut playing = vec![ + PlayingSound { clip_index: 0, position: 0, volume: 1.0, looping: false }, + PlayingSound { clip_index: 0, position: 0, volume: 1.0, looping: false }, + ]; + let mut output = vec![0.0; 4]; + mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 4); + for s in &output { + assert!(*s <= 1.0); // clamped + } + } + + #[test] + fn test_non_looping_removal() { + let clip = AudioClip::new(vec![0.5, 0.5], 44100, 1); + let mut playing = vec![PlayingSound { + clip_index: 0, position: 0, volume: 1.0, looping: false, + }]; + let mut output = vec![0.0; 10]; // more frames than clip has + mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 10); + assert!(playing.is_empty()); // sound was removed + } + + #[test] + fn test_looping_continues() { + let clip = AudioClip::new(vec![0.5, 0.5], 44100, 1); + let mut playing = vec![PlayingSound { + clip_index: 0, position: 0, volume: 1.0, looping: true, + }]; + let mut output = vec![0.0; 10]; + mix_sounds(&mut output, &mut playing, &[clip], 44100, 1, 10); + assert_eq!(playing.len(), 1); // still playing + } + + #[test] + fn test_mono_to_stereo() { + let clip = AudioClip::new(vec![0.7; 4], 44100, 1); + let mut playing = vec![PlayingSound { + clip_index: 0, position: 0, volume: 1.0, looping: false, + }]; + let mut output = vec![0.0; 8]; // 4 frames * 2 channels + mix_sounds(&mut output, &mut playing, &[clip], 44100, 2, 4); + // Both channels should have the mono sample + for frame in 0..4 { + assert!((output[frame * 2] - 0.7).abs() < 1e-5); + assert!((output[frame * 2 + 1] - 0.7).abs() < 1e-5); + } + } +} +``` + +- [ ] **Step 2: lib.rs에 mixing 모듈 등록** + +```rust +pub mod mixing; +pub use mixing::{PlayingSound, mix_sounds}; +``` + +- [ ] **Step 3: 테스트 실행** + +Run: `cargo test -p voltex_audio` +Expected: 13 PASS (2 clip + 5 wav + 6 mixing) + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_audio/src/mixing.rs crates/voltex_audio/src/lib.rs +git commit -m "feat(audio): add mixing functions with volume, looping, and channel conversion" +``` + +--- + +## Task 4: WASAPI FFI 바인딩 + +**Files:** +- Create: `crates/voltex_audio/src/wasapi.rs` +- Modify: `crates/voltex_audio/src/lib.rs` + +NOTE: 이 태스크는 Windows FFI 코드로, 단위 테스트 불가. 컴파일 확인만. + +- [ ] **Step 1: wasapi.rs 작성** + +```rust +// crates/voltex_audio/src/wasapi.rs +//! WASAPI FFI bindings for Windows audio output. +//! This module is only compiled on Windows. +#![allow(non_snake_case, non_camel_case_types, dead_code)] + +use std::ffi::c_void; +use std::ptr; + +// --- COM types --- +type HRESULT = i32; +type UINT = u32; +type DWORD = u32; +type WORD = u16; +type BOOL = i32; +type HANDLE = *mut c_void; +type REFERENCE_TIME = i64; + +const S_OK: HRESULT = 0; +const COINIT_MULTITHREADED: DWORD = 0x0; +const CLSCTX_ALL: DWORD = 0x17; +const AUDCLNT_SHAREMODE_SHARED: u32 = 0; +const AUDCLNT_STREAMFLAGS_EVENTCALLBACK: DWORD = 0x00040000; + +#[repr(C)] +struct GUID { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], +} + +// CLSIDs and IIDs +const CLSID_MMDEVICE_ENUMERATOR: GUID = GUID { + data1: 0xBCDE0395, data2: 0xE52F, data3: 0x467C, + data4: [0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E], +}; + +const IID_IMMDEVICE_ENUMERATOR: GUID = GUID { + data1: 0xA95664D2, data2: 0x9614, data3: 0x4F35, + data4: [0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6], +}; + +const IID_IAUDIO_CLIENT: GUID = GUID { + data1: 0x1CB9AD4C, data2: 0xDBFA, data3: 0x4c32, + data4: [0xB1, 0x78, 0xC2, 0xF5, 0x68, 0xA7, 0x03, 0xB2], +}; + +const IID_IAUDIO_RENDER_CLIENT: GUID = GUID { + data1: 0xF294ACFC, data2: 0x3146, data3: 0x4483, + data4: [0xA7, 0xBF, 0xAD, 0xDC, 0xA7, 0xC2, 0x60, 0xE2], +}; + +// eRender = 0, eConsole = 0 +const E_RENDER: u32 = 0; +const E_CONSOLE: u32 = 0; + +#[repr(C)] +pub struct WAVEFORMATEX { + pub wFormatTag: WORD, + pub nChannels: WORD, + pub nSamplesPerSec: DWORD, + pub nAvgBytesPerSec: DWORD, + pub nBlockAlign: WORD, + pub wBitsPerSample: WORD, + pub cbSize: WORD, +} + +const WAVE_FORMAT_IEEE_FLOAT: WORD = 0x0003; +const WAVE_FORMAT_PCM: WORD = 0x0001; + +// --- COM vtable definitions (raw pointers) --- + +extern "system" { + fn CoInitializeEx(reserved: *mut c_void, coinit: DWORD) -> HRESULT; + fn CoUninitialize(); + fn CoCreateInstance( + rclsid: *const GUID, outer: *mut c_void, ctx: DWORD, + riid: *const GUID, ppv: *mut *mut c_void, + ) -> HRESULT; + fn CoTaskMemFree(pv: *mut c_void); +} + +// IUnknown vtable +#[repr(C)] +struct IUnknownVtbl { + QueryInterface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + AddRef: unsafe extern "system" fn(*mut c_void) -> u32, + Release: unsafe extern "system" fn(*mut c_void) -> u32, +} + +// IMMDeviceEnumerator vtable +#[repr(C)] +struct IMMDeviceEnumeratorVtbl { + base: IUnknownVtbl, + EnumAudioEndpoints: *const c_void, + GetDefaultAudioEndpoint: unsafe extern "system" fn( + *mut c_void, u32, u32, *mut *mut c_void, + ) -> HRESULT, + GetDevice: *const c_void, + RegisterEndpointNotificationCallback: *const c_void, + UnregisterEndpointNotificationCallback: *const c_void, +} + +// IMMDevice vtable +#[repr(C)] +struct IMMDeviceVtbl { + base: IUnknownVtbl, + Activate: unsafe extern "system" fn( + *mut c_void, *const GUID, DWORD, *mut c_void, *mut *mut c_void, + ) -> HRESULT, + OpenPropertyStore: *const c_void, + GetId: *const c_void, + GetState: *const c_void, +} + +// IAudioClient vtable +#[repr(C)] +struct IAudioClientVtbl { + base: IUnknownVtbl, + Initialize: unsafe extern "system" fn( + *mut c_void, u32, DWORD, REFERENCE_TIME, REFERENCE_TIME, + *const WAVEFORMATEX, *const c_void, + ) -> HRESULT, + GetBufferSize: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + GetStreamLatency: *const c_void, + GetCurrentPadding: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + IsFormatSupported: *const c_void, + GetMixFormat: unsafe extern "system" fn(*mut c_void, *mut *mut WAVEFORMATEX) -> HRESULT, + GetDevicePeriod: *const c_void, + Start: unsafe extern "system" fn(*mut c_void) -> HRESULT, + Stop: unsafe extern "system" fn(*mut c_void) -> HRESULT, + Reset: unsafe extern "system" fn(*mut c_void) -> HRESULT, + SetEventHandle: unsafe extern "system" fn(*mut c_void, HANDLE) -> HRESULT, + GetService: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, +} + +// IAudioRenderClient vtable +#[repr(C)] +struct IAudioRenderClientVtbl { + base: IUnknownVtbl, + GetBuffer: unsafe extern "system" fn(*mut c_void, u32, *mut *mut u8) -> HRESULT, + ReleaseBuffer: unsafe extern "system" fn(*mut c_void, u32, DWORD) -> HRESULT, +} + +/// Wraps WASAPI COM objects for audio output. +pub struct WasapiDevice { + client: *mut c_void, + render_client: *mut c_void, + buffer_size: u32, + pub sample_rate: u32, + pub channels: u16, + pub bits_per_sample: u16, + pub is_float: bool, +} + +unsafe impl Send for WasapiDevice {} + +impl WasapiDevice { + /// Initialize WASAPI in shared mode with the default output device. + pub fn new() -> Result { + unsafe { + let hr = CoInitializeEx(ptr::null_mut(), COINIT_MULTITHREADED); + if hr != S_OK && hr != 1 { // 1 = S_FALSE (already initialized) + return Err(format!("CoInitializeEx failed: 0x{:08X}", hr)); + } + + // Create device enumerator + let mut enumerator: *mut c_void = ptr::null_mut(); + let hr = CoCreateInstance( + &CLSID_MMDEVICE_ENUMERATOR, ptr::null_mut(), CLSCTX_ALL, + &IID_IMMDEVICE_ENUMERATOR, &mut enumerator, + ); + if hr != S_OK { + return Err(format!("CoCreateInstance failed: 0x{:08X}", hr)); + } + + // Get default audio endpoint + let vtbl = *(enumerator as *mut *const IMMDeviceEnumeratorVtbl); + let mut device: *mut c_void = ptr::null_mut(); + let hr = ((*vtbl).GetDefaultAudioEndpoint)(enumerator, E_RENDER, E_CONSOLE, &mut device); + ((*vtbl).base.Release)(enumerator); + if hr != S_OK { + return Err(format!("GetDefaultAudioEndpoint failed: 0x{:08X}", hr)); + } + + // Activate IAudioClient + let vtbl = *(device as *mut *const IMMDeviceVtbl); + let mut client: *mut c_void = ptr::null_mut(); + let hr = ((*vtbl).Activate)(device, &IID_IAUDIO_CLIENT, CLSCTX_ALL, ptr::null_mut(), &mut client); + ((*vtbl).base.Release)(device); + if hr != S_OK { + return Err(format!("Activate failed: 0x{:08X}", hr)); + } + + // Get mix format + let client_vtbl = *(client as *mut *const IAudioClientVtbl); + let mut format_ptr: *mut WAVEFORMATEX = ptr::null_mut(); + let hr = ((*client_vtbl).GetMixFormat)(client, &mut format_ptr); + if hr != S_OK { + return Err(format!("GetMixFormat failed: 0x{:08X}", hr)); + } + + let fmt = &*format_ptr; + let sample_rate = fmt.nSamplesPerSec; + let channels = fmt.nChannels; + let bits_per_sample = fmt.wBitsPerSample; + let is_float = fmt.wFormatTag == WAVE_FORMAT_IEEE_FLOAT + || (fmt.wFormatTag == 0xFFFE && bits_per_sample == 32); // EXTENSIBLE float + + // Initialize client (shared mode, 50ms buffer) + let buffer_duration: REFERENCE_TIME = 500_000; // 50ms in 100ns units + let hr = ((*client_vtbl).Initialize)( + client, AUDCLNT_SHAREMODE_SHARED, 0, + buffer_duration, 0, format_ptr, ptr::null(), + ); + CoTaskMemFree(format_ptr as *mut c_void); + if hr != S_OK { + return Err(format!("Initialize failed: 0x{:08X}", hr)); + } + + // Get buffer size + let mut buffer_size: u32 = 0; + ((*client_vtbl).GetBufferSize)(client, &mut buffer_size); + + // Get render client + let mut render_client: *mut c_void = ptr::null_mut(); + let hr = ((*client_vtbl).GetService)(client, &IID_IAUDIO_RENDER_CLIENT, &mut render_client); + if hr != S_OK { + return Err(format!("GetService failed: 0x{:08X}", hr)); + } + + // Start + let hr = ((*client_vtbl).Start)(client); + if hr != S_OK { + return Err(format!("Start failed: 0x{:08X}", hr)); + } + + Ok(WasapiDevice { + client, + render_client, + buffer_size, + sample_rate, + channels, + bits_per_sample, + is_float, + }) + } + } + + /// Write f32 samples to the WASAPI buffer. Returns number of frames written. + pub fn write_samples(&self, samples: &[f32]) -> Result { + unsafe { + let client_vtbl = *(self.client as *mut *const IAudioClientVtbl); + let render_vtbl = *(self.render_client as *mut *const IAudioRenderClientVtbl); + + let mut padding: u32 = 0; + ((*client_vtbl).GetCurrentPadding)(self.client, &mut padding); + let available = self.buffer_size - padding; + + if available == 0 { + return Ok(0); + } + + let frames_to_write = available.min(samples.len() as u32 / self.channels as u32); + if frames_to_write == 0 { + return Ok(0); + } + + let mut buffer_ptr: *mut u8 = ptr::null_mut(); + let hr = ((*render_vtbl).GetBuffer)(self.render_client, frames_to_write, &mut buffer_ptr); + if hr != S_OK { + return Err(format!("GetBuffer failed: 0x{:08X}", hr)); + } + + let total_samples = frames_to_write as usize * self.channels as usize; + + if self.is_float && self.bits_per_sample == 32 { + // Write f32 directly + let out = std::slice::from_raw_parts_mut(buffer_ptr as *mut f32, total_samples); + for i in 0..total_samples { + out[i] = if i < samples.len() { samples[i] } else { 0.0 }; + } + } else if self.bits_per_sample == 16 { + // Convert f32 → i16 + let out = std::slice::from_raw_parts_mut(buffer_ptr as *mut i16, total_samples); + for i in 0..total_samples { + let s = if i < samples.len() { samples[i] } else { 0.0 }; + out[i] = (s.clamp(-1.0, 1.0) * 32767.0) as i16; + } + } + + ((*render_vtbl).ReleaseBuffer)(self.render_client, frames_to_write, 0); + Ok(frames_to_write as usize) + } + } + + pub fn buffer_frames(&self) -> u32 { + self.buffer_size + } +} + +impl Drop for WasapiDevice { + fn drop(&mut self) { + unsafe { + if !self.client.is_null() { + let vtbl = *(self.client as *mut *const IAudioClientVtbl); + ((*vtbl).Stop)(self.client); + ((*vtbl).base.Release)(self.client); + } + if !self.render_client.is_null() { + let vtbl = *(self.render_client as *mut *const IAudioRenderClientVtbl); + ((*vtbl).base.Release)(self.render_client); + } + CoUninitialize(); + } + } +} +``` + +- [ ] **Step 2: lib.rs에 wasapi 모듈 등록 (Windows 조건부)** + +```rust +#[cfg(target_os = "windows")] +pub mod wasapi; +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `cargo build -p voltex_audio` +Expected: 컴파일 성공 (FFI 선언만이므로 링크 에러 없음) + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_audio/src/wasapi.rs crates/voltex_audio/src/lib.rs +git commit -m "feat(audio): add WASAPI FFI bindings for Windows audio output" +``` + +--- + +## Task 5: AudioSystem + 오디오 스레드 + +**Files:** +- Create: `crates/voltex_audio/src/audio_system.rs` +- Modify: `crates/voltex_audio/src/lib.rs` + +- [ ] **Step 1: audio_system.rs 작성** + +```rust +// crates/voltex_audio/src/audio_system.rs +use std::sync::mpsc::{self, Sender, Receiver}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crate::audio_clip::AudioClip; +use crate::mixing::{PlayingSound, mix_sounds}; + +#[cfg(target_os = "windows")] +use crate::wasapi::WasapiDevice; + +pub enum AudioCommand { + Play { clip_index: usize, volume: f32, looping: bool }, + Stop { clip_index: usize }, + SetVolume { clip_index: usize, volume: f32 }, + StopAll, + Shutdown, +} + +pub struct AudioSystem { + sender: Sender, + _thread: JoinHandle<()>, +} + +impl AudioSystem { + /// Create a new audio system. Clips are shared with the audio thread. + #[cfg(target_os = "windows")] + pub fn new(clips: Vec) -> Result { + let (sender, receiver) = mpsc::channel(); + let clips = Arc::new(clips); + + let thread_clips = Arc::clone(&clips); + let handle = thread::spawn(move || { + audio_thread(receiver, thread_clips); + }); + + Ok(AudioSystem { + sender, + _thread: handle, + }) + } + + pub fn play(&self, clip_index: usize, volume: f32, looping: bool) { + let _ = self.sender.send(AudioCommand::Play { clip_index, volume, looping }); + } + + pub fn stop(&self, clip_index: usize) { + let _ = self.sender.send(AudioCommand::Stop { clip_index }); + } + + pub fn set_volume(&self, clip_index: usize, volume: f32) { + let _ = self.sender.send(AudioCommand::SetVolume { clip_index, volume }); + } + + pub fn stop_all(&self) { + let _ = self.sender.send(AudioCommand::StopAll); + } +} + +impl Drop for AudioSystem { + fn drop(&mut self) { + let _ = self.sender.send(AudioCommand::Shutdown); + // Thread will exit when it receives Shutdown + } +} + +#[cfg(target_os = "windows")] +fn audio_thread(receiver: Receiver, clips: Arc>) { + let device = match WasapiDevice::new() { + Ok(d) => d, + Err(e) => { + eprintln!("[voltex_audio] WASAPI init failed: {}", e); + return; + } + }; + + let mut playing: Vec = Vec::new(); + let buffer_frames = device.buffer_frames() as usize; + let channels = device.channels; + let sample_rate = device.sample_rate; + + let mut mix_buffer = vec![0.0f32; buffer_frames * channels as usize]; + + loop { + // Process commands (non-blocking) + while let Ok(cmd) = receiver.try_recv() { + match cmd { + AudioCommand::Play { clip_index, volume, looping } => { + playing.push(PlayingSound { + clip_index, position: 0, volume, looping, + }); + } + AudioCommand::Stop { clip_index } => { + playing.retain(|s| s.clip_index != clip_index); + } + AudioCommand::SetVolume { clip_index, volume } => { + for s in playing.iter_mut() { + if s.clip_index == clip_index { + s.volume = volume; + } + } + } + AudioCommand::StopAll => { + playing.clear(); + } + AudioCommand::Shutdown => { + return; + } + } + } + + // Mix and write + let frames = buffer_frames / 2; // write half buffer at a time + let sample_count = frames * channels as usize; + if mix_buffer.len() < sample_count { + mix_buffer.resize(sample_count, 0.0); + } + + mix_sounds( + &mut mix_buffer[..sample_count], + &mut playing, + &clips, + sample_rate, + channels, + frames, + ); + + match device.write_samples(&mix_buffer[..sample_count]) { + Ok(_) => {} + Err(e) => { + eprintln!("[voltex_audio] Write error: {}", e); + } + } + + thread::sleep(Duration::from_millis(5)); + } +} +``` + +- [ ] **Step 2: lib.rs에 audio_system 모듈 등록** + +```rust +pub mod audio_system; +pub use audio_system::AudioSystem; +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `cargo build -p voltex_audio` +Expected: 컴파일 성공 + +- [ ] **Step 4: 전체 테스트** + +Run: `cargo test --workspace` +Expected: all pass (기존 165 + 13 audio = 178) + +- [ ] **Step 5: 커밋** + +```bash +git add crates/voltex_audio/src/audio_system.rs crates/voltex_audio/src/lib.rs +git commit -m "feat(audio): add AudioSystem with WASAPI audio thread" +``` + +--- + +## Task 6: audio_demo 예제 + +**Files:** +- Create: `examples/audio_demo/Cargo.toml` +- Create: `examples/audio_demo/src/main.rs` +- Modify: `Cargo.toml` (workspace members) + +- [ ] **Step 1: Cargo.toml** + +```toml +# examples/audio_demo/Cargo.toml +[package] +name = "audio_demo" +version = "0.1.0" +edition = "2021" + +[dependencies] +voltex_audio.workspace = true +``` + +- [ ] **Step 2: main.rs — 사인파 생성 + 재생** + +```rust +// examples/audio_demo/src/main.rs +use voltex_audio::{AudioClip, AudioSystem, parse_wav, generate_wav_bytes}; + +fn generate_sine_clip(freq: f32, duration: f32, sample_rate: u32) -> AudioClip { + let num_samples = (sample_rate as f32 * duration) as usize; + let mut samples = Vec::with_capacity(num_samples); + for i in 0..num_samples { + let t = i as f32 / sample_rate as f32; + samples.push((t * freq * 2.0 * std::f32::consts::PI).sin() * 0.3); + } + AudioClip::new(samples, sample_rate, 1) +} + +fn main() { + println!("=== Voltex Audio Demo ==="); + println!("Generating 440Hz sine wave (2 seconds)..."); + + let clip = generate_sine_clip(440.0, 2.0, 44100); + let clip2 = generate_sine_clip(660.0, 1.5, 44100); + + println!("Initializing audio system..."); + let audio = match AudioSystem::new(vec![clip, clip2]) { + Ok(a) => a, + Err(e) => { + eprintln!("Failed to init audio: {}", e); + return; + } + }; + + println!("Playing 440Hz tone..."); + audio.play(0, 0.5, false); + std::thread::sleep(std::time::Duration::from_secs(1)); + + println!("Playing 660Hz tone on top..."); + audio.play(1, 0.3, false); + std::thread::sleep(std::time::Duration::from_secs(2)); + + println!("Done!"); +} +``` + +- [ ] **Step 3: workspace에 예제 추가** + +`Cargo.toml` members에 `"examples/audio_demo"` 추가. + +- [ ] **Step 4: 빌드 확인** + +Run: `cargo build --bin audio_demo` +Expected: 빌드 성공 + +- [ ] **Step 5: 커밋** + +```bash +git add examples/audio_demo/ Cargo.toml +git commit -m "feat(audio): add audio_demo example with sine wave playback" +``` + +--- + +## Task 7: 문서 업데이트 + +**Files:** +- Modify: `docs/STATUS.md` +- Modify: `docs/DEFERRED.md` + +- [ ] **Step 1: STATUS.md에 Phase 6-1 추가** + +Phase 5-3 아래에: +```markdown +### Phase 6-1: Audio System Foundation +- voltex_audio: WAV parser (PCM 16-bit, mono/stereo) +- voltex_audio: AudioClip (f32 samples), mixing (volume, looping, channel conversion) +- voltex_audio: WASAPI backend (Windows, shared mode, COM FFI) +- voltex_audio: AudioSystem (channel-based audio thread, play/stop/volume) +- examples/audio_demo (sine wave playback) +``` + +crate 구조에 voltex_audio 추가. 테스트 수 업데이트. 예제 수 10으로. + +- [ ] **Step 2: DEFERRED.md에 Phase 6-1 미뤄진 항목 추가** + +```markdown +## Phase 6-1 + +- **macOS/Linux 백엔드** — WASAPI(Windows)만 구현. CoreAudio, ALSA 미구현. +- **OGG/Vorbis 디코더** — WAV PCM 16-bit만 지원. +- **24-bit/32-bit WAV** — 16-bit만 파싱. +- **ECS 통합** — AudioSource 컴포넌트 미구현. AudioSystem 직접 호출. +- **비동기 로딩** — 동기 로딩만. +``` + +- [ ] **Step 3: 커밋** + +```bash +git add docs/STATUS.md docs/DEFERRED.md +git commit -m "docs: add Phase 6-1 audio system status and deferred items" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase6-2-3d-audio.md b/docs/superpowers/plans/2026-03-25-phase6-2-3d-audio.md new file mode 100644 index 0000000..7773d1d --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase6-2-3d-audio.md @@ -0,0 +1,462 @@ +# Phase 6-2: 3D Audio 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:** 3D 공간 오디오 — 거리 감쇠와 스테레오 패닝으로 소리의 공간감 표현 + +**Architecture:** `voltex_audio`에 spatial.rs 추가 (순수 함수), mixing.rs와 audio_system.rs를 확장하여 3D 사운드 지원. voltex_math 의존 추가. + +**Tech Stack:** Rust, voltex_math (Vec3) + +**Spec:** `docs/superpowers/specs/2026-03-25-phase6-2-3d-audio.md` + +--- + +## File Structure + +- `crates/voltex_audio/Cargo.toml` — voltex_math 의존 추가 (Modify) +- `crates/voltex_audio/src/spatial.rs` — Listener, SpatialParams, 감쇠/패닝 함수 (Create) +- `crates/voltex_audio/src/mixing.rs` — PlayingSound에 spatial 추가, mix_sounds에 listener 파라미터 (Modify) +- `crates/voltex_audio/src/audio_system.rs` — Play3d, SetListener 명령 추가 (Modify) +- `crates/voltex_audio/src/lib.rs` — spatial 모듈 등록 (Modify) + +--- + +## Task 1: spatial.rs — 감쇠/패닝 순수 함수 + +**Files:** +- Modify: `crates/voltex_audio/Cargo.toml` (add voltex_math dependency) +- Create: `crates/voltex_audio/src/spatial.rs` +- Modify: `crates/voltex_audio/src/lib.rs` + +- [ ] **Step 1: Cargo.toml에 voltex_math 의존 추가** + +```toml +[dependencies] +voltex_math.workspace = true +``` + +- [ ] **Step 2: spatial.rs 작성** + +```rust +// crates/voltex_audio/src/spatial.rs +use voltex_math::Vec3; + +#[derive(Debug, Clone, Copy)] +pub struct Listener { + pub position: Vec3, + pub forward: Vec3, + pub right: Vec3, +} + +impl Default for Listener { + fn default() -> Self { + Self { + position: Vec3::ZERO, + forward: Vec3::new(0.0, 0.0, -1.0), // -Z + right: Vec3::X, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct SpatialParams { + pub position: Vec3, + pub min_distance: f32, + pub max_distance: f32, +} + +impl SpatialParams { + pub fn new(position: Vec3, min_distance: f32, max_distance: f32) -> Self { + Self { position, min_distance, max_distance } + } + + /// Convenience: create with default min=1.0, max=50.0. + pub fn at(position: Vec3) -> Self { + Self { position, min_distance: 1.0, max_distance: 50.0 } + } +} + +/// Compute volume attenuation based on distance (inverse distance model). +/// Returns 1.0 at min_dist or closer, 0.0 at max_dist or farther. +pub fn distance_attenuation(distance: f32, min_dist: f32, max_dist: f32) -> f32 { + if distance <= min_dist { + return 1.0; + } + if distance >= max_dist { + return 0.0; + } + // Inverse distance: min_dist / distance, clamped to [0, 1] + (min_dist / distance).clamp(0.0, 1.0) +} + +/// Compute stereo pan gains (left, right) using equal-power panning. +/// Returns (1.0, 1.0) if emitter is at listener position. +pub fn stereo_pan(listener: &Listener, emitter_pos: Vec3) -> (f32, f32) { + let diff = emitter_pos - listener.position; + let dist_sq = diff.length_squared(); + + if dist_sq < 1e-8 { + return (1.0, 1.0); + } + + let direction = diff.normalize(); + // pan: -1.0 = full left, 0.0 = center, 1.0 = full right + let pan = direction.dot(listener.right).clamp(-1.0, 1.0); + + // Equal-power panning: angle = pan * PI/4 + PI/4 + let angle = pan * std::f32::consts::FRAC_PI_4 + std::f32::consts::FRAC_PI_4; + let left = angle.cos(); + let right = angle.sin(); + + (left, right) +} + +/// Convenience: compute all spatial gains at once. +/// Returns (attenuation, left_gain, right_gain). +pub fn compute_spatial_gains(listener: &Listener, spatial: &SpatialParams) -> (f32, f32, f32) { + let diff = spatial.position - listener.position; + let distance = diff.length(); + let atten = distance_attenuation(distance, spatial.min_distance, spatial.max_distance); + let (left, right) = stereo_pan(listener, spatial.position); + (atten, left, right) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 + } + + // distance_attenuation tests + #[test] + fn test_attenuation_at_min() { + assert!(approx(distance_attenuation(0.5, 1.0, 50.0), 1.0)); + assert!(approx(distance_attenuation(1.0, 1.0, 50.0), 1.0)); + } + + #[test] + fn test_attenuation_at_max() { + assert!(approx(distance_attenuation(50.0, 1.0, 50.0), 0.0)); + assert!(approx(distance_attenuation(100.0, 1.0, 50.0), 0.0)); + } + + #[test] + fn test_attenuation_between() { + let a = distance_attenuation(5.0, 1.0, 50.0); + assert!(a > 0.0 && a < 1.0); + assert!(approx(a, 1.0 / 5.0)); // min_dist / distance = 0.2 + } + + // stereo_pan tests + #[test] + fn test_pan_right() { + let listener = Listener::default(); + // Emitter to the right (+X) + let (left, right) = stereo_pan(&listener, Vec3::new(5.0, 0.0, 0.0)); + assert!(right > left, "right={} should be > left={}", right, left); + } + + #[test] + fn test_pan_left() { + let listener = Listener::default(); + // Emitter to the left (-X) + let (left, right) = stereo_pan(&listener, Vec3::new(-5.0, 0.0, 0.0)); + assert!(left > right, "left={} should be > right={}", left, right); + } + + #[test] + fn test_pan_front() { + let listener = Listener::default(); + // Emitter directly in front (-Z) + let (left, right) = stereo_pan(&listener, Vec3::new(0.0, 0.0, -5.0)); + // Should be roughly equal (center pan) + assert!((left - right).abs() < 0.1, "left={} right={}", left, right); + } + + #[test] + fn test_pan_same_position() { + let listener = Listener::default(); + let (left, right) = stereo_pan(&listener, Vec3::ZERO); + assert!(approx(left, 1.0)); + assert!(approx(right, 1.0)); + } + + // compute_spatial_gains test + #[test] + fn test_compute_spatial_gains() { + let listener = Listener::default(); + let spatial = SpatialParams::new(Vec3::new(5.0, 0.0, 0.0), 1.0, 50.0); + let (atten, left, right) = compute_spatial_gains(&listener, &spatial); + assert!(approx(atten, 0.2)); // 1.0 / 5.0 + assert!(right > left); // right side + } +} +``` + +- [ ] **Step 3: lib.rs에 spatial 모듈 등록** + +```rust +pub mod spatial; +pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, compute_spatial_gains}; +``` + +- [ ] **Step 4: 테스트 실행** + +Run: `cargo test -p voltex_audio` +Expected: 기존 15 + 8 = 23 PASS + +- [ ] **Step 5: 커밋** + +```bash +git add crates/voltex_audio/Cargo.toml crates/voltex_audio/src/spatial.rs crates/voltex_audio/src/lib.rs +git commit -m "feat(audio): add 3D audio spatial functions (distance attenuation, stereo panning)" +``` + +--- + +## Task 2: mixing.rs에 spatial 통합 + +**Files:** +- Modify: `crates/voltex_audio/src/mixing.rs` + +- [ ] **Step 1: PlayingSound에 spatial 필드 추가 + mix_sounds에 listener 파라미터** + +Changes to mixing.rs: + +1. Add import at top: `use crate::spatial::{Listener, SpatialParams, compute_spatial_gains};` + +2. Add field to PlayingSound: +```rust +pub struct PlayingSound { + pub clip_index: usize, + pub position: usize, + pub volume: f32, + pub looping: bool, + pub spatial: Option, // NEW +} +``` + +3. Update PlayingSound::new to set spatial: None: +```rust +impl PlayingSound { + pub fn new(clip_index: usize, volume: f32, looping: bool) -> Self { + Self { clip_index, position: 0, volume, looping, spatial: None } + } + + pub fn new_3d(clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams) -> Self { + Self { clip_index, position: 0, volume, looping, spatial: Some(spatial) } + } +} +``` + +4. Add `listener: &Listener` parameter to mix_sounds: +```rust +pub fn mix_sounds( + output: &mut Vec, + playing: &mut Vec, + clips: &[AudioClip], + device_sample_rate: u32, + device_channels: u16, + frames: usize, + listener: &Listener, // NEW +) +``` + +5. Inside the per-sound loop, before writing to output, compute spatial gains: +```rust +// After existing setup, before the frame loop: +let (vol_left, vol_right) = if let Some(ref sp) = sound.spatial { + let (atten, lg, rg) = compute_spatial_gains(listener, sp); + (sound.volume * atten * lg, sound.volume * atten * rg) +} else { + (sound.volume, sound.volume) +}; +``` + +Then use `vol_left` and `vol_right` instead of `sound.volume` when writing to stereo output: +- For `device_channels == 2`: left channel uses `vol_left`, right channel uses `vol_right` +- For `device_channels == 1`: use `(vol_left + vol_right) * 0.5` + +6. Update ALL existing tests to pass `&Listener::default()` as the last argument to mix_sounds. + +7. Add new spatial tests: + +```rust + #[test] + fn spatial_2d_unchanged() { + // spatial=None should behave exactly like before + let clips = vec![make_mono_clip(1.0, 100, 44100)]; + let mut playing = vec![PlayingSound::new(0, 0.5, false)]; + let mut output = Vec::new(); + mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default()); + for &s in &output { + assert!((s - 0.5).abs() < 1e-5); + } + } + + #[test] + fn spatial_far_away_silent() { + use crate::spatial::SpatialParams; + let clips = vec![make_mono_clip(1.0, 100, 44100)]; + let spatial = SpatialParams::new( + voltex_math::Vec3::new(100.0, 0.0, 0.0), 1.0, 50.0 + ); + let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)]; + let mut output = Vec::new(); + mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default()); + // At distance 100, max_distance=50 → attenuation = 0 + for &s in &output { + assert!(s.abs() < 1e-5, "expected silence, got {}", s); + } + } + + #[test] + fn spatial_right_panning() { + use crate::spatial::SpatialParams; + let clips = vec![make_mono_clip(1.0, 100, 44100)]; + let spatial = SpatialParams::new( + voltex_math::Vec3::new(2.0, 0.0, 0.0), 1.0, 50.0 + ); + let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)]; + let mut output = Vec::new(); + mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default()); + // Emitter on the right → right channel louder + let left = output[0]; + let right = output[1]; + assert!(right > left, "right={} should be > left={}", right, left); + assert!(right > 0.0); + } +``` + +- [ ] **Step 2: 테스트 실행** + +Run: `cargo test -p voltex_audio` +Expected: 기존 15 (updated) + 8 spatial + 3 mixing_spatial = 26 PASS + +- [ ] **Step 3: 커밋** + +```bash +git add crates/voltex_audio/src/mixing.rs +git commit -m "feat(audio): integrate spatial 3D audio into mixing pipeline" +``` + +--- + +## Task 3: AudioSystem에 play_3d, set_listener 추가 + +**Files:** +- Modify: `crates/voltex_audio/src/audio_system.rs` + +- [ ] **Step 1: AudioCommand에 Play3d, SetListener 추가** + +Add imports: +```rust +use crate::spatial::{Listener, SpatialParams}; +``` + +Add to AudioCommand enum: +```rust + Play3d { + clip_index: usize, + volume: f32, + looping: bool, + spatial: SpatialParams, + }, + SetListener { + position: voltex_math::Vec3, + forward: voltex_math::Vec3, + right: voltex_math::Vec3, + }, +``` + +- [ ] **Step 2: AudioSystem에 새 메서드 추가** + +```rust + pub fn play_3d(&self, clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams) { + let _ = self.sender.send(AudioCommand::Play3d { + clip_index, volume, looping, spatial, + }); + } + + pub fn set_listener(&self, position: voltex_math::Vec3, forward: voltex_math::Vec3, right: voltex_math::Vec3) { + let _ = self.sender.send(AudioCommand::SetListener { position, forward, right }); + } +``` + +- [ ] **Step 3: audio_thread_windows에서 새 명령 처리** + +Add `let mut listener = Listener::default();` before the loop. + +In the command match: +```rust +AudioCommand::Play3d { clip_index, volume, looping, spatial } => { + playing.push(PlayingSound::new_3d(clip_index, volume, looping, spatial)); +} +AudioCommand::SetListener { position, forward, right } => { + listener = Listener { position, forward, right }; +} +``` + +Update the mix_sounds call to pass `&listener`: +```rust +mix_sounds(&mut output, &mut playing, &clips, device_sample_rate, device_channels, buffer_frames, &listener); +``` + +Also update the existing test in audio_system to pass `&Listener::default()` if needed (the tests use AudioSystem API, not mix_sounds directly, so they should be fine). + +- [ ] **Step 4: 테스트 실행** + +Run: `cargo test -p voltex_audio` +Expected: all pass + +Run: `cargo test --workspace` +Expected: all pass + +- [ ] **Step 5: 커밋** + +```bash +git add crates/voltex_audio/src/audio_system.rs +git commit -m "feat(audio): add play_3d and set_listener to AudioSystem" +``` + +--- + +## Task 4: 문서 업데이트 + +**Files:** +- Modify: `docs/STATUS.md` +- Modify: `docs/DEFERRED.md` + +- [ ] **Step 1: STATUS.md에 Phase 6-2 추가** + +Phase 6-1 아래에: +```markdown +### Phase 6-2: 3D Audio +- voltex_audio: Listener, SpatialParams +- voltex_audio: distance_attenuation (inverse distance), stereo_pan (equal-power) +- voltex_audio: mix_sounds spatial integration (per-sound attenuation + panning) +- voltex_audio: play_3d, set_listener API +``` + +테스트 수 업데이트. + +- [ ] **Step 2: DEFERRED.md에 Phase 6-2 미뤄진 항목 추가** + +```markdown +## Phase 6-2 + +- **도플러 효과** — 미구현. 상대 속도 기반 주파수 변조. +- **HRTF** — 미구현. 헤드폰용 3D 정위. +- **Reverb/Echo** — 미구현. 환경 반사음. +- **Occlusion** — 미구현. 벽 뒤 소리 차단. +``` + +- [ ] **Step 3: 커밋** + +```bash +git add docs/STATUS.md docs/DEFERRED.md +git commit -m "docs: add Phase 6-2 3D audio status and deferred items" +``` diff --git a/docs/superpowers/plans/2026-03-25-phase6-3-mixer.md b/docs/superpowers/plans/2026-03-25-phase6-3-mixer.md new file mode 100644 index 0000000..c205c10 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase6-3-mixer.md @@ -0,0 +1,412 @@ +# Phase 6-3: Mixer 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:** 그룹 기반 믹서 — BGM/SFX/Voice 그룹별 볼륨 제어와 페이드 인/아웃 + +**Architecture:** `voltex_audio`에 mix_group.rs 추가 (MixGroup enum, GroupState, MixerState). mixing.rs에 group+mixer 통합. audio_system.rs에 SetGroupVolume/FadeGroup 명령 추가. + +**Tech Stack:** Rust, voltex_audio (기존 mixing, audio_system) + +**Spec:** `docs/superpowers/specs/2026-03-25-phase6-3-mixer.md` + +--- + +## File Structure + +- `crates/voltex_audio/src/mix_group.rs` — MixGroup, GroupState, MixerState (Create) +- `crates/voltex_audio/src/mixing.rs` — PlayingSound에 group, mix_sounds에 mixer (Modify) +- `crates/voltex_audio/src/audio_system.rs` — 새 명령 + 메서드 + 스레드 통합 (Modify) +- `crates/voltex_audio/src/lib.rs` — mix_group 등록 (Modify) + +--- + +## Task 1: MixGroup + GroupState + MixerState + +**Files:** +- Create: `crates/voltex_audio/src/mix_group.rs` +- Modify: `crates/voltex_audio/src/lib.rs` + +- [ ] **Step 1: mix_group.rs 작성** + +```rust +// crates/voltex_audio/src/mix_group.rs + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MixGroup { + Master = 0, + Bgm = 1, + Sfx = 2, + Voice = 3, +} + +const GROUP_COUNT: usize = 4; + +#[derive(Debug, Clone)] +pub struct GroupState { + pub volume: f32, + pub fade_target: f32, + pub fade_speed: f32, // units per second, 0 = no fade +} + +impl GroupState { + pub fn new() -> Self { + Self { volume: 1.0, fade_target: 1.0, fade_speed: 0.0 } + } + + /// Advance fade by dt seconds. + pub fn tick(&mut self, dt: f32) { + if self.fade_speed <= 0.0 { + return; + } + let diff = self.fade_target - self.volume; + if diff.abs() < 1e-6 { + self.volume = self.fade_target; + self.fade_speed = 0.0; + return; + } + let step = self.fade_speed * dt; + if step >= diff.abs() { + self.volume = self.fade_target; + self.fade_speed = 0.0; + } else if diff > 0.0 { + self.volume += step; + } else { + self.volume -= step; + } + } +} + +pub struct MixerState { + groups: [GroupState; GROUP_COUNT], +} + +impl MixerState { + pub fn new() -> Self { + Self { + groups: [ + GroupState::new(), // Master + GroupState::new(), // Bgm + GroupState::new(), // Sfx + GroupState::new(), // Voice + ], + } + } + + pub fn set_volume(&mut self, group: MixGroup, volume: f32) { + let g = &mut self.groups[group as usize]; + g.volume = volume.clamp(0.0, 1.0); + g.fade_target = g.volume; + g.fade_speed = 0.0; + } + + pub fn fade(&mut self, group: MixGroup, target: f32, duration: f32) { + let g = &mut self.groups[group as usize]; + let target = target.clamp(0.0, 1.0); + if duration <= 0.0 { + g.volume = target; + g.fade_target = target; + g.fade_speed = 0.0; + return; + } + g.fade_target = target; + g.fade_speed = (target - g.volume).abs() / duration; + } + + pub fn tick(&mut self, dt: f32) { + for g in self.groups.iter_mut() { + g.tick(dt); + } + } + + pub fn volume(&self, group: MixGroup) -> f32 { + self.groups[group as usize].volume + } + + /// Effective volume = group volume * master volume. + /// For Master group, returns just its own volume. + pub fn effective_volume(&self, group: MixGroup) -> f32 { + if group == MixGroup::Master { + self.groups[MixGroup::Master as usize].volume + } else { + self.groups[group as usize].volume * self.groups[MixGroup::Master as usize].volume + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 + } + + #[test] + fn test_group_state_tick_fade() { + let mut g = GroupState::new(); + g.fade_target = 0.0; + g.fade_speed = 2.0; // 2.0 per second + g.tick(0.25); // 0.5 step + assert!(approx(g.volume, 0.5)); + g.tick(0.25); + assert!(approx(g.volume, 0.0)); + assert!(approx(g.fade_speed, 0.0)); // fade done + } + + #[test] + fn test_group_state_tick_no_overshoot() { + let mut g = GroupState::new(); // volume=1.0 + g.fade_target = 0.5; + g.fade_speed = 10.0; // very fast + g.tick(1.0); // would overshoot + assert!(approx(g.volume, 0.5)); + } + + #[test] + fn test_mixer_set_volume() { + let mut m = MixerState::new(); + m.set_volume(MixGroup::Bgm, 0.5); + assert!(approx(m.volume(MixGroup::Bgm), 0.5)); + // Should cancel any fade + assert!(approx(m.groups[MixGroup::Bgm as usize].fade_speed, 0.0)); + } + + #[test] + fn test_mixer_fade() { + let mut m = MixerState::new(); + m.fade(MixGroup::Sfx, 0.0, 1.0); // fade to 0 over 1 second + m.tick(0.5); + assert!(approx(m.volume(MixGroup::Sfx), 0.5)); + m.tick(0.5); + assert!(approx(m.volume(MixGroup::Sfx), 0.0)); + } + + #[test] + fn test_effective_volume() { + let mut m = MixerState::new(); + m.set_volume(MixGroup::Master, 0.5); + m.set_volume(MixGroup::Sfx, 0.8); + assert!(approx(m.effective_volume(MixGroup::Sfx), 0.4)); // 0.5 * 0.8 + assert!(approx(m.effective_volume(MixGroup::Master), 0.5)); // master is just itself + } + + #[test] + fn test_master_zero_mutes_all() { + let mut m = MixerState::new(); + m.set_volume(MixGroup::Master, 0.0); + assert!(approx(m.effective_volume(MixGroup::Bgm), 0.0)); + assert!(approx(m.effective_volume(MixGroup::Sfx), 0.0)); + assert!(approx(m.effective_volume(MixGroup::Voice), 0.0)); + } + + #[test] + fn test_fade_up() { + let mut m = MixerState::new(); + m.set_volume(MixGroup::Bgm, 0.0); + m.fade(MixGroup::Bgm, 1.0, 2.0); // fade up over 2 seconds + m.tick(1.0); + assert!(approx(m.volume(MixGroup::Bgm), 0.5)); + m.tick(1.0); + assert!(approx(m.volume(MixGroup::Bgm), 1.0)); + } +} +``` + +- [ ] **Step 2: lib.rs에 mix_group 모듈 등록** + +```rust +pub mod mix_group; +pub use mix_group::{MixGroup, MixerState}; +``` + +- [ ] **Step 3: 테스트 실행** + +Run: `cargo test -p voltex_audio` +Expected: 기존 26 + 7 = 33 PASS + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_audio/src/mix_group.rs crates/voltex_audio/src/lib.rs +git commit -m "feat(audio): add MixGroup, GroupState, and MixerState with fade support" +``` + +--- + +## Task 2: mixing.rs + audio_system.rs 통합 + +**Files:** +- Modify: `crates/voltex_audio/src/mixing.rs` +- Modify: `crates/voltex_audio/src/audio_system.rs` + +NOTE: 두 파일을 함께 수정해야 컴파일됨 (mix_sounds 시그니처 변경). + +- [ ] **Step 1: mixing.rs 수정** + +Read the current file first. Changes: + +1. Add import: `use crate::mix_group::{MixGroup, MixerState};` + +2. Add `group: MixGroup` field to PlayingSound: +```rust +pub struct PlayingSound { + pub clip_index: usize, + pub position: usize, + pub volume: f32, + pub looping: bool, + pub spatial: Option, + pub group: MixGroup, // NEW +} +``` + +3. Update PlayingSound::new: add `group: MixGroup::Sfx` + +4. Update PlayingSound::new_3d: add `group: MixGroup::Sfx` + +5. Add `mixer: &MixerState` parameter to mix_sounds (last parameter): +```rust +pub fn mix_sounds( + output: &mut Vec, + playing: &mut Vec, + clips: &[AudioClip], + device_sample_rate: u32, + device_channels: u16, + frames: usize, + listener: &Listener, + mixer: &MixerState, // NEW +) +``` + +6. In the per-sound volume computation, multiply by mixer effective volume: +```rust +let group_vol = mixer.effective_volume(sound.group); +let base_vol = sound.volume * group_vol; +``` +Then use `base_vol` where `sound.volume` was used for spatial/non-spatial gain calculation. + +7. Update ALL existing tests to pass `&MixerState::new()` as last arg. + +8. Add 2 new tests: +```rust + #[test] + fn group_volume_applied() { + use crate::mix_group::{MixGroup, MixerState}; + let clips = vec![make_mono_clip(1.0, 100, 44100)]; + let mut playing = vec![PlayingSound::new(0, 1.0, false)]; // group=Sfx + let mut output = Vec::new(); + let mut mixer = MixerState::new(); + mixer.set_volume(MixGroup::Sfx, 0.5); + mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &mixer); + for &s in &output { + assert!((s - 0.5).abs() < 1e-5, "expected 0.5, got {}", s); + } + } + + #[test] + fn master_zero_mutes_output() { + use crate::mix_group::{MixGroup, MixerState}; + let clips = vec![make_mono_clip(1.0, 100, 44100)]; + let mut playing = vec![PlayingSound::new(0, 1.0, false)]; + let mut output = Vec::new(); + let mut mixer = MixerState::new(); + mixer.set_volume(MixGroup::Master, 0.0); + mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &mixer); + for &s in &output { + assert!(s.abs() < 1e-5, "expected silence, got {}", s); + } + } +``` + +- [ ] **Step 2: audio_system.rs 수정** + +Read the current file first. Changes: + +1. Add import: `use crate::mix_group::{MixGroup, MixerState};` + +2. Add to AudioCommand enum: +```rust +SetGroupVolume { group: MixGroup, volume: f32 }, +FadeGroup { group: MixGroup, target: f32, duration: f32 }, +``` + +3. Add methods to AudioSystem: +```rust +pub fn set_group_volume(&self, group: MixGroup, volume: f32) { + let _ = self.sender.send(AudioCommand::SetGroupVolume { group, volume }); +} + +pub fn fade_group(&self, group: MixGroup, target: f32, duration: f32) { + let _ = self.sender.send(AudioCommand::FadeGroup { group, target, duration }); +} +``` + +4. In audio_thread_windows: add `let mut mixer = MixerState::new();` before loop + +5. Handle new commands: +```rust +AudioCommand::SetGroupVolume { group, volume } => { + mixer.set_volume(group, volume); +} +AudioCommand::FadeGroup { group, target, duration } => { + mixer.fade(group, target, duration); +} +``` + +6. Add `mixer.tick(0.005);` at the start of each loop iteration (5ms = 0.005s) + +7. Update mix_sounds call to pass `&mixer` + +- [ ] **Step 3: 테스트 실행** + +Run: `cargo test -p voltex_audio` +Expected: 35 PASS (33 + 2 new mixing tests) + +Run: `cargo test --workspace` +Expected: all pass + +- [ ] **Step 4: 커밋** + +```bash +git add crates/voltex_audio/src/mixing.rs crates/voltex_audio/src/audio_system.rs +git commit -m "feat(audio): integrate mixer groups into mixing pipeline and AudioSystem" +``` + +--- + +## Task 3: 문서 업데이트 + +**Files:** +- Modify: `docs/STATUS.md` +- Modify: `docs/DEFERRED.md` + +- [ ] **Step 1: STATUS.md에 Phase 6-3 추가** + +Phase 6-2 아래에: +```markdown +### Phase 6-3: Mixer +- voltex_audio: MixGroup (Master, Bgm, Sfx, Voice), MixerState +- voltex_audio: GroupState with linear fade (tick-based) +- voltex_audio: effective_volume (group * master) +- voltex_audio: set_group_volume, fade_group API +``` + +테스트 수 업데이트. 다음을 Phase 7로 변경. + +- [ ] **Step 2: DEFERRED.md에 Phase 6-3 미뤄진 항목 추가** + +```markdown +## Phase 6-3 + +- **동적 그룹 생성** — 고정 4개(Master/Bgm/Sfx/Voice)만. 런타임 추가 불가. +- **그룹 간 라우팅/버스** — 미구현. 단순 Master → 개별 그룹 구조만. +- **이펙트 체인** — Reverb, EQ 등 미구현. +- **비선형 페이드 커브** — 선형 페이드만. +``` + +- [ ] **Step 3: 커밋** + +```bash +git add docs/STATUS.md docs/DEFERRED.md +git commit -m "docs: add Phase 6-3 mixer status and deferred items" +``` diff --git a/docs/superpowers/specs/2026-03-25-phase5-1-collision-detection.md b/docs/superpowers/specs/2026-03-25-phase5-1-collision-detection.md new file mode 100644 index 0000000..6c72b98 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-phase5-1-collision-detection.md @@ -0,0 +1,171 @@ +# Phase 5-1: Collision Detection — Design Spec + +## Overview + +`voltex_physics` crate에 충돌 감지 시스템을 구현한다. +Broad phase (BVH) + Narrow phase (전용 함수) 구조로, Sphere와 Box 콜라이더를 지원한다. + +## Dependencies + +- `voltex_math` — Vec3, Mat4, AABB(신규) +- `voltex_ecs` — World, Entity, Transform, SparseSet + +## Module Structure + +``` +crates/voltex_physics/ +├── Cargo.toml +└── src/ + ├── lib.rs — public exports + ├── aabb.rs — AABB type (voltex_math에 추가) + ├── collider.rs — Collider enum + ├── contact.rs — ContactPoint + ├── bvh.rs — BvhTree (broad phase) + ├── narrow.rs — sphere_vs_sphere, sphere_vs_box, box_vs_box + └── collision.rs — detect_collisions (ECS 통합) +``` + +참고: AABB는 `voltex_math`에 추가한다 (스펙에 명시된 기본 수학 타입). + +## Types + +### AABB (voltex_math에 추가) + +```rust +#[derive(Debug, Clone, Copy)] +pub struct AABB { + pub min: Vec3, + pub max: Vec3, +} +``` + +**Methods:** +- `new(min, max) -> Self` +- `from_center_half_extents(center, half_extents) -> Self` +- `center() -> Vec3` +- `half_extents() -> Vec3` +- `contains_point(point: Vec3) -> bool` +- `intersects(other: &AABB) -> bool` +- `merged(other: &AABB) -> AABB` — 두 AABB를 감싸는 최소 AABB +- `surface_area() -> f32` — BVH SAH 비용 계산용 + +### Collider (voltex_physics) + +```rust +#[derive(Debug, Clone, Copy)] +pub enum Collider { + Sphere { radius: f32 }, + Box { half_extents: Vec3 }, +} +``` + +- ECS 컴포넌트로 사용. 형상(shape)만 저장. +- 위치는 같은 entity의 `Transform.position`에서 가져온다. +- `aabb(&self, position: Vec3) -> AABB` — broad phase용 바운딩 박스 생성 + +### ContactPoint (voltex_physics) + +```rust +#[derive(Debug, Clone, Copy)] +pub struct ContactPoint { + pub entity_a: Entity, + pub entity_b: Entity, + pub normal: Vec3, // A에서 B 방향 단위 법선 + pub depth: f32, // 침투 깊이 (양수 = 겹침) + pub point_on_a: Vec3, // A 표면의 접촉점 + pub point_on_b: Vec3, // B 표면의 접촉점 +} +``` + +### BvhTree (voltex_physics) + +바이너리 트리. 각 리프는 하나의 Entity + AABB. + +```rust +pub struct BvhTree { + nodes: Vec, +} + +enum BvhNode { + Leaf { entity: Entity, aabb: AABB }, + Internal { aabb: AABB, left: usize, right: usize }, +} +``` + +**Methods:** +- `build(entries: &[(Entity, AABB)]) -> Self` — 중앙값 분할(median split)로 구축. 가장 긴 축 기준 정렬 후 이분. +- `query_pairs(&self) -> Vec<(Entity, Entity)>` — 트리 순회로 AABB가 겹치는 리프 쌍 반환. + +매 프레임 rebuild한다 (동적 씬 대응). 최적화(incremental update)는 성능 문제 발생 시 추후 적용. + +### Narrow Phase Functions (voltex_physics::narrow) + +모든 함수는 위치(Vec3)와 형상 파라미터를 받아 `Option`를 반환한다. +Entity 정보는 호출부에서 채운다. + +```rust +pub fn sphere_vs_sphere( + pos_a: Vec3, radius_a: f32, + pos_b: Vec3, radius_b: f32, +) -> Option<(Vec3, f32, Vec3, Vec3)> // (normal, depth, point_a, point_b) + +pub fn sphere_vs_box( + sphere_pos: Vec3, radius: f32, + box_pos: Vec3, half_extents: Vec3, +) -> Option<(Vec3, f32, Vec3, Vec3)> + +pub fn box_vs_box( + pos_a: Vec3, half_a: Vec3, + pos_b: Vec3, half_b: Vec3, +) -> Option<(Vec3, f32, Vec3, Vec3)> +``` + +**box_vs_box**: SAT (Separating Axis Theorem) 기반. 축 정렬(AABB vs AABB)만 지원한다. +회전된 OBB는 Convex Hull 추가 시 GJK/EPA로 대체. + +### ECS Integration (voltex_physics::collision) + +```rust +pub fn detect_collisions(world: &World) -> Vec +``` + +1. `Transform` + `Collider`를 가진 entity를 `query2`로 수집 +2. 각 entity의 AABB 계산 (`collider.aabb(transform.position)`) +3. `BvhTree::build()` → `query_pairs()`로 broad phase 후보 추출 +4. 각 후보 쌍에 대해 collider 타입 조합에 맞는 narrow phase 함수 호출 +5. 접촉이 있으면 `ContactPoint`에 entity 정보를 채워 결과에 추가 + +## Conventions + +- 법선(normal): entity_a에서 entity_b 방향 +- 침투 깊이(depth): 양수 = 겹침, 0 이하 = 접촉 없음 +- 좌표계: 기존 voltex_math 규약 (오른손 좌표계) +- WGSL vec3 alignment 규칙은 AABB에 해당 없음 (GPU에 올리지 않음) + +## Test Plan + +### voltex_math (AABB) +- `new`, `from_center_half_extents` 생성 +- `center`, `half_extents` 계산 +- `contains_point`: 내부/외부/경계 +- `intersects`: 겹침/분리/접선 +- `merged`: 두 AABB 합집합 +- `surface_area`: 정확도 + +### voltex_physics +- **narrow::sphere_vs_sphere**: 겹침, 분리, 접선, 완전 포함 +- **narrow::sphere_vs_box**: 면/모서리/꼭짓점 접촉, 분리, 내부 포함 +- **narrow::box_vs_box**: 각 축 겹침, 분리, 접선 +- **collider::aabb**: Sphere/Box의 AABB 생성 +- **bvh::build**: 빈 입력, 단일, 다수 엔트리 +- **bvh::query_pairs**: 겹치는 쌍 정확성, 분리된 쌍 미포함 +- **collision::detect_collisions**: ECS 통합 E2E + +## Out of Scope (Phase 5-1) + +- Capsule, Convex Hull 콜라이더 +- GJK/EPA 알고리즘 +- 회전된 박스(OBB) 충돌 +- 리지드바디 시뮬레이션 (Phase 5-2) +- 레이캐스팅 (Phase 5-3) +- 연속 충돌 감지 (CCD) diff --git a/docs/superpowers/specs/2026-03-25-phase5-2-rigidbody.md b/docs/superpowers/specs/2026-03-25-phase5-2-rigidbody.md new file mode 100644 index 0000000..3536790 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-phase5-2-rigidbody.md @@ -0,0 +1,147 @@ +# Phase 5-2: Rigid Body Simulation — Design Spec + +## Overview + +`voltex_physics`에 리지드바디 시뮬레이션을 추가한다. +Semi-implicit Euler 적분 + 임펄스 기반 충돌 응답으로, 물체가 떨어지고 충돌하여 튕기는 기본 물리를 구현한다. + +## Scope + +- RigidBody 컴포넌트 (질량, 속도, 반발 계수) +- 중력 + Semi-implicit Euler 적분 +- 임펄스 기반 충돌 응답 (선형만) +- 위치 보정 (penetration resolution) +- physics_step 통합 함수 + +## Out of Scope + +- 각속도/회전 물리 (관성 텐서 필요, 추후 추가) +- 마찰 (Coulomb friction) +- Sequential Impulse 솔버 +- 연속 충돌 감지 (CCD) +- Sleep/Island 시스템 + +## Module Structure + +기존 `voltex_physics`에 추가: + +``` +crates/voltex_physics/src/ +├── (기존) collider.rs, contact.rs, narrow.rs, bvh.rs, collision.rs, lib.rs +├── rigid_body.rs — RigidBody 컴포넌트, PhysicsConfig +├── integrator.rs — Semi-implicit Euler 적분 +└── solver.rs — 임펄스 기반 충돌 응답 + 위치 보정 + physics_step +``` + +## Types + +### RigidBody (ECS 컴포넌트) + +```rust +#[derive(Debug, Clone, Copy)] +pub struct RigidBody { + pub velocity: Vec3, + pub angular_velocity: Vec3, + pub mass: f32, + pub restitution: f32, + pub gravity_scale: f32, +} +``` + +- `mass == 0.0` → 정적 물체 (무한 질량, 중력 영향 없음, 움직이지 않음) +- `mass > 0.0` → 동적 물체 +- `restitution` — 반발 계수 (0.0 = 완전 비탄성, 1.0 = 완전 탄성) +- `gravity_scale` — 중력 배율 (기본 1.0) +- `angular_velocity` — 필드만 존재, 이번 Phase에서는 적분하지 않음 + +**Methods:** +- `dynamic(mass: f32) -> Self` — 동적 물체 생성 (velocity=0, restitution=0.3, gravity_scale=1.0) +- `statik() -> Self` — 정적 물체 생성 (mass=0, velocity=0, restitution=0.3) +- `inv_mass(&self) -> f32` — mass가 0이면 0.0, 아니면 1.0/mass +- `is_static(&self) -> bool` — mass == 0.0 + +### PhysicsConfig + +```rust +pub struct PhysicsConfig { + pub gravity: Vec3, + pub fixed_dt: f32, +} +``` + +- `default()` → gravity = (0, -9.81, 0), fixed_dt = 1.0/60.0 + +## Functions + +### integrate (integrator.rs) + +```rust +pub fn integrate(world: &mut World, config: &PhysicsConfig) +``` + +Transform + RigidBody를 가진 동적 entity 순회: +1. `velocity += gravity * gravity_scale * dt` +2. `position += velocity * dt` + +정적 물체(mass == 0.0)는 건너뜀. + +### resolve_collisions (solver.rs) + +```rust +pub fn resolve_collisions(world: &mut World, contacts: &[ContactPoint]) +``` + +각 ContactPoint에 대해: +1. 두 entity의 RigidBody 조회 (없으면 스킵) +2. 상대 속도 계산: `v_rel = velocity_a - velocity_b` +3. 법선 방향 성분: `v_rel_n = v_rel · normal` +4. 분리 중이면 스킵: `v_rel_n > 0` +5. 반발 계수: `e = min(restitution_a, restitution_b)` +6. 임펄스 크기: `j = -(1 + e) * v_rel_n / (inv_mass_a + inv_mass_b)` +7. 속도 업데이트: `v_a += j * normal * inv_mass_a`, `v_b -= j * normal * inv_mass_b` + +**위치 보정 (Positional Correction):** +- 침투 깊이(depth)에 비례하여 물체를 법선 방향으로 분리 +- Baumgarte stabilization: `correction = max(depth - slop, 0) * percent / (inv_mass_a + inv_mass_b)` +- slop = 0.01 (작은 침투 허용), percent = 0.4 (보정 비율) +- `pos_a -= correction * inv_mass_a * normal`, `pos_b += correction * inv_mass_b * normal` + +### physics_step (solver.rs) + +```rust +pub fn physics_step(world: &mut World, config: &PhysicsConfig) +``` + +1. `integrate(world, config)` +2. `let contacts = detect_collisions(world)` +3. `resolve_collisions(world, &contacts)` + +## ECS Integration Notes + +- RigidBody는 Transform + Collider와 함께 entity에 추가 +- 물리 스텝은 게임 루프에서 fixed_update 타이밍에 호출 +- detect_collisions는 Phase 5-1의 기존 함수 재사용 + +## Conventions + +- 단위: SI (미터, 초, 킬로그램) +- 중력: (0, -9.81, 0) — Y-up 좌표계 +- 법선: Phase 5-1과 동일 (entity_a → entity_b 방향) + +## Test Plan + +### rigid_body.rs +- `dynamic()`: 기본값 확인, inv_mass 정확도 +- `statik()`: mass=0, inv_mass=0, is_static=true +- `with_restitution`: 설정 반영 + +### integrator.rs +- 중력 낙하: 1프레임 후 위치 = (0, -9.81/60/60 ≈ position 변화) +- 정적 물체: integrate 후 위치 불변 +- 초기 속도: velocity가 있는 경우 position 변화 확인 + +### solver.rs +- 두 동적 구체 정면 충돌: 속도 반전 확인 +- 동적 구체 vs 정적 바닥: 구체만 튕김, 바닥 불변 +- 위치 보정: 침투 깊이만큼 분리 +- physics_step E2E: 구체가 바닥 위에서 낙하 → 충돌 → 반발 diff --git a/docs/superpowers/specs/2026-03-25-phase5-3-raycasting.md b/docs/superpowers/specs/2026-03-25-phase5-3-raycasting.md new file mode 100644 index 0000000..416979a --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-phase5-3-raycasting.md @@ -0,0 +1,125 @@ +# Phase 5-3: Raycasting — Design Spec + +## Overview + +`voltex_math`에 Ray 타입을 추가하고, `voltex_physics`에 기하 교차 함수와 BVH 가속 레이캐스트를 구현한다. + +## Scope + +- Ray 타입 (voltex_math) +- ray_vs_aabb, ray_vs_sphere, ray_vs_box 교차 함수 +- BVH 가속 ECS 레이캐스트: `raycast(world, ray, max_dist) -> Option` + +## Out of Scope + +- Ray vs Plane, Triangle, Mesh +- 연속 레이캐스트 (sweep) +- 다중 hit 반환 (raycast_all) + +## Module 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 ECS 통합 (Create) +- `crates/voltex_physics/src/lib.rs` — 새 모듈 등록 (Modify) + +## Types + +### Ray (voltex_math) + +```rust +#[derive(Debug, Clone, Copy)] +pub struct Ray { + pub origin: Vec3, + pub direction: Vec3, +} +``` + +- `new(origin, direction) -> Self` — direction을 정규화하여 저장 +- `at(t: f32) -> Vec3` — `origin + direction * t` + +### RayHit (voltex_physics) + +```rust +#[derive(Debug, Clone, Copy)] +pub struct RayHit { + pub entity: Entity, + pub t: f32, + pub point: Vec3, + pub normal: Vec3, +} +``` + +## Functions + +### ray_vs_aabb (voltex_physics::ray) + +```rust +pub fn ray_vs_aabb(ray: &Ray, aabb: &AABB) -> Option +``` + +Slab method. t값만 반환 (BVH 순회용, 법선 불필요). +ray가 AABB 내부에서 시작하면 t=0 반환. + +### ray_vs_sphere (voltex_physics::ray) + +```rust +pub fn ray_vs_sphere(ray: &Ray, center: Vec3, radius: f32) -> Option<(f32, Vec3)> +``` + +이차방정식 풀이. (t, normal) 반환. +ray가 구 내부에서 시작하면 far intersection 반환. + +### ray_vs_box (voltex_physics::ray) + +```rust +pub fn ray_vs_box(ray: &Ray, center: Vec3, half_extents: Vec3) -> Option<(f32, Vec3)> +``` + +Slab method + 진입 면에서 법선 계산. (t, normal) 반환. +ray가 박스 내부에서 시작하면 t=0, 진행 방향 기준 법선 반환. + +### raycast (voltex_physics::raycast) + +```rust +pub fn raycast(world: &World, ray: &Ray, max_dist: f32) -> Option +``` + +1. Transform + Collider 가진 entity 수집, AABB 계산 +2. BvhTree::build()로 BVH 구축 +3. 모든 리프를 순회하며 ray_vs_aabb로 broad phase +4. AABB hit인 경우 콜라이더 타입별 정밀 교차: + - Sphere → ray_vs_sphere + - Box → ray_vs_box +5. t < max_dist인 hit 중 가장 가까운 것 반환 + +NOTE: BVH 조기 종료 최적화는 추후. 첫 구현은 모든 리프 검사 후 최소 t 선택. + +## Conventions + +- Ray direction은 항상 단위 벡터 +- t >= 0 인 교차만 유효 (ray 뒤쪽 무시) +- normal은 ray가 진입하는 면의 바깥 방향 +- max_dist: t의 상한 + +## Test Plan + +### voltex_math (Ray) +- new: direction 정규화 확인 +- at: 정확한 점 계산 + +### voltex_physics (ray.rs) +- ray_vs_aabb: hit, miss, 내부 시작 (t=0) +- ray_vs_sphere: hit (t, normal 정확도), miss, 접선, 내부 시작 +- ray_vs_box: 각 면 hit, miss, 내부 시작 + +### voltex_physics (raycast.rs) +- 빈 world → None +- 단일 entity hit +- 여러 entity 중 가장 가까운 것 반환 +- miss (max_dist 초과) +- Sphere + Box 혼합 diff --git a/docs/superpowers/specs/2026-03-25-phase6-1-audio.md b/docs/superpowers/specs/2026-03-25-phase6-1-audio.md new file mode 100644 index 0000000..83f0e9b --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-phase6-1-audio.md @@ -0,0 +1,203 @@ +# Phase 6-1: Audio System Foundation — Design Spec + +## Overview + +`voltex_audio` crate를 신규 생성한다. WAV 파서, WASAPI 백엔드(Windows), 채널 기반 오디오 스레드로 기본 사운드 재생을 구현한다. + +## Scope + +- WAV 파서 (PCM 16-bit, mono/stereo) +- AudioClip (파싱된 오디오 데이터, f32 샘플) +- WASAPI 백엔드 (Windows, shared mode, FFI 직접 호출) +- 오디오 스레드 + mpsc channel 명령 처리 +- AudioSystem API (load_wav, play, stop, set_volume) +- 믹싱 (다중 동시 재생, 볼륨, 루프) + +## Out of Scope + +- macOS (CoreAudio), Linux (ALSA/PulseAudio) 백엔드 +- 3D 오디오 (거리 감쇠, 패닝) +- 믹서 (채널 그룹, 페이드) +- OGG/Vorbis 디코더 +- 비동기 로딩 +- ECS 통합 (AudioSource 컴포넌트 등) + +## Module Structure + +``` +crates/voltex_audio/ +├── Cargo.toml +└── src/ + ├── lib.rs — public exports + ├── wav.rs — WAV 파서 + ├── audio_clip.rs — AudioClip 타입 + ├── backend_wasapi.rs — WASAPI FFI (Windows 전용, cfg(target_os)) + ├── mixer_thread.rs — 오디오 스레드 + 믹싱 로직 + └── audio_system.rs — AudioSystem (메인 스레드 API) +``` + +## Dependencies + +- 없음 (외부 crate 없음) +- Windows FFI: `windows-sys` 스타일이 아닌 직접 `extern "system"` 선언 +- `std::sync::mpsc`, `std::thread`, `std::sync::Arc` + +## Types + +### AudioClip + +```rust +#[derive(Clone)] +pub struct AudioClip { + pub samples: Vec, // interleaved, normalized -1.0~1.0 + pub sample_rate: u32, + pub channels: u16, +} +``` + +### AudioCommand (내부) + +```rust +enum AudioCommand { + Play { clip_index: usize, volume: f32, looping: bool }, + Stop { clip_index: usize }, + SetVolume { clip_index: usize, volume: f32 }, + StopAll, + Shutdown, +} +``` + +### PlayingSound (내부, 오디오 스레드) + +```rust +struct PlayingSound { + clip_index: usize, + position: usize, // 현재 샘플 위치 + volume: f32, + looping: bool, +} +``` + +### AudioSystem (public API) + +```rust +pub struct AudioSystem { + sender: Sender, + _thread: JoinHandle<()>, +} +``` + +**Methods:** +- `new(clips: Vec) -> Result` — WASAPI 초기화, 오디오 스레드 시작. clips를 Arc로 공유. +- `play(clip_index: usize, volume: f32, looping: bool)` — 재생 명령 +- `stop(clip_index: usize)` — 정지 +- `set_volume(clip_index: usize, volume: f32)` — 볼륨 변경 +- `stop_all()` — 전체 정지 +- `Drop` — Shutdown + join + +## WAV Parser + +### 지원 포맷 +- RIFF WAV, PCM (format_tag = 1) +- 16-bit 샘플만 +- Mono (1ch) 또는 Stereo (2ch) +- 임의 sample rate + +### 파싱 과정 +1. RIFF 헤더 검증 ("RIFF", "WAVE") +2. "fmt " 청크: format_tag, channels, sample_rate, bits_per_sample 읽기 +3. "data" 청크: raw PCM 데이터 +4. i16 → f32 변환: `sample as f32 / 32768.0` + +### 에러 +- 파일 읽기 실패 +- RIFF/WAVE 시그니처 불일치 +- format_tag != 1 (non-PCM) +- bits_per_sample != 16 +- data 청크 미발견 + +```rust +pub fn parse_wav(data: &[u8]) -> Result +``` + +바이트 슬라이스를 받아 파싱. 파일 I/O는 호출부에서 처리. + +## WASAPI Backend + +### 초기화 순서 +1. `CoInitializeEx(null, COINIT_MULTITHREADED)` +2. `CoCreateInstance(CLSID_MMDeviceEnumerator)` → `IMMDeviceEnumerator` +3. `enumerator.GetDefaultAudioEndpoint(eRender, eConsole)` → `IMMDevice` +4. `device.Activate(IID_IAudioClient)` → `IAudioClient` +5. `client.GetMixFormat()` — 장치 기본 포맷 확인 +6. `client.Initialize(AUDCLNT_SHAREMODE_SHARED, ...)` — shared mode +7. `client.GetService(IID_IAudioRenderClient)` → `IAudioRenderClient` +8. `client.Start()` — 재생 시작 + +### 버퍼 쓰기 루프 +1. `client.GetCurrentPadding()` → 사용 가능한 프레임 수 계산 +2. `render_client.GetBuffer(frames)` → 버퍼 포인터 +3. 믹싱된 샘플을 버퍼에 쓰기 +4. `render_client.ReleaseBuffer(frames)` +5. `thread::sleep(Duration::from_millis(5))` — CPU 사용 조절 + +### FFI 타입 +COM 인터페이스를 vtable 기반 raw pointer로 직접 선언. `#[repr(C)]` 구조체 사용. + +### 샘플 레이트 변환 +클립의 sample_rate와 장치의 sample_rate가 다른 경우, 선형 보간으로 리샘플링. + +### 채널 변환 +- Mono 클립 → Stereo 장치: 양 채널에 동일 샘플 +- Stereo 클립 → 장치 채널 수 매칭 + +## Mixing + +오디오 스레드에서 수행하는 순수 함수: + +```rust +fn mix_sounds( + output: &mut [f32], + playing: &mut Vec, + clips: &[AudioClip], + device_sample_rate: u32, + device_channels: u16, +) +``` + +1. output 버퍼를 0으로 초기화 +2. 각 PlayingSound에 대해: + - 클립에서 샘플 읽기 (리샘플링/채널 변환 적용) + - volume 곱하기 + - output에 합산 + - position 전진. 끝에 도달하면 looping이면 0으로, 아니면 제거 +3. 클리핑: -1.0~1.0으로 clamp + +## Test Plan + +### wav.rs (단위 테스트 가능) +- 유효한 WAV 바이트 → AudioClip 파싱 성공 +- 잘못된 RIFF 헤더 → 에러 +- non-PCM format → 에러 +- 24-bit → 에러 (16-bit만 지원) +- 빈 data 청크 → 빈 samples +- i16→f32 변환 정확도 (32767 → ~1.0, -32768 → -1.0) + +### audio_clip.rs +- 생성, 속성 확인 + +### mixer_thread.rs (순수 함수 테스트) +- 단일 사운드 믹싱 → 출력 = 클립 샘플 * 볼륨 +- 두 사운드 합산 +- 클리핑 (-1.0~1.0) +- 루핑: 끝에 도달 후 처음부터 재개 +- 비루핑: 끝에 도달 후 제거 + +### WASAPI + AudioSystem +- 실제 하드웨어 필요 → `examples/audio_demo`로 수동 테스트 +- WAV 파일 로드 → play → 소리 확인 + +## Example + +`examples/audio_demo` — WAV 파일을 로드하고 키 입력으로 재생/정지하는 데모. +테스트용 WAV 파일은 코드로 생성 (440Hz 사인파, 1초). diff --git a/docs/superpowers/specs/2026-03-25-phase6-2-3d-audio.md b/docs/superpowers/specs/2026-03-25-phase6-2-3d-audio.md new file mode 100644 index 0000000..a593534 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-phase6-2-3d-audio.md @@ -0,0 +1,163 @@ +# Phase 6-2: 3D Audio — Design Spec + +## Overview + +`voltex_audio`에 3D 공간 오디오를 추가한다. 거리 기반 감쇠와 스테레오 패닝으로 소리의 공간감을 표현한다. + +## Scope + +- Listener (위치, 방향) +- SpatialParams (이미터 위치, min/max 거리) +- 거리 감쇠 (inverse distance) +- 스테레오 패닝 (equal-power) +- play_3d, set_listener API + +## Out of Scope + +- 도플러 효과 +- HRTF +- Reverb/Echo +- 다중 리스너 + +## Module Structure + +### voltex_audio (수정/추가) +- `crates/voltex_audio/src/spatial.rs` — Listener, SpatialParams, 감쇠/패닝 순수 함수 (Create) +- `crates/voltex_audio/src/mixing.rs` — PlayingSound에 spatial 추가, mix에 spatial 적용 (Modify) +- `crates/voltex_audio/src/audio_system.rs` — Play3d, SetListener 명령 추가 (Modify) +- `crates/voltex_audio/src/lib.rs` — spatial 모듈 등록 (Modify) + +## Dependencies + +- `voltex_math` — Vec3 (위치, 방향 계산) +- `crates/voltex_audio/Cargo.toml`에 voltex_math 의존 추가 + +## Types + +### Listener + +```rust +#[derive(Debug, Clone, Copy)] +pub struct Listener { + pub position: Vec3, + pub forward: Vec3, + pub right: Vec3, +} +``` + +- `default()` → position=ZERO, forward=-Z, right=X + +### SpatialParams + +```rust +#[derive(Debug, Clone, Copy)] +pub struct SpatialParams { + pub position: Vec3, + pub min_distance: f32, + pub max_distance: f32, +} +``` + +- `new(position, min_distance, max_distance)` — 생성 +- `at(position)` — min=1.0, max=50.0 기본값으로 간편 생성 + +## Functions (spatial.rs) + +### distance_attenuation + +```rust +pub fn distance_attenuation(distance: f32, min_dist: f32, max_dist: f32) -> f32 +``` + +- distance <= min_dist → 1.0 +- distance >= max_dist → 0.0 +- 사이: `min_dist / distance` (inverse distance, clamped) + +### stereo_pan + +```rust +pub fn stereo_pan(listener: &Listener, emitter_pos: Vec3) -> (f32, f32) +``` + +1. direction = (emitter_pos - listener.position).normalize() +2. pan = direction.dot(listener.right) — -1.0(왼쪽) ~ 1.0(오른쪽) +3. Equal-power panning: + - left_gain = cos(pan * PI/4 + PI/4) — pan=-1일 때 1.0, pan=1일 때 0.0 + - right_gain = sin(pan * PI/4 + PI/4) — pan=-1일 때 0.0, pan=1일 때 1.0 +4. 이미터가 리스너 위치와 동일하면 (1.0, 1.0) 반환 + +### compute_spatial_gains + +```rust +pub fn compute_spatial_gains(listener: &Listener, spatial: &SpatialParams) -> (f32, f32, f32) +``` + +Returns (attenuation, left_gain, right_gain). +편의 함수: distance_attenuation + stereo_pan 결합. + +## Mixing Integration + +### PlayingSound 변경 + +```rust +pub struct PlayingSound { + pub clip_index: usize, + pub position: usize, + pub volume: f32, + pub looping: bool, + pub spatial: Option, // NEW +} +``` + +### mix_sounds 변경 + +시그니처에 `listener: &Listener` 파라미터 추가: + +```rust +pub fn mix_sounds( + output: &mut [f32], + playing: &mut Vec, + clips: &[AudioClip], + device_sample_rate: u32, + device_channels: u16, + frames: usize, + listener: &Listener, // NEW +) +``` + +spatial이 Some인 사운드: +1. compute_spatial_gains로 (attenuation, left, right) 계산 +2. 볼륨에 attenuation 곱하기 +3. 스테레오 출력에 left/right gain 적용 + +spatial이 None인 사운드 (2D): +- 기존 동작 유지 (전체 볼륨 균등) + +## AudioSystem API 변경 + +### 새 명령 + +```rust +AudioCommand::Play3d { clip_index, volume, looping, spatial: SpatialParams }, +AudioCommand::SetListener { position: Vec3, forward: Vec3, right: Vec3 }, +``` + +### 새 메서드 + +```rust +pub fn play_3d(&self, clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams) +pub fn set_listener(&self, position: Vec3, forward: Vec3, right: Vec3) +``` + +## Test Plan + +### spatial.rs +- distance_attenuation: at min_dist (1.0), at max_dist (0.0), between, zero distance +- stereo_pan: right side → right gain > left, left side → left gain > right, front → roughly equal, same position → (1.0, 1.0) +- compute_spatial_gains: combines both correctly + +### mixing.rs (spatial integration) +- 2D sound unchanged (spatial=None, listener 무관) +- 3D sound at max_distance → near-silent output +- 3D sound at min_distance → full volume +- 3D sound on right → right channel louder diff --git a/docs/superpowers/specs/2026-03-25-phase6-3-mixer.md b/docs/superpowers/specs/2026-03-25-phase6-3-mixer.md new file mode 100644 index 0000000..103c533 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-phase6-3-mixer.md @@ -0,0 +1,128 @@ +# Phase 6-3: Mixer — Design Spec + +## Overview + +`voltex_audio`에 그룹 기반 믹서를 추가한다. BGM/SFX/Voice 그룹별 독립 볼륨 제어와 페이드 인/아웃을 지원한다. + +## Scope + +- MixGroup enum (Master, Bgm, Sfx, Voice) +- GroupState (볼륨 + 페이드) +- MixerState (전체 그룹 관리, effective_volume = group * master) +- PlayingSound에 group 필드 추가 +- mix_sounds에 mixer 파라미터 추가 +- AudioSystem API: set_group_volume, fade_group + +## Out of Scope + +- 동적 그룹 생성 (고정 4개만) +- 그룹 간 라우팅/버스 +- 이펙트 체인 (reverb, EQ 등) +- 페이드 커브 (선형만) + +## Module Structure + +- `crates/voltex_audio/src/mix_group.rs` — MixGroup, GroupState, MixerState (Create) +- `crates/voltex_audio/src/mixing.rs` — PlayingSound에 group, mix_sounds에 mixer (Modify) +- `crates/voltex_audio/src/audio_system.rs` — SetGroupVolume, FadeGroup 명령 (Modify) +- `crates/voltex_audio/src/lib.rs` — mix_group 모듈 등록 (Modify) + +## Types + +### MixGroup + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MixGroup { + Master, + Bgm, + Sfx, + Voice, +} +``` + +### GroupState + +```rust +pub struct GroupState { + pub volume: f32, + pub fade_target: f32, + pub fade_speed: f32, +} +``` + +- `new()` → volume=1.0, fade_target=1.0, fade_speed=0.0 +- `tick(dt: f32)` — volume을 fade_target으로 fade_speed * dt만큼 이동. 도달하면 fade_speed=0. + +### MixerState + +```rust +pub struct MixerState { + groups: [GroupState; 4], // Master, Bgm, Sfx, Voice 순서 +} +``` + +배열 인덱스로 접근 (HashMap 대신 간결하게). + +- `new()` — 전부 volume=1.0 +- `set_volume(group, volume)` — 즉시 설정, fade 중지 +- `fade(group, target, duration)` — fade_speed = |target - current| / duration +- `tick(dt)` — 모든 그룹 업데이트 +- `volume(group) -> f32` — 해당 그룹 현재 볼륨 +- `effective_volume(group) -> f32` — group.volume * master.volume (Master 그룹은 자기 자신만) + +## Mixing Integration + +### PlayingSound 변경 + +```rust +pub group: MixGroup, // 기본 Sfx +``` + +- `new()` → group = MixGroup::Sfx +- `new_3d()` → group = MixGroup::Sfx +- 기존 생성자에 group 파라미터 추가하지 않음 (기본값 사용). 필요 시 직접 설정. + +### mix_sounds 변경 + +`mixer: &MixerState` 파라미터 추가. + +각 사운드의 최종 볼륨 계산: +``` +base_volume = sound.volume * mixer.effective_volume(sound.group) +``` +이후 spatial gains 적용은 기존과 동일. + +### AudioCommand 추가 + +```rust +SetGroupVolume { group: MixGroup, volume: f32 }, +FadeGroup { group: MixGroup, target: f32, duration: f32 }, +``` + +### AudioSystem 메서드 추가 + +```rust +pub fn set_group_volume(&self, group: MixGroup, volume: f32) +pub fn fade_group(&self, group: MixGroup, target: f32, duration: f32) +``` + +### 오디오 스레드 + +- `MixerState` 인스턴스 보유 +- 매 루프: `mixer.tick(dt)` 호출 (dt ≈ 5ms) +- mix_sounds에 `&mixer` 전달 + +## Test Plan + +### mix_group.rs +- GroupState::tick: 페이드 진행, 목표 도달 시 정지 +- MixerState::set_volume: 즉시 반영 +- MixerState::fade: 여러 tick 후 목표 도달 +- MixerState::effective_volume: group * master +- Master=0이면 모든 그룹 effective=0 + +### mixing.rs (통합) +- group 볼륨 적용: Sfx volume=0.5 → 출력 절반 +- master=0 → 전체 무음 +- 기존 2D/3D 테스트는 mixer volume=1.0으로 변화 없음