はじめに
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.PrefabPool
にIPunPrefabPool
を実装したインスタンスを入れておけばPhotonNetwork.Instantiate()
が呼ばれたときにそのインスタンスが使われる。
見るからに好き放題フック入れられそうだし、Instantiate()
でインスタンス返せばいいならResources使わなくて良さそう!←必須だった💩
使用例
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;
}
-
PhotonNetwork.AllocateViewID()
で割り当てに使用するviewID
を得る -
PhotonView#RPC()
でほかのクライアントにスポーン命令を送出する(命令はPhotonTargets.AllBuffered
などを指定して明示的にキャッシュする) - RPCを受け取ったクライアント上でプレハブを
Object.Instantiate()
し、送られてきたviewID
をPhotonView
に割り当てる
- スポーンするプレハブは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は論外な気がする。
IPunPrefabPool
とUniRx.Toolkit.ObjectPool
を使ったNetworkSpawner
のサンプル
スポーンしたオブジェクトを監視するためのクラスを書いてみた。
- UniRx
- PhotonRx
を使用、SingletonMonoBehaviour
は、まぁ自分のシングルトンクラスの実装で置き換えてください。
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();
}
}
}
}
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()
を呼ぶ(たぶん前者のほうが安全)
使用例
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()
とそれ以降の呼び出しタイミングを制御できるのでそういうコードを挟む。
参考資料
- 僕もPhotonを使いたい
- IPunPrefabPool Working Sample with SmartPool(free)
- UniRxのObjectPoolを利用する
- PhotonNetwork.cs(PhotonのSDK内のソース)
- NetworkingPeer.cs(PhotonのSDK内のソース)
- PhotonClasses.cs(PhotonのSDK内のソース)
ソース読まないとわからなかったことがたくさんあったのでソース読むのおすすめ。