Chapter 13

🌶 座標同期の独自実装

o8que
o8que
2021.03.21に更新

ネットワークオブジェクトの座標の同期は、PhotonTransformViewまたはPhotonTransformViewClassicなどを利用するのが最も簡単です。もしそれらの挙動に満足がいかなかったり、ゲームに合わせてもっと細かく挙動を調整したかったりする場合には、オブジェクト同期による座標の同期処理を独自に実装することになるでしょう。

ここでは、その際に役立つ方法として補間(Interpolation)と予測(Prediction)を紹介します。

最小のスクリプト

まず、最小の座標同期スクリプトから始めます。自身の座標はtransform.positionをそのまま送信し、他プレイヤーから受信した座標はtransform.positionへ直接反映します。

AvatarTransformView.cs
using Photon.Pun;
using UnityEngine;

public class AvatarTransformView : MonoBehaviourPunCallbacks, IPunObservable
{
    void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
        if (stream.IsWriting) {
            stream.SendNext(transform.position);
        } else {
            transform.position = (Vector3)stream.ReceiveNext();
        }
    }
}

通常、オブジェクト同期が行われる頻度はフレームレートより低いため、このスクリプトの段階では、他プレイヤーのネットワークオブジェクトは(座標が飛んでワープしたように見えるほど)非常にカクカクとした動きになります。

線形補間

負荷を増やさずに動きをなめらかにするには、受信時の座標から受信した座標へ補間して移動させる処理が有効です。受信した座標はtransform.positionへ直接反映はさせずに、補間の終了座標として変数に値を保持しておき、Update()で補間処理を行うようにします。補間処理は、Vector3.Lerp()Vector3.MoveTowards()を利用するとシンプルに書けます。

AvatarTransformView.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarTransformView : MonoBehaviourPunCallbacks, IPunObservable
 {
+    private const float InterpolationPeriod = 0.1f; // 補間にかける時間

+    private Vector3 p1;
+    private Vector3 p2;
+    private float elapsedTime;

+    private void Start() {
+        p1 = transform.position;
+        p2 = p1;
+        elapsedTime = 0f;
+    }

+    private void Update() {
+        if (!photonView.IsMine) {
+            // 他プレイヤーのネットワークオブジェクトは、補間処理を行う
+            elapsedTime += Time.deltaTime;
+            transform.position = Vector3.Lerp(p1, p2, elapsedTime / InterpolationPeriod);
+        }
+    }

     void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
         if (stream.IsWriting) {
             stream.SendNext(transform.position);
         } else {
+            // 受信時の座標を、補間の開始座標にする
+            p1 = transform.position;
+            // 受信した座標を、補間の終了座標にする
+            p2 = (Vector3)stream.ReceiveNext();
+            // 経過時間をリセットする
+            elapsedTime = 0f;
         }
     }
 }

補間処理は、負荷を増やさずに動きをなめらかにするという大きなメリットを得られますが、補間にかけた時間の分だけ(受信した座標に到達するまでの)遅延が増えてしまうという無視できないデメリットも発生します。

予測

他プレイヤーが座標のデータを送信し、自身がそのデータを受信するまでには、かならずネットワーク上の遅延が発生します。自身がデータを受信した時点(他プレイヤーがデータを送信してから少し時間が経過した時点)で、他プレイヤーがどの座標にいるのかを正確に知ることは基本的にはできません。しかし、これをうまく予測することができれば、遅延による座標のズレを軽減できる可能性があります。

線形外挿

最も簡単な予測は、線形補間を区間の範囲外にも適用する線形外挿(Linear Extrapolation)になるでしょう。例えば、プレイヤーが操作するアバターなどは、(数フレーム間の短いスパンで見ると)大体同じ方向に移動し続けていることが多いだろうと予測するなら、補間が終了した後のおおよその座標は簡単に求められます。これは補間処理を、Vector3.Lerp()のかわりにVector3.LerpUnclamped()を使うだけで実現できます。

AvatarTransformView.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarTransformView : MonoBehaviourPunCallbacks, IPunObservable
 {
     private const float InterpolationPeriod = 0.1f; // 補間にかける時間

     private Vector3 p1;
     private Vector3 p2;
     private float elapsedTime;

     private void Start() {
         p1 = transform.position;
         p2 = p1;
         elapsedTime = 0f;
     }

     private void Update() {
         if (!photonView.IsMine) {
             // 他プレイヤーのネットワークオブジェクトは、補間処理を行う
             elapsedTime += Time.deltaTime;
+            transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / InterpolationPeriod);
         }
     }

     void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
         if (stream.IsWriting) {
             stream.SendNext(transform.position);
         } else {
             // 受信時の座標を、補間の開始座標にする
             p1 = transform.position;
             // 受信した座標を、補間の終了座標にする
             p2 = (Vector3)stream.ReceiveNext();
             // 経過時間をリセットする
             elapsedTime = 0f;
         }
     }
 }

