Знакома ситуация, когда проект работает с рывками и заставляет даже мощный компьютер лагать? Это поправимо.
Цель этой статьи - не просто дать сухие инструкции, а научить тебя видеть и устранять причины низкой производительности. Мы вместе пройдем путь от 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 для моделей, которые могут быть далеко от камеры.

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

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


  1. Jijiki
    01.09.2025 13:17

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

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

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

    Пс может Юнити/Анриал всё делает на бекграунде, но заметил что со строками везде какието недоразумения

    почему происходит выделение строки в toString потомучто например надо из числа получить строку

        //это пример тех строк как hp где число должно быть строкой и оно изменчиво 
        // int n=661;
        // char *ptr=getC(661);
        // printf("%s %d\n",ptr,strlen(ptr));//переписал только число если надо изменил размер
        // free(ptr);//удалил и то число осталось в строке а строка с переменной не нужна уже

    а ключевая строка уже выделена и та строка с числом уже не нужна когда она отправляется в ключевую строку