UNET 位置の同期を自作する
UNETにはNetworkTransformという位置同期の仕組みが標準で搭載されています(使い方はこちらで解説しています)。
これを使うと、とても手軽に位置の同期が行えますが、残念ながら色々と問題があるし、かゆいところに手が届かない機能です。
そこで今回は、簡単な位置同期の仕組みを自作しようと思います。
TargetRPCという機能も使ってみます。
NetworkTransformの問題点
自作する前に、標準のNetworkTransformにはどんな問題があるのかを見ておきましょう。
現状(Unity2017.3)、NetworkTransformにはバグがあり、SnapThresholdが正常に動作しません。これは、オブジェクトが閾値以上の距離を瞬間的に移動した場合、補間せずに瞬間移動させるという機能ですが、バグのせいで異常な動きをします。
↑青いキャラは左側の画面のローカルプレイヤーです。瞬間的に動かした場合、他クライアント(右画面)において、動きがおかしくなります。移動距離が閾値(SnapThreshold)以上となると、異常な動きとなります。
ネットワークのラグでやむを得ず瞬間移動状態になったときや、実際に瞬間移動させたいとき(ワープやリスポーン等)に困ります。
また、NetworkTransformは補間を自分で制御できないのも厄介です。補間無しを選ぶとカクカクになってしまいますし、補間ありのモード(SyncCharacterController等)を選んだ場合は、瞬間移動(リスポーン等)が出来ないので困ります。
キャラクターの移動について、それほどシビアな制御が必要無い、のんびりしたゲームであればNetworkTransformでも問題無いですが、FPSやアクション系のゲームでは厳しいと思います。
NetworkTransformのソースコードを改修しても良いですが(UNETのソースの改造方法はこちら)、今回は同等の機能を自作してみようと思います。
Let's 自作
プレイヤーオブジェクトを適当に用意します。
NetworkIdentityをアタッチします。
新規C#スクリプトを作成&アタッチします。スクリプト名はPlayerとしました。
using UnityEngine; using UnityEngine.Networking; public class Player : NetworkBehaviour { // 歩行速度(メートル/秒) [SerializeField] float m_WalkSpeed = 4f; // 旋回速度(度/秒) [SerializeField] float m_RotateSpeed = 180f; void Start() { } void Update() { if (isLocalPlayer) { // 旋回 transform.Rotate(0, Input.GetAxis("Horizontal") * m_RotateSpeed * Time.deltaTime, 0); // 移動 transform.Translate(0, 0, Input.GetAxis("Vertical") * m_WalkSpeed * Time.deltaTime, Space.Self); // 位置情報をホストへ送信 CmdSyncTransform(transform.position, transform.rotation); } } // 位置情報をホストに送信するCommand [Command] void CmdSyncTransform(Vector3 position, Quaternion rotation) { // 各接続に対して情報を送信する foreach (var conn in NetworkServer.connections) { // 無効なConnectionは無視する if (conn == null || !conn.isReady) continue; // 自分(情報発信元)に送り返してもしょうがない(というかむしろ有害)なので、 // 自分へのConnectionは無視する if (conn == connectionToClient) continue; // このConnectionに対して位置情報を送信する TargetSyncTransform(conn, position, rotation); } } // クライアント側で位置情報を受け取り、オブジェクトに反映させる [TargetRpc] void TargetSyncTransform(NetworkConnection target, Vector3 position, Quaternion rotation) { transform.SetPositionAndRotation(position, rotation); } }
以上です。
あとは、NetworkManagerを用意して動作確認してみてください。
左右キーで旋回、上下キーで前進後退です。
プログラムの流れ
まず、操作感を重視し、自分のプレイヤーはローカルですぐに動かします。
そして移動結果をCommandによってホストに送ります。
Commandを受け取ったホストは、各クライアントに対して、移動結果を配信します。この際にTargetRPCという機能を使って配信しています。
位置同期機能の自作について、SyncVarを使ってPositionを同期するサンプルをWebでよく見かけますが、あまりオススメできません。SyncVarを使った場合、あまり融通が効かず、「じゃあNetworkTransformで良いじゃん」と思うからです。
TargetRPCとは?
TargetRPCというのは、サーバーからクライアントの関数を呼び出すための機能です。似た機能でClientRPCがありますが、ClientRPCは、全クライアントに対して関数呼び出しを行います↓
これに対し、TargetRPCは、指定したクライアントに対してのみ、関数呼び出しを行います↓
今回ClientRPCではなくTargetRPCを使った理由は、情報発信元に対しては情報を送信したくなかったからです。例えば、Aさん、Bさん、Cさんの三人でネットワークゲームをしていたとします。サーバーに対してAさんから位置情報が送られてきたとき、それを配信する必要がある相手はBさんとCさんです。Aさんに配信する必要はありません。ClientRPCを使うとそこらへんの制御ができず、Aさんにも配信してしまうことになるため、今回はTargetRPCを使いました。
TargetRPCの使い方
[TargetRpc]属性をつける
TargetRPCもCommandやClientRPCと同様に関数として実装しますが、その関数に[TargetRpc]属性を付ける必要があります。
関数名はTargetではじめる
関数の名前は「Target」で始めなければいけません。
第一引数はNetworkConnection
関数の第一引数はNetworkConnection型の引数にします。これは、UNETがどの接続に対してRPC(遠隔関数呼び出し)を行うか判断する材料です。別に関数内で使わなくとも(というか大抵使わない)、第一引数はNetworkConnectionでなければいけません。
呼び出し時、第一引数に対象の接続を指定する
TargetRPCでは、第一引数にNetworkConnectionのインスタンスを指定することにより、その接続に対してRPCが行われます。
今回のプログラムでは、情報発信元以外に配信したいため、まずNetworkServer.connectionsにより全接続を取得しています。そして、connectionToClient(NetworkBehaviourの機能で、そのプレイヤーオブジェクトの所有者のクライアントへの接続を取得する)と比較して一致する場合は無視しています(一致するということは、その接続は情報発信元への接続なので)。
もっと良くするためには
現状のプログラムを基に、色々と改造していくと良いでしょう。
送信頻度
現状、同期処理を毎フレーム行っています。LAN接続前提ならそのくらい問題ありませんが、インターネット接続を考えた場合は、送りすぎかもしれません。頻度を落とす必要があるかもしれません。
補間機能
現状、サーバーから送られてきた位置情報を受け取ったクライアントは、直ちにプレイヤーオブジェクトをその座標に移動させます。これもやはりLAN接続前提ならさほど問題にならないと思いますが、インターネット接続を考える場合はマズいです。インターネット接続の場合、どうしても通信が詰まって遅延したりするので、受信した位置情報をそのままキャラクターに適用すると、カクカクしてしまいます。
↑ネットワークの遅延によりカクカクになった例
Lerpを使って、受信した位置情報に対して少しずつ近づくようにするだけでも、すごく改善します。
実装例です。
using UnityEngine; using UnityEngine.Networking; public class Player : NetworkBehaviour { // 歩行速度(メートル/秒) [SerializeField] float m_WalkSpeed = 4f; // 旋回速度(度/秒) [SerializeField] float m_RotateSpeed = 180f; // Lerpの係数 [SerializeField] float m_LerpRate = 4f; // ホストから受信した位置情報 Vector3 m_ReceivedPosition; // ホストから受信した回転情報 Quaternion m_ReceivedRotation; void Start() { m_ReceivedPosition = transform.position; m_ReceivedRotation = transform.rotation; } void Update() { if (isLocalPlayer) { // 旋回 transform.Rotate(0, Input.GetAxis("Horizontal") * m_RotateSpeed * Time.deltaTime, 0); // 移動 transform.Translate(0, 0, Input.GetAxis("Vertical") * m_WalkSpeed * Time.deltaTime, Space.Self); // 位置情報をホストへ送信 CmdSyncTransform(transform.position, transform.rotation); } else { // 補間して移動 InterpolateTransform(); } } // ホストから受信した位置・回転に、補間しながら近づける void InterpolateTransform() { Vector3 pos = Vector3.Lerp(transform.position, m_ReceivedPosition, m_LerpRate * Time.deltaTime); Quaternion rot = Quaternion.Slerp(transform.rotation, m_ReceivedRotation, m_LerpRate * Time.deltaTime); transform.SetPositionAndRotation(pos, rot); } // 位置情報をホストに送信するCommand [Command] void CmdSyncTransform(Vector3 position, Quaternion rotation) { // 各接続に対して情報を送信する foreach (var conn in NetworkServer.connections) { // 無効なConnectionは無視する if (conn == null || !conn.isReady) continue; // 自分(情報発信元)に送り返してもしょうがない(というかむしろ有害)なので、 // 自分へのConnectionは無視する if (conn == connectionToClient) continue; // このConnectionに対して位置情報を送信する TargetSyncTransform(conn, position, rotation); } } // クライアント側で位置情報を受け取り、オブジェクトに反映させる [TargetRpc] void TargetSyncTransform(NetworkConnection target, Vector3 position, Quaternion rotation) { m_ReceivedPosition = position; m_ReceivedRotation = rotation; } }
カクカクが抑えられ、スムーズになりました。
ただし、この方法では、受信した位置情報をそのまま適用していた方法よりも、さらに遅れ(実際の相手プレイヤーの位置と、自分の画面で見えている相手プレイヤー位置のズレ)が酷くなります。ゲームの内容的に、それが許容できない場合、受信した位置情報からさらに未来を予測するような処理を実装しなければならないでしょう。このあたりが実現可能かどうかは、ゲームデザインと密接に関わってきます。
Rocket Leagueというラジコンカーを使ったネットワークゲームがありますが、それは高度な位置予測処理が実装されているようで、海外のプレイヤーとも快適な対戦が楽しめます。
これは、自機がラジコンカーだからこそ可能なことです。ラジコンカーは、急発進・急停止は出来ません。加速・減速・方向転換などは、少しずつ行われます。だから、ある程度の未来予測が可能なのです。急発進・急停止・急な方向転換が可能だと、予測不能です。
というわけで、どんなゲームでも位置の予測が実現出来るわけではありません。いくらプログラムを頑張っても、無理なものは無理。ゲームデザインの時点で戦いは始まっています。
Channel
現状、位置情報の送信に、デフォルトのChannelを使っています。必要に応じて、より軽量なChannelを使うことを検討したほうが良いでしょう。
Channelの指定方法はこちら
あ、ちなみに今回のサンプルスクリプトはホスト/クライアントのシステムを前提としており、Dedicated Server(Server Onlyなサーバー)では正常に動作しません。Dedicated Server内ではRPCが呼ばれないからです。少しコードを追加すれば対応は可能ですが、複雑になるので、やってません。