コンテンツにスキップ

同期の設計

このページは、同期が実際に何をするか(何が届き、何が届かないか)と、それを踏まえた書き方を扱います。属性やメソッドの対応の一覧は同期にあります。

以下で「保証されていません」と書いているのは、こちらが確認していないという意味ではありません。VRChat の公式ドキュメントに、それを保証するという記述が無いという意味です。逆に「公式が明記しています」と書いた事柄は、原文に保証の語があるものだけです。

公式ドキュメントが保証すると書いているのは、インスタンスの master の決定規則と、PlayerObject の所有者の 2 つです。同じページが「これが master の挙動について VRChat が現在行っている唯一の保証であり、それ以外の観測された挙動は変わりうる」と宣言しています。

事柄公式ドキュメントの扱い
最初に入ったプレイヤーが master になる保証すると書いてある
PlayerObject の所有者が本人で固定される保証すると書いてある(範囲は Start と OnDeserialization の実行中)
同期する変数が届く保証する記述が無い
送った順に届く保証する記述が無い
一度だけ届く保証する記述が無い
1 つのオブジェクトの所有者が 1 人である説明はあるが、保証する記述が無い
遅れて入ってきた人に、それまでのネットワークイベントが届く届かないと明記されている

同期する変数の配送については、保証を表す語が公式ドキュメントに 1 つも現れません。そのため、このページの助言はすべて「届かなかった場合にどうなるか」を先に決める形になります。

対象[UdonSynced] を付けたフィールド。型は実行環境が持つものに限られます(一覧は同期)
送られる単位フィールド 1 本ではありません。1 つの Behaviour が持つ同期する変数を全部まとめた 1 通が送られ、片方だけを送る手段はありません
同期する配列必ず初期化してから置きます。初期化していないものが 1 本でもあると、その Behaviour の同期が丸ごと起きません(公式ドキュメントが断定している数少ない失敗条件です)
同期されるか
[UdonSynced] を付けたフィールドされる
付けていないフィールドされない
ローカル変数・static・const・readonly属性を付けられない
所有権そのもの同期する変数ではない。移動は別の経路で伝わる
実行中に生成したオブジェクト公式ドキュメント本体に記述が無く、SDK のリリースノートで同期しないと触れられている
ネットワークイベントの履歴送り直されない

公式ドキュメントが数値を挙げているのは次の 3 つです(原文でも「約」と付いた概数で、同じ節の冒頭に「ここに書いた仕様はすべて変わりうる」という断りがあります)。

何の量か目安
1 秒あたりに送信できる量約 11 キロバイト
1 回の送信の上限手動同期でおよそ 280,496 バイト、連続同期でおよそ 200 バイト
ネットワークイベントを呼べる回数1 つのイベントにつき既定で 1 秒あたり 5 回、[NetworkCallable(100)] のように書いて 100 回まで。加えて、送信側の全体で 1 秒あたり約 100 回
読むときの注意
全体の 1 秒あたり約 100 回動的に決まり設定できないと公式ドキュメントが書いています。自分で指定したレートに収まっていても、こちらに当たることがあります
連続同期の約 200 バイトVRChat の担当者が別の場所で約 256 バイトと述べており、公式ドキュメントの数値と食い違っています。小さいほうで見積もるのが安全です
1 回の送信通信の上でも 1 つになるとは限りません。ネットワークイベントの引数が 1024 バイトを超えると内部で複数のイベントに分割されるので、1 回のつもりで呼んだ送信が回数の上限に当たることがあります

次の数値は公式ドキュメントに書かれていません。

  • 連続同期が 1 秒あたり何回送るか
  • 文字列の長さの上限(1 文字 2 バイトとして、上の上限に収まる範囲、という形でしか言えません)
  • 配列の要素数の上限

[UdonBehaviourSyncMode] で、その Behaviour の同期の方式を決めます。

どちらの方式でも送ったものが必ず届くとは限りません。公式ドキュメントの属性の説明は手動同期について「要求したときの更新が確実になる」と書いていますが、これに反する実機の報告が複数あり、未解決のまま残っています
連続同期送った値の列のうち一部しか届かないものとして扱います。状態が切り替わったことを連続同期の変数で知らせる形は成立しません(切り替わる値は手動同期の側に置きます)
連続同期での OnPreSerialization() / OnPostSerialization()呼ばれるかどうかは公式ドキュメントに記述がありません。呼ばれる前提で軽くしておけば、どちらでも困りません
方式の違う Behaviour の同居同じ GameObject に並べないでください。公式ドキュメントの一方は「最も制約の強い側に合わせる」、もう一方は「使えない」と書いていて、記述が一致していません
sequenceDiagram
    participant O as 所有者
    participant N as ネットワーク

    O->>O: 同期する変数へ代入する

    alt 手動同期
        O->>O: RequestSerialization() を呼ぶ
        O->>N: 送信する
        O->>O: もう一度、同期する変数へ代入する
        Note over O: 代入しただけでは送信されない
    else 連続同期
        O->>N: 送信する
        O->>N: 送信する(値が変わっていなくても繰り返す)
        Note over O: RequestSerialization() は働かない
    end
項目手動同期(Manual)連続同期(Continuous)
送信の契機所有者による RequestSerialization() の呼び出し一定の間隔での自動送信。値が変わっていなくても送る
送信の時点の指定できるできないと公式ドキュメントが明記している
同期する変数への代入時送信されない。次に RequestSerialization() を呼ぶまで、その値は所有者の手元にとどまるそのままにしておけば、次の送信に載る
RequestSerialization() の呼び出し時送信が予約される何も起きない。送信は増えない
送信の詰まり保持して送り直す捨てられて、ログにエラーが出る
1 回の送信の上限およそ 280,496 バイトおよそ 200 バイト
受信する値の順序送った値の一部だけが届くことがある送った値の一部だけが届く。途中の値は飛ばされる
向いているもの得点・状態・盤面など、飛び飛びの値位置・回転・つまみなど、連続して変わる見た目

仕組みそのものは同期にあります。ここでは、設計のときに影響する 3 点だけを扱います。

sequenceDiagram
    participant R as 要求した人
    participant O as いまの所有者
    participant P as ほかのプレイヤー

    R->>R: SetOwner(自分) を呼ぶ
    R->>R: OnOwnershipRequest() が呼ばれる
    O->>O: 同じ OnOwnershipRequest() が、こちらでも呼ばれる
    R->>R: 受けるなら、その場で所有者は自分になる
    R->>R: OnOwnershipTransferred() が呼ばれる
    R-->>P: ほかのプレイヤーへ伝わる
    P->>P: ほかのプレイヤーから見た所有者も、要求した人になる
    P->>P: OnOwnershipTransferred() が呼ばれる
実行される場所要求した人といまの所有者の両方で、それぞれのローカルで実行されます。公式ドキュメントは、両者の判定が食い違うと状態がずれると警告しています
判定に使ってよいもの両者から確実に同じに見えるものだけです(実行中に変わらない静的な情報と、引数で渡ってくるプレイヤーの識別子)
使ってはいけないもの同期する変数と、自分の手元にしか無い値。両者で同じ値になっている保証が無く、結果が割れます
いまの所有者が退出済みのとき呼ばれず、所有者が自動で割り当てられます。割り当て先の規定はありません
何をする場所か所有権から導いているローカルの状態を設定し直す場所です(掴めるか・動かす側はどちらか・どう見せるか)
同期する変数との関係所有権は同期する変数ではないので、変数をどれだけ張ってもこの設定し直しは起きません
書き忘れると掴んだ瞬間に元へ戻る・2 人が同時に動かせる・見え方が人によって割れる、という形になります。API は成功し、エラーも出ません
Networking.IsOwner() の結果保存せず、使う直前に毎回読みます。衝突や持ち上げで所有者が変わることがあり、そのとき何も呼ばれません
sequenceDiagram
    participant X as 1 人目
    participant N as ネットワーク
    participant Y as 2 人目

    X->>X: SetOwner(自分) を呼ぶ
    Y->>Y: SetOwner(自分) を呼ぶ
    X->>X: 1 人目から見た所有者は、1 人目
    Y->>Y: 2 人目から見た所有者は、2 人目
    X->>N: 同期する変数へ代入して送る
    Y->>N: 同期する変数へ代入して送る
    N->>N: 先に着いたほうが所有者になる
    N->>X: 勝ったほうの値を受信する
    N->>Y: 勝ったほうの値を受信する
2 人が同時に Networking.SetOwner() を呼ぶとどちらも自分の側では所有者になります。所有権の取得は排他制御にはなりません(負けたほうの代入は、勝ったほうの値を受信した時点で消えます)
戻らなくなる報告未解決のまま残っています。通信の遅延が小さい環境ほど起こりやすいとされていて、開発中の環境で踏みやすい形です
順番を 1 人に絞りたいとき所有権ではなく、同期する変数の中に順番そのものを持たせます
場所同期する変数への代入
所有者かどうかを確認した後ここに書く
Start の中避ける。Start より前に同期する変数が書き換わったという報告があり、Start の中の RequestSerialization() が届かないという報告もある
Update の中(手動同期)代入してよいが、RequestSerialization() を毎フレーム呼ばない
Update の中(連続同期)代入だけでよい。RequestSerialization() を呼んでも送信は増えない
OnPreSerialization()公式ドキュメントが「同期する変数を置くのによい場所」と書いている。ただし、ここで書いた値がその 1 通に載ることは保証されていない
OnDeserialization()書かない。受け取った値を上書きする
OnOwnershipRequest()読まない。両者で同じ値になっていない
FieldChangeCallback の設定側他の同期する変数を読まない。まだ古い値のことがあると公式ドキュメントが明記している

ネットワークイベントの受け取りは、その Behaviour の Start より前に実行されることがあります。受け取り側は、フィールドが初期値のままでも壊れない形で書きます。実行順序についてはフィールドの初期化と既定値も参照してください。

手段分かること分からないこと
OnDeserialization()同期する変数の書き込みが全部終わったこと値が変わったかどうか。自分が所有者かどうか
FieldChangeCallbackその変数 1 本に書き込みがあったこと他の同期する変数が新しいかどうか。配列の中身の変化
OnPostSerialization()送信を試みたこと相手が受信したかどうか
書き込みの順序変数は 1 本ずつ書き込まれ、順序は決まっていません。書き込みのたびに FieldChangeCallback が実行され、全部書き終えてから OnDeserialization() が実行されます
配列の中身FieldChangeCallback は呼ばれません(配列そのものは同じままだからです)。知らせたいなら、別の同期する変数に世代番号を持たせます
SerializationResult.successtrue でも届いたことにはなりません。公式ドキュメントはこれを「送信を試みた直後」と説明しています
OnDeserialization()所有者では呼ばれないものとして扱われがちですが、公式ドキュメントに記述はなく、所有者で呼ばれた事例が報告されています。所有者かどうかの判定には使えません
到着を確実に知る手段ありません。分かるのは「値が来た」ことであって、「いま持っている値が最新である」ことではありません。最新かどうかを判断したいなら、世代番号を同期する変数として持ちます
症状何が起きているか
自分が代入した値が戻る所有者でないまま代入した。エラーは出ない。いつ戻るかは公式ドキュメントに記述が無く、二次の資料も「数フレーム後」と「不明」で割れている
2 人が同時に動かせる所有権を排他制御に使っている
掴んだ瞬間に元へ戻るOnOwnershipTransferred() を書いていない
遅れて届く、届かない送れる量の上限に当たっている。手動同期は保持して送り直し、連続同期は捨てられる
順序が入れ替わる同期する変数に順序の保証が無い。ネットワークイベントは同期する変数より先に着くことがある
後から入った人に何も無いネットワークイベントは送り直されない。同期する変数も届かないという報告がある
入室した瞬間に演出がまとめて鳴る入室時の受信で OnDeserialization() が呼ばれ、そこで演出を出している
同じ処理が何度も実行されるOnDeserialization() の呼び出しは、値の変化と 1 対 1 ではない
値は正しいのに見た目が古い受信時に見た目を更新していない。同じ処理を送信側と受信側の両方から呼ぶ
ネットワークイベント途中から入ってきたプレイヤーへ送り直されません。公式ドキュメントが明記していて、この分野では数少ない確実な保証です(イベントで状態を配信する設計は、この 1 点で成立しません)
同期する変数配信されることになっています。ただし次の 3 通りの失敗が報告されていて、いずれも未解決です
失敗 1値も通知も来ない
失敗 2値は入っているのに OnDeserialization() が呼ばれない
失敗 3古い値で始まる。型としても内容としても妥当なので、値を見ても気づけません
受け取れていないことの検出できません。失敗 3 があるので「値が初期値のままかどうか」では検出できず、毎フレーム値を読む方法でも直らないという追試があります
書き方受け取れているかを判定するのではなく、受け取れていない状態のまま観測されても危険にならない形で書きます(鍵は閉じている側・ゲームは未参加・所有権は主張しない)
入室を検知して送り直す形OnPlayerJoined() の中で RequestSerialization() を呼ぶ形は、同期しないという報告があります。所有者が反応できない状態のとき、その間のイベントが実行されないことがある、とも公式ドキュメントに書かれています

ここに載せた例はすべてコンパイルを通してあります。実機で値が届くかどうかは、このリファレンスでは確認していません。

所有権を取ってからの書き込み

Section titled “所有権を取ってからの書き込み”

同期する変数を書く前に所有権を取り、書いたあとに送信し、自分の見た目は自分で更新します。受信側と送信側で同じ処理を呼ぶので、Apply() は何度呼んでも同じ結果になる形にします。

using UnityEngine;
using Tsukimi;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncOwnedWrite : TsukimiBehaviour
{
[UdonSynced] private int stage;
public override void Interact()
{
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
stage = (stage + 1) % 4;
RequestSerialization();
Apply();
}
public override void OnDeserialization()
{
Apply();
}
private void Apply()
{
transform.localScale = Vector3.one * (1f + stage);
}
}

所有権が移ったあとの張り直し

Section titled “所有権が移ったあとの張り直し”

所有権から導いているローカルの状態を、移ったあとの前提で設定し直します。

using Tsukimi;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncOwnershipRestore : TsukimiBehaviour
{
[UdonSynced] private int stage;
public override void OnOwnershipTransferred(VRCPlayerApi player)
{
DisableInteractive = !Networking.IsOwner(gameObject);
}
}

要求を断るかどうかは、引数のプレイヤーだけを見て決めます。同期する変数も、自分の手元にしか無い値も読みません。

using Tsukimi;
using VRC.SDKBase;
public class SyncOwnershipRequestArgs : TsukimiBehaviour
{
public override bool OnOwnershipRequest(VRCPlayerApi requestingPlayer, VRCPlayerApi requestedOwner)
{
return requestingPlayer.playerId == requestedOwner.playerId;
}
}

世代番号による古い値の切り捨て

Section titled “世代番号による古い値の切り捨て”

順序の保証が無いので、古い値が新しい値のあとに届くことがあります。世代番号を一緒に送り、受け取った世代が前より小さければ捨てます。番号は代入と同じ場所で進めます。OnPreSerialization() で進める形もありますが、そこで書いた値がその 1 通に載ることは保証されていません。

using UnityEngine;
using Tsukimi;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncGeneration : TsukimiBehaviour
{
[UdonSynced] private int state;
[UdonSynced] private int generation;
private int applied = -1;
public override void Interact()
{
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
state = (state + 1) % 4;
generation = generation + 1;
RequestSerialization();
Apply(generation, state);
}
public override void OnDeserialization()
{
Apply(generation, state);
}
private void Apply(int gen, int value)
{
if (gen <= applied)
{
return;
}
applied = gen;
transform.localScale = Vector3.one * (1f + value);
}
}

変化した部分だけを送る形は、1 回でも欠落すると以後ずっとずれます。いまの状態を丸ごと送れば、欠落しても次の 1 回で元に戻ります。

using UnityEngine;
using Tsukimi;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncWholeState : TsukimiBehaviour
{
[UdonSynced] private int[] slots = new int[8];
public override void Interact()
{
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
for (int i = 0; i < slots.Length; i++)
{
slots[i] = (slots[i] + 1) % 3;
}
RequestSerialization();
Apply();
}
public override void OnDeserialization()
{
Apply();
}
private void Apply()
{
int total = 0;
for (int i = 0; i < slots.Length; i++)
{
total = total + slots[i];
}
transform.localPosition = new Vector3(0f, total * 0.1f, 0f);
}
}

受け取れていない状態でも危険にならない側を、フィールドの初期値にします。この例では、届いていない間は閉じたままになります。

using UnityEngine;
using Tsukimi;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncSafeDefault : TsukimiBehaviour
{
[UdonSynced] private bool unlocked;
void Start()
{
Apply();
}
public override void OnDeserialization()
{
Apply();
}
private void Apply()
{
transform.localScale = unlocked ? new Vector3(1f, 0.1f, 1f) : Vector3.one;
}
}

ネットワークイベントは、同期する変数より先に着くことがあります。イベントに値を持たせず、受け取った側が同期する変数を読み直す形にすれば、どちらが先に着いても結果が同じになります。

using UnityEngine;
using Tsukimi;
using VRC.SDKBase;
using VRC.SDK3.UdonNetworkCalling;
using VRC.Udon.Common.Interfaces;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncNotifyThenRead : TsukimiBehaviour
{
[UdonSynced] private int stage;
public override void Interact()
{
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
stage = (stage + 1) % 4;
RequestSerialization();
SendCustomNetworkEvent(NetworkEventTarget.All, nameof(Changed));
}
[NetworkCallable]
public void Changed()
{
Apply();
}
public override void OnDeserialization()
{
Apply();
}
private void Apply()
{
transform.localScale = Vector3.one * (1f + stage);
}
}

OnPostSerialization() で分かるのは送信を試みたところまでですが、失敗したことは分かります。失敗したら送り直します。

using Tsukimi;
using VRC.SDKBase;
using VRC.Udon.Common;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncRetryOnFailure : TsukimiBehaviour
{
[UdonSynced] private int stage;
public override void Interact()
{
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
stage = (stage + 1) % 4;
RequestSerialization();
}
public override void OnPostSerialization(SerializationResult result)
{
if (!result.success)
{
RequestSerialization();
}
}
}

どれも、コンパイルは通り、エラーも出ません。壊れるとしたら実行中で、しかも常には壊れません。

形なぜ
所有権を排他制御に使う全員が自分を所有者だと思う状態が報告されていて、未解決のまま。通信の遅延が小さいほど起こりやすい
Start で初期化して、以後それを信じるStart より前に同期する変数が書き換わるという報告が残っている。ネットワークイベントの受け取りも Start より前に実行されることがある
Networking.SetOwner() の直後に同期する変数を書く即座に反映されるという記述と、反映されないという記述が両方あり、決着していない
同期する変数を書いてから、イベントで知らせるイベントのほうが先に着くことがあり、受け取った側が読む値が古くなる
OnDeserialization() に一度きりの処理を書く呼ばれない場合と、値が変わっていないのに呼ばれる場合が、どちらも報告されている
Networking.IsOwner() の結果を保存して再利用する通知の無い所有権の変更がある(衝突・持ち上げ)
同期するオブジェクト自身を SetActive(false) にする公式ドキュメントに記述は無いが、非アクティブの間は送受信もコールバックも起きないと複数の解説が書いている。再び有効にしたときの挙動も一定しない
OnPlayerJoined() の中で RequestSerialization() を呼ぶ同期しないという報告が残っている
サーバ時刻の引き算で経過時間を出す基準点が無く、値が一周することがあると公式ドキュメントが書いている。float へ代入すると刻みが粗くなる
返事が無ければ所有権を奪う配送の遅れに上限が無い。どの端末もいつでも一時停止しうると想定して書くように公式ドキュメントが求めていて、その停止が数時間続いた例も報告されている
OnPlayerLeft() で退出したプレイヤーの識別子を使う読めない場合と例外になる場合が報告されている
変化した部分だけを送る1 回でも欠落すると、以後ずっとずれたままになる
人数の上限を決め打って詰め込む上限を超えることがあるので対処するように、と公式が作者へ向けて書いている
実行中に生成したオブジェクトを同期する同期しない

書いたものが実際にどう変換されるか、同期する変数が何本あるかは、MCP サーバの inspect が返します。