Знакома ситуация, когда проект работает с рывками и заставляет даже мощн��й компьютер лагать? Это поправимо.
Цель этой статьи - не просто дать сухие инструкции, а научить тебя видеть и устранять причины низкой производительности. Мы вместе пройдем путь от 30 до 60+ кадров в секунду (FPS).

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

Unity Profiler - диагност

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

Unity Profiler - это встроенный инструмент, который показывает, на что процессор (CPU), видеокарта (GPU) и память тратят драгоценные миллисекунды.

Как им пользоваться?

  1. Открой окно Profiler через Window > Analysis > Profiler.

  2. Запусти свою игру в редакторе.

  3. Наблюдай за графиками.

Нас в первую очередь интересуют две вкладки: CPU Usage и GPU Usage. Если график CPU показывает высокие "пики", значит, проблема в логике, скриптах или физике. Если "задыхается" GPU - виновата графика: шейдеры, текстуры, количество объектов на экране.

CPU Bottlenecks - Узкие места процессора

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

Проблема №1: Вызовы в Update() и FixedUpdate()

Метод Update() вызывается каждый кадр. Если у тебя 100 объектов, и у каждого в Update() происходит что-то ресурсоемкое, это создает огромную нагрузку.

Плохой пример:
using UnityEngine;

public class BadPlayerController : MonoBehaviour
{
    // Каждый кадр мы ищем компонент камеры.
    void Update()
    {
        // FindObjectOfType<T>() проходит по всем объектам на сцене, чтобы найти нужный.
        Camera mainCamera = FindObjectOfType<Camera>(); 
        
        // Постоянный поиск объекта по имени - тоже плохая практика.
        GameObject enemy = GameObject.Find("StrongEnemy"); 
    }
}

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

Хороший пример(кэширование):
using UnityEngine;

public class GoodPlayerController : MonoBehaviour
{
    // Мы создаем приватное поле для хранения ссылки на камеру.
    private Camera _mainCamera; 
    
    // И еще одно для врага.
    private GameObject _enemy;

    // Start() вызывается один раз при создании объекта. Идеальное место для поиска.
    void Start()
    {
        // Мы находим камеру один раз и "запоминаем" ее в нашей переменной.
        _mainCamera = Camera.main; 
        
        // Врага тоже находим один раз.
        _enemy = GameObject.Find("StrongEnemy"); 
    }

    void Update()
    {
        // Теперь в Update() мы просто используем уже найденные объекты.
        // Никаких лишних поисков.
        if (_mainCamera != null)
        {
            // Рабочий код
        }
    }
}

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

Проблема №2: Сборщик мусора (Garbage Collector, GC)

Когда ты создаешь объекты, выделяется память. Когда объект больше не нужен, "сборщик мусора" эту память освобождает. Звучит полезно, но есть нюанс: когда GC работает, он может на короткое время полностью остановить твою игру. Это и есть те самые неприятные "фризы" или "лаги".

Чаще всего мусор создают строки и создание новых объектов в цикле.

Плохой пример:
using UnityEngine;
using UnityEngine.UI;

public class BadUIUpdater : MonoBehaviour
{
    public Text scoreText;
    private int _score = 0;

    void Update()
    {
        _score++;
        scoreText.text = "Score: " + _score; 
        // Каждое сложение строк ("Score: " + _score) создает в памяти новый объект строки.
        // За секунду при 60 FPS создается 60 ненужных объектов. Это много мусора.
    }
}

В этом коде в Update мы постоянно создаем новую строку. Операция + для строк не изменяет старую, а создает новую, объединяя две части. Старая строка становится "мусором".

Хороший пример (использование TextMeshPro):
using UnityEngine;
using TMPro; // 1. Подключаем пространство имен для TextMeshPro

public class GoodUIUpdaterTMP : MonoBehaviour
{
    // 2. Вместо типа Text используем TextMeshProUGUI
    public TextMeshProUGUI scoreText;
    private int _score = 0;

    void Update()
    {
        _score++;
        // 3. Используем специальный метод SetText, который не создает мусор
        scoreText.SetText("Score: {0}", _score); 
    }
}

Почему TextMeshPro лучше для GC?

