Готовый перевод Game Programming Patterns / Паттерны программирования в играх: 2.1. Double Buffer

Цель

Выполнить серию последовательных операций мгновенно или одновременно.

Мотивация

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

Это стало не так актуально с появлением многопоточных и мультиядерных систем, но даже с несколькими ядрами лишь некоторые операции действительно выполняются полностью параллельно.

Рассмотрим для примера то, что есть во всех игровых движках - система рендеринга. Когда игра рисует мир для пользователя, это происходит за один шаг: горы за горизонтом, зеленые холмы, деревья, все выводится сразу. Если пользователь увидит последовательное отображение элементов - иллюзия целостности мира будет разрушена. Сцена должна обновляться плавно и быстро, отображая серию законченных кадров, каждый из которых появляется мгновенно.

Паттерн Double Buffer (двойная буферизация), поможет нам решить эту задачу, но для начала немного поговорим о том, как компьютер отображает графику.

Кратко о том, как работает компьютерная графика

Такой экран как монитор компьютера рисует за раз один пиксель. В каждой строке пикселей он двигается слева направо, затем переходит вниз на следующую. При достижении правого нижнего угла, он возвращается в левый верхний и начинает все заново. Он так быстро это делает(примерно 60 раз в секунду), что наши глаза не успевают разглядеть развертку изображения. Мы видим неподвижное поле цветных пикселей – картинку.

Это очень упрощенное объяснение. Если вы человек, работающий с железом на низком уровне, вас наверное трясет от прочитанного. Не стесняйтесь, переходите к следующей секции. Вы знаете более чем достаточно чтобы понять оставшуюся часть главы. Но если вы не тот о ком я говорю, то мне нужно предоставить вам достаточно контекста чтобы вы поняли паттерн, речь о котором пойдет чуть позже.

Представьте себе крошечный шланг, который выплевывает пиксели на экран. Различные цвета движутся к концу шланга и распыляются по всему дисплею. За один ход, отправляется один бит цвета одному пикселю. Так как же шланг знает, какие цвета отправлять и куда?

В большинстве компьютеров, данные об этом берутся из буфера кадров. Буфер кадров представляет собой массив пикселей в оперативной памяти компьютера, где каждая пара байт представляет цвет пикселя. Когда шланг распыляет пиксели, он считывает цвета из этого массива. По байту за раз.

Конкретное соответствие между числовым значением байта и цветом задается исходя из формата пикселя и глубины цвета в системе. В настоящее время в большинстве игровых консолей каждый пиксель задается 32 битами: по 8 бит на красный, зеленый и голубой каналы, и еще 8 для различных целей.

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

Ранее я сказал, что компьютеры последовательны. Если компьютер выполняет кусок нашего кода для отрисовки картинки, мы не можем ожидать, что он выполнит что-нибудь еще в это же время. В целом все верно, но есть пара вещей, которые происходят в процессе работы нашей программы. Одна из них заключается в том, что видеодисплей будет постоянно читать буфер кадра, пока игра запущена. Это может создать для нас проблему.

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

Мы начинаем закрашивать пиксели, а следом за нами видеодрайвер начинает читать буфер кадра (Рис. 1). Видеодрайвер догоняет отрисовщика и проскальзывает мимо к пикселям, которые мы еще не закрасили (Рис. 2). Мы закончили отрисовку (Рис. 3), но драйвер не прочитал несколько последних пикселей.

В результате (Рис. 4) пользователь видит только половину картины. И "разрыв башки", как вы видите, я употребил не случайно.

Вот почему нам необходим этот паттерн. Наша программа отрисовывает пиксели один за другим, но нам нужно, чтобы видеодрайвер увидел их все сразу - один обновляемый смайлик не лучшее решение, нужен еще один. Двойная буферизация решает проблему. Как? Я объясню это по аналогии.

Акт 1, Сцена 1

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

И, немного увеличив площадь, мы выступим с гениальным решением: мы установим две сцены так, чтобы зрители видели обе. Каждая будет иметь свое освещение. Обозначим их как сцена А и Б. Первая часть представления показывается на сцене А. Тем временем сцена Б погружена в темноту, и рабочие устанавливают на ней декорации. Как только первая часть спектакля заканчивается, мы гасим свет над сценой А и включаем над сценой Б. Зрители переводят внимание на нее, и вторая часть представления немедленно начинается.

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

Вернёмся к графике

Именно так работает двойная буферизация, и подобный процесс лежит в основе графической системы практически любой игры, которую вы когда-нибудь видели. Вместо одного буфера кадра мы пользуемся двумя. Один из них представляет текущий кадр. Это сцена А из нашего примера, и именно его читает видеодрайвер. Сколько прочитать данных и когда - решать ему.

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

Тем временем код отрисовки заполняет другой буфер. Это затемненная сцена Б. Как только кадр сформирован, он "переключает освещение" - меняет местами буферы. Это указывает видеодрайверу на то, что пора читать из второго буфера, вместо первого. Тем самым, переключаясь в конце обновления, мы не получим никаких разрывов и вся сцена отобразится сразу полностью.

Ну а первый буфер уже доступен для использования. Мы начинаем отрисовывать следующий кадр на нем. Вуаля!

Паттерн

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

Когда информация считывается, она берется из текущего буфера. Когда информация записывается, она попадает в последующий. Как только измнения внесены, операция Swap() тут же меняет буферы местами так, что последующий буфер становится доступным для чтения, а текущий буфер теперь может использоваться как последующий буфер.

Когда его использовать?

Этот паттерн один из тех, для которых решение об его использовании принимается вами. Если у вас есть система, в которой отсутствует двойная буферизация, она, вероятно, будет неправильно отображаться (разрывы, и.т.д.) или некорректно себя вести. Но разговор о том, что решение об её использовании за вами, вряд ли вам чем-то поможет. Более конкретно, этот паттерн подходит, когда выполняются следующие четыре пункта:

- у нас имеется некая структура, изменяемая последовательно;

- к этой структуре может осуществляться доступ в процессе изменения;

- мы не хотим, чтобы код, обращающийся к структуре, наткнулся на "ждите, операция не завершена";

- мы хотим читать данные из структуры и не хотим при этом ждать, пока структура заполняется данными.

На заметку

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

Операция Swap требует времени

Эта операция необходима для двойного буфера, как только структура была изменена. Эта операция должна быть атомарной - никакой код не может получить доступ к структуре, пока swap() не закончен. Как правило, swap() выполняется также быстро, как и присвоение указателя. Но если она занимает больше времени, чем изменение полностью всей структуры, то мы врядли чем-то себе помогли.

Нам нужно два буфера

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

Пример кода

Теорию мы рассмотрели, давайте посмотрим, как это работает на практике. Мы напишем очень схематичную графическую систему, позволяющую нам закрашивать пиксели в буфере кадра. В большинстве консолей и ПК это низкоуровневая возможность, предоставляемая видеодрайвером, но это позволит нам увидеть, что происходит на самом деле. Вначале сам буфер:

class Framebuffer

{

public:

Framebuffer() { clear(); }

void clear()

{

for (int i = 0; i < WIDTH * HEIGHT; i++)

{

pixels_[i] = WHITE;

}

}

void draw(int x, int y)

{

pixels_[(WIDTH * y) + x] = BLACK;

}

const char* getPixels()

{

return pixels_;

}

private:

static const int WIDTH = 160;

static const int HEIGHT = 120;

char pixels_[WIDTH * HEIGHT];

};

Класс предоставляет базовые операции: сброс всего буфера в цвет по-умолчанию, и установка цвета отдельного пикселя. Также есть метод getPixels(), для получения содержимого памяти, содержащей данные пикселей. Мы не увидим его в примере, но видеодрайвер будет вызывать этот метод очень часто, для проецирования памяти буфера на экран.

Мы обернем этот буфер в класс Scene. Задачей этого класса будет рисовать путем цепочки вызовов draw() из буфера.

class Scene

{

public:

void draw()

{

buffer_.clear();

buffer_.draw(1, 1);

buffer_.draw(4, 1);

buffer_.draw(1, 3);

buffer_.draw(2, 4);

buffer_.draw(3, 4);

buffer_.draw(4, 3);

}

Framebuffer& getBuffer() { return buffer_; }

private:

Framebuffer buffer_;

};

Конкретно, он рисует этот шедевр:

Для каждого кадра игра вызывает метод Draw класса Scene. Вначале очищается буфер, а затем прорисовывается цепочка пикселей, под одному за раз. Также возможен доступ к внутреннему буферу с помощью getBuffer(), так что видеодрайвер может его прочитать.

Выглядит довольно просто, но если мы оставим все как есть, у нас будут проблемы. Беда в том, что видеодрайвер может вызвать getPixels() в любое время, даже здесь:

buffer_.draw(1, 1);

buffer_.draw(4, 1);

// <- Видеодрайвер считывает пиксели здесь!

buffer_.draw(1, 3);

buffer_.draw(2, 4);

buffer_.draw(3, 4);

buffer_.draw(4, 3);

Когда это произойдет, пользователь увидет на лице глаза, но вот рот исчезнет на этом кадре. На следующем кадре оно может прерваться в другом месте. В результате - отвратительное мерцание на экране и раздраженные пользователи.

Мы исправим это с помощью двойной буферизации:

class Scene

{

public:

Scene()

: current_(&buffers_[0]),

next_(&buffers_[1])

{}

void draw()

{

next_->clear();

next_->draw(1, 1);

// ...

next_->draw(4, 3);

swap();

}

Framebuffer& getBuffer() { return *current_; }

private:

void swap()

{

// Просто меняем указатели

Framebuffer* temp = current_;

current_ = next_;

next_ = temp;

}

Framebuffer buffers_[2];

Framebuffer* current_;

Framebuffer* next_;

};

Сейчас Scene содержит два буфера, в массиве buffers_. Мы не обращаемся к ним напрямую. Вместо этого у нас есть 2 члена: next_ и current_, которые указывают на этот массив. Когда мы рисуем, мы используем последующий буфер, через указатель next_. Когда видеодрайверу нужно обратиться к пикселям, он получает доступ к текущем буферу с помощью current_.

Таким образом, видеодрайвер никогда не видит буфер с которым он работает. Единственным оставшимся кусочком пазла является вызов swap(), происходящий, когда отрисовка кадра закончена. Он меняет местами два буфера, просто меняя указатели next_ и current_ между собой. Теперь, когда видеодрайвер вызовет getBuffer(), он получит новый буфер, отрисовка в котором уже закончена, и отобразит его на экране. Больше никаких разрывов и неприглядных глюков.

Не только для графики

Основная задача, которую решает Двойной Буфер - это осуществление доступа к структуре, в то время когда она изменяется. Для этого есть два распространенных повода. Первый мы рассмотрели с примером для графики: осуществляется прямой доступ к структуре из другого потока или прерывания.

Другой, не менее распространенный повод: когда код, выполняющий изменения, обращается к той же структуре, которую он изменяет. Это может проявляться в различных местах, особенно в физическом движке или искусственном интеллекте, там где у вас есть множество субъектов, взаимодействующих друг с другом. Двойная буферизация частенько необходима и здесь.

Искусственный не-интеллект

Скажем, мы строим поведенческую систему для всех вещей в игре, основанной на дешевом фарсе. В игре есть сцена, на которой находится куча актеров, выступающих с различными шутками и проделками. А вот основа для актера:

class Actor

{

public:

Actor() : slapped_(false) {}

virtual ~Actor() {}

virtual void update() = 0;

void reset() { slapped_ = false; }

void slap() { slapped_ = true; }

bool wasSlapped() { return slapped_; }

private:

bool slapped_;

};

Это пример для паттерна "Update Method".

Игра несет ответственность за вызов update() в каждом кадре, поэтому у актера есть шанс сделать что-нибудь. Важно, с точки зрения пользователей, чтобы все актеры выполнили update() одновременно.

Актеры могут взаимодействовать друг с другом, а под взаимодействием мы подразумеваем, что они могут отвешивать друг другу пощечины. Во время update(), актер может вызвать slap(), чтобы дать пощечину другому актеру, и вызвать wasSlapped(), чтобы проверить - не досталось ли ему.

Актерам нужна сцена, где бы они могли "взаимодействовать", давайте же ее построим:

class Stage

{

public:

void add(Actor* actor, int index) { actors_[index] = actor; }

void update()

{

for (int i = 0; i < NUM_ACTORS; i++)

{

actors_[i]->update();

actors_[i]->reset();

}

}

private:

static const int NUM_ACTORS = 3;

Actor* actors_[NUM_ACTORS];

};

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

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

Итак, продолжим - давайте определим конкректного актера как подкласс. Наш комик довольно прост. Он сталкивается с актером. И всякий раз, когда он получил пощечину, от кого-то, он отвечает пощечиной актеру, с которым столкнулся.

class Comedian : public Actor

{

public:

void face(Actor* actor) { facing_ = actor; }

virtual void update()

{

if (wasSlapped()) facing_->slap();

}

private:

Actor* facing_;

};

Теперь давай запустить комиков на сцену и посмотрим, что произойдет. Мы выставим трех комиков, сталкивая каждого со следующим. Последний столкнется с первым, и так по кругу.

Stage stage;

Comedian* harry = new Comedian();

Comedian* baldy = new Comedian();

Comedian* chump = new Comedian();

harry->face(baldy);

baldy->face(chump);

chump->face(harry);

stage.add(harry, 0);

stage.add(baldy, 1);

stage.add(chump, 2);

Итоговая сцена выглядит приблизительно так, как показано на картинке. Стрелки показывают кто с кем столкнется, а цифрами указывается их порядковый номер на сцене.

Давайте начнем с Гарри - дадим ему пощечину и посмотрим, что случиться, когда мы запустим процесс.

harry->slap();

stage.update();

Помните, что функция update() в классе Stage обновляет каждого актера по очереди, поэтому, если мы пошагово выполним код, мы увидим следующее:

- Stage вызывает update() для актера 0 (Гарри);

- Гарри получил пощечину, поэтому дает ее Балди;

- Stage вызывает update для актера 1 (Балди);

- Балди получил пощечину, поэтому дает ее Чемпу;

- Stage вызывает update для актера 2 (Чемп);

- Чемп получил пощечину, поэтому дает ее Гарри;

- Функция update закончена.

Итак, в этом кадре наша пощечина для Гарри прошла по всем комикам. Теперь давайте всё немного поменяем. Мы сменим расположение комиков на сцене, но порядок столкновений оставим прежним.

Переведено на Нотабеноиде
http://notabenoid.org/book/51192/215217

Переводчики: RSlafka, kSky7000, Deo, kabanov

Настройки

Готово:

67.21% КП = 1.0

Непереведённые фрагменты будут пропущены.