From b717c309248ab669feeb95eead77072aa2598e75 Mon Sep 17 00:00:00 2001 From: chexit Date: Sun, 1 Nov 2026 02:30:58 +0300 Subject: [PATCH] Acoustics follows chunk residency and voice lifecycle (#110 #111 #112) Co-Authored-By: Kimi --- crates/horizon-client/src/acoustics.rs | 480 +++++++++++++++++++- crates/horizon-client/src/chunk_map.rs | 97 +++- crates/horizon-client/src/game/acoustics.rs | 18 +- 3 files changed, 574 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client/src/acoustics.rs b/crates/horizon-client/src/acoustics.rs index 7dd895d83..a5281d1b6 100644 --- a/crates/horizon-client/src/acoustics.rs +++ b/crates/horizon-client/src/acoustics.rs @@ -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>, 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, clock: u64, applied: HashMap, + /// 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, + /// The residency (`Changes::residency`) the last step saw: worker jobs made before a change + /// are dropped (`floor`) when it moves. + residency: Option, 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 = 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 = self.echo_cache.values().map(|e| e.used).collect(); @@ -510,6 +571,14 @@ impl Acoustics { ) -> Vec { 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 = 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 = (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 { 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 { if self.room_cache.len() > ROOM_CACHE { let mut ages: Vec = 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 { + 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 { + 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) -> Vec { 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, + } + impl Geometry for Arriving { + fn walker(&self) -> Box Option + '_> { + 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 = 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 = 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 = 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 = (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 = (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::>(), + [1, 3, 5, 7], + "the recalled voices", + ); + assert_eq!( + told.iter().filter(|(_, t)| !t.is_empty()).map(|(id, _)| *id).collect::>(), + [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); + impl Geometry for Slow { + fn walker(&self) -> Box Option + '_> { + 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 = 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"); + } } diff --git a/crates/horizon-client/src/chunk_map.rs b/crates/horizon-client/src/chunk_map.rs index fddded9e1..a53ffda5e 100644 --- a/crates/horizon-client/src/chunk_map.rs +++ b/crates/horizon-client/src/chunk_map.rs @@ -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, + 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, 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) -> Option> { 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> { 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> { @@ -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); + } } diff --git a/crates/horizon-client/src/game/acoustics.rs b/crates/horizon-client/src/game/acoustics.rs index fe2c26de5..ddaa85a0e 100644 --- a/crates/horizon-client/src/game/acoustics.rs +++ b/crates/horizon-client/src/game/acoustics.rs @@ -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 { 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 = self.acoustic_snapshot(); self.audio.tick_acoustics(Some(&geometry), &changes);