VRChatワールドの同期処理まとめ

はじめに

この記事は、のりたま(私)がこれまでVRChatのワールドやギミックを作った際に得た「同期処理」の知見についてまとめた記事になります。

あくまで趣味でやってる人間が個人の感想としてまとめた記事ですので、ご覧になる方はその程度の記事だと思って見ていただけると嬉しいです。

質問などあれば可能な限りはお答えしたいですが、必ず回答することを保証するものではありません。また、内容の真偽についても、執筆した2022/7/2時点で個人の経験として「正しい」と感じたことを書きますが、絶対的に正しいということを保証するものでもなければ、今後のVRChatの更新で真偽が変わってくる可能性があることをご了承下さい。

また、のりたまは基本的にギミックはU#で記述しています。UdonGraphとは異なる内容を記載している可能性がありますが、それについても保証しません。

また、雑なまとめですので、ある程度はプログラミングが出来る人、そしてUdon固有の関数など、ある程度は自身で検索して調べられる人を想定しての内容になります。なので、細かく「どうプログラミングすれば良いのか?」を説明した内容ではなく、Udonの同期の考え方についてまとめたものになりますので、ご了承下さい。

7/3 更新:サンプルの記述、コメントを増やしました。

VRChatワールドは、基本的にはローカル動作

まずは大前提です。VRChatのワールドは「何もしなければ」すべてローカルで動作します。
ミラーを出そうが、PickUpオブジェクトを動かそうが、照明をON/OFFしようが、すべてローカルでの動作となります。
これをワールドに居る他のプレイヤーと同じものを見ようとする仕組みを「同期」と呼んでいます。
また「同期」する対象には「見えないもの」も含まれていて、例えばゲームワールドでの他プレイヤーの状態を表すデータとかも含まれます。これも広義の意味では「同じゲーム結果を見せるためにデータを同期させている」と考えることもできます。

何を同期させるか?どこまで同期させるか?

前章の通り、VRChatのワールドは「何もしなければ」すべてローカルで動作します。
そのため、「何でもかんでも同期させよう」というのは現実的ではなく(*1)、「何を同期させれば、全プレイヤーが同じものを見ているように感じられるか?」という設計が大事になります。

(*1) 同期させるデータが多いほど実装が複雑になりますし、Udonやネットワークの制限に抵触しやすくなる、という意味も含まれます。

例えば「PickUpしてUseすると水が出てくる水鉄砲」を考えると、

  • 「PickUpしてUseしているか?」を示す情報を同期させる

  • 全プレイヤーはその情報を見て、水鉄砲の先から出る水パーティクルをローカルでON/OFFする

という感じです。
Udonで同期ギミックを組む場合は、どこまでを同期させて、どこからをローカル処理で行えば、ぱっと見キレイに全プレイヤーが同じものを見ているように感じられるか?というのを意識して設計、実装する必要があります。

同期させるための方法は2つ

ここまでは前置きみたいなものです。
ここからが具体的な実装の話になります。
まず、プレイヤー間で同期を行うための方法は2つあります。
イベント同期変数です。

イベントの使い方

一つ目の方法「イベント」は、その名の通り、特定の人にイベントを発生させるものです。
特定の人というのは、現状は「そのUdonのオブジェクトオーナー(*2)」か「全プレイヤー」かの2種類です。

(*2) いきなり「Udonのオブジェクトオーナー」というワードが出てきましたが、これめちゃくちゃ大事です。あとで説明します。

書き方は以下の通りで、SendCustomNetworkEvent関数の第一引数に、そのイベントを発生させる対象を指定します。
VRC.Udon.Common.Interfaces.NetworkEventTarget.Ownerだとオブジェクトオーナー、VRC.Udon.Common.Interfaces.NetworkEventTarget.Allだと全プレイヤーになります。

SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(HogeEvent));
SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(HogeEvent));

ここでは第二引数で nameof(HogeEvent) を指定していますが、これはイベント発生を受けた側ではHogeEvent関数が呼ばれるよ、というものになるので、下記のように、その関数をpublicで作っておく必要があります。

public void HogeEvent()
{
    // イベント発生時の処理
    // 例えば水パーティクルのON/OFFなど、ローカルで動かしたい処理を書く。
}

見ての通り、イベント自体はシンプルな実装になっている反面、HogeEventに引数を渡すことが出来ないので細かな制御ができないという性質になっています。

同期変数の使い方

二つ目の「同期変数」は、U#上の変数にUdonSyncedという属性を付けることで同期変数とし、この変数値をプレイヤー間で共有するというものです。 このUdonSyncedですが、どのように同期させるか?というモードを設定する必要があります。 それがUdonBehaviourのInspectorのSynchronization Methodです。

UdonBehaviourのInspector

大きくはContinuousManualのどちらかを使うことになると思います。 それぞれの説明文ですが…

        /// <summary>
        /// Enforces continuous sync mode on the behaviour
        /// </summary>
        Continuous,
        /// <summary>
        /// Enforces manual sync mode on the behaviour
        /// </summary>
        Manual,

だそうです。はい。
ざっくり補足すると、continuousは継続的に値を同期するが、どのタイミングで同期されるのかがはっきりしないもので、ManualはUdon側で同期タイミングを指定しないといけないが、つまり同期されるタイミングがはっきりしているというものです。
ネットワーク帯域的な問題と、処理をハンドリングしやすいという意味では基本的にManualを使うことになりますので、本noteでは以降はManualという前提で記載します。
(実際、あんまりcontinuousは使うことは無いのかなぁという感覚です。。)

それではUdonSynced属性を付けた変数の宣言例です。

[UdonSynced] private bool bHoge;

宣言としてはこれだけです。しかし、UdonSynced属性を付けると色んな制約が出てきます。

◆値を更新して同期できるのはオブジェクトオーナーのみ
出てきました。オブジェクトオーナー。
これはそのUdonオブジェクトのオーナーのことであって(そのまんま)、Udonの変数同期はUdonオブジェクトのオーナーのみがそのUdonの同期変数を同期する権限を持っているという設計思想になっています。実際、同じインスタンスにいる全員がそれぞれ同期変数を更新し、それぞれ同期をかけたらエラいことになりますよね?
なので、Udonとしては、このオブジェクトオーナーのみが同期変数を更新可能で同期可能となっています。

このオブジェクトオーナーは、インスタンス作成時には、最初にワールドに入った人が全オブジェクトのオーナーとなっています。
しかし、Udonでオブジェクトオーナーを変更することができます(*3)。
例えば水鉄砲ギミックの場合、水鉄砲を持った人をオブジェクトオーナーにして、Useしているかどうかの同期変数を、その人自身に更新させたいですよね?
よく使うのは、以下のような「自身がオブジェクトオーナーでない場合、自身がオブジェクトオーナーとなる」という記述です。

if (!Networking.LocalPlayer.IsOwner(this.gameObject)) {
    Networking.SetOwner(Networking.LocalPlayer, this.gameObject);
}

これでオブジェクトオーナーになれたので、同期変数を更新することができます。

(*3) しかし、必ずしもオブジェクトオーナーの変更に成功するとは限りません。このあたりの話は機会があれば別noteで…

◆値を同期するには、同期を要求しないといけない(同期モードがManual時)
これはManualモード前提の話なのですが、オブジェクトオーナーが同期変数を更新する場合、その同期を要求する関数を呼ぶ必要があります。それがRequestSerialization関数です。
特に引数も不要な関数なので、おまじない的に呼んでおけば良いでしょう。
上の記述に続けて書くなら、以下のような感じです。

if (!Networking.LocalPlayer.IsOwner(this.gameObject)) {
    Networking.SetOwner(Networking.LocalPlayer, this.gameObject);
}
bHoge = true;
RequestSerialization();

◆値が同期された(変更された)ことの判定が必要
今度は同期をされた側の話なのですが、その同期変数の設計にもよりますが、基本的には同期変数の変化をキャッチして何か処理をする必要があります。
同期変数の変更があった場合、OnDeserialization関数が呼ばれます。
ここで処理を行いたい場合、下記のようにOnDeserializationをoverrideしたものを実装しておく必要があります。
但し、OnDeserializationが呼ばれたというだけでは、どの同期変数が何の値になったのかまでは分かりません。そのため、まずはどの変数が何の値となったのかの確認を実装することが多いです。

[UdonSynced] private bool bHoge;
private bool localHoge = false;

public override void OnDeserialization()
{
    // 同期変数が変わったかの確認や、変わった場合の処理
    if (localHoge != bHoge) {
        localHoge = bHoge;
        // 同期変数が変化した場合のローカル処理。
        // 例えば水パーティクルのON/OFFなど。
    }
}

また、OnDeserialization以外にも同期変数の変更をキャッチする方法があって、それがFieldChangeCallback属性です。
これは、その同期変数が変化したら自動で指定したcallbackが発火するというもので、わざわざOnDeserializationのようにどの同期変数が更新されたの確認を実装する必要がありません。
また、set,getを使えば、それがあたかも変数のように扱えるし、setに値変更時の処理を差し込んでおけばそれが自動で呼ばれるので非常に便利です。
これは「オブジェクトオーナーが同期変数を変更した時」も「オブジェクトオーナー以外が同期変数の変更をキャッチした時」もどちらも同じ「HogeChanged()」が呼ばれることによって、両者で同じ処理が行われて、結果、「みんなが同じものを見ている」ように見えることに繋がるという話です。
のりたまもOnDeserializationを実装するより、FieldChangeCallbackで処理するケースの方が多いです。

