Разработка игр высокой производительности

Разработка игр высокой производительности

Ian Qvist

Перевод статьи


http://www.ziggyware.com/readarticle.php?article_id=222


Содержание:

1. Архитектура

a. Использование виртуальных структур

b. Низкая оптимизация CLR

2. Альтернатива

a. Динамическая загрузка

b. Лучшая производительность

c. Высокая оптимизация CLR

3. Генерация/сбор мусора

4. Предварительная загрузка

5. Кэш/пул

6. Многопоточность

7. Ленивая загрузка

8. Структуры и классов

9. Ссылки и Значения

10. Поля и Свойства

11. Строки

12. Переопределение
Equals () и GetHashCode ()

13. Упаковка

14. Коллекции с For и Foreach

15. Оптимизация компилятора CLR/JIT

16. Прочая оптимизация

17. Ссылки

Введение

Вы собираетесь создать игру и размышляете о её производительности, поскольку она должна работать и на старых машинах с кадровой частотой, по крайней мере, 60 кадров в секунду. Вы видели, что другие игры, такие как Spore, Call of Duty и т.п. великолепно работали на вашем компьютере. Так…как вам достигнуть этого?

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

Я не собираюсь показывать вам, как разрабатывать игру или совершенную архитектуру. Я только хочу показать несколько приёмов, которые вы сможете применить для получения лучшей производительности. Мы собираемся затронуть вопросы мультипоточности, кэширования, предварительной загрузки и некоторых «тайн» CLR.

Давайте начнем:

1. Архитектура

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

Логическое разделение кода игры — отличный способ для достижения удобства обслуживания, а также это делает вашу структуру более простой. Еще разделение игры на определенные части могло бы облегчить вам задачу обновления, вы можете обновлять части без разрушения всей структуры.

Давайте взглянем на «сложную» структуру, представленную ниже:

Не смотря на то, что это кажется логичным и удобным, это всё же ставит некоторые проблемы в проекте нашей игры.

a. Низкая производительность с виртуальной структурой

b. Низкая оптимизация CLR

Всё хорошо, но что же это означает?

a. Использование виртуальных структур

Так в чем дело с этой виртуальностью? Наши модули Collector и Engineer не имеют оружия, нам надо сделать метод Fire() виртуальным и переопределить их внутри каждого класса weapon, чтобы удостовериться, что «Weaponless» класса weapon не исполняет последовательность выстрелов (система частиц, прорисовка пуль, разрушения и т.п.).

Но использование виртуального метода может быть до 40 % медленнее, чем статических или экземплярных
методов в.net Compact Framework. Это довольно много, если вы запускаете много оружия одновременно. Это конечно относится не только к нашему классу weapon, но и ко всем виртуальным методам, используемым в игре.

b. Низкая оптимизация CLR

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

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

2. Альтернатива

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

Полученные преимущества:

a. Динамическая загрузка

b. Лучшая производительность

c. Высокая производительность CLR

a. Динамическая Загрузка

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

Это дает нам возможность создания модуля, где пользователи смогут создать свои собственные модули.

Но это также может быть и недостатком. Наша первая архитектура имела четко определенную структуру. Морским пехотинцем (Marine) не могло быть пресмыкающееся (Creeper), потому что пресмыкающееся (Creeper) это – тип пришелец(Alien). А в новой структуре у нас может произойти такое, что морской пехотинец будет пресмыкающимся. Кто-то назвал бы это просто гибкостью, но это может лишить вашу игру логики.

b. Лучшая производительность

Больше никаких виртуальных вызовов! Никакого преобразования от одного типа объекта к другому и больше нет вызов методов между классами layers/inherited. Это сильно повышает производительность, но с другой стороны, и имеет свои затраты.

c. Высокая оптимизация CLR

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

3. Генерация/сбор мусора

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

В разработке игры нам необходимо выискивать мусор, сгенерированный нашим кодом. Сборщику мусора требуется некоторое время, чтобы собрать его. Если он работает слишком быстро или слишком долго, то производительность вашей игры может пострадать. Искать мусор трудно, но анализ вашего кода с профилировщиком или просмотр вашего IL (Intermediate Language) кода может быть хорошим началом.

