【Unity】Unityのパフォーマンスを劇的に改善するカスタムアップデートマネージャー

作成: 2025-12-07最終更新: 2026-07-13

Update()の呼び出し自体にコストがあることを知っていますか?数千のオブジェクトが動く場面で効く高度な最適化「カスタムアップデートマネージャー」を、Update呼び出しの仕組み(ネイティブ/マネージドのブリッジ)から実装まで解説します。

弾幕・敵の群れ・大量のアイテム——動くオブジェクトが数百を超えたあたりから、ProfilerUpdate.ScriptRunBehaviourUpdateがじわじわ膨らんでいく。各オブジェクトのUpdate()の中身は軽いのに、です。

実は、Update()呼び出されること自体にコストがあります。この記事では、その仕組みと、呼び出しを1箇所に集約する高度な最適化 カスタムアップデートマネージャー(Custom Update Manager) を解説します。

カスタムアップデートマネージャーのイメージ。伝令が一人ひとりに個別に声をかけて回る様子と、メガホンを持ったマネージャーが一度に全員へ伝える様子の対比

この記事でわかること

  • Update()の呼び出しになぜコストがかかるのか(ネイティブ⇄マネージドのブリッジ)
  • 呼び出しを1回に集約するマネージャーパターンの考え方
  • IUpdatable+シングルトンによる実装
  • 適用すべき場面とやりがちな落とし穴
  • 実践:画面外の敵を更新ループから丸ごと外す

Sponsored

なぜUpdate()は遅くなるのか

MonoBehaviourUpdate()は、Unityエンジン本体(C++のネイティブコード)から呼び出されます。C#側のUpdate()を1回呼ぶたびに、ネイティブとマネージド(C#)の世界をつなぐ「橋」を渡って往復する必要があり、この橋の通行料はUpdate()の中身が空でもかかります。

ブリッジ往復の図。UnityエンジンとC#の世界の間に「橋(呼び出しコスト)」がかかっており、伝令がオブジェクト1体につき1往復、1,000体なら1,000往復している

オブジェクトが1,000個あれば、毎フレーム1,000回の橋往復です。しかもUpdate()の冒頭で「今は何もしない」と早期リターンしているオブジェクトも、リターンするためだけに通行料を払っています。1回あたりはごくわずかでも、数が揃うと Profiler にはっきり現れる規模になります。

カスタムアップデートマネージャーの考え方

解決のアイデアはシンプルです。

「UnityからのUpdate()はマネージャー1つだけが受け取り、他の全オブジェクトの更新メソッドはマネージャーがC#内で直接呼ぶ」

カスタムアップデートマネージャーの概念図。橋を渡るのはマネージャーへの1回だけで、そこから先はマネージャーがC#内で全オブジェクトへ直接伝える

橋を渡るのは毎フレームマネージャーの1回だけ。マネージャーから各オブジェクトへの呼び出しはすべてC#内で完結するため、橋の通行料がかかりません。

Sponsored

実装方法

ステップ1: 更新される側の「ルール」を決める

全体の構成は「ルール(インターフェース)・呼ぶ側(マネージャー)・呼ばれる側」の3点セットです。

実装の構成図。IUpdatableインターフェースを実装した各オブジェクトが、OnEnableでUpdateManagerのリストに登録され、UpdateManagerのUpdate()から順にManagedUpdate()が呼ばれる

マネージャーが相手の型を知らなくても呼べるように、まずインターフェースを定義します。

// 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);
    }
}

注意(初期化順): シーンロード中、UpdateManagerAwakeと各オブジェクトのOnEnableの実行順は オブジェクト間では保証されませんライフサイクルの記事 参照)。敵のOnEnableが先に走るとInstanceがまだnullで、登録に失敗します。対策はマネージャーを確実に先へ——Edit > Project Settings > Script Execution OrderUpdateManagerを標準より早い順番(例: -100)に設定しておくのが手軽で確実です。

シーンにUpdateManagerを置いたら完成です。見た目の挙動はUpdate()版と同じですが、ProfilerではUpdate.ScriptRunBehaviourUpdateの呼び出しが1本にまとまっているのが確認できます。

Profilerの前後比較。対策前はScriptRunBehaviourUpdateの下にEnemyMover.Update()が何百本もぶら下がって埋め尽くしているが、対策後はUpdateManager.Update()の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()、本当に毎フレーム全員分必要ですか?