Unity
Photon
UniRx
PhotonRx

Photonでオブジェクトをスポーンする方法の調査およびIPunPrefabPoolとUniRx.Toolkit.ObjectPoolを使ったオブジェクトプールのサンプル

More than 1 year has passed since last update.


はじめに

Photonでネットワーク上にオブジェクトを生成・同期(以後「スポーン」と呼ぶ)する方法とそのメリット・デメリットをいろいろ調べた。ついでにUniRxのオブジェクトプールの機構を使う方法も調べた。

制限やデメリットと感じる特徴はわかりやすいように赤字にしてます。

記事の最後に実用的なサンプルコードもあります。


PhotonNetwork.Instantiate()を普通に使う


  • スポーンするプレハブはstringで指定かつResouces直下に入れておく必要あり


  • Awakeが呼ばれた時点でPhotonViewは初期済み(ownerIdなどがセットされている)

  • スポーンの命令は自動でキャッシュされ、後から入ってきたプレイヤー上にもスポーンする

  • スポーンしたオブジェクトの削除(デスポーン)はPhotonNetwork.Destroy()


    • オブジェクトがデスポーン済みなら後から入ってきたプレイヤーにスポーンの命令が飛ばない(Instantiate()命令のキャッシュはDestroy()命令で消える)



  • 呼び出したオーナー以外はスポーン・デスポーンの時にフックできない

最後の特徴は例えば「プレイヤーがスポーンしたらPlayerManagerに登録したい」といったときに不便。スポーンするプレハブのOnPhotonInstantiate内でPlayerManager.Add(this)みたいに登録することもできるけど、それだと密結合になるのでできれば避けたい。

あとResourcesは💩だから使いたくない。


PhotonNetwork.Instantiate()IPunPrefabPoolを使う

IPunPrefabPoolというのはPhoton組み込みのオブジェクトプール機構を使うときに実装が必要になるインターフェース。

public interface IPunPrefabPool

{
// prefabIdを受け取って新しいorプールされたインスタンスを返す必要がある
GameObject Instantiate(string prefabId, Vector3 position, Quaternion rotation);

// スポーンしたオブジェクトの破棄処理を書く
void Destroy(GameObject gameObject);
}

PhotonNetwork.PrefabPoolIPunPrefabPoolを実装したインスタンスを入れておけばPhotonNetwork.Instantiate()が呼ばれたときにそのインスタンスが使われる。

見るからに好き放題フック入れられそうだし、Instantiate()でインスタンス返せばいいならResources使わなくて良さそう!←必須だった💩


使用例

https://github.com/jeanfabre/Exitgames--PUN--Pooling_u4/blob/master/Assets/PUN%20Custom%20Samples/SmartPool/Scripts/PunSmartPoolBridge.cs から引用

using System.Collections.Generic;

using UnityEngine;
using System.Collections;

/// <summary>
/// Pun smart pool bridge.
/// Common Pitfalls:
/// -- even when using a pool manager, you need to store your prefab inside a Resources Folder,
/// it's because PUN needs to load the prefab and assign viewIDs to all PhotonViews. Note: It's not instantiated by pun at all, just loaded for analyzing.
/// -- on the instanciated prefab, use OnPhotonInstantiate() to catch info on initializating if you must, OnEnable and Start aren't suitable due to the network initializaion processes.
/// </summary>
public class PunSmartPoolBridge : MonoBehaviour, IPunPrefabPool
{
public void Start ()
{
PhotonNetwork.PrefabPool = this;
}

public GameObject Instantiate(string prefabId, Vector3 position, Quaternion rotation)
{
Debug.LogWarning("Instantiate Prefab: " + prefabId);

GameObject go = SmartPool.Spawn(prefabId);
go.transform.position = position;
go.transform.rotation = rotation;

return go;
}

public void Destroy(GameObject gameObject)
{
SmartPool.Despawn(gameObject);

}
}

SmartPoolっていうアセットを使ってる。



  • スポーンするプレハブはstringで指定かつResouces直下に入れておく必要あり


  • Awakeが呼ばれた時点でPhotonViewは初期化されていない

  • スポーンの命令は自動でキャッシュされ、後から入ってきたプレイヤー上にもスポーンする

  • スポーンしたオブジェクトの削除はPhotonNetwork.Destroy()


    • オブジェクトがデスポーン済みなら後から入ってきたプレイヤーにスポーンの命令が飛ばない(Instantiate()命令のキャッシュはDestroy()命令で消える)



  • スポーン・デスポーン時のフックは自分で追加可能


PhotonView#RPC()を使う

Photon公式の説明からサンプルコードを引用

void SpawnPlayerEverywhere()

{
// You must be in a Room already
// Marco Polo tutorial shows how to connect and join room
// See: http://doc.photonengine.com/en/pun/current/tutorials/tutorial-marco-polo

// Manually allocate PhotonViewID
int id1 = PhotonNetwork.AllocateViewID();

PhotonView photonView = this.GetComponent<PhotonView>();
photonView.RPC("SpawnOnNetwork", PhotonTargets.AllBuffered, transform.position, transform.rotation, id1, PhotonNetwork.player);
}

public Transform playerPrefab; //set this in the inspector

[RPC]
void SpawnOnNetwork(Vector3 pos, Quaternion rot, int id1, PhotonPlayer np)
{
Transform newPlayer = Instantiate(playerPrefab, pos, rot) as Transform;

// Set player's PhotonView
PhotonView[] nViews = newPlayer.GetComponentsInChildren<PhotonView>();
nViews[0].viewID = id1;
}



  1. PhotonNetwork.AllocateViewID()で割り当てに使用するviewIDを得る


  2. PhotonView#RPC()でほかのクライアントにスポーン命令を送出する(命令はPhotonTargets.AllBufferedなどを指定して明示的にキャッシュする)

  3. RPCを受け取ったクライアント上でプレハブをObject.Instantiate()し、送られてきたviewIDPhotonViewに割り当てる



  • スポーンするプレハブはResourcesに入れておく必要はない


  • Awakeが呼ばれた時点でPhotonViewは初期化されていない(プレハブをゴニョゴニョすると解決可能)


  • OnPhotonInstantiate()が呼ばれない(自分でSendMessage()すれば呼べる)

  • スポーンの命令をキャッシュするように指定すると後から入ってきたプレイヤー上にもスポーンする

  • スポーンしたオブジェクトの削除は同様にRPCを使う(PhotonNetwork.Destroy()は使えない)

  • オブジェクトがデスポーン済みでも後から入ってきたプレイヤーにスポーンの命令が飛ぶ(Instantiate()命令のキャッシュはDestroy()命令で消えない)


    • スポーンと破棄が溜まると後から入ってきたプレイヤー上で無駄な処理が走る



  • スポーン・デスポーン時のフックは自分で追加可能

ちなみにOnPhotonInstantiate()とサーバにキャッシュされたRPCは

  Awake()OnPhotonInstantiate() → サーバにキャッシュされたRPC() → Start()

の順で呼ばれる。

また、PhotonのPrefabPoolを使う場合はプールから取り出したインスタンスのAwake()Start()は呼ばれないのでOnPhotonInstantiate()で初期化すべき。こちらはプールから取り出したインスタンスに対しても呼ばれる。


PhotonNetwork.RaiseEvent()を使う


  • スポーンするプレハブはResourcesに入れておく必要はない


  • Awakeが呼ばれた時点でPhotonViewは初期化されていない(プレハブをゴニョゴニョすると解決可能)


  • OnPhotonInstantiate()が呼ばれない(自分でSendMessage()すれば呼べる)

  • スポーンの命令をキャッシュするように指定すると後から入ってきたプレイヤー上にもスポーンする

  • スポーンしたオブジェクトの削除は同様にRaiseEvent()を使う(PhotonNetwork.Destroy()は使えない)

  • スポーンの命令はキャッシュから削除可能

  • スポーンしたときのフックは自分で追加可能

  • 0~199の200個しかないイベントコードを生成・破棄で2つ消費する


やり方ごとのメリット・デメリットまとめ

◯ … 良い

△ … 工夫でどうにかなる

× … どうにもならない

PhotonNetwork.Instantiate()
RPC()
RaiseEvent()

Resources
×(必須)
◯(不要)
◯(不要)

Awake時点でPhotonViewの初期化
◯(済) or ×(PrefabPool使用時)
△(未)
△(未)

スポーン命令のキャッシュ
◯(賢い)
×(消せない)
△(消せる)

OnPhotonInstantiate
◯(呼ばれる)
△(呼ばれない)
△(呼ばれない)

PhotonNetwork.Destroy()
◯(使える)
×(使えない)
×(使えない)

イベントコード
◯(消費しない)
◯(消費しない)
×(消費する)

スポーン・削除時フック
△(PrefabPool使用時のみ実装可)
△(実装可)
△(実装可)

サーバ上のスポーン命令のキャッシュが消せないからRPCは論外な気がする。


IPunPrefabPoolUniRx.Toolkit.ObjectPoolを使ったNetworkSpawnerのサンプル

スポーンしたオブジェクトを監視するためのクラスを書いてみた。


  • UniRx

  • PhotonRx

を使用、SingletonMonoBehaviourは、まぁ自分のシングルトンクラスの実装で置き換えてください。


NetworkSpawner.cs

using Photon;

using PhotonRx;
using System.Linq;
using UniRx;
using UniRx.Toolkit;
using UniRx.Triggers;
using UnityEngine;

