Version: 2019.4
Оптимизации рендеринга
Experimental

Оптимизация скриптов

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

Profiler is King

There is no such thing as a list of boxes to check that will ensure your project runs smoothly. To optimize a slow project, you have to profile to find specific offenders that take up a disproportionate amount of time. Trying to optimize without profiling or without thoroughly understanding the results that the profiler gives is like trying to optimize with a blindfold on.

Internal mobile profiler

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

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

Оптимизирован по дизайну

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

Пулинг объектов

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

Выделение памяти

Простое объяснение понятия “автоматическое управление памятью”

Скрипты, которые вы пишете в Unity используют автоматическое управление памятью. А низкоуровневые языки, такие как C и C++, наоборот, используют ручное управление памятью - программист может напрямую считывать и записывать данные по указанным адресам памяти и он ответственен за удаление любого создаваемого им объекта. Например, если вы создаёте объекты в вашем C++, то после того как вы закончили с ними работу, вы обязаны вручную освободить выделенную для них память. В скриптовом же языке, достаточно написать objectReference = null;.

Важно: Почему может не уничтожаться переменная типа Game Object, например, GameObject myGameObject; или var myGameObject : GameObject;, когда я пишу myGameObject = null;?

  • Unity всё ещё ссылается на объект, т.к. Unity должна сохранять на него ссылку для отрисовки, обновления и т.д. Вызов Destroy(myGameObject); удаляет эту ссылку и сам объект.

Но если вы создадите объект, о котором Unity ничего не знает, например, экземпляр класса, который ни от чего не наследуется (большинство классов или “скриптовых компонентов” наследуются от MonoBehaviour) и затем установите вашей переменной со ссылкой значение null, то на самом деле объект будет потерян для скрипта и Unity, т.к. они не смогут получить к нему доступ и никогда снова его не увидят, но при этом он останется в памяти. Затем, через какое-то время отработает сборщик мусора и при этом удалит из памяти всё, на что нет ссылок. Он может это сделать, т.к. в недрах сборщика ведётся учёт количества ссылок на каждый блок памяти. Это одна из причин, по которым скриптовые языки медленней C++.

How to Avoid Allocating Memory

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

  • Debug.Log("boo" + "hoo"); создаёт объект.

    • Use System.String.Empty instead of "" when dealing with lots of strings.
  • OnGUI (UnityGUI) работает медленно и его не следует использовать в случаях, когда важна производительность.

  • Различия между классом и структурой:

Classes are objects and behave as references. If Foo is a class and

  Foo foo = new Foo();
  MyFunction(foo); 

then MyFunction will receive a reference to the original Foo object that was allocated on the heap. Any changes to foo inside MyFunction will be visible anywhere foo is referenced.

Classes are data and behave as such. If Foo is a struct and

  Foo foo = new Foo();
  MyFunction(foo); 

then MyFunction will receive a copy of foo. foo is never allocated on the heap and never garbage collected. If MyFunction modifies it’s copy of foo, the other foo is unaffected.

  • Объекты, предназначенные для длительного использования должны быть классами, а объекты для непродолжительного использования - структурами. Vector3 - вероятно самая известная структура. Если бы он был классом, всё работало бы значительно медленней.

Почему пулинг объектов быстрее

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

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

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

Почему пулинг объектов может быть медленнее

One issue is that the creation of a pool reduces the amount of heap memory available for other purposes; so if you keep allocating memory on top of the pools you just created, you might trigger garbage collection even more often. Not only that, every collection will be slower, because the time taken for a collection increases with the number of live objects. With these issues in mind, it should be apparent that performance will suffer if you allocate pools that are too large or keep them active when the objects they contain will not be needed for some time. Furthermore, many types of objects don’t lend themselves well to object pooling. For example, the game may include spell effects that persist for a considerable time or enemies that appear in large numbers but which are only killed gradually as the game progresses. In such cases, the performance overhead of an object pool greatly outweighs the benefits and so it should not be used.

Implementation

