はじめに
エンジニアの松原です。最近の開発でネットワーク回り関連のフレームワークを調査していたところ、ちょうどNetcode for Gameobjects がリリースされました。
仕事上でこれまでUNetやMLAPIを使ったことが無かったため、せっかくなのでこの機会に触ってみようと思い、実際にコードを書きつつ試してみました。
今回自分がハマったポイントやコードの書き方の落としどころについて記事にしました。
また、今回コードに起こしたものを以下の個人用のGitHubにリポジトリに置いていますので、参考になれば幸いです。
実行環境について
下記のUnityバージョン、依存パッケージを使っています。
Unity Version: 2021.2.3f1
InputSystem: 1.1.1
Multiplayer Tools: 1.0.0-pre.2
Netcode for GameObjects: 1.0.0-pre.3
HelloWorldを読みつつ改造
Unityの公式ページにNetcode for GameObjectのチュートリアルであるYour First Networked Game "Hello World"をベースに展開していきたいと思います。
チュートリアルではGUI周りの構築をコードベースから行おうとしていたので、そこに依存しないように分離しました。例えば、Adding Scripts to Hello World の HelloWorldManager のクラスの代わりに、以下のようなクラスを作って、GUIからイベントのシリアライズをしました。ボタンのInspectorのOnClick()にNetworkSelectorのメソッドを呼び出すようにしています。
using Unity.Netcode;
using UnityEngine;
using UnityEngine.Events;
public class NetworkSelector : MonoBehaviour
{
[SerializeField]
UnityEvent<string> onStartNetwork;
public void StartNetworkAsHost()
{
NetworkManager.Singleton.StartHost();
onStartNetwork.Invoke("Started as a host");
}
public void StartNetworkAsClient()
{
NetworkManager.Singleton.StartClient();
onStartNetwork.Invoke("Started as a client");
}
public void StartNetworkAsServer()
{
NetworkManager.Singleton.StartServer();
onStartNetwork.Invoke("Started as a server");
}
}
他、InputSystemを利用して、ゲームパッドの左アナログスティック入力とキーボード(WASDと矢印キー)の入力を扱うようにしています。
このバインドのInputActionAssetをPlayerInputのActionsに設定します。
兄弟コンポーネントにPlayerInputがあり、かつMonobehaviour継承のクラスにOn+[Action名]の関数を書いておくと、自動的にこの関数にInputActionAssetのActionに対応するInputActionのイベントがハンドルされ、入力を取得できるようになります。(型も上の画像に合わせて、Vector2として取得できるようにしています)
この性質を利用して、入力した値をプロパティとして参照できるよう、public属性のプロパティに設定して外部のスクリプトから読み込めるようにしています。
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerInputHelper : MonoBehaviour
{
[SerializeField]
private float stickDeadZone = 0.1f;
public Vector2 Move { get; private set; }
public bool HasMoveInput => Move.magnitude > stickDeadZone;
public void OnMove(InputValue value) => Move = value.Get<Vector2>();
}
NetworkObject、NetworkBehaviour、RPC、NetworkVariableについて
Building on "Hello World"で一旦はチュートリアルは終わるのですが、これだけだと把握しづらかったので、自分なりにまとめてみました。
Playerのオブジェクトはネットワーク接続時、クライアントのオブジェクトと自分のオブジェクトはインスタンス化される
チュートリアルの通り、Netcode for Gameobjectを使ってマルチプレイを行うにはプレイヤーとしてみなすGameObjectにNetworkObjectとNetworkBehaviour継承のクラスの両方をアタッチしPrefab化し、Player PrefabとしてNetworkManagerに登録しておく必要があります。
以下のリンクにもあるように、Player(便宜上、Player Prefabのことを指します)とみなすGameObjectにNetworkObjectとNetworkBehaviour継承のクラス(上の図の例ではPlayerSyncBehaviourという名前のクラス)両方を追加します。
NetworkObjectはNetworkIdを持っており、個々のクライアントを識別するためのオブジェクトとして動作します。NetworkBehaviourはMonobehaviourの拡張したクラスで、そのNetworkObjectがUnity上でどう振舞うかを定義します。
For an object to be replicated across the network, it needs to have a
NetworkObject
component. Each object which uses components networking functionality, likeNetworkTransform
orNetworkBehaviour
s withNetworkVariable
s orRPC
s, needs aNetworkObject
component on the sameGameObject
or in a parent.
ネットワークに接続すると、自分が所有するPlayerオブジェクトのほか、他のクライアントのPlayerオブジェクトもインスタンスとしてHierarchyに表示されます。
各クライアントはサーバーまたはホストから受け取った通信内容をもとに、自分のオブジェクトを含め、各クライアントのPlayerオブジェクトの更新処理を行っているようです。
RPCは引数付きで送れる、関数名の末尾に(ServerRpc)または(ClientRpc)を追加する必要がある
NetworkBehaviour継承のクラス内で指定の関数に[ServerRpc]や[ClientRpc]アトリビュートを付けることにより、その関数をRPCとして扱えるようになります。チュートリアルには特に言及がありませんでしたが、関数の末尾にそれぞれのRPCに対応するServerRpcまたはClientRpcを追加しないとコンパイルエラーが出るようです。
また、引数にプリミティブまたは構造体を設定することで、値を載せてRPCを送ることができます。
[ServerRpc]
private void SubmitMovingDirectionRequestServerRpc(Vector3 direction, ServerRpcParams rpcParams = default)
{
UpdateVariablesOnServer(direction, Vector3.zero);
}
GameObjectやNetworkObject、NetworkBehaviourはRPCで直接送ることはできませんが、NetworkObjectReferenceやNetworkBehaviourReferenceを引数に指定して渡すことでRPCに参照を渡すことができ、サーバー側で処理ができるようです。当たり判定の処理で他のプレイヤーの参照をRPCに渡すなどの使い方ができそうです。
NetworkVariableはサーバー側でのみ書き換え可(※2021/11/22時点)
(今後変更ありそう?)
NetworkVariableはクライアントサーバー間でプレイヤー情報のプロパティとして利用することができ、サーバー側で変更されたプロパティをRPCを介さずにやり取りできる、NetworkBehaviour継承のクラス内で利用できるクラスです。一見するとクライアントサーバー関係なしに扱えるように見えますが、値の変更はサーバー側のみという制限があります。(参考:コンストラクタ呼び出し時、NetworkVariableReadPermissionにOwnerOnlyを指定することで、オーナー以外のクライアントの読み取りを禁止する設定を追加できます)
NetworkVariable のページの説明では、パーミッションの設定を下記のように設定することで書き換えられるように書いていますが、Netcode for GameObject 1.0.0以降のバージョンではサーバーのみでしか書き換えられません。(※2021/11/22時点) 以前のバージョンではサーバー以外でもNetworkVariableの書き換えができていたようです。
Unityフォーラムに正式な回答がありました。1.0.0ではサーバー側のみ書き換え可とあります。
There are no NetworkVariableSettings anymore. Only the server can write to NetworkVariables in the 1.0.0 version.
これは自分が所有しているオブジェクトであっても、同期情報に関するであればすべてサーバー(またはホスト)側でのみでしか変更を受け付けないため、毎回サーバーにRPCで処理を実行し、サーバー側での実行をクライアント側に反映させる実装が必要になります。
もう一つの問題としては、NetworkVariableはNetworkBehaviour継承クラスのメンバとして扱っていますが、このクラスで実装したコードはサーバーでもクライアントでも同様に動作するため、サーバークライアントの2系統+所有権(Owner)ありなしでのオブジェクトの動作が1つのコードに混在するため、しっかりと考えて設計しないとバグの温床になりそうです。
ラグを感じさせないギミックが別途必要
自分に所有権があるオブジェクトであっても、RPC経由でサーバーへ書き換え要求をした後に反映されるため、自分のキャラクターまたはアバターの移動に若干のラグを感じます。
これを解消するため、私のサンプルでは自分のキャラクターを表示用とネットワーク同期用で分けて処理しています。
以下のコードはPlayerSyncBehaviourの OnNetworkSpawn()メソッドの実装です。自分がClientの時かつ、そのオブジェクトのオーナーである時は、このオブジェクトにアタッチされている子GameObject(キャラクターのグラフィックのコンポーネントがあるもの)をダミー表示用のGameObjectを親として移しています。
public override void OnNetworkSpawn()
{
if (IsOwner)
{
playerInputHelper = FindObjectOfType<PlayerInputHelper>();
if (IsServer)
{
onTrackingObjectPresented?.Invoke(gameObject);
}
else
{
playerLocalDummy = new GameObject("PlayerLocalDummy");
for (int i = 0; i < transform.childCount; i++)
{
transform.GetChild(i).SetParent(playerLocalDummy.transform, false);
}
onTrackingObjectPresented?.Invoke(playerLocalDummy!);
}
SetRandomPosition();
}
}
また、位置更新処理は以下のように行っています。
private void Update()
{
var delta = Time.deltaTime;
if (IsOwner)
{
UpdatePlayerInputs(delta);
}
var currentPosition = networkCurrentPosition.Value;
var movingDirection = networkMovingDirection.Value;
if (movingDirection.magnitude > 0.001f)
{
var position = transform.position;
var direction = movingDirection.normalized;
transform.LookAt(direction + position, Vector3.up);
transform.position += movingDirection * speed * delta;
}
else
{
transform.position = currentPosition;
}
}
private void UpdatePlayerInputs(float delta)
{
if (playerInputHelper == null) return;
if (playerInputHelper!.HasMoveInput)
{
var move = playerInputHelper!.Move;
var direction = new Vector3(move.x, 0f, move.y);
if (IsServer)
{
UpdateVariablesOnServer(direction, Vector3.zero);
}
else
{
if (playerLocalDummy != null)
{
var position = playerLocalDummy.transform.position;
playerLocalDummy.transform.LookAt(direction + position, Vector3.up);
playerLocalDummy.transform.position += direction * speed * delta;
}
SubmitMovingDirectionRequestServerRpc(direction);
}
}
else
{
...
NetworkBehaviourの位置情報はNetworkVariableそのままの値を反映し、playerLocalDummy(Gameobject)の位置情報はコントローラーのインプット値を反映しています。このようにすることで見かけ上でラグが無いように見せることができます。
実際に動作しているものは以下のgifアニメーションになります。
最後に
Unity公式のネットワークシステムは今回初めて触ったのですが、DOTS(またはECS)のようにまだまだ変更がありそうです。使い方の目星はついてきたので、今後は動向を注意深く追っていきたいと思います!
お知らせ
今年のSynamonは、アドベントカレンダーを実施する予定です!乞うご期待!
また、弊社ではUnityエンジニアを募集しています。興味がある方は是非以下のページを覗いてみてください!