unity
Unity ECSでSpatial indexを再利用した同陣営分離をA/B計測する
Unity Physicsを使わず、Uniform spatial cellsを索敵と共有して最近傍1体から離れる最小分離を実装し、1,000体と5,000体で比較した設計と結果をまとめます。
- 起票日
- 2026-08-17
- 更新日
- 2026-08-17
- 対象
- Unity Editor 6000.5.1f1 / Entities 6.5.0 / Windows 11
概要
多数のEntityへ分離・回避を追加するとき、最初からUnity Physicsや高品質な群れアルゴリズムを導入する必要があるとは限りません。すでに索敵用の空間索引があるなら、まず同じ索引から近距離の同陣営を1体だけ選び、最小の操舵を加える方法があります。
この記事では、Uniform spatial cellsで構築した索引を敵索敵と同陣営分離で共有し、Unity Physicsを使わずに最近傍1体から離れる処理を追加した設計をまとめます。同じWindows Playerで分離を無効・有効にし、1,000体と5,000体を各3回、合計12回A/B計測した結果も扱います。
結論
- 移動後の位置から空間索引を固定tickごとに1回だけ構築し、敵索敵と同陣営分離の検索を並列に実行できました。
- 全近傍のベクトルを加算せず、距離二乗と安定IDで最近傍1体を選ぶことで、並列コンテナの列挙順へ判断を依存させませんでした。
- 完全に同じ位置にいる2体には、IDペアから決定的かつ互いに逆向きの退避方向を生成しました。
- 1,000体では分離の有無による性能差をRun間変動から分離できませんでした。
- 5,000体では分離有効時のCPU Mainが
0.060 ms、約7.3%増え、参考FPSは約3.2%低下しました。 - 5,000体でもCPU Mainは
1 ms未満だったため、分離を無効化したり先回りで最適化したりせず、次のゲーム要素へ進む判断にしました。
この段階で実装したのは、同陣営の最近傍1体から離れる最小分離だけです。全近傍分離、高品質な群れ行動、敵や障害物からの回避、経路探索は対象に含めません。
空間索引を検索ごとに作り直さない
各Entityの位置をセルキーへ変換し、NativeParallelMultiHashMapへ登録するUniform spatial cellsを、すでに敵索敵で使用していました。同陣営分離にも同じ位置情報が必要ですが、分離専用の索引を追加すると、同じ固定tick内で全Entityの位置をもう一度登録することになります。
そこで、固定ステップ内の処理順を次のようにしました。
- 前tickに保存した分離結果を速度へ適用して移動する。
- 移動後の全位置から空間索引を1回だけ再構築する。
- 別陣営を探す索敵jobと、同陣営を探す分離jobを同じ索引から並列に実行する。
- 分離結果を保存し、次の固定tickの移動で使用する。
- 両方の検索jobが完了した後に、攻撃、ダメージ、死亡処理へ進む。
tick N
前回の分離結果を適用して移動
→ 空間索引を1回構築
├─ 敵索敵
└─ 同陣営分離
→ tick N+1で操舵へ反映
分離結果には1固定tick、60 Hzでは約16.7 msの遅延があります。その代わり、移動、索引構築、検索の依存関係が単純になり、索引の二重構築も避けられます。最小試作ではこの遅延を許容し、操作感に問題が確認されてから処理順を再検討します。
最近傍1体を一意に選ぶ
群れの分離では、半径内にいる全近傍からベクトルを集約する方法も考えられます。しかし、並列MultiHashMapの列挙順で浮動小数点ベクトルを加算すると、順序の違いによる微小な差が生じ得ます。候補を並べ替えてから集約する方法はありますが、最小試作としては仕組みが増えます。
今回は各Entityについて、自セルと分離半径が届く近傍セルだけを調べ、次の順で同陣営の最近傍1体を決めました。
- 自分自身と別陣営を除外する。
- 平方根を使わず、距離二乗が分離半径の二乗以下か判定する。
- より距離二乗が小さい候補を選ぶ。
- 距離二乗が等しければ、安定IDが小さい候補を選ぶ。
var nearestDistanceSquared = float.PositiveInfinity;
var nearestStableId = -1;
if (candidateEntity == queryEntity || candidateFaction != queryFaction)
{
continue;
}
var distanceSquared = math.distancesq(queryPosition, candidatePosition);
if (distanceSquared > radiusSquared)
{
continue;
}
var isNearer = distanceSquared < nearestDistanceSquared;
var winsTie = distanceSquared == nearestDistanceSquared &&
candidateStableId < nearestStableId;
if (isNearer || winsTie)
{
nearestDistanceSquared = distanceSquared;
nearestStableId = candidateStableId;
nearestPosition = candidatePosition;
}
実装では浮動小数点値の比較方法を検索処理全体で共通化し、候補がない状態も明示的な値で表しました。安定IDをtie-breakに使う場合は、同じシミュレーション内でIDが一意かつ不用意に変化しないことが前提です。
最近傍方式は全近傍方式と同じ見た目にはなりません。今回は低コストで重なりを減らす最小手段として採用し、見た目やゲーム性に不足が確認された場合だけ全近傍集約を検討します。
完全重複した2体を逆向きへ逃がす
2体の位置が完全に同じ場合、位置差はゼロになり、正規化しても退避方向を得られません。乱数で方向を選ぶと、再現性の確認が難しくなります。
そこで、2体の安定IDを小さい順に並べたペアをhashし、8方向から基準方向を1つ選びました。最後にIDの大小で符号を反転すると、同じペアは毎回同じ方向を使い、2体は互いに逆向きになります。
var lowerId = math.min(agentId, neighborId);
var upperId = math.max(agentId, neighborId);
var directionIndex = math.hash(new uint2((uint)lowerId, (uint)upperId)) & 7u;
var baseDirection = directions[(int)directionIndex]; // 8方向の単位ベクトル
return agentId <= neighborId ? baseDirection : -baseDirection;
8方向を事前に単位ベクトルとして用意するため、結果は有限で正規化済みです。hashだけで衝突判定を行うのではなく、同じIDペアの退避方向を選ぶ用途に限定しています。
分離後も移動速度を変えない
退避方向は、近いほど強く、分離半径の境界で0になるよう距離二乗に対して線形に減衰させました。
var proximity = math.saturate(1f - nearestDistanceSquared / radiusSquared);
var steering = awayDirection * (acceleration * proximity * deltaTime);
velocity = math.normalizesafe(velocity + steering, awayDirection) * configuredSpeed;
操舵後に設定速度へ正規化することで、分離有効時だけ移動速度が上がる比較を避けます。これは物理的な力の再現ではなく、既存速度を維持したまま進行方向だけを少し変えるゲーム向けの操舵です。
正しさを性能計測より先に確認する
A/B計測の前に、検索と操舵を次の範囲で検証しました。
- 自分自身、別陣営、半径外の候補を除外し、半径境界の同陣営を選ぶ。
- 等距離では安定IDが小さい候補を選ぶ。
- 領域の反対端をwrap近傍として扱わない。
- 固定seedで配置した64体すべてについて、最近傍、距離二乗、退避方向が全探索と一致する。
- 完全重複したIDペアの方向が有限、単位長、決定的かつ互いに逆向きになる。
- 操舵後も設定速度を維持し、保存済みの分離結果を移動前に適用する。
- 敵索敵の更新頻度を落とした場合も、分離結果は全Entityで毎固定tick更新され、戦闘用の検索結果を上書きしない。
- 起動引数による分離の無効・有効、重複指定、値欠落、不正値を検証する。
Unity Editorのバッチ実行でEdit Modeテスト49/49、Windows Standalone x64 Development Build、通常の可視Playerスモークを確認してから正式計測へ進みました。
ここで確認した決定性は、同じ固定tickの純粋計算、64体の全探索比較、完全重複ペアのテスト範囲です。実時間で終了するPlayer全体の最終状態がRun間で完全に一致することまでは主張しません。
同じPlayerで分離の有無だけを変える
分離をScene設定と起動引数で切り替えられるようにし、同じWindows Playerを12回起動しました。
| 項目 | 条件 |
|---|---|
| Unity | 6000.5.1f1 |
| Entities | 6.5.0 |
| Player | Windows Standalone x64 / Development Build |
| 解像度 | 1920×1080 / windowed |
| VSync | 無効 |
| Entity数 | 1,000 / 5,000 |
| seed | 固定 |
| 固定ステップ | 60 Hz |
| ウォームアップ | 10秒 |
| 計測時間 | 30秒 |
| 比較条件 | 分離無効 / 有効 |
| 回数 | 各条件3回、合計12回 |
体数の増減順と分離の無効・有効の順をRunごとに反転し、時間経過や温度変化が常に片方の条件へ偏らないようにしました。Entity数、seed、索敵頻度、HP、攻撃条件、計測方法は固定しています。
コンパイル、テスト、PlayerビルドにはUnity.exe -batchmodeを使用し、正式計測では生成済みWindows PlayerをPowerShellから直接起動しました。Unity CLIのunityコマンドで計測したものではありません。
A/B計測の結果
次の値は、各条件3回の平均です。参考FPSは30秒間の取得フレーム数から算出した処理量の目安であり、モニターに表示されたリフレッシュレートではありません。
| Entity数 | 分離 | 参考FPS | CPU Main | CPU Main範囲 | CPU Render | GPU Frame | Total Reserved Memory |
|---|---|---|---|---|---|---|---|
| 1,000 | 無効 | 736.1 | 0.742 ms | 0.714〜0.757 ms | 0.311 ms | 0.310 ms | 440.20 MiB |
| 1,000 | 有効 | 741.7 | 0.732 ms | 0.715〜0.746 ms | 0.306 ms | 0.310 ms | 440.86 MiB |
| 5,000 | 無効 | 615.6 | 0.821 ms | 0.792〜0.845 ms | 0.303 ms | 0.466 ms | 441.53 MiB |
| 5,000 | 有効 | 596.0 | 0.881 ms | 0.867〜0.908 ms | 0.311 ms | 0.469 ms | 441.53 MiB |
1,000体では、分離有効時のCPU Mainが0.010 ms低く、参考FPSは高くなりました。ただしCPU Mainの範囲は重なっており、この差を分離による改善とは扱いません。今回の計測精度では実質同等です。
5,000体では、分離有効時のCPU Mainが0.821 msから0.881 msへ0.060 ms、約7.3%増えました。無効時の最大0.845 msと有効時の最小0.867 msは重ならず、毎tickの同陣営検索と操舵によるCPUコストを観測できたと判断しました。
同じ5,000体で参考FPSは約3.2%低下しましたが、GPU Frameの差は約0.003 ms、予約メモリ平均は同値でした。主な差はCPU側です。
終了時の近傍数を密集低減の証拠にしない
分離有効時、計測終了時に半径内の同陣営最近傍が記録されていたEntityは、1,000体で平均504、5,000体で平均4,902でした。しかし、分離無効時は検索job自体を実行せず、近傍数として常に0を記録します。
したがって、この値を「分離によって密集が何体減ったか」というA/B比較には使えません。分離有効時にjobが実際に候補を見つけていたことと、局所密度によって検索の仕事量が変わることを確認する補助値としてだけ扱います。
また、計測終了は実時間を基準にしているため、終了時の固定tickはRun間で1 tick程度ずれる場合があります。単一の終了スナップショットを挙動の完全一致や分離品質の証拠にはしません。密集低減を評価するなら、両条件で同じ指標を計算し、固定tickで位置分布、最近傍距離、重なり数などを比較する必要があります。
今回の判断
5,000体でCPUコストは測定できましたが、CPU Mainは分離有効でも1 ms未満でした。そこで、次の判断にしました。
- Sceneの既定値は分離有効のまま維持する。
- 現在の最近傍1体方式を先回りで最適化しない。
- 全近傍集約は、見た目やゲーム性に不足が確認されてから検討する。
- 次のゲーム要素を追加し、同じ条件でCPU Main、GPU Frame、メモリの変化を追う。
小さなコストが測れたことと、すぐ最適化すべきことは同じではありません。絶対時間、Run間変動、次に検証したいゲーム価値を合わせて判断します。
注意点
- この結果は一台のPC、特定の密度、単純な描画を使ったDevelopment Playerの相対比較です。
- 5,000体で問題がなかったことは、より複雑なAI、描画、全近傍分離を含むゲームが同じ性能になることを意味しません。
- 最近傍1体方式は、高品質な群れ行動や衝突解決の代替ではありません。
- 分離検索は領域端をまたぐwrap近傍を扱っていません。移動のwrapと検索上の距離モデルを一致させる必要があるゲームでは別途設計が必要です。
- 平均値だけではフレームスパイクを評価できません。問題が見えた段階でpercentileや短時間のProfiler captureを追加します。
- 生ログには絶対パスや環境固有情報が含まれ得るため公開せず、比較に必要な集計値と条件だけを残します。