Написать игровой движок на первом курсе: легко! (ну почти)

    Привет! Меня зовут Глеб Марьин, я учусь на первом курсе бакалавриата «Прикладная математика и информатика» в Питерской Вышке. Во втором семестре все первокурсники нашей программы делают командные проекты по С++. Мы с моими партнерами по команде решили написать игровой движок. 

    О том, что у нас получается, читайте под катом.


    Всего нас в команде трое: я, Алексей Лучинин и Илья Онофрийчук. Никто из нас не является экспертом в разработке игр, а тем более в создании игровых движков. Для нас это первый большой проект: до него мы выполняли только домашние задания и лабораторные работы, так что едва ли профессионалы в области компьютерной графики найдут здесь новую для себя информацию. Мы будем рады, если наши идеи помогут тем, кто тоже хочет создать свой движок. Но тема эта сложна и многогранна, и статья ни в коем случае не претендует на полноту специализированной литературы.

    Всем остальным, кому интересно узнать о нашей реализации, — приятного чтения!

    Графика


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


    Для создания окон, обработки ввода с мыши и клавиатуры мы выбрали библиотеку 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, у которого есть массив объектов. Каждую итерацию главного цикла система частиц обновляет старые квадратики и собирает сколько-то новых, которые пускает в случайном направлении:



    Камера


    Следующим шагом нужно было написать камеру, которая могла бы перемещаться и смотреть в разные стороны. Чтобы понять, как решить эту задачу, нам потребовались знания из линейной алгебры. Если вам это не очень интересно, можете пропустить раздел, посмотреть гифку и читать дальше.

    Мы хотим нарисовать в координатах экрана вершину, зная ее координаты относительно центра объекта, которому она принадлежит.

    1. Сначала нам понадобится найти ее координаты относительно центра мира, в котором находится объект.
    2. Затем, зная координаты и расположение камеры, найти положение вершины в базисе камеры.
    3. После чего спроецировать вершину на плоскость экрана. 

    Как можно видеть, выделяются три этапа. Им соответствуют домножения на три матрицы. Мы назвали эти матрицы 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

    Комментарии 26

      +7
      glBegin(GL_QUADS);
      glVertex2f(-1.0f, 1.0f);
      glVertex2f(1.0f, 1.0f);
      glVertex2f(1.0f, -1.0f);
      glVertex2f(-1.0f, -1.0f);
      glEnd();

      Ой! Сожгите, пожалуйста, вашу методичку, учебник, что у вас там. OpenGL 1.0, созданный в 1992 году, к данному моменту сильно устарел. Его поддержка сейчас имеется на десктопах, и то в режиме эмуляции. В современных драйверах видеокарт это приводит к проблемам с производительностью — на старых драйверах программы работают лучше, чем на новых.

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

      Так что учите шейдеры. Иначе ваши знания, приобретенные в процессе работы над этим проектом, окажутся на практике бесполезными в большом проценте случаев. Исключением будет разве что перенос дремучего легаси, которое еще надо поискать.
        +1

        Судя по коду, уже используются какие-то стандартные шейдеры.


        Впрочем, компьютерной графики на первом курсе не было вообще, так что сжигать пока нечего.

          –1
          Посмотрел я на этот код. Неплохо так, но есть, что поревьюить. Например, есть странное место:

          //https://github.com/Glebanister/ample/blob/76f20b27e15366f38dd351ec34506d323d002091/core/src/Graphics/Shaders/Shader.cpp
          sstr << shaderStream.rdbuf();
          if (!sstr.good() || !shaderStream.good())
          {
                 throw exception::Exception(exception::exId::FILE_READ,
                                             exception::exType::CASUAL);
          }

          То есть, тут ошибка stream переводится в исключение. Но зачем — stream сам умеет швырять исключения, нужно только настроить.
            +2
            Хотелось кидаться исключениями, которые умеют красиво себя выводить, и которые знают о произошедшей ошибке немного больше, чем стандартные. Поэтому у истоков проекта был написан Exception, его дочерние исключения умеют обрабатывать ошибки OpenGL и SDL2, например вот так:
            exception::OpenGLException::handle();
            довольно удобно. Exception был написан давно и не самым лучшим способом, пользоваться им для обработки ошибок, которые не связаны с графикой не очень удобно, мне стоит переписать это.
              0
              Рассмотрите, на правах идеи, добавление в исключения __FILE__ и __LINE__ места, где оно выбросилось. Для отладочной сборки бывает полезно.
        0
        Как-то пытался писать свой движок в одиночку, к сожалению в какой-то момент потерял исходный код последней версии и забил, осталась копия, но чуть более старая. Движок писать долго, работа и получение денег всё же победили. Так же нашел для себя другой pet проект, которым начал заниматься. Думаю чуть позже вернуть к движку

        Вот пример одной из начальных версий
        www.youtube.com/watch?v=hl0eIBFhKKk

        К сожалению разработка движка сжирает просто огромное количество времени, настолько огромное, что я хотел прикрутить к двигу AngelScript и на нём оформлять логику по типу плагин системы, так как AS позволяет в реалтайме менять логику. Писать двиг это вливать время в пустоту, но очень интересно) Движков, кстати очень много валяется на гите, можно изучать
        • НЛО прилетело и опубликовало эту надпись здесь
            0
            Не очень понятно, что значит написать движок и компилятор. Еще не очень понятно, что значит написать движок под питон: чтобы в нем можно было писать скрипты на этом языке? В нашем движке планируется связки с Python 3, если вопрос в этом.
            0
            Эх, уже больше 15 лет прошло. После учебного Delphi хотелось «настоящего» программирования, тоже взялся за написание 3д-движка. Тоже С++ и OpenGl. Запала хватило на написание импорта моделей с текстурами, управления камерой и движением с прыжками :)
              0
              Иногда от осознания того, сколько всего еще предстоит сделать хотелось все бросить. Останавливало то, что этот проект уже был заявлен как учебный, значит нужно было его доделать любой ценой ) Вот после сессии планировал сделать импорт моделей, у нас сейчас все объекты — это параллелипипеды и правильные многоугольники
              +2
              Хорошая статья, и работа проделана немалая.
              Но если вы планируете на этом движке писать мало-мальски сложную игру, советую отказаться от концепции объекта с виртуальными методами и смотреть в сторону паттерна entity component system. Например, библиотеки EnTT. Они позволяют эффективно использовать кэш CPU и избежать накладных расходов от виртуальных методов а-ля update.
              Также, я бы посоветовал всё же заморочиться и заинтегрировать окно движка в Qt окно для целей редактирования. Сейчас работаю в компании, где огроменный игровой редактор написан на imgui и также интегрирован в игру, и подправлять что-то в нём это боль. Ни шорткатов тебе нормальных, ни окошек, не редактор, а какой-то среднеазиатский рынок, уж простите.
                0
                Большое спасибо)
                Мы решили, что Dear Imgui будет гораздо проще внедрить в проект, и времени на её изучение понадобится гораздо меньше. Qt, конечно, предоставляет больше возможностей, но недостаток времени сыграл решающую роль.
                0
                void onActive():
                onActive()
                for sb in subBehaviors:
                sb.onActive()

                Статья огонь, только что это за странный питоний синтаксис? У вас там какой-то препроцессор все это дело обрабатывает?

                  0
                  Нет, везде в статье приведен псевдокод, хотя до настоящего ему не очень далеко. Не хотелось везде ставить фигурные скобки и приводить настоящие выдержки из кода: мне показалось, что это затрудняло бы чтение. Если вам интересен код, то можете зайти на гитхаб, конкретно этот кусок вот тут
                  0

                  А есть код подсмотреть как выглядят ваши стейтмашинные блюпринты?

                    0
                    Да, все есть на гитхабе.
                    StateMachine.cpp
                    StateMachine.h
                      0

                      Я имел ввиду вот те штуки которые отображаются в редакторе. Хотел подглядеть как вы линии между виджетами дорисовываете. Оно оказывается через imnodes скрафчено

                    +1
                    Задача хорошая в плане понимания, а вот автору советую прекратить использовать OpenGL первой версии. Это бесполезный инструмент. Учите GLSL и вперед!
                      0
                      Уже не используем, это был первый Hello World, сейчас все немного современнее, с шейдерами и вершинными буфферами.
                      Вот например VertexArray.cpp
                      И даже самые простые шейдеры: Shaders
                        0
                        Обратите внимание на это:
                             result[i * 3 + 0] = vector[i].x;
                             result[i * 3 + 1] = vector[i].y;
                             result[i * 3 + 2] = vector[i].z;

                        Это стандартный код, побуждающий сделать стандартную ошибку — эффект последней строки.
                        Даже Кармак на этом ловился:
                        if (fabs(dir[0]) > test->radius ||
                            fabs(dir[1]) > test->radius ||
                            fabs(dir[1]) > test->radius)
                        (Q III)

                        Решение — пользоваться массивами и писать циклы.
                          0
                          Я согласен с вами, что это не пример не очень хорошего кода, но функция, в которой он написан так и называется: expand, раскрывает массив трехмерных координат {{x1, y1, z1}, {x2, y2, z2}} в массив чисел, который можно затем передать в OpenGL {x1, y1, z1, x2, y2, z2}. Во всем проекте не очень удобно оперировать вершинами без структуры Vector3d, содержащей трехмерные координаты, и так могло бы потенциально образоваться гораздо больше ошибок, чем если в одном месте пожертвовать красотой кода, написав такую развертку.
                      0
                      Планируется ли впиливание Lua или какого-нибудь другого скриптового языка в движок?
                        0
                        Да, уже связываю с Python 3, почему-то решил начать с него. Уже получилось с помощью SWIG сделать обертку над C++ кодом, теперь осталось только научиться вызывать написанные на Python скрипты из C++. В теории не должно возникнуть сложностей и с другими языками, как Lua, например, SWIG обо всем позаботится.
                        +1

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

                          0

                          Да, на первом курсе задача у нас в этом году примерно так и ставится: сделать что-нибудь работающее, в ~первый раз потренироваться в командной разработке и проектах на C++ длиннее нескольких недель. Не требуется научной новизны, прорывов или даже детального изучения предметной области или разумного выбора технологий.


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


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

                            0
                            А между тем выглядит вполне интересно. Думается, стоит таки продолжать развивать проект.

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое