Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

Статьи про инди-разработку игр — это всегда интересно и занимательно. Но статьи про разработку игр с нуля, без каких-либо игровых движков — ещё интереснее! У меня есть небольшой фетиш, заключающийся в разработке минимально играбельных 3D-демок, которые нормально работали бы даже на железе 20-летней давности. Полтора года назад, в мае 2022 года, я написал демку гоночной игры с очень знакомым всем нам сеттингом — жигули, девятки, десятки, и всё это даже с тюнингом! В этой статье я расскажу вам о разработке 3D-игр практически с нуля: рендерер, менеджер ресурсов, загрузка уровней и граф сцены, 3D-звук, ввод и интеграция физического движка. Интересна подробнейшая якорная статья о разработке игры с нуля? Тогда добро пожаловать!

❯ Предыстория

На момент написания статьи, я всё ещё остаюсь достаточно юным — буквально 5 дней назад мне исполнилось 22 года. Но если откатиться на 4 года назад и вспомнить момент наступления моего совершеннолетия, то на ум приходят сразу два значимых события: отец приходит в один день и говорит «открывай юлито, будем смотреть авто за 40 тыщ рублей». Понятное дело, что за эту сумму (~700$ по тому курсу) особо не разгуляешься, поэтому мой выбор пал на карбюраторную «семерочку», свою ровесницу (2001 год) синего цвета. Приехали с батькой смотреть на машину, обкатали и приняли решение — надо брать!

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

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

Поём всем дтф!
Поём всем дтф!

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

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

На момент начала разработки гоночки у меня уже были готовы следующие фишки:

  • Рендерер: Direct3D9, причём полностью FFP — для того, чтобы игра запускалась даже на встройках Intel, где нормальной поддержки шейдеров до HD-серии вообще не было. Практически все текстурные техники работали через комбайнеры — дальний предок пиксельных шейдеров, где программист оперировал целыми стадиями пиксельного конвейера, а не писал программу напрямую, что накладывало множество ограничений. Поддерживались: многослойные материалы, однопроходной сплат-маппинг для плавного текстурирования ландшафтов, отражения в кубмапах, плавный морфинг (вершинная анимация) с линейной интерполяцией между кадрами, MSAA (это заслуга GAPI), отсечение невидимой геометрии по пирамиде видимости и примитивный альфа-блендинг с ручной сортировкой.
  • Звук: 3D-звук на DirectSound с позиционированием относительно источника звука, ускорением и т. п. Тут моей заслуги особо нет, кроме загрузчика wav-файлов я ничего не писал.
  • Ввод: WinAPI + DirectInput. Клавиатура опрашивалась с помощью классического GetAsyncKeyState, в то время как геймпады с помощью DirectInput. Была и абстракция осей ввода — дабы не адаптировать управление под кучу разных контроллеров.
  • Менеджер ресурсов: достаточно примитивен. К менеджеру ресурсов я отнесу и загрузчики — фреймворк поддерживал модели в форматах SMD (не анимированные меши, формат Half-life) и MD2 (анимированные меши, формат Quake 2, строго один материал на один меш), звуки — wav и простенький самопальный формат конфигов. Стандартный набор: трекинг ресурсов на слабых ссылках, пул ассетов для исключения дублирующейся загрузки и т. п.

Фреймворк выдавал не слишком крутую графику:

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

Зато был очень простым «под капотом», имел довольно неплохую расширяемую архитектуру и в целом, на нем можно было запилить что-то прикольное. Где-то за неделю я запилил вот такую демку шутера от первого лица:

Игрушка даже на VIA UniChrome работала — последователе всем известного S3 Savage/S3 Virge!

Минимальное приложение выглядело примерно так:

public sealed class GameMain : IGameApp { private Mesh mesh; private Material material; private Vector3 pos = new Vector(0, 0, -150); public void OnCreate() { mesh = Engine.Current.Data.GetMesh("test.md2"); material = new Material(); material.Text = Engine.Current.Data.GetTexture("test.png"); } public void OnDraw() { Engine.Current.Graphics.DrawMesh(mesh, 0, material, pos, Vector3.Zero); } public void OnExit() { } public void OnDrawGUI() { } public void OnUpdate() { } }

Приведённый выше код нарисует модельку с текстурой перед лицом игрока. Всё просто и понятно. Однако, как это всё работает «под капотом»? Давайте попробуем разобраться:

❯ Основа и рендерер

В своих играх я стараюсь придерживаться одной архитектуры: есть условный класс Engine, который управляет созданием платформозависимых окон, организацией главного цикла игры и инициализацией подсистем. Поскольку в одном процессе обычно запущен только один экземпляр игры (исключение — выделенные авторитарные серверы с комнатами, на манерLeft 4 Dead), сам по себе Engine является синглтоном и содержит в себе ссылки на все необходимые подмодули.

Game.Initialize(new GameApp()); Game.Current.Run();

Как я уже говорил выше, сам по себе рендерер построен на базе графического API Direct3D9. Выбор DX9 обусловлен его распространенностью на железе прошлых лет, хорошей совместимостью (DX9 легко запускается на железе времен DX8 и даже DX7) и иногда лучшей производительностью на видеочипах от ATI. По сути, всё начинается с создания контекста или устройства в терминологии DirectX: в параметрах создания контекста указывается ширина и высота вторичного буфера, желаемый уровень сглаживания MSAA, видеорежим и частота желаемая частота обновления экрана.

