NetworkedProperty(に相当する機能)のすゝめ

エンジニアの岡村です。

Unityでネットワークマルチプレイを行うアプリケーションを開発する場合、ある程度以上の規模があるのならば、その実装にはネットワークライブラリを利用するのが一般的かと思います。

その際に使われるライブラリにもいろいろな製品があるのですが、メジャーなところではPhotonやMonobitやMirror、最近ならUnity謹製のNetCode for GameObjectも選択肢に上がるでしょう。

弊社では長らくPUN Classic(PUN2ではない)を自社向けにカスタマイズしたものを使ってマルチプレイを実装していました。基本的な機能はPUNに準拠していたのですが、細かい同期の制御をコンポーネント内でのイベントとRPCの実装で行っており、大人数対応などの拡張が難しい状態になっていました。

例えばNEUTRANSでは、プレイヤーの入室時に他プレイヤーからの同期を全てRPCで実装し、プレイヤーがルーム内で描いた絵(UGC)に対して、自前で後から参加したユーザー向けの遅延同期処理もRPCで実装するなど、かなり無理のある使い方をしていました。

そのままでは入室人数を増やしたり、より複雑な機能の開発を行うには限界があったため、思い切って新しい仕組みに書き換えることを決め、新しい選択肢を求めて他のライブラリの検証をいました。その過程で触ったNetCode for GameObjectやPhoton Fuisonといった新しめのライブラリには「NetworkedProperty」というような名前で、同期する必要がある変数1個1個の単位で同期する為の機能が搭載されていることに気づきました。

(UNetにも同様の機能は存在しますが、自分がUNetを触るより前にPhotonを触っていたので知りませんでした)

UnityのマルチプレイライブラリにはRPCと、それ以外の値ベースの同期機能が搭載されていることになります。今まではRPCばかり使っていたので、今回改めてこれらの機能の存在理由について調べ、纏めてみました。

以下の内容では機能名は基本的にFusionのものを利用していますが、他のネットワークライブラリにもほぼ同様の機能が別名で存在するので、適宜読み替えてください。


Unityにおけるマルチプレイライブラリの特徴

前提として、UnityゲームエンジンはGameObjectを処理の単位としています。Unityと深く統合されたマルチプレイライブラリはその設計を汲んで、GameObjectとそこにアタッチしたコンポーネント内で簡単に同期のための機能を利用できる仕組みを搭載しています。

マルチプレイを制御するRunnerが一つと、それの子としてマルチプレイのシーンを構成するObjectが0個以上ある形になります。また、Runner内におけるインターネットの向こうにあるサーバーやクライアントとメッセージをやり取りする仕組みと、Runnerが各Objectに対して同期サービスを提供する仕組みは分離可能な実装になっていることが多いです。

NetworkObjectは基本的にGameObjectにコンポーネントを付けたPrefabの形で実装されます。同期するにはPrefab同士が同じ構造である必要があるので、それぞれのPrefabにはIDが振られており、特定のIDで特定のPrefabが取得できるようになっています。必要になったタイミングでInstantiateするとともにネットワーク上で一意となるIDを振り、Runnerと接続して初期化を行います。

NetworkedProperty

変数の同期機能を持っているライブラリは、コンポーネントに同期ロジックを実装する際、フィールドメンバーをAttribute等で同期可能な変数としてマークします。マークされた変数は同期サービス側で監視され、値の変更があれば自動でシリアライズされて他のクライアントに同期されます。

基本的にこの同期はいずれかのクライアント上の値を真として一方向のみで行われ、他のクライアント上で値を変更しても反映されません。これはどちらのクライアントが持つ値が正しいのかをハッキリさせ、お互いが値を送り合って状態が収束しなくなってしまうのを防ぐためです。

RPC(Remote Procedure Call)

RPC(Remote Procedure Call)は、同期オブジェクト側から能動的にリクエストが送られる機能です。RPCとしてマークされたメソッドを呼び出すと、その引数がシリアライズされて受け取り手に届きます。これらの処理は基本的に即時に、遅滞なく行われます。

ただし、Networked Propertyのように状態を持っておらず、送信しようとしたタイミングで送信相手がルーム内に存在する必要があります。後から入室した相手には届きません。

ちなみに、PUN2ではRPCをバッファリングして後から入室したプレイヤーにも送信する機能がありましたが、Fusionでは削除されました。この変更は恐らくRPCをステートレスにすることで、NetworkedPropertyとの使い分けを明確にしたのだと思います。

使い分け

RPCは、

  • 特にリアルタイム性が要求される同期処理(マルチプレイゲームでの攻撃処理)
  • 同期権限のないクライアントから、同期権限のあるクライアント(サーバー)へ値の更新を依頼するとき
  • 状態の変更を伴わない一時的なトリガーの同期(送信経路を選べるなら、到達保証を無しにしても良い)

一方で、NetworkedPropertyは、

  • RPCで挙げた以外の同期処理全て

これくらいの認識で使い分けをして問題ないと思います。

Fusionでも入退室時のイベントと、RPCを使うことで全てRPCで完結することもできなくはないですが、それではここでは紹介しなかったFusionの様々な便利機能を使えない、実装の手間が増える、同期時の負荷が上がるといったデメリットが色々あるので、基本的にはNetworkedPropertyとRPCを組み合わせて同期処理を作っていくのがいいでしょう。

上図のフローを参考に同期するコードを書いてみるとこのような感じになります。

using Fusion;
using UnityEngine;

public class SampleScript : NetworkBehaviour
{
    [Networked(OnChanged = nameof(OnColorChanged))]
    private Color Color { get; set; }

    public void ChangeColor(Color color)
    {
        RPC_SetColor(color);
    }

    [Rpc(sources: RpcSources.All, RpcTargets.StateAuthority, InvokeLocal = true)]
    private void RPC_SetColor(Color color)
    {
        Color = color;
    }

    private void OnColorChanged()
    {
        var block = new MaterialPropertyBlock();
        block.SetColor("_Color", Color);
        var renderer = GetComponent<Renderer>();
        renderer.SetPropertyBlock(block);
    }
}

他のプレイヤーに対する同期は全てNetworkedPropertyがやってくれるので、コードではとにかく「同期権限のあるクライアント側のNetworkedPropertyに値を入れる」「NetworkedPropertyが更新された時にビューを更新する」の2点だけを考えればよくなっています。

ちなみに、以前はこんな感じで実装していました(PUN2のコード)。

using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class SampleScript : MonoBehaviourPunCallbacks
{
    private Color color;

    public void ChangeColor(Color color)
    {
        photonView.RPC(nameof(RPC_SetColor), RpcTarget.All, color);
    }

    [PunRPC]
    private void RPC_SetColor(Color color)
    {
        this.color = color;
        var block = new MaterialPropertyBlock();
        block.SetColor("_Color", color);
        var renderer = GetComponent<Renderer>();
        renderer.SetPropertyBlock(block);
    }

    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        if (photonView.IsMine)
        {
            photonView.RPC(nameof(RPC_SetColor), newPlayer, color);
        }
    }
}

コード量はあまり変わりませんが、他のプレイヤーが入室したときの処理を手動でハンドリングしています。このコードではオブジェクトが大量にあった場合、入室時に現在の状態を同期する為にRPC通信が大量に飛ぶことが予想されます。小規模なアプリであれば動作に問題はないのですが、大規模な拡張をするのは難しいでしょう。

その他

NetworkedPropertyでも対応が難しいパターン

先程はNetworkedPropertyを万能かのように書いたのですが、そもそもリアルタイムネットワークの特徴として、あまり大容量のデータを扱うことは苦手です。そのため、UGC(ユーザーが作ったコンテンツ)の同期、特にマルチプレイ空間内で動的に作成される作品を同期したりといった事は苦手です。そのようなものを実装することになった場合は、大人しくUGCの同期用の仕組みを別途用意するのがいいでしょう。

NetworkedPropertyの拡張

この仕組みはルーム内の全ての状態が同期サービス側から読み書きが可能な為、その口をほんの少し拡張することで、ルーム内の状態のセーブ、ロードやルーム状態のCDNを通じたライブ配信など、様々な応用が出来るのではないか、と考えています。

おわりに

最近の同期ライブラリの情報自体は追っていたのですが、実際に使っているコードはPUN Classicをカスタマイズしたものだった為、この記事を書くにあたって調査するたびに新しい発見があって大分情報が古くなっているな、と改めて感じました。やはり手を動かしてみるのが大事ですね。

今回の記事はPhoton Fusionに限らないモダンなUnityのマルチプレイライブラリに共通した考え方について書いた(つもり)です。Photon Fusionにしかないような便利な機能は沢山あるのですが、この場での紹介は割愛しました。もしPhoton Fusionに興味がある方は、先日行われたこちらのセミナーの資料が参考になったので、是非一読をお勧めします。

photonjp.connpass.com