[Из песочницы] Как не надо разрабатывать звуковые движки
Программируя звук в приложениях и в играх, мне часто приходилось переписывать всю кодовую базу звуковых модулей, так как многие из них обладали либо слишком запутанной архитектурой, либо наоборот ничего не умели кроме простого проигрывания звуков.
Со звуковыми движками хорошо подходит аналогия с рендером изображения в играх: Если у тебя слишком простой pipeline с большим кол-вом абстракций, то ты вряд ли сможешь адекватно программировать что-то сложнее чем куб с шестеренками. С другой стороны, если у тебя весь код состоит из прямых OpenGL или D3D вызовов, то ты не сможешь без боли масштабировать свой спагетти-код.
Насколько уместно сравнение с графическим рендером?
В рендере звука происходят те же процессы, что и в рендере графики: Обновления ресурсов из игровой логики, обработка данных в удобоваримый вид, пост-обработка, вывод конечного результата. Все это может занимать довольно большой промежуток времени, поэтому для показательности я используя свою аудио библиотеку для теста производительности рендера.
Помимо чтения файла из SSD диска, декодирования Opus файла и записывания его даты в микшерный буфер, библиотека создает имитацию объемного звука, обрабатывает сигнал с помощью DSP модулей (компрессор, эквалайзер), а также ресемплирует сигнал. Конфиг машины, на которой проводился тест: Inte Core i9 9900 4.5GHz, 32GB RAM, SSD 480GB SATA. Ресемплинг принимал на вход сигнал с частотой дискретизации 48000Гц и выдавал с 44100Гц.
FRESPONZE_BEGIN_TEST
if (!pFirstListener) return false;
// Обновляем размеры буферов если параметры аудио-девайса изменились
if (RingBuffer.GetLeftBuffers()) return false;
RingBuffer.SetBuffersCount(RING_BUFFERS_COUNT);
RingBuffer.Resize(Frames * Channels);
OutputBuffer.Resize(Frames * Channels);
tempBuffer.Resize(Channels, Frames);
mixBuffer.Resize(Channels, Frames);
for (size_t i = 0; i < RING_BUFFERS_COUNT; i++) {
tempBuffer.Clear();
mixBuffer.Clear();
pListNode = pFirstListener;
while (pListNode) {
/* Обновляем значения эмиттеров и обрабатываем сигнал */
EmittersNode* pEmittersNode = nullptr;
if (!pListNode->pListener) break;
pListNode->pListener->GetFirstEmitter(&pEmittersNode);
while (pEmittersNode) {
tempBuffer.Clear();
pEmittersNode->pEmitter->Process(tempBuffer.GetBuffers(), Frames);
// Микшируем сигнал
for (size_t o = 0; o < Channels; o++) {
MixerAddToBuffer(mixBuffer.GetBufferData((fr_i32)o), tempBuffer.GetBufferData((fr_i32)o), Frames);
}
pEmittersNode = pEmittersNode->pNext;
}
pListNode = pListNode->pNext;
}
/* Обновляем кольцевые буфера для вывода отрендеренного звука */
PlanarToLinear(mixBuffer.GetBuffers(), OutputBuffer.Data(), Frames * Channels, Channels);
RingBuffer.PushBuffer(OutputBuffer.Data(), Frames * Channels);
RingBuffer.NextBuffer();
}
FRESPONZE_END_TEST("Audio render")
[00:00:59:703]: 'Audio render' operation passed: 551 microseconds
[00:00:59:797]: 'Audio render' operation passed: 512 microseconds
[00:00:59:906]: 'Audio render' operation passed: 541 microseconds
[00:01:00:000]: 'Audio render' operation passed: 583 microseconds
Если добавить уже несколько элементов, то время рендера будет сопоставимо с графическими рендером движком, с единственным отличием в том, что это все происходит в одном потоке. Если же это все попробовать распараллелить на систему задач, сделать более оптимальные алгоритмы микширования, то время рендера с большим количеством уникальных звуков может уменьшиться в несколько раз.
Каким стоит делать звуковой рендер для игр?
Чтобы ответь на этот вопрос, нужно уточнить ваши первоначальные данные. Если вы — инди-разработчик, и вы не обладая знаниями в звуке решили разрабатывать игру на C++, то вам подойдут простые библиотеки вроде SoLoud или OpenAL. Они сочетают в себе удобство более продвинутых систем и относительно неплохим функционалом, но при этом обладают важнейшим недостатком — плохая переносимость. Так как у всех этих библиотек API элементарный и монолитный, то сложно представить себе портирования с OpenAL на тот же Wwise.
В пример могу поставить звук из одного популярного движка. В нем есть как интерфейсы высокого уровня — ref_sound
и ISoundManager
, с помощью которых можно манипулировать звуками на уровне объектов, которые могут проигрываться, останавливаться, обладать дополнительными звуковыми свойствами, а также эмулировать перемещение в виртуальном пространстве. Проще говоря — это то, что видит разработчик игровой логики по аналогии с UAudioComponent
в Unreal Engine.
void Class::Function()
{
// проигрывание звука с позицией игрока и без повтора
snd.play_at_pos(0, Position(), false);
// игровой код
if (IsHappened())
{
// ...
}
// ...
}
Помимо высокого уровня, в звуковом модуле есть элементы низкого уровня — CSoundRender_Target
, отвечающий за вывод конкретного звука через низкоуровневое API OpenAL или DirectSound, и CSoundRender_Cache
— модуль кэширования декодированных Vorbis звуков. Самое необычным здесь является target
— он отвечает не за вывод замикшированного сигнала, а за вывод звука отрендеренного с помощью комбинации source
+ emitter
.
Как выглядит сейчас этот звуковой движок
По этой причине, Core часть звукового движка проблематично портировать как на высокоуровневые фреймворки (FMOD либо Wwise), так и на низкоуровневые прослойки над системным API (PortAudio).
А как бы он мог выглядеть, если из него вырезать ненужные компоненты
Основные архитектуры звуковых движков
Как говорилось раннее, движок должен состоять из двух частей — низкоуровневой (hardware) и высокоуровневой (mixer). Низкоуровневая часть отвечает либо за вывод звука напрямую в динамик, либо за вывод звука через дополнительную прослойку, облегчающую работу с системным API. Высокоуровневая часть отвечает за микширование и управление звуками.
Приведу пример:
void GameScheduler::Update() {
// ...
// То, за что отвечает высокоуровневая часть.
// Мы хотим проиграть звук, а низкоуровневый модуль пусть
// позаботится об этом сам.
SoundManager::StopSound(id);
// ...
}
// ...
void AudioHardware::Update() {
// ...
// Упрощенная реализация работы низкоуровневой части:
// помимо операций копирования, здесь могут быть системные
// функции вывода звука в системный микшер.
AudioMixer::Render(input, frames);
memcpy(output, input, frames * channels * frame_size);
// ...
}
Такая архитектура позволяет очень легко поменять реализацию одних из модулей на что-то более продвинутое с точки зрения технологий. Тот же AudioHardware
я могу написать как через прослойку PortAudio, так и напрямую через Windows Audio Session API. Также и с SoundManager
— он может быть переписан с использованием библиотек FMOD или Wwise. В этом случае работу модуля AudioHardware
принимает на себя именно фреймворк, и вам даже не придется думать о реализации вывода звука.
Высокоуровневая часть звукового движка может быть реализована с помощью разных архитектур: routing
и emitters-source
систем. Первая в основном используется в DAW, и представляет из себя звуковые дорожки, которые связаны между собой с помощью систем маршрутизаций. Это позволяет посылать сигнал из одного канала в другой, делать side-chain
из одного канала в другой, а также использовать сразу несколько звуков на одной дорожке. Данный функционал подходит для рабочего софта, но никак не подходит для игровых движков из-за сложности в реализации, а также высоких требований к железу.
Как выглядит современная звуковая система
По этой причине мы возьмем вторую архитектуру — emitters-source
. В ней есть 2 объекта — источник звука (source), который ничего кроме чтения, декодирования и ресемплинга звука не делает, и обработчик звука (emitter) — на его плечах лежит обработка сигнала с помощью фильтров, позиционирование объекта, пост-обработка. В модифицированной архитектуре emitters-source
также предусматривается и возможность использования нескольких обработчиков на одном источнике звука (в системах Wwise и FMOD это называется virtual emitters
), что позволяет проигрывать тысячи одинаковых звуковых без сильной нагрузки на железо.
Пример реализации emitters-source
системы можно посмотреть на этом репозитории. Здесь я воспользовался библиотекой miniaudio, поэтому проблем с реализацией вывода звука у меня не было.