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

Автор: Catalin Zima http://www.ziggyware.com/readarticle.php?article_id=221

Содержание:

  1. Введение
  2. Взгляд на многопоточные примитивы
  3. Многопоточное обновление / рисование
  4. Пример: шарики
  5. Заключение
  6. Загрузки
  7. Ссылки

В этом учебнике вы научитесь использовать многопоточность в Ваших XNA играх. Учебник начинается с небольшого вступления в многопоточность, архитектуру Xbox 360 и с преимуществ / недостатков использования многопоточности в XNA играх. Затем идёт краткий обзор классов и примитивов, которые мы будем использовать в остальной части статьи. После этого, главная часть учебника нацелена на использование многопоточности для главного цикла игры. Вы научитесь, как отделять код прорисовки и обновления Вашей игры, а также как выполнять параллельно 2 задачи. Есть и другие способы использования многопоточности в играх, но они не покрываются этим учебником. В качестве заключительного слова мы сделаем некоторые выводы и взглянем на дальнейшую разработку.

  1. Введение

Будучи разработчиками игр мы всегда хотим чтобы наша игра была как можно лучше. Мы хотим чтобы графика была лучше, лучшую физику, хороший ИИ и т. д. Просто для того, чтобы покупатель был доволен. Тем не менее, времени у нас всегда мало времени. Говоря про время, я имею в виду не время разработки игры — оно малоинтересно нашим геймерам, я говорю про процессорное время. Гейдеры очень разборчивы. Они ожидают от игры хорошей скорости, достаточной плавности, но в то же время резкости. Это означает, что игра должна иметь 30 или даже 60 FPS (кадров в секунду). Это оставляет нам всего 16,66 мс для просчета всей физики, графики, геймплея или требований ИИ. Для некоторых игр это более чем достаточно, для других же это болезненно низкий порог.

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

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

Есть несколько важных моментов, которые нужно помнить, глядя на эту иллюстрацию

  • Не все задачи должны выполняться параллельно. В этом примере мы оставили обработку входных данных выполняться последовательно. Может быть и для просчета логики игры, и анимации необходимы наши входные данные, а может быть Вам просто нужно будет, чтобы какая то задача выполнялась сама по себе, без каких либо других параллельных ей. Просто помните, что это тоже осуществимо, и рано или поздно, Вы наверняка захотите этим воспользоваться.
  • Задачи должны быть независимы друг от друга. Так как они выполняются параллельно, не просто обеспечить обмен данными между ними, поэтому нам нужен механизм передачи данных между параллельными задачами. В идеале мы должны иметь совершенно независимые друг от друга задачи, но в играх это редко осуществимо. Для анимации нам нужна физика и данные ИИ. Для ИИ также нужны данные физики, а для рендеринга нужны данные вообще всех наших задач. Мы рассмотрим этот момент немного позже в этой статье.
  • Одна из самых важных вещей — мы должны помнить, что, не смотря ни на что, производительности в игре ограничена скоростью выполнения самой медленной задачи. В этом примере, не смотря на то, что мы используем 2 потока и делим задачи примерно пополам, время выполнения одного кадра вряд ли будет даже половина от времени параллельного выполнения всех задач. Мы можем выделить каждую задачу в отдельный поток, но так или иначе мы не добьёмся времени просчёта кадра меньше, чем просчет всей физики (в нашем примере физика — самая длительная задача). Обмен данными между потоками, а также синхронизация потоков — тоже расходы, которые составляют часть итогового времени.

Теперь давайте посмотрим, где мы будем использовать эти потоки. В последние годы, компьютеры эволюционировали и сейчас зачастую оборудованы 2мя или даже 4мя ядрами. Это означает, что мы можем запустить до 4х потоков абсолютно независимо друг от друга, не разделяя процессорное время между ними. Конечно никто не мешает Вам заставить одно ядро выполнять несколько потоков, но лучше это делать только для потоков, требующих большую вычислительную мощность. Xbox 360 имеет несколько иную архитектуру. Он оборудован специальным процессором IBM, с 3мя ядрами, каждое из которых может выполнять 2 независимых («железных») потока. К сожалению 2 из них зарезервированы и не могут быть нами использованы, так как они используются XNA Framework и другими системными задачами. Но иметь 4 «железных» потока. Ниже Вы можете увидеть эти потоки.

Итак, в чем преимущество использования мультипоточности в Ваших играх? Главное преимущество — лучшая производительности, высокая частота кадров, возможность добавить более сложное поведение физики, ИИ и другие вещи. Недостатки — это бОльшая сложность написания игры с использованием многопоточности. Во многих случаях Ваша игра обойдётся и без многопоточности. Разделение задач на потоки и отладка всех связанных с этим проблем — это сложная задача, требующая сложных конструкций данных и синхронизации кода, которая возможно того не стоит. Вы должны иметь хорошее представление о том, как устроены потоки, совместное использование памяти, а иначе Ваши труды могут оказаться напрасными и итоговая игра с многопоточностью окажется медленней, чем первоначальная однопоточная. Другой большой минус — многопоточный код сложно отлаживать. Ошибка, которые появляются в результате взаимодействия потоков, трудно повторить, локализовать, и соответственно, нелегко исправить.

