仕掛けを10個ほど作ったあたりから、ワールドが重くなってきました。飾りは増やしていないのに、フレームレートだけが落ちています。
Udonのコードは、見た目はふつうのC#です。ただ、実行のされ方が違います。 翻訳された命令を1つずつ解釈しながら動くので、1行のコストが想像より高くつきます。
だからこそ効くのは、書き方を凝ることではありません。何回呼ぶかを減らすこと です。この記事では、毎フレームの処理をイベントに置き換えます。
この記事でわかること
- 毎フレーム確かめる処理をイベントに置き換える
- 取得した結果を覚えておく効果
- 同期の送信回数を減らす考え方
- 直したあと、測って確かめる方法
メソッドとカスタムイベント と 重さを測る を読んだ状態から始めます。
毎秒60回、同じ質問をしない
重いUdonのコードには、共通の形があります。
private void Update()
{
// 近づいたか? を毎フレーム確かめる
float d = Vector3.Distance(player.GetPosition(), transform.position);
doorAnimator.SetBool("IsOpen", d < 3f);
}
書いてあることは正しいのですが、1秒に60回、同じ質問をしています。 実際に答えが変わるのは、人が近づいた瞬間と離れた瞬間の2回だけです。

置き換え先は、たいていすでに用意されています。
| 毎フレーム確かめていること | 置き換えるイベント |
|---|---|
| 範囲に人がいるか | OnPlayerTriggerEnter / OnPlayerTriggerExit |
| 掴まれているか | OnPickup / OnDrop |
| ボタンが押されたか | Interact |
| 同期の値が変わったか | OnDeserialization |
| 誰かが入ってきたか | OnPlayerJoined / OnPlayerLeft |
「誰かが触った」「誰かが入った」「値が届いた」。 この3種類で、ワールドのUpdateの大半は消せます。
もう1つ、「いまは待って、あとで1回だけ」という処理もあります。これはUpdateで数えるのではなく、予約で書きます。
// 3秒後に1回だけ閉める
SendCustomEventDelayedSeconds(nameof(CloseDoor), 3f);
Updateで経過時間を足していくより短く、負荷もかかりません。詳しくは 時間差で処理を呼ぶ を参照してください。
Updateを使ってよい場面もあります。 回り続ける看板、揺れ続ける水面のように、毎フレーム本当に変わり続けるものです。避けたいのは「変わったかどうかを確かめる」使い方だけです。
同じものを何度も探さない
もう1つ、Udonで特に高くつくものがあります。探す処理 です。
private void Update()
{
// 毎フレーム探している
GetComponent<AudioSource>().volume = 0.5f;
}
GetComponent は、そのオブジェクトの部品を1つずつ見て回る処理です。ネイティブのC#でも避けたいところですが、Udonでは特に高くつきます。

答えは単純で、Start() で1回取って、フィールドに覚えておきます。
private AudioSource cachedAudio;
private void Start()
{
cachedAudio = GetComponent<AudioSource>(); // 1回だけ
}
覚えておきたいのは、この3つです。
| 高くつくもの | どうするか |
|---|---|
GetComponent | Start() で取って覚える |
GameObject.Find 系 | 使わない。インスペクタで割り当てる |
| 文字列をつなげる処理 | 表示が変わるときだけ作る |
インスペクタで割り当てられるものは、コードで探さない。 これが基本の姿勢です。
実践:距離を測るドアを、イベントに置き換える
毎フレーム距離を測る自動ドアを作って、測って、イベントに置き換えて、また測ります。

1. 重い形で作る
AnimatorとUdonで扉を動かす と同じ扉を用意します。DoorPivot にAnimatorが付いていて、IsOpen というBoolのパラメータがある状態です。
Assets/Scripts で PollingDoor を作ります。
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
// 重い書き方。あとで置き換える
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class PollingDoor : UdonSharpBehaviour
{
[SerializeField] private Animator doorAnimator;
[SerializeField] private float openDistance = 3f;
private void Update()
{
VRCPlayerApi player = Networking.LocalPlayer;
if (!Utilities.IsValid(player)) return;
float d = Vector3.Distance(player.GetPosition(), transform.position);
doorAnimator.SetBool("IsOpen", d < openDistance);
}
}
DoorPivot に付けて、Door Animatorに自分自身を割り当てます。
Playすると、ちゃんと動きます。 近づけば開き、離れれば閉じます。動くからこそ、そのままにしてしまいがちな形です。
2. 測る
この扉を 20個 複製します。DoorPivot を選んで Ctrl + D を19回押し、位置を少しずつずらします。
Profilerを開いてPlayし、Udon関連の帯の太さを控えます。20個分のUpdateが毎フレーム回っています。
3. イベントに置き換える
DoorPivot に「3D Object → Cube」で子オブジェクト DoorSensor を作り、次のようにします。
| 項目 | 設定 |
|---|---|
| Position | (0.8, 0, 0)(扉の前あたり) |
| Scale | (3, 2, 3) |
| Box Collider の Is Trigger | オン |
| Mesh Renderer | オフ(見えなくする) |
Assets/Scripts で SensorDoor を作ります。
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class SensorDoor : UdonSharpBehaviour
{
[SerializeField] private Animator doorAnimator;
// 入った瞬間だけ呼ばれる
public override void OnPlayerTriggerEnter(VRCPlayerApi player)
{
if (!player.isLocal) return;
doorAnimator.SetBool("IsOpen", true);
}
// 出た瞬間だけ呼ばれる
public override void OnPlayerTriggerExit(VRCPlayerApi player)
{
if (!player.isLocal) return;
doorAnimator.SetBool("IsOpen", false);
}
}
Update() が消えました。 呼ばれるのは、人が入った瞬間と出た瞬間だけです。
DoorSensor にこのスクリプトを付けて、Door Animatorに親の DoorPivot を割り当てます。PollingDoor は外します。20個すべてで同じ入れ替えをします。
4. 測り直す
もう一度Profilerを見ます。
| 項目 | 置き換え前 | 置き換え後 |
|---|---|---|
| Udonの帯の太さ | ||
| 毎フレーム動く扉の数 | 20 | 0 |
動きはまったく同じです。 近づけば開き、離れれば閉じます。それでいて、誰も近づいていないあいだの処理はゼロになりました。
これが、この 記事でいちばん伝えたい形です。機能を削ったのではなく、聞く回数を減らしただけです。
同期は、送る回数を減らす
Udonの処理と並んで効いてくるのが、同期の送信です。人数が増えるほど響きます。

考え方は3つです。
| 方針 | 具体的にすること |
|---|---|
| 変わったときだけ送る | 同期モードを Manual にして、RequestSerialization() を必要なときだけ呼ぶ |
| 送る値を減らす | 見た目そのものではなく、元になる値を1つ送って、各自が組み立てる |
| まとめて送る | 変数を何度も書き換えたあと、最後に1回だけ送信を頼む |
2つ目は、これまでの記事でずっとやってきた形です。扉のときも「開いているか」の真偽1つだけを送って、各自がAnimatorに反映しました。軽さのためだけでなく、途中参加にも強い書き方でした。
Continuous は、値が変わっていなくても送り続けます。位置を送り続ける必要があるものだけに使い、それ以外は Manual にしてください。
うまくいかないときは
- イベントが呼ばれない → コライダーの Is Trigger がオフか、範囲が小さすぎます
- 他の人が近づくと自分の扉も開く →
player.isLocalの判定が抜けています - 置き換えたのに軽くならない → 他にUpdateを使っている処理が残っています。すべてのスクリプトを確認します
- 人が増えると重い → 処理ではなく同期です。同期モードと送信の回数を見ます
- どこが重いか分からない → 重さを測る の手順で先に測ってください
おまけ:先に知っておくと良いこと
- 数字は覚えなくてよい: 「Udonは何倍遅い」という話をよく見かけますが、環境で変わります。覚えるのは「Updateで回すほど損」という感覚だけで足ります
- どうしてもUpdateが要るなら間引く: 数フレームに1回だけ処理する書き方もあります。ただ、その前に「本当に毎フレーム確かめる必要があるか」をもう一度考えてください
- 早すぎる最適化はしない: 仕掛けが3つのワールドで悩む話ではありません。測って重かったところだけ直します
- 見えていない物のUpdateも動く: 部屋の隅に置いた仕掛けも、画面に映っていなければ描画は止まりますが、Udonは動き続けます。使わないときはオブジェクトごと無効にするのが確実です
- 使いど ころはいろいろ: 自動ドア、近づくと点く照明、範囲に入ると流れるBGM、視線を追う看板。どれも「変わる瞬間」がはっきりしているので、イベントに置き換えられます
まとめ
Udonを軽くするのは、回数の引き算です。
- 毎フレーム確かめる処理を、変化の瞬間のイベントに置き換える
GetComponentはStart()で1回。探す処理はインスペクタの割り当てに寄せる- 同期は
Manualにして、変わったときだけ送る - 直したあと、測って確かめる
書く前の問いかけは、「この質問の答えは、いつ変わるか」 です。変わる瞬間が言えるなら、そこにイベントがあります。