Zenject Memory Poolsをなんとなくふわっと理解したくなった方へ
まえがき
Zenjectでは、動的に生成したオブジェクトに対するInjectionを行うためにFactoryを用いることを推奨しています。*1
しかしながら、ゲーム内で動的にオブジェクトを生成破棄することは望ましくありません。通常このような場合は、あらかじめオブジェクトを必要数生成しておき、オブジェクトを再利用する「オブジェクトプーリング」*2を行います。
Zenjectにも、オブジェクトプーリングのようにあらかじめ生成したオブジェクトをプールする「Memory Pool」という機能があります。今回はこの「Memory Pool」について解説します。
Factoryについて
まずは、Memory Poolを用いないFactoryでの一番単純な例を考えてみます。
usingSystem.Collections.Generic;usingZenject;namespaceMemoryPoolsSample.Scripts.Factory{// Pooling を行わない悪い実装例publicclassFoo{publicclassFactory:PlaceholderFactory<Foo>{}}publicclassFooSpawner{privatereadonlyFoo.Factory_fooFactory=default;privatereadonlyList<Foo>_foos=newList<Foo>();// Constructor InjectionpublicFooSpawner(Foo.FactoryfooFactory){_fooFactory=fooFactory;}// AddFoo を呼び出たびに、新しいヒープメモリが割り当てられるpublicvoidAddFoo(){_foos.Add(_fooFactory.Create());}// RemoveFoo が呼び出されるたびに、FooSpawnerからFooに対する参照が1つずつ失われ// 最終的にガーベージコレクタによって回収される。その際、スパイクが発生してしまう。publicvoidRemoveFoo(){_foos.RemoveAt(0);}}publicclassFooInstaller:MonoInstaller<FooInstaller>{publicoverridevoidInstallBindings(){Container.Bind<FooSpawner>().AsSingle();Container.BindFactory<Foo,Foo.Factory>();}}}
上記は、FooSpawnerがFoo.Factoryによって生成されたFooに対する参照を管理しています。この場合、RemoveFooが呼ばれるとFooSpawnerからFooに対する参照が失われていき、最終的にガーベージコレクタによって回収されます。この時スパイクが発生してしまい望ましくありません。
今度はFactoryで書いたコードをMemoryPoolで書き直してみます。
FactoryをMemory Poolにしてみる
usingSystem.Collections.Generic;usingZenject;namespaceMemoryPoolsSample.Scripts.Pool{publicclassFoo{// Factoryと異なり、PlaceholderFactoryではなくMemoryPoolを継承する。publicclassPool:MemoryPool<Foo>{}}publicclassFooSpawner{privatereadonlyFoo.Pool_fooPool=default;privatereadonlyList<Foo>_foos=newList<Foo>();// Constructor InjectionpublicFooSpawner(Foo.PoolfooPool){_fooPool=fooPool;}// AddFoo を呼び出たすと、生成時には新しくヒープが割り当てられるが、// 未使用のFooがある場合そちらが再利用される。publicvoidAddFoo(){// Pool.Spawn()によってFooを生成、再利用する_foos.Add(_fooPool.Spawn());}// RemoveFoo が呼び出されるとFooSpawnerからはFooに対する参照は失われるが// Pool内に未使用のFooとして山荘が残される。publicvoidRemoveFoo(){varfoo=_foos[0];// Pool.Despawn()によってPoolに使用していたFooを戻す_fooPool.Despawn(foo);_foos.Remove(foo);}}publicclassFooInstaller:MonoInstaller<FooInstaller>{publicoverridevoidInstallBindings(){Container.Bind<FooSpawner>().AsSingle();// BindFactoryではなくBindMemoryPoolになるContainer.BindMemoryPool<Foo,Foo.Pool>();}}}
上記では、Fooを生成するときはFactoryと同じですが、Fooを破棄する際FooをPoolに戻すということを行っています。これにより、Foo.Pool,Spawn()を新たに呼び出したとき、以前に生成されたFooを再利用するので、ヒープに再割り当てが行われません。
また、Fooに対する参照はPool内に残っているため、ガーベージコレクタによって使用済みのFooが回収されスパイクが発生することもありません。
Memory PoolのBinding Syntax
Memory PoolのBinding Syntaxは、Factoryとほぼ同じです。ただしWithInitialSize
やExpandBy
などの、Poolingする初期値や最大値に関するメソッドがあります。
Container.BindMemoryPool<ObjectType,MemoryPoolType>().With(InitialSize|FixedSize).WithMaxSize(MaxSize).ExpandBy(OneAtATime|Doubling)().WithFactoryArguments(FactoryArguments).To<ResultType>().WithId(Identifier).FromConstructionMethod().AsScope().WithArguments(Arguments).OnInstantiated(InstantiatedCallback).When(Condition).CopyIntoAllSubContainers().NonLazy();
・WithInitialSize
- Bind時にあらかじめプールするオブジェクトの初期値を決定します。この値を設定することで、ゲーム中における生成時のヒープ割り当てを回避することができます。
・WithFixedSize
- Bind時に設定された数のオブジェクトがプールされ、設定された数を超えるを例外がスローされます。
・MaxSize
- 設定された値以上のオブジェクトをプールせずに破棄します。使用されるオブジェクトの数があらかじめわかっている場合、メモリを節約することができます。
・ExpandBy
- プールサイズが最大に達したときに呼び出す動作を設定できます。ただしWithFixedSize
との併用はできません。
-ExpandByOneAtATime
- プールのサイズを1つずつ大きくします。
-ExpandByDoubling
- 現在のプールのサイズの2倍のプールを新たに確保します。
Pool内から再利用するときのリセット処理
Poolingを行う際、再利用するオブジェクトをリセットする必要があります。例えば「敵」の情報をリセットせずに再利用した場合、位置情報や体力など以前のまま再利用してしまうことになってしまいます。
そのためにMemoryPoolの派生クラスに以下のメソッドを定義します。
usingZenject;namespaceMemoryPoolsSample.Scripts.ResettingPool{publicclassFoo{privateint_index=default;publicvoidReset(intindex){_index=index;}// パラメータを追加する場合は、引数を追加する。publicclassPool:MemoryPool<int,Foo>{protectedoverridevoidOnCreated(Fooitem){// オブジェクトがプールされた直後に呼ばれます。}protectedoverridevoidOnDestroyed(Fooitem){// オブジェクトがプールから削除された時によばれます。// WithMaxSizeを設定したときや、ShrinkBy、ResizeメソッドによってPoolのサイズ// が明示的に縮小したときに発生します。}protectedoverridevoidOnSpawned(Fooitem){// オブジェクトがPoolから取り出されたときに呼ばれます。}protectedoverridevoidOnDespawned(Fooitem){// オブジェクトがPoolに戻されたときに呼ばれます。}protectedoverridevoidReinitialize(intindex,Foofoo){// OnSpawnedと呼ばれるタイミングはほぼ同じです。// ただし、Pool.Spawn()で渡された引数はここで渡されます。foo.Reset(index);}}}publicclassFooSpawner{privatereadonlyFoo.Pool_fooPool=default;privateint_index=0;publicFooSpawner(Foo.PoolfooPool){_fooPool=fooPool;}publicvoidAddFoo(){// パラメータを追加するとSpawnに引数が追加される。_fooPool.Spawn(_index);_index++;}}publicclassFooInstaller:MonoInstaller<FooInstaller>{publicoverridevoidInstallBindings(){Container.Bind<FooSpawner>().AsSingle();Container.BindMemoryPool<Foo,Foo.Pool>();}}}
DisposeパターンによるMemory Pools
上記のアプローチは、十分機能しますが、まだいくつか問題点が残っています。それは、クラスをPool可能にしようとするたびにReinitializeメソッドを追加しResetメソッドを呼び出すようにしなければなりません。また、FactoryからPoolに修正を行う際のコストも非常に高くなっています。
PlaceholderFactoryとDisposeパターンを使用して、これらの問題を解決する方法が、Zenjectには用意されています。
usingSystem;usingSystem.Collections.Generic;usingZenject;namespaceMemoryPoolsSample.Scripts.DisposableMemoryPool{// IPoolable<IMemoryPool>、IDisposableを実装するpublicclassFoo:IPoolable<IMemoryPool>,IDisposable{privateIMemoryPool_pool=default;publicvoidDispose(){_pool.Despawn(this);}publicvoidOnDespawned(){_pool=null;}// 生成時に呼ばれる。初期化を書くのはここpublicvoidOnSpawned(IMemoryPoolpool){_pool=pool;}// Factoryの時と同様にPlaceholderFactoryの派生クラスを作る。publicclassFactory:PlaceholderFactory<Foo>{}}publicclassFooSpawner{privatereadonlyFoo.Factory_factory=default;privatereadonlyList<Foo>_foos=newList<Foo>();publicFooSpawner(Foo.Factoryfactory){// Factory と同様にCreate()でオブジェクトを生成できる。_foos.Add(_factory.Create());}publicvoidAddFoo(){varfoo=_foos[0];// Poolに戻すときはDispose()を呼ぶ。foo.Dispose();_foos.Remove(foo);}}publicclassTestInstaller:MonoInstaller<TestInstaller>{publicoverridevoidInstallBindings(){// FromPoolableMemoryPoolを追加するContainer.BindFactory<Foo,Foo.Factory>().FromPoolableMemoryPool<Foo,FooPool>();}}// IL2CPP AOT エラーが発生する場合があるので、Poolクラスは明確に定義する必要がある。publicclassFooPool:PoolableMemoryPool<IMemoryPool,Foo>{}}
PoolされるクラスにIPoolable、IDisposableを実装することで、上記が解決できるようになっています。ただし、IL2CPPビルドを行う際AOTエラーが発生する場合があるのでPoolクラスは明確に定義する必要があります。
GameObjectsのMemory Pool
GameObjectsのMemory Poolも、MemoryPool
の代わりにMonoMemoryPool
の派生クラスを作成することで実装することができます。
usingSystem.Collections.Generic;usingUnityEngine;usingZenject;namespaceMemoryPoolsSample.Scripts.GameObjectMemoryPool{publicclassFoo:MonoBehaviour{privateVector3_velocity=default;publicvoidUpdate(){transform.position+=_velocity*Time.deltaTime;}privatevoidReset(Vector3velocity){transform.position=Vector3.zero;_velocity=velocity;}publicclassPool:MonoMemoryPool<Vector3,Foo>{protectedoverridevoidReinitialize(Vector3velocity,Foofoo){foo.Reset(velocity);}}}publicclassFooSpawner{privatereadonlyFoo.Pool_fooPool=default;privatereadonlyList<Foo>_foos=newList<Foo>();publicFooSpawner(Foo.PoolfooPool){_fooPool=fooPool;}publicvoidAddFoo(){varmaxSpeed=10.0f;varminSpeed=1.0f;_foos.Add(_fooPool.Spawn(Random.onUnitSphere*Random.Range(minSpeed,maxSpeed)));}publicvoidRemoveFoo(){varfoo=_foos[0];_fooPool.Despawn(foo);_foos.Remove(foo);}}publicclassTestInstaller:MonoInstaller<TestInstaller>{[SerializeField]privateFoo_fooPrefab=default;publicoverridevoidInstallBindings(){Container.Bind<FooSpawner>().AsSingle();Container.BindMemoryPool<Foo,Foo.Pool>().WithInitialSize(2).FromComponentInNewPrefab(_fooPrefab).UnderTransformGroup("Foos");}}}
MonoMemoryPoolは、Poolにオブジェクトが追加されたときにゲームオブジェクトを自動的に有効、無効に切り替えてくれています。
publicabstractclassMonoMemoryPool<TParam1,TValue>:MemoryPool<TParam1,TValue>whereTValue:Component{Transform_originalParent;protectedoverridevoidOnCreated(TValueitem){item.gameObject.SetActive(false);// Record the original parent which will be set to whatever is used in the UnderTransform method_originalParent=item.transform.parent;}protectedoverridevoidOnDestroyed(TValueitem){GameObject.Destroy(item.gameObject);}protectedoverridevoidOnSpawned(TValueitem){item.gameObject.SetActive(true);}protectedoverridevoidOnDespawned(TValueitem){item.gameObject.SetActive(false);if(item.transform.parent!=_originalParent){item.transform.SetParent(_originalParent,false);}}}
非GameObjectのPoolでも説明したIPoolable、IDisposableを実装したように、GameObjectのPoolでも同様のことが可能です。
usingSystem;usingSystem.Collections.Generic;usingUnityEngine;usingZenject;usingRandom=UnityEngine.Random;namespaceMemoryPoolsSample.Scripts.GameObjectMemoryPool{publicclassFoo:MonoBehaviour,IPoolable<Vector3,IMemoryPool>,IDisposable{privateVector3_velocity=default;privateIMemoryPool_pool=default;publicvoidDispose(){_pool.Despawn(this);}publicvoidUpdate(){transform.position+=_velocity*Time.deltaTime;}publicvoidOnDespawned(){_pool=null;_velocity=Vector3.zero;}// Create()で渡された引数がここに渡されるpublicvoidOnSpawned(Vector3velocity,IMemoryPoolpool){transform.position=Vector3.zero;_pool=pool;_velocity=velocity;}// Factoryになり派生クラスにResetを描く必要がなくなったpublicclassFactory:PlaceholderFactory<Vector3,Foo>{}}publicclassFooSpawner{privatereadonlyFoo.Factory_fooFactory=default;privatereadonlyList<Foo>_foos=newList<Foo>();publicFooSpawner(Foo.FactoryfooFactory){_fooFactory=fooFactory;}publicvoidAddFoo(){varmaxSpeed=10.0f;varminSpeed=1.0f;//SpawnからCreateに_foos.Add(_fooFactory.Create(Random.onUnitSphere*Random.Range(minSpeed,maxSpeed)));}publicvoidRemoveFoo(){varfoo=_foos[0];// DespawnからDisposeにfoo.Dispose();_foos.Remove(foo);}}publicclassTestInstaller:MonoInstaller<DisposableMemoryPool.TestInstaller>{publicGameObjectFooPrefab;publicoverridevoidInstallBindings(){Container.Bind<FooSpawner>().AsSingle();Container.BindFactory<Vector3,Foo,Foo.Factory>()// 本来ここではFromMonoPoolableMemoryPoolを用いますが、IL2CPPのAOTを回避するために// Poolクラスを明示的に宣言してFromPoolableMemoryPoolを使用します。.FromPoolableMemoryPool<Vector3,Foo,FooPool>(pool=>pool.WithInitialSize(2).FromComponentInNewPrefab(FooPrefab).UnderTransformGroup("FooPool"));}}// IL2CPP AOT エラーが発生する場合があるので、Poolクラスは明確に定義する必要がある。publicclassFooPool:PoolableMemoryPool<Vector3,IMemoryPool,Foo>{}}
注意しなければならない点は、IL2CPP AOTを回避するためにPoolクラスを明示的に宣言したため、Bindする際、FromMonoPoolableMemoryPool
ではなくFromPoolableMemoryPool
を使用します。
あとがき
以上がZenjectMemory Poolsの概要です。今回解説した内容はDocumentationのIntroduction部分のみでAdvancedの部分は解説できていません。*3
内容に誤りがありましたら、@sai_maple_にご連絡いただけると幸いです。
参考
*1 Zenject/Documentation/Factories
*2 第01回 オブジェクトプーリング
*3 Zenject/Documentation/MemoryPools