Skip to content

Instantly share code, notes, and snippets.

@hhrhhr
Last active March 10, 2018 05:02
Show Gist options
  • Save hhrhhr/03c8e5fd1d43191f6d5d to your computer and use it in GitHub Desktop.
Save hhrhhr/03c8e5fd1d43191f6d5d to your computer and use it in GitHub Desktop.
Разработка многопоточных кроссплатформенных игр (копия https://int-software.intel.com/ru-ru/articles/threaded-cross-platform-game-development)
Примечание
Русскоязычный оригинал в настоящее время недоступен. Ссылка на исходники и изображения взяты из статьи на английском языке.

Разработка многопоточных кроссплатформенных игр

Введение

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

Используемое в качестве примера приложение можно загрузить здесь. В комплекте с ним вы найдете файл решения для Microsoft Visual Studio 2005 для сборки и запуска в среде Windows, а также файл инструкций для сборки под Linux. После запуска демонстрационное приложение в оконном режиме отрисовывает сцену OpenGL (Рисунок 1). Это приложение и включенный в него код будут использоваться для иллюстрации всех приводимых в статье примеров.

Внешний вид демо при запуске

Рисунок 1. Внешний вид демо при запуске

Общие сведения

Разработка программного обеспечения с поддержкой многопоточности – это серьезный вопрос, требующий отдельного обсуждения, но его основные принципы вполне очевидны. В обычном однопоточном программном обеспечении весь код выполняется в последовательном режиме. С точки зрения игр, это обычно подразумевает наличие центрального процесса, в рамках которого выполняются все игровые задачи (обработка ввода, обновление игрового мира, рендеринг и т.п.), пошагово для каждого выводимого на экран кадра. Модель последовательного вычисления вполне приемлема для одноядерных процессоров, однако в случае многоядерных процессоров их дополнительные ресурсы задействованы не будут. Избежать этого можно за счет разделения игровых задач на независимые «потоки», которые могут выполняться на каждом из логических ядер многоядерного процессора. Данный принцип называется моделью параллельного или многопоточного вычисления. Параллельное выполнение задач – это ключевой фактор повышения производительности игр на современных процессорах, все чаще обладающих многоядерной архитектурой.

Демонстрационное приложение включает в себя три потока: один используется в качестве базового сценария событий, другой отвечает за обновление положения объектов в игровом мире, а третий выполняет рендеринг игрового мира в оконной среде. В нижней части окна показано , насколько часто выполняются задачи, вынесенные в каждый поток В качестве единиц измерения используются вызовы в секунду (calls per second, CPS) (Рисунок 2). Данное значение соответствует кадрам в секунду при рендеринге, однако для описания всех потоков, обеспечивающих циклическое выполнение задач, мы использовали более общий термин. В нижней части окна также отображается информация о том, выполняется ли задача в последовательном или параллельном режиме. Нажимая клавишу Tab, вы можете переключаться между этими режимами, чтобы оценить CPS для каждой задачи.

Вычислительную нагрузку на задачи рендеринга и обновления можно увеличивать или уменьшать в диалоговом режиме, нажимая соответственно клавиши Z и X для рендеринга и “.” (точку) и “/” (слэш) для обновления. За счет регулирования нагрузки можно симулировать игровые задачи, скорость выполнения которых определяется либо сложностью графики, либо вычислений.

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

Выполнение демо с увеличенной нагрузкой

Рисунок 2. Выполнение демо с увеличенной нагрузкой

##Подробное описание программной реализации

Демонстрационная программа состоит из четырех классов C++ и кода «обвязки». Использованы следующие классы:

ThreadManager: Данный класс управляет пулом потоков - группой потоков, создаваемых при запуске программы и назначаемых для задач, которые выполняются на протяжении всей работы программы. Однократное создание потоков позволяет избежать затрат на их повторное создание и прекращение во время выполнения. Реализация пула потоков позволяет «закрепить» каждый поток за конкретной игровой задачей; после запуска поток будет регулярно вызывать одну и ту же функцию. Данная схема удобна для простого запуска и остановки потоков и для расчета вызовов в секунду. Также существуют другие схемы управления пулом потоков, которые рассматриваются в разделе «Дальнейшие исследования».

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

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

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

OpenGLWindow: Этот класс использует библиотеку SDL (Simple Direct Media Layer) для построения кроссплатформенного контекста рендеринга с фиксированным разрешением. Применение этого класса позволяет сгладить некоторые неочевидные трудности рендеринга в OpenGL. Рендеринг может производиться в оконном или полноэкранном режиме. Контексты рендеринга OpenGL становятся недействительными при повторном создании окна (например, при переходе в полноэкранный режим). В целях воссоздания контекстов рендеринга, данный класс позволяет потокам определить наличие действительного контекста рендеринга. Дело в том, что среда OpenGL требует ассоциации каждого потока с отдельным контекстом, при этом потокам автоматически не возвращается значение, указывающее на недействительность контекста.

World: Этот класс является специфичным для нашего демо. Он управляет статическим фоном из треугольников и набором движущихся объектов на переднем плане, обновление и рендеринг которых осуществляется в рамках отдельных потоков. Фоновые треугольники создают необходимую нагрузку на поток рендера. Точки на переднем плане используются для моделирования задачи «n-тела» - классического примера закона всеобщей гравитации. Представьте, что каждая точка является планетой или астероидом, который движется в космическом пространстве, притягивает другие тела и сливается с ними при столкновении. В центре пространства находится невидимая черная дыра, которая поглощает все попадающие в нее объекты, но, в конце концов, «переполняется» и выбрасывает новые объекты наружу. Задача «n-тела» создает нагрузку на поток обновления состояния мира.

Эксперименты с помощью демонстрационной программы

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

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

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

При запуске демонстрационная программа загружается в оконном режиме и выводит 10000 объектов на фоне и 50 на переднем плане. Все приведенные ниже сценарии исходят из этих стартовых значений. Количество треугольников можно регулировать с шагом 10000, нажимая клавиши Z и X. Количество тел изменяется с шагом 50 по нажатию клавиш . (точка) и / (слэш). Клавиша Tab переключает последовательный и параллельный режим выполнения задач.

Сценарий 1: Симулирование среды с основной нагрузкой на вычисления. Отрегулируйте нагрузку на процесс обновления клавишей / (слэш), чтобы добавить объекты для вычисления задачи n-тела. Увеличьте нагрузку до такого предела, чтобы показатель CPS для потока обновления был значительно ниже, чем для потока рендера, но не менее 5 (если возможно). Добиться этого на одноядерной системе может быть не так просто, поскольку увеличение нагрузки на поток обновления приведет к падению CPS в двух других задачах. Выполнение данного условия на многоядерной системе не составляет труда. При таком условии тела на переднем плане движутся несколько волнообразно - что является побочным эффектом работы потоков рендера и обновления с одними и теми же данными. Поскольку такое взаимодействие не является поточно-ориентированным, поток рендера выводит каждый кадр с частично завершенным обновлением. Поточно-ориентированный подход к визуализации рассматривается в разделе «Дальнейшие исследования».

Запомните значение CPS для потока обновления и нажмите клавишу Tab для перехода в режим последовательного выполнения. На одноядерной системе CPS всех задач упадет до такого же минимального значения (возможно, чуть более высокого). На многоядерной системе показатели всех задач упадут до ЕЩЕ МЕНЬШЕГО значения. Почему это происходит? В первом случае все задачи уже выполняются на одном и том же логическом ядре, таким образом, их перевод в последовательный режим лишь ограничивает самые быстрые из них уровнем производительности самой медленной задачи, в результате чего самой медленной задаче потребуется несколько меньше времени, чтобы уравняться с другими. На многоядерной системе медленная задача вероятно уже выполняется на отдельном ядре (но не обязательно, см. раздел «Дальнейшие исследования»), поэтому она не конкурирует с другими задачами. Однако при включении последовательного режима такая медленная задача начинает обсчитываться на том же ядре, что и другие процессы, что приводит к еще большему замедлению. Из этого следует вывод: на многоядерной платформе многопоточность игры позволяет ускорить выполнение всех задач.

Сценарий 2: Симулирование среды с основной нагрузкой на графику. Отрегулируйте нагрузку на поток рендера нажатием клавиши X, которая добавляет треугольники на заднем фоне. Увеличьте нагрузку, чтобы значение CPS для рендера находилось в пределах от 5 до 10. Значение CPS для потока обновления должно быть существенно выше (на одноядерной системе, вероятно, не намного выше). Данная ситуация объясняется тем, что несмотря на медленную работу рендера, происходят частые обращения к потоку обновления, симулирующего задачу n-тела, что обеспечивает высокую точность расчетов. Даже при низких значениях CPS/FPS можно отслеживать движение отдельных тел.

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

Заключение

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

Дальнейшие исследования

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

Добавление поддержки других платформ: Очевидным направлением является поддержка MacOS*, что может принести вполне закономерные результаты. Возможно также реализовать поддержку и других платформ (консоли, карманные устройства и т.п.), однако широкий охват разных платформ потребует более глубокой проработки интерфейса, управления и т.п.

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

Задание фиксированной частоты обновления мира: Задача «n-тела» является чувствительным процессом физической симуляции, точность которого зависит от частоты обновления состояния мира. Выбор фиксированной частоты для обновления состояния мира поможет устранить отклонения в точности симуляции. После завершения процесса обновления задача может быть переведена в режим ожидания сзаданным интервалом времени.

Двойная буферизация обновления мира для корректного многопоточного рендеринга и увеличения количества FPS: Если основная нагрузка в проекте приходится на вычисления, состояние игрового мира может быть поделено на статические и динамические объекты. Располагая двумя экземплярами динамического объекта, поток вычисления состояния мира может обновить один из них в то время, пока поток рендера производит визуализацию другого (наряду со статическими объектами игрового мира). Именно такой способ используется во многих коммерческих играх для ускорения операций рендеринга на многоядерных процессорах.

Использование системно-зависимого API для распределения потоков: Потоки могут быть соотнесены с любым из имеющихся логических ядер – но нет гарантии того, что любые два потока действительно будут выполняться одновременно. Каждая платформа имеет свои особенности распределения потоков и разграничения их общего времени выполнения. Некоторые платформы предоставляют специальный прикладной программный интерфейс (API) для управления политикой распределения и упорядочения потоков. Можно выполнить абстракцию таких интерфейсов и включить их в класс ThreadManager.

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

Отказ от последовательных алгоритмов и использование класса ThreadManager вместо ThreadManagerSerial: Это позволит задачам использовать более эффективные блокирующие вызовы, ожидая выполнения необходимых условий. Наприер, в среде Windows функция runWindowLoop может вызывать функцию WaitMessage вместо PeekMessage, блокируя ее до наступления нужного события для обработки. Функция рендера может вызвать glFinish вместо glFlush, блокируя ее до момента полной прорисовки кадра. Кроме того, класс ThreadManager может быть изменен для поддержки политики однократного распределения пула потоков (сценарий «поставщик-потребитель»), что позволит точно соотнести количество потоков с количеством логических ядер, в теории обеспечивая минимально возможные непроизводительные затраты на многопоточность.

Приложение: Сборка компонентов демонстрационного приложения

  • Распакуйте архив TCPGD.zip, в каталог TCPGD.
  • Windows: Запустите Microsoft Visual Studio* 2005 и откройте файл решения TCPGD.sln, находящийся в каталоге TCPGD. Выберите пункт Release configuration, соберите и запустите решение. При сборке проекта GLF могут появиться предупреждения об ошибках - однако это не помешает успешной сборке и запуску приложения.
  • Linux: Перейдите в каталог TCPGD. Для сборки демо вам понадобится отдельно выполнить сборку проектов GLF и Main. Для сборки и запуска программы воспользуйтесь следующими командами:
cd GLF
make
cd ../Main
make
./main

Используемые материалы

http://www.libsdl.org SDL – библиотека Simple Direct Media Layer library. Кросплатформенная библиотека SDL используется для создания оконных интерфейсов и рендеринга контекстов OpenGL.

http://www.opengl.org/resources/features/fontsurvey/#glf GLF – библиотека рендеринга шрифтов OpenGL. GLF использовалась для вывода текста в демо-приложении.

OpenGL Programming Guide Fifth Edition, Addison Wesley, 2005. Этот справочник - незаменимый источник информации по всем вопросам программирования под OpenGL.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment