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:
2026-10-30 22:09:23 +03:00
committed by airdef
parent 32f6161921
commit 7012640e54
17 changed files with 1298 additions and 4 deletions
+543
View File
@@ -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);
}
}
+339 -2
View File
@@ -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);
+1
View File
@@ -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;
+113
View File
@@ -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);
}
}
+4
View File
@@ -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;
};
+4
View File
@@ -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();
+1
View File
@@ -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
View File
@@ -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;
+9
View File
@@ -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
+5
View File
@@ -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
+4 -2
View File
@@ -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
+11
View File
@@ -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,
+1
View File
@@ -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. |
+4
View File
@@ -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