同期の設計
このページは、同期が実際に何をするか(何が届き、何が届かないか)と、それを踏まえた書き方を扱います。属性やメソッドの対応の一覧は同期にあります。
以下で「保証されていません」と書いているのは、こちらが確認していないという意味ではありません。VRChat の公式ドキュメントに、それを保証するという記述が無いという意味です。逆に「公式が明記しています」と書いた事柄は、原文に保証の語があるものだけです。
保証されていること
Section titled “保証されていること”公式ドキュメントが保証すると書いているのは、インスタンスの 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 バイトとして、上の上限に収まる範囲、という形でしか言えません)
- 配列の要素数の上限
送るタイミング
Section titled “送るタイミング”[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() が呼ばれる
| 実行される場所 | 要求した人といまの所有者の両方で、それぞれのローカルで実行されます。公式ドキュメントは、両者の判定が食い違うと状態がずれると警告しています |
| 判定に使ってよいもの | 両者から確実に同じに見えるものだけです(実行中に変わらない静的な情報と、引数で渡ってくるプレイヤーの識別子) |
| 使ってはいけないもの | 同期する変数と、自分の手元にしか無い値。両者で同じ値になっている保証が無く、結果が割れます |
| いまの所有者が退出済みのとき | 呼ばれず、所有者が自動で割り当てられます。割り当て先の規定はありません |
所有権の移動
Section titled “所有権の移動”| 何をする場所か | 所有権から導いているローカルの状態を設定し直す場所です(掴めるか・動かす側はどちらか・どう見せるか) |
| 同期する変数との関係 | 所有権は同期する変数ではないので、変数をどれだけ張ってもこの設定し直しは起きません |
| 書き忘れると | 掴んだ瞬間に元へ戻る・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 人に絞りたいとき | 所有権ではなく、同期する変数の中に順番そのものを持たせます |
書き込みの場所
Section titled “書き込みの場所”| 場所 | 同期する変数への代入 |
|---|---|
| 所有者かどうかを確認した後 | ここに書く |
Start の中 | 避ける。Start より前に同期する変数が書き換わったという報告があり、Start の中の RequestSerialization() が届かないという報告もある |
Update の中(手動同期) | 代入してよいが、RequestSerialization() を毎フレーム呼ばない |
Update の中(連続同期) | 代入だけでよい。RequestSerialization() を呼んでも送信は増えない |
OnPreSerialization() | 公式ドキュメントが「同期する変数を置くのによい場所」と書いている。ただし、ここで書いた値がその 1 通に載ることは保証されていない |
OnDeserialization() | 書かない。受け取った値を上書きする |
OnOwnershipRequest() | 読まない。両者で同じ値になっていない |
FieldChangeCallback の設定側 | 他の同期する変数を読まない。まだ古い値のことがあると公式ドキュメントが明記している |
ネットワークイベントの受け取りは、その Behaviour の Start より前に実行されることがあります。受け取り側は、フィールドが初期値のままでも壊れない形で書きます。実行順序についてはフィールドの初期化と既定値も参照してください。
| 手段 | 分かること | 分からないこと |
|---|---|---|
OnDeserialization() | 同期する変数の書き込みが全部終わったこと | 値が変わったかどうか。自分が所有者かどうか |
FieldChangeCallback | その変数 1 本に書き込みがあったこと | 他の同期する変数が新しいかどうか。配列の中身の変化 |
OnPostSerialization() | 送信を試みたこと | 相手が受信したかどうか |
| 書き込みの順序 | 変数は 1 本ずつ書き込まれ、順序は決まっていません。書き込みのたびに FieldChangeCallback が実行され、全部書き終えてから OnDeserialization() が実行されます |
| 配列の中身 | FieldChangeCallback は呼ばれません(配列そのものは同じままだからです)。知らせたいなら、別の同期する変数に世代番号を持たせます |
SerializationResult.success | true でも届いたことにはなりません。公式ドキュメントはこれを「送信を試みた直後」と説明しています |
OnDeserialization() | 所有者では呼ばれないものとして扱われがちですが、公式ドキュメントに記述はなく、所有者で呼ばれた事例が報告されています。所有者かどうかの判定には使えません |
| 到着を確実に知る手段 | ありません。分かるのは「値が来た」ことであって、「いま持っている値が最新である」ことではありません。最新かどうかを判断したいなら、世代番号を同期する変数として持ちます |
よくある失敗
Section titled “よくある失敗”| 症状 | 何が起きているか |
|---|---|
| 自分が代入した値が戻る | 所有者でないまま代入した。エラーは出ない。いつ戻るかは公式ドキュメントに記述が無く、二次の資料も「数フレーム後」と「不明」で割れている |
| 2 人が同時に動かせる | 所有権を排他制御に使っている |
| 掴んだ瞬間に元へ戻る | OnOwnershipTransferred() を書いていない |
| 遅れて届く、届かない | 送れる量の上限に当たっている。手動同期は保持して送り直し、連続同期は捨てられる |
| 順序が入れ替わる | 同期する変数に順序の保証が無い。ネットワークイベントは同期する変数より先に着くことがある |
| 後から入った人に何も無い | ネットワークイベントは送り直されない。同期する変数も届かないという報告がある |
| 入室した瞬間に演出がまとめて鳴る | 入室時の受信で OnDeserialization() が呼ばれ、そこで演出を出している |
| 同じ処理が何度も実行される | OnDeserialization() の呼び出しは、値の変化と 1 対 1 ではない |
| 値は正しいのに見た目が古い | 受信時に見た目を更新していない。同じ処理を送信側と受信側の両方から呼ぶ |
遅れて入ってきた人
Section titled “遅れて入ってきた人”| ネットワークイベント | 途中から入ってきたプレイヤーへ送り直されません。公式ドキュメントが明記していて、この分野では数少ない確実な保証です(イベントで状態を配信する設計は、この 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); }}引数だけで決める可否
Section titled “引数だけで決める可否”要求を断るかどうかは、引数のプレイヤーだけを見て決めます。同期する変数も、自分の手元にしか無い値も読みません。
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); }}状態そのものの送信
Section titled “状態そのものの送信”変化した部分だけを送る形は、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); }}受信前の安全な既定
Section titled “受信前の安全な既定”受け取れていない状態でも危険にならない側を、フィールドの初期値にします。この例では、届いていない間は閉じたままになります。
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; }}通知のあとの読み直し
Section titled “通知のあとの読み直し”ネットワークイベントは、同期する変数より先に着くことがあります。イベントに値を持たせず、受け取った側が同期する変数を読み直す形にすれば、どちらが先に着いても結果が同じになります。
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); }}送信の失敗と送り直し
Section titled “送信の失敗と送り直し”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(); } }}書かないほうがよい形
Section titled “書かないほうがよい形”どれも、コンパイルは通り、エラーも出ません。壊れるとしたら実行中で、しかも常には壊れません。
| 形 | なぜ |
|---|---|
| 所有権を排他制御に使う | 全員が自分を所有者だと思う状態が報告されていて、未解決のまま。通信の遅延が小さいほど起こりやすい |
Start で初期化して、以後それを信じる | Start より前に同期する変数が書き換わるという報告が残っている。ネットワークイベントの受け取りも Start より前に実行されることがある |
Networking.SetOwner() の直後に同期する変数を書く | 即座に反映されるという記述と、反映されないという記述が両方あり、決着していない |
| 同期する変数を書いてから、イベントで知らせる | イベントのほうが先に着くことがあり、受け取った側が読む値が古くなる |
OnDeserialization() に一度きりの処理を書く | 呼ばれない場合と、値が変わっていないのに呼ばれる場合が、どちらも報告されている |
Networking.IsOwner() の結果を保存して再利用する | 通知の無い所有権の変更がある(衝突・持ち上げ) |
同期するオブジェクト自身を SetActive(false) にする | 公式ドキュメントに記述は無いが、非アクティブの間は送受信もコールバックも起きないと複数の解説が書いている。再び有効にしたときの挙動も一定しない |
OnPlayerJoined() の中で RequestSerialization() を呼ぶ | 同期しないという報告が残っている |
| サーバ時刻の引き算で経過時間を出す | 基準点が無く、値が一周することがあると公式ドキュメントが書いている。float へ代入すると刻みが粗くなる |
| 返事が無ければ所有権を奪う | 配送の遅れに上限が無い。どの端末もいつでも一時停止しうると想定して書くように公式ドキュメントが求めていて、その停止が数時間続いた例も報告されている |
OnPlayerLeft() で退出したプレイヤーの識別子を使う | 読めない場合と例外になる場合が報告されている |
| 変化した部分だけを送る | 1 回でも欠落すると、以後ずっとずれたままになる |
| 人数の上限を決め打って詰め込む | 上限を超えることがあるので対処するように、と公式が作者へ向けて書いている |
| 実行中に生成したオブジェクトを同期する | 同期しない |
書いたものが実際にどう変換されるか、同期する変数が何本あるかは、MCP サーバの inspect が返します。