[UdonSynced, FieldChangeCallback(nameof(Hoge))] private bool bHoge;
public bool Hoge
{
    set { bHoge = value; HogeChanged(); }
    get => bHoge;
}

private void SetHoge()
{
    // 同期変数を更新するためにオブジェクトオーナー取得
    if (!Networking.LocalPlayer.IsOwner(this.gameObject)) {
        Networking.SetOwner(Networking.LocalPlayer, this.gameObject);
    }

    // 同期変数の更新。この場合、更新するのはHogeの方
    Hoge = true;

    // 同期を要求
    RequestSerialization();
}

private void HogeChanged()
{
    // 同期変数によって行いたい処理を記述。例えば水パーティクルのON/OFFなど。
    // オブジェクトオーナーの場合は自身でHogeをsetしたタイミングでこの処理が走り、
    // オブジェクトオーナーでない場合は、同期変数が同期されたタイミングでこの処理が走る。
}

イベントと同期変数の違い

ここまで、同期の手段としてイベントと同期変数を説明しました。
ところで、例に出した水鉄砲ギミックの場合、その同期はイベントと同期変数のどちらで作るのが良いのでしょうか。

  • イベントを使う場合
    PickUpしてUseした時のイベントと、Useしなくなった時のイベントを作成。どちらも全プレイヤーにイベント発行するものとし、それぞれのイベントを受けた時に水パーティクルをON/OFFする

  • 同期変数を使う場合
    PickUpしてUseしているかどうかの同期変数(bool)を用意する。誰かが水鉄砲をPickUpしてUseしたときに、その人をオブジェクトオーナーに設定する。以降、Useしているときは同期変数をtrue、そうでなければfalseに更新する。
    また、その同期変数の変化をキャッチして、true/falseによって水パーティクルをON/OFFする。

このように、結論としてはどちらでも実装できます。
ただ、「途中でJoinしてきた人の挙動」については違いがあります。
同期変数の場合は、途中でJoinしても、その時にその時点での同期変数の値が同期されるので、水鉄砲の状態は同期されますが、イベントの場合は、後になってUseした時のイベントを拾うことは出来ないため、ONの場合の水鉄砲の状態は同期されません。

一般にイベントの方が実装は簡単になりますが、このような違いがありますので、要件に応じでどちらを使うかを検討の上、設計するのが良いでしょう。

オブジェクトオーナーを変更しないという選択

ここまでの同期変数の説明では、「オブジェクトオーナーを変更してから同期変数を更新する」という話をしてきましたが、実はオブジェクトオーナーの変更はリスクが伴う行為です。

皆さんも、同期ギミックを複数人で同時に操作して、見え方や動作がおかしくなったことは無いでしょうか?
おそらくそれは、複数人が同時にオブジェクトオーナーを取得しようとしたが、結果的には特定の一人しかオブジェクトオーナーは取得できないため、おかしな挙動に繋がったと考えられます。
この時の挙動は、Udonのウィークポイントのひとつだと思っていて、それも別の機会があればnoteに書きます。。

それを回避するために、「オブジェクトオーナーを変更しない」という選択肢が出てきます。

public void SetHoge()
{
    if (Networking.LocalPlayer.IsOwner(this.gameObject)) {
        // オブジェクトオーナーだった時の処理
        // そのまま同期変数の更新処理を行う
        SetHogeOwner();
    } else {
        // オブジェクトオーナーでは無い時の処理
        // オブジェクトオーナーに同期変数の更新処理をイベントとして発行する
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(SetHogeOwner));
    }
}

public void SetHogeOwner()
{
    bHoge = true;
    RequestSerialization();
}

このように、オブジェクトオーナーであれば同期変数を更新する、そうでなければオブジェクトオーナーに更新イベントを発行するという方法があります。
これにより、オブジェクトオーナーを変更せずに、処理をオブジェクトオーナーに集中させることができます。
オブジェクトオーナーに更新イベントを発行する分、処理的には遅延が発生しますが、オブジェクトオーナー変更が重複した時の変な挙動を防げるだけでなく、例えば大規模なゲームワールドだと、ひとりのプレイヤーが集中してゲームデータを管理する方が実装的に簡易、堅牢になりやすいので、こういう実装に強みが出てきます。

最期に

とりあえず個人的な知見をささっと書きました。
Udonは日々更新が入っていたり、その情報もTwitterには流れているものの新旧の情報や真偽不明の情報が入り交ざっていたりと、検索するにはなかなか骨が折れるものでしたし、結局、自分で確かめてみないと判断つかないようなものも多かったので、noteにまとめてみました。

誰かのお役に立てるようであれば幸いです。
それでは素敵なUdonライフを。

この記事が気に入ったらサポートをしてみませんか?