推測航法

推測航法(Dead Reckoning)は、座標・速度・送信時刻から現在時刻における座標を予測します。座標に加えて速度と時刻を送信すると、速度と経過時間(送信時刻と受信時刻の差)から移動距離が計算できるので、現在時刻におけるおおよその座標を求めることができます。

AvatarTransformView.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarTransformView : MonoBehaviourPunCallbacks, IPunObservable
 {
     private const float InterpolationPeriod = 0.1f; // 補間にかける時間

     private Vector3 p1;
     private Vector3 p2;
     private float elapsedTime;

     private void Start() {
         p1 = transform.position;
         p2 = p1;
+        elapsedTime = Time.deltaTime;
     }

     private void Update() {
+        if (photonView.IsMine) {
+            // 自身のネットワークオブジェクトは、毎フレームの移動量と経過時間を記録する
+            p1 = p2;
+            p2 = transform.position;
+            elapsedTime = Time.deltaTime;
+        } else {
             // 他プレイヤーのネットワークオブジェクトは、補間処理を行う
             elapsedTime += Time.deltaTime;
             transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / InterpolationPeriod);
         }
     }

     void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
         if (stream.IsWriting) {
             stream.SendNext(transform.position);
+            // 毎フレームの移動量と経過時間から、秒速を求めて送信する
+            stream.SendNext((p2 - p1) / elapsedTime);
         } else {
+            var networkPosition = (Vector3)stream.ReceiveNext();
+            var networkVelocity = (Vector3)stream.ReceiveNext();
+            var lag = Mathf.Max(0f, unchecked(PhotonNetwork.ServerTimestamp - info.SentServerTimestamp) / 1000f);

             // 受信時の座標を、補間の開始座標にする
             p1 = transform.position;
+            // 現在時刻における予測座標を、補間の終了座標にする
+            p2 = networkPosition + networkVelocity * lag;
             // 経過時間をリセットする
             elapsedTime = 0f;
         }
     }
 }

予測処理は、予測が大体合っている時には遅延を軽減する効果が得られますが、予測が外れた時には逆に実際の座標とのズレが大きくなってしまう可能性があります。移動に慣性が効いたゲームなどでは予測が効果的に機能しやすいですが、急な加減速や方向転換が可能なゲームなどでは予測がうまくいかないことの方が多いかもしれません。どの程度予測するのか(そもそも予測しないのか)は、ゲームごとに調整の余地があるでしょう。

スプライン補間

大抵のケースでは、補間は線形補間で十分問題ありません。しかし例えば、通信量を削減するためにオブジェクト同期の頻度をギリギリまで下げたりすると、線形補間では直線的な動きが目立ってしまうことがあります。その動きをよりなめらかに見せたいのであれば、曲線による補間(スプライン補間)を検討すると良いかもしれません。

エルミート曲線

エルミート曲線は、始点(p1)とその速度(v1)、終点(p2)とその速度(v2)から、始点と終点の間を繋ぐ滑らかな曲線を描きます。これを座標の補間に使う場合は、始点を補間の開始座標、終点を補間の終了座標にします。もし既に座標の同期処理で速度を送信しているなら、実装をほとんど修正せずに導入することができるでしょう。

HermiteSpline.cs
using UnityEngine;

public static class HermiteSpline
{
    public static float Interpolate(float p1, float p2, float v1, float v2, float t) {
        float a = 2f * p1 - 2f * p2 + v1 + v2;
        float b = -3f * p1 + 3f * p2 - 2f * v1 - v2;
        return t * (t * (t * a + b) + v1) + p1;
    }

    public static Vector2 Interpolate(Vector2 p1, Vector2 p2, Vector2 v1, Vector2 v2, float t) {
        return new Vector2(
            Interpolate(p1.x, p2.x, v1.x, v2.x, t),
            Interpolate(p1.y, p2.y, v1.y, v2.y, t)
        );
    }

    public static Vector3 Interpolate(Vector3 p1, Vector3 p2, Vector3 v1, Vector3 v2, float t) {
        return new Vector3(
            Interpolate(p1.x, p2.x, v1.x, v2.x, t),
            Interpolate(p1.y, p2.y, v1.y, v2.y, t),
            Interpolate(p1.z, p2.z, v1.z, v2.z, t)
        );
    }
}

