Пару слов об архитектуре игрового движка

от автора


Предисловие

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

На протяжении всей статьи я предлагаю забыть про конкретные gamedev-паттерны (напр., update method, component-gameobject), графические библиотеки (OpenGL, DirectX), все различные wrapper’ы (типа SDL, GLUT), которые, несомненно, важны, но находятся на более высоком уровне абстракции в игровом движке.

Самый низкий уровень

Любой движок, каким бы сложным он ни был, по сути, выполняет три операции: считать ввод данных, обновить игру, отрисовать её. Эти операции могут быть бесконечно сложны и разделены на другие операции. Отсюда вытекает концепция простого движка:
void processInput() { ... }
void update() { ... }
void draw() { ... }
void gameloop() {
while (не нажали кнопку "выход") {
processInput();
update();
draw();
}
}

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

Абстрагируемся

Пусть класс Core отвечает за выполнение метода gameloop(). Так как ему достаточно знать, в каком порядке выполнять операции в данном методе, мы можем избавится от конкретных реализаций и вынести их в отдельные абстрактные классы: Renderer, Updater, Inputer. Renderer будет отвечать за отрисовку объектов, Updater за обновление скриптов, а Inputer за считывание ввода.
Тогда реализация Core будет выглядеть примерно так:

class Core final { private:     Renderer* _renderer;     Inputer* _inputer;     Updater* _updater;          void gameloop() {           while(true) {                         // условие выхода я рассмотрю чуть позже              _inputer.processInput();              _updater.update();              _renderer.render();          }     }  public:     Core(Renderer* renderer, Inputer* inputer, Updater* updater) : _renderer(renderer), _inputer(inputer), _updater(updater) {} 

Однако оставим на время класс Core и поговорим об этих трех классах.

Renderer

Данный класс отвечает за отрисовку всей игры. Но, прежде чем начать что-то рисовать, необходимо инициализировать графику, допустим, в каком-нибудь методе setup(). Так же нашему абстрактному отрисовщику нужно знать частоту кадров. Только потом мы рисуем, опять же, в каком-нибудь абстрактном методе render().
Таким образом, класс будет выглядеть примерно так:

class Renderer {      protected:     Renderer(int targetFPS) : _targetFPS(targetFPS) {}     virtual ~Renderer() {}      public:     virtual void setup() = 0;     virtual void render() = 0;          const int _targetFPS; }; 

В результате мы можем реализовать различные отрисовщики, работающие на разных API: в методе setup() инициализируем оболочку (напр. создаем окно, контекст OpenGL/DirectX и т.д.), а в методе render() указываем, как именно рисовать.

Inputer

Теперь поговорим о считывании ввода данных. Наша основная задача остается той же — абстрагироваться от основной реализации. Класс Inputer должен уметь считывать данные и проверять, не была ли нажата кнопка «выход (из игры)». Как это будет реализовано, ему не важно. Исходный код класса может выглядеть так:

 class Inputer { protected:     bool _running = true;    // игра работает по умолчанию, ставим false в реализации, если кнопка "выход" была нажата       public:     virtual ~Inputer() {}     virtual void processInput() = 0;     bool quitRequested() const { return !_running; } }; 

Updater

Класс Updater слишком зависит от модели, на которой строится движок (будь-то Component-GameObject или др.), но его основная суть заключается в обновлении/инициализации всех задействованных в игре скриптов. Исходный код Updater здесь я приводить не буду.

Назад к Core

С определенными классами Renderer, Inputer и Updater класс Core теперь будет выглядеть примерно так:

class Core final { private:     Renderer* _renderer;     Inputer* _inputer;     Updater* _updater;          void gameloop() {           while(!_inputer->quitRequested()) {              _inputer.processInput();              _updater.update();              _renderer.render();          }     }  public:     Core(Renderer* renderer, Inputer* inputer, Updater* updater) : _renderer(renderer), _inputer(inputer), _updater(updater) {}     void Core::run() {           _renderer->setup();            gameloop();    } 

Однако на этом еще не всё. Наш игровой цикл выполняется настолько быстро, насколько может: нужно ограничить его выполнение, основываясь на значении targetFPS из класса Renderer.

Timing

Так как наша цель — независимость от конкретного API, то для обеспечения тайминга класс Core будет хранить указатель на функцию, которая отсчитывает количество миллисекунд от запуска программы. Эту функцию мы передадим в конструктор при инициализации Core, и в методе gameloop(), на основе её значений, уже реализуем сам тайминг.

Финальный вариант класса Core:

 class Core final {     typedef unsigned int (*timeFunc)(void);      private:     Renderer* _renderer;     Inputer* _inputer;     Updater* _updater;     timeFunc _getTicks;   // в миллисекундах!          void gameloop() {          auto currentTime = _getTicks();          ...     }   public:     Core(Renderer* renderer, Inputer* inputer, Updater* updater, timeFunc getTicks) : _renderer(renderer), _inputer(inputer), _updater(updater), _getTicks(getTicks) {} 

Итог

В результате мы получили каркас движка, к которому можно прикрутить любую библиотеку, не зависящий от реализации конкретных методов. Своеобразный вариант паттерна «Шаблонный метод».

ссылка на оригинал статьи https://habrahabr.ru/post/274623/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *