自分の画面ではライ トが点いたのに、隣の人の画面は暗いまま。ローカルで動くスイッチを作った直後に、必ず出会う問題です。
同じインスタンスにいても、実際には各自のPCがそれぞれワールドを動かしています。自分の手元で Light.enabled を変えても、その変更は他の人には届きません。
この記事では、「ライトが点いているか」という1つの値を全員へ伝えて、それぞれの画面でライトを点ける 仕組みを作ります。
この記事でわかること
- 「残る状態」と「その場かぎりの合図」の使い分け
- 所有権(Owner)が「いま値を書ける人」であること
- 送信を頼む処理と、受け取って反映する処理
- 2人で確認する手順と、途中参加の確かめ方
最初のUdonSharp でスクリプトを作れる状態から始めます。
状態は黒板、合図は叫び
VRChatで情報を全員に伝える方法は、大きく2つあります。役割がはっきり違います。

同期変数 は、部屋の黒板です。いまの状態を書いておくと、みんながそれを読めます。あとから入ってきた人も、黒板を見れば現在の状態が分かります。
Network Event は、その場での叫びです。「いま花火を撃て」「いま効果音を鳴らせ」と伝えます。声は残らないので、あとから入ってきた人には届きません。
使い分けは、これだけです。
| 伝えたいもの | 使うもの | 理由 |
|---|---|---|
| ライトが点いているか | 同期変数 | 途中参加の人も現在の状態を知る必要がある |
| 扉が開いているか | 同期変数 | 同上 |
| いま鳴らす短い効果音 | Network Event | 過去の音を鳴らし直す必要はない |
| ゲームの得点 | 同期変数 | 途中から見た人にも今の点数を見せたい |
この記事で作るライトは、黒板のほうです。
もう1つ、先に知っておくことがあります。同期するのは「値」であって、ライトそのものではありません。 送るのは isOn という真偽の値だけで、その値を見てライトを点けるのは、各自のPCの仕事です。

書けるのは、マイクを持っている人だけ
黒板には、誰でも書けるわけではありません。所有権(Owner) を持っている人だけが書けます。

カラオケで考えると分かりやすくなります。
- 曲名を変えられるのは、いま マイクを持っている人 だけ(Owner)
- 他の人は画面の曲名を見られるが、書き換えはできない
- 歌いたい人は、まずマイクを受け取ってからボタンを押す
コードでは、こういう順番になります。
1. マイクを受け取る → Networking.SetOwner(自分, このオブジェクト)
2. 黒板に書く → isOn = !isOn
3. 送ってと頼む → RequestSerialization()
ここで Master という言葉も出てきますが、別のものです。Masterはインスタンス全体の進行役、いわばカラオケ店の店長です。店長であることと、いまマイクを持っていることは別 です。
最初はMasterがOwnerになっていることが多いので、「Masterなら同期できる」と誤解しがちです。でもそれは偶然そうなっているだけで、仕組みではありません。押した人がマイクを受け取る、と書いておけば誰が押しても動きます。
実践:全員で切り替わる照明を作る
押すと全員の画面でライトが切り替わるスイッチを作ります。あとから入った人にも、そのときの点灯状態が伝わります。
1. スイッチとライトを置く
歩けるシーンに2つ置きます。変化を見やすくするため、Directional LightのIntensityを 0.2 くらいまで下げておきます。
| 名前 | 作り方と設定 |
|---|---|
SharedSwitch | Cube。Position (0, 1, 1)、Scale (0.4, 0.4, 0.4) |
RoomLight | Light → Point Light。Position (0, 2, 1)、Range 5、Intensity 2、Mode「Realtime」 |

ライトのModeは必ず Realtime にします。Bakedだと焼き込んだ光が残るので、実行中に切り替えても見た目が変わりません。
2つは親子にせず、別々のオブジェクトとして置きます。
2. コードを書く
Assets/Scripts で「Create → U# Script」を選び、SharedLightSwitch という名前で作ります。
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] // 変わったときだけ送る
public class SharedLightSwitch : UdonSharpBehaviour
{
[SerializeField] private Light targetLight;
[UdonSynced] private bool isOn; // これが「黒板」。全員で共有する値
private void Start()
{
if (targetLight == null)
{
Debug.LogWarning("[SharedLightSwitch] Target Lightが未設定です。");
}
ApplyState(); // 手元の値を、まず見た目へ反映する
}
public override void Interact()
{
if (!Utilities.IsValid(Networking.LocalPlayer)) return;
// 1. マイクを受け取る
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
if (!Networking.IsOwner(gameObject)) return;
// 2. 黒板に書く
isOn = !isOn;
ApplyState();
// 3. 送ってと頼む
RequestSerialization();
}
// 値が届いたときに呼ばれる(押した本人では呼ばれない)
public override void OnDeserialization()
{
ApplyState();
}
// 値を見た目へ反映するだけ。何度呼んでも同じ結果になる
private void ApplyState()
{
if (targetLight != null)
{
targetLight.enabled = isOn;
}
}
}
大事な点が3つあります。
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)] を付けています。Manualは「変わったときだけ送る」方式です。スイッチや扉のように、たまにしか変わらない状態に向いています。
ApplyState() を独立させています。 起動時、押したとき、受信したときの3か所から呼びます。「値を見て、見た目をそろえる」だけの処理なので、何度呼んでも結果が変わりません。ここに isOn = !isOn のような反転を書くと、受信するたびに勝手に切り替わってしまいます。
OnDeserialization() は、押した本人では呼ばれません。 だから押した側でも ApplyState() を明示的に呼んでいます。ここを忘れると「自分の画面だけ変わらない」という妙な状態になります。
3. 割り当てて保存する
SharedSwitch に Udon Behaviour を追加し、Program Source に SharedLightSwitch を指定します。
| 欄 | 指定するもの |
|---|---|
| Target Light | Hierarchyの RoomLight |
| Interaction Text | 照明を切り替える |

