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

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

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

Теперь пришло время спуститься и до вопросов более низкого уровня. В главе с названием «Генерация Мусора», я писал, что объекты создаются в куче, в то время как структуры создаются в стеке, и только куча нуждается в сборе мусора. Но что все это означает для нас, разработчиков игры?

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

Структуры похожи на классы, но есть некоторые различия:

• У структур нет наследования.

• У структур не может быть параметров меньше, чем конструкторов.

• В отличие от классов, структуре можно приписывать значение и без «нового» оператора.

• На структуры нельзя ссылаться (как на объекты по крайней мере), они могут быть только скопированы как значение.

Структуры быстрее, чем объекты. Если у вас есть объекты, которые вы могли бы классифицировать как «контейнеры данных» (классы, которые содержат только данные и не имеют больше никакого другого предназначения), вам следует преобразовать их в структуры, и тем самым вы немного выиграете в производительности. Однако вы должны быть осторожными, поскольку ваше приложение может пострадать от высокого потребления памяти. Подробнее об это можно найти в главе «Ссылки и Значения».

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

Используя структуры, вы может неплохо повысить производительность, но вам надо следить за памятью. Нормальные классы (объекты) используют ссылки для того, чтобы держать потребление памяти настолько низким насколько это возможно, но на структуры нельзя ссылаться, вместо этого они копируются значением. Это делается каждый раз, когда вы используете их как, например, параметр в методе.

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

Вот пример того, как ссылаться на структуру:

Unit alienCollector = loader.LoadUnit(UnitType.Alien, 
 UnitSpecial.Collector); 
UnitData unitData = new UnitData(); 
unitData.Bullets = 100; 
//Reference the unitData to LoadUnitData instead of copying. 
alienCollector.LoadUnitData(ref unitData); 

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

public void LoadUnitData(ref UnitData unitData) 
{ 
} 

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

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

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

Использование полей вместо свойств может дать вам большую производительность, поскольку уменьшаются расходы на вызов метода. Но у них также есть некоторые недостатки:

• Отсутствие возможности получать или устанавливать специальную логику

• Отсутствие управления чтением\записью

• Уменьшение гибкости при динамически подключаемых библиотеках

Что касается последнего пункта, это может и не относиться к вашему приложению, если у вас есть исходный код всех проектов, на которые вы ссылаетесь. Но если вы ссылаетесь на предварительно откомпилированную библиотеку, то не сможете откомпилировать проект, если перейдете от свойств к полям. Это могло бы показаться странным, но в CLR, поля и свойства это 2 разных метода изменения состояния объекта.

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

11. Строки

Строки не изменяемы. Это означает, что каждый раз, когда вы изменяете содержимое строки, создается новая строка. Можно весьма подробно объяснить, как работает внутренняя таблица CLR, но вместо этого можно сказать, что платформа .NET весьма эффективна, когда дело доходит до распределений строк.

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

Пример:

string temp = «Hello there»;

for (int i = 0; i < 1000; i++)

{

temp += «Hello to you too»;

}

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

Вот, как мы бы использовали StringBuilder:

StringBuilder temp = new StringBuilder(«Hello there»);

for (int i = 0; i < 1000; i++)

{

temp.Append(«Hello to you too»);

}

Это создало бы одну строку со всеми новыми значениями. Гораздо эффективнее. Помните, что, использование StringBuilder вместо обычных строк, в действительности может ухудшить производительность. Не забудьте её протестировать.

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

Вы можете и не знать об Equals() и GetHashCode(), но они находятся во всех объектах и типах (структурах и примитивные типах). Equals(), используется, чтобы проверить равенство между значениями 2 объектов (или структур). Если 2 объекта содержат одинаковые значения, они будут равны друг другу.

Есть некоторые рекомендации для переопределения Equals():

• x.Equals(x) возвращает истину

• x.Equals(y) возвращает тоже, что и y.Equals(x).

• если ((x.Equals(y) && y.Equals(z)) возвращает истину, тогда x.Equals(z) возвращает истину.

• Последовательные вызовы x.Equals(y) возвращают одно и тоже значение, пока объекты на которые ссылаются x и y не будут изменены.

• x.Equals(null) возвращает ложь.

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

Как и у Equals, GetHashCode также имеет некоторые рекомендации к реализации:

• Если два объекта равны, то метод GetHashCode для каждого объекта должен возвратить одно и то же значение. Однако, если два объекта не равны, то GetHashCode для этих двух объектов не обязан возвращать разные значения.

• Метод GetHashCode объекта должен последовательно возвращать тот же самый хэш код, пока нет изменений в состоянии объекта, которые определяются по возвращаемому значению метода Equals. Заметьте, что это выполняется только при текущем исполнении программы, при последующем запуске хэш-код может измениться.

• Для лучшей производительности хеш-функция должна генерировать случайное распределение при всех вызовах.

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

Вот пример переопределения Equals и GetHashCode:

public struct UnitData

{

public int Bullets { get; set; }

public int Life { get; set; }

public override bool Equals(object obj)

{

if (obj is UnitData)

{

UnitData data = (UnitData)obj;

if (data.Bullets == Bullets &&

data.Life == Life)

{

return true;

}

return false;

}

return false;

}

public override int GetHashCode()

{

return Life ^ Bullets;

}

}

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

13. Упаковка

Упаковка это термин.NET, она используется при инкапсулировании типа в объект. Вкратце она обертывает данный тип в «коробочку». Распаковывание наоборот, разворачивает объект в тип.

Если мы имеем целое число и помещаем его в объект, а затем обертываем объект в целое число снова, мы выполним IL операции упаковывания и распаковывания:

int number = 60;

object obj = (object) number; // Упаковка

Console.Write(obj);

int newNumber = (int) obj; // Распаковка

Console.Write(newNumber);

Если мы смотрим на IL код ниже, то увидим операции упаковки и распаковки:

.method private hidebysig static void Main(string[] args) cil managed

{

.entrypoint

.maxstack 1

.locals init (

[0] int32 number,

[1] object obj,

[2] int32 newNumber)

L_0000: ldc.i4.s 60

L_0002: stloc.0

L_0003: ldloc.0

L_0004: box int32

L_0009: stloc.1

L_000a: ldloc.1

L_000b: call void [mscorlib]System.Console::Write(object)

L_0010: ldloc.1

L_0011: unbox.any int32

L_0016: stloc.2

L_0017: ldloc.2

L_0018: call void [mscorlib]System.Console::Write(int32)

L_001d: ret

}

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

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

И For и Foreach используются для итерации коллекций, почему мы должны о них беспокоиться? Вы может быть удивитесь, но мы можем улучшить нашу итерацию в 5 раз, лишь изменив вид этой коллекции и способ итерации.

Пример кода, который вы может быть узнаёте:

List<int> numbers = new List<int>();

int currentNumber;

foreach (int number in numbers)

{

currentNumber = number;

}

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

Вот наш новый и улучшенный код:

//Массив вместо списка

int[] numbers = new int[100000000];

int currentNumber;

//Оптимизация счетчика

int count = numbers.Length;

//For цикл, вместо Foreach цикла

for (int i = 0; i < count; i++)

{

currentNumber = numbers[i];

}

Есть несколько причин, почему мы хотим использовать этот код, а не старый.

1. Цикл For не создает перечисление объета (Foreach также не создает его в массивах, но он делает это в списках.)

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

3. Для итераций лучше использовать массив вместо списка.

Выполнение теста на моем компьютере (Конфигурация релиза, без отладчика, платформа x86) выдает следующие результаты:

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

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

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

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

• Сворачивание констант

• Распространение констант и копий

• Устранение общих подвыражений

• Вынесение части кода из тела цикла

• Устранение «мертвой» памяти и неиспользуемого кода

• Регистрирование распределений

• Встроенные методы

• Развертывание цикла (маленькие циклы с маленькими телами)

Я не собираюсь объяснять каждый пункт. Достаточно сказать, что есть некие хитрости и иногда JIT пропускает код, который должен быть оптимизирован. Я написал список оптимизаций, которые вы можете сделать вручную, чтобы увеличить производительность игры.

Встроенные методы.

Если у вас есть методы, которые вы используете в одном — двух местах, то лучше сделать их встроенными, чтобы увеличить производительность.

До:

if (IsColorBlack(new Color(10, 4, 1)))

{

}

private bool IsColorBlack(Color color)

{

return color == Color.Black;

}

После:

if (new Color(10, 4, 1) == Color.Black)

{

}

Встроенные свойства

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

До:

Vector2 distance = Vector2.Zero;

Vector2.Subtract(ref vectorA, ref vectorB, out distance);

После:

Vector2 distance = Vector2.Zero;

distance.X = vectorA.X — vectorB.X;

distance.Y = vectorA.Y — vectorB.Y;

Встроенные конструкторы

Вызов конструктора медленнее, чем установка значений вручную

До:

Vector2 distance = new Vector2(10,10);

После:

Vector2 distance = Vector2.Zero;

distance.X = 10;

distance.Y = 10;

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

Упростите переменные

Переменные, которые постоянно вычисляются, лучше упростить

До:

int offset = 10;

int centerX = 300;

int centerY = 300;

Vector2 center = new Vector2(centerX + offset + 50, centerY + offset + 10);

После:

Vector2 center = new Vector2(360, 320);

Используйте константы

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

До:

int position = 10;

Vector2 center = new Vector2(position, position);

После:

const int offset = 10;

Vector2 center = new Vector2(position, position);

Используйте запечатанные классы

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

До:

public class Unit

После:

public sealed class Unit

Используйте статический ключ

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

До:

public class Unit

После:

public static class Unit

Используйте уже инициализированные структуры

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

До:

Vector2 zeroVector = new Vector2();

После:

Vector2 zeroVector = Vector2.Zero;

17. Ссылки

http://codebetter.com/blogs/patricksmacchia/archive/2008/01/05/rambling-on-the-sealed-keyword.aspx
http://www.physicspoweredgames.com/FarseerPhysics/Manual2.0.htmhttp://msdn.microsoft.com/en-us/library/ms973919.aspxhttp://blogs.msdn.com/charlie/archive/2006/10/11/Optimizing-C_2300_-String-Performance.aspxhttp://blogs.msdn.com/netcfteam/archive/2005/05/04/414820.aspxhttp://blogs.msdn.com/netcfteam/archive/2006/12/22/managed-code-performance-on-xbox-360-for-xna-part-2-gc-and-tools.aspxhttp://codebetter.com/blogs/patricksmacchia/archive/2008/11/19/an-easy-and-efficient-way-to-improve-net-code-performances.aspx

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

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

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

Логотип WordPress.com

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

Фотография Twitter

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

Фотография Facebook

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

Google+ photo

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

Connecting to %s