Рефакторинг звукового движка, ч. 1 - ретроспектива
Итак, новый звуковой движок уже написан и опробован в деле.
Как вы помните, я стараюсь сначала сделать, а потом уже рассказывать, что было сделано. Поскольку XashNT строится на базе Xash3D, то неудивительно что все подсистемы ему достались по наследству. Но, поскольку современным требованиям они не отвечают, их все надо было переписать, имея работоспособный движок на каждом этапе рефакторинга. На данный момент от легаси-кода осталось совсем немного: низкоуровневая часть сетевого кода (отправляет сетевые пакеты) и сломанный механизм записи-воспроизведения демок.
Этим я займусь вероятно уже в следующем году, т.к. на данный момент в приоритете улучшение рендера (динамический свет) и написание просмотрщика ресурсов, редактора уровней. Ну а звуковая подсистема, хотя на первый взгляд и не кажется сложной, «прорастает» в движок через множество мест, отчего её рефакторинг оказался не так прост и осуществлялся по частям.
Этапы рефакторинга
- непосредственно сам звуковой движок
- менеджер загруженных звуков
- менеджер сентенций (последовательное воспроизведение указанных звуков)
- сериализация звуков и последующее их восстановление
- проигрывание музыки и звуковых дорожек видеороликов
- система эффектов (реверберация, фильтры, эхо)
- бэкенд звуковой подсистемы (запись закольцованных смикшированных каналов в физический вывод аудио)
К рефакторингу я приступил еще в феврале-марте 2025 года. Сперва вынес бэкенд в платформозависимый слой, затем настала очередь менеджера звуков и музыки (он двухуровневый, как и загрузчик текстур), потом подошла очередь менеджера сентенций и, наконец, за оставшиеся две недели было переписано ядро звукового движка.
Вот о том, что же именно было сделано, с какой целью это делалось, и что получилось в итоге, я расскажу далее.
Поскольку Xash3D планировался как бинарно-совместимый с GoldSource, то и механизмы проигрывания звуков тоже унаследовал от него, а последний, в свою очередь - от первого Quake. На тот момент (1996-й год), это был вероятно мощный и продвинутый механизм, но в дальнейшем он оброс дополнительным функционалом без смены основной концепции и, как следствие, стал запутанным, не интуитивным, а часть функционала и вовсе начала мешать.
Звуковой движок
Взаимодействие со звуковым движком со стороны игрового кода представлено всего-навсего одной функцией:
void (*pfnEmitSound)( edict_t *entity, int channel, const char *sample, float volume, float attenuation, int fFlags, int pitch );
Да, если вы покопаетесь в интерфейсе GoldSource, то найдете какие-то похожие функции, но это всё нечто вроде шаблонов с жестко определёнными параметрами для вышеприведённой функции. Рассмотрим её аргументы подробнее:
edict_t *entity - ссылка на объект, от лица которого будет проигрываться звук.
В силу некоторых причин, завязанных на механизмы внутренней реализации сетевой подсистемы, нередка ситуация, когда эвент проигрывания звука приходит на клиент раньше, чем дельта-пакет с обновлением для указанного объекта. И мы попадаем в интересную ситуацию, когда звук уже надо проигрывать, а объекта на клиенте ещё нет. Соответственно для каждого звука нам нужно также передать его позицию в мировом пространстве, что делается скрытно от пользователя. Затем, когда информация о состоянии объекта доберётся на клиент (обычно спустя один-два кадра), мы сможем подхватить её и динамически обновить позицию нашего эмиттера звука (например, если это летящая ракета или бегущий NPC).
А если передвижение звука не планируется, то информацию об объекте можно и не передавать, но откуда тогда взять изначальную позицию звука в пространстве? В GoldSource, да и в первом Quake, для этого служила ещё одна дополнительная функция, которая принимала позицию в пространстве вместо указателя на объект. Однако, звуки, созданные таким образом, уже не поддаются контролю со стороны пользователя, поэтому их никто не использовал, а наличие двух функций только вносило путаницу.
int channel – номер канала - это самая запутанная вещь в звуковой подсистеме.
Потому что выбор номера канала не влияет практически ни на что. В оригинальном Quake он влиял на замену звука в канале новым, из-за чего проигрываемые звуки могли резко обрываться. Отдельно отмечу, что никакого плавающего лимита на число каналов тут нет. То есть мы не можем рассчитать потенциально число ВСЕХ звуковых каналов, умножив кол-во объектов (в Quake это 600, в GoldSource 900) на кол-во каналов (7). Ничего подобного!
По факту реальных звуковых каналов в Quake было 128, а в GoldSource 256 (не уверен на 100% по известной причине – отсутствие исходников). То есть указание номера канала в объекте никоим образом не соотносилось с реальным доступным числом физических каналов, а механизм их высвобождения был непрозрачен для пользователя.
Единственное, что можно было гарантировать, так это то, что если для одного объекта с небольшим промежутком вызывать проигрывание звука, указав один и тот же канал, то первый звук будет резко прерываться, после чего начинает проигрываться другой. Но как раз именно такое поведение никому и не было нужно!
Единственный случай, где подобное оказалось востребовано — это двери, лифты и прочие подъемники: закольцованный звук движения двери заменялся на указанном канале звуком остановки двери при завершении движения. В первом Quake это именно так и работало.
Однако уже в Half-Life 1 разработчики стали отправлять явный эвент остановки того или иного звука. А дело было вот в чём. Это настолько любопытная история, что, пожалуй, следует рассказать о ней подробнее.
Как мы помним, закольцованные звуки в Quake, Half-Life и Source получались путём расстановки в них особых меток в звуковом редакторе. Загрузчик звуков видел метку и понимал с какого момента перезапускать звук. Таким образом, поскольку это был классический data-driven подход, игровой программист уже не контролировал воспроизведение зацикленного звука явным образом. Единственное, что он мог сделать в Quake для остановки звука - отправить на зацикленный канал новый звук без метки зацикливания. Собственно, звуки дверей так и реализованы.
Но вылезла любопытная деталь. Поскольку сервер не играет звуки, то все функции проигрывания на самом деле являются отправкой сетевого сообщения на клиент, а механизм отправки имеет гибкие настройки и может игнорировать отправку по разным причинам, например если объект или игрок вышли из области видимости. Можно вспомнить древнюю китайскую "мудрость" - если события никто не видел и не слышал - можно ли считать его произошедшим? Ну вот со звуками дело примерно так и обстояло. Положение усугублял ещё тот факт, что все эти двери и подъемники при открывании уезжали куда-то за границу уровня, а видимость для отправки сообщений могла считаться по упрощенному алгоритму, т.е. просто по видимости центра объекта, который уже на 90% уехал за границу уровня. В общем, иногда сообщения на остановку звука не отправлялись, и он проигрывался бесконечно. В Valve рассудили, что им такого счастья не надо и ввели для вышеупомянутой функции EmitSound флаг SND_STOP. Вызов функции с этим флагом уже не делал никаких проверок на видимость и отправлялся всегда. Таким образом, потребность в указании номера канала могла бы отпасть окончательно, но в Valve этот механизм трогать никто не стал, а наоборот посадили на CHAN_VOICE анимацию контроллера костей рта у NPC, а для CHAN_STATIC сделали бесконечное кол-во каналов.
Иными словами, каждый новый звук, отправленный в CHAN_STATIC не заменял собой предыдущий. А чтобы остановить отправленный звук, следовало отправить его же, но с флагом SND_STOP.
Вишенкой на торте было то, что никакой документации ни по изначальному поведению, ни по "улучшенному" от Valve не существовало.
const char *sample. Интуитивно понятно – это путь к звуковому сэмплу на диске.
Только его сперва надо закэшировать при помощи соответствующей функции (PRECACHE_SOUND), иначе звук не проиграется. Так же, опционально, можно указывать имя сентенции, начинающееся с восклицательного знака. Сентенции кэшировать не нужно, они кэшируются автоматически на клиенте.
float volume - громкость. Тут тоже всё просто, значение от 0 до 1.
float attenuation - затухание.
И тут снова чертовщина. Несмотря на то, что параметр можно гибко регулировать, на практике используются всего четыре значения. Им соответствуют числа от 0 до 4, где 0 — это совсем без затухания, а 4 - затухание на минимальной дистанции. Проблема, очевидно, в том, что эти числа - ненаглядные. К тому же формула, по которой данные числа превращаются в радиус затухания, пользователю неизвестна. Для удобства приведу её здесь:
float radius = 1000.0f / attenuation;
1000 — это просто магическое число, когда-то подобранное Кармаком для Quake и перекочевавшее в GoldSource без изменений. Воспользовавшись формулой, можно рассчитать, что для ATTN_NORM = 0.8, максимальный радиус воспроизведения составляет 1250 юнитов. Конечно, было бы гораздо удобнее задавать этот радиус безо всяких формул, прямо в редакторе. Видимо этим и объясняется тот факт, что значения, отличные от констант, почти никто и никогда не использовал.
int fFlags - с ним тоже всё непросто.
Дело в том, что, как я уже упоминал, сервер звуки не проигрывает. Он их отсылает на клиент. Для этого строится сетевое сообщение. В рамках экономии сетевого трафика, пересылать все аргументы на клиент вовсе необязательно. К примеру, зачем пересылать громкость если она всегда равна единице? Вот и выходит, что все параметры нужно пометить особым образом - надо их пересылать или нет. Удобнее всего это сделать, выставляя биты в одной целочисленной переменной. То есть флаги.
Это внутренняя кухня механизма отправки события воспроизведения звука и в норме пользователь не должен знать какие именно биты для отправки каких параметров были выставлены. В Quake так оно и было. Но в GoldSource понадобился дополнительный контроль над звуками, в частности изменение громкости для уже играющих, а также тона. Это необходимо для всяких крутящихся штуковин, которые плавно набирают обороты и так же плавно останавливаются, а звук от их работы, соответственно, так же плавно набирает громкость и увеличивает тон воспроизведения.
В Valve не придумали ничего лучше, чем добавить флаги для контроля за этим поведением прямо в общий набор флагов, выставляемый при отправки звукового события на клиент. Путаницы добавляет ещё и то, что часть флагов напрямую управляет механизмом отправки на сервере, но на клиент при этом - не отсылается. Проще говоря, часть спецификации внутреннего протокола отправки звуков на клиент вывалили в общий доступ, тем самым затруднив возможность их дальнейшей модификации или полной переделки.
К тому же и сам пользователь может записать в эту переменную что-то лишнее, особых проверок там не проводится. Понятно, что потребность в той или иной фиче возникала "здесь и сейчас", а времени на грамотную реализацию, как всегда, не было. Но в норме внутренние флаги протокола и внешние пользовательские флаги должны быть разделены, смешивать их нельзя.
int pitch - последний параметр
В Quake его не было. Задаёт тон воспроизведения и частично влияет на скорость. Потому что смена тона без изменения скорости довольно дорогая операция и заниматься этим в реальном времени в 1998-м году смысла не было. Да и сейчас, наверное, мало кто этим заморачивается, всё же движок — это не звуковой редактор.
Стоит отметить, что благодаря даже таким простым элементам контроля как динамическая смена громкости и тона можно реализовывать много разных интересных эффектов, то есть эти изменения были однозначно позитивными. Плохо было то, что их проводили на скорую руку, не продумав новые интерфейсы управления, а наложив на уже существующие.
Прочие особенности прежней звуковой системы
В GoldSource есть, как вы помните, система сентенций, но про нее особо сказать нечего — это нечто вроде плейлиста из звуков, с возможностью указать для каждого персональную громкость, тон и тайм-компрессию.
Также присутствует простенький DSP-процессор, который автоматически включается, если игрок находится под водой, и регулируется гейм-дизайнером вручную во всех остальных случаях. Система смены эффекта устроена тоже крайне неинтуитивно - на уровень предлагается поставить точечный объект и настроить ему радиус. По замыслу тех, кто делал данную систему, игрок, оказавшись в радиусе этого объекта, активирует смену предустановленного стиля DSP-процессора. Вот только при выходе из радиуса обратной смены не происходит, для этого вам понадобится отдельный объект с другими настройками. Остается открытым вопрос, что случится если два объекта своими радиусами частично перекроют друг друга. В уже упоминавшемся Spirit Of Half-Life, предлагался более традиционный вариант - переключать эти настройки по обычному триггеру. Но это не сильно исправляло положение - дизайнеру очевидно надо было расставить эти триггеры на всех входах и выходах из помещения, причём триггеры, разумеется, не различали заходит ли игрок или выходит. Всё это привело к тому, что DSP-эффекты использовались не очень охотно, ввиду сложности настройки. Нормальной DSP-зоны, каковую предлагал из коробки тот же Unreal Engine, здесь увы, так и не появилось.
Отдельно отмечу тот факт, что подавляющее большинство настроек были малозаметными на слух. По большому счёту только два стиля DSP-процессора оказались востребованы - звук в вентиляции (или просто в трубе) и эхо в большом ущелье/каньоне.
Собственно, такими возможностями и располагал Xash3D, унаследовав от GoldSource звуковую систему, а значит и XashNT, до проведённого рефакторинга.
О том, что же изменилось в процессе рефакторинга, что было выброшено на свалку истории, а какие возможности добавились, я расскажу во второй части.