見出し画像

VRChatワールドの同期を考える(前編)

誰が言ったか、「同期のコツは、同期しないこと」なんて言葉もあります。
この記事は、私が幾つかのUDONギミックを作っていく中で気づいた、同期のコツみたいなもののアウトプットです。
U#の文法(書き方)ような初歩的な話は、有志による入門向け記事がもう充実しているので、ここでは省略します。


同期するということ

VR世界は根本的に、その人個人のローカルな世界が人の数だけ存在しています。同期とは、インターネットを介して他の人達の状態(情報)や行動(イベント)をリアルタイムに受け取る事で、あたかもその人達が自分のVR世界で同じ動きをしているかのように見せることです。
つまり、情報(変化した状態、実行した行動)があり、それを発信する人と受信する人がいる、という事です。

一方で、VR世界は物理的制約から解放されたように思えても、基底現実の上に成り立っている以上、その呪縛からは完全に逃れることは出来ません。基底現実では、光の速さをもってしても情報の伝達に無視できないタイムラグが発生します。これはそのまま、VR世界の同期にタイムラグが存在することを意味します。

VRChatが同期するもの

VRChatでは最低限プレイヤー間でコミュニケーションが取れるように、様々な同期が既に行われています。
これら同期済みの情報を加工してその結果を同期し直すのは、2回の同期…2倍のタイムラグを発生させることになり、動作がもっさりしたりチャタリングの原因に繋がります。これら同期済み情報を元にあれこれしたい場合は、ローカル処理にすべきです。

最初に同期されるのはワールドそのものです。我々は同じワールドデータをダウンロードし、展開する事で、同じ景色を見ることができています。要するに初期状態ですね。この初期状態から変化があるものだけは同期すればいい、という事です。
逆に言うと、PCとQuestで見えている景色が異なるのは、このワールドデータが別物になるためです。シェーダーやビデオプレイヤーなどのビジュアル面では仕方ない部分もありますが、ギミック類は極力同じになるようにしたいところ…

次に各プレイヤー情報。これはその殆どをVRChatが同期してくれていて、UdonからはVRCPlayerApi型の変数としてその情報を得ることができます。
一例として、プレイヤーの座標、プレイヤー名、プレイヤーの声、アバターのボーン座標、アバタースケール等です。
具体例を出すと、特定のプレイヤーに追従するオブジェクトを作る時、「本人のローカルで追従させたオブジェクトの座標情報を同期する」のは、プレイヤー座標とオブジェクト座標を二重に同期しているため無駄です。「追従対象が誰か(VRCPlayerApi.playerID)を同期しておいて、追従対象の座標情報を元に、各プレイヤーがオブジェクトを追従させる処理を実行する」。こうする事で、同期ラグによる位置ズレをすることなく、特定プレイヤーにオブジェクトが追従できます。

プレイヤー追従の例

そしてVRChatが用意する一部のコンポーネント (VRC Object SyncとVRC Object Pool)には、そのコンポーネント自身に同期する機能があるため、これらの同期管理も不要です。
ただし、これらのコンポーネントを操作する場合は、そのオブジェクトのオーナー権が必要になります。

なお、Unity/VRChatが実行するイベントはほとんどローカルイベントですが、実行条件的に全クライアント同時に実行されるため、実行条件に気を付けて使えば、間接的に同期イベントとして扱えるものがあります。
例えばOnPlayerJoined()はプレイヤーが新しく読み込まれる度に実行されるイベントです。そのため誰かがJoinする度に、本人含めたインスタンス内の全員に実行されます。一方で、later-joinerにとっては"Joinした時点でそのインスタンス内に居る全プレイヤー"を読み込むので、その分も実行される事に注意が必要です。

Udonで同期できるもの

前述のもの以外は、Udonを使って同期してやる必要があります。
同期には「同期する情報」「発信者」「受信者」の要素があると説明しました。Udonの同期でこれらにあたるのが、それぞれ「イベント同期/同期変数」「同期オブジェクトのオーナー(以下Owner)」「同期オブジェクトのオーナー以外」です。

イベント同期

イベント同期はSendCustomNetworkEvent()のみで、誰でも実行でき、実行先として「Owner(Ownerのみ)」「All(自分含めたその場の全員)」のどちらかを指定できます。
「Owner」で実行したのがOwner自身だった場合と、「All」で実行した時の実行者本人は、同期先が自分自身であるためタイムラグ無しで即座に実行されます。
イベント同期は実行時に変数を持たせる事ができない事と、同期イベント実行後にJoinしてきた人に対しては実行されない特徴があります。

イベント同期を使うにあたって、同期イベントを実行するためのローカルイベントに注意を払う必要があります。
例えばOnPlayerTriggerEnter()イベント、はそれを観測した全員の元で実行されます。誰かがOnPlayerTriggerEnter()したらSendCustomNetworkevent()を「All」で実行するように組んであったとき、インスタンスに5人いたら5人が5人に向けてSendCustomNetworkEvent()を実行する…つまり、自分の実行分含めて5回実行されてしまいます。
以下のようにすることで、コライダーに飛び込んだプレイヤー本人からのみ全員に向けてSendCustomNetworkEvent()が実行されるため、全員が1回だけ同期イベントを実行します。

SendCustomNetworkEvent()の例

「Owner」指定のイベント同期は、オブジェクトオーナーは(頻繁に)切り替えたくないが同期変数の再計算などをさせたい、といった時に使えます。
一方で、これは 誰か→オーナー→オーナー以外 へと2回同期する事にはなるため、タイムラグがそこそこ長くなる事を考慮しましょう。
また、複数人から同時に「Owner」に向けて実行されると、オーナーがその回数分だけ同期イベントを実行する事になるので、同期イベント内で実行にインターバル処理を設けるか、実行済みフラグを設けると良いでしょう。

変数同期

変数同期にはContinuousとManualの2種類があります。
前者は同期できるデータ量が少ないが、常にOwnerから同期変数を送信し続ける他、[UdonSynced]のオプションとして、float値に対して補完手段が選べます。
後者は1度に同期できるデータ量が多く、同期データ量が少なければ同期データの着弾も早いです。無駄な同期通信が大幅に抑えられるので、基本はこれを使う機会が多いでしょう。

ContinuousもManualも、同期データを送信する周期は(たぶん10Hz刻みくらいで)決まっており、同期周期が来た時点での同期変数の値が送信されます。
Continuousでは元々値の補完もあり、最終的(Ownerの元で値の変動が落ち着いた時)に一つの値に収束すれば良い考えで動くものなので気にしなくても良いのですが、Manualでは、毎フレームRequestSerialization()したとしても同期周期までは値が送信されない事を気にとめておく必要があるでしょう。

Ownerが同期変数を書き換えて送信すると、その値は常にサーバー側でもキャッシュされていて、later-joiner(後からjoinしてきた人)は最初にこのキャッシュされた同期変数を受け取るようになっています。これにより、変数同期を使うにあたっては、Manualでもlater-joinerの事をあまり意識しなくて良いようになっています。

流石に長くなってきたので、記事を分割しました。
後半に続く


いいなと思ったら応援しよう!