Случалось ли вам ловить падение приложения из-за исключения OutOfMemoryException? Управление памятью — важная часть разработки игр, и оно способно сберечь немало нервов. В этом материале разберём, как устроено выделение памяти, как профилировать состояние памяти вашего приложения и, наконец, как его улучшить.

Как работает память

Операционная система управляет памятью. Она выделяет процессам объём памяти, который те могут использовать. Приложения отправляют ей запросы вроде «Мне нужно больше памяти, чтобы работать, пожалуйста, выдели её» или «Эта часть памяти больше не нужна, забери и освободи её».

Обычно приложения выделяют память с некоторым запасом, а затем создают обёртку над такими выделенными участками, делая эту память доступной приложению через так называемые аллокаторы. Когда им требуется больше, чем ранее выделенный запас, они отправляют ОС запрос на дополнительную память. Иногда ОС отказывает — ресурсов не хватает. В этом случае приложение падает с OutOfMemoryException.

Фрагментация памяти

Для массивов и похожих структур требуется непрерывный участок памяти. Это значит, что если вы хотите, например, создать массив данных, приложение выделяет под него непрерывный блок памяти. В упрощённой модели блоки идут один за другим. Частые выделения и освобождения уменьшают размер доступных непрерывных свободных областей.

Допустим, у нас есть такой блок памяти:

Представление единственного блока памяти
Представление единственного блока памяти

И вот такие выделения памяти:

Представление крошечного выделения памяти
Представление крошечного выделения памяти
Представление малого выделения памяти
Представление малого выделения памяти
Представление большого выделения памяти
Представление большого выделения памяти
Представление сверхбольшого выделения памяти
Представление сверхбольшого выделения памяти

За время работы приложения мы сделали несколько выделений, и наш блок теперь выглядит так:

 Блок памяти с несколькими выделениями
Блок памяти с несколькими выделениями

Теперь мы освобождаем из памяти сверхбольшое выделение, и получается так:

Блок памяти после освобождения сверхбольшого выделения
Блок памяти после освобождения сверхбольшого выделения

Затем выполняем серию мелких выделений:

Блок памяти с новыми выделениями
Блок памяти с новыми выделениями

Теперь мы хотим снова выполнить сверхбольшое выделение. Между последним крошечным и большим выделениями есть свободное место, но его недостаточно для непрерывного сверхбольшого блока. Приложению приходится отправить запрос операционной системе на дополнительный объём памяти. После этого наш блок памяти с ещё одним сверхбольшим выделением выглядит так:

Представление фрагментации памяти
Представление фрагментации памяти

Такое состояние, когда между занятыми блоками появляются «дыры» и не хватает непрерывного пространства под крупные выделения, называется фрагментацией памяти.

Сборщик мусора в Unity некопактирующий: он не перемещает объекты и не «уплотняет» управляемую кучу, поэтому фрагментацию таким образом устранить нельзя; это относится только к управляемой памяти (Managed). Для нативной памяти (Native) такой возможности нет — частое выделение нативной памяти может приводить к её фрагментации.

Ниже показаны примеры фрагментированной и нефрагментированной памяти. Обратите внимание, как аккуратно выстраиваются выделения в блоке памяти, когда фрагментации нет (или она минимальна).

Фрагментированная память
Фрагментированная память
Память без фрагментации
Память без фрагментации

Типы памяти

Существует несколько типов памяти, которые Unity учитывает (и показывает в профайлере):

  • Нативная память (Native Memory)

  • Исполняемые файлы и отображённые области (Executables and Mapped)

  • Управляемая память (Managed Memory)

  • Память драйвера GPU (GPU Driver Memory)

  • Неотслеживаемая память (Untracked Memory)

Нативная память — часть памяти, выделяемая самой Unity и её встроенным аллокатором. Используется для таких объектов, как:

  • объекты сцены (GameObject’ы и их компоненты),

  • ассеты и менеджеры,

  • выделения нативной памяти, включая NativeArray и другие нативные контейнеры,

  • память графических ассетов на стороне CPU,

  • и прочее.

Эта память не управляется сборщиком мусора (GC).

Исполняемые файлы и отображённые в память области — память, которую занимает исполняемый код приложения, а также все разделяемые библиотеки и сборки (и управляемые, и нативные).

Управляемая память — это память среды выполнения .NET (CLR) для C#. Здесь живут списки, массивы, хеш-наборы (HashSet<T>), объекты классов и т. п.

Память драйвера GPU — память, которую драйвер графики выделяет под рендеринг. Здесь лежит большинство текстур, мешей и т. д.

Неотслеживаемая память — память, выделяемая сторонними плагинами, которую Unity не учитывает (вне её механизмов учёта).

Типы памяти, показанные в Memory Profiler
Типы памяти, показанные в Memory Profiler

Что выделяется в моём проекте?

Теперь, когда мы понимаем, как в целом работает выделение памяти и какие есть типы памяти в Unity, посмотрим, как Unity загружает и удерживает в памяти конкретные объекты.

Базовый принцип таков: Unity загружает в память всё, что находится внутри загруженной сцены, а также все объекты, на которые есть ссылки у уже загруженных объектов. Что это значит?

Допустим, мы загружаем сцену с 5 объектами. Эти объекты загружаются в память. Теперь предположим, что на одном из объектов висит компонент MonoBehaviour со ссылкой на ScriptableObject. Этот ScriptableObject, в свою очередь, содержит ссылку на меш, который в текущей сцене не используется. Результат? Меш, на который ссылается ScriptableObject, будет загружен в память и останется там, пока где-то в этой цепочке ссылок не произойдёт разрыв. Скорее всего, это случится после выгрузки сцены. Ещё хуже, если ScriptableObject ссылается на синглтон (Singleton) с установленным флагом DontDestroyOnLoad. В таком случае меш не будет выгружен из памяти даже после выгрузки сцены.

using UnityEngine;


public class SomeMonobehaviourWithReference : MonoBehaviour
{
   public Object reference;
}
using UnityEngine;


[CreateAssetMenu(fileName = "SomeScriptableObjectWithReference", menuName = "Scriptable Objects/SomeScriptableObjectWithReference")]
public class SomeScriptableObjectWithReference : ScriptableObject
{
   public Object reference;
}
Настройка тестовой сцены с MonoBehaviour, содержащим ссылку на ScriptableObject.
Настройка тестовой сцены с MonoBehaviour, содержащим ссылку на ScriptableObject.
ScriptableObject с ссылкой
ScriptableObject с ссылкой

Этот пример в крупном масштабе может привести к серьёзным проблемам с памятью, особенно если есть ссылки на объекты, в которых находится вся игровая логика. Как это решить? Вариантов много; один из них — использовать систему Addressables и загружать объекты только тогда, когда они действительно нужны.

Вместо жёсткой ссылки на меш можно использовать AssetReferenceT<Mesh>:

[SerializeField] private AssetReference meshReference;

Теперь можно вызвать LoadAssetAsync<Mesh>() для загрузки ассета:

AsyncOperationHandle<Mesh> handle = meshReference.LoadAssetAsync<Mesh>();

А для освобождения из памяти — ReleaseAsset():

meshReference.ReleaseAsset();

Unity включает содержимое папки Resources в билд, а загружается оно по вызовам Resources.Load*. Из-за слабого контроля за зависимостями и объёмом памяти такой подход сегодня считается плохой практикой.


В моей игре слишком большие выделения памяти. Как от них избавиться?

Сначала нужно диагностировать источник проблемы. Для этого используйте Memory Profiler.

Установить Memory Profiler можно через Package Manager.

Прежде чем погружаться в детали, необходимо снять снимки состояния памяти (memory snapshots) вашего проекта. Есть несколько базовых правил профилирования памяти:

  • Всегда профилируйте собранный билд на целевой платформе. У каждой платформы — свои особенности управления памятью, поэтому профилирование в редакторе не даст релевантных данных. По сути вы будете профилировать редактор, а не игру. Соберите билд под целевую платформу, запустите его и уже затем профилируйте.

  • Сделайте несколько снимков в рамках одного прохождения. Каждый запуск билда может вести себя по-разному с точки зрения памяти. Чтобы понять общий «поток» памяти в приложении, сделайте несколько снимков одного прохождения в ключевых точках, например:

    • из главного меню сразу после запуска игры — это даст представление о базовом объёме памяти, загружаемом на старте,

    • из игровой сцены сразу после её загрузки из главного меню — это покажет, что именно загружается в начале игрового цикла,

    • в конце игровой сессии — это покажет, какие части остаются в памяти постоянно, а какие выгружаются,

    • в главном меню после игровой сессии — это подскажет, корректно ли выгружаются игровые ассеты после выхода в меню.

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

  • Если возможно, делайте снимки памяти на устройстве с увеличенным объёмом ОЗУ. Если вы быстро упираетесь в максимальную ёмкость памяти устройства, имеет смысл профилировать на устройстве с большим объёмом ОЗУ, чтобы сделать корректные снимки и диагностировать проблему.

  • По возможности используйте вместе с Memory Profiler и другие инструменты для профилирования памяти. Memory Profiler может лишь оценивать использование памяти и иногда показывает не всё, поэтому результаты стоит сравнивать с данными других инструментов.

В текущей версии Memory Profiler есть 3 вкладки:

  • Summary — сводная визуализация используемой памяти.

  • Unity Objects — список всех Unity-объектов с указанием их объёма, доли в общем потреблении памяти и размеров нативной, управляемой и графической частей.

  • All Of Memory — список всех выделенных объектов с разбивкой по типам памяти.

Вкладка Summary инструмента Memory Profiler
Вкладка Summary инструмента Memory Profiler
Вкладка Unity Objects инструмента Memory Profiler
Вкладка Unity Objects инструмента Memory Profiler
Вкладка All Of Memory инструмента Memory Profiler
Вкладка All Of Memory инструмента Memory Profiler

Снимки можно просматривать как по одному, так и в режиме сравнения. Это полезно, когда нужно наглядно показать различия между отдельными частями игры.

Вкладка Unity Objects в режиме сравнения
Вкладка Unity Objects в режиме сравнения

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

Освоить архитектурные принципы и подходы разработки игр можно на курсе "Unity Game Developer. Professional" под руководством экспертов.

Что занимает так много памяти?

Во время оптимизации памяти первое, что стоит сделать, — это проверить, какие ассеты потребляют больше всего памяти и можно ли уменьшить их размер. Начните с самых очевидных оптимизаций — это самый простой способ улучшить управление памятью, сократив базовое использование. Что это может быть?

Texture2D

Размер текстуры в оперативной памяти зависит исключительно от:
– разрешения текстуры;
– формата текстуры;
– количества мип-карт (mip-maps).

Размер текстуры в оперативной памяти не зависит от сжатия Crunch. Сжатие Crunch влияет только на размер текстуры в сборке (build). После загрузки такая текстура декомпрессируется в оперативной памяти. Таким образом, размер текстур в форматах RGB Compressed DXT1 и RGB Crunched DXT1 в оперативной памяти одинаков, даже если редактор показывает иначе. Это стоит держать в уме.

Хорошей практикой считается подгонять разрешение текстур под разрешение целевого устройства. Текстура 4K не покажет всех своих деталей на экране 720p. Максимальный размер текстуры можно задать в Texture Importer в редакторе.

Настройка размера и формата текстуры для конкретной платформы в Texture Importer
Настройка размера и формата текстуры для конкретной платформы в Texture Importer

Можно также выбрать формат, в который будет сжиматься текстура. Самое важное помнить: разрешение сжимаемой текстуры должно быть кратно размеру блока сжатия. Например, сжатие DXT1 с блоком 4×4 требует, чтобы разрешение сжимаемой текстуры было кратно 4.

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

Если у текстуры есть мип-карты, её разрешение должно быть степенью двойки, иначе сжатие будет недоступно. Например, текстура 800×1200 с мип-картами не может быть сжата, а текстура 1024×1024 — может.

Это видно на следующем примере. Текстура 256×255 помечена как NPOT (non-power-of-two — не степень двойки) и не может быть сжата.

Пример NPOT-текстуры
Пример NPOT-текстуры

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

Визуально разницы нет, но теперь текстуру можно сжать.
Визуально разницы нет, но теперь текстуру можно сжать.

9Вы также можете включить в импортере текстур автоматическое масштабирование NPOT-текстуры. Варианты: To Nearest (к ближайшему), To Larger (к большему), To Smaller (к меньшему). Это удобный способ гарантировать, что импортируемая текстура будет сжимаемой, хотя иногда лучше делать это вручную для большего контроля.

Меню авто-масштабирования NPOT в Texture Importer.
Меню авто-масштабирования NPOT в Texture Importer.

Ниже — шпаргалка, показывающая, какие текстуры можно сжать в зависимости от наличия мип-карт.

Неподходящее для блочного сжатия (не кратно размеру блока, напр. 193×921)

Кратно 4 по обеим осям (например, 356×272)

Степень двойки (например, 256×256)

Мип-карты включены

нет сжатия

нет сжатия

сжатие доступно

Мип-карты выключены

нет сжатия*

сжатие доступно

сжатие доступно

*некоторые форматы (например, ASTC) допускают сжатие в этом случае

Mesh

Меши могут потреблять значительный объём памяти проекта, особенно если они сложные и/или их много.

Есть несколько способов уменьшить размер мешей:

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

Установка параметра Tangents в значение None в Mesh Importer.
Установка параметра Tangents в значение None в Mesh Importer.

