ObjectPoolを使ったリーチングタスクの実装
この記事では,VRでの実験でよく用いられるタスクであるリーチングタスクについて,ObjectPoolというデザインパターンを使って実装していきます.
リーチングタスクとは,バーチャル空間に配置された物体(リーチングターゲット)に手や指先で触れていくというタスクです.リーチングタスクのスコアが高ければ,実験参加者がアバターを思い通りに動かすことができることの指標になりえます.他にも,単にバーチャル空間やアバターに慣れるために,主たるタスクの前座で行われることもあります.
殆どの場合リーチングターゲットは全て同質ですので,同質な複数のオブジェクトの生成・破棄を効率化するためのデザインパターン「ObjectPool」を使うと綺麗に実装できます.ObjectPoolでは対象のオブジェクトを予め多数生成して保持(Pooling)しておき,使いたいと思った時に保持しているオブジェクト(PooledObject)の中から1つ選んで有効化します.使い終わった後は無効化してからPoolに戻します.このようにすることで,オブジェクトの生成や破棄の際に呼ばれるたくさんの処理を飛ばして計算量を節約できます.
実験で使うようなPCはハイスペックなものを使うのが普通なのでObjectPoolの恩恵はあまり無いですが,知識として仕入れておくと後々便利かと思います.
サンプルコードはGitHubに公開したので適宜参考にしてください.
https://github.com/AmaneYamaguchi/GenericReachingTask
基本構造
ObjectPoolで一般的に使われるクラス(インタフェース)は以下の通りです.PoolManagerはIPooledObjectを継承したクラスの生成を管理するためのクラスです.
IPooledObjectインタフェース
PoolManager
これらのクラス(インタフェース)を継承する形で,リーチングタスク用のObjectPoolを実現します.
PooledReachingTarget <- IPooledObjectを継承
ReachingTargetPoolManager <- PoolManagerを継承
リーチングタスクの仕様は実験ごとに異なると思うので,PooledObjectをバーチャル環境に配置するクラスは実験ごとに実装するのが良いでしょう.この機能を持つクラスをここではSpawnerと呼ぶことにします.
実験を管理するクラスについては以前記事を書いたので参考にしてください.
Spawner
ここまでのクラスの関係をまとめると下図のようになります.重要なのはリーチングタスクをObjectPool(左側)と実験用クラス(右側)に分離したことです.例えば頑張って作った実験系を先輩や教授にチェックしてもらい,「うーん,なんか違うんだよなぁ…,ターゲットの配置場所とか生成頻度とかどうにかできない?」と言われた時のことを想像してみてください.実験系の全ての実装を再度チェックして修正するか,Spawnerを別のものに差し替えるかを選ぶとしたら後者の方が絶対に健康的です.あるいは,自分の実験系を後輩に見せる時,どの部分が再利用できる(できない)か明示されていると後で恨まれずに済みます.

サンプルコード(基幹)
一般的なObjectPool
一般的なMonoBehaviourクラスTに対するObjectPoolは以下のように書けます.このObjectPoolはリーチングターゲットの他にも例えば射撃ゲームの弾丸やその発射エフェクト,アクションゲームの敵mob等に対しても適用できます.特殊なことをしたいのでない限り,そのまま写経して結構だと思います.
IPooledObjectインタフェースは4つのメソッドの実装を要請します.ただし,そのうちset_ObjectPool,Initialize(),get_OnDeactivate()はObjectPoolデザインパターンの範疇での使用を想定しているので外部クラス(例えばSpawner)では使いません.残ったDeactivate()はこのオブジェクトを消去したい場合に呼び出します.
public interface IPooledObject<T> where T : class
{
// このオブジェクトに対応するObjectPoolを設定する
IObjectPool<T> ObjectPool { set; }
// オブジェクトの状態を初期化する
void Initialize();
// オブジェクトを消去する(Poolに戻す)
void Deactivate();
// Deactivate()が呼ばれた時に呼ばれるイベント
UnityEvent OnDeactivate { get; }
}
PoolManagerは対応するオブジェクトの生成と消去を管理します.少しコードが長々しいですが,外部に露出しているメソッドは事実上TryGet()の1つだけなので実用上はシンプルに使えます.
public abstract class PoolManager<T> : MonoBehaviour where T : MonoBehaviour, IPooledObject<T>
{
[SerializeField] private T pooledPrefab;
protected IObjectPool<T> _objectPool;
[SerializeField] private bool _collectionCheck = true;
[SerializeField] private int _defaultCapacity = 32;
[SerializeField] private int _maxSize = 100;
public virtual void Initialize()
{
_objectPool = new ObjectPool<T>(Create, OnGetFromPool, OnReleaseToPool, OnDestroyPooledObject,
_collectionCheck, _defaultCapacity, _maxSize);
}
public bool TryGet(out T obj)
{
obj = _objectPool.Get();
obj?.Initialize();
return obj != null;
}
protected virtual T Create()
{
T instance = Instantiate(pooledPrefab, transform.position, Quaternion.identity, transform);
instance.ObjectPool = _objectPool;
return instance;
}
protected virtual void OnReleaseToPool(T pooledObject)
{
pooledObject.gameObject.SetActive(false);
}
protected virtual void OnGetFromPool(T pooledObject)
{
pooledObject.gameObject.SetActive(true);
}
protected virtual void OnDestroyPooledObject(T pooledObject)
{
pooledObject.Deactivate();
Destroy(pooledObject.gameObject);
}
protected virtual void Awake()
{
Initialize();
}
}
なお,これらのクラスは以下の記事を参考にさせていただきました.
リーチングターゲット用のObjectPool
上記のクラス(インタフェース)を継承してリーチングターゲット用のObjectPoolを作ります.
まずはリーチングターゲットのコードを見てみましょう.一般的なリーチングターゲットはリーチングの成功または一定時間(≦∞)の経過で消滅するのが普通でしょうから,その仕様をUnity内で実装します.前者はOnTriggerEnter()で,後者はUpdate()で実装しました.OnTriggerEnterが呼び出されるにはRigidbodyとColliderが必要です.仕様がよくわからない人はググってください.
public class PooledReachingTarget : MonoBehaviour, IPooledObject<PooledReachingTarget>
{
private IObjectPool<PooledReachingTarget> m_pool;
public IObjectPool<PooledReachingTarget> ObjectPool
{
set => m_pool = value;
}
/// <summary>
/// オブジェクトが非アクティブになったときに呼ばれるイベント
/// </summary>
public UnityEvent OnDeactivate => m_onDeactivate;
private UnityEvent m_onDeactivate = new();
/// <summary>
/// オブジェクトが生成されてから自動で消えるまでの時間
/// </summary>
[SerializeField]
private float m_lifeTime = float.MaxValue;
/// <summary>
/// オブジェクトが生成されてから自動で消えるまでの時間。
/// デフォルトでは無限(float.MaxValue)。Initialize()の直後に設定すること。
/// </summary>
public float LifeTime
{
get => m_lifeTime;
set => m_lifeTime = value;
}
private float m_currentLifeTime = 0f;
/// <summary>
/// オブジェクトが寿命を過ぎているかどうか
/// </summary>
public bool IsOutOfLife => m_lifeTime <= m_currentLifeTime;
public void Initialize()
{
m_onDeactivate.RemoveAllListeners();
m_currentLifeTime = 0f;
m_lifeTime = float.MaxValue;
}
public void Deactivate()
{
m_onDeactivate.Invoke();
m_pool.Release(this);
}
/// <summary>
/// Updateはゲームオブジェクトが有効な場合にのみ呼ばれるため、
/// Deactivate()後から次のInitialize()までは呼ばれない。
/// </summary>
void Update()
{
m_currentLifeTime += Time.deltaTime;
if (IsOutOfLife)
{
Deactivate();
}
}
void OnTriggerEnter(Collider other)
{
Deactivate();
}
}
特に機能を追加する必要が無いPoolManagerは極めてシンプルな実装になります.Tに上記のPooledReachingTargetを指定します.
public class ReachingTargetPoolManager : PoolManager<PooledReachingTarget>
{
}
サンプルコード(Spawner)
以下にSpawnerのサンプルをいくつか置いておきます.いずれもSpawn()をどこかのタイミングで呼び出すことでReachingTargetPoolManagerからPooledReachingTargetを1つ取得してどこかに配置するものです.実験管理用クラスはSpawnerやReachingTargetPoolManagerを含むゲームオブジェクトを有効化(非有効化)することでリーチングタスクを開始(終了)できます.
ランダムな位置に配置するSpawner
以下のコードは,Spawnerの周囲のランダムな位置にリーチングターゲットを配置するものです.Playモード中に出現するボタンかキーボードのスペースバーを押すことで新しいリーチングターゲットを配置します.
ついでに配置できる最大数を設定しています.
public class ReachingTargetSpawnerRandom : MonoBehaviour
{
[SerializeField]
private ReachingTargetPoolManager m_poolManager;
/// <summary>
/// リーチングターゲットを生成する範囲。
/// 立方体の辺の長さの半分(m)。
/// </summary>
[SerializeField]
[Tooltip("リーチングターゲットを生成する範囲。\n立方体の辺の長さの半分(m)。")]
private float m_spawnRange = 1f;
private int m_spawnCount = 0;
/// <summary>
/// リーチングターゲットが自動で消えるまでの時間(秒)。
/// </summary>
[SerializeField]
[Tooltip("リーチングターゲットが自動で消えるまでの時間(秒)。\n無限にしたい場合はコメントアウトしてください。")]
private float m_lifeTime = 10f;
private void Spawn()
{
Vector3 localPos = new Vector3
{
x = Random.Range(-m_spawnRange, m_spawnRange),
y = Random.Range(-m_spawnRange, m_spawnRange),
z = Random.Range(-m_spawnRange, m_spawnRange)
};
Vector3 globalPos = transform.position + localPos;
// get and spawn pooled object
if (m_poolManager.TryGet(out var obj) && obj != null)
{
obj.LifeTime = m_lifeTime;
obj.transform.position = globalPos;
m_spawnCount++;
obj.OnDeactivate.AddListener(() =>
{
m_spawnCount--;
});
}
else
{
Debug.LogError($"ReachingTargetSpawnRandom.Spawn(): Could not get pooled object!");
}
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Spawn();
}
}
// IMGUI
private int m_windowId = 10000;
private Rect m_windowRect = new Rect(0, 0, 200, 100);
[SerializeField] private bool m_drawGUI = true;
private void OnGUI()
{
if (!m_drawGUI) { return; }
m_windowRect = GUI.Window(m_windowId, m_windowRect, (id) =>
{
GUILayout.Label($"Spawn Count: {m_spawnCount}");
if (GUILayout.Button("Spawn (Space)"))
{
Spawn();
}
GUI.DragWindow();
}, name);
}
}
ランダムはランダムでも,配置する具体的な場所を予め指定しておき,その中から選んで配置したい場合もあると思います.その場合は以下のようなコードが使えるでしょう.
public class ReachingTargetSpawnerSelectedPlaces : MonoBehaviour
{
[System.Serializable]
public class SpawnPoint
{
public Transform Point => m_point;
[SerializeField] private Transform m_point;
/// <summary>
/// このSpawnPointにオブジェクトが生成されているかどうか。
/// 生成されているなら確率抽選からはじく。
/// </summary>
public bool HasObject { get; set; } = false;
public SpawnPoint(Transform t)
{
m_point = t;
HasObject = false;
}
}
[SerializeField]
private ReachingTargetPoolManager m_poolManager;
[SerializeField]
private List<SpawnPoint> m_targetPoints;
private int m_spawnCount;
private void Spawn()
{
// まだオブジェクトが生成されていない場所をランダムに選ぶ
var validPoints = m_targetPoints.Where(x => !x.HasObject).ToList();
var index = Random.Range(0, validPoints.Count());
var validPoint = validPoints[index];
// オブジェクトを取り出して生成する
if (m_poolManager.TryGet(out var obj) && obj != null)
{
obj.transform.position = validPoint.Point.position;
m_spawnCount++;
validPoint.HasObject = true;
obj.OnDeactivate.AddListener(() =>
{
m_spawnCount--;
validPoint.HasObject = false;
});
}
else
{
Debug.LogError($"ReachingTargetSpawnSelectedPlaces.Spawn(): Could not get pooled object!");
}
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Spawn();
}
}
// IMGUI
private int m_windowId = 10001;
private Rect m_windowRect = new Rect(0, 0, 200, 100);
[SerializeField]
private bool m_drawGUI = true;
private void OnGUI()
{
if (!m_drawGUI) { return; }
m_windowRect = GUI.Window(m_windowId, m_windowRect, (id) =>
{
GUILayout.Label($"Spawn Count: {m_spawnCount}");
if (GUILayout.Button("Spawn (Space)"))
{
Spawn();
}
GUI.DragWindow();
}, name);
}
}
指定した位置に順番で配置するSpawner
A地点にあるリーチングターゲットに触れたら次はB地点,その次はC地点,というように,指定した位置に順番にリーチングターゲットを配置したい場合は以下のようなコードを使うと良いでしょう.
Spawn()内ではこれまでのサンプルコードと同様にリーチングターゲットを生成しますが,リーチングターゲットのOnDeactivateでSpawn()自身を呼ぶようにしています.すなわち,1つ前のリーチングターゲットが消滅した瞬間,次の場所にリーチングターゲットが配置されます.
public class ReachingTargetSpawnerInOrder : MonoBehaviour
{
[SerializeField]
private ReachingTargetPoolManager m_poolMangaer;
[SerializeField]
private List<Transform> m_spawnPoints;
private int m_index = 0;
[SerializeField]
private int m_increment = 1;
/// <summary>
/// ある値に特定の値を足しこんだ後、範囲内の数値に収める
/// </summary>
/// <param name="inc"></param>
private int GetIncrementedValue(int input, int length, int inc = 1)
{
input += inc;
// 範囲内に収める
input = (int)Mathf.Repeat(input, length);
return input;
}
private void Spawn()
{
Vector3 pos = m_spawnPoints[m_index].position;
if (m_poolMangaer.TryGet(out var obj) && obj != null)
{
obj.transform.position = pos;
obj.OnDeactivate.AddListener(() =>
{
Debug.Log($"Reached {m_index}th Object");
m_index = GetIncrementedValue(m_index, m_spawnPoints.Count, m_increment);
Spawn();
});
}
else
{
Debug.LogError("ReachingTargetSpawnerInOrder.Spawn(): Could not get pooled object!");
}
}
private void Start()
{
m_index = 0;
Spawn();
}
}
ちなみに,最初のSpawn()をAwake()ではなくStart()で行っているのは,m_poolManagerの初期化がそちらのAwake()で行われており,それよりも先にこちらのSpawn()を呼んでエラーが起きるのを防ぐためです.一般的に自分自身で完結する初期化はAwake()を,そうでない初期化はStart()を使うようにしておくと変なエラーに悩まされずに済みます.