Zenject Memory Poolsをなんとなくふわっと理解したくなった方へ
まえがき
Zenjectでは、動的に生成したオブジェクトに対するInjectionを行うためにFactoryを用いることを推奨しています。*1
しかしながら、ゲーム内で動的にオブジェクトを生成破棄することは望ましくありません。通常このような場合は、あらかじめオブジェクトを必要数生成しておき、オブジェクトを再利用する「オブジェクトプーリング」*2を行います。
Zenjectにも、オブジェクトプーリングのようにあらかじめ生成したオブジェクトをプールする「Memory Pool」という機能があります。今回はこの「Memory Pool」について解説します。
Factoryについて
まずは、Memory Poolを用いないFactoryでの一番単純な例を考えてみます。
using System.Collections.Generic;
using Zenject;
namespace MemoryPoolsSample.Scripts.Factory
{
// Pooling を行わない悪い実装例
public class Foo
{
public class Factory : PlaceholderFactory<Foo>
{
}
}
public class FooSpawner
{
private readonly Foo.Factory _fooFactory = default;
private readonly List<Foo> _foos = new List<Foo>();
// Constructor Injection
public FooSpawner(Foo.Factory fooFactory)
{
_fooFactory = fooFactory;
}
// AddFoo を呼び出たびに、新しいヒープメモリが割り当てられる
public void AddFoo()
{
_foos.Add(_fooFactory.Create());
}
// RemoveFoo が呼び出されるたびに、FooSpawnerからFooに対する参照が1つずつ失われ
// 最終的にガーベージコレクタによって回収される。その際、スパイクが発生してしまう。
public void RemoveFoo()
{
_foos.RemoveAt(0);
}
}
public class FooInstaller : MonoInstaller<FooInstaller>
{
public override void InstallBindings()
{
Container.Bind<FooSpawner>().AsSingle();
Container.BindFactory<Foo, Foo.Factory>();
}
}
}
上記は、FooSpawnerがFoo.Factoryによって生成されたFooに対する参照を管理しています。この場合、RemoveFooが呼ばれるとFooSpawnerからFooに対する参照が失われていき、最終的にガーベージコレクタによって回収されます。この時スパイクが発生してしまい望ましくありません。
今度はFactoryで書いたコードをMemoryPoolで書き直してみます。
FactoryをMemory Poolにしてみる
using System.Collections.Generic;
using Zenject;
namespace MemoryPoolsSample.Scripts.Pool
{
public class Foo
{
// Factoryと異なり、PlaceholderFactoryではなくMemoryPoolを継承する。
public class Pool : MemoryPool<Foo>
{
}
}
public class FooSpawner
{
private readonly Foo.Pool _fooPool = default;
private readonly List<Foo> _foos = new List<Foo>();
// Constructor Injection
public FooSpawner(Foo.Pool fooPool)
{
_fooPool = fooPool;
}
// AddFoo を呼び出たすと、生成時には新しくヒープが割り当てられるが、
// 未使用のFooがある場合そちらが再利用される。
public void AddFoo()
{
// Pool.Spawn()によってFooを生成、再利用する
_foos.Add(_fooPool.Spawn());
}
// RemoveFoo が呼び出されるとFooSpawnerからはFooに対する参照は失われるが
// Pool内に未使用のFooとして山荘が残される。
public void RemoveFoo()
{
var foo = _foos[0];
// Pool.Despawn()によってPoolに使用していたFooを戻す
_fooPool.Despawn(foo);
_foos.Remove(foo);
}
}
public class FooInstaller : MonoInstaller<FooInstaller>
{
public override void InstallBindings()
{
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(Factory Arguments)
.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の派生クラスに以下のメソッドを定義します。
using Zenject;
namespace MemoryPoolsSample.Scripts.ResettingPool
{
public class Foo
{
private int _index = default;
public void Reset(int index)
{
_index = index;
}
// パラメータを追加する場合は、引数を追加する。
public class Pool : MemoryPool<int, Foo>
{
protected override void OnCreated(Foo item)
{
// オブジェクトがプールされた直後に呼ばれます。
}
protected override void OnDestroyed(Foo item)
{
// オブジェクトがプールから削除された時によばれます。
// WithMaxSizeを設定したときや、ShrinkBy、ResizeメソッドによってPoolのサイズ
// が明示的に縮小したときに発生します。
}
protected override void OnSpawned(Foo item)
{
// オブジェクトがPoolから取り出されたときに呼ばれます。
}
protected override void OnDespawned(Foo item)
{
// オブジェクトがPoolに戻されたときに呼ばれます。
}
protected override void Reinitialize(int index, Foo foo)
{
// OnSpawnedと呼ばれるタイミングはほぼ同じです。
// ただし、Pool.Spawn()で渡された引数はここで渡されます。
foo.Reset(index);
}
}
}
public class FooSpawner
{
private readonly Foo.Pool _fooPool = default;
private int _index = 0;
public FooSpawner(Foo.Pool fooPool)
{
_fooPool = fooPool;
}
public void AddFoo()
{
// パラメータを追加するとSpawnに引数が追加される。
_fooPool.Spawn(_index);
_index++;
}
}
public class FooInstaller : MonoInstaller<FooInstaller>
{
public override void InstallBindings()
{
Container.Bind<FooSpawner>().AsSingle();
Container.BindMemoryPool<Foo, Foo.Pool>();
}
}
}
DisposeパターンによるMemory Pools
上記のアプローチは、十分機能しますが、まだいくつか問題点が残っています。それは、クラスをPool可能にしようとするたびにReinitializeメソッドを追加しResetメソッドを呼び出すようにしなければなりません。また、FactoryからPoolに修正を行う際のコストも非常に高くなっています。
PlaceholderFactoryとDisposeパターンを使用して、これらの問題を解決する方法が、Zenjectには用意されています。
using System;
using System.Collections.Generic;
using Zenject;
namespace MemoryPoolsSample.Scripts.DisposableMemoryPool
{
// IPoolable<IMemoryPool>、IDisposableを実装する
public class Foo : IPoolable<IMemoryPool>, IDisposable
{
private IMemoryPool _pool = default;
public void Dispose()
{
_pool.Despawn(this);
}
public void OnDespawned()
{
_pool = null;
}
// 生成時に呼ばれる。初期化を書くのはここ
public void OnSpawned(IMemoryPool pool)
{
_pool = pool;
}
// Factoryの時と同様にPlaceholderFactoryの派生クラスを作る。
public class Factory : PlaceholderFactory<Foo>
{
}
}
public class FooSpawner
{
private readonly Foo.Factory _factory = default;
private readonly List<Foo> _foos = new List<Foo>();
public FooSpawner(Foo.Factory factory)
{
// Factory と同様にCreate()でオブジェクトを生成できる。
_foos.Add(_factory.Create());
}
public void AddFoo()
{
var foo = _foos[0];
// Poolに戻すときはDispose()を呼ぶ。
foo.Dispose();
_foos.Remove(foo);
}
}
public class TestInstaller : MonoInstaller<TestInstaller>
{
public override void InstallBindings()
{
// FromPoolableMemoryPoolを追加する
Container.BindFactory<Foo, Foo.Factory>()
.FromPoolableMemoryPool<Foo, FooPool>();
}
}
// IL2CPP AOT エラーが発生する場合があるので、Poolクラスは明確に定義する必要がある。
public class FooPool : PoolableMemoryPool<IMemoryPool, Foo>
{
}
}
PoolされるクラスにIPoolable、IDisposableを実装することで、上記が解決できるようになっています。ただし、IL2CPPビルドを行う際AOTエラーが発生する場合があるのでPoolクラスは明確に定義する必要があります。
GameObjectsのMemory Pool
GameObjectsのMemory Poolも、MemoryPool
の代わりにMonoMemoryPool
の派生クラスを作成することで実装することができます。
using System.Collections.Generic;
using UnityEngine;
using Zenject;
namespace MemoryPoolsSample.Scripts.GameObjectMemoryPool
{
public class Foo : MonoBehaviour
{
private Vector3 _velocity = default;
public void Update()
{
transform.position += _velocity * Time.deltaTime;
}
private void Reset(Vector3 velocity)
{
transform.position = Vector3.zero;
_velocity = velocity;
}
public class Pool : MonoMemoryPool<Vector3, Foo>
{
protected override void Reinitialize(Vector3 velocity, Foo foo)
{
foo.Reset(velocity);
}
}
}
public class FooSpawner
{
private readonly Foo.Pool _fooPool = default;
private readonly List<Foo> _foos = new List<Foo>();
public FooSpawner(Foo.Pool fooPool)
{
_fooPool = fooPool;
}
public void AddFoo()
{
var maxSpeed = 10.0f;
var minSpeed = 1.0f;
_foos.Add(_fooPool.Spawn(
Random.onUnitSphere * Random.Range(minSpeed, maxSpeed)));
}
public void RemoveFoo()
{
var foo = _foos[0];
_fooPool.Despawn(foo);
_foos.Remove(foo);
}
}
public class TestInstaller : MonoInstaller<TestInstaller>
{
[SerializeField] private Foo _fooPrefab = default;
public override void InstallBindings()
{
Container.Bind<FooSpawner>().AsSingle();
Container.BindMemoryPool<Foo, Foo.Pool>()
.WithInitialSize(2)
.FromComponentInNewPrefab(_fooPrefab)
.UnderTransformGroup("Foos");
}
}
}
MonoMemoryPoolは、Poolにオブジェクトが追加されたときにゲームオブジェクトを自動的に有効、無効に切り替えてくれています。
public abstract class MonoMemoryPool<TParam1, TValue> : MemoryPool<TParam1, TValue>
where TValue : Component
{
Transform _originalParent;
protected override void OnCreated(TValue item)
{
item.gameObject.SetActive(false);
// Record the original parent which will be set to whatever is used in the UnderTransform method
_originalParent = item.transform.parent;
}
protected override void OnDestroyed(TValue item)
{
GameObject.Destroy(item.gameObject);
}
protected override void OnSpawned(TValue item)
{
item.gameObject.SetActive(true);
}
protected override void OnDespawned(TValue item)
{
item.gameObject.SetActive(false);
if (item.transform.parent != _originalParent)
{
item.transform.SetParent(_originalParent, false);
}
}
}
非GameObjectのPoolでも説明したIPoolable、IDisposableを実装したように、GameObjectのPoolでも同様のことが可能です。
using System;
using System.Collections.Generic;
using UnityEngine;
using Zenject;
using Random = UnityEngine.Random;
namespace MemoryPoolsSample.Scripts.GameObjectMemoryPool
{
public class Foo : MonoBehaviour, IPoolable<Vector3, IMemoryPool>, IDisposable
{
private Vector3 _velocity = default;
private IMemoryPool _pool = default;
public void Dispose()
{
_pool.Despawn(this);
}
public void Update()
{
transform.position += _velocity * Time.deltaTime;
}
public void OnDespawned()
{
_pool = null;
_velocity = Vector3.zero;
}
// Create()で渡された引数がここに渡される
public void OnSpawned(Vector3 velocity, IMemoryPool pool)
{
transform.position = Vector3.zero;
_pool = pool;
_velocity = velocity;
}
// Factoryになり派生クラスにResetを描く必要がなくなった
public class Factory : PlaceholderFactory<Vector3, Foo>
{
}
}
public class FooSpawner
{
private readonly Foo.Factory _fooFactory = default;
private readonly List<Foo> _foos = new List<Foo>();
public FooSpawner(Foo.Factory fooFactory)
{
_fooFactory = fooFactory;
}
public void AddFoo()
{
var maxSpeed = 10.0f;
var minSpeed = 1.0f;
//SpawnからCreateに
_foos.Add(_fooFactory.Create(
Random.onUnitSphere * Random.Range(minSpeed, maxSpeed)));
}
public void RemoveFoo()
{
var foo = _foos[0];
// DespawnからDisposeに
foo.Dispose();
_foos.Remove(foo);
}
}
public class TestInstaller : MonoInstaller<DisposableMemoryPool.TestInstaller>
{
public GameObject FooPrefab;
public override void InstallBindings()
{
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クラスは明確に定義する必要がある。
public class FooPool : MonoPoolableMemoryPool<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