docs: add Phase 5-1 through 6-3 specs, plans, and Cargo.lock

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 11:37:16 +09:00
parent 0991f74275
commit 2b3e3a6a5e
13 changed files with 5400 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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::<Transform, RigidBody>()
.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::<RigidBody>(entity) {
rb.velocity = new_velocity;
}
if let Some(t) = world.get_mut::<Transform>(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::<RigidBody>(e).unwrap();
let t = world.get::<Transform>(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::<Transform>(e).unwrap();
assert!(approx(t.position.y, 5.0));
let rb = world.get::<RigidBody>(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::<Transform>(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::<RigidBody>(contact.entity_a).copied();
let rb_b = world.get::<RigidBody>(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::<RigidBody>(entity) {
rb.velocity = rb.velocity + dv;
}
}
// Apply position corrections
for (entity, dp) in position_changes {
if let Some(t) = world.get_mut::<Transform>(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::<RigidBody>(a).unwrap().velocity;
let vb = world.get::<RigidBody>(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::<RigidBody>(ball).unwrap();
let floor_rb = world.get::<RigidBody>(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::<Transform>(a).unwrap().position;
let pb = world.get::<Transform>(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::<Transform>(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::<Transform>(a).unwrap().position;
let pb = world.get::<Transform>(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"
```

View File

@@ -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<f32> {
let mut t_min = f32::NEG_INFINITY;
let mut t_max = f32::INFINITY;
let o = [ray.origin.x, ray.origin.y, ray.origin.z];
let d = [ray.direction.x, ray.direction.y, ray.direction.z];
let bmin = [aabb.min.x, aabb.min.y, aabb.min.z];
let bmax = [aabb.max.x, aabb.max.y, aabb.max.z];
for i in 0..3 {
if d[i].abs() < 1e-8 {
// Ray parallel to slab
if o[i] < bmin[i] || o[i] > bmax[i] {
return None;
}
} else {
let inv_d = 1.0 / d[i];
let mut t1 = (bmin[i] - o[i]) * inv_d;
let mut t2 = (bmax[i] - o[i]) * inv_d;
if t1 > t2 {
std::mem::swap(&mut t1, &mut t2);
}
t_min = t_min.max(t1);
t_max = t_max.min(t2);
if t_min > t_max {
return None;
}
}
}
if t_max < 0.0 {
return None; // AABB is behind ray
}
Some(t_min.max(0.0))
}
/// Ray vs Sphere. Returns (t, normal) or None.
pub fn ray_vs_sphere(ray: &Ray, center: Vec3, radius: f32) -> Option<(f32, Vec3)> {
let oc = ray.origin - center;
let a = ray.direction.dot(ray.direction); // 1.0 if normalized
let b = 2.0 * oc.dot(ray.direction);
let c = oc.dot(oc) - radius * radius;
let discriminant = b * b - 4.0 * a * c;
if discriminant < 0.0 {
return None;
}
let sqrt_d = discriminant.sqrt();
let mut t = (-b - sqrt_d) / (2.0 * a);
if t < 0.0 {
t = (-b + sqrt_d) / (2.0 * a); // try far intersection
if t < 0.0 {
return None; // both behind ray
}
}
let point = ray.at(t);
let normal = (point - center).normalize();
Some((t, normal))
}
/// Ray vs axis-aligned Box. Returns (t, normal) or None.
pub fn ray_vs_box(ray: &Ray, center: Vec3, half_extents: Vec3) -> Option<(f32, Vec3)> {
let aabb = AABB::from_center_half_extents(center, half_extents);
let t = ray_vs_aabb(ray, &aabb)?;
// Compute normal from the face where the ray enters
if t == 0.0 {
// Ray starts inside box — return t=0 with normal opposite to ray direction
// Find closest face
let bmin = aabb.min;
let bmax = aabb.max;
let p = ray.origin;
let faces: [(f32, Vec3); 6] = [
(p.x - bmin.x, Vec3::new(-1.0, 0.0, 0.0)),
(bmax.x - p.x, Vec3::new(1.0, 0.0, 0.0)),
(p.y - bmin.y, Vec3::new(0.0, -1.0, 0.0)),
(bmax.y - p.y, Vec3::new(0.0, 1.0, 0.0)),
(p.z - bmin.z, Vec3::new(0.0, 0.0, -1.0)),
(bmax.z - p.z, Vec3::new(0.0, 0.0, 1.0)),
];
let mut min_dist = f32::INFINITY;
let mut normal = Vec3::Y;
for (dist, n) in &faces {
if *dist < min_dist {
min_dist = *dist;
normal = *n;
}
}
return Some((0.0, normal));
}
// Find which face was hit by checking the hit point
let hit = ray.at(t);
let rel = hit - center;
let hx = half_extents.x;
let hy = half_extents.y;
let hz = half_extents.z;
let normal = if (rel.x - hx).abs() < 1e-4 {
Vec3::X
} else if (rel.x + hx).abs() < 1e-4 {
Vec3::new(-1.0, 0.0, 0.0)
} else if (rel.y - hy).abs() < 1e-4 {
Vec3::Y
} else if (rel.y + hy).abs() < 1e-4 {
Vec3::new(0.0, -1.0, 0.0)
} else if (rel.z - hz).abs() < 1e-4 {
Vec3::Z
} else {
Vec3::new(0.0, 0.0, -1.0)
};
Some((t, normal))
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
fn approx_vec(a: Vec3, b: Vec3) -> bool {
approx(a.x, b.x) && approx(a.y, b.y) && approx(a.z, b.z)
}
// ray_vs_aabb tests
#[test]
fn test_aabb_hit() {
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
let aabb = AABB::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::ONE);
let t = ray_vs_aabb(&ray, &aabb).unwrap();
assert!(approx(t, 4.0));
}
#[test]
fn test_aabb_miss() {
let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X);
let aabb = AABB::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::ONE);
assert!(ray_vs_aabb(&ray, &aabb).is_none());
}
#[test]
fn test_aabb_inside() {
let ray = Ray::new(Vec3::ZERO, Vec3::X);
let aabb = AABB::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::ONE);
let t = ray_vs_aabb(&ray, &aabb).unwrap();
assert!(approx(t, 0.0));
}
// ray_vs_sphere tests
#[test]
fn test_sphere_hit() {
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
let (t, normal) = ray_vs_sphere(&ray, Vec3::ZERO, 1.0).unwrap();
assert!(approx(t, 4.0));
assert!(approx_vec(normal, Vec3::new(-1.0, 0.0, 0.0)));
}
#[test]
fn test_sphere_miss() {
let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X);
assert!(ray_vs_sphere(&ray, Vec3::ZERO, 1.0).is_none());
}
#[test]
fn test_sphere_tangent() {
let ray = Ray::new(Vec3::new(-5.0, 1.0, 0.0), Vec3::X);
let result = ray_vs_sphere(&ray, Vec3::ZERO, 1.0);
// Tangent: discriminant ≈ 0, should still hit
assert!(result.is_some());
}
#[test]
fn test_sphere_inside() {
let ray = Ray::new(Vec3::ZERO, Vec3::X);
let (t, _normal) = ray_vs_sphere(&ray, Vec3::ZERO, 2.0).unwrap();
// Should return far intersection
assert!(approx(t, 2.0));
}
// ray_vs_box tests
#[test]
fn test_box_hit_face() {
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
let (t, normal) = ray_vs_box(&ray, Vec3::ZERO, Vec3::ONE).unwrap();
assert!(approx(t, 4.0));
assert!(approx_vec(normal, Vec3::new(-1.0, 0.0, 0.0)));
}
#[test]
fn test_box_miss() {
let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X);
assert!(ray_vs_box(&ray, Vec3::ZERO, Vec3::ONE).is_none());
}
#[test]
fn test_box_inside() {
let ray = Ray::new(Vec3::ZERO, Vec3::X);
let (t, _normal) = ray_vs_box(&ray, Vec3::ZERO, Vec3::ONE).unwrap();
assert!(approx(t, 0.0));
}
}
```
- [ ] **Step 2: lib.rs에 ray 모듈 등록**
NOTE: voltex_physics already has modules. The new module name `ray` should not conflict.
```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<RayHit> {
// 1. Gather entities with Transform + Collider
let entities: Vec<(Entity, Vec3, Collider)> = world
.query2::<Transform, Collider>()
.into_iter()
.map(|(e, t, c)| (e, t.position, *c))
.collect();
if entities.is_empty() {
return None;
}
// 2. Build BVH
let bvh_entries: Vec<(Entity, voltex_math::AABB)> = entities
.iter()
.map(|(e, pos, col)| (*e, col.aabb(*pos)))
.collect();
let bvh = BvhTree::build(&bvh_entries);
// 3. Test all leaves (broad + narrow)
let mut closest: Option<RayHit> = None;
for (entity, pos, collider) in &entities {
let aabb = collider.aabb(*pos);
// Broad phase: ray vs AABB
let aabb_t = match ray_tests::ray_vs_aabb(ray, &aabb) {
Some(t) if t <= max_dist => t,
_ => continue,
};
// Early skip if we already have a closer hit
if let Some(ref hit) = closest {
if aabb_t >= hit.t {
continue;
}
}
// Narrow phase: ray vs collider
let result = match collider {
Collider::Sphere { radius } => {
ray_tests::ray_vs_sphere(ray, *pos, *radius)
}
Collider::Box { half_extents } => {
ray_tests::ray_vs_box(ray, *pos, *half_extents)
}
};
if let Some((t, normal)) = result {
if t <= max_dist {
if closest.is_none() || t < closest.as_ref().unwrap().t {
closest = Some(RayHit {
entity: *entity,
t,
point: ray.at(t),
normal,
});
}
}
}
}
closest
}
#[cfg(test)]
mod tests {
use super::*;
use voltex_ecs::World;
use voltex_ecs::Transform;
use voltex_math::Vec3;
use crate::Collider;
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-3
}
#[test]
fn test_empty_world() {
let world = World::new();
let ray = Ray::new(Vec3::ZERO, Vec3::X);
assert!(raycast(&world, &ray, 100.0).is_none());
}
#[test]
fn test_hit_sphere() {
let mut world = World::new();
let e = world.spawn();
world.add(e, Transform::from_position(Vec3::new(5.0, 0.0, 0.0)));
world.add(e, Collider::Sphere { radius: 1.0 });
let ray = Ray::new(Vec3::ZERO, Vec3::X);
let hit = raycast(&world, &ray, 100.0).unwrap();
assert_eq!(hit.entity, e);
assert!(approx(hit.t, 4.0)); // 5.0 - 1.0 radius
assert!(approx(hit.point.x, 4.0));
}
#[test]
fn test_closest_of_multiple() {
let mut world = World::new();
let far = world.spawn();
world.add(far, Transform::from_position(Vec3::new(10.0, 0.0, 0.0)));
world.add(far, Collider::Sphere { radius: 1.0 });
let near = world.spawn();
world.add(near, Transform::from_position(Vec3::new(3.0, 0.0, 0.0)));
world.add(near, Collider::Sphere { radius: 1.0 });
let ray = Ray::new(Vec3::ZERO, Vec3::X);
let hit = raycast(&world, &ray, 100.0).unwrap();
assert_eq!(hit.entity, near);
assert!(approx(hit.t, 2.0)); // 3.0 - 1.0
}
#[test]
fn test_max_dist() {
let mut world = World::new();
let e = world.spawn();
world.add(e, Transform::from_position(Vec3::new(50.0, 0.0, 0.0)));
world.add(e, Collider::Sphere { radius: 1.0 });
let ray = Ray::new(Vec3::ZERO, Vec3::X);
assert!(raycast(&world, &ray, 10.0).is_none()); // too far
}
#[test]
fn test_hit_box() {
let mut world = World::new();
let e = world.spawn();
world.add(e, Transform::from_position(Vec3::new(5.0, 0.0, 0.0)));
world.add(e, Collider::Box { half_extents: Vec3::ONE });
let ray = Ray::new(Vec3::ZERO, Vec3::X);
let hit = raycast(&world, &ray, 100.0).unwrap();
assert_eq!(hit.entity, e);
assert!(approx(hit.t, 4.0)); // 5.0 - 1.0 half_extent
}
#[test]
fn test_mixed_sphere_box() {
let mut world = World::new();
let sphere = world.spawn();
world.add(sphere, Transform::from_position(Vec3::new(10.0, 0.0, 0.0)));
world.add(sphere, Collider::Sphere { radius: 1.0 });
let box_e = world.spawn();
world.add(box_e, Transform::from_position(Vec3::new(3.0, 0.0, 0.0)));
world.add(box_e, Collider::Box { half_extents: Vec3::ONE });
let ray = Ray::new(Vec3::ZERO, Vec3::X);
let hit = raycast(&world, &ray, 100.0).unwrap();
assert_eq!(hit.entity, box_e); // box is closer
assert!(approx(hit.t, 2.0)); // 3.0 - 1.0
}
}
```
- [ ] **Step 2: lib.rs에 raycast 모듈 등록**
```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"
```

File diff suppressed because it is too large Load Diff

View File

@@ -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<SpatialParams>, // 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<f32>,
playing: &mut Vec<PlayingSound>,
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"
```

View File

@@ -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<SpatialParams>,
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<f32>,
playing: &mut Vec<PlayingSound>,
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"
```