#ObjectPoolとは
ObjectPool(オブジェクトプール)とは、オブジェクトを使いまわすための機構のことです。
オブジェクトのInstantiateとDestroyは負荷が大きく、大量に呼び出すとフレームレートを著しく下げてしまうことにつながります。
この問題を回避するための仕組みがObjectPoolであり、一度生成したオブジェクトを残しておいて必要になった時に再利用することが可能になります。
特にモバイルゲーム開発においてObjectPoolは当たり前のように利用されており、自前で実装した方は何人もいると思います。
UniRx.Toolkit.ObjectPool
なぜここでUniRxが?と思われるかもしれませんが、実はUniRxはObjectPoolを作成する機構が最初から用意されています。
これを用いことでObjectPoolを簡単に実装することができるようになっています。
#エフェクトを再生する例
ボタンがクリックされるごとにEffekseerのエフェクトを再生する、というものを題材に実装を紹介したいと思います
##まずはObjectPoolを使わない実装例
ではまず比較のために、ObjectPoolを使わない場合の実装例を紹介します。
動作例
ボタンがクリックされるたびにエフェクトオブジェクトが生成され、一定時間後に破棄が繰り返されている。
実装例
using System;
using UniRx;
using UnityEngine;
namespace Samples
{
/// <summary>
/// エフェクトを再生して一定時間後に削除する
/// </summary>
public class ExplosionEffect : MonoBehaviour
{
private EffekseerEmitter _effectEmitter;
private void Start()
{
_effectEmitter = GetComponent<EffekseerEmitter>();
//エフェクトを再生
_effectEmitter.Play();
//1秒後に破棄
Observable.Timer(TimeSpan.FromSeconds(1.0f)).Subscribe(_ => Destroy(gameObject));
}
}
}
using UniRx;
using UnityEngine;
using UnityEngine.UI;
namespace Samples
{
/// <summary>
/// エフェクトを生成する奴
/// </summary>
public class EffectManager : MonoBehaviour
{
[SerializeField]
private Button _button;
[SerializeField]
private ExplosionEffect _effectPrefab; //エフェクトのPrefab
void Start()
{
int num = 0;
_button.OnClickAsObservable()
.Subscribe(_ =>
{
//ランダムな場所
var position = new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f));
//ボタンが押されたらエフェクト生成
var o = Instantiate(_effectPrefab, position, Quaternion.identity);
//生成と破棄をわかりやすくするために名前を連番にする(本来は不要)
o.name = o.name + num;
num++;
});
}
}
}
解説
特に解説することもなく、必要なタイミングでInstansiateして一定時間後にDestroyしているだけです。
UniRx.Toolkit.ObjectPoolを使った実装
ではこれをUniRx.Toolkit.ObjectPool
を使った実装例に書き換えてみましょう。
使い方は簡単で次の通りに利用すればOKです。
- 対象のコンポーネントTのObjectPoolとして、
UniRx.Toolkit.ObjectPool<T>
を継承したクラスを作成する - 用意したObjectPoolを利用前にインスタンス化する
-
ObjectPool.Rent()
でオブジェクトを取得する - オブジェクトを使い終わったら
ObjectPool.Return(obj)
でオブジェクトを返却する - ObjectPool自体を使い終わったら
Dispose()
を呼ぶ
UniRx.ToolKit.ObjectPool
はオブジェクトの貸出・返却時に自動的にGameObjectのSetAtctiveを切り替えてくれます。
この動作が問題になる場合は後述のOnBeforeRent
、OnBeforeReturn
を実装することで対応することができます。
##実装例
using UniRx.Toolkit;
using UnityEngine;
namespace Samples
{
/// <summary>
/// ExplosionEffectのPool
/// </summary>
public class EffectPool : ObjectPool<ExplosionEffect>
{
private readonly ExplosionEffect _prefab;
private readonly Transform _parenTransform;
//コンストラクタ
public EffectPool(Transform parenTransform, ExplosionEffect prefab)
{
_parenTransform = parenTransform;
_prefab = prefab;
}
/// <summary>
/// オブジェクトの追加生成時に実行される
/// </summary>
protected override ExplosionEffect CreateInstance()
{
//新しく生成
var e = GameObject.Instantiate(_prefab);
//ヒエラルキーが散らからないように一箇所にまとめる
e.transform.SetParent(_parenTransform);
return e;
}
}
}
ObjectPool自体はMonoBehaviourを継承していないため、Componentとして振る舞うことはできません。
using System;
using UniRx;
using UnityEngine;
namespace Samples
{
/// <summary>
/// エフェクトを再生して一定時間後に通知する
/// </summary>
public class ExplosionEffect : MonoBehaviour
{
private EffekseerEmitter _effectEmitter;
private EffekseerEmitter Emitter
{
get
{
//遅延初期化に変更
return _effectEmitter ?? (_effectEmitter = GetComponent<EffekseerEmitter>());
}
}
/// <summary>
/// エフェクトを再生する
/// </summary>
/// <param name="position">再生する座標</param>
/// <returns>再生終了通知</returns>
public IObservable<Unit> PlayEffect(Vector3 position)
{
transform.position = position;
//エフェクトを再生
Emitter.Play();
//1秒後にエフェクトを止めて終了通知
return Observable.Timer(TimeSpan.FromSeconds(1.0f))
.ForEachAsync(_ => Emitter.Stop());
}
}
}
Start()
で再生していたのを変更し、PlayEffect()
を呼び出すことでエフェクトを生成するように変更しました。
また、再生から1秒後に終了通知を発行するようにしました。ForEachAsync()
は Do()
+AsUnitObservable()
と同じ挙動という認識で今回は問題ありません
(なおこの実装の場合はPlayEffect
が返すIObservable
をSubscribeしないとEmitter.Stop()
が実行されないので注意が必要です)
using UniRx;
using UniRx.Triggers;
using UnityEngine;
using UnityEngine.UI;
namespace Samples
{
/// <summary>
/// エフェクトを生成する奴
/// </summary>
public class EffectManager : MonoBehaviour
{
[SerializeField]
private Button _button;
[SerializeField]
private ExplosionEffect _effectPrefab;
[SerializeField]
private Transform _hierarchyTransform; //追加
private EffectPool _effectPool; //追加
void Start()
{
//オブジェクトプールを生成
_effectPool = new EffectPool(_hierarchyTransform, _effectPrefab);
//破棄されたときにPoolを解放する
this.OnDestroyAsObservable().Subscribe(_ => _effectPool.Dispose());
//ボタンが押されたらエフェクト生成
_button.OnClickAsObservable()
.Subscribe(_ =>
{
//ランダムな場所
var position = new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f));
//poolから1つ取得
var effect = _effectPool.Rent();
//エフェクトを再生し、再生終了したらpoolに返却する
effect.PlayEffect(position)
.Subscribe(__ =>
{
_effectPool.Return(effect);
});
});
}
}
}
ObjectPoolをインスタンス化し、そこからオブジェクトを取得する形式になったのが大きな違いです。
比較
_button.OnClickAsObservable()
.Subscribe(_ =>
{
//ランダムな場所
var position = new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f));
//ボタンが押されたらエフェクト生成
Instantiate(_effectPrefab, position, Quaternion.identity);
});
//ボタンが押されたらエフェクト生成
_button.OnClickAsObservable()
.Subscribe(_ =>
{
//ランダムな場所
var position = new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f));
//poolから1つ取得
var effect = _effectPool.Rent();
//エフェクトを再生し、再生終了したらpoolに返却する
effect.PlayEffect(position)
.Subscribe(__ =>
{
_effectPool.Return(effect);
});
});
このように、オブジェクトが必要になったタイミングでRent()
を呼び出すことでオブジェクトを取得することができます。なお、オブジェクトの利用完了した時はReturn()
を呼び出してPoolにオブジェクトを手動で返却する必要があります。
動作例
(2個のオブジェクトを使いまわしてエフェクトを再生している)
(Poolに貯めてある個数以上のオブジェクトが必要になった場合は都度生成される)
UniRx.ToolKit.ObjectPoolの機能をいくつか抜粋して紹介
プールの中身を空にする
Clear()
を呼び出すことでプールサイズを空にすることができます。
似たようなものにDispose()
がありますが、こちらはObjectPool自体を破棄する場合に呼び出すメソッドとなっています。
まだプールを使うならClear()
、もう使わないのならDispose()
を呼ぶとよいでしょう。
プールサイズを事前に拡張する
PreloadAsync
を利用することでプールサイズを拡張する(オブジェクトを事前に生成する)ことができます。
第一引数に目標のサイズを、第二引数に1フレームあたりいくつまでオブジェクトを生成するかを指定します。
(1フレームにまとめて拡張することもでき、何フレームかに分散して拡張することもできるようになっています)
なおPreloadAsync
は非同期実行を前提にしており、Subscribe()を行わないと拡張作業が始まらない点に注意してください。
//プールサイズを10まで拡張(1フレームでまとめて生成)
_effectPool.PreloadAsync(10, 10).Subscribe();
//プールサイズを20まで拡張(1フレームあたり2個ずつゆっくりと生成)
_effectPool.PreloadAsync(20, 2).Subscribe();
//OnNext or OnCompleted は拡張が完了したタイミングで実行される
//生成の途中で例外が出た場合はOnErrorとなって中断される
_effectPool.PreloadAsync(100, 1).Subscribe(_ => Debug.Log("拡張終了"), Debug.LogError);
プールサイズを縮小する
一度に縮小する
Shrink()
を呼び出すことでプールサイズを縮小(オブジェクトを破棄)することができます。
引数は3つあり、それぞれ「縮小の比率」「最低個数」「OnBeforeRent
コールバックの実行の有無」となっています
//プールサイズを30%のサイズまで縮小、ただし最低3個は残す、削除されたオブジェクトのOnBeforeRentコールバックは実行しない
_effectPool.Shrink(instanceCountRatio: 0.3f, minSize: 3, callOnBeforeRent: false);
一定時間ごとにShrinkを実行する
StartShrinkTimer()
を利用することで定期的にShrink()
を実行することができます。
内部実装はObservable.Interval
で定期的にShrink()
を呼び出しているだけになっています。
/// <summary>
/// If needs shrink pool frequently, start check timer.
/// </summary>
/// <param name="checkInterval">Interval of call Shrink.</param>
/// <param name="instanceCountRatio">0.0f = clearAll ~ 1.0f = live all.</param>
/// <param name="minSize">Min pool count.</param>
/// <param name="callOnBeforeRent">If true, call OnBeforeRent before OnClear.</param>
public IDisposable StartShrinkTimer(TimeSpan checkInterval, float instanceCountRatio, int minSize, bool callOnBeforeRent = false)
{
return Observable.Interval(checkInterval)
.TakeWhile(_ => disposedValue)
.Subscribe(_ =>
{
Shrink(instanceCountRatio, minSize, callOnBeforeRent);
});
}
オブジェクトの貸出、返却、削除時に処理を挟む
ObjectPoolの実装時にOnBeforeRent
、OnBeforeReturn
、OnClear
をそれぞれoverrideしてあげることで処理を挟むことができるようになります。
using UniRx.Toolkit;
using UnityEngine;
namespace Samples
{
/// <summary>
/// ExplosionEffectのPool
/// </summary>
public class EffectPool : ObjectPool<ExplosionEffect>
{
private readonly ExplosionEffect _prefab;
private readonly Transform _parenTransform;
//コンストラクタ
public EffectPool(Transform parenTransform, ExplosionEffect prefab)
{
_parenTransform = parenTransform;
_prefab = prefab;
}
/// <summary>
/// オブジェクトの追加生成時に実行される
/// </summary>
protected override ExplosionEffect CreateInstance()
{
var e = GameObject.Instantiate(_prefab);
//ヒエラルキーが散らからないようまとめる
e.transform.SetParent(_parenTransform);
return e;
}
/// <summary>
/// オブジェクトの貸出時に実行される
/// </summary>
protected override void OnBeforeRent(ExplosionEffect instance)
{
//貸し出すオブジェクトのインスタンスIDを出力
Debug.Log(instance.GetInstanceID());
//baseではinstance.gameObject.SetActive(true)を実行している
base.OnBeforeRent(instance);
}
protected override void OnBeforeReturn(ExplosionEffect instance)
{
//baseではinstance.gameObject.SetActive(false)を実行している
base.OnBeforeReturn(instance);
//返却されたオブジェクトのインスタンスIDを出力
Debug.Log(instance.GetInstanceID());
}
protected override void OnClear(ExplosionEffect instance)
{
//削除オブジェクトのインスタンスIDを出力
Debug.Log(instance.GetInstanceID());
//baseでDestoryされる
base.OnClear(instance);
}
}
}
ObjectPool全体の挙動を非同期にする
今紹介したObjectPoolではRent()
およびCreateInstance()
が同期処理でした。
これらの処理を非同期処理として振る舞わせることができるAsyncObjectPool
も用意されており、こちらを用いることでオブジェクトの生成と貸出を非同期化することができます。
using UniRx;
using UniRx.Toolkit;
using UnityEngine;
namespace Samples
{
/// <summary>
/// EffectPoolのAsync版
/// </summary>
public class EffectPoolAsync : AsyncObjectPool<ExplosionEffect>
{
private readonly ExplosionEffect _prefab;
private readonly Transform _parenTransform;
//コンストラクタ
public EffectPoolAsync(Transform parenTransform, ExplosionEffect prefab)
{
_parenTransform = parenTransform;
_prefab = prefab;
}
/// <summary>
/// 非同期でインスタンスを生成できる
/// </summary>
protected override IObservable<ExplosionEffect> CreateInstanceAsync()
{
var e = GameObject.Instantiate(_prefab);
e.transform.SetParent(_parenTransform);
//今回の例だと非同期にする要素が無いのでObservable.Returnで値をそのまま返して終了
return Observable.Return(e);
}
}
}
var _asyncPool = new EffectPoolAsync(_hierarchyTransform, _effectPrefab);
//プールに未使用のオブジェクトがある場合はRentAsyncは即値を返す
//オブジェクトが足りない場合はCreateInstanceを実行しその結果を持ってRentAsyncは値を返す
_asyncPool.RentAsync().Subscribe(effect =>
{
effect.PlayEffect(Vector3.zero).Subscribe(__ => _asyncPool.Return(effect));
});
普段はObjectPool<T>
を利用し、CreateInstance
中に非同期でリソースの読み込みが必要な場合など本当に非同期にしなくていはいけない場合のみAsyncObjectPool<T>
を使うと良さそうです。
UniRxの作者のneueccさんもブログで述べられていますが、同期的な実装の方が圧倒的に使いやすいです。無理に非同期にする必要はなく、できるだけ同期に寄せて書いたほうが考えることが減るので自分もオススメです。
最後に
もしObjectPoolが必要になった場合は、UniRxのObjectPoolを使うと簡単に実装できるよという話でした。
そもそも本当にObjectPoolが必要なのかというと、リリースしようとしているプラットフォームによるかと思います。
PCをターゲットにしているなら、ObjectPoolを使わずに富豪的プログラミングで書いても問題なかったりします。
まずはUnityProfilerで動作を確認して、InstantiateやDestroyのコストが無視できない、GC Allocが発生していてまずいとなってからObjectPoolに差し替えるという方針でよいかなぁと思いました(個人の感想)
(余談)
Effekseer、UnityのShurikenより圧倒的に使いやすいので個人的にはおすすめです。