use super::{AudioBus, AudioEffects, Ctx, Playing, Sound, SoundParams};
use crate::{Entity, Quat, Transform3D, Vec3};
#[derive(Clone, Copy, Debug, Default)]
pub enum AudioRolloff {
#[default]
Logarithmic,
Linear,
Keyed(&'static [(f32, f32)]),
}
#[derive(Clone, Copy, Debug)]
pub struct AudioSettings3D {
pub sound: SoundParams,
pub bus: AudioBus,
pub effects: AudioEffects,
pub min_distance: f32,
pub max_distance: f32,
pub rolloff: AudioRolloff,
pub doppler: f32,
}
impl Default for AudioSettings3D {
fn default() -> Self {
Self {
sound: SoundParams::default(),
bus: AudioBus::default(),
effects: AudioEffects::default(),
min_distance: 1.0,
max_distance: 100.0,
rolloff: AudioRolloff::default(),
doppler: 1.0,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct AudioListener3D {
pub position: Vec3,
pub rotation: Quat,
}
#[derive(Debug)]
pub struct AudioSource3D {
pub sound: Sound,
pub settings: AudioSettings3D,
playing: Option<Playing>,
}
impl AudioSource3D {
pub fn new(sound: Sound, settings: AudioSettings3D) -> Self {
Self {
sound,
settings,
playing: None,
}
}
}
struct Voice {
id: Playing,
entity: Option<Entity>,
position: Vec3,
settings: AudioSettings3D,
fresh: bool,
remaining: f32,
}
#[derive(Default)]
pub(super) struct SpatialState {
listener: Option<AudioListener3D>,
custom: Option<AudioListener3D>,
voices: Vec<Voice>,
next_silent: u64,
}
impl SpatialState {
pub(super) fn remove(&mut self, id: Playing) {
self.voices.retain(|voice| voice.id != id);
}
}
fn gain(distance: f32, settings: AudioSettings3D) -> f32 {
let min = settings.min_distance.max(0.001);
let max = settings.max_distance.max(min + 0.001);
if distance >= max {
return 0.0;
}
if distance <= min {
return 1.0;
}
let t = (distance - min) / (max - min);
match settings.rolloff {
AudioRolloff::Linear => 1.0 - t,
AudioRolloff::Logarithmic => min / distance,
AudioRolloff::Keyed(keys) => {
let Some(&(mut x, mut y)) = keys.first() else {
return 0.0;
};
for &(next_x, next_y) in &keys[1..] {
if t <= next_x {
return (y + (next_y - y) * ((t - x) / (next_x - x).max(f32::EPSILON)).clamp(0.0, 1.0)).max(0.0);
}
(x, y) = (next_x, next_y);
}
y.max(0.0)
}
}
}
fn pan(offset: Vec3, rotation: Quat) -> f32 {
offset.normalize_or_zero().dot(rotation * Vec3::X).clamp(-1.0, 1.0)
}
fn doppler(direction: Vec3, emitter: Vec3, listener: Vec3, factor: f32) -> f32 {
let factor = factor.max(0.0);
let radial = |velocity: Vec3| (velocity.dot(direction) * factor).clamp(-300.0, 300.0);
((343.0 + radial(listener)) / (343.0 + radial(emitter))).clamp(0.25, 4.0)
}
impl Ctx {
pub fn play_sound_at(&mut self, sound: &Sound, position: Vec3, settings: AudioSettings3D) -> Playing {
let listener = self.audio_listener();
let offset = position - listener.position;
let mut params = settings.sound;
params.volume *= gain(offset.length(), settings);
let id = if self.headless {
self.audio.spatial.next_silent += 1;
Playing(self.audio.spatial.next_silent)
} else {
self.play_sound_on(sound, params, settings.bus, settings.effects)
};
self.audio.spatialize(
id.0,
params.volume,
pan(offset, listener.rotation),
params.speed.max(0.01),
);
self.audio.spatial.voices.push(Voice {
id,
entity: None,
position,
settings,
fresh: true,
remaining: sound.seconds(),
});
id
}
}
impl Ctx {
pub fn set_audio_listener(&mut self, listener: Option<AudioListener3D>) {
self.audio.spatial.custom = listener;
}
pub fn audio_listener(&self) -> AudioListener3D {
self.audio.spatial.custom.unwrap_or(AudioListener3D {
position: self.camera3d.position,
rotation: self.camera3d.rotation,
})
}
}
pub(crate) fn update_audio3d(ctx: &mut Ctx) {
let listener = ctx.audio_listener();
let dt = ctx.dt();
let inverse_dt = if dt > 0.0 { dt.recip() } else { 0.0 };
let velocity = ctx
.audio
.spatial
.listener
.map_or(Vec3::ZERO, |old| (listener.position - old.position) * inverse_dt);
ctx.audio.spatial.listener = Some(listener);
for (entity, transform, source) in ctx.world.query::<(Entity, &Transform3D, &mut AudioSource3D)>().iter() {
if source.playing.is_none() {
let offset = transform.position - listener.position;
let mut params = source.settings.sound;
params.volume *= gain(offset.length(), source.settings);
let id = Playing(if ctx.headless {
ctx.audio.spatial.next_silent += 1;
ctx.audio.spatial.next_silent
} else {
ctx.audio
.play_on(source.sound.id, params, source.settings.bus, source.settings.effects)
});
source.playing = Some(id);
ctx.audio.spatial.voices.push(Voice {
id,
entity: Some(entity),
position: transform.position,
settings: source.settings,
fresh: true,
remaining: source.sound.seconds(),
});
}
}
let mut i = 0;
while i < ctx.audio.spatial.voices.len() {
let voice = &mut ctx.audio.spatial.voices[i];
let mut position = voice.position;
let mut changed_effects = None;
if let Some(entity) = voice.entity {
match (
ctx.world.get::<Transform3D>(entity),
ctx.world.get::<AudioSource3D>(entity),
) {
(Some(transform), Some(source)) if source.playing == Some(voice.id) => {
position = transform.position;
let looping = voice.settings.sound.looping;
if source.settings.effects != voice.settings.effects {
changed_effects = Some(source.settings.effects);
}
voice.settings = source.settings;
voice.settings.sound.looping = looping;
}
_ => {
let id = voice.id;
ctx.audio.stop(id.0);
ctx.audio.spatial.voices.swap_remove(i);
continue;
}
}
}
let emitter_velocity = if voice.fresh {
Vec3::ZERO
} else {
(position - voice.position) * inverse_dt
};
let offset = position - listener.position;
let settings = voice.settings;
let speed = settings.sound.speed.max(0.01)
* doppler(offset.normalize_or_zero(), emitter_velocity, velocity, settings.doppler);
voice.remaining -= dt.max(0.0) * speed;
voice.position = position;
voice.fresh = false;
let (id, volume, panning) = (
voice.id,
settings.sound.volume * gain(offset.length(), settings),
pan(offset, listener.rotation),
);
let finished = !settings.sound.looping && voice.remaining <= 0.0;
if let Some(effects) = changed_effects {
ctx.audio.effects(id.0, effects);
}
ctx.audio.spatialize(id.0, volume, panning, speed);
if finished {
ctx.audio.spatial.voices.swap_remove(i);
} else {
i += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_distance_curves_and_boundaries() {
let mut settings = AudioSettings3D {
min_distance: 1.0,
max_distance: 11.0,
rolloff: AudioRolloff::Linear,
..Default::default()
};
assert_eq!(gain(0.0, settings), 1.0);
assert_eq!(gain(6.0, settings), 0.5);
assert_eq!(gain(11.0, settings), 0.0);
settings.rolloff = AudioRolloff::Logarithmic;
assert_eq!(gain(2.0, settings), 0.5);
settings.rolloff = AudioRolloff::Keyed(&[(0.0, 1.0), (1.0, 0.2)]);
assert!((gain(6.0, settings) - 0.6).abs() < 1e-6);
}
#[test]
fn audio_pan_and_doppler_follow_listener_orientation_and_velocity() {
assert_eq!(pan(Vec3::X, Quat::IDENTITY), 1.0);
assert_eq!(pan(-Vec3::X, Quat::IDENTITY), -1.0);
assert_eq!(pan(Vec3::ZERO, Quat::IDENTITY), 0.0);
assert!(doppler(Vec3::X, -Vec3::X * 20.0, Vec3::ZERO, 1.0) > 1.0);
assert!(doppler(Vec3::X, Vec3::X * 20.0, Vec3::ZERO, 1.0) < 1.0);
assert_eq!(doppler(Vec3::X, Vec3::X, Vec3::ZERO, 0.0), 1.0);
}
#[test]
fn audio_headless_sources_start_update_and_cleanup_without_device() {
crate::Game::new("audio test", 1, 1)
.system(|ctx| {
update_audio3d(ctx);
assert!(ctx.world.query::<&AudioSource3D>().iter().all(|s| s.playing.is_some()));
})
.run_headless(2, |ctx| {
let sound = ctx.create_sound(100, &[0.0; 100]);
let entity = ctx.world.spawn((
Transform3D::default(),
AudioSource3D::new(sound, AudioSettings3D::default()),
));
update_audio3d(ctx);
assert_eq!(ctx.audio.spatial.voices.len(), 1);
ctx.world.get_mut::<Transform3D>(entity).unwrap().position = Vec3::X * 5.0;
update_audio3d(ctx);
assert_eq!(ctx.audio.spatial.voices[0].position, Vec3::X * 5.0);
ctx.world.despawn(entity);
update_audio3d(ctx);
assert!(ctx.audio.spatial.voices.is_empty());
});
}
}