Mod canvas with terrain tiles and mouse input (API 24), set-acoustics (API 23), acoustics stage 3: reverb
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,13 +21,21 @@
|
||||
//! 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.
|
||||
//! 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
|
||||
//! level and decay of the reverb; each sound is told its sends to SPR's four slots
|
||||
//! ([`Acoustics::take_sends`]) and the mixer's shared reverb does the rest (`audio/reverb.rs`).
|
||||
//!
|
||||
//! `HORIZON_ACOUSTICS=1` or a mod (`render.set-acoustics`) turns it on; off (the default) nothing
|
||||
//! here runs and the mixer is the mixer it was.
|
||||
pub mod echo;
|
||||
pub mod room;
|
||||
pub mod spr_bounce;
|
||||
pub mod spr_occlusion;
|
||||
pub mod spr_reverb;
|
||||
|
||||
pub use echo::Tap;
|
||||
pub use room::Room;
|
||||
use spr_occlusion::run_occlusion;
|
||||
|
||||
use crate::chunk::{Chunk, HEIGHT, MIN_Y};
|
||||
@@ -65,6 +73,10 @@ const ECHO_JOB_CELLS: usize = 240_000;
|
||||
const ECHO_FILM_CELLS: usize = 480_000;
|
||||
/// Cached echo estimates.
|
||||
const ECHO_CACHE: usize = 256;
|
||||
/// Cells one room measure may walk (about 2 thousand in a field, 20 thousand in a maze).
|
||||
const ROOM_CELLS: usize = 60_000;
|
||||
/// Cached rooms of listener cells.
|
||||
const ROOM_CACHE: usize = 64;
|
||||
/// Change records kept to judge a job that returns late.
|
||||
const LOG: usize = 2048;
|
||||
|
||||
@@ -190,10 +202,23 @@ fn box_touches(min: [i32; 3], max: [i32; 3], s: Cell) -> bool {
|
||||
#[derive(Clone, Debug)]
|
||||
struct EchoEntry {
|
||||
taps: Vec<Tap>,
|
||||
/// The space round the source (None: not measured).
|
||||
room: Room,
|
||||
min: [i32; 3],
|
||||
max: [i32; 3],
|
||||
used: u64,
|
||||
}
|
||||
/// The space round the listener's block.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct RoomEntry {
|
||||
room: Room,
|
||||
used: u64,
|
||||
}
|
||||
/// The box a room measure from `cell` looked in.
|
||||
fn room_box(cell: Cell) -> ([i32; 3], [i32; 3]) {
|
||||
let r = room::LEN as i32 + 1;
|
||||
([cell.0 - r, cell.1 - r, cell.2 - r], [cell.0 + r, cell.1 + r, cell.2 + r])
|
||||
}
|
||||
|
||||
/// What the engine measured, for `status`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -223,6 +248,14 @@ pub struct Stats {
|
||||
pub ticks: u64,
|
||||
/// Sources that deserved echoes but were over the cap, in steps.
|
||||
pub echo_capped: u64,
|
||||
/// Room measures made, the cache's hits, the cells they walked, the sends told to the mixer, and
|
||||
/// the space now (enclosure of the listener's room, the decay it gives, seconds).
|
||||
pub room_traces: u64,
|
||||
pub room_hits: u64,
|
||||
pub room_cells: u64,
|
||||
pub sends: u64,
|
||||
pub room_enclosed: f32,
|
||||
pub room_decay: f32,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
@@ -231,11 +264,13 @@ struct Job {
|
||||
listener: [f64; 3],
|
||||
items: Vec<(Key, [f64; 3], usize)>,
|
||||
echoes: Vec<Key>,
|
||||
rooms: Vec<Cell>,
|
||||
}
|
||||
struct Done {
|
||||
seq: u64,
|
||||
entries: Vec<(Key, Entry)>,
|
||||
echoes: Vec<(Key, EchoEntry, u64)>,
|
||||
rooms: Vec<(Cell, Room)>,
|
||||
cells: usize,
|
||||
unknown: usize,
|
||||
}
|
||||
@@ -254,8 +289,16 @@ fn centre(c: Cell) -> [f64; 3] {
|
||||
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);
|
||||
// The space round the source has a budget of its own: running out must not read as a field.
|
||||
let mut room_budget = ROOM_CELLS;
|
||||
let m = room::measure(geometry, centre(key.0), &mut room_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)
|
||||
(
|
||||
EchoEntry { taps: t.taps, room: m.room, min: t.min, max: t.max, used: clock },
|
||||
t.cells + m.cells,
|
||||
t.unknown + m.unknown,
|
||||
us,
|
||||
)
|
||||
}
|
||||
|
||||
fn cell_of(p: [f64; 3]) -> Cell {
|
||||
@@ -302,6 +345,11 @@ pub struct Acoustics {
|
||||
/// 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>)>,
|
||||
room_cache: HashMap<Cell, RoomEntry>,
|
||||
/// The space the listener is in now (the last one measured: it stays while the next is on its way).
|
||||
room_now: Option<Room>,
|
||||
sends_applied: HashMap<u32, [f32; 4]>,
|
||||
sends_pending: Vec<(u32, [f32; 4])>,
|
||||
pub stats: Stats,
|
||||
}
|
||||
|
||||
@@ -321,6 +369,10 @@ impl Acoustics {
|
||||
echo_cache: HashMap::new(),
|
||||
echo_applied: HashMap::new(),
|
||||
echo_pending: Vec::new(),
|
||||
room_cache: HashMap::new(),
|
||||
room_now: None,
|
||||
sends_applied: HashMap::new(),
|
||||
sends_pending: Vec::new(),
|
||||
stats: Stats::default(),
|
||||
}
|
||||
}
|
||||
@@ -332,6 +384,7 @@ impl Acoustics {
|
||||
self.echo_cache.clear();
|
||||
self.echo_applied.clear();
|
||||
self.echo_pending.clear();
|
||||
self.forget_rooms();
|
||||
self.log.clear();
|
||||
// A job in flight returns into a world that is gone: its sequence number is stale.
|
||||
self.seq += 1;
|
||||
@@ -339,6 +392,13 @@ impl Acoustics {
|
||||
self.in_flight = None;
|
||||
}
|
||||
|
||||
fn forget_rooms(&mut self) {
|
||||
self.room_cache.clear();
|
||||
self.room_now = None;
|
||||
self.sends_applied.clear();
|
||||
self.sends_pending.clear();
|
||||
}
|
||||
|
||||
fn spawn(&mut self) {
|
||||
let (job_tx, job_rx) = std::sync::mpsc::channel::<Job>();
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<Done>();
|
||||
@@ -349,6 +409,7 @@ impl Acoustics {
|
||||
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;
|
||||
let mut rooms = Vec::with_capacity(job.rooms.len());
|
||||
for (key, at, paths) in job.items {
|
||||
let (entry, c, u) = trace(&*job.geometry, job.listener, at, paths, &mut budget);
|
||||
cells += c;
|
||||
@@ -361,7 +422,14 @@ impl Acoustics {
|
||||
unknown += u;
|
||||
echoes.push((key, e, us));
|
||||
}
|
||||
if done_tx.send(Done { seq: job.seq, entries, echoes, cells, unknown }).is_err() {
|
||||
for cell in job.rooms {
|
||||
let mut b = ROOM_CELLS;
|
||||
let m = room::measure(&*job.geometry, centre(cell), &mut b);
|
||||
cells += m.cells;
|
||||
unknown += m.unknown;
|
||||
rooms.push((cell, m.room));
|
||||
}
|
||||
if done_tx.send(Done { seq: job.seq, entries, echoes, rooms, cells, unknown }).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -379,6 +447,7 @@ impl Acoustics {
|
||||
self.echo_cache.clear();
|
||||
self.echo_applied.clear();
|
||||
self.echo_pending.clear();
|
||||
self.forget_rooms();
|
||||
self.log.clear();
|
||||
self.seq += 1;
|
||||
self.floor = self.seq;
|
||||
@@ -391,6 +460,10 @@ impl Acoustics {
|
||||
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.room_cache.retain(|&c, _| {
|
||||
let (lo, hi) = room_box(c);
|
||||
!box_touches(lo, hi, s)
|
||||
});
|
||||
self.log.push_back((self.seq, s));
|
||||
}
|
||||
while self.log.len() > LOG {
|
||||
@@ -448,7 +521,7 @@ 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() + d.echoes.len()) as u64;
|
||||
self.stats.dropped_late += (d.entries.len() + d.echoes.len() + d.rooms.len()) as u64;
|
||||
continue;
|
||||
}
|
||||
for (key, e, us) in d.echoes {
|
||||
@@ -462,6 +535,15 @@ impl Acoustics {
|
||||
self.insert_echo(key, e);
|
||||
}
|
||||
}
|
||||
for (cell, room) 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 });
|
||||
}
|
||||
}
|
||||
for (key, e) in d.entries {
|
||||
if self.log.iter().any(|(s, c)| *s >= d.seq && e.touches(*c)) {
|
||||
self.stats.dropped_late += 1;
|
||||
@@ -478,6 +560,7 @@ impl Acoustics {
|
||||
.collect();
|
||||
self.stats.sources = live.len();
|
||||
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));
|
||||
let Some(geometry) = geometry else {
|
||||
return self.settle(&live, listener, None);
|
||||
@@ -517,7 +600,9 @@ impl Acoustics {
|
||||
}
|
||||
}
|
||||
let echo_keys = self.settle_echoes(&**geometry, &live, &order, listener);
|
||||
let job_empty = job.is_empty() && echo_keys.is_empty();
|
||||
let rooms = self.settle_room(&**geometry, listener);
|
||||
self.settle_sends(&live, listener);
|
||||
let job_empty = job.is_empty() && echo_keys.is_empty() && rooms.is_empty();
|
||||
if !job_empty && !self.sync {
|
||||
if self.worker.is_none() {
|
||||
self.spawn();
|
||||
@@ -528,7 +613,7 @@ impl Acoustics {
|
||||
self.seq += 1;
|
||||
let seq = self.seq;
|
||||
if tx
|
||||
.send(Job { seq, geometry: geometry.clone(), listener, items: job, echoes: echo_keys })
|
||||
.send(Job { seq, geometry: geometry.clone(), listener, items: job, echoes: echo_keys, rooms })
|
||||
.is_ok()
|
||||
{
|
||||
self.in_flight = Some(seq);
|
||||
@@ -591,6 +676,71 @@ impl Acoustics {
|
||||
want
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn settle_room(&mut self, geometry: &dyn Geometry, listener: [f64; 3]) -> Vec<Cell> {
|
||||
let cell = block_of(listener);
|
||||
if let Some(e) = self.room_cache.get_mut(&cell) {
|
||||
e.used = self.clock;
|
||||
self.stats.room_hits += 1;
|
||||
self.room_now = Some(e.room);
|
||||
} else if self.sync {
|
||||
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_now = Some(m.room);
|
||||
} else {
|
||||
return vec![cell];
|
||||
}
|
||||
if self.room_cache.len() > ROOM_CACHE {
|
||||
let mut ages: Vec<u64> = self.room_cache.values().map(|e| e.used).collect();
|
||||
ages.sort_unstable();
|
||||
let cut = ages[ages.len() / 4];
|
||||
self.room_cache.retain(|_, e| e.used > cut);
|
||||
}
|
||||
if let Some(r) = &self.room_now {
|
||||
let (_, decay) = room::wet_and_decay(r);
|
||||
self.stats.room_enclosed = r.enclosed;
|
||||
self.stats.room_decay = decay;
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Each live sound's sends to the reverb slots, from the listener's space and, for the sounds
|
||||
/// whose own space is known (the echo ones), a blend with it. Only the changed ones are queued.
|
||||
fn settle_sends(&mut self, live: &[Source], listener: [f64; 3]) {
|
||||
let Some(now) = self.room_now else {
|
||||
return;
|
||||
};
|
||||
let lcell = block_of(listener);
|
||||
for s in live {
|
||||
let source = if s.echo { self.echo_cache.get(&(block_of(s.at), lcell)).map(|e| e.room) } else { None };
|
||||
let to = room::sends(&room::space(&now, source.as_ref()));
|
||||
let changed = match self.sends_applied.get(&s.id) {
|
||||
Some(prev) => (0..4).any(|k| (prev[k] - to[k]).abs() > 0.01),
|
||||
None => to != [0.0; 4],
|
||||
};
|
||||
if changed {
|
||||
self.sends_applied.insert(s.id, to);
|
||||
self.sends_pending.push((s.id, to));
|
||||
self.stats.sends += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The space the listener is in now, if it has been measured.
|
||||
pub fn room_now(&self) -> Option<Room> {
|
||||
self.room_now
|
||||
}
|
||||
|
||||
/// The reverb sends that changed since the last call, by sound id.
|
||||
pub fn take_sends(&mut self) -> Vec<(u32, [f32; 4])> {
|
||||
std::mem::take(&mut self.sends_pending)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
@@ -730,15 +880,16 @@ mod tests {
|
||||
}
|
||||
panic!("the worker did not answer");
|
||||
};
|
||||
wait(&mut a, 1, &s);
|
||||
// (The first job measured the room round the listener; the second refines the source.)
|
||||
wait(&mut a, 2, &s);
|
||||
// Refined: the same cell asks nothing more.
|
||||
for _ in 0..3 {
|
||||
a.step(Some(&wall), l, &s, &Changes::default());
|
||||
}
|
||||
assert_eq!(a.stats.jobs, 1);
|
||||
assert_eq!(a.stats.jobs, 2);
|
||||
// A moved source in a new cell goes to the worker too.
|
||||
let moved = [src(1, 94.5)];
|
||||
wait(&mut a, 2, &moved);
|
||||
wait(&mut a, 3, &moved);
|
||||
assert_eq!(a.stats.traces, 1, "the game thread traced only the new source");
|
||||
}
|
||||
|
||||
@@ -832,4 +983,54 @@ mod tests {
|
||||
}
|
||||
panic!("the worker did not answer");
|
||||
}
|
||||
|
||||
fn hall() -> Arc<dyn Geometry> {
|
||||
Arc::new(Blocks(Box::new(|x, y, z| !((0..24).contains(&x) && (60..70).contains(&y) && (0..24).contains(&z)))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_room_sends_to_the_reverb_a_field_does_not_and_a_destroyed_roof_takes_it_away() {
|
||||
let mut a = Acoustics::new(true);
|
||||
let inside = [12.5, 64.5, 12.5];
|
||||
let sources = [Source { id: 1, at: [12.5, 64.5, 18.5], loud: 0.5, echo: false }, Source { id: 2, at: [5.5, 64.5, 5.5], loud: 0.5, echo: true }];
|
||||
a.step(Some(&hall()), inside, &sources, &Changes::default());
|
||||
let sends = a.take_sends();
|
||||
assert_eq!(sends.len(), 2);
|
||||
assert!(sends.iter().all(|(_, s)| s.iter().any(|v| *v > 0.0)));
|
||||
// Nothing changed: the mixer is not told again.
|
||||
a.step(Some(&hall()), inside, &sources, &Changes::default());
|
||||
assert!(a.take_sends().is_empty() && a.stats.room_hits >= 1);
|
||||
// The room is blown open: every section within reach changes, and the sends go to zero.
|
||||
let field: Arc<dyn Geometry> = Arc::new(Blocks(Box::new(|_, y, _| y <= 50)));
|
||||
let all: Vec<(i32, i32, i32)> = (-5..6).flat_map(|x| (0..8).flat_map(move |y| (-5..6).map(move |z| (x, y, z)))).collect();
|
||||
a.step(Some(&field), inside, &sources, &Changes { sections: all, ..Default::default() });
|
||||
let gone = a.take_sends();
|
||||
assert_eq!(gone.len(), 2);
|
||||
assert!(gone.iter().all(|(_, s)| *s == [0.0; 4]), "{gone:?}");
|
||||
// A field from the start sends nothing at all.
|
||||
let mut b = Acoustics::new(true);
|
||||
b.step(Some(&field), [0.5, 60.5, 0.5], &sources, &Changes::default());
|
||||
assert!(b.take_sends().is_empty());
|
||||
assert_eq!(b.stats.room_enclosed, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_worker_measures_the_room_and_a_new_world_forgets_it() {
|
||||
let mut a = Acoustics::new(false);
|
||||
let g = hall();
|
||||
let s = [Source { id: 1, at: [12.5, 64.5, 18.5], loud: 0.5, echo: false }];
|
||||
a.step(Some(&g), [12.5, 64.5, 12.5], &s, &Changes::default());
|
||||
assert!(a.take_sends().is_empty(), "not on the game thread");
|
||||
for _ in 0..400 {
|
||||
a.step(Some(&g), [12.5, 64.5, 12.5], &s, &Changes::default());
|
||||
if !a.take_sends().is_empty() {
|
||||
assert_eq!(a.stats.room_traces, 1);
|
||||
a.step(Some(&g), [12.5, 64.5, 12.5], &s, &Changes { epoch: 3, ..Default::default() });
|
||||
assert!(a.room_now().is_none(), "the old world's room is gone");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the worker did not answer");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,13 @@ grass, gravel and dirt 0.3, sand 0.2, snow 0.15, wool, moss and honey 0.1). What
|
||||
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.
|
||||
|
||||
## Stage 3: `spr_reverb.rs`
|
||||
|
||||
`spr_reverb.rs` is derived from the same commit and licence. What is taken: from `ReverbParams.java` the
|
||||
four reverb slots (decay 0.15, 0.55, 1.68 and 4.142 s, their gains 0.2, 0.3, 0.5 and 0.4, `decay_hf_ratio`
|
||||
0.6 and 0.7, late reverb delays 0.011 and 0.021 s); from `SoundPhysics.java` the gain scale of the slots
|
||||
(`0.7 * reverb_gain * 0.85`) and the fade of the sends with distance (`reverb_distance` 1.5). What SPR does
|
||||
with them (it hands the slots to OpenAL Soft's EAX reverb, LGPL) is not used: the reverb itself
|
||||
(`audio/reverb.rs`: combs, allpasses, the stereo split), the measure of the space that decides the sends
|
||||
(`room.rs`) and the mixer side are our own and MIT.
|
||||
|
||||
@@ -66,19 +66,19 @@ pub struct Traced {
|
||||
pub unknown: usize,
|
||||
}
|
||||
|
||||
struct Hit {
|
||||
axis: usize,
|
||||
pub(super) struct Hit {
|
||||
pub(super) axis: usize,
|
||||
/// +1 or -1: which way the face looks.
|
||||
normal: i32,
|
||||
pub(super) normal: i32,
|
||||
/// The plane's coordinate along `axis`.
|
||||
coord: i64,
|
||||
refl: f32,
|
||||
pub(super) coord: i64,
|
||||
pub(super) refl: f32,
|
||||
}
|
||||
|
||||
type Surface<'a> = Box<dyn FnMut(i32, i32, i32) -> Option<(f32, f32)> + 'a>;
|
||||
pub(super) 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(
|
||||
pub(super) fn first_hit(
|
||||
surface: &mut Surface<'_>,
|
||||
from: [f64; 3],
|
||||
dir: [f64; 3],
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! AirDef fork: acoustics stage 3 (#96): what space a point is in. Rays that go sideways and up
|
||||
//! from the point find the walls and the roof round it: how many of them hit (enclosed), how far
|
||||
//! (size), what the walls give back (reflective). Under a roof or in a tunnel nearly all hit and
|
||||
//! near; in a field none; in a gorge some, far away. From the three numbers come the level of the
|
||||
//! reverb ("wet") and its decay, and the sends to SPR's four slots (`spr_reverb`).
|
||||
use super::echo::{Surface, first_hit};
|
||||
use super::{Geometry, spr_bounce, spr_reverb};
|
||||
|
||||
/// Rays from a point (the upper half of a Fibonacci sphere of [`RAYS`] is used: the ground does
|
||||
/// not make a room).
|
||||
pub const RAYS: usize = 48;
|
||||
/// How far a ray looks, in blocks (SPR sees 64 too).
|
||||
pub const LEN: f64 = 64.0;
|
||||
|
||||
/// The space round a point.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Room {
|
||||
/// 0 (nothing within reach) to 1 (walls all round and above, near).
|
||||
pub enclosed: f32,
|
||||
/// The mean distance of the walls found, in blocks.
|
||||
pub size: f32,
|
||||
/// The mean reflectivity of the walls found (SPR's table).
|
||||
pub reflect: f32,
|
||||
}
|
||||
impl Room {
|
||||
pub const OPEN: Room = Room { enclosed: 0.0, size: LEN as f32, reflect: spr_bounce::DEFAULT_REFLECTIVITY };
|
||||
/// `w` of `b` and the rest of `a`.
|
||||
pub fn blend(a: &Room, b: &Room, w: f32) -> Room {
|
||||
let l = |x: f32, y: f32| x + (y - x) * w;
|
||||
Room { enclosed: l(a.enclosed, b.enclosed), size: l(a.size, b.size), reflect: l(a.reflect, b.reflect) }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a measure found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Measured {
|
||||
pub room: Room,
|
||||
pub cells: usize,
|
||||
pub unknown: usize,
|
||||
}
|
||||
|
||||
/// The space round `at`. `budget` counts block cells, as the echo's does.
|
||||
pub fn measure(geometry: &dyn Geometry, at: [f64; 3], budget: &mut usize) -> Measured {
|
||||
let start = *budget;
|
||||
let mut surface: Surface<'_> = geometry.surface();
|
||||
let (mut rays, mut unknown) = (0usize, 0usize);
|
||||
let (mut w_sum, mut d_sum, mut r_sum) = (0.0f64, 0.0f64, 0.0f64);
|
||||
for i in 0..RAYS {
|
||||
let dir = spr_bounce::fibonacci_dir(i, RAYS);
|
||||
if dir[1] < 0.0 {
|
||||
continue;
|
||||
}
|
||||
rays += 1;
|
||||
if let Some(h) = first_hit(&mut surface, at, dir, LEN, budget, &mut unknown) {
|
||||
let d = ((h.coord as f64 - at[h.axis]).abs() / dir[h.axis].abs().max(1e-9)).min(LEN);
|
||||
// A wall at the edge of the reach barely rings.
|
||||
let w = 1.0 - (d / LEN).powi(2);
|
||||
w_sum += w;
|
||||
d_sum += d * w;
|
||||
r_sum += f64::from(h.refl) * w;
|
||||
}
|
||||
}
|
||||
let room = if w_sum > 1e-9 && rays > 0 {
|
||||
Room {
|
||||
enclosed: (w_sum / rays as f64) as f32,
|
||||
size: (d_sum / w_sum) as f32,
|
||||
reflect: (r_sum / w_sum) as f32,
|
||||
}
|
||||
} else {
|
||||
Room::OPEN
|
||||
};
|
||||
Measured { room, cells: start - *budget, unknown }
|
||||
}
|
||||
|
||||
/// The space a sound is heard in: the listener's, and, where the sound's own is known, a blend
|
||||
/// (the listener's counts more: it is where the ear is).
|
||||
pub fn space(listener: &Room, source: Option<&Room>) -> Room {
|
||||
match source {
|
||||
Some(s) => Room::blend(listener, s, 0.4),
|
||||
None => *listener,
|
||||
}
|
||||
}
|
||||
|
||||
fn smoothstep(a: f32, b: f32, x: f32) -> f32 {
|
||||
let t = ((x - a) / (b - a)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// The level (0..1) and decay (s) of the reverb of a space. Nothing below an enclosure of 0.03 (a
|
||||
/// field with a hill or two); the decay grows with the square of the enclosure (a half-open gorge
|
||||
/// does not ring like a hall), with the size of the walls up to 30 blocks, and with what they give back.
|
||||
pub fn wet_and_decay(room: &Room) -> (f32, f32) {
|
||||
let e = room.enclosed.clamp(0.0, 1.0);
|
||||
let wet = smoothstep(0.03, 0.4, e);
|
||||
let size = 0.4 + 0.1 * room.size.clamp(0.0, 30.0);
|
||||
let walls = 0.6 + 0.8 * room.reflect.clamp(0.0, 1.5);
|
||||
let decay = (e * e * size * walls).clamp(spr_reverb::SLOTS[0].decay, spr_reverb::SLOTS[3].decay);
|
||||
(wet, decay)
|
||||
}
|
||||
|
||||
/// The gain of the slot the sends are measured against (SPR's second).
|
||||
const LEVEL: f32 = 0.3;
|
||||
|
||||
/// The sends to SPR's four slots: a crossfade between the two whose decays (on a log scale) a
|
||||
/// space lies between.
|
||||
pub fn sends(room: &Room) -> [f32; 4] {
|
||||
let (wet, decay) = wet_and_decay(room);
|
||||
let mut out = [0.0f32; 4];
|
||||
if wet <= 0.0 {
|
||||
return out;
|
||||
}
|
||||
let d = |i: usize| spr_reverb::SLOTS[i].decay;
|
||||
let mut i = 0;
|
||||
while i < 2 && decay > d(i + 1) {
|
||||
i += 1;
|
||||
}
|
||||
let f = ((decay / d(i)).ln() / (d(i + 1) / d(i)).ln()).clamp(0.0, 1.0);
|
||||
// The two slots share the energy of the send by an equal-power crossfade (no jump where one
|
||||
// gives way to the next), and each is levelled for its own gain (SPR's slots differ in gain:
|
||||
// a longer slot is louder), so the level of the reverb does not change with its decay. The
|
||||
// long slot's tail is heard last and longest, so it gets the position squared: the decay that
|
||||
// measures out is then closer to the space's than to the long slot's.
|
||||
let angle = f * f * std::f32::consts::FRAC_PI_2;
|
||||
for (k, w) in [(i, angle.cos()), (i + 1, angle.sin())] {
|
||||
out[k] = wet * w * LEVEL / spr_reverb::SLOTS[k].gain;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The RT60 a listener would read (Schroeder: the time from -5 to -35 dB of the energy decay
|
||||
/// curve, doubled) from the reverb that these sends make: the sum of the slots' exponential
|
||||
/// decays, each with the energy its send gives it. The long slot, once the short one is gone,
|
||||
/// sets the reading, so a space between two slots reads longer than the decay it was given.
|
||||
pub fn effective_decay(sends: &[f32; 4]) -> f32 {
|
||||
let e: Vec<f64> = (0..4).map(|k| (f64::from(sends[k]) * f64::from(spr_reverb::SLOTS[k].gain) / f64::from(LEVEL)).powi(2)).collect();
|
||||
let total: f64 = e.iter().sum();
|
||||
if total <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let edc = |t: f64| -> f64 {
|
||||
// 10 log10 of the energy left, relative: each slot loses 60 dB in its decay.
|
||||
let left: f64 = (0..4).map(|k| e[k] * 10f64.powf(-6.0 * t / f64::from(spr_reverb::SLOTS[k].decay))).sum();
|
||||
10.0 * (left / total).log10()
|
||||
};
|
||||
let when = |db: f64| {
|
||||
let (mut lo, mut hi) = (0.0f64, 60.0f64);
|
||||
for _ in 0..60 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if edc(mid) > db { lo = mid } else { hi = mid }
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
};
|
||||
(2.0 * (when(-35.0) - when(-5.0))) as f32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub(crate) 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 }))
|
||||
}
|
||||
}
|
||||
pub(crate) fn blocks(f: impl Fn(i32, i32, i32) -> bool + Send + Sync + 'static) -> Blocks {
|
||||
Blocks(Box::new(f))
|
||||
}
|
||||
/// A room of 24 x 10 x 24 (inside), a gorge of 95 between walls, a field, a tunnel 5 x 4.
|
||||
pub(crate) fn scene(name: &str) -> (Blocks, [f64; 3]) {
|
||||
match name {
|
||||
"room" => (blocks(|x, y, z| !((0..24).contains(&x) && (60..70).contains(&y) && (0..24).contains(&z))), [12.5, 64.5, 12.5]),
|
||||
"gorge" => (blocks(|x, y, _| y <= 60 || x <= -40 || x >= 55), [0.5, 66.5, 0.5]),
|
||||
"field" => (blocks(|_, y, _| y <= 60), [0.5, 62.5, 0.5]),
|
||||
"tunnel" => (blocks(|x, y, _| !((0..5).contains(&x) && (61..65).contains(&y))), [2.5, 62.5, 0.5]),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
fn room_of(name: &str) -> Room {
|
||||
let (g, at) = scene(name);
|
||||
let mut b = 1_000_000;
|
||||
measure(&g, at, &mut b).room
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_room_is_enclosed_near_a_field_is_not_and_a_gorge_is_in_between() {
|
||||
let (room, field, gorge, tunnel) = (room_of("room"), room_of("field"), room_of("gorge"), room_of("tunnel"));
|
||||
assert_eq!(field, Room::OPEN);
|
||||
assert!(room.enclosed > 0.8 && room.size < 15.0, "{room:?}");
|
||||
assert!(tunnel.enclosed > 0.8 && tunnel.size < room.size, "{tunnel:?}");
|
||||
assert!(gorge.enclosed > 0.05 && gorge.enclosed < room.enclosed * 0.6 && gorge.size > room.size, "{gorge:?}");
|
||||
assert_eq!(room.reflect, spr_bounce::DEFAULT_REFLECTIVITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decay_orders_field_gorge_room_and_a_field_has_no_send() {
|
||||
let (f, g, r) = (sends(&room_of("field")), sends(&room_of("gorge")), sends(&room_of("room")));
|
||||
assert_eq!(f, [0.0; 4]);
|
||||
let (wf, df) = wet_and_decay(&room_of("field"));
|
||||
assert_eq!(wf, 0.0);
|
||||
assert!(df >= 0.15);
|
||||
let (wg, dg) = wet_and_decay(&room_of("gorge"));
|
||||
let (wr, dr) = wet_and_decay(&room_of("room"));
|
||||
assert!(dr > dg && wr > wg, "room {dr} s {wr}, gorge {dg} s {wg}");
|
||||
assert!(r.iter().map(|v| v * v).sum::<f32>() > g.iter().map(|v| v * v).sum::<f32>());
|
||||
// Two slots at most, and their energies add up to the level of the space.
|
||||
for s in [g, r] {
|
||||
assert!(s.iter().filter(|v| **v > 0.0).count() <= 2);
|
||||
}
|
||||
let energy: f32 = (0..4).map(|k| (r[k] * spr_reverb::SLOTS[k].gain / LEVEL).powi(2)).sum();
|
||||
assert!((energy - wr * wr).abs() < 1e-5, "{energy} {wr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stone_rings_longer_than_wool_and_the_sends_move_smoothly_with_the_space() {
|
||||
let base = Room { enclosed: 0.9, size: 10.0, reflect: 0.5 };
|
||||
let stone = Room { reflect: 1.5, ..base };
|
||||
let wool = Room { reflect: 0.1, ..base };
|
||||
assert!(wet_and_decay(&stone).1 > wet_and_decay(&base).1 && wet_and_decay(&base).1 > wet_and_decay(&wool).1);
|
||||
// No jump in the sends for a small step in the space.
|
||||
let mut prev = sends(&Room { enclosed: 0.0, ..base });
|
||||
for i in 1..=100 {
|
||||
let now = sends(&Room { enclosed: i as f32 / 100.0, ..base });
|
||||
for k in 0..4 {
|
||||
let level = |v: f32| v * spr_reverb::SLOTS[k].gain / LEVEL;
|
||||
assert!((level(now[k]) - level(prev[k])).abs() < 0.15, "{i}: {prev:?} -> {now:?}");
|
||||
}
|
||||
prev = now;
|
||||
}
|
||||
// A blend with a source in a room is between the two.
|
||||
let mixed = space(&Room::OPEN, Some(&base));
|
||||
assert!(mixed.enclosed > 0.0 && mixed.enclosed < base.enclosed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_slot_reads_its_own_decay_and_a_mix_reads_longer_than_the_short_one() {
|
||||
for k in 0..4 {
|
||||
let mut s = [0.0; 4];
|
||||
s[k] = 1.0;
|
||||
assert!((effective_decay(&s) - spr_reverb::SLOTS[k].decay).abs() < 0.01 * spr_reverb::SLOTS[k].decay);
|
||||
}
|
||||
assert_eq!(effective_decay(&[0.0; 4]), 0.0);
|
||||
let mixed = effective_decay(&[0.0, 1.0, 0.2, 0.0]);
|
||||
assert!(mixed > spr_reverb::SLOTS[1].decay && mixed < spr_reverb::SLOTS[2].decay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_budget_is_counted() {
|
||||
let (g, at) = scene("room");
|
||||
let mut b = 100;
|
||||
let m = measure(&g, at, &mut b);
|
||||
assert!(m.cells <= 100 && b < 100);
|
||||
}
|
||||
|
||||
/// The cost of one measure in release: `cargo test -p horizon-client --lib --release
|
||||
/// acoustics::room::tests::bench -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_measures() {
|
||||
for name in ["room", "tunnel", "gorge", "field"] {
|
||||
let (g, at) = scene(name);
|
||||
let n = 5000;
|
||||
let (mut cells, start) = (0, std::time::Instant::now());
|
||||
for i in 0..n {
|
||||
let mut b = 1_000_000;
|
||||
cells += measure(&g, [at[0] + (i % 3) as f64 * 0.1, at[1], at[2]], &mut b).cells;
|
||||
}
|
||||
println!("{name:7} {:.4} ms per measure, {} cells", start.elapsed().as_secs_f64() * 1e3 / n as f64, cells / n);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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/config/ReverbParams.java (the four slots)
|
||||
// common/src/main/java/com/sonicether/soundphysics/SoundPhysics.java
|
||||
// (`setupEFX` gain scale 0.7 * 0.85, the distance fade of the sends, `reverb_distance` 1.5)
|
||||
// Ported to Rust for Horizon's own mixer reverb (`audio/reverb.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 reverb side of SPR: four slots of fixed decay, to which a source sends in proportion to
|
||||
//! how much of each kind of space it hears.
|
||||
|
||||
/// One of SPR's four auxiliary effect slots (the EAX reverb it sets, the numbers we use).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SlotParams {
|
||||
/// `decay_time`, seconds.
|
||||
pub decay: f32,
|
||||
/// `gain` of the slot.
|
||||
pub gain: f32,
|
||||
/// `decay_hf_ratio`: how long the highs last, as a fraction of the decay.
|
||||
pub decay_hf_ratio: f32,
|
||||
/// `late_reverb_delay`, seconds.
|
||||
pub late_delay: f32,
|
||||
}
|
||||
|
||||
/// The four slots, from the shortest to the longest. (Not used: `density`, `gain_hf`, the
|
||||
/// reflections' gain and delay, `decay_lf_ratio` and the rolloff: our reverb has no such knobs.)
|
||||
pub const SLOTS: [SlotParams; 4] = [
|
||||
SlotParams { decay: 0.15, gain: 0.2, decay_hf_ratio: 0.6, late_delay: 0.011 },
|
||||
SlotParams { decay: 0.55, gain: 0.3, decay_hf_ratio: 0.7, late_delay: 0.011 },
|
||||
SlotParams { decay: 1.68, gain: 0.5, decay_hf_ratio: 0.7, late_delay: 0.021 },
|
||||
SlotParams { decay: 4.142, gain: 0.4, decay_hf_ratio: 0.7, late_delay: 0.021 },
|
||||
];
|
||||
|
||||
/// The slots' gain is scaled by `0.7 * reverb_gain * 0.85` (`reverb_gain` 1).
|
||||
pub const OUTPUT_GAIN: f32 = 0.7 * 0.85;
|
||||
|
||||
/// `reverb_distance`: the sends fade to nothing at this many times the sound's range.
|
||||
pub const REVERB_DISTANCE: f32 = 1.5;
|
||||
|
||||
/// `send *= 1 - min(distance / (max_distance * reverb_distance), 1)`.
|
||||
pub fn distance_fade(distance: f32, max: f32) -> f32 {
|
||||
if max <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
1.0 - (distance / (max * REVERB_DISTANCE)).min(1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_slots_grow_and_the_fade_reaches_zero() {
|
||||
for w in SLOTS.windows(2) {
|
||||
assert!(w[0].decay < w[1].decay);
|
||||
}
|
||||
assert_eq!(distance_fade(0.0, 100.0), 1.0);
|
||||
assert!((distance_fade(75.0, 100.0) - 0.5).abs() < 1e-6);
|
||||
assert_eq!(distance_fade(500.0, 100.0), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use crate::sound_events::{Sound, SoundRegistry};
|
||||
#[cfg(feature = "audio")]
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
mod reverb;
|
||||
use reverb::{Reverb, Send};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
@@ -45,6 +47,8 @@ struct Channel {
|
||||
occlusion: Option<Occlusion>,
|
||||
/// Acoustics stage 2: the echoes of the sound, copies of it delayed by the longer way.
|
||||
echo: Option<Echo>,
|
||||
/// Acoustics stage 3: how much of the sound goes to the shared reverb, per slot.
|
||||
send: Option<Send>,
|
||||
}
|
||||
/// 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
|
||||
@@ -318,6 +322,9 @@ struct Mixer {
|
||||
limiter_peak: f32,
|
||||
/// Film capture: the output device hears nothing, `Audio::film_mix` mixes instead.
|
||||
offline: bool,
|
||||
/// Acoustics stage 3: the shared late reverb, made when a voice first sends to it and dropped
|
||||
/// when its tail is over.
|
||||
reverb: Option<Reverb>,
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -520,6 +527,7 @@ impl Backend for MixerBackend {
|
||||
voice: None,
|
||||
occlusion: None,
|
||||
echo: None,
|
||||
send: None,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
@@ -705,6 +713,14 @@ fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
|
||||
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
|
||||
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
|
||||
}
|
||||
/// SPR's fade of a voice's sends with its distance (`reverb_distance`), 1 for a sound that has no range.
|
||||
fn send_fade(linear: Option<f32>, relative: bool, at: [f32; 3], listener: [f64; 3]) -> f32 {
|
||||
let (Some(max), false) = (linear, relative) else {
|
||||
return 1.0;
|
||||
};
|
||||
let d = [f64::from(at[0]) - listener[0], f64::from(at[1]) - listener[1], f64::from(at[2]) - listener[2]];
|
||||
crate::acoustics::spr_reverb::distance_fade((d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32, max)
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
/// distance 0 and rolloff 1: the distance clamps to the max distance).
|
||||
fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
|
||||
@@ -802,6 +818,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// In a fixed order, so the sum (and a film's track) is the same in every run.
|
||||
voices.sort_by_key(|v| v.0);
|
||||
let buffer_seconds = (data.len() / channels.max(1)) as f32 / rate as f32;
|
||||
let mut rv_in: Vec<[f32; reverb::SLOT_COUNT]> = Vec::new();
|
||||
for (id, [left, right, gain], mut step) in voices {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -826,6 +843,11 @@ 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 fk = send_fade(c.linear, c.relative, c.at, listener);
|
||||
let sends = c.send.as_mut().map(|s| s.buffer(buffer_seconds)).map(|(a, b)| (a.map(|v| v * fk), b.map(|v| v * fk)));
|
||||
if sends.is_some() && rv_in.is_empty() {
|
||||
rv_in.resize(frames, [0.0; reverb::SLOT_COUNT]);
|
||||
}
|
||||
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() {
|
||||
@@ -858,6 +880,12 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
_ => s,
|
||||
};
|
||||
let lerp = |a: f32, b: f32| a + (b - a) * t;
|
||||
if let Some((s0, s1)) = &sends {
|
||||
let dry = s * lerp(from[2], gain);
|
||||
for j in 0..reverb::SLOT_COUNT {
|
||||
rv_in[k][j] += dry * lerp(s0[j], s1[j]);
|
||||
}
|
||||
}
|
||||
if channels >= 2 {
|
||||
frame[0] += s * lerp(from[0], left) + echoes[0] * v.env;
|
||||
frame[1] += s * lerp(from[1], right) + echoes[1] * v.env;
|
||||
@@ -867,12 +895,20 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
c.position += step;
|
||||
}
|
||||
finish_echo(&mut c.echo, &plans);
|
||||
if c.send.as_ref().is_some_and(Send::done) {
|
||||
c.send = None;
|
||||
}
|
||||
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 fk = send_fade(c.linear, c.relative, c.at, listener);
|
||||
let sends = c.send.as_mut().map(|s| s.buffer(buffer_seconds)).map(|(a, b)| (a.map(|v| v * fk), b.map(|v| v * fk)));
|
||||
if sends.is_some() && rv_in.is_empty() {
|
||||
rv_in.resize(frames, [0.0; reverb::SLOT_COUNT]);
|
||||
}
|
||||
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() {
|
||||
@@ -897,6 +933,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
(Some(o), Some((g, cut, a))) => o.run(s, k as f32 / frames as f32, *g, *cut, *a),
|
||||
_ => s,
|
||||
};
|
||||
if let Some((s0, s1)) = &sends {
|
||||
let u = k as f32 / frames as f32;
|
||||
let dry = s * gain;
|
||||
for j in 0..reverb::SLOT_COUNT {
|
||||
rv_in[k][j] += dry * (s0[j] + (s1[j] - s0[j]) * u);
|
||||
}
|
||||
}
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left + echoes[0];
|
||||
frame[1] += s * right + echoes[1];
|
||||
@@ -907,6 +950,17 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
c.position += step;
|
||||
}
|
||||
finish_echo(&mut c.echo, &plans);
|
||||
if c.send.as_ref().is_some_and(Send::done) {
|
||||
c.send = None;
|
||||
}
|
||||
}
|
||||
// The shared reverb: what the voices sent, and the tail of what they sent before. It rings on
|
||||
// when the voices are gone and is dropped when it is silent.
|
||||
if !rv_in.is_empty() || mixer.reverb.is_some() {
|
||||
let rv = mixer.reverb.take().filter(|r| r.rate() == rate).unwrap_or_else(|| Reverb::new(rate));
|
||||
let mut rv = rv;
|
||||
rv.process(&rv_in, data, channels);
|
||||
mixer.reverb = (!rv.idle()).then_some(rv);
|
||||
}
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 200 ms
|
||||
@@ -1111,6 +1165,7 @@ impl Audio {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.offline = true;
|
||||
m.channels.clear();
|
||||
m.reverb = None;
|
||||
}
|
||||
if let Some(a) = self.acoustics.as_mut() {
|
||||
a.reset();
|
||||
@@ -1142,6 +1197,7 @@ impl Audio {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.offline = false;
|
||||
m.channels.clear();
|
||||
m.reverb = None;
|
||||
}
|
||||
if let Some(a) = self.acoustics.as_mut() {
|
||||
a.reset();
|
||||
@@ -1260,6 +1316,34 @@ impl Audio {
|
||||
m.tick(listener, e, b);
|
||||
});
|
||||
}
|
||||
/// Turns the acoustics on or off while the game runs: off, what plays eases back to the plain
|
||||
/// sound and its echoes stop.
|
||||
pub fn set_acoustics(&mut self, on: bool) {
|
||||
if on == self.acoustics.is_some() {
|
||||
return;
|
||||
}
|
||||
let Ok(mut m) = self.backend.mixer.lock() else {
|
||||
return;
|
||||
};
|
||||
if on {
|
||||
let mut a = crate::acoustics::Acoustics::new(false);
|
||||
a.sync = m.offline;
|
||||
self.acoustics = Some(a);
|
||||
} else {
|
||||
self.acoustics = None;
|
||||
for c in m.channels.values_mut() {
|
||||
if let Some(o) = c.occlusion.as_mut() {
|
||||
o.gain_to = 1.0;
|
||||
o.cut_to = 1.0;
|
||||
}
|
||||
c.echo = None;
|
||||
// The reverb fades out with the send; what already rings in it rings out.
|
||||
if let Some(s) = c.send.as_mut() {
|
||||
s.to = [0.0; reverb::SLOT_COUNT];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether acoustics are on and a sound is playing in the world: the game then builds the
|
||||
/// geometry for [`Audio::tick_acoustics`].
|
||||
pub fn acoustics_wanted(&self) -> bool {
|
||||
@@ -1310,7 +1394,8 @@ impl Audio {
|
||||
};
|
||||
let applied = acoustics.step(geometry, listener, &sources, changes);
|
||||
let echoes = acoustics.take_echoes();
|
||||
if !applied.is_empty() || !echoes.is_empty() {
|
||||
let sends = acoustics.take_sends();
|
||||
if !applied.is_empty() || !echoes.is_empty() || !sends.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)) {
|
||||
@@ -1323,6 +1408,14 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, to) in sends {
|
||||
if let Some(c) = m.channels.get_mut(&(id as ChannelId)) {
|
||||
match c.send.as_mut() {
|
||||
Some(s) => s.to = to,
|
||||
None => c.send = Some(Send::new(to)),
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -1352,6 +1445,10 @@ impl Audio {
|
||||
}
|
||||
/// `SoundManager.stop()`: every sound.
|
||||
pub fn stop_all(&mut self) {
|
||||
// Every sound ends here, so the tail of the reverb does too.
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.reverb = None;
|
||||
}
|
||||
self.mod_sources.release_all(&mut self.backend);
|
||||
self.engine.stop_all(&mut self.backend);
|
||||
}
|
||||
@@ -1487,6 +1584,11 @@ impl Audio {
|
||||
"ms_max": t.echo_us_max as f64 / 1e3, "capped": t.echo_capped,
|
||||
"voices": crate::acoustics::ECHO_VOICES,
|
||||
},
|
||||
"reverb": {
|
||||
"rooms": t.room_traces, "room_hits": t.room_hits, "room_cells": t.room_cells,
|
||||
"sends": t.sends,
|
||||
"enclosed": t.room_enclosed, "decay_s": t.room_decay,
|
||||
},
|
||||
"tick_ms_total": t.tick_us as f64 / 1e3, "tick_ms_max": t.tick_us_max as f64 / 1e3,
|
||||
"ticks": t.ticks,
|
||||
});
|
||||
@@ -1602,6 +1704,7 @@ mod tests {
|
||||
voice: None,
|
||||
occlusion: None,
|
||||
echo: None,
|
||||
send: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2134,6 +2237,196 @@ mod tests {
|
||||
std::fs::write(std::path::Path::new(&dir).join("expected.txt"), expected).unwrap();
|
||||
}
|
||||
|
||||
/// A click of `n` frames at the start of a sound `len` frames long.
|
||||
fn click(len: usize, n: usize) -> Vec<f32> {
|
||||
let mut seed = 99u32;
|
||||
(0..len)
|
||||
.map(|i| {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
if i < n { ((seed >> 8) as f32 / 8_388_608.0 - 1.0) * (1.0 - i as f32 / n as f32) } else { 0.0 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn sending(to: [f32; 4]) -> Option<Send> {
|
||||
Some(Send { now: to, to })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reverb_rings_on_after_the_sound_and_is_dropped_when_it_is_over() {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(click(4_800, 96), None, [0.0; 3], false);
|
||||
c.relative = true;
|
||||
c.send = sending([0.0, 0.0, 1.0, 0.0]);
|
||||
m.channels.insert(1, c);
|
||||
let out = render(&mut m, 200, 480);
|
||||
// The sound is 0.1 s long; a second later the reverb still rings.
|
||||
let dry: f32 = out[..2 * 4_800].iter().map(|v| v * v).sum();
|
||||
let tail: f32 = out[2 * 48_000..2 * 72_000].iter().map(|v| v * v).sum();
|
||||
assert!(dry > 0.0 && tail > 0.0, "{dry} {tail}");
|
||||
assert!(m.channels[&1].state == State::Stopped);
|
||||
assert!(m.reverb.is_some());
|
||||
// Eight seconds on, it is over and gone (nothing runs for it).
|
||||
render(&mut m, 400, 480);
|
||||
assert!(m.reverb.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_send_there_is_no_reverb_and_the_output_is_what_it_was() {
|
||||
let play = |send: Option<Send>| {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(click(4_800, 96), None, [3.0, 0.0, 4.0], false);
|
||||
c.send = send;
|
||||
m.channels.insert(1, c);
|
||||
let out = render(&mut m, 40, 480);
|
||||
(out, m.reverb.is_some())
|
||||
};
|
||||
let (plain, rv) = play(None);
|
||||
assert!(!rv);
|
||||
let (zero, rv) = play(sending([0.0; 4]));
|
||||
assert!(!rv && zero == plain, "a zero send adds nothing");
|
||||
let (wet, rv) = play(sending([0.0, 1.0, 0.0, 0.0]));
|
||||
assert!(rv && wet != plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changing_space_and_turning_it_off_make_no_click_and_the_tail_fades() {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(tone(220.0, 400_000), None, [0.0; 3], true);
|
||||
c.relative = true;
|
||||
m.channels.insert(1, c);
|
||||
let quiet = render(&mut m, 20, 480);
|
||||
let dry_step = max_step(&quiet);
|
||||
// Walk into a hall: the send goes up.
|
||||
m.channels.get_mut(&1).unwrap().send = Some(Send::new([0.0, 0.0, 0.0, 1.0]));
|
||||
let hall = render(&mut m, 200, 480);
|
||||
// A click is a step steeper than a 220 Hz tone can have at the level it has.
|
||||
let slope = |x: &[f32]| max_step(x) / (std::f32::consts::TAU * 220.0 / 48_000.0 * x.iter().fold(0.0f32, |p, v| p.max(v.abs())));
|
||||
assert!(rms(&hall[hall.len() - 9_600..]) > rms(&quiet) * 1.3, "the hall adds to the sound");
|
||||
assert!(slope(&hall) < 1.2, "{}", slope(&hall));
|
||||
// And off: the sound goes on, the reverb sends fade, the tail rings out.
|
||||
m.channels.get_mut(&1).unwrap().send.as_mut().unwrap().to = [0.0; 4];
|
||||
let off = render(&mut m, 400, 480);
|
||||
assert!(slope(&off) < 1.2, "{}", slope(&off));
|
||||
assert!(m.channels[&1].send.is_none(), "a gone send is dropped");
|
||||
assert!(m.reverb.is_some(), "the tail is not cut");
|
||||
// The tone stops; the tail shrinks smoothly, then is gone.
|
||||
m.channels.get_mut(&1).unwrap().state = State::Stopped;
|
||||
let rest = render(&mut m, 1_500, 480);
|
||||
assert!(max_step(&rest) < 0.05);
|
||||
let (early, late) = (rms(&rest[..9_600]), rms(&rest[rest.len() - 9_600..]));
|
||||
assert!(early > late, "{early} {late}");
|
||||
assert!(m.reverb.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_the_acoustics_off_sends_nothing_more_and_a_film_begins_dry() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
audio.acoustics = Some(crate::acoustics::Acoustics::new(false));
|
||||
{
|
||||
let mut m = audio.backend.mixer.lock().unwrap();
|
||||
let mut c = channel(tone(220.0, 48_000), None, [0.0; 3], true);
|
||||
c.send = sending([0.0, 0.5, 0.5, 0.0]);
|
||||
m.channels.insert(1, c);
|
||||
m.reverb = Some(Reverb::new(48_000));
|
||||
}
|
||||
audio.set_acoustics(false);
|
||||
assert!(audio.acoustics.is_none());
|
||||
{
|
||||
let m = audio.backend.mixer.lock().unwrap();
|
||||
assert_eq!(m.channels[&1].send.unwrap().to, [0.0; 4], "fading, not cut");
|
||||
assert!(m.channels[&1].send.unwrap().now[1] > 0.0);
|
||||
assert!(m.reverb.is_some());
|
||||
}
|
||||
audio.film_begin();
|
||||
let m = audio.backend.mixer.lock().unwrap();
|
||||
assert!(m.reverb.is_none() && m.channels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverb_rendering_is_deterministic() {
|
||||
let run = || {
|
||||
let mut m = Mixer::default();
|
||||
for id in 1..=5u64 {
|
||||
let mut c = channel(click(9_600, 96), Some(200.0), [id as f32 * 7.0, 1.0, 3.0], false);
|
||||
c.send = sending([0.1, 0.2 * id as f32 / 5.0, 0.3, 0.2]);
|
||||
m.channels.insert(id, c);
|
||||
}
|
||||
render(&mut m, 120, 480)
|
||||
};
|
||||
assert_eq!(run(), run());
|
||||
}
|
||||
|
||||
/// Writes the track of a click in a room, a tunnel, a gorge and a field, through the real
|
||||
/// acoustics and mixer, for `python3 tools/acoustics_check.py --reverb-dir`:
|
||||
/// `HORIZON_ACOUSTICS_OUT=/dir cargo test -p horizon-client --lib audio::tests::export_reverb_tracks -- --ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn export_reverb_tracks() {
|
||||
use crate::acoustics::{Acoustics, Changes, Geometry, Source, room};
|
||||
let Some(dir) = std::env::var_os("HORIZON_ACOUSTICS_OUT") else {
|
||||
return;
|
||||
};
|
||||
let mut expected = String::new();
|
||||
for name in ["room", "tunnel", "gorge", "field"] {
|
||||
let (geometry, at) = room::tests::scene(name);
|
||||
let geometry: Arc<dyn Geometry> = Arc::new(geometry);
|
||||
let src = [at[0], at[1], at[2] + 4.0];
|
||||
let mut m = Mixer::default();
|
||||
m.listener = at;
|
||||
m.channels.insert(1, channel(click(48_000, 96), Some(200.0), [src[0] as f32, src[1] as f32, src[2] as f32], false));
|
||||
let mut acoustics = Acoustics::new(true);
|
||||
let source = Source { id: 1, at: src, loud: 0.97, echo: true };
|
||||
acoustics.step(Some(&geometry), at, &[source], &Changes::default());
|
||||
let sends = acoustics.take_sends();
|
||||
for (id, to) in &sends {
|
||||
m.channels.get_mut(&(*id as ChannelId)).unwrap().send = sending(*to);
|
||||
}
|
||||
let (wet, decay) = room::wet_and_decay(&acoustics_room(&acoustics));
|
||||
let effective = sends.first().map_or(0.0, |(_, to)| room::effective_decay(to));
|
||||
let out = render(&mut m, 700, 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();
|
||||
expected.push_str(&format!("{name} {decay:.3} {wet:.3} {effective:.3}\n"));
|
||||
}
|
||||
std::fs::write(std::path::Path::new(&dir).join("expected_reverb.txt"), expected).unwrap();
|
||||
}
|
||||
/// The listener's room, as the engine measured it (stats carry its numbers).
|
||||
fn acoustics_room(a: &crate::acoustics::Acoustics) -> crate::acoustics::Room {
|
||||
a.room_now().unwrap_or(crate::acoustics::Room::OPEN)
|
||||
}
|
||||
|
||||
/// 100 looping voices (a quarter sending to the reverb, all four slots ringing) against none:
|
||||
/// `cargo test -p horizon-client --lib --release audio::tests::bench_reverb -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_reverb_with_one_hundred_voices() {
|
||||
let make = |sends: u64| {
|
||||
let mut m = Mixer::default();
|
||||
for id in 1..=100u64 {
|
||||
let mut c = voice_channel(tone(110.0 + id as f32 * 7.0, 96_000), [id as f32 * 3.0, 1.0, 40.0], true, 0.0, 0.0, 0.0);
|
||||
c.linear = Some(500.0);
|
||||
if id <= sends {
|
||||
c.send = sending([0.1 + 0.001 * id as f32, 0.2, 0.3, 0.2]);
|
||||
}
|
||||
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, quarter, all) = (time(make(0)), time(make(25)), time(make(100)));
|
||||
println!("100 voices, 512 frames: {plain:.3} ms plain, {quarter:.3} ms with 25 sending ({:+.3}), {all:.3} ms with 100 sending ({:+.3}); buffer is 10.67 ms", quarter - plain, all - plain);
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! AirDef fork: acoustics stage 3 (#96): the mixer's shared late reverb. Four slots of SPR's
|
||||
//! decays (`acoustics::spr_reverb`), each a pre-delay, two allpasses to diffuse, and four feedback
|
||||
//! combs with a low-pass in the loop (the highs die faster, as SPR's `decay_hf_ratio` says). Every
|
||||
//! voice sends its sound (after the wall, before the pan) to the slots by its own levels; the left
|
||||
//! output takes combs 0 and 2, the right 1 and 3, so the two ears hear different tails and a mono
|
||||
//! sum is the plain average of all four.
|
||||
//!
|
||||
//! No allocation or randomness in the audio path (the delay lines are made with the reverb), the
|
||||
//! same input gives the same output (a film's track), and a slot with nothing in and nothing left
|
||||
//! to ring is not run at all, and is cleared when it falls silent (no denormals).
|
||||
use crate::acoustics::spr_reverb::{OUTPUT_GAIN, SLOTS};
|
||||
|
||||
pub const SLOT_COUNT: usize = 4;
|
||||
/// Comb lengths per slot at 48 kHz, in samples (mutually prime, longer for longer slots).
|
||||
const COMB_LEN: [[usize; 4]; SLOT_COUNT] =
|
||||
[[353, 457, 563, 641], [613, 797, 947, 1117], [997, 1259, 1471, 1693], [1301, 1627, 1913, 2203]];
|
||||
/// Allpass lengths at 48 kHz.
|
||||
const ALLPASS_LEN: [usize; 2] = [225, 341];
|
||||
/// Below this the output of a slot with no input is silence (-120 dB).
|
||||
const SILENT: f32 = 1e-6;
|
||||
/// The master level of the wet signal, set by ear against a measured tail (see `docs/ACOUSTICS.md`).
|
||||
const WET: f32 = 1.6;
|
||||
/// Where the highs are judged for the loop's low-pass (OpenAL's `HFREFERENCE`).
|
||||
const HF_HZ: f32 = 5000.0;
|
||||
/// A voice's sends move to their target over about this many seconds.
|
||||
const SLEW: f32 = 0.3;
|
||||
|
||||
/// The sends of one voice to the four slots: where they are and where they go.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Send {
|
||||
pub now: [f32; SLOT_COUNT],
|
||||
pub to: [f32; SLOT_COUNT],
|
||||
}
|
||||
impl Send {
|
||||
pub fn new(to: [f32; SLOT_COUNT]) -> Self {
|
||||
Self { now: [0.0; SLOT_COUNT], to }
|
||||
}
|
||||
/// Moves towards the target for a buffer of `seconds`; the levels at its start and end.
|
||||
pub fn buffer(&mut self, seconds: f32) -> ([f32; SLOT_COUNT], [f32; SLOT_COUNT]) {
|
||||
let alpha = 1.0 - (-seconds / SLEW).exp();
|
||||
let from = self.now;
|
||||
for k in 0..SLOT_COUNT {
|
||||
self.now[k] += (self.to[k] - self.now[k]) * alpha;
|
||||
if self.to[k] == 0.0 && self.now[k] < 1e-4 {
|
||||
self.now[k] = 0.0;
|
||||
}
|
||||
}
|
||||
(from, self.now)
|
||||
}
|
||||
/// Gone: nothing is sent now or will be.
|
||||
pub fn done(&self) -> bool {
|
||||
self.now == [0.0; SLOT_COUNT] && self.to == [0.0; SLOT_COUNT]
|
||||
}
|
||||
}
|
||||
|
||||
fn scaled(len: usize, rate: u32) -> usize {
|
||||
(len as u64 * u64::from(rate) / 48_000).max(2) as usize
|
||||
}
|
||||
|
||||
struct Comb {
|
||||
buf: Vec<f32>,
|
||||
pos: usize,
|
||||
fb: f32,
|
||||
damp: f32,
|
||||
store: f32,
|
||||
/// Keeps the energy of a ring the same whatever its decay.
|
||||
scale: f32,
|
||||
}
|
||||
impl Comb {
|
||||
/// A comb of `len` samples that decays by 60 dB in `decay` seconds, whose highs (at 5 kHz)
|
||||
/// last `hf_ratio` of that.
|
||||
fn new(len: usize, decay: f32, hf_ratio: f32, rate: u32) -> Self {
|
||||
let fb = 10f32.powf(-3.0 * len as f32 / (decay * rate as f32));
|
||||
// The loop is `fb * (1 - d) / (1 - d z^-1)`; at HF_HZ it must be `fb^(1/ratio)`.
|
||||
let want = fb.powf(1.0 / hf_ratio - 1.0).min(1.0);
|
||||
let cos = (std::f32::consts::TAU * HF_HZ / rate as f32).cos();
|
||||
let (mut lo, mut hi) = (0.0f32, 0.95f32);
|
||||
for _ in 0..24 {
|
||||
let d = 0.5 * (lo + hi);
|
||||
let h = (1.0 - d) / (1.0 - 2.0 * d * cos + d * d).sqrt();
|
||||
if h > want { lo = d } else { hi = d }
|
||||
}
|
||||
Self { buf: vec![0.0; len], pos: 0, fb, damp: 0.5 * (lo + hi), store: 0.0, scale: (1.0 - fb * fb).sqrt() }
|
||||
}
|
||||
#[inline]
|
||||
fn run(&mut self, x: f32) -> f32 {
|
||||
let y = self.buf[self.pos];
|
||||
self.store = y * (1.0 - self.damp) + self.store * self.damp;
|
||||
self.buf[self.pos] = x * self.scale + self.store * self.fb;
|
||||
self.pos += 1;
|
||||
if self.pos == self.buf.len() {
|
||||
self.pos = 0;
|
||||
}
|
||||
y
|
||||
}
|
||||
fn clear(&mut self) {
|
||||
self.buf.fill(0.0);
|
||||
self.store = 0.0;
|
||||
self.pos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
struct Allpass {
|
||||
buf: Vec<f32>,
|
||||
pos: usize,
|
||||
}
|
||||
impl Allpass {
|
||||
#[inline]
|
||||
fn run(&mut self, x: f32) -> f32 {
|
||||
let b = self.buf[self.pos];
|
||||
self.buf[self.pos] = x + b * 0.5;
|
||||
self.pos += 1;
|
||||
if self.pos == self.buf.len() {
|
||||
self.pos = 0;
|
||||
}
|
||||
b - x
|
||||
}
|
||||
}
|
||||
|
||||
struct Slot {
|
||||
pre: Vec<f32>,
|
||||
pre_pos: usize,
|
||||
aps: [Allpass; 2],
|
||||
combs: [Comb; 4],
|
||||
gain: f32,
|
||||
/// Something still rings (or came in) in this slot.
|
||||
live: bool,
|
||||
/// Frames since the last input; the sound needs the delay lines' length to come out.
|
||||
since_input: usize,
|
||||
}
|
||||
impl Slot {
|
||||
fn new(i: usize, rate: u32) -> Self {
|
||||
let p = SLOTS[i];
|
||||
let comb = |k: usize| Comb::new(scaled(COMB_LEN[i][k], rate), p.decay, p.decay_hf_ratio, rate);
|
||||
let ap = |k: usize| Allpass { buf: vec![0.0; scaled(ALLPASS_LEN[k], rate)], pos: 0 };
|
||||
Self {
|
||||
pre: vec![0.0; scaled((p.late_delay * 48_000.0) as usize, rate)],
|
||||
pre_pos: 0,
|
||||
aps: [ap(0), ap(1)],
|
||||
combs: [comb(0), comb(1), comb(2), comb(3)],
|
||||
gain: p.gain * OUTPUT_GAIN * WET,
|
||||
live: false,
|
||||
since_input: 0,
|
||||
}
|
||||
}
|
||||
/// Frames a sound takes to get through the pre-delay, the allpasses and the longest comb, and
|
||||
/// to be heard once.
|
||||
fn reach(&self) -> usize {
|
||||
self.pre.len() + self.aps.iter().map(|a| a.buf.len()).sum::<usize>() + self.combs.iter().map(|c| c.buf.len()).max().unwrap_or(0) + 1
|
||||
}
|
||||
fn clear(&mut self) {
|
||||
self.pre.fill(0.0);
|
||||
self.pre_pos = 0;
|
||||
for a in &mut self.aps {
|
||||
a.buf.fill(0.0);
|
||||
a.pos = 0;
|
||||
}
|
||||
for c in &mut self.combs {
|
||||
c.clear();
|
||||
}
|
||||
self.live = false;
|
||||
self.since_input = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The four slots and their output scratch.
|
||||
pub struct Reverb {
|
||||
rate: u32,
|
||||
slots: Vec<Slot>,
|
||||
wet: Vec<[f32; 2]>,
|
||||
}
|
||||
impl Reverb {
|
||||
pub fn new(rate: u32) -> Self {
|
||||
Self { rate, slots: (0..SLOT_COUNT).map(|i| Slot::new(i, rate)).collect(), wet: Vec::new() }
|
||||
}
|
||||
pub fn rate(&self) -> u32 {
|
||||
self.rate
|
||||
}
|
||||
/// Nothing rings: the mixer may drop the reverb until a voice sends again.
|
||||
pub fn idle(&self) -> bool {
|
||||
self.slots.iter().all(|s| !s.live)
|
||||
}
|
||||
/// Adds the reverb of `input` (the mono sum per slot per frame; empty: silence) to `data`
|
||||
/// (`channels` interleaved). Returns the peak of the wet signal.
|
||||
pub fn process(&mut self, input: &[[f32; SLOT_COUNT]], data: &mut [f32], channels: usize) -> f32 {
|
||||
let frames = data.len() / channels.max(1);
|
||||
self.wet.clear();
|
||||
self.wet.resize(frames, [0.0; 2]);
|
||||
let mut peaks = [0.0f32; SLOT_COUNT];
|
||||
for f in input.iter().take(frames) {
|
||||
for k in 0..SLOT_COUNT {
|
||||
peaks[k] = peaks[k].max(f[k].abs());
|
||||
}
|
||||
}
|
||||
for (si, slot) in self.slots.iter_mut().enumerate() {
|
||||
if peaks[si] == 0.0 && !slot.live {
|
||||
continue;
|
||||
}
|
||||
let mut out_peak = 0.0f32;
|
||||
for (k, w) in self.wet.iter_mut().enumerate() {
|
||||
let x = input.get(k).map_or(0.0, |f| f[si]);
|
||||
let delayed = slot.pre[slot.pre_pos];
|
||||
slot.pre[slot.pre_pos] = x;
|
||||
slot.pre_pos += 1;
|
||||
if slot.pre_pos == slot.pre.len() {
|
||||
slot.pre_pos = 0;
|
||||
}
|
||||
let d0 = slot.aps[0].run(delayed);
|
||||
let d = slot.aps[1].run(d0);
|
||||
let (c0, c1, c2, c3) = (
|
||||
slot.combs[0].run(d),
|
||||
slot.combs[1].run(d),
|
||||
slot.combs[2].run(d),
|
||||
slot.combs[3].run(d),
|
||||
);
|
||||
let (l, r) = (0.5 * (c0 + c2) * slot.gain, 0.5 * (c1 + c3) * slot.gain);
|
||||
w[0] += l;
|
||||
w[1] += r;
|
||||
out_peak = out_peak.max(l.abs()).max(r.abs());
|
||||
}
|
||||
slot.since_input = if peaks[si] > 0.0 { 0 } else { slot.since_input + frames };
|
||||
slot.live = slot.since_input < slot.reach() || out_peak > SILENT;
|
||||
if !slot.live {
|
||||
slot.clear();
|
||||
}
|
||||
}
|
||||
let mut peak = 0.0f32;
|
||||
for (frame, w) in data.chunks_mut(channels.max(1)).zip(&self.wet) {
|
||||
if channels >= 2 {
|
||||
frame[0] += w[0];
|
||||
frame[1] += w[1];
|
||||
} else {
|
||||
frame[0] += 0.5 * (w[0] + w[1]);
|
||||
}
|
||||
peak = peak.max(w[0].abs()).max(w[1].abs());
|
||||
}
|
||||
peak
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RATE: u32 = 48_000;
|
||||
/// The reverb of an impulse into slot `k` at level 1: `seconds` of the left channel.
|
||||
fn impulse(slot: usize, seconds: f32) -> Vec<f32> {
|
||||
let frames = (seconds * RATE as f32) as usize;
|
||||
let mut r = Reverb::new(RATE);
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
while at < frames {
|
||||
let n = 512.min(frames - at);
|
||||
let mut input = vec![[0.0f32; SLOT_COUNT]; n];
|
||||
if at == 0 {
|
||||
input[0][slot] = 1.0;
|
||||
}
|
||||
let mut data = vec![0.0f32; n * 2];
|
||||
r.process(&input, &mut data, 2);
|
||||
out.extend(data.chunks(2).map(|f| f[0]));
|
||||
at += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
/// RT60 by Schroeder's backward integration (the time the energy decay curve takes from -5 to -35 dB, doubled).
|
||||
pub(super) fn rt60(x: &[f32]) -> f32 {
|
||||
let mut edc = vec![0.0f64; x.len()];
|
||||
let mut acc = 0.0f64;
|
||||
for i in (0..x.len()).rev() {
|
||||
acc += f64::from(x[i]).powi(2);
|
||||
edc[i] = acc;
|
||||
}
|
||||
let db = |i: usize| 10.0 * (edc[i] / edc[0]).max(1e-30).log10();
|
||||
let a = (0..x.len()).find(|&i| db(i) <= -5.0).unwrap();
|
||||
let b = (0..x.len()).find(|&i| db(i) <= -35.0).unwrap();
|
||||
2.0 * (b - a) as f32 / RATE as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_slot_decays_at_its_time() {
|
||||
for (i, p) in SLOTS.iter().enumerate() {
|
||||
let ir = impulse(i, p.decay * 2.5 + 1.0);
|
||||
let t = rt60(&ir);
|
||||
// The highs die sooner than the lows; the broadband decay lies below the nominal one.
|
||||
assert!(t > p.decay * 0.5 && t < p.decay * 1.15, "slot {i}: {t} s for {}", p.decay);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tail_is_stable_finite_and_silent_after_it_ends() {
|
||||
let mut r = Reverb::new(RATE);
|
||||
let mut energy = Vec::new();
|
||||
for b in 0..(RATE as usize * 12 / 512) {
|
||||
let mut input = vec![[0.0f32; SLOT_COUNT]; 512];
|
||||
if b == 0 {
|
||||
for f in input.iter_mut().take(64) {
|
||||
*f = [1.0; SLOT_COUNT];
|
||||
}
|
||||
}
|
||||
let mut data = vec![0.0f32; 1024];
|
||||
r.process(&input, &mut data, 2);
|
||||
assert!(data.iter().all(|s| s.is_finite() && s.abs() < 2.0));
|
||||
energy.push(data.iter().map(|s| s * s).sum::<f32>());
|
||||
}
|
||||
// The energy of a buffer never rises after the input ends, by more than the echo of a comb.
|
||||
let peak = energy.iter().cloned().fold(0.0, f32::max);
|
||||
assert!(energy.last().copied().unwrap() == 0.0 && r.idle(), "ten seconds after 4.1 s: {:?}", energy.last());
|
||||
assert!(energy[energy.len() / 2] < peak * 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_reverb_adds_nothing_and_costs_no_slot() {
|
||||
let mut r = Reverb::new(RATE);
|
||||
let mut data = vec![0.25f32; 1024];
|
||||
r.process(&[], &mut data, 2);
|
||||
assert!(data.iter().all(|s| *s == 0.25) && r.idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_ears_hear_different_tails_and_mono_is_their_average() {
|
||||
let mut stereo = Reverb::new(RATE);
|
||||
let mut mono = Reverb::new(RATE);
|
||||
let mut input = vec![[0.0f32; SLOT_COUNT]; 4096];
|
||||
input[0] = [0.0, 0.0, 1.0, 0.0];
|
||||
let (mut s, mut m) = (vec![0.0f32; 8192], vec![0.0f32; 4096]);
|
||||
stereo.process(&input, &mut s, 2);
|
||||
mono.process(&input, &mut m, 1);
|
||||
let diff = s.chunks(2).filter(|f| (f[0] - f[1]).abs() > 1e-6).count();
|
||||
assert!(diff > 100, "decorrelated: {diff}");
|
||||
for (f, v) in s.chunks(2).zip(&m) {
|
||||
assert!((0.5 * (f[0] + f[1]) - v).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_slides_and_a_gone_send_is_done() {
|
||||
let mut s = Send::new([0.0, 0.0, 1.0, 0.0]);
|
||||
let mut last = 0.0;
|
||||
for _ in 0..80 {
|
||||
let (a, b) = s.buffer(0.0107);
|
||||
assert!(a[2] >= last && b[2] - a[2] < 0.05, "no step: {a:?} {b:?}");
|
||||
last = b[2];
|
||||
}
|
||||
assert!(last > 0.8 && !s.done());
|
||||
s.to = [0.0; 4];
|
||||
for _ in 0..400 {
|
||||
s.buffer(0.0107);
|
||||
}
|
||||
assert!(s.done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_is_deterministic() {
|
||||
assert_eq!(impulse(2, 1.0), impulse(2, 1.0));
|
||||
}
|
||||
|
||||
/// The cost of the four slots in release when all of them ring: `cargo test -p horizon-client
|
||||
/// --lib --release audio::reverb::tests::bench -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_four_busy_slots() {
|
||||
let mut r = Reverb::new(RATE);
|
||||
let input = vec![[0.3f32; SLOT_COUNT]; 512];
|
||||
let mut data = vec![0.0f32; 1024];
|
||||
for _ in 0..50 {
|
||||
r.process(&input, &mut data, 2);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..2000 {
|
||||
r.process(&input, &mut data, 2);
|
||||
}
|
||||
println!("reverb, 4 busy slots, 512 frames: {:.4} ms", start.elapsed().as_secs_f64() * 1e3 / 2000.0);
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,8 @@ mod mod_browser_recipes;
|
||||
mod mod_input;
|
||||
mod mod_models;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_canvas;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_recipes;
|
||||
mod mod_render;
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -13203,7 +13205,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.stats_click(size);
|
||||
}
|
||||
Screen::Stats if button == MouseButton::Left => self.stats_release(),
|
||||
Screen::Mod if pressed => self.mod_screen_click(button),
|
||||
Screen::Mod => self.mod_screen_mouse(button, pressed),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Capability `render`'s canvas (AirDef fork, API level 24): a full-screen surface the mod
|
||||
//! draws with shapes and drives with the pointer and keys, and the terrain tiles it shows
|
||||
//! (`TerrainShape`), scanned from the loaded chunks within a time budget and composed into
|
||||
//! atlas slots only when a tile or the mod's choice of tile changes.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::{
|
||||
font::Patch,
|
||||
hud::HudBatch,
|
||||
minimap::{self, Minimap},
|
||||
};
|
||||
use horizon_mods::{CanvasButton, CanvasEvent, CanvasScroll, RenderPoint, TerrainShape};
|
||||
use std::time::Duration;
|
||||
use winit::event::MouseButton;
|
||||
|
||||
/// Terrain tiles the game keeps at once (atlas slots `horizon/mapview{i}`).
|
||||
pub(crate) const SLOTS: usize = 24;
|
||||
/// CPU time the chunk scans may take a frame.
|
||||
const SCAN_BUDGET: Duration = Duration::from_micros(1500);
|
||||
/// Tiles composed and uploaded a frame at most.
|
||||
const COMPOSE_PER_FRAME: usize = 6;
|
||||
|
||||
pub(crate) fn slot_name(i: usize) -> String {
|
||||
format!("horizon/mapview{i}")
|
||||
}
|
||||
|
||||
type Key = (i32, i32, u32);
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Slot {
|
||||
want: Option<Key>,
|
||||
shown: Option<Key>,
|
||||
version: u64,
|
||||
stamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct CanvasState {
|
||||
map: Minimap,
|
||||
dim: Option<Dim>,
|
||||
slots: Vec<Slot>,
|
||||
frame: u64,
|
||||
cursor: Option<(f32, f32)>,
|
||||
size: Option<(f32, f32)>,
|
||||
scratch: Vec<u8>,
|
||||
}
|
||||
|
||||
fn button_index(b: MouseButton) -> u32 {
|
||||
match b {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Back => 3,
|
||||
MouseButton::Forward => 4,
|
||||
MouseButton::Other(n) => 5 + u32::from(n),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether a mod's canvas is the screen.
|
||||
pub(super) fn canvas_up(&self) -> bool {
|
||||
self.screen == Screen::Mod && self.mods.screen.as_ref().is_some_and(|m| m.canvas)
|
||||
}
|
||||
fn canvas_event(&mut self, event: CanvasEvent) {
|
||||
if let Some(ms) = self.mods.screen.as_ref().filter(|m| m.canvas) {
|
||||
self.mods.mods.canvas_push(ms.owner, event);
|
||||
}
|
||||
}
|
||||
pub(super) fn canvas_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
let at = self.cursor;
|
||||
let b = CanvasButton {
|
||||
button: button_index(button),
|
||||
at: RenderPoint { x: at.0, y: at.1 },
|
||||
};
|
||||
self.canvas_event(if pressed { CanvasEvent::Pressed(b) } else { CanvasEvent::Released(b) });
|
||||
}
|
||||
pub(super) fn canvas_scroll(&mut self, steps: f32) {
|
||||
let at = self.cursor;
|
||||
self.canvas_event(CanvasEvent::Scrolled(CanvasScroll {
|
||||
steps,
|
||||
at: RenderPoint { x: at.0, y: at.1 },
|
||||
}));
|
||||
}
|
||||
pub(super) fn canvas_key(&mut self, key: winit::keyboard::KeyCode) {
|
||||
if let Some(name) = horizon_client::keybinds::key_name(key) {
|
||||
self.canvas_event(CanvasEvent::Key(name.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a frame, after the mods' frame: opens or closes the canvas as the mod asks, tells it
|
||||
/// of the pointer and the size, and keeps its terrain tiles.
|
||||
pub(super) fn canvas_frame(&mut self) {
|
||||
let wanted = self.mods.mods.canvas_wanted();
|
||||
let up = self.canvas_up();
|
||||
let owner = self.mods.screen.as_ref().filter(|m| m.canvas).map(|m| m.owner);
|
||||
match (wanted, owner) {
|
||||
(Some(i), None) => {
|
||||
if self.screen == Screen::Playing && self.player.is_some() && self.title.is_none() {
|
||||
self.open_canvas_screen(i);
|
||||
} else {
|
||||
// Not now: the mod hears that it is not open.
|
||||
self.mods.mods.canvas_closed(i);
|
||||
}
|
||||
}
|
||||
(None, Some(_)) => self.close_mod_screen(false),
|
||||
(Some(a), Some(b)) if a != b => self.close_mod_screen(false),
|
||||
_ => {}
|
||||
}
|
||||
if !up && !self.canvas_up() {
|
||||
self.mods.canvas.cursor = None;
|
||||
self.mods.canvas.size = None;
|
||||
return;
|
||||
}
|
||||
if self.player.is_none() {
|
||||
if let Some(i) = wanted {
|
||||
self.mods.mods.canvas_closed(i);
|
||||
}
|
||||
self.close_mod_screen(false);
|
||||
return;
|
||||
}
|
||||
let Some(owner) = self.mods.screen.as_ref().filter(|m| m.canvas).map(|m| m.owner) else {
|
||||
return;
|
||||
};
|
||||
let size = self.gui_size();
|
||||
if self.mods.canvas.size != Some(size) {
|
||||
self.mods.canvas.size = Some(size);
|
||||
self.canvas_event(CanvasEvent::Resized(size));
|
||||
}
|
||||
if self.mods.canvas.cursor != Some(self.cursor) {
|
||||
self.mods.canvas.cursor = Some(self.cursor);
|
||||
let c = self.cursor;
|
||||
self.canvas_event(CanvasEvent::Moved(RenderPoint { x: c.0, y: c.1 }));
|
||||
}
|
||||
self.canvas_terrain(owner);
|
||||
}
|
||||
|
||||
fn open_canvas_screen(&mut self, owner: usize) {
|
||||
self.mods.screen = Some(super::mods::ModScreen::canvas(owner, self.screen));
|
||||
self.screen = Screen::Mod;
|
||||
self.mods.canvas.cursor = None;
|
||||
self.mods.canvas.size = None;
|
||||
if self.player.is_some() {
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tiles the canvas's shapes name: slots, chunk scans, composing.
|
||||
fn canvas_terrain(&mut self, owner: usize) {
|
||||
let mut keys: Vec<Key> = Vec::new();
|
||||
for shape in self.mods.mods.canvas_shapes(owner) {
|
||||
if let horizon_mods::Shape::Terrain(t) = shape {
|
||||
let k = (t.x, t.z, t.blocks_per_texel);
|
||||
if !keys.contains(&k) && keys.len() < SLOTS {
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = &mut self.mods.canvas;
|
||||
if c.dim != Some(self.dim) {
|
||||
c.map = Minimap::default();
|
||||
c.slots.clear();
|
||||
c.dim = Some(self.dim);
|
||||
}
|
||||
c.slots.resize(SLOTS, Slot::default());
|
||||
c.frame += 1;
|
||||
let frame = c.frame;
|
||||
// A tile already in a slot stays; a new one takes a free slot or the longest unused.
|
||||
for k in &keys {
|
||||
if let Some(s) = c.slots.iter_mut().find(|s| s.want == Some(*k)) {
|
||||
s.stamp = frame;
|
||||
continue;
|
||||
}
|
||||
let pick = (0..SLOTS)
|
||||
.filter(|&i| c.slots[i].want.is_none_or(|w| !keys.contains(&w)))
|
||||
.min_by_key(|&i| (c.slots[i].want.is_some(), c.slots[i].stamp));
|
||||
if let Some(i) = pick {
|
||||
c.slots[i] = Slot {
|
||||
want: Some(*k),
|
||||
shown: None,
|
||||
version: 0,
|
||||
stamp: frame,
|
||||
};
|
||||
}
|
||||
}
|
||||
for s in &mut c.slots {
|
||||
if s.want.is_some_and(|w| !keys.contains(&w)) {
|
||||
s.want = None;
|
||||
}
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The chunks the tiles cover, nearest the middle of the view first.
|
||||
let regions: Vec<(i32, i32, i32, i32)> = keys
|
||||
.iter()
|
||||
.map(|&(x, z, b)| {
|
||||
let span = 128 * b as i32;
|
||||
(x >> 4, z >> 4, (x + span - 1) >> 4, (z + span - 1) >> 4)
|
||||
})
|
||||
.collect();
|
||||
let near = {
|
||||
let n = keys.len() as f64;
|
||||
let (sx, sz) = keys.iter().fold((0.0, 0.0), |a, &(x, z, b)| {
|
||||
(a.0 + f64::from(x) + 64.0 * f64::from(b), a.1 + f64::from(z) + 64.0 * f64::from(b))
|
||||
});
|
||||
(sx / n, sz / n)
|
||||
};
|
||||
c.map.scan_loaded(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
|(cx, cz)| regions.iter().any(|r| cx >= r.0 && cx <= r.2 && cz >= r.1 && cz <= r.3),
|
||||
near,
|
||||
SCAN_BUDGET,
|
||||
);
|
||||
let Some(first) = self.gui.atlas.sprites.get(&slot_name(0)).copied() else {
|
||||
return;
|
||||
};
|
||||
let _ = first;
|
||||
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
|
||||
let version = c.map.version();
|
||||
let mut done = 0;
|
||||
for i in 0..SLOTS {
|
||||
let s = c.slots[i];
|
||||
let Some(k) = s.want else { continue };
|
||||
if s.shown == Some(k) && s.version == version {
|
||||
continue;
|
||||
}
|
||||
if done == COMPOSE_PER_FRAME {
|
||||
break;
|
||||
}
|
||||
let Some(rect) = self.gui.atlas.sprites.get(&slot_name(i)).copied() else {
|
||||
continue;
|
||||
};
|
||||
let mut out = std::mem::take(&mut c.scratch);
|
||||
c.map.compose_terrain(k.0, k.1, k.2 as i32, &mut out);
|
||||
self.map_patches.push(Patch {
|
||||
x: (rect.u0 * aw).round() as u32,
|
||||
y: (rect.v0 * ah).round() as u32,
|
||||
width: minimap::SIZE as u32,
|
||||
height: minimap::SIZE as u32,
|
||||
rgba: out.clone(),
|
||||
});
|
||||
c.scratch = out;
|
||||
c.slots[i].shown = Some(k);
|
||||
c.slots[i].version = version;
|
||||
done += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// A terrain tile of the canvas, when its slot holds it.
|
||||
pub(super) fn draw_terrain(&self, batch: &mut HudBatch, t: &TerrainShape, ox: f32, oy: f32) {
|
||||
let key = (t.x, t.z, t.blocks_per_texel);
|
||||
let Some(i) = self
|
||||
.mods
|
||||
.canvas
|
||||
.slots
|
||||
.iter()
|
||||
.position(|s| s.want == Some(key) && s.shown == Some(key))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(r) = self.gui.atlas.sprites.get(&slot_name(i)).copied() {
|
||||
batch.rect_uv(ox + t.rect.x, oy + t.rect.y, t.rect.w, t.rect.h, r, u32::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas: a dark ground under the mod's shapes.
|
||||
pub(super) fn draw_canvas(&self, batch: &mut HudBatch, owner: usize) {
|
||||
batch.fill(&self.gui.atlas, 0.0, 0.0, batch.width, batch.height, 0xFF0A100C);
|
||||
self.draw_shapes(batch, self.mods.mods.canvas_shapes(owner), (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,12 @@ impl Game<'_> {
|
||||
/// screen) is its `key-down` / `key-up`. Vanilla's mapping on the same
|
||||
/// key still runs.
|
||||
pub(super) fn mod_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if self.canvas_up() {
|
||||
if pressed {
|
||||
self.canvas_key(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if repeat || !self.mods.mods.has_keys() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ struct Jolt {
|
||||
|
||||
/// The HUD batch's colour (ABGR) from a mod's 0xAARRGGBB.
|
||||
#[cfg(feature = "mods")]
|
||||
fn argb(color: u32) -> u32 {
|
||||
pub(super) fn argb(color: u32) -> u32 {
|
||||
let [a, r, g, b] = color.to_be_bytes();
|
||||
rgba(r, g, b, a)
|
||||
}
|
||||
@@ -129,6 +129,7 @@ impl Game<'_> {
|
||||
mode: v.mode,
|
||||
});
|
||||
}
|
||||
Shape::Terrain(t) => self.draw_terrain(batch, t, ox, oy),
|
||||
Shape::Text(t) => {
|
||||
let text = Text::literal(t.text.clone());
|
||||
let centre = if t.centered {
|
||||
|
||||
@@ -108,7 +108,10 @@ pub(super) struct ModsState {
|
||||
remote_tree: Option<Dispatcher>,
|
||||
/// A `ui` mod's screen or settings page while it is up.
|
||||
#[cfg(feature = "mods")]
|
||||
screen: Option<ModScreen>,
|
||||
pub(super) screen: Option<ModScreen>,
|
||||
/// Capability `render`'s canvas: terrain tiles and pointer state.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) canvas: super::mod_canvas::CanvasState,
|
||||
/// The HUD font's advances for `render.text-width`, made once.
|
||||
#[cfg(feature = "mods")]
|
||||
font_widths: std::sync::Arc<[f32]>,
|
||||
@@ -153,9 +156,9 @@ const FONT_CHARS: u32 = 0x250;
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods: the
|
||||
/// widgets the mod pushed, laid out and drawn with the menus' own widgets.
|
||||
#[cfg(feature = "mods")]
|
||||
struct ModScreen {
|
||||
pub(super) struct ModScreen {
|
||||
/// The running mod's index in `mods.mods.entries()`.
|
||||
owner: usize,
|
||||
pub(super) owner: usize,
|
||||
/// Opened from Options > Mods: Done and Esc return there.
|
||||
settings: bool,
|
||||
/// What the mod's screen replaced (Playing, Chat, ...), to restore.
|
||||
@@ -164,6 +167,21 @@ struct ModScreen {
|
||||
scrolls: Vec<ui::Scroll>,
|
||||
/// A key row waiting for the key to bind: the mod key's name.
|
||||
binding: Option<String>,
|
||||
/// The mod's canvas (`render.open-canvas`), not widgets.
|
||||
pub(super) canvas: bool,
|
||||
}
|
||||
#[cfg(feature = "mods")]
|
||||
impl ModScreen {
|
||||
pub(super) fn canvas(owner: usize, under: Screen) -> Self {
|
||||
Self {
|
||||
owner,
|
||||
settings: false,
|
||||
under,
|
||||
scrolls: Vec::new(),
|
||||
binding: None,
|
||||
canvas: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ModsState {
|
||||
/// The chat's command tree: vanilla's, plus the running mods' commands.
|
||||
@@ -366,6 +384,7 @@ impl Game<'_> {
|
||||
pub(super) fn close_mod_screen(&mut self, _tell: bool) {}
|
||||
pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {}
|
||||
pub(super) fn mod_screen_click(&mut self, _button: winit::event::MouseButton) {}
|
||||
pub(super) fn mod_screen_mouse(&mut self, _button: winit::event::MouseButton, _pressed: bool) {}
|
||||
pub(super) fn mod_screen_key(&mut self, _key: winit::keyboard::KeyCode) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -899,6 +918,7 @@ impl Game<'_> {
|
||||
self.mods.mods.set_source_generation(self.audio.mod_sources.epoch);
|
||||
self.mods.mods.set_acoustics(self.audio.acoustics.is_some());
|
||||
self.mods.mods.frame(elapsed.as_secs_f32());
|
||||
self.canvas_frame();
|
||||
self.show_mod_output();
|
||||
self.sync_mod_sources();
|
||||
}
|
||||
@@ -1065,6 +1085,10 @@ impl Game<'_> {
|
||||
self.audio.film_allow(false);
|
||||
continue;
|
||||
}
|
||||
Output::SetAcoustics { on } => {
|
||||
self.audio.set_acoustics(on);
|
||||
continue;
|
||||
}
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
self.mod_place_recipe(book_id, all);
|
||||
continue;
|
||||
@@ -1215,6 +1239,10 @@ impl Game<'_> {
|
||||
/// A key while a mod screen is up: true when a key row waited for
|
||||
/// it (Key Binds: Escape binds none).
|
||||
pub(super) fn mod_screen_key(&mut self, key: KeyCode) -> bool {
|
||||
// A canvas has every key for itself (`mod_key` passes them on); Escape closes nothing.
|
||||
if self.mods.screen.as_ref().is_some_and(|ms| ms.canvas) {
|
||||
return true;
|
||||
}
|
||||
if self
|
||||
.mods
|
||||
.screen
|
||||
@@ -1270,6 +1298,7 @@ impl Game<'_> {
|
||||
under: self.screen,
|
||||
scrolls: Vec::new(),
|
||||
binding: None,
|
||||
canvas: false,
|
||||
});
|
||||
self.screen = Screen::Mod;
|
||||
if self.player.is_some() {
|
||||
@@ -1299,6 +1328,11 @@ impl Game<'_> {
|
||||
/// The mod's screen or settings page: its title over widgets stacked
|
||||
/// down the middle, a Done button, Esc to leave (vanilla's menus).
|
||||
pub(super) fn draw_mod_screen(&mut self, batch: &mut HudBatch) {
|
||||
if let Some(ms) = self.mods.screen.as_ref().filter(|m| m.canvas) {
|
||||
let owner = ms.owner;
|
||||
self.draw_canvas(batch, owner);
|
||||
return;
|
||||
}
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
// The screen was dropped while it was up: back to the world,
|
||||
// mouse held again like any return to Playing.
|
||||
@@ -1471,6 +1505,13 @@ impl Game<'_> {
|
||||
}
|
||||
/// A click on the mod screen: Done leaves it, a `list` row is the mod's
|
||||
/// `ui-events::select`.
|
||||
pub(super) fn mod_screen_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
if self.canvas_up() {
|
||||
self.canvas_mouse(button, pressed);
|
||||
} else if pressed {
|
||||
self.mod_screen_click(button);
|
||||
}
|
||||
}
|
||||
pub(super) fn mod_screen_click(&mut self, button: MouseButton) {
|
||||
// Key Binds: a mouse button pressed while waiting binds it.
|
||||
if self
|
||||
@@ -1550,6 +1591,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// The wheel over a `list` scrolls it.
|
||||
pub(super) fn mod_screen_scroll(&mut self, delta: f32) {
|
||||
if self.canvas_up() {
|
||||
self.canvas_scroll(delta);
|
||||
return;
|
||||
}
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
|
||||
@@ -300,6 +300,10 @@ impl HudAtlas {
|
||||
crate::minimap::SIZE as u32,
|
||||
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
|
||||
));
|
||||
// A mod's terrain tiles (game/mod_canvas.rs).
|
||||
for i in 0..24 {
|
||||
images.push((format!("horizon/mapview{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
}
|
||||
// Downloaded skins (game/scoreboard.rs).
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
|
||||
@@ -262,6 +262,8 @@ pub struct Minimap {
|
||||
composed: Option<View>,
|
||||
/// Tiles changed since the image was composed.
|
||||
changed: bool,
|
||||
/// Moves on when a scanned tile changed (a mod's terrain tiles).
|
||||
version: u64,
|
||||
/// The composed image, `SIZE` x `SIZE` RGBA.
|
||||
pub image: Vec<u8>,
|
||||
}
|
||||
@@ -389,7 +391,109 @@ impl Minimap {
|
||||
pub fn tile_count(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
/// A mod's terrain tiles (AirDef fork): scan the loaded chunks for which
|
||||
/// `wanted` is true that have no tile or a stale one, nearest `near`
|
||||
/// first, until `budget` is spent; tiles of chunks nobody wants are kept
|
||||
/// up to a bound. Returns the tiles scanned; [`Minimap::version`] moves
|
||||
/// on when any tile's content changed.
|
||||
pub fn scan_loaded(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
wanted: impl Fn((i32, i32)) -> bool,
|
||||
near: (f64, f64),
|
||||
budget: Duration,
|
||||
) -> usize {
|
||||
let palette = self.palette.get_or_insert_with(|| Palette::new(facts));
|
||||
let started = Instant::now();
|
||||
let air = world.air.0;
|
||||
if self.tiles.len() > KEPT_TILES {
|
||||
self.tiles.retain(|k, _| wanted(*k) && world.chunks.contains_key(k));
|
||||
self.version += 1;
|
||||
}
|
||||
let mut stale: Vec<(f64, (i32, i32))> = Vec::new();
|
||||
for key in world.chunks.keys().copied().filter(|k| wanted(*k)) {
|
||||
let due = match self.tiles.get(&key) {
|
||||
None => true,
|
||||
Some(t) if t.layer != Layer::Surface => true,
|
||||
Some(t) => t.revision != world.revision && t.scanned.elapsed() >= RESCAN,
|
||||
};
|
||||
if due {
|
||||
let (dx, dz) = (f64::from(key.0 * 16 + 8) - near.0, f64::from(key.1 * 16 + 8) - near.1);
|
||||
stale.push((dx * dx + dz * dz, key));
|
||||
}
|
||||
}
|
||||
stale.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut scanned = 0;
|
||||
for (_, key) in stale {
|
||||
let Some(chunk) = world.chunks.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
let tile = scan_tile(chunk, palette, air, Layer::Surface, world.revision);
|
||||
if self
|
||||
.tiles
|
||||
.get(&key)
|
||||
.is_none_or(|t| t.color != tile.color || t.height != tile.height || t.depth != tile.depth)
|
||||
{
|
||||
self.version += 1;
|
||||
}
|
||||
self.tiles.insert(key, tile);
|
||||
scanned += 1;
|
||||
if started.elapsed() >= budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
scanned
|
||||
}
|
||||
/// Moves on whenever a tile was added or changed.
|
||||
pub fn version(&self) -> u64 {
|
||||
self.version
|
||||
}
|
||||
/// One 128 x 128 RGBA tile of the map for a mod (AirDef fork): texel
|
||||
/// (i, j) shows the column (`x + i * step`, `z + j * step`) with the map
|
||||
/// item's colours and height shading, transparent where its chunk has no
|
||||
/// tile. Returns the texels that are known.
|
||||
pub fn compose_terrain(&self, x: i32, z: i32, step: i32, out: &mut Vec<u8>) -> usize {
|
||||
out.clear();
|
||||
out.resize(SIZE * SIZE * 4, 0);
|
||||
let mut known = 0;
|
||||
let mut cached: Option<((i32, i32), &Tile)> = None;
|
||||
let mut column = |x: i32, z: i32| -> Option<(u8, i16, u8)> {
|
||||
let key = (x >> 4, z >> 4);
|
||||
let tile = match cached {
|
||||
Some((k, t)) if k == key => t,
|
||||
_ => {
|
||||
let t = self.tiles.get(&key)?;
|
||||
cached = Some((key, t));
|
||||
t
|
||||
}
|
||||
};
|
||||
let i = ((z & 15) * 16 + (x & 15)) as usize;
|
||||
Some((tile.color[i], tile.height[i], tile.depth[i]))
|
||||
};
|
||||
let shade = f64::from(step.max(1)).sqrt();
|
||||
for j in 0..SIZE as i32 {
|
||||
for i in 0..SIZE as i32 {
|
||||
let (wx, wz) = (x.wrapping_add(i * step), z.wrapping_add(j * step));
|
||||
let Some((color, height, depth)) = column(wx, wz).filter(|c| c.0 & !WALL != 0) else {
|
||||
continue;
|
||||
};
|
||||
// The neighbour to the north, a texel away; its height difference is eased by the
|
||||
// step so a coarse texel isn't all ridge.
|
||||
let north = column(wx, wz - step.max(1))
|
||||
.filter(|c| c.0 & !WALL != 0)
|
||||
.map(|c| (f64::from(height) - f64::from(height - c.1) / shade).round() as i16);
|
||||
let b = brightness(color, height, north, depth, wx + wz);
|
||||
let o = (j as usize * SIZE + i as usize) * 4;
|
||||
out[o..o + 4].copy_from_slice(&packed_rgba(color & !WALL, b));
|
||||
known += 1;
|
||||
}
|
||||
}
|
||||
known
|
||||
}
|
||||
}
|
||||
/// Tiles a mod's terrain keeps beyond the ones it wants.
|
||||
const KEPT_TILES: usize = 4096;
|
||||
/// Chunk offsets at Chebyshev distance `ring`.
|
||||
fn ring_offsets(ring: i32) -> impl Iterator<Item = (i32, i32)> {
|
||||
(-ring..=ring).flat_map(move |dz| {
|
||||
|
||||
@@ -423,6 +423,23 @@ interface render {
|
||||
/// 0 as seen, 1 thermal (white hot), 2 night vision (green).
|
||||
mode: u32,
|
||||
}
|
||||
/// A square of the world's land seen from above, in the map colours of
|
||||
/// a map item with its height shading (AirDef fork, API level 24); only
|
||||
/// in `set-canvas`'s shapes. It is 128 × 128 texels, a texel being
|
||||
/// `blocks-per-texel` × `blocks-per-texel` blocks whose north-west
|
||||
/// corner is (`x`, `z`); the game keeps up to 24 such tiles at a time
|
||||
/// and draws none of the rest. A mod aligns the corners to multiples of
|
||||
/// 128 × `blocks-per-texel` so tiles meet and are reused as the view
|
||||
/// moves. Where a chunk isn't loaded the tile is transparent (the mod
|
||||
/// draws its own "unknown" under it); the game scans chunks within a
|
||||
/// small time budget a frame, so a tile fills in over a few frames.
|
||||
record terrain-shape {
|
||||
rect: rect,
|
||||
x: s32,
|
||||
z: s32,
|
||||
/// 1, 2, 4, 8, 16, 32 or 64; another value is rounded to one.
|
||||
blocks-per-texel: u32,
|
||||
}
|
||||
variant shape {
|
||||
fill(fill-shape),
|
||||
quad(quad-shape),
|
||||
@@ -430,6 +447,7 @@ interface render {
|
||||
item(item-shape),
|
||||
text(text-shape),
|
||||
view(view-shape),
|
||||
terrain(terrain-shape),
|
||||
}
|
||||
/// Replaces the mod's HUD shapes: up to 1024, drawn every frame in
|
||||
/// order over the HUD (after the hotbar, under chat and screens), hidden
|
||||
@@ -667,6 +685,10 @@ interface render {
|
||||
/// muffles by itself (a `_far` variant for a distant blast) plays the near
|
||||
/// one instead when this is true, so nothing is muffled twice.
|
||||
acoustics: func() -> bool;
|
||||
/// Turns that on or off (AirDef fork, API level 23), from the next tick:
|
||||
/// what `HORIZON_ACOUSTICS` sets at the start. Sounds that play go back
|
||||
/// to the plain ones smoothly.
|
||||
set-acoustics: func(on: 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
|
||||
@@ -692,6 +714,49 @@ interface render {
|
||||
/// 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 the player did on the mod's canvas (AirDef fork, API level 24).
|
||||
/// Points are in GUI pixels from the screen's top left.
|
||||
record canvas-button {
|
||||
/// 0 left, 1 right, 2 middle, 3 and up the side buttons.
|
||||
button: u32,
|
||||
at: point,
|
||||
}
|
||||
record canvas-scroll {
|
||||
/// Wheel steps, positive away from the player (up).
|
||||
steps: f32,
|
||||
at: point,
|
||||
}
|
||||
variant canvas-event {
|
||||
/// The pointer moved (one event a frame at most).
|
||||
moved(point),
|
||||
pressed(canvas-button),
|
||||
released(canvas-button),
|
||||
scrolled(canvas-scroll),
|
||||
/// A key went down or repeated: `key.keyboard.escape`, as
|
||||
/// options.txt writes it. Escape does not close the canvas by
|
||||
/// itself: the mod does, with `close-canvas`.
|
||||
key(string),
|
||||
/// The screen's size changed, or the canvas opened: width, height.
|
||||
resized(tuple<f32, f32>),
|
||||
}
|
||||
/// Shows a full-screen canvas over the game (AirDef fork, API level 24):
|
||||
/// the mod's `set-canvas` shapes over a dark background, with the
|
||||
/// pointer free. The world goes on running (no pause), the mouse is
|
||||
/// released, and every key and button goes to the mod as
|
||||
/// `canvas-events`, none to the game. Needs nothing the mod can't do
|
||||
/// without: when the mod stops the canvas closes. Not on the title
|
||||
/// screen.
|
||||
open-canvas: func();
|
||||
/// Closes the canvas when it is open.
|
||||
close-canvas: func();
|
||||
/// Replaces the canvas's shapes: up to 8192, drawn in order every frame
|
||||
/// until the next call.
|
||||
set-canvas: func(shapes: list<shape>);
|
||||
/// The events since the last call, oldest first, at most 256.
|
||||
canvas-events: func() -> list<canvas-event>;
|
||||
/// Whether the canvas is up (the game never closes it but at the mod's
|
||||
/// word, or when the world is left).
|
||||
canvas-open: func() -> bool;
|
||||
/// 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.
|
||||
|
||||
@@ -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 = 22;
|
||||
pub const LEVEL: u32 = 24;
|
||||
|
||||
/// 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); 84] = [
|
||||
pub const FUNCTIONS: [(&str, u32); 90] = [
|
||||
("log.write", 0),
|
||||
("game.get-player", 0),
|
||||
("game.get-clock", 0),
|
||||
@@ -110,6 +110,14 @@ pub const FUNCTIONS: [(&str, u32); 84] = [
|
||||
("render.acoustics", 21),
|
||||
// AirDef fork: the game loads chunks for a mod for a while.
|
||||
("render.load-chunks", 22),
|
||||
// AirDef fork: a mod switches the game's acoustics.
|
||||
("render.set-acoustics", 23),
|
||||
// AirDef fork: a full-screen canvas with the pointer, and terrain tiles for it.
|
||||
("render.open-canvas", 24),
|
||||
("render.close-canvas", 24),
|
||||
("render.set-canvas", 24),
|
||||
("render.canvas-events", 24),
|
||||
("render.canvas-open", 24),
|
||||
];
|
||||
|
||||
/// What the game's program carries so the launcher can read its level
|
||||
|
||||
@@ -89,6 +89,7 @@ pub use render_bindings::horizon::mods::render::{
|
||||
RayAnswer as WorldRayAnswer, Rect as RenderRect, Section as WorldSection, SectionFill, Shape,
|
||||
SmokeSegment, SourceOp, SourceOpKind, SpotLight, SpriteShape, TextShape, Verdict as WorldVerdict, ViewShape,
|
||||
Zoom as ModZoom,
|
||||
CanvasButton, CanvasEvent, CanvasScroll, TerrainShape,
|
||||
};
|
||||
use render_bindings::{RenderHost, horizon::mods::render};
|
||||
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
|
||||
@@ -221,6 +222,8 @@ impl Runs {
|
||||
pub enum Output {
|
||||
/// `chat.show`: a line after the mod's name.
|
||||
Chat { name: String, text: String },
|
||||
/// `render.set-acoustics`: the game's acoustics on or off.
|
||||
SetAcoustics { on: bool },
|
||||
/// A mod command's feedback, red when `error`.
|
||||
Feedback { text: String, error: bool },
|
||||
/// `command.run`: a vanilla command line, without its slash, to run as
|
||||
@@ -501,6 +504,8 @@ struct State {
|
||||
shapes: Vec<Shape>,
|
||||
/// Capability `render`'s corner panel.
|
||||
corner: Option<CornerPanel>,
|
||||
/// Capability `render`'s canvas (AirDef fork, API level 24).
|
||||
canvas: CanvasState,
|
||||
/// Capability `render`'s zoom, when the mod zooms.
|
||||
zoom: Option<ModZoom>,
|
||||
/// Capability `render`'s minimap, when the mod shows one.
|
||||
@@ -777,6 +782,7 @@ impl Mods {
|
||||
panel: None,
|
||||
ui: UiState::default(),
|
||||
shapes: Vec::new(),
|
||||
canvas: CanvasState::default(),
|
||||
corner: None,
|
||||
zoom: None,
|
||||
minimap: None,
|
||||
@@ -1360,6 +1366,51 @@ impl Mods {
|
||||
.filter_map(|e| e.run.as_ref()?.store.data().zoom)
|
||||
.max_by(|a, b| a.factor.total_cmp(&b.factor))
|
||||
}
|
||||
/// The mod whose canvas is up (`render.open-canvas`), if any: the first.
|
||||
pub fn canvas_wanted(&self) -> Option<usize> {
|
||||
self.entries
|
||||
.iter()
|
||||
.position(|e| e.run.as_ref().is_some_and(|r| r.store.data().canvas.open))
|
||||
}
|
||||
/// Mod `i`'s canvas shapes.
|
||||
pub fn canvas_shapes(&self, i: usize) -> &[Shape] {
|
||||
self.entries
|
||||
.get(i)
|
||||
.and_then(|e| e.run.as_ref())
|
||||
.map_or(&[], |r| &r.store.data().canvas.shapes)
|
||||
}
|
||||
/// The player did something on mod `i`'s canvas; a pointer move or a
|
||||
/// resize replaces the one still waiting.
|
||||
pub fn canvas_push(&mut self, i: usize, event: CanvasEvent) {
|
||||
let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let c = &mut run.store.data_mut().canvas;
|
||||
if !c.open {
|
||||
return;
|
||||
}
|
||||
let same = |a: &CanvasEvent, b: &CanvasEvent| {
|
||||
matches!((a, b), (CanvasEvent::Moved(_), CanvasEvent::Moved(_)) | (CanvasEvent::Resized(_), CanvasEvent::Resized(_)))
|
||||
};
|
||||
if let Some(last) = c.events.back_mut()
|
||||
&& same(last, &event)
|
||||
{
|
||||
*last = event;
|
||||
return;
|
||||
}
|
||||
if c.events.len() == CANVAS_EVENTS {
|
||||
c.events.pop_front();
|
||||
}
|
||||
c.events.push_back(event);
|
||||
}
|
||||
/// Mod `i`'s canvas was closed by the game (the world was left).
|
||||
pub fn canvas_closed(&mut self, i: usize) {
|
||||
if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) {
|
||||
let c = &mut run.store.data_mut().canvas;
|
||||
c.open = false;
|
||||
c.events.clear();
|
||||
}
|
||||
}
|
||||
/// The minimap the first running mod shows (capability `render`).
|
||||
pub fn minimap(&self) -> Option<ModMinimap> {
|
||||
self.entries
|
||||
@@ -2196,6 +2247,19 @@ impl input::Host for State {
|
||||
|
||||
/// HUD shapes one mod may keep (capability `render`).
|
||||
const SHAPES: usize = 1024;
|
||||
/// Shapes a canvas keeps, and events waiting for `canvas-events`.
|
||||
const CANVAS_SHAPES: usize = 8192;
|
||||
const CANVAS_EVENTS: usize = 256;
|
||||
|
||||
/// A mod's canvas (`render.open-canvas`): whether it is up, what it draws
|
||||
/// and what the player did on it.
|
||||
#[derive(Default)]
|
||||
struct CanvasState {
|
||||
open: bool,
|
||||
shapes: Vec<Shape>,
|
||||
events: std::collections::VecDeque<CanvasEvent>,
|
||||
}
|
||||
|
||||
/// The longest jolt `render.shake` makes, in seconds.
|
||||
const SHAKE_SECONDS: f32 = 10.0;
|
||||
/// `render.set-zoom`'s limits: `Camera.tickFov` keeps the modifier at 0.1
|
||||
@@ -2254,7 +2318,45 @@ fn clip_shapes(shapes: Vec<Shape>) -> Vec<Shape> {
|
||||
|
||||
impl render::Host for State {
|
||||
fn set_hud(&mut self, shapes: Vec<Shape>) {
|
||||
self.shapes = clip_shapes(shapes);
|
||||
self.shapes = clip_shapes(shapes)
|
||||
.into_iter()
|
||||
.filter(|s| !matches!(s, Shape::Terrain(_)))
|
||||
.collect();
|
||||
}
|
||||
fn open_canvas(&mut self) {
|
||||
self.canvas.open = true;
|
||||
}
|
||||
fn close_canvas(&mut self) {
|
||||
self.canvas.open = false;
|
||||
self.canvas.events.clear();
|
||||
}
|
||||
fn set_canvas(&mut self, shapes: Vec<Shape>) {
|
||||
let mut shapes = shapes;
|
||||
shapes.truncate(CANVAS_SHAPES);
|
||||
for shape in &mut shapes {
|
||||
match shape {
|
||||
Shape::Sprite(s) => s.sprite = clip(std::mem::take(&mut s.sprite)),
|
||||
Shape::Item(i) => {
|
||||
i.item = clip(std::mem::take(&mut i.item));
|
||||
i.scale = if i.scale.is_finite() { i.scale.clamp(0.0, 16.0) } else { 1.0 };
|
||||
}
|
||||
Shape::Text(t) => {
|
||||
t.text = clip(std::mem::take(&mut t.text));
|
||||
t.color &= 0xFF_FFFF;
|
||||
}
|
||||
Shape::Terrain(t) => {
|
||||
t.blocks_per_texel = t.blocks_per_texel.clamp(1, 64).next_power_of_two().min(64);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.canvas.shapes = shapes;
|
||||
}
|
||||
fn canvas_events(&mut self) -> Vec<CanvasEvent> {
|
||||
self.canvas.events.drain(..).collect()
|
||||
}
|
||||
fn canvas_open(&mut self) -> bool {
|
||||
self.canvas.open
|
||||
}
|
||||
fn set_corner(&mut self, corner: RenderCorner, width: f32, height: f32, shapes: Vec<Shape>) {
|
||||
let size = |v: f32| {
|
||||
@@ -2717,6 +2819,9 @@ impl render::Host for State {
|
||||
sections: Vec::new(),
|
||||
})
|
||||
}
|
||||
fn set_acoustics(&mut self, on: bool) {
|
||||
self.out.push(Output::SetAcoustics { on });
|
||||
}
|
||||
fn acoustics(&mut self) -> bool {
|
||||
self.model_table.lock().is_ok_and(|t| t.acoustics)
|
||||
}
|
||||
|
||||
@@ -462,6 +462,17 @@ A picture of one of the mod's `set-views` cameras (AirDef fork).
|
||||
| `rect` | `rect` | |
|
||||
| `mode` | `u32` | 0 as seen, 1 thermal (white hot), 2 night vision (green). |
|
||||
|
||||
### record `terrain-shape`
|
||||
|
||||
A square of the world's land seen from above, in the map colours of a map item with its height shading (AirDef fork, API level 24); only in `set-canvas`'s shapes. It is 128 × 128 texels, a texel being `blocks-per-texel` × `blocks-per-texel` blocks whose north-west corner is (`x`, `z`); the game keeps up to 24 such tiles at a time and draws none of the rest. A mod aligns the corners to multiples of 128 × `blocks-per-texel` so tiles meet and are reused as the view moves. Where a chunk isn't loaded the tile is transparent (the mod draws its own "unknown" under it); the game scans chunks within a small time budget a frame, so a tile fills in over a few frames.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `rect` | `rect` | |
|
||||
| `x` | `s32` | |
|
||||
| `z` | `s32` | |
|
||||
| `blocks-per-texel` | `u32` | 1, 2, 4, 8, 16, 32 or 64; another value is rounded to one. |
|
||||
|
||||
### variant `shape`
|
||||
|
||||
| Case | Payload | |
|
||||
@@ -472,6 +483,7 @@ A picture of one of the mod's `set-views` cameras (AirDef fork).
|
||||
| `item` | `item-shape` | |
|
||||
| `text` | `text-shape` | |
|
||||
| `view` | `view-shape` | |
|
||||
| `terrain` | `terrain-shape` | |
|
||||
|
||||
### record `frame-rate`
|
||||
|
||||
@@ -588,6 +600,33 @@ One change of a persistent sound source (AirDef fork, API level 20): a sound tha
|
||||
| `loop-from` | `f32` | A looping source repeats the part of its sound from `loop-from` to `loop-to` seconds into it (both 0: all of it), the join crossfaded over 50 ms: for sounds that fade in and die away at their ends. |
|
||||
| `loop-to` | `f32` | |
|
||||
|
||||
### record `canvas-button`
|
||||
|
||||
What the player did on the mod's canvas (AirDef fork, API level 24). Points are in GUI pixels from the screen's top left.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `button` | `u32` | 0 left, 1 right, 2 middle, 3 and up the side buttons. |
|
||||
| `at` | `point` | |
|
||||
|
||||
### record `canvas-scroll`
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `steps` | `f32` | Wheel steps, positive away from the player (up). |
|
||||
| `at` | `point` | |
|
||||
|
||||
### variant `canvas-event`
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `moved` | `point` | The pointer moved (one event a frame at most). |
|
||||
| `pressed` | `canvas-button` | |
|
||||
| `released` | `canvas-button` | |
|
||||
| `scrolled` | `canvas-scroll` | |
|
||||
| `key` | `string` | A key went down or repeated: `key.keyboard.escape`, as options.txt writes it. Escape does not close the canvas by itself: the mod does, with `close-canvas`. |
|
||||
| `resized` | `tuple<f32, f32>` | The screen's size changed, or the canvas opened: width, height. |
|
||||
|
||||
### enum `policy`
|
||||
|
||||
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.
|
||||
@@ -804,10 +843,16 @@ Where a capture is.
|
||||
| `play-sound` | `func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32)` | Plays a sound event (`minecraft:entity.generic.explode`, `<mod>:<name>`) at a point on this client. |
|
||||
| `sound-source-generation` | `func() -> u32` | Moves on whenever the game released every source of every mod (the world closed, the session ended, a film began or ended, the sounds reloaded). A mod reads it before `sound-sources` and starts its sources again when it changed. |
|
||||
| `acoustics` | `func() -> bool` | Whether the game muffles sounds by what lies between them and the listener (AirDef fork, API level 21: `HORIZON_ACOUSTICS`). A mod that muffles by itself (a `_far` variant for a distant blast) plays the near one instead when this is true, so nothing is muffled twice. |
|
||||
| `set-acoustics` | `func(on: bool)` | Turns that on or off (AirDef fork, API level 23), from the next tick: what `HORIZON_ACOUSTICS` sets at the start. Sounds that play go back to the plain ones smoothly. |
|
||||
| `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; -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. |
|
||||
| `open-canvas` | `func()` | Shows a full-screen canvas over the game (AirDef fork, API level 24): the mod's `set-canvas` shapes over a dark background, with the pointer free. The world goes on running (no pause), the mouse is released, and every key and button goes to the mod as `canvas-events`, none to the game. Needs nothing the mod can't do without: when the mod stops the canvas closes. Not on the title screen. |
|
||||
| `close-canvas` | `func()` | Closes the canvas when it is open. |
|
||||
| `set-canvas` | `func(shapes: list<shape>)` | Replaces the canvas's shapes: up to 8192, drawn in order every frame until the next call. |
|
||||
| `canvas-events` | `func() -> list<canvas-event>` | The events since the last call, oldest first, at most 256. |
|
||||
| `canvas-open` | `func() -> bool` | Whether the canvas is up (the game never closes it but at the mod's word, or when the world is left). |
|
||||
| `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. |
|
||||
|
||||
Reference in New Issue
Block a user