Mini Doom современный C++ через игру том 5 шум свет и финальные титры

Алексей Примак
MINI DOOM современный C++ через игру том 5 шум свет и финальные титры
Вместо предисловия
Это пятый том книги «MINI DOOM. Современный C++ через игру». К концу четвёртого тома в лабиринте живут монстры: спрайты с Z-буфером, имп и зомби, нарисованные кодом, пистолет и дробовик, конечный автомат, поиск в ширину, огненные шары и смерть с рестартом.
Остался последний слой: звук, экраны, уровни в файлах и то, как всё это собирают и проверяют взрослые. Номера глав, листингов и рисунков в серии сквозные, поэтому ссылка «см. главу 12» ведёт туда же, куда и в однотомнике.
Серия целиком
— Том 1. Учимся держать оружие — главы 1–8: основы C++ в консоли, от первой программы до памяти и санитайзеров.
— Том 2. Окно в ад — главы 9–13: своё окно Windows, пиксели, игровой цикл, клавиатура и мышь, текст.
— Том 3. Третье измерение — главы 14–19: рейкастинг, стены, текстуры, пол и свет, двери.
— Том 4. Здесь кто-то есть — главы 20–25: спрайты, монстры, оружие, искусственный интеллект, смерть.
— Том 5. Шум, свет и финальные титры — главы 26–29: звук, экраны, уровни, CMake и тесты.
Все исходники серии — на GitHub: https://github.com/RushSex/MiniDoom. Папка examples/chNN для каждой главы, итоговая игра — examples/ch29/doom. Там же README с инструкцией по сборке в Visual Studio 2026. Каждая глава начинается с проекта предыдущей, так что можно начать читать этот том, взяв готовый код последней главы прошлого тома.
Часть V. Шум, свет и финальные титры
Четыре части позади, и у нас уже есть игра: лабиринт, двери, монстры, оружие, смерть. Но играть в неё — как смотреть фильм ужасов с выключенным звуком: вроде страшно, а вроде и нет. И начинается она сразу с выстрелов, без титульника, и кончается ничем — выхода с уровня нет.
Последняя часть превращает прототип в игру, которую не стыдно отдать другу. Звук — синтезированный кодом, как текстуры и монстры. Экраны: титульник с огнём, пауза, итоги уровня. Уровни в файлах, с проверкой, что их вообще можно пройти. И наконец, то, как всё это собирают и проверяют взрослые: CMake, автотесты, санитайзеры, статический анализ.
Глава 26. Звук из ничего
Попробуй вспомнить Doom без звука. Не получится: рычание импа из темноты, сухой хлопок дробовика, лязг двери — это половина атмосферы. Хорошая новость: для звука, как и для текстур, файлы не нужны. Любой звук — это просто последовательность чисел, а числа мы умеем считать.
В этой главе ты узнаешь, что такое сэмпл и из каких «кирпичиков» складываются звуки, напишешь синтезатор с огибающей и фильтром, микшер, который играет несколько звуков сразу, — и впервые запустишь в своей программе второй поток. А вместе с ним встретишь самую подлую ошибку многопоточности — гонку данных.
Звук — это числа
Звук — это колебания давления воздуха. Динамик двигает диффузор вперёд-назад, тот толкает воздух, воздух — барабанную перепонку. Компьютер управляет динамиком очень просто: много раз в секунду говорит ему «встань в такое-то положение». Каждое такое число называется сэмплом (sample — отсчёт), а сколько их в секунду — частотой дискретизации.

Рисунок 26.1 — Волна и её сэмплы: компьютер помнит только синие точки
Наш синтезатор работает на частоте 22 050 сэмплов в секунду — вдвое чаще, чем звук в Doom (11 025), и вдвое реже, чем компакт-диск (44 100). Сэмпл внутри программы — float от −1 до 1, а на звуковую карту уходит 16-битное целое от −32 768 до 32 767. По рисунку 26.1 видно главное: чем больше сэмплов на период, тем точнее волна. Частота дискретизации должна быть хотя бы вдвое выше самого высокого звука, который хочешь передать, — так что 22 050 честно передают звуки до 11 кГц. Для взрывов и рычания хватает с запасом.
Четыре кирпичика
Любой синтезатор, от игрушечного до студийного, начинается с генераторов — функций, которые рисуют одну волну. У волны есть фаза — где мы сейчас внутри периода, число от 0 до 1. Генератор по фазе отвечает, где диффузор. Классических форм четыре, и три из них — в листинге 26.1.

