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

3.3.4 Использование буферов

Мы уже почти на финише. На данный момент у нас есть классы DoubleBuffer, ChangeBuffer и ChangeMessages. Следующий шаг — классы GameData и RenderData. Я не буду давать много подробностям по этим классам, потому как, как я ранее упоминал, эти классы сильно зависят от игры. Просто пример может выглядеть примерно так:

class GameData

{


public Vector3 Acceleration;


public Vector3 Velocity;


public Vector3 Position;


public Matrix Rotation;


public
bool IsAlive;

}

class RenderData

{


public Vector3 HighlightColor;


public Matrix WorldMatrix;


public Model Model;


public
bool IsAlive;

}

В простом виде, менеджер обновления может содержать ссылку на двойной буфер и список объектов GameData. Другие поля могут быть добавлены при необходимости.

class UpdateManager

{


public List<GameData> GameDataOjects { get; set; }


private DoubleBuffer doubleBuffer;


private GameTime gameTime;


protected ChangeBuffer messageBuffer;


protected Game game;


public UpdateManager(DoubleBuffer doubleBuffer, Game game)


{


this.doubleBuffer = doubleBuffer;


this.game = game;


this.GameDataOjects = new List<GameData>();


}

Нам необходимо добавить функцию, которая будет вызвана на каждом кадре, и которая будет содержать код для обновления. Мы разделим эту функцию на 2. Одна будет заниматься синхронизацией с doubleBuffer, другая будет заниматься непосредственно обновлением. Мы делаем это разделение для того чтобы обеспечить лёгкое расширение класса. Класс который будет наследовать UpdateManager должен будет всего лишь переопределить функцию Update, а обо всём остальном мы уже позаботились. Выполним это ниже:


public
void DoFrame()


{


doubleBuffer.StartUpdateProcessing(out messageBuffer, out gameTime);


this.Update(gameTime);


doubleBuffer.SubmitUpdate();


}


public
virtual
void Update(GameTime gameTime)


{


}

Финальный шаг — написать функцию для запуска в отдельном потоке. Мы добавляем поле для контроля потока (например, если главный поток завершит своё выполнение, нам надо будет завершить и дочерний поток) и функцию, которая будет выполняться в отдельном потоке. На самом деле эта функция будет просто вызывать в цикле функцию DoFrame. Если мы собираемся выполнять это на Xbox, то нам нужно будет установить affinity для процессора, чтобы функция запустилась в отдельном железном потоке. Для этого выполним функцию Thread.SetProcessorAffinity, передав железный поток, в котором мы хотим выполнять эту функцию.


public Thread RunningThread { get; set; }


private
void run()


{


#if XBOX


Thread.CurrentThread.SetProcessorAffinity(5);


#endif


while (true)


{


DoFrame();


}


}


public
void StartOnNewThread()


{


ThreadStart ts = new ThreadStart(run);


RunningThread = new Thread(ts);


RunningThread.Start();


}

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

RenderManager будет похож на UpdateManager. Мы не включаем сюда механизм для запуска RenderManager в отдельном потоке, поскольку они будут идентичны. Также мы не будем использовать этот механизм в примере, потому как мы можем просто вызвать вызвать функцию DoFrame из главного потока, потому что мы оставляем операции по прорисовке в отдельном потоке, как было сказано выше.

class RenderManager

{


public List<RenderData> RenderDataOjects { get; set; }


private DoubleBuffer doubleBuffer;


private GameTime gameTime;


protected ChangeBuffer messageBuffer;


protected Game game;


public RenderManager(DoubleBuffer doubleBuffer, Game game)


{


this.doubleBuffer = doubleBuffer;


this.game = game;


this.RenderDataOjects = new List<RenderData>();


}


public
virtual
void LoadContent()


{


}


public
void DoFrame()


{


doubleBuffer.StartRenderProcessing(out messageBuffer, out gameTime);


this.Draw(gameTime);


doubleBuffer.SubmitRender();


}


public
virtual
void Draw(GameTime gameTime)


{


}

}

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

public
class Game1 : Microsoft.Xna.Framework.Game

{


[…]


DoubleBuffer doubleBuffer;


RenderManager renderManager;


UpdateManager updateManager;

Затем, во время LoadContent Вы можете проинициализировать их. Здесь перед началом запуска параллельного потока, Вы можете загрузить данные в объекты и добавить их в список GameData UpdateManager’а а также в список RenderData RenderManager’а. Но будьте внимательны и добавляйте их с осторожностью, чтобы позиции обоих списков совпадали. Как я упоминал ранее, у Вас может быть более сложная политика идентификации объектов, если она Вам нужна, но мы оставим её простой для образовательных целей. В конце концов, после того, как вы загрузили все данные, Вы можете сказать UpdateManager’у запуститься в отдельном потоке.

protected
override
void LoadContent()


{


[…]


doubleBuffer = new DoubleBuffer();


renderManager = new RenderManager(doubleBuffer, this);


renderManager.LoadContent();


updateManager = new UpdateManager(doubleBuffer, this);


//здесь Вы можете загрузить данные и добавить их в список


//RenderDataObjects и GameDataObjects


renderManager.RenderDataOjects.Add(…);


updateManager.GameDataOjects.Add(…);


[…]


updateManager.StartOnNewThread();


}

Ранее мы говорили о том, что код прорисовки мы оставим в главном потоке. Итак, теперь в функцию Draw класса Game мы кладём код синхронизации. Мы подаем сигнал для начала нового кадра. Как только мы это сделали, поток UpdateManager’а который ждал этого сигнала, начинает своё выполнение. Теперь мы также можем сказать RenderManager’у отрисовывать кадр. После того как менеджер отрисовки завершит своё выполнение, мы ждём окончания UpdateManager, вызывая doubleBuffer.GlobalSynchronize(). Когда мы выходим из этой функции, мы знаем что поток обновления ждёт от нас сигнала к новому кадру.


protected
override
void Draw(GameTime gameTime)


{


doubleBuffer.GlobalStartFrame(gameTime);


graphics.GraphicsDevice.Clear(Color.Black);


renderManager.DoFrame();


base.Draw(gameTime);


doubleBuffer.GlobalSynchronize();


}

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

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

protected
override
void OnExiting(object sender, EventArgs args)

{


doubleBuffer.CleanUp();


if (updateManager.RunningThread != null)


updateManager.RunningThread.Abort();

}

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

4. Пример: Шары.

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


Если честно, мои навыки написания кода физики не так хороши как могли бы быть, поэтому иногда вы будете видеть как шары проходят сквозь стены и растворяются в синеве, но код выполняет возложенную на него задачу. Что я сделал так это расширил класс UpdateManager и создал новый класс, названный BallsUpdater. В него я добавил код для управления камерой, для получения данных с клавиатуры, а также код для реализации физики. Большая часть кода — это разрешение коллизий. Одна важная деталь заключается в том, что функция UpdatePhysics(), которая обновляет состояние шара, возвращает переменную типа boolean, которая говорит нам о том, изменилось ли состояние шара или нет. Таким образом, в коде функции Update мы увидим примерно следующее:

public
override
void Update(GameTime gameTime)

{


messageBuffer.Clear();


HandleInput();


for (int i = 0; i < GameDataOjects.Count; i++)


{


GameData gd = GameDataOjects[i];


if (UpdatePhysics(gd, (float)gameTime.ElapsedGameTime.TotalSeconds))


{


Matrix newWorldMatrix =


gd.rotation * Matrix.CreateTranslation(gd.position);


ChangeMessage msg = new ChangeMessage();


msg.ID = i;


msg.MessageType = ChangeMessageType.UpdateWorldMatrix;


msg.UpdatedWorldMatrix = newWorldMatrix;


messageBuffer.Add(msg);


}


}


UpdateCamera();


base.Update(gameTime);

}

Как Вы видите, благодаря тому, что мы наследуемся от класса UpdateManager, нам нужно только написать код для функции Update(), не имея дел с кодом многопоточности. Мы можем просто использовать ChangeBuffer для передачи данных потоку прорисовки. Первое что мы делаем — очищаем буфер. После этого мы вызываем функцию, которая обрабатывает ввод. Внутри неё если Вы нажали на кнопку B, будет создан новый шар, и в буфер будет добавлено сообщение типа CreateNewRenderData. Далее мы идем в цикле по всем объектам списка GameDataObjects. Заметьте, что мы не используем foreach потому что мы хотим иметь доступ к индексам объектов. Индекс используется как ID в нашем примере. Таким образом, для каждого объекта мы вызываем функцию UpdatePhysics, которая двигает шар и обновляет физику. Затем, если шар был сдвинут в этом кадре, мы вычисляем новую матрицу мира и создаем сообщение типа UpdateWorldMatrix. Далее кладём это сообщение в буфер чтобы поток прорисовки поздней прочитал это сообщение. Если шар не сдвинулся в этом кадре, то сообщений послано не будет. Здесь Вы наверняка замечаете, что если бы наши сообщения были сделаны в виде объектов а не структур, то на этапе движения шара было бы довольно много мусора. В нашем методе мусор не генерируется. Наконец мы вызываем функцию UpdateCamera(), которая вычисляет новую позицию и ориентацию камеры, базируясь на позиции шара игрока и его ориентации, а также создает сообщение типа UpdateCameraView и кладёт его в буфер.

Для прорисовки я расширил класс RenderManager и создал новый класс BallsRenderer. В функции LoadContent() мы загружаем необходимые данные, такие как модели шаров, модели стола, и т. д. Самая важная функция в этом учебнике — это функция Draw(). Здесь, в начале функции, мы должны получить сообщения, которые были переданы менеджером обновления в предыдущем кадре.

public
override
void Draw(GameTime gameTime)

{


foreach (ChangeMessage msg in messageBuffer.Messages)


{


switch (msg.MessageType)


{


case ChangeMessageType.UpdateCameraView:


viewMatrix = msg.CameraViewMatrix;


break;


case ChangeMessageType.UpdateWorldMatrix:


RenderDataOjects[msg.ID].worldMatrix =


msg.UpdatedWorldMatrix;


break;


case ChangeMessageType.CreateNewRenderData:


if (RenderDataOjects.Count == msg.ID)


{


RenderData newRD = new RenderData();


newRD.color = msg.Color;


newRD.worldMatrix =


Matrix.CreateTranslation(msg.Position);


RenderDataOjects.Add(newRD);


}


break;


default:


break;


}


}


//draw the scene


[…]

}

Таким образом, получая каждое сообщение из буфера, мы проверяем его тип. Если это сообщение для установки матрицы камеры, то мы используем значения, хранимые в поле CameraViewMatrix и устанавливаем их в качестве значения нашей локальной переменной. Если тип сообщения — UpdateWorldMatrix, то мы изменяем матрицу мира объекта, чей идентификатор сохранён в сообщении. Если же сообщение имеет тип CreateNewRenderData, то мы создаем новый объект для прорисовки и добавляем в список RenderDataObjects. Так как мы не удаляем шары в нашем примере, новые шары, создаваемые потоком обновления должны всегда добавляться в конец нашего списка с новым индексом. Если бы мы хотели удалять шары в коде, то возможно нам понадобилась бы более сложная логика хранения ID объектов. Но в нашем случае ничего такого нам не нужно. Теперь мы можем начать отрисовку сцены. Другая приятная вещь — это то, что не все объекты требуют каких то действий от потока обновления. Например, стол никогда не шевелится, поэтому код, который его рисует прописан жестко в классе BallsRenderer.

Для начала мы просто имели 197 сферических шаров, и никаких больше эффектов. Но благодаря тому, что у Xbox очень мощный графический процессор, а операции на процессоре с плавающей точкой не так быстры, поток обсчета гораздо дольше выполняется и в итоге прирост от использования многопоточности не так очевиден (всего лишь около 30-40%). Поэтому я решил дополнительно нагрузить графическую подсистему. Первым делом, я создал новую модель для шаров. В ней более 9000 полигонов. Но даже это (197 * 9000) было элементарно для GPU Xbox’а.


Тогда я стал писать код для добавления мультипликационной анимации для объектов, который по сути дважды прорисовывает каждый объект (с нормальной глубиной и цветом), а затем добавил некоторые эффекты.


Теперь всё было сбалансировано и разница между обсчетом физики и прорисовыванием была гораздо меньше.

Итак, каковы показатели? Код был выполнен на Xbox 360, в результате чего были получены следующие значения:

Физика (мс)Прорисовка (мс)Полное время кадра (мс)Средний FPSОднопоточность28-3823-2451-6216-19Многопоточность30-4025-2630-4026-33
Таким образом, не смотря на то, что многопоточность добавила по несколько мс к временам обсчета и прорисовки, общее время кадра было уменьшено, и было достигнуто необходимое сокращение времени. Вы с лёгкостью можете изменить количество шаров (Game1.LoadContent()) и пронаблюдать другие показатели. Также Вы можете пронаблюдать количество сообщений в буфере от 1 (когда все шары стоят на месте) до 198 (когда все катаются).
Ссылка на архив в конце статьи. Управление в игре следующее:
Левый джойстик поворачивает камеру влево или вправо
А — ускорение шара игрока

  • Y — короткое ускорение шара игрока
  • X — короткое ускорение всех шаров
  • B — создание нового шара и придать ему ускорение

5. Заключение.

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

Как бы там ни было, это не единственный способ использования многопоточности. Есть и другие способы, не покрытые этим учебником. Мы можем использовать её при создании анимированных экранов загрузки, сложных асинхронных вычислений, связанных с ИИ, подгрузка данных прямо во время игры. Результат может быть разным, начиная от чуть более высокой производительности в игре, до более умных врагов.

6. Загрузка.

Код фреймворка может быть загружен с http://www.ziggyware.com/ZiggywareImages/Articles/MTPhysics/MultithreadingFramework.zip

код пример «шары» может быть загружен с http://www.ziggyware.com/ZiggywareImages/Articles/MTPhysics/MultithreadedBalls.zip

7. Ссылки.

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

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

  1. dixus:

    А можете перезалить исходники на какой-нибудь файлообмениик?
    http://www.ziggyware.com выдает ошибку 404 😦

    • Я попробую и найти, но обещать не могу.
      Просто это старые переводы (то есть статьи и исходники не мои), а ziggyware давно умер.

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

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

Логотип WordPress.com

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

Фотография Twitter

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

Фотография Facebook

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

Google+ photo

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

Connecting to %s