Here’s a simple side by side comparison of a script for a simple projectile, one using Instantiation, and one using Object Pooling.

 // GunWithInstantiate.js                                                  // GunWithObjectPooling.js

 #pragma strict                                                            #pragma strict

 var prefab : ProjectileWithInstantiate;                                   var prefab : ProjectileWithObjectPooling;
                                                                           var maximumInstanceCount = 10;
 var power = 10.0;                                                         var power = 10.0;

                                                                           private var instances : ProjectileWithObjectPooling[];

                                                                           static var stackPosition = Vector3(-9999, -9999, -9999);

                                                                           function Start () {
                                                                               instances = new ProjectileWithObjectPooling[maximumInstanceCount];
                                                                               for(var i = 0; i < maximumInstanceCount; i++) {
                                                                                   // place the pile of unused objects somewhere far off the map
                                                                                   instances[i] = Instantiate(prefab, stackPosition, Quaternion.identity);
                                                                                   // disable by default, these objects are not active yet.
                                                                                   instances[i].enabled = false;
                                                                               }
                                                                           }

 function Update () {                                                      function Update () {
     if(Input.GetButtonDown("Fire1")) {                                        if(Input.GetButtonDown("Fire1")) {
         var instance : ProjectileWithInstantiate =                                var instance : ProjectileWithObjectPooling = GetNextAvailiableInstance();
             Instantiate(prefab, transform.position, transform.rotation);          if(instance != null) {
         instance.velocity = transform.forward * power;                                instance.Initialize(transform, power);
     }                                                                             }
 }                                                                             }
                                                                           }

                                                                           function GetNextAvailiableInstance () : ProjectileWithObjectPooling {
                                                                               for(var i = 0; i < maximumInstanceCount; i++) {
                                                                                   if(!instances[i].enabled) return instances[i];
                                                                               }
                                                                               return null;
                                                                           }




 // ProjectileWithInstantiate.js                                           // ProjectileWithObjectPooling.js

 #pragma strict                                                            #pragma strict

 var gravity = 10.0;                                                       var gravity = 10.0;
 var drag = 0.01;                                                          var drag = 0.01;
 var lifetime = 10.0;                                                      var lifetime = 10.0;

 var velocity : Vector3;                                                   var velocity : Vector3;

 private var timer = 0.0;                                                  private var timer = 0.0;

                                                                           function Initialize(parent : Transform, speed : float) {
                                                                               transform.position = parent.position;
                                                                               transform.rotation = parent.rotation;
                                                                               velocity = parent.forward * speed;
                                                                               timer = 0;
                                                                               enabled = true;
                                                                           }

 function Update () {                                                      function Update () {
     velocity -= velocity * drag * Time.deltaTime;                             velocity -= velocity * drag * Time.deltaTime;
     velocity -= Vector3.up * gravity * Time.deltaTime;                        velocity -= Vector3.up * gravity * Time.deltaTime;
     transform.position += velocity * Time.deltaTime;                          transform.position += velocity * Time.deltaTime;

     timer += Time.deltaTime;                                                  timer += Time.deltaTime;
     if(timer > lifetime) {                                                    if(timer > lifetime) {
                                                                                   transform.position = GunWithObjectPooling.stackPosition;
         Destroy(gameObject);                                                      enabled = false;
     }                                                                         }
 }                                                                         }


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

Другой пример: вечеринка монеток!

Для демонстрации того, как можно создать впечатляющий эффект с помощью скриптинга, компонентов Unity, таких как Particle System, и пользовательских шейдеров, без поблажек для слабого железа мобильных устройств, будет использован пример из раздела о методах скриптинга “Сотни вращающихся собираемых монет с динамическим освещением на экране в один момент времени”.

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

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

But mobile hardware would choke on that many objects, and a glow effect is totally out of the question. So what do we do?

Система частиц из анимированных спрайтов

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

  • Collectables or Coins
  • Летающий мусор
  • Hordes or Flocks of Simple Enemies
  • Ликующие толпы
  • Сотни пуль или взрывов

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

Пример реализации

В Sprite Packer включён проект-пример, демонстрирующий решение именно для этой проблемы.

Он использует семейство ассетов различных типов для достижения ослепительного эффекта на низкопроизводительном железе:

  • A control script
  • Specialized textures created from the output of the SpritePacker
  • A specialized shader which is intimately connected with both the control script and the texture.

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

