Технология многопоточности для Ваших XNA игр Часть 2

3.2 Смена буферов

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

Всё что осталось — синхронизовать эти структуры. Звучит знакомо? Вот для чего нам нужен двойной буфер. Но теперь, вместо того, чтобы просто класть все данные об игре в них, мы будем их использовать только для того, чтобы уведомлять поток прорисовки об изменениях в состоянии объектов. Эти буферы будут использованы как некая разновидность буферов сообщений, в которых каждое сообщение описывает изменения в объекте.


Взгляните на следующие иллюстрации


Как Вы видите, в кадре k, поток обновления работает над объектами и изменениями в состоянии некоторых объектов (1 и 3). Так как состояние этих объектов, поток обновления кладёт уведомление в буфер сообщений. Эти уведомления должны содержать достаточно информации, для того чтобы отразить новое состояние этих объектов. Затем, буферы меняются местами. В кадре k+1 поток обновления очищает буфер, а затем производит обновление игрового состояния. В это время, он замечает, что состояния объектов 2 и 3 изменилось. Он пишет уведомление с изменениями в буфер. В это время поток прорисовывания считывает свой буфер, где он находит уведомления об изменении состояний объектов 1 и 3. Он использует эти уведомления для обновления данных рендеринга, а затем начинает прорисовывать сцену. Когда оба потока завершают, мы меняем буферы. Это шаг, на котором предыдущий метод провалился. Но теперь в то время как поток обновления производит свои дальнейшие обновления, поток прорисовки обнаруживает, что состояния объектов 2 и 3 изменились и совершает необходимые изменения в их данных рендеринга. Данные, содержащиеся в потоке прорисовки целы и верны, поэтому все объекты прорисовываются верно. Тенденция продолжится и дальше. Благодаря тому, что у каждого потока есть своя копия данных, а буферы используются только для передачи сообщений, всё остается в целостности.

А как насчет памяти? Не использует ли этот метод больше памяти, чем предыдущие? На самом деле нет. Игровые данные, используемые для потока обновления, занимают примерно столько, сколько занимал 1 буфер в предыдущих методах. Данные рендеринга во много раз меньше, чем общие данные, таким образом суммарно они расходуют меньше памяти, чем 2 буфера в предыдущих методах. Также есть буфер, которых хранит изменения в состоянии объектов, но он должен быть довольно небольшим. Так как данные, считываемые из памяти и записываемые в неё, гораздо меньше, суммарно процесс должен быть быстрей. Плохая сторона этого метода в сложности передачи изменений объектов.

3.3 Имплементация

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

  • Класс Game производит некоторые действия с GraphicsDevice за нас, поэтому, чтобы избежать ненужных проблем, мы не будем переписывать эти вещи самостоятельно.
  • Это значит, что мы перенесём обновление в другой поток. Для этого мы будем использовать 3,4 или 5й.
  • Некоторые данные, которые нужны для обоих потоков, такие как текущее игровое время. Мы будем хранить эти данные в общем доступе. Мы будем получать и сохранять эти данные до того, как кадр начнет обработку. Такой же метод мы будем использовать в тех случаях, когда данные будут нужны для обоих потоков.

3.3.1 Классы

Прежде чем непосредственно начать писать код, давайте подумаем о классах, которые нам понадобятся, и поместим их на диаграмму. Очевидно, нам нужен класс для игровых данных каждой сущности. Как мы говорили ранее, нам также нужен будет класс для хранения данных рендеринга. Поля, которые будут в этих классах, очень зависят от типа игры, которую Вы собираетесь создать. Также у нас будут классы, которые называются UpdateManager, RenderManager, которые будут содержать массивы классов, упомянутых ранее. В этой статье положим, что данные будут идентифицироваться по позиции в этих массивах. Итак, объект, который находится на позиции 4 в массиве из UpdateManager, будет отвечать за данные на позиции 4 в массиве RenderManager. Можно заменить эту схему на какую-нибудь другую, используя глобально-уникальные идентификаторы для объектов, или таблицы-хеши, или словари для поиска объекта по его id, но мы используем эту схему для простоты.

Сообщения об изменениях будут храниться в структурах ChangeMessage. Мы обсудим детали имплементации этих структур немного позднее. Мы решили хранить эти сообщения в коллекции ChangeBuffers. По причине того, что бы будем использовать двойную буферизацию, определим класс DoubleBuffer, который будет содержать 2 ChangeBuffer, и предоставлять каждый из них по требование потока обновления или прорисовки. Тогда общая диаграмма будет выглядеть примерно так.


Теперь приступим к реализации этих классов.

3.3.2 Поточность и синхронизация

Как вы увидите в следующих нескольких пунктах, нам предстоит написать не так много кода для реализации многопоточности. Самая главная часть многопоточных игр — планирование структур данных и несколько, строк кода для синхронизации в нужных местах. Итак, всё, что нам нужно это: синхронизовать поток обновления и прорисовки в начале и конце каждого кадра, убедиться в том, что каждый поток обращается к правильному буферу и блокировать объекты, к которым можно обратиться одновременно из 2х кадров. К счастью, благодаря тому, как мы спланировали структуры данных, нам необходимо будет всего несколько инструкций для синхронизации. По некоторым причинам (простота отладки, например) я предпочитаю держать всю многопоточность и синхронизацию кода в одном классе и в нашем случае это класс DoubleBuffer.

Вот последовательность действий, которую мы выполняем на каждом кадре:

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

Класс DoubleBuffer должен содержать некоторые поля. Нам нужен массив из двух ChangeBuffer. Нам будут нужны 2 числа, которые будут содержать индексы текущих буферов для потока обновления и прорисовки. Объявим их volatile, чтобы всегда иметь актуальные значения. Ранее Вы читали про AutoResetEvents. Сейчас мы будем использовать 4 AutoResetEvents для того чтобы дать сигнал о начале кадра, а также чтобы синхронизовать завершение обоих потоков.

class DoubleBuffer

{


private ChangeBuffer[] buffers;


private
volatile
int currentUpdateBuffer;


private
volatile
int currentRenderBuffer;


private AutoResetEvent renderFrameStart;


private AutoResetEvent renderFrameEnd;


private AutoResetEvent updateFrameStart;


private AutoResetEvent updateFrameEnd;


private
volatile GameTime gameTime;

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

public DoubleBuffer()

{


//создаем буферы


buffers = new ChangeBuffer[2];


buffers[0] = new ChangeBuffer();


buffers[1] = new ChangeBuffer();


//создаем WaitHandlers


renderFrameStart = new AutoResetEvent(false);


renderFrameEnd = new AutoResetEvent(false);


updateFrameStart = new AutoResetEvent(false);


updateFrameEnd = new AutoResetEvent(false);


//сбрасываем значения


Reset();

}

public
void Reset()

{


//сброс индексов буферов


currentUpdateBuffer = 0;


currentRenderBuffer = 1;


//сбрасываем состояния всех события


renderFrameStart.Reset();


renderFrameEnd.Reset();


updateFrameStart.Reset();


updateFrameEnd.Reset();

}

public
void CleanUp()

{


//освобождаем ресурсы системы


renderFrameStart.Close();


renderFrameEnd.Close();


updateFrameStart.Close();


updateFrameEnd.Close();

}

Функции, которая будет менять буферы местами, необходимо будут только поменять местами значения, хранимые в переменных currentUpdateBuffer и currentRenderBuffer. Так как мы запускаем эту функцию только тогда, когда активен один поток, нам не нужно будет ставить блокировку.

private
void SwapBuffers()

{


currentRenderBuffer = currentUpdateBuffer;


currentUpdateBuffer = (currentUpdateBuffer + 1) % 2;

}

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

public
void GlobalStartFrame(GameTime gameTime)

{


this.gameTime = gameTime;


SwapBuffers();


//подаём сигнал к началу прорисовки


renderFrameStart.Set();


updateFrameStart.Set();

}

public
void GlobalSynchronize()

{


//ждём пока оба потока подадут сигнал о том, что они закончили


renderFrameEnd.WaitOne();


updateFrameEnd.WaitOne();

}

Последние функции, которые нам надо добавить к этому классу это функции, которые будут вызваны потоками обновления и прорисовки для того чтобы получить ссылки на их текущие буферы. Когда какой то поток обращается к этому методу, он начинает ждать соответствующий WaitHandle до тех пор, пока не будет сигнализирован функцией GlobalStartFrame(). После того как будет получен сигнал, мы знаем что необходимые значения инициализированы правильно и можем передать их вызывающему потоку через выходные параметры и вернуться из метода, чтобы дать вызывающему потоку продолжить выполнение. Завершающая функция просто вызывает события updateFrameEnd и renderFrameEnd, для того, чтобы GlobalSynchronize могла продолжить.

public
void StartUpdateProcessing(out ChangeBuffer updateBuffer,

out GameTime gameTime)

{


//ждём начальный сигнал


updateFrameStart.WaitOne();


//обновляем буфер


updateBuffer = buffers[currentUpdateBuffer];


//получаем игровое время


gameTime = this.gameTime;

}

public
void StartRenderProcessing(out ChangeBuffer renderBuffer,


out GameTime gameTime)

{


//ждём начальный сигнал


renderFrameStart.WaitOne();


//получаем буфер прорисовки


renderBuffer = buffers[currentRenderBuffer];


//возвращаем игровое время


gameTime = this.gameTime;

}

public
void SubmitUpdate()

{


//обновление готово


updateFrameEnd.Set();

}

public
void SubmitRender()

{


//прорисовки готова


renderFrameEnd.Set();

}

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

В начале игры или после завершения последнего кадра поток прорисовки и обновления вызывают функцию StartUpdateProcessing() и StartRenderProcessing(), объявляющие, что они готовы начать, и что они ждут своих данных. Далее они засыпают, потому что события renderFrameStart и updateFrameStart ещё не установлены.

Где-то внутри нашей игры мы вызываем функцию GlobalStartFrame(), которая меняет буферы местами и сохраняет gameTime, подготавливая все данные, которые будут переданы потокам обновления и прорисовки. После этого она устанавливает события renderFrameStart и updateFrameStart.

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

Далее в нашем классе Game также была запущенная функция GlobalSynchronize(), которая начинает ожидание завершения потоков прорисовки и обновления, наблюдая за событиями renderFrameEnd, updateFrameEnd.

Когда потоки обновления и прорисовки завершаются, они вызывают функции SubmitUpdate() и SubmitRender()

Реклама
Запись опубликована в рубрике Uncategorized. Добавьте в закладки постоянную ссылку.

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

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s