Листинг 26.1 — Синус, пила, меандр — и первые сэмплы ноты ля
Синус — чистый, мягкий тон, как свист. Пила растёт от −1 до 1 и падает обратно — звук резкий, «жужжащий», богатый обертонами: из неё получается рычание. Меандр (квадратная волна) прыгает между +1 и −1 — это голос старых приставок, «8-бит». Последняя строка вывода — шесть первых сэмплов ноты ля (440 Гц) в 16-битном виде: за шесть отсчётов волна прошла едва восьмую часть периода, ведь период — это 22 050 / 440 ≈ 50 сэмплов.
Четвёртый кирпичик — шум: случайное число в каждом сэмпле. Чистый шум — это шипение радио между станциями. Но если менять случайное значение не каждый сэмпл, а, скажем, 800 раз в секунду, а между сменами держать прежнее, шум получает «высоту»: низкий хриплый гул вместо шипения. Это приём «sample and hold», и из него получаются выстрелы и взрывы. Все четыре формы — на рисунке 26.2.

Рисунок 26.2 — Два периода каждой формы. Шум — ступеньки случайной высоты
Огибающая и фильтр
Генератор звучит бесконечно и одинаково громко. Настоящий звук так не делает: выстрел вспыхивает мгновенно и затихает, рычание нарастает и тянется. Форму громкости во времени задаёт огибающая — ещё одна функция, на которую умножаем каждый сэмпл. Наша — простейшая: короткий линейный подъём («атака») и плавное затухание по параболе, рисунок 26.3.

Рисунок 26.3 — Огибающая: громкость звука от начала до конца
Второй инструмент — фильтр нижних частот. Он срезает высокие, «пискливые» частоты и оставляет низкие. Устроен он изумительно просто — листинг 26.2, строки 57–68: выход y на каждом шаге догоняет вход на долю a. Представь тяжёлую лодку на верёвке: дёргай верёвку часто-часто — лодка почти не сдвинется (высокие частоты пропали), тяни медленно — лодка послушно идёт следом (низкие прошли). Чем ниже частота среза cutoff, тем меньше a и тем «тяжелее» лодка: звук становится глухим, как из-за стены.

Листинг 26.2 — Огибающая и фильтр нижних частот
Остаётся собрать всё вместе. Звук в нашей игре — рецепт: два слоя (генератор, начальная и конечная частота, громкость), атака, длина и частота среза фильтра, листинг 26.3. Частота слоя за время звука плавно едет от from к to: выстрел начинается высоким треском и проседает в бас, рык импа сползает вниз на октаву.

Листинг 26.3 — Слой и рецепт звука
Двенадцать рецептов — таблица прямо в коде, constexpr-массив в порядке перечисления SoundId. Вот первые четыре, листинг 26.4. Писать их — чистое удовольствие: меняешь число, собираешь, слушаешь.

Листинг 26.4 — Рецепты: пистолет, дробовик, имп, зомби
Строка 12 — using enum Shape из C++20: внутри этой области можно писать Noise вместо Shape::Noise. В таблице это экономит половину ширины строки.
Сам синтез — листинг 26.5. Для каждого слоя идём по сэмплам, двигаем фазу на freq / kSampleRate (столько периодов проходит за один сэмпл) и складываем значение генератора в общий буфер. Потом фильтр, огибающая и нормировка: все звуки приводим к пику 0,8, чтобы ни один не был тише остальных просто потому, что так получилось.

Листинг 26.5 — Синтез звука по рецепту

Листинг 26.5 (продолжение) — Синтез звука по рецепту
Строки 104–106. Частота едет не по прямой, а по степени: from · (to / from)^u. Ухо слышит не герцы, а их отношения: 110 → 220 Гц и 220 → 440 Гц — это одинаковый шаг, октава. При линейном спуске звук сначала почти стоял бы на месте, а в конце рушился вниз.
Строки 107–111. Когда фаза перевалила за 1, период закончился: вычитаем единицу и, для шума, берём новое случайное число. Вот и весь sample and hold.
Строка 93. Генератор случайных чисел у каждого звука свой, с собственным зерном, — шум каждый раз одинаковый, и звуки не меняются от запуска к запуску. Для тестов это важно так же, как для монстров в главе 22.
Послушаем, что получилось. Тест умеет записывать все звуки в WAV-файлы — листинг 26.6. Файлы лежат и в архиве примеров, в папке ch26/wav: открой любой плеером.

Листинг 26.6 — Синтезируем все звуки игры и записываем в WAV

Рисунок 26.4 — Настоящие звуки из WAV-файлов: выстрел, рык импа, взрыв
На рисунке 26.4 видна огибающая каждого звука: выстрел — короткий удар, рык импа нарастает и тянется, взрыв бьёт резко и долго гаснет. А внутри видны и слои: у взрыва в начале частый шум, к концу — медленные волны низкого синуса.

Микшер
В бою звучит много всего сразу: твой дробовик, рык импа, взрыв шара, лязг двери. А динамик один, и в каждый момент он может стоять только в одном положении. Решение — сложить: колебания воздуха от разных источников в жизни тоже просто складываются. Программа, которая это делает, называется микшер, листинг 26.7.

Листинг 26.7 — Микшер: сложить все звучащие голоса
Каждый звучащий звук — голос (Voice): указатель на волну из банка звуков, номер следующего сэмпла и громкость. play добавляет голос, mix заполняет буфер для звуковой карты: для каждого сэмпла складывает текущие сэмплы всех голосов, умноженные на громкость, и двигает их вперёд. Доигравшие голоса удаляем — знакомым std::erase_if. Голосов не больше 16: если места нет, самый старый замолкает. Doom поступал так же — у него было всего 8 каналов.
Строка 26 — важнейшая. Два звука с пиком 0,8 в сумме дают 1,6, а 16-битное целое не вмещает больше 32 767. Что будет без std::clamp — узнаешь в упражнении 26.2, и это стоит увидеть. С clamp громкие места просто срезаются: звук слегка «хрипит», но не превращается в мусор.
Остаётся вопрос: откуда микшер узнаёт, что пора играть? Game про звук ничего не знает и знать не должна — это та же стена между игрой и платформой, что и в главе 12. Поэтому игра лишь записывает события: «прозвучал выстрел, громкость 0,75» — листинг 26.8.

Листинг 26.8 — Звук в точке мира: чем дальше, тем тише
Громкость падает с расстоянием линейно и обнуляется на 16 клетках. Звуки дверей, выстрелов, взрывов, рыка — всё идёт через sound. openDoor заодно следит, чтобы дверь лязгала один раз, когда начинает открываться, а не каждый кадр, пока её держат открытой.
Главный цикл раз в кадр забирает накопленные события методом takeSounds:

Листинг 26.9 — Забрать события и очистить очередь
std::exchange(sounds_, {}) делает две вещи одним махом: кладёт в sounds_ пустой вектор и возвращает то, что там лежало раньше. Без копирования — вектор перемещается. Это стандартная идиома «забрать и обнулить».
Автотест, листинг 26.10, повторяет бой из главы 25 — игрок стоит в коридоре под огнём импов — и выписывает все события за две секунды. Звук здесь не нужен: проверяем логику.

Листинг 26.10 — Какие звуки рождает бой
— t 0.02 — два импа и зомби заметили игрока. Громкость рыка — по расстоянию: ближний имп 0,80, дальний 0,75, зомби в другом конце зала — 0,38.
— t 0.33 — оба импа бросили шары, слышно, как они вспыхнули.
— t 0.50 — наш выстрел (кадр 30) — 1,00, это мы сами. Следом — monster_pain: попали в импа.
— t 0.73 и 0.87 — шары долетели: вскрик игрока и взрыв почти на полной громкости — он ведь рядом.
— still playing: 1 — в конце двух секунд ещё не доиграл последний звук.
Второй поток
Звуковой карте нужны данные постоянно: 22 050 сэмплов каждую секунду, без пауз. Опоздаешь на пару миллисекунд — в колонках щелчок или заикание. А игровой цикл живёт своей жизнью: кадр может затянуться, окно — свернуться, Windows — задуматься. Звук нельзя кормить из игрового цикла, ему нужен собственный исполнитель, который занимается только им. Это поток (thread).
До сих пор у каждой нашей программы был ровно один поток: процессор шёл по коду строчка за строчкой. Второй поток — это второй «исполнитель», который идёт по своей функции одновременно с первым, на другом ядре процессора. В C++20 его создают одной строкой: std::jthread t(функция). Буква j — от join: когда объект jthread уничтожается, он дожидается, пока поток закончит работу. Старый std::thread в такой ситуации просто роняет программу.
Звучит прекрасно, но у двух исполнителей появляется общая проблема — общие данные. Вот простейший опыт, листинг 26.11: два потока по миллиону раз прибавляют единицу к одной переменной.

