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

Кэширование

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

private void OnTriggerEnter()
{
    var allColliders = FindObjectsOfType<BoxCollider>();
    DoAThing(allColliders);
}

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

private BoxCollider[] _allColliders;

private void Start()
{
    _allColliders = FindObjectsOfType<BoxCollider>();
}


private void OnTriggerEnter(Collider other)
{
    DoAThing(_allColliders);
}

LINQ

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

В целом, писать LINQ-эквивалентный код обычно удается быстрее, и при этом остается меньше мусора. Когда требуется учитывать производительность, совершенно незачем отказываться от обычного цикла for, который перебирал бы код, выполняя методы, эквивалентные методам LINQ, в том числе (но не только): Where(), Select(), Sum(), Count(), т.д. Джексон Данстан написал на данную тему эту и эту статью, где демонстрирует, насколько серьезно будет отличаться производительность, если создавать ту же логику вручную.

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

Распространенные API Unity

В Unity есть несколько API, которые, пусть и полезны, но могут серьезно бить по производительности. В частности:

  • GameObject.SendMessage

  • GameObject.BroadcastMessage

  • GameObject.Find / Object.Find

  • GameObject.FindWithTag / Object.FindWithTag

  • GameObject.FindObjectOfType / Object.FindObjectOfType

  • GameObject.FindgameObjectsWithTag

Большинство вышеприведенных операций требуют, чтобы вы обошли весь граф сцены в поисках подходящих объектов для некоторого списка GameObjects.

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

Хотя, методы Find могут показывать плохую производительность, и у них есть своя ниша. Да, они медленные, но, если пользоваться ими время от времени в надежном коде, не требующем особой производительности – это не ужас-ужас. Однако, даже не думайте ставить их в том месте, где их может заметить пользователь.

GetComponentsInChildren

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

Однако в большинстве случаев эта функция используется для кэширования во время инициализации. В данном случае гораздо лучше сериализовать массив объектов в редакторе.

Инстанцирование

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

Ограничивайте выделения в часто вызываемых функциях

Не ошибусь, если скажу, что функции Update и LateUpdate – как раз из тех, что вызываются часто. Если в этих функциях мы выделяем память кучи, то, вероятно, нам вскоре не поздоровится. Мусор накопится очень быстро, в результате в дело неизбежно вмешается сборщик мусора, из-за которого игра начнет заметно буксовать.

private void Update()
{
    VeryExpensiveGarbageGeneratingFunction(transform.position);
}

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

Прямо сейчас она вызывается в каждом кадре. Добавив простую проверку, такую:

private Vector3 _prevPos;

private void Update()
{
    if (transform.position != _prevPos)
    {
        VeryExpensiveFunction(transform.Position);
        _prevPos = transform.postion;
    }
}

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

Повторное использование коллекций

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

private readonly List<Enemies> _enemiesToCheck = new List<Enemies>();

private void Update()
{
    _enemiesToCheck.Clear();
    EnemyLogic(_enemiesToCheck);
}

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

Кэширование с использованием корутин

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

private WaitForSeconds _delay = new WaitForSeconds(5f);
private IEnumerator WaitCo()
{
    while (!animationFinished)
    {
        yield return delay;
    }
}

Сравнение потоков и корутин

Вопреки распространенному мнению, внутри Unity могут использоваться потоки. Конечно, с некоторыми оговорками.

Во-первых, корутины и потоки – это совершенно разные вещи.

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

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

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

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

Само по себе создание потоков может дорого обходиться, поэтому рекомендуется использовать ThreadPool.QueueUserWorkItem.

Кроме того, API Unity не является потокобезопасным. Это значит: все, что предусмотрено делать в Unity API, все равно должно вызываться в главном потоке.

Подробнее можно почитать здесь.

Resources.Load

Resources.Load медленный. На некоторых платформах это самый медленный из возможных способов подхватить ресурс внутри игры. Есть разнообразные способы обойтись без Resources.Load, но, как правило, следует стремиться к использованию сериализованной ссылки на тот элемент, который нужен вам в проекте. Опять же, для этого можно найти время и место, например, спрятать за экраном загрузки, но все равно следует избегать использования Resources.Load в тех точках кода, где критична высокая производительность.

Циклы

Хотя, циклы foreach уже не так плохи, как когда-то, на самых жарких участках кода все равно стоит предпочитать стандартные циклы for циклам foreach. В Unity есть IL2CPP, что может дополнительно усугубить проблему.

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

int count = _array.Length;
for (int i = 0; i < count; ++i)
{
    // ЗДЕСЬ ЧТО-ТО ДЕЛАЕМ
}

Дело в основном в том коде, который генерируется на C++. Подробнее об этом можно почитать здесь. Практика подсказывает: старайтесь применять циклы с такими коллекциями, которые приспособлены для циклической обработки, например, с массивами и списками – и придерживайтесь этого, но избегайте коллекций, которые лучше подходят для прямого поиска (таковы, например, словари и наборы хэшей).

Свойства

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

Например, нет никаких причин делать:

public int TheNumber { set; get; }

а не:

public int TheNumber;

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

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

Методы доступа в Unity и специальные функции.

Некоторые методы доступа в Unity, которые кажутся совсем невинными, на самом деле могут генерировать мусор. Таков, например, GameObject.tag. Мне нравятся теги. Они могут быть по-настоящему хороши, чтобы проверить, является ли игровой объект вещью. Но бывает, что этой возможностью злоупотребляют.

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

GameObject.CompareTag()

Это специальная функция Unity, не оставляющая мусора, и она такая не одна.

