paint-brush
Comment améliorer votre projet de jeu à l'aide du modèle ObjectPool ? (Un guide d'unité)par@jokeresdeu
906 lectures
906 lectures

Comment améliorer votre projet de jeu à l'aide du modèle ObjectPool ? (Un guide d'unité)

par Les Dibrivniy16m2023/05/10
Read on Terminal Reader

Trop long; Pour lire

Object Pool est la base de 99% de l'optimisation des projets de jeux. Il repose sur un principe élémentaire : après avoir terminé sa tâche, l'objet n'est pas supprimé mais déplacé dans un environnement séparé et peut être récupéré et réutilisé. Les Dibrivniy explique pourquoi ce patron est essentiel.
featured image - Comment améliorer votre projet de jeu à l'aide du modèle ObjectPool ? (Un guide d'unité)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

Bonjour, je m'appelle Oles Dibrivniy et je suis développeur Unity chez Keiki . Nous créons des produits à l'intersection d'EdTech et de GameDev - des applications et des produits Web pour le développement des enfants. Aujourd'hui, nos produits comptent plus de quatre millions d'utilisateurs.

Si quelqu'un vous interroge lors d'un entretien d'embauche sur les modèles de programmation que vous utilisez dans le développement de jeux, l'une des premières choses que vous devriez mentionner est ObjectPool. Il repose sur un principe élémentaire : après avoir terminé sa tâche, l'objet n'est pas supprimé mais déplacé dans un environnement séparé et peut être récupéré et réutilisé.

Ce modèle affecte directement la perception de l'application par l'utilisateur, c'est pourquoi il est crucial. Il devrait être la base de 99% de l'optimisation des projets de jeux.

Cet article peut ne pas être pertinent pour les gourous de la programmation mais pour les débutants et vice versa. Ici, je vais utiliser des exemples pour expliquer pourquoi ce modèle est essentiel.


Comment ça se passe sans ObjectPool ?

Dans un premier temps, nous analyserons le cas du projet sans ObjectPool, en commençant par le paramétrage. Nous avons une scène relativement simple avec le protagoniste et deux ennemis lançant des boules de feu :

Considérons à quoi ressemblera la boule de feu elle-même. Les champs

 Rigidbody2D
et
 _speed
sera responsable du mouvement des boules de feu ; le
 OnTriggerEnter2D
fonctionnera après une collision — l'objet avec le composant FireBall sera détruit :

 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 }

L'ennemi aura également l'air assez simple:

 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 }

Vous pouvez appeler le

 Cast
méthode à partir de n'importe quel point de votre projet ou de vos événements d'animation. Nous avons le flux suivant : l'ennemi crée une boule de feu et la lance dans la direction de son regard. La boule de feu est détruite en atteignant le premier obstacle sur le chemin.

Cette approche semble optimale, mais c'est l'une des pires options. Analysons tout point par point :

1. Utilisation inefficace du processeur et de la mémoire de l'appareil.

 Instantiate
et
 Destroy
sont des opérations coûteuses : les appeler toutes les quelques secondes entraîne des retards et des décalages. Cela est particulièrement visible lorsque vous travaillez avec des gadgets tels que les smartphones, où il est nécessaire de sauvegarder chaque octet de mémoire. Vous pouvez ouvrir le profileur Unity et saisir Instancier dans le champ de recherche de la fenêtre Hiérarchie pour voir à quel point ces opérations sont "douloureuses" pour le jeu. Il y aura ce qui suit :

Bien sûr, j'ai augmenté le nombre de boules de feu créées par chaque ennemi à la fois à 500 pour maximiser l'effet dramatique. Cependant, il est facile d'imaginer des projets avec des objets créés et supprimés encore plus constamment, en particulier lorsqu'il s'agit d'éléments ou de particules d'interface utilisateur.

Ces processus se produisent constamment pendant l'exécution. Le joueur peut subir une baisse notable des images en raison de l'allocation constante de mémoire après avoir fait apparaître des centaines d'objets sur la scène.

2. Tout ce que vous avez créé doit être détruit et nettoyé. Le

 Destroy
La méthode supprime l'objet de jeu de la scène et l'envoie au ramasse-miettes. Vous n'avez aucun contrôle sur le moment ou la manière dont le collecteur le traite. D'ailleurs,
 Destroy
est très sournois. Il supprime l'objet de jeu lui-même, mais ses composants peuvent continuer à vivre séparément. Cela peut arriver si un autre objet est lié à ce composant. Par exemple, il peut s'agir d'un abonnement à un certain événement.

3. Contrôle des codes. Différentes classes sont responsables de la création et de la destruction d'un objet, et des dizaines d'entre elles peuvent se trouver dans le projet. Trouver quoi et où est créé ou supprimé n'est parfois pas une tâche triviale - et je suis silencieux sur le contrôle des objets dans la hiérarchie maintenant.

Intégrons ObjectPool dans le projet !

Après avoir défini le problème, passons à sa solution. Comme je l'ai mentionné précédemment, le principe de l'opération de modèle ObjectPool est simple : après avoir terminé le travail avec l'objet, il n'est pas supprimé mais se cache dans le "pool". L'objet peut être récupéré et réutilisé à partir de celui-ci :

Une entité sera responsable de la création, de la réutilisation et de la destruction d'un objet - nous l'appellerons

 ObjectPool
. Nous pouvons le faire ressembler à ceci pour travailler avec une boule de feu :

 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 }

Le

 _freeFireBalls
liste apparaît dans ce code. Nous stockerons les boules de feu créées qui y ont fait leur travail. L'ennemi ressemblera maintenant à ceci :

 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 }

La racine de notre problème : comment renvoyer la boule de feu dans la piscine ? Nous ne pouvons pas compter sur l'ennemi; il ne sait pas quand la boule de feu sera détruite. Nous ne voulons pas non plus donner des connaissances boule de feu sur

 ObjectPool
, car cela créera des connexions inutiles.

J'espère que vous avez remarqué que j'ai fait le

 ReturnFireBall
méthode privée. Par conséquent, nous utiliserons l'un des modèles de base de C# — observer, et son implémentation — events . La boule de feu ressemblera à ceci maintenant :

 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
souscrira à la
 Destroyed
événement après avoir passé l'objet au monde :

 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 }

Il reste à accrocher l'Object Pool sur l'objet avec le

 Enemy
composant — félicitations ! Le ramasse-miettes se reposera jusqu'à ce que vous passiez à une autre scène ou que vous fermiez le jeu. Nous avons ObjectPool dans son implémentation de base, mais nous pouvons l'améliorer.

Interfaces et génériques : le complément parfait à ObjectPool

Le

 FireBall
n'est pas le seul type d'objet que nous exécuterons dans le pool d'objets. Vous devez rédiger un
 ObjectPool
pour que chacun travaille avec d'autres types. Cela élargira la base de code et rendra le code moins lisible. Alors, utilisons des imitations et des génériques.

Chaque objet que nous parcourons

 ObjectPool
doit être lié à un type spécifique. Ils doivent être traités indépendamment de l'implémentation particulière. Il est essentiel de conserver la hiérarchie d'héritage de base des objets que le pool traitera. Ils hériteront de
 MonoBehaviour
, au moins. Utilisons l'interface IPoolable :

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

Nous héritons

 FireBall
à partir de cela:

 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 }

La dernière tâche est d'enseigner

 ObjectPool
travailler avec n'importe quel objet. IPoolable ne suffira pas car Instantiate ne peut créer qu'une copie de l'objet avec le
 gameObject
propriété. Nous utiliserons
 Component
; chacun de ces objets hérite de cette classe.
 MonoBehaviour
en est également hérité. En conséquence, nous obtiendrons ce qui suit
 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 }

Nous pouvons maintenant créer un

 ObjectPool
pour tout objet remplissant les conditions d'héritage de
 Component
et IPoolable. j'ai enlevé le
 ObjectPool
héritage de
 MonoBehaviour
, réduisant ainsi le nombre de composants que nous accrocherons aux objets.

Il y a un problème qui doit être résolu. Plusieurs ennemis peuvent être sur scène en même temps, et chacun d'eux engendre les mêmes boules de feu. Ce serait bien s'ils traversaient tous la même chose

 ObjectPool
— il n'y a jamais trop d'économies de ressources ! Avoir une classe demandant un type d'objet spécifique est également très pratique. Une telle classe prendra en charge la génération, le contrôle et l'élimination éventuelle de l'objet. Cette option est le meilleur choix car la gestion de chaque
 ObjectPool
individuellement dans le projet est un défi.

Pour compléter les exigences de base de

 ObjectPool
, nous devons générer un nombre défini d'objets lors du chargement de la scène. Pour éviter tout problème de consommation de ressources, nous inclurons une fenêtre de chargement. Commençons à mettre cela en œuvre maintenant.

Nous allons créer une entité supplémentaire

 PoolTask
dans la mise en œuvre finale de
 ObjectPool
. Cette classe contrôlera le travail avec des objets créés à partir d'un préfabriqué :

 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 aura des fonctionnalités supplémentaires :

1. Suivre les objets que nous avons lancés dans le monde afin que, si nécessaire, ils puissent être détruits ou remis dans la piscine à un moment précis ;

2. Génération d'une quantité prédéterminée d'objets libres.

Enfin, créons un ObjectPool qui répondra à tous nos besoins et prendra entièrement en charge le contrôle et la génération des objets :

 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 }

L'utilisation de Singleton peut attirer votre attention - ce n'est qu'un exemple. Vous pouvez personnaliser l'utilisation dans votre projet - il peut être en cours d'exécution

 ObjectPool
via les constructeurs ou l'injection via Zenject.

Nous avons la version finale de notre

 Cast
méthode dans
 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 }

Nous obtenons un objet du type dont nous avons besoin pour le traitement immédiat en raison de l'utilisation de générique.

 ObjectPool
regroupera les tâches internes selon le préfabriqué — s'il y en a plusieurs avec le
 FireBall
composant, le pool les traitera correctement et vous donnera le bon. Cette approche aidera à générer n'importe quel objet pour la scène de jeu.

Cependant, soyez prudent lorsque vous travaillez avec des éléments d'interface utilisateur : lors du déplacement d'un objet entre des transformations parent avec des

 localScale
, le
 localScale
de l'objet lui-même va changer. Si vous avez une interface utilisateur adaptative dans votre projet, les transformations avec le composant canvas changeront leur
 localScale
en fonction de l'extension. Je vous conseille de faire cette opération simple :

 poolable.GameObject.transform.localScale = Vector2. on e ;

Vous pouvez utiliser 3 scripts dans d'autres options :

 ObjectPool
,
 PoolTask
, et
 IPoolable
. N'hésitez donc pas à les ajouter à votre projet et à utiliser le pattern Object Pool à 100% !