Листинг 26.11 — Два потока, одна переменная
Должно быть 2 000 000. Получилось почти вдвое меньше, и при каждом запуске — другое число. Дело в том, что ++counter — это не одно действие, а три: прочитать значение из памяти, прибавить единицу, записать обратно. Если два потока прочитали одно и то же значение, оба запишут одно и то же, и одна прибавка пропадёт, рисунок 26.5.

Рисунок 26.5 — Гонка данных: два потока прибавили по единице, а счётчик вырос на одну
Это называется гонка данных (data race): два потока одновременно обращаются к одной памяти, и хотя бы один пишет. По стандарту C++ гонка данных — неопределённое поведение, как выход за границу массива. Счётчик, который «немного недосчитал», — лучший из исходов. Хуже, когда один поток читает вектор, пока второй его расширяет: тогда первый читает освобождённую память.
Самое противное — гонки не воспроизводятся. Программа может месяц работать и упасть у одного игрока из тысячи. Поэтому для них, как и для памяти, есть санитайзер: ThreadSanitizer. Он следит за каждым обращением к памяти из каждого потока, листинг 26.12.

Листинг 26.12 — ThreadSanitizer ловит гонку
Под санитайзером счёт вдруг сошёлся — он сильно замедляет программу, и потоки реже сталкиваются. Но гонку он всё равно увидел: это его главная сила, ему не нужно, чтобы ошибка проявилась. Санитайзер называет обе строки: поток T2 читает counter в строке 16, а поток T1 перед этим писал в строке 11, и между ними нет никакой синхронизации. Ровно то, что на рисунке 26.5.
Лечение — мьютекс (mutex, от mutual exclusion — взаимное исключение). Это замок с одним ключом: кто взял ключ, тот работает с данными, остальные ждут у двери. В C++ ключ берут объектом std::scoped_lock: конструктор запирает мьютекс, деструктор отпирает. Это RAII из главы 7 — забыть отпереть невозможно, даже если внутри вылетит исключение. Листинг 26.13.

Листинг 26.13 — Тот же счётчик под замком

Теперь понятно, зачем в микшере mutex_ и почему его берут и play, и mix, и даже playing, которая только читает размер вектора. play вызывает поток игры, mix — звуковой поток, и вектор голосов у них общий. Вся схема — на рисунке 26.6.

Рисунок 26.6 — Два потока и микшер между ними
Обрати внимание, чего игра не делает: не вызывает микшер из глубины update, посреди расчётов. Она копит события у себя, а главный цикл отдаёт их пачкой. Чем реже и короче потоки встречаются у общего замка, тем меньше они друг друга ждут.
waveOut: звук в колонки
Последний шаг — отдать сэмплы Windows. Способов несколько, и самый старый и простой — waveOut, ровесник Windows 3.1. Он работает так: мы даём ему буфер с сэмплами, он его проигрывает и ставит в заголовке буфера флаг WHDR_DONE — «доиграл». Чтобы не было пауз, буферов три и они ходят по кругу, рисунок 26.7: один звучит, второй ждёт своей очереди, третий мы заполняем.

Рисунок 26.7 — Три буфера по кругу: звуковая карта не ждёт ни миллисекунды
Код — листинг 26.14. Это единственный файл главы, который знает про Windows; в автотест под Linux он не входит, как и main_win32.cpp.

Листинг 26.14 — Звуковой поток: waveOut и три буфера

Листинг 26.14 (продолжение) — Звуковой поток: waveOut и три буфера
Строки 14–16. Поток запускается прямо в списке инициализации конструктора: thread_ получает лямбду, которая вызовет run. Параметр std::stop_token — подарок jthread: через него поток узнаёт, что его просят остановиться. Деструктор jthread сам вызывает request_stop, а потом ждёт, пока run вернётся. Никаких флагов «пора выходить» руками.
Строки 28–33. waveOutOpen открывает звуковое устройство и обещает дёргать событие done каждый раз, когда буфер доиграл. Если звуковой карты нет (так бывает — например, на сервере сборки), просто выходим: игра будет беззвучной, но не упадёт.
Строки 45–53 — сердце потока. Проверяем каждый буфер: доиграл — заполняем свежими сэмплами из микшера и отдаём обратно. Потом спим, пока Windows не разбудит событием, но не дольше 20 мс, чтобы не прозевать просьбу остановиться.
Строки 54–59. Перед выходом — всё вернуть: waveOutReset останавливает воспроизведение и отдаёт буферы, потом разбираем заголовки и закрываем устройство. Буферы живут на стеке run, и выйти, пока Windows ещё в них пишет, — верный способ получить порчу памяти.