internal Graphics() { d3d = new Direct3D(); CreateDevice(); Camera = new Camera(); } private void CreateDevice() { int msaa = PickBestMSAALevel(Game.Current.Config.GetInt("renderer.msaa")); bool fullScreen = Game.Current.Config.GetBool("renderer.fullScreen"); PresentParameters pp = new PresentParameters(); pp.AutoDepthStencilFormat = Format.D24S8; pp.BackBufferCount = 1; pp.BackBufferWidth = fullScreen ? Game.Current.Config.GetInt("engine.width") : Game.Current.Width; pp.BackBufferHeight = fullScreen ? Game.Current.Config.GetInt("engine.height") : Game.Current.Height; pp.BackBufferFormat = !fullScreen ? Format.Unknown : Format.X8R8G8B8; pp.DeviceWindowHandle = Game.Current.Form.Handle; pp.EnableAutoDepthStencil = true; pp.SwapEffect = SwapEffect.Discard; pp.Windowed = !fullScreen; pp.MultiSampleType = (MultisampleType)msaa; pp.FullScreenRefreshRateInHz = 0; pp.MultiSampleQuality = 0; pp.PresentFlags = PresentFlags.None; pp.PresentationInterval = PresentInterval.Default; pp.SwapEffect = SwapEffect.Discard; device = new Device(d3d, 0, DeviceType.Hardware, Game.Current.Form.Handle, CreateFlags.HardwareVertexProcessing | CreateFlags.PureDevice, pp); Game.Current.Log.Print("Initialized graphics"); Game.Current.Log.Print("Total video memory: " + device.AvailableTextureMemory / 1024 / 1024); } private int PickBestMSAALevel(int level) { int ret = level; for (int i = level; i > 1; i--) { if (d3d.CheckDeviceMultisampleType(0, DeviceType.Hardware, Format.Unknown, !Game.Current.Config.GetBool("renderer.fullScreen"), (MultisampleType)i)) { ret = i; continue; } } if(ret != level) Game.Current.Log.Print("Picked MSAA x" + ret + " , as your GPU doesn't support desired level"); return ret; }

При создании контекста есть свои нюансы, которые необходимо учитывать — например, большинство встроенных видеокарт не поддерживают аппаратную обработку вершин (D3DCREATE_HARDWARE_VERTEXPROCESSING), из-за чего создание контекста будет заканчиваться ошибкой без соответствующего флага, разные видеокарты поддерживают разные форматы буфера глубины и трафарета (сейчас видеокарты нативно даже 24х-битный RGB для рендертаргетов не умеют использовать, только выравненный XRGB), а видеокарты до GF5xxx-GF6xxx не поддерживали Pure режим D3D, который предполагает, что программист возлагает всю обработку ошибок на себя, при этом количество проверок в самом GAPI уменьшается, благодаря чему мы получаем небольшой выигрыш в производительности.

Важно так же отметить такой аспект, как управление ресурсами. К ресурсам видеокарты в терминологии старых GAPI относятся текстуры и буферы (как вершинные, так и индексные). В OpenGL особо нет такого понятия, как Device Lost. Если пользователь сворачивает ваше приложение из полноэкранного режима, или, например, видеодрайвер крашится — то GL сам должен позаботится о перезагрузке ресурсов обратно в видеопамять (исключение — Android и iOS, на мобилках контекст не уничтожится, но ресурсы будут выгружены и их хендлы станут некорректными). У D3D есть событие Lost, которое вызывается при потенциальной потере контекста — и его тоже нужно грамотно обрабатывать. Поэтому в D3D есть несколько пулов:

  • Managed: D3D9 сам сохраняет копию текстуры или геометрии в ОЗУ, а затем при потере контекста пересоздаёт аппаратные буферы и перезагружает нужные данные сам.
  • Default: данные загружаются напрямую в видеопамять (в терминологии D3D — AGP memory), или, если видеопамяти не хватает — в ОЗУ, если видеокарта, конечно, поддерживает Shared Memory Architecture.
  • System: загрузка ресурсов только в ОЗУ. Этот пул обычно не используется в играх — слишком медленно.

И грузить данные желательно в пул Default. Иначе при относительно большом количестве ресурсов, игра начнет «жрать» ОЗУ не в себя (пример — Civilization 5). При потере контекста, ресурсы нужно перезагружать с диска «на горячую»!

Переходим к самому важному — отрисовке геометрии. Для задания внешнего вида объектов на экране, используются так называемые материалы, которые содержат в себе данные о том, какая текстура должна быть наложена на объект, насколько объект отражает свет, какая техника должна использоваться и т. п. В современных движках система материалов обычно гибкая, поскольку шейдеры могут принимать самые разные параметры. В нашем случае шейдеров нет вообще, набор параметров фиксирован и зависит от видеокарты: стандартные техники типа повершинного затенения по Фонгу/Гуро, цвет объекта, туман и т. п.

Формат материалов в фреймворке выглядит вот так:

public struct Material { public Shader Shader; // Null for default public Texture Texture; public Texture Detail; public Texture Detail2; public Texture Reflection; public MaterialDetailType DetailType; public MaterialFlags Flags; public bool NonLit; public bool SampleShadowMap; public Color4 Color; public bool DepthTest; public bool DepthWrite; // false - write public Vector2 UVScale; public static Material CreateDefault() { return new Material() { Color = new Color4(1, 1, 1, 1) }; } }

Однако даже без шейдеров была возможность сделать относительно гибкую систему материалов — с помощью комбайнеров, как это делала Quake 3. Самые первые 3D-ускорители не поддерживали смешивание нескольких текстур за один вызов отрисовки, поэтому некоторые игры шли на ухищрение: к примеру Quake вручную сортировал геометрию по отдаленности без использования буфера глубины, он просто… накладывал альфа-блендингом ту же самую геометрию с затененной текстурой освещения (лайтмапа). Это называется многопроходной рендеринг. Комбайнеры, которые появились ближе к концу 90-х, позволяли смешивать несколько текстур с помощью различных операций (Add, Sub, Mul, Xor и т. п.), а также умножать финальный цвет на определенный коэффициент. Именно комбайнеры я использовал в своём фреймворке для реализации некоторых относительно сложных эффектов — например, плавное смешивание текстур на ландшафте:

Основная проблема комбайнеров — каша из стейтов, поэтому код выглядит не особо презентабельно. Входная текстура-маска выглядит вот так:

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

Переходим к отрисовке. По сути, за рисование полигональной геометрии отвечает один метод — DrawMesh, с несколькими перегрузками (в идеале — основной должен принимать матрицу трансформации, а остальные принимать обычные World-space координаты, из которых будет построена матрица трансформации). В оригинале метод рисует геометрию с помощью DIPUP, поскольку практически вся геометрия в игре была анимирована (и анимация, само собой, обрабатывалась для каждой вершины софтварно, на ЦПУ, поэтому я не видел разницы между перезаливкой геометрии на GPU каждый кадр и DIPUP), однако в одном из бранчей фреймворка я переписал отрисовку статику на обычный DIP. Обратите внимание, что DIPUP для комплексной геометрии на старых GPU будет слишком медленным — когда-то этим страдал графический движок Irrlicht.

public void DrawMesh(Mesh mesh, int frame, Material mat, Vector3 position, Vector3 rotation, Vector3 scale, float morphFactor, Vector2 uv) { if(mesh != null) { Matrix matrix = Matrix.Identity; matrix *= Matrix.Scaling(scale) * Matrix.RotationY(rotation.Y * Mathf.DegToRad) * Matrix.RotationZ(rotation.Z * Mathf.DegToRad) * Matrix.RotationX(rotation.X * Mathf.DegToRad) * Matrix.Translation(position); if (mat.Flags.HasFlag(MaterialFlags.ShadowMesh)) matrix *= Matrix.Shadow(new Vector4(0, 1, 1, 0), new Plane(new Vector3(0, 1, 0), 0.95f)); // Note: This is hardcoded. We should calculate mesh height by it's bounding box(not implemented yet) device.SetTransform(TransformState.World, matrix); SetRenderState(mat); if (mat.Flags.HasFlag(MaterialFlags.ShadowMesh)) { SetShadowMeshState(); } else { device.SetRenderState(RenderState.Lighting, !mat.NonLit); SetTextureState(mat); } bool lerpEnable = Game.Current.Config.GetBool("renderer.lerp"); if (morphFactor > 0) { if (frame < mesh.Frames.Length && lerpEnable) { mesh.UpdateMorphTarget(frame, frame + 1, morphFactor); } } if (morphFactor > 0 && lerpEnable) device.DrawUserPrimitives<Vertex>(PrimitiveType.TriangleList, mesh.Frames[frame].Length / 3, mesh.MorphVerts); else device.DrawUserPrimitives<Vertex>(PrimitiveType.TriangleList, mesh.Frames[frame].Length / 3, mesh.Frames[frame]); } }

В более позднем бранче добавилось отсечение по дистанции от «глаз» игрока и по пирамиде видимости.Переходим к анимации. Есть три основных метода анимации геометрии в играх:

  • Скиннинг: анимация вершин относительно скелета модели. Очень хорошо подходит для различных персонажей. Весь скелет является иерархией, где каждый элемент трансформируется относительно позиции родителя, что позволяет легко интегрировать «скелетку» в граф-сцены самого движка (Unity — самый яркий пример). Иногда скелетку используют и для «неоживленных» предметов — например, анимация подвески авто.
  • Морфинг: классический способ анимации суть которого заключается в «запекании» всех кадров в виде множества мешей. Затем игра интерполирует вершины между кадрами анимации, благодаря чему достигается эффект плавности.
  • Object-Transform: классический метод иерархической анимации, очень похож на скиннинг, только трансформируются не сами вершины, а привязанные к ним объекты. Применялась, например, во многих играх на PS1 и в GTA III (замечали отсутствие плавности в местах сочленений персонажей — это и есть OT).

Я не умею нормально работать с скиннингом моделей в 3D-редакторах и обычно не юзаю скиннинг в своих игрушках — для небольших демок хватает обычного морфинга с интерполяцией. Если интерполяцию не использовать, то анимация будет выглядеть топорно (в Quake 1 при отключении CVar'а такая и была):

internal void UpdateMorphTarget(int currFrame, int nextFrame, float time) { for(int i = 0; i < MorphVerts.Length; i++) { MorphVerts[i].Position = Vector3.Lerp(Frames[currFrame][i].Position, Frames[nextFrame][i].Position, time); MorphVerts[i].Normal = Vector3.Lerp(Frames[currFrame][i].Normal, Frames[nextFrame][i].Normal, time); MorphVerts[i].UV = Frames[currFrame][i].UV; } }

Работа с анимациями выглядела вот так:

По сути, одна из самых комплексных частей — рендерер, готова. Однако в играх требуются и другие подсистемы, которые реализовать куда проще.

❯ Звук и ввод

Реализация звука в играх задача не шибко сложная, если дело не доходит до программной реализации микшера, 3D-позиционирования и различных эффектов. Большинству игр хватает обычного не-сжатого wav, звук в котором хранится в виде PCM-потока.

В качестве API для звука я выбрал DirectSound. Очень удобное API, хотя сейчас его фактически вытеснил XAudio. DirectSound поддерживает любые звуковые карты, сам занимается микшированием звука, а в некоторых старичках типа AC97 умеет даже аппаратное ускорение! На современных машинах обычно микширование реализовано полностью софтварно, дабы не упираться в количество каналов/память на борту звукового адаптера, но в прошлом это помогало снизить нагрузку на процессор.

В DirectSound есть два основных объекта: сам IDirectSound8, представляющий интерфейс к звуковой карте и управлению её ресурсами и буфер — который может быть подкреплен как собственными данными, так и данными из другого буфера. В играх, они делятся на три базовых понятия:

  • Слушатель: описание позиции и иных параметров «слушателя» — позиции ушей в игровом мире. Обычно позиция слушателя совпадает с позицией игрока.
  • Источник: описание источника звука в 3D-пространстве. Например, если мимо нас проносится машина, то звуковому API необходимо знать позицию, ускорение и дальность звука, дабы правильно скорректировать звук в пространстве.
  • Поток: поток, который содержит в себе звук. Может быть как обычным буфером, куда звук уже предварительно загружен, так и потоковым буфером, куда загружается часть музыки или другого длинного трека.

Переходим к реализации примитивного звука:

public struct WavHeader { public int chunkId; public int chunkSize; public int format; public int subchunkid; public int subchunksize; public short audioFormat; public short numChannels; public int sampleRate; public int byteRate; public short blockAlign; public short bitsPerSample; public int subchunk2Id; public int subchunk2Size; } public enum AudioType { Music, Effect } public sealed class AudioStream { private SecondarySoundBuffer buffer; public AudioType Type { get; set; } public float Volume { get { return buffer.Volume; } set { buffer.Volume = (int)(value * -2000.0f); } } public bool IsPlaying { get { return buffer.Status == (int)BufferStatus.Playing; } } public AudioStream() { } public void Play() { buffer.Play(0, PlayFlags.None); } public void Stop() { buffer.Stop(); } public void Upload(int sampleRate, int align, int channels, byte[] data, int size) { if(data != null) { if (buffer != null) buffer.Dispose(); SoundBufferDescription desc = new SoundBufferDescription(); desc.Flags = BufferFlags.Software | BufferFlags.ControlVolume; desc.Format = new SharpDX.Multimedia.WaveFormat(sampleRate, align, channels); desc.BufferBytes = size; buffer = new SecondarySoundBuffer(Game.Current.AudioManager.ds, desc); DataStream ds2 = null; DataStream strm = buffer.Lock(0, size, LockFlags.EntireBuffer, out ds2); strm.WriteRange<byte>(data, 0, size); buffer.Unlock(strm, ds2); } } public unsafe static AudioStream LoadWav(System.IO.Stream strm) { BinaryReader reader = new BinaryReader(strm); WavHeader hdr = new WavHeader() { chunkId = reader.ReadInt32(), chunkSize = reader.ReadInt32(), format = reader.ReadInt32(), subchunkid = reader.ReadInt32(), subchunksize = reader.ReadInt32(), audioFormat = reader.ReadInt16(), numChannels = reader.ReadInt16(), sampleRate = reader.ReadInt32(), byteRate = reader.ReadInt32(), blockAlign = reader.ReadInt16(), bitsPerSample = reader.ReadInt16(), subchunk2Id = reader.ReadInt32(), subchunk2Size = reader.ReadInt32() }; byte[] data = new byte[strm.Length - sizeof(WavHeader)]; reader.Read(data, 0, data.Length); AudioStream stream = new AudioStream(); stream.Upload(hdr.sampleRate, hdr.bitsPerSample, hdr.numChannels, data, data.Length); return stream; } public unsafe static AudioStream LoadVorbis(System.IO.Stream strm) { Vorbis.Vorbis vorbisStrm = new Vorbis.Vorbis(strm); return null; } } public sealed class AudioManager { internal DirectSound ds; private PrimarySoundBuffer buffer; internal AudioManager() { ds = new DirectSound(); ds.SetCooperativeLevel(Game.Current.Form.Handle, CooperativeLevel.Normal); SoundBufferDescription desc = new SoundBufferDescription(); desc.Flags = BufferFlags.PrimaryBuffer; desc.Format = null; buffer = new PrimarySoundBuffer(ds, desc); } }

Теперь мы можем воспроизводить звуки в нашей игре!

Однако, нам нужно чтобы пользователь мог взаимодействовать с нашей игрой. Для этого в разных системах есть различные API для взаимодействия с устройствами ввода. В случае Windows — это DirectInput для обычных USB-геймпадов и рулей, и XInput для геймпадов, совместимых с Xbox 360/Xbox One. Нажатия с клавиатуры можно обрабатывать двумя способами: с помощью событий WM_KEYDOWN и WM_KEYUP и функции WinAPI GetAsyncKeyState.

Пока что мне нужна только клавиатура и мышь:

public sealed class Input { private KeyboardState[] keyboardState; [DllImport("user32.dll")] private static extern short GetAsyncKeyState(Keys vKey); public Input() { this.keyboardState = new KeyboardState[(int) byte.MaxValue]; } private bool IsPressed(Keys key) { return ((int) Input.GetAsyncKeyState(key) & 32768) > 0; } public KeyboardState GetKeyState(Keys key) { return this.keyboardState[(int) key]; } public Vector2 GetMousePosition() { if (Game.Current.Form.IsDisposed) return Vector2.Zero; Point client = Game.Current.Form.PointToClient(new Point(Cursor.Position.X, Cursor.Position.Y)); return new Vector2((float) client.X, (float) client.Y); } internal void Update() { for (int key = 0; key < (int) byte.MaxValue; ++key) { if (this.keyboardState[key] == KeyboardState.Hot) this.keyboardState[key] = KeyboardState.Pressed; if (this.keyboardState[key] == KeyboardState.Idle && this.IsPressed((Keys) key)) this.keyboardState[key] = KeyboardState.Hot; if (this.keyboardState[key] == KeyboardState.Pressed && !this.IsPressed((Keys) key)) this.keyboardState[key] = KeyboardState.Idle; } } }

В идеале обработку ввода лучше абстрагировать от физических устройств взаимодействия. Для этого вводятся понятия осей, кнопок действий и т. п. Желательно сразу продумать, как игра будет работать с геймпадом и уже затем назначать кнопкам на клавиатуре действия абстрактного геймпада.

Ну что ж, самый базовый фреймворк для игр у нас есть. Пора переходить к написанию самой игры!

❯ Редактор уровней

Поскольку у фреймворка нет какого-либо своего графа сцены, я реализовываю механизм загрузки уровней в каждой игре с нуля — под конкретные нужды. В какой-то игре нужен стриминг для открытого мира, в другой — быстрая загрузка уровней, где есть множество объектов с разными параметрами. Изначально я использовал Blender в качестве редактора уровней и экспортировал карты небольшим скриптом, который сохранял основные параметры в файл.

Однако блендер (особенно 2.79 и ниже) не очень удобный редактор для работы с достаточно большими картами. Поэтому в определенный момент встал вопрос с организацией графа сцены и собственного редактора карт.

Граф сцены и графом то не назовешь — это просто линейный список объектов, которые присутствуют на сцене. Каждый объект наследуется от базового абстрактного типа Entity, если это «невидимый» объект, или PhysicsEntity, если объект должен интегрироваться с физическим движком. У базового объекта есть только имя и флаг выборки в редакторе.

public abstract class Entity { public string Name { get; set; } public bool IsSelected { get; set; } public virtual void Update() { } public virtual void Draw() { } public virtual void TransparentDraw() { } public virtual void Destroy() { } public override string ToString() => this.Name; } }

Вообще, для редактирования уровней можно хоть редактор Unity использовать, предварительно написав экспортер в самопальный формат. Однако я решил реализовать свой редактор: как обычное Windows Forms приложение + панель, в которую движок рендерит картинку. В его реализации нет ничего необычного: он точно также загружает уровень, как и основная игра, но при этом не создаёт игрока и ботов и имеет свободную камеру.

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

Формат уровней примитивный донельзя. В процессе разработки небольших игрушек я обычно следую принципу KISS и не люблю распыляться сложными сериализаторами/десериализаторами и прочими заморочками, реализовывая лишь самые необходимый функционал. Формат карт — текстовый, одна линия на один объект:

p ferns 0 0 10.8 0 0 0 1

Где p — «класс» объекта, в случае p — это Prop, «декорация».ferns — модель пропа. При этом сами пропы описаны в отдельных текстовых файлах, где в виде key-value значений хранятся настройки коллизии, материала, текстуры и т. п.XYZ — позиция в мире.XYZ — поворот в мировых координатах, задаётся в углах Эйлера (это только для статики, которая не подвержена Gimbal Lock, под капотом вся работа идёт с кватернионами).

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

❯ Физика автомобилей

После того, как граф сцены был готов, я приступил к реализации физики автомобилей. Но как я уже говорил, физический движок я использовал готовый — т. е. вся работа по резолвингу столкновений, распаралелливанию вычислений и Joint'ам сводилась чисто к нему. Я лишь использовав физику колеса, реализовал поведение машинки, внеся в него некоторые изменения: в основном — вынес в публичные свойства параметры трения колеса.

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

Само колесо реализовано по классическому принципу рейкастинга — колесо пускает под себя лучи и определяет трение относительно поверхности, на котором стоит, при этом двигая остальное тело используя собственное ускорение. Сейчас в играх для более точной симуляции используется Pacejka Magic Formula — формула, позволяющая рассчитать физически корректное поведение покрышки с различными диаметрами.

Поскольку класс сущности машинки слишком большой и требует контроля самых разных аспектов (коробка передач, аспекты тюнинга, отрисовка и материалы), я вынес часть физики в отдельный класс CarPhysics:

public sealed class CarPhysics { private AudioStream[] hitSounds; private AudioSource srcHit; public Wheel[] Wheels { get; private set; } public Rigidbody Rigidbody { get; set; } public float MaxSteerAngle { get; set; } public float Acceleration { get; set; } public float MaxSpeed { get; set; } public Car Car { get; private set; } internal CarPhysics(Car car) { this.Car = car; this.Wheels = new Wheel[4]; this.hitSounds = new AudioStream[3] { Game.Current.Data.GetAudioStream("sound/hit1.wav"), Game.Current.Data.GetAudioStream("sound/hit2.wav"), Game.Current.Data.GetAudioStream("sound/hit3.wav") }; this.Car.game.track.JWorld.Events.BodiesBeginCollide += new Action<RigidBody, RigidBody>(this.OnCollide); } private void OnCollide(RigidBody arg1, RigidBody arg2) { if (arg1 != this.Rigidbody.Body && arg2 != this.Rigidbody.Body) return; new AudioSource(this.hitSounds[new Random().Next(0, this.hitSounds.Length - 1)]) { Position = this.Rigidbody.Position, Velocity = new Vector3(this.Rigidbody.Body.LinearVelocity.X, this.Rigidbody.Body.LinearVelocity.Y, this.Rigidbody.Body.LinearVelocity.Z) }.Play(); } public void Move(float gas, float steer) { if (this.Wheels[0] != null && this.Wheels[1] != null) { this.Wheels[0].SteerAngle = steer * this.MaxSteerAngle; this.Wheels[1].SteerAngle = steer * this.MaxSteerAngle; } this.Wheels[0].AddTorque(gas * this.Acceleration); this.Wheels[1].AddTorque(gas * this.Acceleration); this.Wheels[2].AddTorque(gas * this.Acceleration); this.Wheels[3].AddTorque(gas * this.Acceleration); this.Rigidbody.Body.LinearVelocity = new JVector(Mathf.Clamp(this.Rigidbody.Body.LinearVelocity.X, -this.MaxSpeed, this.MaxSpeed), Mathf.Clamp(this.Rigidbody.Body.LinearVelocity.Y, -this.MaxSpeed, this.MaxSpeed), Mathf.Clamp(this.Rigidbody.Body.LinearVelocity.Z, -this.MaxSpeed, this.MaxSpeed)); } public void PreStep(float step) { for (int index = 0; index < this.Wheels.Length; ++index) { if (this.Wheels[index] != null) this.Wheels[index].PreStep(step); } } public void PostStep(float step) { for (int index = 0; index < this.Wheels.Length; ++index) { if (this.Wheels[index] != null) this.Wheels[index].PostStep(step); } } }

Как можно видеть из метода Move, наша машинка полноприводная и имеет две управляющие оси (передние, само собой). Конфигурацию привода легко можно модифицировать в будущем.Коллизия кузова машинки — обычный OBB прямоугольник, ну или «коробка».

А вот как это работает на практике:

Пока что гонки на утюгах. Но ездит же. :))Но с кем мы гоняемся?

❯ Боты

Я не стал называть этот раздел ИИ — боты в игре слишком примитивные. Здесь нет никакого поиска пути, боты просто ездят по заранее отмеченным точкам на карте, которые называютсявейпоинтами. Это стандартная практика во многих гонках, однако её реализация отличается от игры к игре. Вообще, для гонок есть несколько общеизвестных практик реализации навигации противников:

  • Вейпоинты с поиском путей: довольно комплексный метод, который позволяет сделать, например, гонки в открытом городе, где боты сами смогут находить путь к чекпоинтам. Подобный способ используется для гонок в GTA, например. Строго говоря, сам по себе поиск путей — это тоже набор чекпоинтов и преград, поэтому для такого метода навигации необходимо довольно большое количество информации (пути для трафика, светофоры и т. п).
  • Вручную раставленые вейпоинты: классика. Левелдизайнер вручную расставляет вейпоинты и задает им параметры: например, на этом повороте нужно притормозить, а на этой прямой можно поддать газку.
  • Заранее записанные пути: способ с вейпоинтами, где разработчики сначала сами катаются по трассе, стараясь выбить лучшее время, а затем используют записанный набор вейпоинтов для противников.

При этом некоторые разработчики не стесняются красивых фейков: реализация реалистичного входа в поворот с крутой физикой может быть сложной, особенно когда боты «тупые», поэтому в некоторых играх ботам намеренно подкручивали управляемость или максимальную скорость. Помните, как быстро нагоняли соперники в NFS Underground? Вот то-то же. :)

Некоторые могут вообще записать фейковый трек, по которым машина будет просто скользить, без учета физики авто. Но «беспалевные» реализации этого способа я пока не видел.По настоящему «трушный» способ — это когда противники используют всё те же способы, которые использует игрок — т. е. также «нажимают» на виртуальные кнопки и управляют осями автомобиля. Кроме того, частенько каждому сопернику подмешивают дополнительный фактор, куда он будет ехать — иначе машинки будут толпиться друг за другом и будет выглядеть не интересно.Я использую классические вейпоинты с подсказками.