Проблема была определена как “Сотни вращающихся собираемых монет с динамическим освещением на экране в один момент времени”.

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

  • Viewing angles are a problem because particles don’t have them.
    • We assume that the camera stays right-side up and the coins rotate around the Y-axis.
    • We create the illusion of coin rotation with an animated texture that we packed using the SpritePacker.
      • This introduces a new problem: Monotony of rotating coins all rotating at the same speed and in the same direction
      • We keep track of rotation and lifetime ourselves and “render” rotation to the particle lifetimes in script to fix this.
  • Normals are a problem because particles don’t have them, and we need real time lighting.
    • Generate a single normal vector for the face of the coin in each animation frame generated by the Sprite Packer.
    • Do Blinn-Phong lighting for each particle in script, based on the normal vector grabbed from the above list.
    • Apply the result to the particle as a color.
    • Handle the face of the coin and the rim of the coin separately in the shader. Introduces a new problem: How does the shader know where the rim is, and what part of the rim it’s on?
      • Can’t use UV’s, they are already used for the animation.
      • Use a texture map.
        • Need Y-position relative to coin.
        • Need binary “on face” vs “on rim”.
      • We don’t want to introduce another texture, more texture reads, more texture memory.
      • Combine needed information into one channel and replace one of the texture’s color channels with it.
        • Now our coin is the wrong color! What do we do?
        • Use the shader to reconstruct missing channel as a combination of the two remaining channels.
  • Say we want glow from light glinting off our coins. Post process is too expensive for mobile devices.
    • Create another particle system and give it a softened, glowy version of the coin animation.
    • Color a glow only when the corresponding coin’s color is super bright.
    • Can’t have glow rendered on every coin every frame - fill rate killer.
      • Reset glows every frame, only position ones with brightness > 0.
  • Physics is a problem, collecting coins is a problem - particles don’t collide very well.
    • Could use built-in particle collision?
    • Instead, just wrote collision into the script.
  • Finally, we have one more problem - this script does a lot, and its getting slow!
    • Performance scales linearly with number of active coins.
      • Limit maximum coins. This works well enough to acheive our goal: 100 coins, 2 lights, runs really fast on mobile devices.
  • Things to try to optimize further:
    • Instead of calculating lighting for every coin individually, cut the world into chunks and calculate lighting conditions for every rotation frame in every chunk.
      • Use as a lookup table with coin position and coin rotation as indices.
      • Increase fidelity by using bilinear interpolation with position.
      • Sparse updates on the lookup table, or, entirely static lookup table.
      • Use Light Probes for this? *Instead of calculating lighting in script, use normal-mapped particles?
      • Use “Display Normals” shader to bake frame animation of normals.
      • Limits number of lights.
      • Fixes slow script problem.

The end goal of this example or “moral of the story” is that if there is something which your game really needs, and it causes lag when you try to achieve it through conventional means, that doesn’t mean that it is impossible, it just means that you have to put in some work on a system of your own that runs much faster.

Techniques for Managing Thousands of Objects

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

Избегайте или минимизируйте O(n2) операции на больших наборах данных

В компьютерной науке, порядок операции, обозначаемый O(n), относится к способу, при котором при увеличении количества объектов, к которым применяется операция (n), соответственно увеличивается количество вызовов этой операции.

Например, представьте простой алгоритм сортировки. У меня есть n чисел и я хочу отсортировать их от меньшего к большему.

 void sort(int[] arr) {
    int i, j, newValue;
    for (i = 1; i < arr.Length; i++) {
        // record
        newValue = arr[i];
        //shift everything that is larger to the right
        j = i;
        while (j > 0 && arr[j - 1] > newValue) {
            arr[j] = arr[j - 1];
            j--;
        }
        // place recorded value to the left of large values
        arr[j] = newValue;
    }
 }

The important part is that there are two loops here, one inside the other.

 for (i = 1; i < arr.Length; i++) {
    ...
    j = i;
    while (j > 0 && arr[j - 1] > newValue) {
        ...
        j--;
    }
 }

Предположим, что алгоритм будет работать с самым худшим случаем: входящие числа отсортированы, но в обратном порядке. В таком случае, вложенный цикл выполнится j раз. В среднем, когда i меняется от 1 до arr.Length–1, j будет arr.Length/2. С точки зрения O(n), arr.Length - это наше n, так что, в итоге вложенный цикл выполнится n*n/2 раз, или n2/2 раз. Но в рамках O(n) мы выбрасываем все постоянные вроде 1/2, т.к. мы желаем говорить о том, как увеличивается количество операций, а не о самом количестве операций. Так что алгоритм будет таким: O(n2). Порядок операций имеет большое значение при работе с большим набором данных, т.к. количество операций может стремительно расти по экспоненциальной кривой.

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

Кэшируйте ссылки вместо осуществления повторного поиска

Например, в вашей игре есть 100 врагов, и все они двигаются в сторону игрока.

 // EnemyAI.js
 var speed = 5.0;
 
 function Update () {
    transform.LookAt(GameObject.FindWithTag("Player").transform);
    // this would be even worse:
    //transform.LookAt(FindObjectOfType(Player).transform);
 
    transform.position += transform.forward * speed * Time.deltaTime;
 }

Это может работать медленно, если среди них достаточно много бегущих одновременно. Небольшой известный факт: все поля для доступа к компонентам в MonoBehaviour, такие как transform, renderer, и audio, эквивалентны соответствующим вызовам GetComponent(Transform), и потому они работают немного медленно. Метод GameObject.FindWithTag был оптимизирован, но в некоторых случаях, например, во вложенных циклах, или в скриптах, которые запущены на большом количестве экземпляров, оно тоже может работать немного медленно.

Вот улучшенная версия скрипта.

 // EnemyAI.js
 var speed = 5.0;
 
 private var myTransform : Transform;
 private var playerTransform : Transform;
 
 function Start () {
    myTransform = transform;
    playerTransform = GameObject.FindWithTag("Player").transform;
 }
 
 function Update () {
    myTransform.LookAt(playerTransform);
 
    myTransform.position += myTransform.forward * speed * Time.deltaTime;
 }

