В этом разделе рассказано как вам следует оптимизировать существующие скрипты и методы, используемые в вашей игре, а также о том, почему оптимизации работают и почему их применение в некоторых ситуациях для вас выгодно.
Не существует такой штуки, как список задач, по которому надо пройтись, чтобы ваш проект стал работать плавнее. Для оптимизации медленного проекта вам придётся профилировать определённые участки, занимающие слишком много времени при выполнении. Попытки оптимизировать без профилирования или без чёткого понимания результатов, выдаваемых профайлером, это как пытаться оптимизировать с завязанными глазами.
Вы можете использовать встроенный профайлер для выяснения процесса, тормозящего вашу игру, будь то физика, скрипты, или отрисовка, но вы не сможете взглянуть на определённые скрипты и методы для поиска точного источника проблемы. Однако, добавив в вашу игру переключатели для включения или отключения того или иного функционала, вы сможете отследить самые проблемные участки. Например, если вы удалите скрипт AI для персонажей противника и при этом частота кадров возрастёт вдвое, вы поймёте, что следует оптимизировать этот скрипт, или что-то из того, что он привносит в игру. Основная проблема в том, что вам может потребоваться много попыток, прежде чем вы сможете обнаружить проблему.
Для дополнительной информации по профилированию на мобильных устройствах прочтите раздел о профайлинге.
Попытка разработать что-то изначально так, чтобы оно работало быстро довольно рискованная, т.к. существует баланс между тратой времени для создания вещей, которые были бы так же быстры, если бы они не были оптимизированы и созданием вещей, которые придётся вырезать или заменить позже, т.к. они будут слишком медленны. Требуется хорошая интуиция и знания аппаратной части для принятия верных решений при решении этой задачи, особенно из-за того, что каждая игра отличается от других и то, что могло быть решающим в оптимизации для одной игры может оказаться провалом для другой.
Во введении в методы оптимизированного скриптинга в качестве примера пересечения игрового процесса и хорошего дизайна кода мы привели пулинг объектов. Использование пулинга объектов для недолговечных объектов быстрее, чем их создание и уничтожение, т.к. пулинг упрощает процесс выделения памяти, исключает динамическое выделение памяти и сборку мусора (Garbage Collection или GC).
Скрипты, которые вы пишете в Unity используют автоматическое управление памятью. А низкоуровневые языки, такие как C и C++, наоборот, используют ручное управление памятью - программист может напрямую считывать и записывать данные по указанным адресам памяти и он ответственен за удаление любого создаваемого им объекта. Например, если вы создаёте объекты в вашем C++, то после того как вы закончили с ними работу, вы обязаны вручную освободить выделенную для них память. В скриптовом же языке, достаточно написать objectReference = null;
.
Важно: Почему может не уничтожаться переменная типа Game Object, например, GameObject myGameObject;
или var myGameObject : GameObject;
, когда я пишу myGameObject = null;
?
Destroy(myGameObject);
удаляет эту ссылку и сам объект.Но если вы создадите объект, о котором Unity ничего не знает, например, экземпляр класса, который ни от чего не наследуется (большинство классов или “скриптовых компонентов” наследуются от MonoBehaviour) и затем установите вашей переменной со ссылкой значение null, то на самом деле объект будет потерян для скрипта и Unity, т.к. они не смогут получить к нему доступ и никогда снова его не увидят, но при этом он останется в памяти. Затем, через какое-то время отработает сборщик мусора и при этом удалит из памяти всё, на что нет ссылок. Он может это сделать, т.к. в недрах сборщика ведётся учёт количества ссылок на каждый блок памяти. Это одна из причин, по которым скриптовые языки медленней C++.
Память выделяется каждый раз, когда создаётся объект. Зачастую в коде вы создаёте объекты даже не подозревая об этом.
Debug.Log("boo" + "hoo");
создаёт объект.
System.String.Empty
вместо ""
при работе с большим количеством строк.Классы - это объекты и ведут себя как ссылки. Если Foo - это класс и
Foo foo = new Foo();
MyFunction(foo);
тогда MyFunction получит ссылку на оригинальный объект Foo, память для которого была выделена в куче. Любые изменения foo в MyFunction отразятся везде, где есть ссылки на foo.
Классы - это данные и ведут себя соответствующе. Если Foo - это структура и
Foo foo = new Foo();
MyFunction(foo);
то MyFunction получит копию foo. Память для foo никогда не выделяется из кучи и никогда не подвергается сборке мусора. Если MyFunction изменяет свою копию foo, то это не влияет на остальные foo.
Дело в том, что частое использование Instantiate и Destroy подкидывает сборщику мусора прилично работы, что может привести к рывкам во время игры. Как рассказано на странице про автоматическое управление памятью, существуют другие способы обойти основные проблемы с производительностью, окружающие Instantiate и Destroy, такие как ручной запуск сборщика мусора пока на экране ничего не происходит, или очень частый его запуск для предотвращения накапливания большого количества работы для сборщика.
Другая причина в том, что иногда в память должны прогрузиться дополнительные вещи при создании первого экземпляра определённого префаба, либо в GPU должны загрузиться текстуры и меши. Это также может вызвать рывок, а при использовании пулинга объектов это произойдёт при загрузке уровня, а не во время игры.
Представьте кукловода в бесконечным количеством кукол в коробке, достающего новую копию куклы из коробки каждый раз, когда скрипт создаёт нового персонажа, и каждый раз, когда персонаж покидает сцену, кукловод бросает текущую копию. Пулинг объектов - эквивалент получения всех кукол из коробки до начала шоу и размещения их на столе за кулисами каждый раз когда они не должны быть видимы.
Одна из причин в том, что создание пула уменьшает количество доступной для других целей памяти кучи. Так что, если вы продолжите выделять память в только что созданных пулах, вы можете вызывать слишком частое срабатывание сборщика мусора. Кроме того, каждая сборка мусора будет проходить медленней, т.к. затрачиваемое на сборку время увеличивается с ростом количества активных объектов. Исходя из этих соображений, должно быть очевидно, что при использовании слишком больших пулов или активных пулов с объектами, которые какое-то время не понадобятся, пострадает производительность. Более того, многие типы объектов не подходят для содержания в пулах. Например, в игре могут быть существующие определённое время магические эффекты, или враги, которые появляются в больших количествах, но уничтожаются постепенно по мере прохождения игры. В таких случаях ресурсоёмкость объектного пула значительно перевешивает выгоду от его использования, так что его лучше вообще не использовать.
Вот простое сравнение скриптов простой пули с использованием Instantiate и с использованием пулинга объектов.
// 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 игры-скроллера, с тоннами монеток, которые падают, отскакивают и вращаются. Монетки динамически подсвечены точечными источниками освещения. Мы желаем захватить блеск монет от света, чтобы сделать игру более впечатляющей.
Если бы у нас было мощное железо, мы могли бы использовать стандартный подход к этой проблеме. Создать каждую монетку отдельным объектом, применить к объекту шейдер с повершинным, прямым или упреждающим освещением, и затем добавить свечение поверх всего этого с помощью пост-эффекта для получения ярко блистающих монет излучающих свет в окружающее пространство.
Но железо мобильных устройств задохнётся от такого большого количества объектов и вопрос свечения даже не будет рассматриваться. Что же нам делать?
Если вы желаете отобразить множество однообразно перемещающихся объектов, которые не могут быть детально рассмотрены игроком, вероятно вы сможете быстро отрисовывать их с помощью системы частиц. Вот несколько стереотипных применений этой техники:
Существует бесплатное расширение для редактора Sprite Packer, которое позволят создавать системы частиц из анимированных спрайтов. Оно отрисовывает кадры вашего объекта в текстуру, которая затем может использоваться в качестве атласа анимированных спрайтов для системы частиц. В нашем случае, мы могли бы использовать его для вращения монетки.
В Sprite Packer включён проект-пример, демонстрирующий решение именно для этой проблемы.
Он использует семейство ассетов различных типов для достижения ослепительного эффекта на низкопроизводительном железе:
Вместе с примером поставляется файл readme, в котором объясняется как и почему система работает, с изложением процесса, использованного для определения необходимых функций и их реализации. Вот этот файл:
Проблема была определена как “Сотни вращающихся собираемых монет с динамическим освещением на экране в один момент времени”.
Наивный подход - создать кучу экземпляров перфабы монетки, но вместо этого мы собираемся использовать частицы для отрисовки наших монеток. Однако, это привносит ряд испытаний, которые нам придётся преодолеть.
Основная цель этого примера или “мораль сей истории” - показать, что если при использовании стандартных подходов при реализации чего-то действительно нужного вашей игре, у вас оно “тормозит”, это вовсе не значит что это невозможно реализовать, это лишь значит, что вам следует немного поработать над собственной системой, которая будет работать намного быстрее.
Существуют специфичные оптимизации скриптов, которые применимы в ситуациях, в которых участвуют сотни или тысячи динамических объектов. Применение этих способов в каждом скрипте вашей игры - ужасная идея. Их следует приберечь в качестве инструментов и советов по дизайну для больших скриптов, которые обрабатывают массу объектов или данных во время выполнения приложения.
В компьютерной науке, порядок операции, обозначаемый 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;
}
}
Важно отметить, что тут применяется два цикла, один внутри другого.
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.
Если вам приходится выполнять что-то ресурсоёмкое, вы могли бы оптимизировать это с помощью более редких вызовов и кэширования результата. Например, вот скрипт для пули, который использует 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;
}
}
Мы сразу могли бы улучшить скрипт заменив FixedUpdate на Update и fixedDeltaTime на deltaTime. FixedUpdate относится к обновлению физики, что происходит чаще, чем обновление кадра. Но давайте пойдём ещё дальше, производя рейкаст только каждые n секунд. Чем меньше n, тем выше временное разрешение, и чем выше n, там выше производительность. Чем крупнее и медленнее ваши цели, тем большее значение может быть использовано для n до тех пор, пока не начнёт происходить темпоральное сглаживание (появление задержки, когда игрок попал по цели, но взрыв появляется там, где цель была n секунд назад, или когда игрок попал по цели, но пуля прошла сквозь неё).
// 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);.
Используемый в Unity движок NVIDIA PhysX доступен и на мобильных устройствах, но там, по сравнению с настольными системами, намного проще упереться в ограничения производительности железа.
Вот несколько советов по настройке физики для получения более высокой производительности на мобильных устройствах:-