Files
airdef/docs/ACOUSTICS.md
chexit abf3d76fdb docs: record the real MP4 acoustics check (#96)
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>
2026-11-04 20:52:11 +03:00

67 KiB
Raw Permalink Blame History

Акустика 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. Команда, результат и оставшаяся приёмка, JSON и SHA дорожки.

Сначала приведён текущий статус реализации. Разбор 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/):

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 задаёт только начальное значение.