Этот вопрос заслуживает отдельного объяснения. Проблема старого компонента Text в том, что у него есть только одно свойство для изменения - .text, которое принимает string. Любая попытка ��ередать туда число заставит C# неявно вызвать метод .ToString(), который создает в памяти новую строку. Конкатенация (сложение) строк, как в "плохом" примере, создает еще больше мусора.

TextMeshPro был создан для решения этой проблемы.

Его метод SetText() - это не просто замена для .text. Он имеет множество "перегрузок", то есть версий для разных типов данных. Когда ты вызываешь scoreText.SetText("Score: {0}", _score);, происходит следующее:

  1. TextMeshPro видит, что ты передаешь ему строку-шаблон и число (int).

  2. Он не создает новую строку "Score: 123" в управляемой куче, где работает сборщик мусора.

  3. Вместо этого он использует свой внутренний, предварительно выделенный массив символов (буфер), в который и "собирает" финальную строку.

  4. Эта операция п��оисходит в основном в неуправляемой памяти или с переиспользованием буферов, что не генерирует мусор, за которым пришлось бы приходить сборщику (GC).

Проще говоря, TextMeshPro - как опытный повар, у которого есть многоразовая посуда (внутренние буферы), в то время как старый Text для каждого блюда берет новый одноразовый контейнер, который потом нужно выбрасывать. Использование TextMeshPro - это самый простой и эффективный способ избавиться от "лагов" в UI, связанных с GC.

Альтернативный подход: StringBuilder

Справедливости ради, стоит упомянуть и классический способ борьбы с мусором от строк, который был популярен до повсеместного внедрения TextMeshPro. Это использование класса StringBuilder из стандартной библиотеки C#.

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

Пример с StringBuilder:
using UnityEngine;
using UnityEngine.UI; // Используем старый UI Text для примера
using System.Text;   // Подключаем пространство имен для StringBuilder

public class GoodUIUpdaterStringBuilder : MonoBehaviour
{
    public Text scoreText;
    private int _score = 0;
    
    // Создаем экземпляр StringBuilder один раз, заранее выделив память
    private StringBuilder _scoreStringBuilder = new StringBuilder("Score: ", 12);

    void Update()
    {
        // Увеличивает значение переменной _score на единицу в каждом кадре
        _score++;
        // Мы не создаем новую строку, а изменяем существующую.
        _scoreStringBuilder.Length = 7; // Очищаем старое значение, оставляя "Score: "
        _scoreStringBuilder.Append(_score); // Добавляем новое число в конец
        
        // Присваиваем результат. Мусора почти нет (ToString() все же выделяет немного).
        scoreText.text = _scoreStringBuilder.ToString(); 
    }
}

Этот метод абсолютно рабочий и значительно лучше, чем просто сложение строк. Однако, как ты можешь видеть, он требует больше кода и менее читаем по сравнению с лаконичным методом SetText() у TextMeshPro. Сегодня в Unity TextMeshPro является предпочтительным решением, но знание о StringBuilder остается полезным для других, более сложных задач, где требуется многократная манипуляция со строками вне UI.

GPU Bottlenecks - Узкие места видеокарты

Если процессор - мозг, то видеокарта - это художник. Она рисует все, что ты видишь на экране. Если заставить ее рисовать слишком много или слишком сложно, FPS упадет.

Проблема №1: Draw Calls (Вызовы отри��овки)

Draw Call - это команда от CPU к GPU на отрисовку одного объекта с одним материалом. Чем больше таких команд, тем хуже. Оптимально - держать их количество как можно ниже.

Решения:

  • Static Batching: Если у тебя много статичных (неподвижных) объектов с одинаковым материалом (например, заборы, деревья, стены), Unity может объединить их в один большой объект перед отправкой на рендер. Это сильно сокращает Draw Calls. Просто выдели объекты и поставь галочку Static в инспекторе.

  • Dynamic Batching: Для маленьких движущихся объектов с одинаковым материалом Unity тоже может их "склеивать" на лету. Работает автоматически, но с ограничениями (например, на количество вершин в модели).

  • Атласы текстур: Вместо 10 материалов для 10 разных объектов (каждый со своей текстурой), создай одну большую текстуру (атлас), где будут все 10 картинок, и один материал. Так 10 объектов можно будет нарисовать за один Draw Call.

Проблема №2: Физика

Физические расчеты могут быть очень требовательны к процессору. Особенно если на сцене много объектов с компонентом Rigidbody и сложными коллайдерами (Mesh Collider).

