Acoustics follows chunk residency and voice lifecycle (#110 #111 #112)

Co-Authored-By: Kimi <noreply@moonshot.ai>
This commit is contained in:
2026-11-01 02:30:58 +03:00
committed by airdef
parent 6a893473a4
commit b717c30924
3 changed files with 574 additions and 21 deletions
+463 -17
View File
@@ -13,13 +13,25 @@
//! 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.
//! and the listener, and dropped when a block changes in a section a path passed through, or when
//! the chunk underneath them arrives, is replaced by a reload or goes away. A trace that met a
//! block nobody knows is marked so and is traced again once it is a bit old, so a wall that
//! streams in is found without anything else happening; answers that come back possibly after a
//! chunk moved are checked against that change and dropped when it was theirs.
//!
//! What the mixer is told is also scoped: a sound that leaves the acoustic range (beyond
//! [`RANGE`], too quiet to hear, or a world that is gone for a moment) is explicitly given the
//! plain numbers again — the air's absorption at its distance, no wall, no echoes, no reverb sends
//! — so what was once traced for it is not remembered for it; when it comes back, the current
//! geometry applies.
//!
//! Stage 2 adds echo taps for loud one-shots (`echo`): rays from the source and the listener find
//! the walls, and each wall that is really there gives a copy of the sound delayed by the longer
//! path. They are traced on the same worker, cached by the 1-block cells of the source and the
//! listener, and handed to the mixer by [`Acoustics::take_echoes`]; only [`ECHO_VOICES`] sounds
//! at a time get them.
//! get them at once (the loudest that deserve them) — a sound that drops out of that set has its
//! taps recalled, which the mixer fades out over about 50 ms, so the taps actually sounding may
//! briefly reach another round of [`ECHO_VOICES`] while a rotation plays out.
//!
//! Stage 3 adds the reverb (`room`): rays from the listener (and from each echo source, in its
//! echo trace) measure how enclosed, how large and how reflective the space is, which gives the
@@ -39,7 +51,7 @@ pub use room::Room;
use spr_occlusion::run_occlusion;
use crate::chunk::{Chunk, HEIGHT, MIN_Y};
use crate::fast_hash::FxHashMap;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};
@@ -61,9 +73,17 @@ const JOB_CELLS: usize = 400_000;
const JOB_SOURCES: usize = 32;
/// Sources that get the nine paths; the rest get one.
const FULL_PATHS: usize = 16;
/// Ticks a trace that met unknown cells is kept before it is traced again: the snapshot then may
/// miss chunks that are not there yet, and they arrive on their own. A section change always
/// drops the trace at once; this only bounds how long a partial answer is mixed with.
const UNKNOWN_TICKS: u64 = 40;
/// Cached estimates.
const CACHE: usize = 4096;
/// Sounds that have echo taps at the same time (the loudest of those that deserve them).
/// Sounds that have echo taps at the same time (the loudest of those that deserve them). This is
/// the budget of the engine's work, not of the mixer's taps alone: a sound that leaves the set has
/// its taps recalled, which the mixer fades out over about 50 ms (about five mixer buffers), so
/// right after a full rotation up to twice this many taps are sounding, and within that fade the
/// steady number is `ECHO_VOICES` again.
pub const ECHO_VOICES: usize = 8;
/// Echo traces one worker job holds.
const ECHO_JOB: usize = 4;
@@ -100,6 +120,9 @@ pub struct Snapshot {
pub reflect: Arc<Vec<f32>>,
pub epoch: u32,
pub revision: u64,
/// The residency of the columns it was made from (`ChunkMap::content_revision`): a reload,
/// an unload or a new column means the ranks' contents may differ from these `Arc`s.
pub chunk_rev: u64,
pub tick: u64,
pub center: [f64; 3],
}
@@ -177,6 +200,10 @@ pub struct Changes {
pub epoch: u32,
pub overflow: bool,
pub sections: Vec<(i32, i32, i32)>,
/// What the resident chunks held (`ChunkMap::content_revision`): when it moves, a worker job
/// made from the snapshot before it applies to chunks that may be gone, and its answers are
/// dropped. The `sections` of the same change still drop the individual cached estimates.
pub residency: u64,
}
type Cell = (i32, i32, i32);
@@ -187,6 +214,11 @@ struct Entry {
/// The box (in blocks) the paths lay in.
min: [i32; 3],
max: [i32; 3],
/// The tick it was traced at, so a trace that met unknown cells (see
/// [`UNKNOWN_TICKS`]) is not kept for ever.
born: u64,
/// The walk met blocks nobody knows (a chunk the snapshot is missing).
unk: bool,
used: u64,
}
impl Entry {
@@ -206,12 +238,16 @@ struct EchoEntry {
room: Room,
min: [i32; 3],
max: [i32; 3],
born: u64,
unk: bool,
used: u64,
}
/// The space round the listener's block.
#[derive(Clone, Copy, Debug)]
struct RoomEntry {
room: Room,
born: u64,
unk: bool,
used: u64,
}
/// The box a room measure from `cell` looked in.
@@ -270,7 +306,7 @@ struct Done {
seq: u64,
entries: Vec<(Key, Entry)>,
echoes: Vec<(Key, EchoEntry, u64)>,
rooms: Vec<(Cell, Room)>,
rooms: Vec<(Cell, Room, bool)>,
cells: usize,
unknown: usize,
}
@@ -294,7 +330,15 @@ fn trace_echo(geometry: &dyn Geometry, key: Key, budget: &mut usize, clock: u64)
let m = room::measure(geometry, centre(key.0), &mut room_budget);
let us = start.elapsed().as_micros() as u64;
(
EchoEntry { taps: t.taps, room: m.room, min: t.min, max: t.max, used: clock },
EchoEntry {
taps: t.taps,
room: m.room,
min: t.min,
max: t.max,
born: clock,
unk: t.unknown + m.unknown > 0,
used: clock,
},
t.cells + m.cells,
t.unknown + m.unknown,
us,
@@ -320,6 +364,8 @@ fn trace(geometry: &dyn Geometry, listener: [f64; 3], at: [f64; 3], paths: usize
paths: paths as u8,
min: [lo(0), lo(1), lo(2)],
max: [hi(0), hi(1), hi(2)],
born: 0,
unk: unknown > 0,
used: 0,
},
before - *budget,
@@ -334,6 +380,12 @@ pub struct Acoustics {
cache: HashMap<Key, Entry>,
clock: u64,
applied: HashMap<u32, Applied>,
/// The sounds that were in the acoustic range at the last step (with the geometry present),
/// so one that leaves it (beyond `RANGE`, inaudible, or the world gone) is told so plainly.
scoped: FxHashSet<u32>,
/// The residency (`Changes::residency`) the last step saw: worker jobs made before a change
/// are dropped (`floor`) when it moves.
residency: Option<u64>,
log: std::collections::VecDeque<(u64, Cell)>,
seq: u64,
/// Jobs up to this number belong to a world that is gone.
@@ -360,6 +412,8 @@ impl Acoustics {
cache: HashMap::new(),
clock: 0,
applied: HashMap::new(),
scoped: FxHashSet::default(),
residency: None,
log: Default::default(),
seq: 0,
floor: 0,
@@ -381,6 +435,8 @@ impl Acoustics {
pub fn reset(&mut self) {
self.cache.clear();
self.applied.clear();
self.scoped.clear();
self.residency = None;
self.echo_cache.clear();
self.echo_applied.clear();
self.echo_pending.clear();
@@ -427,7 +483,7 @@ impl Acoustics {
let m = room::measure(&*job.geometry, centre(cell), &mut b);
cells += m.cells;
unknown += m.unknown;
rooms.push((cell, m.room));
rooms.push((cell, m.room, m.unknown > 0));
}
if done_tx.send(Done { seq: job.seq, entries, echoes, rooms, cells, unknown }).is_err() {
break;
@@ -453,6 +509,7 @@ impl Acoustics {
self.floor = self.seq;
self.in_flight = None;
self.applied.clear();
self.scoped.clear();
return;
}
for &s in &changes.sections {
@@ -473,6 +530,7 @@ impl Acoustics {
fn insert(&mut self, key: Key, mut entry: Entry) {
entry.used = self.clock;
entry.born = 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();
@@ -484,6 +542,9 @@ impl Acoustics {
fn insert_echo(&mut self, key: Key, mut entry: EchoEntry) {
entry.used = self.clock;
if entry.born == 0 {
entry.born = self.clock;
}
self.echo_cache.insert(key, entry);
if self.echo_cache.len() > ECHO_CACHE {
let mut ages: Vec<u64> = self.echo_cache.values().map(|e| e.used).collect();
@@ -510,6 +571,14 @@ impl Acoustics {
) -> Vec<Applied> {
self.clock += 1;
self.invalidate(changes);
// The resident chunks changed: a job traced from the snapshot before them applies to
// chunks that may be gone, so its answers are dropped with the old residency's answers.
if self.residency != Some(changes.residency) {
self.residency = Some(changes.residency);
self.seq += 1;
self.floor = self.seq;
self.in_flight = None;
}
// 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();
@@ -535,13 +604,16 @@ impl Acoustics {
self.insert_echo(key, e);
}
}
for (cell, room) in d.rooms {
for (cell, room, unknown) in d.rooms {
let (lo, hi) = room_box(cell);
self.stats.room_traces += 1;
if self.log.iter().any(|(s, c)| *s >= d.seq && box_touches(lo, hi, *c)) {
self.stats.dropped_late += 1;
} else {
self.room_cache.insert(cell, RoomEntry { room, used: self.clock });
self.room_cache.insert(
cell,
RoomEntry { room, born: self.clock, unk: unknown, used: self.clock },
);
}
}
for (key, e) in d.entries {
@@ -562,8 +634,11 @@ impl Acoustics {
self.applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
self.sends_applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
self.echo_applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
// A sound that left the acoustic range no longer holds what it was once told: the mixer
// gets the plain numbers for it (and no taps, no sends), instead of its last wall.
let mut told = self.settle_scope(sources, &live, listener, geometry.is_none());
let Some(geometry) = geometry else {
return self.settle(&live, listener, None);
return told;
};
// The loudest first; ties by id, so the order never depends on a hash.
let mut order: Vec<usize> = (0..live.len()).collect();
@@ -577,7 +652,9 @@ impl Acoustics {
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 {
// A trace that met unknown cells is not kept for ever: a chunk may have arrived.
let partial = e.unk && self.clock >= e.born + UNKNOWN_TICKS;
if !partial && (e.paths as usize >= want || e.paths == 9) {
self.stats.hits += 1;
continue;
}
@@ -621,7 +698,8 @@ impl Acoustics {
}
}
self.stats.cached = self.cache.len();
self.settle(&live, listener, Some(lcell))
told.extend(self.settle(&live, listener, Some(lcell)));
told
}
/// Gives the loudest echo sources their taps from the cache, traces the missing ones at once
@@ -638,10 +716,22 @@ impl Acoustics {
}
if served >= ECHO_VOICES {
self.stats.echo_capped += 1;
// It was served before (its taps are in the mixer): they are recalled, so the
// cap holds on the taps that sound, not only on these traced just now.
self.recall_echo(s.id);
continue;
}
served += 1;
let key = (block_of(s.at), lcell);
// A trace that met unknown cells is not kept for ever: a chunk may have arrived
// under it, and it is traced again like a missing one would be.
if self
.echo_cache
.get(&key)
.is_some_and(|e| e.unk && self.clock >= e.born + UNKNOWN_TICKS)
{
self.echo_cache.remove(&key);
}
if let Some(e) = self.echo_cache.get_mut(&key) {
e.used = self.clock;
self.stats.echo_hits += 1;
@@ -677,24 +767,38 @@ impl Acoustics {
}
/// The space the listener is in: from the cache, measured at once (a film) or asked of the
/// worker (the cell is returned). The last known space stays until the new one is.
/// worker (the cell is returned). The last known space stays until the new one is. A measure
/// that met unknown cells is not kept for ever: a chunk may have arrived under it.
fn settle_room(&mut self, geometry: &dyn Geometry, listener: [f64; 3]) -> Vec<Cell> {
let cell = block_of(listener);
if let Some(e) = self.room_cache.get_mut(&cell) {
e.used = self.clock;
self.stats.room_hits += 1;
self.room_now = Some(e.room);
} else if self.sync {
if !(e.unk && self.clock >= e.born + UNKNOWN_TICKS) {
self.stats.room_hits += 1;
self.room_now = Some(e.room);
return self.room_cache_tidy();
}
self.room_cache.remove(&cell);
}
if self.sync {
let mut budget = ROOM_CELLS;
let m = room::measure(geometry, centre(cell), &mut budget);
self.stats.room_traces += 1;
self.stats.room_cells += m.cells as u64;
self.stats.unknown_cells += m.unknown as u64;
self.room_cache.insert(cell, RoomEntry { room: m.room, used: self.clock });
self.room_cache.insert(
cell,
RoomEntry { room: m.room, born: self.clock, unk: m.unknown > 0, used: self.clock },
);
self.room_now = Some(m.room);
} else {
return vec![cell];
}
self.room_cache_tidy()
}
/// Keeps the room cache bounded and carries the listener's space into the stats.
fn room_cache_tidy(&mut self) -> Vec<Cell> {
if self.room_cache.len() > ROOM_CACHE {
let mut ages: Vec<u64> = self.room_cache.values().map(|e| e.used).collect();
ages.sort_unstable();
@@ -741,6 +845,76 @@ impl Acoustics {
std::mem::take(&mut self.sends_pending)
}
/// A sound that left the acoustic range is not traced any more, but what the mixer was told
/// keeps playing: the sounds that just left the range (beyond [`RANGE`], too quiet to hear, or
/// with the world gone for a step) are given the plain numbers again — the air's absorption
/// and no wall — and their echo taps are recalled and their reverb sends to nothing. Nothing
/// is kept for a sound the mixer never had numbers for.
fn settle_scope(&mut self, sources: &[Source], live: &[Source], listener: [f64; 3], no_geometry: bool) -> Vec<Applied> {
let mut out = Vec::new();
let mut was = std::mem::take(&mut self.scoped);
if no_geometry {
// The world is gone for a step: nothing can be traced for the live ones either, so
// the plain numbers stand for them until the world is read again.
for s in live {
if was.remove(&s.id) {
let cutoff = AIR.powf(dist(s.at, listener) as f32);
out.extend(self.unhold(s.id, 1.0, cutoff));
}
}
} else {
for s in live {
was.remove(&s.id);
}
}
// What is left are the sounds that left the range while the geometry was there: beyond
// RANGE, or quieter than the ear hears. The air still takes the highs by the distance,
// so the numbers continue the open air's numbers without the old wall.
for s in sources {
if !was.remove(&s.id) {
continue;
}
let cutoff = AIR.powf(dist(s.at, listener) as f32);
out.extend(self.unhold(s.id, 1.0, cutoff));
}
if !no_geometry {
self.scoped.extend(live.iter().map(|s| s.id));
}
out
}
/// Puts one sound's acoustics back to the plain numbers. The mixer hears them only when they
/// differ; the echo taps are recalled (they fade out there) and the reverb send goes to
/// nothing (it fades in the mixer too). Returns the numbers, if the mixer has to hear them.
fn unhold(&mut self, id: u32, gain: f32, cutoff: f32) -> Option<Applied> {
let out = self
.applied
.get(&id)
.is_some_and(|p| {
(p.gain - gain).abs() >= 0.002 || (p.cutoff - cutoff).abs() >= 0.002 * p.cutoff.max(0.01)
})
.then_some(Applied { id, gain, cutoff });
if let Some(now) = out {
self.applied.insert(id, now);
}
self.recall_echo(id);
if self.sends_applied.get(&id).is_some_and(|s| s.iter().any(|v| v > &0.0)) {
self.sends_applied.insert(id, [0.0; 4]);
self.sends_pending.push((id, [0.0; 4]));
self.stats.sends += 1;
}
out
}
/// The echo taps of one sound are recalled (the mixer fades them out, so a tap that comes
/// back a moment later picks up where it left off). A sound that had none gains none.
fn recall_echo(&mut self, id: u32) {
if self.echo_applied.get(&id).is_some_and(|t| !t.is_empty()) {
self.echo_applied.insert(id, Vec::new());
self.echo_pending.push((id, Vec::new()));
}
}
/// 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));
@@ -1033,4 +1207,276 @@ mod tests {
}
panic!("the worker did not answer");
}
/// A wall appears at x = 40..41 once `arrived` is set; while it is not, its columns are
/// unknown to the walk (a chunk that is not resident yet), and open ground otherwise.
struct Arriving {
arrived: std::sync::atomic::AtomicU32,
wall: std::ops::Range<i32>,
}
impl Geometry for Arriving {
fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
let arrived = self.arrived.load(std::sync::atomic::Ordering::Relaxed);
let wall = self.wall.clone();
Box::new(move |x, _, _| match (arrived, wall.contains(&x)) {
(0, true) => None,
(1, true) => Some(1.0),
_ => Some(0.0),
})
}
}
/// (AirDef fork #110.) A wall streams in underneath a trace made while its columns were
/// unknown: nothing invalidates the cache, but the unknown trace does not outlive it.
#[test]
fn an_unknown_trace_is_not_kept_and_is_traced_again_when_the_chunks_answer() {
let concrete = Arc::new(Arriving { arrived: 0.into(), wall: 40..42 });
let g: Arc<dyn Geometry> = concrete.clone();
let mut a = Acoustics::new(true);
let l = [0.5, 70.5, 0.5];
let before = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default());
assert!(before[0].cutoff > 0.5, "nothing is known yet: {before:?}");
assert!(a.stats.unknown_cells > 0);
concrete.arrived.store(1, std::sync::atomic::Ordering::Relaxed);
// The partial answer is mixed with only for a bounded while.
for _ in 1..UNKNOWN_TICKS {
let out = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default());
assert!(out.is_empty(), "coasting on the partial answer: {out:?}");
}
// Then it is traced again against the geometry that now knows the wall: muted.
let after = a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default());
assert_eq!(after.len(), 1);
assert!(after[0].cutoff < 0.01, "{after:?}");
assert!(a.stats.traces >= 2);
// And being out of partial answers, the cache answers again.
assert!(a.step(Some(&g), l, &[src(1, 90.5)], &Changes::default()).is_empty());
}
/// (AirDef fork #110.) The column is reloaded (a chunk packet replaces a loaded one) and
/// then it goes away: the estimates traced through its sections are dropped both ways.
#[test]
fn a_reload_of_the_same_column_with_other_content_changes_the_sound() {
let mut a = Acoustics::new(true);
let open: Arc<dyn Geometry> = Arc::new(Arriving { arrived: 0.into(), wall: 0..0 });
let l = [0.5, 70.5, 0.5];
let first = a.step(Some(&open), l, &[src(1, 90.5)], &Changes::default());
assert!(first[0].cutoff > 0.5, "{first:?}");
// Reloaded with a wall in it: its section counts as changed, and the estimate traced
// through it is not kept.
let walled: Arc<dyn Geometry> = Arc::new(Arriving { arrived: 1.into(), wall: 40..42 });
let reload = a.step(
Some(&walled),
l,
&[src(1, 90.5)],
&Changes { residency: 3, sections: vec![(2, 4, 0)], ..Default::default() },
);
assert!(reload[0].cutoff < 0.01, "{reload:?}");
// Unloaded again: bright once more.
let gone = a.step(
Some(&open),
l,
&[src(1, 90.5)],
&Changes { residency: 4, sections: vec![(2, 4, 0)], ..Default::default() },
);
assert!(gone[0].cutoff > 0.5, "{gone:?}");
}
/// (AirDef fork #111.) A sound (or a listener) that walks out of the acoustic range gives
/// up its old wall and takes the plain numbers of its new distance until it comes back.
#[test]
fn a_sound_that_leaves_the_range_lets_the_wall_go_and_comes_back_traced_anew() {
let mut a = Acoustics::new(true);
let wall = Wall::new(100..103, 1.0);
let l = [0.5, 70.5, 0.5];
let first = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
assert!(first[0].cutoff < 0.001, "{first:?}");
// The same sound moves past the range: the wall it was behind stops applying.
let far = a.step(Some(&wall), l, &[src(1, 700.5)], &Changes::default());
assert_eq!(far.len(), 1);
assert_eq!(far[0].gain, 1.0);
assert!((far[0].cutoff - AIR.powf(700.0)).abs() < 1e-6, "{far:?}");
// Already there: the mixer hears nothing new.
assert!(a.step(Some(&wall), l, &[src(1, 700.5)], &Changes::default()).is_empty());
// It walks back into the range and the actual wall applies again.
let back = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
assert!(back[0].cutoff < 0.001 && (back[0].gain - 0.41).abs() < 0.01, "{back:?}");
// A listener walking past the range falls back the same way.
let away = a.step(Some(&wall), [800.5, 70.5, 0.5], &[src(1, 200.5)], &Changes::default());
assert!((away[0].cutoff - AIR.powf(600.0)).abs() < 1e-6, "{away:?}");
let here = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
assert!(here[0].cutoff < 0.001, "{here:?}");
// One sound, one set of numbers: nothing piles up.
assert_eq!(a.applied.len(), 1);
}
/// (AirDef fork #111.) A sound the distance gain makes inaudible stops holding the wall.
#[test]
fn a_temporarily_inaudible_sound_goes_plain_and_gets_the_wall_back_when_loud() {
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 quiet = [Source { loud: 0.0001, ..src(1, 90.5) }];
let plain = a.step(Some(&wall), l, &quiet, &Changes::default());
assert_eq!(plain.len(), 1, "{plain:?}");
assert_eq!(plain[0].gain, 1.0);
assert!((plain[0].cutoff - AIR.powf(90.0)).abs() < 1e-6);
let loud_again = a.step(Some(&wall), l, &[src(1, 90.5)], &Changes::default());
assert!(loud_again[0].cutoff < 0.01, "{loud_again:?}");
}
/// (AirDef fork #111.) A world that is gone for a step (no geometry to read) does not keep
/// any sound's numbers beyond its last trace, and the real numbers apply when it returns.
#[test]
fn a_world_that_is_gone_for_a_step_lets_every_sound_go_until_it_can_be_read() {
let mut a = Acoustics::new(true);
let wall = Wall::new(100..103, 1.0);
let l = [0.5, 70.5, 0.5];
let muted = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
assert!(muted[0].cutoff < 0.001, "{muted:?}");
// No geometry: the plain numbers, and nothing gets traced.
let plain = a.step(None, l, &[src(1, 200.5)], &Changes::default());
assert_eq!(plain.len(), 1);
assert_eq!(plain[0].gain, 1.0);
assert!((plain[0].cutoff - AIR.powf(200.0)).abs() < 1e-6);
assert!(a.step(None, l, &[src(1, 200.5)], &Changes::default()).is_empty(), "no repeats");
assert_eq!(a.stats.traces, 1, "nothing was traced without the geometry");
// The world is back: the actual numbers apply again.
let back = a.step(Some(&wall), l, &[src(1, 200.5)], &Changes::default());
assert!(back[0].cutoff < 0.001 && (back[0].gain - 0.41).abs() < 0.01, "{back:?}");
assert_eq!(a.applied.len(), 1);
}
/// (AirDef fork #111.) An echo sound out of the range recalls its taps (an empty set, which
/// the mixer fades out) and takes the current geometry's taps when it comes back.
#[test]
fn an_echo_sound_leaving_the_range_recalls_its_taps_and_takes_them_back() {
let mut a = Acoustics::new(true);
let g = gorge(true);
a.step(Some(&g), LISTENER, &[shot(1, 0.5)], &Changes::default());
assert_eq!(a.take_echoes().len(), 1, "served once");
a.take_sends();
assert_eq!(a.echo_applied.len(), 1);
assert_eq!(a.echo_applied[&1].len(), 2);
a.step(Some(&g), LISTENER, &[shot(1, 700.5)], &Changes::default());
assert_eq!(a.take_echoes(), [(1, Vec::new())], "recalled");
a.take_sends();
// Nothing more while it stays out.
a.step(Some(&g), LISTENER, &[shot(1, 700.5)], &Changes::default());
assert!(a.take_echoes().is_empty() && a.take_sends().is_empty());
// Back in range: the current taps apply again.
a.step(Some(&g), LISTENER, &[shot(1, 0.5)], &Changes::default());
assert_eq!(a.echo_applied[&1].len(), 2);
assert_eq!(a.echo_applied.len(), 1, "one sound, one record");
}
/// (AirDef fork #111.) A sound out of the range stops its reverb send explicitly.
#[test]
fn a_sound_leaving_the_range_sends_nothing_to_the_reverb_until_it_returns() {
let mut a = Acoustics::new(true);
let inside = [12.5, 64.5, 12.5];
let source = Source { id: 1, at: [12.5, 64.5, 18.5], loud: 0.5, echo: true };
a.step(Some(&hall()), inside, &[source], &Changes::default());
assert!(!a.take_sends().is_empty(), "the hall sends");
// Far away: the send goes to nothing explicitly (the mixer fades it out).
let far = Source { at: [700.5, 64.5, 12.5], ..source };
a.step(Some(&hall()), inside, &[far], &Changes::default());
assert_eq!(a.take_sends(), [(1, [0.0; 4])]);
// Already nothing: no repeats.
assert!(a.step(Some(&hall()), inside, &[far], &Changes::default()).is_empty());
// Back in the hall: its sends apply again.
a.step(Some(&hall()), inside, &[source], &Changes::default());
let again = a.take_sends();
assert_eq!(again.len(), 1);
assert!(again[0].1.iter().any(|v| *v > 0.0), "{again:?}");
}
/// (AirDef fork #112.) Rotating the loudest ECHO_VOICES around: the voices that leave the
/// budget have their taps recalled, so the budget holds on the taps that sound.
#[test]
fn the_echo_budget_recalls_the_taps_of_the_voices_that_leave_it() {
let mut a = Acoustics::new(true);
let rocks: Vec<Source> = (0..16).map(|i| shot(i, f64::from(i) * 1.5 + 0.5)).collect();
a.step(Some(&gorge(true)), LISTENER, &rocks, &Changes::default());
a.take_echoes();
// Equal loudnesses: the first eight ids take the budget, each with taps.
assert_eq!(a.echo_applied.len(), 8);
assert_eq!(a.echo_applied[&0].len(), 2);
// Now the even ids are louder: they take the budget, the odd ones' taps are recalled.
let swapped: Vec<Source> = (0..16)
.map(|i| Source { loud: if i % 2 == 0 { 0.9 } else { 0.1 }, ..shot(i, f64::from(i) * 1.5 + 0.5) })
.collect();
a.step(Some(&gorge(true)), LISTENER, &swapped, &Changes::default());
let told = a.take_echoes();
assert_eq!(
told.iter().filter(|(_, t)| t.is_empty()).map(|(id, _)| *id).collect::<Vec<_>>(),
[1, 3, 5, 7],
"the recalled voices",
);
assert_eq!(
told.iter().filter(|(_, t)| !t.is_empty()).map(|(id, _)| *id).collect::<Vec<_>>(),
[8, 10, 12, 14],
"the new voices",
);
// Exactly the cap holds on the voices that carry taps, through the rotation.
assert_eq!(a.echo_applied.iter().filter(|(_, t)| !t.is_empty()).count(), ECHO_VOICES);
assert_eq!(a.echo_applied.iter().filter(|(_, t)| t.is_empty()).count(), 4);
// And rotating back does not pile anything up.
a.step(Some(&gorge(true)), LISTENER, &rocks, &Changes::default());
a.take_echoes();
assert_eq!(a.echo_applied.iter().filter(|(_, t)| !t.is_empty()).count(), ECHO_VOICES);
assert_eq!(a.echo_applied.len(), 12);
assert_eq!(a.stats.echo_capped, 24);
}
/// A geometry whose walk is slow, so that a worker job can be watched without a race.
struct Slow(std::ops::Range<i32>);
impl Geometry for Slow {
fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
Box::new(move |x, _, _| {
std::hint::black_box(
(0..3_000).fold(1u64, |s, i| s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(i)),
);
Some(if self.0.contains(&x) { 1.0 } else { 0.0 })
})
}
}
/// (AirDef fork #110.) A worker job that traced the snapshot before the resident chunks
/// changed is dropped as a late answer; the job made after the change applies.
#[test]
fn an_answer_made_from_the_old_residency_is_dropped_and_the_new_one_applies() {
let mut a = Acoustics::new(false);
let slow: Arc<dyn Geometry> = Arc::new(Slow(40..42));
let l = [0.5, 70.5, 0.5];
let s = [src(1, 90.5)];
a.step(Some(&slow), l, &s, &Changes::default());
// The one-path estimate goes to the worker for its nine paths; before the answer lands,
// the resident columns change: its answer is dropped with the old residency's answers.
a.step(Some(&slow), l, &s, &Changes { residency: 1, ..Default::default() });
let after = Changes { residency: 7, ..Default::default() };
for _ in 0..200 {
a.step(None, l, &s, &after);
if a.stats.jobs >= 1 && a.stats.dropped_late > 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(a.stats.dropped_late >= 1, "the job was not dropped: {:?}", &a.stats);
assert_eq!(a.cache.len(), 1, "only the game thread's own estimate is cached");
assert_eq!(a.cache.values().next().unwrap().paths, 1, "the worker's refined answer never landed");
// The next job, made after the change, is kept. Wait for its actual result rather than
// for the number of unrelated echo/room jobs that happened to finish first.
a.step(Some(&slow), l, &s, &after);
for _ in 0..400 {
a.step(None, l, &s, &after);
if a.cache.values().next().is_some_and(|e| e.paths == 9) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(a.stats.jobs >= 2);
assert_eq!(a.cache.len(), 1, "the new residency's answer is cached");
assert_eq!(a.cache.values().next().unwrap().paths, 9, "it refined to nine paths");
}
}
+95 -2
View File
@@ -1,7 +1,9 @@
//! Resident chunk membership, with a token for consumers that cache its keys.
use crate::{chunk::Chunk, fast_hash::FxHashMap};
//! Resident chunk membership, with a token for consumers that cache its keys and the columns
//! whose residency changed (AirDef fork #110: the acoustics must hear that too).
use crate::{chunk::Chunk, fast_hash::{FxHashMap, FxHashSet}};
use std::{
collections::hash_map::{Iter, ValuesMut},
collections::VecDeque,
ops::Deref,
sync::{
Arc,
@@ -16,18 +18,31 @@ fn next_revision() -> u64 {
NEXT_REVISION.fetch_add(1, Ordering::Relaxed)
}
/// Columns kept in the changed log past this many of them: everything counts as changed, then.
const CHANGE_CAP: usize = 256;
/// Read access behaves like a map. Structural writes are explicit so edits to
/// blocks or light don't invalidate consumers of the loaded chunk key set.
/// Tokens are process-wide: replacing the entire map also invalidates caches.
pub struct ChunkMap {
columns: Columns,
revision: u64,
/// Bumped by every change of what the columns hold: an insertion (new or
/// replacing a loaded one) and a removal. Edits through `get_mut` are
/// block writes, which the world reports by itself.
content: u64,
/// The columns whose residency changed since `take_changed` last read,
/// in the order they changed, up to [`CHANGE_CAP`] of them.
changed: VecDeque<Key>,
changed_dropped: bool,
}
impl Default for ChunkMap {
fn default() -> Self {
Self {
columns: Columns::default(),
revision: next_revision(),
content: next_revision(),
changed: VecDeque::default(),
changed_dropped: false,
}
}
}
@@ -35,17 +50,41 @@ impl ChunkMap {
pub fn membership_revision(&self) -> u64 {
self.revision
}
/// What the columns hold, not just which of them are resident: a consumer
/// that keeps chunk contents behind `Arc`s (the acoustics' snapshot) must
/// be made again when this moves.
pub fn content_revision(&self) -> u64 {
self.content
}
/// The columns whose residency changed since the last call, and whether
/// changes were dropped (every column counts as changed, then).
pub fn take_changed(&mut self) -> (FxHashSet<Key>, bool) {
let dropped = std::mem::take(&mut self.changed_dropped);
let changed = self.changed.drain(..).collect();
(changed, dropped)
}
fn changed_column(&mut self, key: Key) {
if self.changed.len() >= CHANGE_CAP {
self.changed_dropped = true;
self.changed.clear();
}
self.changed.push_back(key);
}
pub fn insert(&mut self, key: Key, chunk: Arc<Chunk>) -> Option<Arc<Chunk>> {
let previous = self.columns.insert(key, chunk);
self.content = next_revision();
if previous.is_none() {
self.revision = next_revision();
}
self.changed_column(key);
previous
}
pub fn remove(&mut self, key: &Key) -> Option<Arc<Chunk>> {
let previous = self.columns.remove(key);
if previous.is_some() {
self.revision = next_revision();
self.content = next_revision();
self.changed_column(*key);
}
previous
}
@@ -53,6 +92,9 @@ impl ChunkMap {
if !self.columns.is_empty() {
self.columns.clear();
self.revision = next_revision();
self.content = next_revision();
self.changed_dropped = true;
self.changed.clear();
}
}
pub fn get_mut(&mut self, key: &Key) -> Option<&mut Arc<Chunk>> {
@@ -122,4 +164,55 @@ mod tests {
map = ChunkMap::default();
assert_ne!(cleared, map.membership_revision());
}
#[test]
fn insertions_replacements_and_removals_change_the_content_token_and_are_reported() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let (changed, dropped) = map.take_changed();
assert!(changed.is_empty() && !dropped);
// An insertion is reported once.
map.insert((0, 0), chunk.clone());
let content = map.content_revision();
let membership = map.membership_revision();
let had_it = map.take_changed();
assert_eq!(had_it.0.len(), 1);
assert!(had_it.0.contains(&(0, 0)));
// A replacement (a reload with other content): the key set is the
// same, what the column holds is not.
let mut other = Chunk::empty(0, 0);
other.set(0, 0, 0, 9);
let reload = map.insert((0, 0), Arc::new(other)).is_some();
assert!(reload);
assert_eq!(membership, map.membership_revision(), "the keys did not move");
assert_ne!(content, map.content_revision(), "the content did");
let reloaded = map.take_changed();
assert_eq!(reloaded.0.len(), 1);
assert!(reloaded.0.contains(&(0, 0)));
// A removal is reported; reads of an idle map report nothing.
assert!(map.remove(&(0, 0)).is_some());
let removed = map.take_changed();
assert_eq!(removed.0.len(), 1);
assert!(removed.0.contains(&(0, 0)));
let (idle, dropped) = map.take_changed();
assert!(idle.is_empty() && !dropped, "{dropped}");
// Endless change stays bounded: the drop says everything counts.
for x in 0..1_000 {
map.insert((x % 5, 0), chunk.clone());
}
let (_, dropped) = map.take_changed();
assert!(dropped);
// Idle again; a clear of a loaded map says so.
let (empty, dropped) = map.take_changed();
assert!(empty.is_empty() && !dropped);
let was = map.content_revision();
map.clear();
let (_, dropped) = map.take_changed();
assert!(dropped, "a clear loses every column");
assert_ne!(was, map.content_revision());
// A clear of an idle map is idle.
map.clear();
let (empty, dropped) = map.take_changed();
assert!(empty.is_empty() && !dropped);
}
}
+16 -2
View File
@@ -60,13 +60,16 @@ impl Game<'_> {
(table, reflect)
}
/// The snapshot of the chunks round the listener, made again when a block changed or the
/// listener went far from where it was made.
/// The snapshot of the chunks round the listener, made again when a block changed, the
/// resident columns arrived, were replaced or went away (AirDef fork #110), or the listener
/// went far from where it was made.
fn acoustic_snapshot(&mut self) -> Arc<Snapshot> {
let at = self.audio.listener;
let chunk_rev = self.world.chunks.content_revision();
if let Some(s) = &self.mods.acoustic.snapshot
&& s.epoch == self.world.epoch
&& s.revision == self.world.revision
&& s.chunk_rev == chunk_rev
&& (s.center[0] - at[0]).abs() < 96.0
&& (s.center[2] - at[2]).abs() < 96.0
{
@@ -87,6 +90,7 @@ impl Game<'_> {
reflect,
epoch: self.world.epoch,
revision: self.world.revision,
chunk_rev,
tick: self.ticks as u64,
center: at,
});
@@ -101,6 +105,15 @@ impl Game<'_> {
return;
}
self.world.sound_changes.watching = true;
// The columns whose residency changed (a chunk arrived, was replaced by a reload or is
// gone) count as changed sections for the acoustics (AirDef fork #110).
let (columns, dropped) = self.world.chunks.take_changed();
for (x, z) in columns {
self.world.sound_changes.column(x, z);
}
if dropped {
self.world.sound_changes.overflow();
}
if !self.audio.acoustics_wanted() {
return;
}
@@ -109,6 +122,7 @@ impl Game<'_> {
epoch: self.world.epoch,
overflow,
sections,
residency: self.world.chunks.content_revision(),
};
let geometry: Arc<dyn Geometry> = self.acoustic_snapshot();
self.audio.tick_acoustics(Some(&geometry), &changes);