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



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

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


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



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


Давайте споём Хабр для всех!

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



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

  • Рендерер: Direct3D9, и полностью FFP — так что игра работает даже на оборудовании Intel, где до серии HD не было нормальной поддержки шейдеров. Почти все методы текстурирования работали через комбайнеры — далекий предок пиксельного затенения, где программист управлял целыми этапами пиксельного конвейера, а не писал программу напрямую, что накладывало множество ограничений. Поддерживаются: многослойные материалы, однопроходное сплат-маппинг для плавного текстурирования ландшафтов, отражения в кубических картах, плавный морфинг (вершинная анимация) с линейной интерполяцией между кадрами, MSAA (это заслуга GAPI), обрезка невидимой геометрии по видимости пирамида и примитивное альфа-смешение с ручной сортировкой.

  • Звук: 3D звук на DirectSound с позиционированием относительно источника звука, ускорением и т.д. Мне здесь особо нечего добавить, кроме загрузчика wav-файлов, я ничего не писал.

  • Ввод: WinAPI + DirectInput. Клавиатура опрашивалась с помощью классического GetAsyncKeyState, а геймпады опрашивались с помощью DirectInput. Еще была абстракция входных осей — чтобы не адаптировать управление под кучу разных контроллеров.

  • Менеджер ресурсов: довольно примитивный. В качестве менеджера ресурсов я также включу загрузчики - фреймворк поддерживает модели в форматах SMD (неанимированные сетки, формат Half-life) и MD2 (анимированные сетки, формат Quake 2, строго один материал на сетку), звуки - wav и простой самодельный формат конфигурации. Стандартный набор: отслеживание ресурсов по слабым ссылкам, пул ресурсов для исключения дублирующих загрузок и т.д.


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



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

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

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

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

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


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

Game.Initialize(новое GameApp());

Игра.Текущий.Выполнить();

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

https://pastebin.com/uba62bhu

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

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

  • Управляемый: D3D9 сам хранит копию текстуры или геометрии в оперативной памяти, а затем, при потере контекста, воссоздает аппаратные буферы и перезагружает необходимые данные.

  • Стандартный: данные загружаются непосредственно в видеопамять (в терминологии D3D — AGP-память), либо, если видеопамяти недостаточно, в оперативную память, если видеокарта, конечно, поддерживает Shared Memory Architecture.

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


И желательно загружать данные в пул по умолчанию. В противном случае при относительно большом количестве ресурсов игра начнет неадекватно «кушать» оперативную память (например, Civilization 5). Если контекст потерян, ресурсы необходимо перезагрузить с диска «на горячую»!

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

Формат материала в рамках выглядит следующим образом:

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

https://pastebin.com/X6vA76CU

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



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

https://pastebin.com/L0GYCkmt

В более поздней ветке была добавлена ​​нарезка по расстоянию от «глаз» игрока и по пирамиде прицела.

Перейдем к анимации. Существует три основных метода анимации геометрии в играх:

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

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

  • Object-Transform: классический метод иерархической анимации, очень похожий на скиннинг, только трансформируются не сами вершины, а прикрепленные к ним объекты. Его использовали, например, во многих играх на PS1 и GTA III (заметили недостаточную плавность суставов персонажей - это ОТ).


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

https://pastebin.com/Y5r9eDAk

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

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

❯ Звук и ввод


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

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

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

  • Слушатель: описание положения и других параметров "слушателя" - расположение ушей в игровом мире.

    Обратите внимание: «Дети стали рабами телефонов». Дмитрий Шепелев полностью запретил своим детям гаджеты.

    Обычно позиция слушателя совпадает с позицией игрока.

  • Источник: Описание источника звука в 3D-пространстве. Например, если мимо нас проносится машина, звуковому API необходимо знать положение, ускорение и дальность звука, чтобы правильно настроить звук в комнате.

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


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

https://pastebin.com/jrG2iaiT

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

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

На данный момент мне нужна только клавиатура и мышь:

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

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

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


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

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

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

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



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

п папоротники 0 0 10,8 0 0 0 1

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

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


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



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

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

https://pastebin.com/k9KXtreT

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

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

На данный момент это железные гонки. Но он водит. :))
Но с кем мы боремся?

❯ Боты


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

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

  • Путевые точки, размещаемые вручную: классика. Дизайнер уровней вручную расставляет путевые точки и задает для них параметры: например, на этом повороте нужно притормозить, а на этой прямой можно использовать газ.

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


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

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

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

частный Vector3 WorldToLocalSpace (Vector3 worldPoint)

{

Преобразование матрицы = Matrix.Invert(Matrix.RotationQuaternion(Rigidbody.Rotation) * Matrix.Translation(Rigidbody.Position));

Vector4 вектор4 = Vector4.Transform(новый Vector4(worldPoint, 1f), преобразование);

returnnew Vector3(vector4.X, вектор4.Y, вектор4.Z);

}

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

частный float AngleBetween (Vector3 v1) {

return (float) Math.Atan2((double) v1.X, (double) v1.Z) * 57.29578f;

}

Полная логика работы бота выглядит так:

Просто и понятно, не так ли?

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


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

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

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

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



Сами гонки можно запустить, зайдя в RaceManager и передав структуру RaceParameters:

общественная структура RaceParameters {

общедоступная строка Имя;

публичная строка Mode;

public int NumberOppents;

public int Сложность;

общественный int Цена;

общественный ИНТ ProgressAffection;

}


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

https://pastebin.com/mgQJjyJL

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

https://pastebin.com/0ExwDZaY'

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

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

❯ Заключение


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

Конечно, я хочу поделиться исходным кодом игры: вот он на GitHub.
Вот ссылки для скачивания демо-версии:

Гонки

Придворный шут

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

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

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

[my]GamedevИндиИгрыПрограммированиеГрафикаDirektexВидеокартаЖелезоЖигулиАвтоВАЗЛадаWindowsВидеоYouTubeДлинный пост 46 Support Emotions

Больше интересных статей здесь: Гаджеты.

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