
Истории о создании инди-игр всегда увлекательны. Но особенно интересны рассказы о разработке с полного нуля, без использования готовых игровых движков! У меня есть особая страсть к созданию минималистичных, но играбельных 3D-демо, которые могли бы запускаться даже на оборудовании двадцатилетней давности. В мае 2022 года я решил написать демо-версию гоночной игры в очень знакомом для многих сеттинге — с Жигулями, девятками, десятками и даже элементами тюнинга! В этой статье я подробно расскажу о процессе создания 3D-игры практически с чистого листа: о написании рендерера, менеджера ресурсов, загрузке уровней и графа сцены, реализации 3D-звука, интеграции системы ввода и физического движка. Интересна ли вам подробная статья в стиле «старого Пикабу» о разработке игры с нуля? Тогда добро пожаловать!
❯ Предыстория и вдохновение
На момент написания игры мне было 22 года. Если же отмотать на четыре года назад, ко времени моего совершеннолетия, то вспоминаются два ключевых события. Одно из них — отец сказал: «Открывай «Хулито», смотрим машину за 40 тысяч рублей». Понятно, что за такие деньги (около 700 долларов по тогдашнему курсу) выбор был невелик, и моим первым автомобилем стала карбюраторная «семёрка» 2001 года синего цвета. Мы с отцом её осмотрели, прокатились и решили — берём!

С тех пор я ездил на своём «тазике» без особых проблем — машина не ломалась, не подводила и почти не требовала вложений. Это пробудило во мне интерес к автомобилям, и со временем я полюбил российский автопром, особенно АвтоВАЗ. Вспомнив о некогда популярной, но не самой удачной игре Lada Racing Club, я захотел создать свои собственные «Жигулёвские гонки», причём написать всё с нуля. Поскольку готовых артов для города у меня не было, игра получила простое и понятное название: «Кубок ТАЗ по ралли» :)

Но с чего начать такой масштабный проект? Конечно, нужен был план. У меня уже был готовый самодельный 3D-каркас (фреймворк), который я использовал в одной из предыдущих демок — это был аренный шутер от первого лица с моделями из модов для Quake. Фреймворк работал неплохо, но для «Кубка ТАЗ» требовались некоторые доработки.

На старте разработки гонки у меня уже были реализованы следующие базовые системы:
Рендерер: на основе Direct3D9, использовался полностью Fixed Function Pipeline (FFP) — это обеспечивало работу даже на старом оборудовании Intel без нормальной поддержки шейдеров. Текстурирование работало через комбайнеры — далёких предков пиксельных шейдеров, где программист управлял целыми этапами конвейера, что накладывало ограничения, но было совместимо. Поддерживались: многослойные материалы, однопроходный сплат-маппинг для плавного текстурирования ландшафтов, отражения в кубических картах, плавный морфинг (вершинная анимация) с линейной интерполяцией, MSAA (заслуга API), отсечение невидимой геометрии и примитивное альфа-смешение.
Звук: 3D-звук на DirectSound с позиционированием источника, учётом скорости и т.д. По сути, я использовал готовый загрузчик WAV-файлов, не углубляясь в низкоуровневую реализацию микшера.
Ввод: WinAPI + DirectInput. Клавиатура опрашивалась через GetAsyncKeyState, геймпады — через DirectInput. Была реализована абстракция «осей ввода» для унификации управления с разных устройств.
Менеджер ресурсов: относительно простой. Включал загрузчики для моделей в форматах SMD (статичные сетки из Half-Life) и MD2 (анимированные сетки из Quake 2), звуков WAV и простого конфигурационного формата. Были базовые функции: отслеживание ресурсов, пул для избежания дублирующих загрузок.
Графика, которую выдавал фреймворк, была не самой продвинутой:

Но внутри он имел простую и расширяемую архитектуру, на которой можно было построить что-то интересное. Незадолго до этого я сделал на нём демо шутера от первого лица, которое работало даже на древних интегрированных видеокартах вроде VIA UniChrome!
Минимальное приложение выглядело очень просто:

Приведённый код загружал и отрисовывал текстурированную модель перед камерой. Всё просто и понятно. Но как это работает «под капотом»? Давайте разберёмся.
❯ Архитектура и рендерер
В своих проектах я придерживаюсь определённой архитектуры: есть ядро (движок), которое управляет созданием окна, организацией игрового цикла и инициализацией подсистем. Поскольку игра обычно запускается в одном экземпляре, сам движок реализован как синглтон и содержит ссылки на все модули.
Game.Initialize(new GameApp());
Game.Current.Run();
Как уже упоминалось, рендерер построен на Direct3D9. Выбор пал на DX9 из-за его широкой распространённости на старом железе, хорошей обратной совместимости (работает на GPU времён DX8 и даже DX7) и иногда лучшей производительности на видеокартах ATI. Всё начинается с создания контекста устройства (Device). В параметрах указываются разрешение, уровень сглаживания MSAA, видеорежим и частота обновления.
https://pastebin.com/uba62bhu
При создании контекста есть нюансы: многие старые интегрированные видеокарты не поддерживают аппаратную обработку вершин (D3DCREATE_HARDWARE_VERTEXPROCESSING), поэтому нужен флаг программной обработки. Разные GPU поддерживают разные форматы буфера глубины. Карты до серии GeForce 5/6 не поддерживали «чистый» режим (Pure Device), который отключает некоторые проверки для повышения производительности.
Важный аспект — управление ресурсами (текстурами, вершинными и индексными буферами). В отличие от OpenGL, в Direct3D есть понятие «потери устройства» (Device Lost) — если пользователь сворачивает полноэкранное приложение или падает драйвер, контекст может быть потерян. Для корректного восстановления ресурсы нужно хранить в определённых пулах:
Управляемый (Managed): D3D9 сам хранит копию данных в оперативной памяти и при потере контекста автоматически воссоздаёт ресурсы в видеопамяти.
По умолчанию (Default): данные загружаются напрямую в видеопамять (или в AGP-память). При нехватке видеопамяти могут использоваться системные ресурсы, если это поддерживается.
Системный (System): ресурсы хранятся только в оперативной памяти. Для игр этот пул слишком медленный и используется редко.
Для производительности лучше использовать пул по умолчанию, иначе игра может потреблять слишком много оперативной памяти. Но если контекст потерян, ресурсы из этого пула нужно перезагружать вручную.
Теперь о рисовании геометрии. Внешний вид объектов задаётся материалами, которые определяют, какую текстуру использовать, как объект отражает свет и т.д. В современных движках материалы гибкие благодаря шейдерам. В моём случае шейдеров не было, набор параметров был фиксированным и зависел от возможностей видеокарты: вершинное затенение по Гуро/Фонгу, цвет, туман и т.п.
Формат материала во фреймворке выглядел так:

Даже без шейдеров можно было создать гибкую систему с помощью комбайнеров, как в Quake 3. Ранние 3D-ускорители не могли смешивать несколько текстур за один вызов отрисовки, поэтому использовали многопроходный рендеринг. Комбайнеры, появившиеся позже, позволяли смешивать текстуры с помощью операций (Add, Sub, Mul и т.д.). Я использовал их для эффектов вроде плавного смешивания текстур на ландшафте:
https://pastebin.com/X6vA76CU
Основная проблема комбайнеров — сложность управления состояниями, из-за чего код выглядит запутанно. Маска для смешивания текстур выглядела так:

Перейдём к отрисовке. Основной метод — DrawMesh, который принимает матрицу преобразования или мировые координаты. Изначально геометрия отрисовывалась через DrawIndexedPrimitiveUP (DIPUP), так как почти все модели были анимированы на CPU (морфинг), и не было смысла загружать их в GPU каждый кадр. Для статичной геометрии в другой ветке я использовал обычный DrawIndexedPrimitive. Важно отметить, что DIPUP на старых GPU мог быть медленным для сложных сцен.
https://pastebin.com/L0GYCkmt
Позже была добавлена отсечение геометрии по расстоянию от камеры и пирамиде видимости.
Теперь об анимации. В играх есть три основных метода анимации 3D-моделей:
Скиннинг (Skinned Animation): анимация на основе скелета. Вершины трансформируются относительно костей. Идеально подходит для персонажей и часто интегрируется в граф сцены движка.
Морфинг (Vertex Morphing): классический метод, где каждый кадр анимации — это отдельный набор вершин. Игра интерполирует вершины между кадрами для плавности. Использовался в Quake 2.
Трансформация объектов (Object-Transform): иерархическая анимация, где трансформируются не вершины, а прикреплённые объекты. Часто встречался в играх на PS1.
Я не мастер скиннинга в 3D-редакторах, поэтому для своих демо использовал морфинг с интерполяцией. Без интерполяции анимация выглядела бы рваной (как в Quake 1 с отключённой интерполяцией):
https://pastebin.com/Y5r9eDAk
Вот как выглядела работа с анимацией:

Итак, рендерер — одна из самых сложных частей — готов. Но игра требует и других подсистем.
❯ Звук и система ввода
Реализация звука в играх — задача не самая сложная, если не требуется программный микшер или сложные 3D-эффекты. Для большинства игр достаточно несжатого WAV (PCM-поток).
В качестве аудио-API я выбрал DirectSound. Это удобный и широко поддерживаемый API (хотя сейчас его сменил XAudio). DirectSound сам занимается микшированием, а на старых звуковых картах AC97 даже было аппаратное ускорение! На современных системах микширование программное, но это не проблема.
В DirectSound есть два ключевых объекта: интерфейс IDirectSound8 (управление звуковой картой) и буферы для данных. В играх обычно оперируют тремя понятиями:
Слушатель (Listener): описывает положение «ушей» в игровом мире (обычно совпадает с позицией игрока).
Источник (Source): описывает источник звука в 3D-пространстве (позиция, скорость, дальность слышимости).
Поток/Буфер (Stream/Buffer): содержит сами аудиоданные. Может быть статическим (звук загружен полностью) или потоковым (для длинной музыки).
Вот упрощённая реализация воспроизведения звука:
https://pastebin.com/jrG2iaiT
Теперь в игре есть звук!
Но игрок должен как-то взаимодействовать с игрой. В Windows для этого используются разные API: DirectInput для стандартных USB-геймпадов и рулей, XInput для контроллеров Xbox, а клавиатуру и мышь можно опрашивать через WinAPI (GetAsyncKeyState или обработку оконных сообщений).
На начальном этапе мне хватило клавиатуры и мыши:

В идеале систему ввода стоит абстрагировать от физических устройств. Для этого вводятся понятия «осей» (например, «руление», «газ/тормоз») и «действий» («переключить передачу»). Это позволяет легко переназначать управление и поддерживать разные типы контроллеров.
Итак, у нас есть основа. Пора переходить к созданию самой игры!
❯ Создание уровней и редактор
Поскольку во фреймворке не было встроенного графа сцены, мне пришлось реализовать загрузку уровней с нуля под конкретные нужды игры. Сначала я использовал Blender как редактор уровней и писал скрипт для экспорта данных в файл.
Однако Blender (особенно старые версии) не очень удобен для работы с большими игровыми картами. Поэтому встал вопрос о создании собственного графа сцены и редактора.
Граф сцены в моём случае — это просто линейный список объектов на уровне. Каждый объект наследуется от базового класса Entity (для невидимых объектов) или PhysicsEntity (для объектов с физикой). Базовый объект в редакторе имел только имя и флаг выделения.

Можно было бы использовать, например, редактор Unity с экспортером, но я решил написать свой: простое приложение на Windows Forms с панелью, где движок рендерит сцену. Редактор загружает уровень так же, как и игра, но вместо игрока использует свободную камеру.

Формат уровня был максимально простым (текстовый, одна строка на объект), следуя принципу KISS (Keep It Simple, Stupid):
p fern 0 0 10.8 0 0 0 1
Где:
p — класс объекта (Prop, «украшение»).
fern — имя модели-реквизита.
0 0 10.8 — позиция в мире (X, Y, Z).
0 0 0 — вращение в углах Эйлера (внутри преобразуется в кватернионы).
Сами реквизиты описывались в отдельных конфигурационных файлах с параметрами коллизии, материала и т.д.

❯ Физика автомобиля
После подготовки графа сцены я приступил к реализации физики автомобиля. Я не писал физический движок с нуля, а использовал готовую библиотеку, взяв за основу модель колеса и доработав её, в основном вынеся параметры трения в публичные свойства.

Колёса реализованы по классическому принципу рейкастинга: из центра колеса выпускается луч вниз, определяется поверхность и рассчитывается сила трения. Сегодня для реалистичности часто используют «Волшебную формулу Пачейки», но для демо хватило и упрощённой модели.
Поскольку класс автомобиля стал слишком большим (управление коробкой передач, тюнинг, рендеринг), я вынес физику в отдельный класс CarPhysics:
https://pastebin.com/k9KXtreT
Как видно из метода Move, машина получилась полноприводной с двумя управляемыми осями (конечно, передними). Конфигурацию можно было легко изменить. Коллизия кузова — простой OBB (ориентированный ограничивающий параллелепипед).
Вот как это выглядело в действии:
Пока что это были «железные» гонки. Но машина ехала! :))
Однако с кем же гоняться?
❯ Искусственный интеллект соперников (боты)
Я не назвал этот раздел «ИИ», потому что поведение ботов было очень простым. Здесь не было поиска пути — боты просто ехали по заранее расставленным на карте точкам (путевым точкам или waypoints). Это стандартный подход для многих гоночных игр. Существует несколько способов реализации навигации для соперников:
Путевые точки с поиском пути: сложный метод, позволяющий боту самостоятельно находить маршрут в открытом мире (как в серии GTA). Требует много данных о дорожной сети.
Путевые точки, расставленные вручную: классика. Дизайнер уровня расставляет точки и задаёт параметры (например, «здесь притормозить»).
Предзаписанные траектории: разработчики сами проезжают трассу на лучшем времени, а боты повторяют эту запись.
Некоторые разработчики идут на хитрости: чтобы боты не выглядели «тупыми», им искусственно завышают сцепление или максимальную скорость. Помните, как в NFS Underground соперники моментально догоняли вас? Вот это оно.
Самый честный метод — когда боты используют те же виртуальные «органы управления», что и игрок (нажимают газ, тормоз, поворачивают руль). Чтобы машины не ехали «паровозиком», каждой добавляют небольшой случайный фактор.
Я выбрал классические путевые точки с подсказками.
Алгоритм работы бота:
1. Найти угол между направлением автомобиля бота и следующей путевой точкой. Для этого координаты точки преобразуются в локальное пространство автомобиля с учётом его поворота.
2. Вычислить угол с помощью atan2 и преобразовать радианы в градусы.
3. В зависимости от угла повернуть руль, при необходимости — нажать газ или тормоз.
Вот как выглядела ключевая функция преобразования координат:
private Vector3 WorldToLocalSpace(Vector3 worldPoint)
{
Matrix transform = Matrix.Invert(Matrix.RotationQuaternion(Rigidbody.Rotation) * Matrix.Translation(Rigidbody.Position));
Vector4 vector4 = Vector4.Transform(new Vector4(worldPoint, 1f), transform);
return new Vector3(vector4.X, vector4.Y, vector4.Z);
}
А вот полная логика принятия решений ботом:

Всё просто и прозрачно.
❯ Гараж, тюнинг и организация гонок
Какие гонки без... самих гонок? Поскольку ресурсов на создание города не было, я сделал трассу на пересечённой местности. Режимов гонок планировалось два: кольцевые заезды и спринты от точки к точке.
Также в игре должен был быть гараж, где игрок покупает новые машины и тюнингует старые. Стартовой машиной была «копейка» или «Москвич», а дальше — побеждай в гонках, зарабатывай деньги и улучшай авто. Мечтал я повторить успех Lada Racing Club!
Я начал с реализации гаража. Это отдельный уровень со своим контроллером и первым в фреймворке простым GUI — меню со списками. Гараж делился на разделы: тюнинг, выбор гонки, автосалон.
https://pastebin.com/dakm4Avb
Параллельно разрабатывалась система тюнинга — апгрейды описывались в текстовых файлах и влияли на параметры автомобиля. К сожалению, визуальных изменений моделей не предусматривалось — не было ресурсов на их создание.

Чтобы начать гонку, RaceManager получал структуру с параметрами:
public struct RaceParameters
{
public string Name;
public string Mode;
public int NumberOpponents;
public int Difficulty;
public int Price;
public int ProgressAffection;
}
После этого игра загружала уровень, расставляла ботов на стартовой сетке (игрок всегда был последним) и начинала заезд.
https://pastebin.com/mgQJjyJL
В каждом кадре вычислялись позиции всех участников:
https://pastebin.com/0ExwDZaY
Логикой движения ботов управляли они сами. На первых порах был костыль для определения финиша, но в целом гоночный цикл работал. :)
Вот мы и подошли к моменту, когда демо-версия игры была готова! Она запускалась даже на GeForce 4, хотя и с оговорками (для полной оптимизации под старые карты нужно было бы сжимать текстуры и упрощать некоторые эффекты).
❯ Итоги и выводы
Вот так я написал прототип гоночной игры за одну неделю. Да, за семь дней можно создать работающий каркас игры с нуля. Понимаю, что в комментариях игру будут сравнивать с Lada Racing Club и шутить о сроках — но в этом и был вызов! На мой взгляд, игр про Лады, сделанных с душой, слишком мало. Вот что у меня получилось в итоге:
Исходный код игры доступен на GitHub.
Ссылки для скачивания демо-версии:
Гонки
Придворный шут
Для меня это был интересный вызов, и я его завершил — получилась рабочая демка! Я вижу, что вас, читатели, интересует тема отечественной разработки игр, графического программирования и «железа». Возможно, темой следующей статьи станет описание архитектуры графических ускорителей конца 90-х, история их API и написание 3D-игры для легендарной 3dfx Voodoo с нуля на Glide!
Также интересно было бы рассказать про графический API знаменитого «3D-замедлителя» S3 Virge. Интересна ли вам такая тема? Пишите в комментариях!
Статья подготовлена при поддержке TimeWeb Cloud. Подписывайтесь на меня и @Timeweb.Cloud, чтобы не пропускать новые материалы!
[my]GamedevИндиИгрыПрограммированиеГрафикаDirektexВидеокартаЖелезоЖигулиАвтоВАЗЛадаWindowsВидеоYouTubeДлинный пост 46 Support EmotionsБольше интересных статей здесь: Гаджеты.
Источник статьи: Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?.