ネットワークシミュレーションループ
はじめに
Fusionは、使用中のモードにかかわらず、一定のタイムステップで離散的なティックベースシミュレーションを実行します。これを処理する全体的なプロセスは「ネットワークシミュレーションループ(Network Simulation Loop)」と呼ばれます。すべてのロジックはNetworkBehaviour
を継承したクラスに記述するため、Unity標準のゲームオブジェクトがシミュレーションループの中心的な存在になります。
Fusionは、ワールドの状態のスナップショットを進行することに加えて、ローカルプレイヤーの入力、または物理ベースのオブジェクトに基づいて、未来の状態を予測することもできます。(物理のクライアントサイド予測はオプションになります)
どんなオブジェクトに対しても、既知のデータや独自コード(例:弾道軌道の補外)に基づいて、クライアントサイド予測のロジックを記述することが可能です。
ここでは、非常に重要な概念である「Fusionのシミュレーションループはどのように動作するのか」の詳細について説明します。
ティック
異なる端末間では、実行タイミングの不整合が自然に発生します。Fusionは、時間を直接使用するかわりに、「ティック」と呼ばれる離散的抽象時間単位を使用してシミュレーションを処理します。ティックは、実際の現実時間とは分離されて、任意のクライアントやホストに渡されます。ハードウェアクロックのかわりにティックを使用することで、セッション内のすべてのクライアントは、「時間」という概念の基準枠を共有できます。ティックは精度問題などでずれることはないため、複数の端末間に渡る未来/過去のイベントを正確に推定するために非常に重要です。
各ティック間のタイムステップは、NetworkProjectConfig
のSimulation > Tick Rate
から定義できます。ティックレートは、ヘルツ(Hz)で定義するため、値を60にすると1/60秒と同じになります。これはつまり、前回と現在のティック間で実際に経過した時間にかかわらず、ゲームの状態は1/60秒のタイムステップで進行するということです。コード上では、NetworkRunner.DeltaTime
プロパティから、タイムステップを取得できます。
重要: シミュレーションループに使用されるティックレートは、フレームレートとは異なります!
各ティックでは、その時点でのワールドの「真実」を明示的に表す、状態のスナップショットが生成されます。Fusionは、関連するゲームオブジェクトの状態を更新するために、すべてのNetworkBehaviour
のFixedUpdateNetwork()
を呼び出し、次のティックの新しいスナップショットを生成します。そこで参照されるのは、GamaObject
のState Authority
(状態権限)を持つ端末の状態です。
どんなオブジェクトに対しても、State Authority
を持つ端末は必ず一つです。
- ホストモードでは、常にサーバーが権限を持ちます
- 共有モードでは、各クライアントがオブジェクトの権限を持つことができ、通常は自身が生成したオブジェクトの権限を持ちます
入力処理
入力は、クライアントサイド予測でも使用されます。クライアントは、ローカルにある(古い)情報と、ローカルプレイヤーの入力に基づいて、次のサーバーの状態を予測します。実際のサーバーの状態を受信すると、クライアントはそれを真実の情報として、新しい未来の状態を再シミュレーションします。
予測を可能にするために、入力処理は2つのステップに分けられます。
- 入力のポーリング:特定のオブジェクトに対して入力権限を持つクライアントのローカルのハードウェアから、入力を収集します
- 入力の消費:ローカルのシミュレーション、または入力を共有されたサーバーのシミュレーションで、入力を適用します
入力の定義/ポーリング/消費などの詳細は、マニュアルのプレイヤー入力をご覧ください。
予測
予測とは、クライアントが現在のローカルの情報に基づいて未来のサーバーの状態を予測することです。(サーバーから受信した真実のゲームの状態のスナップショットに基づく)ローカルの情報は、端末間の遅延の影響を受けるため、常に古い情報となります。クライアントは予測を使用して、ローカルのゲームの状態がサーバーのゲームの状態に遅れないように試みます。これは、通信上の遅延がプレイヤー体験に与える影響を軽減するために必須です。通信上の遅延は、クライアント/サーバー間を往復するラウンドトリップタイムと常に同じで、ティック単位で推定されます。
以下のようなシナリオを想定してみましょう。
- 現在のサーバーのティック:100
- ティックレート:1/60秒
- クライアント1の遅延は、4ティックと同等(サーバーからクライアントまで2ティック、クライアントからサーバーまで2ティック)
- クライアント2の遅延は、6ティックと同等(サーバーからクライアントまで3ティック、クライアントからサーバーまで3ティック)
各クライアントは、自身の遅延を把握しています。クライアントがサーバーより遅れないためには何ティック予測する必要があるかを、サーバーから伝えられるためです。予測によって、サーバーが入力を必要とする任意のティックに間に合うように、クライアントは入力を送信できるようになります。また、クライアントが入力権限を持つオブジェクト(例:プレイヤーキャラクター)は、ローカルの入力を使用して予測するため、即時レスポンスを得られる錯覚が与えられます。入力権限と呼ばれるのは、オブジェクトの状態は明示的に制御できないものの、オブジェクトの状態を更新するための入力は制御できるためです。
具体的には、クライアント1は自身のプレイヤーキャラクターを移動する際に、サーバーから受信した最新の検証済みティックに基づいて未来の状態を予測します。その予測に基づいて、クライアント1がサーバーから見て2ティック先(ティック102)で何をする予定かをサーバーに伝えます。その入力は十分な余裕をもって通信され、ティック102の検証済み状態をシミュレーションするための消費に間に合うように、サーバーに届けられます。クライアント1のキャラクターの移動に干渉するものが何もなければ、クライアント1の予測したティック102と、サーバー上の検証済みティック102は、同じ状態になります。
クライアント1は、遅延を補償できるように、常にサーバーの状態より少なくとも2ティック先を予測するようにサーバーから伝えられているため、十分先のティックを予測することが保証されています。一方で、クライアント2は高遅延のため、3ティック先を予測する必要があります。
図に示されているように、クライアント1とクライアント2は、サーバーとの遅延が異なるため、全く同じ状態にはなりません。クライアントは、他のクライアントからまだ届いていない情報があり、自身が認識している「真実」には誤りがあることを知っています。ここで、レプリケーションとリコンシリエーションの出番です。
レプリケーション
状態は、コンパクトなメモリバッファで管理・シミュレーションされます。サーバーから状態のスナップショットを受信すると、そのスナップショットがメモリバッファに展開されます。バッファ上にある現在の状態は予測に基づいているため、スナップショットの状態に置き換えられます。これによって、サーバーから受信したスナップショットのティックまで、クライアントは効率的にロールバックします。その後、受信した状態からクライアントがローカルで予測していた状態までの、各ティックのシミュレーションループが一度に実行されるため、スナップショットを受信する前の状態に戻りつつ、より正確に予測した状態となります。
リコンシリエーション
クライアントは、より直近の(より正確な)ワールドの状態の新しいスナップショットを受信すると、その状態とローカルの状態とを調整(リコンシリエーション)します。リコンシリエーションは、クライアントが受信した最新のサーバースナップショットの複製(レプリケーション)と、サーバースナップショットのティックから現在のティックまでのローカルの状態の再シミュレーションとで行われます。
その結果として、サーバーは常に調整済みの状態となり、クライアントは予期せぬイベントの発生を徐々に自己調整できます。クライアントの状態をサーバーの状態と調整するために、新しい検証済み状態に基づいて入力を再適用することから、Fusionでは再シミュレーションとも呼ばれます。
これが非常に重要になるのは、サーバー上でクライアント1とクライアント2が衝突した場合です。衝突後の位置はサーバーによって解決されます。
なぜなら、任意のティックにおけるすべてのオブジェクトの状態は、サーバーが独占的に決定できるためで、これがサーバーが状態権限を持つと言われる理由でもあります。
例
サーバーはクライアントへ継続してデータを送信します。クライアント1は検証済みティック100を受信すると、ティック100の検証済み状態をメモリバッファに複製して、過去に予測したティック100を上書きします。
- 予期せぬことが発生していなければ、ローカルで予測したティック100は既に正しい状態になっているため、クライアント1がサーバーより2ティック進んでいることを除けば、クライアント1とサーバーは完全に同期しています
- 検証済み状態とクライアント1が予測した状態が異なっている場合は、検証済みティック100が正しい状態として使用されます
どちらの場合も、クライアントは連続して100・101・102の入力を再適用し、新規のティック103に到達します。
クライアント1視点で時間を進めて、検証済みティック101を受信した際は、102・103の入力を適用してティック104に到達することになります。
クライアント2も、全く同じ流れでシミュレーションループを進めます。唯一の違いは、高遅延の影響でサーバーの検証済みティックを受信するまでに1ティック遅延が大きいことで、これはより先の未来を予測することで補償されます。
FixedUpdateNetwork()
Fusionは、シーン上のすべてのNetworkBehaviour
コンポーネントでFixedUpdateNetwork()
を呼び出して、現在のティックから次のティックへ状態を進めます。FixedUpdateNetwork
は、レプリケーションと予測処理の両方で呼び出されるため、ローカルの状態は、ネットワーク上の状態からのみ導き出されます。FixedUpdateNetwork
では、ローカルの状態の差分を漸進的に更新していくため、非常に多くの変更が適用される結果になります。
スナップショット補間
各クライアントは、1つまたは選ばれた少数のGameObject
に対してのみ入力権限を持つことが多く、それ以外のGameObject
の状態は、スナップショットの更新によってクライアントに与えられます。予測した状態とは異なり、スナップショットは常にサーバーより遅れた状態になります。これは、サーバーが送信した時間と、クライアントが受信して複製する時間の間には、いくらかの遅延が存在するためです。
「リモートで」制御されるGameObject
は、Fusionではプロキシ(Proxy)と呼びます。
プロキシの現在の(ビジュアルの)状態にスナップショットをそのまま適用すると、クライアントのフレームレートではなく、シミュレーションのティックレートで、プロキシの更新やアニメーションが行われます。これは、いくつかの理由から避けるべきです。
- 帯域を節約するため、シミュレーションはレンダリングより大幅に低頻度で実行することが望ましいです。例えば、フレームレート120Hzに対して、ティックレート30Hzなど。
- 通信パケットは一定間隔で届くわけではありません。このような非常にランダムな特徴を持つイベントと、ゲームのフレームレートを結びつけると、ギクシャクした動きが発生してしまいます。
通信パケットの頻度とフレームレートを分離しつつ、スムーズな状態遷移を提供するために、Fusionは2つのスナップショット間の「描画状態」を補間します。補間の目的は、ティックよりリアルタイムにスムーズな描画を行うことで、理想的には2つの直近のスナップショットに基づいた補間になります。ただし、スナップショットは一定間隔では届かないことがあります。わずかなマージン(バッファ)を持たせているのでもない限り、一定間隔で行われる補間が、一定間隔ではなく届くスナップショットに追いついてしまうリスクがあります。
Fusionの強みの一つが補間アルゴリズムで、現在のネットワーク状況に応じたオフセットやバッファリングに適応します。これによって、クライアントの遅延は可能な限り低減されつつ、スムーズな描画が可能になります。
NetworkTransform
のようなFusionの主要なコンポーネントは、ビジュアル要素の変更に補間を使用しています。開発者は、ネットワークプロパティに対しても、Fusionの補間機能を使用することができます。補間機能の詳細はこちらをご覧ください。