Notifications
Article
PUN2で始めるオンラインゲーム開発入門【その2】
Updated a year ago
8.0 K
0
いい感じにオブジェクトの座標を同期させてみよう
【その1】|【その2】|【その3】|【その4】|【その5】

自分のオブジェクトだけを操作しよう

ネットワークオブジェクトは通常のオブジェクトと同じように、スクリプトを追加して自由に操作することができます。ただし、自身側で生成したインスタンスと他プレイヤー側によって自動的に生成されたインスタンスを区別して処理を行うようにしないと、自身のオブジェクトへの操作が他プレイヤーのオブジェクトにも影響してしまいます
ネットワークオブジェクトのインスタンスが、自身側で生成したものか他プレイヤー側で生成したものかはPhotonView.IsMineによって判別することができます。ネットワークオブジェクトのプレハブに以下のスクリプトを追加して、自分自身が生成したオブジェクトだけがちゃんと操作できているか、ビルドして確認してみてください。
using Photon.Pun; using UnityEngine; // MonoBehaviourPunCallbacksを継承すると、photonViewプロパティが使えるようになる public class GamePlayer : MonoBehaviourPunCallbacks { private void Update() { // 自身が生成したオブジェクトだけに移動処理を行う if (photonView.IsMine) { var dx = 0.1f * Input.GetAxis("Horizontal"); var dy = 0.1f * Input.GetAxis("Vertical"); transform.Translate(dx, dy, 0f); } } }
もし自身が生成したオブジェクトと他プレイヤーが生成したオブジェクトを区別して処理を行わないとどうなってしまうのか、一度コードのphotonView.IsMineをコメントアウトして確認してみてもよいでしょう。

入力周りを改善しよう

上記スクリプトの移動処理は動作確認用の単純な実装ですが、もう少しまともな操作にするために以下の点を修正します。
  • 斜め移動すると、移動量が大きくなってしまう
  • 移動速度がフレームレート依存なので、異なる環境のプレイヤー間で速度に差が出ることがある
