記事

Flow Field 最適化 — 3,000から10,000エージェントへのスケーリング

Flow Field 最適化 — 3,000から10,000エージェントへのスケーリング
前提知識 — 先にこちらをご確認ください
FlowField シリーズ (2 / 2)
  1. Flow Field パスファインディング — 大規模群衆のための最適解
  2. Flow Field 最適化 — 3,000から10,000エージェントへのスケーリング
TL;DR — 要点まとめ
  • 3,000エージェントの実際のボトルネックはパスファインディングではなく、メインスレッドのソート(37%)、レンダリング(30%)、分離計算(20%)だった
  • NativeArray.Sort()をBurst SortJobに置き換える一行の変更だけで、p50フレームタイムが7.71ms → 5.02ms(-34.9%)改善された
  • GPU Instancing + VATで10,000エージェントをGameObjectなしでレンダリングし、空間ハッシングで分離ステアリングをO(N²) → O(N)に削減した
Visitors

はじめに

前回の記事では、Flow Fieldパスファインディングの概念と3段階パイプラインを解説した。Flow Fieldはエージェント数に関係なく \(O(V)\) で計算されるため、パスファインディング自体はボトルネックではない。

では、3,000エージェントでフレームレートが低下する原因は何か?そして10,000エージェントまでスケールアップするには何を変える必要があるのか?

この記事では、実際のプロファイリングデータに基づいてボトルネックを特定し、5つの主要な最適化を適用して3,000 → 10,000エージェントまでスケーリングした過程を解説する。

以下の動画は、すべての最適化適用後、VATアニメーションと実際のゾンビモデルで10,000エージェントが動作するデモだ。


Part 1: ボトルネックの特定 — パスファインディングではなかった

プロファイリング結果

3,000エージェントでUnity Profilerによりフレームを分析した結果、Flow Fieldパイプライン(Cost → Integration → Flow)はフレームの10%未満だった。本当のボトルネックは全く別の場所にあった。

ボトルネックフレーム比率原因
NativeArray.Sort~37%分離ステアリング用ソートがメインスレッドをブロッキング
レンダリング~30%GameObjectベースレンダリングのコンポーネントオーバーヘッド
分離計算~20%エージェント間衝突回避 \(O(N^2)\) 演算
パスファインディング<10%Flow Field再計算(既に効率的)
pie title フレームタイム比率(3Kエージェント)
    "NativeArray.Sort" : 37
    "レンダリング (GameObject)" : 30
    "分離ステアリング" : 20
    "パスファインディング" : 8
    "その他" : 5

このデータが示すことは明確だ。パスファインディングアルゴリズムをどれだけ改善してもフレームは10%しか改善しない。本当の性能向上には残りの90%を攻略する必要がある。

最適化の順序決定

ボトルネックの比率と実装難易度を基準に最適化順序を決定した:

順序最適化予想効果難易度
1Burst SortJobフレーム ~37% 削減一行変更
2GPU Instancing + VATレンダリングコスト大幅削減シェーダー + アーキテクチャ変更
3空間ハッシング分離ステアリング\(O(N^2) → O(N)\)Jobパイプライン再設計
4Tiered LOD不要な演算の排除システム追加
5Frustum CullingGPU負荷削減Job追加

最も少ない労力で最大の効果が得られるものから適用する。


Part 2: Burst SortJob — 一行で37%削減

問題:メインスレッドのブロッキング

分離ステアリングのためにエージェントをセルインデックス基準でソートする必要がある。同じセルのエージェントをメモリ上で連続配置することで、近傍検索が高速化されるためだ。

問題はNativeArray.Sort()メインスレッドで同期的に実行されることだった。20,000エージェント基準でこのソートに~2.9msかかっており、これは全フレームタイムの37%に相当した。その間、19個のJob Workerスレッドはアイドル状態だった。

1
2
3
4
[Before] メインスレッドでの同期ソート
──────────────────────────────────────────────────
Main Thread: ████ Sort (2.9ms) ████ Separation ████
Worker 1~19: ░░░░░░░░░░░░░░░░ (待機) ░░░░░░░░░░░░░░░░

解決策:.SortJob()

Unity CollectionsパッケージはNativeArray.SortJob()拡張メソッドを提供している。これは内部的にBurstコンパイルされたMerge Sortを使用し、Jobチェーンに組み込むことができる。

1
2
3
4
5
6
7
// Before: メインスレッドブロッキング
_data.CellAgentPairs.Sort(new CellIndexComparer());

// After: Burst SortJob、ワーカースレッドで実行
var h2 = _data.CellAgentPairs
    .SortJob(new CellIndexComparer())
    .Schedule(h1);  // 前のJobハンドルにチェイニング

一行の変更だ。.Sort().SortJob().Schedule()に置き換えれば、ソートがワーカースレッドに移動し、メインスレッドは解放される。

1
2
3
4
[After] Burst SortJobによるワーカースレッドでの非同期ソート
──────────────────────────────────────────────────
Main Thread: ░░░░░░░░░░░░░░ (他の処理を実行) ░░░░░░░░░░░░░░
Worker 1:    ████ SortJob ████ → Separation ████

全体のJobチェーン

SortJobは分離ステアリングパイプラインの4段階中2段階目に該当する。チェーン全体がワーカースレッドで依存関係の順序に従って実行される:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Phase 1: 各エージェントのセルインデックス割り当て
var h1 = assignJob.Schedule(_activeCount, 64);

// Phase 2: セルインデックス基準ソート(Burst SortJob)
var h2 = _data.CellAgentPairs
    .SortJob(new CellIndexComparer())
    .Schedule(h1);

// Phase 3: セル別(開始インデックス、個数)構築
var h3 = rangesJob.Schedule(h2);

// Phase 4: 3×3近傍セルベースの分離計算
var handle = sepJob.Schedule(_activeCount, 64, h3);

メインスレッドはこのチェーンをスケジュールするだけで即座にリターンする。実際の演算はすべてワーカースレッドで行われる。

結果

指標BeforeAfter改善
Separation全体3.34ms0.73ms-78%
p50フレームタイム7.71ms5.02ms-34.9%
Job Worker稼働率~0%9.0%

一行の変更でp50が7.71ms → 5.02msに改善された。最小の労力で最大の効果を得た最適化だ。

教訓:最適化の第一歩は常にプロファイリングだ。直感で「パスファインディングが遅いはず」と思い込んでいたら、実際のボトルネック(Sort)を見逃していただろう。


Part 3: GPU Instancing + VAT — Zero-GameObjectアーキテクチャ

問題:GameObjectのコスト

3,000体のゾンビをそれぞれGameObjectとして生成すると:

1
2
3
ゾンビ1体 = Transform + MeshRenderer + Animator + Collider
→ 3,000体 = CPU側コンポーネント12,000個以上
→ 10,000体 = CPU側コンポーネント40,000個以上

Transform同期、Animator更新、レンダリングカリング — すべてUnityエンジンが毎フレーム処理するコストだ。10,000体ではこれだけでフレーム予算を超過する。

解決策:GameObjectを排除する

GPU InstancingGraphics.RenderMeshInstanced()を使用すれば、同じメッシュ+マテリアルを共有するインスタンスを単一ドローコールで最大1,023個までレンダリングできる。TransformはNativeArray<float4x4>マトリックス配列で管理し、Burst Jobが毎フレーム更新する。

1
2
3
4
5
6
7
8
9
10
11
12
// PositionToMatrixJob(Burstコンパイル、IJobParallelFor)
// 位置 + 速度方向 → TRSマトリックス変換
float4x4 matrix = float4x4.TRS(position, rotation, scale);
Matrices[index] = matrix;

// レンダリング:タイプ別にバッチ分割
for (int offset = 0; offset < visibleCount; offset += 1023)
{
    int batchSize = math.min(1023, visibleCount - offset);
    var batch = matrices.GetSubArray(offset, batchSize);
    Graphics.RenderMeshInstanced(renderParams, mesh, 0, batch);
}

VAT (Vertex Animation Texture):Animatorなしでgpu上でアニメーションを再生する。原理はシンプルだ:

  1. オフラインでゾンビの歩行アニメーションの全フレーム、全頂点位置をテクスチャに保存
  2. ランタイムにシェーダーが現在の時刻に対応するテクスチャ行をサンプリング
  3. サンプリングした値で頂点位置を変形
1
2
3
4
5
6
テクスチャレイアウト:
  U軸 → 頂点インデックス (0 ~ 4,349)
  V軸 → フレームインデックス (0 ~ 59)

  各テクセル = RGBAHalf = 該当フレームにおける該当頂点の (x, y, z) オフセット
  VRAMコスト:~1MB per クリップ (4,350 verts × 60 frames × RGBAHalf)

位相オフセット:同期の回避

VATの落とし穴は、すべてのゾンビが同じタイミングで同じフレームを再生することだ。数千体が完璧に同期した群舞を踊ると不自然になる。

解決策はワールド座標ベースのハッシュでエージェントごとに開始位相をずらすことだ:

1
2
3
4
5
6
7
8
9
10
// ワールドXZ座標でハッシュ → エージェントごとに異なる開始位相
float phaseOffset = frac(worldPos.x * 0.137 + worldPos.z * 0.241);
float time = (_Time.y * _AnimSpeed + phaseOffset * _AnimLength)
             % _AnimLength;

// 隣接フレーム補間 → 滑らかなアニメーション
float frameFloat = (time / _AnimLength) * (_FrameCount - 1);
float frame0 = floor(frameFloat);
float frame1 = frame0 + 1;
float blend = frameFloat - frame0;

このコードでインスタンスごとの追加データ転送なしに、位置だけで自然な非同期アニメーションが実現できる。

ルートモーションのストリッピング

VATにルートモーションが含まれると、ゾンビが定位置で移動する代わりにシェーダー空間で歩き去ってしまう。これを防ぐために頂点0(ルートボーン)のXZオフセットを除去する:

1
2
3
4
5
6
7
8
if (_StripRootMotion > 0.5)
{
    // ルートボーン(vertex 0)のXZオフセットをサンプリング
    float2 rootUV0 = float2(0.5 / _TexWidth, v0);
    float3 rootOffset0 = tex2Dlod(_VATPosTex, float4(rootUV0, 0, 0)).xyz;
    // 現在の頂点からルートのXZ移動分を除去
    offset0.xz -= rootOffset0.xz;
}

Before vs After

1
2
3
4
5
6
7
8
9
10
11
[Before] GameObjectベース
───────────────────────────────────
CPU: Transform × 10K + Animator × 10K + MeshRenderer × 10K
GPU: 10,000ドローコール(バッチングなし)
→ 物理的に不可能

[After] NativeArray + GPU Instancing + VAT
───────────────────────────────────
CPU: NativeArray<float4x4> 更新(Burst Job)
GPU: ~58ドローコール(1,023個ずつバッチング)
→ 10,000エージェント @ 60fps

Part 4: 空間ハッシング分離ステアリング — O(N²) → O(N)

問題:N²比較

分離ステアリング(Separation Steering)は、エージェントが互いに重ならないよう押し出す力を計算する。素朴な実装は全エージェントペアを比較する:

\[\text{比較回数} = \frac{N(N-1)}{2}\]
エージェント数比較回数
1,000499,500
3,0004,498,500
10,00049,995,000

10,000エージェントでは毎フレーム5,000万回の距離計算が必要になる。Burstでコンパイルしてもこの規模は処理しきれない。

解決策:セルベース空間ハッシング

核心となるアイデアは近くのエージェントだけを比較することだ。Flow Fieldが既にグリッドを使用しているため、同じグリッド構造を再利用する。

flowchart LR
    A["<b>AssignCells</b><br/>エージェント → セルマッピング<br/>(IJobParallelFor)"] --> B["<b>SortJob</b><br/>セルインデックス基準ソート<br/>(Burst)"] --> C["<b>BuildCellRanges</b><br/>セル別開始/個数<br/>(IJob)"] --> D["<b>ComputeSeparation</b><br/>3×3近傍のみ検査<br/>(IJobParallelFor)"]
    
    style A fill:#e8d5b7,stroke:#b8860b,color:#333
    style B fill:#b7d5e8,stroke:#4682b4,color:#333
    style C fill:#d5b7e8,stroke:#8b57b4,color:#333
    style D fill:#b7e8c4,stroke:#2e8b57,color:#333

Step 1: AssignCellsJob

各エージェントのワールド座標をグリッドセルインデックスに変換する。

1
2
3
4
5
6
// (position.x, position.z) → (cellX, cellZ) → 1Dインデックス
int cellX = (int)(position.x / cellSize);
int cellZ = (int)(position.z / cellSize);
int cellIndex = cellZ * gridWidth + cellX;

CellAgentPairs[i] = new int2(cellIndex, agentIndex);

Step 2: SortJob

セルインデックス基準でソートすると、同じセルのエージェントが配列内で連続して配置される:

1
2
3
ソート前: [(5,A), (2,B), (5,C), (2,D), (3,E)]
ソート後: [(2,B), (2,D), (3,E), (5,A), (5,C)]
              ▲ セル2 ▲         ▲セル3▲   ▲ セル5 ▲

これがPart 2で解説したBurst SortJobだ。

Step 3: BuildCellRangesJob

ソート済み配列を一度走査し、各セルの(開始インデックス、個数)を記録する:

1
2
3
4
CellRanges[cellIndex] = new int2(startIndex, count);
// 例: CellRanges[2] = (0, 2)  → インデックス0から2個
//     CellRanges[3] = (2, 1)  → インデックス2から1個
//     CellRanges[5] = (3, 2)  → インデックス3から2個

Step 4: ComputeSeparationJob

各エージェントは自身のセル + 周囲8セル = 3×3範囲のみを検査する:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
for (int dx = -1; dx <= 1; dx++)
    for (int dz = -1; dz <= 1; dz++)
    {
        int2 checkCell = myCell + new int2(dx, dz);
        int cellIdx = CellToIndex(checkCell);
        int2 range = CellRanges[cellIdx];  // O(1) ルックアップ
        
        for (int k = range.x; k < range.x + range.y; k++)
        {
            int otherIdx = SortedPairs[k].y;
            float3 diff = myPos - Positions[otherIdx];
            float dist = math.length(diff);
            
            if (dist > 0 && dist < radius)
            {
                // 二次減衰:近いほど強く押し出す
                float strength = (1 - dist / radius);
                strength *= strength;
                force += math.normalize(diff) * strength;
            }
        }
    }

なぜO(N)なのか

  • 各エージェントが検査するセル:常に9個(3×3)
  • セルあたりのエージェント数:密度により異なるが、実測平均5~15個
  • エージェントあたりの比較回数:9 × 平均密度 = 定数に近い
  • 全体の比較回数:\(O(N \times \text{const}) = O(N)\)
 素朴な実装空間ハッシング
10,000エージェント49,995,000比較~90,000~150,000比較
計算量\(O(N^2)\)\(O(N)\)

追加の利点:ソート済み配列のおかげで、同じセルのエージェントがメモリ上で連続配置される。CPUキャッシュラインが効率的に活用され、単純な比較回数以上の性能向上が得られる。


Part 5: Tiered LOD — 距離ベースの品質差別化

アイデア

画面の隅で点のようにしか見えないゾンビにRigidbody物理とAnimatorを動かす必要はない。プレイヤーとの距離に応じて処理レベルを差別化する。

AI LOD Tier System 左:距離別Tier区間とヒステリシス(Promote 20m / Demote 25m)。右:Tier別エージェント数対CPUコスト — Tier 0が3,500体だが0.5ms、Tier 2は200体で2.5ms。

Tier距離表現物理アニメーション
Tier 0> 50mNativeArray + GPU InstancingなしVAT (GPU)
Tier 125~50mNativeArray + GPU InstancingなしVAT (GPU)
Tier 2< 20mGameObject + RigidbodyMovePositionAnimator(予定)

Tier 0/1は純粋なデータだ。NativeArrayに位置/速度のみ格納し、GPU Instancingでレンダリングする。CPUコストはMove JobとMatrix Jobだけだ。

Tier 2のみGameObjectを使用し、プレイヤーとの近接戦闘が可能なフルスペックゾンビだ。同時最大300体に制限する。

ヒステリシス:境界での振動防止

昇格距離(20m)と降格距離(25m)を異なる値に設定する。この5mのギャップが境界線でTierが繰り返し切り替わることを防止する。

1
2
3
4
5
距離:  0m ────── 20m ──── 25m ──── 50m ──── 55m ────→
       │  Tier 2  │ ギャップ(5m) │  Tier 1  │ ギャップ(5m) │ Tier 0
       │          │        │          │        │
       └─ 昇格 ───┘        │          └─ 昇格 ──┘
              └─── 降格 ───┘                └─── 降格 ───┘

フレームあたりの遷移制限

100体が同時に20mラインを越えると、1フレームで100個のGameObjectを生成しなければならない。これを防ぐためにフレームあたり最大10個に遷移を制限する:

1
2
[SerializeField] private int _maxPromotionsPerFrame = 10;
[SerializeField] private int _maxDemotionsPerFrame = 10;

残りは次フレームに繰り越される。プレイヤーの体感では10フレーム(~167ms)かけて自然に遷移する。


Part 6: Frustum Culling — 見えないものは描画しない

カメラ外のゾンビをGPUに送るのは純粋な無駄だ。BurstコンパイルされたCompactVisibleByTypeJobが毎フレーム処理する。

動作方式

  1. カメラの視錐台6平面(上/下/左/右/近/遠)を抽出
  2. 各エージェントのAABB(軸平行バウンディングボックス)と6平面をテスト
  3. すべての平面の内側にあるエージェントのみ出力配列にコンパクト化
1
2
3
4
5
6
7
8
9
10
11
12
13
// 6平面テスト
bool visible = true;
for (int p = 0; p < 6; p++)
{
    float4 plane = FrustumPlanes[p];
    float dist = math.dot(plane.xyz, center) + plane.w;
    float radius = math.dot(math.abs(plane.xyz), extents);
    if (dist + radius < 0f)
    {
        visible = false;
        break;  // 1つでも外なら即座に除外
    }
}

タイプ別分離コンパクト化

ゾンビタイプ(Fast/Slow)別に異なるメッシュとマテリアルを使用するため、可視ゾンビをタイプ別に分離コンパクト化する:

1
2
3
4
5
6
出力配列レイアウト:
[Type 0 マトリックス: 0 ~ Capacity-1]
[Type 1 マトリックス: Capacity ~ 2×Capacity-1]

各タイプの実数をVisibleCountsに記録
→ レンダリング時にタイプ別に正確な個数分だけドローコールを発行

カメラがマップ全体の1/4だけを映している場合、レンダリングコストも~1/4に減少する。


Part 7: SoAデータレイアウト — Burstが好むメモリ構造

AoS vs SoA

従来のOOP方式はAoS(Array of Structures)だ:

1
2
3
4
5
6
7
8
9
10
// AoS: ゾンビ1体のすべてのデータが連続
struct Zombie {
    float3 Position;    // 12B
    float3 Velocity;    // 12B
    float Health;       // 4B
    byte State;         // 1B
    byte Tier;          // 1B
    // ... 合計 ~80B per zombie
}
Zombie[] zombies = new Zombie[10000];

SoA(Structure of Arrays)同じフィールドをまとめて格納する:

1
2
3
4
5
6
// SoA: 同じ種類のデータが連続
NativeArray<float3> Positions;     // 10K × 12B = 120KB(連続)
NativeArray<float3> Velocities;    // 10K × 12B = 120KB(連続)
NativeArray<float> Healths;        // 10K × 4B  = 40KB (連続)
NativeArray<byte> States;          // 10K × 1B  = 10KB (連続)
NativeArray<byte> AiTiers;         // 10K × 1B  = 10KB (連続)

なぜSoAが速いのか

Move Jobが位置を更新する際、Positions配列のみを逐次アクセスする:

1
2
3
4
5
6
7
8
9
10
11
12
13
[AoS] Positionsを読む時 — 80Bごとに12Bだけ有効
┌─────────────────────────────────────────────────┐
│ Pos₀ Vel₀ HP₀ ... │ Pos₁ Vel₁ HP₁ ... │ Pos₂  │
│ ████ ░░░░░░░░░░░░░ │ ████ ░░░░░░░░░░░░░ │ ████  │
└─────────────────────────────────────────────────┘
  キャッシュライン64B中12Bのみ使用 → 効率15%

[SoA] Positions配列 — 連続する12Bが隙間なく並ぶ
┌─────────────────────────────────────────────────┐
│ Pos₀ │ Pos₁ │ Pos₂ │ Pos₃ │ Pos₄ │ Pos₅ │ ... │
│ ████ │ ████ │ ████ │ ████ │ ████ │ ████ │ ... │
└─────────────────────────────────────────────────┘
  キャッシュライン64B中60B使用 → 効率93%

さらにBurstコンパイラがSoAレイアウトを検出すると、SIMD(SSE/AVX)の自動ベクトライゼーションを適用する。float3を4つ同時に処理するため、理論上4倍高速になる。

実際のプロジェクトでは17個のNativeArrayでゾンビデータを管理している。Position、Velocity、Health、State、Tier、Type、Matrixなど、各Jobが必要な配列のみ選択的にアクセスし、キャッシュ効率を最大化している。


Part 8: 全体フレームパイプライン

すべての最適化が適用された後のフレームパイプラインだ。各Phaseが前のPhaseの結果に依存する構造で、可能な限り並列にスケジュールする:

flowchart TD
    subgraph Phase1["Phase 1: 並列Jobスケジュール (~1.0ms)"]
        A[DistanceJob] 
        B[SortJob チェーン<br/>Assign→Sort→Ranges→Separation]
        C[KnockbackDecayJob]
        D[EffectTimerJob]
    end
    
    subgraph Phase2["Phase 2: 距離同期 + 追加スケジュール (~0.3ms)"]
        E[TierAssignJob]
        F[AttackJob]
    end
    
    subgraph Phase3["Phase 3: 全体同期 + LOD遷移 (~0.5ms)"]
        G[LOD Transitions<br/>Tier 昇格/降格]
        H[Attack 結果処理]
    end
    
    subgraph Phase4["Phase 4: 移動 + レンダリング (~2.1ms)"]
        I[MoveJob<br/>Flow Field 参照 + 移動]
        J[PositionToMatrixJob]
        K[CompactVisibleByTypeJob<br/>Frustum Culling]
        L[Graphics.RenderMeshInstanced]
    end
    
    Phase1 --> Phase2 --> Phase3 --> Phase4
    
    style Phase1 fill:#e8f5e9,stroke:#4caf50,color:#333
    style Phase2 fill:#e3f2fd,stroke:#2196f3,color:#333
    style Phase3 fill:#fff3e0,stroke:#ff9800,color:#333
    style Phase4 fill:#fce4ec,stroke:#e91e63,color:#333

核心原則:メインスレッドはスケジューリングと同期のみを担当し、実際の演算はすべてBurstコンパイルされたワーカースレッドで実行する。


最終結果

プロファイリング比較

指標3K(最適化前)10K(最適化後)20K(ストレステスト)
p50フレームタイム~10ms~5.2ms7.71ms
レンダリング方式デバッグカプセルGPU Instancing + VATGPU Instancing + VAT
分離ステアリング~3.3ms~0.73ms~0.73ms
ドローコール-58(バッチング)152
三角形/フレーム--41.2M
Job Worker稼働率~0%~9%~3.6%

10,000エージェントが3,000よりもエージェントあたりのフレームコストがむしろ低い。ボトルネックを除去し、パイプラインをデータ指向に再設計した結果だ。

最適化別の貢献度

flowchart LR
    A["3K Baseline<br/>~10ms"] -->|"SortJob<br/>-34.9%"| B["5.02ms"]
    B -->|"GPU Instancing<br/>+ VAT"| C["レンダリングコスト<br/>大幅削減"]
    C -->|"空間ハッシング<br/>O(N)→O(N)"| D["分離 0.73ms"]
    D -->|"LOD + Culling"| E["10K agents<br/>~5.2ms"]
    
    style A fill:#ffcdd2,stroke:#e53935,color:#333
    style E fill:#c8e6c9,stroke:#43a047,color:#333

まだ残っている課題

この記事で紹介した最適化で10,000エージェント @ 60fpsを達成したが、ここで終わりではない。後続の記事では:

  • Multi-Goal & Layered Flow Field — 複数目的地とレイヤー分離によるより複雑なAI行動の実装
  • クロスプラットフォームベンチマーク — Apple Silicon(M4/M4 Pro)での最適化結果

参考資料

この記事は著者の CC BY 4.0 ライセンスの下で提供されています。