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

22
Cargo.lock generated
View File

@@ -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"

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"
```

View File

@@ -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<BvhNode>,
}
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<ContactPoint>`를 반환한다.
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<ContactPoint>
```
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)

View File

@@ -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: 구체가 바닥 위에서 낙하 → 충돌 → 반발

View File

@@ -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<RayHit>`
## 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<f32>
```
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<RayHit>
```
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 혼합

View File

@@ -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<f32>, // 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<AudioCommand>,
_thread: JoinHandle<()>,
}
```
**Methods:**
- `new(clips: Vec<AudioClip>) -> Result<Self, String>` — 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<AudioClip, String>
```
바이트 슬라이스를 받아 파싱. 파일 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<PlayingSound>,
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초).

View File

@@ -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<SpatialParams>, // NEW
}
```
### mix_sounds 변경
시그니처에 `listener: &Listener` 파라미터 추가:
```rust
pub fn mix_sounds(
output: &mut [f32],
playing: &mut Vec<PlayingSound>,
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

View File

@@ -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으로 변화 없음