1人用のゲームであれば、フレームレートが下がって移動速度が遅くなっても「処理落ちして遅くなってるな~」で済むこともあります。しかしオンラインゲームでは、フレームレートが安定している高スペック環境のプレイヤーと、フレームレートが不安定な低スペック環境のプレイヤーで、移動速度に大きな差が出てしまうというのは致命的な問題になりえるので、気を付けなければならない所です。
private void Update() { if (photonView.IsMine) { // 入力方向(ベクトル)を正規化する var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; // 移動速度を時間に依存させて、移動量を求める var dv = 6f * Time.deltaTime * direction; transform.Translate(dv.x, dv.y, 0f); } }

座標を同期させる最も簡単な方法

ネットワークオブジェクトを操作するだけでは、その操作が他プレイヤー側の画面上に反映されることはありません。操作によって更新された値(例えば、座標ならTransform.position)を他プレイヤーへ送信して同期する処理が必要です。
PUN2には、座標を同期するためのPhotonTransformViewというコンポーネントがあらかじめ用意されていますので、それを使いましょう。以下の画像を参考にコンポーネントを追加してPhotonViewの監視対象に設定してください。 監視オプションは「Unreliable On Change」がオススメです。監視対象の値が更新された場合にのみデータを送信するようになるため、無駄な通信を抑えることができます。
他プレイヤー側の画面上でちゃんと座標が同期されているか、ビルドして複数起動して確認してみてください。

任意の値を定期的に同期させる方法

IPunObservableインターフェースを実装したスクリプトをPhotonViewの監視対象に設定すると、定期的にデータを送受信するためのメソッドIPunObservable.OnPhotonSerializeView()が呼ばれるようになります。 この仕組みはオブジェクト同期(Object Synchronization)と呼ばれ、高い頻度で値が更新されるデータを送受信するのに適しています。実はPhotonTransformViewは、オブジェクト同期を利用して作成されている単なるスクリプトにすぎません。
ここでは実装例として、オブジェクトが移動中はスプライトの色がカラフルに変化して、停止中はスプライトが暗くなる処理を同期させてみます。スプライトの色の変化を同期するためのfloat型の色相値のデータと、停止中にスプライトを暗くする処理を同期するためのbool型の移動中のフラグのデータを、それぞれ送受信します。 IPunObservable.OnPhotonSerializeView()では、自身側が生成したオブジェクトの場合は送信処理が、他プレイヤー側が生成したオブジェクトの場合は受信処理が行われます。これはstream.IsWritingtrueかどうかで判別できます。
using Photon.Pun; using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] // IPunObservableインターフェースを実装する public class GamePlayer : MonoBehaviourPunCallbacks, IPunObservable { private SpriteRenderer spriteRenderer; private float hue = 0f; // 色相値 private bool isMoving = false; // 移動中フラグ private void Awake() { spriteRenderer = GetComponent<SpriteRenderer>(); ChangeBodyColor(); } private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; var dv = 6f * Time.deltaTime * direction; transform.Translate(dv.x, dv.y, 0f); // 移動中なら色相値を変化させていく isMoving = direction.magnitude > 0f; if (isMoving) { hue = (hue + Time.deltaTime) % 1f; } ChangeBodyColor(); } } // データを送受信するメソッド void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { // 自身側が生成したオブジェクトの場合は // 色相値と移動中フラグのデータを送信する stream.SendNext(hue); stream.SendNext(isMoving); } else { // 他プレイヤー側が生成したオブジェクトの場合は // 受信したデータから色相値と移動中フラグを更新する hue = (float)stream.ReceiveNext(); isMoving = (bool)stream.ReceiveNext(); ChangeBodyColor(); } } private void ChangeBodyColor() { float h = hue; float s = 1f; float v = (isMoving) ? 1f : 0.5f; spriteRenderer.color = Color.HSVToRGB(h, s, v); } }
GamePlayerを忘れずにPhotonViewの監視対象に追加しましょう。ビルドして色が同期されているか確認してみてください。
この例では、float型とbool型のデータを送受信しました。Photonがデフォルトで送受信できるデータ型は決まっていて、以下のリンクからその一覧を確認することができます。 https://doc.photonengine.com/ja-jp/pun/v2/reference/serialization-in-photon

メッセージの送信頻度を調整しよう

オブジェクト同期はデータを定期的に送信しますが、その頻度によって、同期の精度や処理コスト、通信量やその負荷などが大きく変わってきます。PhotonではPhotonNetworkから簡単に送信頻度を調整することができます。
PhotonNetwork.SendRate = 20; // 1秒間にメッセージ送信を行う回数 PhotonNetwork.SerializationRate = 10; // 1秒間にオブジェクト同期を行う回数
この記事では便宜上、IPunObservable.OnPhotonSerializeView()PhotonStreamに値を書き込むことを「データを送信する」と書いていますが、厳密には「送信データを作成する」が正しいです。Photonには、複数の送信データを可能な限りまとめて送信することで、通信を最適化する仕組みが備わっています。 SerializationRateの間隔で作成された送信データは、SendRateの間隔でまとめて送信されます。つまり、データが作成されてから送信されるまでには若干の遅延があるということです。SendRateを上げることで、この遅延を最小に抑えることができますが、複数の送信データがバラバラに送られるようになり通信量が増える可能性があります。

以下の★パートは中級者向けです。わからない部分は読み飛ばして、慣れた頃に読み返すのがオススメです。

★必要な場合のみ送信してメッセージ数を節約しよう

オブジェクト同期で、送信する必要がないデータを送信しないようにすることで、通信量を削減できます。

PhotonStreamに書き込まない

空のPhotonStreamは送信されません。例えば、フラグを使って書き込みを行わないようにするだけで、オブジェクト同期のデータ送信を一時停止することが可能です。
private bool isSyncing = true; // 同期フラグ void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { // 同期フラグが立っている場合のみ送信を行う if (isSyncing) { stream.SendNext(transform.position); } } else { transform.position = (Vector3)stream.ReceiveNext(); } }

PhotonViewの監視オプションを設定する

PhotonViewの監視オプションを「Reliable Delta Compressed」か「Unreliable On Change」に設定すると、監視対象の値が更新された場合にのみデータを送信するようになります。

値が更新されたとみなす閾値を調整する

PhotonViewの監視対象の値が更新されたとみなす閾値は、PhotonNetworkから調整することができます。
PhotonNetwork.PrecisionForVectorSynchronization = 0.00001f; PhotonNetwork.PrecisionForQuaternionSynchronization = 1f; PhotonNetwork.PrecisionForFloatSynchronization = 0.01f;
しかし、これはデータ型の単位でしか設定することができないので、正直使いづらい所があります。同じような処理を独自で実装してしまう方が楽なことが多いです。
private Vector3 lastPosition = transform.position; void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { // 前回に送信した座標から、一定の距離以上移動した場合のみ現在の座標を送信する if (Vector3.Distance(transform.position, lastPosition) > 0.01f) { stream.SendNext(transform.position); lastPosition = transform.position; } } else { transform.position = (Vector3)stream.ReceiveNext(); } }

★座標の同期を独自実装する方法

座標の同期は、前半で紹介した通りPhotonTransformViewを使うのが最も簡単ですが、もしその挙動に満足がいかない場合は、独自で座標の同期処理を実装することになります。その際に検討すべき基本的な方法を紹介します。
まず最も単純なスクリプトから始めましょう。自身の座標はtransform.positionをそのまま送信し、他プレイヤーから受信した座標はtransform.positionへ直接反映します。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks, IPunObservable { private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; var dv = 6f * Time.deltaTime * direction; transform.Translate(dv.x, dv.y, 0f); } } void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { stream.SendNext(transform.position); } else { transform.position = (Vector3)stream.ReceiveNext(); } } }
通常、オブジェクト同期が行われる頻度はフレームレートより低いので、そのままでは更新のたびに座標が飛んでワープしているような動きになってしまいます。もちろんオブジェクト同期が行われる頻度を上げていくことで、なめらかな動きに改善していくことができますが、比例して通信量も多くなり負荷が増えてしまうため、オススメはできません。

線形補間

通信量を増やさずに動きをなめらかにするためには、受信時の座標から受信した座標の間を補間して移動させる処理が有効です。受信した座標をtransform.positionへ直接反映はせず、いったん変数に値を保持しておき、Update()の移動処理で使うようにします。移動処理はVector3.Leap()Vector3.MoveTowards()を利用するとシンプルに書けます。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks, IPunObservable { // 補間にかける時間 private const float InterpolationDuration = 0.2f; private Vector3 startPosition; private Vector3 endPosition; private float elapsedTime = 0f; private void Awake() { startPosition = transform.position; endPosition = transform.position; } private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; var dv = 6f * Time.deltaTime * direction; transform.Translate(dv.x, dv.y, 0f); } else { // 受信時の座標から受信した座標へ補間移動する elapsedTime += Time.deltaTime; transform.position = Vector3.Lerp(startPosition, endPosition, elapsedTime / InterpolationDuration); } } void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { stream.SendNext(transform.position); } else { // 受信時の座標を、補間の開始座標にする startPosition = transform.position; // 受信した座標を、(transfrom.positionへ直接反映させずに)補間の終了座標にする endPosition = (Vector3)stream.ReceiveNext(); elapsedTime = 0f; } } }
補間処理には、動きをなめらかにするという大きなメリットがありますが、補間にかけた時間の分だけ(受信した座標に到達するまでの)遅延が増えるという無視できないデメリットもあります。受信時の座標と受信した座標の差が、(ワープしたようには見えないほど)十分に小さい、または逆に(補間処理が追いつかないほど)大きい場合は、補間処理を行わずに受信した座標にワープさせる処理を行って、遅延を解消させる方が良いこともあるでしょう。

推測航法

他プレイヤーが座標のデータを送信し、自身がそのデータを受信するまでの、ネットワーク上の遅延を避けることはできません。他プレイヤーの座標を受信した時にわかるのは、数十ミリ秒~数百ミリ秒前の時刻にはその座標にいたということだけです。現在時刻(受信時刻)にどの座標にいるのかは正確に知ることはできないので、予測するしかありません。 推測航法は、座標・速度・送信時刻から現在時刻における座標を予測します。座標に加えて速度と時刻を送信し、受信側では送信時刻と受信時刻の差から遅延した時間を求めて、その時間の分だけ座標を先に進めます。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks, IPunObservable { private const float InterpolationDuration = 0.2f; private Vector3 velocityPerSecond = Vector3.zero; private Vector3 startPosition; private Vector3 endPosition; private float elapsedTime = 0f; private void Awake() { startPosition = transform.position; endPosition = transform.position; } private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; velocityPerSecond = 6f * direction; var dv = velocityPerSecond * Time.deltaTime; transform.Translate(dv.x, dv.y, 0f); } else { elapsedTime += Time.deltaTime; transform.position = Vector3.Lerp(startPosition, endPosition, elapsedTime / InterpolationDuration); } } void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { stream.SendNext(transform.position); stream.SendNext(velocityPerSecond); } else { startPosition = transform.position; var networkPosition = (Vector3)stream.ReceiveNext(); var networkVelocityPerSecond = (Vector3)stream.ReceiveNext(); // 送信時刻と受信時刻の差から、遅延を求める var lag = Mathf.Max(0f, unchecked(PhotonNetwork.ServerTimestamp - info.SentServerTimestamp) / 1000f); // 現在時刻における予測座標を、補間の終了座標にする endPosition = networkPosition + networkVelocityPerSecond * lag; elapsedTime = 0f; } } }
予測処理は、予測が大体合っている時は遅延を軽減できますが、予測が外れた時には全く違う方向に移動してしまいます。 例えば以下のコードのように、(受信時刻より先の)補間完了時刻における座標まで予測すると、予測が合っている時はほとんど遅延を感じさせない動きになりますが、予測が外れた時はかなり大きく座標がずれてしまいます。
endPosition = networkPosition + networkVelocityPerSecond * (lag + InterpolationDuration);
予測座標の計算は受信した速度の影響が大きいため、慣性が効いた動作のゲーム等では上手くいくことが多いですが、急に速度が変わるようなきびきびとした動作のゲームには向かないかもしれません。 以下のコードのように、予測座標の計算に加速度も使うことができるなら、予測の精度を上げることができます。
endPosition = networkPosition + (networkVelocityPerSecond * lag) + (networkAcceleration * lag * lag);
どの程度予測するのか(そもそもしないのか)は、ゲーム毎に調整の必要があるでしょう。

★座標を同期させる方法(その他)

PhotonTransformViewClassic

PUN2のv2.5から使用できるようになったコンポーネントです。といっても、単にPUN1のPhotonTransformViewをそのまま移植しただけのものです。PUN2のPhotonTransformViewは、細かい設定不要でいい感じに座標を同期するようにできていますが、逆に細かい設定ができないという不満点も上がっていました。PhotonTransformViewClassicは、PhotonTransformViewの挙動に満足がいかない、もっと細かい調整がしたい、でも自前実装は避けたい、そんな人向けのコンポーネントです。

物理エンジンにお任せする

物理演算でオブジェクトを動かしている場合は、Rigidbodyの値を直接更新するだけでそれなりに上手くいくことがあります。下手に補間や予測を入れると挙動がおかしくなる可能性があるため、利用する場合は気を付けましょう。PUN2には、PhotonRigidbodyViewというコンポーネントも用意されているので、そちらを試してみるのも良いかもしれません。
void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { stream.SendNext(rigidbody.position); stream.SendNext(rigidbody.velocity); } else { rigidbody.position = (Vector3)stream.ReceiveNext(); rigidbody.velocity = (Vector3)stream.ReceiveNext(); } }

★3次スプライン補間を使ってみよう

大抵のケースでは、座標の補間は線形補間で十分問題はありません。しかし例えば、通信量削減のために座標の同期の頻度を大きく落としたりすると、直線的な動きが目立ってくることがあります。もしその動きを、もっとなめらかに見せたいのであれば、曲線による補間(スプライン補間)を検討すると良いかもしれません。 注意して欲しいのは、スプライン補間も線形補間と同じように、あくまでなめらかに動いているように見せるためもので、実際の軌道をより正確に描くようにする方法ではないということです。

Ferguson/Coons曲線

現在の座標(p1)と目標の座標(p2)に加えて、現在の速度(v1)と目標座標到達時の速度(v2)を使って、現在座標と目標座標をなめらかに繋ぐ曲線を描きます。もし既に座標の同期の際、座標と速度を送受信しているなら、そのデータをそのまま使うことができます。
public static partial class MathEx { public static float FergusonCoons(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 Vector3 FergusonCoons(Vector3 p1, Vector3 p2, Vector3 v1, Vector3 v2, float t) { return new Vector3( FergusonCoons(p1.x, p2.x, v1.x, v2.x, t), FergusonCoons(p1.y, p2.y, v1.y, v2.y, t), FergusonCoons(p1.z, p2.z, v1.z, v2.z, t) ); } }

Catmull-Rom曲線

過去の座標(p0)、現在の座標(p1)、目標の座標(p2)、未来の座標(p3)の4点を使って、現在座標と目標座標をなめらかに繋ぐ曲線を描きます。速度を求めるより、前後の座標を求めるのが簡単な場合は、こちらの方が便利でしょう。
public static partial class MathEx { public static float CatmullRom(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 Vector3 CatmullRom(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { return new Vector3( CatmullRom(p0.x, p1.x, p2.x, p3.x, t), CatmullRom(p0.y, p1.y, p2.y, p3.y, t), CatmullRom(p0.z, p1.z, p2.z, p3.z, t) ); } }

実装例

以下のコードは、Ferguson/Coon曲線を使ったスプライン補間の実装例です。
using Photon.Pun; using UnityEngine; public class GamePlayer : MonoBehaviourPunCallbacks, IPunObservable { private const float InterpolationDuration = 0.2f; private Vector3 velocityPerSecond = Vector3.zero; private Vector3 p1; private Vector3 p2; private Vector3 v1 = Vector3.zero; private Vector3 v2 = Vector3.zero; private float elapsedTime = 0f; private void Awake() { p1 = transform.position; p2 = transform.position; } private void Update() { if (photonView.IsMine) { var direction = new Vector2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical")).normalized; velocityPerSecond = 6f * direction; var dv = velocityPerSecond * Time.deltaTime; transform.Translate(dv.x, dv.y, 0f); } else { elapsedTime += Time.deltaTime; if (elapsedTime < InterpolationDuration) { transform.position = MathEx.FergusonCoons(p1, p2, v1, v2, elapsedTime / InterpolationDuration); } else { transform.position = p2 + velocityPerSecond * (elapsedTime - InterpolationDuration); } } } void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { stream.SendNext(transform.position); stream.SendNext(velocityPerSecond); } else { var networkPosition = (Vector3)stream.ReceiveNext(); var networkVelocityPerSecond = (Vector3)stream.ReceiveNext(); var lag = Mathf.Max(0f, unchecked(PhotonNetwork.ServerTimestamp - info.SentServerTimestamp) / 1000f); p1 = transform.position; p2 = networkPosition + networkVelocityPerSecond * lag; v1 = velocityPerSecond * InterpolationDuration; v2 = networkVelocityPerSecond * InterpolationDuration; elapsedTime = 0f; velocityPerSecond = networkVelocityPerSecond; } } }

次の記事

PUN2で始めるオンラインゲーム開発入門【その3】
o8que
Ghost - Programmer
5
Comments