Catmull-Rom曲線

Catmull-Rom曲線は、連続した4点(p0~p3)から、4点全てを通る滑らかな曲線を描きます。座標の補間に使う場合は、補間の開始座標より前の座標(p0)、補間の開始座標(p1)、補間の終了座標(p2)、補間の終了座標より後の座標(p3)を選びます。速度を求めるより、前後の座標を求める方が簡単なら、Catmull-Rom曲線の方がエルミート曲線より便利に使えます。

CatmullRomSpline.cs
using UnityEngine;

public static class CatmullromSpline
{
    public static float Interpolate(float p0, float p1, float p2, float p3, float t) {
        float a = -p0 + 3f * p1 - 3f * p2 + p3;
        float b = 2f * p0 - 5f * p1 + 4f * p2 - p3;
        float c = -p0 + p2;
        float d = 2f * p1;
        return 0.5f * (t * (t * (t * a + b) + c) + d);
    }

    public static Vector2 Interpolate(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float t) {
        return new Vector2(
            Interpolate(p0.x, p1.x, p2.x, p3.x, t),
            Interpolate(p0.y, p1.y, p2.y, p3.y, t)
        );
    }

    public static Vector3 Interpolate(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) {
        return new Vector3(
            Interpolate(p0.x, p1.x, p2.x, p3.x, t),
            Interpolate(p0.y, p1.y, p2.y, p3.y, t),
            Interpolate(p0.z, p1.z, p2.z, p3.z, t)
        );
    }
}

まとめ

このチャプターで紹介した方法を組み合わせて、現在時刻の座標を推測航法で予測し、その座標までエルミート曲線でなめらかに補間、そして補間が終了した後は線形外挿で座標を予測する、というような座標同期スクリプトを作ると、以下のようなコードになります。

AvatarTransformView.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarTransformView : MonoBehaviourPunCallbacks, IPunObservable
 {
     private const float InterpolationPeriod = 0.1f; // 補間にかける時間

     private Vector3 p1;
     private Vector3 p2;
+    private Vector3 v1;
+    private Vector3 v2;
     private float elapsedTime;

     private void Start() {
         p1 = transform.position;
         p2 = p1;
+        v1 = Vector3.zero;
+        v2 = v1;
         elapsedTime = Time.deltaTime;
     }

     private void Update() {
         if (photonView.IsMine) {
             // 自身のネットワークオブジェクトは、毎フレームの移動量と経過時間を記録する
             p1 = p2;
             p2 = transform.position;
             elapsedTime = Time.deltaTime;
         } else {
             // 他プレイヤーのネットワークオブジェクトは、補間処理を行う
             elapsedTime += Time.deltaTime;
+            if (elapsedTime < InterpolationPeriod) {
+                transform.position = HermiteSpline.Interpolate(p1, p2, v1, v2, elapsedTime / InterpolationPeriod);
+            } else {
                transform.position = Vector3.LerpUnclamped(p1, p2, elapsedTime / InterpolationPeriod);
+            }
         }
     }

     void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
         if (stream.IsWriting) {
             stream.SendNext(transform.position);
             // 毎フレームの移動量と経過時間から、秒速を求めて送信する
             stream.SendNext((p2 - p1) / elapsedTime);
         } else {
             var networkPosition = (Vector3)stream.ReceiveNext();
             var networkVelocity = (Vector3)stream.ReceiveNext();
             var lag = Mathf.Max(0f, unchecked(PhotonNetwork.ServerTimestamp - info.SentServerTimestamp) / 1000f);

             // 受信時の座標を、補間の開始座標にする
             p1 = transform.position;
             // 現在時刻における予測座標を、補間の終了座標にする
             p2 = networkPosition + networkVelocity * lag;
+            // 前回の補間の終了速度を、補間の開始速度にする
+            v1 = v2;
+            // 受信した秒速を、補間にかける時間あたりの速度に変換して、補間の終了速度にする
+            v2 = networkVelocity * InterpolationPeriod;
             // 経過時間をリセットする
             elapsedTime = 0f;
         }
     }
 }

このチャプターでは、座標の同期をテーマにいくつかの方法を紹介しましたが、座標以外のデータを同期する時でも基本的な考え方は同じです。データ間を補間して、データ外は予測する。その精度を上げれば上げるほど、通信量を削減しつつ同期ズレを抑えることができるでしょう。