62 KiB
Акустика AirDef: реализация, проверки и разбор Sound Physics Remastered (#96)
Сначала приведён текущий статус реализации. Разбор 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.
Стадия 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_fadeSPR (1 - d/(1.5*дальность)). -
Шина (
audio/reverb.rs): 4 слота, в каждом пред-задержка (late delay SPR), 2 allpass, 4 гребёнки с lowpass в петле (коэффициент изdecay_hf_ratioSPR на 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 — стандартная техника):
- 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 (там НЧ не режется, у нас и так почти не режется), для «глухо» достаточно; при сомнениях заменить на два каскада. - Сглаживание параметров (slew 50-100 мс на gain и
a), иначе щелчки при смене окклюзии. - Ранние эхо: 2-6 отводов на голос, читающих тот же
Arc<Vec<f32>>источника на позицииposition - delay*step. Памяти на линии задержки не нужно (звуки у нас целиком декодированы,Channel.samples); для стримов (музыка) отводы выключены. Параметры отвода: задержка (сэмплы), gain,aфильтра. Голос после конца сэмплов остаётся жить в состоянии «хвост» ещёmax_delay, иначе эхо оборвётся вместе с хлопком. - Посылка в реверб. На голос уровень посылки
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. - Состояние сброса: шины и хвосты обнуляются в
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— проверить, что модуль не за featuremods; иначе вынести в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)
- Разделение работы. Источник = голос с
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использовать для класса.
- Очередь с бюджетом. Бюджет ~1 мс времени расчёта на игровой тик (замеряется
Instant) и потолок клеток; в очереди работы приоритетloud * (1 + возраст); недоделанное остаётся со старыми значениями. Взрыв ставится вперёд очереди (он звучит один раз). Телеметрия вcensus()(status): лучей, клеток, мс, длина очереди, попаданий в кеш, отброшенных. - Рабочий поток (стадии 2-3; стадию 1 можно на игровом потоке под бюджетом, как
mod_world). Он получает снимокArc<Chunk>в радиусе ~512 блоков и список изменений мира; отдаётAcousticStateчерез канал; игровой поток применяет их в начале тика. - Кеш. Ключ: (клетка источника 4x4x4, клетка слушателя 4x4x4, класс); значение: параметры и
AABBлучей. Инвалидация:world-changes(revision и секции) — запись удаляется, если её AABB задевает изменённую секцию;epoch— всё. Разрушенная преграда (в полигоне) так обновляется за один тик очереди. Для окружения слушателя («зонд»: 24 луча вокруг камеры, раз в 250 мс или при сдвиге на 4 блока) отдельная запись, общая для всех источников: из неё берётся класс реверба и хвост (комната, тоннель, поле, ущелье); этим «много источников используют общие шины и зонды». - Движущиеся источники (дрон, ракета): интервал пересчёта
clamp(distance / 200 с, 0.1, 1.0)с фазовым сдвигом от id голоса (какhashCode % intervalв SPR); значения сглаживаются в микшере; решение об окклюзии с гистерезисом. Ракета близко пролетает мимо слушателя — частоту повышаем по сближению, а не по времени. - Оффлайн и реплей. При съёмке расчёт синхронный и бюджет по счётчику (лучи/клетки на тик), не по часам, иначе
результат зависит от скорости GPU и пишется недетерминированно. Слушатель — камера реплея (та же
set_listener). Геометрия — текущий мир; если реплей показывает уже разрушенное, акустика покажет сегодняшнюю карту (проверить, что делает реплей с блоками). - Двойное эхо с 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 с) и gain0.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 задаёт только начальное значение.