Разработка 3D-гоночной игры «Кубок ТАЗ по ралли» с нуля за неделю: от рендерера до физики



Истории о создании инди-игр всегда увлекательны. Но особенно интересны рассказы о разработке с полного нуля, без использования готовых игровых движков! У меня есть особая страсть к созданию минималистичных, но играбельных 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-гонки «на жигулях» за неделю, полностью с нуля?.