Сначала нам необходимо получить угол между машинкой игрока и вейпоинтом. Для этого нам нужно перевести координаты текущего вейпоинта в локальные координаты машинки с учетом её поворота (т. е. относительно неё и её Forward-вектора). Поскольку поворот автомобиля считается в кватернионах, нам нужно помножить матрицу поворота на матрицу позиции машинки в мире и умножить позицию вейпоинта используя полученную инвертированную матрицу. Звучит сложно, на деле легко:

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); }

Если очень условно, то это выражение эквивалентно a — b с учетом поворота. Поскольку мы вычислили локальные координаты вейпоинта, нам остаётся только вычислить угол между ними с помощью классического atan2 и перевести радианы в градусы:

private float AngleBetween(Vector3 v1) { return (float) Math.Atan2((double) v1.X, (double) v1.Z) * 57.29578f; }

Полностью логика бота выглядит так:

Легко и просто, да?

❯ Гараж и гонки

Какой интерес в гонках без… гонок? Поскольку у меня не было особо ассетов для создания пригорода, я решил сделать пересеченную местность. А на пересеченной местности у нас есть как кольцевые гонки, так и спринт — от точки до точки.

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

Помимо этого, в игре должен быть гараж, где игрок мог бы купить новую машину или тюнинговать текущую. В начале игры выдавалась бы старая дедова копейка (модельки Оки не нашел), а то и Москвич, а потом игрок выигрывал бы в гонках и получал возможности про прокачке тачек и покупке новых. Эээх, лавры Lada Racing Club не дали покоя!

Начал я с реализации гаража. Сам по себе гараж — отдельный уровень, который обрабатывается своим контроллером, также в гараже применяется самый первый доступный UI в фреймворке — меню со списками. Сам гараж поделен на множество подменю: тюнинг, гонки и автосалон.

this.uiColor = new Color4(0.5f, 0.5f, 1f, 1f); this.uiRaces = new UIMenu(); for (int index = 0; index < 3; ++index) { RaceParameters p = this.GenerateRace(); this.uiRaces.AddItem(p.Name, (Action) (() => this.game.StartRace(p)), this.uiColor); } this.uiUpgrades = new UIMenu[7]; for (int index1 = 0; index1 < this.uiUpgrades.Length; ++index1) { this.uiUpgrades[index1] = new UIMenu(); for (int index2 = 0; index2 < this.playerCar.CarInfo.Parts.Count; ++index2) { if (this.playerCar.CarInfo.Parts[index2].Class == index1) { string text = string.Format("{0} - {1}руб.", (object) this.playerCar.CarInfo.Parts[index2].Name, (object) this.playerCar.CarInfo.Parts[index2].Price); int partId = index2; this.uiUpgrades[index1].AddItem(text, (Action) (() => this.OnPurchasePart(partId)), this.uiColor); } } this.uiUpgrades[index1].AddItem("Вернуться", (Action) (() => this.currMenu = this.uiUpgrade), this.uiColor); } this.uiPaintBooth = new UIMenu(); this.uiUpgrade = new UIMenu(); this.uiUpgrade.AddItem("Блок цилиндров", (Action) (() => this.currMenu = this.uiUpgrades[0]), this.uiColor); this.uiUpgrade.AddItem("Топливная система", (Action) (() => this.currMenu = this.uiUpgrades[1]), this.uiColor); this.uiUpgrade.AddItem("Валы", (Action) (() => this.currMenu = this.uiUpgrades[2]), this.uiColor); this.uiUpgrade.AddItem("Зажигание", (Action) (() => this.currMenu = this.uiUpgrades[3]), this.uiColor); this.uiUpgrade.AddItem("Турбонаддув", (Action) (() => this.currMenu = this.uiUpgrades[4]), this.uiColor); this.uiUpgrade.AddItem("Трансмиссия", (Action) (() => this.currMenu = this.uiUpgrades[5]), this.uiColor); this.uiUpgrade.AddItem("Шины", (Action) (() => this.currMenu = this.uiUpgrades[6]), this.uiColor); this.uiUpgrade.AddItem("Высота пружин", (Action) (() => { }), this.uiColor); this.uiUpgrade.AddItem("Покраска", (Action) (() => this.currentState = GarageState.PaintBooth), this.uiColor); this.uiUpgrade.AddItem("Вернуться", (Action) (() => this.currMenu = this.uiMain), this.uiColor); this.InitCarDealer(); this.uiMain = new UIMenu(); this.uiMain.AddItem("События", (Action) (() => this.currMenu = this.uiRaces), this.uiColor); this.uiMain.AddItem("Тюнинг", (Action) (() => this.currMenu = this.uiUpgrade), this.uiColor); this.uiMain.AddItem("Автосалон", (Action) (() => { this.currMenu = this.uiCarDealer; this.playerCar.Destroy(); this.playerCar = (Car) null; }), this.uiColor); this.uiMain.AddItem("Статистика", (Action) (() => { }), this.uiColor); this.uiMain.AddItem("Главное меню", (Action) (() => { }), this.uiColor); this.currMenu = this.uiMain;

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

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?

Сами гонки можно было начать, обратившись к RaceManager и передав структуру RaceParameters:

public struct RaceParameters { public string Name; public string Mode; public int NumOpponents; public int Difficulty; public int Prize; public int ProgressAffection; }

После этого, игра загружала уровень, создавала ботов на месте spawnPoint (игрок оказывался, как обычно, последним) и запускала гонку.

Сам написал, сам погонял: Как я написал 3D-гонки «на жигулях» за неделю, полностью с нуля?
public void Begin(RaceParameters param) { this.Parameters = param; this.Cars = new Car[8]; this.game.track.Load(param.Name); foreach (PhysicsEntity entity in this.game.track.FindEntities("waypoint", true)) { Prop prop = (Prop) entity; this.Waypoints.Add(new Waypoint() { Position = entity.Rigidbody.Position, Flags = (WaypointFlags) prop.Flags }); } PhysicsEntity playerStart = this.FindPlayerStart(); if (playerStart == null) Game.Current.Log.Print("Warning: No player start!"); this.Cars[0] = new Car(this.game, this.game.PlayerProfile.Car.Name, false); this.Cars[0].Rigidbody.Position = playerStart.Rigidbody.Position; this.Cars[0].Rigidbody.Rotation = playerStart.Rigidbody.Rotation; PhysicsEntity[] entities = this.game.track.FindEntities("startMark"); string[] carList = Car.GetCarList(); this.carPositions = new int[entities.Length + 1]; for (int index = 0; index < entities.Length; ++index) { this.carPositions[index] = index + 1; this.Cars[index + 1] = (Car) new AICar(this.game, this, carList[new Random(DateTime.Now.Millisecond).Next(0, carList.Length - 1)], false); this.Cars[index + 1].SetColor(Car.Colors[new Random(DateTime.Now.Millisecond + index).Next(0, Car.Colors.Length - 2)]); this.Cars[index + 1].Rigidbody.Position = entities[index].Rigidbody.Position; this.Cars[index + 1].Rigidbody.Rotation = entities[index].Rigidbody.Rotation; this.Cars[index + 1].PlayerName = RaceManager.Names[index]; } this.isPlayerCarControllable = true; }

