
Разрабатывая игры, вы могли заметить, что создание порядка 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 (сбросить).
В случае с шутером вы можете создавать и уничтожать пули каждый раз при выстреле… или вы можете заранее создать определенное количество, а затем повторно использовать их следующим образом:
-
Вы создаете тысячу пуль и помещаете их в пул.
-
Каждый раз, когда вы стреляете из своего оружия, вы берете пулю из этого пула.
-
Когда пуля попадает во что-то и исчезает, вы возвращает ее обратно в пул.
Таким образом вы экономите такты процессора, необходимые для создания и уничтожения этих префабов. Кроме того, вы уменьшаете нагрузку на сборщик мусора.
Теперь, прежде чем сразу нырнуть в пулинг, обратите внимание на несколько моментов…
Когда следует отказаться от использования пула?
У техники пулинга есть несколько (потенциальных) проблем:
-
Ваши элементы могут загрязняться. Поскольку они уже использовались в прошлом, вы могли оставить их в непригодном состоянии, например, пули с небольшими остатками красной краски на них. Это означает, что вам нужно потратить несколько тактов процессора, чтобы очистить свои элементы перед их использованием: операция reset.
-
Вы резервируете память, которая может вам так и не понадобиться. Если вы создаете пул с тысячами пуль, но все, что ваш игрок хотел сделать, это полюбоваться видами, то вы зря потратили память.
-
Это усложняет вашу кодовую базу. Вам необходимо управлять жизненным циклом своих пулов. Это не только увеличивает количество тактов процессора, но и увеличивает количество мозговых тактов из-за обработки большей кодовой базы.
Все, что вам нужно сделать, — это избегать пулов в тех случаях, когда вы не получите от них никакой выгоды.
Скажем, нет никакой необходимости пулить финального босса. В конце концов, он существует в единственном экземпляре.
Помните: самое главное — это частота ваших операций создания и уничтожения.
Если вы делаете их часто, рассматривайте возможность использования пулов. В противном случае даже не думайте об этом.
Позже мы рассмотрим больше проблем с пулами.
Теперь давайте посмотрим на наши доступные варианты для реализации пулов.
Пулы объектов в 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:
|
|
Вызывается для создания нового экземпляра вашего объекта, например |
|
|
Вызывается, когда вы берете экземпляр из пула, например, для активации игрового объекта. |
|
|
Вызывается, когда вы возвращаете экземпляр в пул, например, чтобы очистить и деактивировать экземпляр. |
|
|
Вызывается, когда пул уничтожает этот элемент, то есть когда он не помещается (превышает максимальный размер) или пул уничтожается. |
|
|
True, если вы хотите, чтобы Unity проверяла, что этот элемент еще не был в пуле, когда вы пытаетесь его вернуть (только в редакторе). |
|
|
Размер пула по умолчанию: начальный размер стека/списка, который будет содержать ваши элементы. |
|
|
Размер пула: максимальное количество свободных элементов, которые находятся в пуле в любой момент времени. Если вы вернете предмет в заполненный пул, он будет уничтожен. |
Вот как вы можете создать пул 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);
Тогда пул:
-
Вызовет функцию
actionOnRelease, которую вы предоставили с этим элементом качестве аргумента, чтобы деактивировать его, остановить систему частиц и т.д. … -
Проверит, есть ли достаточно места в своем внутреннем списке/стеке на основе MaxSize
-
Если есть достаточно свободного пространство в контейнере, он поместит туда объект.
-
Если свободного места нет, то он уничтожит объект, вызвав
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 статьи, подробно описывающих неприятные проблемы, которые могут возникнуть с пулами.
Но вместо того, чтобы делать это, я просто обозначу их здесь, основываясь на отличном посте Джексона Данстана.
Вот некоторые из проблем, с которыми вы можете столкнуться при использовании пулов:
-
Вашим объектам требуется явный возврат. Если вы забудете вернуть объект пула, сборщик мусора должен будет выполнить ту работу, которой вы хотели избежать в первую очередь (в лучшем случае).
-
Вы должны сбросить состояние ваших объектов. Если вы не сбросите состояние своих объектов, старые данные будут попадать в экземпляры, которые вы получаете из пула. Объект больше не будет «свежим». Ваши пули могут содержать следы крови.
-
Если вы используете один и тот же объект из пула в разных местах, вы должны вернуть объект только тогда, когда все его пользователи закончат с ним. Вы не хотите возвращать объект несколько раз, так как это гарантированно вызовет у вас головную боль. Таким образом, вам потребуется какой-то ручной подсчет ссылок, дорогостоящие для процессора проверки или стратегия, чтобы убедиться, что есть только один владелец.
-
Управление памятью коллекций трудно. Вы можете пулить списки, хэш-множества, словари и тому подобное. Но делать предположения об их размерах сложно. Когда вы получаете список из пула, он может иметь размер 4, в то время как вам действительно нужен список размером 1000+. Вы бы принудительно изменили размер. Бывает и наоборот. Короче говоря, вы можете в конечном итоге потратить много памяти на поддержание жизни огромных коллекций, когда вам нужно всего несколько предметов для них.
-
По умолчанию пулы не являются потокобезопасными. А если вы добавите механизмы для поддержки потоковой безопасности, тогда вы добавите накладные расходы на процессор, которые могут больше не окупаться.
Хорошая пища для размышлений.
Так, что еще?
Пулы — отличные инструменты для снижения:
-
затрат производительности, связанных с распределением ресурсов в игровом процессе;
-
давления, которое вы оказываете на бедный сборщик мусора;
А с Unity 2021+ теперь стало проще, чем когда-либо, принять пул как образ жизни разработчика, поскольку теперь у нас есть встроенное pooling API.
Однако я объяснил темную сторону пулов. Сторона, которая может доставить вам массу боли во время разработки вашего проекта.
Пул — это еще один инструмент повышения производительности, который вы должны знать. И чем больше инструментов вы знаете, тем лучше.
Перевод материала подготовлен в рамках курса «Unity Game Developer. Professional». Если вам интересно узнать о курсе подробнее, приглашаем на день открытых дверей: на нем преподаватель расскажет о формате и особенностях обучения, о программе и выпускном проекте.
ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/560880/
Добавить комментарий