Написать игровой движок на первом курсе: легко! (ну почти)
Привет! Меня зовут Глеб Марьин, я учусь на первом курсе бакалавриата «Прикладная математика и информатика» в Питерской Вышке. Во втором семестре все первокурсники нашей программы делают командные проекты по С++. Мы с моими партнерами по команде решили написать игровой движок.
О том, что у нас получается, читайте под катом.
Всего нас в команде трое: я, Алексей Лучинин и Илья Онофрийчук. Никто из нас не является экспертом в разработке игр, а тем более в создании игровых движков. Для нас это первый большой проект: до него мы выполняли только домашние задания и лабораторные работы, так что едва ли профессионалы в области компьютерной графики найдут здесь новую для себя информацию. Мы будем рады, если наши идеи помогут тем, кто тоже хочет создать свой движок. Но тема эта сложна и многогранна, и статья ни в коем случае не претендует на полноту специализированной литературы.
Всем остальным, кому интересно узнать о нашей реализации, — приятного чтения!
Первое окно, мышь и клавиатура
Для создания окон, обработки ввода с мыши и клавиатуры мы выбрали библиотеку SDL2. Это был случайный выбор, но мы о нем пока что не пожалели.
Важно было на самом первом этапе написать удобную обертку над библиотекой, чтобы можно было парой строчек создавать окно, проделывать с ним манипуляции вроде перемещения курсора и входа в полноэкранный режим и обрабатывать события: нажатия клавиш, перемещения курсора. Задача оказалось несложной: мы быстро сделали программу, которая умеет закрывать и открывать окно, а при нажатии на ПКМ выводить «Hello, World!».
Тут появился главный игровой цикл:
Event ev;
bool running = true;
while (running):
ev = pullEvent();
for handler in handlers[ev.type]:
handler.handleEvent(ev);
К каждому событию привязаны обработчики — handlers
, например, handlers[QUIT] = {QuitHandler()}
. Их задача — обрабатывать соответствующее событие. QuitHandler
в примере будет выставлять running = false
, тем самым останавливая игру.
Hello World
Для рисования в движке мы используем OpenGL
. Первым Hello World
у нас, как, думаю, и во многих проектах, был белый квадрат на черном фоне:
glBegin(GL_QUADS);
glVertex2f(-1.0f, 1.0f);
glVertex2f(1.0f, 1.0f);
glVertex2f(1.0f, -1.0f);
glVertex2f(-1.0f, -1.0f);
glEnd();
Затем мы научились рисовать двумерный многоугольник и вынесли фигуры в отдельный класс GraphicalObject2d
, который умеет поворачиваться с помощью glRotate
, перемещаться с glTranslate
и растягиваться с glScale
. Цвет мы задаем по четырем каналам, используя glColor4f(r, g, b, a)
.
С таким функционалом уже можно сделать красивый фонтан из квадратиков. Создадим класс ParticleSystem
, у которого есть массив объектов. Каждую итерацию главного цикла система частиц обновляет старые квадратики и собирает сколько-то новых, которые пускает в случайном направлении:
Камера
Следующим шагом нужно было написать камеру, которая могла бы перемещаться и смотреть в разные стороны. Чтобы понять, как решить эту задачу, нам потребовались знания из линейной алгебры. Если вам это не очень интересно, можете пропустить раздел, посмотреть гифку и читать дальше.
Мы хотим нарисовать в координатах экрана вершину, зная ее координаты относительно центра объекта, которому она принадлежит.
- Сначала нам понадобится найти ее координаты относительно центра мира, в котором находится объект.
- Затем, зная координаты и расположение камеры, найти положение вершины в базисе камеры.
- После чего спроецировать вершину на плоскость экрана.
Как можно видеть, выделяются три этапа. Им соответствуют домножения на три матрицы. Мы назвали эти матрицы Model
, View
и Projection
.
Начнем с получения координат объекта в базисе мира. С объектом можно делать три преобразования: масштабировать, поворачивать и перемещать. Все эти операции задаются домножением исходного вектора (координат в базисе объекта) на соответствующие матрицы. Тогда матрица Model
будет выглядеть так:
Model = Translate * Scale * Rotate.
Дальше, зная положение камеры, мы хотим определить координаты в ее базисе: домножить полученные ранее координаты на матрицу View
. В C++ это удобно вычислить с помощью функции:
glm::mat4 View = glm::lookAt(cameraPosition, objectPosition, up);
Дословно: посмотри на objectPosition
с позиции cameraPosition
, причем направление вверх — это «up». Зачем нужно это направление? Представьте, что вы фотографируете чайник. Вы направляете на него камеру и располагаете чайник в кадре. В этот момент вы можете точно сказать, где у кадра верх (скорее всего там, где у чайника крышка). Программа не может за нас додумать, как расположить кадр, и именно поэтому нужно указывать вектор «up».
Мы получили координаты в базисе камеры, осталось спроецировать полученные координаты на плоскость камеры. Этим занимается матрица Projection
, которая создает эффект уменьшения объекта при его отдалении от нас.
Чтобы получить координаты вершины на экране, нужно перемножить вектор на матрицу по крайней мере пять раз. Все матрицы имеют размер 4 на 4, так что придется проделать довольно много операций умножения. Мы не хотим нагружать ядра процессора большим количеством простых задач. Для этого лучше подойдет видеокарта, у которой есть необходимые ресурсы. Значит, нужно написать шейдер: небольшую инструкцию для видеокарты. В OpenGL есть специальный шейдерный язык GLSL, похожий на C, который поможет нам это сделать. Не будем вдаваться в подробности написания шейдера, лучше наконец-то посмотрим на то, что вышло:
Пояснение: есть десять квадратов, которые находятся на небольшой дистанции друг за другом. По правую сторону от них находится игрок, который вращает и перемещает камеру.
Физика
Какая же игра без физики? Для обработки физического взаимодействия мы решили использовать библиотеку Box2d и создали класс WorldObject2d
, который наследовался от GraphicalObject2d
. К сожалению, не получилось использовать Box2d «из коробки», поэтому отважный Илья написал обертку к b2Body и всем физическим соединениям, которые есть в этой библиотеке.
До этого момента мы думали сделать графику в движке абсолютно двумерной, а для освещения, если решим его добавлять, использовать технику raycasting. Но у нас под рукой была замечательная камера, которая умеет отображать объекты во всех трех измерениях. Поэтому мы добавили всем двумерным объектам толщину — почему бы и нет? К тому же в перспективе это позволит делать довольно красивое освещение, которое будет оставлять тени от толстых объектов.
Освещение появилось между делом. Для его создания потребовалось написать соответствующие инструкции для рисования каждого пикселя — фрагментный шейдер.
Текстуры
Для загрузки изображений мы использовали библиотеку DevIL. Каждому GraphicalObject2d
стал соответствовать один экземпляр класса GraphicalPolygon
— лицевая часть объекта — и GraphicalEdge
— боковая часть. На каждую можно натянуть свою текстуру. Первый результат:
Все основное, что требуется от графики, уже готово: отрисовка, один источник освещения и текстуры. Графика — на данном этапе все.
Каждый объект, каким бы он ни был, — состоянием в машине состояний, графическим или же физическим — должен «тикать», то есть обновляться каждую итерацию игрового цикла.
Объекты, которые умеют обновляться, наследуются от созданного нами класса Behavior. У него есть функции onStart, onActive, onStop
, которые позволяют переопределить поведение наследника при запуске, при жизни и при завершении его активности. Теперь нужно создать верховный объект Activity
, который бы вызывал эти функции от всех объектов. Функция loop, которая это делает, выглядит следующим образом:
void loop():
onAwake();
awake = true;
while (awake):
onStart();
running = true
while (running):
onActive();
onStop();
onDestroy();
Пока running == true
, кто-нибудь может вызвать функцию pause()
, которая сделает running = false
. Если же кто-то вызовет kill()
, то awake
и running
обратятся в false
, и активность остановится полностью.
Проблема: хотим поставить группу объектов на паузу, например, систему частиц и частицы внутри нее. В текущем состоянии для этого нужно вручную вызвать onPause
для каждого объекта, что не очень удобно.
Решение: у каждого Behavior
пусть будет массив subBehaviors
, которые он будет обновлять, то есть:
void onStart():
onStart() // не забыть стартовать себя самого
for sb in subBehaviors:
sb.onStart() // а только после этого все дочерние Behavior
void onActive():
onActive()
for sb in subBehaviors:
sb.onActive()
И так далее, для каждой функции.
Но не любое поведение можно задать таким образом. Например, если по платформе гуляет враг — enemy, то у него, скорее всего, есть разные состояния: он стоит — idle_stay
, он гуляет по платформе, не замечая нас — idle_walk
, и в любой момент может заметить нас и перейти в состояние атаки — attack
. Еще хочется удобным образом задавать условия перехода между состояниями, например:
bool isTransitionActivated(): // для idle_walk->attack
return canSee(enemy);
Нужным паттерном является машина состояний. Ее мы тоже сделали наследником Behavior
, так как на каждом тике нужно проверять, пришло ли время переключить состояние. Это полезно не только для объектов в игре. Например, Level
— это состояние Level Switcher
, а переходы внутри машины контроллера — это условия на переключения уровней в игре.
У состояния есть три стадии: оно началось, оно тикает, оно остановлено. К каждой из стадий можно добавлять какие-то действия, например, прикрепить к объекту текстуру, применить к нему импульс, установить скорость и так далее.
Создавая уровень в редакторе, хочется иметь возможность сохранить его, а сама игра должна уметь загружать уровень из сохраненных данных. Поэтому все объекты, которые нужно сохранять, наследуются от класса NamedStoredObject
. Он хранит строку с именем, названием класса и обладает функцией dump()
, которая сбрасывает данные об объекте в строку.
Чтобы сделать сохранение, остается просто переопределить dump()
для каждого объекта. Загрузка — это конструктор от строки, содержащей всю информацию об объекте. Загрузка завершена, когда такой конструктор сделан для каждого объекта.
На самом деле, игра и редактор — это почти один и тот же класс, только в игре уровень загружается в режиме чтения, а в редакторе — в режиме записи. Для записи и чтения объектов из json-а движок использует библиотеку rapidjson.
В какой-то момент перед нами встал вопрос: пусть уже написана графика, машина состояний и все прочее. Как пользователь сможет написать игру, используя это?
В первоначальном варианте ему пришлось бы отнаследоваться от Game2d
и переопределить onActive
, а в полях класса создавать объекты. Но во время создания он не может видеть того, что создает, и нужно было бы еще и скомпилировать его программу и прилинковать к нашей библиотеке. Ужас! Были бы и плюсы — можно было бы задавать столь сложные поведения, на какие только хватило бы фантазии: например, передвинуть блок земли на столько, сколько жизней у игрока, и делать это при условии, что Уран в созвездии Тельца, а курс евро не превышает 40 рублей. Однако мы все-таки решили сделать графический интерфейс.
В графическом интерфейсе количество действий, которые можно произвести с объектом, будет ограничено: перелистнуть слайд анимации, применить силу, установить определенную скорость и так далее. Та же ситуация с переходами в машине состояний. В больших движках проблему ограниченного количества действий решают связыванием текущей программы с другой — например, в Unity и Godot используется связывание с C#. Уже из этого скрипта можно будет сделать что угодно: и посмотреть, в каком созвездии Уран, и какой сейчас курс евро. У нас такой функциональности на данный момент нет, но в наши планы входит связать движок с Python 3.
Для реализации графического интерфейса мы решили использовать Dear ImGui, потому что она очень маленькая (по сравнению с широко известным Qt) и писать на ней очень просто. ImGui — парадигма создания графического интерфейса. В ней каждую итерацию главного цикла все виджеты и окна отрисовываются заново только если это нужно. С одной стороны, это уменьшает объем потребляемой памяти, но с другой, скорее всего, занимает больше времени, чем одно выполнение сложной функции создания и сохранение нужной информации для последующего рисования. Тут уже осталось только реализовать интерфейсы для создания и редактирования.
Вот как в момент выхода статьи выглядит графический интерфейс:
Редактор уровня
Редактор машины состояний
Мы создали только основу, на которую можно вешать что-то более интересное. Иными словами, есть куда расти: можно реализовать отрисовку теней, возможность создания более чем одного источника освещения, можно связать движок с интерпретатором Python 3, чтобы писать скрипты для игры. Хотелось бы доработать интерфейс: сделать его красивее, добавить больше различных объектов, поддержку горячих клавиш…
Работы еще предстоит много, но мы довольны тем, что имеем на данный момент.
За время создания проекта мы получили много разнообразного опыта: работы с графикой, создания графических интерфейсов, работы с json файлами, обертки многочисленных C библиотек. А еще опыт написания первого большого проекта в команде. Надеемся, что нам удалось рассказать о нем так же интересно, как было интересно им заниматься :)
Ссылка на гихаб проекта: github.com/Glebanister/ample