paint-brush
ObjectPool パターンを使用してゲーム プロジェクトを強化する方法(Unity ガイド)@jokeresdeu
900 測定値
900 測定値

ObjectPool パターンを使用してゲーム プロジェクトを強化する方法(Unity ガイド)

Les Dibrivniy16m2023/05/10
Read on Terminal Reader

長すぎる; 読むには

オブジェクト プールは、ゲーム プロジェクトの最適化の 99% の基盤です。これは基本原則に基づいています。タスクの完了後、オブジェクトは削除されませんが、別の環境に移動され、取得して再利用できます。 Les Dibrivniy は、なぜこのパターンが不可欠なのかを説明しています。
featured image - ObjectPool パターンを使用してゲーム プロジェクトを強化する方法(Unity ガイド)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

こんにちは、私の名前は Oles Dibrivniy です。Keikiの Unity 開発者です。 EdTech と GameDev が交差する製品、つまり子供の発達のためのアプリと Web 製品を作成しています。現在、当社の製品には 400 万人以上のユーザーがいます。

就職の面接で、ゲーム開発で使用するプログラミング パターンについて尋ねられた場合、最初に言及すべきことの 1 つは ObjectPool です。これは基本原則に基づいています。タスクの完了後、オブジェクトは削除されませんが、別の環境に移動され、取得して再利用できます。

このパターンは、アプリに対するユーザーの認識に直接影響するため、非常に重要です。これは、ゲーム プロジェクトの最適化の 99% の基礎となるはずです。

この記事は、プログラミングの達人には関係ないかもしれませんが、その逆の初心者には関係ありません。ここでは、例を使用して、このパターンが不可欠である理由を説明します。


ObjectPool がなければどうなるでしょうか?

まず、設定から始めて、ObjectPool を使用しないプロジェクト ケースを分析します。主人公と 2 人の敵が火の玉を投げる比較的単純なシーンがあります。

火の玉自体がどのように見えるかを考えてみましょう。フィールド

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 つです。すべてをポイントごとに分析しましょう。

1. デバイスの CPU とメモリの非効率的な使用。

 Instantiate
Destroy
コストのかかる操作です。数秒ごとに呼び出すと、遅延や遅延が発生します。これは、メモリのすべてのバイトを保存する必要があるスマートフォンなどのガジェットで作業する場合に特に顕著です。 Unity プロファイラーを開き、Hierarchy ウィンドウの検索フィールドに Instantiate と入力して、これらの操作がゲームにとってどれほど「苦痛」であるかを確認できます。次のようになります。

確かに、劇的な効果を最大化するために、各敵が一度に作成する火の玉の数を 500 に増やしました。ただし、特に UI 要素やパーティクルを扱う場合は、より頻繁にオブジェクトが作成および削除されるプロジェクトを想像するのは簡単です。

これらのプロセスは、実行時に常に発生します。ステージ上に何百ものオブジェクトをスポーンした後、一定のメモリ割り当てが原因で、プレーヤーのフレームが著しく低下することがあります。

2.作成したものはすべて破棄してクリーンアップする必要があります。

Destroy
メソッドはゲーム オブジェクトをシーンから削除し、ガベージ コレクターに送信します。コレクターがそれをいつ、どのように処理するかを制御することはできません。ところで、
 Destroy
非常に卑劣です。ゲーム オブジェクト自体は削除されますが、そのコンポーネントは個別に存続できます。これは、別のオブジェクトがこのコンポーネントにリンクされている場合に発生することがあります。たとえば、特定のイベントへのサブスクリプションである場合があります。

3. コード制御。さまざまなクラスがオブジェクトの作成と破棄を担当し、プロジェクトには数十のクラスが存在する可能性があります。何がどこで作成または削除されたかを見つけることは、時には簡単な作業ではありません。階層内のオブジェクトを制御することについては、今のところ沈黙しています。

ObjectPool をプロジェクトに統合しましょう!

問題を定義したら、その解決策に移りましょう。前述したように、ObjectPool パターン操作の原則は単純です。オブジェクトの処理が終了すると、オブジェクトは削除されず、「プール」に隠されます。オブジェクトを取得して再利用できます。

1 つのエンティティが、オブジェクトの作成、再利用、および破棄を担当します — これを呼び出します

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# パターンの 1 つであるオブザーバーとその実装であるeventsを使用します。火の玉は次のようになります。

 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 }

オブジェクトにオブジェクト プールを掛ける必要があります。

 Enemy
コンポーネント — おめでとうございます!ガベージ コレクターは、別のシーンに移動するか、ゲームを終了するまで残ります。基本的な実装には 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
任意のオブジェクトを操作します。 Instantiate はオブジェクトのコピーしか作成できないため、IPoolable では不十分です。
 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
そしてIPoolable。私は
ObjectPool
からの継承
MonoBehaviour
、したがって、オブジェクトにぶら下がるコンポーネントの数を減らします。

解決しなければならない問題があります。複数の敵が同時にステージにいる可能性があり、それぞれが同じ火の玉を生成します。全員が同じ道を歩めたら最高だな

ObjectPool
— リソースを節約しすぎることは決してありません。 1 つのクラスに特定のオブジェクト タイプを要求させることも非常に便利です。このようなクラスは、オブジェクトの生成、制御、および最終的な破棄を引き継ぎます。このオプションは、すべてを管理するための最良の選択です。
 ObjectPool
プロジェクトで個別に挑戦することは困難です。

の基本的な要件を完了するには

ObjectPool
、シーンのロード時に一定数のオブジェクトを生成する必要があります。リソース消費の問題を回避するために、読み込みウィンドウが含まれます。さっそく実装してみましょう。

追加のエンティティを作成します

PoolTask
の最終的な実装で
ObjectPool
.このクラスは、1 つのプレハブから作成されたオブジェクトの操作を制御します。

 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
コンストラクターまたは 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 がある場合、キャンバス コンポーネントを使用した変換により、
 localScale
拡張子によります。この簡単な操作を行うことをお勧めします。

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

他のオプションで 3 つのスクリプトを使用できます。

 ObjectPool
 PoolTask
、 と
IPoolable
.したがって、それらをプロジェクトに自由に追加して、オブジェクト プール パターンを 100% 使用してください。