Як оптимізувати ігровий проєкт за допомогою патерну 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
може викликатися з будь-якого місця вашого проєкту чи анімаційних івентів. Маємо такий флоу: ворог створив фаєрбол і запустив його в напрямку погляду. Фаєрбол знищується досягнувши першої перешкоди на шляху.
Такий підхід, на перший погляд, є оптимальним, але це чи не найгірший варіант, що ви можете використати. Розберемо все по пунктах:
- Абсолютно неефективне використання CPU та пам’яті девайсу, на якому буде запускатись гра.
Instantiate
iDestroy
є доволі дорогими операціями: викликаючи їх щокілька секунд ви провокуєте затримки та лаги. Особливо це помітно при роботі з мобільними гаджетами, де ми боремось за кожен байт памʼяті. Щоб побачити, наскільки ці операції «болісні» для гри, можна відкрити Unity Profiler та ввести Instantiate в полі пошуку вікна Hierarchy. Ви побачите таке:Щоб довести до максимуму драматичний ефект, я збільшив кількість фаєрболів, які створюються за раз кожним ворогом, до 500. Проте легко уявити проєкти, де постійно створюється і видаляється куди більша кількість обʼєктів — особливо при роботі з UI елементами чи партиклами.
Ці процеси відбуваються постійно в рантаймі. Коли ви будете спавнити на сцені сотні обʼєктів, гравець може відчути помітну просадку фреймів, внаслідок постійного виділення памʼяті.
- Все вами створене має бути знищеним та підчищеним. Метод
Destroy
видаляє game-object зі сцени, віддаючи його на поталу garbage-collector. Ви не можете контролювати те, коли і як колектор його обробить.Destroy
насправді дуже підступний. Він видаляє сам game-object, але його компоненти можуть продовжувати жити окремо. Це відбувається якщо в іншого обʼєкту буде посилання на цю компоненту, наприклад підписка на певний івент. - Контроль коду. За створення та знищення об’єкта відповідають різні класи, їх можуть бути десятки. Знайти що й де створюється чи видаляється часом не зовсім тривіальна задача — про контроль обʼєктів в ієрархії взагалі мовчу.
Інтегруємо 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
матиме додаткові можливості:
- Відстежування обʼєктів, які ми віддали у світ, щоб за потреби їх знищити чи повернути в пул в певний момент;
- Генерацування заданої кількості вільних обʼєктів.
Нарешті створимо 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 на повну!
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів