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

Şrift:

Вместо предисловия

Это пятый том книги «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, пауза, итоги и титры. И напоследок — автокарта, которая открывается по мере того, как ты смотришь по сторонам.

Статус-бар

В Doom нижнюю полосу экрана занимает статус-бар: патроны, здоровье, броня, оружие, лицо героя. Сделаем свой — высотой 44 пикселя, на всю ширину. Пять ячеек: патроны, здоровье, оружие, лицо, убитые монстры и время. Ячейка — это крупное число красным и мелкая подпись под ним, листинг 27.1.



Листинг 27.1 — Ячейка статус-бара: число и подпись

Высоту статус-бара объявили в Game как static constexpr int kStatusBar = 44. Статическое поле принадлежит классу целиком, а не каждому объекту — поэтому к нему обращаются через имя класса: Game::kStatusBar, без всякой игры под рукой. Им пользуются три места: статус-бар, автокарта и оружие, которое теперь рисуется на 44 пикселя выше, чтобы не утонуть под панелью.



Листинг 27.2 — Статус-бар целиком


Строка 76 — range-for по списку в фигурных скобках: {110, 230, 296, 344, 470} — это std::initializer_list, и по нему можно пройтись, как по массиву. Удобно для пары-тройки чисел, ради которых не хочется заводить именованный массив.



Pulsuz fraqment bitdi.

Yaş həddi:
18+
Litresdə buraxılış tarixi:
25 oktyabr 2026
Yazılma tarixi:
2026
Həcm:
115 səh. 109 illustrasiyalar
Müəllif hüququ sahibi:
Автор
Yükləmə formatı:

Oxşar kitablar