namespace Jagapippi.Network
{
public abstract class NetworkSpawner<T> : SingletonMonoBehaviour<NetworkSpawner<T>> where T : PunBehaviour
{
protected ObjectPoolAdapter _objectPoolAdapter;

protected virtual ObjectPoolAdapter objectPoolAdapter
{
get
{
if (_objectPoolAdapter == null)
{
_objectPoolAdapter = CreateObjectPoolAdapter();

_objectPoolAdapter.OnPhotonInstantiateAsObservable()
.Subscribe(instance => _ComponentCollection.Add(instance))
.AddTo(this);

_objectPoolAdapter.OnReturnAsObservable()
.Subscribe(instance => _ComponentCollection.Remove(instance))
.AddTo(this);
}
return _objectPoolAdapter;
}
}

protected abstract ObjectPoolAdapter CreateObjectPoolAdapter();

#region properties

[SerializeField] protected GameObject _prefab;
[SerializeField] protected bool _usePool = true;

protected readonly ReactiveCollection<T> _ComponentCollection = new ReactiveCollection<T>();

public IReadOnlyReactiveCollection<T> ComponentCollection
{
get { return _ComponentCollection; }
}

#endregion

protected virtual void Awake()
{
if (PhotonNetwork.PrefabPool == null)
{
PhotonNetwork.PrefabPool = new ObjectPoolManager();
}

var objectPoolManager = (ObjectPoolManager) PhotonNetwork.PrefabPool;

objectPoolManager.AddObjectPool(this.objectPoolAdapter);

this.OnDestroyAsObservable()
.Subscribe(_ =>
{
objectPoolManager.RemoveObjectPool(this.objectPoolAdapter);
this.objectPoolAdapter.Clear();
});
}

#region Spawn methods

public static void Spawn()
{
instance.Spawn(Vector3.zero, Quaternion.identity);
}

public static void Spawn(Transform pos)
{
instance.Spawn(pos.position, pos.rotation);
}

public static void Spawn(Vector3 position)
{
instance.Spawn(position, Quaternion.identity);
}

public static void Spawn(Quaternion rotation)
{
instance.Spawn(Vector3.zero, rotation);
}

public virtual void Spawn(Vector3 position, Quaternion rotation, params object[] args)
{
PhotonNetwork.Instantiate(_prefab.name, position, rotation, 0, args);
}

public static void SpawnSceneObject()
{
instance.SpawnSceneObject(Vector3.zero, Quaternion.identity);
}

public static void SpawnSceneObject(Transform pos)
{
instance.SpawnSceneObject(pos.position, pos.rotation);
}

public static void SpawnSceneObject(Vector3 position)
{
instance.SpawnSceneObject(position, Quaternion.identity);
}

public static void SpawnSceneObject(Quaternion rotation)
{
instance.SpawnSceneObject(Vector3.zero, rotation);
}

public virtual void SpawnSceneObject(Vector3 position, Quaternion rotation, params object[] args)
{
PhotonNetwork.InstantiateSceneObject(_prefab.name, position, rotation, 0, args);
}

#endregion

public static void Destroy(PhotonView photonView)
{
if (photonView == null
|| photonView.isMine == false
|| instance.ComponentCollection.Contains(photonView.gameObject.GetComponent<T>()) == false
)
{
Debug.LogWarning("tried to destroy invalid PhotonView!");
return;
}

PhotonNetwork.Destroy(photonView);
}

protected abstract class ObjectPool : ObjectPool<T>
{
protected ObjectPool(GameObject prefab, bool usePool)
{
_prefab = prefab;
_usePool = usePool;
}

private readonly GameObject _prefab;
private readonly bool _usePool = true;

protected GameObject prefab
{
get { return _prefab; }
}

protected bool usePool
{
get { return _usePool; }
}

public virtual bool HasPrefab(string prefabId)
{
// TODO: 名前以外の衝突しないIDで照合する
return _prefab.name == prefabId;
}

protected override T CreateInstance()
{
// TODO: Awake時点でのPhotonViewの初期化を保証する
return Instantiate(this.prefab).GetComponent<T>();
}

public new virtual void Return(T instance)
{
base.Return(instance);
if (this.usePool == false)
{
this.Clear();
}
}
}

protected class ObjectPoolAdapter : ObjectPoolManager.ObjectPool
{
private readonly ObjectPool _objectPool;

public ObjectPoolAdapter(ObjectPool objectPool)
{
_objectPool = objectPool;
}

private readonly Subject<T> _onPhotonInstantiate = new Subject<T>();

public IObservable<T> OnPhotonInstantiateAsObservable()
{
return _onPhotonInstantiate;
}

private readonly Subject<T> _onReturn = new Subject<T>();

public IObservable<T> OnReturnAsObservable()
{
return _onReturn;
}

public override Component Rent()
{
var instance = _objectPool.Rent();

// TODO: すべてのOnPhotonInstantiateが呼ばれてからOnNext()する
instance.OnPhotonInstantiateAsObservable()
.Take(1)
.TakeUntil(instance.OnDisableAsObservable())
.Subscribe(_ => _onPhotonInstantiate.OnNext(instance))
.AddTo(instance);

return instance;
}

public override bool Return(GameObject gameObject)
{
var instance = gameObject.GetComponent<T>();
if (instance && instance.GetType().IsSubclassOf(typeof(T)) == false)
{
_onReturn.OnNext(instance);
_objectPool.Return(instance);
return true;
}
return false;
}

public override bool HasPrefab(string prefabId)
{
return _objectPool.HasPrefab(prefabId);
}

public virtual void Clear()
{
_objectPool.Clear();
}
}
}
}



ObjectPoolManager.cs

using System.Collections.Generic;

using System.Linq;
using UnityEngine;

namespace Jagapippi.Network
{
public class ObjectPoolManager : IPunPrefabPool
{
private readonly List<ObjectPool> _objectPoolList = new List<ObjectPool>();

public void AddObjectPool(ObjectPool _objectPool)
{
if (_objectPoolList.Contains(_objectPool) == false)
{
_objectPoolList.Add(_objectPool);
}
}

public void RemoveObjectPool(ObjectPool _objectPool)
{
_objectPoolList.Remove(_objectPool);
}

GameObject IPunPrefabPool.Instantiate(string prefabId, Vector3 position, Quaternion rotation)
{
var objectPool = _objectPoolList.First(pool => pool.HasPrefab(prefabId));
var go = objectPool.Rent().gameObject;
go.transform.SetPositionAndRotation(position, rotation);
return go;
}

void IPunPrefabPool.Destroy(GameObject gameObject)
{
foreach (var objectPool in _objectPoolList)
{
if (objectPool.Return(gameObject))
{
return;
}
}
}

public abstract class ObjectPool
{
public abstract bool HasPrefab(string prefabId);
public abstract Component Rent();
public abstract bool Return(GameObject gameObject);
}
}
}



使い方

NetworkSpawnerクラスを継承して使う。


  • Tにはキャッシュしたいコンポーネントを指定

  • キャッシュしたいコンポーネントのオブジェクトプールクラスを定義する

  • スポーン・デスポーンは_ComponentCollectionへの追加・削除をSubscribe()で監視する

  • インスペクタで


    • スポーンさせたいプレハブをセットする(Resources以下にも入れる必要あり)

    • 「Use Pool」フラグをセットする(オブジェクトプールは不要でフックだけほしいときはfalseにする)



  • オブジェクトのスポーンは各種Spawn()を呼ぶ

  • シーンオブジェクトのスポーンは各種SpawnSceneObject()を呼ぶ

  • デスポーンはクラスのstaticメソッドのDestroy()を呼ぶかPhotonNetwork.Destroy()を呼ぶ(たぶん前者のほうが安全)


使用例


PlayerSpawner.cs

using UniRx;

using UnityEngine;

namespace Jagapippi.Network.Game
{
public class PlayerSpawner : NetworkSpawner<Player>
{
private readonly ReactiveProperty<Player> _LocalPlayer = new ReactiveProperty<Player>();

public ReadOnlyReactiveProperty<Player> LocalPlayer
{
get { return _LocalPlayer.ToReadOnlyReactiveProperty(); }
}

public IReadOnlyReactiveCollection<Player> PlayerList
{
get { return _ComponentCollection; }
}

protected override void Awake()
{
base.Awake();

this.PlayerList.ObserveAdd()
.Select(e => e.Value)
.Subscribe(player => PhotonPlayer.Find(player.photonView.ownerId).TagObject = player)
.AddTo(this);

this.PlayerList.ObserveAdd()
.Select(e => e.Value)
.Where(player => player.photonView.isMine)
.Subscribe(player => _LocalPlayer.Value = player)
.AddTo(this);

this.PlayerList.ObserveRemove()
.Select(e => e.Value)
.Where(player => player.photonView.isMine)
.Subscribe(player => _LocalPlayer.Value = null)
.AddTo(this);
}

protected override ObjectPoolAdapter CreateObjectPoolAdapter()
{
return new ObjectPoolAdapter(new PlayerObjectPool(_prefab, _usePool));
}

protected class PlayerObjectPool : ObjectPool
{
public PlayerObjectPool(GameObject prefab, bool usePrefab) : base(prefab, usePrefab)
{
}
}
}
}



感想とかTODOとか

スポーンで生まれる側がどういう手段でスポーンしたかを気にせずに実装できるようにしたかったが、Awake時点でPhotonViewの初期化が完了しているようにするのはできなかった。あと結局Resourcesが必須💩

まとめてて思ったけどRaiseEvent()使えばイベントコード消費する以外全部解決できそうな気がする。というかPhotonNetwork.Instantiate()も内部的にRaiseEvent()使ってるし。

Awake呼び出し時点でうんぬんはプレハブをSetActive(false)しておけばAwake()とそれ以降の呼び出しタイミングを制御できるのでそういうコードを挟む。


参考資料

ソース読まないとわからなかったことがたくさんあったのでソース読むのおすすめ。