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:
1155
docs/superpowers/plans/2026-03-25-phase5-1-collision-detection.md
Normal file
1155
docs/superpowers/plans/2026-03-25-phase5-1-collision-detection.md
Normal file
File diff suppressed because it is too large
Load Diff
614
docs/superpowers/plans/2026-03-25-phase5-2-rigidbody.md
Normal file
614
docs/superpowers/plans/2026-03-25-phase5-2-rigidbody.md
Normal 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"
|
||||
```
|
||||
607
docs/superpowers/plans/2026-03-25-phase5-3-raycasting.md
Normal file
607
docs/superpowers/plans/2026-03-25-phase5-3-raycasting.md
Normal 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"
|
||||
```
|
||||
1191
docs/superpowers/plans/2026-03-25-phase6-1-audio.md
Normal file
1191
docs/superpowers/plans/2026-03-25-phase6-1-audio.md
Normal file
File diff suppressed because it is too large
Load Diff
462
docs/superpowers/plans/2026-03-25-phase6-2-3d-audio.md
Normal file
462
docs/superpowers/plans/2026-03-25-phase6-2-3d-audio.md
Normal 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"
|
||||
```
|
||||
412
docs/superpowers/plans/2026-03-25-phase6-3-mixer.md
Normal file
412
docs/superpowers/plans/2026-03-25-phase6-3-mixer.md
Normal 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"
|
||||
```
|
||||
Reference in New Issue
Block a user