概要
Unityでスクリプトを書くとき、私たちは当たり前のようにUpdate()関数を使って毎フレームの処理を記述します。しかし、シーン内に数百、数千という多数のオブジェクトがそれぞれUpdate()を持つようになった時、その裏側で何が起きているかを意識したことはありますか?
実は、UnityのUpdate()呼び出しには、C#のマネージドコードとUnityエンジンのネイティブコード(C++)との間を行き来するための、無視できないオーバーヘッドが存在します。オブジェクトの数が増えるほど、このオーバーヘッドは積み重なり、パフォーマンスのボトルネックとなり得ます。この問題は、特に海外のハイパフォーマンスを要求する開発コミュニティでよく議論されています。
この記事では、このUpdate()の呼び出しコスト問題を解決するための高度な最適化テクニックであるカスタムアップデートマネージャー (Custom Update Manager) の概念と、その具体的な実装方法について詳しく解説します。
なぜUpdate()は遅くなるのか?
MonoBehaviourのUpdate()関数は、Unityエンジン側から呼び出されます。そのプロセスは以下のようになります。
- Unityエンジン(ネイティブコード)が、
Update()を持つ全アクティブオブジェクトのリストを走査する。 - リスト内の各オブジェクトに対して、ネイティブコードからC#のマネージドコードへの「ブリッジ」を渡り、
Update()関数を呼び出す。 - C#での
Update()の処理が完了すると、再びブリッジを渡ってネイティブコード側に戻る。
この「ブリッジを渡る」処理(マーシャリングとも呼ばれます)には、たとえUpdate()の中身が空であっても、一定のコストがかかります。オブジェクトが1000個あれば、毎フレーム1000回のブリッジ往復が発生するわけです。特に、Update()内で「今は何もしない」という早期リターン(if (!condition) return;)を多用している場合、この呼び出しコス トが無駄に積み重なってしまいます。
カスタムアップデートマネージャーの概念
この問題を解決するアイデアが「カスタムアップデートマネージャー」です。その考え方は非常にシンプルです。
「UnityからのUpdate()呼び出しは、シーンに一つだけ存在する特別なマネージャークラスで一括して受け取り、そのマネージャーが、更新が必要な他の全てのオブジェクトの更新用メソッドをC#コード内で直接呼び出す」
これにより、ネイティブコードとC#コード間のブリッジ往復は、毎フレームたったの1回で済むようになります。マネージャーから各オブジェクトへの呼び出しは、すべてC#のマネージドコード内で完結するため、非常に高速です。
実装方法
それでは、具体的な実装を見ていきましょう。
ステップ1: 更新可能なインターフェースの定義
まず、更新処理を持つオブジェクトが実装すべき共通の「ルール」として、インターフェースを定義します。これにより、マネージャーは相手がどんなクラスかを知らなくても、決まったメソッドを呼び出すことができます。
// IUpdatable.cs
public interface IUpdatable
{
void ManagedUpdate(float deltaTime);
}
ステップ2: アップデートマネージャーの作成
次に、シーンに一つだけ配置するシングルトン(Singleton)パターンのマネージャークラスを作成します。このクラスが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;
// 登録されている全てのオブジェクトの更新メソッドを呼び出す
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()関数は使いません。
// MovableObject.cs
using UnityEngine;
public class MovableObject : MonoBehaviour, IUpdatable
{
public float speed = 2f;
void OnEnable()
{
// 自身をアップデートマネージャーに登録
UpdateManager.Instance.Register(this);
}
void OnDisable()
{
// インスタンスが破棄されていないか確認してから登録解除
if (UpdateManager.Instance != null)
{
UpdateManager.Instance.Unregister(this);
}
}
// Update()の代わりにこのメソッドがマネージャーから呼ばれる
public void ManagedUpdate(float deltaTime)
{
transform.Translate(Vector3.forward * speed * deltaTime);
}
}
これで完成です!シーンにUpdateManagerをアタッチした空のゲームオブジェクトを一つ配置し、MovableObjectスクリプトをたくさんのオブジェクトにアタッチして実行してみてください。見た目はUpdate()を使った場合と変わりませんが、Profilerで確認すると、Update.ScriptRunBehaviourUpdateの呼び出し回数が激減し、パフォーマンスが向上していることが確認できるはずです。
メリットと注意点
- メリット: 大量のオブジェクトが存在するシーンでのパフォーマンスが大幅に向上する。
- メリット: 更新の有効/無効を
Register/Unregisterで動的に制御できるため、不要な時は更新処理そのものをリストから外すことができ、無駄なif文を削減できる。 - 注意点: プロジェクトのすべての
Updateをこの方式に統一する必要はなく、本当に多数存在するオブジェクト(例: 弾、敵の群れ、エフェクトなど)に限定して適用するのが効果的です。 - 注意点:
FixedUpdateやLateUpdateに関しても同様のマネージャーを作成することができます。
まとめ
カスタムアップデートマネージャーは、UnityのUpdate呼び出しの仕組みを理解し、そのボトルネックを回避するための洗練されたテクニックです。すべてのプロジェクトで必須というわけではありませんが、特にモバイルゲームや、数千のオブジェクトが動くような大規模なゲームの開発において、この知識は大きな武器となります。
Update()の呼び出しには、ネイティブ/マネージド間のオーバーヘッドがある。- カスタムアップデートマネージャーは、その呼び出しを1回に集約する手法。
- インターフェース、シングルトン、リストを使って実装する。
パフォーマンスの限界に挑戦する時、このテクニックを思い出してください。