Предисловие


В этой статье я хочу поговорить об абстракциях, на которых строится любой игровой движок. А конкретно — о реализации гибкой, «модульной» и без проблем расширяющейся платформы, на которой стоит вся «начинка» движка.
Примеры исходного кода я буду приводить на 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) {}


Итог


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

Комментарии (2)


  1. AtomKrieg
    29.03.2016 09:15
    +2

    Очень короткая статья практически ни о чем интересном


  1. NeonMercury
    29.03.2016 10:19
    +1

    А ещё, мне кажется, что закладывать код ниже в архитектуру плохо:

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

    У нас не Inputer отвечать должен за выход из игры. Хоть во время отладки и можно выходить по Esc или, например, по F12, но в готовой игре это происходит либо по событию закрытия окна, либо по выбору пункта меню, либо из консоли, либо ещё как.
    Логичнее, как мне кажется, чтобы за это отвечало ядро событийной системы, рассылая всем подписчикам событие on_exit(...). Или, так как здесь событийной системы не реализовано, то просто ядро системы.