Прежде, чем мы начнём анализировать, где искать мусор, нам нужно понять основы объектно-ориентированного программирования и распределения памяти. Объекты представляют собой ссылочный тип данных, и структуры – «тип значение». Ссылочные типы данных хранятся в куче, а «тип значение» лежит в стеке. Важная вещь, которую нужно помнить – это то, что только объекты (в куче) высвобождаются сборщиком мусора. Поподробнее я расскажу позже.

В C # все основные типы представляют собой «тип значение», а все другие наследуемые типы — ссылочные типы.

Вот список основных типов (хранятся в стеке):
• bool
• byte
• char
• decimal
• double
• enum
• float
• int
• long
• sbyte
• short
• struct
• uint
• ulong
• ushort

И ссылочные типы (хранятся в куче):
• class
• interface
• delegate
• object
• string

Один из важнейших инструментов для анализа мусора является CLR Профилировщик (скачать здесь: CLR Profiler for .net 2.0). Это очень ценный инструмент для анализа использования ресурсов и распределения объектов в вашей игре.

Другой ценный инструмент — это Рефлектор (скачать здесь: Reflector), который может разобрать ваши библиотеки и представить их как код IL.

4. Предварительная загрузка

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

Большая часть предварительной загрузки должна быть выполнена непосредственно до загрузки уровня. Обычно это сопровождается соответствующим окном загрузки, так что пользователь может видеть, что игра работает (а не гадает: О_О.. может она зависла?)

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

Но как мы выполняем предварительную загрузку контента игры? В нашем примере, мы это делаем так:

Загрузка текстуры, анимации, звуков, эффектов частиц и физических свойств занимает довольно долгое время. Мы расширим нашу структуру до следующей:

И внутри Level.LoadUnits() у нас будет следующий код:

public void LoadUnits() 
{ 
 for (int i = 0; i < 100; i++) 
 { 
 ContentLoader loader = new ContentLoader(); 
 Unit alienCollector = loader.LoadUnit(UnitType.Alien, 
 UnitSpecial.Collector); 
 alienCollector.Weapon.Damage = 100; 
 Units.Add(alienCollector); 
 } 
} 

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

5. Кэш/Пул

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

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

Все эти действия не возможны даже на самой продвинутой на сегодня машине. Вот короткий жизненный цикл вторжения пришельцев:

1. Создайте 500 совершенно новых пришельцев

2. Нападите на 100 с использованием ядерного оружия

3. 100 умирают

4. Удалите 100 из игры

5. Создайте 100 совершенно новых пришельцев

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

• Загрузка текстур (0.01 мс)

• Эффекты частиц (0.5 мс)

• Загрузка звука (1 мс)

• Физика (1 мс)

• Анимация (0.3 мс)

В общем, это занимает 2.81 мс, и это довольно быстро, правда? А вот и нет… Нам нужно 100 таких пришельцев, таким образом, это — 281 мс всего. Если мы запустим нашу игру с кадровой частотой в 60 кадров в секунду, один кадр занимает 1/60 секунды (16.66 мс). Это означает задержку в 17 кадров каждый раз, когда мы будем стрелять из оружия.

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

1. Создайте 500 совершенно новых пришельцев (с предварительно загрузкой конечно)

2. Возьмите 500 из кэша.

3. Нападите на 100 с использованием ядерного оружия

4. 100 умирают (дезактивируйте их – а не удаляйте)

5. Повторно используйте 100 из кэша

Теперь это занимает 2.81 x 500 = 1.4 секунды, чтобы загрузить 500 пришельцев, и 0 секунд, чтобы возродить 100 из них!

Все замечательно, но как мы это используем? Здесь представлен простой класс из проекта Farseer Physics Engine:

public class Pool<T> where T : new() 
{ 
 private Stack<T> _stack; 
 public Pool() 
 { 
 _stack = new Stack<T>(); 
 } 
 public Pool(int size) 
 { 
 _stack = new Stack<T>(size); 
 for (int i = 0; i < size; i++) 
 { 
 _stack.Push(new T()); 
 } 
 } 
 public T Fetch() 
 { 
 if (_stack.Count > 0) 
 { 
 return _stack.Pop(); 
 } 
 return new T(); 
 } 
 public void Insert(T item) 
 { 
 _stack.Push(item); 
 } 
} 

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