Есть и еще некоторые такие функции, в том числе, Physics.SphereCastNonAlloc(). Этот метод не выделяет массив с результатами запроса, а сохраняет результаты в массиве, предоставляемом пользователем. Он рассчитает лишь столько попаданий, сколько умещается в буфере, и сохранит их в произвольном порядке. Не гарантировано, что он сохранит только ближайшие попадания. Но, что самое важное, этот метод не генерирует никакого мусора.

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

Структуры, в которых содержатся ссылки

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

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

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

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

Передача структур

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

На горячих участках старайтесь передавать маленькие структуры.

Упаковка

Упаковка в C# может бить по производительности, даже, если вы работаете с приложением на чистом C#, а не на Unity. Для тех, кто еще не знает, расскажу: упаковка происходит, когда значимый тип используется на месте ссылочного типа, обычно это случается с функциями, у которых объектные параметры.

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

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

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

Пустые обновления, Awake, OnEnable и другие функции

Очень легко оставить в скриптах функции Update и Awake, даже если у вас в них ничего нет – поскольку, например, при создании MonoBehaviour они генерируются автоматически. Если вам кажется, что их нормально оставлять в скриптах – нет, это не так.

Unity при работе постоянно переключается между неуправляемым кодом движка Unity и управляемым кодом игры. Это происходит при вызове как раз таких функций как Update. Такое переключение контекста может дорого обходиться, даже если не приходится вызывать никакого конкретного кода. Если здесь нечего выполнять, то это пустая трата времени. Поначалу влияние таких операций может быть и невелико, но, если у вас сотни игровых объектов, и в каждом есть скрипт, содержащий пустые циклы Update, то вы, вероятно, увидите, как плохо это скажется на производительности.

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

Комментарии (14)


  1. WhiteBlackGoose
    02.12.2021 11:39
    +1

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

    Вообще аналогичных либ - легион, в readme можно увидеть их сравнения, если хочется повыбирать.


    1. arTk_ev
      03.12.2021 09:10

      Непонятно почему linq на c# такой убогий. На f# он прекрасен, мощный и без всяких аллокаций, хотя платформа одна и та же.


  1. KAW
    02.12.2021 13:24
    +1

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

    Эммм, а ничего что массив является ссылочным типом?

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

    Эти статьи от 2015 и 2018 года со "старым" фреймворком и "старой" LINQ. Надо бы провести повторные замеры.

    Классический пример этого - String.Format

    Жалко что автор не потрудился сказать, что делать, когда нужно отобразить "Убито 42 монстра" в UI. А решение тут простое: 3 контрола TextMeshPro + кеширование строки по ключу int.


    1. holydel
      02.12.2021 15:27
      +4

      3 контроллера вместо одного, чтобы не вызывать String.Format? Выглядит как пессимизация.


    1. freeExec
      05.12.2021 11:43

      А чтобы это путёво выглядело придется еще LayoutGroup на них повесить, а это еще те тормоза, string.format отдыхает


      1. KAW
        06.12.2021 15:34

        Это да, потому и стоит смотреть и профилировать


    1. luvjungle
      06.12.2021 18:34

      Перебор. Есть zString или StringBuilder на худой конец


  1. rPman
    02.12.2021 21:02

    Передача структур — определяй параметры структуры по ссылке ref

    Есть возможность использовать llvm компилятор? говорят он может дать под 10-20% бонуса к производительности, а если повезет то и все +100% (если я верно понимаю, если не используются try catch, так как иначе будет хуже)


  1. Kelevra_S
    02.12.2021 22:12
    +1

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

    но разве массив символов все так же не является ссылочным типом, как и строка?

    Arrays are mechanisms that allow you to treat several items as a single collection. The Microsoft® .NET Common Language Runtime (CLR) supports single-dimensional arrays, multidimensional arrays, and jagged arrays (arrays of arrays). All array types are implicitly derived from System.Array, which itself is derived from System.Object. This means that all arrays are always reference types which are allocated on the managed heap, and your app's variable contains a reference to the array and not the array itself.

    https://docs.microsoft.com/en-us/archive/msdn-magazine/2002/february/net-array-types-in-net


  1. TrueRomanus
    03.12.2021 02:14
    -1

    Меня всегда интересовало зачем взяли такой тяжеловесный язык как C# в качестве скриптового языка для игрового движка, когда можно было взять что-то более простое вроде Lua или Python. С одной стороны понятно что можно использовать библиотеки из мира C# но вот читая такие статьи у меня складывается впечатление что разработчики библиотек могут и не знать о таких нюансах и вовсю использовать linq, foreach и прочие влияющие на производительность вещи. А с другой стороны если для него надо писать "свой" Linq и бороться за каждую сборку мусора то стоит ли игра свеч?


    1. scar289
      03.12.2021 23:13
      +1

      >Меня всегда интересовало зачем взяли такой тяжеловесный язык как C#

      Затем, кто сообщество C# в десятки раз крупнее чем у какого-нибудь lua. Первые версии unity поддерживали java script, c#, lua и собственный скриптовый язык и поняли что люди используют только C#, а остальное выпилили.


    1. SnakeMonster
      03.12.2021 23:17
      +1

      Думаешь питон как скриптовый язык для игр это хорошая идея?))


    1. freeExec
      05.12.2021 11:46

      Будто пользователи питона вкурсе как работают внутри другие пакеты. Там все намного хуже и Linq на их фоне хорош.


  1. scar289
    03.12.2021 09:58
    +3

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

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

    Хотя, циклы foreach уже не так плохи, как когда-то, на самых жарких участках кода все равно стоит предпочитать стандартные циклы for циклам foreach. В Unity есть IL2CPP, что может дополнительно усугубить проблему.

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

    Свойства

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

    Например, нет никаких причин делать:

    public int TheNumber { set; get; }

    а не:

    public int TheNumber;

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

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