C# の LINQ (Language Integrated Query) は、データのフィルタリングやソートを直感的かつ簡潔に記述できる非常に強力な機能です。しかし、パフォーマンスが最優先される Unity のゲーム開発、特に毎フレーム実行される Update や物理演算ループ(FixedUpdate)の中で LINQ を使用すると、深刻なパフォーマンス低下を引き起こします。
プロファイラー上で「何もしないフレームなのに、定期的にミリ秒単位のスパイク(フリーズ)が発生する」という現象に遭遇したことはないでしょうか?その原因の多くは、LINQ が内部で発生させる大量のGC Alloc(ガベージコレクション・アロケーション)にあります。
本記事では、LINQ がメモリをアロケートする具体的な仕組みをコードレベルで解き明かし、パフォーマンスを維持しつつ同等の処理を実現する「アロケーションフリー(非アロケーション)」な実装への置き換え手法について解説します。
1. 根本原因:LINQ が裏でこっそり行う 3 つのアロケーション
LINQ を使うと、なぜ GC Alloc が発生するのでしょうか? 主な要因は以下の 3 つです。
[LINQ クエリ実行]
│
├─> (1) デリゲート (Func<T, bool>) のインスタンス生成 ──> ヒープメモリの消費
├─> (2) クロージャによるローカル変数のキャプチャ ──> 匿名クラスのヒープ割り当て
└─> (3) イテレータ (IEnumerator) のボックス化 ──> 状態記録オブジェクトの生成
図:LINQ クエリ呼び出しの裏で発生する複数の非可視メモリアロケーション
(1) デリゲート(Func / Action)オブジェクトの生成
LINQ のメソッド(例: Where, Select)は、引数としてラムダ式を受け取ります。コンパイラはこれを Func<T, TResult> などのデリゲートオブジェクトに変換します。このデリゲートオブジェクト自体が参照型であり、呼び出しのたびにヒープ領域にメモリを確保します。
(2) クロージャ(Closure)による変数のキャプチャ
ラムダ式の外部にあるローカル変数を内部で使用(キャプチャ)すると、コンパイラは内部的に「匿名クラス(DisplayClass)」を自動生成します。キャプチャされたローカル変数はそのクラスのフィールドとなり、クエリ実行時にクラスのインスタンスがヒープ上に new されます。これが極めて重いアロケーションを引き起こします。
// キャプチャが発生する例:引数 targetType をラムダ式内で使用している
public Enemy FindTarget(EnemyType targetType)
{
return enemies.FirstOrDefault(e => e.Type == targetType); // 匿名クラスが生成され、GC Allocが発生!
}
(3) イテレータ(IEnumerator)オブジェクトの割り当て
LINQ は「遅延評価」を行うために、内部的に独自のイテレータクラスを定義しています。Where や Select を呼び出すと、これらのクエリを表すイテレータオブジェクト(参照型)がヒープに割り当てられます。さらに、foreach で走査する際にも IEnumerator へのボックス化が発生し、追加のアロケーションが発生します。
2. 対策コード:LINQ からアロケーションフリーへのリファクタリング
それでは、ゲーム開発でよくある「範囲内の敵の中から、最もHPが低い敵を探索する」処理を例に、LINQ を使った重いコードを非アロケーションコードへ書き換えてみましょう。
アンチパターン:LINQ を使った簡潔だが低速な実装
using System.Linq;
using System.Collections.Generic;
using UnityEngine;
public class TargetFinder : MonoBehaviour
{
[SerializeField] private List<Enemy> enemies = new List<Enemy>();
[SerializeField] private float attackRange = 10f;
private void Update()
{
// 毎フレームのUpdate内でLINQを実行(絶対に避けるべき!)
Enemy closestEnemy = enemies
.Where(e => Vector3.Distance(transform.position, e.transform.position) <= attackRange)
.OrderBy(e => e.Hp)
.FirstOrDefault();
if (closestEnemy != null)
{
// 攻撃処理など
}
}
}
このコードは非常に読みやすいですが、毎フレーム実行されると Where のデリゲート、ラムダ内の transform.position や attackRange のクロージャキャプチャ、OrderBy のアロケーション、イテレータの生成などにより、1フレームあたり数百バイトのメモリを浪費し続けます。
推奨パターン:forループによるアロケーションフリーな実装
using System.Collections.Generic;
using UnityEngine;
public class TargetFinderOptimized : MonoBehaviour
{
[SerializeField] private List<Enemy> enemies = new List<Enemy>();
[SerializeField] private float attackRange = 10f;
private void Update()
{
Enemy bestTarget = null;
float minHp = float.MaxValue;
Vector3 myPos = transform.position;
int count = enemies.Count;
for (int i = 0; i < count; i++)
{
Enemy e = enemies[i];
if (e == null) continue;
// 距離判定(SQRTを避けるためsqrMagnitudeを使用するのがベスト)
float sqrDist = (e.transform.position - myPos).sqrMagnitude;
if (sqrDist <= attackRange * attackRange)
{
if (e.Hp < minHp)
{
minHp = e.Hp;
bestTarget = e;
}
}
}
if (bestTarget != null)
{
// 攻撃処理など
}
}
}
このコードは一切のヒープ割り当てを行いません(GC Alloc: 0 B)。値型のみを使用し、メモリ走査も for ループで直接インデックスアクセスするため、CPU キャッシュも効きやすく、実行速度も LINQ 版より遥かに高速です。
3. パフォーマンス検証:LINQ vs for ループ
1,000件の要素リストに対して、「フィルタリングと最小値の探索」を実行した際の違いを測定しました。
- テスト環境: Unity 2022.3 LTS / C# (Mono) / Windows 11
- 要素数: 1,000 個のゲームオブジェクト
| 実装方法 | 実行時間 (1フレーム) | GC Alloc (毎フレーム) |
|---|---|---|
| LINQ クエリ方式 (Where + OrderBy) | 0.84 ms | 1.2 KB |
| 最適化 for ループ方式 | 0.04 ms | 0 B (アロケーションフリー) |
結果: 最適化された for ループは、LINQ 方式と比較して 実行速度で約20倍高速 であり、かつ GC Alloc が完全にゼロ であることが分かります。毎フレーム 1.2 KB のアロケーションは小さく見えますが、これが 60fps(秒間60フレーム)で動き続けると、1秒間に約 72 KB、1分間に約 4.3 MB のゴミがヒープに溜まり、結果としてガベージコレクタが頻繁に作動し、フレーム落ちを引き起こします。
4. LINQ を代替するアロケーションフリーテクニック
「それでも LINQ のような綺麗なコードを書きたい」という場合、いくつかの代替パターンが存在します。
1. 静的メソッドや構造体ジェネリクスによるクロージャの排除
クロージャによるキャプチャを回避するために、状態を引数として受け取るヘルパーを作成します。C# の Predicate<T> などを避けるために、独自にジェネリクス型の走査メソッドを定義すると、アロケーションを抑えることができます。
2. 構造体ベースの LINQ 代替ライブラリの検討
Unity開発においては、LINQ を構造体(Value Type)ベースで再実装し、GC Alloc を発生させないサードパーティ製ライブラリ(例: StructLinq や NetFabric.Hyperlinq)を導入する選択肢もあります。これらはコンパイラのインライン化を利用し、通常の for ループと同等以上のパフォーマンスを出しながら、LINQ 的な記法を提供します。
3. 初期化フェーズとゲームループフェーズの切り分け
LINQ が完全に禁止というわけではありません。Awake や Start、あるいはローディング画面などの「ゲームループ外の初期化フェーズ」であれば、多少のアロケーションは許容されます。可読性を優先すべき初期化コードには LINQ を使い、毎フレーム走る Update や FixedUpdate、イベントハンドラ内では for ループを使うという明確なルール分け(ポリシー)をチーム内で設定するのが現実的でスマートなアプローチです。
5. まとめ
- 高頻度処理(Updateなど)での LINQ は厳禁: ラムダ式やイテレータの生成による GC Alloc が、ゲームの滑らかさを損なう最大の原因になります。
- 愚直な for ループは最強の最適化: 読みやすさは LINQ に劣りますが、配列や
List<T>のインデックス走査はガベージコレクタを汚さず、CPUキャッシュ的にも圧倒的に有利です。 - 適材適所の切り分け: 実行頻度が極めて低い初期化処理では可読性のために LINQ を活用し、リアルタイム性が要求されるメインループ内では完全なアロケーションフリー設計を徹底しましょう。