Теперь давайте взглянем на основные понятия, классы, которые Вы должны понимать.

2. Обзор примитивов многопоточности.

Этот раздел не является полным руководством по многопоточности в C#. Для более глубоких знаний Вы можете обратиться к книге «Threading in C#». Но я не могу просто дать Вам ссылку на эту книгу и ничего не объяснять Вам, поэтому я раскрою основные понятия, которые я буду использовать в этой статье.

В языке C# класс Thread создает поток, контролирует его, устанавливает приоритет, считывает его состояние. При создании экземпляра класса Thread мы должны передать конструктору ThreadStart или ParameterizedThreadStart. ThreadStart представляет метод без аргументов, который нужно выполнить в этом потоке. Например если у нас есть метод a(), мы может создать новый поток, в котором выполним наш метод, с помощью следующего кода:

void a()

{


[…]

}

[…]

ThreadStart threadStart = new ThreadStart(a);

Thread newThread = new Thread(threadStart);

newThread.Start();

или просто

Thread t = new Thread(new ThreadStart(a));

t.Start()

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

lock(lockObject)

{


// этот и любой другой код, окруженный lockObject


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


// будут заблокированы до тех пор пока этот не завершит выполнение.

}

Последний класс, который я вкратце объясню — это класс AutoResetEvent. Этот класс позволяет потокам общаться друг с другом посредством сигналов. Для того чтобы начать ожидание сигнала, поток вызывает метод WaitOne() объекта AutoResetEvent. Если этот сигнал уже был послан да этого, поток продолжает выполнение. В противном случае поток блокируется до того момента, пока какой-нибудь другой поток не пришлёт ему этот сигнал с помощью метода Set() объекта AutoResetEvent. Следующий код показывает как объявить и инициализировать AutoResetEvent.

// можете определить начальное состояние в конструкторе

AutoResetEvent myEvent = new AutoResetEvent(false);

[…]

//когда поток пытается войти в сомнительный участок, он вызывает WaitOne()

myEvent.WaitOne();

[…]

//поток остаётся заблокированным до тех пор, пока где-нибудь не будет вызван метод Set события

myEvent.Set()

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

3. Многопоточное обновление / рисование.

Самая популярная задача, о которой мы слышим, когда речь идёт о многопоточности в играх — это разделение обновления и рендеринга игрового мира в двух разных потоках. Причина очевидна. Когда мы используем один поток, то прежде чем начать обсчет следующего кадра, мы должны дождаться окончания прорисовки предыдущего. Это пустая трата времени, потому как для обсчета кадра не требуется каких либо результатов прорисовки предыдущего. Используя многопоточность пока система занята прорисовкой текущей сцены, мы можем использовать свободные процессорные ядра для вычислений следующего состояния мира. В этом случае вычисления для обсчета следующего кадра не дожидаясь окончания прорисовки, работают в параллели, а мы сокращаем общее время на каждый кадр. Если это звучит так просто в теории, то почему на деле так мало примеров?

3.1 Двойная буферизация

Главная проблема с разделением Обновления / Прорисовки в том, что процесс прорисовки нуждается в данных от процесса обновления. Также мы должны убедиться в том, что данные для всех рисуемых объектов корректны. Для этого нам понадобятся примитивы синхронизации. Также мы хотим предотвратить ожидание внутри блокировки пока каждый кусок данных обрабатывается, потому как в противном случае выигрыш в производительности сойдёт на нет.

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

На рисунке Кадр 1 поток прорисовки использует состояние, хранимое в буфере 0, в то время как поток обновления использует буфер 1. Когда начинается Кадр 2, поток прорисовки начинает рисовать мир, используя новое состояние, хранимое в буфере 1, а поток обновления может начинать вычисления следующего состояния в буфере 0. На рисунке Кадр 3 они опять меняются буферами. И так далее. Таким образом на каждом шаге, поток прорисовки использует состояние мира, вычисленное в предыдущем кадре, в то время как поток обновления вычисляет следующее состояние и сохраняет его в свободном буфере.

В то время, как основная идея выглядит просто, на этом месте часто возникают трудности. Давайте проанализируем. Что за данные хранятся в буферах, и как ты организуем их? Самый очевидный ответом, особенно, если вы занимались ООП, будет «Это основные данные о нашей игре. Данные о физике (такие как скорость, ускорение, примитивы коллизии, позиции, углы поворота). Также нужны данные об анимации, такие как кости, движения, ограничения и т. д. Когда мы рисуем объект, мы используем так же позиции и вращения, которые также описывает физика объектов. И не забудьте специфичные игровые данные — здоровье, скрипты ИИ и т д.». Затем Вы продолжаете и создаете класс GameEntity, который описывает Ваш объект.

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

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

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

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

К счастью, да. Далее мы посмотрим на решение, предложенное Яном Льюисом на презентации Gamefest, а также его имплементацию в код.

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

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

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

Логотип WordPress.com

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

Фотография Twitter

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

Фотография Facebook

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

Google+ photo

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

Connecting to %s