В main_win32.cpp добавилось шесть строк, листинг 26.15: банк звуков, микшер, вывод звука — и раздача событий после update.

Листинг 26.15 — Звук в главном цикле

Листинг 26.16 — Раздача событий микшеру
Порядок объектов в main тоже важен по той же причине: audio объявлен после mixer, а mixer — после bank. Уничтожатся они в обратном порядке: сначала остановится поток, потом исчезнет микшер, последним — банк звуков. Запусти игру, выстрели — и услышишь, как имп отзовётся из-за стены.

Итоги главы
— Звук — это сэмплы: 22 050 чисел в секунду от −1 до 1, на карту — 16-битными целыми.
— Кирпичики синтеза: синус, пила, меандр, шум; огибающая задаёт громкость во времени, фильтр срезает высокие частоты.
— Звук игры — рецепт из двух слоёв; таблица рецептов — constexpr-массив, using enum сокращает имена.
— Микшер складывает голоса и обрезает сумму std::clamp. Игра только записывает события; std::exchange забирает их пачкой.
— Звук живёт во втором потоке: std::jthread сам ждёт завершения, std::stop_token сообщает об остановке.
— Общие данные двух потоков — только под мьютексом; std::scoped_lock запирает и отпирает сам. Гонки ловит ThreadSanitizer.
— waveOut: три буфера по кругу. Поле с потоком — последнее в классе.
Упражнения
Упражнение 26.1. Что выведет? Листинг 26.17.

Листинг 26.17 — Что выведет программа?
Упражнение 26.2. Найди баг. Микшер без std::clamp складывает два громких звука, листинг 26.18. Сумма 1,5 — значит, на выходе должно быть что-то около максимума, 32 767. Почему числа такие?

Листинг 26.18 — Громко + громко = тихо и наоборот?
Упражнение 26.3. Напиши сам. Сделай звук стереофоническим: монстр слева должен звучать громче в левой колонке. Подсказка: угол на источник относительно взгляда — atan2, а waveOut умеет два канала.
Упражнение 26.4. Подумай. Метод Mixer::playing только читает размер вектора. Зачем ему мьютекс? Что может случиться без него?
Задачи
Задача 26.1. Мягкое ограничение. std::clamp срезает пики углом, и громкие места хрипят. Замени его на std::tanh(sum): малые значения он почти не трогает, а большие плавно прижимает к ±1. Послушай разницу на двух одновременных взрывах.
Задача 26.2. Звук шагов. Придумай рецепт короткого глухого шага и проигрывай его, пока игрок идёт, примерно дважды в секунду. Подсказка: фаза покачивания оружия bob_ из главы 22 уже считает шаги.
Задача 26.3. Не больше двух одинаковых. Когда очередь дробовика бьёт по толпе, звучит шесть одинаковых monster_pain — каша. Сделай, чтобы одного звука одновременно звучало не больше двух экземпляров: третий заменяет самый старый из них.
Глава 27. HUD и экраны
Наша игра до сих пор похожа на техно-демку: запустил — и ты уже в лабиринте, цифры здоровья висят по углам, а выйти с уровня некуда. Настоящая игра встречает титульником, позволяет поставить паузу, показывает итоги уровня и провожает титрами. Всё это — экраны, и в этой главе мы научимся ими управлять.
План такой: сначала статус-бар внизу экрана, как в Doom, — с лицом героя, которое морщится от боли. Потом машина состояний экранов, класс App. Потом титульник с живым огнём из PlayStation-версии Doom, пауза, итоги и титры. И напоследок — автокарта, которая открывается по мере того, как ты смотришь по сторонам.
Конец ознакомительного фрагмента.
Текст предоставлен ООО «Литрес».
Прочитайте эту книгу целиком, купив полную легальную версию на Литрес.
Безопасно оплатить книгу можно банковской картой Visa, MasterCard, Maestro, со счета мобильного телефона, с платежного терминала, в салоне МТС или Связной, через PayPal, WebMoney, Яндекс.Деньги, QIWI Кошелек, бонусными картами или другим удобным Вам способом.
Приобретайте полный текст книги у нашего партнера:
