Acoustics stage 1: direct-path occlusion ported from Sound Physics Remastered 453654e (GPL-3.0, separate files), off by default; stereo output on surround devices; mixer sum in a fixed order (API 21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
//! AirDef fork: acoustics for the mixer (#96), stage 1: the direct path. What lies between a
|
||||
//! sound and the listener (a wall, a mountain) dims the sound and cuts its highs.
|
||||
//!
|
||||
//! The formulas are Sound Physics Remastered's (`spr_occlusion`, GPL-3.0, see
|
||||
//! `THIRD_PARTY_SPR.md`); the rest is ours. The world is read from a [`Geometry`], a snapshot of
|
||||
//! the resident chunks (their `Arc`s, so a block written meanwhile copies the chunk and the
|
||||
//! snapshot stays what it was). Tracing never happens in the audio callback: the mixer only
|
||||
//! holds the numbers (`audio::Occlusion`) that [`Acoustics::step`] hands to it, once per tick.
|
||||
//!
|
||||
//! Work comes in two sizes. A sound the engine has no number for yet is traced at once on the
|
||||
//! game thread by one path (cheap, so a blast starts muffled, not bright), under a count budget.
|
||||
//! The full estimate (nine paths) of every audible source is traced on a worker thread, one job
|
||||
//! at a time, and applied a tick or two later. While a film is being made, everything is traced
|
||||
//! at once and under the same kind of budget (cells, never time), so the sound of a film does not
|
||||
//! depend on the speed of the machine. Results are cached by the cells (2 blocks) of the source
|
||||
//! and the listener, and dropped when a block changes in a section a path passed through.
|
||||
//!
|
||||
//! `HORIZON_ACOUSTICS=1` turns it on; off (the default) nothing here runs and the mixer is the
|
||||
//! mixer it was.
|
||||
pub mod spr_occlusion;
|
||||
|
||||
use crate::chunk::{Chunk, HEIGHT, MIN_Y};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
/// Sources further than this from the listener are not traced (the world beyond is unknown to the
|
||||
/// walk anyway); SPR gives up at 512 too, but sees only 64 blocks of world.
|
||||
pub const RANGE: f64 = 512.0;
|
||||
/// The gain of the highs the air leaves per block (EFX `AIR_ABSORPTION_GAINHF` default).
|
||||
pub const AIR: f32 = 0.994;
|
||||
/// Sources quieter than this (volume times distance gain) are left alone.
|
||||
const AUDIBLE: f32 = 0.003;
|
||||
/// Cells a tick may walk at once for sources with no number yet (live), one path each.
|
||||
const INLINE_CELLS: usize = 40_000;
|
||||
/// Cells a film's tick may walk (nine paths where the source is loud).
|
||||
const FILM_CELLS: usize = 600_000;
|
||||
/// Cells one worker job may walk.
|
||||
const JOB_CELLS: usize = 400_000;
|
||||
/// Sources one job holds, the loudest first.
|
||||
const JOB_SOURCES: usize = 32;
|
||||
/// Sources that get the nine paths; the rest get one.
|
||||
const FULL_PATHS: usize = 16;
|
||||
/// Cached estimates.
|
||||
const CACHE: usize = 4096;
|
||||
/// Change records kept to judge a job that returns late.
|
||||
const LOG: usize = 2048;
|
||||
|
||||
/// The environment's occlusion by block: `None` is a block nobody knows (no chunk).
|
||||
pub trait Geometry: Send + Sync {
|
||||
/// A function giving the occlusion of the block at x, y, z (0: sound passes).
|
||||
fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_>;
|
||||
}
|
||||
|
||||
/// The resident chunks and a table of the occlusion of each block state.
|
||||
pub struct Snapshot {
|
||||
pub chunks: FxHashMap<(i32, i32), Arc<Chunk>>,
|
||||
pub table: Arc<Vec<f32>>,
|
||||
pub epoch: u32,
|
||||
pub revision: u64,
|
||||
pub tick: u64,
|
||||
pub center: [f64; 3],
|
||||
}
|
||||
impl Geometry for Snapshot {
|
||||
fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
|
||||
let mut last: Option<((i32, i32), &Arc<Chunk>)> = None;
|
||||
Box::new(move |x, y, z| {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let key = (x >> 4, z >> 4);
|
||||
let chunk = match last {
|
||||
Some((k, c)) if k == key => c,
|
||||
_ => {
|
||||
let c = self.chunks.get(&key)?;
|
||||
last = Some((key, c));
|
||||
c
|
||||
}
|
||||
};
|
||||
let state = chunk.get(x & 15, y, z & 15) as usize;
|
||||
Some(self.table.get(state).copied().unwrap_or(0.0))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A sound the mixer is playing, as the engine sees it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Source {
|
||||
pub id: u32,
|
||||
pub at: [f64; 3],
|
||||
/// Volume times the distance gain: what the listener would hear without any wall.
|
||||
pub loud: f32,
|
||||
}
|
||||
impl Source {
|
||||
pub fn audible(&self) -> bool {
|
||||
self.loud >= AUDIBLE
|
||||
}
|
||||
}
|
||||
|
||||
/// What the mixer is told about a source: the overall gain and the gain of the highs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Applied {
|
||||
pub id: u32,
|
||||
pub gain: f32,
|
||||
pub cutoff: f32,
|
||||
}
|
||||
|
||||
/// The block changes since the last step, as the world reports them.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Changes {
|
||||
pub epoch: u32,
|
||||
pub overflow: bool,
|
||||
pub sections: Vec<(i32, i32, i32)>,
|
||||
}
|
||||
|
||||
type Cell = (i32, i32, i32);
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Entry {
|
||||
occlusion: f64,
|
||||
paths: u8,
|
||||
/// The box (in blocks) the paths lay in.
|
||||
min: [i32; 3],
|
||||
max: [i32; 3],
|
||||
used: u64,
|
||||
}
|
||||
impl Entry {
|
||||
fn touches(&self, s: Cell) -> bool {
|
||||
let b = [s.0 * 16, s.1 * 16, s.2 * 16];
|
||||
(0..3).all(|a| self.min[a] < b[a] + 16 && self.max[a] >= b[a])
|
||||
}
|
||||
}
|
||||
|
||||
/// What the engine measured, for `status`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Stats {
|
||||
pub traces: u64,
|
||||
pub cells: u64,
|
||||
pub unknown_cells: u64,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub jobs: u64,
|
||||
pub dropped_late: u64,
|
||||
pub invalidated: u64,
|
||||
pub skipped_busy: u64,
|
||||
pub sources: usize,
|
||||
pub cached: usize,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
seq: u64,
|
||||
geometry: Arc<dyn Geometry>,
|
||||
listener: [f64; 3],
|
||||
items: Vec<(Key, [f64; 3], usize)>,
|
||||
}
|
||||
struct Done {
|
||||
seq: u64,
|
||||
entries: Vec<(Key, Entry)>,
|
||||
cells: usize,
|
||||
unknown: usize,
|
||||
}
|
||||
type Key = (Cell, Cell);
|
||||
|
||||
fn cell_of(p: [f64; 3]) -> Cell {
|
||||
((p[0] / 2.0).floor() as i32, (p[1] / 2.0).floor() as i32, (p[2] / 2.0).floor() as i32)
|
||||
}
|
||||
|
||||
/// Traces the direct path of one source. Returns the entry, the cells walked and the unknown ones.
|
||||
fn trace(geometry: &dyn Geometry, listener: [f64; 3], at: [f64; 3], paths: usize, budget: &mut usize) -> (Entry, usize, usize) {
|
||||
let before = *budget;
|
||||
let mut cell = geometry.walker();
|
||||
let (occlusion, unknown) =
|
||||
spr_occlusion::calculate_occlusion(&mut *cell, at, listener, paths, budget);
|
||||
let pad = 1.0;
|
||||
let lo = |a: usize| (at[a].min(listener[a]) - pad).floor() as i32;
|
||||
let hi = |a: usize| (at[a].max(listener[a]) + pad).floor() as i32;
|
||||
(
|
||||
Entry {
|
||||
occlusion,
|
||||
paths: paths as u8,
|
||||
min: [lo(0), lo(1), lo(2)],
|
||||
max: [hi(0), hi(1), hi(2)],
|
||||
used: 0,
|
||||
},
|
||||
before - *budget,
|
||||
unknown,
|
||||
)
|
||||
}
|
||||
|
||||
/// The engine: a cache of estimates, a worker, and what the mixer was last told.
|
||||
pub struct Acoustics {
|
||||
/// Film capture (or a test): traced at once, never on the worker.
|
||||
pub sync: bool,
|
||||
cache: HashMap<Key, Entry>,
|
||||
clock: u64,
|
||||
applied: HashMap<u32, Applied>,
|
||||
log: std::collections::VecDeque<(u64, Cell)>,
|
||||
seq: u64,
|
||||
/// Jobs up to this number belong to a world that is gone.
|
||||
floor: u64,
|
||||
in_flight: Option<u64>,
|
||||
worker: Option<(Sender<Job>, Receiver<Done>)>,
|
||||
epoch: u32,
|
||||
pub stats: Stats,
|
||||
}
|
||||
|
||||
impl Acoustics {
|
||||
pub fn new(sync: bool) -> Self {
|
||||
Self {
|
||||
sync,
|
||||
cache: HashMap::new(),
|
||||
clock: 0,
|
||||
applied: HashMap::new(),
|
||||
log: Default::default(),
|
||||
seq: 0,
|
||||
floor: 0,
|
||||
in_flight: None,
|
||||
worker: None,
|
||||
epoch: 0,
|
||||
stats: Stats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget everything (another world, a film began or ended).
|
||||
pub fn reset(&mut self) {
|
||||
self.cache.clear();
|
||||
self.applied.clear();
|
||||
self.log.clear();
|
||||
// A job in flight returns into a world that is gone: its sequence number is stale.
|
||||
self.seq += 1;
|
||||
self.floor = self.seq;
|
||||
self.in_flight = None;
|
||||
}
|
||||
|
||||
fn spawn(&mut self) {
|
||||
let (job_tx, job_rx) = std::sync::mpsc::channel::<Job>();
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<Done>();
|
||||
let spawned = std::thread::Builder::new().name("acoustics".into()).spawn(move || {
|
||||
for job in job_rx {
|
||||
let mut budget = JOB_CELLS;
|
||||
let (mut cells, mut unknown) = (0, 0);
|
||||
let mut entries = Vec::with_capacity(job.items.len());
|
||||
for (key, at, paths) in job.items {
|
||||
let (entry, c, u) = trace(&*job.geometry, job.listener, at, paths, &mut budget);
|
||||
cells += c;
|
||||
unknown += u;
|
||||
entries.push((key, entry));
|
||||
}
|
||||
if done_tx.send(Done { seq: job.seq, entries, cells, unknown }).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
self.worker = Some((job_tx, done_rx));
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate(&mut self, changes: &Changes) {
|
||||
if changes.epoch != self.epoch || changes.overflow {
|
||||
self.epoch = changes.epoch;
|
||||
self.stats.invalidated += self.cache.len() as u64;
|
||||
self.cache.clear();
|
||||
self.log.clear();
|
||||
self.seq += 1;
|
||||
self.floor = self.seq;
|
||||
self.in_flight = None;
|
||||
self.applied.clear();
|
||||
return;
|
||||
}
|
||||
for &s in &changes.sections {
|
||||
let before = self.cache.len();
|
||||
self.cache.retain(|_, e| !e.touches(s));
|
||||
self.stats.invalidated += (before - self.cache.len()) as u64;
|
||||
self.log.push_back((self.seq, s));
|
||||
}
|
||||
while self.log.len() > LOG {
|
||||
self.log.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: Key, mut entry: Entry) {
|
||||
entry.used = self.clock;
|
||||
self.cache.insert(key, entry);
|
||||
if self.cache.len() > CACHE {
|
||||
let mut ages: Vec<u64> = self.cache.values().map(|e| e.used).collect();
|
||||
ages.sort_unstable();
|
||||
let cut = ages[ages.len() / 4];
|
||||
self.cache.retain(|_, e| e.used > cut);
|
||||
}
|
||||
}
|
||||
|
||||
/// One step (a game tick): take what the worker finished, settle the changes, give each
|
||||
/// source its numbers, trace what is missing, send the rest to the worker. Returns the
|
||||
/// numbers that changed, for the mixer.
|
||||
pub fn step(
|
||||
&mut self,
|
||||
geometry: Option<&Arc<dyn Geometry>>,
|
||||
listener: [f64; 3],
|
||||
sources: &[Source],
|
||||
changes: &Changes,
|
||||
) -> Vec<Applied> {
|
||||
self.clock += 1;
|
||||
self.invalidate(changes);
|
||||
// The worker's results; one that began before a change in its box is not kept.
|
||||
if let Some((_, rx)) = &self.worker {
|
||||
let done: Vec<Done> = rx.try_iter().collect();
|
||||
for d in done {
|
||||
if self.in_flight == Some(d.seq) {
|
||||
self.in_flight = None;
|
||||
}
|
||||
self.stats.cells += d.cells as u64;
|
||||
self.stats.unknown_cells += d.unknown as u64;
|
||||
self.stats.jobs += 1;
|
||||
if d.seq <= self.floor {
|
||||
self.stats.dropped_late += d.entries.len() as u64;
|
||||
continue;
|
||||
}
|
||||
for (key, e) in d.entries {
|
||||
if self.log.iter().any(|(s, c)| *s >= d.seq && e.touches(*c)) {
|
||||
self.stats.dropped_late += 1;
|
||||
} else {
|
||||
self.insert(key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let live: Vec<Source> = sources
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| s.audible() && dist(s.at, listener) <= RANGE)
|
||||
.collect();
|
||||
self.stats.sources = live.len();
|
||||
self.applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
|
||||
let Some(geometry) = geometry else {
|
||||
return self.settle(&live, listener, None);
|
||||
};
|
||||
// The loudest first; ties by id, so the order never depends on a hash.
|
||||
let mut order: Vec<usize> = (0..live.len()).collect();
|
||||
order.sort_by(|&a, &b| live[b].loud.total_cmp(&live[a].loud).then(live[a].id.cmp(&live[b].id)));
|
||||
let lcell = cell_of(listener);
|
||||
let mut inline = if self.sync { FILM_CELLS } else { INLINE_CELLS };
|
||||
let mut job: Vec<(Key, [f64; 3], usize)> = Vec::new();
|
||||
for (rank, &i) in order.iter().enumerate() {
|
||||
let s = &live[i];
|
||||
let key = (cell_of(s.at), lcell);
|
||||
let want = if rank < FULL_PATHS { 9 } else { 1 };
|
||||
if let Some(e) = self.cache.get_mut(&key) {
|
||||
e.used = self.clock;
|
||||
if e.paths as usize >= want || e.paths == 9 {
|
||||
self.stats.hits += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.stats.misses += 1;
|
||||
// No number yet (a new sound) or a film: at once. Otherwise the worker refines it.
|
||||
let fresh = !self.applied.contains_key(&s.id) && !self.cache.contains_key(&key);
|
||||
if self.sync || fresh {
|
||||
if inline == 0 {
|
||||
continue;
|
||||
}
|
||||
let paths = if self.sync { want } else { 1 };
|
||||
let (e, cells, unknown) = trace(&**geometry, listener, s.at, paths, &mut inline);
|
||||
self.stats.traces += 1;
|
||||
self.stats.cells += cells as u64;
|
||||
self.stats.unknown_cells += unknown as u64;
|
||||
self.insert(key, e);
|
||||
} else if job.len() < JOB_SOURCES {
|
||||
job.push((key, s.at, want));
|
||||
}
|
||||
}
|
||||
if !job.is_empty() && !self.sync {
|
||||
if self.worker.is_none() {
|
||||
self.spawn();
|
||||
}
|
||||
if self.in_flight.is_some() {
|
||||
self.stats.skipped_busy += 1;
|
||||
} else if let Some((tx, _)) = &self.worker {
|
||||
self.seq += 1;
|
||||
let seq = self.seq;
|
||||
if tx
|
||||
.send(Job { seq, geometry: geometry.clone(), listener, items: job })
|
||||
.is_ok()
|
||||
{
|
||||
self.in_flight = Some(seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.stats.cached = self.cache.len();
|
||||
self.settle(&live, listener, Some(lcell))
|
||||
}
|
||||
|
||||
/// The numbers for each live source from the cache; only the changed ones are returned.
|
||||
fn settle(&mut self, live: &[Source], listener: [f64; 3], lcell: Option<Cell>) -> Vec<Applied> {
|
||||
let lcell = lcell.unwrap_or_else(|| cell_of(listener));
|
||||
let mut out = Vec::new();
|
||||
for s in live {
|
||||
let Some(e) = self.cache.get(&(cell_of(s.at), lcell)) else {
|
||||
continue;
|
||||
};
|
||||
let (cutoff, gain) = spr_occlusion::direct_params(e.occlusion);
|
||||
// The air takes the highs with the distance (OpenAL's default for sources that ask
|
||||
// for it: 0.994 of the gain at 5 kHz per block).
|
||||
let cutoff = cutoff * AIR.powf(dist(s.at, listener) as f32);
|
||||
let now = Applied { id: s.id, gain, cutoff };
|
||||
let same = self.applied.get(&s.id).is_some_and(|p| {
|
||||
(p.gain - gain).abs() < 0.002 && (p.cutoff - cutoff).abs() < 0.002 * p.cutoff.max(0.01)
|
||||
});
|
||||
if !same {
|
||||
self.applied.insert(s.id, now);
|
||||
out.push(now);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A grid of blocks for tests: a wall at x in `wall`, occlusion `v`.
|
||||
struct Wall {
|
||||
wall: std::ops::Range<i32>,
|
||||
v: std::sync::atomic::AtomicU32,
|
||||
}
|
||||
impl Wall {
|
||||
fn new(wall: std::ops::Range<i32>, v: f32) -> Arc<dyn Geometry> {
|
||||
Arc::new(Wall { wall, v: std::sync::atomic::AtomicU32::new(v.to_bits()) })
|
||||
}
|
||||
}
|
||||
impl Geometry for Wall {
|
||||
fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
|
||||
let v = f32::from_bits(self.v.load(std::sync::atomic::Ordering::Relaxed));
|
||||
Box::new(move |x, _, _| Some(if self.wall.contains(&x) { v } else { 0.0 }))
|
||||
}
|
||||
}
|
||||
fn src(id: u32, x: f64) -> Source {
|
||||
Source { id, at: [x, 70.5, 0.5], loud: 0.5 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wall_between_dims_and_muffles_and_open_ground_does_not() {
|
||||
let mut a = Acoustics::new(true);
|
||||
let wall = Wall::new(100..103, 1.0);
|
||||
let out = a.step(Some(&wall), [0.5, 70.5, 0.5], &[src(1, 200.5), src(2, 50.5)], &Changes::default());
|
||||
let behind = out.iter().find(|o| o.id == 1).unwrap();
|
||||
assert!(behind.cutoff < 0.001 && (behind.gain - 0.41).abs() < 0.01, "{behind:?}");
|
||||
// The open one needs no change to the mixer's defaults.
|
||||
let open = out.iter().find(|o| o.id == 2).unwrap();
|
||||
assert_eq!(open.gain, 1.0);
|
||||
assert!((open.cutoff - AIR.powf(50.0)).abs() < 1e-6, "only the air: {open:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_inputs_give_the_same_numbers_whatever_the_machine() {
|
||||
let run = || {
|
||||
let mut a = Acoustics::new(true);
|
||||
let wall = Wall::new(40..42, 1.0);
|
||||
let sources: Vec<Source> = (0..40).map(|i| src(i, 80.5 + f64::from(i) * 3.0)).collect();
|
||||
let mut all = Vec::new();
|
||||
for _ in 0..3 {
|
||||
all.extend(a.step(Some(&wall), [0.5, 70.5, 0.5], &sources, &Changes::default()));
|
||||
}
|
||||
all
|
||||
};
|
||||
assert_eq!(run(), run());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_section_drops_what_passed_through_it_and_the_wall_gone_unmuffles() {
|
||||
let mut a = Acoustics::new(true);
|
||||
let wall = Wall::new(40..42, 1.0);
|
||||
let l = [0.5, 70.5, 0.5];
|
||||
let first = a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default());
|
||||
assert!(first[0].cutoff < 0.01);
|
||||
// Same cell, cached: nothing to tell the mixer.
|
||||
assert!(a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default()).is_empty());
|
||||
assert!(a.stats.hits >= 1);
|
||||
// The wall is destroyed: the section (2, 4, 0) changed.
|
||||
let open = Wall::new(0..0, 1.0);
|
||||
let change = Changes { sections: vec![(2, 4, 0)], ..Default::default() };
|
||||
let out = a.step(Some(&open), l, &[src(1, 90.5)], &change);
|
||||
assert_eq!(out[0].gain, 1.0);
|
||||
assert!((out[0].cutoff - AIR.powf(90.0)).abs() < 1e-6, "{:?}", out[0]);
|
||||
// A change elsewhere keeps the cache.
|
||||
let hits = a.stats.hits;
|
||||
a.step(Some(&open), l, &[src(1, 90.5)], &Changes { sections: vec![(50, 4, 50)], ..Default::default() });
|
||||
assert_eq!(a.stats.hits, hits + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_world_forgets_everything_and_the_budget_is_counted() {
|
||||
let mut a = Acoustics::new(true);
|
||||
let wall = Wall::new(40..42, 1.0);
|
||||
let l = [0.5, 70.5, 0.5];
|
||||
a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default());
|
||||
let out = a.step(Some(&wall), l, &[src(1, 90.5)], &Changes { epoch: 9, ..Default::default() });
|
||||
assert_eq!(out.len(), 1, "told again after the epoch moved");
|
||||
// 400 loud sources: the film budget caps the cells walked in a step.
|
||||
let mut b = Acoustics::new(true);
|
||||
let many: Vec<Source> = (0..400).map(|i| src(i, 300.0 + f64::from(i % 50) * 4.0)).collect();
|
||||
b.step(Some(&Wall::new(5..6, 1.0)), l, &many, &Changes::default());
|
||||
assert!(b.stats.cells <= FILM_CELLS as u64, "{}", b.stats.cells);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_worker_refines_a_source_and_never_the_game_thread() {
|
||||
let mut a = Acoustics::new(false);
|
||||
let wall = Wall::new(40..42, 1.0);
|
||||
let l = [0.5, 70.5, 0.5];
|
||||
let s = [src(1, 90.5)];
|
||||
// A new source: one path at once.
|
||||
let first = a.step(Some(&wall), l, &s, &Changes::default());
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(a.stats.traces, 1);
|
||||
// The one-path estimate is refined to nine on the worker, and its answer arrives.
|
||||
let wait = |a: &mut Acoustics, want: u64, srcs: &[Source]| {
|
||||
for _ in 0..400 {
|
||||
a.step(Some(&wall), l, srcs, &Changes::default());
|
||||
if a.stats.jobs >= want {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the worker did not answer");
|
||||
};
|
||||
wait(&mut a, 1, &s);
|
||||
// Refined: the same cell asks nothing more.
|
||||
for _ in 0..3 {
|
||||
a.step(Some(&wall), l, &s, &Changes::default());
|
||||
}
|
||||
assert_eq!(a.stats.jobs, 1);
|
||||
// A moved source in a new cell goes to the worker too.
|
||||
let moved = [src(1, 94.5)];
|
||||
wait(&mut a, 2, &moved);
|
||||
assert_eq!(a.stats.traces, 1, "the game thread traced only the new source");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Third-party code: Sound Physics Remastered
|
||||
|
||||
`spr_occlusion.rs` in this folder is derived from Sound Physics Remastered by henkelmax and its
|
||||
contributors, https://github.com/henkelmax/sound-physics-remastered, branch `master`, commit
|
||||
453654ece086f40c97712d9016baa4dee7c4f6d2, distributed under the GNU General Public License,
|
||||
version 3 (the `LICENSE` file of that repository).
|
||||
|
||||
What is taken: from `SoundPhysics.java` the direct path's occlusion (`runOcclusion`: the sum of
|
||||
the occlusion of the solid blocks along the line, at most 16 of them, stopped at 64;
|
||||
`calculateOcclusion`: the least over the straight route and eight routes moved by 0.35 blocks
|
||||
at both ends) and its result (`cutoff = exp(-occlusion * 3 * block_absorption)`,
|
||||
`gain = cutoff^0.1`, the source's own block ignored); from `OcclusionConfig.java` the occlusion
|
||||
of the block groups (1.0 by default, wool 1.5, moss 0.75, honey 0.5, glass and snow 0.1, plants
|
||||
and ladders 0) and `non_full_block_occlusion_factor` 0.25. The ray walk (a voxel walk instead of
|
||||
Minecraft's `traverseBlocks` over collision shapes), the filter that applies the result, the
|
||||
scheduler, the cache and the worker are our own. SPR's reflections, reverb and EFX parts are not
|
||||
used at this stage.
|
||||
|
||||
Because this file is GPL-3.0-only, a build of Horizon that contains it is distributed under the
|
||||
terms of the GPL-3.0 for the whole program (source on request, same licence for the combination).
|
||||
The other files of the fork remain MIT; this file carries its notice in its header.
|
||||
|
||||
The licence text is at https://www.gnu.org/licenses/gpl-3.0.txt.
|
||||
@@ -0,0 +1,234 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
//
|
||||
// Derived from Sound Physics Remastered by henkelmax and contributors
|
||||
// (https://github.com/henkelmax/sound-physics-remastered), commit
|
||||
// 453654ece086f40c97712d9016baa4dee7c4f6d2, GPL-3.0:
|
||||
// common/src/main/java/com/sonicether/soundphysics/SoundPhysics.java
|
||||
// (calculateOcclusion, runOcclusion, the direct cutoff and gain of evaluateEnvironment)
|
||||
// common/src/main/java/com/sonicether/soundphysics/config/OcclusionConfig.java (the table)
|
||||
// common/src/main/java/com/sonicether/soundphysics/config/SoundPhysicsConfig.java (defaults)
|
||||
// Ported to Rust for Horizon's own mixer, not line by line: the ray walk is a voxel walk, the
|
||||
// filter is ours (see `crate::audio`), the block groups are Horizon's sound groups. See
|
||||
// THIRD_PARTY_SPR.md next to this file. The rest of this crate is MIT; this file is GPL-3.0-only.
|
||||
//! The direct path of a sound: how much solid material lies between the source and the listener
|
||||
//! and what that does to the sound (SPR's occlusion and absorption).
|
||||
|
||||
/// `block_absorption` (1.0) times SPR's constant 3.
|
||||
pub const ABSORPTION: f64 = 3.0;
|
||||
/// `max_occlusion`: the sum stops growing here.
|
||||
pub const MAX_OCCLUSION: f64 = 64.0;
|
||||
/// `max_occlusion_rays`: the solid blocks counted on one path.
|
||||
pub const MAX_HITS: usize = 16;
|
||||
/// `occlusion_variation`: the corners of the cube the nine paths are offset by.
|
||||
pub const VARIATION: f64 = 0.35;
|
||||
/// `non_full_block_occlusion_factor`.
|
||||
pub const NON_FULL: f32 = 0.25;
|
||||
/// `defaultBlockOcclusionFactor`.
|
||||
pub const DEFAULT_OCCLUSION: f32 = 1.0;
|
||||
|
||||
/// What one block group does to a sound passing through (`OcclusionConfig.addDefaults`), by the
|
||||
/// group name in the block's step sound (`minecraft:block.wool.step` is `wool`).
|
||||
pub fn group_occlusion(group: &str) -> f32 {
|
||||
match group {
|
||||
"wool" => 1.5,
|
||||
"moss" => 0.75,
|
||||
"honey_block" => 0.5,
|
||||
"glass" | "snow" | "powder_snow" | "bamboo" | "bamboo_sapling" | "wet_grass"
|
||||
| "moss_carpet" => 0.1,
|
||||
"weeping_vines" | "twisting_vines" | "vine" | "cave_vines" | "sweet_berry_bush"
|
||||
| "spore_blossom" | "small_dripleaf" | "big_dripleaf" | "roots" | "hanging_roots"
|
||||
| "pointed_dripstone" | "scaffolding" | "glow_lichen" | "crop" | "nether_sprouts"
|
||||
| "fungus" | "lily_pad" | "large_amethyst_bud" | "medium_amethyst_bud"
|
||||
| "small_amethyst_bud" | "ladder" | "chain" => 0.0,
|
||||
_ => DEFAULT_OCCLUSION,
|
||||
}
|
||||
}
|
||||
|
||||
/// The group of a step sound id: `minecraft:block.stone.step` gives `stone`.
|
||||
pub fn group_of(step: &str) -> &str {
|
||||
let name = step.rsplit(':').next().unwrap_or(step);
|
||||
let name = name.strip_prefix("block.").unwrap_or(name);
|
||||
name.strip_suffix(".step").unwrap_or(name)
|
||||
}
|
||||
|
||||
/// The direct sound's `(cutoff, gain)` for a summed occlusion: the high-frequency gain
|
||||
/// `exp(-occlusion * 3)` and the overall gain `cutoff^0.1`.
|
||||
pub fn direct_params(occlusion: f64) -> (f32, f32) {
|
||||
let cutoff = (-occlusion * ABSORPTION).exp() as f32;
|
||||
(cutoff, cutoff.powf(0.1))
|
||||
}
|
||||
|
||||
/// The solid material along `from` to `to`: the sum of the occlusion of every block the segment
|
||||
/// passes through, except the block the source is in. `cell` gives a block's occlusion (0: the
|
||||
/// sound passes) or `None` where the world is not known, which counts as open air. `budget` is the
|
||||
/// number of block cells left to look at; the walk stops when it is spent. Returns the sum and
|
||||
/// the number of unknown cells met.
|
||||
pub fn run_occlusion(
|
||||
mut cell: impl FnMut(i32, i32, i32) -> Option<f32>,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
budget: &mut usize,
|
||||
) -> (f64, usize) {
|
||||
let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
|
||||
let mut at = [from[0].floor() as i32, from[1].floor() as i32, from[2].floor() as i32];
|
||||
let end = [to[0].floor() as i32, to[1].floor() as i32, to[2].floor() as i32];
|
||||
let mut step = [0i32; 3];
|
||||
let mut t_max = [f64::INFINITY; 3];
|
||||
let mut t_delta = [f64::INFINITY; 3];
|
||||
for a in 0..3 {
|
||||
if d[a] > 0.0 {
|
||||
step[a] = 1;
|
||||
t_delta[a] = 1.0 / d[a];
|
||||
t_max[a] = (f64::from(at[a]) + 1.0 - from[a]) / d[a];
|
||||
} else if d[a] < 0.0 {
|
||||
step[a] = -1;
|
||||
t_delta[a] = -1.0 / d[a];
|
||||
t_max[a] = (from[a] - f64::from(at[a])) / -d[a];
|
||||
}
|
||||
}
|
||||
let (mut sum, mut hits, mut unknown) = (0.0f64, 0usize, 0usize);
|
||||
let mut first = true;
|
||||
loop {
|
||||
// SPR ignores the block the sound is in.
|
||||
if !first {
|
||||
if *budget == 0 {
|
||||
break;
|
||||
}
|
||||
*budget -= 1;
|
||||
match cell(at[0], at[1], at[2]) {
|
||||
None => unknown += 1,
|
||||
Some(v) if v > 0.0 => {
|
||||
sum += f64::from(v);
|
||||
hits += 1;
|
||||
if sum > MAX_OCCLUSION || hits >= MAX_HITS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
if at == end {
|
||||
break;
|
||||
}
|
||||
let a = if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
|
||||
0
|
||||
} else if t_max[1] <= t_max[2] {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
if t_max[a] > 1.0 {
|
||||
break;
|
||||
}
|
||||
at[a] += step[a];
|
||||
t_max[a] += t_delta[a];
|
||||
}
|
||||
(sum, unknown)
|
||||
}
|
||||
|
||||
/// The least occlusion over `paths` routes (`calculateOcclusion`): the straight one, then (for 9)
|
||||
/// the same segment moved by `(+-v, +-v, +-v)` at both ends, so that a gap by the door or the
|
||||
/// edge of a wall lets the sound through. A free route ends the search, as nothing is less.
|
||||
pub fn calculate_occlusion(
|
||||
mut cell: impl FnMut(i32, i32, i32) -> Option<f32>,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
paths: usize,
|
||||
budget: &mut usize,
|
||||
) -> (f64, usize) {
|
||||
let (mut best, mut unknown) = run_occlusion(&mut cell, from, to, budget);
|
||||
if paths <= 1 {
|
||||
return (best.min(MAX_OCCLUSION), unknown);
|
||||
}
|
||||
for x in [-1.0, 1.0] {
|
||||
for y in [-1.0, 1.0] {
|
||||
for z in [-1.0, 1.0] {
|
||||
if best <= 0.0 {
|
||||
return (0.0, unknown);
|
||||
}
|
||||
let o = [x * VARIATION, y * VARIATION, z * VARIATION];
|
||||
let (v, u) = run_occlusion(
|
||||
&mut cell,
|
||||
[from[0] + o[0], from[1] + o[1], from[2] + o[2]],
|
||||
[to[0] + o[0], to[1] + o[1], to[2] + o[2]],
|
||||
budget,
|
||||
);
|
||||
best = best.min(v);
|
||||
unknown += u;
|
||||
}
|
||||
}
|
||||
}
|
||||
(best.min(MAX_OCCLUSION), unknown)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A wall of `thick` blocks of `v` occlusion at x = 5.., open elsewhere.
|
||||
fn wall(thick: i32, v: f32) -> impl FnMut(i32, i32, i32) -> Option<f32> {
|
||||
move |x, _, _| Some(if (5..5 + thick).contains(&x) { v } else { 0.0 })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stone_walls_cut_the_highs_as_spr_does() {
|
||||
// 1, 2 and 3 blocks of stone: cutoff 0.050, 0.0025, 0.0001, gain 0.74, 0.55, 0.41.
|
||||
for (thick, cutoff, gain) in [(1, 0.0498, 0.741), (2, 0.00248, 0.549), (3, 0.000123, 0.407)] {
|
||||
let mut budget = 10_000;
|
||||
let (occ, _) = run_occlusion(wall(thick, 1.0), [0.5, 0.5, 0.5], [12.5, 0.5, 0.5], &mut budget);
|
||||
assert!((occ - f64::from(thick)).abs() < 1e-9, "{thick}: {occ}");
|
||||
let (c, g) = direct_params(occ);
|
||||
assert!((c - cutoff).abs() / cutoff < 0.02, "{thick}: {c}");
|
||||
assert!((g - gain).abs() < 0.005, "{thick}: {g}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_block_is_ignored_and_open_air_is_free() {
|
||||
let mut budget = 10_000;
|
||||
let inside = |x: i32, _, _| Some(if x == 0 { 1.0 } else { 0.0 });
|
||||
assert_eq!(run_occlusion(inside, [0.5; 3], [9.5, 0.5, 0.5], &mut budget).0, 0.0);
|
||||
assert_eq!(run_occlusion(|_, _, _| Some(0.0), [0.5; 3], [-90.0, 30.0, 12.0], &mut budget).0, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_chunks_count_as_open_air() {
|
||||
let mut budget = 10_000;
|
||||
let (occ, unknown) = run_occlusion(|_, _, _| None, [0.5; 3], [30.5, 0.5, 0.5], &mut budget);
|
||||
assert_eq!(occ, 0.0);
|
||||
assert_eq!(unknown, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_beside_a_wall_lets_the_sound_through_with_nine_paths() {
|
||||
// A wall at x = 5 with a hole 1 block wide at y = 0..1; the source looks along y = 0.9
|
||||
// at the hole's edge: one path hits the wall's rim, the shifted ones pass.
|
||||
let cell = |x: i32, y: i32, _| Some(if x == 5 && y != 0 { 1.0 } else { 0.0 });
|
||||
let mut budget = 100_000;
|
||||
let single = calculate_occlusion(cell, [0.5, 1.2, 0.5], [12.5, 1.2, 0.5], 1, &mut budget).0;
|
||||
let nine = calculate_occlusion(cell, [0.5, 1.2, 0.5], [12.5, 1.2, 0.5], 9, &mut budget).0;
|
||||
assert_eq!(single, 1.0);
|
||||
assert!(nine < single, "{nine}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sum_is_capped_and_the_budget_is_counted() {
|
||||
let mut budget = 10_000;
|
||||
let (occ, _) = run_occlusion(|_, _, _| Some(40.0), [0.5; 3], [90.5, 0.5, 0.5], &mut budget);
|
||||
assert!(occ > MAX_OCCLUSION && occ <= MAX_OCCLUSION + 40.0);
|
||||
assert_eq!(calculate_occlusion(|_, _, _| Some(40.0), [0.5; 3], [90.5, 0.5, 0.5], 1, &mut budget).0, MAX_OCCLUSION);
|
||||
let mut small = 5;
|
||||
let (occ, _) = run_occlusion(|_, _, _| Some(0.0), [0.5; 3], [90.5, 0.5, 0.5], &mut small);
|
||||
assert_eq!((occ, small), (0.0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_come_from_the_step_sound() {
|
||||
assert_eq!(group_of("minecraft:block.wool.step"), "wool");
|
||||
assert_eq!(group_occlusion("wool"), 1.5);
|
||||
assert_eq!(group_occlusion(group_of("minecraft:block.glass.step")), 0.1);
|
||||
assert_eq!(group_occlusion(group_of("minecraft:block.stone.step")), 1.0);
|
||||
assert_eq!(group_occlusion(group_of("minecraft:block.ladder.step")), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,49 @@ struct Channel {
|
||||
relative: bool,
|
||||
/// A mod source (AirDef fork): smoothed gain, pan and pitch, a click-free envelope.
|
||||
voice: Option<Voice>,
|
||||
/// Acoustics (AirDef fork, #96): what stands between the source and the listener.
|
||||
occlusion: Option<Occlusion>,
|
||||
}
|
||||
/// The direct sound's treatment by what is between the source and the listener: an overall gain
|
||||
/// and a gain of the highs (a shelf above [`SHELF_HZ`]: the low part passes at `gain`, the high
|
||||
/// part at `gain * cut`), both moving towards their targets over about 80 ms, so a new estimate
|
||||
/// is no step. The lows are kept on purpose: a mountain muffles a blast, it does not silence its
|
||||
/// rumble.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Occlusion {
|
||||
gain: f32,
|
||||
cut: f32,
|
||||
gain_to: f32,
|
||||
cut_to: f32,
|
||||
/// The low-pass states (two poles) that split the sound at [`SHELF_HZ`].
|
||||
low: f32,
|
||||
low2: f32,
|
||||
}
|
||||
/// Where the occlusion shelf splits the sound.
|
||||
const SHELF_HZ: f32 = 1000.0;
|
||||
impl Occlusion {
|
||||
fn new(gain: f32, cut: f32) -> Self {
|
||||
Self { gain, cut, gain_to: gain, cut_to: cut, low: 0.0, low2: 0.0 }
|
||||
}
|
||||
/// Moves towards the targets for a buffer of `seconds`: the gain and cut at its start and end,
|
||||
/// and the coefficient of the split.
|
||||
fn buffer(&mut self, seconds: f32, rate: u32) -> ([f32; 2], [f32; 2], f32) {
|
||||
let alpha = 1.0 - (-seconds / 0.08).exp();
|
||||
let (g0, c0) = (self.gain, self.cut);
|
||||
self.gain += (self.gain_to - self.gain) * alpha;
|
||||
self.cut += (self.cut_to - self.cut) * alpha;
|
||||
let a = 1.0 - (-std::f32::consts::TAU * SHELF_HZ / rate as f32).exp();
|
||||
([g0, self.gain], [c0, self.cut], a)
|
||||
}
|
||||
/// One sample, `t` of the way through the buffer.
|
||||
#[inline]
|
||||
fn run(&mut self, x: f32, t: f32, g: [f32; 2], c: [f32; 2], a: f32) -> f32 {
|
||||
// Two poles, so the highs really are cut (one would leave an eighth of 8 kHz).
|
||||
self.low += a * (x - self.low);
|
||||
self.low2 += a * (self.low - self.low2);
|
||||
let cut = c[0] + (c[1] - c[0]) * t;
|
||||
(self.low2 + cut * (x - self.low2)) * (g[0] + (g[1] - g[0]) * t)
|
||||
}
|
||||
}
|
||||
/// What makes a channel a mod source's voice.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -228,6 +271,12 @@ pub struct Audio {
|
||||
film_saved: Option<(bool, bool)>,
|
||||
/// The mods' persistent sound sources (mod API level 20).
|
||||
pub mod_sources: crate::mod_sources::ModSources,
|
||||
/// Acoustics (AirDef fork, #96), when `HORIZON_ACOUSTICS` asks for them.
|
||||
pub acoustics: Option<crate::acoustics::Acoustics>,
|
||||
}
|
||||
/// Whether `HORIZON_ACOUSTICS` turns the acoustics on (unset, empty or `0`: off).
|
||||
pub fn acoustics_requested() -> bool {
|
||||
std::env::var("HORIZON_ACOUSTICS").is_ok_and(|v| !v.is_empty() && v != "0")
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -297,6 +346,7 @@ impl Backend for MixerBackend {
|
||||
at: [0.0; 3],
|
||||
relative: false,
|
||||
voice: None,
|
||||
occlusion: None,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
@@ -571,6 +621,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0) * dop);
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
// In a fixed order, so the sum (and a film's track) is the same in every run.
|
||||
voices.sort_by_key(|v| v.0);
|
||||
let buffer_seconds = (data.len() / channels.max(1)) as f32 / rate as f32;
|
||||
for (id, [left, right, gain], mut step) in voices {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
@@ -595,6 +647,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let end = if looping { region.1 } else { span };
|
||||
let frames = (data.len() / channels.max(1)).max(1);
|
||||
let env_step = v.env_rate / rate as f32;
|
||||
let shelf = c.occlusion.as_mut().map(|o| o.buffer(buffer_seconds, rate));
|
||||
for (k, frame) in data.chunks_mut(channels).enumerate() {
|
||||
if c.position >= end {
|
||||
if looping {
|
||||
@@ -615,6 +668,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
let s = voice_sample(&samples, c.position, looping, region) * v.env;
|
||||
let t = k as f32 / frames as f32;
|
||||
let s = match (c.occlusion.as_mut(), &shelf) {
|
||||
(Some(o), Some((g, cut, a))) => o.run(s, t, *g, *cut, *a),
|
||||
_ => s,
|
||||
};
|
||||
let lerp = |a: f32, b: f32| a + (b - a) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * lerp(from[0], left);
|
||||
@@ -628,7 +685,9 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
v.age += buffer_seconds;
|
||||
continue;
|
||||
}
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let shelf = c.occlusion.as_mut().map(|o| o.buffer(buffer_seconds, rate));
|
||||
let frames = (data.len() / channels.max(1)).max(1);
|
||||
for (k, frame) in data.chunks_mut(channels).enumerate() {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= samples.len() {
|
||||
if c.looping && samples.len() > 1 {
|
||||
@@ -641,6 +700,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
let s = match (c.occlusion.as_mut(), &shelf) {
|
||||
(Some(o), Some((g, cut, a))) => o.run(s, k as f32 / frames as f32, *g, *cut, *a),
|
||||
_ => s,
|
||||
};
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
@@ -692,7 +755,7 @@ fn open_device(
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let config = preferred_config(device)?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer),
|
||||
@@ -707,6 +770,42 @@ fn open_device(
|
||||
stream.play()?;
|
||||
Ok((stream, config))
|
||||
}
|
||||
/// The device's default configuration, except that a device with more than two channels (a
|
||||
/// virtual 7.1 headset) opens as stereo, at the same rate, when it supports that: the mixer
|
||||
/// pans for two channels.
|
||||
#[cfg(feature = "audio")]
|
||||
fn preferred_config(device: &cpal::Device) -> Result<cpal::SupportedStreamConfig> {
|
||||
let default = device.default_output_config()?;
|
||||
if default.channels() <= 2 {
|
||||
return Ok(default);
|
||||
}
|
||||
let rate = default.sample_rate();
|
||||
let stereo = device
|
||||
.supported_output_configs()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| c.channels() == 2 && c.min_sample_rate() <= rate && rate <= c.max_sample_rate())
|
||||
.min_by_key(|c| c.sample_format() != default.sample_format());
|
||||
Ok(stereo.map_or(default, |c| c.with_sample_rate(rate)))
|
||||
}
|
||||
/// On a device with more than two channels the stereo mix is also copied to every further pair
|
||||
/// (an odd last channel gets the left), so a driver that routes another pair than the front one
|
||||
/// still plays it.
|
||||
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
|
||||
fn mirror_pairs(data: &mut [f32], channels: usize) {
|
||||
if channels <= 2 {
|
||||
return;
|
||||
}
|
||||
for frame in data.chunks_mut(channels) {
|
||||
if frame.len() < channels {
|
||||
break;
|
||||
}
|
||||
let (left, right) = (frame[0], frame[1]);
|
||||
for (i, s) in frame.iter_mut().enumerate().skip(2) {
|
||||
*s = if i % 2 == 0 { left } else { right };
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
@@ -725,6 +824,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
scratch.fill(0.0);
|
||||
} else {
|
||||
mix(&mut m, &mut scratch, channels, rate);
|
||||
mirror_pairs(&mut scratch, channels);
|
||||
}
|
||||
} else {
|
||||
scratch.fill(0.0);
|
||||
@@ -798,6 +898,7 @@ impl Audio {
|
||||
stopped: Default::default(),
|
||||
film_saved: None,
|
||||
mod_sources: Default::default(),
|
||||
acoustics: acoustics_requested().then(|| crate::acoustics::Acoustics::new(false)),
|
||||
}
|
||||
}
|
||||
/// Film capture starts: every sound stops, and from now on only the sounds played between
|
||||
@@ -817,6 +918,10 @@ impl Audio {
|
||||
m.offline = true;
|
||||
m.channels.clear();
|
||||
}
|
||||
if let Some(a) = self.acoustics.as_mut() {
|
||||
a.reset();
|
||||
a.sync = true;
|
||||
}
|
||||
}
|
||||
/// Whether the sounds played now are the film's.
|
||||
pub fn film_allow(&mut self, on: bool) {
|
||||
@@ -844,6 +949,10 @@ impl Audio {
|
||||
m.offline = false;
|
||||
m.channels.clear();
|
||||
}
|
||||
if let Some(a) = self.acoustics.as_mut() {
|
||||
a.reset();
|
||||
a.sync = false;
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
@@ -957,6 +1066,70 @@ impl Audio {
|
||||
m.tick(listener, e, b);
|
||||
});
|
||||
}
|
||||
/// Whether acoustics are on and a sound is playing in the world: the game then builds the
|
||||
/// geometry for [`Audio::tick_acoustics`].
|
||||
pub fn acoustics_wanted(&self) -> bool {
|
||||
self.acoustics.is_some()
|
||||
&& self.backend.mixer.lock().is_ok_and(|m| {
|
||||
m.channels.values().any(|c| c.state == State::Playing && !c.relative)
|
||||
})
|
||||
}
|
||||
/// The positional sounds that play now, for the acoustics.
|
||||
fn acoustic_sources(&self) -> Vec<crate::acoustics::Source> {
|
||||
let Ok(m) = self.backend.mixer.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<crate::acoustics::Source> = m
|
||||
.channels
|
||||
.iter()
|
||||
.filter(|(_, c)| c.state == State::Playing && !c.relative && c.samples.is_some())
|
||||
.map(|(id, c)| {
|
||||
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
|
||||
let d = [at[0] - m.listener[0], at[1] - m.listener[1], at[2] - m.listener[2]];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
crate::acoustics::Source {
|
||||
id: *id as u32,
|
||||
at,
|
||||
loud: c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|s| s.id);
|
||||
out
|
||||
}
|
||||
/// Once per tick, with the geometry (None: the world is not there to read): traces what is
|
||||
/// missing and tells the mixer what lies between each sound and the listener.
|
||||
pub fn tick_acoustics(
|
||||
&mut self,
|
||||
geometry: Option<&Arc<dyn crate::acoustics::Geometry>>,
|
||||
changes: &crate::acoustics::Changes,
|
||||
) {
|
||||
if self.acoustics.is_none() {
|
||||
return;
|
||||
}
|
||||
let sources = self.acoustic_sources();
|
||||
let listener = self.listener;
|
||||
let Some(acoustics) = self.acoustics.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let applied = acoustics.step(geometry, listener, &sources, changes);
|
||||
if applied.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
for a in applied {
|
||||
if let Some(c) = m.channels.get_mut(&(a.id as ChannelId)) {
|
||||
match c.occlusion.as_mut() {
|
||||
Some(o) => {
|
||||
o.gain_to = a.gain;
|
||||
o.cut_to = a.cutoff;
|
||||
}
|
||||
None => c.occlusion = Some(Occlusion::new(a.gain, a.cutoff)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
|
||||
pub fn pause(&mut self) {
|
||||
self.mod_sources.pause(&mut self.backend);
|
||||
@@ -1094,6 +1267,15 @@ impl Audio {
|
||||
"sources": sources, "voices": voiced, "fading": fading,
|
||||
"epoch": self.mod_sources.epoch, "dropped": self.mod_sources.dropped,
|
||||
});
|
||||
if let Some(a) = &self.acoustics {
|
||||
let t = &a.stats;
|
||||
census["acoustics"] = serde_json::json!({
|
||||
"sync": a.sync, "sources": t.sources, "cached": t.cached, "traces": t.traces,
|
||||
"cells": t.cells, "unknown_cells": t.unknown_cells, "hits": t.hits,
|
||||
"misses": t.misses, "jobs": t.jobs, "dropped_late": t.dropped_late,
|
||||
"invalidated": t.invalidated, "skipped_busy": t.skipped_busy,
|
||||
});
|
||||
}
|
||||
if let Ok(cache) = self.backend.cache.lock() {
|
||||
census["decoded"] = cache.entries.len().into();
|
||||
census["decoded_mib"] = (cache.bytes >> 20).into();
|
||||
@@ -1203,6 +1385,7 @@ mod tests {
|
||||
at,
|
||||
relative: false,
|
||||
voice: None,
|
||||
occlusion: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1462,6 +1645,160 @@ mod tests {
|
||||
assert_eq!(run(), run());
|
||||
}
|
||||
|
||||
/// The rms of a stereo buffer's left channel.
|
||||
fn rms(x: &[f32]) -> f32 {
|
||||
let l: Vec<f32> = x.chunks(2).map(|f| f[0]).collect();
|
||||
(l.iter().map(|v| v * v).sum::<f32>() / l.len() as f32).sqrt()
|
||||
}
|
||||
fn tone(hz: f32, n: usize) -> Vec<f32> {
|
||||
(0..n).map(|i| (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin()).collect()
|
||||
}
|
||||
/// A relative source dead ahead, so the pan is a plain number; `occlusion`: (gain, cut).
|
||||
fn heard(hz: f32, occlusion: Option<(f32, f32)>) -> f32 {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(tone(hz, 48_000), None, [0.0; 3], true);
|
||||
c.occlusion = occlusion.map(|(g, k)| Occlusion::new(g, k));
|
||||
m.channels.insert(1, c);
|
||||
let out = render(&mut m, 40, 480);
|
||||
rms(&out[out.len() / 2..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_surround_device_hears_the_stereo_mix_on_every_pair() {
|
||||
// Two frames of a 7.1 device: the mixer wrote the first pair only.
|
||||
let mut data = [0.3, -0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
mirror_pairs(&mut data, 8);
|
||||
assert_eq!(&data[..8], &[0.3, -0.2, 0.3, -0.2, 0.3, -0.2, 0.3, -0.2]);
|
||||
assert_eq!(&data[8..], &[0.5, 0.1, 0.5, 0.1, 0.5, 0.1, 0.5, 0.1]);
|
||||
// An odd count (5.0): the last channel gets the left.
|
||||
let mut five = [0.4, 0.6, 0.0, 0.0, 0.0];
|
||||
mirror_pairs(&mut five, 5);
|
||||
assert_eq!(five, [0.4, 0.6, 0.4, 0.6, 0.4]);
|
||||
// Stereo and mono are left alone.
|
||||
let mut stereo = [0.1, 0.2];
|
||||
mirror_pairs(&mut stereo, 2);
|
||||
assert_eq!(stereo, [0.1, 0.2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wall_cuts_the_highs_and_keeps_the_lows() {
|
||||
// One block of stone in SPR's numbers: highs x0.05, overall gain 0.74.
|
||||
let (g, k) = (0.741, 0.0498);
|
||||
let open_low = heard(100.0, None);
|
||||
let open_high = heard(8_000.0, None);
|
||||
let low = heard(100.0, Some((g, k))) / open_low;
|
||||
let high = heard(8_000.0, Some((g, k))) / open_high;
|
||||
assert!((low - g).abs() < 0.03, "the lows pass at the gain: {low}");
|
||||
assert!(high < g * (k + 0.03), "the highs are cut to about gain x cut: {high}");
|
||||
// And with nothing between, the shelf is no filter.
|
||||
let clear = heard(8_000.0, Some((1.0, 1.0))) / open_high;
|
||||
assert!((clear - 1.0).abs() < 0.01, "{clear}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_estimate_is_a_slide_not_a_step() {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(tone(300.0, 48_000), None, [0.0; 3], true);
|
||||
c.occlusion = Some(Occlusion::new(1.0, 1.0));
|
||||
m.channels.insert(1, c);
|
||||
let before = render(&mut m, 10, 480);
|
||||
let o = m.channels.get_mut(&1).unwrap().occlusion.as_mut().unwrap();
|
||||
(o.gain_to, o.cut_to) = (0.4, 0.001);
|
||||
let after = render(&mut m, 40, 480);
|
||||
let all: Vec<f32> = before.iter().chain(&after).copied().collect();
|
||||
assert!(max_step(&all) < 0.1, "no click: {}", max_step(&all));
|
||||
assert!(rms(&after[after.len() - 960..]) < 0.5 * rms(&before[before.len() - 960..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occluded_rendering_is_deterministic() {
|
||||
let run = || {
|
||||
let mut m = Mixer::default();
|
||||
for id in 1..=5u64 {
|
||||
let mut c = voice_channel(tone(200.0 * id as f32, 9_600), [id as f32, 0.0, 9.0], true, 0.0, 0.0, 0.0);
|
||||
c.occlusion = Some(Occlusion::new(0.5, 0.05 * id as f32));
|
||||
m.channels.insert(id, c);
|
||||
}
|
||||
render(&mut m, 30, 333)
|
||||
};
|
||||
assert_eq!(run(), run());
|
||||
}
|
||||
|
||||
/// Writes the track of a blast with and without a wall between it and the listener, through
|
||||
/// the real acoustics and mixer, for `tools/acoustics_check.py`:
|
||||
/// `HORIZON_ACOUSTICS_OUT=/dir cargo test -p horizon-client --lib audio::tests::export_wall_tracks -- --ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn export_wall_tracks() {
|
||||
use crate::acoustics::{Acoustics, Changes, Geometry, Source};
|
||||
struct Wall(bool);
|
||||
impl Geometry for Wall {
|
||||
fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
|
||||
let wall = self.0;
|
||||
Box::new(move |x, _, _| Some(if wall && (20..22).contains(&x) { 1.0 } else { 0.0 }))
|
||||
}
|
||||
}
|
||||
let Some(dir) = std::env::var_os("HORIZON_ACOUSTICS_OUT") else {
|
||||
return;
|
||||
};
|
||||
// A blast: noise with a fast decay and a low thump, 1.5 s.
|
||||
let mut seed = 12345u32;
|
||||
let blast: Vec<f32> = (0..72_000)
|
||||
.map(|i| {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
let noise = (seed >> 8) as f32 / 8_388_608.0 - 1.0;
|
||||
let t = i as f32 / 48_000.0;
|
||||
(noise * 0.6 + (std::f32::consts::TAU * 70.0 * t).sin() * 0.5) * (-t * 4.0).exp()
|
||||
})
|
||||
.collect();
|
||||
for (name, wall) in [("open", false), ("wall", true)] {
|
||||
let mut m = Mixer::default();
|
||||
m.listener = [0.5, 70.5, 0.5];
|
||||
m.channels.insert(1, channel(blast.clone(), Some(500.0), [60.5, 70.5, 0.5], false));
|
||||
let geometry: Arc<dyn Geometry> = Arc::new(Wall(wall));
|
||||
let mut acoustics = Acoustics::new(true);
|
||||
let source = Source { id: 1, at: [60.5, 70.5, 0.5], loud: 0.88 };
|
||||
for a in acoustics.step(Some(&geometry), m.listener, &[source], &Changes::default()) {
|
||||
m.channels.get_mut(&1).unwrap().occlusion = Some(Occlusion::new(a.gain, a.cutoff));
|
||||
}
|
||||
let out = render(&mut m, 150, 480);
|
||||
let raw: Vec<u8> = out.iter().flat_map(|s| ((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes()).collect();
|
||||
std::fs::write(std::path::Path::new(&dir).join(format!("{name}.raw")), raw).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The cost of the shelf for 100 voices, in release: `cargo test -p horizon-client --lib
|
||||
/// --release audio::tests::bench -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_one_hundred_voices_with_and_without_occlusion() {
|
||||
let make = |occluded: bool| {
|
||||
let mut m = Mixer::default();
|
||||
for id in 1..=100u64 {
|
||||
let mut c = voice_channel(tone(110.0 + id as f32 * 7.0, 96_000), [id as f32 * 3.0, 1.0, 40.0], true, 0.0, 0.0, 0.0);
|
||||
c.linear = Some(500.0);
|
||||
if occluded {
|
||||
c.occlusion = Some(Occlusion::new(0.5, 0.1));
|
||||
}
|
||||
m.channels.insert(id, c);
|
||||
}
|
||||
m
|
||||
};
|
||||
let time = |mut m: Mixer| {
|
||||
let mut data = vec![0.0f32; 1024];
|
||||
for _ in 0..50 {
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
}
|
||||
start.elapsed().as_secs_f64() * 1e3 / 1000.0
|
||||
};
|
||||
let (plain, occluded) = (time(make(false)), time(make(true)));
|
||||
println!("100 voices, 512 frames: {plain:.3} ms plain, {occluded:.3} ms with occlusion ({:+.3} ms); buffer is 10.67 ms", occluded - plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doppler_is_bounded_and_off_at_zero() {
|
||||
assert_eq!(doppler_factor(-300.0, 0.0), 1.0);
|
||||
|
||||
@@ -109,6 +109,7 @@ mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_sounds;
|
||||
mod acoustics;
|
||||
mod mod_browser;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_browser_recipes;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! AirDef fork: the game's side of the acoustics (#96): the table of what each block state does
|
||||
//! to a sound, the snapshot of the resident chunks they trace through, the block changes that
|
||||
//! drop what was traced. The engine is `horizon_client::acoustics`; this only feeds it.
|
||||
use super::Game;
|
||||
use horizon_client::acoustics::{Changes, Geometry, Snapshot, spr_occlusion};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The chunks are read within this many chunks of the listener (the audible range and a margin).
|
||||
const CHUNKS: i32 = 36;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct AcousticState {
|
||||
/// The occlusion of each block state, made for a world (its epoch) and the table's size.
|
||||
table: Option<(u32, usize, Arc<Vec<f32>>)>,
|
||||
snapshot: Option<Arc<Snapshot>>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The occlusion of every block state (SPR's table by the block's sound group, a quarter of it
|
||||
/// for a block that is not a full cube); fluids and plants let the sound pass.
|
||||
fn acoustic_table(&mut self) -> Arc<Vec<f32>> {
|
||||
let epoch = self.world.epoch;
|
||||
let facts = self.res.facts();
|
||||
if let Some((e, n, t)) = &self.mods.acoustic.table
|
||||
&& *e == epoch
|
||||
&& *n == facts.states.len()
|
||||
{
|
||||
return t.clone();
|
||||
}
|
||||
let cached = self.collision.cached_shapes();
|
||||
let mut table = vec![0.0f32; facts.states.len()];
|
||||
for (i, f) in facts.states.iter().enumerate() {
|
||||
let state = BlockStateId(i as u32);
|
||||
if facts.is_air(state) {
|
||||
continue;
|
||||
}
|
||||
let (solid, full) = match cached.cached_shape(state).ok().flatten() {
|
||||
Some(s) if s.is_empty() => (false, false),
|
||||
Some(s) => (true, s.is_block()),
|
||||
// Its shape depends on the neighbours: the outline stands in, fluids aside.
|
||||
None => (
|
||||
f.fluid == horizon_items::blocks::Fluid::None
|
||||
&& !f.outline.is_empty()
|
||||
&& f.full_collision,
|
||||
f.full_collision,
|
||||
),
|
||||
};
|
||||
if !solid {
|
||||
continue;
|
||||
}
|
||||
let group = spr_occlusion::group_of(&f.sounds.step);
|
||||
let v = spr_occlusion::group_occlusion(group);
|
||||
table[i] = if full { v } else { v * spr_occlusion::NON_FULL };
|
||||
}
|
||||
let table = Arc::new(table);
|
||||
self.mods.acoustic.table = Some((epoch, facts.states.len(), table.clone()));
|
||||
table
|
||||
}
|
||||
|
||||
/// The snapshot of the chunks round the listener, made again when a block changed or the
|
||||
/// listener went far from where it was made.
|
||||
fn acoustic_snapshot(&mut self) -> Arc<Snapshot> {
|
||||
let at = self.audio.listener;
|
||||
if let Some(s) = &self.mods.acoustic.snapshot
|
||||
&& s.epoch == self.world.epoch
|
||||
&& s.revision == self.world.revision
|
||||
&& (s.center[0] - at[0]).abs() < 96.0
|
||||
&& (s.center[2] - at[2]).abs() < 96.0
|
||||
{
|
||||
return s.clone();
|
||||
}
|
||||
let table = self.acoustic_table();
|
||||
let (cx, cz) = ((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4);
|
||||
let chunks = self
|
||||
.world
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|((x, z), _)| (x - cx).abs() <= CHUNKS && (z - cz).abs() <= CHUNKS)
|
||||
.map(|(k, c)| (*k, c.clone()))
|
||||
.collect();
|
||||
let snapshot = Arc::new(Snapshot {
|
||||
chunks,
|
||||
table,
|
||||
epoch: self.world.epoch,
|
||||
revision: self.world.revision,
|
||||
tick: self.ticks as u64,
|
||||
center: at,
|
||||
});
|
||||
self.mods.acoustic.snapshot = Some(snapshot.clone());
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Once per tick, after the mods and the sound engine: what lies between each sound and the
|
||||
/// listener. Nothing happens unless `HORIZON_ACOUSTICS` is on and a sound plays in the world.
|
||||
pub(super) fn tick_acoustics(&mut self) {
|
||||
if self.audio.acoustics.is_none() {
|
||||
return;
|
||||
}
|
||||
self.world.sound_changes.watching = true;
|
||||
if !self.audio.acoustics_wanted() {
|
||||
return;
|
||||
}
|
||||
let (sections, overflow) = self.world.sound_changes.take();
|
||||
let changes = Changes {
|
||||
epoch: self.world.epoch,
|
||||
overflow,
|
||||
sections,
|
||||
};
|
||||
let geometry: Arc<dyn Geometry> = self.acoustic_snapshot();
|
||||
self.audio.tick_acoustics(Some(&geometry), &changes);
|
||||
}
|
||||
}
|
||||
@@ -738,6 +738,10 @@ impl Game<'_> {
|
||||
|
||||
/// After the frame: the finished copies go to the writer, the audio of the frame is mixed.
|
||||
pub(super) fn film_after_render(&mut self, rendered: bool) {
|
||||
// Sounds started since the tick's step are traced before they are mixed (#96).
|
||||
if self.film.film.as_ref().is_some_and(|f| f.phase == Phase::Recording) {
|
||||
self.tick_acoustics();
|
||||
}
|
||||
let Some(film) = self.film.film.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,8 @@ use {
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ModsState {
|
||||
/// The acoustics' view of the world (AirDef fork, #96).
|
||||
pub(super) acoustic: super::acoustics::AcousticState,
|
||||
/// The open world, for `game.world-id` (AirDef fork): its folder, or the server's address.
|
||||
pub(super) world_id: Option<String>,
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -708,6 +710,7 @@ impl Game<'_> {
|
||||
})
|
||||
});
|
||||
self.mods.mods.set_source_generation(self.audio.mod_sources.epoch);
|
||||
self.mods.mods.set_acoustics(self.audio.acoustics.is_some());
|
||||
self.mods.mods.tick(self.ticks);
|
||||
self.refresh_mod_trees();
|
||||
self.update_mod_models();
|
||||
@@ -833,6 +836,7 @@ impl Game<'_> {
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
}
|
||||
self.mods.mods.set_source_generation(self.audio.mod_sources.epoch);
|
||||
self.mods.mods.set_acoustics(self.audio.acoustics.is_some());
|
||||
self.mods.mods.frame(elapsed.as_secs_f32());
|
||||
self.show_mod_output();
|
||||
self.sync_mod_sources();
|
||||
|
||||
@@ -44,6 +44,7 @@ impl Game<'_> {
|
||||
frozen: !self.clock.runs_normally(),
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
self.tick_acoustics();
|
||||
}
|
||||
/// `getSituationalMusic` and `getMusicVolume`.
|
||||
fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Horizon native client: resources, world streaming, rendering and the
|
||||
//! bindings between the validated simulation pieces and the game loop.
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod acoustics;
|
||||
pub mod advancements;
|
||||
pub mod ambience;
|
||||
pub mod animate_tick;
|
||||
|
||||
@@ -105,6 +105,8 @@ pub struct World {
|
||||
/// The sections whose blocks changed since a mod last asked, kept only
|
||||
/// while a mod watches (`render.world-changes`).
|
||||
pub mod_changes: ModChanges,
|
||||
/// The same for the acoustics (AirDef fork, #96): their cache is dropped where a block changed.
|
||||
pub sound_changes: ModChanges,
|
||||
/// AirDef fork: the log of block changes a mod asked for (`render.world-log-start`) and the
|
||||
/// game tick the changes are stamped with.
|
||||
pub log: Option<crate::world_log::WorldLog>,
|
||||
@@ -231,6 +233,7 @@ impl World {
|
||||
pois: Default::default(),
|
||||
epoch: NEXT_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
mod_changes: ModChanges::default(),
|
||||
sound_changes: ModChanges::default(),
|
||||
log: None,
|
||||
log_tick: 0,
|
||||
}
|
||||
@@ -335,6 +338,9 @@ impl World {
|
||||
if self.mod_changes.watching {
|
||||
self.mod_changes.note(x, y, z);
|
||||
}
|
||||
if self.sound_changes.watching {
|
||||
self.sound_changes.note(x, y, z);
|
||||
}
|
||||
if let Some(log) = &mut self.block_log {
|
||||
log.push((x, y, z));
|
||||
}
|
||||
@@ -417,6 +423,9 @@ impl crate::world_log::Blocks for ShownBlocks<'_> {
|
||||
if w.mod_changes.watching {
|
||||
w.mod_changes.note(x, y, z);
|
||||
}
|
||||
if w.sound_changes.watching {
|
||||
w.sound_changes.note(x, y, z);
|
||||
}
|
||||
w.touched(x, y, z, previous, state);
|
||||
}
|
||||
true
|
||||
|
||||
@@ -662,6 +662,11 @@ interface render {
|
||||
/// reloaded). A mod reads it before `sound-sources` and starts its
|
||||
/// sources again when it changed.
|
||||
sound-source-generation: func() -> u32;
|
||||
/// Whether the game muffles sounds by what lies between them and the
|
||||
/// listener (AirDef fork, API level 21: `HORIZON_ACOUSTICS`). A mod that
|
||||
/// muffles by itself (a `_far` variant for a distant blast) plays the near
|
||||
/// one instead when this is true, so nothing is muffled twice.
|
||||
acoustics: func() -> bool;
|
||||
/// Starts, updates and stops persistent sound sources. `generation` is
|
||||
/// the `sound-source-generation` the mod's sources belong to; if it is
|
||||
/// not the current one the call is ignored and returns false (read the
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
//! version's level so the launcher can tell before it installs.
|
||||
|
||||
/// The newest level this Horizon serves.
|
||||
pub const LEVEL: u32 = 20;
|
||||
pub const LEVEL: u32 = 21;
|
||||
|
||||
/// Every function the game offers mods, as `interface.function`, with its
|
||||
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
|
||||
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
|
||||
/// test fails until it is listed here).
|
||||
pub const FUNCTIONS: [(&str, u32); 82] = [
|
||||
pub const FUNCTIONS: [(&str, u32); 83] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -106,6 +106,8 @@ pub const FUNCTIONS: [(&str, u32); 82] = [
|
||||
// AirDef fork: persistent sound sources over the mixer.
|
||||
("render.sound-source-generation", 20),
|
||||
("render.sound-sources", 20),
|
||||
// AirDef fork: whether the game muffles sounds by what is between them and the listener.
|
||||
("render.acoustics", 21),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
|
||||
@@ -328,6 +328,8 @@ pub struct ModelTable {
|
||||
time_view: Option<(u32, u64)>,
|
||||
/// `render.sound-source-generation`: moves on when the game released every source.
|
||||
source_generation: u32,
|
||||
/// `render.acoustics`: the game muffles sounds itself.
|
||||
acoustics: bool,
|
||||
}
|
||||
|
||||
/// The ids of block-change logs: never reused in a run of the game.
|
||||
@@ -1403,6 +1405,12 @@ impl Mods {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the game muffles sounds itself (`render.acoustics`).
|
||||
pub fn set_acoustics(&mut self, on: bool) {
|
||||
if let Ok(mut t) = self.model_table.lock() {
|
||||
t.acoustics = on;
|
||||
}
|
||||
}
|
||||
/// The source changes every running mod made since the last call, tagged with the
|
||||
/// number of the mod's run ([`Mods::source_runs`]).
|
||||
pub fn take_source_changes(&mut self) -> Vec<(u64, crate::sources::SourceChange)> {
|
||||
@@ -2690,6 +2698,9 @@ impl render::Host for State {
|
||||
sections: Vec::new(),
|
||||
})
|
||||
}
|
||||
fn acoustics(&mut self) -> bool {
|
||||
self.model_table.lock().is_ok_and(|t| t.acoustics)
|
||||
}
|
||||
fn sound_source_generation(&mut self) -> u32 {
|
||||
self.model_table.lock().map_or(0, |t| t.source_generation)
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ impl Mod for Guest {
|
||||
// API level 20: the generation, and a source started and stopped at once.
|
||||
"source" | "source_keep" => {
|
||||
let g = render::sound_source_generation();
|
||||
let _ = render::acoustics();
|
||||
let op = |kind| render::SourceOp {
|
||||
kind,
|
||||
id: 1,
|
||||
|
||||
@@ -803,6 +803,7 @@ Where a capture is.
|
||||
| `spawn-particles` | `func(particles: list<particle-spawn>)` | Spawns particles on this client only: no command, no server. At most 512 spawns a call. |
|
||||
| `play-sound` | `func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32)` | Plays a sound event (`minecraft:entity.generic.explode`, `<mod>:<name>`) at a point on this client. |
|
||||
| `sound-source-generation` | `func() -> u32` | Moves on whenever the game released every source of every mod (the world closed, the session ended, a film began or ended, the sounds reloaded). A mod reads it before `sound-sources` and starts its sources again when it changed. |
|
||||
| `acoustics` | `func() -> bool` | Whether the game muffles sounds by what lies between them and the listener (AirDef fork, API level 21: `HORIZON_ACOUSTICS`). A mod that muffles by itself (a `_far` variant for a distant blast) plays the near one instead when this is true, so nothing is muffled twice. |
|
||||
| `sound-sources` | `func(generation: u32, ops: list<source-op>) -> bool` | Starts, updates and stops persistent sound sources. `generation` is the `sound-source-generation` the mod's sources belong to; if it is not the current one the call is ignored and returns false (read the generation, forget the sources, start them again). Stops are never dropped by a limit: only new sources beyond 256 running are, and at most 512 entries a call count. Every source of a mod that stops or is reloaded is released by the game. |
|
||||
| `query-ground` | `func(columns: list<tuple<s32, s32>>)` | Asks for the ground height of these columns (x, z): at most 512 a tick, answered by `ground` from the next tick on. |
|
||||
| `ground` | `func() -> list<tuple<s32, s32, s32>>` | The answers to the last tick's `query-ground`: (x, z, y), y one above the highest block that blocks motion or holds a fluid, as the motion blocking heightmap; -64 where the chunk isn't loaded. |
|
||||
|
||||
@@ -114,6 +114,10 @@ What real mods need next, in the order modpacks and servers need it:
|
||||
source updated once a tick has no steps; 24 sources hold a voice (loudest and nearest times priority), the rest of
|
||||
the loops stay virtual; a stop is never lost to a limit; the sources of a mod that stops or is reloaded are
|
||||
released. A film mixes them on its own clock like `play-sound`.
|
||||
- API level 21 (AirDef fork, capability `render`): `acoustics` tells whether the game muffles sounds by what
|
||||
lies between them and the listener (`HORIZON_ACOUSTICS=1`: a wall or a mountain dims a sound and cuts its highs,
|
||||
traced from a snapshot of the loaded chunks, never in the audio callback). A mod that muffles by itself, such as a
|
||||
`_far` variant for a distant blast, plays the near one when it is true, so nothing is muffled twice.
|
||||
- API level 19 (AirDef fork, capability `command`): `set-tree` gives a client mod's command a tree of subcommands
|
||||
(literals), typed words (`word`, `choice`, `integer` and `number` with bounds, `names` from a list, `block-pos`),
|
||||
optional branches (`runs`), the rest of the line (`text`) and `key=value` options; `set-suggestions` sets the list a
|
||||
|
||||
Reference in New Issue
Block a user