А затем каждый кадр просчитывала позиции каждого участника гонки:

private void CalculateCarPlaces() { Array.Sort<int>(this.carPositions, (Comparison<int>) ((a, b) => { int index; if (this.Cars[a] is AICar) index = ((AICar) this.Cars[a]).CurrentWaypoint; else index = this.CurrentCheckpoint; int num1; if (this.Cars[b] is AICar) num1 = ((AICar) this.Cars[b]).CurrentWaypoint; else num1 = this.CurrentCheckpoint; float num2 = Vector3.Distance(this.Cars[a].Rigidbody.Position, this.Waypoints[index].Position); float num3 = Vector3.Distance(this.Cars[b].Rigidbody.Position, this.Waypoints[index].Position); if (index > num1) return 1; if (index < num1) return -1; if ((double) num2 > (double) num3) return 1; if ((double) num2 < (double) num3) return -1; return 0; })); }

Всё! Логикой движения и всем остальным заправляли уже боты. Хотя, там и был костыль на первых этапах, который помечает флаг конца гонки, в остальном — функционал гоночек рабочий. :)

Вот мы и дошли до этапа, когда простенькая, но рабочая демка игры у нас уже есть! Игра запускается на GF4, однако работает не совсем корректно — но оптимизировать её под видеокарты тех лет не составит труда (в основном — пережать текстуры, убрать некоторые техники на комбайнерах и запечь статические пропы в батчи).

❯ Заключение

Вот так я и написал гоночки за неделю. Время разработки демки с нуля до состояния, которое вы видите в статье — всего неделю. Да, за это время реально написать прототип гоночной игры. И я ведь знаю, что в комментариях игру будут сравнивать с Lada Racing Club и шутить о сроках её разработки — ведь в этом и суть! Слишком мало реально прикольных ламповых гоночек на жигулях. Вот что у меня получилось в итоге:

Исходниками игры я конечно же поделюсь: тык на GitHub.

А вот линки на загрузку демки:

Ну а для меня это был своеобразный челлендж. И я его выполнил — у меня получилась рабочая демка на выходе! Я вижу что вам, моим читателям, интересна тематика самопальной разработки игр. Судя по комментариям, вам нравится тематика геймдева, программирования графики и разработки игр. Темой одной из следующих статей может стать описание архитектуры графических ускорителей конца 90х, история их API (без D3D) и написание 3D-игры для 3dfx Voodoo с нуля, на базе Glide!

Кроме того, я хотел бы рассказать о графическом API известного многим «3D декселератора» S3 Virge. Интересна ли вам такая рубрика? Пишите в комментариях!

Хотите поддержать автора? Там в комментариях есть кнопочнка рубля. Спасибо всем читателям, кто так или иначе помогает с контентом! :)

Вкуснючево и балдеж!
Хрючево!!!!
Нравятся статьи про программироние игр?
Конечно! Якорный контент на дтф!
Не нравятся.
Это же порносайт. Не понял причем здесь геймдев...

Статья подготовлена при поддержке TimeWeb Cloud. Подписывайтесь на меня и Таймвеб, чтобы не пропускать новые статьи каждую неделю!

172172
39 комментариев

Комментарий недоступен

12

Комментарий недоступен

10

Друзья! Уж не знаю, насколько якорная статья получилась. Пишите мнение в комментариях!

Исходники будут чуть-чуть попозже. Мой ноут с репозиторием гита перестал видеть хдд и мне удалось все починить как раз к моменту публикации статьи. Линки на загрузки демок рабочие.

Пока что в игре есть парочку нюаносв:

В игре нет нормального таймера. Физический движок не поддерживает переменные дельты для апдейтов, только фиксированные. В полноценных играх реализация таймера разная, но обычно ограничивают кол-во обновлений логики, если оно выше того, чем может выдать железо и "досимулируют" дополнительные кадры логики, если оно ниже желаемого FPS. Чуть позже допилю.

У игры косячные тени - связано это с методикой реализации. Стенсильные тени я сразу же отмел - трансформировать геометрию для них "ручками" будет довольно накладно. На DX9 можно запилить полноценные шедоумапы - я это делал в случае с шутером, но поддерживается только на относительно свежих видеокартах. Поэтому пока что, на время, я реализовал планарные тени - старая техника, которая заключается в трансформировании геометрии специальной матрицей, которая "сплющит" модель в одну из сторон относительно источника света. Такие тени косячные, чуть позже запилю проективные (как в Flatout 2).

7

Спасибо за статью, очень крутая!
А если не сложно, можешь в кратце рассказать:
1. Почему решил именно свой движок склепать?
2. Давно этим занимаешься?
3. С чего можно начать чтобы научится делать свои демки?
4. С чего ты начинал, какие были первые проекты? Можешь дать пару слов о своих первых проектах, сложно ли было начинать?
5. Где в основном берешь инфу и узнаешь о каких то фишках, которые потом пробуешь реализовать у себя?

2