Избегайте использования ресурсоёмких математических функций

Трансцендентные функции (Mathf.Sin, Mathf.Pow, и т.д.), деление, и квадратный корень - все примерно занимают столько же времени, сколько занимает сотня операций произведения. В общей картине это может не иметь значения, но если вы вызываете их тысячи раз за кадр, то это уже может быть заметно.

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

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

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

Ещё совет. Если вы делите снова и снова на одну и ту же константу c, вы вместо этого можете умножать на обратное число. Только сперва рассчитайте обратное число с помощью выражения 1.0/c.

Сложные операции, напр., Physics.Raycast(), выполняйте лишь изредка

Если вам приходится выполнять что-то ресурсоёмкое, вы могли бы оптимизировать это с помощью более редких вызовов и кэширования результата. Например, вот скрипт для пули, который использует Raycast:

 // Bullet.js
 var speed = 5.0;
 
 function FixedUpdate () {
    var distanceThisFrame = speed * Time.fixedDeltaTime;
    var hit : RaycastHit;
 
    // every frame, we cast a ray forward from where we are to where we will be next frame
    if(Physics.Raycast(transform.position, transform.forward, hit, distanceThisFrame)) {
        // Do hit
    } else {
        transform.position += transform.forward * distanceThisFrame;
    }
 }

Right away, we could improve the script by replacing FixedUpdate with Update and fixedDeltaTime with deltaTime. FixedUpdate refers to the Physics update, which happens more often than the frame update. But let’s go even further by only raycasting every n seconds. A smaller n gives greater temporal resolution, and a bigger n gives better performance. The bigger and slower your targets are, the bigger n can be before temporal aliasing occurs. (Appearance of latency, where the player hit the target, but the explosion appears where the target used to be n seconds ago, or the player hit the target, but the projectile goes right through).

 // BulletOptimized.js
 var speed = 5.0;
 var interval = 0.4; // this is 'n', in seconds.
 
 private var begin : Vector3;
 private var timer = 0.0;
 private var hasHit = false;
 private var timeTillImpact = 0.0;
 private var hit : RaycastHit;
 
 // set up initial interval
 function Start () {
    begin = transform.position;
    timer = interval+1;
 }
 
 function Update () {
    // don't allow an interval smaller than the frame.
    var usedInterval = interval;
    if(Time.deltaTime > usedInterval) usedInterval = Time.deltaTime;
 
    // every interval, we cast a ray forward from where we were at the start of this interval
    // to where we will be at the start of the next interval
    if(!hasHit && timer >= usedInterval) {
        timer = 0;
        var distanceThisInterval = speed * usedInterval;
 
        if(Physics.Raycast(begin, transform.forward, hit, distanceThisInterval)) {
            hasHit = true;
            if(speed != 0) timeTillImpact = hit.distance / speed;
        }
 
        begin += transform.forward * distanceThisInterval;
    }
 
    timer += Time.deltaTime;
 
    // after the Raycast hit something, wait until the bullet has traveled
    // about as far as the ray traveled to do the actual hit
    if(hasHit && timer > timeTillImpact) {
        // Do hit
    } else {
        transform.position += transform.forward * speed * Time.deltaTime;
    }
 }

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

Просто вызов метода уже сам по себе несёт в себе небольшую нагрузку. Если вы вызываете такие вещи, как x = Mathf.Abs(x) тысячи раз за кадр, то лучше просто выполнять x = (x > 0 ? x : -x);.

Оптимизация производительности физики

The NVIDIA PhysX physics engine used by Unity is available on mobiles, but the performance limits of the hardware will be reached more easily on mobile platforms than desktops.

Here are some tips for tuning physics to get better performance on mobiles:-

  • You can adjust the Fixed Timestep setting (in the Time window) to reduce the time spent on physics updates. Increasing the timestep will reduce the CPU overhead at the expense of the accuracy of the physics. Often, lower accuracy is an acceptable tradeoff for increased speed.
  • Set the Maximum Allowed Timestep in the Time window in the 8–10fps range to cap the time spent on physics in the worst case scenario.
  • Меш коллайдеры требуют значительно больше ресурсов, чем примитивные коллайдеры, так что старайтесь избегать их использования. Зачастую можно усреднить форму меша используя дочерние объекты с примитивными коллайдерами. Дочерние коллайдеры будут использоваться в виде цельного слитного коллайдера твёрдым телом (компонентом rigidbody) родителя.
  • While wheel colliders are not strictly colliders in the sense of solid objects, they nonetheless have a high CPU overhead.
Оптимизации рендеринга
Experimental