弾幕・敵の群れ・大量のアイテム——動くオブジェクトが数百を超えたあたりから、Profiler のUpdate.ScriptRunBehaviourUpdateがじわじわ膨らんでいく。各オブジェクトのUpdate()の中身は軽いのに、です。
実は、Update()は呼び出されること自体にコストがあります。この記事では、その仕組みと、呼び出しを1箇所に集約する高度な最適化 カスタムアップデートマネージャー(Custom Update Manager) を解説します。
この記事でわかること
Update()の呼び出しになぜコストがかかるのか(ネイティブ⇄マネージドのブリッジ)- 呼び出しを1回に集約するマネージャーパターンの考え方
- IUpdatable+シングルトンによる実装
- 適用すべき場面とやりがちな落とし穴
- 実践:画面外の敵を更新ルー プから丸ごと外す
なぜUpdate()は遅くなるのか
MonoBehaviourのUpdate()は、Unityエンジン本体(C++のネイティブコード)から呼び出されます。C#側のUpdate()を1回呼ぶたびに、ネイティブとマネージド(C#)の世界をつなぐ「橋」を渡って往復する必要があり、この橋の通行料はUpdate()の中身が空でもかかります。

オブジェクトが1,000個あれば、毎フレーム1,000回の橋往復です。しかもUpdate()の冒頭で「今は何もしない」と早期リターンしているオブジェクトも、リターンするためだけに通行料を払っています。1回あたりはごくわずかでも、数が揃うと Profiler にはっきり現れる規模になります。
カスタムアップデートマネージャーの考え方
解決のアイデアはシンプルです。
「UnityからのUpdate()はマネージャー1つだけが受け取り、他の全オブジェクトの更新メソッドはマネージャーがC#内で直接呼ぶ」

橋を渡るのは毎フレームマネージャーの1回だけ。マネージャーから各オブジェクトへの呼び出しはすべてC#内で完結するため、橋の通行料がかかりません。
実装方法
ステップ1: 更 新される側の「ルール」を決める
全体の構成は「ルール(インターフェース)・呼ぶ側(マネージャー)・呼ばれる側」の3点セットです。

マネージャーが相手の型を知らなくても呼べるように、まずインターフェースを定義します。
// IUpdatable.cs
public interface IUpdatable
{
void ManagedUpdate(float deltaTime);
}
ステップ2: マネージャーを作る
シーンに1つだけ置く シングルトン のマネージャーです。UnityのUpdate()を受け取り、登録済みオブジェクトのManagedUpdate()を順に呼びます。
// UpdateManager.cs
using System.Collections.Generic;
using UnityEngine;
public class UpdateManager : MonoBehaviour
{
public static UpdateManager Instance { get; private set; }
private readonly List<IUpdatable> updatables = new List<IUpdatable>();
void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
}
void Update()
{
float dt = Time.deltaTime;
// 橋を渡るのはこのUpdate()の1回だけ。ここから先はC#内で完結する
for (int i = 0; i < updatables.Count; i++)
{
updatables[i].ManagedUpdate(dt);
}
}
public void Register(IUpdatable updatable)
{
if (!updatables.Contains(updatable))
{
updatables.Add(updatable);
}
}
public void Unregister(IUpdatable updatable)
{
updatables.Remove(updatable);
}
}
ステップ3: 更新される側を実装する
IUpdatableを実装し、OnEnableで登録・OnDisableで解除します。このクラスにUpdate()はもう書きません。
// EnemyMover.cs
using UnityEngine;
public class EnemyMover : MonoBehaviour, IUpdatable
{
public float speed = 2f;
void OnEnable()
{
UpdateManager.Instance.Register(this);
}
void OnDisable()
{
// シーン終了時はマネージャーが先に消えていることがあるためnullチェック
if (UpdateManager.Instance != null)
{
UpdateManager.Instance.Unregister(this);
}
}
// Update()の代わりにマネージャーから呼ばれる
public void ManagedUpdate(float deltaTime)
{
transform.Translate(Vector3.forward * speed * deltaTime);
}
}
注意(初期化順): シーンロード中、
UpdateManagerのAwakeと各オブジェクトのOnEnableの実行順は オブジェクト間では保証されません(ライフサイクルの記事 参照)。敵のOnEnableが先に走るとInstanceがまだnullで、登録に失敗します。対策はマネージャーを確実に先へ——Edit > Project Settings > Script Execution OrderでUpdateManagerを標準より早い順番(例:-100)に設定しておくのが手軽で確実です。
シーンにUpdateManagerを置いたら完成です。見た目の挙動はUpdate()版と同じですが、ProfilerではUpdate.ScriptRunBehaviourUpdateの呼び出しが1本にまとまっているのが確認できます。

見た目が同じで数字だけが軽くなる——この「効果を数字で確認する」ところまでが最適化の1セットです(計測の作法は Profilerの使い方 参照)。
メリットと注意点
- メリット: 橋の往復が毎フレーム1回になる。さらに
Register/Unregisterで更新のオン・オフをリスト操作で制御できるため、「今は動かない敵」を更新ループから丸ごと外せます(早期リターンのif文より根本的です)。 - 注意点(適用範囲): プロジェクト全体を書き換える必要はありません。大量に存在するオブジェクト(弾・敵の群れ・エフェクト)に限定して使うのが費用対効果の正解です。
- 注意点(実行順):
ManagedUpdateの実行順はリストへの登録順です。順序に依存する処理がある場合は、Script Execution Orderではなくマネージャー側で制御することになります。
判断基準は1行です。 「同種のオブジェクトが数百体を超え、Profilerで呼び出しコストが本当に原因だと確認できたときだけ導入する」。数十体の規模では、登録・解除の管理が増える複雑さのほうが上回り、むしろ逆効果です。それまでは普通のUpdate()で十分です。
実践:大群の敵を「必要なときだけ」動かす
サバイバー系で画面を埋め尽くす敵の大群、RTSで待機中のユニット、オープンワールドで遠くの村を歩くNPC——大量オブジェクトのゲームでは、「今この瞬間、実は動かなくていい個体」が大半を占めます。マネージャーパターンの真価は、橋の通行料の節約だけでなく、この「動かなくていい個体」をリスト操作で更新ループから丸ごと外せることにあります。
EnemyMoverを少し拡張して、画面に映っている間だけ更新される敵を作ってみます。

// SwarmEnemy.cs
using UnityEngine;
public class SwarmEnemy : MonoBehaviour, IUpdatable
{
public float speed = 2f;
private Transform target; // プレイヤーなど
void Start()
{
target = GameObject.FindGameObjectWithTag("Player").transform;
}
// 画面に映った瞬間、更新リストに入る
void OnBecameVisible()
{
UpdateManager.Instance.Register(this);
}
// 画面から消えた瞬間、更新リストから外れる
void OnBecameInvisible()
{
if (UpdateManager.Instance != null)
{
UpdateManager.Instance.Unregister(this);
}
}
public void ManagedUpdate(float deltaTime)
{
// プレイヤーへ向かって進む(映っている敵だけがこれを実行する)
Vector3 dir = (target.position - transform.position).normalized;
transform.Translate(dir * speed * deltaTime, Space.World);
}
}
OnBecameVisible/OnBecameInvisibleはレンダラーがカメラに映った/消えたタイミングでUnityが呼んでくれるイベント関数です。敵が2,000体いても、画面内の200体だけがManagedUpdateを実行し、残り1,800体はリストにすら載っていないので完全にゼロコスト——Update()の早期リターン方式では絶対に届かない水準です。
ポイントは2つです。 「更新のオン・オフをif文ではなくリストの出入りで表現する」 こと(判定処理そのものが消えます)と、 「外すタイミングはゲームのルールで決める」 こと。画面外のほかにも「死亡演出中」「スポーン待機中」「ポーズメニュー表示中」など、あなたのゲームの「動かなくていい瞬間」がそのままUnregisterのタイミングになります。大量の敵の生成側は オブジェクトプーリング と組み合わせるのが定番です。
おまけ:先に知っておくと良いこと
- 空のUpdate()は消す: 中身のない
Update() {}が残っているだけで橋の通行料が発生します。使っていないイベント関数はメソッドごと削除するのが基本です。 - ループ中のUnregisterに注意:
ManagedUpdate()の中からUnregister()を呼ぶと、走査中のリストを書き換えてしまい要素のスキップが起きます。「削除予約リストに積んでループ後に処理する」か、逆順ループにするのが定石です。 - FixedUpdate/LateUpdate版: 同じ構造で
ManagedFixedUpdate用・ManagedLateUpdate用のマネージャーも作れます。実 行タイミングの違いは UpdateとFixedUpdateの使い分け を参照してください。 - さらに先の世界: 数万体規模になると、C# Job SystemやECS(Entities)といった「MonoBehaviourの外」の仕組みが視野に入ります。カスタムアップデートマネージャーはその手前の、MonoBehaviourのまま戦える最終ラインです。
まとめ
Update()は呼ばれるだけでコストがかかる(ネイティブ⇄C#の橋の通行料)。- カスタムアップデートマネージャーは橋の往復を毎フレーム1回に集約するパターン。
- 実装はインターフェース+シングルトン+リストの3点セット。
- 真価はリスト操作による更新のオン・オフ。「動かなくていい個体」を丸ごと外せる。
- 適用は大量オブジェクトに限定。全部を書き換える必要はない。
Update()の裏側の仕組みを知っていること自体が、パフォーマンスチューニングの引き出しを増やしてくれます。あなたのゲームで一番数が多い「動くもの」——そのUpdate()、本当に毎フレーム全員分必要ですか?