public void LoadUnits() 
{ 
 //Create our cache/pool 
 Pool<Unit> pool = new Pool<Unit>(); 
 //Create 500 aliens 
 for (int i = 0; i < 500; i++) 
 { 
 ContentLoader loader = new ContentLoader(); 
 Unit alienCollector = loader.LoadUnit(UnitType.Alien, UnitSpecial.Collector); 
 alienCollector.LoadTexture(); 
 alienCollector.LoadParticleSystem(); 
 alienCollector.LoadPhysics(); 
 alienCollector.LoadSound(); 
 alienCollector.Enabled = false; 
 //Insert alien into cache/pool 
 pool.Insert(alienCollector); 
 } 
 //Load 100 aliens into game 
 for (int i = 0; i < 100; i++) 
 { 
 Units.Add(pool.Fetch()); 
 } 
} 

Это создаст 500 пришельцев и поместит 100 из них на наш уровень. Заметьте, что alienCollector.Enabled установлен в значении false. Используя кэш, вам надо деактивировать врагов, а не удалять их.

6. Мультипоточность

Этот раздел — один из наиболее хитрых. Мультипоточность может быть решением всех ваших проблем или может быть началом очень длинного и интересного приключения в отладке игры.

Мультипоточность служит скорее инструментом для получения больших возможностей в вашей игре, чем улучшает производительности игры. Большинство людей думает: если я обеспечу мультипоточность, я могу увеличить производительность в 2 раза!

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

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

7. Ленивая загрузка

Если у вас есть группа огромных структур данных (или классы помощники), которые вам нужно использовать, но только когда подойдет 3-й уровень в вашей игре, возможно, вы бы хотели посмотреть на ленивую загрузку. Ленивая загрузка, по сути, это просто класс, который загружает только тогда, когда это нужно. Его также называют Ленивым Образцом Инициализации (Lazy Initialization Pattern). Хороший пример использования Lazy Initialization Pattern это Singleton pattern, который можно найти на Wikipedia: Singleton Pattern

Простая реализация, которая используется в Farseer Physics, представлена здесь:

public class Factory 
{ 
 private static Factory _instance; 
 private Factory() 
 { 
 } 
 public static Factory Instance 
 { 
 get 
 { 
 if (_instance == null) 
 { 
 _instance = new Factory(); 
 } 
 return _instance; 
 } 
 } 
} 

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

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

2 комментария на «Разработка игр высокой производительности»

  1. Алексей:

    Экономить на спичках в 2008 году — это что-то. Компиляторы даже тогда умели оптимизировать всё, что можно ~ не поверю, что в дотнете это по-другому.
    Советы по архитектуре больше вредны, чем полезны — зачем юзать полиморфизм, когда можно сделать enum и блок if’ов на полторы тыщи строк.
    Ну и совсем добило присваивание значений полям вместо инициализации в конструкторе.

    • Для начала скажу, что это перевод, как и написано в шапке.
      Но вообще автор все правильно пишет, разработчики серьезных игр (да и несерьезных) как раз таки «экономят на спичках». Иначе, например, на консолях прошлого поколения мы бы ничего серьезнее тетриса не увидели. Сейчас можно увидеть как горе-программисты, которые думают, что об оптимизации задумываться не надо, делают на каком-нибудь Юнити (или другом движке с низким порогом вхождения) 2Д игру с двумя картинками, которая умудряется тормозить\вылетать на современном железе.
      Не забывайте, что код должен работать как можно быстрее, ведь каждая итерация игрового цикла должна выполняться за 16мс. Так что экономить нужно на всем.
      Про полиморфизм там все написано — можно выиграть до 40% производительности. Виртуальные функции в играх всегда старались не использовать.
      Присваивание значений полям — это про ленивую загрузку? Если так, то это тоже используется повсеместно и не только в играх. Нужно просто хорошо понимать, как именно должно работать приложение/игра.

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

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

Логотип WordPress.com

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

Фотография Twitter

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

Фотография Facebook

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

Google+ photo

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

Connecting to %s