Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
43 KiB
Акустика AirDef: разбор Sound Physics Remastered и план переноса (#96, этап 1)
Это результат первого этапа: чтение исходников и план. Кода движка и мода этот этап не меняет. Если код разойдётся с текстом, прав код.
- Изучено: 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 (прямой звук) сделана, включается HORIZON_ACOUSTICS=1 (по умолчанию выключено, звук как был). Стадии 2-3
не начаты. Коммит SPR прежний, 453654ec; атрибуция: horizon/crates/horizon-client/src/acoustics/THIRD_PARTY_SPR.md.
GPL-файл один: acoustics/spr_occlusion.rs; остальное (движок acoustics.rs, склейка game/acoustics.rs, DSP в audio.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 делал дорожку недетерминированной при нескольких голосах). - Кеш по ячейкам 2x2x2 источника и слушателя, 4096 записей; сброс по секциям
World.sound_changes(какmod_changes), по эпохе мира и при переполнении; результат задания, начатого до изменения в его коробке, отбрасывается. - Воздух:
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.
Не сделано / не проверено: запуск в игре и экспорт видео с настоящей сценой (склейка game/acoustics.rs — таблица состояний,
снимок чанков — компилируется, но в игре не прогонялась); влияние на кадр при налёте; жидкости; дверь как проём (считается
ячейкой); потолок памяти снимка. Стадии 2-3 (отводы эха, шины реверба) не начаты.
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
Пересчёт по интервалам (§3.5), acoustics=on ⇒ мод играет близкие варианты (§3.7), категории и слушатель реплея.
Зависит от #95 (постоянные источники): ждать его слияния, audio.rs сейчас правит другой агент — не начинать
стадию 1 до него, чтобы не конфликтовать по Channel/mix.
Что оказалось неверным или неточным в тексте #96
- «Ранние отражения с задержкой и затуханием» в SPR нет: есть только 4 фиксированных реверб-слота OpenAL; индекс слота вычисляется из длины пути. Эхо от скалы с реальной задержкой — наша собственная разработка (стадия 2).
- DSP и реверб SPR целиком отдаёт OpenAL (LGPL, не портируем). Портируются формулы и таблицы, а не «микшер».
- SPR видит мир только в ~64 блоках вокруг игрока (клон 8x8 чанков) и не кеширует результаты: к «дальним взрывам за горой» и к «100 источникам» он не готов, это придётся решать самим (длинные лучи, секции, очередь).
- Коммит из #96 (
76f680eb…) не найден в недавней историиmaster; разбор сделан по453654ec. - Мод-API
query-raysс политикойsoundдля этого недостаточно (нет материала и полупрозрачных долей), поэтому акустика в клиенте; мод только сообщает события (#95).