Files
game_engine/docs/superpowers/plans/2026-03-25-phase6-2-3d-audio.md
2026-03-25 11:37:16 +09:00

14 KiB

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 의존 추가

[dependencies]
voltex_math.workspace = true
  • Step 2: spatial.rs 작성
// 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 모듈 등록
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: 커밋
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:

pub struct PlayingSound {
    pub clip_index: usize,
    pub position: usize,
    pub volume: f32,
    pub looping: bool,
    pub spatial: Option<SpatialParams>,  // NEW
}
  1. Update PlayingSound::new to set spatial: None:
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) }
    }
}
  1. Add listener: &Listener parameter to mix_sounds:
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
)
  1. Inside the per-sound loop, before writing to output, compute spatial gains:
// 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
  1. Update ALL existing tests to pass &Listener::default() as the last argument to mix_sounds.

  2. Add new spatial tests:

    #[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: 커밋
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:

use crate::spatial::{Listener, SpatialParams};

Add to AudioCommand enum:

    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에 새 메서드 추가
    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:

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:

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: 커밋
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 아래에:

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

- **도플러 효과** — 미구현. 상대 속도 기반 주파수 변조.
- **HRTF** — 미구현. 헤드폰용 3D 정위.
- **Reverb/Echo** — 미구현. 환경 반사음.
- **Occlusion** — 미구현. 벽 뒤 소리 차단.
  • Step 3: 커밋
git add docs/STATUS.md docs/DEFERRED.md
git commit -m "docs: add Phase 6-2 3D audio status and deferred items"