Compare commits

..

1 Commits

Author SHA1 Message Date
chexit ab6b61db59 Acoustics porting plan from Sound Physics Remastered 453654e (#96 stage 1)
build / mod (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:54:01 +03:00
+334
View File
@@ -0,0 +1,334 @@
# Акустика 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).