ホストモードのプレイヤー入力
はじめに
ホストモード(クライアントサーバー型トポロジー)において、FusionはNetwork Input Systemを使用してサーバーへ入力を共有します。これによって、クライアントサイド予測・ロールバック・再シミュレーションが可能になります。
これを実現するために、Fusionはクライアント上で収集した入力を履歴バッファに保存し、このバッファをホストと同期します。NetworkObjectに入力を与えるクライアントはInputAuthorityと呼ばれ、ホスト/サーバーはStateAuthorityと呼ばれます。クライアントがホストを兼ねることも可能で、その場合はオブジェクトに対するInputAuthorityとStateAuthorityの両方を担います。
入力はFixedUpdateNetworkで以下のように使用されます。
- InputAuthority: ローカルな移動を即時にシミュレートする(予測)
- StateAuthority: 権限下の移動を処理し、「正しい」状態を定義する
- InputAuthority(再):
StateAuthorityから新しいデータを受信した際に、フレームを再シミュレートする(ロールバックと再シミュレーション)
備考: サンプルコードはUnity Input System (Package)の'Dynamic Update'モードをデフォルトで使用しています。サンプルはInput Actionsではなく、KeyboardやMouseを直接使用しています。Legacy Input Systemを使用する場合、構文は違いますがロジックは同じです。
基本的な入力 - 全容
このセクションでは、Fusionでプレイヤー入力を動作させるための最小限の手順(入力構造体の定義・入力のポーリング・入力の消費)を説明します。
1. 入力構造体の定義
ネットワーク同期するすべての入力は、INetworkInputを実装する構造体で定義する必要があります。
C#
public struct GameplayInput : INetworkInput
{
public Vector2 MoveDirection;
}
備考: INetworkInputsは以下の制約に従う必要があります。
INetworkInputを継承する必要がある- プリミティブ型と構造体のみを含む
- 入力構造体とそれに含まれる構造体はすべてトップレベルにある(例:構造体はクラス内の入れ子にできない)
boolのかわりにNetworkBoolを使用する(C#はプラットフォーム間でboolのサイズが統一されていないため、ネットワーク通信用に適切にシリアライズするためにNetworkBoolが使用される)
2. 入力のポーリング(OnInput)
FusionはInputAuthority上でINetworkRunnerCallbacks.OnInput()を呼び出すことで入力を収集します。入力構造体に値を設定し、input.Set()でFusionへと渡してください。
これによって、入力が履歴バッファの一部となり、StateAuthorityとの同期が行われます。
C#
public class PlayerInput : NetworkBehaviour, INetworkRunnerCallbacks
{
public override void Spawned() => Runner?.AddCallbacks(this);
public override void Despawned(NetworkRunner runner, bool hasState) => runner?.RemoveCallbacks(this);
public void OnInput(NetworkRunner runner, NetworkInput input)
{
var myInput = new GameplayInput();
var keyboard = Keyboard.current;
var moveDirection = Vector2.zero;
if (keyboard.wKey.isPressed) moveDirection += Vector2.up;
if (keyboard.sKey.isPressed) moveDirection += Vector2.down;
if (keyboard.aKey.isPressed) moveDirection += Vector2.left;
if (keyboard.dKey.isPressed) moveDirection += Vector2.right;
myInput.MoveDirection = moveDirection.normalized;
input.Set(myInput);
}
...
}
ヒント: プレイヤープレハブには、PlayerMovementスクリプトと一緒にPlayerInputスクリプトを追加してください。
3. 入力の消費(GetInput)
任意のNetworkBehaviourのFixedUpdateNetwork内でGetInput(out T input)を呼び出します。このメソッドは、シミュレート中のティックに対する(予測か再シミュレーションかに関わらず)正しい入力を返します。
C#
public class PlayerMovement : NetworkBehaviour
{
public override void FixedUpdateNetwork()
{
if (GetInput(out GameplayInput input))
{
// クライアントからのデータは信頼できないため、
// StateAuthorityへの入力を効果的にサニタイズするには、
// ここで正規化を適用することが重要
var direction = input.MoveDirection.normalized;
DoMove(direction);
}
}
}
備考: GetInputは、InputAuthorityとStateAuthorityに対してのみtrueを返します。その他のクライアント(プロキシ)ではfalseを返します。
ヒント: ごく稀に、実行までに入力が間に合わない場合(パケットロス・レイテンシスパイクなど)、StateAuthorityでもfalseが返される可能性があります。最後に受信した「正常な」入力をキャッシュして再利用するロジックを追加実装することで、このケースに対処できます。
ネットワーク同期する入力ループは、これら3つの要素で動作します。ここからは、この基盤を拡張するためのルール・注意点・パターンを解説します。
入力失敗の回避
短時間しか確認できない入力値もあります。例えば、毎フレームリセットされるマウス移動量や、数フレームだけ押されたボタンなどです。持続的に押される移動キーとは異なり、これらは適切なタイミングでキャプチャしないと失われる可能性があります。
これは、Unity Input SystemがUpdateで更新される一方、FixedUpdateNetworkは固定のタイムステップで実行されるためです。Updateの1フレーム間に、FixedUpdateNetworkの呼び出しが0回/1回/複数回含まれることがあるため、短い入力はUpdateで蓄積して見逃されないようにする必要があります。
マウス移動量
UnityはUpdate毎にマウス移動量をリセットします。OnInput内でのみ値を取集すると、精度が失われ、回転に「カクつき」が生じます。これは、OnInput呼び出し直前1フレームの移動量しか認識されていないためです。
アドバイス: 視線の回転はUpdate内で蓄積させます。各フレームの移動量を合計し、その値をOnInputでFusionに渡してください。
実装例:
C#
public struct GameplayInput : INetworkInput
{
public Vector2 MoveDirection;
public Vector2 LookRotation;
}
public class PlayerInput : NetworkBehaviour, INetworkRunnerCallbacks
{
public override void Spawned() => Runner?.AddCallbacks(this);
public override void Despawned(NetworkRunner runner, bool hasState) => runner?.RemoveCallbacks(this);
private GameplayInput _input;
void Update()
{
if (HasInputAuthority == false)
return;
// マウス移動量はUpdate毎にリセットされるため、
// 合計値を蓄積して、
// 次のOnInputコールバックのために備える
_input.LookRotation += Mouse.current.delta.ReadValue();
}
public void OnInput(NetworkRunner runner, NetworkInput input)
{
input.Set(_input);
}
}
public class PlayerMovement : NetworkBehaviour
{
public override void FixedUpdateNetwork()
{
if (GetInput(out GameplayInput input))
{
transform.rotation = Quaternion.Euler(0f, input.LookRotation.x, 0f);
}
}
}
ボタンの「タップ」
ボタン(ジャンプ・ダッシュ・射撃など)を追加して、その遷移状態(押された・離された)を正確にキャプチャするには、NetworkButtons型を使用します。これは、複数のボタン状態を単一のビットマスク(ボタン毎に1ビット)にまとめて、遷移を検知するためのヘルパー関数を提供します。
ボタンを列挙型として定義し、入力構造体にNetworkButtonsフィールドを追加してください。
C#
public enum EInputButton
{
Jump,
Sprint,
}
public struct GameplayInput : INetworkInput
{
public NetworkButtons Buttons;
}
プレイヤーが非常に素早い「タップ」操作を行い、OnInputが特定フレーム間で実行されなかった場合、ボタン押下状態を完全に見逃す可能性があります。これに対処するため、ボタン入力はUpdate内で蓄積する必要があります。
実装例:
C#
public class PlayerInput : NetworkBehaviour, INetworkRunnerCallbacks
{
public override void Spawned() => Runner?.AddCallbacks(this);
public override void Despawned(NetworkRunner runner, bool hasState) => runner?.RemoveCallbacks(this);
private bool _resetAccumulatedInputs;
private GameplayInput _input;
void Update()
{
if (_resetAccumulatedInputs)
{
_input.Buttons.Set(EInputButton.Jump, false);
_resetAccumulatedInputs = false;
}
// このフレームでボタンが押されたら、Jumpをtrueに固定する
// 一度設定したら、OnInputで消費するまで設定されたままになる
if (Keyboard.current.spaceKey.isPressed)
{
_input.Buttons.Set(EInputButton.Jump, true);
}
// Sprintは継続保持される状態のため、
// OnInput呼び出し直前のUpdateの値のみが重要になる
// そのため、値を安全に上書きできる
_input.Buttons.Set(EInputButton.Sprint, Keyboard.current.leftShiftKey.isPressed);
}
public void OnInput(NetworkRunner runner, NetworkInput input)
{
input.Set(_input);
// 次のUpdateまでリセット処理を遅延させる
// これによって、1フレーム内で複数のFixedUpdateNetwork呼び出しが発生した場合でも
// 固定された押下状態が維持される
_resetAccumulatedInputs = true;
}
...
}
public class PlayerMovement : NetworkBehaviour
{
// 以前のボタン状態をネットワークプロパティとして保存することで、
// StateAuthorityでアクセス可能、かつ再シミュレーション時にも正しい状態を取得できるようにする
[Networked] NetworkButtons _previousButtons { get; set; }
public override void FixedUpdateNetwork()
{
if (GetInput(out GameplayInput input))
{
// 以前のティックのボタンと比較して、タップを検知する
if (input.Buttons.WasPressed(_previousButtons, EInputButton.Jump))
{
DoJump();
}
if (input.Buttons.IsSet(EInputButton.Sprint))
{
DoSprint();
}
_previousButtons = input.Buttons;
}
}
...
}
ヒント: GetPressed()/GetReleased()は一括比較も可能です。これは、状態が変更されたすべてのNetworkButtons値を返すため、値はIsSet()で確認できます。
C#
var pressed = input.Buttons.GetPressed(previousButtons);
var released = input.Buttons.GetReleased(previousButtons);
if (pressed.IsSet(EInputButton.Jump)) { DoJump(); }
カメラの応答性向上
ネットワーク同期された一人称カメラの一般的な不満点として、視線の回転がオフラインに比べて遅れて感じる点が挙げられます。これは、FixedUpdateNetwork内でカメラを入力に応じて直接移動させる場合に発生します。FixedUpdateNetworkは固定レートで実行されるため、Updateよりも低頻度になる可能性があることを覚えておいてください。
アドバイス: 蓄積された視線の回転をRender()で直接読み取ることで、次の固定ティックを待たずに、レンダリングのフレームレートでカメラを移動させます。OnInputからFixedUpdateNetworkを経由してネットワーク同期する視線の回転は、サーバー上でプレイヤーの向きを決定します。カメラは完全にローカルな視覚要素です。
実装例:
C#
[RequireComponent(typeof(PlayerInput))]
public class PlayerMovement : NetworkBehaviour
{
...
public override void Render()
{
if (Camera.main == null)
return;
// LookRotationはUpdate()毎に更新されるローカルなフィールドのため、
// ここで読み取ることでカメラの応答遅延がゼロになる
var lookRotation = GetComponent<PlayerInput>().LookRotation;
Camera.main.transform.rotation = Quaternion.Euler(lookRotation.x, lookRotation.y, 0f);
// これがNetworkTransformであると仮定すると、
// ここで得られる位置には補間値が使用されるため、
// FixedUpdateNetwork呼び出し間のRender()内でスムーズに更新される
Camera.main.transform.position = transform.position;
}
}
備考: 完全なRender予測移動は、実践的な入力処理をご覧ください。
PlayerInputの完全な例
これまでのスニペットは、それぞれ単一のテクニックを示していました。以下のクラスではそれらを統合し、本番運用対応のPlayerInputになっています。Updateですべての入力を蓄積し、OnInputでFusionに渡します。このパターンは、Fusion Starterサンプルから採用したものです。
C#
public sealed class PlayerInput : NetworkBehaviour, INetworkRunnerCallbacks
{
public Vector2 LookRotation => _input.LookRotation;
private GameplayInput _input;
public override void Spawned() => Runner?.AddCallbacks(this);
public override void Despawned(NetworkRunner runner, bool hasState) => runner?.RemoveCallbacks(this);
private void Update()
{
if (HasInputAuthority == false) return;
var lookRotationDelta = new Vector2(
-Mouse.current.delta.y.ReadValue(),
Mouse.current.delta.x.ReadValue());
_input.LookRotation = ClampLookRotation(
_input.LookRotation + lookRotationDelta);
var keyboard = Keyboard.current;
var moveDirection = Vector2.zero;
if (keyboard.wKey.isPressed) moveDirection.y += 1f;
if (keyboard.sKey.isPressed) moveDirection.y -= 1f;
if (keyboard.aKey.isPressed) moveDirection.x -= 1f;
if (keyboard.dKey.isPressed) moveDirection.x += 1f;
_input.MoveDirection = moveDirection.normalized;
_input.Buttons.Set(EInputButton.Jump, keyboard.spaceKey.isPressed);
_input.Buttons.Set(EInputButton.Sprint, keyboard.leftShiftKey.isPressed);
}
public void OnInput(NetworkRunner runner, NetworkInput networkInput)
{
networkInput.Set(_input);
}
private Vector2 ClampLookRotation(Vector2 lookRotation)
{
lookRotation.x = Mathf.Clamp(lookRotation.x, -30f, 70f);
return lookRotation;
}
}
さらなる例
単一ピアのマルチプレイヤー
これは「カウチ」「画面分割」「ローカル」マルチプレイヤーとも呼ばれ、複数人のプレイヤーが単一のピア(複数のコントローラーを接続したゲーム機など)に入力を与えながら、同時にオンラインマルチプレイヤーゲームに参加できます。Fusionは単一ピア上のすべてのプレイヤーを、単一のPlayerRefの一部として区別せずに扱います(PlayerRefは人間のプレイヤーではなく、ネットワークピアを識別するため)。それでも、「プレイヤー」の定義や入力内容については、開発者が自由に決定できます。
これを実現する方法の一つは、各プレイヤーのINetworkStructを入れ子にしたINetworkInput構造体を定義することです。
C#
public struct PlayerInputs : INetworkStruct
{
// 各プレイヤー固有の入力はここに置く
public Vector2 dir;
}
public struct CombinedPlayerInputs : INetworkInput
{
// この例では、単一ピアの最大プレイヤー数を4人とする
public PlayerInputs PlayerA;
public PlayerInputs PlayerB;
public PlayerInputs PlayerC;
public PlayerInputs PlayerD;
// 入れ子のプレイヤー構造体にアクセスしやすくするためのインデクサー
public PlayerInputs this[int i]
{
get {
switch (i) {
case 0: return PlayerA;
case 1: return PlayerB;
case 2: return PlayerC;
case 3: return PlayerD;
default: return default;
}
}
set {
switch (i) {
case 0: PlayerA = value; return;
case 1: PlayerB = value; return;
case 2: PlayerC = value; return;
case 3: PlayerD = value; return;
default: return;
}
}
}
}
以下のように、複数プレイヤーの入力を収集します。
C#
public class CouchCoopInput : NetworkBehaviour, INetworkRunnerCallbacks
{
...
// 接続しているゲームパッドのキャッシュ
public void OnInput(NetworkRunner runner, NetworkInput input)
{
var myInput = new CombinedPlayerInputs();
var gamepads = Gamepad.all;
for (int i = 0; i < Mathf.Min(gamepads.Count, 4); i++)
{
var stick = gamepads[i].leftStick.ReadValue();
myInput[i] = new PlayerInputs() { dir = stick };
}
input.Set(myInput);
}
...
}
そして、シミュレーションで入力を取得します。
C#
public class CouchCoopController : NetworkBehaviour
{
// 0~3のプレイヤーインデックス
// このオブジェクトの操作に関連するプレイヤーが4人の中の誰かを示す
private int _playerIndex;
public override void FixedUpdateNetwork()
{
if (GetInput<CombinedPlayerInputs>(out var input))
{
var dir = input[_playerIndex].dir;
// ジョイスティック方向をプレイヤーの向きに変換する
float heading = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;
transform.rotation = Quaternion.Euler(0f, heading - 90, 0f);
}
}
...
}
Samples
入力処理はコンテキスト依存で、高精度の格闘ゲームと物理ベースのレーシングゲームでは、異なるアプローチが必要になります。ここで紹介した実装例が、実際のゲームにどのように適用するかを確認するには、Fusionのゲームサンプルをぜひご覧ください。これらのサンプルは、Fusionの入力システムを特定のジャンルに合わせて調整する方法を示していて、独自実装を行う際の参考にもなります。
実践的トピック
低遅延が求められる対戦ゲームなどでは、以下の技術によって知覚的な遅延をさら低減し応答性を向上できます。
- Render予測 — キャラクタービジュアルの位置を、直近2ティック間で補間するかわりに、
Render()内で完全な予測移動シミュレーションを実行することで、システム遅延を約12msから約4msに低減できます。詳細はAdvanced KCCのRender Behaviorをご覧ください。 - 入力スムージング — マウス移動量の生データはノイズが多く、高フレームレートではわずかなジッターが発生します。短時間範囲(10~20ms)で移動量を平均化することで、数ミリ秒の遅延を代償にカメラ回転をスムーズにします。詳細はAdvanced KCCの入力スムージングをご覧ください。
- IBeforeUpdateのタイミング —
Update()ではなくIBeforeUpdate.BeforeUpdate()で入力をキャプチャすることで、Fusionパイプライン実行直前に最新の値を確保できるため、低フレームレート時のレイテンシをわずかに低減できます。実際の動作はStarter Shooter Sampleで確認できます。 - 実践的かつ完全な実装 — 高精度な入力処理を目指す場合は、BR200サンプルが実践的かつ優れた例になります。ぜひご確認ください。