Unity開発において、C#の便利な機能であるラムダ式(Lambda Expression)匿名メソッド(Anonymous Method)は、イベントハンドラやコールバック、非同期処理(UniTaskやコルーチン)の実装に欠かせない存在です。しかし、これらの記述方法を誤ると、バックグラウンドで毎フレーム数十〜数百バイトのGC Alloc(ガベージコレクションによるメモリ確保)が発生し続けます。蓄積された不要メモリが引き起こすガベージコレクション(GC)の作動は、ゲームプレイ中の重大なパフォーマンススパイク(一瞬のカクつき / マイクロスタッター)の主な原因となります。本記事では、ラムダ式によってGC Allocが発生するコンパイル時の裏側の仕組みを解き明かし、Unity Profilerでの検出方法と、アロケーションを完全にゼロにするための実践的なリファクタリング手順を解説します。

1. 根本原因:なぜラムダ式でGC Allocが発生するのか?

ラムダ式 () => { ... } 自体は構文糖衣(シンタックスシュガー)に過ぎず、コンパイル時にC#コンパイラによって通常のメソッドやクラスへと展開されます。このコンパイルプロセスにおいて、メモリ(ヒープ)の割り当てが発生するパターンは大きく分けて2つあります。

① 外部のローカル変数をキャプチャすることによる「クロージャ(Closure)」の生成

ラムダ式の内部で、その外側のスコープ(メソッド内など)で宣言されたローカル変数を参照することを「変数のキャプチャ(Capture)」と呼びます。コンパイラはキャプチャされたローカル変数の寿命をラムダ式の実行時まで引き延ばすため、裏で一時的なクラス(コンパイラ生成クラス)を生成し、そのクラスのフィールドに変数を格納します。そして、ラムダ式が評価されるたびに、このクラスがヒープ上に new されます。これがクロージャによるGC Allocです。

// 悪い例:外部変数 targetId をキャプチャしている
int targetId = 10;
var result = enemies.Find(e => e.Id == targetId); // 毎フレーム評価されると、ここでGC Allocが発生!

② デリゲート(Delegate)インスタンスの毎フレーム生成

たとえ外部の変数をキャプチャしていなくても、ラムダ式をデリゲート(ActionFunc<T> など)を要求するメソッドの引数に直接渡すと、評価のたびにデリゲートオブジェクトのインスタンス(new Action(...))がヒープに割り当てられる場合があります。C#コンパイラは一部のケース(変数をキャプチャしない静的なラムダ式)を最適化して静的フィールドにキャッシュしますが、クラスの非静的メンバー(メンバ変数や this)を参照している場合は、やはりインスタンスの生成が避けられません。

// 悪い例:メンバ変数 _status を参照しているため、デリゲートのキャッシュが効かない
private string _status = "Active";

void Update()
{
    // 評価されるたびに、thisをキャプチャするデリゲートインスタンスが生成される
    DoProcess(() => Debug.Log(_status)); 
}

2. 実例で見る:GC Allocが発生するコードと回避策

よくある開発時のシチュエーションをもとに、問題のあるコードとアロケーションフリー(GC Alloc = 0)に改善したコードを比較します。

実例1:LINQやListの検索における一時キャプチャ

List.FindList.Exists などにラムダ式を渡す際、検索対象の値をメソッドのローカル変数から渡すとクロージャが発生します。

// X: 毎フレームGC Allocが発生するコード
void Update()
{
    int searchCategory = 3;
    // searchCategory をキャプチャするため、コンパイラ生成クラスのインスタンス化が発生 (約32〜48B)
    var item = itemList.Find(x => x.category == searchCategory);
}

改善方法: ループや Update 内でのLINQ/List検索を避け、あらかじめ辞書型(Dictionary)などのデータ構造でキャッシュしておくか、手動で for ループを回すことで、ラムダ式の使用自体を回避します。

// O: GC Allocをゼロにするコード(手動ループによる回避)
void Update()
{
    int searchCategory = 3;
    Item foundItem = null;
    
    // ガベージフリーで完全に同一の検索処理を実現
    for (int i = 0; i < itemList.Count; i++)
    {
        if (itemList[i].category == searchCategory)
        {
            foundItem = itemList[i];
            break;
        }
    }
}

実例2:Tweenアニメーションや非同期コールバックのラムダ式

DOTween等のTween系ライブラリや非同期処理のコールバック設定で、完了時に引数を渡したい場合によくラムダ式が使われます。

// X: DOTweenのコールバックで引数をキャプチャしてGC Allocが発生するコード
void StartTween(GameObject target, Vector3 destination, int index)
{
    // target と index をキャプチャするためクロージャが生成される
    target.transform.DOMove(destination, 1f)
        .OnComplete(() => OnTweenComplete(target, index)); 
}

改善方法: 外部変数のキャプチャを不要にするため、デリゲート自体をキャッシュするか、ライブラリ側が提供している「状態オブジェクト(State)を渡せるAPI」を利用します。多くの先進的なUnityライブラリは、デリゲートに状態を流し込めるオーバーロードを用意しています。

// O: 状態(State)を渡せるオーバーロードを使用したアロケーションフリーなコード
void StartTween(GameObject target, Vector3 destination, int index)
{
    // DOTweenの引数付きOnComplete(またはカスタム実装)などを利用して、
    // ラムダ式の外側からオブジェクトをキャストして受け取り、キャプチャを回避する
    // (※ライブラリごとの実装に合わせて、引数をキャプチャしない静的デリゲートを渡す)
}

3. Unity Profilerでの検出・特定手順

ゲームにカクつきが発生している場合、プロファイラーを使用してラムダ式によるGC Allocを特定できます。

  1. Unityエディタで Window > Analysis > Profiler を開きます。
  2. Deep Profiler Support(詳細プロファイリング)を有効にすると、より詳細なコールスタックが見られます(ただしエディタの動作が重くなります)。
  3. ゲームを実行し、カクつきが発生した瞬間、または適当なフレームでプロファイラーを一時停止します。
  4. CPU Usage モジュールを選択し、下の詳細表示を Hierarchy モードに切り替えます。
  5. GC Alloc カラムをクリックし、割り当て量が多い順にソートします。
  6. メソッド名の中に <MethodName>b__0<>c__DisplayClass... といった難解な名前(コンパイラ生成のクラス名・メソッド名)がないか探します。これがラムダ式によって自動生成された処理です。
  7. 対象の行を選択し、画面下部の Show Calls / Call Stacks を有効にして、C#コード内のどの行から呼び出されているかを突き止めます。

4. GC Allocをゼロにするための3つの対策テクニック

対策1:デリゲートインスタンスのキャッシュ化

外部変数をキャプチャしていないが、メンバー変数を参照しているためにキャッシュされないデリゲートは、初期化時(AwakeStart)にデリゲートオブジェクトを作成し、メンバー変数にキャッシュしておきます。

// キャッシュを用いたアロケーション回避
private Action _cachedCallback;
private int _score;

void Awake()
{
    // 初期化時に一度だけデリゲートインスタンスをヒープに生成
    _cachedCallback = UpdateScoreDisplay; 
}

void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        // 毎フレーム呼び出しても、すでに生成されたデリゲートを再利用するためGC Allocは0
        ExecuteCallback(_cachedCallback); 
    }
}

void UpdateScoreDisplay()
{
    Debug.Log($"Score: {_score}");
}

対策2:C# 9.0の「static ラムダ式」によるキャプチャの強制禁止

Unity 2021.2以降(C# 9.0以降対応)では、ラムダ式の前に static キーワードを付与することができます。これにより、ラムダ式の内部から外部のローカル変数やインスタンス変数(thisなど)へのアクセスがコンパイルエラーとなります。キャプチャしていないことをコンパイラレベルで保証でき、意図しないGC Allocの発生を未然に防ぐ強力な手段です。

// staticラムダ式の利用
void ProcessData()
{
    int limit = 50;
    
    // コンパイルエラー! static ラムダ内から外部変数 limit は参照できない
    // DoAction(static () => Debug.Log(limit)); 
    
    // OK: キャプチャを行わないため、コンパイラが自動的に静的なデリゲートとしてキャッシュする(GC Allocは0)
    DoAction(static () => Debug.Log("No capture")); 
}

対策3:引数(State)を渡せるコールバックAPIの実装

自作のマネージャークラスやユーティリティ関数でデリゲートを引数に取る場合は、デリゲート自身に状態オブジェクトを渡せるように設計します。ジェネリクス型の TState を使用したオーバーロードを提供することで、ラムダ式の外部から値を渡すことができ、クロージャが不要になります。

// アロケーションフリーなカスタムAPI設計の例
public class EventNotifier : MonoBehaviour
{
    // 状態(State)を引数で受け取るデリゲートを定義
    public delegate void NotifyAction<TState>(TState state);

    // 状態を外部から受け取れるメソッドを定義
    public void RegisterNotification<TState>(NotifyAction<TState> action, TState state)
    {
        // actionを実行する際、渡されたstateをそのまま引き渡す
        action(state);
    }
}

// 利用側
void Start()
{
    var notifier = GetComponent<EventNotifier>();
    int currentId = 123;
    
    // ラムダ式自体はstatic(キャプチャなし)にし、引数 'id' を介して状態を受け取る
    // これによりクロージャの生成を完全に回避できる
    notifier.RegisterNotification(
        static (id) => Debug.Log($"Received ID: {id}"), 
        currentId
    );
}

5. まとめ:アロケーションフリーなコーディングを習慣づける

ラムダ式はコードの見通しを良くする一方で、パフォーマンスクリティカルな処理(毎フレーム実行されるループ、Update()、描画・物理関連のコールバックなど)においてはサイレントキラーとなり得ます。

  • Updateなどの毎フレーム処理では、原則としてラムダ式の使用を避ける。
  • どうしても使用する場合は、初期化時にメンバ変数にキャッシュしておく。
  • C# 9.0の static ラムダ式を積極的に利用し、変数のキャプチャを禁止する。
  • 自作メソッドでコールバックを受け取る場合は、引数(State)を渡せる設計にする。

プロファイラーを常に確認し、これらの習慣を意識することで、GCのスパイクに悩まされない快適でスムーズなゲーム体験を実現しましょう。