From 7012640e548bfaf2ac0ebdf37b5bda46c403b75c Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 30 Oct 2026 22:09:23 +0300 Subject: [PATCH] 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 --- crates/horizon-client/src/acoustics.rs | 543 ++++++++++++++++++ .../src/acoustics/THIRD_PARTY_SPR.md | 23 + .../src/acoustics/spr_occlusion.rs | 234 ++++++++ crates/horizon-client/src/audio.rs | 341 ++++++++++- crates/horizon-client/src/game.rs | 1 + crates/horizon-client/src/game/acoustics.rs | 113 ++++ crates/horizon-client/src/game/film.rs | 4 + crates/horizon-client/src/game/mods.rs | 4 + crates/horizon-client/src/game/music.rs | 1 + crates/horizon-client/src/lib.rs | 1 + crates/horizon-client/src/world.rs | 9 + crates/horizon-mod-sdk/wit/horizon.wit | 5 + crates/horizon-mods/src/api_level.rs | 6 +- crates/horizon-mods/src/runtime.rs | 11 + crates/horizon-mods/tests/ui_guest/src/lib.rs | 1 + docs/api/mods-reference.md | 1 + docs/api/mods.md | 4 + 17 files changed, 1298 insertions(+), 4 deletions(-) create mode 100644 crates/horizon-client/src/acoustics.rs create mode 100644 crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md create mode 100644 crates/horizon-client/src/acoustics/spr_occlusion.rs create mode 100644 crates/horizon-client/src/game/acoustics.rs diff --git a/crates/horizon-client/src/acoustics.rs b/crates/horizon-client/src/acoustics.rs new file mode 100644 index 000000000..8863e4e39 --- /dev/null +++ b/crates/horizon-client/src/acoustics.rs @@ -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 Option + '_>; +} + +/// The resident chunks and a table of the occlusion of each block state. +pub struct Snapshot { + pub chunks: FxHashMap<(i32, i32), Arc>, + pub table: Arc>, + pub epoch: u32, + pub revision: u64, + pub tick: u64, + pub center: [f64; 3], +} +impl Geometry for Snapshot { + fn walker(&self) -> Box Option + '_> { + let mut last: Option<((i32, i32), &Arc)> = 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, + 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, + clock: u64, + applied: HashMap, + 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, + worker: Option<(Sender, Receiver)>, + 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::(); + let (done_tx, done_rx) = std::sync::mpsc::channel::(); + 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 = 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>, + listener: [f64; 3], + sources: &[Source], + changes: &Changes, + ) -> Vec { + 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 = 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 = 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 = (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) -> Vec { + 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, + v: std::sync::atomic::AtomicU32, + } + impl Wall { + fn new(wall: std::ops::Range, v: f32) -> Arc { + Arc::new(Wall { wall, v: std::sync::atomic::AtomicU32::new(v.to_bits()) }) + } + } + impl Geometry for Wall { + fn walker(&self) -> Box Option + '_> { + 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 = (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 = (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"); + } +} diff --git a/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md b/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md new file mode 100644 index 000000000..9c756af88 --- /dev/null +++ b/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md @@ -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. diff --git a/crates/horizon-client/src/acoustics/spr_occlusion.rs b/crates/horizon-client/src/acoustics/spr_occlusion.rs new file mode 100644 index 000000000..1bb5fbbbc --- /dev/null +++ b/crates/horizon-client/src/acoustics/spr_occlusion.rs @@ -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, + 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, + 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 { + 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); + } +} diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs index 61df7bf7d..9ef116839 100644 --- a/crates/horizon-client/src/audio.rs +++ b/crates/horizon-client/src/audio.rs @@ -41,6 +41,49 @@ struct Channel { relative: bool, /// A mod source (AirDef fork): smoothed gain, pan and pitch, a click-free envelope. voice: Option, + /// Acoustics (AirDef fork, #96): what stands between the source and the listener. + occlusion: Option, +} +/// 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, +} +/// 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, 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>, ) -> 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::(device, &config, mixer), F::F64 => build_stream::(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 { + 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>( device: &cpal::Device, @@ -725,6 +824,7 @@ fn build_stream>( 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>) -> Result { @@ -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 { + let Ok(m) = self.backend.mixer.lock() else { + return Vec::new(); + }; + let mut out: Vec = 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>, + 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 = x.chunks(2).map(|f| f[0]).collect(); + (l.iter().map(|v| v * v).sum::() / l.len() as f32).sqrt() + } + fn tone(hz: f32, n: usize) -> Vec { + (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 = 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 Option + '_> { + 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 = (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 = 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 = 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); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 7a87e9ffb..735f26d21 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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; diff --git a/crates/horizon-client/src/game/acoustics.rs b/crates/horizon-client/src/game/acoustics.rs new file mode 100644 index 000000000..e4c8cc400 --- /dev/null +++ b/crates/horizon-client/src/game/acoustics.rs @@ -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>)>, + snapshot: Option>, +} + +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> { + 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 { + 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 = self.acoustic_snapshot(); + self.audio.tick_acoustics(Some(&geometry), &changes); + } +} diff --git a/crates/horizon-client/src/game/film.rs b/crates/horizon-client/src/game/film.rs index 1535eb281..4450624bb 100644 --- a/crates/horizon-client/src/game/film.rs +++ b/crates/horizon-client/src/game/film.rs @@ -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; }; diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 59851d91f..edece10f7 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -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, #[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(); diff --git a/crates/horizon-client/src/game/music.rs b/crates/horizon-client/src/game/music.rs index 6e303e7d3..9ffe94f1f 100644 --- a/crates/horizon-client/src/game/music.rs +++ b/crates/horizon-client/src/game/music.rs @@ -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, f32) { diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index ba7c8a7a7..879133e81 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -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; diff --git a/crates/horizon-client/src/world.rs b/crates/horizon-client/src/world.rs index 40ecbdf7d..d42e51ac8 100644 --- a/crates/horizon-client/src/world.rs +++ b/crates/horizon-client/src/world.rs @@ -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, @@ -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 diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index b7d17d075..5bf3fa665 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -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 diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index 8abd768eb..ee826712f 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -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 diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index f974495d6..182ea7f3f 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -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) } diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 5219e1ffe..ccd1f6527 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -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, diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index c33e79ec4..bcbd91ebe 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -803,6 +803,7 @@ Where a capture is. | `spawn-particles` | `func(particles: list)` | 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`, `:`) 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) -> 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>)` | 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>` | 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. | diff --git a/docs/api/mods.md b/docs/api/mods.md index 7f0db0417..73c3cf780 100644 --- a/docs/api/mods.md +++ b/docs/api/mods.md @@ -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