Что делать?

  1. Используй простые коллайдеры (Box, Sphere, Capsule) вместо Mesh Collider, где это возможно. Mesh Collider - самый медленный.

  2. Настрой матрицу коллизий (Edit > Project Settings > Physics). Если пули не должны сталкиваться с пулями, а бонусы - с бонусами, просто сними галочки в этой матрице. Меньше проверок - выше производительность.

  3. Уменьшай количество Fixed Timestep в Project Settings > Time, если физика не требует высокой точности. Это уменьшит частоту вызова FixedUpdate.

Продвинутые техники

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

Object Pooling (Пул объектов)

Часто в играх нужно постоянно создавать и уничтожать объекты. Классический пример - пули. Постоянное Instantiate() и Destroy() создает много мусора и нагружает CPU.

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

Пример реализации пула:
using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
    // Префаб объекта, который мы будем "пулить".
    public GameObject objectToPool; 
    // Количество объектов, которое мы создадим заранее.
    public int amountToPool; 

    // "Коробка" для наших объектов - список.
    private List<GameObject> _pooledObjects; 

    void Start()
    {
        _pooledObjects = new List<GameObject>();
        GameObject tmp;
        // В самом начале создаем нужное количество объектов.
        for (int i = 0; i < amountToPool; i++)
        {
            // Создаем объект из префаба.
            tmp = Instantiate(objectToPool); 
            // Выключаем его, чтобы он не был виден и не работал.
            tmp.SetActive(false); 
            // Добавляем в наш список (в "коробку").
            _pooledObjects.Add(tmp); 
        }
    }

    // Метод, чтобы получить объект из пула.
    public GameObject GetPooledObject()
    {
        // Проходим по всему списку.
        for (int i = 0; i < amountToPool; i++)
        {
            // Ищем неактивный объект (тот, что лежит в "коробке").
            if (!_pooledObjects[i].activeInHierarchy) 
            {
                // Если нашли - возвращаем его.
                return _pooledObjects[i]; 
            }
        }
        // Если свободных объектов нет, возвращаем null.
        return null; 
    }
}

Этот скрипт создает пул. Другой скрипт, например, у оружия, будет вызывать метод GetPooledObject(), чтобы "взять" пулю, активировать ее (SetActive(true)), задать ей позицию и скорость. А сама пуля, достигнув цели или пролетев нужное расстояние, будет деактивировать себя (SetActive(false)), тем самым "возвращаясь" в пул.

LOD (Level of Detail)

Зачем рисовать модель с 10 000 полигонов, если она находится в 200 метрах от камеры и занимает на экране всего пару пикселей? LOD - это механизм, который позволяет использовать разные версии модели в зависимости от расстояния до камеры:

  • Близко: высокополигональная, красивая модель.

  • Средне: модель попроще.

  • Далеко: совсем простая модель или даже просто картинка.

Это сильно экономит ресурсы GPU. Настроить LOD можно в компоненте LOD Group, который добавляется на объект.

Что осталось за кадром: пути для дальнейшего изучения

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

  • DOTS (Data-Oriented Technology Stack) и ECS (Entity Component System): Это совершенно иная парадигма программирования, позволяющая достичь производительности при работе с тысячами однотипных объектов. Это сложная, но очень мощная технология от самих Unity.

  • Addressable Asset System: Система для управления ассетами и памятью. Позволяет загружать и выгружать ресурсы по требованию, что критически важно для больших игр, чтобы снизить потребление оперативной памяти.

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

  • Оптимизация шейдеров (Shader optimization): Мы лишь вскользь коснулись темы GPU. Глубокая оптимизация шейдеров, уменьшение числа их вариантов и использование более простых вычислений может кардинально ускорить рендеринг.

Заключение

Оптимизация - это систематическая работа.

  1. Всегда начинай с Profiler. Не гадай, а измеряй.

  2. Кэшируй ссылки на компоненты и объекты.

  3. Следи за созданием "мусора", особенно в Update().

  4. Объединяй объекты для снижения Draw Calls.

  5. Используй Object Pooling для часто создаваемых/уничтожаемых объектов.

  6. Применяй LOD для моделей, которые могут быть далеко от камеры.

Надеюсь, эта статья была для тебя полезна. Теперь ты знаешь, как сделать свои проекты чуть быстрее.