Files
airdef/docs/ACOUSTICS.md
T
chexit ab6b61db59
build / mod (push) Successful in 3m52s
Acoustics porting plan from Sound Physics Remastered 453654e (#96 stage 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:54:01 +03:00

38 KiB
Raw Permalink Blame History

Акустика 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. Как 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

Пересчёт по интервалам (§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).