На чем написать движок
Написать игровой движок на первом курсе: легко! (ну почти)
Привет! Меня зовут Глеб Марьин, я учусь на первом курсе бакалавриата «Прикладная математика и информатика» в Питерской Вышке. Во втором семестре все первокурсники нашей программы делают командные проекты по С++. Мы с моими партнерами по команде решили написать игровой движок.
О том, что у нас получается, читайте под катом.
Всего нас в команде трое: я, Алексей Лучинин и Илья Онофрийчук. Никто из нас не является экспертом в разработке игр, а тем более в создании игровых движков. Для нас это первый большой проект: до него мы выполняли только домашние задания и лабораторные работы, так что едва ли профессионалы в области компьютерной графики найдут здесь новую для себя информацию. Мы будем рады, если наши идеи помогут тем, кто тоже хочет создать свой движок. Но тема эта сложна и многогранна, и статья ни в коем случае не претендует на полноту специализированной литературы.
Всем остальным, кому интересно узнать о нашей реализации, — приятного чтения!
Графика
Первое окно, мышь и клавиатура
Для создания окон, обработки ввода с мыши и клавиатуры мы выбрали библиотеку SDL2. Это был случайный выбор, но мы о нем пока что не пожалели.
Важно было на самом первом этапе написать удобную обертку над библиотекой, чтобы можно было парой строчек создавать окно, проделывать с ним манипуляции вроде перемещения курсора и входа в полноэкранный режим и обрабатывать события: нажатия клавиш, перемещения курсора. Задача оказалось несложной: мы быстро сделали программу, которая умеет закрывать и открывать окно, а при нажатии на ПКМ выводить «Hello, World!».
Тут появился главный игровой цикл:
Hello World
Камера
Следующим шагом нужно было написать камеру, которая могла бы перемещаться и смотреть в разные стороны. Чтобы понять, как решить эту задачу, нам потребовались знания из линейной алгебры. Если вам это не очень интересно, можете пропустить раздел, посмотреть гифку и читать дальше.
Мы хотим нарисовать в координатах экрана вершину, зная ее координаты относительно центра объекта, которому она принадлежит.
Начнем с получения координат объекта в базисе мира. С объектом можно делать три преобразования: масштабировать, поворачивать и перемещать. Все эти операции задаются домножением исходного вектора (координат в базисе объекта) на соответствующие матрицы. Тогда матрица Model будет выглядеть так:
Чтобы получить координаты вершины на экране, нужно перемножить вектор на матрицу по крайней мере пять раз. Все матрицы имеют размер 4 на 4, так что придется проделать довольно много операций умножения. Мы не хотим нагружать ядра процессора большим количеством простых задач. Для этого лучше подойдет видеокарта, у которой есть необходимые ресурсы. Значит, нужно написать шейдер: небольшую инструкцию для видеокарты. В OpenGL есть специальный шейдерный язык GLSL, похожий на C, который поможет нам это сделать. Не будем вдаваться в подробности написания шейдера, лучше наконец-то посмотрим на то, что вышло:
Пояснение: есть десять квадратов, которые находятся на небольшой дистанции друг за другом. По правую сторону от них находится игрок, который вращает и перемещает камеру.
Физика
До этого момента мы думали сделать графику в движке абсолютно двумерной, а для освещения, если решим его добавлять, использовать технику raycasting. Но у нас под рукой была замечательная камера, которая умеет отображать объекты во всех трех измерениях. Поэтому мы добавили всем двумерным объектам толщину — почему бы и нет? К тому же в перспективе это позволит делать довольно красивое освещение, которое будет оставлять тени от толстых объектов.
Освещение появилось между делом. Для его создания потребовалось написать соответствующие инструкции для рисования каждого пикселя — фрагментный шейдер.
Текстуры
Для загрузки изображений мы использовали библиотеку DevIL. Каждому GraphicalObject2d стал соответствовать один экземпляр класса GraphicalPolygon — лицевая часть объекта — и GraphicalEdge — боковая часть. На каждую можно натянуть свою текстуру. Первый результат:
Все основное, что требуется от графики, уже готово: отрисовка, один источник освещения и текстуры. Графика — на данном этапе все.
Машина состояний, задание поведений объектов
Каждый объект, каким бы он ни был, — состоянием в машине состояний, графическим или же физическим — должен «тикать», то есть обновляться каждую итерацию игрового цикла.
Проблема: хотим поставить группу объектов на паузу, например, систему частиц и частицы внутри нее. В текущем состоянии для этого нужно вручную вызвать onPause для каждого объекта, что не очень удобно.
И так далее, для каждой функции.
У состояния есть три стадии: оно началось, оно тикает, оно остановлено. К каждой из стадий можно добавлять какие-то действия, например, прикрепить к объекту текстуру, применить к нему импульс, установить скорость и так далее.
Сохранения
Чтобы сделать сохранение, остается просто переопределить dump() для каждого объекта. Загрузка — это конструктор от строки, содержащей всю информацию об объекте. Загрузка завершена, когда такой конструктор сделан для каждого объекта.
На самом деле, игра и редактор — это почти один и тот же класс, только в игре уровень загружается в режиме чтения, а в редакторе — в режиме записи. Для записи и чтения объектов из json-а движок использует библиотеку rapidjson.
Графический интерфейс
В какой-то момент перед нами встал вопрос: пусть уже написана графика, машина состояний и все прочее. Как пользователь сможет написать игру, используя это?
В графическом интерфейсе количество действий, которые можно произвести с объектом, будет ограничено: перелистнуть слайд анимации, применить силу, установить определенную скорость и так далее. Та же ситуация с переходами в машине состояний. В больших движках проблему ограниченного количества действий решают связыванием текущей программы с другой — например, в Unity и Godot используется связывание с C#. Уже из этого скрипта можно будет сделать что угодно: и посмотреть, в каком созвездии Уран, и какой сейчас курс евро. У нас такой функциональности на данный момент нет, но в наши планы входит связать движок с Python 3.
Для реализации графического интерфейса мы решили использовать Dear ImGui, потому что она очень маленькая (по сравнению с широко известным Qt) и писать на ней очень просто. ImGui — парадигма создания графического интерфейса. В ней каждую итерацию главного цикла все виджеты и окна отрисовываются заново только если это нужно. С одной стороны, это уменьшает объем потребляемой памяти, но с другой, скорее всего, занимает больше времени, чем одно выполнение сложной функции создания и сохранение нужной информации для последующего рисования. Тут уже осталось только реализовать интерфейсы для создания и редактирования.
Вот как в момент выхода статьи выглядит графический интерфейс:
Редактор машины состояний
Заключение
Мы создали только основу, на которую можно вешать что-то более интересное. Иными словами, есть куда расти: можно реализовать отрисовку теней, возможность создания более чем одного источника освещения, можно связать движок с интерпретатором Python 3, чтобы писать скрипты для игры. Хотелось бы доработать интерфейс: сделать его красивее, добавить больше различных объектов, поддержку горячих клавиш…
Работы еще предстоит много, но мы довольны тем, что имеем на данный момент.
За время создания проекта мы получили много разнообразного опыта: работы с графикой, создания графических интерфейсов, работы с json файлами, обертки многочисленных C библиотек. А еще опыт написания первого большого проекта в команде. Надеемся, что нам удалось рассказать о нем так же интересно, как было интересно им заниматься 🙂
Как я писал кросплатформенный 3d игровой движок
Что меня побудило писать движок
Началась история довольно давно и с того, что мне захотелось поиграть в замечательную логическую головоломку «Pusher» на Vista 64. Однако оригинальная игра была 16 битной и напрочь отказывалась запускаться. Ничего лучше, чем написать на Си её клон, я не придумал (иногда не лучшее изначально решение приводит к более полезным результатам). Спустя небольшое время я реализовал игру на платформе SDL v1.2 + OpenGL.
Для удобства переноса карт добавил редактор уровней и вручную клонировал все 64 карты. Через какое-то время интерес к «Pusher» ослаб, и мне уже захотелось погонять в Tomb raider 1 с 3dfx графикой. И как многие уже догадались, существующие решения это сделать меня не особо порадовали (скорее даже субъективно) и я занялся поиском портов. Кроме известного проекта Open raider я ничего не нашел. До сих пор помню как мучился с его сборкой под windows с помощью компилятора mingw (среду разработки не помню, либо code::blocks, либо netbeans). Результат сборки меня совсем не порадовал: загрузка уровня около минуты и черный экран в итоге. Умения ковырять чужой код, понимать его структуру и смысл функций у меня не было. Попытки собрать «лучше» прекратились. Однако я загорелся идеей собрать хоть один из открытых движков вручную, не автоконфигом Die GNU Autotools, а с самостоятельно собранного проектника в среде разработки.
Таким образом, после кучи времени за монитором, определенного количества мата и т.д., я собрал Quake Tenebrae без звука. Зато он работал! Это была маленькая победа, которая принесла плоды: я стал лучше разбираться в чужом коде и наконец-то стал хоть что-то понимать в организации работы компилятора – без чего вообще никак нельзя. После было сделано несколько мелких доработок, устранены некоторые баги и запущен звук, но проект так и не был залит в интернет (даже тогда он был морально устаревшим, особенно с учетом наличия Dark places engine). Однако из кода движка Quake Tenebrae я узнал как организована работа игры в целом, отдельных её компонентов и менеджера памяти (я добавил в него функцию realloc, пусть довольно простую, но все работало без вылетов).
Пишем движок
Когда я немного освоился, то решил начать писать свой движок с нуля. Просто ради интереса и саморазвития. Базой для создания движка служило следующее: компилятор GCC-TDM v4.Х.Х + msys и среда разработки Netbeans; библиотеки: SDL v1.2 + OpenGL. Первая реализованная функция была созданием скриншота и его сохранением в файл *.bmp с применением самописной библитечки для работы с этим форматом. Какой движок может обойтись без консоли для ввода читов команд и вывода текста – наверное никакой, поэтому следующим делом я изучил вопрос о том, как выводить текст в окно OpenGL и выбрал связку freetype 1 + gltt. Первой распознаваемой командой была команда exit и уже после – команды для игры с размерами шрифтов, строк и т.д…. Для справки: мне понравился код, используемый в Quake I для парсинга строк и последовательного его разбития на токены, который до сих пор присутствует в движке:
Забегая вперед: когда потребовалась поддержка скриптов я решил использовать подход к разработке движка как в «ID software» на примере «DOOM 3 engine», который был очень хорошо описан здесь, на Хабре =) (хочется еще раз сказать огромное спасибо авторам за статью, перевод и написавшим интересные комментарии к ней людям). Под впечатлением статьи я решил внедрить в свой движок LUA не только для внутриигровых скриптовых нужд, но еще для парсинга конфигурационных файлов и консольных команд (т.е. везде используется единообразная система). Подход оправдал себя абсолютно.
Перейдем к 3d
Мне повезло, что в институте мне нравились линейная алгебра, матричные преобразования, вектора и численные методы. Без этих основ к программированию движка с нуля приступать очень опрометчиво (разве для того, чтобы изучить эти разделы на примерах, однако без определенной теоретической базы знаний это будет мало реально). В освоении графики мне сильно помогла книга А. Борескова «Графика трехмерной компьютерной игры на основе OPENGL». Перечитал ее не один раз (для ознакомления с математическим аппаратом, типами рендереров и структурой движков). Без понятия о принципе построения сцены и предназначения видовой матрицы и матрицы проекции продвинуться никуда не удастся. После изучения некоторого материала в интернете и просто литературы, я решил делать портальный рендерер. Первое что было реализовано в движке – это свободно летающая камера и несколько порталов, которые можно было увидеть только друг через друга.
После хардкодом была добавлена wireframe сцена из 3-х комнат (две побольше и коридор). Но разве интересно летать в таком примитивном мире… И тут я решил воспользоваться загрузчиком ресурсов из Open raider и порендерить уровни. Результат меня порадовал и тут уже окончательно было решено реализовывать замысел по созданию порта для игры в Tomb raider, хотя бы в первую её часть. Для позиционирования объектов в пространстве я использовал матрицу в формате OpenGL так как это позволяет обращаться к базовым векторам локальной системы координат объекта, задавать положение объекта одной командой glMultMatrixf(transform) и задавать ориентацию объекта в физическом движке bullet одной командой setFromOpenGLMatrix(transform), о чем чуть позже. Ниже приведено изображение со структурой матрицы OpenGL с выше приведенной ссылки:
Для ведения истории изменений в движке и возможности бэкапа и просто для саморазвития было решено использовать систему контроля версий mercurial. Её применение позволило не только прослеживать прогресс в написании кода, но и дало возможность загрузить результаты на sourceforge.com. Следует отметить, что когда в движок начала загружаться информация о порталах с реальных карт, сразу всплыло огромное количество недоработок моей реализации портальной системы. На борьбу с пропадающими объектами и вылетами ушло немало времени, и даже сейчас я считаю, что портальный модуль нуждается в серьезной доработке. Сейчас рендерер движка в зависимости от положения камеры и ее ориентации начинает прохождение по порталам комнат и добавляет в список только видимые комнаты. Потом рендерер рисует комнаты и их содержимое из списка. Понятно, что для больших открытых пространств такой подход не самый удачный, однако для целей проекта его вполне достаточно. Вот пример рекурсивной функции обхода по комнатам:
Скелетные анимированные модели
И вот началась одна из самых кропотливых работ в проекте. Рендерить статичные комнаты со статичными объектами оказалось сравнительно несложно, но вот когда дело дошло до анимированных скелетных моделей… Первое что я понял: загрузчик ресурсов Open raider не грузит всю необходимую информацию о скелетной модели. Количество фреймов в анимации определяется некорректно, из-за чего одна анимация содержит в себе фреймы сразу от нескольких.
В ходе попыток решения этих проблем я нашел различные документации по формату уровней Tomb raider и заодно проект vt, в котором был свой загрузчик ресурсов. Пусть в этом проекте не было загрузки кадров анимации моделей, зато в нем был более структурированный код, удобный для чтения и доведения до ума. Так я заменил загрузчик в проекте на vt. Для примера: в Open raider все 5 частей Tomb raider грузятся одной длинной функцией с кучей if и switch по номеру версии игры, что заметно усложняло чтение кода и поиск ошибок. В vt было 5 модулей, каждый из которых отвечал за свою версию уровня, благодаря чему код читался достаточно легко, и внесение изменений не представляло трудностей.
Главной проблемой с анимациями оказалось извлечение углов поворотов костей в скелетной модели. Дело в том, что для экономии места углы хранились в байткоде с шагом в 2-4 байта. В первые 2 байта входит флаг о том один ли здесь поворот и вокруг какой оси, или сразу три и сами углы. В случае 3-х поворотов флаги и углы хранятся в 4-х байтах, в случае одного используются только 2 байта. При этом углы для всех моделей, анимаций и кадров хранятся в одном массиве и смещения надо вычислять. К тому же в этом байткоде еще хранятся заголовки отдельных фреймов модели и путаница со смещениями критична, а теперь прибавим то, что количество фреймов грузится некорректно (в последствии оказалось, что количество фреймов дано для «интерполированных» анимаций с частотой 30 fps, а реально, кадры могут хранятся в «ужатом» виде с fps со множителями 1, 1/2, 1/3 и 1/4). После допиливания загрузчика фреймов анимаций, скелетные модельки перестали выворачиваются наизнанку и превращаться в кашу из искаженных полигонов! Теперь надо «оживить» Лару. Ниже приведен код функции, генерирующей скелетную модель, сохранена орфография и закомментированые участки кода для отладки:
Выход в свет
Когда скелетные модели заработали, уже можно было переходить к их расстановке по уровню и «оживлять» Лару, что требовало наличия физики. Для начала было решено писать свой физический движок, чтобы лучше ознакомиться с темой и потом уже более основательно подойти к выбору уже готовых продуктов. Первое что требуется для создания контроллера персонажа – это определение высот. Изначально была написана (на основе барицентрического алгоритма) функция определения пересечения треугольника и луча. После были добавлены такие базовые методы, как определение пересечения движущихся отрезков, треугольника и сферы, треугольника и треугольника. Следует отметить, что такой подход исключает возможность появления так называемого «туннельного эффекта» (когда из-за большой скорости объекты с большими скоростями могут пролететь друг сквозь друга без столкновения), присущего impulse based физическим движкам.
И вот Лара бегает по уровням, пусть и минуя все ступени любых размеров, зато не вываливается за пределы карты! Когда проект был в таком состоянии мне написал Анатолий Lwmte о том, что круто, что хоть кому-то интересны первые части Tomb raider. Так началась переписка, благодаря которой к проекту начал заново появляться интерес. После я зарегистрировался на tombraiderforums.com (Анатолий был там уже достаточно долго, со своим проектом по улучшению движка четвертой части Tomb raider). Благодаря нему на этом форуме появилась тема с моим движком и много доработок в коде: менеджер звука, переделка системы контроля состояний (до этого у меня был switch по номерам анимаций, теперь он по номерам состояний) и т.д. Наличие заинтересованных в проекте людей хорошо мотивирует развивать проект.
Физика + оптимизация рендерера
Поскольку я использовал свою физику, да еще и с плохой оптимизацией, в некоторых местах стало проседать fps. Путем долгих ковыряний различных физических движков с открытым исходным кодом был выбран bullet. Первой делом я добавил фильтр на столкновения в случае пересекающихся комнат. Дело в том, что дизайн оригинальных уровней допускает пересечение 2-х и более совершенно различных комнат в одном месте, при этом объекты одной комнаты ни как не должны влиять на объекты другой; аналогично и с отрисовкой. В настоящее время я стараюсь довести до ума контроллер персонажа: устранить возможность прохода сквозь стены (происходит в ряде анимаций в упор к стене) и доделать реакцию и поведение персонажа в случае климбинга по стенам и потолку.
Вернемся к OpenGL. Изначально в движке отрисовка полигонов велась с помощью glVertex3fv(. ) и т.д.; Про производительность такого подхода и скорость работы движка можно сказать одно: их нет. Поэтому после изучения части, касающейся VBO (Vertex Buffer Object), я сделал оптимизацию и стал по возможности хранить данные вершин полигонов в видео памяти и отрисовывать меш одним заходом. Скорость заметно возросла. Однако из-за того, что для одного меша текстуры могли лежать в разных массивах пикселей, переключение OpenGL текстур было чаще чем надо, а то, что текстуры многих различных объектов могут храниться в одном массиве пикселей, создавало «артефакты» при включенном сглаживании. Cochrane с tombraiderforums.com взялся за оптимизацию рендерера и написал текстурный атлас с границами между текстурами. Благодаря этому нововведению все текстуры уровня хранятся в 1 – 2 OpenGL текстурах и сглаживание не приводит к появлению «артефактов». К тому же он сделал порт проекта на MacOS.
Когда совсем не было идей за что и как браться в движке, я просто искал ошибки в коде, подправлял его структуру или менял подключаемые библиотеки. Таким образом было проведено «переселение» с SDL1 на SDL2, с freetype1 + gltt к freetype2 + ftgl. Аналогичным образом мне пришла идея добавить сглаживание анимациям с помощью сферической интерполяции slerp. Здесь я хочу добавить: будьте внимательны к математическим алгоритмам, особенно когда дело касается «арок» (asin, acos, atan…) – потеря знака чревата убойными кадрами с перекошенным и перекрученным скелетом. Советую посмотреть реализацию slerp в исходном коде bullet. После добавления сглаживания, я уже не мог смотреть на несглаженные анимации. Далее возникла необходимость грузить и проигрывать звук, а то бегать по уровням в гробовой тишине не очень-то, хоть и Tomb raider.
Добавляем звук
Для использования звука применение SDLAudio + SDLMixer совершенно недостаточно, а лезть в алгоритмы преобразования звукового потока и делать велосипеды для создания эффектов – совсем плохая идея. Посоветовавшись с Анатолием было решено использовать OpenAL. Поскольку я руководствовался тем, чтобы как можно больше платформо-зависимого кода переложить на SDL, то ничего лучше написания SDL_backend для OpenAL я не придумал. Однако оно сработало, я добавил инструмент в движок, а Анатолий заставил все играть когда надо, где надо и еще с нужными эффектами.
И вот подошла пора оживлять всевозможные рычаги, ловушки и прочие триггеры игрового мира. Фактически здесь разработка шла по логике: мне нужно что-то реализовать, какие для этого нужны инструменты, как их реализовать. Основная функция, применяемая для работы скриптов – это получение указателя на объект по числовому id, дальше любая LUA функция сможет обрабатывать все необходимые объекты. Для динамического добавления и удаления объектов и возможности быстрого доступа к ним по id я применил красно-черные деревья. По идее можно было применить хэш таблицы, но тут скорее уже сработали личные предпочтения.
В итоге уже сейчас скриптовая система позволяет проводить практические любые манипуляции с объектами и анимациями, создавать задачи (и на их основе таймеры), подбирать предметы, нажимать рычании и кнопки, открывая и закрывая тем самым двери и не только. Благодаря стараниям людей из сообщества tombraiderforums.com был добавлен gameflow_manager, отвечающий за переход с одного уровня на другой, загрузку нужных скриптов и экранных заставок, загрузка информации об источниках света и реализация простейшего lightmap на основе корректировки цветов вершин и cmake скрипт для сборки под OS Lunux.
Послесловие
В конце хочется обратить внимание на то, что когда используешь сторонние ресурсы, то проще с тестами и не надо нагружаться созданием контента, однако это накладывает ограничения на архитектуру движка или приводит к необходимости конвертирования форматов при загрузке для того, чтобы не городить ужасных костылей уже внутри игрового движка. А костылей в оригинальных Tomb raider очень много.
Дальнейшие планы в проекте просты:
1) исправить существующие баги, в особенности с физикой, и расширить возможности контроллера персонажей;
2) «оживить» врагов на картах, добавить ИИ и оружие;
3) расширить систему управления анимациями скелетных моделей для переключения мешей;
4) расширить возможности скриптовой системы и написать ключевые уровневые скрипты, чтобы можно было пройти по нормальному игру;
5) улучшить графику в игре, добавить эффекты, однако здесь я рассчитываю на помощь более квалифицированных программистов OpenGL;
Напоследок несколько видео с примером работы движка:
Как написать собственный игровой движок на C++
Перевод статьи Джеффа Прешинга (Jeff Preshing) How to Write Your Own C++ Game Engine.
Как написать собственный игровой движок на C++
В последнее время я занят тем, что пишу игровой движок на C++. Я пользуюсь им для создания небольшой мобильной игры Hop Out. Вот ролик, записанный с моего iPhone 6. (Можете включить звук!)
Hop Out — та игра, в которую мне хочется играть самому: ретро-аркада с мультяшной 3D-графикой. Цель игры — перекрасить каждую из платформ, как в Q*Bert.
Hop Out всё ещё в разработке, но движок, который приводит её в действие, начинает принимать зрелые очертания, так что я решил поделиться здесь несколькими советами о разработке движка.
С чего бы кому-то хотеть написать игровой движок? Возможных причин много:
Игровые платформы в 2017-ом — мобильные, консоли и ПК — очень мощные и во многом похожи друг на друга. Разработка игрового движка перестала быть борьбой со слабым и редким железом, как это было в прошлом. По-моему, теперь это скорее борьба со сложностью вашего собственного произведения. Запросто можно сотворить монстра! Вот почему все советы в этой статье вращаются вокруг того, как сохранить код управляемым. Я объединил их в три группы:
Эти советы применимы к любому игровому движку. Я не собираюсь рассказывать, как написать шейдер, что такое октодерево или как добавить физику. Я полагаю, вы и так в курсе, что должны это знать — и во многом эти темы зависят от типа игры, которую вы хотите сделать. Вместо этого я сознательно выбрал темы, которые не освещаются широко — темы, которые я нахожу наиболее интересными, когда пытаюсь развеять завесу тайны над чем-либо.
Используйте итеративный подход
Мой первый совет — не задерживаясь заставьте что-нибудь (что угодно!) работать, затем повторите.
Вуаля! У меня появился замечательный вращающийся кубик, использующий OpenGL ES 2.0.
Моим следующим шагом было скачивание сделанной кем-то 3D-модели Марио. Я быстро написал черновой загрузчик OBJ-файлов — этот формат не так уж сложен — и подправил пример, чтобы он отрисовывал Марио вместо кубика. Ещё я интегрировал SDL_Image, чтобы загружать текстуры.
Затем я реализовал управление двумя стиками, чтобы перемещать Марио. (Поначалу я рассматривал идею создания dual-stick шутера. Впрочем, не с Марио).
Следующим делом я хотел познакомиться со скелетной анимацией, так что открыл Blender, создал модель щупальца и привязал к нему скелет из двух костей, которые колебались туда-сюда.
К тому моменту я отказался от формата OBJ и написал скрипт на Python для экспорта собственных JSON-файлов из Blender. Эти JSON-файлы описывали заскиненный меш, скелет и данные анимации. Я загружал эти файлы в игру с помощью библиотеки C++ JSON.
Как только всё заработало, я вернулся в Blender и создал более проработанного персонажа (Это был первый сделанный и зариганный мной трёхмерный человек. Я им весьма гордился.)
В течение следующих нескольких месяцев я сделал такие шаги:
Ключевой момент в следующем: я не планировал архитектуру движка до того как начал программировать. Это был осознанный выбор. Вместо этого я всего лишь писал максимально простой код, реализующий следующую часть функционала, затем смотрел на него, чтобы увидеть, какая архитектура возникла естественным образом. Под «архитектурой движка» я понимаю набор модулей, которые составляют игровой движок, зависимости между этими модулями и API для взаимодействия с каждым модулем.
Этот подход итеративен, потому что фокусируется на небольших практических результатах. Он хорошо работает при написании игрового движка, потому что на каждом шаге у вас есть работающая программа. Если что-то идёт не так, когда вы выделяете код в новый модуль, вы всегда можете сравнить изменения с кодом, который раньше работал. Разумеется, я предполагаю, что вы пользуетесь какой-нибудь системой контроля версий.
Готов поспорить, что больше времени тратится при противоположном подходе: пытаться заранее продумать архитектуру, которая будет делать всё, что вам понадобится. Две моих любимых статьи про опасности чрезмерной инженерии — The Vicious Circle of Generalization Томаша Дабровски и Don’t Let Architecture Astronauts Scare You Джоэла Спольски.
Я не говорю, что вы не должны решать проблемы на бумаге до того, как столкнётесь с ними в коде. Я также не утверждаю, что вам не следует заранее решить, какой функционал вам нужен. Например, я знал с самого начала, что хочу, чтобы движок загружал все ресурсы в фоновом потоке. Просто я не пытался спроектировать или реализовать этот функционал до тех пор, пока мой движок не начал загружать хоть какие-то ресурсы.
Итеративный подход дал мне куда более элегантную архитектуру, чем я мог бы вообразить, глядя на чистый лист бумаги. iOS-сборка моего движка сегодня на 100% состоит из оригинального кода, включая собственную математическую библиотеку, шаблоны контейнеров, систему рефлексии/сериализации, фреймворк рендеринга, физику и аудио микшер. У меня были причины писать каждый из этих модулей самостоятельно, но для вас это может быть необязательным. Вместо этого есть множество отличных библиотек с открытым исходным кодом и разрешительной лицензией, которые могут оказаться подходящими вашему движку. GLM, Bullet Physics и STB headers — лишь некоторые из интересных примеров.
Дважды подумайте, прежде чем слишком обобщать
Как программисты, мы стремимся избегать дублирования кода, и нам нравится, когда код следует единому стилю. Тем не менее, я думаю, что полезно не давать этим инстинктам управлять всеми решениями.
Время от времени нарушайте принцип DRY
95% времени повторное использование существующего кода — верный путь. Но если оно начинает вас сковывать или вы обнаруживаете, что усложняете что-то, однажды бывшее простым, спросите себя: не должна ли эта часть кодовой базы в действительности быть разделена надвое.
Использовать разные соглашения о вызове — это нормально
Одна из вещей, которая мне не нравится в Java — то, что она заставляет вас определять каждую функцию внутри класса. По-моему, это бессмысленно. Это может придать вашему коду более единообразный вид, но также поощряет переусложнение и не поддерживает итеративный подход, описанный мной ранее.
А ещё есть динамическая диспетчеризация, которая является формой полиморфизма. Часто нам нужно вызвать функцию объекта, не зная точного типа этого объекта. Первый порыв программиста на C++ — определить абстрактный базовый класс с виртуальными функциями, затем перегрузить эти функции в производном классе. Работает, но это лишь одна из техник. Существуют и другие методы динамической диспетчеризации, которые не привносят так много дополнительного кода, или имеют другие преимущества:
Осознайте, что сериализация — обширная тема
Сериализация — это преобразование объектов времени выполнения в последовательность байтов и обратно. Другими словами, сохранение и загрузка данных.
При подготовке такого пайплайна выбор форматов файлов на каждой из стадий остаётся за вами. Вы можете определить несколько собственных форматов, и они могут эволюционировать в процессе того как вы добавляете функциональность в движок. В то время как они эволюционируют, у вас может возникнуть необходимость сохранить совместимость некоторых программ с ранее сохранёнными файлами. Не важно в каком формате, в конце концов вам придётся сериализовать их в C++.
Можно писать более гибкий, менее подверженный ошибкам код сериализации, пользуясь преимуществом рефлексии — а именно, созданием данных времени выполнения, описывающих расположение ваших C++ типов. Чтобы получить краткое представление о том, как рефлексия может помочь с сериализацией, взглянем на то, как это делает Blender, проект с открытым исходным кодом.
Как и Blender, многие игровые движки — и связанные с ними инструменты — создают и используют собственные данные рефлексии. Есть много способов делать это: вы можете разбирать собственный исходный код на C/C++, чтобы извлечь информацию о типах, как это делает Blender. Можете создать отдельный язык описания данных и написать инструмент для генерации описаний типов и данных рефлексии C++ из этого языка. Можете использовать макросы препроцессора и шаблоны C++ для генерации данных рефлексии во время выполнения. И как только у вас под рукой появятся данные рефлексии, открываются бесчисленные способы написать общий сериализатор поверх всего этого.
Несомненно, я упускаю множество деталей. В этой статье я хотел только показать, что есть много способов сериализовать данные, некоторые из которых очень сложны. Программисты просто не обсуждают сериализацию столько же, сколько другие системы движка, даже несмотря на то, что большинство других систем зависят от неё. Например, из 96 программистских докладов GDC 2017, я насчитал 31 доклад о графике, 11 об онлайне, 10 об инструментах, 3 о физике, 2 об аудио — и только один, касающийся непосредственно сериализации.
Как минимум, постарайтесь представить, насколько сложными будут ваши требования. Если вы делаете маленькую игру вроде Flappy Bird, с несколькими ассетами, вам скорее всего не придётся много думать о сериализации. Вероятно, вы можете загружать текстуры напрямую из PNG и этого будет достаточно. Если вам нужен компактный бинарный формат с обратной совместимостью, но вы не хотите разрабатывать свой — взгляните на сторонние библиотеки, такие как Cereal или Boost.Serialization. Не думаю, что Google Protocol Buffers идеально подходят для сериализации игровых ресурсов, но они всё равно стоят изучения.
Написание игрового движка — даже маленького — большое предприятие. Я мог бы сказать намного больше, но, если честно, самый полезный совет, который я могу придумать для статьи такой длины: работайте итеративно, слегка сопротивляйтесь тяге к обобщению кода, и помните, что сериализация — обширная тема, так что понадобится выбрать подходящую стратегию. Мой опыт показывает, что каждый из этих пунктов может стать камнем преткновения, если его игнорировать.
Я люблю сравнивать наблюдения по этой теме, так что мне очень интересно услышать мнение других разработчиков. Если вы писали движок, привел ли ваш опыт к тем же выводам? А если не писали или ещё только собираетесь, ваши мысли мне тоже интересны. Что вы считаете хорошим ресурсом для обучения? Какие аспекты ещё кажутся вам загадочными? Не стесняйтесь оставлять комментарии ниже или свяжитесь со мной через Twitter.