paint-brush
So verbessern Sie Ihr Spielprojekt mithilfe des ObjectPool-Musters (ein Unity-Leitfaden)von@jokeresdeu
906 Lesungen
906 Lesungen

So verbessern Sie Ihr Spielprojekt mithilfe des ObjectPool-Musters (ein Unity-Leitfaden)

von Les Dibrivniy16m2023/05/10
Read on Terminal Reader
Read this story w/o Javascript

Zu lang; Lesen

Der Objektpool ist die Grundlage für die Optimierung von 99 % der Spieleprojekte. Es basiert auf einem elementaren Prinzip: Nach Abschluss seiner Aufgabe wird das Objekt nicht gelöscht, sondern in eine separate Umgebung verschoben und kann abgerufen und wiederverwendet werden. Les Dibrivniy erklärt, warum dieses Muster so wichtig ist.
featured image - So verbessern Sie Ihr Spielprojekt mithilfe des ObjectPool-Musters (ein Unity-Leitfaden)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

Hallo, mein Name ist Oles Dibrivniy und ich bin Unity-Entwickler bei Keiki . Wir entwickeln Produkte an der Schnittstelle von EdTech und GameDev – Apps und Webprodukte für die Entwicklung von Kindern. Mittlerweile haben unsere Produkte mehr als vier Millionen Nutzer.

Wenn Sie während eines Vorstellungsgesprächs nach den Programmiermustern gefragt werden, die Sie in der Spieleentwicklung verwenden, sollten Sie als Erstes ObjectPool erwähnen. Es basiert auf einem elementaren Prinzip: Nach Abschluss seiner Aufgabe wird das Objekt nicht gelöscht, sondern in eine separate Umgebung verschoben und kann abgerufen und wiederverwendet werden.

Dieses Muster wirkt sich direkt auf die Wahrnehmung der App durch den Benutzer aus und ist daher von entscheidender Bedeutung. Es sollte die Grundlage für die Optimierung von 99 % der Spieleprojekte sein.

Dieser Artikel ist möglicherweise nicht für Programmiergurus relevant, sondern umgekehrt für Anfänger. Hier werde ich anhand von Beispielen erklären, warum dieses Muster wichtig ist.


Wie läuft es ohne ObjectPool?

Zuerst analysieren wir den Projektfall ohne ObjectPool und beginnen mit der Einstellung. Wir haben eine relativ einfache Szene mit dem Protagonisten und zwei Feinden, die Feuerbälle werfen:

Überlegen wir, wie der Feuerball selbst aussehen wird. Die Felder

 Rigidbody2D
Und
 _speed
wird für die Bewegung der Feuerbälle verantwortlich sein; Die
 OnTriggerEnter2D
Die Methode funktioniert nach einer Kollision – das Objekt mit der FireBall-Komponente wird zerstört:

 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 }

Der Feind wird auch ganz einfach aussehen:

 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 }

Sie können das anrufen

 Cast
Methode von jedem Punkt in Ihrem Projekt oder Animationsereignissen aus. Wir haben den folgenden Ablauf: Der Feind erzeugte einen Feuerball und schleuderte ihn in die Richtung seines Blicks. Der Feuerball wird zerstört, wenn das erste Hindernis auf dem Weg erreicht wird.

Dieser Ansatz scheint optimal, ist aber eine der schlechtesten Optionen. Lassen Sie uns alles Punkt für Punkt analysieren:

1. Ineffiziente Nutzung der CPU und des Speichers des Geräts.

 Instantiate
Und
 Destroy
sind teure Vorgänge: Der Aufruf alle paar Sekunden führt zu Verzögerungen und Verzögerungen. Dies macht sich besonders bei der Arbeit mit Gadgets wie Smartphones bemerkbar, bei denen jedes Byte Speicher gespart werden muss. Sie können den Unity Profiler öffnen und Instantiate in das Suchfeld des Hierarchiefensters eingeben, um zu sehen, wie „schmerzhaft“ diese Vorgänge für das Spiel sind. Es wird Folgendes geben:

Klar, ich habe die Anzahl der von jedem Feind gleichzeitig erzeugten Feuerbälle auf 500 erhöht, um den dramatischen Effekt zu maximieren. Es ist jedoch leicht, sich Projekte mit noch mehr ständig erstellten und gelöschten Objekten vorzustellen – insbesondere wenn es um UI-Elemente oder Partikel geht.

