Files
airdef/horizon-patches/0040-Mod-chunk-tickets-through-the-server-thread-API-22-s.patch
T
chexit ccc3e20902
build / mod (push) Successful in 4m4s
release / windows (push) Successful in 23m13s
Playtest fixes: smoke by engine, searchlights, far launch sites that load their chunks, air defence balance, last chance and radar-cued shots, radars in smart raids, echo, Windows release build from a tag
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:44:14 +03:00

2203 lines
100 KiB
Diff

From 449edb87be5b5ddc61d0d2fbeef3a91316a97b62 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Fri, 30 Oct 2026 23:44:14 +0300
Subject: [PATCH 40/40] Mod chunk tickets through the server thread (API 22),
softer searchlights and light halos, acoustics stage 2: echo taps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/acoustics.rs | 306 +++++++++++-
.../src/acoustics/THIRD_PARTY_SPR.md | 12 +
crates/horizon-client/src/acoustics/echo.rs | 403 ++++++++++++++++
.../src/acoustics/spr_bounce.rs | 79 ++++
crates/horizon-client/src/audio.rs | 445 +++++++++++++++++-
crates/horizon-client/src/game.rs | 6 +
crates/horizon-client/src/game/acoustics.rs | 21 +-
crates/horizon-client/src/game/integrated.rs | 4 +
crates/horizon-client/src/game/lan.rs | 9 +
crates/horizon-client/src/game/mods.rs | 65 ++-
crates/horizon-client/src/game/remote.rs | 10 +-
crates/horizon-client/src/gfx/hd/light.wgsl | 10 +-
crates/horizon-client/src/gfx/hd/spot.wgsl | 10 +-
crates/horizon-client/src/lan/guest.rs | 4 +
crates/horizon-client/src/lan/hub.rs | 50 +-
crates/horizon-mod-sdk/wit/horizon.wit | 12 +-
crates/horizon-mods/src/api_level.rs | 6 +-
crates/horizon-mods/src/runtime.rs | 19 +
crates/horizon-mods/tests/ui_guest/src/lib.rs | 1 +
docs/api/mods-reference.md | 3 +-
docs/api/mods.md | 4 +
21 files changed, 1426 insertions(+), 53 deletions(-)
create mode 100644 crates/horizon-client/src/acoustics/echo.rs
create mode 100644 crates/horizon-client/src/acoustics/spr_bounce.rs
diff --git a/crates/horizon-client/src/acoustics.rs b/crates/horizon-client/src/acoustics.rs
index 8863e4e39..323685878 100644
--- a/crates/horizon-client/src/acoustics.rs
+++ b/crates/horizon-client/src/acoustics.rs
@@ -15,10 +15,21 @@
//! 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.
//!
+//! 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.
+//!
//! `HORIZON_ACOUSTICS=1` turns it on; off (the default) nothing here runs and the mixer is the
//! mixer it was.
+pub mod echo;
+pub mod spr_bounce;
pub mod spr_occlusion;
+pub use echo::Tap;
+use spr_occlusion::run_occlusion;
+
use crate::chunk::{Chunk, HEIGHT, MIN_Y};
use crate::fast_hash::FxHashMap;
use std::collections::HashMap;
@@ -44,6 +55,16 @@ const JOB_SOURCES: usize = 32;
const FULL_PATHS: usize = 16;
/// Cached estimates.
const CACHE: usize = 4096;
+/// Sounds that have echo taps at the same time (the loudest of those that deserve them).
+pub const ECHO_VOICES: usize = 8;
+/// Echo traces one worker job holds.
+const ECHO_JOB: usize = 4;
+/// Cells the echo traces of one worker job may walk.
+const ECHO_JOB_CELLS: usize = 240_000;
+/// Cells the echo traces of one film tick may walk.
+const ECHO_FILM_CELLS: usize = 480_000;
+/// Cached echo estimates.
+const ECHO_CACHE: usize = 256;
/// Change records kept to judge a job that returns late.
const LOG: usize = 2048;
@@ -51,18 +72,48 @@ const LOG: usize = 2048;
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> + '_>;
+ /// As the walker, with the reflectivity of the block too (what an echo needs); by default
+ /// every solid block reflects as SPR's default does.
+ fn surface(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<(f32, f32)> + '_> {
+ let mut w = self.walker();
+ Box::new(move |x, y, z| w(x, y, z).map(|o| (o, spr_bounce::DEFAULT_REFLECTIVITY)))
+ }
}
/// 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>>,
+ /// The reflectivity of each block state.
+ pub reflect: Arc<Vec<f32>>,
pub epoch: u32,
pub revision: u64,
pub tick: u64,
pub center: [f64; 3],
}
impl Geometry for Snapshot {
+ fn surface(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<(f32, 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, 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),
+ self.reflect.get(state).copied().unwrap_or(spr_bounce::DEFAULT_REFLECTIVITY),
+ ))
+ })
+ }
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| {
@@ -91,6 +142,8 @@ pub struct Source {
pub at: [f64; 3],
/// Volume times the distance gain: what the listener would hear without any wall.
pub loud: f32,
+ /// A loud one-shot that deserves echoes (the mixer decides which).
+ pub echo: bool,
}
impl Source {
pub fn audible(&self) -> bool {
@@ -126,10 +179,21 @@ struct Entry {
}
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])
+ box_touches(self.min, self.max, s)
}
}
+fn box_touches(min: [i32; 3], max: [i32; 3], s: Cell) -> bool {
+ let b = [s.0 * 16, s.1 * 16, s.2 * 16];
+ (0..3).all(|a| min[a] < b[a] + 16 && max[a] >= b[a])
+}
+/// The echo taps of one source in one pair of cells.
+#[derive(Clone, Debug)]
+struct EchoEntry {
+ taps: Vec<Tap>,
+ min: [i32; 3],
+ max: [i32; 3],
+ used: u64,
+}
/// What the engine measured, for `status`.
#[derive(Clone, Debug, Default)]
@@ -145,6 +209,20 @@ pub struct Stats {
pub skipped_busy: u64,
pub sources: usize,
pub cached: usize,
+ /// Echo traces made (game thread plus worker), the cells they walked, the cache's hits, the
+ /// taps found, and the time they took: total and longest, in microseconds.
+ pub echo_traces: u64,
+ pub echo_cells: u64,
+ pub echo_hits: u64,
+ pub echo_taps: u64,
+ pub echo_us: u64,
+ pub echo_us_max: u64,
+ /// The game thread's time in `Audio::tick_acoustics`: total, longest, calls.
+ pub tick_us: u64,
+ pub tick_us_max: u64,
+ pub ticks: u64,
+ /// Sources that deserved echoes but were over the cap, in steps.
+ pub echo_capped: u64,
}
struct Job {
@@ -152,15 +230,34 @@ struct Job {
geometry: Arc<dyn Geometry>,
listener: [f64; 3],
items: Vec<(Key, [f64; 3], usize)>,
+ echoes: Vec<Key>,
}
struct Done {
seq: u64,
entries: Vec<(Key, Entry)>,
+ echoes: Vec<(Key, EchoEntry, u64)>,
cells: usize,
unknown: usize,
}
type Key = (Cell, Cell);
+/// A block's cell, for the echo keys: the trace is made from the cells' centres, so that it is the
+/// same whatever the exact positions were.
+fn block_of(p: [f64; 3]) -> Cell {
+ (p[0].floor() as i32, p[1].floor() as i32, p[2].floor() as i32)
+}
+fn centre(c: Cell) -> [f64; 3] {
+ [f64::from(c.0) + 0.5, f64::from(c.1) + 0.5, f64::from(c.2) + 0.5]
+}
+
+/// Traces the echoes for a pair of block cells; the time is in microseconds.
+fn trace_echo(geometry: &dyn Geometry, key: Key, budget: &mut usize, clock: u64) -> (EchoEntry, usize, usize, u64) {
+ let start = std::time::Instant::now();
+ let t = echo::trace(geometry, centre(key.0), centre(key.1), budget);
+ let us = start.elapsed().as_micros() as u64;
+ (EchoEntry { taps: t.taps, min: t.min, max: t.max, used: clock }, t.cells, t.unknown, us)
+}
+
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)
}
@@ -201,6 +298,10 @@ pub struct Acoustics {
in_flight: Option<u64>,
worker: Option<(Sender<Job>, Receiver<Done>)>,
epoch: u32,
+ echo_cache: HashMap<Key, EchoEntry>,
+ /// What the mixer was last told, and the changes it has not been told yet.
+ echo_applied: HashMap<u32, Vec<Tap>>,
+ echo_pending: Vec<(u32, Vec<Tap>)>,
pub stats: Stats,
}
@@ -217,6 +318,9 @@ impl Acoustics {
in_flight: None,
worker: None,
epoch: 0,
+ echo_cache: HashMap::new(),
+ echo_applied: HashMap::new(),
+ echo_pending: Vec::new(),
stats: Stats::default(),
}
}
@@ -225,6 +329,9 @@ impl Acoustics {
pub fn reset(&mut self) {
self.cache.clear();
self.applied.clear();
+ self.echo_cache.clear();
+ self.echo_applied.clear();
+ self.echo_pending.clear();
self.log.clear();
// A job in flight returns into a world that is gone: its sequence number is stale.
self.seq += 1;
@@ -240,13 +347,21 @@ impl Acoustics {
let mut budget = JOB_CELLS;
let (mut cells, mut unknown) = (0, 0);
let mut entries = Vec::with_capacity(job.items.len());
+ let mut echoes = Vec::with_capacity(job.echoes.len());
+ let mut echo_budget = ECHO_JOB_CELLS;
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() {
+ for key in job.echoes {
+ let (e, c, u, us) = trace_echo(&*job.geometry, key, &mut echo_budget, 0);
+ cells += c;
+ unknown += u;
+ echoes.push((key, e, us));
+ }
+ if done_tx.send(Done { seq: job.seq, entries, echoes, cells, unknown }).is_err() {
break;
}
}
@@ -261,6 +376,9 @@ impl Acoustics {
self.epoch = changes.epoch;
self.stats.invalidated += self.cache.len() as u64;
self.cache.clear();
+ self.echo_cache.clear();
+ self.echo_applied.clear();
+ self.echo_pending.clear();
self.log.clear();
self.seq += 1;
self.floor = self.seq;
@@ -272,6 +390,7 @@ impl Acoustics {
let before = self.cache.len();
self.cache.retain(|_, e| !e.touches(s));
self.stats.invalidated += (before - self.cache.len()) as u64;
+ self.echo_cache.retain(|_, e| !box_touches(e.min, e.max, s));
self.log.push_back((self.seq, s));
}
while self.log.len() > LOG {
@@ -290,6 +409,22 @@ impl Acoustics {
}
}
+ fn insert_echo(&mut self, key: Key, mut entry: EchoEntry) {
+ entry.used = 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();
+ ages.sort_unstable();
+ let cut = ages[ages.len() / 4];
+ self.echo_cache.retain(|_, e| e.used > cut);
+ }
+ }
+
+ /// The echo taps that changed since the last call, by sound id.
+ pub fn take_echoes(&mut self) -> Vec<(u32, Vec<Tap>)> {
+ std::mem::take(&mut self.echo_pending)
+ }
+
/// 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.
@@ -313,9 +448,20 @@ impl Acoustics {
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;
+ self.stats.dropped_late += (d.entries.len() + d.echoes.len()) as u64;
continue;
}
+ for (key, e, us) in d.echoes {
+ self.stats.echo_traces += 1;
+ self.stats.echo_us += us;
+ self.stats.echo_us_max = self.stats.echo_us_max.max(us);
+ if self.log.iter().any(|(s, c)| *s >= d.seq && box_touches(e.min, e.max, *c)) {
+ self.stats.dropped_late += 1;
+ } else {
+ self.stats.echo_taps += e.taps.len() as u64;
+ self.insert_echo(key, e);
+ }
+ }
for (key, e) in d.entries {
if self.log.iter().any(|(s, c)| *s >= d.seq && e.touches(*c)) {
self.stats.dropped_late += 1;
@@ -332,6 +478,7 @@ impl Acoustics {
.collect();
self.stats.sources = live.len();
self.applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
+ self.echo_applied.retain(|id, _| sources.iter().any(|s| s.id == *id));
let Some(geometry) = geometry else {
return self.settle(&live, listener, None);
};
@@ -369,7 +516,9 @@ impl Acoustics {
job.push((key, s.at, want));
}
}
- if !job.is_empty() && !self.sync {
+ let echo_keys = self.settle_echoes(&**geometry, &live, &order, listener);
+ let job_empty = job.is_empty() && echo_keys.is_empty();
+ if !job_empty && !self.sync {
if self.worker.is_none() {
self.spawn();
}
@@ -379,7 +528,7 @@ impl Acoustics {
self.seq += 1;
let seq = self.seq;
if tx
- .send(Job { seq, geometry: geometry.clone(), listener, items: job })
+ .send(Job { seq, geometry: geometry.clone(), listener, items: job, echoes: echo_keys })
.is_ok()
{
self.in_flight = Some(seq);
@@ -390,6 +539,58 @@ impl Acoustics {
self.settle(&live, listener, Some(lcell))
}
+ /// Gives the loudest echo sources their taps from the cache, traces the missing ones at once
+ /// (a film: counted cells) or returns their keys for the worker (live).
+ fn settle_echoes(&mut self, geometry: &dyn Geometry, live: &[Source], order: &[usize], listener: [f64; 3]) -> Vec<Key> {
+ let lcell = block_of(listener);
+ let mut want: Vec<Key> = Vec::new();
+ let mut budget = ECHO_FILM_CELLS;
+ let mut served = 0;
+ for &i in order {
+ let s = &live[i];
+ if !s.echo {
+ continue;
+ }
+ if served >= ECHO_VOICES {
+ self.stats.echo_capped += 1;
+ continue;
+ }
+ served += 1;
+ let key = (block_of(s.at), lcell);
+ if let Some(e) = self.echo_cache.get_mut(&key) {
+ e.used = self.clock;
+ self.stats.echo_hits += 1;
+ } else if self.sync {
+ if budget == 0 {
+ continue;
+ }
+ let (e, cells, unknown, us) = trace_echo(geometry, key, &mut budget, self.clock);
+ self.stats.echo_traces += 1;
+ self.stats.echo_cells += cells as u64;
+ self.stats.unknown_cells += unknown as u64;
+ self.stats.echo_taps += e.taps.len() as u64;
+ self.stats.echo_us += us;
+ self.stats.echo_us_max = self.stats.echo_us_max.max(us);
+ self.insert_echo(key, e);
+ } else if want.len() < ECHO_JOB && !want.contains(&key) {
+ want.push(key);
+ continue;
+ } else {
+ continue;
+ }
+ let taps = self.echo_cache[&key].taps.clone();
+ let changed = match self.echo_applied.get(&s.id) {
+ Some(prev) => *prev != taps,
+ None => !taps.is_empty(),
+ };
+ if changed {
+ self.echo_applied.insert(s.id, taps.clone());
+ self.echo_pending.push((s.id, taps));
+ }
+ }
+ want
+ }
+
/// 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));
@@ -440,7 +641,7 @@ mod tests {
}
}
fn src(id: u32, x: f64) -> Source {
- Source { id, at: [x, 70.5, 0.5], loud: 0.5 }
+ Source { id, at: [x, 70.5, 0.5], loud: 0.5, echo: false }
}
#[test]
@@ -540,4 +741,95 @@ mod tests {
wait(&mut a, 2, &moved);
assert_eq!(a.stats.traces, 1, "the game thread traced only the new source");
}
+
+ /// Blocks for the echo tests: solid where the predicate holds.
+ struct Blocks(Box<dyn Fn(i32, i32, i32) -> bool + Send + Sync>);
+ impl Geometry for Blocks {
+ fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
+ Box::new(move |x, y, z| Some(if (self.0)(x, y, z) { 1.0 } else { 0.0 }))
+ }
+ }
+ /// Ground at y <= 60, a wall at x = -39 (its face) and one at x = 55 if `right`.
+ fn gorge(right: bool) -> Arc<dyn Geometry> {
+ Arc::new(Blocks(Box::new(move |x, y, _| y <= 60 || x <= -40 || (right && x >= 55))))
+ }
+ fn shot(id: u32, z: f64) -> Source {
+ Source { id, at: [0.5, 66.5, z], loud: 0.5, echo: true }
+ }
+ const LISTENER: [f64; 3] = [0.5, 66.5, 30.5];
+
+ #[test]
+ fn an_echo_sound_gets_its_taps_once_and_a_destroyed_wall_changes_them() {
+ let mut a = Acoustics::new(true);
+ let sources = [shot(1, 0.5), Source { echo: false, ..shot(2, 3.5) }];
+ a.step(Some(&gorge(true)), LISTENER, &sources, &Changes::default());
+ let first = a.take_echoes();
+ assert_eq!(first.len(), 1, "only the sound that deserves echoes");
+ assert_eq!(first[0].0, 1);
+ assert_eq!(first[0].1.len(), 2);
+ // Nothing new: the cache answers and the mixer is not told again.
+ a.step(Some(&gorge(true)), LISTENER, &sources, &Changes::default());
+ assert!(a.take_echoes().is_empty());
+ assert!(a.stats.echo_hits >= 1 && a.stats.echo_traces == 1);
+ // A change elsewhere keeps it.
+ a.step(Some(&gorge(true)), LISTENER, &sources, &Changes { sections: vec![(90, 4, 90)], ..Default::default() });
+ assert!(a.take_echoes().is_empty() && a.stats.echo_traces == 1);
+ // The right wall is destroyed (section x = 3 holds block 55): one echo is left.
+ let change = Changes { sections: vec![(3, 4, 0)], ..Default::default() };
+ a.step(Some(&gorge(false)), LISTENER, &sources, &change);
+ let after = a.take_echoes();
+ assert_eq!(after.len(), 1);
+ assert_eq!(after[0].1.len(), 1, "{:?}", after[0].1);
+ assert!(after[0].1[0].dir[0] < 0.0);
+ }
+
+ #[test]
+ fn open_ground_sends_no_taps_and_a_new_world_forgets_them() {
+ let mut a = Acoustics::new(true);
+ let field: Arc<dyn Geometry> = Arc::new(Blocks(Box::new(|_, y, _| y <= 60)));
+ a.step(Some(&field), LISTENER, &[shot(1, 0.5)], &Changes::default());
+ assert!(a.take_echoes().is_empty());
+ let mut b = Acoustics::new(true);
+ b.step(Some(&gorge(true)), LISTENER, &[shot(1, 0.5)], &Changes::default());
+ assert_eq!(b.take_echoes().len(), 1);
+ b.step(Some(&gorge(true)), LISTENER, &[shot(1, 0.5)], &Changes { epoch: 4, ..Default::default() });
+ assert_eq!(b.take_echoes().len(), 1, "told again after the epoch moved");
+ }
+
+ #[test]
+ fn only_the_loudest_few_sounds_get_echoes_and_the_cells_are_counted() {
+ let mut a = Acoustics::new(true);
+ // Twenty shots in a row along the gorge, each in a cell of its own.
+ let sources: Vec<Source> = (0..20)
+ .map(|i| Source { loud: 0.2 + 0.02 * i as f32, ..shot(i, 0.5 + f64::from(i) * 1.5) })
+ .collect();
+ a.step(Some(&gorge(true)), LISTENER, &sources, &Changes::default());
+ assert_eq!(a.stats.echo_traces, ECHO_VOICES as u64);
+ assert_eq!(a.stats.echo_capped, 20 - ECHO_VOICES as u64);
+ assert!(a.stats.echo_cells <= (ECHO_FILM_CELLS) as u64);
+ let told = a.take_echoes();
+ // Those near the listener have no echo worth the name; the others are among the loudest.
+ assert!(told.iter().all(|(id, _)| *id >= 12), "{:?}", told.iter().map(|t| t.0).collect::<Vec<_>>());
+ }
+
+ #[test]
+ fn the_worker_traces_the_echoes_off_the_game_thread() {
+ let mut a = Acoustics::new(false);
+ let g = gorge(true);
+ let s = [shot(1, 0.5)];
+ a.step(Some(&g), LISTENER, &s, &Changes::default());
+ assert!(a.take_echoes().is_empty(), "not on the game thread");
+ assert_eq!(a.stats.echo_traces, 0);
+ for _ in 0..400 {
+ a.step(Some(&g), LISTENER, &s, &Changes::default());
+ let told = a.take_echoes();
+ if !told.is_empty() {
+ assert_eq!(told[0].1.len(), 2);
+ assert_eq!(a.stats.echo_traces, 1);
+ return;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ }
+ panic!("the worker did not answer");
+ }
}
diff --git a/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md b/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md
index 9c756af88..134c0e754 100644
--- a/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md
+++ b/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md
@@ -21,3 +21,15 @@ terms of the GPL-3.0 for the whole program (source on request, same licence for
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.
+
+## Stage 2: `spr_bounce.rs`
+
+`spr_bounce.rs` is derived from the same commit and licence. What is taken: from `SoundPhysics.java`
+the directions of the environment rays (a Fibonacci spiral, `longitude = i * phi * 2 pi`,
+`latitude = asin(2 i / n - 1)`) and the energy a bounce sends towards the player
+(`0.25 * (reflectivity * 0.75 + 0.25)`); from `ReflectivityConfig.java` the reflectivity of the block
+groups (0.5 by default, stone and the like 1.5, netherrack 1.1, metal 1.25, glass 0.75, wood 0.4,
+grass, gravel and dirt 0.3, sand 0.2, snow 0.15, wool, moss and honey 0.1). What SPR does with the
+rays (it spreads their energy over four reverb slots) is not used: the echo taps in `echo.rs`
+(image sources, delays from the path length, near-reflection cutoff, selection) are our own and
+MIT, as is the mixer side.
diff --git a/crates/horizon-client/src/acoustics/echo.rs b/crates/horizon-client/src/acoustics/echo.rs
new file mode 100644
index 000000000..5f77f5345
--- /dev/null
+++ b/crates/horizon-client/src/acoustics/echo.rs
@@ -0,0 +1,403 @@
+//! AirDef fork: acoustics stage 2 (#96): echo taps. A loud one-shot (a blast, a launch, a shot)
+//! answers from the walls that are really there, a copy of itself that arrives later by the
+//! longer way: source to wall to listener.
+//!
+//! Method: rays (SPR's Fibonacci directions, `spr_bounce`) go out from the source and from the
+//! listener and find the planes of the walls round them (block faces are axis-aligned, so a hit is
+//! a plane). For each plane the image of the source across it gives the exact specular path, whose
+//! length is the straight line from the image to the listener; the echo is kept only if the point
+//! where that line crosses the plane is a solid block, and the way source-wall and wall-listener is
+//! free. So the delay is the geometry's, to the sample, and a wall that is not there gives nothing.
+//!
+//! Level is the bounce's (SPR's `energyTowardsPlayer`) times the spreading of the longer way. The
+//! sound plays at the time the direct sound arrives, so a tap's delay is the extra path over the
+//! speed of sound. A tap closer than [`NEAR_CUT`] behind the direct sound is dropped and one up to
+//! [`NEAR_FULL`] is faded in: a wall beside the player would only comb the sound, not echo it.
+//! Of the planes found, the strongest [`MAX_TAPS`] that are at least [`SPACING`] apart are kept.
+use super::{AIR, Geometry, run_occlusion, spr_bounce};
+use std::collections::BTreeMap;
+
+/// Rays from each end.
+pub const RAYS: usize = 32;
+/// How far a ray looks for a wall, in blocks.
+pub const RAY_LEN: f64 = 300.0;
+/// Blocks a second.
+pub const SOUND_SPEED: f64 = 343.0;
+/// Echoes less than this many seconds behind the direct sound are dropped (they would comb).
+pub const NEAR_CUT: f64 = 0.030;
+/// And are at full level from here.
+pub const NEAR_FULL: f64 = 0.050;
+/// The longest delay kept, in seconds.
+pub const MAX_DELAY: f64 = 2.0;
+/// Taps a sound gets.
+pub const MAX_TAPS: usize = 6;
+/// Taps are at least this many seconds apart, or they are one echo.
+pub const SPACING: f64 = 0.040;
+/// Quieter taps (relative to the direct sound) are not kept.
+pub const MIN_LEVEL: f32 = 0.02;
+/// Planes tried per source, at most (each costs two walks).
+const TRIES: usize = 24;
+/// The most one wall gives back (amplitude).
+const MAX_REFLECT: f32 = 0.85;
+
+/// One echo of a sound.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Tap {
+ /// Seconds behind the direct sound.
+ pub delay: f32,
+ /// The length of the way source-wall-listener, for the distance gain.
+ pub path: f32,
+ /// Amplitude relative to the sound at its own distance, before the distance gain of `path`.
+ pub level: f32,
+ /// The gain of the highs the air leaves over the extra way (1: none).
+ pub cut: f32,
+ /// Unit vector from the listener to where the echo comes from (the point on the wall).
+ pub dir: [f32; 3],
+}
+
+/// What a trace found.
+#[derive(Clone, Debug, Default)]
+pub struct Traced {
+ pub taps: Vec<Tap>,
+ /// The box (blocks) of the source, the listener and the wall points the taps rest on.
+ pub min: [i32; 3],
+ pub max: [i32; 3],
+ pub cells: usize,
+ pub unknown: usize,
+}
+
+struct Hit {
+ axis: usize,
+ /// +1 or -1: which way the face looks.
+ normal: i32,
+ /// The plane's coordinate along `axis`.
+ coord: i64,
+ refl: f32,
+}
+
+type Surface<'a> = Box<dyn FnMut(i32, i32, i32) -> Option<(f32, f32)> + 'a>;
+
+/// The first solid block along `dir` from `from` (not the block `from` is in), within `len`.
+fn first_hit(
+ surface: &mut Surface<'_>,
+ from: [f64; 3],
+ dir: [f64; 3],
+ len: f64,
+ budget: &mut usize,
+ unknown: &mut usize,
+) -> Option<Hit> {
+ let mut at = [from[0].floor() as i32, from[1].floor() as i32, from[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 dir[a] > 0.0 {
+ step[a] = 1;
+ t_delta[a] = 1.0 / dir[a];
+ t_max[a] = (f64::from(at[a]) + 1.0 - from[a]) / dir[a];
+ } else if dir[a] < 0.0 {
+ step[a] = -1;
+ t_delta[a] = -1.0 / dir[a];
+ t_max[a] = (from[a] - f64::from(at[a])) / -dir[a];
+ }
+ }
+ loop {
+ 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] > len || *budget == 0 {
+ return None;
+ }
+ at[a] += step[a];
+ t_max[a] += t_delta[a];
+ *budget -= 1;
+ match surface(at[0], at[1], at[2]) {
+ None => *unknown += 1,
+ Some((occ, refl)) if occ > 0.0 => {
+ let coord = i64::from(at[a]) + i64::from(step[a] < 0);
+ return Some(Hit { axis: a, normal: -step[a], coord, refl });
+ }
+ Some(_) => {}
+ }
+ }
+}
+
+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()
+}
+
+/// The echo taps of a sound at `src` heard at `lis`. `budget` is the number of block cells the
+/// walks may look at (the work stops, keeping what it has, when it is spent).
+pub fn trace(geometry: &dyn Geometry, src: [f64; 3], lis: [f64; 3], budget: &mut usize) -> Traced {
+ let start = *budget;
+ let mut surface = geometry.surface();
+ let mut unknown = 0usize;
+ let direct = dist(src, lis);
+ // The planes of the walls round both ends. A map, so the order never depends on a hash.
+ let mut planes: BTreeMap<(usize, i32, i64), f32> = BTreeMap::new();
+ for end in [src, lis] {
+ for i in 0..RAYS {
+ let dir = spr_bounce::fibonacci_dir(i, RAYS);
+ if let Some(h) = first_hit(&mut surface, end, dir, RAY_LEN, budget, &mut unknown) {
+ planes.entry((h.axis, h.normal, h.coord)).or_insert(h.refl);
+ }
+ }
+ }
+ // Candidates: the planes both ends stand in front of, with a delay worth an echo.
+ struct Cand {
+ axis: usize,
+ normal: i32,
+ coord: f64,
+ path: f64,
+ delay: f64,
+ level: f32,
+ }
+ let mut cands: Vec<Cand> = Vec::new();
+ for (&(axis, normal, coord), &refl) in &planes {
+ let (c, n) = (coord as f64, f64::from(normal));
+ if (src[axis] - c) * n <= 0.0 || (lis[axis] - c) * n <= 0.0 {
+ continue;
+ }
+ let mut image = src;
+ image[axis] = 2.0 * c - src[axis];
+ let path = dist(image, lis);
+ let delay = (path - direct) / SOUND_SPEED;
+ if !(NEAR_CUT..=MAX_DELAY).contains(&delay) {
+ continue;
+ }
+ let rho = (2.0 * spr_bounce::energy_towards_player(refl)).min(MAX_REFLECT);
+ let spread = (direct.max(4.0) / path).min(1.0) as f32;
+ let near = ((delay - NEAR_CUT) / (NEAR_FULL - NEAR_CUT)).clamp(0.0, 1.0) as f32;
+ let level = rho * spread * near;
+ if level < MIN_LEVEL {
+ continue;
+ }
+ cands.push(Cand { axis, normal, coord: c, path, delay, level });
+ }
+ cands.sort_by(|a, b| b.level.total_cmp(&a.level).then(a.delay.total_cmp(&b.delay)));
+ let mut taps: Vec<Tap> = Vec::new();
+ let pad = 1.0;
+ let mut lo = [0, 1, 2].map(|a| (src[a].min(lis[a]) - pad).floor() as i32);
+ let mut hi = [0, 1, 2].map(|a| (src[a].max(lis[a]) + pad).floor() as i32);
+ let mut tries = 0;
+ for c in cands {
+ if taps.len() >= MAX_TAPS || tries >= TRIES || *budget == 0 {
+ break;
+ }
+ if taps.iter().any(|t| (f64::from(t.delay) - c.delay).abs() < SPACING) {
+ continue;
+ }
+ tries += 1;
+ let n = f64::from(c.normal);
+ let mut image = src;
+ image[c.axis] = 2.0 * c.coord - src[c.axis];
+ // Where the line from the image to the listener crosses the plane.
+ let t = (c.coord - image[c.axis]) / (lis[c.axis] - image[c.axis]);
+ let q = [0, 1, 2].map(|a| image[a] + (lis[a] - image[a]) * t);
+ // The wall is there: the block behind the face at that point is solid.
+ let mut inner = q;
+ inner[c.axis] = c.coord - n * 0.5;
+ *budget = budget.saturating_sub(1);
+ let solid = surface(inner[0].floor() as i32, inner[1].floor() as i32, inner[2].floor() as i32)
+ .is_some_and(|(occ, _)| occ > 0.0);
+ if !solid {
+ continue;
+ }
+ // And the way there and back is free.
+ let mut out = q;
+ out[c.axis] = c.coord + n * 0.02;
+ let (mut free, mut u) = (true, 0);
+ for (a, b) in [(src, out), (out, lis)] {
+ let (o, un) = run_occlusion(|x, y, z| surface(x, y, z).map(|s| s.0), a, b, budget);
+ u += un;
+ free &= o < 0.2;
+ }
+ unknown += u;
+ if !free {
+ continue;
+ }
+ let len = dist(q, lis).max(1e-9);
+ taps.push(Tap {
+ delay: c.delay as f32,
+ path: c.path as f32,
+ level: c.level,
+ cut: AIR.powf((c.path - direct) as f32),
+ dir: [0, 1, 2].map(|a| ((q[a] - lis[a]) / len) as f32),
+ });
+ for a in 0..3 {
+ lo[a] = lo[a].min((q[a] - pad).floor() as i32);
+ hi[a] = hi[a].max((q[a] + pad).floor() as i32);
+ }
+ }
+ taps.sort_by(|a, b| a.delay.total_cmp(&b.delay));
+ Traced { taps, min: lo, max: hi, cells: start - *budget, unknown }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Blocks for tests: occlusion 1 where the predicate holds.
+ struct Blocks(Box<dyn Fn(i32, i32, i32) -> bool + Send + Sync>);
+ impl Geometry for Blocks {
+ fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
+ Box::new(move |x, y, z| Some(if (self.0)(x, y, z) { 1.0 } else { 0.0 }))
+ }
+ }
+ fn blocks(f: impl Fn(i32, i32, i32) -> bool + Send + Sync + 'static) -> Blocks {
+ Blocks(Box::new(f))
+ }
+ /// Seconds behind the direct sound of the specular path off the plane `axis = coord`.
+ pub(crate) fn expected(src: [f64; 3], lis: [f64; 3], axis: usize, coord: f64) -> f64 {
+ let mut image = src;
+ image[axis] = 2.0 * coord - src[axis];
+ (dist(image, lis) - dist(src, lis)) / SOUND_SPEED
+ }
+
+ /// Ground at y <= 60 and two walls: the left face at x = -39, the right one at x = 55.
+ pub(crate) fn gorge() -> impl Geometry {
+ blocks(|x, y, _| y <= 60 || x <= -40 || x >= 55)
+ }
+
+ #[test]
+ fn a_gorge_gives_one_tap_per_wall_at_the_delay_of_its_path() {
+ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]);
+ let mut budget = 1_000_000;
+ let t = trace(&gorge(), src, lis, &mut budget);
+ let want = [expected(src, lis, 0, -39.0), expected(src, lis, 0, 55.0)];
+ let mut want = want.to_vec();
+ want.sort_by(f64::total_cmp);
+ assert_eq!(t.taps.len(), 2, "{:?}", t.taps);
+ for (tap, w) in t.taps.iter().zip(&want) {
+ assert!((f64::from(tap.delay) - w).abs() < 1.0 / 48_000.0, "{} vs {w}", tap.delay);
+ assert!(tap.level > 0.05 && tap.level < MAX_REFLECT);
+ assert!(tap.cut < 1.0 && tap.cut > 0.5, "{}", tap.cut);
+ }
+ // About 0.2 s each: tens of blocks of extra path.
+ assert!(want[0] > 0.1 && want[1] < 0.5, "{want:?}");
+ // The echo of the left wall comes from the left (-x), the right wall's from the right.
+ let left = t.taps.iter().find(|t| t.dir[0] < 0.0).expect("left");
+ let right = t.taps.iter().find(|t| t.dir[0] > 0.0).expect("right");
+ assert!(left.delay != right.delay);
+ assert!(budget < 1_000_000 && t.cells == 1_000_000 - budget);
+ }
+
+ #[test]
+ fn open_ground_answers_with_nothing() {
+ // Just the ground: its reflection is a few blocks of extra path, a comb and no echo.
+ let (src, lis) = ([0.5, 62.5, 0.5], [90.5, 62.5, 40.5]);
+ let mut budget = 1_000_000;
+ let t = trace(&blocks(|_, y, _| y <= 60), src, lis, &mut budget);
+ assert!(t.taps.is_empty(), "{:?}", t.taps);
+ // Nothing at all, not even ground: the sky.
+ let t = trace(&blocks(|_, _, _| false), src, lis, &mut budget);
+ assert!(t.taps.is_empty());
+ }
+
+ #[test]
+ fn an_enclosed_room_gives_a_short_echo() {
+ let room = blocks(|x, y, z| !((0..24).contains(&x) && (60..70).contains(&y) && (0..24).contains(&z)));
+ let (src, lis) = ([4.5, 64.5, 12.5], [20.5, 64.5, 12.5]);
+ let mut budget = 1_000_000;
+ let t = trace(&room, src, lis, &mut budget);
+ assert!(!t.taps.is_empty());
+ // The side walls (z = 0 and z = 24) are 40 ms behind; the end walls and the ceiling are
+ // closer than that to the direct sound and are dropped.
+ let side = expected(src, lis, 2, 0.0);
+ assert!(side > NEAR_CUT, "{side}");
+ for tap in &t.taps {
+ assert!(f64::from(tap.delay) < 0.15, "short: {}", tap.delay);
+ }
+ assert!(t.taps.iter().any(|tap| (f64::from(tap.delay) - side).abs() < 1.0 / 48_000.0));
+ for (axis, coord) in [(0, 0.0), (0, 24.0), (1, 70.0), (1, 60.0)] {
+ assert!(expected(src, lis, axis, coord) < NEAR_CUT);
+ }
+ }
+
+ #[test]
+ fn a_wall_beside_the_listener_does_not_comb() {
+ // Listener 2.5 blocks from a wall, a shot 50 blocks away: the echo would be 15 ms behind.
+ let wall = blocks(|x, y, _| y <= 60 || x <= -3);
+ let (src, lis) = ([50.5, 66.5, 0.5], [0.5, 66.5, 0.5]);
+ assert!(expected(src, lis, 0, -2.0) < NEAR_CUT);
+ let mut budget = 1_000_000;
+ assert!(trace(&wall, src, lis, &mut budget).taps.is_empty());
+ // Just past the cutoff it fades in, not in a step.
+ let at = |x: f64| {
+ let mut b = 1_000_000;
+ trace(&blocks(move |xx, y, _| y <= 60 || f64::from(xx) <= x), [50.5, 66.5, 0.5], lis, &mut b).taps
+ };
+ let far = at(-12.0);
+ assert_eq!(far.len(), 1);
+ let mid = at(-7.0);
+ if let Some(m) = mid.first() {
+ assert!(m.level < far[0].level, "{} {}", m.level, far[0].level);
+ }
+ }
+
+ #[test]
+ fn a_wall_that_is_not_where_the_ray_found_it_gives_no_tap() {
+ // A pillar at x = 55..56 only on z in 20..24: rays find its face, but the specular point
+ // for this pair is at z ~ 15, in the open.
+ let pillar = blocks(|x, y, z| y <= 60 || ((55..56).contains(&x) && (20..24).contains(&z)));
+ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]);
+ let mut budget = 1_000_000;
+ let t = trace(&pillar, src, lis, &mut budget);
+ assert!(t.taps.is_empty(), "{:?}", t.taps);
+ }
+
+ #[test]
+ fn a_screen_in_the_way_blocks_the_echo_path() {
+ // The right wall is there, but a screen stands between the listener and its echo point.
+ let g = blocks(|x, y, z| y <= 60 || x <= -40 || x >= 55 || ((20..22).contains(&x) && (20..30).contains(&z)));
+ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]);
+ let mut budget = 1_000_000;
+ assert_eq!(trace(&gorge(), src, lis, &mut budget).taps.len(), 2);
+ let screened = trace(&g, src, lis, &mut budget);
+ assert_eq!(screened.taps.len(), 1, "{:?}", screened.taps);
+ assert!(screened.taps[0].dir[0] < 0.0, "only the left wall is left");
+ }
+
+ #[test]
+ fn the_budget_is_counted_and_the_result_is_deterministic() {
+ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]);
+ let mut small = 500;
+ let t = trace(&gorge(), src, lis, &mut small);
+ assert!(t.cells <= 500 && small < 500);
+ let run = || {
+ let mut b = 1_000_000;
+ trace(&gorge(), src, lis, &mut b).taps
+ };
+ assert_eq!(run(), run());
+ }
+
+ /// The cost of one trace in release: `cargo test -p horizon-client --lib --release
+ /// acoustics::echo::tests::bench -- --ignored --nocapture`.
+ #[test]
+ #[ignore]
+ fn bench_traces() {
+ let (src, lis) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]);
+ let cases: Vec<(&str, Box<dyn Geometry>)> = vec![
+ ("gorge", Box::new(gorge())),
+ ("open field", Box::new(blocks(|_, y, _| y <= 60))),
+ ("room", Box::new(blocks(|x, y, z| !((-20..40).contains(&x) && (60..80).contains(&y) && (-20..50).contains(&z))))),
+ ];
+ for (name, g) in cases {
+ let n = 2000;
+ let (mut cells, mut taps) = (0, 0);
+ let start = std::time::Instant::now();
+ for i in 0..n {
+ let mut b = 1_000_000;
+ let t = trace(&*g, [src[0] + (i % 7) as f64 * 0.1, src[1], src[2]], lis, &mut b);
+ cells += t.cells;
+ taps += t.taps.len();
+ }
+ let ms = start.elapsed().as_secs_f64() * 1e3 / n as f64;
+ println!("{name:11} {ms:.3} ms per trace, {} cells, {} taps", cells / n, taps / n);
+ }
+ }
+}
diff --git a/crates/horizon-client/src/acoustics/spr_bounce.rs b/crates/horizon-client/src/acoustics/spr_bounce.rs
new file mode 100644
index 000000000..05f4ca3e3
--- /dev/null
+++ b/crates/horizon-client/src/acoustics/spr_bounce.rs
@@ -0,0 +1,79 @@
+// 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
+// (the Fibonacci-sphere ray directions and `energyTowardsPlayer` of evaluateEnvironment)
+// common/src/main/java/com/sonicether/soundphysics/config/ReflectivityConfig.java (the table)
+// Ported to Rust for Horizon's own echo taps, not line by line: SPR spreads the energy of its
+// rays over four reverb slots, we use the same rays, the same energy and the same table to
+// place discrete echoes (see `echo.rs`, ours). See THIRD_PARTY_SPR.md next to this file. The rest
+// of this crate is MIT; this file is GPL-3.0-only.
+//! The bounce side of SPR: where its environment rays point, how much a bounce keeps, and how
+//! reflective each block group is.
+
+/// `defaultBlockReflectivity`.
+pub const DEFAULT_REFLECTIVITY: f32 = 0.5;
+
+/// The `i`-th of `n` directions on a sphere by a Fibonacci spiral (`longitude = i * phi * 2 pi`,
+/// `latitude = asin(2 i / n - 1)`, phi the golden ratio's fraction), as SPR aims its 32
+/// environment rays.
+pub fn fibonacci_dir(i: usize, n: usize) -> [f64; 3] {
+ let phi = 5f64.sqrt() * 0.5 + 0.5 - 1.0;
+ let longitude = i as f64 * phi * std::f64::consts::TAU;
+ let latitude = (2.0 * i as f64 / n as f64 - 1.0).clamp(-1.0, 1.0).asin();
+ [latitude.cos() * longitude.cos(), latitude.sin(), latitude.cos() * longitude.sin()]
+}
+
+/// What a bounce off a block of this reflectivity sends towards the player
+/// (`energyTowardsPlayer = 0.25 * (reflectivity * 0.75 + 0.25)`).
+pub fn energy_towards_player(reflectivity: f32) -> f32 {
+ 0.25 * (reflectivity * 0.75 + 0.25)
+}
+
+/// The reflectivity of a block group (`ReflectivityConfig`), by the group name of the block's
+/// step sound (`minecraft:block.stone.step` is `stone`).
+pub fn group_reflectivity(group: &str) -> f32 {
+ match group {
+ "stone" | "deepslate" | "deepslate_bricks" | "deepslate_tiles" | "polished_deepslate"
+ | "tuff" | "tuff_bricks" | "polished_tuff" | "basalt" | "calcite" | "bone_block"
+ | "nether_bricks" => 1.5,
+ "netherrack" => 1.1,
+ "metal" | "copper" | "copper_bulb" | "copper_grate" | "anvil" | "netherite_block" => 1.25,
+ "glass" => 0.75,
+ "wood" | "nether_wood" | "cherry_wood" | "bamboo_wood" => 0.4,
+ "grass" | "gravel" | "dirt" | "rooted_dirt" => 0.3,
+ "sand" => 0.2,
+ "snow" | "powder_snow" => 0.15,
+ "wool" | "moss" | "honey_block" => 0.1,
+ _ => DEFAULT_REFLECTIVITY,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_directions_are_unit_and_cover_the_sphere() {
+ let n = 32;
+ let dirs: Vec<[f64; 3]> = (0..n).map(|i| fibonacci_dir(i, n)).collect();
+ for d in &dirs {
+ assert!((d.iter().map(|v| v * v).sum::<f64>() - 1.0).abs() < 1e-9);
+ }
+ // Up and down are both covered, and no hemisphere is empty.
+ assert!(dirs.iter().any(|d| d[1] < -0.9) && dirs.iter().any(|d| d[1] > 0.9));
+ for a in 0..3 {
+ assert!(dirs.iter().any(|d| d[a] > 0.3) && dirs.iter().any(|d| d[a] < -0.3));
+ }
+ }
+
+ #[test]
+ fn stone_keeps_more_than_grass_and_wool() {
+ assert!((energy_towards_player(1.5) - 0.34375).abs() < 1e-6);
+ assert!(energy_towards_player(group_reflectivity("stone")) > energy_towards_player(group_reflectivity("grass")));
+ assert!(group_reflectivity("wool") < group_reflectivity("glass"));
+ assert_eq!(group_reflectivity("coral_block"), DEFAULT_REFLECTIVITY);
+ }
+}
diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs
index 9ef116839..640206803 100644
--- a/crates/horizon-client/src/audio.rs
+++ b/crates/horizon-client/src/audio.rs
@@ -43,6 +43,159 @@ struct Channel {
voice: Option<Voice>,
/// Acoustics (AirDef fork, #96): what stands between the source and the listener.
occlusion: Option<Occlusion>,
+ /// Acoustics stage 2: the echoes of the sound, copies of it delayed by the longer way.
+ echo: Option<Echo>,
+}
+/// The echo taps of a one-shot (AirDef fork, #96 stage 2): each reads the sound again `delay`
+/// seconds behind the direct one, through a low-pass for the air it travelled, panned to where
+/// the echo comes from. A tap changes level over about 50 ms, so a new estimate is no click, and a
+/// sound that has ended plays on until its last tap is done.
+#[derive(Clone, Debug, Default)]
+struct Echo {
+ taps: Vec<EchoTap>,
+}
+#[derive(Clone, Debug)]
+struct EchoTap {
+ delay: f32,
+ path: f32,
+ level: f32,
+ level_to: f32,
+ cut: f32,
+ dir: [f32; 3],
+ /// The low-pass state.
+ lp: f32,
+}
+impl Echo {
+ /// New taps: one that matches a tap already sounding (within 3 ms) keeps its delay and moves
+ /// its level; one without a match comes in from silence; one that is gone goes out.
+ fn set(&mut self, taps: &[crate::acoustics::Tap]) {
+ for t in &mut self.taps {
+ t.level_to = 0.0;
+ }
+ let mut claimed = vec![false; self.taps.len()];
+ for n in taps {
+ let found = self
+ .taps
+ .iter()
+ .enumerate()
+ .position(|(i, t)| !claimed[i] && (t.delay - n.delay).abs() < 0.003);
+ match found {
+ Some(i) => {
+ claimed[i] = true;
+ let t = &mut self.taps[i];
+ (t.path, t.level_to, t.cut, t.dir) = (n.path, n.level, n.cut, n.dir);
+ }
+ None => self.taps.push(EchoTap {
+ delay: n.delay,
+ path: n.path,
+ level: 0.0,
+ level_to: n.level,
+ cut: n.cut,
+ dir: n.dir,
+ lp: 0.0,
+ }),
+ }
+ if found.is_none() {
+ claimed.push(true);
+ }
+ }
+ }
+}
+/// One tap for one buffer.
+struct TapPlan {
+ /// The delay in source frames.
+ delay_src: f64,
+ /// Left, right and plain gain at the buffer's start and end.
+ g: [[f32; 3]; 2],
+ a: f32,
+ lp: f32,
+ idx: usize,
+}
+/// The one-pole coefficient for a tap whose highs (at 5 kHz) are `cut` of the sound.
+fn echo_lp(cut: f32, rate: u32) -> f32 {
+ if cut >= 0.999 {
+ return 1.0;
+ }
+ let g = cut.max(0.001);
+ let fc = 5000.0 / (1.0 / (g * g) - 1.0).sqrt();
+ (1.0 - (-std::f32::consts::TAU * fc / rate as f32).exp()).clamp(0.0, 1.0)
+}
+/// The taps of a channel for a buffer of `seconds`, panned by `pan` (unit vector from the
+/// listener to a tap's source of sound); the sound's own gain and the distance gain of the tap's
+/// path apply, the direct path's occlusion does not (the echo comes another way).
+fn plan_echo(
+ echo: &mut Option<Echo>,
+ gain: f32,
+ linear: Option<f32>,
+ relative: bool,
+ seconds: f32,
+ rate: u32,
+ step: f64,
+ pan: &dyn Fn([f32; 3]) -> [f32; 2],
+) -> Vec<TapPlan> {
+ let Some(e) = echo.as_mut().filter(|_| !relative) else {
+ return Vec::new();
+ };
+ let alpha = 1.0 - (-seconds / 0.05).exp();
+ let base = gain.clamp(0.0, 1.0);
+ e.taps
+ .iter_mut()
+ .enumerate()
+ .map(|(idx, t)| {
+ let l0 = t.level;
+ t.level += (t.level_to - t.level) * alpha;
+ if t.level_to == 0.0 && t.level < 1e-3 {
+ t.level = 0.0;
+ }
+ let g = base * distance_gain(linear, t.path);
+ let [pl, pr] = pan(t.dir);
+ let (g0, g1) = (g * l0, g * t.level);
+ TapPlan {
+ delay_src: f64::from(t.delay) * f64::from(rate) * step,
+ g: [[g0 * pl, g0 * pr, g0], [g1 * pl, g1 * pr, g1]],
+ a: echo_lp(t.cut, rate),
+ lp: t.lp,
+ idx,
+ }
+ })
+ .collect()
+}
+/// Keeps the taps' filter states, and drops the taps that are done.
+fn finish_echo(echo: &mut Option<Echo>, plans: &[TapPlan]) {
+ if let Some(e) = echo.as_mut() {
+ for p in plans {
+ if let Some(t) = e.taps.get_mut(p.idx) {
+ t.lp = p.lp;
+ }
+ }
+ e.taps.retain(|t| t.level > 0.0 || t.level_to > 0.0);
+ }
+}
+/// How far past the end of the sound the last tap still sounds, in source frames.
+fn echo_tail(plans: &[TapPlan]) -> f64 {
+ plans.iter().map(|p| p.delay_src).fold(0.0, f64::max) + 2.0
+}
+/// The sum of the taps at source position `pos`, `t` of the way through the buffer.
+#[inline]
+fn run_taps(plans: &mut [TapPlan], samples: &[f32], pos: f64, t: f32) -> [f32; 3] {
+ let mut out = [0.0f32; 3];
+ for p in plans {
+ let q = pos - p.delay_src;
+ if q < 0.0 {
+ continue;
+ }
+ let i = q as usize;
+ if i + 1 >= samples.len() {
+ continue;
+ }
+ let f = (q - i as f64) as f32;
+ let x = samples[i] * (1.0 - f) + samples[i + 1] * f;
+ p.lp += p.a * (x - p.lp);
+ for k in 0..3 {
+ out[k] += p.lp * (p.g[0][k] + (p.g[1][k] - p.g[0][k]) * t);
+ }
+ }
+ out
}
/// 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
@@ -85,6 +238,25 @@ impl Occlusion {
(self.low2 + cut * (x - self.low2)) * (g[0] + (g[1] - g[0]) * t)
}
}
+/// A sound this loud and this far-carrying, once and not a loop, gets echoes (a blast, a launch,
+/// a gunshot; not footsteps, not a hundred engines). The numbers are a guess nobody has listened to.
+const ECHO_GAIN: f32 = 0.5;
+const ECHO_RANGE: f32 = 96.0;
+/// The lengths of sound, in seconds, that are one-shots worth an echo.
+const ECHO_SECONDS: (f32, f32) = (0.1, 12.0);
+fn deserves_echo(c: &Channel) -> bool {
+ let Some((samples, rate)) = &c.samples else {
+ return false;
+ };
+ let seconds = samples.len() as f32 / (*rate).max(1) as f32;
+ !c.looping
+ && !c.relative
+ && c.voice.as_ref().is_none_or(|v| !v.releasing)
+ && c.gain >= ECHO_GAIN
+ && c.linear.is_some_and(|r| r >= ECHO_RANGE)
+ && (ECHO_SECONDS.0..=ECHO_SECONDS.1).contains(&seconds)
+ && c.position < f64::from(seconds) * f64::from(*rate) * 0.7
+}
/// What makes a channel a mod source's voice.
#[derive(Clone, Debug)]
struct Voice {
@@ -347,6 +519,7 @@ impl Backend for MixerBackend {
relative: false,
voice: None,
occlusion: None,
+ echo: None,
},
);
Some(id)
@@ -568,6 +741,11 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
forward[0] * up[1] - forward[1] * up[0],
];
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
+ // Where an echo comes from, as a pair of pan gains.
+ let echo_pan = |d: [f32; 3]| {
+ let d = [f64::from(d[0]), f64::from(d[1]), f64::from(d[2])];
+ stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
+ };
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
for (id, c) in &mixer.channels {
@@ -648,10 +826,16 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
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));
+ let mut plans = plan_echo(&mut c.echo, c.gain, c.linear, c.relative, buffer_seconds, rate, step, &echo_pan);
+ let tail = echo_tail(&plans);
for (k, frame) in data.chunks_mut(channels).enumerate() {
+ let mut past = false;
if c.position >= end {
if looping {
c.position = c.position - region.1 + region.0 + region.2;
+ } else if !plans.is_empty() && c.position < end + tail {
+ // The sound is over, its echoes are not.
+ past = true;
} else {
c.state = State::Stopped;
break;
@@ -666,53 +850,63 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.state = State::Stopped;
break;
}
- let s = voice_sample(&samples, c.position, looping, region) * v.env;
+ let s = if past { 0.0 } else { voice_sample(&samples, c.position, looping, region) * v.env };
let t = k as f32 / frames as f32;
+ let echoes = if plans.is_empty() { [0.0; 3] } else { run_taps(&mut plans, &samples, c.position, t) };
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);
- frame[1] += s * lerp(from[1], right);
+ frame[0] += s * lerp(from[0], left) + echoes[0] * v.env;
+ frame[1] += s * lerp(from[1], right) + echoes[1] * v.env;
} else {
- frame[0] += s * lerp(from[2], gain);
+ frame[0] += s * lerp(from[2], gain) + echoes[2] * v.env;
}
c.position += step;
}
+ finish_echo(&mut c.echo, &plans);
v.last = Some([left, right, gain]);
v.age += buffer_seconds;
continue;
}
let shelf = c.occlusion.as_mut().map(|o| o.buffer(buffer_seconds, rate));
let frames = (data.len() / channels.max(1)).max(1);
+ let mut plans = plan_echo(&mut c.echo, c.gain, c.linear, c.relative, buffer_seconds, rate, step, &echo_pan);
+ let tail = echo_tail(&plans);
for (k, frame) in data.chunks_mut(channels).enumerate() {
let mut i = c.position as usize;
+ let mut past = false;
if i + 1 >= samples.len() {
if c.looping && samples.len() > 1 {
c.position = 0.0;
i = 0;
+ } else if !plans.is_empty() && c.position < samples.len() as f64 + tail {
+ // The sound is over, its echoes are not.
+ past = true;
} else {
c.state = State::Stopped;
break;
}
}
let t = (c.position - i as f64) as f32;
- let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
+ let s = if past { 0.0 } else { samples[i] * (1.0 - t) + samples[i + 1] * t };
+ let echoes = if plans.is_empty() { [0.0; 3] } else { run_taps(&mut plans, &samples, c.position, k as f32 / frames as f32) };
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;
+ frame[0] += s * left + echoes[0];
+ frame[1] += s * right + echoes[1];
} else {
// A mono output has no panning: the source's own gain.
- frame[0] += s * gain;
+ frame[0] += s * gain + echoes[2];
}
c.position += step;
}
+ finish_echo(&mut c.echo, &plans);
}
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
@@ -1091,6 +1285,7 @@ impl Audio {
id: *id as u32,
at,
loud: c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance),
+ echo: deserves_echo(c),
}
})
.collect();
@@ -1107,28 +1302,40 @@ impl Audio {
if self.acoustics.is_none() {
return;
}
+ let started = std::time::Instant::now();
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;
+ let echoes = acoustics.take_echoes();
+ if !applied.is_empty() || !echoes.is_empty() {
+ 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)),
}
- None => c.occlusion = Some(Occlusion::new(a.gain, a.cutoff)),
+ }
+ }
+ for (id, taps) in echoes {
+ if let Some(c) = m.channels.get_mut(&(id as ChannelId)) {
+ c.echo.get_or_insert_with(Echo::default).set(&taps);
}
}
}
}
+ if let Some(a) = self.acoustics.as_mut() {
+ let us = started.elapsed().as_micros() as u64;
+ a.stats.ticks += 1;
+ a.stats.tick_us += us;
+ a.stats.tick_us_max = a.stats.tick_us_max.max(us);
+ }
}
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
pub fn pause(&mut self) {
@@ -1274,6 +1481,14 @@ impl Audio {
"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,
+ "echo": {
+ "traces": t.echo_traces, "hits": t.echo_hits, "taps": t.echo_taps,
+ "cells": t.echo_cells, "ms": t.echo_us as f64 / 1e3,
+ "ms_max": t.echo_us_max as f64 / 1e3, "capped": t.echo_capped,
+ "voices": crate::acoustics::ECHO_VOICES,
+ },
+ "tick_ms_total": t.tick_us as f64 / 1e3, "tick_ms_max": t.tick_us_max as f64 / 1e3,
+ "ticks": t.ticks,
});
}
if let Ok(cache) = self.backend.cache.lock() {
@@ -1386,6 +1601,7 @@ mod tests {
relative: false,
voice: None,
occlusion: None,
+ echo: None,
}
}
@@ -1757,7 +1973,7 @@ mod tests {
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 };
+ let source = Source { id: 1, at: [60.5, 70.5, 0.5], loud: 0.88, echo: false };
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));
}
@@ -1767,6 +1983,195 @@ mod tests {
}
}
+ /// A tap for the tests, at full level at once.
+ fn echo_with(taps: &[(f32, f32)]) -> Echo {
+ let mut e = Echo::default();
+ let list: Vec<crate::acoustics::Tap> = taps
+ .iter()
+ .map(|&(delay, level)| crate::acoustics::Tap { delay, path: 30.0, level, cut: 1.0, dir: [0.0, 0.0, -1.0] })
+ .collect();
+ e.set(&list);
+ for t in &mut e.taps {
+ t.level = t.level_to;
+ }
+ e
+ }
+ /// The energy of the left and right together in frames `from..to` of a stereo track.
+ fn energy(x: &[f32], from: usize, to: usize) -> f32 {
+ x.chunks(2).skip(from).take(to - from).map(|f| f[0] * f[0] + f[1] * f[1]).sum()
+ }
+
+ #[test]
+ fn an_echo_comes_at_its_delay_and_outlives_the_sound() {
+ // 0.1 s of tone, a tap 0.2 s behind: nothing between, the tap after, then the channel ends.
+ for voiced in [false, true] {
+ let mut c = if voiced {
+ let mut c = voice_channel(tone(400.0, 4_800), [0.0, 0.0, 10.0], false, 0.005, 0.0, 0.0);
+ c.voice.as_mut().unwrap().env = 1.0;
+ c
+ } else {
+ channel(tone(400.0, 4_800), Some(500.0), [0.0, 0.0, 10.0], false)
+ };
+ c.echo = Some(echo_with(&[(0.2, 0.5)]));
+ let mut m = Mixer::default();
+ m.channels.insert(1, c);
+ let out = render(&mut m, 60, 480);
+ let direct = energy(&out, 0, 4_700);
+ assert!(direct > 1.0, "{voiced}: the direct sound plays {direct}");
+ assert!(energy(&out, 5_200, 9_300) < direct * 1e-6, "{voiced}: silence between");
+ let echo = energy(&out, 9_600, 14_600);
+ assert!(echo > direct * 0.05 && echo < direct, "{voiced}: the echo plays {echo} vs {direct}");
+ // And it starts on its frame, to the sample.
+ let first = out.chunks(2).skip(5_000).position(|f| f[0].abs() + f[1].abs() > 1e-4).unwrap() + 5_000;
+ assert!((first as i64 - 9_600).abs() <= 3, "{voiced}: first echo frame {first}");
+ assert_eq!(m.channels[&1].state, State::Stopped, "{voiced}: over once the last tap is");
+ // Without the tap the channel ends with its sound.
+ let mut plain = Mixer::default();
+ let mut c = channel(tone(400.0, 4_800), Some(500.0), [0.0, 0.0, 10.0], false);
+ c.echo = Some(Echo::default());
+ plain.channels.insert(1, c);
+ let out = render(&mut plain, 60, 480);
+ assert!(energy(&out, 5_200, 28_800) == 0.0 && plain.channels[&1].state == State::Stopped);
+ }
+ }
+
+ #[test]
+ fn echo_taps_slide_in_without_a_click_and_a_dropped_tap_goes_out() {
+ let mut c = channel(tone(300.0, 48_000), Some(500.0), [0.0, 0.0, 10.0], false);
+ c.echo = Some(echo_with(&[(0.1, 0.5)]));
+ let mut m = Mixer::default();
+ m.channels.insert(1, c);
+ let before = render(&mut m, 20, 480);
+ // A second tap comes, the first goes.
+ let list = [crate::acoustics::Tap { delay: 0.3, path: 60.0, level: 0.4, cut: 0.6, dir: [1.0, 0.0, 0.0] }];
+ m.channels.get_mut(&1).unwrap().echo.as_mut().unwrap().set(&list);
+ let after = render(&mut m, 40, 480);
+ let all: Vec<f32> = before.iter().chain(&after).copied().collect();
+ assert!(max_step(&all) < 0.2, "no click: {}", max_step(&all));
+ let e = m.channels[&1].echo.as_ref().unwrap();
+ assert_eq!(e.taps.len(), 1, "the dropped tap is gone: {:?}", e.taps);
+ assert!((e.taps[0].delay - 0.3).abs() < 1e-6 && e.taps[0].level > 0.35);
+ }
+
+ #[test]
+ fn echo_rendering_is_deterministic() {
+ let run = || {
+ let mut m = Mixer::default();
+ for id in 1..=3u64 {
+ let mut c = voice_channel(tone(150.0 * id as f32, 9_600), [id as f32, 0.0, 9.0], false, 0.01, 0.0, 0.0);
+ c.echo = Some(echo_with(&[(0.05 * id as f32, 0.4), (0.3, 0.3)]));
+ m.channels.insert(id, c);
+ }
+ render(&mut m, 60, 333)
+ };
+ assert_eq!(run(), run());
+ }
+
+ /// Writes the track of a click in a gorge, an open field and a room, through the real
+ /// acoustics and mixer, and the delays (ms) the geometry says, for `tools/acoustics_check.py
+ /// --echo`: `HORIZON_ACOUSTICS_OUT=/dir cargo test -p horizon-client --lib
+ /// audio::tests::export_echo_tracks -- --ignored`.
+ #[test]
+ #[ignore]
+ fn export_echo_tracks() {
+ use crate::acoustics::{Acoustics, Changes, Geometry, Source};
+ struct Blocks(fn(i32, i32, i32) -> bool);
+ impl Geometry for Blocks {
+ fn walker(&self) -> Box<dyn FnMut(i32, i32, i32) -> Option<f32> + '_> {
+ Box::new(move |x, y, z| Some(if (self.0)(x, y, z) { 1.0 } else { 0.0 }))
+ }
+ }
+ let Some(dir) = std::env::var_os("HORIZON_ACOUSTICS_OUT") else {
+ return;
+ };
+ // The delay of the specular path off the plane `axis = coord`, in ms.
+ let ms = |src: [f64; 3], lis: [f64; 3], axis: usize, coord: f64| {
+ let d = |a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
+ let mut image = src;
+ image[axis] = 2.0 * coord - src[axis];
+ (d(image, lis) - d(src, lis)) / 343.0 * 1e3
+ };
+ let (gs, gl) = ([0.5, 66.5, 0.5], [0.5, 66.5, 30.5]);
+ let (rs, rl) = ([4.5, 66.5, 12.5], [20.5, 66.5, 12.5]);
+ type Scene = (&'static str, fn(i32, i32, i32) -> bool, [f64; 3], [f64; 3], Vec<f64>);
+ let scenes: [Scene; 3] = [
+ ("gorge", |x, y, _| y <= 60 || x <= -40 || x >= 55, gs, gl, vec![ms(gs, gl, 0, -39.0), ms(gs, gl, 0, 55.0)]),
+ ("field", |_, y, _| y <= 60, gs, gl, vec![]),
+ // The side walls are the only ones far enough behind the direct sound to be an echo.
+ ("room", |x, y, z| !((0..24).contains(&x) && (60..70).contains(&y) && (0..24).contains(&z)), rs, rl, vec![ms(rs, rl, 2, 0.0)]),
+ ];
+ let mut expected = String::new();
+ for (name, solid, src, lis, want) in scenes {
+ let geometry: Arc<dyn Geometry> = Arc::new(Blocks(solid));
+ // A click: 2 ms of noise, then silence.
+ let mut seed = 99u32;
+ let click: Vec<f32> = (0..9_600)
+ .map(|i| {
+ seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
+ if i < 96 { ((seed >> 8) as f32 / 8_388_608.0 - 1.0) * (1.0 - i as f32 / 96.0) } else { 0.0 }
+ })
+ .collect();
+ let mut m = Mixer::default();
+ m.listener = lis;
+ m.channels.insert(1, channel(click, Some(500.0), [0.5, 66.5, 0.5], false));
+ let mut acoustics = Acoustics::new(true);
+ let source = Source { id: 1, at: src, loud: 0.94, echo: true };
+ acoustics.step(Some(&geometry), lis, &[source], &Changes::default());
+ for (id, taps) in acoustics.take_echoes() {
+ let mut e = Echo::default();
+ e.set(&taps);
+ for t in &mut e.taps {
+ t.level = t.level_to;
+ }
+ m.channels.get_mut(&(id as ChannelId)).unwrap().echo = Some(e);
+ }
+ 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();
+ let list: Vec<String> = want.iter().map(|v| format!("{v:.2}")).collect();
+ expected.push_str(&format!("{name} {}\n", list.join(",")));
+ }
+ std::fs::write(std::path::Path::new(&dir).join("expected.txt"), expected).unwrap();
+ }
+
+ /// The cost of the echo taps in release: 100 looping voices and 8 one-shots, without and with
+ /// 6 taps on each of the 8: `cargo test -p horizon-client --lib --release audio::tests::bench_echo
+ /// -- --ignored --nocapture`.
+ #[test]
+ #[ignore]
+ fn bench_echo_taps() {
+ let make = |taps: 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);
+ m.channels.insert(id, c);
+ }
+ for id in 101..=108u64 {
+ let mut c = voice_channel(tone(90.0 + id as f32, 960_000), [id as f32, 1.0, 40.0], false, 0.0, 0.0, 0.0);
+ c.linear = Some(500.0);
+ if taps {
+ c.echo = Some(echo_with(&[(0.05, 0.3), (0.12, 0.3), (0.2, 0.3), (0.31, 0.2), (0.45, 0.2), (0.7, 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, taps) = (time(make(false)), time(make(true)));
+ println!("108 voices, 512 frames: {plain:.3} ms without taps, {taps:.3} ms with 8 x 6 taps ({:+.3} ms); buffer is 10.67 ms", taps - plain);
+ }
+
/// The cost of the shelf for 100 voices, in release: `cargo test -p horizon-client --lib
/// --release audio::tests::bench -- --ignored --nocapture`.
#[test]
diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 735f26d21..0fcb43243 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -12501,6 +12501,12 @@ impl Game<'_> {
self.streaming
.extra
.extend(waiting.into_iter().map(|chunk| (chunk, 0)));
+ // Chunks mods asked for (`render.load-chunks`), while their ticket lasts.
+ let now = self.ticks;
+ self.mods.chunk_tickets.retain(|t| t.1 > now);
+ self.streaming
+ .extra
+ .extend(self.mods.chunk_tickets.iter().map(|t| (t.0, 0)));
let (uploads, unloaded) =
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
self.streaming.extra.truncate(areas);
diff --git a/crates/horizon-client/src/game/acoustics.rs b/crates/horizon-client/src/game/acoustics.rs
index e4c8cc400..fe2c26de5 100644
--- a/crates/horizon-client/src/game/acoustics.rs
+++ b/crates/horizon-client/src/game/acoustics.rs
@@ -2,7 +2,7 @@
//! 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_client::acoustics::{Changes, Geometry, Snapshot, spr_bounce, spr_occlusion};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
@@ -12,24 +12,25 @@ 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>>)>,
+ table: Option<(u32, usize, Arc<Vec<f32>>, 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>> {
+ fn acoustic_table(&mut self) -> (Arc<Vec<f32>>, Arc<Vec<f32>>) {
let epoch = self.world.epoch;
let facts = self.res.facts();
- if let Some((e, n, t)) = &self.mods.acoustic.table
+ if let Some((e, n, t, r)) = &self.mods.acoustic.table
&& *e == epoch
&& *n == facts.states.len()
{
- return t.clone();
+ return (t.clone(), r.clone());
}
let cached = self.collision.cached_shapes();
let mut table = vec![0.0f32; facts.states.len()];
+ let mut reflect = vec![spr_bounce::DEFAULT_REFLECTIVITY; facts.states.len()];
for (i, f) in facts.states.iter().enumerate() {
let state = BlockStateId(i as u32);
if facts.is_air(state) {
@@ -51,11 +52,12 @@ impl Game<'_> {
}
let group = spr_occlusion::group_of(&f.sounds.step);
let v = spr_occlusion::group_occlusion(group);
+ reflect[i] = spr_bounce::group_reflectivity(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
+ let (table, reflect) = (Arc::new(table), Arc::new(reflect));
+ self.mods.acoustic.table = Some((epoch, facts.states.len(), table.clone(), reflect.clone()));
+ (table, reflect)
}
/// The snapshot of the chunks round the listener, made again when a block changed or the
@@ -70,7 +72,7 @@ impl Game<'_> {
{
return s.clone();
}
- let table = self.acoustic_table();
+ let (table, reflect) = self.acoustic_table();
let (cx, cz) = ((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4);
let chunks = self
.world
@@ -82,6 +84,7 @@ impl Game<'_> {
let snapshot = Arc::new(Snapshot {
chunks,
table,
+ reflect,
epoch: self.world.epoch,
revision: self.world.revision,
tick: self.ticks as u64,
diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs
index df40ff377..8a17590a3 100644
--- a/crates/horizon-client/src/game/integrated.rs
+++ b/crates/horizon-client/src/game/integrated.rs
@@ -90,6 +90,10 @@ impl Integrated {
pub fn published(&self) -> Option<u16> {
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
}
+ /// `MinecraftServer.execute`: run `f` on the server thread between two ticks, without waiting.
+ pub fn execute(&self, f: impl FnOnce(&mut Game<'_>) + Send + 'static) {
+ let _ = self.tasks.send(Box::new(f));
+ }
/// `executeBlocking`: run `f` on the server thread between two ticks and
/// wait for its answer; `None` once the server has stopped.
pub fn call<T: Send + 'static>(
diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs
index 83fdaf4be..a28efb006 100644
--- a/crates/horizon-client/src/game/lan.rs
+++ b/crates/horizon-client/src/game/lan.rs
@@ -2041,6 +2041,15 @@ impl Game<'_> {
.filter(|g| host.hub.here(g))
.map(|g| (g.chunk(), radius))
.collect();
+ // The chunks guests' mods asked for (`render.load-chunks`): loaded as their own small areas.
+ let now = host.hub.ticks;
+ self.streaming.extra.extend(
+ host.hub
+ .guests
+ .iter()
+ .filter(|g| host.hub.here(g))
+ .flat_map(|g| g.mod_chunks.iter().filter(|t| t.1 > now).map(|t| (t.0, 0))),
+ );
}
/// The guests' models, for the host to see them.
diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs
index edece10f7..8a710054a 100644
--- a/crates/horizon-client/src/game/mods.rs
+++ b/crates/horizon-client/src/game/mods.rs
@@ -37,6 +37,12 @@ use {
winit::{event::MouseButton, keyboard::KeyCode},
};
+/// Chunks mods may have loaded at once, and how long one is held after its last request (20 s).
+#[cfg(feature = "mods")]
+const CHUNK_TICKETS: usize = 64;
+#[cfg(feature = "mods")]
+const CHUNK_TICKET_TICKS: u64 = 400;
+
#[derive(Default)]
pub(super) struct ModsState {
/// The acoustics' view of the world (AirDef fork, #96).
@@ -45,6 +51,9 @@ pub(super) struct ModsState {
pub(super) world_id: Option<String>,
#[cfg(feature = "mods")]
pub(super) mods: Mods,
+ /// The chunks mods asked to have loaded (`render.load-chunks`, AirDef fork) and the game
+ /// tick each is held until; at most `CHUNK_TICKETS`, the oldest request goes first.
+ pub(super) chunk_tickets: Vec<((i32, i32), u64)>,
/// Capability `input`: a claimed item's use while the button is down.
#[cfg(feature = "mods")]
pub(super) using: Option<super::mod_input::ModUse>,
@@ -689,10 +698,11 @@ impl Game<'_> {
return;
}
self.tick_mod_use();
+ self.take_mod_chunk_requests();
for (i, columns) in self.mods.mods.take_ground_queries() {
- let ground = columns
+ let ground: Vec<(i32, i32, i32)> = columns
.into_iter()
- .map(|(x, z)| (x, z, self.rain_height(x, z)))
+ .map(|(x, z)| (x, z, self.mod_ground(x, z)))
.collect();
self.mods.mods.answer_ground(i, ground);
}
@@ -717,6 +727,57 @@ impl Game<'_> {
self.show_mod_output();
self.sync_mod_sources();
}
+ /// `render.load-chunks` (API level 22): the chunks mods asked for become tickets held for
+ /// `CHUNK_TICKET_TICKS`; `update_world` keeps them loaded as extra areas of the streaming,
+ /// whose workers generate or read them. Nothing where the chunks come from a server.
+ fn take_mod_chunk_requests(&mut self) {
+ let asked = self.mods.mods.take_chunk_requests();
+ if asked.is_empty() && self.mods.chunk_tickets.is_empty() {
+ return;
+ }
+ // The window's own server thread holds and sends them (`Hub::set_mod_chunks`); a server of someone
+ // else's, or a LAN host's, is not ours to load for.
+ let own = self.integrated.is_some();
+ if (self.remote.is_some() && !own) || (self.streaming.remote && !own) {
+ self.mods.chunk_tickets.clear();
+ return;
+ }
+ if own && !asked.is_empty() {
+ let chunks = asked.clone();
+ if let Some(server) = &self.integrated {
+ server.execute(move |game| {
+ let owner = game.serving.as_ref().map(|s| s.owner);
+ if let (Some(owner), Some(host)) = (owner, game.lan.as_mut()) {
+ host.hub.set_mod_chunks(owner, &chunks);
+ }
+ });
+ }
+ }
+ let until = self.ticks + CHUNK_TICKET_TICKS;
+ let tickets = &mut self.mods.chunk_tickets;
+ for chunk in asked {
+ match tickets.iter_mut().find(|t| t.0 == chunk) {
+ Some(t) => t.1 = until,
+ None => tickets.push((chunk, until)),
+ }
+ }
+ // The least recently asked go when over the limit.
+ if tickets.len() > CHUNK_TICKETS {
+ tickets.sort_by_key(|t| std::cmp::Reverse(t.1));
+ tickets.truncate(CHUNK_TICKETS);
+ }
+ }
+ /// `render.query-ground`'s answer: the heightmap, or `i32::MIN` for a column whose chunk a
+ /// mod asked to load and which is not there yet.
+ fn mod_ground(&self, x: i32, z: i32) -> i32 {
+ let key = (x >> 4, z >> 4);
+ if !self.world.chunks.contains_key(&key)
+ && self.mods.chunk_tickets.iter().any(|t| t.0 == key)
+ {
+ return i32::MIN;
+ }
+ self.rain_height(x, z)
+ }
/// The mods' persistent sound sources (API level 20): their changes go to the mixer, and the
/// sources of a mod that no longer runs are released. The generation the mods read is the
/// audio's: whenever it released every source (world closed, film begun), they start again.
diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs
index 89df0616b..0c0add753 100644
--- a/crates/horizon-client/src/game/remote.rs
+++ b/crates/horizon-client/src/game/remote.rs
@@ -1111,10 +1111,12 @@ impl Game<'_> {
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
- self.remote.as_ref().is_none_or(|r| {
- let range = r.view_distance.max(2) + 3;
- (x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
- })
+ // A chunk a mod asked the window's own server for (`render.load-chunks`) is kept for its ticket.
+ self.mods.chunk_tickets.iter().any(|t| t.0 == (x, z))
+ || self.remote.as_ref().is_none_or(|r| {
+ let range = r.view_distance.max(2) + 3;
+ (x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
+ })
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
diff --git a/crates/horizon-client/src/gfx/hd/light.wgsl b/crates/horizon-client/src/gfx/hd/light.wgsl
index 6505c5c76..92a971956 100644
--- a/crates/horizon-client/src/gfx/hd/light.wgsl
+++ b/crates/horizon-client/src/gfx/hd/light.wgsl
@@ -528,18 +528,24 @@ fn fs_light(in: FullOut) -> LightOut {
let beam_len = select(1100.0, length(ps), d > 0.0);
air_s += spot_beams(view_s, beam_len, ign(in.position.xy));
}
+ var halos = vec3<f32>(0.0);
for (var k = 0u; k < halo_count; k++) {
let l = lights[select(tiles[halo_start + k], k, halo_all)];
// Nearest point of the view ray to the light, and the glow along the ray in front of it.
let t0 = dot(l.pos.xyz, view_s);
let hh = length(l.pos.xyz - view_s * t0) + 0.5;
- if hh > l.pos.w * 0.5 { continue; }
+ let reach = l.pos.w * 0.5;
+ if hh > reach { continue; }
let a0 = atan((0.0 - t0) / hh);
let a1 = atan((far - t0) / hh);
+ // It fades out before its reach: no hard edge round a bright light.
+ let fade = 1.0 - smoothstep(reach * 0.25, reach, hh);
// Thicker air glows more round a light, but only so far: the mist at dusk and many fires
// at once would otherwise wash the picture out.
- air_s += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9));
+ halos += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9)) * fade;
}
+ // However many blasts at once: a glow in the air, never a white wall.
+ air_s += halos / (1.0 + max(max(halos.r, halos.g), halos.b) / 1.2);
var out: LightOut;
if water {
out.lit = vec4<f32>(c, 0.5);
diff --git a/crates/horizon-client/src/gfx/hd/spot.wgsl b/crates/horizon-client/src/gfx/hd/spot.wgsl
index 726bfe312..a208fd9bf 100644
--- a/crates/horizon-client/src/gfx/hd/spot.wgsl
+++ b/crates/horizon-client/src/gfx/hd/spot.wgsl
@@ -4,7 +4,9 @@
// `frame.vfog.w`. The shader that includes this defines `spot_air(pos) -> f32`, the air's
// extinction per block at a camera-relative point.
-const SPOT_BEAM_GAIN: f32 = 260.0;
+const SPOT_BEAM_GAIN: f32 = 170.0;
+// The sum of all beams per pixel is soft-limited to about this: any number overlapping stays a beam.
+const SPOT_BEAM_LIMIT: f32 = 0.5;
const SPOT_BEAM_STEPS: i32 = 8;
// Even clear air shows a faint beam: extinction per block, at least.
const SPOT_AIR_FLOOR: f32 = 0.0009;
@@ -25,7 +27,7 @@ fn spot_light(j: i32, p: vec3<f32>, n: vec3<f32>) -> vec3<f32> {
// The range is where the beam is free: the light stops there over a few blocks.
let window = clamp((a.w - dist) / 3.0, 0.0, 1.0);
let facing = max(dot(n, l) * 0.9 + 0.1, 0.0);
- return c.rgb * (c.w / (dist * dist + 1.0)) * edge * window * facing;
+ return c.rgb * (c.w / (dist * dist + 400.0)) * edge * window * facing;
}
// The part of the ray from the camera along v, within [0, len], that is inside spot j's cone and
@@ -92,7 +94,7 @@ fn spot_beams(v: vec3<f32>, len: f32, jitter: f32) -> vec3<f32> {
// Looking into the lamp the beam scatters forward: brighter.
let cos_s = dot(b.xyz, -v);
let g = 0.55;
- let phase = 0.4 + 0.25 * min((1.0 - g * g) / pow(1.0 + g * g - 2.0 * g * cos_s, 1.5), 8.0);
+ let phase = 0.4 + 0.12 * min((1.0 - g * g) / pow(1.0 + g * g - 2.0 * g * cos_s, 1.5), 5.0);
// The air between the camera and the beam's first point.
let first = v * span.x;
let through0 = exp(-spot_air(first) * span.x);
@@ -112,5 +114,5 @@ fn spot_beams(v: vec3<f32>, len: f32, jitter: f32) -> vec3<f32> {
}
sum += c.rgb * (acc * ds * phase * k.y * SPOT_BEAM_GAIN) * through0;
}
- return sum;
+ return sum / (1.0 + max(sum.x, max(sum.y, sum.z)) / SPOT_BEAM_LIMIT);
}
diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs
index cc53eeaed..6b846c746 100644
--- a/crates/horizon-client/src/lan/guest.rs
+++ b/crates/horizon-client/src/lan/guest.rs
@@ -151,6 +151,9 @@ pub struct Guest {
pub center: Option<(i32, i32)>,
pub sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>,
pub chunk_queue: Vec<(i32, i32)>,
+ /// Chunks the player's mod asked to have loaded beyond the view (AirDef fork, `render.load-chunks`), and
+ /// the hub tick each is held until: sent like the view's chunks, forgotten when the ticket ends.
+ pub mod_chunks: Vec<((i32, i32), u64)>,
/// PlayerChunkSender: batches sent but not yet acknowledged, how many
/// may be (1 until the client's first acknowledgement, then 10), the
/// rate the client asks for and the chunks the next batch may hold.
@@ -317,6 +320,7 @@ impl Guest {
center: None,
sent_chunks: Default::default(),
chunk_queue: Vec::new(),
+ mod_chunks: Vec::new(),
unacknowledged: 0,
max_unacknowledged: 1,
chunks_per_tick: 9.0,
diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs
index 8f1845de1..dda7931aa 100644
--- a/crates/horizon-client/src/lan/hub.rs
+++ b/crates/horizon-client/src/lan/hub.rs
@@ -294,6 +294,10 @@ fn entity_type(
/// ChunkTrackingView.contains: the chunks a player at (cx, cz) with view
/// distance `r` is sent (isWithinDistance with the neighbours' buffer).
+/// Chunks a player's mod may have loaded beyond the view at once, and how long (20 s) one is held.
+pub const MOD_CHUNKS: usize = 64;
+pub const MOD_CHUNK_TICKS: u64 = 400;
+
fn tracked(cx: i32, cz: i32, r: i32, x: i32, z: i32) -> bool {
let dx = i64::from(((x - cx).abs() - 2).max(0));
let dz = i64::from(((z - cz).abs() - 2).max(0));
@@ -1363,6 +1367,7 @@ impl Hub {
for i in 0..self.guests.len() {
if self.here(&self.guests[i]) {
self.update_view(i);
+ self.update_mod_chunks(i);
if let Some(batch) = self.next_batch(i, world) {
batches.push((i, batch));
}
@@ -1422,6 +1427,49 @@ impl Hub {
}
}
+ /// The chunks a player's mod asked for (`set_mod_chunks`): queued (after the view's) while their ticket
+ /// lasts, forgotten when it ends unless the view holds them.
+ fn update_mod_chunks(&mut self, i: usize) {
+ let now = self.ticks;
+ let g = &mut self.guests[i];
+ if g.mod_chunks.is_empty() {
+ return;
+ }
+ let (cx, cz) = g.chunk();
+ let r = g.view_distance;
+ let ended: Vec<(i32, i32)> = g.mod_chunks.iter().filter(|t| t.1 <= now).map(|t| t.0).collect();
+ g.mod_chunks.retain(|t| t.1 > now);
+ for c in ended {
+ if !tracked(cx, cz, r, c.0, c.1) && g.sent_chunks.remove(&c) {
+ g.send(&C::ForgetLevelChunk(play::ForgetLevelChunk { pos: [c.0, c.1] }));
+ }
+ g.chunk_queue.retain(|q| *q != c || tracked(cx, cz, r, c.0, c.1));
+ }
+ let wanted: Vec<(i32, i32)> = g.mod_chunks.iter().map(|t| t.0).collect();
+ for c in wanted {
+ if !g.sent_chunks.contains(&c) && !g.chunk_queue.contains(&c) {
+ g.chunk_queue.push(c);
+ }
+ }
+ }
+
+ /// A player's mod asked for these chunks (`render.load-chunks`): held [`MOD_CHUNK_TICKS`] from now, at
+ /// most [`MOD_CHUNKS`] at once (the latest requests stay).
+ pub fn set_mod_chunks(&mut self, uuid: u128, chunks: &[(i32, i32)]) {
+ let until = self.ticks + MOD_CHUNK_TICKS;
+ let Some(g) = self.guests.iter_mut().find(|g| g.uuid == uuid) else { return };
+ for &c in chunks {
+ match g.mod_chunks.iter_mut().find(|t| t.0 == c) {
+ Some(t) => t.1 = until,
+ None => g.mod_chunks.push((c, until)),
+ }
+ }
+ if g.mod_chunks.len() > MOD_CHUNKS {
+ g.mod_chunks.sort_by_key(|t| std::cmp::Reverse(t.1));
+ g.mod_chunks.truncate(MOD_CHUNKS);
+ }
+ }
+
/// ChunkMap.move's view change: the new centre, the chunks that left
/// the view forgotten and the ones that entered it queued, closest first.
fn update_view(&mut self, i: usize) {
@@ -1443,7 +1491,7 @@ impl Hub {
.sent_chunks
.iter()
.copied()
- .filter(|&(x, z)| !tracked(cx, cz, r, x, z))
+ .filter(|&(x, z)| !tracked(cx, cz, r, x, z) && !g.mod_chunks.iter().any(|t| t.0 == (x, z)))
.collect();
for (x, z) in forget {
g.sent_chunks.remove(&(x, z));
diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit
index 5bf3fa665..a055af8bd 100644
--- a/crates/horizon-mod-sdk/wit/horizon.wit
+++ b/crates/horizon-mod-sdk/wit/horizon.wit
@@ -680,8 +680,18 @@ interface render {
query-ground: func(columns: list<tuple<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.
+ /// blocking heightmap; -64 where the chunk isn't loaded; -2147483648
+ /// (`i32::MIN`) where it isn't loaded yet but `load-chunks` has it on its
+ /// way: ask again in a few ticks.
ground: func() -> list<tuple<s32, s32, s32>>;
+ /// Has the game load these chunks (chunk x, chunk z) for a while (AirDef
+ /// fork, API level 22): generated or read from disk on the game's worker
+ /// threads, meshed never, and kept loaded for 20 seconds after the last
+ /// call that named them. At most 64 chunks are wanted at once (the
+ /// oldest requests fall away first; 64 a call). On a server, in a world
+ /// the game does not simulate itself, or with nothing running, it does
+ /// nothing, and `ground` keeps answering -64 for the unloaded.
+ load-chunks: func(chunks: list<tuple<s32, s32>>);
/// What a block is to a query (AirDef fork): the three policies differ
/// on purpose, so a sensor and a missile may disagree about a pane of
/// glass.
diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs
index ee826712f..8894fad5b 100644
--- a/crates/horizon-mods/src/api_level.rs
+++ b/crates/horizon-mods/src/api_level.rs
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
-pub const LEVEL: u32 = 21;
+pub const LEVEL: u32 = 22;
/// 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); 83] = [
+pub const FUNCTIONS: [(&str, u32); 84] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -108,6 +108,8 @@ pub const FUNCTIONS: [(&str, u32); 83] = [
("render.sound-sources", 20),
// AirDef fork: whether the game muffles sounds by what is between them and the listener.
("render.acoustics", 21),
+ // AirDef fork: the game loads chunks for a mod for a while.
+ ("render.load-chunks", 22),
];
/// What the game's program carries so the launcher can read its level
diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs
index 182ea7f3f..4b31c8dce 100644
--- a/crates/horizon-mods/src/runtime.rs
+++ b/crates/horizon-mods/src/runtime.rs
@@ -513,6 +513,8 @@ struct State {
model_table: ModelSlot,
/// `render.query-ground`'s columns, answered before the next tick.
ground_queries: Vec<(i32, i32)>,
+ /// `render.load-chunks`: the chunks asked for since the game last took them.
+ chunk_requests: Vec<(i32, i32)>,
/// The game's answers to them.
ground: Vec<(i32, i32, i32)>,
/// `render.query-rays`' segments, answered before the next tick.
@@ -782,6 +784,7 @@ impl Mods {
models: Vec::new(),
model_table: model_table.clone(),
ground_queries: Vec::new(),
+ chunk_requests: Vec::new(),
ground: Vec::new(),
ray_queries: Vec::new(),
ray_over: Vec::new(),
@@ -1548,6 +1551,10 @@ impl Mods {
}
/// The columns each running mod asked the ground height of, taken.
pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> {
+ // The last answers are read once: a mod that asked nothing since gets none (not the same again).
+ for run in self.entries.iter_mut().filter_map(|e| e.run.as_mut()) {
+ run.store.data_mut().ground.clear();
+ }
self.entries
.iter_mut()
.enumerate()
@@ -1557,6 +1564,14 @@ impl Mods {
})
.collect()
}
+ /// The chunks the running mods asked the game to load (`render.load-chunks`), taken.
+ pub fn take_chunk_requests(&mut self) -> Vec<(i32, i32)> {
+ self.entries
+ .iter_mut()
+ .filter_map(|e| Some(std::mem::take(&mut e.run.as_mut()?.store.data_mut().chunk_requests)))
+ .flatten()
+ .collect()
+ }
/// The first running mod's post pass (version, WGSL) and its parameters.
pub fn post(&self) -> Option<(u64, &str, &[f32])> {
self.entries.iter().find_map(|e| {
@@ -2663,6 +2678,10 @@ impl render::Host for State {
let room = 512usize.saturating_sub(self.ground_queries.len());
self.ground_queries.extend(columns.into_iter().take(room));
}
+ fn load_chunks(&mut self, chunks: Vec<(i32, i32)>) {
+ let room = 64usize.saturating_sub(self.chunk_requests.len());
+ self.chunk_requests.extend(chunks.into_iter().take(room));
+ }
fn ground(&mut self) -> Vec<(i32, i32, i32)> {
self.ground.clone()
}
diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs
index ccd1f6527..f586ce0ec 100644
--- a/crates/horizon-mods/tests/ui_guest/src/lib.rs
+++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs
@@ -269,6 +269,7 @@ impl Mod for Guest {
"source" | "source_keep" => {
let g = render::sound_source_generation();
let _ = render::acoustics();
+ render::load_chunks(&[(0, 0)]);
let op = |kind| render::SourceOp {
kind,
id: 1,
diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md
index bcbd91ebe..9ce18e77a 100644
--- a/docs/api/mods-reference.md
+++ b/docs/api/mods-reference.md
@@ -806,7 +806,8 @@ Where a capture is.
| `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. |
+| `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; -2147483648 (`i32::MIN`) where it isn't loaded yet but `load-chunks` has it on its way: ask again in a few ticks. |
+| `load-chunks` | `func(chunks: list<tuple<s32, s32>>)` | Has the game load these chunks (chunk x, chunk z) for a while (AirDef fork, API level 22): generated or read from disk on the game's worker threads, meshed never, and kept loaded for 20 seconds after the last call that named them. At most 64 chunks are wanted at once (the oldest requests fall away first; 64 a call). On a server, in a world the game does not simulate itself, or with nothing running, it does nothing, and `ground` keeps answering -64 for the unloaded. |
| `query-rays` | `func(rays: list<ray>)` | Traces segments through the loaded blocks (AirDef fork): each is walked block by block on the host, against the real collision shapes (`policy`), and answered by `rays` from the next tick on. At most 256 segments a tick, each at most 512 blocks long (longer ones are answered `unknown` past that); over the budget they are answered `unknown` too, never `clear`. A game with no chunk at a place answers `unknown` for it. |
| `rays` | `func() -> list<ray-answer>` | The answers to the last tick's `query-rays`, in the order asked. |
| `query-sections` | `func(policy: policy, sections: list<tuple<s32, s32, s32>>)` | Asks what these sections hold (AirDef fork), for a mod that traces its own paths against a cache of them: at most 64 a tick, answered by `sections` from the next tick on. Over the budget they are not answered: ask again. |
diff --git a/docs/api/mods.md b/docs/api/mods.md
index 73c3cf780..9adccabf8 100644
--- a/docs/api/mods.md
+++ b/docs/api/mods.md
@@ -114,6 +114,10 @@ What real mods need next, in the order modpacks and servers need it:
source updated once a tick has no steps; 24 sources hold a voice (loudest and nearest times priority), the rest of
the loops stay virtual; a stop is never lost to a limit; the sources of a mod that stops or is reloaded are
released. A film mixes them on its own clock like `play-sound`.
+- API level 22 (AirDef fork, capability `render`): `load-chunks` has the game load chunks for 20 seconds after the last
+ request that names them (at most 64 at once): generated or read from disk on the worker threads, not meshed. `ground`
+ answers `i32::MIN` for a column of such a chunk that is not there yet, -64 for one that is not coming. Nothing happens
+ on a server or in a LAN-hosted world.
- 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
--
2.54.0 (Apple Git-157)