Sync Method の欄は表示されないか、Manualになっています。コード側の属性で指定しているためです。
4. ま ず1人で確かめる
ClientSimでPlayして、スイッチを押してみてください。自分の画面でライトが切り替われば、ここまでは正しく書けています。
ただし、これで同期できたとは言えません。 ClientSimに映るもう1人は本物のプレイヤーではないので、送信も受信も本番と同じにはなりません。同期の確認は、次の手順で行います。
2人と途中参加で確かめる
実際のVRChatを2つ起動して確かめます。
- SDKのControl PanelでBuilderを開く
- Number of Clients を
2にする - Force Non-VR を有効にする(デスクトップで確認する場合)
- Build & Test を押す
VRChatが2つ起動して、同じインスタンスに2人入ります。片方をAさん、もう片方をBさんとして、順に確かめてください。
| 順序 | 操作 | 期待する結果 |
|---|---|---|
| 1 | AとBが入室する | 両方とも消灯から始まる |
| 2 | Aだけが1回押す | Aが点灯し、少し遅れてBも点灯する |
| 3 | Bだけが1回押す | Bが所有権を取り、両方とも消灯する |
| 4 | Aが点灯させたあと、Bが入り直す | Bは押していないのに点灯した状態で入る |
4番目が 途中参加 の確認です。同期変数を使っているので、あとから来た人にも最新の値が届きます。Aさんが押し直す必要はありません。
確認するときの注意が2つあります 。送信側の画面だけを見ても、同期できたか分かりません。 操作する画面と、結果を見る画面を分けてください。また、全部のクライアントを閉じてから作り直すと初期状態に戻るので、途中参加の確認になりません。片方を残したまま、もう片方だけ入り直します。
同期でつまずく順番
同期は、上から順に段階を踏んで壊れます。自分がどこで止まっているかを見つけてください。
- 自分の画面も変わらない → 同期ではなく、まだローカルの問題です。Interactが動いているか、Target Lightが入っているか、ライトがRealtimeかを確認します
- 自分だけ変わって、相手に届かない →
Manualの指定があるか、RequestSerialization()を呼んでいるか、そもそも同じインスタンスにいるかを見ます - 押した人には効かないが、相手には効く →
OnDeserialization()にだけ反映処理を書いています。押した側でもApplyState()を呼びます - その場の人には効くが、途中参加の人が暗い → Network Eventだけで伝えていないか確認します。残る状態は同期変数で持ちます
- 相手が押すと元に戻る → 複数のスクリプトが同じライトを書き換えていないかを見ます
[UdonSynced] を付けただけでは送られません。印を付けた箱に値を入れて、送れと頼む。 この2段階だと覚えてください。
おまけ:先に知っておくと良いこと
- 同期できるのは値だけ:
bool、数値、文字列、色、座標などは送れますが、GameObjectやTransformといったシーンの参照は送れません。「どのオブジェクトか」は各自のシーンにあるものを使います - Continuousは位置向け:
Continuousは所有者の値を定期的に送り続ける方式で、動き続ける物の位置に向いています。スイッチのように「変わった瞬間だけ」の用途はManualです - 全部にVRC Object Syncを付けない: 位置を送り続ける部品なので、投げる小物には向きますが、スイッチや椅子には合いません。何を共有したいのかで選びます
- 同時に押されたときの保証はない: 2人がほぼ同時に押すと、両方が同じ値を書く可能性があります。照明なら最終的にそろえば十分ですが、得点の加算のように取りこぼしが困る場合は、所有者に依頼して1か所で処理する設計を考えます
- Ownerが退出しても大丈夫: VRChatが新しい所有者を自動で割り当てます。ただし「誰が選ばれるか」に依存した作りにはしないでください
まとめ
同期は、3つの役割を分けると整理できます。
- 残る状態は同期変数(黒板)、その場かぎりの合図はNetwork Event(叫び)
- 黒板に書けるのは、いま所有権を持っている人だけ。押す人がまず所有権を取る
[UdonSynced]を付けて、値を入れて、RequestSerialization()で送信を頼む- 見た目への反映は
ApplyState()にまとめ、起動時・押したとき・受信時の3か所から呼ぶ
作るときの問いかけは、「これは、あとから来た人にも見えていてほしいか?」 です。イエスなら同期変数、ノーならイベントです。
次は所有権をもっと詳しく見ます。所有権を理解する で、操作する人が切り替わる様子を確かめてください。一瞬の演出を伝えたいなら Network Events へ進みます。