Unityアドベントカレンダー14日目になります!
昨日は @monry さんによるUnity Localization の設定をプロジェクトを跨いで共有するでした!
はじめに
Graffity株式会社のAzukiです。
会社でUnity用マルチプレイエンジンのPhotonFusionを検証した際に、せっかくなのでゲームっぽいモックを作ろうと思い制作を行っていたのですが、PhotonFusionのお作法とか実装方法の情報が思った以上に少なかったため、自分用メモも兼ねてTips集として発信しようと思います
(まだ全機能を触り切ったわけではない&1週間程度利用した上での知識なので間違いなどありましたら編集リクエストお願いします!🙇)
PhotonFusionとは
https://doc.photonengine.com/ja-jp/fusion/current/getting-started/fusion-intro
マルチプレイヤーを実装するためのライブラリであるPhotonの最新版です。
従来のPhotonUnityNetworking2(PUN2)と比較し、さまざまな機能が強化されております。
2022年にリリースされたばかりの今注目されているマルチプレイライブラリです。
なお、PUN2は今後新機能の追加などは行われないとの事です。
そのため今後はFusionに移行していくフェーズなのかなと思われます
作ったもの
私が所属するGraffityという会社はARゲームをやってる会社なのでせっかくなのでAR空間上で位置共有をして弾を撃ち合うというモックを作りました。
とはいえ今回はARの位置合わせだったり空間共有については一切触れないので、
ただ単にプレイヤー同士の位置を同期し、弾を撃ち、当たった時にHPを減らし、同期を行う というモックだと思ってください。
このデモではPhotonFusionのHostedModeを使用しています。
今回紹介するポイントは以下の5つになります。
- FusionのHostedModeとは
- FusionのInput権限、State権限について
- UIを起点とした入力をどうやって同期させるか
- 値の共有をRxでやってみた話と注意点
- RPCを使用する
それぞれについて今回はご紹介します
その前にHostedModeとは
Hosted Modeでは、専用ヘッドレスサーバーとしての動作または同一のデバイス上で組み合わされたクライアントとサーバーとしての動作どちらの場合でも、全てのオブジェクトに対してサーバーに完全かつ独占的な権限があります。例外はありません。
クライアントが修正できるのはネットワークオブジェクトのみで、サーバーにインプットを送信する(そしてサーバーにそのインプットを反省してもらう)か、RPCを使用して趨勢を依頼することで行います。
PhotonFusionは従来のPUN2などと違い、(デフォルト設定であるHostedModeを使用した場合)
原則としてホスト(サーバー)がオブジェクトに対する変更権限を保有し、
クライアントがオブジェクトの状態を変更することは原則できない(共有されない) 様になっています。
厳密にはクライアント側で変更しても直後にHost側が状態を上書きしてしまう&共有はされない という感じです。
クライアント側がネットワークで共有されているオブジェクトの状態を変更したい場合は、NetworkInputという機能を使い、
Input情報を送信→Host側がその情報を受け取りオブジェクトを操作→全プレイヤーに反映される
という流れになります。
それに対してSharedモードという実行モードもあり、こちらは各種権限をそれぞれのプレイヤーが保持しているため、
プレイヤーが任意にオブジェクトの操作を行えます。挙動としては従来のPUN2に近いです。
FusionのInput権限、State権限の違い
PhotonFusionには2つの権限の概念が存在します。
InputAuthorityとStateAuthorityと呼ばれています。
InputAuthorityについて
- オブジェクトに対する入力権限になります(前述のHostedModeについてで紹介しているものです)
- ネットワークで同期するオブジェクトを生成する際(生成はHostしかできない)、PlayerRefと呼ばれるプレイヤー情報構造体を一緒に渡すことで、そのプレイヤーに入力権限を与えることができます
生成時に権限を渡す例
//IsServer(ホスト)じゃないと生成できない(クライアントがSpawnをコールするとnullが返る)
if(!m_networkRunner.IsServer) return;
PlayerRef playerRef = プレイヤーの情報を取得する処理
//ネットワークで同期するオブジェクトの生成
//prefabの参照,位置,角度,入力権限を与えるPlayerRef
NetworkObject obj = m_networkRunner.Spawn(prefab,Vector3.zero,Quaternion.identity,playerRef);
PhotonFusionではNetworkRunnerと呼ばれるMonoBehaviourクラスを起点にさまざまな処理を行います
PUN2でいうところのstaticクラスとして提供されていたPhotonNetworkクラスみたいなものです
Spawn関数の第四引数のPlayerRefが、入力権限を渡したいプレイヤー情報です。
例えば、『プレイヤーがセッションに参加してきた時にプレイヤーのオブジェクトを生成する』 という処理をする場合、そのセッションに参加してきたプレイヤーのPlayerRefを入れることでそのプレイヤーがオブジェクトのInput権限を保有することになります。
Input権限を持ったオブジェクトを使う時はINetworkInputを継承した入力構造体を作成し、
Fusionが提供してくれるOnInputというコールバックイベントが呼ばれたタイミングで入力をチェック&構造体を作成しSetすることでHostプレイヤーに伝達されるようになっています
PlayerRef自体はFusionが提供しているコールバックイベント(セッションに入ってきた時など)で取得することができます。
自身のPlayerRefはNetworkRunnerがLocalPlayerという名称で保持しています
今回はこんな感じで定義してます
public struct ArBattleSampleNetworkInput : INetworkInput
{
/// <summary>
/// カメラの位置
/// </summary>
public Vector3 CameraPosition;
/// <summary>
/// カメラ角度
/// </summary>
public Quaternion CameraRotation;
省略
}
そしてFusionが提供しているコールバックイベントでこんな感じでデータを入れます
void INetworkRunnerCallbacks.OnInput(NetworkRunner runner, NetworkInput input)
{
//Input情報をセット
var inputData = new ArBattleSampleNetworkInput()
{
CameraPosition = m_camera == null ? UnityEngine.Vector3.zero : m_camera.transform.position,
CameraRotation = m_camera == null ? Quaternion.identity : m_camera.transform.rotation
};
省略
//入力情報をセットする(Hostなどに共有される)
input.Set(inputData);
}
ちなみに本来であればここで位置情報などを直接入れてしまうのは推奨されていない行為と思われます(クライアント側で位置を偽装されてもホスト側は信じるしかない。 本来はボタンの押下フラグなどを流してホスト側で状態を確認しつつ反映 という流れになるべき)
しかしARアプリの場合、カメラ位置をそのまま送るくらいしかパッと方法が思いつかなかったのでこういったアプローチにしています。
なお、注意点として、アプリケーションでOnInputを実装しているクラスが一つになるようにするべき、とされています。
もしOnInputを複数実装してしまった場合、input.Setでセットする構造体が上書きされてしまい、インプットが正常に送られなくなります(1敗)
そのためInput構造体はプロダクトに1つのみ存在する様にした上で、OnInput関数を実装する処理もプロダクトに1箇所だけ、という風に実装しておくのがおすすめです。(OnInput系だけinterface分けて提供して欲しい...)
Fusionはローカルクライアントをポーリングし、事前に定義された入力構造体に入力することで入力を収集します。Fusion Runnerは1つの入力構造体しか追跡しないので、予期せぬ動作を避けるために、入力ポーリングは一箇所で実装することを強く推奨します。
入力を実際に反映する際はこんな感じで記述をします
PhotonFusionで共有するオブジェクトのロジックを実装する際はクラス定義時にNetworkBehaviourクラスを継承する必要があります。
//PhotonFusionが提供するUpdate関数
//Tickベースのシミュレーションの度に呼ばれる
//基本的にここでオブジェクトの操作を行う
public override void FixedUpdateNetwork()
{
base.FixedUpdateNetwork();
//Input情報を取得する このオブジェクトのInputAuthorityを持っているユーザーがInputをセットしたらここで取得できる
if (GetInput(out ArBattleSampleNetworkInput input))
{
//位置更新
this.transform.SetPositionAndRotation(input.CameraPosition,input.CameraRotation);
}
}
入力権限のあるユーザーがOnInputのタイミングでセットしたInput構造体を、
FixedUpdateNetwork関数でGetInputを叩くことで取得できるようになります。
そこで位置情報を更新することでネットワーク上でのプレイヤーの位置が同期される様になります
(Editor2つ起動して片方のプレイヤーのカメラを移動させている図)
StateAuthorityについて
PhotonFusionにはNetworkBehaviourを継承したクラスのプロパティを自動的に同期するという機能が搭載されています
PUN2などでいうところのCustomProperty、OnPhotonSerializeViewあたりの機能の代わりに使える様なイメージです。
使い方は簡単で、以下の様に[Networked]というアトリビュートをつけ、{set;get;}のアクセサーを書くだけで他プレイヤーの同じオブジェクト内で値が同期される様になります
public class PlayerSyncParameter : NetworkBehaviour
省略
[Networked] public int m_playerHp { private set; get; }
ちなみにprivate変数として定義しても問題なく同期されます
publicにしたうえでsetをprivate set;にしても同期はされていました。
また、値の変動イベントを定義することもでき、さらには過去の値も遡って比較、みたいなこともできます
//OnChangedにメソッド名を指定することで呼び出される様になる
[Networked(OnChanged = nameof(OnChangePlayerHp))] public int m_playerHp { private set; get; }
//値の変動イベント(staticで定義する必要あり)
private static void OnChangePlayerHp(Changed<PlayerSyncParameter> changed)
{
//Behaviourからクラスのメンバにアクセスできる(privateも拾える)
Debug.Log(changed.Behaviour.m_playerHp);
changed.LoadOld();//前の状態を取得
Debug.Log(changed.Behaviour.m_playerHp);//前の状態になっている
}
Changedという形で引数をとったstatic関数を定義し、アトリビュートで指定するだけです。
結構シンプルですね。
UniRxとかでView/Logicを分ける様な実装する場合はここで値の更新イベント発行したりすると扱いやすいかなーと思います
公式Docはこちら
このNetworkedアトリビュートが入った値を更新するには、State変更権限(StateAuthority)が必要です。
StateAuthorityは原則としてHostが所持しており、クライアントがNetworkedな値を変更しても同期されません。
また、Spawn関数で生成したNetworkObjectを破棄する際もStateAuthorityを持っている必要があります
ネットワークオブジェクトを削除するには、そのオブジェクトのState Authorityを持つピアが、Runner.Despawn()を呼び出します。
https://doc.photonengine.com/ja-jp/fusion/current/manual/spawning
そのため、クライアント側が自身のNetworkedな値を変更したい時は
- Inputをセットした上でHost側で取得→更新してもらう
- 値が変動する処理はStateAuthorityがTrueの時のみ動く様にロジックを実装する
- NetworkBehaviourを継承しているとHasInputAuthority,HasStateAuthorityというboolがメンバにいるのでそれを使って適切に処理を分けるのが良さそうです
- RPCを使ってホストに値の更新を依頼する
などの方法を利用するしか無いと思われます。
Fusionの思想的には上2つがお勧めかなと思います。
RPCは後述しますが基本的にはあまり使わない方が良いです。
ちなみに、NetworkBehaviourを継承しているNetworkObjectクラスではInput/State Authorityを所有しているPlayerのPlayerRefを取得することができます
this.Object.InputAuthority//このオブジェクトの入力権限をもっているユーザーのPlayerRef
this.Object.StateAuthority//このオブジェクトのステート変更権限をもっているユーザーのPlayerRef
UIを起点とした入力をどうやって同期させるか
PhotonFusionのチュートリアルやサンプルを読んでいると、PCのキーボード操作が前提だったりする実装になってたので、
今回のように「画面上に表示されているボタン」を押した時、どのようにInputを設定しHostへ伝えるか、すこし悩んでしまいました。
結論から言うと、NetworkButtonsというFusionが提供している構造体を利用することでUI起点のイベントをInputで流すことができました。
まず、Input構造体にこんな感じで定義します
public struct ArBattleSampleNetworkInput : INetworkInput
{
/// <summary>
/// カメラの位置
/// </summary>
public Vector3 CameraPosition;
/// <summary>
/// カメラ角度
/// </summary>
public Quaternion CameraRotation;
//↓New!
//UIボタン
public NetworkButtons UiButtons;
}
そしてEnumを別途定義します。
//UIイベントをInputで流すためのEnum
public enum ArBattleUiButton
{
Fire = 0,
}
そして、OnInputの中をこのように書き換えます
void INetworkRunnerCallbacks.OnInput(NetworkRunner runner, NetworkInput input)
{
//Input情報をセット
var inputData = new ArBattleSampleNetworkInput()
{
CameraPosition = m_camera == null ? UnityEngine.Vector3.zero : m_camera.transform.position,
CameraRotation = m_camera == null ? Quaternion.identity : m_camera.transform.rotation
};
//追加
var button = new NetworkButtons();
if(UIのFireボタンが押されてたら){
button.Set((int)ArBattleUiButton.Fire,true);
}
inputData.UiButtons = button;
//追加ここまで
//入力情報をセットする(Hostなどに共有される)
input.Set(inputData);
}
NetworkButtonsは結局のところint(ボタンのID的なもの)とboolのペアを複数個登録することができる構造体なので、
このようにEnumをint変換した上で、ボタンが押されていたらフラグをtrueにしてセットしてやります。
その上でinputDataを流す事でホスト側で「UIのボタンが押された」タイミングを知ることができます。
実際にNetworkBehaviourを継承したクラスで入力イベントを受け取る時はこのように実装しています
public override void FixedUpdateNetwork()
{
base.FixedUpdateNetwork();
if (GetInput(out ArBattleSampleNetworkInput input))
{
transform.SetLocalPositionAndRotation(input.CameraPosition,input.CameraRotation);
//UIでボタンが押されてたら(Hostのみ実行する)
if (Runner.IsServer && input.UiButtons.IsSet((int)ArBattleUiButton.Fire))
{
//発射!
var obj = Runner.Spawn(m_bulletRef, this.transform.position + (this.transform.forward * 0.1f), Quaternion.identity,
Object.InputAuthority);
}
}
//省略
IsSet関数を使うことで指定したint型の数値がtrueかfalseかを知ることができます。
これを使い判定を取ることで弾を撃つという処理をクライアントのUI起点でHostが処理することができました。
NetworkButtonsという型名なのでコントローラとかのボタンイメージが強かったのですが、UI起点の操作もこれで表現できるのでお勧めです
また、今回はわかりやすさ重視でOnInputの中でUIの入力イベントをとるようなコードになっていますが、
OnInputで流すボタンのフラグ情報を管理するSingletonクラスなどは作っておいた方が良いかなと思います(あまりにもめんどくさいので..)
private void OnClickEvent(){
InputSetter.SetInput((int)ArBattleUiButton.Fire);
}
用意しておけばこんな感じでクリックイベントでボタンのセットができる様になるので便利です。
値の共有をRxでやってみた話と注意点
前述の通り、PhotonFusionでは[Networked]をつけることで値の共有が簡単に行えます。
今回作ったサンプルでは、プレイヤーのパラメータ的なものを保持するためのクラスをNetworkObject(NetworkBehaviourを継承したクラス)として用意し、プレイヤーがセッションに参加した時点でそのプレイヤー用のオブジェクトを生成することでパラメータを取得できる様な作りをしてみました。
また、値の更新イベントが公式の機能だとstatic voidで関数定義が必要で、どうせならIObservableとして公開したかったのでトライしてみました。
以下に実際に書いたサンプルコードを記しているので参考にしてみてください
このコードではHPとユーザー名を設定し同期しています。
public class PlayerSyncParameter : NetworkBehaviour
{
//HP更新イベント
public IObservable<Unit> OnUpdatePlayerHp => m_onUpdatePlayerHp;
private readonly Subject<Unit> m_onUpdatePlayerHp = new Subject<Unit>();
[Networked(OnChanged = nameof(OnChangePlayerHp))] public int m_playerHp { private set; get; }
private static void OnChangePlayerHp(Changed<PlayerSyncParameter> changed)
{
//ここでOnNextしちゃう
changed.Behaviour.m_onUpdatePlayerHp.OnNext(Unit.Default);
}
public void SetHp(int hp)
{
//StateAuthorityがない場合、更新しても無駄なので何もしない
if (!HasStateAuthority) return;
m_playerHp = hp;
}
//プレイヤー名
public IObservable<Unit> OnUpdatePlayerName => m_onUpdatePlayerName;
private readonly Subject<Unit> m_onUpdatePlayerName = new Subject<Unit>();
[Networked(OnChanged = nameof(OnChangePlayerName))] public string m_playerName { private set; get; }
private static void OnChangePlayerName(Changed<PlayerSyncParameter> changed)
{
changed.Behaviour.m_onUpdatePlayerName.OnNext(Unit.Default);
}
//オブジェクトが生成された時に呼ばれる
public override void Spawned()
{
base.Spawned();
if (HasStateAuthority)
{
//host(StateAuthority)が初期値を決める
m_playerHp = 5;
//↑ここで変更したら自動的に全プレイヤーのこのオブジェクトの変数が書き換わる
//このオブジェクトの持ち主でありStateAuthorityである場合は名前をそのままセットするだけでOK
if (HasInputAuthority)
{
SetRandomName();
}
}
//StateAuthorityではないけどこのオブジェクトは自分のものだよーという場合
//InputAuthorityはこのオブジェクトに対する入力権限があるかのフラグ
else if (HasInputAuthority)
{
//RPC飛ばしてStateAuthority側で処理してもらうやで(そうすることでNetworkedがついたプロパティが更新される)
RPC_SetRandomName();
}
}
private void SetRandomName()
{
m_playerName = $"Graffity{Random.Range(1,100)}太郎";
}
/// <summary>
/// StateAuthorityに名前を変えて欲しいという思いを伝えるRPC
/// </summary>
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
private void RPC_SetRandomName()
{
SetRandomName();
}
}
このコードでは、自身がクライアント側の時は自分の名前の決定をRPCでHostに対して処理を依頼しています
名前の決定権はこの場合、Hostではなくクライアント側が持つべきであり、親が検証する必要ないからです(強いて言うならバリデーションかけるとかはHost(StateAuthority)側でやっていいかもですが)
RPCについての詳細は後述します
ちなみにこのやり方はPhotonFusion公式サンプルのAsteroidSampleを参考にしています。
余談ですが実際にPhotonFusionで大規模なゲームを開発する場合はこういったパラメータクラスをキャッシュして取得、値のチェックが簡単にできるような機構は必須そうですね。
RPCを使用する
PhotonFusionでもPUN2などに引き続き、RPC(Remote Procedure Call)をサポートしています
RPCを使うことで、クライアント側からHostに対して処理の実行をさせることができたり、全プレイヤーに対して一気に処理を走らせる様なことが可能です。
しかしながらPhotonFusion的には積極的には使わない方が良いように取れる文章もあるので、個人的には基本的に[Networked]で値の同期をする方法を利用していくのがいいのかなと思います(せっかくのInput,State権限が崩壊するコードになりかねないですし、設計でカバーできるならしたほうが良さそうだなと思います)
Fusionのようなティックベース同期ライブラリにおいては、RPCは特定のティックに紐づいておらず様々なクライアント上で、様々なタイミングで実行してしまうため問題を起こす可能性があります。それ以上に、ネットワークステートの一部ではないので、RPCが送信された後に接続または再接続したクライアントや、アンリライアブルで送信されたことにより受信できなかったクライアントは、その結果が表示されないということになります。
ステート同期自体は、多くのケースではプレイヤーの進捗を合わせておくには充分で、ネットワークプロパティへのOnChangeリスナーの追加は、アプリケーションが地齋のステート自体のみでなくステートでの変更も対処する多くの過渡的なケースに対処することができます。
RPCメソッドはこのように定義します。先ほどのサンプルコードに出てた部分ですね
private void SetRandomName()
{
m_playerName = $"Graffity{Random.Range(1,100)}太郎";
}
/// <summary>
/// StateAuthorityに名前を変えて欲しいという思いを伝えるRPC
/// </summary>
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
private void RPC_SetRandomName()
{
SetRandomName();
}
RPCメソッドには以下の制約があります
- voidメソッドである必要がある
- メソッド名先頭が「RPC」から始まる必要がある
- [Rpc]アトリビュートが必要
Rpcアトリビュートでは、実行可能なソース、Rpcを飛ばすターゲットを指定することができます
RpcSourcesとRpcTargets はフィルターです。 RpcSourcesはRPCを送信できるピアを定義し、RpcTargetsは実行されるピアを定義します。
All: セッション内のすべてのピア(サーバーを含む)から送信・実行可能。
PROXIES:オブジェクトにインプット権限もステート権限も持たないピアにより送信・実行可能。
InputAuthority: オブジェクトにインプット権限を持つピアにより送信・実行可能。
StateAuthority: オブジェクトにステート権限を持つピアにより送信・実行可能。
今回の場合、「入力権限のあるオブジェクトの場合コール可能」で、「StateAuthorityで呼び出される」 という意味になっています。
(とはいえ、StateAuthority以外でこの関数が実行されたとしてもNetworked変数の変更を行う権限がないので何も起きません。メリットは通信量減るくらいですね)
呼び出す際はそのまま関数を呼べばOKです。PUN2時代みたいにPhotonView.RPCみたいなことをする必要もないです
//RPC実行
RPC_SetRandomName();
PUN2などと比べるとかなり気軽にRPCが飛ばせる様になりました。
メソッド名の制約は出ていますが扱いやすさは上がってますね!
まとめ
実際にPhotonFusionを使ってゲームっぽいものを作ってみたわけですが、
PUN2よりは確実に進化している反面、学習コストはやはり高くなっているなと感じました。
コード書く量はPUN2と比べるとどうしても上がってしまう(入力周りなど)のでサクッと検証するみたいな使い方をする場合、自分で処理をラップしたライブラリっぽいものを用意しておくなど、ある程度の工夫は必要そうだなと思いました。
ただ、メソッドがTask対応してたりオブジェクトのロードにAddressableAssetSystemを利用できる様になってたりと、最近のUnity開発との相性は間違いなく良いのでこれから積極的に利用していきたいなと感じました。
まだ軽くしか触れてないとはいえ、ポテンシャルは強く感じたので引き続き利用し何かネタがあれば記事にしていこうと思います。
質問や「ここどうしてるの?」みたいなのがありましたらTwitterとかでお気軽にメンションいただければと思います(答えれる範囲で答えます!)
参考にした記事など
PhotonFusion Fusion100
- 公式なので一番参考になる!..と思ってたんですが結構情報が古かったりするので参考程度に読むのが良いかと思います(もう存在しないクラス使ってたりします)
- また翻訳も怪しいところあるのでEN版を読んだ方が良いです
https://doc.photonengine.com/ja-jp/fusion/current/fusion-100/overview
- また翻訳も怪しいところあるのでEN版を読んだ方が良いです
PhotonFusion Sample (Fusion Asteroids)
- HostModeのサンプルがめちゃくちゃ参考になりました
- Fusionのチュートリアルをやった後はこのプロジェクトをDLして勉強するのが一番把握への近道な気がします
https://doc.photonengine.com/ja-jp/fusion/current/game-samples/fusion-asteroids
- Fusionのチュートリアルをやった後はこのプロジェクトをDLして勉強するのが一番把握への近道な気がします
Photon Discordコミュニティ
- Fusion、ググっても情報全然出てこないのですが結構Discordサーバーでコミュニケーションが盛んでした
https://doc.photonengine.com/en-us/pun/current/getting-started/get-help
Photon Fusion 始めました
PhotonFusionを使って オンラインマルチプレイゲームを 作った話
明日のUnityアドカレ2022
明日のUnityアドカレ2022 カレンダー1は @anidomanta さんです!
カレンダー2は @makaroi2022 さんになります!