Diese Prozesse finden ständig zur Laufzeit statt. Aufgrund der konstanten Speicherzuweisung kann es beim Player zu einem spürbaren Rückgang der Frames kommen, nachdem Sie Hunderte von Objekten auf der Bühne erzeugt haben.

2. Alles, was Sie erstellt haben, muss zerstört und gereinigt werden. Der

 Destroy
Die Methode entfernt das Spielobjekt aus der Szene und sendet es an den Garbage Collector. Sie haben keine Kontrolle darüber, wann und wie der Collector sie verarbeitet. Übrigens,
 Destroy
ist sehr hinterhältig. Es löscht das Spielobjekt selbst, aber seine Komponenten können separat weiterleben. Dies kann passieren, wenn ein anderes Objekt mit dieser Komponente verknüpft ist. Beispielsweise kann es sich um ein Abonnement für eine bestimmte Veranstaltung handeln.

3. Codekontrolle. Für die Erstellung und Zerstörung eines Objekts sind verschiedene Klassen verantwortlich, von denen Dutzende im Projekt vorkommen können. Herauszufinden, was und wo erstellt oder gelöscht wird, ist manchmal keine triviale Aufgabe – und ich schweige derzeit über die Steuerung von Objekten in der Hierarchie.

Lassen Sie uns ObjectPool in das Projekt integrieren!

Nachdem wir das Problem definiert haben, fahren wir mit seiner Lösung fort. Wie ich bereits erwähnt habe, ist das Prinzip der ObjectPool-Musteroperation einfach: Nach Abschluss der Arbeit mit dem Objekt wird es nicht gelöscht, sondern im „Pool“ versteckt. Das Objekt kann daraus abgerufen und wiederverwendet werden:

Eine Entität wird für die Erstellung, Wiederverwendung und Zerstörung eines Objekts verantwortlich sein – wir nennen es

 ObjectPool
. Wir können es so gestalten, dass es mit einem Feuerball funktioniert:

 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 }

Der

 _freeFireBalls
Die Liste erscheint in diesem Code. Wir werden die erzeugten Feuerbälle, die dort ihre Arbeit getan haben, speichern. Der Feind sieht nun so aus:

 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 }

Die Wurzel unseres Problems: Wie können wir den Feuerball zurück in den Pool bringen? Wir können uns nicht auf den Feind verlassen; Er weiß nicht, wann der Feuerball zerstört wird. Wir möchten auch kein Feuerballwissen weitergeben

 ObjectPool
, weil dadurch unnötige Verbindungen entstehen.

Ich hoffe, Sie haben bemerkt, dass ich das gemacht habe

 ReturnFireBall
Methode privat. Daher werden wir eines der grundlegenden C#-Muster – Observer – und seine Implementierung – Events – verwenden. Der Feuerball wird jetzt so aussehen:

 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
werde das abonnieren
 Destroyed
Ereignis nach der Übergabe des Objekts an die Welt:

 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 }

Es bleibt noch, den Objektpool mit dem an das Objekt zu hängen

 Enemy
Komponente – herzlichen Glückwunsch! Der Garbage Collector bleibt stehen, bis Sie zu einer anderen Szene wechseln oder das Spiel schließen. Wir haben ObjectPool in seiner Grundimplementierung, aber wir können es verbessern.

Schnittstellen und Generika: die perfekte Ergänzung zu ObjectPool

Der

 FireBall
ist nicht der einzige Objekttyp, den wir durch den Objektpool laufen lassen. Sie müssen eine separate schreiben
 ObjectPool
damit jeder mit anderen Typen arbeiten kann. Dadurch wird die Codebasis erweitert und der Code weniger lesbar. Nutzen wir also Nachahmungen und Generika.

Jedes Objekt durchlaufen wir

 ObjectPool
muss an einen bestimmten Typ gebunden sein. Sie müssen unabhängig von der jeweiligen Implementierung verarbeitet werden. Es ist wichtig, die grundlegende Vererbungshierarchie der Objekte beizubehalten, die der Pool verarbeiten wird. Sie werden von erben
 MonoBehaviour
, mindestens. Verwenden wir die IPoolable-Schnittstelle:

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

Wir erben

 FireBall
davon:

 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 }

Die letzte Aufgabe besteht darin, zu unterrichten

 ObjectPool
um mit beliebigen Objekten zu arbeiten. IPoolable wird nicht ausreichen, da Instantiate nur eine Kopie des Objekts mit erstellen kann
 gameObject
Eigentum. Wir werden verwenden
 Component
; Jedes dieser Objekte erbt von dieser Klasse.
 MonoBehaviour
wird auch davon geerbt. Als Ergebnis erhalten wir Folgendes
 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 }

Jetzt können wir eine erstellen

 ObjectPool
für jedes Objekt, das die Bedingungen der Vererbung erfüllt
 Component
und IPoolable. Ich habe das entfernt
 ObjectPool
Erbe von
 MonoBehaviour
Dadurch wird die Anzahl der Komponenten reduziert, die wir an Objekten hängen.

Es gibt ein Problem, das gelöst werden muss. Es können mehrere Feinde gleichzeitig auf der Bühne sein, und jeder von ihnen erzeugt die gleichen Feuerbälle. Es wäre toll, wenn sie alle das Gleiche durchmachen würden

 ObjectPool
— Es kann nie zu viel Ressourceneinsparung geben! Es ist auch sehr praktisch, wenn eine Klasse nach einem bestimmten Objekttyp fragt. Eine solche Klasse übernimmt die Generierung, Kontrolle und eventuelle Entsorgung des Objekts. Diese Option ist die beste Wahl für die Verwaltung aller
 ObjectPool
individuell in das Projekt einzubinden ist eine Herausforderung.

Um die Grundvoraussetzungen zu erfüllen

 ObjectPool
, müssen wir beim Laden der Szene eine festgelegte Anzahl von Objekten generieren. Um Probleme mit dem Ressourcenverbrauch zu vermeiden, werden wir ein Ladefenster einbauen. Beginnen wir jetzt mit der Umsetzung.

Wir werden eine zusätzliche Entität erstellen

 PoolTask
in der endgültigen Umsetzung von
 ObjectPool
. Diese Klasse steuert die Arbeit mit Objekten, die aus einem Fertighaus erstellt wurden:

 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 wird über zusätzliche Funktionen verfügen:

1. Verfolgung der Objekte, die wir in die Welt entlassen haben, damit sie bei Bedarf zu einem bestimmten Zeitpunkt zerstört oder in den Pool zurückgebracht werden können;

2. Generieren einer vorgegebenen Menge freier Objekte.

Lassen Sie uns abschließend einen ObjectPool erstellen, der alle unsere Anforderungen erfüllt und die Steuerung und Generierung von Objekten vollständig übernimmt:

 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 }

Die Verwendung von Singleton könnte Ihnen ins Auge fallen – es handelt sich lediglich um ein Beispiel. Sie können die Verwendung in Ihrem Projekt anpassen – es kann ausgeführt werden

 ObjectPool
durch Konstruktoren oder Injektion durch Zenject.

Wir haben die endgültige Version unseres

 Cast
Methode in
 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 }

Durch die Verwendung von generic erhalten wir sofort ein Objekt des Typs, den wir zur Verarbeitung benötigen.

 ObjectPool
gruppiert interne Aufgaben entsprechend dem Fertigteil – wenn es mehrere davon gibt
 FireBall
Komponente verarbeitet der Pool sie korrekt und gibt Ihnen die richtige Komponente. Dieser Ansatz hilft dabei, jedes beliebige Objekt für die Spielszene zu generieren.

Seien Sie jedoch vorsichtig, wenn Sie mit UI-Elementen arbeiten: Beim Verschieben eines Objekts zwischen übergeordneten Transformationen mit unterschiedlichen

 localScale
, Die
 localScale
des Objekts selbst wird sich ändern. Wenn Ihr Projekt über eine adaptive Benutzeroberfläche verfügt, ändern Transformationen mit der Canvas-Komponente deren
 localScale
abhängig von der Erweiterung. Ich empfehle Ihnen, diesen einfachen Vorgang durchzuführen:

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

Sie können 3 Skripte in anderen Optionen verwenden:

 ObjectPool
,
 PoolTask
, Und
 IPoolable
. Fügen Sie sie also gerne zu Ihrem Projekt hinzu und nutzen Sie das Object Pool-Muster zu 100 %!