ゲーム内で動く大量のキャラクター、飛び交う弾丸、回転するアイテムなど、数千個のオブジェクトが動いていると、それぞれのスクリプトに書かれた void Update() が原因でゲームが著しく重くなることがあります。
面白いことに、たとえ Update の中身が完全に空(何もしない)であっても、数千個の Update が存在するだけでフレームレートが激しく低下します。この原因は、Unityが持つ「Managed-to-Native(マネージド - ネイティブ)オーバーヘッド」と呼ばれる仕組みにあります。
本記事では、このオーバーヘッドが発生する仕組みをプロファイラーを通じて解き明かし、1つの親マネージャからC#ループで一括して更新処理を呼び出す「一括アップデート(Centralized Update Pattern)」の実装手順と、使い勝手を損なわないための「自動登録システム」について解説します。
1. 根本原因:C++とC#の境界線「Managed-to-Native」の重い壁
なぜ個別の Update は重いのでしょうか?
Unityのエンジンコア(物理演算、描画、ゲームオブジェクト管理など)は、高速なC++(ネイティブコード)で書かれています。一方で、私たちが記述するゲームロジックはC#(マネージドコード)で動作します。
Unityでゲームオブジェクトがアクティブになると、内部で「このオブジェクトは Update メソッドを持っているか」が検出され、リストに登録されます。そして毎フレーム、ネイティブ側(C++)からマネージド側(C#)へ、それぞれのオブジェクトの Update が呼び出されます。
[Unity Native (C++) Engine] ──(境界線を跨ぐ重い処理)──> [MonoBehaviour (C#) Update()]
図:ネイティブエンジンから個々のMonoBehaviourへ毎フレーム行われる高コストな境界横断呼び出し
この「ネイティブからマネージドの関数を呼び出す(P/Invokeに類する処理)」境界線の往復には、セキュリティチェックや状態の同期といった様々なオーバーヘッドがかかります。
通常のC#内でのメソッド呼び出し(例: methodA() から methodB() を呼ぶ)は極めて高速で、ナノ秒単位で完了します。しかし、Managed-to-Nativeの境界線を跨ぐ呼び出しは、その数倍から数十倍のコストがかかります。
オブジェクトが100個程度であれば無視できますが、1,000個、5,000個と増えていくと、この「境界線を跨ぐオーバーヘッド」だけでミリ秒単位のCPU時間を消費し、ゲーム全体のボトルネックになってしまいます。
2. 解決策:一括アップデート(Centralized Update Pattern)
この問題を解消するための設計パターンが「Centralized Update Pattern(一括アップデート)」です。
考え方は非常にシンプルです:
- 各ゲームオブジェクトから
void Update()メソッドを削除します。 - 代わりに、更新用のインターフェース(例:
IUpdateable)を実装させます。 - シーンに1つだけ
UpdateManagerというMonoBehaviourを配置します。 UpdateManagerが唯一のUpdate()を持ち、そこから登録されたすべてのIUpdateableオブジェクトの更新メソッドをC#のforループで呼び出します。
[Unity Native (C++) Engine]
│
(1回だけの呼び出し)
▼
[UpdateManager (C#)]
│
(高速なC#ループ処理)
├─> [Enemy 1 (IUpdateable.OnUpdate)]
├─> [Enemy 2 (IUpdateable.OnUpdate)]
└─> [Projectile (IUpdateable.OnUpdate)]
図:境界横断をフレームあたり1回に抑え、C#のループで効率よく更新処理を回す構成
このアプローチにより、Managed-to-Nativeの境界線を跨ぐ回数が「毎フレームオブジェクトの数だけ」から「毎フレーム1回だけ」に劇的に削減されます。
3. 実装手順と具体的なソースコード
それでは、実用的かつ安全な UpdateManager の実装コードを作成してみましょう。
ポイントは、更新処理(OnUpdate)の実行中に、オブジェクトが破壊されたり新規生成されたりして「リストが走査中に変更される」バグを正しく回避することです。
1. インターフェースの定義 (IUpdateable.cs)
まず、一括更新の対象となるオブジェクトが実装するインターフェースを定義します。
public interface IUpdateable
{
void OnUpdate(float deltaTime);
}
2. マネージャクラスの実装 (UpdateManager.cs)
次に、一括アップデートを制御するシングルトンマネージャです。走査中のリスト変更(Concurrent Modification)を防ぐため、追加・削除のリクエストを一時バッファに溜め、フレームの開始時または終了時に一括でリストに反映する設計にしています。
using System.Collections.Generic;
using UnityEngine;
public class UpdateManager : MonoBehaviour
{
private static UpdateManager _instance;
public static UpdateManager Instance
{
get
{
if (_instance == null)
{
var go = new GameObject("UpdateManager");
_instance = go.AddComponent<UpdateManager>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
private readonly List<IUpdateable> _updateables = new List<IUpdateable>();
private readonly List<IUpdateable> _toAdd = new List<IUpdateable>();
private readonly List<IUpdateable> _toRemove = new List<IUpdateable>();
private bool _isUpdating;
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
}
// 更新対象に登録
public void Register(IUpdateable updateable)
{
if (updateable == null) return;
if (_isUpdating)
{
if (!_toAdd.Contains(updateable) && !_updateables.Contains(updateable))
{
_toAdd.Add(updateable);
_toRemove.Remove(updateable);
}
}
else
{
if (!_updateables.Contains(updateable))
{
_updateables.Add(updateable);
}
}
}
// 登録を解除
public void Unregister(IUpdateable updateable)
{
if (updateable == null) return;
if (_isUpdating)
{
if (!_toRemove.Contains(updateable))
{
_toRemove.Add(updateable);
_toAdd.Remove(updateable);
}
}
else
{
_updateables.Remove(updateable);
}
}
private void Update()
{
_isUpdating = true;
float deltaTime = Time.deltaTime;
int count = _updateables.Count;
// ガベージコレクション(GC Alloc)の発生を防ぐため、単純なforループで走査
for (int i = 0; i < count; i++)
{
// 走査中にDestroyされる等でnullになる場合の安全対策
if (_updateables[i] != null)
{
_updateables[i].OnUpdate(deltaTime);
}
}
_isUpdating = false;
// 走査完了後に、保留されていた追加・削除リクエストを処理
ProcessPendingRequests();
}
private void ProcessPendingRequests()
{
if (_toRemove.Count > 0)
{
for (int i = 0; i < _toRemove.Count; i++)
{
_updateables.Remove(_toRemove[i]);
}
_toRemove.Clear();
}
if (_toAdd.Count > 0)
{
for (int i = 0; i < _toAdd.Count; i++)
{
if (!_updateables.Contains(_toAdd[i]))
{
_updateables.Add(_toAdd[i]);
}
}
_toAdd.Clear();
}
}
}
3. 自動登録システムの構築 (ManagedMonoBehaviour.cs)
各クラスで毎回登録・解除のコードを書くのは手間です。そこで、インフラ部分を隠蔽した基底クラス ManagedMonoBehaviour を用意します。
using UnityEngine;
public abstract class ManagedMonoBehaviour : MonoBehaviour, IUpdateable
{
protected virtual void OnEnable()
{
// アクティブになったら自動で登録
UpdateManager.Instance.Register(this);
}
protected virtual void OnDisable()
{
// 非アクティブ・破棄されたら自動で解除
if (UpdateManager.Instance != null)
{
UpdateManager.Instance.Unregister(this);
}
}
// 派生クラスでUpdateの代わりにオーバーライドする
public abstract void OnUpdate(float deltaTime);
}
4. 具現化クラスでの実装例
この基底クラスを継承するだけで、自動的に一括アップデートの仕組みに組み込まれます。
using UnityEngine;
public class Projectile : ManagedMonoBehaviour
{
[SerializeField] private float speed = 10f;
private Vector3 _direction = Vector3.forward;
public override void OnUpdate(float deltaTime)
{
// 毎フレームの移動処理
transform.Translate(_direction * (speed * deltaTime));
}
}
4. パフォーマンス測定:どれほど効果があるのか?
通常方式(MonoBehaviourの個別Update)と一括アップデート方式(UpdateManager)で、オブジェクト数を増やした時の動作比較を行いました(対象:空の更新処理)。
- テスト環境: PC (Core i7-12700 / Windows 11 / Unity 2022.3 LTS)
- 要素数: 10,000個
| 更新方式 | Profilerの表示箇所 (CPU時間) | GC Alloc |
|---|---|---|
| 通常方式 (個別Update × 10,000) | BehaviourUpdate: 8.2ms |
0 B |
| 一括アップデート方式 | UpdateManager.Update: 1.4ms |
0 B |
結果: 10,000個ものオブジェクトが存在する場合、処理の中身が全く同じであっても、一括アップデート方式を導入するだけで CPU負荷が約83%削減(8.2ms → 1.4ms) されることが分かります。
これによって節約された約7msは、グラフィックスの描画や物理シミュレーションといったゲームの他の重要な処理に回すことができます。
5. まとめと応用:実戦導入での注意点
一括アップデートパターンは非常に効果的ですが、導入にあたっていくつかの注意点があります。
- 実行順序(Execution Order)の管理:
もし特定のオブジェクトが他のオブジェクトより先に更新される必要がある場合、リスト内でのソートや、優先度(Priority)を伴う複数のリスト管理を行う必要があります(例:
UpdateManager.Register(IUpdateable, int priority)のように拡張する)。 - FixedUpdate / LateUpdate への拡張:
Updateだけでなく、FixedUpdateや `LateUpdate` に対しても同様に一括管理を行うためのインターフェースとリスト(例:IFixedUpdateable、ILateUpdateable)を追加すると、物理計算やカメラ追従などの更新も最適化できます。 - インスペクターによるコンポーネント無効化との連動:
自動登録クラス(
ManagedMonoBehaviour)はOnEnableとOnDisableで登録・解除を行っているため、インスペクター上でチェックボックスをオフにすると自動的に更新が止まります。ただし、登録・解除の処理が頻繁に発生すると、リストの走査や追加・削除のオーバーヘッドがかかるため、頻繁なトグルは避け、オブジェクトプールなどと組み合わせるのがベストプラクティスです。
ゲームの規模が大きくなると、小さなオーバーヘッドの積み重ね(チリツモ負荷)がパフォーマンスを大きく阻害します。「Managed-to-Native」というUnity内部の挙動を理解し、一括アップデートを効果的に活用して快適なゲーム体験を提供しましょう。