Something went wrong. Try again.
Homebrew 4D Game Engine In Rust, inspired by Engine4D
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244//! 4D Spatial Audio Calculations//!//! This module implements spatial audio for 4D environments with the following model://!//! ## 4D Audio Model//!//! - **Volume attenuation** uses the full 4D Euclidean distance between the listener//! and sound source. This means sounds in the W dimension also attenuate naturally.//!//! - **Stereo panning** operates on the XZ plane projection only. This is intentional://! standard stereo output can only represent left/right positioning. The Y and W//! components are ignored for panning purposes since://! - Y (typically vertical) doesn't map to left/right speakers//! - W (4th dimension) has no physical speaker representation//!//! This design allows 4D games to have spatially accurate volume falloff while//! maintaining familiar stereo positioning behavior.
use rust4d_math::Vec4;
/// Epsilon value for panning calculations to avoid division by zero/// when the sound source is directly above/below or at the same XZ position as the listener.const PANNING_EPSILON: f32 = 0.0001;
/// Configuration for spatial audio playback#[derive(Debug, Clone, Copy)]pub struct SpatialConfig { /// Position of the sound source in 4D space pub position: Vec4, /// Distance at which volume is at full (1.0) pub min_distance: f32, /// Distance at which volume reaches zero pub max_distance: f32,}
impl SpatialConfig { /// Default min distance for spatial sounds pub const DEFAULT_MIN_DISTANCE: f32 = 1.0; /// Default max distance for spatial sounds pub const DEFAULT_MAX_DISTANCE: f32 = 50.0;
/// Create a new spatial config with default distance parameters pub fn new(position: Vec4) -> Self { Self { position, min_distance: Self::DEFAULT_MIN_DISTANCE, max_distance: Self::DEFAULT_MAX_DISTANCE, } }
/// Set the minimum distance (full volume) pub fn with_min_distance(mut self, distance: f32) -> Self { self.min_distance = distance.max(0.0); self }
/// Set the maximum distance (zero volume) pub fn with_max_distance(mut self, distance: f32) -> Self { self.max_distance = distance.max(self.min_distance); self }}
impl Default for SpatialConfig { fn default() -> Self { Self::new(Vec4::ZERO) }}
/// Calculate volume attenuation based on 4D distance////// Uses linear falloff between min_distance and max_distance./// Returns a value between 0.0 (silent) and 1.0 (full volume).pub fn calculate_attenuation(listener: Vec4, config: &SpatialConfig) -> f32 { let distance = listener.distance(config.position);
if distance <= config.min_distance { 1.0 } else if distance >= config.max_distance { 0.0 } else { // Linear falloff let range = config.max_distance - config.min_distance; let relative_distance = distance - config.min_distance; 1.0 - (relative_distance / range) }}
/// Calculate stereo panning based on the XZ projection of the 4D direction.////// Returns a value between 0.0 (left) and 1.0 (right), with 0.5 being center.////// # 4D Panning Model////// Panning is calculated using only the X and Z components, ignoring Y and W:/// - **X axis**: Maps to left/right stereo positioning/// - **Z axis**: Represents forward/backward (affects perceived direction angle)/// - **Y axis**: Vertical height - no stereo equivalent/// - **W axis**: 4th dimension - no physical speaker representation////// This is intentional: while volume attenuates based on full 4D distance,/// panning must operate in the 2D plane that maps to stereo speakers.pub fn calculate_panning(listener: Vec4, config: &SpatialConfig) -> f32 { let direction = config.position - listener;
// Project to XZ plane (horizontal plane in typical game coordinates) let xz_length_sq = direction.x * direction.x + direction.z * direction.z;
if xz_length_sq < PANNING_EPSILON { // Source is directly above/below or at same position - center panning return 0.5; }
// Normalize the XZ direction let xz_length = xz_length_sq.sqrt(); let normalized_x = direction.x / xz_length;
// Convert from [-1, 1] to [0, 1] // Positive X is to the right (normalized_x + 1.0) * 0.5}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_attenuation_at_source() { let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::ZERO); let attenuation = calculate_attenuation(listener, &config); assert!((attenuation - 1.0).abs() < 0.0001); }
#[test] fn test_attenuation_within_min_distance() { let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::new(0.5, 0.0, 0.0, 0.0)).with_min_distance(1.0); let attenuation = calculate_attenuation(listener, &config); assert!((attenuation - 1.0).abs() < 0.0001); }
#[test] fn test_attenuation_at_max_distance() { let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::new(50.0, 0.0, 0.0, 0.0)).with_max_distance(50.0); let attenuation = calculate_attenuation(listener, &config); assert!(attenuation < 0.0001); }
#[test] fn test_attenuation_beyond_max_distance() { let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::new(100.0, 0.0, 0.0, 0.0)).with_max_distance(50.0); let attenuation = calculate_attenuation(listener, &config); assert_eq!(attenuation, 0.0); }
#[test] fn test_attenuation_midpoint() { let listener = Vec4::ZERO; // Distance 25.5 is midpoint between 1 and 50 let config = SpatialConfig::new(Vec4::new(25.5, 0.0, 0.0, 0.0)) .with_min_distance(1.0) .with_max_distance(50.0); let attenuation = calculate_attenuation(listener, &config); assert!((attenuation - 0.5).abs() < 0.01); }
#[test] fn test_attenuation_4d_distance() { // Test that 4D distance is used (not just 3D) let listener = Vec4::ZERO; // A point that's 2 units away in 4D (1^2 + 1^2 + 1^2 + 1^2 = 4, sqrt(4) = 2) let config = SpatialConfig::new(Vec4::new(1.0, 1.0, 1.0, 1.0)) .with_min_distance(1.0) .with_max_distance(3.0); let attenuation = calculate_attenuation(listener, &config); // Distance is 2, range is 2 (3-1), so attenuation should be 0.5 assert!((attenuation - 0.5).abs() < 0.01); }
#[test] fn test_panning_center() { let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::ZERO); let panning = calculate_panning(listener, &config); assert!((panning - 0.5).abs() < 0.01); }
#[test] fn test_panning_right() { let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::new(10.0, 0.0, 0.0, 0.0)); let panning = calculate_panning(listener, &config); assert!((panning - 1.0).abs() < 0.01); }
#[test] fn test_panning_left() { let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::new(-10.0, 0.0, 0.0, 0.0)); let panning = calculate_panning(listener, &config); assert!(panning < 0.01); }
#[test] fn test_panning_front() { // Sound directly in front (positive Z) should be centered let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::new(0.0, 0.0, 10.0, 0.0)); let panning = calculate_panning(listener, &config); assert!((panning - 0.5).abs() < 0.01); }
#[test] fn test_panning_diagonal() { // Sound at 45 degrees to the right let listener = Vec4::ZERO; let config = SpatialConfig::new(Vec4::new(10.0, 0.0, 10.0, 0.0)); let panning = calculate_panning(listener, &config); // Should be between 0.5 and 1.0 assert!(panning > 0.5 && panning < 1.0); // 45 degrees -> sin(45) = 0.707, so panning = (0.707 + 1) / 2 = 0.854 assert!((panning - 0.854).abs() < 0.01); }
#[test] fn test_panning_ignores_y_and_w() { // Y and W components shouldn't affect XZ panning let listener = Vec4::ZERO; let config1 = SpatialConfig::new(Vec4::new(5.0, 0.0, 5.0, 0.0)); let config2 = SpatialConfig::new(Vec4::new(5.0, 100.0, 5.0, 0.0)); let config3 = SpatialConfig::new(Vec4::new(5.0, 0.0, 5.0, 100.0));
let panning1 = calculate_panning(listener, &config1); let panning2 = calculate_panning(listener, &config2); let panning3 = calculate_panning(listener, &config3);
assert!((panning1 - panning2).abs() < 0.01); assert!((panning1 - panning3).abs() < 0.01); }}