Патерн ObjectPool для космічного шутеру в Unity: визначення, реалізація, приклади
Вітаю! Мене звати Андрій, я Unity Developer в Lumamind Studio. В цій статті хочу підняти тему оптимізації проєктів, створених з допомогою рушія Unity, а саме — патерн object pool.
Проблема
Оптимізація ігор — тема, про яку можна говорити багато. В мобільних іграх час від часу доводиться шукати нестандартні рішення для тривіальних проблем, викручуватись шейдерами, десять разів думати перед використанням фізики тощо.
Однією з таких проблем є постійний спавн та видалення об’єктів, наприклад, проджектайлів, луту або юнітів. Методи Instantiate та Destroy — досить вимогливі, тому їх використання може перетворити будь-яку гру на пекло з лоу-фпс.
Розберемось на прикладі простої гри типу space shooter:

У нас є один гравець та вороги, які спавняться нагорі. І гравець, і вороги спавнять проджектайли. Ну і ще літають метеорити, щоб було не так сумно :)
Виглядає не надто складно, чи не так? Ваш девайс теж легко із цим впорається. А тепер уявимо, що ворогів буде набагато більше, стріляють вони та гравець не поодинокими проджектайлами, а цілими чергами. Девайсу вже не так добре, бо йому постійно, майже на кожному фреймі, доведеться обчислювати спавн та знищення різноманітних об’єктів.
Як наслідок маємо збільшення часу обробки цих методів та просадку частоти кадрів:

Object pool
В таких випадках на допомогу приходить патерн «object pool»! Ідея в тому, що об’єкти інстанціюються лише один раз, а після використання не видаляються, а просто деактивуються, щоб потім їх знову можна було використати.
Таким чином, пул дозволяє уникнути постійного створення нових об’єктів, що дозволяє значно зекономити ресурси у великих (насправді, не тільки) проєктах, в яких є потреба постійно спавнити нові об’єкти.
Загалом життєвий цикл таких об’єктів можна зобразити двома схемами:

Реалізація
Перш за все, варто подумати над архітектурою самого пулу. Варіантів реалізації безліч, одна з них може виглядати так:

В основі нам потрібен спільний інтерфейс для всіх об’єктів, які ми будемо зберігати в пулі. Це може бути безпосередньо інтерфейс, я ж створю базовий клас PoolableObject із двома методами, від якого будуть успадковуватись всі ці об’єкти:
public class PoolableObject : MonoBehaviour
{
public virtual void OnSpawn()
{
gameObject.SetActive(true);
}
public virtual void OnDespawn()
{
gameObject.SetActive(false);
}
}
Щодо решти сутностей — варто розібратися, що це таке та навіщо воно нам потрібно.
- Pool — власне клас пулу, який буде створювати, зберігати та віддавати нам об’єкти.
- PoolController — це ScriptableObject, який буде зберігати в собі об’єкти пулів за типом (окремо проджектайли, окремо вороги тощо).
- PoolManager — MonoBehaviour, який буде ініціалізувати та чистити контролери.
Пройдемося по кожному з класів та розберемо їх структуру.
Pool
Клас пулу виконує головну роль — створення, зберігання та контроль об’єктів. Відповідно йому потрібне поле для префаба, з якого він буде створювати об’єкти, та власне сам стек пулу. Також додаємо початковий розмір (пул буде розширюватися автоматично за потребою) та трансформ контейнера, щоб вони не валялися по сцені аби як:
public class Pool<T> where T : PoolableObject
{
[SerializeField] private T prefab;
[Range(0, 100)][SerializeField] private int poolSize;
private Stack<T> _pool = new Stack<T>();
private Transform _container;
}
Pool робимо generic-класом, оскільки типи об’єктів можуть бути різними, і саме за ними ми будемо шукати потрібний нам пул.
Тепер по функціоналу. Перш за все, метод ініціалізації. Тут ми ініціалізуємо контейнер та створюємо об’єкти для пулу:
public void Init(Transform globalContainer)
{
// Створюємо контейнер для об'єктів пулу
_container = new GameObject($"{prefab.GetType().Name}_Pool").transform;
_container.SetParent(globalContainer);
for (int i = 0; i < poolSize; i++)
{
SpawnItem(i);
}
}
Сам метод спавну, який створює об’єкт та додає його в стек. Якщо стеку нема, ініціалізуємо і його:
private void SpawnItem(int number)
{
// Створюємо об'єкт
T item = Object.Instantiate(prefab);
// Перевіряємо, чи існує пул, якщо ні - ініціалізуємо новий
_pool ??= new Stack<T>();
// Додаємо об'єкт в пул
_pool.Push(item);
// Переносимо об'єкт в контейнер та вимикаємо його
item.name += $" {number}";
item.transform.SetParent(_container);
item.OnDespawn();
}
Методи отримання та повернення об’єктів. Якщо об’єкти в пулі закінчилися, створюємо новий:
public T GetItem()
{
// Якщо пул не проініціалізовано, повертаємо дефолтне значення
if (_pool == null)
return default;
// Якщо об'єкти в пулі закінчилися, спавнимо новий
if (_pool.Count == 0)
SpawnItem(0);
// Беремо об'єкт з пулу та вмикаємо його
T item = _pool.Pop();
item.OnSpawn();
return item;
}
public void ReturnItem(T item)
{
// Повертаємо об'єкт в пул та вимикаємо
_pool?.Push(item);
item.OnDespawn();
}
І на останок метод очистки пула:
public void Clear()
{
for (int i = 0; i < _pool.Count; i++)
{
Object.Destroy(_pool.Pop().gameObject);
}
_pool?.Clear();
_pool = null;
}
На цьому з пулами все, переходимо до контролерів.
PoolController
Головні задачі контролера — ініціалізація та очищення пулів, а також пошук об’єктів у відповідних пулах та повернення їх назад. Посилання на контролери ми будемо передавати об’єктам, які працюватимуть з пулами. Крім того, тут ми робимо глобальний контейнер для пулів цього типу.
Поле буде одне — серіалізований масив із самими пулами:
public class PoolController : ScriptableObject
{
[SerializeField] private Pool<PoolableObject>[] pools;
}
Ініціалізація та очищення:
public void Init()
{
Transform globalContaner = new GameObject($"{name}_Container").transform;
for (int i = 0; i < pools.Length; i++)
{
pools[i].Init(globalContaner);
}
}
public void Clear()
{
for (int i = 0; i < pools.Length; i++)
{
pools[i].Clear();
}
}
Для зручності пошук пулу винесено в окремий метод, де ми як раз і перевіряємо тип об’єкта:
private Pool<PoolableObject> GetPool<T>()
{
// Проходимо по всім пулам
for (int i = 0; i < pools.Length; i++)
{
// Якщо тип пулу співпадає з потрібним, повертаємо потрібний пул
if (pools[i].CheckItemType<T>())
return pools[i];
}
return null;
}
І власне методи для отримання та повернення об’єкта у відповідний пул:
public T GetFromPool<T>() where T : PoolableObject
{
// Знаходимо потрібний пул
Pool<PoolableObject> pool = GetPool<T>();
if (pool == null)
return null;
// Забираємо з нього об'єкт
return pool.GetItem() as T;
}
public void ReturnToPool<T>(T item) where T : PoolableObject
{
Pool<PoolableObject> pool = GetPool<T>();
pool?.ReturnItem(item);
}
PoolManager
На останок менеджер. В цьому прикладі ми будемо використовувати менеджер виключно як MonoBehaviour-клас, який буде ініціалізувати та очищати контролери.
За бажанням можна обійтися без нього, делегувавши ці задачі іншому менеджеру. Або ж, навпаки, зробити PoolManager головною точкою входу в систему пулів. Для цього треба додати менеджеру методи для пошуку об’єктів та повернення їх у пул.
Для нашого варіанту нам потрібен тільки масив самих контролерів:
[SerializeField] private PoolController[] controllers;
На івенти Awake та OnDestroy чіпляємо ініціалізацію та очистку відповідно:
private void Awake()
{
for (int i = 0; i < controllers.Length; i++)
{
controllers[i].Init();
}
}
private void OnDestroy()
{
for (int i = 0; i < controllers.Length; i++)
{
controllers[i].Clear();
}
}
Приклад використання
Тепер, коли у нас є система пулів, ми можемо замінити створення, наприклад, проджектайлів на виклик їх з пулу. Було:
public class Enemy : MonoBehaviour
{
private void Attack()
{
Instantiate(projectilePrefab, _transform.position, Quaternion.identity);
}
// Some code...
}
Стало:
public class Enemy : MonoBehaviour
{
// Додаємо посилання на потрібні контролери
[SerializeField] private PoolController projectilePools;
[SerializeField] private PoolController enemyPools;
private void Attack()
{
// Беремо готовий проджектайл з пулу
EnemyProjectile projectile = projectilePools.GetFromPool<EnemyProjectile>();
projectile.transform.position = _transform.position;
}
// Some code...
}
А також видалення об’єктів замінимо на повернення їх до пулу для повторного використання. Було:
public class EnemyProjectile : MonoBehaviour
{
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("Player"))
Destroy(gameObject);
}
}
Стало:
public class EnemyProjectile : PoolableObject
{
[SerializeField] private PoolController projectilePools;
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("Player"))
ReturnToPool();
}
private void ReturnToPool()
{
// Повертаємо об'єкт до пулу
projectilePools.ReturnToPool(this);
}
// Some code...
}
В редакторі це виглядатиме так:


Підбиваючи підсумки
Object pool — необхідна річ для життєдіяльності майже будь-якого середнього або великого проекту.
Як саме ви реалізуєте його — залежить від вашої задачі та ваших уподобань. Хтось може сказати, що в моєму прикладі велосипед з масивів на масивах, і навіщо так ускладнювати; комусь можливо простіше буде зробити єдину точку входу в менеджері чи взагалі відмовитись від ScriptableObject’ів; хтось можливо захоче додати чогось свого.
Сподіваюсь, розробники-початківці знайдуть цей туторіал корисним, і він допоможе запобігти виникненню проблем з перфомансом у вашій грі.
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів