来場者にボールを配りたい。押すたびに1個出てきて、遊び終わったら棚に戻る。
Unityの感覚なら Instantiate で作りたくなるところですが、VRChatでは その場で作った物は全員には見えません。 同期して共有したい物は、あらかじめシーンに置いておく必要があります。
この記事では、3個のボールを貸し出して、返してもらう棚 を作ります。
この記事でわかること
- 増やすのではなく、隠してある物を出すという考え方
- 出し入れできるのは所有者1人だけであること
- 在庫切れの伝え方
- 返すときに位置を戻す理由
所有権を理解する と Network Events を試した状態から始めます。
増やすのではなく、隠してある物を出す
VRCObjectPool は、あらかじめ用意しておいた物を 有効と無効で切り替える 部品です。新しく作っているわけではありません。

倉庫にボールが3個あると考えてください。棚から出す、棚に戻す。4個目は存在しません。
操作は2つだけです。
| メソッド | すること |
|---|---|
TryToSpawn() | まだ出ていない物を1つ有効にして返す。空きがなければ null |
Return(対象) | その物を無効に戻す。次の TryToSpawn() でまた使える |
この仕組みには、ありがたい副作用が3つあります。
- 数が勝手に増えない: 「3個まで」が仕組みとして守られます。来場者が押し続けても、部屋がボールで埋まりません
- 重くならない: 作っては消すを繰り返すと、そのたびに一瞬引っかかります。使い回せば起きません
- 途中参加に強い: いま何が出ているかはVRChatが同期してくれるので、あとから入った人にも同じ景色が見えます
SetActive を自分で呼びたくなりますが、それはしません。 自分で切り替えると、プールが把握している状態と食い違って、人によって見え方が変わります。出し入れはプールに任せます。
出し入れするのは、在庫係1人
ここで所有権の話が効いてきます。
TryToSpawn() と Return() が実際に効くのは、プールの所有者だけ です。所有者以外が呼ぶと null が返って、何も起きません。エラーも出ません。

つまり、こういう役割分担になります。
ボタンを押した人が出すのではなく、部屋の在庫係が出す。
来場者は「出してください」と頼むだけです。頼み方は Network Events で使った NetworkEventTarget.Owner そのものです。
// 押した人がやること:所有者に依頼を出す
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(LendBall));
なぜ「押した人が所有権を取ってから出す」ではないのか。2人が同時に押したときに、4個目が生まれないため です。窓口が1つなら、依頼は順番に処理されます。
もう1つ注意があります。プールの所有者と、ボールの所有者は別です。 在庫係は棚の担当で、出たボールを持って走っている人はそのボールの担当です。役割が違います。
実践:3個のボール貸出機を作る
押すとボールが1個出て、投げて遊べて、返却ボタンで全部棚に戻る仕掛けを作ります。
1. ボールと棚を置く
床のあるシーンに、次を置きます。
| 名前 | 作り方と設定 |
|---|---|
PoolRoot | Create Empty。(0, 0, 0) |
Ball1 Ball2 Ball3 | Sphere。PoolRoot の子。(0, 1.2, 2) (0.3, 1.2, 2) (0.6, 1.2, 2)、Scale (0.2, 0.2, 0.2) |
LendButton | Cube。(-1, 1, 1)、Scale (0.4, 0.4, 0.4) |
ReturnButton | Cube。(-1, 0.5, 1)、Scale (0.4, 0.4, 0.4) |
StockCanvas | UI Canvas(World Space)。(0, 1.8, 2)、Scale (0.005, 0.005, 0.005) |
StockCanvas の子に TextMeshPro を作り、StockText と名付けます。残りの数をここに出します。

3つのボールには、それぞれ次を追加します。
- VRC Pickup(Rigidbodyも一緒に付きます)
- VRC Object Sync
投げて遊べて、位置が共有される状態です。VRC Object Sync と同じ構成です。
3つとも、Inspectorの左上のチェックを外して 無効にしておきます。 出すのはプールの役目です。
PoolRoot に「Add Component」で VRC Object Pool を追加し、Pool配列のSizeを 3 にして、Ball1 から Ball3 をドラッグします。

2. コードを書く
Assets/Scripts で「Create → U# Script」を選び、BallDispenser を作ります。
using UdonSharp;
using UnityEngine;
using TMPro;
using VRC.SDK3.Components;
using VRC.SDKBase;
using VRC.Udon.Common.Interfaces;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class BallDispenser : UdonSharpBehaviour
{
[SerializeField] private VRCObjectPool pool;
[SerializeField] private TextMeshProUGUI stockText;
[UdonSynced] private int remaining;
private void Start()
{
if (Networking.IsOwner(gameObject) && pool != null)
{
remaining = pool.Pool.Length;
}
ApplyState();
}
// 貸出ボタンから呼ぶ。誰が押しても動く
public void RequestLend()
{
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(LendBall));
}
// 返却ボタンから呼ぶ
public void RequestReturnAll()
{
SendCustomNetworkEvent(NetworkEventTarget.Owner, nameof(ReturnAll));
}
// ここから先は、在庫係(プールの所有者)だけが実行する
[NetworkCallable]
public void LendBall()
{
if (!Networking.IsOwner(gameObject)) return;
GameObject ball = pool.TryToSpawn();
if (ball == null)
{
Debug.Log("[BallDispenser] 在庫がありません");
return;
}
remaining--;
ApplyState();
RequestSerialization();
}
[NetworkCallable]
public void ReturnAll()
{
if (!Networking.IsOwner(gameObject)) return;
GameObject[] balls = pool.Pool;
for (int i = 0; i < balls.Length; i++)
{
GameObject ball = balls[i];
if (ball == null || !ball.activeSelf) continue;
// 位置を棚へ戻してから、しまう
VRCObjectSync sync = ball.GetComponent<VRCObjectSync>();
if (sync != null) sync.Respawn();
pool.Return(ball);
}
remaining = balls.Length;
ApplyState();
RequestSerialization();
}
public override void OnDeserialization()
{
ApplyState();
}
private void ApplyState()
{
if (stockText != null) stockText.text = "残り " + remaining + " 個";
}
}
見どころは3つあります。
TryToSpawn() の戻り値を必ず見ます。 null は「在庫切れ」の合図です。ここを見ないと、出ていないのに残り数だけ減っていきます。
返す前に Respawn() を呼んでいます。 Return() は非表示にするだけで、位置は投げられた場所のままです。戻さないと、次に出したときに部屋の隅や床下から現れます。
残り数は同期変数で持っています。 プール自体の状態はVRChatが同期してくれますが、「残り何個」という表示は自分で用意します。ネットワーク同期入門 と同じ形です。
3. ボタンをつなぐ
PoolRoot に Udon Behaviour を追加し、BallDispenser を指定します。Poolに PoolRoot 自身を、Stock Textに StockText をドラッグします。

2つのボタンには、短い中継のスクリプトを付けます。
using UdonSharp;
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class DispenserButton : UdonSharpBehaviour
{
[SerializeField] private BallDispenser dispenser;
[SerializeField] private bool returnMode; // true なら返却
public override void Interact()
{
if (dispenser == null) return;
if (returnMode) dispenser.RequestReturnAll();
else dispenser.RequestLend();
}
}
LendButton は Return Mode をオフ、ReturnButton はオンにします。Interaction Text も ボールを借りる 全部返す にしておきます。
4. 2人で確かめる
Build & Test で Number of Clients を 2 にして起動します。
| 順序 | 操作 | 期待する結果 |
|---|---|---|
| 1 | AとBが入る | 掲示に「残り 3 個」。ボールは見えない |
| 2 | Aが貸出ボタンを押す | 両方の画面でボールが1個現れ、「残り 2 個」になる |
| 3 | Aがボールを掴んで投げる | Bの画面でも同じように飛ぶ |
| 4 | さらに3回押す | 3個目まで出たあと、4回目は何も起きない。「残り 0 個」 |
| 5 | 返却ボタンを押す | 3個とも消えて、「残り 3 個」に戻る |
| 6 | Bが入り直す | いま出ている数がそのまま見える |
4番目が、この記事でいちばん大事な確認です。 在庫がなくなると、押しても静かに何も起きません。エラーは出ません。
実際のワールドでは、ここで「いま貸出中です」と掲示に出すか、ボタン自体を押せなくします。押しても反応がない状態を、そのまま残さない のが親切です。
うまくいかないときは
- 押しても出ない → 所有者以外が
TryToSpawn()を直接呼んでいます。NetworkEventTarget.Ownerで依頼する形にします - 自分だけ出る → ボールを自分で
SetActiveしています。出し入れはプールに任せます - 人によって出ている数が違う → 同じく、プールの外で有効・無効を触っています
- 返したあと、変な場所から出てくる →
Return()の前にRespawn()を呼んでいません - 残り数が合わない →
TryToSpawn()がnullを返したときにも減らしています - 依頼が届かない →
[NetworkCallable]が付いているか、メソッドがpublicか確認します
おまけ:先に知っておくと良いこと
- 数は最初に決まる: あとから増やせません。想定人数から「同時に何個まで出るか」を決めて、Pool配列に並べておきます
- 出す物の中身もリセットする: 位置だけでなく、点灯や色を変えていたら、それも初期状態に戻します。前の人の状態が残ったまま次の人に渡ると、気持ちが悪いものです
- 在庫係は退出しても大丈夫: 所有者が抜けると、VRChatが別の人に割り当てます。残った誰かが在庫係を引き継ぐので、貸出は止まりません
- 手に直接渡すのは一段むずかしい: 「押した人の手に出す」には、誰が頼んだかを所有者へ伝 える必要があります。棚に出して自分で拾ってもらうほうが、ずっと簡単で確実です
- 使いどころはいろいろ: 貸し出しの道具、打ち上げる花火、シューティングの弾、イベントの名札。どれも「同時に出る数が決まっている物」です
まとめ
VRCObjectPoolは、用意しておいた物を出し入れする棚です。
- 実行中に増やすのではなく、あらかじめ置いた物を有効・無効で切り替える
- 出し入れが効くのは所有者だけ。押した人は所有者へ依頼する
TryToSpawn()がnullを返したら在庫切れ。その場合の見せ方まで作る- 返す前に位置を戻す。
Return()は非表示にするだけ
使う前の問いかけは、「これは、同時に何個まで出ればいいか」 です。数が決まるなら、プールの出番です。
各自の設定を次回まで残すなら PlayerDataで音量を保存する、動画を流す仕掛けなら 動画プレイヤーを設置する へ進んでください。