🎮🚀 8 простых способов улучшить производительность вашей игры в Unity

Хотите, чтобы ваша игра в Unity шла плавно и не тормозила? В этой статье мы рассмотрим 8 быстрых и эффективных способов оптимизировать проект: от минимизации тяжёлой логики в Update до приемов оптимизации UI и физики.

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

1. Минимизируйте выполнение тяжелой логики в Update, FixedUpdate и LateUpdate

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

Методы получения компонентов

GetComponent<T>(): Ищет компонент типа T на том же объекте (GameObject).

GetComponentsInChildren<T>(bool includeInactive = false): Ищет все компоненты T в дочерних объектах (при includeInactive = true – включая отключенные).

GetComponentsInParent<T>(bool includeInactive = false): Аналогично предыдущему, но ищет в родительских объектах.

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

Избегать использования таких методов в Update очень просто – надо всего лишь вызывать их один раз и кешировать результат, например, в методе Awake.

Например, вот так делать не стоит:

public class PlayerMovement: MonoBehaviour
{
    [SerializeField] private float _speed;
    private CharacterController _controller;

    private void Update()
    {
        _controller = GetComponent<CharacterController>();
        _controller.Move(Vector3.forward * _speed);
    }
}

Лучше сделать вот так:

public class PlayerMovement: MonoBehaviour
{
    [SerializeField] private float _speed;
    private CharacterController _controller;

    private void Awake()
    {
        _controller = GetComponent<CharacterController>();
    }

    private void Update()
    {
        _controller.Move(Vector3.forward * _speed);
    }
}

Методы поиска объектов на сцене

Методы поиска объектов на сцене

FindObjectOfType<T>(): Находит первый объект в сцене, у которого есть компонент типа T.

FindObjectsOfType<T>(): Находит все объекты в сцене, у которых есть компонент типа T.

GameObject.Find(string name): Пытается найти объект по имени.

GameObject.FindWithTag(string tag) / FindGameObjectsWithTag(string tag): Ищут объекты по тегу.

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

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

Методы создания и удаления игровых объектов

Instantiate(): Создает новый экземпляр (копию) игрового объекта объекта в сцене.

Destroy(): Удаляет объект со сцены и освобождает связанную с ним память.

Метод Instantiate выделяет память под создание нового объекта.

При Destroy объект не всегда удаляется мгновенно. Это также запускает механизмы освобождения памяти и работу сборщика мусора, которая сильно влияет на производительность.

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

Поиск главной камеры

Знакомая многим запись Camera.main, фактически, является оберткой над методом FindObjectOfType<Camera>(), которая ищет камеру с тегом «MainCamera» в сцене, поэтому его вызов будет так же неэффективен, как и использование других подобных методов. Стратегия избегания проблем здесь такая же – просто единожды кешируйте ссылку на камеру и используйте ее.

2. Разбивайте большие холсты для снижения нагрузки на отрисовку пользовательского интерфейса

Когда вы используете Canvas в режиме Screen Space – Overlay или Screen Space – Camera, любое изменение на любом элементе внутри Canvas может приводить к пересчету расположения и перерисовке всего содержимого. Это связано с тем, что Canvas рассматривается движком как единая сущность, и при обновлении хотя бы одного объекта пересчитывается вся иерархия UI-элементов. В результате при большом количестве элементов UI (тексты, иконки, панельки, кнопки, слайдеры) и частых изменениях (навигация по меню, анимации интерфейса, взаимодействие с кнопками или полями ввода) возрастает нагрузка на процессор и видеокарту.

Чтобы избежать ненужных перерасчетов интерфейса и сохранить высокую производительность, можно следовать следующим правилам:

  1. Разделяйте один большой Canvas на несколько небольших. Логически разбивайте интерфейс на блоки: основной HUD, окно инвентаря, меню паузы, всплывающие подсказки, и т. д. Тогда изменение в одном Canvas не будет затрагивать отрисовку других. К примеру, если часто меняется только небольшая иконка или текст в вашем HUD, это событие не будет вызывать лишних действий по перерисовке всего остального UI. При этом не следует перебарщивать с разбиением и каждый отдельный UI-элемент выносить на отдельный холст, ибо тогда их станет слишком много, что ударит как и по производительности, так и по удобству работы с интерфейсом;
  2. Минимизируйте динамические элементы. Если какая-то часть интерфейса редко обновляется, поместите ее в отдельный Canvas. Регулярно меняющиеся объекты (например, счетчики и полоски здоровья) можно вынести в другой Canvas, чтобы их обновление не затрагивало статичные элементы;
  3. Следите за иерархией. Canvas, вложенный в другой Canvas, по сути, остается частью общей структуры. Если нужно действительно разграничить отрисовку, используйте отдельный корневой Canvas (не дочерний объект), иначе единичная перерисовка верхнего Canvas может вызвать «цепную реакцию».

3. Не используйте Animator для анимаций в UI

Animator в Unity отлично справляется с воспроизведением анимаций персонажей или сложных 3D-моделей, где важны переходы между множеством состояний (Idle, Walk, Run, Jump и т. д.). Однако, когда дело доходит до анимации UI-элементов, использование Animator может быть избыточным и ресурсозатратным.

Во-первых, для простой UI-анимации в духе «двинуть панель на экран и обратно» приходится создавать аниматор-контроллер с минимум двумя-тремя клипами, настраивать переходы, параметры, триггеры – всё это усложняет процесс, который можно сделать одной-двумя строчками кода.

Во-вторых, при таком подходе начинаются проблемы с производительностью. Каждый Animator держит свою State Machine, которую Unity обновляет в каждом кадре (или в фиксированном шаге, если вы используете анимацию, завязанную на физику). Для UI, у которого часто есть десятки элементов, такое «множество аниматоров» может вызвать ощутимую нагрузку, особенно на слабых устройствах или в мобильных проектах. Даже если анимация в данный момент не проигрывается, Animator-контроллер каждую итерацию цикла обрабатывает логику переходов, параметры, проверки слоев. В случае с UI, где анимации включаются редко и не имеет сложных условий, эти проверки по сути бессмысленны.

Наиболее удобным вариантом решения этой проблемы будет использование специальных библиотек с твинами (Tweens). Ниже будет приведен пример анимации панели с помощью наиболее популярной библиотеки DOTween:

using UnityEngine;
using DG.Tweening;
using UnityEngine.UI;

public class PanelAnimator : MonoBehaviour
{
    [SerializeField] private RectTransform _panel; 
    [SerializeField] private float _moveOffset = 300f;  // Зададим смещение панели
    [SerializeField] private float _duration = 0.5f;    // Длительность анимации

    private void Awake()
    {
        // Сдвигаем панель ниже исходной позиции на moveOffset пикселей
        _panel.anchoredPosition = new Vector2(_panel.anchoredPosition.x, -_moveOffset);
    }

    // Показываем панель анимацией
    public void ShowPanel()
    {
        // Анимируем позицию по оси Y до 0 за duration секунд
        _panel.DOAnchorPosY(0, _duration).SetEase(Ease.OutBack);
    }

    // Скрываем панель анимацией
    public void HidePanel()
    {
        // Возвращаем панель к исходному смещению
        _panel.DOAnchorPosY(-_moveOffset, _duration).SetEase(Ease.InBack);
    }
}

В результате, для большинства анимаций в UI разумнее использовать подобные библиотеки, нежели громоздкие Animator-контроллеры. Такой подход является более простым с точки зрения разработки и поддержки, а также более эффективным и производительным. К тому же, показанные в скрипте методы SetEase() позволяют нам одной строчкой придавать различные эффекты анимациям, делая их более красивыми и интересными.

4. Старайтесь избегать чрезмерного использования Layout Groups

В Unity есть несколько типов автоматических лейаутов (Layout Group) – HorizontalLayoutGroup, VerticalLayoutGroup и GridLayoutGroup, а также вспомогательные компоненты типа ContentSizeFitter. Они позволяют динамически расставлять дочерние элементы UI без ручной подгонки и выравнивания. Это невероятно мощные и удобные инструменты, однако с точки зрения производительности они достаточно ресурсозатратные и могут стать источником снижения производительности, особенно в случаях, когда у вас много динамических или часто меняющихся UI-элементов.

Каждый раз, когда в дочернем объекте (или в самом Layout Group) что-то меняется, Unity пересчитывает размеры и расположение всех элементов. Чем сложнее иерархия, тем сильнее возрастает нагрузка. На слабых устройствах или при резких изменениях (добавление десятков элементов за раз) FPS может заметно проседать.

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

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

5. Исключайте из рейкаста (Raycast Target) неинтерактивные элементы

Когда вы взаимодействуйте с UI в Unity, каждый элемент интерфейса по умолчанию может быть «мишенью» для лучей (raycasts), которые проверяют, наведен ли пользователь курсор мыши или палец на этот элемент, и нужно ли генерировать событие (клик, наведение, нажатие и т. д.). Если у вас много UI-объектов, каждый из которых не предназначен для пользовательского взаимодействия, то их постоянная проверка на лучи – лишняя операция. На слабых устройствах или при большом количестве объектов это может негативно влиять на производительность.

Если UI-элемент не предназначен для клика или другого пользовательского взаимодействия, то его не нужно «просвечивать» лучом. Для этого достаточно найти компонент Image, Text (TextMeshProUGUI) или любой другой графический компонент на вашем UI-объекте, а затем в Inspector снять галочку Raycast Target.

Если UI-элемент не предназначен для клика или другого пользовательского взаимодействия, то его не нужно «просвечивать» лучом.

После этого данный элемент не будет участвовать в событии Raycast. Он продолжит отрисовываться, но EventSystem перестанет проверять его на касание или клик. Аналогичные настройки есть и для компонентов TextMeshPro, хоть они и спрятаны (Extra SettingsRaycast Target).

Настройки для компонентов TextMeshPro
📖#️⃣ Книги для шарпистов
Больше полезных книг вы найдете на нашем телеграм-канале «Книги для шарпистов | C#, .NET, F#»

6. Используйте спрайт-атласы

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

Зачем это нужно:

  1. Сокращение количества draw calls. При использовании нескольких отдельных спрайтов каждый может потребовать свой draw call (вызов отрисовки). Если они объединены в один атлас и используют общий материал, движок может нарисовать их «за один подход» (batching). Это уменьшает нагрузку на GPU и повышает FPS;
  2. Упрощение управления ресурсами. Спрайты в атласе сохраняются в едином файле, что упрощает их загрузку и выгрузку. Unity загружает в память одну большую текстуру вместо множества маленьких;
  3. Оптимальное сжатие. Спрайт-атлас можно сжать в нужном формате (ETC2, ASTC, DXT и т. д.) сразу для всех спрайтов, часто экономя место на диске и в оперативной памяти.

Для создания атласа в папке проекта нажмите правой кнопкой мыши на папку проекта или выбери пункт Assets в панели инструментов. Выберите Create2DSprite Atlas.

Создание атласа в папке проекта

Кликните по созданному атласу. В Inspector вы увидите настройки:

  • Objects for Packing: сюда вы можете «перетащить» папки или отдельные спрайты, которые хотите упаковать;
  • Include in Build: отмечает, должен ли атлас быть включён в финальную сборку;
  • Packing Settings: позволяет контролировать метод упаковки (Tight или Rectangle), размер текстуры (Max Size), формат сжатия (Format), политику генерации Mip Map и т. д.

При необходимости можно добавить несколько Sprite Atlas Variant, где тот же набор спрайтов может быть упакован с другими параметрами (другое разрешение, другой формат).

Использование спрайт-атласов поможет вам снизить нагрузку на отрисовку элементов пользовательского интерфейса, что может повысить производительность игры.

7. Уменьшайте число draw calls с помощью батчинга

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

Draw calls (вызовы отрисовки) – один из ключевых показателей, влияющих на производительность. Каждый раз, когда движок отправляет на видеокарту команду прорисовать объект, происходит draw call. Чем их больше, тем тяжелее рендерить сцену.

Unity предлагает несколько способов «склеить» объекты и тем самым уменьшить количество draw calls:

  1. Static Batching (Статический батчинг). Если у вас есть несколько объектов, которые не двигаются, не вращаются и не масштабируются во время игры, пометьте их как Static. Тогда Unity сможет объединить их в один большой меш, сократив количество вызовов отрисовки;
  2. Dynamic Batching (Динамический батчинг). Подходит для простых объектов (с количеством вершин до определенного лимита), которые могут двигаться. Unity объединяет их геометрию за кадр и отправляет единой группой на рендеринг;
  3. Sprite/Texture Atlas – о них мы уже упоминали в предыдущем пункте. Собирайте текстуры и спрайты в единые атласты, тогда все элементы, использующие один атлас, будут рендериться за один draw call или значительно меньшим их количеством.

Важно следить за тем, чтобы объекты имели одинаковые шейдеры и материалы. Любое различие приводит к новому draw call. Следовательно, используйте общий материал там, где это возможно. Экономия может оказаться колоссальной: например, вместо сотен вызовов на каждый объект, вы получите всего десяток.

#️⃣ Библиотека шарписта
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека шарписта»

8. Оптимизируйте физику

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

Здесь можно дать несколько простых советов по оптимизации:

  • Уменьшайте количество физически симулируемых тел. Если объект не участвует в физических расчетах, не двигается и не взаимодействует с другими физическими объектами, используйте для него коллайдер без Rigidbody;
  • Используйте коллайдеры-триггеры там, где это возможно. Trigger-коллайдеры не участвуют в реальных физических расчётах, что снижает нагрузку на устройство;
  • Снижайте частоту Fixed Timestep, если это допустимо. По умолчанию в Unity физика рассчитывается на частоте 50 кадров в секунду (Fixed Timestep = 0.02). Если ваша игра может работать с меньшей точностью физики (например, 30 кадров), увеличьте значение Fixed Timestep. Это уменьшит нагрузку на процессор.

Отдельно стоит выделить упрощение коллайдеров. Вместо сложных меш-коллайдеров, которые являются очень ресурсозатратными, используйте простые примитивы (Box, Sphere, Capsule). Например, для прямоугольного здания с небольшим количеством выступов можно воспользоваться несколькими Box-коллайдерами, вместо одного Mesh Collider, как показано на изображении ниже.

Зачастую, коллайдерам не нужно идеально и до последних мелочей повторять визуальную форму объекта

Заключение

В этой статье мы рассмотрели 8 простых, но эффективных способов оптимизации игры в Unity. Разумеется, таких способов еще очень и очень много – можно, например, вспомнить про запекание света, использование LOD (Level of Detail) групп, Occlusion Culling и другие более комплексные способы. И все же следование вышеперечисленным рекомендациям позволит вам быстро повысить производительность вашей игры.


ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

matyushkin
18 марта 2020

ТОП-10 книг по C#: от новичка до профессионала

Отобрали актуальные книги по C#, .NET, Unity c лучшими оценками. Расположил...
Библиотека программиста
25 августа 2019

Почему C# программисты скоро будут нарасхват

C# программисты становятся более востребованными благодаря развивающейся эк...
Библиотека программиста
23 июня 2017

Разработка игр – это просто: 12 этапов изучения геймдева

Разработка игр на плаву, она перспективна и набирает популярность. Мы подго...