2. Если в модели не используются Blend Shapes (морф-таргеты), отключите Import Blend Shapes.

Отключение Import Blend Shapes (false) в Mesh Importer.
Отключение Import Blend Shapes (false) в Mesh Importer.

3. Не забудьте держать флаг «Read/Write Enabled» в положении false, если он не нужен (например, если вы не планируете изменять меш во время выполнения). Если включить эту опцию, меш будет продублирован и храниться одновременно в оперативной памяти и в памяти GPU.

Установка Read/Write в значение false в Mesh Importer.
Установка Read/Write в значение false в Mesh Importer.

Анимация

Вы можете уменьшить размер анимаций импортируемых моделей, включив сжатие анимации (Animation Compression). В этом случае Unity опустит часть лишних ключевых кадров.

Настройки Animation Compression в Mesh Importer.
Настройки Animation Compression в Mesh Importer.

Шейдер

Каждый шейдер может иметь несколько вариантов, то есть компилироваться многократно для разных наборов возможностей. На практике часть вариантов может оказаться ненужной — это зависит от числа источников света, их параметров и настроек качества. Поэтому важно отслеживать варианты шейдеров и управлять ими. Unity часто не может сама определить, какие варианты нужно «вычистить» (исключить из сборки), поэтому делать это придётся вручную.

Сделать это можно, добавив в проект скрипт IPreprocessShaders примерно следующего вида:

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEditor.Build;
using UnityEditor.Rendering;


public class ShaderStripper : IPreprocessShaders
{
   private List<ShaderKeyword> keywords = new List<ShaderKeyword>()
   {
       new ShaderKeyword("POINT_COOKIE"),
       new ShaderKeyword("DIRECTIONAL_COOKIE"),
   };


   public int callbackOrder { get { return 0; } }


   public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data)
   {
       StripVariants(shader, snippet, data, keywords);
   }


   private void StripVariants(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data, List<ShaderKeyword> keywordsToStrip)
   {
       for (int i = 0; i < data.Count; ++i)
       {
           if (keywordsToStrip.Any(keyword => data[i].shaderKeywordSet.IsEnabled(keyword)))
           {
               data.RemoveAt(i);
               --i;
           }
       }
   }
}

Достаточно просто держать этот скрипт где-нибудь в проекте — он выполнится во время компиляции.

Вы можете отслеживать количество используемых в проекте шейдеров и их вариантов через окно Project Settings -> Graphics. Перейдите в Play Mode и запустите игру. Обратите внимание, как счётчики растут при каждой загрузке нового варианта шейдера. Если текущее состояние вас устраивает, выйдите из Play Mode и нажмите кнопку «Save to asset…», чтобы сохранить список вариантов шейдеров в файл с расширением .shadervariants. Затем откройте этот файл в Inspector и посмотрите, какие варианты шейдеров загружались.

Трекер вариантов шейдеров в окне Project Settings -> Graphics.
Трекер вариантов шейдеров в окне Project Settings -> Graphics.

На основе этой информации определите, какие варианты можно исключить. Просто добавьте соответствующие ключевые слова в ваш скрипт — и в сборке шейдеры станут заметно «легче».

В Unity есть ещё одна «магическая» настройка, позволяющая улучшить использование памяти вариантами шейдеров, — Shader Variant Loading Settings. Её можно найти в Project Settings -> Player Settings -> Other Settings.

Раздел Shader Variant Loading Settings в Player Settings.
Раздел Shader Variant Loading Settings в Player Settings.

Unity хранит несколько «чанков» (фрагментов) сжатых данных вариантов шейдеров. Каждый чанк содержит множество вариантов. Когда Unity загружает сцену во время выполнения, она загружает все чанки сцены в память CPU и распаковывает их. По умолчанию Unity распаковывает все варианты шейдеров в другой области памяти CPU. Затем Unity передаёт вариант шейдера и его данные в графический API и драйвер видеокарты. Драйвер создаёт специфичную для GPU версию варианта шейдера и загружает её на GPU. Unity кэширует каждую такую GPU-версию, чтобы избежать повторной задержки при следующем обращении к этому варианту. Наконец, Unity полностью удаляет вариант шейдера из памяти CPU и GPU, когда на него больше не остаётся ссылок.

С помощью Shader Variant Loading Settings вы можете управлять размером чанков и тем, сколько распакованных чанков Unity держит в памяти.

  • Default chunk size (MB) — максимальный размер сжатых чанков.

  • Default chunk count — сколько распакованных чанков Unity хранит в памяти. Значение по умолчанию — 0, то есть без ограничения.

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


Что насчёт утечек памяти?

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

Утечка памяти — это состояние, при котором в памяти остаются объекты, которые больше не будут использоваться. Такое может случиться по нескольким причинам:
— где-то остаётся ссылка на объект, который уже не нужен, и сборщик мусора не может освободить его из памяти из-за этой ссылки;
— используются структуры, требующие ручного выделения и освобождения памяти, и шаг освобождения не выполняется.

Чтобы обнаружить утечку памяти, профилируйте потребление памяти приложения в ходе основного игрового цикла. Помните о принципах профилирования, описанных выше. Проанализируйте и сравните снимки памяти после нескольких итераций игрового цикла. Если рост составляет порядка 10–20 MB, вероятно, это не проблема — возможно, просто фрагментация. Если значительно больше, например около 100 MB, это, скорее всего, утечка.

Здесь мы видим крупную утечку памяти. Оба снимка сделаны в главном меню игры: первый — в начале игрового процесса, второй — после нескольких итераций основного игрового цикла.

Вкладка Summary инструмента Memory Profiler в режиме сравнения.
Вкладка Summary инструмента Memory Profiler в режиме сравнения.

В Unity Editor можно включить обнаружение утечек памяти, но оно работает только для нативных выделений, таких как NativeArray, UnsafeList, UnsafeParallelHashSet, AllocatorManager.Allocate() и т. п.

Эта опция находится в Edit → Preferences → Jobs (Leak Detection). Обнаруженные утечки нативной памяти будут выводиться в консоль как ошибки.

Как исправить утечку памяти?

Чтобы найти конкретную утечку, откройте снимок памяти в одиночном режиме и перейдите на вкладку All Of Memory. В поисковой строке можно ввести «leaked» — ниже отобразится список всех утечек обёрток управляемых объектов (помечены как Leaked Managed Shell).

Утечки управляемых оболочек можно найти на вкладке All Of Memory.
Утечки управляемых оболочек можно найти на вкладке All Of Memory.

Рассмотрим простой пример утечки памяти, подготовленный для этого материала. По снимку памяти видно, что утекло 140 объектов с именем «Bullet».

Погружаемся глубже в утечки памяти.
Погружаемся глубже в утечки памяти.

Выберем один из них и посмотрим на правую часть окна профилировщика памяти.

На правой панели можно отследить иерархию ссылок.
На правой панели можно отследить иерархию ссылок.

Видны разделы «References» и «Selection Details». Как видно в «Selection Details», Unity даже подсказывает, почему объект считается утечкой. Похоже, его следовало уничтожить, но сборщик мусора не смог освободить его, потому что где-то на него всё ещё есть ссылка. Давайте переключимся на вкладку «Referenced By» выше.

Как читать это дерево?

  • Ссылка на List<Bullet> хранится в объекте типа Weapon.

  • Ссылка на объект типа Weapon хранится в объекте типа Entity.

  • Ссылка на Entity хранится в List<Entity>.

  • List<Entity> хранится в классе EntitiesManager как статическое поле.

Мы добрались до корня проблемы — это статическая ссылка на List<Entity> в EntitiesManager. Что происходит: экземпляры Entity были уничтожены после выгрузки сцены, где они создавались, но на них всё ещё оставались ссылки, поэтому они не были освобождены из памяти. Более того, Entity держал ссылку на другой объект — Weapon, а Weapon держал ссылки на объекты Bullet, из-за чего и они тоже не были освобождены.

public class EntitiesManager : MonoBehaviour
{
   public static List<Entity> Entities { get; private set; } = new List<Entity>();

   ...
}

После корректной очистки (сброса) статических полей/ссылок всё будет в порядке.


Если хотите прокачать бережное обращение с памятью до уровня «по умолчанию правильно», в курсе Unity Game Developer. Professional мы собираем архитектуру, где Addressables, DI (Zenject), ECS и DDD работают согласованно — от ресурсной модели и загрузки/сохранения до профилирования и TDD. Курс для мидлов, кто систематизирует принципы SOLID/GRASP и KISS-DRY-YAGNI и собирает игровую логику как конструктор, без костылей.

Чтобы узнать, подходит ли вам программа курса, пройдите вступительный тест. А также приходите на открытые занятия, которые преподаватели проведут бесплатно в рамках набора:

  • 28 октября: «Unity Архитектура: от хаоса к порядку». Записаться

  • 6 ноября: «ECS: Секретное оружие топовых разработчиков». Записаться

  • 20 ноября: «Мультиплеер в Unity: Photon — ваш билет в мир онлайн-игр». Записаться

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