Сам написал, сам погонял: Как я написал 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!
Минимальное приложение выглядело примерно так:
Приведённый выше код нарисует модельку с текстурой перед лицом игрока. Всё просто и понятно. Однако, как это всё работает «под капотом»? Давайте попробуем разобраться:
❯ Основа и рендерер
В своих играх я стараюсь придерживаться одной архитектуры: есть условный класс Engine, который управляет созданием платформозависимых окон, организацией главного цикла игры и инициализацией подсистем. Поскольку в одном процессе обычно запущен только один экземпляр игры (исключение — выделенные авторитарные серверы с комнатами, на манер Left 4 Dead), сам по себе Engine является синглтоном и содержит в себе ссылки на все необходимые подмодули.
Game.Initialize(new GameApp());
Game.Current.Run();
Как я уже говорил выше, сам по себе рендерер построен на базе графического API Direct3D9. Выбор DX9 обусловлен его распространенностью на железе прошлых лет, хорошей совместимостью (DX9 легко запускается на железе времен DX8 и даже DX7) и иногда лучшей производительностью на видеочипах от ATI. По сути, всё начинается с создания контекста или устройства в терминологии DirectX: в параметрах создания контекста указывается ширина и высота вторичного буфера, желаемый уровень сглаживания MSAA, видеорежим и частота желаемая частота обновления экрана.
При создании контекста есть свои нюансы, которые необходимо учитывать — например, большинство встроенных видеокарт не поддерживают аппаратную обработку вершин (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). При потере контекста, ресурсы нужно перезагружать с диска «на горячую»!
Переходим к самому важному — отрисовке геометрии. Для задания внешнего вида объектов на экране, используются так называемые материалы, которые содержат в себе данные о том, какая текстура должна быть наложена на объект, насколько объект отражает свет, какая техника должна использоваться и т. п. В современных движках система материалов обычно гибкая, поскольку шейдеры могут принимать самые разные параметры. В нашем случае шейдеров нет вообще, набор параметров фиксирован и зависит от видеокарты: стандартные техники типа повершинного затенения по Фонгу/Гуро, цвет объекта, туман и т. п.
Формат материалов в фреймворке выглядит вот так:
Однако даже без шейдеров была возможность сделать относительно гибкую систему материалов — с помощью комбайнеров, как это делала Quake 3. Самые первые 3D-ускорители не поддерживали смешивание нескольких текстур за один вызов отрисовки, поэтому некоторые игры шли на ухищрение: к примеру Quake вручную сортировал геометрию по отдаленности без использования буфера глубины, он просто… накладывал альфа-блендингом ту же самую геометрию с затененной текстурой освещения (лайтмапа). Это называется многопроходной рендеринг. Комбайнеры, которые появились ближе к концу 90-х, позволяли смешивать несколько текстур с помощью различных операций (Add, Sub, Mul, Xor и т. п.), а также умножать финальный цвет на определенный коэффициент. Именно комбайнеры я использовал в своём фреймворке для реализации некоторых относительно сложных эффектов — например, плавное смешивание текстур на ландшафте:
Основная проблема комбайнеров — каша из стейтов, поэтому код выглядит не особо презентабельно. Входная текстура-маска выглядит вот так:
Переходим к отрисовке. По сути, за рисование полигональной геометрии отвечает один метод — DrawMesh, с несколькими перегрузками (в идеале — основной должен принимать матрицу трансформации, а остальные принимать обычные World-space координаты, из которых будет построена матрица трансформации). В оригинале метод рисует геометрию с помощью DIPUP, поскольку практически вся геометрия в игре была анимирована (и анимация, само собой, обрабатывалась для каждой вершины софтварно, на ЦПУ, поэтому я не видел разницы между перезаливкой геометрии на GPU каждый кадр и DIPUP), однако в одном из бранчей фреймворка я переписал отрисовку статику на обычный DIP. Обратите внимание, что DIPUP для комплексной геометрии на старых GPU будет слишком медленным — когда-то этим страдал графический движок Irrlicht.
В более позднем бранче добавилось отсечение по дистанции от «глаз» игрока и по пирамиде видимости.
Переходим к анимации. Есть три основных метода анимации геометрии в играх:
Скиннинг: анимация вершин относительно скелета модели. Очень хорошо подходит для различных персонажей. Весь скелет является иерархией, где каждый элемент трансформируется относительно позиции родителя, что позволяет легко интегрировать «скелетку» в граф-сцены самого движка (Unity — самый яркий пример). Иногда скелетку используют и для «неоживленных» предметов — например, анимация подвески авто.
Морфинг: классический способ анимации суть которого заключается в «запекании» всех кадров в виде множества мешей. Затем игра интерполирует вершины между кадрами анимации, благодаря чему достигается эффект плавности.
Object-Transform: классический метод иерархической анимации, очень похож на скиннинг, только трансформируются не сами вершины, а привязанные к ним объекты. Применялась, например, во многих играх на PS1 и в GTA III (замечали отсутствие плавности в местах сочленений персонажей — это и есть OT).
Я не умею нормально работать с скиннингом моделей в 3D-редакторах и обычно не юзаю скиннинг в своих игрушках — для небольших демок хватает обычного морфинга с интерполяцией. Если интерполяцию не использовать, то анимация будет выглядеть топорно (в Quake 1 при отключении CVar'а такая и была):
Работа с анимациями выглядела вот так:
По сути, одна из самых комплексных частей — рендерер, готова. Однако в играх требуются и другие подсистемы, которые реализовать куда проще.
❯ Звук и ввод
Реализация звука в играх задача не шибко сложная, если дело не доходит до программной реализации микшера, 3D-позиционирования и различных эффектов. Большинству игр хватает обычного не-сжатого wav, звук в котором хранится в виде PCM-потока.
В качестве API для звука я выбрал DirectSound. Очень удобное API, хотя сейчас его фактически вытеснил XAudio. DirectSound поддерживает любые звуковые карты, сам занимается микшированием звука, а в некоторых старичках типа AC97 умеет даже аппаратное ускорение! На современных машинах обычно микширование реализовано полностью софтварно, дабы не упираться в количество каналов/память на борту звукового адаптера, но в прошлом это помогало снизить нагрузку на процессор.
В DirectSound есть два основных объекта: сам IDirectSound8, представляющий интерфейс к звуковой карте и управлению её ресурсами и буфер — который может быть подкреплен как собственными данными, так и данными из другого буфера. В играх, они делятся на три базовых понятия:
Слушатель: описание позиции и иных параметров «слушателя» — позиции ушей в игровом мире. Обычно позиция слушателя совпадает с позицией игрока.
Источник: описание источника звука в 3D-пространстве. Например, если мимо нас проносится машина, то звуковому API необходимо знать позицию, ускорение и дальность звука, дабы правильно скорректировать звук в пространстве.
Поток: поток, который содержит в себе звук. Может быть как обычным буфером, куда звук уже предварительно загружен, так и потоковым буфером, куда загружается часть музыки или другого длинного трека.
Переходим к реализации примитивного звука:
Теперь мы можем воспроизводить звуки в нашей игре!
Однако, нам нужно чтобы пользователь мог взаимодействовать с нашей игрой. Для этого в разных системах есть различные API для взаимодействия с устройствами ввода. В случае Windows — это DirectInput для обычных USB-геймпадов и рулей, и XInput для геймпадов, совместимых с Xbox 360/Xbox One. Нажатия с клавиатуры можно обрабатывать двумя способами: с помощью событий WM_KEYDOWN и WM_KEYUP и функции WinAPI GetAsyncKeyState.
Пока что мне нужна только клавиатура и мышь:
В идеале обработку ввода лучше абстрагировать от физических устройств взаимодействия. Для этого вводятся понятия осей, кнопок действий и т. п. Желательно сразу продумать, как игра будет работать с геймпадом и уже затем назначать кнопкам на клавиатуре действия абстрактного геймпада.
Ну что ж, самый базовый фреймворк для игр у нас есть. Пора переходить к написанию самой игры!
❯ Редактор уровней
Поскольку у фреймворка нет какого-либо своего графа сцены, я реализовываю механизм загрузки уровней в каждой игре с нуля — под конкретные нужды. В какой-то игре нужен стриминг для открытого мира, в другой — быстрая загрузка уровней, где есть множество объектов с разными параметрами. Изначально я использовал Blender в качестве редактора уровней и экспортировал карты небольшим скриптом, который сохранял основные параметры в файл.
Однако блендер (особенно 2.79 и ниже) не очень удобный редактор для работы с достаточно большими картами. Поэтому в определенный момент встал вопрос с организацией графа сцены и собственного редактора карт.
Граф сцены и графом то не назовешь — это просто линейный список объектов, которые присутствуют на сцене. Каждый объект наследуется от базового абстрактного типа Entity, если это «невидимый» объект, или PhysicsEntity, если объект должен интегрироваться с физическим движком. У базового объекта есть только имя и флаг выборки в редакторе.
Вообще, для редактирования уровней можно хоть редактор Unity использовать, предварительно написав экспортер в самопальный формат. Однако я решил реализовать свой редактор: как обычное Windows Forms приложение + панель, в которую движок рендерит картинку. В его реализации нет ничего необычного: он точно также загружает уровень, как и основная игра, но при этом не создаёт игрока и ботов и имеет свободную камеру.
Формат уровней примитивный донельзя. В процессе разработки небольших игрушек я обычно следую принципу KISS и не люблю распыляться сложными сериализаторами/десериализаторами и прочими заморочками, реализовывая лишь самые необходимый функционал. Формат карт — текстовый, одна линия на один объект:
p ferns 0 0 10.8 0 0 0 1
Где p — «класс» объекта, в случае p — это Prop, «декорация».
ferns — модель пропа. При этом сами пропы описаны в отдельных текстовых файлах, где в виде key-value значений хранятся настройки коллизии, материала, текстуры и т. п.
XYZ — позиция в мире.
XYZ — поворот в мировых координатах, задаётся в углах Эйлера (это только для статики, которая не подвержена Gimbal Lock, под капотом вся работа идёт с кватернионами).
❯ Физика автомобилей
После того, как граф сцены был готов, я приступил к реализации физики автомобилей. Но как я уже говорил, физический движок я использовал готовый — т. е. вся работа по резолвингу столкновений, распаралелливанию вычислений и Joint'ам сводилась чисто к нему. Я лишь использовав физику колеса, реализовал поведение машинки, внеся в него некоторые изменения: в основном — вынес в публичные свойства параметры трения колеса.
Само колесо реализовано по классическому принципу рейкастинга — колесо пускает под себя лучи и определяет трение относительно поверхности, на котором стоит, при этом двигая остальное тело используя собственное ускорение. Сейчас в играх для более точной симуляции используется Pacejka Magic Formula — формула, позволяющая рассчитать физически корректное поведение покрышки с различными диаметрами.
Поскольку класс сущности машинки слишком большой и требует контроля самых разных аспектов (коробка передач, аспекты тюнинга, отрисовка и материалы), я вынес часть физики в отдельный класс CarPhysics:
Как можно видеть из метода 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;
}
Полностью логика бота выглядит так:
Легко и просто, да?
❯ Гараж и гонки
Какой интерес в гонках без… гонок? Поскольку у меня не было особо ассетов для создания пригорода, я решил сделать пересеченную местность. А на пересеченной местности у нас есть как кольцевые гонки, так и спринт — от точки до точки.
Помимо этого, в игре должен быть гараж, где игрок мог бы купить новую машину или тюнинговать текущую. В начале игры выдавалась бы старая дедова копейка (модельки Оки не нашел), а то и Москвич, а потом игрок выигрывал бы в гонках и получал возможности про прокачке тачек и покупке новых. Эээх, лавры Lada Racing Club не дали покоя!
Начал я с реализации гаража. Сам по себе гараж — отдельный уровень, который обрабатывается своим контроллером, также в гараже применяется самый первый доступный UI в фреймворке — меню со списками. Сам гараж поделен на множество подменю: тюнинг, гонки и автосалон.
https://pastebin.com/dakm4AvbПараллельно с гаражом, была проработана система тюнячек — они тоже описывались в простых текстовых файлах и так или иначе влияли на ходовые качества машины. Правда, визуального тюнинга не было предусмотрена — некому моделлить апгрейды. :(
Сами гонки можно было начать, обратившись к RaceManager и передав структуру RaceParameters:
public struct RaceParameters {
public string Name;
public string Mode;
public int NumOpponents;
public int Difficulty;
public int Prize;
public int ProgressAffection;
}
После этого, игра загружала уровень, создавала ботов на месте spawnPoint (игрок оказывался, как обычно, последним) и запускала гонку.
А затем каждый кадр просчитывала позиции каждого участника гонки:
Всё! Логикой движения и всем остальным заправляли уже боты. Хотя, там и был костыль на первых этапах, который помечает флаг конца гонки, в остальном — функционал гоночек рабочий. :)
Вот мы и дошли до этапа, когда простенькая, но рабочая демка игры у нас уже есть! Игра запускается на GF4, однако работает не совсем корректно — но оптимизировать её под видеокарты тех лет не составит труда (в основном — пережать текстуры, убрать некоторые техники на комбайнерах и запечь статические пропы в батчи).
❯ Заключение
Вот так я и написал гоночки за неделю. Время разработки демки с нуля до состояния, которое вы видите в статье — всего неделю. Да, за это время реально написать прототип гоночной игры. И я ведь знаю, что в комментариях игру будут сравнивать с Lada Racing Club и шутить о сроках её разработки — ведь в этом и суть! Слишком мало реально прикольных ламповых гоночек на жигулях. Вот что у меня получилось в итоге:
Исходниками игры я конечно же поделюсь: тык на GitHub.
А вот линки на загрузку демки:
Гоночки
Шутан
Ну а для меня это был своеобразный челлендж. И я его выполнил — у меня получилась рабочая демка на выходе! Я вижу что вам, моим читателям, интересна тематика самопальной разработки игр. Судя по комментариям, вам нравится тематика геймдева, программирования графики и разработки игр. Темой одной из следующих статей может стать описание архитектуры графических ускорителей конца 90х, история их API (без D3D) и написание 3D-игры для 3dfx Voodoo с нуля, на базе Glide!
Кроме того, я хотел бы рассказать о графическом API известного многим «3D декселератора» S3 Virge. Интересна ли вам такая рубрика? Пишите в комментариях!
Статья подготовлена при поддержке TimeWeb Cloud. Подписывайтесь на меня и @Timeweb.Cloud, , чтобы не пропустить новые статьи каждую неделю!
Сделал игру
Всем привет! Никогда не имел опыта в программировании и вот, в 35 лет, решил начать изучать питон. Начал с самого начала, "Hello World!" все дела. Учился самостоятельно в течение года и когда добрался до библиотеки pygame, решил сделать небольшую игру. Она простенькая, и конечно можно еще многое добавить или поправить, но для первого проекта думаю сойдет. Кому интересно можете попробовать поиграть, сделал инстальник, ссылку прикреплю. Вирусов там нет.
Цель игры: уворачиваться от падающих листьев и пёселя)
Пошаговая инструкция по созданию текстового квеста на android. Часть 1
Пару недель назад, в сотый раз перепроходя Colossal Cave Adventure, я задумался, почему сейчас выпускают так мало игр данного жанра. Ведь это целый мир, в который погружаешься с головой. С этой мыслью, я решил начать делать свои текстовые квесты. Именно этому и посвящается сегодняшняя статья, надеюсь вам понравится!
В первой части, я опишу все инструменты по созданию игры, не затрагивая сюжет. Вторая часть, будет посвящена работе с комментариями, дереву повествования и самому сюжету в целом. В третьей части я опишу, все затраты на рекламу, получение сертификатов, способы монетизации приложения.
Создание игры:
Первой трудностью, с которой мне пришлось столкнуться, стало то, что я по образованию далеко не программист. Раньше я думал, что создание текстовых квестов, требует кучи навыков, помимо умения грамотно писать. Но немного походив по форумам я нашел ее, программу, для таких же «безруких» как и я, и имя ей Twine. https://twinery.org
Twine – это программа, для создания интерактивной литературы. Ее функционал заключается в очень простой визуальной блок-схеме, а скриптинг представляет собой создание гиперссылок между отдельными узлами(блоками). В итоге получается готовая html игра, которую можно сразу запускать в браузере. Не смотря на свою простоту, она поддается глубокой настройке – вы можете поменять фон, добавить музыку, поиграться со шрифтами и т.д. и т.п. Все скрипты, для смены дизайна можете найти на этих сайтах: http://twinery.org/wiki/script
https://github.com/scelis/twine
Встроенный функционал включает в себя базовые элементы программирования, такие как переменные и операторы «если». Сообщество Twine написало множество разных макросов на Javascript, значительно расширяющих возможности утилиты, и их можно добавлять в любой проект простым копированием. Также можно скачивать темы для смены оформления программы. Вы можете создать свою таблицу стилей для самостоятельной настройки внешнего вида. Лично я, приверженец классического черно белого стиля. Но мне не понравился стандартный шрифт, тогда я буквально за минуту нашел код, и добавил нотку индивидуальности в свой квест. Если кому интересно скрипт в ссылке на диск. https://yadi.sk/i/ZvdI07aQ3JozAc
Конечно, у Twine есть свои ограничения. Она создана только для текстовых игр, поэтому к работе над “серьезными” играми программа не пригодна. Всё, что требует обширных познаний в программировании, лучше создавать в Unity или Construct 2.0.
Преобразование:
После написания квеста, вы можете “опубликовать его в файл” и скинуть друзьям или на форумы, но я решил пойти дальше и занялся поиском программы для вывода игры в apk, и публикации на PlayMarket. Добрые, часов 5 искал этот долбанный инструмент, и вот в момент, когда я уже опустил руки, нашел Intel Xdk. https://software.intel.com/ru-ru/intel-xdk
Intel XDK – это интегрированный набор инструментов для кросс-платформенной разработки приложений. Вот такое сложное описание нам предлагает сам Intel, но для нас, это конвертер (с возможностью модификации) с html в apk. Он позволяет быстро конвертировать HTML5-приложения для устройств, работающих под управлением iOS, Android и Windows. Здесь можно найти подробную документацию по XDK. Так как разработчики программы не сделали этого, я сам написал нормальную инструкцию, по загрузке html файла в компилятор.
1.Запустите Intel XDK
2.Войдите в свою учётную запись. Если у вас ещё её нет – зарегистрируйтесь.
3.На стартовом экране можно выбрать шаблон для проекта или посмотреть примеры приложений. Но это нам особо и не нужно!
4.На панели Создать новый проект (Start A New Project) пройдите по пути Templates > Blank
5.Выбираем вариант HTML5 + Cordova.
6.Открываем в браузере свою игру и жмем Ctrl+U и в появившемся окне жмем:
Ctrl+A и Ctrl+C.
7.Далее в Xdk заходим в окошко Develop и жмем Ctrl+A и Ctrl+V. Все ваша игра загружена в компилятор.
8.Далее проверяем игру. В окошке Simulate выбираем устройство и жмем кнопку запуска. Если все хорошо, отлично). Если нет, повторяем предыдущие пункты.
9.Далее самая дебильная часть в Xdk, нам нужно создать сертификат. Я сам над этим промучился с полчаса, а потом нашел видео инструкцию как это сделать, кидаю ее вам: https://www.youtube.com/watch?v=HGkCn58AScI&t=121s
10.В окошке Projects жмем Build setting и настраиваем название и версию вашей игры, далее открывайте Launch icons и настраиваем иконки приложения для всех платформ, в разрешениях 96х96, 72х72, 48х48 и так далее.
11.И наконец, переходим во вкладку Build и выводим нашу игру на любую возможную платформу!
Публикация!
После генерации apk файла, вы можете загрузить его в любой магазин для Windows, IOS или Android. Но я расскажу вам только про android.
1. Зайдите в Developer Console. https://developer.android.com/distribute/console/index.html
2.Выберите Создать учетную запись продавца.
3.После этого Вам необходимо заплатить 25$, это можно сделать любым удобным для вас способом. платеж обрабатывается 12-24 часа.
4.Далее вам необходимо заполнить все данные. Отнеситесь внимательно к заполнению информации о компании. Общая информация будет видна всем пользователям вашего приложения. В “Название компании” можно вписать имя разработчика. Если у вас есть сайт – обязательно укажите это, потом это может стать для вас большим плюсом.
5.Просмотрите все ваши данные на предмет ошибки и нажимайте Отправить.
С этого момента, вы стали официальным разработчиком и можете размещать свои приложения в маркете, поздравляю!) Теперь расскажу, что нужно иметь для публикации!
Иконка:
Технические требования к изображению с высоким разрешением:
32-битный PNG (с альфа-каналом);
Размер: 512х512 пикселей;
Максимальный вес файла: 1024 Кб.
Скриншоты:
Технические требования к скринам:
JPG или 24-битный PNG (без альфа-канала);
Размер: от 320 пикселей до 3840 пикселей;
Соотношение сторон не больше, чем 2:1;
Не менее двух скриншотов.
Советую, на скринах продемонстрировать функционал и геймплей приложения.
Название:
Требования:
От 25 до 55 символов. Креативьте и если это выходит, добавляйте теги в название.
Описание:
Ограничения при создании описания:
Не более 80 символов для краткого описания;
До 4000 символов полного описания.
Не спешите с текстом, это самая главная часть публикации. Подумайте, что вы хотите рассказать о своей игре и как вы завлечете покупателя. Советую почитать описания известных разработчиков и сделать, как они, или даже лучше)
Рекламное видео:
Требования:
До 2 минут.
Это необязательный пункт, но очень рекомендованный, ведь с ютуба тоже могут прийти покупатели!
Создание рейтинга возрастного приложения:
Опишите все, что у вас есть в игре, насилие мат и тому подобное, ни в коем случае не врите, а то приложение попросту могут заблокировать!
Ну вот и все, первая часть инструкции подошла к концу, во второй части я расскажу о сюжете своей новой игры. В комментариях все критиковали повествование и я, как добросовестный фанат “Фарго” (особенно 1 сезона), нашел реальную историю, которая происходила в Украине. По количеству трупов и конспирологии, “Фарго” просто отдыхает, а то, как это дело раскрыли и объяснили, вообще на ни в какие ворота не лезет. Я учел абсолютно все отзывы, и надеюсь новая игра оставит вам приятное впечатление.
Также во второй части, я подробно расскажу о создании игры в Twine, с примерами и пошаговыми скринами.
Ну а третья часть будет посвящена рекламе, получению сертификатов и способам монетизации приложения. А в конце отвечу на вопрос, стоит ли этим заниматься?!
Надеюсь, вам понравилась моя статья, если это так, не поскупитесь на лайки и комменты, а также пишите, о чем еще написать в блоге.
P.S. по традиции, ставь лайк, если убил дракона голыми руками)
No Man's Sky
Команда программистов разработала саморазвивающийся космос, но даже сами создатели не догадываются, что прячется в его бескрайних просторах.
Во вселенной каждая частица на счету. Вычислены точная форма и месторасположение каждой травинки на каждой планете. Каждая снежинка и капля дождя пронумерована. На экране перед нами горы быстро растут и уменьшаются до мягко перекатывающихся холмов, прежде чем превратиться в пустыню. Миллионы лет пролетают в одно мгновение.
Здесь, в полутемной комнате в получасе езды к югу от Лондона, группа программистов склонилась над компьютерами, создавая необъятный цифровой космос. Или лучше так: с помощью процедурной генерации они делают программу, которая позволяет вселенной создавать саму себя.
.
Амбициозный проект будет выпущен в качестве видеоигры под названием No Man’s Sky в июне этого года. В игре случайно размещенные космонавты, отделенные друг от друга миллионами световых лет, должны будут найти собственную цель существования, бороздя галактику из 18 446 744 073 709 551 616 уникальных планет.
«Физика в других играх — подделка. Когда находишься на планете, ты окружен „небесной коробкой“ — кубом, на гранях которого кто-то нарисовал облака и звезды. Если день и ночь сменяются, то это происходит потому что они медленно перемещаются между коробками», — объяснил главный архитектор проекта Шон Мюррей. Небесная коробка — это также и барьер, который игрок пересечь не может. Звезды — лишь пучки света. Однако в No Man’s Sky каждая звезда открыта для посещения. Эта вселенная бесконечна. Ее края простираются в безжизненную бездну, в которую можно погружаться вечно.
«У нас, когда вы находитесь на планете, — продолжает Мюррей, — то можете видеть настолько далеко, насколько позволяет ландшафт. Если идти несколько лет, то можно обогнуть ее и оказаться в той же точке, откуда начали. У нас день сменяется ночью потому, что планета поворачивается вдоль своей оси, обращаясь вокруг ее звезды. Это реальная физика. У нас есть люди, которые прибывают на планету с космической станции и, когда они летят обратно, станцию на том же месте не находят — планета повернулась. Некоторые посчитали, что это баг».
На мониторе перед нами мелькают зашифрованные фрагменты исходного кода. Пока земные физики никак не могут найти математическую систему для описания всех феноменов Вселенной, ее эквивалент в No Man's Sky уже существует. Перед нами законы природы для целого космоса, уместившийся в 600 тысяч строк.
Вселенная начинается с единственного ввода, субъективного числового семечка — телефонного номера одного из программистов. Этот номер математически мутирует в большее количество семян при помощи каскадной серии алгоритмов — компьютеризированного генератора псевдослучайных чисел. Семена определяют характеристики каждого игрового элемента. Машины, безусловно, не способны на истинную случайность, так что генерируемые числа только кажутся случайными, потому что процессы, которые их создают, слишком сложны для понимания человеческим разумом.
Физики до сих пор спорят, определено все в нашей Вселенной все или случайно. Пока некоторые ученые верят, что квантовая механика практически точно подразумевает неопределенность, Альберт Эйнштейн, как известно, предпочитал противоположную позицию, сказав: «Бог не играет в кости». No Man's Sky тоже не играет в кости. Как только первое «семенное» число занесено в пустоты программы, вселенная задана — создание звезд, планет и организмов уже не обратить. Прошлое, настоящее и будущее зафиксированы, и только внешняя сила способна на изменения в системе — игрок.
С одной стороны (из-за процедурного дизайна игры), вся вселенная уже существует в момент ее создания. С другой, поскольку игра изображает только мир, непосредственно окружающий игрока, ничего не существует, пока человек это не увидит.
«На самом деле, не имеет значения существует ли то, что вокруг тебя или нет, потому что даже вещи, недоступные взгляду, постоянно занимаются своими делами. Существа на далекой планете, которую никто никогда не посещал, собираются на водопой или засыпают, так как живут по формуле, которая определяет куда им идти и что делать; мы просто не включаем эту формулу для места, пока не доберемся до него», — размышляет Мюррей.
Существа генерируются по процедурному искажению архетипов, и каждое из них наделяется уникальным поведенческим профилем. «Есть список объектов, о которых знают животные. Определенные животные схожи с некоторыми объектами и не похожи на другие, что является частью процесса придания им личных качеств и индивидуального стиля. У них есть друзья и лучшие друзья. Это всего лишь метка на куске кода, но другое существо такого же типа, находящееся рядом — его потенциальный друг. Они телепатически связываются с друзьями, спрашивают куда те направляются, и так координируют свои действия», — объясняет программист искусственного интеллекта Чарли Тангора.
Хоть и базовое поведение существ само по себе просто, их взаимодействие может впечатлить своей комплексностью. Арт-директор Грант Дункан вспоминает, как однажды бродил по незнакомой планете, стреляя от скуки по птицам: «Я попал в одну, и она упала в океан. Ее покачивали волны на поверхности, когда вдруг появилась акула и съела ее. Когда такое случилось в первый раз, мой мозг взорвался».
Команда запрограммировала часть физических законов из эстетических соображений. Например, Дункан настоял на том, чтобы орбиты спутников пролегали ближе к их планетам, чем позволяет ньютоновская физика. Когда он пожелал создать зеленое небо, команда была вынуждена переосмыслить периодическую таблицу, чтобы разработать атмосферные частицы, который преломляют свет при самой подходящей длине волны.
«Природа видео-игр — это конфликт, — настаивает Мюррей, — интересно поразмыслить над тем, к чему мы пришли. В нашей игре людям дается контроллер, они прилетают на планету, они видят инопланетянина, и если они играют в первый раз, скорее всего, они его застрелят, несмотря на то, что они только что проделали огромный путь, добравшись сюда. Но вот что мне действительно нравится, так это то, что 9 из 10 людей жалеют о содеянном. Ты не получаешь очки за убийство. Нет никаких золотых монет. Ты просто сделал это».
У игрока также нет никакого альтер-эго, за которым можно спрятаться.
«В большинстве игр ты начинаешь с выбора персонажа, — описывает Мюррей, — зачастую ты будешь играть за малоприятного персонажа с тонной броских фразочек. Твоим ником будет что-то вроде Айриша или Текса. В самом начале ты должен решить кто ты, и это может быть даже прежде, чем ты поймешь, как ты действительно хочешь играть. Мы хотим, чтобы люди пользовались своим воображением. Они могут быть кем хотят. Они могут быть пришельцами, если они хотят в них верить. Мне это очень нравится»
Во вселенной, где нет зеркал, как в этой, единственный способ увидеть себя — это попросить другого персонажа посмотреть на тебя и описать, что он увидел. Но, учитывая невообразимый простор этого космоса, случайно натолкнуться друг на друга — почти невероятное событие, достойное настоящего восхищения.
Для команды No Man’s Sky это чувство восхищения и есть сама суть.
Сам написал, сам полетал: как и зачем я разработал 3D-игру с нуля под компьютеры из 90-х в 2024 году?1
Осторожно: Несмотря на кажущуюся сложность статьи о разработке целой 3D-игры с нуля, я постарался систематизировать и упростить материал так, чтобы понятно было любому заинтересованному читателю, даже если вы далеки от программирования в целом!
Статьи о разработке инди-игр — это всегда интересно. Но разработка чего-то абсолютно с нуля, без каких-либо движков или фреймворков — ещё интереснее! Почти всю свою жизнь, буквально с 13-14 лет меня тянет пилить какие-нибудь прикольные 3D-демки и игрушки. Ещё на первом курсе ПТУ я написал небольшую демку с 3D-вертолетиками по сети и идея запилить какие-нибудь прикольные леталки не покидала меня по сей день! Спустя 6 лет, в 22 года я собрался с силами и решил написать небольшую аркадную демку про баталии на самолетиках, да так, чтобы работало аж на видеокартах из 90-х — NVidia Riva 128 и 3DFX Voodoo 3! Интересно, как происходит процесс разработки игры с нуля — от первого «тридэ» треугольника, до работающей на реальном железе демки? Тогда добро пожаловать под кат!
❯ Мотивация
Друзья! Вижу, что вам очень заходит моя постоянная рубрика о том, как работали графические ускорители из 90-х «под капотом», где мы не только разбираем их архитектуру, но и пишем демки на их собственных графических API. Мы уже успели с вами рассмотреть 3Dfx Voodoo, S3 ViRGE и мобильный PowerVR MBX и, думаю, теперь пришло время рассмотреть инструменты для разработчиков игр под Windows из 90-х. Про «старый» OpenGL рассказывать смысла не вижу — до сих пор многие новички учатся по материалам с glBegin/glEnd и FFP (Fixed Function Pipeline), а спецификацию с описанием первой версии API можно найти прямо на сайте Khronos. Зато про «старый» DirectX информации в сети очень мало и большинство документации уже потёрли даже из MSDN, хотя в нём было много чего интересного!
Вероятно читатель спросит — зачем пилить что-то для компьютеров 90-х годов, если большинство таких машин (к сожалению) отправились на цветмет и «никто в своем уме» не будет ими пользоваться? Ну, ретро-компьютинг и программирование демок — это, во-первых, всегда интересно. Среди моих подписчиков довольно много ребят, которые ещё учатся в школе, а уже натаскали с барахолок Pentium III или Pentium IV и GeForce 4 MX440 и сидят, балдеют и играют в замечательные игрушки из нулевых на таких машинах с по настоящему трушным опытом, да и я сам таким был и остаюсь по сей день. Вон, мне даже dlinyj скидывал свои девайсы в личку, а я сидел и слюни пускал. Так что факт остаётся фактом — ретро-компьютинг становится всё более и более популярен — что не может не радовать!
А во-вторых — это челлендж для самого себя! Посмотреть на то, как делали игры «деды» и попытаться запилить что-то самому, не забыв об этом написать статью и снять интересное видео в попытке донести это как можно большему числу читателей и зрителей! Конечно сам DirectX6 в целом значительно проще DX12, но некоторые техники весьма заковыристые и для достижения оптимальной производительности приходится пользоваться хаками. Ну а почему именно леталки? Потому что, наверное, хотел бы когда-нибудь полетать :)
Игру я решил писать на C#. Кому-то решение может показаться странным, но я уже не раз говорил, что это мой любимый язык, а при определенной сноровке — программы на нем работают даже под Windows 98. В качестве основного API для игры я выбрал DirectX 6, который вышел 7 августа 1998 года — за 3 года до моего рождения :)
Перед тем как что-то начинать делать, нужно определиться с тем, что нам нужно для нашей 3D-игры:
Графический движок или рендерер, работающий на базе Direct3D. В его задачи входит отрисовка геометрии, работа с освещением и материалами, отсечение моделей, находящихся вне поле зрения глаз, генерация ландшафтов из карт высот и т. п. Собственно, в нашем конкретном случае это графическим движком назвать сложно — никакого полноценного графа (иерархической структуры, как в Unity) сцены нет, толковой анимации тоже, зато есть довольно продвинутая система материалов :)
Звуковой движок на базе DirectSound. Здесь всё по классике: программный 3D-звук с эффектами типа «виу» и «вжух» с загрузкой звуковых дорожек из wav-файлов. Никакого стриминга звука с кольцевыми буферами и ogg/mp3 здесь не нужно!
Подсистема ввода, которая представляет из себя «получить состояние кнопки на клавиатуре» и «получить позицию курсора» :)
Остальные модули — сюда входят алгоритмы расчёта коллизий, математическая библиотека для работы с векторами и матрицами, система игровых объектов и загрузчики ресурсов. Это весьма небольшие и легкие в реализации подсистемы, но писать про каждый отдельный пункт смысла не очень много, поскольку они так или иначе часть других систем.
Как известно, в самолёте всё зависит от винта! Ну, или в нашем случае, от 3D-движка — поэтому предлагаю рассмотреть архитектуру нашего рендерера и заложить первые кирпичики в нашу 3D-игру!
❯ Графический движок
Поскольку C# — управляемый язык и напрямую дёргать COM-интерфейсы формально не может, а готовых обёрток для DirectX 6 по понятным причинам нет, мне пришлось писать свою. Простыми словами, обёртка обеспечивает слой совместимости между нативными библиотеками, написанными на C++ и управляемым кодом, написанном на C#/VB и т.п. Благо в мире .NET есть такое замечательное, но увы, забытое расширение плюсов, как С++/CLI, которое позволяет прозрачно смешивать нативный код и «байткод» .NET, благодаря которому разработка пошла значительно быстрее.
Любой графический движок начинается с создания окна и инициализации контекста графического API (инициализации видеокарты, если простыми словами) для рисования в это самое окно. В случае Direct3D6 всё интереснее тем, что фактически здесь уже был свой аналог современного DXGI (DirectX Graphics Infrastructure — библиотека для управления видеокартами, мониторами в системе), который назывался DirectDraw. Изначально DDraw использовался для аппаратного ускорения графики на VGA 2D-акселеллераторах — тех самых S3 ViRGE и Oak Technology и предназначался в основном для операций блиттинга (копирования картинки в картинку), но в D3D ему выделили функции управления видеопамятью и поэтому они очень тесно связаны.
Инициализация начинается с создания так называемой первичной поверхности (которая будет отображаться на экран) и заднего буфера (в который будет рисоваться само изображение), или в терминологии современных API — Swap-chain.
DDSURFACEDESC rtDesc;
memset(&rtDesc, 0, sizeof(rtDesc));
rtDesc.dwSize = sizeof(rtDesc);
rtDesc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT;
rtDesc.ddsCaps.dwCaps = DDSCAPS_OFFSCREENPLAIN | DDSCAPS_3DDEVICE;
rtDesc.dwWidth = Width;
rtDesc.dwHeight = Height;
Guard(ddraw->CreateSurface(&rtDesc, &sSurf, 0));
Guard(sSurf->QueryInterface(IID_IDirectDrawSurface4, (LPVOID*)&sSurf4));
Теперь у нас есть окно, куда можно что-нибудь нарисовать!
Но 3D мы пока рисовать не можем — ведь контекста D3D у нас всё ещё нет, благо создаётся он очень просто. Единственный момент: Z-буфер нужно создать перед созданием устройства, иначе работать он не будет.
DDSURFACEDESC zbufDesc;
memset(&zbufDesc, 0, sizeof(zbufDesc));
zbufDesc.dwSize = sizeof(zbufDesc);
zbufDesc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT | DDSD_PIXELFORMAT;
zbufDesc.ddsCaps.dwCaps = DDSCAPS_ZBUFFER | DDSCAPS_VIDEOMEMORY;
memcpy(&zbufDesc.ddpfPixelFormat, Window::zBufferFormat, sizeof(zbufDesc.ddpfPixelFormat));
zbufDesc.dwWidth = Width;
zbufDesc.dwHeight = Height;
IDirectDrawSurface* zTemp;
IDirectDrawSurface4* zSurface;
Guard(ddraw->CreateSurface(&zbufDesc, &zTemp, 0));
Guard(zTemp->QueryInterface(IID_IDirectDrawSurface4, (LPVOID*)&zSurface));
// Attach Z-Buffer to backbuffer
Guard(d3dSurface->AddAttachedSurface(zSurface));
Guard(d3d->CreateDevice(IID_IDirect3DHALDevice, surf, &device, 0));
Мы уже на полпути перед тем как нарисовать первый тридэ-треугольник: осталось лишь объявить структуру вершины и написать обёртки над… Begin/End! Да, в Direct3D когда-то тоже была концепция из OpenGL, а связана она с тем, что в видеокартах тех лет вершины передавались не буферами, а по одному, уже трансформированные. Подробнее об этом можно почитать в моей статье о S3 ViRGE:
public value struct Vertex
{
public:
float X, Y, Z;
float NX, NY, NZ;
D3DCOLOR Diffuse;
float U, V;
};
...
Vertex[] v = new Vertex[3];
v[0] = new Vertex()
{
X = 0,
Y = 0,
Z = 0,
U = 0,
V = 0
};
v[1] = new Vertex()
{
X = 1,
Y = 0,
Z = 0,
U = 1,
V = 0
};
v[2] = new Vertex()
{
X = 1,
Y = 1,
Z = 0,
U = 1,
V = 1
};
dev.BeginScene();
dev.Begin(PrimitiveType.TriangleList, Device.VertexFormat);
dev.Vertex(v[0]);
dev.Vertex(v[1]);
dev.Vertex(v[2]);
dev.End();
dev.EndScene();
И вот, у нас есть первый треугольник! Читатель может спросить — а где же здесь игра и причём здесь треугольники, мы же не на уроке геометрии… Дело в том, что вся 3D-графика в современных играх строится из треугольников. Любая моделька на экране — это набор из маленьких примитивов, которые в процессе рисования на экран подвергаются процессу трансформации — преобразованию из мировых координат (то есть абсолютной позиции в мире) сначала в координаты камеры (таким образом, при движении камеры, на самом деле двигаются объекты вокруг камеры), а затем и в экранные координаты, где происходит перспективное деление и каждый треугольник начинает выглядеть как трёхмерный…
Таким образом, из тысяч треугольников можно описать самые разные объекты — от трёхмерной модели моих любимых «жигулей», до персонажей.
Но если сейчас нарисовать самолетик, то он будет исключительно белым, без намёка на освещение или детали. А для его «раскрашивания» служат текстуры — специальные изображения, подогнанные под текстурные координаты геометрии, которые помогают дополнить образ 3D-моделей деталями: асфальт на дороге, трава на земле, дверная карты в жигулях…
И вот с текстурами ситуация в D3D6 не менее интересная и очень похожа на современные GAPI: нам необходимо сначала создать текстуру в системной памяти (ОЗУ) и только затем скопировать её в видеопамять. Причём форматов текстур не слишком много. Я выбрал RGB565 (16-битный), хотя есть поддержка и форматов со сжатием — тот-же S3TC.
bool hasMips = mipCount > 1; // If texture has more than 1 mipmap, then create surface as complex, if not - then as single-level.
DDSURFACEDESC2 desc;
memset(&desc, 0, sizeof(desc));
desc.dwSize = sizeof(desc);
desc.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT | DDSD_PIXELFORMAT | DDSD_TEXTURESTAGE | DDSD_CKSRCBLT;
desc.ddsCaps.dwCaps = DDSCAPS_TEXTURE | (hasMips ? (DDSCAPS_MIPMAP | DDSCAPS_COMPLEX) : 0);
desc.ddsCaps.dwCaps2 = DDSCAPS2_TEXTUREMANAGE;
desc.ddckCKSrcBlt.dwColorSpaceHighValue = 0;
desc.ddckCKSrcBlt.dwColorSpaceLowValue = 0;
memcpy(&desc.ddpfPixelFormat, DXSharp::Helpers::Window::opaqueTextureFormat, sizeof(desc.ddpfPixelFormat));
desc.dwWidth = Width = width;
desc.dwHeight = Height = height;
IDirectDrawSurface4* surf;
IDirect3DTexture2* tex;
IDirectDraw4* dd2;
window->ddraw->QueryInterface(IID_IDirectDraw4, (LPVOID*)&dd2);
Guard(dd2->CreateSurface(&desc, &surf, 0));
Guard(surf->QueryInterface(IID_IDirect3DTexture2, (LPVOID*)&tex));
А чтобы её использовать, нужно «сказать» об этом видеокарте с помощью биндинга текстуры к текстурному юниту. Те, у кого были в свое время 3dfx Voodoo, наверняка поймут, о чём я :)
Guard(device->SetTexture(stage, tex->texture));
И вот у нас уже есть треугольник с текстурой! Осталось лишь домножить его матрицы трансформации, перспективную матрицу…
Реализуем простенький загрузчик моделей из формата SMD (GoldSrc, Half-Life или CS1.6), который грузит статичные модельки без скиннинга, а также загрузчик текстур из bmp и вот — мы уже имеем 3D-модельку самолёта с текстурой.
for(int i = 0; i < smd.Triangles.Count; i++)
{
uint c = new Color(255, 255, 255, 255).GetRGBA();
for (int j = 0; j < 3; j++)
vert[i * 3 + j] = new Vertex()
{
X = smd.Triangles[i].Verts[j].Position.X,
Y = smd.Triangles[i].Verts[j].Position.Y,
Z = smd.Triangles[i].Verts[j].Position.Z,
U = smd.Triangles[i].Verts[j].UV.X,
V = smd.Triangles[i].Verts[j].UV.Y,
NX = smd.Triangles[i].Verts[j].Normal.X,
NY = smd.Triangles[i].Verts[j].Normal.Y,
NZ = smd.Triangles[i].Verts[j].Normal.Z,
Diffuse = c
};
}
Возможно в каких-то играх и не нужно небо, но в леталках — уж точно необходимо. И без учёта динамических облаков, здесь есть две популярные техники:
Возможно в каких-то играх и не нужно небо, но в леталках — уж точно необходимо. И без учёта динамических облаков, здесь есть две популярные техники:
Sky-sphere, которая заключается в том, что небо представляет из себя полусферу с наложенной поверх текстурой неба в специальном формате. Такую полусферу очень часто крутят вокруг своей оси по оси Y, создавая эффект плывущих облаков. И получается вполне себе симпатичное анимированное небо. Иные варианты включают в себя многослойные реализации, где крутится могут лишь облака, когда статичные элементы фона остаются на месте.
Skybox — здесь суть простая, вокруг камеры рисуется «коробка» с вывернутыми в обратную сторону треугольниками, на которых рисуется текстура одной из сторон панорамы с выключенной записью в Z-буфер. Получается не только симпатично, но ещё и быстрее Skysphere на слабом железе, правда скайбоксы обычно статичным. Скайбоксы можно найти почти везде: например, в Counter-Strike, Half-Life.
На скриншоте ниже можно увидеть пример скайбокса:
Я выбрал скайбоксы. Реализация — проще пареной репы:
materials[0].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_bk.bmp", Path, name));
materials[1].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_ft.bmp", Path, name));
materials[2].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_lf.bmp", Path, name));
materials[3].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_rt.bmp", Path, name));
materials[4].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_up.bmp", Path, name));
materials[5].Texture = TextureLoader.LoadFromImage(string.Format("{0}{1}_dn.bmp", Path, name));
....
Engine.Current.Graphics.DrawMesh(mesh, 0, 6, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[1]); // Forward
Engine.Current.Graphics.DrawMesh(mesh, 6, 12, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[3]); // Right
Engine.Current.Graphics.DrawMesh(mesh, 12, 18, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[0]); // Back
Engine.Current.Graphics.DrawMesh(mesh, 18, 24, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[2]); // Left
Engine.Current.Graphics.DrawMesh(mesh, 24, 30, v, new Vector3(0, 0, 0), new Vector3(1, 1, 1), materials[4]); // Left
Но летать в пустом мире неинтересно и для этого нам нужен хотя бы ландшафт, который называется Terrain. Концепция Terrain простая — у нас есть карта высот, каждый пиксель который описывает высоту той или иной точки.
Мы проходимся по всей картинке и строим сетку треугольников, где высота определяется именно соседними пикселями на этой самой карте высот. На практике это выглядит так:
for (int i = 1; i < bmp.Width - 1; i++)
{
for(int j = 1; j < bmp.Height - 1; j++)
{
float baseX = (float)i * XZScale;
float baseZ = (float)j * XZScale;
// Transform vertices
verts[vertOffset] = new DXSharp.D3D.Vertex()
{
X = baseX,
Y = ((float)bmp.GetPixel(i, j).R / 255.0f) * YScale,
Z = baseZ,
U = 0,
V = 1 * TextureScale,
NY = 1
};
verts[vertOffset + 2] = new DXSharp.D3D.Vertex()
{
X = baseX,
Y = ((float)bmp.GetPixel(i, j + 1).R / 255.0f) * YScale,
Z = baseZ + XZScale,
U = 0,
V = 0,
NY = 1
};
verts[vertOffset + 1] = new DXSharp.D3D.Vertex()
{
X = baseX + XZScale,
Y = ((float)bmp.GetPixel(i + 1, j + 1).R / 255.0f) * YScale,
Z = baseZ + XZScale,
U = 1 * TextureScale,
V = 0,
NY = 1
};
verts[vertOffset + 3] = new DXSharp.D3D.Vertex()
{
X = baseX,
Y = ((float)bmp.GetPixel(i, j).R / 255.0f) * YScale,
Z = baseZ,
U = 0,
V = 1 * TextureScale,
NY = 1
};
verts[vertOffset + 4] = new DXSharp.D3D.Vertex()
{
X = baseX + XZScale,
Y = ((float)bmp.GetPixel(i + 1, j).R / 255.0f) * YScale,
Z = baseZ,
U = 1 * TextureScale,
V = 1 * TextureScale,
NY = 1
};
verts[vertOffset + 5] = new DXSharp.D3D.Vertex()
{
X = baseX + XZScale,
Y = ((float)bmp.GetPixel(i + 1, j + 1).R / 255.0f) * YScale,
Z = baseZ + XZScale,
U = 1 * TextureScale,
V = 0,
NY = 1
};
vertOffset += 6;
}
}
А результат — такой! Это самый простой кейс с Terrain'ом: в реальных играх, где ландшафт достаточно большой, его обычно бьют на так называемые патчи и дальние участки ландшафта упрощают с помощью специальных алгоритмов. Таким образом построены ландшафтры, например, в TES Skyrim.
Но ландшафт выглядит слишком скучно — ни травы, ни деревьев, ни даже разных текстур! Одна трава — да что ж это за ландшафтр такой :) И здесь нам на помощь приходят т. н. комбайнеры — которые дают возможность наносить сразу несколько текстур за один проход отрисовки геометрии. Конкретно в данном случае я решил использовал альфа-канал в цвете вершины в качестве значения, определяющего какой текстурой красить тот или иной участок ландшафта. Визуализировать это можно так (где прозрачные участки — там должна быть вторая текстура):
Этот способ даёт возможность использовать всего лишь две текстуры за один проход, в современных играх используется сплат-маппинг, позволяющий использовать более 4х-текстур за один проход!
Context.SetTextureStageState(1, (int)TextureStageState.AlphaOp, (int)TextureStageOp.Modulate);
Context.SetTextureStageState(1, (int)TextureStageState.AlphaArg1, (int)TextureArgument.Texture);
Context.SetTextureStageState(1, (int)TextureStageState.AlphaArg2, (int)TextureArgument.Texture);
Context.SetTextureStageState(0, (int)TextureStageState.ColorOp, (int)TextureStageOp.SelectArg1);
Context.SetTextureStageState(0, (int)TextureStageState.ColorArg1, (int)TextureArgument.Texture);
Context.SetTextureStageState(0, (int)TextureStageState.ColorArg2, (int)TextureArgument.Texture);
Context.SetTextureStageState(1, (int)TextureStageState.ColorOp, (int)TextureStageOp.BlendDiffuseAlpha);
Context.SetTextureStageState(1, (int)TextureStageState.ColorArg1, (int)TextureArgument.Texture);
Context.SetTextureStageState(1, (int)TextureStageState.ColorArg2, (int)TextureArgument.Current);
Но тем не менее, выглядит вполне прикольно. Однако текстуры вдали выглядят слишком грубо и отдают пикселями. Ретро-стайл скажете вы? Согласен, но фильтрация и мипмаппинг здесь необходимы! Мип-маппинг — это техника, которая делит большую текстуру на несколько небольших разного размера. Каждый размер называется mip-уровнем и в два раза меньше прошлого: таким образом, у текстуры 256x256 9 уровней: 256x256, 128x128, 64x64 и так до 1x1. Мой самопальный конвертер текстур в собственный формат заранее «запекает» все мип-уровни, дабы быстро грузить текстуры с медленных HDD, а линейная фильтрация с мипмаппингом позволяет сгладить текстуры вдали, дабы они не резали глаза:
device->SetTextureStageState(0, D3DTSS_MIPFILTER, D3DTFP_LINEAR);
device->SetTextureStageState(0, D3DTSS_MINFILTER, D3DFILTER_LINEAR);
device->SetTextureStageState(0, D3DTSS_MAGFILTER, D3DFILTER_LINEAR);
device->SetTextureStageState(1, D3DTSS_MIPFILTER, D3DTFP_LINEAR);
device->SetTextureStageState(1, D3DTSS_MINFILTER, D3DFILTER_LINEAR);
device->SetTextureStageState(1, D3DTSS_MAGFILTER, D3DFILTER_LINEAR);
Ну и давайте же посадим немного деревьев на наш ландшафт! Для этого я добавил псевдослучайное добавление деревьев и кустов при генерации геометрии ландшафта:
if (rand.Next(0, 32) % 8 == 0)
foliageBatches.Add(new FoliagePlacement()
{
Mesh = foliage[rand.Next(0, foliage.Length)],
Position = new Vector3(baseX, ((float)bmp.GetPixel(i, j).R / 255.0f) * YScale, baseZ)
});
Упс, наши деревья — черные! А всё потому, что у них нет альфа-канала, благодаря которому видеокарта может отделить прозрачные пиксели текстуры от непрозрачных. Полноценный альфа-блендинг (полупрозрачность) здесь слишком дорогой, поэтому приходится использовать технику, называемую колоркеями (Color key). Техника очень схожая с Chromakey, благодаря которым вырезают фон из видео, но чуть попроще (тем, что цвет прозрачности фиксированный, без Threshold). У нас есть определенный цвет, который считается прозрачным и не используется во всей картинке. Нередко это Magenta, в моём случае — полностью чёрный:
Включаем колоркей и наслаждаемся прозрачными деревьями на фоне ландшафта!
Ой-ой, а FPS то успел просесть с 1.000 до 50 из-за большого количества DIP'ов (и не очень хорошей работе современных GPU с старыми гапи). Время оптимизаций! Пока что нам хватит обычного Frustum culling'а, также известного как «отсечение по пирамиде видимости». Суть алгоритма простая: из матрицы вида и проекции строятся 6 плоскостей, каждая из которых описывает одну из сторон системы координат: левая, правая, верхняя, нижняя, ближняя и дальняя. Таким образом, делая обычную проверку нахождения точки в World-space и одной из плоскостей, мы можем отсечь невидимую глазам геометрию и не тратить ресурсы GPU и CPU на отрисовку невидимой геометрии:
public void Calculate(Matrix viewProj)
{
float[] items = viewProj.Items;
Planes[0] = new Vector4(items[3] - items[0], items[7] - items[4], items[11] - items[8], items[15] - items[12]);
Planes[0].Normalize();
Planes[1] = new Vector4(items[3] + items[0], items[7] + items[4], items[11] + items[8], items[15] + items[12]);
Planes[1].Normalize();
Planes[2] = new Vector4(items[3] + items[1], items[7] + items[5], items[11] + items[9], items[15] + items[13]);
Planes[2].Normalize();
Planes[3] = new Vector4(items[3] - items[1], items[7] - items[5], items[11] - items[9], items[15] - items[13]);
Planes[3].Normalize();
Planes[4] = new Vector4(items[3] - items[2], items[7] - items[6], items[11] - items[10], items[15] - items[14]);
Planes[4].Normalize();
Planes[5] = new Vector4(items[3] + items[2], items[7] + items[6], items[11] + items[10], items[15] + items[14]);
Planes[5].Normalize();
}
// Allocation-less
public bool IsPointInFrustum(float x, float y, float z)
{
foreach(Vector4 v in Planes)
{
if (v.X * x + v.Y * y + v.Z * z + v.W <= 0)
return false;
}
return true;
}
public bool IsSphereInFrustum(float x, float y, float z, float radius)
{
foreach (Vector4 v in Planes)
{
if (v.X * x + v.Y * y + v.Z * z + v.W <= -radius)
return false;
}
return true;
}
Затем проверяем, находится ли сфера внутри каждой из 6 плоскостей и если нет, то не рисуем геометрию вообще:
if (mesh.Radius > 0 && !Camera.IsSphereVisible(position, mesh.Radius))
return;
С учётом всех оптимизацией, получаем 17-20 кадров на этом GPU что можно считать… весьма неплохим результатом, учитывая что всё ещё есть куда оптимизировать!
❯ Звук
Эта часть статьи будет без иллюстраций, поскольку звук нужно слушать :) Но тем не менее, детали реализации звуковой подсистемы в DirectX весьма интересны и значительно отличаются от современного подхода.
Инициализация контекста DSound начинается с создания primary-буфера, который выступает в роли микшера перед отправкой звука на аудио-карту. Создаётся он довольно легко:
BufferDescription desc = new BufferDescription();
desc.Flags = BufferFlags.PrimaryBuffer | BufferFlags.Control3D;
primaryBuffer = Context.CreateSoundBuffer(desc);
После этого, в самом простом случае (без стриминга звука) нам достаточно лишь выгрузить PCM-поток на аудио-карту и начать его играть:
public WaveBuffer(WaveFormat fmt, byte[] pcmData)
{
BufferDescription desc = new BufferDescription();
desc.BufferBytes = (uint)pcmData.Length;
desc.Flags = BufferFlags.ControlDefault |BufferFlags.Software;
desc.Format = fmt;
buffer = Engine.Current.Sound.Context.CreateSoundBuffer(desc);
IntPtr data = buffer.Lock();
Marshal.Copy(pcmData, 0, data, pcmData.Length);
buffer.Unlock();
buffer.Play();
}
И всё! Да, вот так легко. BufferFlags.Software заменяется на Hardware, если необходимо аппаратное ускорение.
❯ Ввод
Пожалуй, это самая простая часть нашей статьи :) Как я уже говорил ранее, никакого особого функционала от модуля обработки ввода не нужно, лишь получать состояние кнопок — и с этим справляется лишь один метод…
[DllImport("user32.dll")]
static extern short GetAsyncKeyState(Keys vKey);
public static bool GetKeyState(Keys key)
{
return (GetAsyncKeyState(key) & 0x8000) != 0;
}
Ну что ж, основа готова, давайте перейдем к реализации самого геймплея!
❯ Пилим геймплей
Сначала нам нужно реализовать логику полёта нашего самолётика. В целом, в нашем конкретном кейсе всё просто — для поворотов используем углы Эйлера (лень было писать класс для кватерниона), считаем Forward-вектор (вектор, указывающий на направление прямо) и просто крутим повороты по оси X и Y в нужную сторону, прибавляя к позиции самолетика Forward вектор, умноженный на скорость полёта. Правда, с таким подходом есть некоторые проблемы: выполнить петлю не получится, поскольку Forward-вектор всегда смотрит именно прямо и не учитывает обратную направленность по оси X.
Rotation.X += -v * (YawSpeed * Engine.Current.DeltaTime);
Rotation.Y += h * (YawSpeed * Engine.Current.DeltaTime);
Rotation.Z = MathUtils.Lerp(Rotation.Z, 35 * -h, 4.0f * Engine.Current.DeltaTime);
Vector3 fw = GetForward();
Position.X += fw.X * (Speed * Engine.Current.DeltaTime);
Position.Y += fw.Y * (Speed * Engine.Current.DeltaTime);
Position.Z += fw.Z * (Speed * Engine.Current.DeltaTime);
Мы с вами хотим, чтобы камера всегда следила за нашим самолётиком. Для этого нужно взять Forward-вектор объекта и умножить каждую его компоненту на дальность от источника камеры. Эдакая бомж-версия lookat, правда с кучей ограничений, как минимум с Gimbal lock (потерей одной из осей поворота), а чтобы камера казалась плавной и придавала динамичности игре — мы делаем EaseIn/EaseOut эффект путём неправильного использования формулы линейной интерполяции :)
Vector3 forward = GetForward();
// Adjust camera
Engine.Current.Graphics.Camera.Position = new Vector3(Position.X + (forward.X * -12.0f),
Position.Y + (forward.Y * -12.0f) + 4.0f, Position.Z + (forward.Z * -12.0f));
Engine.Current.Graphics.Camera.Rotation.Y = MathUtils.Lerp(Engine.Current.Graphics.Camera.Rotation.Y, Rotation.Y + (yaw * 30), 3.0f * Engine.Current.DeltaTime);
Engine.Current.Graphics.Camera.Rotation.X = MathUtils.Lerp(Engine.Current.Graphics.Camera.Rotation.X, Rotation.X + (pitch * 5), 3.0f * Engine.Current.DeltaTime);
Engine.Current.Graphics.Camera.MarkUpdated();
Ну, летать мы с вами уже можем… да, сильно по аркадному, но всё же :) Пришло время реализовать каких-нибудь соперников, а именно вражеские самолёты! Вообще, реализация нормального ИИ на самолетах, тем более в симуляторах — задачка очень нетривиальная, поскольку боты будут либо читерить, используя не те рычаги, что использует игрок, либо тупить и играть будет не сильно интересно. Вон, что «Варгейминг», что «Гайдзины» крутые в этом плане — я б ниасилил нормальных ботов для мультиплеерного симулятора или даже аркады :))
Вычисляем угол между позицией самолетика соперника и позицией игрока и интерполируем текущий угол по оси Y: получается вполне плавно, правда в нормальных играх ещё и компенсируют эффект «плаванья» вокруг игрока по синусоиде. Для подъёма и спуска по вертикали просто берём абсолютную величину выше/ниже:
float angle = (float)Math.Atan2(Game.Current.Player.Position.X - Position.X, Game.Current.Player.Position.Z - Position.Z);
float vert = MathUtils.Clamp(Position.Y - Game.Current.Player.Position.Y, -1, 1);
Rotation.X = MathUtils.Lerp(Rotation.X, vert * 35, 1.5f * Engine.Current.DeltaTime);
float prevY = Rotation.Y;
Rotation.Y = MathUtils.Lerp(Rotation.Y, angle * MathUtils.RadToDeg, 1.5f * Engine.Current.DeltaTime);
float diffY = Rotation.Y - prevY > 0 ? 1 : -1;
Rotation.Z = MathUtils.Lerp(Rotation.Z, 15 * -diffY, 4.0f * Engine.Current.DeltaTime);
Наши боты будут читерить, причём жёстко. Они будут иметь значительно большую маневренность, нежели игрок, но при этом их скорость будет сильно медленнее игрока, дабы можно было их обогнать и стряхнуть с хвоста.
Ну что ж, демка у нас есть и в этот раз я подготовился получше, чем в статье про 3dfx Voodoo: я собрал целых два тестовых стенда и попросил у подписчиков потестировать демку на своих машинах с диковинным железом из 90-х и нулевых годов. Железо у нас такое:
Процессор: Celeron 600MHz Coppermine
ОЗУ: 192Mb SDRAM 133MHz
GPU: Asus GeForce 4 MX420
ОС: WinXP SP3
На Win98 я так и не смог нормально накатить драйвера на MSDC (Mass Storage Device Class — «флэшки»), поэтому «считерил» и поставил WinXP. Изначально я планировал ставить Win2000 — но там .NET 2.0 работает с косяками (при том что этот же самый .NET работает на Win98!).
❯ Тесты
Давайте же посмотрим, как демка идёт на трушном железе. Для наглядности, я решил записать видео.
Переходим к интегрированной графике, а именно к EEEPC 701 4G с Intel GMA 900 на борту! Те, кто знают что такое GMA, понимают насколько эти встройки не приспособлены для игр. Несмотря на наличие поддержки вторых шейдеров, из-за отсутствия аппаратного вершинного конвейера чип ничего не тянет. Но моя игрушка — исключение и она работает на удивление очень даже неплохо! 15-20 кадров точно есть и это при том что есть куда оптимизировать!
А дальше у нас идут тесты от подписчиков в Telegram-канале, которым я скинул билд и пригласил потестить демку на ретро-железе. Первый тест от читателя на ноутбуке с Pentium III и редкой встройкой Trident CyberBlade XP показал весьма неплохой результат — 15-20 кадров:
Дальше тот же читатель, имя которое он просил не раскрывать, потестил демку на ATI Rage M6 — очень и очень бодрый GPU, который выдает стабильные 20-25-30 кадров!
❯ Заключение
Вот такая демка, мини-игрушка у меня получилось. Да, весьма примитивненько, зато прикольно, запилено за пару дней и можно полетать на виртуальных самолетиках. Также у меня есть Telegram-канал, куда я публикую различные мысли связанные с подручным ремонтом, моддингом и программированием под гаджеты прошлых лет, а также публикую туда ссылки на новые статьи и видео! Найти исходный код демки вы можете на моём Github.
Понравилась статья? Пишите своё мнение в комментариях, я старался :)
Статья подготовлена при поддержке TimeWeb.Cloud. Подписывайтесь на меня и @Timeweb.Cloud, чтобы не пропускать новые статьи каждую неделю!
























































