Данная статья является переводом другой статьи. С оригиналом вы можете ознакомиться по ссылке. В оригинальную статью в процессе перевода были внесены некоторые правки, а материал расширен для лучшего раскрытия темы.
Почему оптимизация памяти так важна
Память — это драгоценный ресурс для любой игры, особенно на мобильных устройствах с ограниченным объёмом ОЗУ. Когда ваша игра потребляет слишком много памяти, могут возникать следующие проблемы:
- Лаги: если FPS (Frames Per Second, количество кадров секунду) снижается, геймплей становится рваным и неотзывчивым.
- Краши: в системе заканчивается доступная память и игра просто вылетает.
Благодаря оптимизации памяти вы сможете гарантировать плавность работы вашего проекта и подарить игрокам приятный игровой опыт.
Аналогия с игрушками: как понять управление памятью

Представьте, что у вас есть коробка с игрушками, и вы хотите содержать их в порядке. Управление памятью в C# похоже на расстановку этих игрушек по своим местам, чтобы ничего не потерялось.
Внутри программы существует много разных объектов. Эти объекты похожи на игрушки, и им тоже нужно место в памяти компьютера.
Как коробка с игрушками имеет ограниченный объём, так и компьютер располагает ограниченным объемом памяти. Поэтому управление памятью — это процесс, который следит, чтобы всем объектам хватало места и они не терялись.
Часть работы по управлению памятью — определять, когда какой-то объект уже не нужен и может быть удален, как если бы вы убирали ненужную игрушку.
Компьютер отслеживает, какие объекты используются, а какие нет. Если объект перестал быть нужным, менеджер памяти освобождает его место для других данных.
Иногда компьютер «забывает» очистить память, как если бы вы забыли убрать игрушку в коробку. Тогда возникает «утечка памяти» (memory leak): программа все больше расходует память, которая уже не нужна, что может привести к неполадкам.
Чтобы предотвратить такие проблемы, в C# есть механизм, называемый «сборщиком мусора» (Garbage Collection, GC). Он самостоятельно находит и очищает объекты, больше не используемые программой, сохраняя память в порядке и не давая ей переполниться.
Таким образом, управление памятью в C# сводится к тому, чтобы следить за объектами, выделять им пространство и вовремя освобождать, когда они становятся не нужны. Аналогия с игрушками помогает понять, что память — ресурс ограниченный и требует аккуратного обращения.
Неэффективное использование памяти ведет к частым вызовам сборщика мусора (что «подъедает» процессорное время) и утечкам памяти, что чревато вылетами. Оба варианта недопустимы в современных играх.
Ручное и автоматическое управление памятью

Управление памятью включает в себя три основные задачи:
- Выделение памяти (Allocating)
- Отслеживание времени жизни объектов (Tracking)
- Освобождение памяти (Deallocating)
В C или C++ эти действия обычно делаются вручную, а в C# — автоматически, с помощью сборщика мусора (GC). GC отвечает за управление всеми управляемыми (managed) объектами, но не затрагивает неуправляемые (unmanaged) объекты. В Unity роль GC выполняет механизм в Mono или IL2CPP.
Управляемый код (Managed code) — это код, который работает внутри .NET и CLR; любые объекты, созданные в нём, управляются CLR. Примеры: string
, int
, bool
и другие базовые типы.
Неуправляемый код (Unmanaged code) — это код, работающий вне среды .NET под управлением ОС. Например, работа с файлами или сетевые подключения.
Stack и Heap

В C# память делится на стек (Stack) и кучу (Heap).
- Стек упорядочен, быстр, но ограничен по объёму. В нём обычно хранятся локальные переменные и данные, которые существуют только в рамках текущего вызова функции.
- Куча более объёмна и свободна по структуре. Объекты, выделенные в куче, могут быть доступны из любой части программы. Именно кучей управляет сборщик мусора.
Размер стека, как правило, невелик (порядка мегабайта). Если вы вызываете слишком много функций рекурсивно или используете очень много локальных переменных, можно столкнуться с ошибкой переполнения стека (stack overflow), но на практике это случается редко.
Куча же представляет собой большой блок памяти, который может динамически расширяться или освобождаться под нужды приложения. При запуске Unity-приложения среда Mono запрашивает у ОС блок памяти, чтобы управлять кучей для нашего C#-кода (часто именуется «управляемой кучей»). Если Unity решит, что память не используется, она может вернуть её обратно ОС.
Хотя говорят, что «ссылочные типы» хранятся в куче, а «значимые типы» — в стеке, на деле многое зависит от того, где и как они объявлены. Значимые типы (struct
) могут оказаться в куче, если они являются полями класса. А если значимый тип (struct
) объявлен как локальная переменная внутри метода, то он будет храниться в стеке.
Когда мы передаем значение типа int
, bool
или другой структуры в метод, который ожидает объект (object), происходит «упаковка» (boxing): значение копируется в кучу, завёрнутое в ссылочный тип.
Сборщик мусора (Garbage Collector, GC)

GC управляет памятью в куче, отслеживая, какие участки памяти используются, а какие нет. Когда объект становится невидимым для программы, GC может освободить занимаемую им память. При этом работа всей программы приостанавливается, пока GC делает свою работу, что может вызвать скачки нагрузки на процессор и просадки FPS. Разумеется, такой эффект крайне нежелателен для динамичных игр.
Вызов GC может происходить явно из кода (например, GC.Collect()
), но чаще он вызывается неявно, когда память почти закончилась, или когда сборщику «кажется», что пора освободить место. Мы не можем полностью контролировать GC, однако можем сократить количество ненужных аллокаций, чтобы вызовов сборщика мусора было меньше. В Unity есть возможность временно отключать GC и управлять памятью вручную, но это достаточно сложный и продвинутый сценарий использования.
Mono и IL2CPP
Mono — это открытая реализация платформы Microsoft .NET Framework, позволяющая запускать код C# на разных операционных системах (Linux, macOS, Windows, ARM и др.). Unity использует Mono как скриптовый интерфейс, а движок «под капотом» написан на C++.
IL2CPP — другой механизм компиляции скриптов в Unity. Он берёт промежуточный код (CIL) и преобразует его напрямую в нативный C++, что даёт выигрыш в скорости. IL2CPP может использовать собственный AOT-компилятор и виртуальную машину, оптимизируя процессы GC и компиляции.
Почему IL2CPP предпочтительнее Mono:
- Генерирует более оптимизированный машинный код.
- Поддерживает AOT-компилятор.
- Позволяет использовать «code stripping» (удаление неиспользуемого кода), уменьшая размер сборки.
- Обеспечивает лучшую защиту кода.
Давайте же рассмотрим методы, которые позволят нам более эффективно работать с памятью при разработке игр на Unity!
Профилирование памяти
Главный показатель здорового управления памятью — регулярный анализ, как часто и как долго работает GC. Чем интенсивнее работает сборщик мусора, тем сильнее падает производительность.
В Unity можно использовать Profiler (разделы CPU Usage и Memory) для наблюдения, насколько часто срабатывает GC и сколько времени это занимает.
Object Pooling
В Unity можно создавать «пулы объектов» (Object Pools) — заранее созданные экземпляры, которые многократно переиспользуются вместо создания новых. Это существенно уменьшает расходы на выделение и освобождение памяти.
Правильное использование ресурсов

В Unity мы можем работать с множеством различных ресурсов — текстуры, аудио, модели, анимации. Важно знать, какие настройки импорта использовать в том или ином случае, грамотно управлять ими, подгружать и выгружать, когда что-то становится ненужным. Так вы избежите ненужного расхода памяти.
Instantiate и Destroy
Каждый Instantiate создаёт новый объект и порождает дополнительные аллокации в памяти, а при Destroy движку требуется время, чтобы корректно очистить и освободить связанные ресурсы. Частые вызовы этих методов могут привести к избыточной фрагментации памяти, дополнительным срабатываниям сборщика мусора (GC) и ощутимым падениям производительности в пиковые моменты игрового цикла. Поэтому, если объект больше не нужен, важно не только «уничтожать» его через Destroy, но и подумать, нельзя ли сократить общее число инстанцирований — например, применять пул объектов, повторно используя существующие объекты вместо постоянных Instantiate/Destroy.
AssetBundles и Addressables
AssetBundles — это внешние файлы, содержащие различные ресурсы (модели, текстуры, аудио и т. д.). Используя AssetBundles, можно загружать данные по мере необходимости, уменьшая стартовое потребление памяти. Система Addressables в Unity упрощает работу с AssetBundles и предоставляет удобный интерфейс для динамической загрузки и выгрузки ресурсов. При использовании Addressables разработчик оперирует абстракциями «адреса» или «ключа» ресурса, не заботясь напрямую о том, где и как хранится этот файл. За кадром Addressables управляет созданием и распаковкой соответствующих AssetBundles, а также следит за зависимостями и вовремя освобождает память (например, когда все ссылки на определённый ресурс исчезают). Такое разделение обязанностей и автоматизация позволяют легко масштабировать проект, загружая только нужные ассеты в конкретный момент и сводя к минимуму лишние аллокации.
Сжатие текстур

Текстуры занимают значительную часть памяти. Unity предлагает разные форматы сжатия, позволяющие уменьшить их вес без заметной потери качества. Правильный выбор формата компрессии поможет снизить расход памяти.
Важно помнить
Оптимизация — это постоянный процесс. Регулярно проверяйте производительность и профилируйте игру, чтобы находить и устранять проблемные места.
Удерживайте баланс между оптимизацией и качеством графики. Не стоит приносить визуальную составляющую в жертву нескольким дополнительным FPS. Ищите «золотую середину», где игра выглядит хорошо и идёт плавно.
И еще полезные советы
- По возможности не используйте директорию Resources.
- Используйте Addressables для загрузки ресурсов.
- Отключайте автосинхронизацию трансформов, если она не нужна.
- Включайте «texture streaming» (стриминг текстур).
- Применяйте сжатие анимаций.
- Используйте сжатие мешей.
- Включайте Deep Profiler для анализа времени загрузки.
- Используйте родной формат сжатия текстур для целевой платформы (во избежание перекодирования во время работы).
- Отключайте флаг «read/write» при импортировании текстур, если они не нужны для чтения на CPU.
- Избегайте затратного кода в методах Update, FixedUpdate, LateUpdate.
- Устанавливайте правильные настройки импорта аудиоклипов и текстур.
- Создавайте атласы текстур и уменьшайте их разрешение, если это возможно. Существуют инструменты вроде Mesh Baker для автоматизации.
- Старайтесь использовать косвенные ссылки (через Addressables) вместо прямых, чтобы не грузить лишние ассеты заблаговременно.
Заключение
Используя перечисленные методы оптимизации памяти, вы сможете заметно снизить ее расход и повысить производительность ваших Unity-проектов. Это не только улучшит впечатление игроков, но и повысит шансы вашей игры на успех. Так что «выжимайте максимум из минимума», оптимизируйте использование памяти и раскрывайте весь потенциал своих Unity-игр!
Комментарии