Як оптимізувати ігровий проєкт за допомогою патерну ObjectPool. Посібник для Unity-розробників

Привіт, мене звати Олесь Дібрівний, я Unity Developer в компанії Keiki з екосистеми Genesis. Ми створюємо продукт на стику EdTech та GameDev — застосунки й вебпродукти для розвитку дітей. Ними користуються понад п’ять мільйонів юзерів.

Якщо на співбесіді питають про патерни програмування, які ви використовуєте у розробці ігор, одним із перших назвіть ObjectPool. В його основі лежить дуже простий принцип: об’єкт після виконання свого завдання не видаляється, а переміщується в окреме середовище, з якого його можна дістати й використати повторно.

Цей патерн важливий, бо прямо впливає на сприйняття застосунку користувачем. Він має бути в основі оптимізації 99% ігрових проєктів.

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

Як воно, без ObjectPool

Спершу розберемо кейс без ObjectPool, але почнемо з сетингу. Маємо доволі просту сценку з протагоністом і двома ворогами, що кастують фаєрболи:

Розглянемо, як виглядатиме сам фаєрбол. Поля Rigidbody2D та _speed відповідатимуть за рух фаєрболів, а метод OnTriggerEnter2D спрацює після зіткнення — обʼєкт з компонентою FireBall буде знищений:

public class FireBall : MonoBehaviour
2{
3    [SerializeField] private Rigidbody2D _rigidbody;
4    [SerializeField] private float _speed;
5    
6    public void FlyInDirection(Vector2 flyDirection)
7    {
8        _rigidbody.velocity = flyDirection * _speed;
9    }
10    
11    private void OnTriggerEnter2D(Collider2D other)
12    {
13        Destroy(gameObject);
14    }
15}

Ворог теж виглядатиме доволі просто:

public class Enemy : MonoBehaviour
2{
3    [SerializeField] private FireBall _fireBall;
4    [SerializeField] private Transform _castPoint;
5    
6    public void Cast()
7    {
8        FireBall fireBall = Instantiate(_fireBall, _castPoint.position, Quaternion.identity);
9        fireBall.FlyInDirection(transform.right);
10    }
11}

Метод Cast може викликатися з будь-якого місця вашого проєкту чи анімаційних івентів. Маємо такий флоу: ворог створив фаєрбол і запустив його в напрямку погляду. Фаєрбол знищується досягнувши першої перешкоди на шляху.

Такий підхід, на перший погляд, є оптимальним, але це чи не найгірший варіант, що ви можете використати. Розберемо все по пунктах:

  1. Абсолютно неефективне використання CPU та пам’яті девайсу, на якому буде запускатись гра. Instantiate i Destroy є доволі дорогими операціями: викликаючи їх щокілька секунд ви провокуєте затримки та лаги. Особливо це помітно при роботі з мобільними гаджетами, де ми боремось за кожен байт памʼяті. Щоб побачити, наскільки ці операції «болісні» для гри, можна відкрити Unity Profiler та ввести Instantiate в полі пошуку вікна Hierarchy. Ви побачите таке:

    Щоб довести до максимуму драматичний ефект, я збільшив кількість фаєрболів, які створюються за раз кожним ворогом, до 500. Проте легко уявити проєкти, де постійно створюється і видаляється куди більша кількість обʼєктів — особливо при роботі з UI елементами чи партиклами.

    Ці процеси відбуваються постійно в рантаймі. Коли ви будете спавнити на сцені сотні обʼєктів, гравець може відчути помітну просадку фреймів, внаслідок постійного виділення памʼяті.

  2. Все вами створене має бути знищеним та підчищеним. Метод Destroy видаляє game-object зі сцени, віддаючи його на поталу garbage-collector. Ви не можете контролювати те, коли і як колектор його обробить.

    Destroy насправді дуже підступний. Він видаляє сам game-object, але його компоненти можуть продовжувати жити окремо. Це відбувається якщо в іншого обʼєкту буде посилання на цю компоненту, наприклад підписка на певний івент.

  3. Контроль коду. За створення та знищення об’єкта відповідають різні класи, їх можуть бути десятки. Знайти що й де створюється чи видаляється часом не зовсім тривіальна задача — про контроль обʼєктів в ієрархії взагалі мовчу.

Інтегруємо ObjectPool у проєкт

Після визначення проблематики перейдемо до її рішення. Як згадував раніше, принцип роботи патерну ObjectPool дуже простий: після завершення роботи з обʼєктом він не видаляється, а ховається в «басейн». З нього об’єкт можна дістати й використати повторно:

За створення, перевикористання й знищення обʼєкта буде відповідати одна сутність — її й назвемо ObjectPool. Для роботи з фаєрболом він може виглядати так:

public class ObjectPool : MonoBehaviour
2{
3    [SerializeField] private FireBall _fireBallPrefab;
4    
5    private readonly List<FireBall> _freeFireBalls = new List<FireBall>();
6    
7    public FireBall GetFireBall()
8    {
9        FireBall fireBall;
10        if (_freeFireBalls.Count > 0)
11        {
12            fireBall = _freeFireBalls[0];
13            _freeFireBalls.Remove(fireBall);
14        }
15        else
16        {
17            fireBall = Instantiate(_fireBallPrefab, transform);
18        }
19        return fireBall;
20    }
21    
22    private void ReturnFireBall(FireBall fireBall)
23    {
24        _freeFireBalls.Add(fireBall);
25    }
26}

В коді з’являється список _freeFireBalls, в якому ми зберігатимемо створені фаєрболи, що виконали свою роботу. Ворог тепер матиме такий вигляд:

public class Enemy : MonoBehaviour
2{
3    [SerializeField] private ObjectPool _objectPool;
4    [SerializeField] private Transform _castPoint;
5    
6    public void Cast()
7    {
8        FireBall fireBall = _objectPool.GetFireBall();
9        fireBall.transform.position = _castPoint.position;
10        fireBall.FlyInDirection(transform.right);
11    }
12}

Ключове питання: як нам повернути фаєрбол в пул? Покладатися на ворога ми не можемо, він не знає в який момент фаєрбол буде знищено. Давати знання фаєрболу про ObjectPool також не хочеться, бо це створить непотрібні нам звʼязки.

Гадаю, ви помітили, що метод ReturnFireBall я зробив приватним. Тому використаємо один з базових патернів C# — observer і його реалізацію — івенти. Ось як тепер виглядатиме фаєрбол:

public class FireBall : MonoBehaviour
2{
3    [SerializeField] private Rigidbody2D _rigidbody;
4    [SerializeField] private float _speed;
5    
6    public event Action<FireBall> Destroyed;
7    
8    public void FlyInDirection(Vector2 flyDirection)
9    {
10        _rigidbody.velocity = flyDirection * _speed;
11    }
12    
13    private void OnTriggerEnter2D(Collider2D other)
14    {
15        Destroyed?.Invoke(this);
16    }
17}

ObjectPool, передаючи обʼєкт світові, буде підписуватись на івент Destroyed:

public class ObjectPool : MonoBehaviour
2{
3    [SerializeField] private FireBall _fireBallPrefab;
4    private readonly List<FireBall> _freeFireBalls = new List<FireBall>();
5    
6    public FireBall GetFireBall()
7    {
8        FireBall fireBall;
9        if (_freeFireBalls.Count > 0)
10        {
11            fireBall = _freeFireBalls[0];
12            _freeFireBalls.Remove(fireBall);
13        }
14        else
15        {
16            fireBall = Instantiate(_fireBallPrefab, transform);
17        }
18        fireBall.Destroyed += ReturnFireBall;
19        return fireBall;
20    }
21    
22    private void ReturnFireBall(FireBall fireBall)
23    {
24        fireBall.Destroyed -= ReturnFireBall;
25        _freeFireBalls.Add(fireBall);
26    }
27}

Лишається повісити Object Pool на обʼєкт з компонентом Enemy — готово. Тепер garbage colllector відпочиватиме до моменту переходу на іншу сцену чи закриття гри. Маємо ObjectPool в його базовій реалізації, проте його можна покращити.

Інтерфейси та дженеріки — ідеальне доповення ObjectPool

Основна ідея полягає в тому, що FireBall — не єдиний тип обʼєктів, який ми проганятимемо через пул обʼєктів. Щоб працювати з іншими типами доведеться писати окремий ObjectPool для кожного. Це розширить кодову базу й ускладнить читабельність коду, тому використаємо наслідування та дженеріки.

Кожен обʼєкт, який ми проганятимемо через ObjectPool, має підв’язуватись до певного типу. Їх необхідно обробляти незалежно від конкретної реалізації. Важливо не порушувати основну ієрархію наслідування обʼєктів, що будуть оброблятися пулом. Вони, як мінімум, будуть наслідуватись від MonoBehaviour. Використаємо інтерфейс IPoolable:

public interface IPoolable
2{
3    GameObject GameObject { get; }
4    event Action<IPoolable> Destroyed;
5    void Reset();
6}

Від нього наслідуємо FireBall:

public class FireBall : MonoBehaviour, IPoolable
2{
3    [SerializeField] private Rigidbody2D _rigidbody;
4    [SerializeField] private float _speed;
5    public GameObject GameObject => gameObject;
6    
7    public event Action<IPoolable> Destroyed;
8    
9    private void OnTriggerEnter2D(Collider2D other)
10    {
11        Reset();
12    }
13    public void Reset()
14    {
15        Destroyed?.Invoke(this);
16    }
17    
18    public void FlyInDirection(Vector2 flyDirection)
19    {
20        _rigidbody.velocity = flyDirection * _speed;
21    }
22}

Лишилось навчити ObjectPool працювати з будь-якими обʼєктами. IPoolable буде замало, адже Instantiate може створити тільки копію обʼєкта з властивістю gameObject. Використаємо клас, від якого наслідується кожен такий обʼєкт — Component. MonoBehaviour теж наслідується від цієї компоненти. В результаті вийде такий ObjectPool:

public class ObjectPool<T> where T : Component, IPoolable 
2{
3    private readonly List<IPoolable> _freeObjects;
4    private readonly Transform _container;
5    private readonly T _prefab;
6    
7    public ObjectPool(T prefab)
8    {
9        _freeObjects = new List<IPoolable>();
10        _container = new GameObject().transform;
11        _container.name = prefab.GameObject.name;
12        _prefab = prefab;
13    }
14    
15    public IPoolable GetFreeObject()
16    {
17        IPoolable poolable;
18        if (_freeObjects.Count > 0)
19        {
20            poolable = _freeObjects[0] as T;
21            _freeObjects.RemoveAt(0);
22        }
23        else
24        {
25            poolable = Object.Instantiate(_prefab, _container);
26        }
27        poolable.GameObject.SetActive(true);
28        poolable.Destroyed += ReturnToPool;
29        return poolable;
30    }
31    
32    private void ReturnToPool(IPoolable poolable)
33    {
34        _freeObjects.Add(poolable);
35        poolable.Destroyed -= ReturnToPool;
36        poolable.GameObject.SetActive(false);
37        poolable.GameObject.transform.SetParent(_container);
38    }
39}

Тепер можемо створити ObjectPool для будь-якого об’єкта, який виконує умови наслідування від Component i IPoolable. Наслідування ObjectPool від MonoBehaviour я прибрав, зменшивши таким чином кількість компонент, що навішуватимемо на обʼєкти.

Виникає така задача, яку потрібно розв’язати. На сцені одночасно може перебувати кілька ворогів, кожен з яких спавнить однакові фаєрболи. Було б чудово, якби всі вони проходили через один ObjectPool — економії ресурсів не буває забагато! Та й взагалі, мати один клас, в якого можна просити певний тип об’єктів доволі зручно. Такий клас візьме на себе генерацію, контроль та кінцеве видалення обʼєкта, лишивши нам ігрове використання. Цей варіант є оптимальним, бо окремо контролювати кожен ObjectPool в проєкті складно.

Одразу реалізуймо останню з базових потреб ObjectPool: генерацію наперед визначеної кількості обʼєктів під час завантаження сцени, прикриваючи витрату ресурсів вікном завантаження.

У фінальній реалізації ObjectPool створимо додаткову сутність PoolTask — клас, який контролюватиме роботу з обʼєктами, створеними з одного префабу:

public class PoolTask
2{
3    private readonly List<IPoolable> _freeObjects;
4    private readonly List<IPoolable> _objectsInUse;
5    private readonly Transform _container;
6    
7    public PoolTask(Transform container)
8    {
9        _container = container;
10        _objectsInUse = new List<IPoolable>();
11        _freeObjects = new List<IPoolable>();
12    }
13    
14    public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable
15    {
16        for (var i = 0; i < count; i++)
17        {
18            var poolable = Object.Instantiate(prefab, _container);
19            _freeObjects.Add(poolable);
20        }
21    }
22    
23    public T GetFreeObject<T>(T prefab) where T : Component, IPoolable
24    {
25        T poolable;
26        if (_freeObjects.Count > 0)
27        {
28            poolable = _freeObjects[0] as T;
29            _freeObjects.RemoveAt(0);
30        }
31        else
32        {
33            poolable = Object.Instantiate(prefab, _container);
34        }
35        poolable.Destroyed += ReturnToPool;
36        poolable.GameObject.SetActive(true);
37        _objectsInUse.Add(poolable);
38        return poolable;
39    }
40    
41    public void ReturnAllObjectsToPool()
42    {
43        foreach (var poolable in _objectsInUse)
44            poolable.Reset();
45    }
46    
47    public void Dispose()
48    {
49        foreach (var poolable in _objectsInUse)
50            Object.Destroy(poolable.GameObject);
51        
52        foreach (var poolable in _freeObjects)
53            Object.Destroy(poolable.GameObject);
54    }
55    
56    private void ReturnToPool(IPoolable poolable)
57    {
58        _objectsInUse.Remove(poolable);
59        _freeObjects.Add(poolable);
60        poolable.Destroyed -= ReturnToPool;
61        poolable.GameObject.SetActive(false);
62        poolable.GameObject.transform.SetParent(_container);
63    }
64}

Крім функціональності, що ми розібрали, PoolTask матиме додаткові можливості:

  1. Відстежування обʼєктів, які ми віддали у світ, щоб за потреби їх знищити чи повернути в пул в певний момент;
  2. Генерацування заданої кількості вільних обʼєктів.

Нарешті створимо ObjectPool, який буде відповідати всім нашим потребам і повністю братиме на себе контроль та генерування обʼєктів:

public class ObjectPool
2{
3    private static ObjectPool _instance;
4    public static ObjectPool Instance => _instance ??= new ObjectPool();
5    
6    private readonly Dictionary<Component, PoolTask> _activePoolTasks;
7    private readonly Transform _container;
8   
9    private ObjectPool()
10    {
11        _activePoolTasks = new Dictionary<Component, PoolTask>();
12        _container = new GameObject().transform;
13        _container.name = nameof(ObjectPool);
14    }
15    
16    public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable
17    {
18        if(!_activePoolTasks.TryGetValue(prefab, out var poolTask))
19            AddTaskToPool(prefab, out poolTask);
20        
21        poolTask.CreateFreeObjects(prefab, count);
22    }
23    
24    public T GetObject<T>(T prefab) where T : Component, IPoolable
25    {
26        if(!_activePoolTasks.TryGetValue(prefab, out var poolTask))
27            AddTaskToPool(prefab, out poolTask);
28        return poolTask.GetFreeObject(prefab);
29    }
30    
31    public void Dispose()
32    {
33        foreach (var poolTask in _activePoolTasks.Values)
34            poolTask.Dispose();
35    }
36    
37    private void AddTaskToPool<T>(T prefab, out PoolTask poolTask) where T : Component, IPoolable
38    {
39        var taskContainer = new GameObject
40        {
41            name = $"{prefab.name}_pool",
42            transform =
43            {
44                parent = _container
45            }
46        };
47        poolTask = new PoolTask(taskContainer.transform);
48        _activePoolTasks.Add(prefab, poolTask);
49    }
50}

В око може впасти використання Singleton. Тут він використаний лише для прикладу, щоб отримати доступ до ObjectPool. У своєму проєкті ви зможете налаштувати використання під себе — це можуть бути прокидання ObjectPool через конструктори чи інжект через Zenject.

Маємо фінальний вигляд нашого методу Cast в Enemy:

public class Enemy : MonoBehaviour
2{
3    [SerializeField] private FireBall _prefab;
4    [SerializeField] private Transform _castPoint;
5    
6    public void Cast()
7    {
8        FireBall fireBall = ObjectPool.Instance.GetObject(_prefab);
9        fireBall.transform.position = _castPoint.position;
10        fireBall.FlyInDirection(transform.right);
11    }
12}

Завдяки дженерік ми отримуємо обʼєкт потрібного нам для обробки типу відразу. ObjectPool групуватиме внутрішні таски саме за префабом — якщо з компонентою FireBall буде кілька, пул опрацює їх коректно і віддасть вам потрібний. Такий підхід допоможе згенерувати будь-який обʼєкт для ігрової сцени.

Проте будьте уважні при роботі з UI елементами: при перенесенні об’єкта між батьківськими трансформами з різними localScale, буде змінюватись і localScale самого обʼєкту. Якщо у вас в проєкті UI адаптивний, трансформи з компонентою canvas будуть змінювати свій localScale в залежності від розширення. Раджу робити таку просту операцію:

poolable.GameObject.transform.localScale = Vector2.one;

В усьому іншому можна використовувати 3 скрипти: ObjectPool, PoolTask та IPoolable. Тож сміливо додавайте їх в проєкт і починайте використовувати патерн Object Pool на повну!

Підписуйтеся на Telegram-канал @gamedev_dou, щоб не пропустити найважливіші статті і новини

👍ПодобаєтьсяСподобалось16
До обраногоВ обраному7
LinkedIn


Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Думаю додатково до статі можна глянути мій відеоурок на цю тему на українській мові ))

Хотів відкоментувати «підписавсь», а виявляється, що я й був підписаний :)

Підписатись на коментарі