Photon Fusionのシミュレーションを理解しよう
はじめに
Photon Fusionにはオンラインゲーム開発に役立つ魅力的な機能が多数搭載されていますが、そこで大量に登場する専門的な技術用語は、あまり聞き慣れていない開発者も多いかもしれません。ただ多くの用語はFusion独自の概念ではなく、オンラインゲーム黎明期(約20年前のFPSやMMO、格闘ゲームのオンライン対戦など)の頃からある技術の流れを汲んだものになっているので、各技術の基礎知識を知っておくことで、Fusionの学習を効率的に進めることができるようになるでしょう。この記事では、Fusionで使われている技術はどういったものなのか?Fusionはどのような仕組みで同期を行っているのか?を、三つの観点に分けて説明していきます。
観点 | 内容 |
---|---|
概念 | (Fusion独自ではない)一般的なオンラインマルチプレイゲーム開発技術についての話 |
仕様 | 概念で説明した技術を、Fusionはどのような機能・仕組みとして取り入れているのかの話 |
実装 | 仕様で説明した機能を、Unity(C#)のスクリプト上ではどう記述して利用するのかの話 |
概念
オンラインゲーム開発で避けられない大きな課題の一つが「遅延」です。ネットワーク上で何かしらのデータを送受信するには必ず時間がかかり、その影響をゼロにするのは物理的に不可能だということです。そうした状況の中で、いかにプレイヤーのゲーム体験を損ねずに(実際には発生している遅延をプレイヤーには感じさせないように)同期するかが重要になります。
ティックベースシミュレーション
ティックベースシミュレーション(Tick-Based Simulation)はその名の通り、ティック(Tick)と呼ばれる離散的な時間単位を基準に進行するシミュレーションを指します。1秒間にティックが更新される回数はティックレート(Tick Rate)と呼ばれます。
と言うと何か難しそうな用語に聞こえますが、Unityは「フレーム」という単位を基準に進行する(スクリプトの実行と描画を行う)フレームベースのシミュレーションであり、フレームはティックともみなせるので、ティックベースシミュレーションであるといえます。さらに、Unityの物理演算もティックベースシミュレーションになるので、実はUnityの開発者であれば既に自然に利用している仕組みになっています。ここでは、Fusionはそのどちらでもないネットワーク専用のティックベースシミュレーションを導入しているということを覚えておきましょう。
エンティティ補間
通常、オンラインゲームにおけるティックレートは、フレームレートより低く設定することが多いため、ティック単位で更新されるゲームの要素をそのまま描画しようとすると、表示がカクついて見えたり、断続的にテレポートして見えたりすることがあります。
これを自然なアニメーションとして見せるために、ゲームの要素(エンティティ)をスムーズに補間しながら更新するのが、エンティティ補間(Entity Interpolation)です。補間の有無で、ホスト(左側)が操作するゲームの要素が、他のクライアント(右側)からはどう違って見えるのかを比較してみると、以下のようになります。
「補間なし(上)」と「補間あり(下)」の比較
エンティティ補間には、ネットワーク上で同期しているゲームの要素がスムーズに表示されるという大きなメリットがありますが、補間にかける時間の分だけ「本来のゲームの状態」に到達するまでの遅延が増加するという無視できないデメリットもあります。ゲームの状態の差異が「テレポートしたようには見えないほど十分に小さい」または「補間が追いつかないほど非常に大きい」場合には、補間処理をスキップして遅延を解消する方が良いこともあるでしょう。
ラグ補償
オンラインゲームでは、ネットワーク通信上の遅延(ラグ)は避けられません。エンティティ補間を行っているなら、画面表示上の遅延はさらに大きいものになるでしょう。そのため、クライアント/サーバー型(Fusionにおけるホストモード)のオンラインゲームでは、クライアントは常に遅延の分だけ過去のゲームの状態を基準にしてゲームをプレイしていることになります。
ここで、FPSやTPSのようなオンライン対戦シューティングゲームなど、正確な当たり判定が求められる射撃を行うケースを考えてみると、以下のような問題に直面するでしょう。
- サーバー側で当たり判定を行う:過去のゲームの状態を基準に狙いを定めるクライアントは、射撃を意図した通りに命中させるのが困難になる
- クライアント側で当たり判定を行う:クライアントは射撃を意図した通りに命中させることができる反面、悪意のあるクライアントからのチートには弱くなる
この問題を解決するために使われるのが、ラグ補償(Lag Compensation)です。サーバーが見ている「現在のゲームの状態」ではなく、クライアントが見ている「過去のゲームの状態」を基準にして当たり判定を取れるようにすることで、ゲームの状態を更新する権限をサーバー側に持たせたままで、クライアントは射撃を意図した通りに命中させられるようになります。
「現在のゲームの状態(左)」ではなく「過去のゲームの状態(右)」で当たり判定を取る
ただし、過去のゲームの状態を基準にして当たり判定を取れるということは、クライアントは回避したはず(安全な場所に隠れて当たらないはず)の相手の射撃に当たるようになるということでもあるため、ラグ補償をするどうかはゲーム内容によって検討する余地があります。
クライアントサイド予測
クライアント/サーバー型(Fusionにおけるホストモード)のオンラインゲームは、ゲームの状態を更新する権限がサーバー側にあるので、各クライアントは「正しいゲームの状態」をサーバーから受信するまで待って更新していく形になります。もしここで何も考慮せずに同期を行うと、以下のようなゲーム体験を大きく損なう問題が発生する可能性があります。
- クライアントはネットワーク通信上の遅延の分だけ、ホスト(サーバー)よりゲームの状態を更新するタイミングが遅れるため、ホストは常にクライアントより時間的に有利になる
- クライアントは自身のアバターなどを操作する場合でも、入力をサーバーに送信してサーバーが更新した状態を受信するまで待つ必要があるため、入力操作のレスポンスが悪くなる
クライアントサイド予測(Client-Side Prediction)は、サーバーからの「正しいゲームの状態」の受信を待つことなく、クライアント側で予測としてゲームの状態を更新して進めることによって、クライアントの入力操作に対する即時のフィードバックを可能にします。ホストとクライアント間の有利不利も軽減されるため、クライアントのゲーム体験は大きく向上します。しかし予測はあくまで予測であり、「サーバーから受信した正しいゲームの状態」と「クライアントが予測した仮のゲームの状態」が異なった時に同期ズレが起こるという別の問題が発生するようにもなるので、その誤差を修正する処理が必要になります。
サーバーリコンシリエーション
サーバーリコンシリエーション(Server Reconciliation)は、クライアントサイド予測で発生する「サーバーから受信した正しいゲームの状態」と「クライアントが予測した仮のゲームの状態」との同期ズレを修正する方法の一つとして、ロールバック(Rollback)と再シミュレーション(Resimulation)を行います。
- ロールバック:クライアントはサーバーからゲームの状態を受信すると、それまでの「クライアントが予測した仮のゲームの状態」にかかわらず、「サーバーから受信した正しいゲームの状態」までゲームの状態を巻き戻す(過去のゲームの状態で上書きする)
- 再シミュレーション:クライアントがロールバックを実行した後の「ロールバックして戻った時点の時間」から、あらためて予測としてゲームの状態を「クライアントが予測していた時点の時間」まで更新して進める
これにより、クライアントは常に「最新の『正しいゲームの状態』を元に予測した仮のゲームの状態」を維持することができるようになります。クライアントは再シミュレーションで、1フレーム中に何度もゲームの状態を更新する処理を実行する必要があるので、その分だけ負荷が高くなる可能性があることに注意しましょう。
仕様
Fusionのティックベースシミュレーションの全体的な処理の流れは、ネットワークシミュレーションループ(Network Simulation Loop)と呼びます。ゲーム開発者は、Unity標準のライフサイクルではなく、ネットワークシミュレーションループに基づいて、ネットワーク上での同期ロジックを実装することになります。
FixedUpdateNetwork
ここでまず、なぜFusionは独自にネットワーク専用のティックベースシミュレーションを導入しているのか?Unity標準のライフサイクルでは何が不十分なのか?を考えてみましょう。
Update()
Unity標準で毎フレーム実行される汎用的なイベント関数です。Update()
の特徴をオンラインゲーム開発の視点から見ると、現実時間に合わせて進行する、実行周期は可変、そして当然ですが異なる端末間での同期は行われていない等が挙げられるでしょう。
Update()
は異なる端末間で実行周期や実行タイミングが全く異なる(現実時間)
しかし、異なる端末間で実行周期や実行タイミングが異なるということは、ネットワーク通信上で発生する同期ズレ以前の段階で、端末ごとにゲームの状態にズレが生まれているといえます。これがオンラインゲームにおいては、同期の精度を落としてしまう原因の一つになっています。
FixedUpdate()
Unity標準の固定周期で実行されるイベント関数で、主に物理演算のために使用されます。
FixedUpdate()
はUpdate()
と同じくフレーム単位で実行される(現実時間)
FixedUpdate()
は固定周期で実行される(離散時間)
実行周期が固定なので、ゲームの状態にズレは発生しにくいものの、そもそもUnityの物理演算用の仕組みをネットワーク用でも共有して利用するのは、色々と不都合が多い面があります。
FixedUpdateNetwork()
Fusion独自のティックベースシミュレーションに基づいて、ティックが進むごとに呼び出されるメソッドです。FixedUpdateNetwork()
は、ティックが現実時間とは切り離された離散的な時間単位で、実行周期が固定のネットワーク専用の仕組みになっているため、異なる端末間でもズレを生じさせずにゲームの状態を同期することができます。
FixedUpdateNetwork()
は異なる端末間で全く同じ間隔・タイミングで実行される(離散時間)
また、FixedUpdateNetwork()
では、クライアントサイド予測とサーバーリコンシリエーション(ロールバックと再シミュレーション)が自動的に行われるようになっています。ホストモードのクライアントでは、1フレームの間にFixedUpdateNetwork()
が何度も呼ばれるのが正常な動作になるので、不具合と間違えないようにしましょう。
ホストモードのクライアントでは、1フレームの間にFixedUpdateNetwork()
が何度も呼ばれる(現実時間)
スナップショット
Fusionではネットワーク上で同期しているゲームの状態全体を、スナップショット(Snapshot)と呼びます。これは実際には全てのネットワークプロパティの集合になります。
つまり、Fusionで「ゲームの状態を更新する」とは「FixedUpdateNetwork()
内でネットワークプロパティを更新する」ことになります。この記事では、Fusionのシミュレーションの内部的な動作について色々と説明してはいますが、通常それらの動作は透過的に(意識せずに)扱えるようになっているので、ゲーム開発者は「あるティック(FixedUpdateNetwork()
内で実装する」ことだけを考えれば、後はFusionが裏側で全部良い感じにしてくれると思ってもらっても良いでしょう。
FixedUpdateNetwork()
内でネットワークプロパティを更新する処理のみにフォーカスできる
ちなみにFusionでは、エンティティ補間はスナップショット補間(Snapshot Interpolation)と呼ばれています。呼び方は違いますが、処理の内容自体は先に説明したエンティティ補間と全く同じです。スナップショット補間は、スナップショット(各ネットワークプロパティの値)に対して、ティック間のスムーズな値の補間を行う機能になっています。
シミュレーションの流れ
ここでは、ホストモードの動作を例にして、ホスト(サーバー)とクライアントがどのように通信を行って、正確な同期を実現していくかを見ていきましょう。
入力の送信(クライアントサイド予測)
クライアントは、ホストより数ティック先の未来を予測して処理が進められます。具体的には、レイテンシ(Latency:クライアント/サーバー間の通信にかかる片道の時間)を補うのに十分先のティックを予測するように調整されます。ホストから受信するスナップショットは、レイテンシの分だけ過去のティックのものになるので、実際には、RTT(Round Trip Time:クライアント/サーバー間の通信にかかる往復の時間)相当のティック数の予測を行うことになります。
クライアントはホストより先のティックを予測してその入力を送信する(現実時間)
上の図の例では、レイテンシは2ティック分になっていて、ホストがTick100の時に、クライアントはTick98から4ティック分の予測を行ってTick102に進みます。ここでクライアントが送信したTick102の入力は、ホストがTick102のスナップショットを作成する際に正しく反映されます。
状態の受信(サーバーリコンシリエーション)
クライアントは、ホストからスナップショットを受信すると、ゲームの状態をそのスナップショットのティックまでのロールバックします。この時、それまでクライアントが予測していたティックのスナップショットは完全に上書きされます。そしてその後、クライアントが予測していたティックまでの再シミュレーションを行った上で、さらにその先のティックの予測処理を進める流れになります。
クライアントはホストからスナップショットを受信するとそのTickまでロールバックする(現実時間)
上の図の例では、Tick102まで予測していたクライアントは、ホストから受信したスナップショットのTick99までロールバックした後、Tick100からTick102までの再シミュレーションを行い、新たに次のTick103へ予測を進めます。
シミュレーションの実行順序
Fusionの実行ループは、以下の3つのループに分解することができます。
- Resimulationループ:(ロールバック後の)再シミュレーション処理を実行します
- Forwardループ:新しいティックに進む処理を実行します
- Renderループ:ネットワーク上で同期するゲームの状態やロジック以外の処理を実行します
ResimulationループとForwardループは合わせて、Updateループ(ネットワークシミュレーションループ)と呼ばれます。Updateループはティック毎に実行されるのに対して、Renderループはフレーム毎に実行されます。Renderループの主な用途は、スナップショット補間、アニメーションやビジュアルエフェクトの再生などの、描画関連の更新になるでしょう。
実装
ネットワークシミュレーションループに基づいた同期ロジックは、ネットワークオブジェクトにNetworkBehaviour
かSimulationBehaviour
を継承したスクリプトを追加して実装します。
NetworkBehaviour
NetworkBehaivour
を継承したスクリプトでは、ネットワークプロパティが定義できるようになり、以下のメソッドをオーバーライドして使用できるようになります。
メソッド | 説明 |
---|---|
Spawned() |
ネットワークオブジェクトが生成された時に呼ばれる |
Despawned() |
ネットワークオブジェクトが破棄された時に呼ばれる |
FixedUpdateNetwork() |
ティック毎に呼ばれる(FusionのUpdateループに対応) |
Render() |
フレーム毎に呼ばれる(FusionのRenderループに対応) |
using Fusion;
using TMPro;
public class NetworkBehaviourExample : NetworkBehaviour
{
[Networked]
private int Score { get; set; }
private TextMeshProUGUI scoreLabel;
private Interpolator<int> scoreInterpolator;
public override void Spawned() {
scoreLabel = GetComponent<TextMeshProUGUI>();
scoreInterpolator = GetInterpolator<int>(nameof(Score));
}
public override void FixedUpdateNetwork() {
Score += 100;
}
public override void Render() {
scoreLabel.SetText("{0}", scoreInterpolator.Value);
}
}
SimulationBehaviour
SimulationBehaviour
は、ネットワークプロパティを定義する必要はない(状態を持たない)が、ネットワークシミュレーションループに基づいた処理を実行させたいネットワークオブジェクトに利用できます。
SimulationBehaviour
のプロパティからは、NetworkRunner
やNetworkObject
の参照が取得できるので、これらの参照を経由して、現在のティックに関する様々な情報や、ネットワークオブジェクトの権限などを確認することができます。以下の表はその一例です。
プロパティ | 説明 |
---|---|
Runner |
(ネットワークオブジェクトを生成した)NetworkRunner の参照 |
Runner.Tick |
現在のティック値 |
Runner.IsResimulation |
Resimulationループ中であるかどうか |
Runner.IsForward |
Forwardループ中であるかどうか |
Runner.IsFirstTick |
(ResimulationループかForwardループの)最初のティックかどうか |
Runner.IsLastTick |
(ResimulationループかForwardループの)最後のティックかどうか |
プロパティ | 説明 |
---|---|
Object |
ネットワークオブジェクト(NetworkObject コンポーネント)の参照 |
Object.HasStateAuthority |
自分自身がネットワークオブジェクトの状態権限を持っているかどうか |
Object.HasInputAuthority |
自分自身がネットワークオブジェクトの入力権限を持っているかどうか |
using Fusion;
using UnityEngine;
public class SimulationBehaviourExample : SimulationBehaviour
{
public override void FixedUpdateNetwork() {
Debug.Log($"FUN: {Runner.Tick} {Runner.IsResimulation} {Runner.IsForward} {Runner.IsFirstTick} {Runner.IsLastTick}");
}
public override void Render() {
Debug.Log($"Render: {Time.deltaTime}");
}
}
ティックレートの設定
ティックレートは、NetworkProjectConfig
から設定できます。FixedUpdateNetwork()
が呼ばれる頻度に直接影響を与えるので、開発するゲームの内容に応じて適切な値に調整しましょう。
Unity標準のイベント関数の注意点
ネットワークオブジェクトで、Spawned()
が呼ばれる前やDespawned()
が呼ばれた後に、ネットワークプロパティにアクセスするとエラーになります。これは主にFusion初心者が、Unity標準のライフサイクルとネットワークシミュレーションループを混同したことで発生する典型的なエラーです。NetworkBehaviour
(SimulationBehaviour
)を継承したスクリプトで、Unity標準のイベント関数を使用する際の注意点を、以下の表にまとめたので参考にしてみてください。
イベント関数 | 注意点 |
---|---|
Awake() |
ネットワークオブジェクトが生成された時に一度だけ実行したい(オブジェクトプールで再利用する時には実行したくない)処理に使用できますが、特にこだわりや最適化等の必要がなければ、素直にSpawned() を使用するのが良いでしょう。 |
Start() |
Awake() と同様です。 |
Update() |
原則として使用を避けるのをオススメします。ティック毎の処理を実行したいならFixedUpdateNetwork() を、フレーム毎の処理を実行したいならRender() を使用しましょう。 |
LateUpdate() |
Update() と同様です。全てのFixedUpdateNetwork() が呼ばれた後に実行したい処理があるなら、後述するAfterTick() やAfterAllTicks() などが使用できます。 |
FixedUpdate() |
基本的に使用することはありません。Fusionはデフォルトで物理シミュレーションをFixedUpdateNetwork() のタイミングで実行するので、物理演算を行う場合でも使用する機会はないでしょう。 |
OnDestroy() |
基本的に使用することはありません。ネットワークオブジェクトが破棄された時の処理を実行したいなら、素直にDespawned() を使用すると良いでしょう。 |
シミュレーションのコールバックインターフェース
基本的にネットワークの同期ロジックは全てFixedUpdateNetwork()
内で実行しますが、複雑で実践的な処理を実装する際には、その実行順序をより細かく制御したいケースもあるでしょう。
Fusionには、ネットワークシミュレーションループ中に任意の処理をフックするためのインターフェースが多数定義されているので、それらのインターフェースを実装することで様々なタイミングのコールバックを受け取ることができるようになります。以下の表はその一例です。
インターフェース | 説明 |
---|---|
IBeforeUpdate() |
FusionのUpdateループ開始前に呼ばれる |
IBeforeAllTicks() |
ResimulationループとForwardループの実行前に呼ばれる |
IBeforeTick() |
(ティック毎に)FixedUpdateNetwork() 実行前に呼ばれる |
IAfterTick() |
(ティック毎に)FixedUpdateNetwork() 実行後に呼ばれる |
IAfterAllTicks() |
ResimulationループとForwardループの実行後に呼ばれる |
IAfterUpdate() |
FusionのUpdateループ終了後に呼ばれる |
using Fusion;
using UnityEngine;
public class CallbackInterfaceExample : NetworkBehaviour, IBeforeAllTicks, IAfterTick, IAfterAllTicks
{
public void BeforeAllTicks(bool resimulation, int tickCount) {
Debug.Log($"BeforeAllTicks: {resimulation} {tickCount}");
}
public override void FixedUpdateNetwork() {
Debug.Log($"FUN: {Runner.Tick}");
}
public void AfterTick() {
Debug.Log($"AfterTick: {Runner.Tick}");
}
public void AfterAllTicks(bool resimulation, int tickCount) {
Debug.Log($"AfterAllTicks: {resimulation} {tickCount}");
}
}
実行順序とコールバックの全容(以下の公式ドキュメントページからダウンロード出来ます)
参考リンク(英語)
Discussion