abf3d76fdb
Pairs of the same replay exported with acoustics on and off on the scratch world (no HORIZON_NO_AUDIO): acoustics and capture ownership both ready. The open field has no reverb send by design, so it shows occlusion/air absorption only; the stone court's on-track is quieter and darker, and its residual after a gain fit is low-passed and stereo-decorrelated, i.e. the wet path. A clean reverb tail/RT60 in a real MP4, echoes alone and listening remain open. No code changes. Co-Authored-By: Kimi Code <noreply@moonshot.ai>
550 lines
67 KiB
Markdown
550 lines
67 KiB
Markdown
# Акустика AirDef: реализация, проверки и разбор Sound Physics Remastered (#96)
|
||
|
||
4 ноября добавлен opt-in массовый offline capture fixture: 100 движущихся двигателей и до
|
||
16 импульсных источников, настоящие `Audio::tick_acoustics`/`film_mix`, две одинаковые дорожки,
|
||
сброс хвоста при новом capture. Release на Apple M5: p50 3.55 мс, p95 4.30 мс, max 5.57 мс
|
||
на 50 мс аудио. Геометрия и сэмплы синтетические; это не полный игровой CPU/GPU/FPS и не MP4.
|
||
[Команда, результат и оставшаяся приёмка](CONTINUATION-2026-11-04.md),
|
||
[JSON и SHA дорожки](measurements/mass-acoustics-2026-11-04.json).
|
||
|
||
Сначала приведён текущий статус реализации. Разбор SPR и первоначальный план ниже сохранены как
|
||
история решений; формулировки будущего времени в плане не заменяют текущий статус. При расхождении прав код.
|
||
|
||
- Изучено: https://github.com/henkelmax/sound-physics-remastered, ветка `master`, коммит
|
||
`453654ece086f40c97712d9016baa4dee7c4f6d2` (2026-10-29), лицензия GPL-3.0 (`LICENSE`). Клон лежит только в
|
||
scratchpad, ничего из него не собиралось и не запускалось.
|
||
- Пути ниже даны от `common/src/main/java/com/sonicether/soundphysics/`. Весь расчёт — это `SoundPhysics.java`
|
||
(741 строка) плюс таблицы в `config/`.
|
||
- Коммит из текста #96 (`76f680eb…`) в последних 200 коммитах `master` не нашёлся. Выводы привязаны к
|
||
`453654ec`.
|
||
|
||
## Статус
|
||
|
||
**Стадии 1 (прямой звук), 2 (отводы эха) и 3 (реверб) сделаны**, включается кнопкой мода или `HORIZON_ACOUSTICS=1` (по умолчанию выключено,
|
||
звук как был). Коммит SPR прежний, `453654ec`; атрибуция: `horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md`.
|
||
GPL-файлы три: `acoustics/spr_occlusion.rs`, (стадия 2) `acoustics/spr_bounce.rs` и (стадия 3) `acoustics/spr_reverb.rs`; остальное (движок `acoustics.rs`, `acoustics/room.rs`, склейка `game/acoustics.rs`, DSP в `audio.rs` и `audio/reverb.rs`) наше.
|
||
|
||
Что вышло и чем отличается от плана выше:
|
||
|
||
- Фильтр голоса: не однополюсный lowpass, а **полка** (`audio::Occlusion`): ниже 1 кГц звук проходит с `gain`, выше с
|
||
`gain * cutoff`, два полюса, сглаживание 80 мс на буфер. Однополюсник резал бы и басы толстой стены; нужно «глухо», а не «тихо».
|
||
- Формулы SPR: `cutoff = exp(-occ*3)`, `gain = cutoff^0.1`, 9 путей со сдвигом 0.35 (для 16 самых громких; остальные 1 путь),
|
||
блок источника не считается, неизвестный чанк = воздух, не полный блок x0.25. Луч — обход вокселей (свой), а не clip по формам:
|
||
забор, дверь считаются ячейкой целиком (x0.25). Жидкости пока не глушат.
|
||
- Дальность 512 блоков (SPR: ~64), счёт клеток, не времени: 40 тыс. клеток на тик встроенно (новый звук, 1 путь, чтобы взрыв
|
||
не начинался «ярким»), 400 тыс. на задание рабочего потока (9 путей, одно задание за раз); при съёмке всё встроенно, 600 тыс.
|
||
клеток на тик. Порядок: по громкости, затем по id; сумма голосов в `mix` теперь тоже по id (раньше порядок HashMap делал дорожку
|
||
недетерминированной при нескольких голосах). При выходе за дальность/порог слышимости или потере геометрии микшеру явно
|
||
возвращают обычные `gain=1` и воздушный `cutoff` без старой стены; посылки и эхо отзываются, при возвращении считаются заново.
|
||
- Кеш по ячейкам 2x2x2 источника и слушателя, 4096 записей; сброс по секциям `World.sound_changes` (как `mod_changes`),
|
||
по эпохе мира и при переполнении; результат задания, начатого до изменения в его коробке, отбрасывается. Вставка, замена
|
||
и выгрузка чанка меняют residency-счётчик и инвалидируют затронутые секции и старые worker-задания. Трасса, встретившая
|
||
Unknown, пересчитывается не позднее чем через 40 тиков, поэтому приехавшая геометрия применяется без движения слушателя.
|
||
- Воздух: `0.994^расстояние` на верха (EFX по умолчанию, не проверено по OpenAL Soft) — иначе при `acoustics=on` пропадала бы
|
||
дальность, которую раньше давали `_far`-сэмплы.
|
||
- Мод: API 21, `render.acoustics()`; при включённой акустике мод играет ближние варианты до 480 блоков (`ACOUSTIC_FAR`), дальше
|
||
`_far`. Задержку распространения мод держит сам (не трогали).
|
||
- Реплей: мир, из которого читает акустика, — тот самый `World`, где реплей показывает блоки на нужный момент (журнал
|
||
изменений, `set-world-time-view`); перемотка пишет блоки через `put`/`set_block`, а они попадают в `sound_changes`. Значит
|
||
стена, которой уже нет, в реплее не глушит, а которая была — глушит. Проверено чтением кода, не запуском.
|
||
|
||
Измерено (release, M-серия, `audio::tests::bench_one_hundred_voices_with_and_without_occlusion`, 100 голосов, буфер 512 кадров
|
||
10.67 мс): 0.110 мс без акустики, 0.327 мс с полкой на всех (+0.217 мс, около 2% буфера); оценка 0.5-1 мс выше была завышена.
|
||
|
||
Проверка без слуха (`audio::tests::export_wall_tracks` через настоящие `Acoustics` и `mix`, затем `tools/acoustics_check.py`),
|
||
выстрел 1.5 с на 60 блоках, стена камня в 2 блока: ниже 300 Гц -5.2 дБ (SPR: gain 0.55), 300-1500 -7.4, 1500-5000 -19.7, выше 5000 -40.0 дБ.
|
||
Тесты: `cargo test -p horizon-client --lib acoustics` (формулы, неизвестное, щель, кеш, инвалидация, детерминизм, рабочий поток) и `audio`.
|
||
|
||
### Настоящий MP4 с включённой акустикой (4 ноября, pin `73b9d3070`, API 29)
|
||
|
||
Пары настоящих экспортов одного и того же повтора, с акустикой и без: кнопка мода «Акустика
|
||
(преграды, эхо)», затем тот же `/airdef replay export` (640×360, 30 fps, crf 20), offscreen Metal,
|
||
ffmpeg 9.0.1, scratch `HORIZON_HOME`/мир (не сохранения игрока). `HORIZON_ACOUSTICS=1` без
|
||
`HORIZON_NO_AUDIO`. В сохранённом мире значение акустики хранится в состоянии мода, поэтому env
|
||
задаёт только начальное значение: в проверке её включали и выключали кнопкой.
|
||
|
||
Статусы в игре во время и после экспорта: `Акустика: API=true, включено=true, запрос=true,
|
||
подготовлено=true, ready`; `Захват этого мода: запрос=true, подготовлено=true, ready`. Контроль
|
||
`HORIZON_ACOUSTICS=1` + `HORIZON_NO_AUDIO=1`: `включено=true, запрос=true, подготовлено=false,
|
||
awaiting-backend` — ожидаемое отсутствие offline-микшера, не замена звуковому MP4.
|
||
|
||
| Сцена | ON: кадры/с | OFF: кадры/с | RMS ON/OFF | пик ON/OFF | L/R corr ON/OFF | centroid ON/OFF | HF>4k ON/OFF |
|
||
|---|---|---|---|---|---|---|---|
|
||
| поле, 3 события, `sec=4` | 123/4.100 | 124/4.133 | −23.53/−23.26 | −7.18/−5.60 | 0.675/0.627 | 1089/1382 Гц | 0.071/0.095 |
|
||
| поле, 1 событие, весь фильм | 351/11.700 | 352/11.733 | −32.18/−16.19 | −12.41/−2.65 | 0.829/0.963 | 1157/1862 Гц | 0.075/0.138 |
|
||
| каменный двор 80×100×30 (стены), весь фильм | 430/14.333 | 430/14.333 | −22.06/−20.09 | −5.97/−5.76 | 0.915/0.985 | 232/652 Гц | 0.005/0.039 |
|
||
|
||
Все пары — `h264` + AAC 48 кГц stereo, без обрезки звука; файлы `airdef-films/{ac,one,court}_{on,off}.mp4`
|
||
в scratch. Уже это показывает, что акустика реально обрабатывает дорожку фильма: в каменном дворе
|
||
OFF почти моно (L/R 0.985–1.000), ON декоррелирован (0.915); centroid и доля >4 кГц падают, как и
|
||
ожидается от окклюзии/воздушного поглощения; в поле ON тише OFF до 16 дБ по RMS за рельефом.
|
||
|
||
Реверб/эхо: в каменном дворе отрезок 9–14 с (после склейки) описывается ON ≈ g·OFF, g = −2.03 дБ
|
||
(лучшее совмещение −32 сэмпла = 0.67 мс). Остаток −25.18 dBFS при сигнале −17.61 dBFS (на 7.6 дБ
|
||
ниже), centroid 546 Гц, доля >4 кГц 0.009, L/R corr 0.495: низкочастотный диффузный
|
||
декоррелированный компонент — это wet-путь (реверб/эхо), а не масштаб сухого сигнала. В открытом
|
||
поле send реверба равен нулю по построению (`acoustics/room.rs`: `a field has no send`), поэтому
|
||
поле показывает только окклюзию/поглощение, а реверб — только в замкнутой геометрии.
|
||
|
||
Ограничения: чистый хвост/RT60 в настоящем MP4 не выделен — в замкнутом зале ON-дорожка была на
|
||
30–40 дБ тише (сильная окклюзия), а в открытом дворе длинный сэмпл взрыва (`blast_big`, 15 с; его
|
||
21 Гц тон виден в дорожке) маскирует хвост; камеру в комнате с источником и тишиной после него в
|
||
этом проходе получить не удалось. Ранние отражения отдельно не измерены. Субъективного
|
||
прослушивания не было; 10-минутный экспорт, Windows/Linux и полный бой не проверялись.
|
||
|
||
### Стадия 2: эхо (сделано)
|
||
|
||
Что игрок получает: взрыв в ущелье или между домами отвечает настоящим эхо от тех стен, что там есть, с задержкой по
|
||
настоящему пути; в чистом поле эха нет. Код: `acoustics/echo.rs` (наш, MIT), `acoustics/spr_bounce.rs` (GPL: спираль
|
||
Фибоначчи, `energyTowardsPlayer`, таблица отражательности), отводы в `audio.rs` (`Echo`), склейка `game/acoustics.rs`
|
||
(таблица отражательности по состояниям блоков).
|
||
|
||
- Метод: 32 луча от источника и 32 от слушателя (направления SPR, длина 300) находят плоскости стен (грани блоков
|
||
осевые). Для каждой плоскости мнимый источник (зеркальное отражение источника) даёт точный путь `|образ - слушатель|`;
|
||
отвод оставляем, если точка пересечения этой прямой с плоскостью лежит на твёрдом блоке (стена там правда есть) и пути
|
||
источник-стена и стена-слушатель свободны (окклюзия < 0.2). Это и есть «задержка по реальному пути»: сверка с формулой
|
||
до сэмпла.
|
||
- Задержка отвода = (путь - прямое расстояние) / 343 с: звук играет в момент прихода прямого (задержку распространения
|
||
держит мод). Уровень = `min(0.85, 2 * energyTowardsPlayer(refl)) * min(1, max(d,4)/путь)`, затем в микшере умножается на
|
||
громкость голоса и `distance_gain` длины пути; срез верхов по воздуху `0.994^(лишний путь)` (однополюсник на голос-отвод).
|
||
Окклюзия прямого звука на эхо не действует (оно приходит другим путём).
|
||
- Срез близких отражений: отвод ближе 30 мс к прямому звуку отбрасывается, до 50 мс плавно входит (иначе стена рядом с
|
||
игроком даёт гребёнку, а не эхо). Из найденных плоскостей берутся до 6 сильнейших не ближе 40 мс друг к другу.
|
||
- Кому: `deserves_echo` в `audio.rs`: не петля, не относительный, громкость >= 0.5, дальность (linear) >= 96, длина 0.1-12 с,
|
||
прошло меньше 70% звука; не более `ECHO_VOICES = 8` активных наборов (самые громкие). При смене приоритетов старый набор
|
||
отзывается и затухает ~50 мс, поэтому во время ротации кратковременно звучат до 16, затем снова 8. Сотня двигателей эха
|
||
не получает (петли), сотня выстрелов: восемь самых громких.
|
||
- Планирование: счёт по клеткам, не по времени. Живьём трассировка идёт на том же рабочем потоке (в задании до 4 трасс и
|
||
240 тыс. клеток), кеш по блочным ячейкам источника и слушателя (центры ячеек, 256 записей), сброс по секциям, которых
|
||
касаются точки опоры отводов, и по эпохе; в съёмке трасса синхронная, 480 тыс. клеток на тик. Микшер получает отводы
|
||
числами (`take_echoes`), новый набор входит за ~50 мс (совпавший по задержке в пределах 3 мс отвод меняет только уровень).
|
||
Голос после конца сэмпла живёт ещё `max_delay` (хвост), потом останавливается.
|
||
- Измерено (release, M-серия): трасса `bench_traces`: ущелье 0.054 мс (7 тыс. клеток, 2 отвода), открытое поле 0.115 мс
|
||
(15 тыс. клеток, 0 отводов), комната 0.022 мс; всё на рабочем потоке, игровой поток платит только клонированием `Arc` и
|
||
отправкой. Микшер: 108 голосов (100 петель + 8 одноразовых) 0.213 мс -> 0.348 мс с 8 x 6 отводами (+0.135 мс, 1.3% буфера
|
||
512 кадров). Время игрового потока в `tick_acoustics` и эхо-трасс копится в `status` (`acoustics.tick_ms_*`,
|
||
`acoustics.echo.ms*`); в игре не снималось.
|
||
- Проверка без слуха: `audio::tests::export_echo_tracks` (настоящие `Acoustics` и `mix`, щелчок) +
|
||
`python3 tools/acoustics_check.py --echo-dir папка`: ущелье даёт пики 158.9 и 242.1 мс (ожидалось 158.9 и 242.1),
|
||
поле ничего, комната один пик 39.9 мс (боковые стены; торцы и потолок ближе 30 мс срезаны). Тесты: `--lib acoustics`
|
||
(ущелье, поле, комната, стена у слушателя, ширма, «стены нет там, где луч её нашёл», бюджет, детерминизм, кеш, инвалидация,
|
||
потолок голосов, рабочий поток) и `--lib audio` (отвод стоит на своём кадре до сэмпла, хвост, без щелчка, детерминизм).
|
||
|
||
Не слушали: громкость отводов (уровни из формул SPR и 1/r, не подстраивались), пороги `deserves_echo` (угаданы: не знаем,
|
||
какую громкость и дальность мод ставит выстрелам), срез 30/50 мс и разнос 40 мс на слух, срез верхов отвода. Первые 1-2 тика
|
||
эхо нового звука ещё нет (трасса на рабочем потоке): теряется только начало отводов с задержкой менее ~100 мс. Лучи стадии
|
||
видят стены только в 32 направлениях с каждого конца: узкая далёкая стена (меньше нескольких градусов) может не найтись.
|
||
Косые стены считаются ступеньками: на склоне горы отводов много, остаются сильнейшие с разносом 40 мс. Отводы идут только
|
||
в мире (реплей читает те же блоки), на съёмке не прогонялось в игре.
|
||
|
||
Не сделано / не проверено: запуск в игре и экспорт видео с настоящей сценой (склейка `game/acoustics.rs` — таблица состояний,
|
||
снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается
|
||
ячейкой); потолок памяти снимка.
|
||
|
||
### Стадия 3: реверб (сделано)
|
||
|
||
Что игрок получает: под крышей и в тоннеле звук звенит иначе, чем в поле; в чистом поле реверба нет. Код: `acoustics/room.rs`
|
||
(наш: замер пространства, уровень и спад, посылки), `acoustics/spr_reverb.rs` (GPL: четыре слота SPR, `0.7*0.85`, затухание посылок
|
||
с расстоянием), `audio/reverb.rs` (наш: DSP общей шины), посылки голосов и шина в `audio.rs` (`Channel.send`, `Mixer.reverb`),
|
||
склейка в `acoustics.rs` (`take_sends`).
|
||
|
||
- Замер пространства (`room::measure`): 48 лучей по сфере Фибоначчи, берутся те, что идут вбок и вверх (пол комнаты не делает), длина 64 блока
|
||
(столько видит и SPR). Вес попадания `1 - (d/64)^2`. Из них: **замкнутость** (сумма весов / число лучей, 0..1), **размер** (средняя
|
||
дальность стен) и **отражательность** (средняя по таблице SPR). Слушатель замеряется по своей клетке 1x1x1 (кеш 64, сброс по секциям в
|
||
кубе ±65 и по эпохе, на рабочем потоке; пока новый замер в пути, остаётся прежний); у звуков с эхом замеряется ещё и пространство
|
||
источника (в той же трассе эха, свой потолок 60 тыс. клеток). Пространство звука = слушатель 60% + источник 40% (без источника: слушатель).
|
||
- Уровень и спад (`wet_and_decay`): `wet = smoothstep(0.03, 0.4, замкнутость)`; `спад = замкнутость^2 * (0.4 + 0.1*min(размер,30)) *
|
||
(0.6 + 0.8*отражательность)`, в пределах 0.15-4.14 с (слоты SPR). Камень (1.5) звенит дольше шерсти (0.1). Ущелье с открытым верхом
|
||
почти не даёт реверба (его ответ - эхо стадии 2); комната и тоннель - да.
|
||
- Посылки: спад ложится между двумя соседними слотами SPR (0.15 / 0.55 / 1.68 / 4.14 с, по логарифму) равномерной по мощности
|
||
перекрёстной кривой (квадрат положения у длинного, иначе по Шрёдеру читается только он), каждый слот выравнен по своему `gain` из
|
||
таблицы SPR: громкость реверба не растёт с его длиной. Посылка голоса умножается на `distance_fade` SPR (`1 - d/(1.5*дальность)`).
|
||
- Шина (`audio/reverb.rs`): 4 слота, в каждом пред-задержка (late delay SPR), 2 allpass, 4 гребёнки с lowpass в петле (коэффициент из
|
||
`decay_hf_ratio` SPR на 5 кГц). Левое ухо берёт гребёнки 0 и 2, правое 1 и 3 (разные хвосты), моно = среднее всех четырёх. Голос
|
||
кладёт в шину сигнал после стены (окклюзии) и до панорамы; посылки голоса плавно (постоянная 0.3 с) идут к цели, так что вход в тоннель
|
||
и выход из него без щелчка. Шина считается и без голосов (хвост звучит после конца хлопка), слот без входа и хвоста не считается и
|
||
обнуляется (без денормалей), шина сбрасывается, когда затихла.
|
||
- Выключение (`set_acoustics(false)`): посылки идут к нулю за ~1-2 с, хвост доигрывает сам; `stop_all` (и `film_begin/film_end`) шину сбрасывают.
|
||
Съёмка: замер синхронный (счёт клеток), шина считается без случайности и от часов не зависит - дорожка та же при каждом прогоне
|
||
(`audio::tests::reverb_rendering_is_deterministic`).
|
||
- Измерено (release, M-серия, буфер 512 кадров = 10.67 мс): шина с четырьмя занятыми слотами 0.017 мс; 100 голосов 0.119 мс без реверба ->
|
||
+0.04 мс с реверб-посылками (25 или 100 голосов, около 0.4% буфера); замер пространства: комната 0.0075 мс (392 клетки), тоннель
|
||
0.0037, ущелье 0.033 (2208), поле 0.035 мс (2315); всё на рабочем потоке. Счётчики в `status`: `acoustics.reverb` (замеры, попадания
|
||
кеша, клетки, посылки, замкнутость и спад сейчас); в игре не снимались.
|
||
- Проверка без слуха: `audio::tests::export_reverb_tracks` (настоящие `Acoustics`, посылки, `mix`, щелчок в комнате 24x10x24, тоннеле 5x4,
|
||
ущелье, поле) + `python3 tools/acoustics_check.py --reverb-dir папка`. Порядок **поле < ущелье < тоннель < комната** (в плане было
|
||
«ущелье длиннее всех»; открытый верх ущелья этого не даёт):
|
||
|
||
| сцена | спад пространства, с | RT60 по посылкам, с | RT60 измерено, с | хвост/щелчок, дБ |
|
||
|---|---|---|---|---|
|
||
| поле | 0.15 | - | хвоста нет | нет |
|
||
| ущелье | 0.15 | 0.15 | 0.10 | -47.7 |
|
||
| тоннель | 0.79 | 0.97 | 0.87 | -11.7 |
|
||
| комната | 1.37 | 1.67 | 1.39 | -7.7 |
|
||
|
||
Колонка «по посылкам» - сумма экспонент слотов по Шрёдеру (`room::effective_decay`), измеренное лежит на 10-20% ниже (lowpass в петле
|
||
укорачивает). Каждый слот отдельно (импульс в одну шину, `reverb::tests::each_slot_decays_at_its_time`) читается в 0.5-1.15 от
|
||
номинала. Тесты: `--lib acoustics` (замеры комната/тоннель/ущелье/поле, порядок спадов, гладкость посылок, кеш, сброс, рабочий поток)
|
||
и `--lib audio` (хвост после конца звука и сброс шины, нулевая посылка - ровно прежний вывод, без щелчка при смене и выключении,
|
||
детерминизм, выключение и `film_begin`).
|
||
|
||
Не слушали: громкость реверба (`WET = 1.6` и уровни слотов не подстраивались на слух; в комнате хвост -8 дБ от щелчка по энергии), формулы
|
||
замкнутости/спада (пороги 0.03-0.4, веса, перекрёстная кривая - выбраны по замерам сцен, не по слуху), звучание гребёнок (металличность на
|
||
коротких слотах, пред-задержки), окраска левого и правого, 60/40 слушатель/источник, скорость скольжения посылок 0.3 с. Замкнутость
|
||
по 24 лучам вверх-вбок грубая: узкая щель в крыше может не найтись, деревья и листва (окклюзия 0) пространства не делают. Реверб не
|
||
получают относительные звуки (интерфейс, музыка) и звуки дальше 512 блоков; эхо-отводы в шину не идут. `gain_hf` слотов, `density`,
|
||
отражения слота SPR не перенесены.
|
||
|
||
|
||
### Повторная проверка на закреплённом движке (2026-11-02)
|
||
|
||
Движок `209559080` (baseline v0.2.97), macOS arm64/Apple M5. Три ignored export fixtures
|
||
действительно выполнены через Acoustics и Mixer, затем проверены `tools/acoustics_check.py`.
|
||
Это процедурные тестовые сигналы без аудиоустройства, не субъективное прослушивание игры.
|
||
|
||
- Два блока камня: изменение энергии ниже 300 Гц **−5.2 dB**, 300–1500 **−7.4 dB**,
|
||
1500–5000 **−19.7 dB**, выше 5000 **−40.0 dB**. Проверка spectral wall/open прошла.
|
||
- Echo fixture: ущелье **158.9 и 242.1 ms**, комната **39.9 ms**, поле — без отражений.
|
||
Задержки совпали с расчётными в допуске **1 ms**.
|
||
- Reverb fixture: измеренный RT60 ущелья **0.10 s**, тоннеля **0.87 s**, комнаты **1.39 s**;
|
||
в поле хвост не обнаружен. Для тоннеля/комнаты прогноз по sends **0.97/1.67 s**.
|
||
Проверка использовала текущий допуск анализатора **35%**, а не целевые 15% из раннего плана.
|
||
|
||
Экспорт echo и reverb нужно выполнять **в разные каталоги**: оба пишут field.raw/gorge.raw/room.raw,
|
||
иначе один эксперимент перезапишет сигналы другого. Пример (из horizon/):
|
||
|
||
```sh
|
||
mkdir -p /tmp/airdef-echo
|
||
HORIZON_ACOUSTICS_OUT=/tmp/airdef-echo cargo test -p horizon-client --lib audio::tests::export_echo_tracks -- --ignored
|
||
# Из корня AirDef:
|
||
python3 tools/acoustics_check.py --echo-dir /tmp/airdef-echo
|
||
```
|
||
|
||
Лицензии/атрибуция включены в `docs/licenses/`; game bundle workflows копируют GPL text,
|
||
Horizon MIT notice и THIRD_PARTY_SPR.md в архив рядом с build-identity.json.
|
||
|
||
## 1. Как SPR идёт от источника к результату
|
||
|
||
Сигнал: Minecraft вызывает `Channel.play` → `mixin/SourceMixin.play` → `SoundPhysics.onPlaySound` →
|
||
`evaluateEnvironment` (SoundPhysics.java:240). Это один синхронный расчёт на каждый новый звук, в потоке звукового
|
||
движка. Движущиеся звуки пересчитываются только при `update_moving_sounds=true` (по умолчанию выключено), раз в
|
||
`sound_update_interval=5` тиков, со сдвигом по `sound.hashCode()` (`mixin/SoundSystemMixin.tickNonPaused`). Относительные
|
||
звуки (интерфейс) и дальше `max_sound_processing_distance=512` пропускаются (строки 248-259), как и звуки, превысившие
|
||
лимит за тик (`utils/SoundRateManager`: по умолчанию без лимита, 0 только у дождя, грома и гудка козы).
|
||
|
||
### 1.1 Прямой путь: окклюзия и поглощение (`calculateOcclusion`, `runOcclusion`, строки 535-613)
|
||
|
||
- `runOcclusion`: луч от источника к камере, до `max_occlusion_rays=16` последовательных попаданий. Каждое попадание
|
||
добавляет `occlusion` блока (`config/OcclusionConfig`), затем луч продолжается от точки попадания (так учитывается
|
||
толщина стены). Остановка при сумме `> max_occlusion=64`.
|
||
- Не полный блок (`!isFaceSturdy` грани, в которую попали): вклад `* 0.25` (`non_full_block_occlusion_factor`).
|
||
- Если не `strict_occlusion`: берётся минимум по 9 путям: прямой и 8 со сдвигом обоих концов на `(±v,±v,±v)`,
|
||
`v = occlusion_variation = 0.35`; у блочных звуков `v >= 0.49`. Минимум даёт «звук проходит в щель, у двери,
|
||
у угла»; это недорогая замена дифракции.
|
||
- Итог (строки 287, 302-303):
|
||
`cutoff = exp(-occ * 3 * block_absorption)`, `gain = scale * cutoff^0.1`.
|
||
Один обычный блок (occ 1): cutoff 0.050 (в OpenAL это `GAINHF`, усиление на 5 кГц), gain 0.74. Два блока:
|
||
0.0025 и 0.55. Три: 0.0001 и 0.41. Громкость падает мало, главное — срез верхов; это нужное нам «глухо за горой».
|
||
- Значения блоков (`OcclusionConfig.addDefaults`): по умолчанию 1.0; шерсть 1.5, мох 0.75, мёд 0.5, стекло, снег,
|
||
бамбук, мокрая трава 0.1; лианы, кусты, посевы, лестницы, цепи, листья-подобное 0; вода 0.25, лава 0.75.
|
||
- Фильтр воды и «подводные» множители (строки 319-321, 497-502) нам не нужны.
|
||
|
||
### 1.2 Выбор и трассировка лучей отражения (строки 323-432)
|
||
|
||
- `environment_evaluation_ray_count=32` направлений вокруг источника по спирали Фибоначчи на сфере:
|
||
`longitude = i * phi * 2pi`, `latitude = asin(2i/N - 1)`; длина луча 256.
|
||
- Попавший луч (`RaycastUtils.rayCast`, обход блоков с реальными формами коллизии и жидкостей) отражается
|
||
`reflect(d, n) = d - 2(d.n)n` до `environment_evaluation_ray_bounces=4` раз. Если очередной отражённый луч
|
||
ушёл в пустоту (`MISS`), цикл отскоков прерывается.
|
||
- Для каждого отскока: `energyTowardsPlayer = 0.25 * (reflectivity * 0.75 + 0.25)` (reflectivity последнего
|
||
блока, `ReflectivityConfig`: по умолчанию 0.5; камень, глубинный сланец, базальт, туф, кость, незер-кирпич 1.5;
|
||
незерак 1.1; металл 1.25, медь 1.25; стекло 0.75; дерево 0.4; трава, гравий 0.3; песок 0.2, снег 0.15;
|
||
шерсть, мох, мёд 0.1).
|
||
- Аккумулируемая длина пути `totalRayDistance` (путь до первого попадания + сумма отрезков). Если луч последнего
|
||
отскока ушёл в пустоту, к ней добавляется расстояние от точки отскока до слушателя.
|
||
- «Общее воздушное пространство» (`getSharedAirspace`, строки 632-653): из каждой точки отскока и из самого
|
||
источника пускается луч к слушателю без пропусков; если он свободен, это отражённый или прямой путь, который
|
||
реально доходит. Из таких путей считаются `sharedAirspaces` и направления.
|
||
- Перенаправление источника (`ReflectedAudio.evaluateSoundPosition`): новая позиция источника — направление,
|
||
взвешенное по `1/(длина пути)^2` по всем свободным путям (плюс прямое, если оно есть), на прежнем расстоянии от
|
||
слушателя. Так звук из-за горы «приходит» из проёма, а не сквозь гору. Не применяется, если путь виден напрямую
|
||
(`redirect_non_occluded_sounds`).
|
||
|
||
### 1.3 Затухание, задержки, оценка окружения (строки 409-492)
|
||
|
||
Важное, чего в README нет и что менее очевидно, чем кажется по описанию #96:
|
||
|
||
- **Задержек и дискретных эхо у SPR нет.** `reflectionDelay = totalRayDistance * 0.12 * blockReflectivity` — не время,
|
||
а индекс, который перекрёстным смешиванием (`cross0..cross3`, «шляпы» шириной 1) раскладывает вклад луча по
|
||
четырём фиксированным реверб-слотам. Слот 0 берёт delay 0..1, слот 1 — 1, слот 2 — 2, слот 3 — больше 2.
|
||
Для камня (1.5) слот 3 начинается примерно с пути 11 блоков, для травы (0.3) с 55. В ущелье почти всё
|
||
уходит в слот 3: один длинный хвост, а не отдельное «бабах... бабах» от стенки.
|
||
- Вклад в слот: `send_i += cross_i * energy * {6.4, 12.8, 12.8, 12.8} / (rays * bounces)`; затем слот 1
|
||
умножается на долю отскоков 1-го порядка с отражающим блоком (`bounceReflectivityRatio[1]`), слот 2 на `ratio[2]^3`,
|
||
слот 3 на `ratio[3]^4` (чем меньше лучей доживает до глубокого отскока, тем тише длинный хвост); слоты 2 и 3
|
||
`*1.05 - 0.05`; всё в 0..1.
|
||
- Фильтр посылки (`sendCutoff_i`): доля общего воздушного пространства
|
||
`shared = sharedAirspaces * 64 / (rays * bounces)` даёт вес `clamp(shared / {20,15,10,10})`;
|
||
`sendCutoff = exp(-occ*3*absorb) * (1-w) + w`. То есть, если до слушателя дотягивается много свободных путей,
|
||
реверб идёт без срезания верхов, иначе приглушён как и прямой звук. Сухой звук при этом получает ту же
|
||
логику: `directCutoff = max(sqrt(avg(w)) * 0.2, directCutoff)`: через щели всегда проходит что-то приглушённое.
|
||
- Затухание реверба по расстоянию добавлено костылём (комментарий в коде, строки 484-488):
|
||
`send *= 1 - min(dist / (maxDistance * reverb_distance), 1)`, `reverb_distance=1.5`.
|
||
Сам звук затухает штатно в OpenAL (линейно по расстоянию до `AL_MAX_DISTANCE`; у нас это `distance_gain`).
|
||
- Воздушное поглощение — штатное `AL_AIR_ABSORPTION_FACTOR = 1` (OpenAL Soft сам считает срез верхов от
|
||
расстояния; по документации EFX это 0.994 усиления на 5 кГц на единицу длины, то есть 0.55 на 100, 0.16 на 300
|
||
— проверить по исходникам OpenAL Soft, их здесь не читали).
|
||
|
||
### 1.4 Реверб и EFX (`setupEFX`, `setReverbParams`, `config/ReverbParams`)
|
||
|
||
Четыре auxiliary effect slot'а (`ALC_MAX_AUXILIARY_SENDS=4` через `mixin/LibraryMixin`), в каждом `EAXREVERB`
|
||
с неизменными (не зависящими от места!) параметрами. Окружение выражено только уровнями посылок источника в эти
|
||
слоты:
|
||
|
||
| слот | decay, с | density | gain | gainHF | decayHFratio | reflections gain / delay, с | late gain / delay, с | rolloff |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| 0 | 0.15 | 0.0 | 0.2 | 0.99 | 0.6 | 2.5 / 0.001 | 1.26 / 0.011 | 0.16 |
|
||
| 1 | 0.55 | 0.0 | 0.3 | 0.99 | 0.7 | 0.2 / 0.015 | 1.26 / 0.011 | 0.15 |
|
||
| 2 | 1.68 | 0.1 | 0.5 | 0.99 | 0.7 | 0.0 / 0.021 | 1.26 / 0.021 | 0.13 |
|
||
| 3 | 4.142 | 0.5 | 0.4 | 0.89 | 0.7 | 0.0 / 0.025 | 1.26 / 0.021 | 0.11 |
|
||
|
||
`gain` умножается на `0.7 * reverb_gain * 0.85`; `diffusion = 1` везде. На источник вешаются 4 lowpass-фильтра
|
||
посылок (`AL_AUXILIARY_SEND_FILTER`), 1 прямой lowpass (`AL_DIRECT_FILTER`): `GAIN` и `GAINHF`
|
||
(`setEnvironment`, строки 663-704). Всё остальное — собственный DSP OpenAL Soft (LGPL, и его мы не портируем).
|
||
|
||
### 1.5 Что SPR вычисляет сам и что отдаёт бэкенду
|
||
|
||
| Сам SPR | Отдаёт OpenAL |
|
||
|---|---|
|
||
| лучи, окклюзия, отскоки, пути, доли, `exp(-occ*3)`, перенаправление позиции | реальный lowpass (фильтр LP/HF-shelf на 5 кГц), посылки, EAXReverb, воздушное поглощение, затухание по расстоянию, панорама |
|
||
|
||
### 1.6 Мир и потоки, кеш
|
||
|
||
- `world/ClonedClientLevel`: копия секций чанков в радиусе `level_clone_range=4` чанка (петля `-4..3`, то есть
|
||
8x8 чанков, примерно 64 блока вокруг игрока), обновляется раз в 20 тиков или после сдвига на 16 блоков
|
||
(`utils/LevelAccessUtils.tickLevelCache`). Лучи читают только эту копию, поэтому звук можно считать вне главного
|
||
потока. Блок вне копии — `VOID_AIR`. **Следствие: SPR не видит гор дальше ~64 блоков.** Дальний взрыв за
|
||
горой для него — открытое поле.
|
||
- Кеша результатов нет. Каждый новый звук — свои ~350 лучей (прямой 1..9 путей по до 16 пересечений, 32 луча x до 5
|
||
отрезков, ещё ~160 лучей воздушного пространства). `TaskProfiler` только измеряет. Платит SPR временем потока
|
||
звука на каждый `play`, поэтому и существует лимит `SoundRateManager`.
|
||
- Приоритетов, бюджета по времени, затухающей детализации по расстоянию нет, только жёсткие пороги.
|
||
|
||
## 2. Что зависит от OpenAL, и чем это заменить в нашем микшере
|
||
|
||
`audio.rs` сегодня (`mix`, ~строки 402-501): на голос — линейная интерполяция, `distance_gain`, панорама
|
||
`stereo_gains`; потом общий лимитер. Нет ни фильтров, ни посылок. Вызов `mix` общий для живого потока cpal и
|
||
`film_mix` (48 кГц, на часах съёмки), поэтому любой DSP, живущий в `Mixer`, автоматически одинаков вживую,
|
||
в реплее и в оффлайн-MP4.
|
||
|
||
Нужный минимум (всё своё, без кода OpenAL Soft; фильтры и FDN — стандартная техника):
|
||
|
||
1. **Low-pass на голос.** Однополюсный `y += a*(x-y)`; отсечка из «GAINHF на 5 кГц» SPR:
|
||
`fc = 5000 / sqrt(1/g^2 - 1)` (для `g` в 0.001..0.999), `a = 1 - exp(-2 pi fc / rate)`. Плюс gain.
|
||
Однополюсник — упрощение OpenAL-овского HF-shelf (там НЧ не режется, у нас и так почти не режется), для
|
||
«глухо» достаточно; при сомнениях заменить на два каскада.
|
||
2. **Сглаживание параметров** (slew 50-100 мс на gain и `a`), иначе щелчки при смене окклюзии.
|
||
3. **Ранние эхо: 2-6 отводов на голос**, читающих тот же `Arc<Vec<f32>>` источника на позиции `position - delay*step`.
|
||
Памяти на линии задержки не нужно (звуки у нас целиком декодированы, `Channel.samples`); для стримов (музыка)
|
||
отводы выключены. Параметры отвода: задержка (сэмплы), gain, `a` фильтра. Голос после конца сэмплов остаётся
|
||
жить в состоянии «хвост» ещё `max_delay`, иначе эхо оборвётся вместе с хлопком.
|
||
4. **Посылка в реверб.** На голос уровень посылки `send` (1-2 шины), накапливается моно в буфер шины перед
|
||
панорамой. Шина: вход → pre-delay → 4 all-pass (диффузия) → FDN из 8 линий (взаимно простые длины 7..80 мс,
|
||
матрица Адамара 8x8, потери на линию `g = 10^(-3 L / (RT60 * rate))`, lowpass в петле для `decayHFratio`) → стерео-
|
||
декорреляция (разные отводы для L и R). Две шины с разным RT60 (комната/тоннель ~0.4-1.2 с и открытая местность/
|
||
ущелье 2-4 с), между ними перекрёстное смешивание по классу окружения (как слоты SPR). Параметры из §1.4
|
||
(0.15/0.55/1.68/4.14 с, gainHF) годятся стартовыми значениями RT60.
|
||
5. **Состояние сброса:** шины и хвосты обнуляются в `film_begin/film_end`, при смене мира, `stop_all`.
|
||
|
||
Оценка стоимости (расчёт по числу операций, не замер; замер — условие включения, см. этап 1):
|
||
|
||
| Часть | Операций на сэмпл | 100 голосов, 48 кГц |
|
||
|---|---|---|
|
||
| сегодня: интерполяция + панорама | ~12 | 58 Mflop/с |
|
||
| + LP + slew | ~6 | +29 |
|
||
| + 4 отвода (чтение, интерполяция, LP, gain) | ~32 | +150 |
|
||
| + посылка | ~2 | +10 |
|
||
| 2 шины FDN8 на всё | ~70 на шину | ~7 всего |
|
||
|
||
Итого около 250 Mflop/с на 100 одновременно звучащих голосов, то есть порядка 0.5-1 мс на буфер 512 кадров
|
||
(10.7 мс) при скалярном коде — единицы процента одного ядра M-серии. Ограничения: отводы только у `N_full=16` самых
|
||
слышимых голосов (остальные — LP и посылка), и всё равно сначала бенч `mix` в release с 100 голосами. Память:
|
||
шины ~2x160 КБ, на голос ~100 байт состояния; задержки у голосов нет. Callback не аллоцирует: буферы шин и
|
||
состояние в `Channel` создаются заранее, параметры пишет игровой поток под тем же `Mutex<Mixer>`, что уже есть
|
||
(держать критическую секцию короткой; если встретятся пропуски — перейти на тройной буфер/атомики).
|
||
|
||
## 3. Лучи: что есть и как сопоставляется
|
||
|
||
Что есть:
|
||
|
||
- Мод: `render.query-rays` (до 256 лучей/тик, ответ со следующего тика, до 512 блоков, политика `sound` =
|
||
только полные кубы), `query-sections` (64/тик, битовые сетки 16^3), `world-changes` (revision, секции) — в `horizon.wit`,
|
||
реализация `game/mod_world.rs` (`Probe` над `World.chunks`, `CELLS_PER_TICK = 200_000`) и чистый обход
|
||
`horizon_mods::world_query::trace` (обобщён над `BlockProbe`).
|
||
- Клиент: `World.chunks` — `Arc<Chunk>` с копированием при записи (`Arc::make_mut`), поэтому рабочий поток может держать
|
||
снимок чанков без блокировок и увидеть стабильную, пусть и устаревшую картину.
|
||
|
||
Что не хватает: политика `sound` бинарна и без материала; ответ на луч не говорит, что за блок попали (только
|
||
координаты), а звуку нужны вклад окклюзии и отражательная способность. Поэтому **акустика живёт в клиенте** (это же
|
||
требование #96: общий DSP для live/реплея/MP4), а мод только сообщает события и движущиеся источники через #95.
|
||
Мод не должен сам считать лучи для звука и дублировать эхо.
|
||
|
||
Решение:
|
||
|
||
- Новый модуль `horizon-client/src/acoustics/`: `world.rs` (свой `AcousticProbe`: `Cell::Solid` с таблицей по
|
||
состоянию блока `occ: u8` и `refl: u8`, строится из `StateFacts.sounds.step`/`classes`/`full_collision`, один раз на
|
||
эпоху мира, как `WorldTables`); `trace.rs` (тот же обход `world_query::trace` — проверить, что модуль не за
|
||
feature `mods`; иначе вынести в `horizon-core`/`horizon-world`).
|
||
- Ускорение дальних лучей: пропуск пустых секций 16^3 (DDA по секциям поверх `section_bits`), иначе 512-блочный луч
|
||
это ~1500 клеток, и бюджет 200k даёт только ~130 лучей. Со секциями луч — десятки шагов. Секции кешируются
|
||
(`empty`/`full` — 1 байт, `mixed` — 512 байт; LRU, потолок 16 МБ).
|
||
- Неизвестное (`Unknown`, чанка нет) трактуется как открытый воздух, а не стена; доля Unknown идёт в телеметрию.
|
||
Исходный SPR делает то же (`VOID_AIR`), но у него «неизвестно» начинается за ~64 блоками, у нас за 512 и дальше.
|
||
- В callback мира нет: callback видит только числа (`gain, lp_a, send, taps[]`), которые положил планировщик.
|
||
|
||
### Планировщик (для ~100 источников, взрыв издалека, большой налёт на Mac)
|
||
|
||
1. **Разделение работы.** Источник = голос с `AcousticState {dry_gain, dry_a, send, taps, virtual_pos, stamp}`.
|
||
Есть три класса:
|
||
- `Full` (до 16 самых слышимых, плюс все взрывы мощностью >= 2 вблизи 400 блоков): прямой путь (до 3 путей из 9
|
||
SPR), 16 лучей среды x 2 отскока, до 4 эхо-лучей;
|
||
- `Direct` (остальные до 100): 1 прямой путь, 1 пересечение стен;
|
||
- `Free` (дальше 512 или тише порога): без расчёта, только воздушное поглощение по расстоянию.
|
||
Слышимость: `loud = volume * distance_gain` до окклюзии. Cinema.rs уже режет налёт по `FULL=3` на тик — тот же
|
||
`rank` использовать для класса.
|
||
2. **Очередь с бюджетом.** Бюджет ~1 мс времени расчёта на игровой тик (замеряется `Instant`) и потолок клеток;
|
||
в очереди работы приоритет `loud * (1 + возраст)`; недоделанное остаётся со старыми значениями. Взрыв
|
||
ставится вперёд очереди (он звучит один раз). Телеметрия в `census()` (`status`): лучей, клеток, мс, длина
|
||
очереди, попаданий в кеш, отброшенных.
|
||
3. **Рабочий поток** (стадии 2-3; стадию 1 можно на игровом потоке под бюджетом, как `mod_world`). Он получает снимок
|
||
`Arc<Chunk>` в радиусе ~512 блоков и список изменений мира; отдаёт `AcousticState` через канал; игровой поток
|
||
применяет их в начале тика.
|
||
4. **Кеш.** Ключ: (клетка источника 4x4x4, клетка слушателя 4x4x4, класс); значение: параметры и `AABB` лучей.
|
||
Инвалидация: `world-changes` (revision и секции) — запись удаляется, если её AABB задевает изменённую секцию;
|
||
`epoch` — всё. Разрушенная преграда (в полигоне) так обновляется за один тик очереди.
|
||
Для окружения слушателя («зонд»: 24 луча вокруг камеры, раз в 250 мс или при сдвиге на 4 блока) отдельная запись,
|
||
общая для всех источников: из неё берётся класс реверба и хвост (комната, тоннель, поле, ущелье); этим
|
||
«много источников используют общие шины и зонды».
|
||
5. **Движущиеся источники** (дрон, ракета): интервал пересчёта `clamp(distance / 200 с, 0.1, 1.0)` с фазовым сдвигом от
|
||
id голоса (как `hashCode % interval` в SPR); значения сглаживаются в микшере; решение об окклюзии с гистерезисом.
|
||
Ракета близко пролетает мимо слушателя — частоту повышаем по сближению, а не по времени.
|
||
6. **Оффлайн и реплей.** При съёмке расчёт синхронный и бюджет по счётчику (лучи/клетки на тик), не по часам, иначе
|
||
результат зависит от скорости GPU и пишется недетерминированно. Слушатель — камера реплея (та же `set_listener`).
|
||
Геометрия — текущий мир; если реплей показывает уже разрушенное, акустика покажет сегодняшнюю карту (проверить, что
|
||
делает реплей с блоками).
|
||
7. **Двойное эхо с cinema.rs.** `_far`-варианты (приглушённый звук, поздний гул `blast.big_far` с `delay 10+2*power`)
|
||
и `travels()` с задержкой по `SOUND_SPEED` — это моделирование «издалека» заранее запечёнными сэмплами. С включённой
|
||
акустикой они дублируют срез верхов и хвост. При `acoustics=on` мод должен играть близкие варианты (без `_far`) и без
|
||
«позднего гула»: срез от расстояния, эхо и хвост делает движок. Задержку распространения оставить в моде до #95, потом
|
||
перенести в движок (по длине пути, с точностью до сэмпла), иначе она дважды считается и привязана к тику (50 мс).
|
||
|
||
## 4. Какие материалы и группы звуков важны
|
||
|
||
Берём `StateFacts.sounds.step` (например `minecraft:block.stone.step`), из него группа; для нее два числа
|
||
из SPR (`occ`, `refl`). Большинство блоков остаётся на значениях по умолчанию (`occ` 1.0, `refl` 0.5).
|
||
|
||
| Группа | occ | refl | Зачем |
|
||
|---|---|---|---|
|
||
| stone, deepslate, tuff, basalt, calcite, netherrack, nether_bricks, bone | 1.0 | 1.5 (1.1 незерак) | горы, здания, бетон, кирпич: основные отражатели эха |
|
||
| metal, copper, anvil, netherite | 1.0 | 1.25 | ангары, пусковые |
|
||
| wood | 1.0 | 0.4 | крыши, дома |
|
||
| gravel, grass, dirt | 1.0 | 0.3 | холмы глушат, но слабо переотражают |
|
||
| sand | 1.0 | 0.2 | |
|
||
| snow | 0.1 | 0.15 | снег не глушит |
|
||
| wool | 1.5 | 0.1 | мешки с песком, окна: глушит, не отражает |
|
||
| glass | 0.1 | 0.75 | окна пропускают звук, отражают |
|
||
| water | 0.25 | 0.5 | море, озёра: гладкая плоскость |
|
||
| листья, трава-растения, лианы, лестницы, цепи, посевы | 0 | 0.1 | нет окклюзии |
|
||
| неполный блок (`!full_collision`) | `occ * 0.25` | как у группы | двери, ступеньки, заборы |
|
||
|
||
Пропускаем: подводный фильтр, мёд, аметист, фонари и прочее по `SoundType` (всего ~45 групп у SPR; нам хватает
|
||
~12), звуки ambient и музыка, записи, интерфейс (`relative`), голосовой чат, расширение дальности `getRange x4`
|
||
(у нас свои дальности в моде). Ключевое для AirDef: стоит ценность в **дальности лучей** (512, не 256) и
|
||
в дешёвом обходе, а не в количестве материалов.
|
||
|
||
Целевые ощущения и как их получить:
|
||
|
||
- Ущелье: лучи среды находят стены на 20-150 блоков, из них 4 самых длинных (разнесённых по направлению) становятся
|
||
эхо-отводами с задержкой `path/343 с` (150 блоков = 0.44 с, 400 = 1.2 с) и gain `0.25*(0.75 refl + 0.25) / (1 + path/50)`;
|
||
плюс шина с RT60 2-4 с. **Дискретное эхо SPR не даёт, это наше добавление** (§1.3).
|
||
- За горой: окклюзия `exp(-3 * occ_sum)` по нескольким блокам горы (до 64) даёт глухой остаток, а перенаправление позиции
|
||
(§1.2) сдвигает источник в проём. Длинные лучи нужны, чтобы вообще её увидеть.
|
||
- Под крышей, тоннель: зонд слушателя видит близкие стены во всех направлениях (средняя длина < 12 блоков), RT60 шины
|
||
0.4-1.2 с, сильная посылка, малая задержка раннего. В открытом поле: лучи уходят в небо (MISS), хвоста нет.
|
||
- Воздушный взрыв: короткий и сильный; эхо считаем один раз на событие и общим для его слоёв, а не на каждый слой.
|
||
|
||
## 5. Стадии
|
||
|
||
Общее правило: включается настройкой форка/мода `acoustics` (`off|direct|reflect|full`), по умолчанию `off` — звук
|
||
как сегодня, бит в бит. Каждая стадия за своим флагом `HORIZON_ACOUSTICS_*` для отладки. Сводка логики в телеметрии
|
||
`status`.
|
||
|
||
Лицензия: GPL-3.0 код переносить только в отдельные файлы. Планируется: `horizon-client/src/acoustics/spr_*.rs` (порт
|
||
формул и таблиц) с шапкой «GPL-3.0-only, портировано из Sound Physics Remastered @453654ec, файл/функция»; рядом
|
||
`acoustics/THIRD_PARTY_SPR.md` в стиле `gfx/THIRD_PARTY_PHOTON.md` (коммит, список взятого, полный текст GPL-3.0,
|
||
что осталось нашим). Весь остальной код (микшер, планировщик, DSP) пишется заново и остаётся MIT. Предупреждение
|
||
владельцу: бинарник форка, куда слинкован GPL-код, при распространении должен соответствовать GPL-3.0 (исходники
|
||
целиком); «отдельные файлы» делают код опознаваемым, но не освобождают от этого. Смягчение: порт под cargo feature
|
||
`spr-gpl`, без неё остаётся наш собственный `direct` на простой модели. Решение за владельцем. В SPR переносимы
|
||
по сути только формулы (§1) и таблицы (§4); DSP у SPR нет вообще (он в OpenAL).
|
||
|
||
### Стадия 1. Прямой звук (окклюзия)
|
||
|
||
- Файлы: `audio.rs` (LP на голос, slew, поля `Channel`), `acoustics/{mod,world,trace,spr_occlusion}.rs`,
|
||
`sound_engine.rs` (где голос получает позицию: вызов планировщика), `game/film.rs` (синхронный режим).
|
||
- Порт: `runOcclusion`, `calculateOcclusion` (9 путей), `exp(-occ*3)`, `^0.1`, таблицы §4, перенаправление позиции.
|
||
- Тесты: чистые, без игры: (а) фикстура `BlockProbe` как в `world_query::tests`: стена из 1, 2, 3 блоков → cutoff
|
||
0.050 / 0.0025 / 0.0001, gain 0.74 / 0.55 / 0.41; щель у двери проходит; неизвестный чанк не глушит;
|
||
(б) `mix` с синусом 8 кГц: энергия на выходе падает в нужное число раз, для 500 Гц почти не меняется; (в) slew
|
||
без скачков (макс. разность соседних сэмплов); (г) выключатель: `off` даёт тот же вывод, что до изменения (хеш).
|
||
- Проверка без слуха: `film_mix` на сцене «источник за стеной» → `.audio.raw` (s16le 48 кГц) → скрипт
|
||
`tools/acoustics_check.py` (stdlib): энергия по полосам (до 1 кГц и выше 4 кГц), отношение «за стеной/на открытом»
|
||
против ожидаемого; сравнение `off` и `direct` на одном сэмпле и точке.
|
||
- Критерий: падение верхов на 20-30 дБ за 1 блоком камня, на открытом месте разницы нет; бюджет в `status`.
|
||
|
||
### Стадия 2. Ранние отражения и эхо
|
||
|
||
- Файлы: `audio.rs` (отводы, хвост голоса), `acoustics/{scheduler,echo,cache}.rs`, рабочий поток, `spr_bounce.rs`
|
||
(спираль Фибоначчи, `reflect`, `energyTowardsPlayer`, общее воздушное пространство).
|
||
- Тесты: детерминированная фикстура «ущелье» (две стены, расстояние до слушателя известно): отводы появляются с задержкой
|
||
`path/343` ± 1 сэмпл; кеш: повтор запроса — попадание; разрушенная стена (изменение секции) инвалидирует запись;
|
||
бюджет: 100 запросов за тик не превышают потолок клеток, очередь убывает; эхо живёт после конца сэмпла (голос в
|
||
состоянии «хвост» ровно `max_delay`); `off` бит-в-бит.
|
||
- Проверка без слуха: импульсный сэмпл (щелчок) в сцене «ущелье» и «поле»; в записи ищем пики автокорреляции/огибающей:
|
||
в ущелье пики на расчётных задержках с точностью до 1 мс, в поле нет. Оффлайн-MP4 дважды с разным GPU-таймингом
|
||
(`HORIZON_*` число кадров на тик) → побайтно одинаковая дорожка.
|
||
- Критерий: измеренные задержки совпадают с геометрией, замер CPU `mix` на 100 голосах и время планировщика на налёте
|
||
(по `status` и `hd_bench`-подобному прогону).
|
||
|
||
### Стадия 3. Поздний реверб
|
||
|
||
- Файлы: `audio.rs` (шины FDN, посылки, сброс), `acoustics/probe.rs` (зонд слушателя, класс окружения),
|
||
`spr_reverb.rs` (формулы посылок §1.3, начальные параметры §1.4).
|
||
- Тесты: FDN устойчив (энергия монотонно убывает при любых параметрах класса, нет NaN/denorm на 10 с тишины после
|
||
импульса); RT60 шины совпадает с заданным (Schroeder backward integration, допуск 15%); перекрёстное смешивание
|
||
классов не даёт скачка; сброс при `film_begin/film_end`; лимитер не срабатывает на нормальном налёте.
|
||
- Проверка без слуха: импульсный отклик шины из `film_mix` → RT60 по спаду (`tools/acoustics_check.py`) для четырёх
|
||
классов сцены (поле, ущелье, комната, тоннель) — таблица в отчёте; спад должен упорядочиться
|
||
поле < ущелье < тоннель < комната (у ущелья открытый верх); налёт 100 источников: пиковый уровень, число срабатываний лимитера, CPU.
|
||
- Критерий: хвост после конца исходного хлопка длится ожидаемое RT60 и не продолжается после смены мира.
|
||
|
||
### Стадия 4 (по #96): движущиеся источники и интеграция с #95/cinema
|
||
|
||
Постоянные источники #95 реализованы: live и replay объявляют движущиеся engine voices;
|
||
мод передаёт их host через sound-sources, listener фильма берётся из его камеры. Общий DSP находится
|
||
в движке. Необходимая оставшаяся приёмка — массовый живой бой, прослушивание и настоящий MP4 с
|
||
проверкой смены listener/мира и бюджета; наличие общего кода не заменяет эти проверки.
|
||
|
||
## Что оказалось неверным или неточным в тексте #96
|
||
|
||
- «Ранние отражения с задержкой и затуханием» в SPR нет: есть только 4 фиксированных реверб-слота OpenAL; индекс слота
|
||
вычисляется из длины пути. Эхо от скалы с реальной задержкой — наша собственная разработка (стадия 2).
|
||
- DSP и реверб SPR целиком отдаёт OpenAL (LGPL, не портируем). Портируются формулы и таблицы, а не «микшер».
|
||
- SPR видит мир только в ~64 блоках вокруг игрока (клон 8x8 чанков) и не кеширует результаты: к «дальним
|
||
взрывам за горой» и к «100 источникам» он не готов, это придётся решать самим (длинные лучи, секции, очередь).
|
||
- Коммит из #96 (`76f680eb…`) не найден в недавней истории `master`; разбор сделан по `453654ec`.
|
||
- Мод-API `query-rays` с политикой `sound` для этого недостаточно (нет материала и полупрозрачных долей), поэтому
|
||
акустика в клиенте; мод только сообщает события (#95).
|
||
|
||
Включается кнопкой в меню мода (ещё → «Акустика»), сохраняется с миром. `HORIZON_ACOUSTICS=1` задаёт только начальное значение.
|