Как стать автором
Обновить

Как мы оптимизировали скрипты в Unity

Время на прочтение14 мин
Количество просмотров19K
Автор оригинала: Ondřej Kofroň
Существует множество отличных статей и туториалов о производительности в Unity. Этой статьёй мы не пытаемся заменить или улучшить их, это всего лишь краткое изложение шагов, сделанных нами после прочтения этих статей, а также шагов, позволивших решить наши проблемы. Настоятельно рекомендую вам как минимум изучить материалы на https://learn.unity.com/.

В процессе разработки своей игры мы столкнулись с проблемами, время от времени вызывавшими торможения игрового процесса. Потратив какое-то время в Unity Profiler, мы обнаружили два типа проблем:

  • Неоптимизированные шейдеры
  • Неоптимизированные скрипты на C#

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

Поиск слабых мест


Цель этой статьи — не написать туториал по использованию профилировщика; я хотел просто рассказать о том, что нас в основном интересовало в процессе профилирования.

Unity Profiler — всегда самый лучший способ поиска причин задержек в скриптах. Настоятельно рекомендую профилировать игру непосредственно в устройстве, а не в редакторе. Поскольку наша игра создавалась для iOS, мне нужно было подключить устройство и использовать показанные на изображении Build Settings, после чего профилировщик подключался автоматически.


Build Settings (параметры сборки) для профилирования

Если вы попробуете загуглить «Random lag in Unity» или другой похожий запрос, то обнаружите, что большинство людей рекомендует сосредоточиться на сборке мусора, поэтому именно так я и поступил. Мусор генерируется каждый раз, когда вы прекращаете использовать какой-то объект (экземпляр класса), после чего время от времени запускается сборщик мусора Unity для уборки беспорядка и освобождения памяти, что требует безумного количества времени и приводит к падению частоты кадров.

Как найти в профилировщике скрипты, вызывающие появление мусора?


Просто выберите CPU Usage -> Choose Hierarchy view -> Sort by GC Alloc


Параметры Profiler для сборки мусора

Ваша задача — добиться одних нулей в столбце GC alloc для геймплейной сцены.

Ещё один хороший способ — отсортировать записи по «Time ms» (времени исполнения) и оптимизировать скрипты, чтобы они занимали как можно меньше времени. Этот шаг имел для нас огромное влияние, потому что один из наших компонентов содержал большой цикл for, для выполнения которого требовалась целая вечность (да, мы пока не нашли способа избавиться от цикла), поэтому оптимизация времени выполнения всех скриптов была для нас абсолютно необходима, ведь нам требовалось сэкономить время выполнения на этом затратном цикле for, сохраняя при этом стабильную частоту в 60 fps.

На основании данных профилирования я разделил оптимизацию на две части:

  • Избавление от мусора
  • Снижение времени выполнения

Часть 1: боремся с мусором


В этой части я расскажу, что мы делали, чтобы избавиться от мусора. Это самые фундаментальные знания, которые должен понимать любой разработчик; они стали важной частью нашего ежедневного анализа в каждом pull/merge request.

Первое правило: никаких новых объектов в методах Update


В идеале методы Update, FixedUpdate и LateUpdate не должны содержать ключевых слов «new». Всегда нужно использовать то, что у вас уже есть.

Иногда создание нового объекта сокрыто в некоторых внутренних методах Unity, поэтому оно не так очевидно. Мы расскажем об этом позже.

Второе правило: создавать один раз и использовать многократно!


По сути, это означает, что выделять память для всего, что можно, следует в методах Start и Awake. Это правило очень похоже на первое. На самом деле это просто ещё один способ устранения ключевых слов «new» из методов Update.

Код, который:

  • создаёт новые экземпляры
  • ищет какие-нибудь игровые объекты

следует всегда стараться перемещать из методов Update в Start или Awake.

Вот примеры внесённых нами изменений:

Выделение памяти под списки в методе Start, их очистка (Clear) и повторное использование при необходимости.

//Bad code
private List<GameObject> objectsList;
void Update()
{
    objectsList = new List<GameObject>();
    objectsList.Add(......)
}
//Better Code
private List<GameObject> objectsList;
void Start()
{
    objectsList = new List<GameObject>();
}
void Update()
{
    objectsList.Clear();
    objectsList.Add(......)
}

Хранение ссылок и повторное использование их следующим образом:

//Bad code
void Update()
{
    var levelObstacles = FindObjectsOfType<Obstacle>();
    foreach(var obstacle in levelObstacles) { ....... }
}
//Better code
private Object[] levelObstacles;
void Start()
{
    levelObstacles = FindObjectsOfType<Obstacle>();
}
void Update()
{
    foreach(var obstacle in levelObstacles) { ....... }
}

То же относится к методу FindGameObjectsWithTag или любому другому методу, возвращающему новый массив.

Третье правило: остерегайтесь строк и избегайте их конкатенации


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

Вот пример того, как можно улучшить ситуацию:

//Bad code
void Start()
{
    text = GetComponent<Text>();
}
void Update()
{
    text.text = "Player " + name + " has score " + score.toString();
}
//Better code
void Start()
{
    text = GetComponent<Text>();
    builder = new StringBuilder(50);
}
void Update()
{
    //StringBuilder has overloaded Append method for all types
    builder.Length = 0;
    builder.Append("Player ");
    builder.Append(name);
    builder.Append(" has score ");
    builder.Append(score);
    text.text = builder.ToString();
}

С показанным выше примером всё в порядке, но в нём есть ещё много возможностей для улучшения кода. Как видите, почти всю строку можно считать статической. Мы разделяем строку на две части для двух объектов UI.Text. Сначала одна содержит только статический текст «Player » + name + " has score ", который можно назначить в методе Start, а вторая содержит значение счёта, которое обновляется в каждом кадре. Всегда делайте статические строки действительно статическими и генерируйте их в методе Start или Awake. После этого усовершенствования почти всё в порядке, но по-прежнему генерируется немного мусора при вызове Int.ToString(), Float.ToString() и т.п.

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

public static readonly string[] NUMBERS_THREE_DECIMAL = {
        "000", "001", "002", "003", "004", "005", "006",..........

Четвёртое правило: кешировать значения, возвращаемые методами доступа


Это может быть очень сложно, потому что даже простой метод доступа (accessor), наподобие показанного ниже, генерирует мусор:

//Bad Code
void Update()
{
    gameObject.tag;
    //or
    gameObject.name;
}

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

В общем случае я рекомендую НЕ вызывать никаких методов доступа к строкам или методов доступа к массивам в методе Update. В большинстве случаев достаточно один раз получить ссылку в методе Start.

Вот ещё два распространённых примера ещё одного неоптимизированного кода методов доступа:

//Bad Code
void Update()
{
    //Allocates new array containing all touches
    Input.touches[0];
}
//Better Code
void Update()
{
    Input.GetTouch(0);
}
//Bad Code
void Update()
{
    //Returns new string(garbage) and compare the two strings
    gameObject.Tag == "MyTag";
}
//Better Code
void Update()
{
    gameObject.CompareTag("MyTag");
}

Пятое правило: используйте функции, не выполняющие выделение памяти


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

Physics2D. CircleCast();

Для этого конкретного случая можно найти не выделяющую память функцию под названием

Physics2D. CircleCastNonAlloc();

Многие другие функции тоже имеют подобные альтернативы, поэтому всегда проверяйте в документации наличие NonAlloc-функций.

Шестое правило: не используйте LINQ


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

Седьмое правило: создавать один раз и использовать многократно, часть 2


На этот раз речь идёт о пулинге объектов. Я не буду вдаваться в подробности пулинга, потому что об этом говорилось много раз, например, изучите этот туториал: https://learn.unity.com/tutorial/object-pooling

В нашем случае используется следующий сценарий пулинга объектов. У нас есть сгенерированный уровень, заполненный препятствиями, существующими в течение определённого периода времени, пока игрок не пройдёт эту часть уровня. Экземпляры таких препятствий создаются из префабов в случае соблюдения определённых условий. Код находится в методе Update. Этот код совершенно неэффективен с точки зрения памяти и времени выполнения. Мы решили проблему, сгенерировав пул из 40 препятствий: при необходимости мы получаем препятствия из пула и возвращаем объект обратно в пул, когда он больше не нужен.

Восьмое правило: внимательнее с упаковкой-преобразованием (Boxing)!


Boxing генерирует мусор! Но что такое boxing? Чаще всего boxing возникает, когда вы передаёте тип значения (int, float, bool и т.д.) в функцию, которая ожидает параметр типа Object.

Вот пример boxing-а который нам нужно исправить в нашем проекте:

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

Dictionary<string, object> data;

Также у нас есть сеттер, задающий значения в этом словаре:

public Action SetAttribute(string attribute, object value)
{
    data[attribute] = value;
}

Boxing здесь довольно очевиден. Можно вызвать функцию следующим образом:

SetAttribute("my_int_value", 12);

Тогда значение «12» подвергается boxing-у и это генерирует мусор.

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

Dictionary<string, object> data;
Dictionary<string, bool> dataBool;
Dictionary<string, int> dataInt;
.......

Также у нас есть отдельные сеттеры для каждого типа данных:

SetBoolAttribute(string attribute, bool value)
SetIntAttribute(string attribute, int value)

И все эти сеттеры реализованы таким образом, что вызывают одинаковую обобщённую функцию:

SetAttribute<T>(ref Dictionary<string, T> dict, string attribute, T value)

Проблема boxing-а решена!

Подробнее об этом можно прочитать в статье https://docs.microsoft.com/cs-cz/dotnet/csharp/programming-guide/types/boxing-and-unboxing.

Девятое правило: циклы всегда под подозрением


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

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

Десятое правило: никакого мусора во внешних библиотеках


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

Часть 2: максимально снижаем время выполнения


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

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

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

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

Первое правило: правильный порядок выполнения


Переместите код из методов FixedUpdate, Update, LateUpdate в методы Start и Awake. Знаю, это звучит безумно, но поверьте, если вы углубитесь в свой код, то найдёте сотни строк кода, которые можно переместить в методы, исполняемые только один раз.

В нашем случае такой код обычно связан с

  • Вызовами GetComponent<>
  • Вычислениями, которые на самом деле возвращают в каждом кадре одинаковый результат
  • Многократным созданием экземпляров одних и тех же объектов, обычно списков
  • Поиском GameObjects
  • Получением ссылок на Transform и использованием других методов доступа

Вот список примеров кода, который мы переместили из методов Update в методы Start:

//There must be a good reason to keep GetComponent in Update
gameObject.GetComponent<LineRenderer>();
gameObject.GetComponent<CircleCollider2D>();

//Examples of calculations returning same result every frame
Mathf.FloorToInt(Screen.width / 2);
var width = 2f * mainCamera.orthographicSize * mainCamera.aspect;
var castRadius = circleCollider.radius * transform.lossyScale.x;
var halfSize = GetComponent<SpriteRenderer>().bounds.size.x / 2f;

//Finding objects
var levelObstacles = FindObjectsOfType<Obstacle>();
var levelCollectibles = FindGameObjectsWithTag("COLLECTIBLE");

//References
objectTransform = gameObject.transform;
mainCamera = Camera.main;

Второе правило: выполняйте код только тогда, когда это необходимо


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

//Bad code
Text text;
GameState gameState;

void Start()
{
    gameState = StoreProvider.Get<GameState>();    
    text = GetComponent<Text>();
}
      
void Update()
{
    text.text = gameState.CollectedCollectibles.ToString();
}

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

//Better code
Text text;
GameState gameState;
int collectiblesCount;

void Start()
{
    gameState = StoreProvider.Get<GameState>();    
    text = GetComponent<Text>();
    collectiblesCount = gameState.CollectedCollectibles;
}
      
void Update()
{
    if(collectiblesCount != gameState.CollectedCollectibles) {
        
        //This code is ran only about 5 times each level
        collectiblesCount = gameState.CollectedCollectibles;
        text.text = collectiblesCount.ToString();
    }
}

Этот код гораздо лучше, особенно если действия намного сложнее, чем простое изменение UI.

Если вы ищете более комплексное решение, то рекомендую реализовать шаблон «Наблюдатель» при помощи событий C# (https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/events/).

Как бы то ни было, этого всё равно было для нас недостаточно, и мы хотели реализовать совершенно обобщённое решение, поэтому создали библиотеку, реализующую Flux в Unity. Это привело к очень простому решению, при котором всё состояние игры хранится в объекте «Store», а все элементы UI и другие компоненты уведомляются при изменении состояния и реагируют на это изменение без кода в методе Update.

Третье правило: циклы всегда под подозрением


Это точно такое же правило, которое я упоминал в первой части статьи. Если в коде есть какой-то цикл, итеративно обходящий большое количество элементов, то для улучшения производительности цикла используйте оба правила из обеих частей статьи.

Четвёртое правило: For лучше, чем Foreach


Цикл Foreach очень легко написать, но «очень сложно» выполнять. Внутри цикла Foreach используются Enumerator для итеративной обработки набора данных и возврата значения. Это сложнее, чем итерация индексов в простом цикле For.

Поэтому в нашем проекте мы по возможности заменили циклы Foreach на For:

//Bad code
foreach (GameObject obstacle in obstacles)

//Better code
var count = obstacles.Count;
for (int i = 0; i < count; i++) {
    obstacles[i];
}

В нашем случае с большим циклом for это изменение очень значимо. Простой цикл for ускорил код в два раза.

Пятое правило: массивы лучше, чем списки


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

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

Шестое правило: операции с Float лучше, чем операции с векторами


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

Мы вносили подобные изменения:

Vector3 pos1 = new Vector3(1,2,3);
Vector3 pos2 = new Vector3(4,5,6);

//Bad code
var pos3 = pos1 + pos2;

//Better code
var pos3 = new Vector3(pos1.x + pos2.x, pos1.y + pos2.y, ......);
Vector3 pos1 = new Vector3(1,2,3);


//Bad code
var pos2 = pos1 * 2f;

//Better code
var pos2 = new Vector3(pos1.x * 2f, pos1.y * 2f, ......);

Седьмое правило: искать объекты правильно


Всегда думайте, действительно ли нужно использовать метод GameObject.Find(). Этот метод тяжёл и занимает безумное количество времени. Никогда не следует использовать такой метод в методах Update. Мы выяснили, что большинство наших вызовов Find можно заменить прямыми ссылками в редакторе, что, разумеется, гораздо лучше.

//Bad Code
GameObject player;

void Start()
{
    player = GameObject.Find("PLAYER");
}

//Better Code

//Assign the reference to the player object in editor
[SerializeField]
GameObject player;
void Start()
{
}

В случае, если так сделать невозможно, то хотя бы рассмотрите возможность использования меток (Tag) и поиска объекта по его метке при помощи GameObject.FindWithTag.

Итак, в общем случае: Прямая ссылка > GameObject.FindWithTag() > GameObject.Find()

Восьмое правило: работайте только с относящимися к делу объектами


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

Вот пример

//Bad Code
void DetectCollision()
{
    var count = Physics2D.CircleCastNonAlloc(
       position, radius, direction, results, distance);
    for (int i = 0; i < count; i++) {
       var obj = results[i].collider.transform.gameObject;
       if(obj.CompareTag("FOO")) {
           ProcessCollision(results[i]);
       }
    }
}
                              
//Better Code
//We added all objects with tag FOO into the same layer
void DetectCollision()
{
    //8 is number of the desired layer
    var mask = 1 << 8;
    var count = Physics2D.CircleCastNonAlloc(
       position, radius, direction, results, distance, mask);
    for (int i = 0; i < count; i++) {
       ProcessCollision(results[i]);
    }
}

Девятое правило: правильно используйте метки


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

//Bad Code
gameObject.Tag == "MyTag";

//Better Code
gameObject.CompareTag("MyTag");

Десятое правило: опасайтесь хитростей с камерой!


Так легко использовать Camera.main, но производительность этого действия очень плоха. Причина заключается в том, что за кулисами каждого вызова Camera.main движок Unity на самом деле выполняет для получения результата FindGameObjectsWithTag(), поэтому мы уже понимаем, что часто его вызывать не нужно, и лучше всего решить эту проблему, кешировав ссылку в методе Start или Awake.

//Bad code
void Update()
{
    Camera.main.orthographicSize //Some operation with camera
}

//Better Code
private Camera cam;
void Start()
{
    cam = Camera.main;
}
void Update()
{
    cam.orthographicSize //Some operation with camera
}

Одиннадцатое правило: LocalPosition лучше, чем Position


Везде, где это возможно, используйте для геттеров и сеттеров Transform.LocalPosition вместо Transform.Position. Внутри каждого вызова Transform.Position выполняется гораздо больше операций, например, вычисление глобальной позиции в случае вызова геттера или вычисление локальной позиции из глобальной в случае вызова сеттера. В нашем проекте выяснилось, что можно использовать LocalPositions в 99% случаев использования Transform.Position, и в коде при этом не нужно делать никаких других изменений.

Двенадцатое правило: не использовать LINQ


Об этом уже говорили в первой части. Просто не используйте его, вот и всё.

Тринадцатое правило: не бойтесь (иногда) нарушать правила


Иногда даже вызов простой функции может быть слишком затратным. В этом случае всегда следует рассмотреть возможность встраивания кода (Code Inlining). Что это значит? По сути, мы просто берём код из функции и копируем его непосредственно в место, где хотим использовать функцию, чтобы избежать вызовов дополнительных методов.

В большинстве случаев это не окажет никакого влияния, потому что встраивание кода выполняется автоматически на этапе компиляции, но существуют определённые правила, по которым компилятор решает, должен ли встраиваться код (например, виртуальные методы никогда не встраиваются; подробнее об этом см. в https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity8.html). Поэтому просто откройте профилировщик, запустите игру на целевом устройстве и посмотрите, можно ли что-то улучшить.

В нашем случае нашлось несколько функций, которые мы решили встроить для улучшения производительности, особенно в большом цикле for.

Вывод


Применив перечисленные в статье правила, мы легко добились стабильных 60 fps в игре для iOS даже на iPhone 5S. Возможно, некоторые из правил могут быть специфичными только для нашего проекта, но я считаю, что большинство из них стоит помнить при написании кода или его проверке, чтобы избежать проблем в дальнейшем. Всегда лучше постоянно писать код с учётом производительности, чем позже рефакторить большие фрагменты кода.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+14
Комментарии3

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн