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

  • A: уменьшить количество пуль до 20

  • B: реализовать свою собственную пулинговую систему 

  • C: заплатить 50 долларов за пулинговую систему в Asset Store

  • D: использовать новый Pooling API Unity, представленный в 2021 году

(СМОТРИТЕ ВИДЕО В ОРИГИНАЛЕ СТАТЬИ)

В этой статье мы рассмотрим последний вариант.

Сегодня вы узнаете, как использовать новый Pooling API, представленный в 2021 году.

Начиная с Unity 2021, у вас есть доступ к широкому набору фич для работы с пулами, которые помогут вам разрабатывать высокопроизводительные проекты на Unity.

Готовы узнать о них побольше?

Когда вам нужен пул?

Начнем с самого главного вопроса: когда вам нужен пул?

Я задаю его, потому что пулы не должны быть вашим дежурным решением.

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

Но мы рассмотрим это позже.

Если вкратце, то вам стоит рассматривать возможность использования пулов, когда:

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

  • Вы часто аллоцируете и высвобождаете объекты, хранящиеся в куче (вместо их повторного использования). Это относится и к коллекциям C#.

Эти операции вызывают много аллокаций, следовательно вы сталкиваетесь с:

  • Избыточным расходом тактов процессора на операций создания и уничтожения (или new/dispose).

  • Преждевременной сборкой мусора, вызывающей фризы, которые ваши игроки не оценят.

  • Фрагментацией памяти, которая затрудняет поиск свободных смежных областей памяти.

Не кажется ли вам, что эти проблемы могут представлять для вас угрозу?

(Если сейчас - нет, то они могут позже)

Но давайте продолжим.

Итак, что же такое это (объектный) пулинг в Unity в конце-то концов?

Теперь, когда вы понимаете, попали ли вы в беду (или все еще в безопасности), позвольте мне быстро объяснить, что такое пулинг.

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

Сущность может быть чем угодно: игровым объектом, инстансом префаба, словарем C# и т. д.

Позвольте мне продемонстрировать концепцию пулинга в контекст реального примера.

Допустим, вам нужно завтра утром пойти за продуктами.

Что вы обычно берете с собой, кроме кошелька и ключей?

Ну, можно взять многоразовые сумки. В конце концов, вам нужны какие-то контейнеры, чтобы донести продукты домой.

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

Вернувшись домой, вы опустошаете свои сумки и кладете их обратно в ящик.

Это и есть пул.

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

Вам нужна сумка?

Хорошо, вы идете к своему пулу сумок (например, ящик на кухне), берете несколько, используете их, вытаскиваете все из них и, наконец, возвращаете их обратно в пул.

Поняли в чем соль?

Вот основные детали юзкейса пулинга:

  • Элементы, для которых вы хотите задействовать пул, например, многоразовые сумки, инстанцированная пуля и т.д. ...

  • Глобальная цель для всех этих элементов, например, перенос продуктов, стрельба пулями и т.д. ...

  • Функции, которые вы выполняете над пулом и его элементами: Take (взять), Return (вернуть), Reset (сбросить).

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

  • Вы создаете тысячу пуль и помещаете их в пул.

  • Каждый раз, когда вы стреляете из своего оружия, вы берете пулю из этого пула.

  • Когда пуля попадает во что-то и исчезает, вы возвращает ее обратно в пул.

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

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

Когда следует отказаться от использования пула?

У техники пулинга есть несколько (потенциальных) проблем:

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

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

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

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

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

Помните: самое главное — это частота ваших операций создания и уничтожения.

Если вы делаете их часто, рассматривайте возможность использования пулов. В противном случае даже не думайте об этом.

Позже мы рассмотрим больше проблем с пулами.

Теперь давайте посмотрим на наши доступные варианты для реализации пулов.

Пулы объектов в Unity 2021: ваши варианты

Если вы хотите добавить пул объектов в свой проект Unity, у вас есть три варианта:

  • Создать свою собственную систему

  • Купить стороннюю систему пулинга

  • Импортировать UnityEngine.Pool

Давайте рассмотрим их.

A) Создаем свою собственную систему пулинга

Один из вариантов — применить на практике свое мастерство.

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

  • Создать и удалить пул (Create & dispose)

  • Взять из пула (Take)

  • Вернуться в пул (Return)

  • Операции сброса (Reset)

Но это часто становится гораздо сложнее, когда вы начинаете думать о:

  • Типобезопасности

  • Управление памятью и структурах данных

  • Пользовательской аллокации/высвобождении объектов

  • Потокобезопасности

Это уже больше похоже на головную боль? Чувствую, ваше лицо побледнело...

Предлагаю не изобретать велосипед (если только это не учебное упражнение).

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

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

B) Сторонние системы пулинга объектов

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

  • The Unity Asset Store

  • Github

  • Друг или член семьи

Давайте рассмотрим несколько примеров:

Pooling Toolkit

13Pixels Pooling

Pure Pool

Pro Pooling

Но прежде чем вы нажмете кнопку покупки… прочитайте немного дальше.

Сторонние инструменты могут творить чудеса и обладают множеством фич.

Но у них есть недостатки:

  • Вы полагаетесь на их поддержку в исправлении проблем и обновлении пакетов для более новых версий редактора.

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

  • Больше фич = сложнее код. Вам потребуется время, чтобы понять и поддерживать их систему.

  • Они могут быть достаточно дорогими (и по деньгам и по времени).

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

И в настоящее время осталось еще меньше причин для использования сторонних решений, поскольку Unity втихаря зарелизила новый API для пулинга в Unity 2021.

И это основная тема этой статьи.

C) Новый Pooling API от Unity

Начиная с версии 2021 года, Unity зарелизила несколько механизмов пулинга C#, которые помогут вам во множестве юзкейсов.

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

Огромный плюс — у вас есть доступ к их исходному коду.

И я должен отметить, что их реализации довольно просты. Это приятное вечернее чтиво.

Давайте посмотрим, как вы можете начать использовать Unity Pooling API прямо сегодня, чтобы снизить затраты на операции, о которых и вы, и я прекрасно знаем.

Как использовать новый Object Pooling API в Unity 2021

Первый шаг — убедиться, что вы используете Unity 2021+.

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

После этого, это просто вопрос знания Unity Pooling API:

  •  Операции пулинга

  • Различные контейнеры пулов

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

(СМОТРИТЕ ВИДЕО В ОРИГИНАЛЕ СТАТЬИ)

1. Построение вашего пула

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

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

createFunc

Вызывается для создания нового экземпляра вашего объекта, например () => new GameObject(“Bullet”) or () => new Vector3(0,0,0)

actionOnGet

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

actionOnRelease

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

actionOnDestroy

Вызывается, когда пул уничтожает этот элемент, то есть когда он не помещается (превышает максимальный размер) или пул уничтожается.

collectionCheck

True, если вы хотите, чтобы Unity проверяла, что этот элемент еще не был в пуле, когда вы пытаетесь его вернуть (только в редакторе).

defaultCapacity

Размер пула по умолчанию: начальный размер стека/списка, который будет содержать ваши элементы.

maxSize

Размер пула: максимальное количество свободных элементов, которые находятся в пуле в любой момент времени. Если вы вернете предмет в заполненный пул, он будет уничтожен.

Вот как вы можете создать пул GameObjects:

_pool = new ObjectPool<GameObject>(createFunc: () => new GameObject("PooledObject"), actionOnGet: (obj) => obj.SetActive(true), actionOnRelease: (obj) => obj.SetActive(false), actionOnDestroy: (obj) => Destroy(obj), collectionChecks: false, defaultCapacity: 10, maxPoolSize: 10);

Я оставил названия параметров для наглядности; не стесняйтесь пропускать их в вашем коде.

И, конечно же, это всего лишь пример с GameObject. Вы можете использовать его с любым типом, с которым захотите.

Хорошо, теперь у вас есть пул _GameObject_’ов.

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

2. Создание элементов пула

Первое, что Unity нужно знать, — это как создавать больше ваших _GameObject_’ов, когда вы запрашиваете больше, чем доступно.

Мы уже указали это в конструкторе, поскольку передали функцию createFunc в качестве первого параметра конструктору пула.

Каждый раз, когда вы захотите взять GameObject из пустого пула, Unity создаст его для вас и отдаст вам.

И для его создания он будет использовать переданную вами функцию createFunc.

А как нам взять GameObject из пула?

3. Извлечение элемента из пула

Теперь, когда ссылка на пул хранится в _pool, вы можете вызвать его функцию Get:

GameObject myGameObject = _pool.Get();

Вот и все.

Теперь вы можете использовать объект по своему усмотрению (в определенных рамках).

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

4. Возврат элемента в пул

Итак, вы использовали свой элемент несколько минут, и теперь он вам больше не нужен. Что дальше?

Вот чего вы сейчас не делаете: вы не уничтожаете (destroy/dispose) его сами. Вместо этого вы возвращаете его в пул, чтобы пул мог правильно управлять своим жизненным циклом в соответствии с предоставленными вами функциями.

Как это сделать? Легко:

_pool.Return(myObject);

Тогда пул:

  1. Вызовет функцию actionOnRelease, которую вы предоставили с этим элементом качестве аргумента, чтобы деактивировать его, остановить систему частиц и т.д. ...

  2. Проверит, есть ли достаточно места в своем внутреннем списке/стеке на основе MaxSize

  3. Если есть достаточно свободного пространство в контейнере, он поместит туда объект.

  4. Если свободного места нет, то он уничтожит объект, вызвав actionOnDestroy.

Вот и все.

А теперь об уничтожении элементов.

5. Уничтожение элемента из вашего пула

Всякий раз, когда вы утилизируете (dispose) свой пул, или в нем нет внутреннего пространства для хранения возвращаемых вами элементов, пул уничтожает эти элементы.

И делает это он, вызывая функцию actionOnDestroy, которую вы передали в его конструкторе.

Эта функция может быть совершенно пустой или вызывать Destroy(myObject), если мы говорим об объектах, управляемых Unity.

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

6. Очистка и утилизация вашего пула

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

_pool.Dispose();

Вот это собственно и есть вся функциональность пула. Но нам все еще не хватает одного важного момента.

Не все пулы созданы для одних и тех же юзкейсов

Давайте посмотрим, какие типы пулов предлагает Unity, чтобы удовлетворить ваши потребности.

Типы пулов в Unity 2021+

LinkedPool и ObjectPool

Первая группа пулов — это те, которые охватывают обычные объекты C# (95%+ элементов, которые вы, возможно, захотите поместить в пул).

Типичным вариантом использования пулов этого типа являются игровые объекты — независимо от того, созданы они из префабов или нет.

Разница между LinkedPool и ObjectPool заключается во внутренней структуре данных, которую Unity использует для хранения элементов, которые вы хотите поместить в пул.

ObjectPool просто использует Stack C#, который использует массив C# под капотом:

private T[] _array;

Будучи стеком, он содержит большой кусок непрерывной памяти.

Наихудший случай — наличие 0 элементов (длина = 0) в большом пуле (емкость = 100000). Там у вас будет большой кусок зарезервированной памяти, который вы не используете.

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

Подсказка: вы можете избежать изменения размера стека, играя с параметром конструктора maxCapacity.

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

internal class LinkedPoolItem { internal LinkedPool<T>.LinkedPoolItem poolNext; internal T value; }

Используя LinkedPool, вы используете память только для элементов, которые фактически хранятся в пуле.

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

Итак, давайте поговорим о следующей категории классов пулов в Unity.

ListPool, DictionaryPool, HashSetPool, CollectionPool

Теперь мы поговорим о пулах коллекций C# в Unity.

Видите ли, при разработке игр вам, скорее всего, придется использовать списки, словари, хеш-множества и коллекции.

И достаточно часто вам нужно часто создавать/уничтожать эти коллекции.

Мы часто делаем это в структурах ИИ при выполнении определенных одноразовых действий или алгоритмов. Там нам часто требуются вспомогательные структуры данных для выполнения поиска, оценки и скоринга.

Вот в чем собственно дело.

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

  • Аллоцируете и высвобождаете коллекцию плюс ее внутренние структуры данных.

  • Вы можете динамически изменять размер своих коллекций.

Таким образом, решение, которое помогает с некоторыми из этих рантайм аллокаций в Unity, - это пулинг коллекций.

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

Вот пример:

var manuallyReleasedPooledList = ListPool<Vector2>.Get();
manuallyReleasedPooledList.Add(Random.insideUnitCircle);
// Use your pool
// ...
ListPool<Vector2>.Release(manuallyReleasedPooledList);

А вот другая конструкция, которая освобождает для вас пул коллекций:

using (var pooledObject = ListPool<Vector2>.Get(out List<Vector2> automaticallyReleasedPooledList))
{
   automaticallyReleasedPooledList.Add(Random.insideUnitCircle);
   // Use your pool
   // ...
}

Каждый раз, когда вы выходите за пределы этого using блока, Unity будет возвращать список в пул за вас.

CollectionPool - это базовый класс для этих конкретных коллекций; поэтому, если вы создаете свои собственные коллекции, вы можете создать для них пул, унаследовав от него.

ListPool, DictionaryPool и HashSetPool - это особые пулы для соответствующих типов коллекций.

Но вы должны быть осторожны с этими пулами для коллекций. Я говорю это, потому что внутри все эти пулы коллекций в Unity работают на основе статической переменной пула. Это означает вот что.

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

Наконец, давайте посмотрим на других плохишей: GenericPool и его близнеца UnsafeGenericPool.

Они, как и описывают их названия, являются пулами общих объектов. Но в них есть кое-что особенное.

GenericPool и UnsafeGenericPool

Итак, что такого особенного с этими пулами объектов?

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

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

Вы просто используете их, когда и где бы (и кем бы) вы ни находились.

var pooledGameObject = GenericPool<GameObject>.Get(); pooledGameObject.transform.position = Vector3.one; GenericPool<GameObject>.Release(pooledGameObject);

Вот так просто.

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

В этом случае элемент может дважды появиться во внутренней структуре данных пула. И угадайте, что происходит, когда вы берете два элемента?

БАБАХ!

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

Подводя итоги различий:

GenericPool использует статический ObjectPool с collectionCheck = true

UnsafeGenericPool использует статический ObjectPool с collectionCheck = false

Хорошо, как вы видели, не все в пулах красиво и аккуратно. Но вотрем еще немного соли в рану.

Проблемы с пулами (почему вы не должны ими злоупотреблять)

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

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

Вот некоторые из проблем, с которыми вы можете столкнуться при использовании пулов:

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

  2. Вы должны сбросить состояние ваших объектов. Если вы не сбросите состояние своих объектов, старые данные будут попадать в экземпляры, которые вы получаете из пула. Объект больше не будет «свежим». Ваши пули могут содержать следы крови.

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

  4. Управление памятью коллекций трудно. Вы можете пулить списки, хэш-множества, словари и тому подобное. Но делать предположения об их размерах сложно. Когда вы получаете список из пула, он может иметь размер 4, в то время как вам действительно нужен список размером 1000+. Вы бы принудительно изменили размер. Бывает и наоборот. Короче говоря, вы можете в конечном итоге потратить много памяти на поддержание жизни огромных коллекций, когда вам нужно всего несколько предметов для них.

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

Хорошая пища для размышлений.

Так, что еще?

Пулы — отличные инструменты для снижения:

  • затрат производительности, связанных с распределением ресурсов в игровом процессе;

  • давления, которое вы оказываете на бедный сборщик мусора;

А с Unity 2021+ теперь стало проще, чем когда-либо, принять пул как образ жизни разработчика, поскольку теперь у нас есть встроенное pooling API.

Однако я объяснил темную сторону пулов. Сторона, которая может доставить вам массу боли во время разработки вашего проекта.

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


Перевод материала подготовлен в рамках курса "Unity Game Developer. Professional". Если вам интересно узнать о курсе подробнее, приглашаем на день открытых дверей: на нем преподаватель расскажет о формате и особенностях обучения, о программе и выпускном проекте.