unity
Unity 6のWindows PlayerでECS 1,000〜5,000体を再現可能に計測する
Unity ECSの最小プロトタイプをWindows Playerで15回計測し、軽量な性能比較へ切り替えた手順と結果の読み方をまとめます。
- 起票日
- 2026-08-16
- 更新日
- 2026-08-16
- 対象
- Unity Editor 6000.5.1f1 / Entities 6.5.0 / Windows 11
概要
ECSで多数のEntityを表示できても、Entity数を増やしたときにCPUとGPUのどちらが先に影響を受けるかは、同じ条件で測らなければ判断できません。
この記事では、単純な移動と描画だけを行うUnity ECSのプロトタイプを、Windows Standalone x64 Development Playerとして実行し、1,000〜5,000体を1,000体刻みで各3回、合計15回計測した方法をまとめます。
重要なのは特定環境のFPSそのものではなく、次の3点です。
- 実行条件を固定し、変更前後を比較できるようにする。
- 生のProfilerデータを長時間保存せず、段階比較に必要な高レベル指標だけを集める。
- 現在最も長い処理と、Entity数の増加に最も敏感な処理を分けて考える。
結論
- 1,000体から5,000体へ増やすと、取得フレーム数から算出した参考FPSは約13%低下しました。
- CPU Main Threadの3回平均は
0.726 msから0.753 msで、Entity数に対する増加は小さい結果でした。 - GPU Frameの3回平均は
0.310 msから0.464 msへ約50%増え、体数増加に対して最も一貫した変化を示しました。 - 絶対時間ではCPU Main ThreadがGPU Frameより長い一方、追加Entityへの感度はGPU側が高いと読めます。
- 5,000体の段階では先回りの最適化をせず、次の処理を加えた後に同じ条件で再計測する判断にしました。
- 起動直後からの生Profiler captureは約4.59 GBまで膨らみ、バッファ上限へ達したため、
FrameTimingManagerとUnityの予約メモリ値を使う軽量計測へ切り替えました。
今回のコンパイル、テスト、PlayerビルドにはUnity.exe -batchmodeを使用し、正式計測では生成済みのWindows PlayerをPowerShellから直接起動しました。Unity CLIのunityコマンドで計測したものではありません。
計測対象を小さく固定する
計測用プロトタイプには、次の処理だけを含めました。
- 固定seedからEntityを生成する。
- 60 Hzの固定ステップで単純移動する。
- 領域端へ到達したEntityを反対側へ戻す。
- 単純なMeshと単一Materialで描画する。
戦闘、索敵、衝突、空間セル、分離・回避、資源、アップグレードは含めていません。Unity Physicsと個体ごとのNavMeshも使用していません。
これは実際のゲーム性能を予測するためではなく、今後処理を追加したときの差分を測る基準を作るためです。最初の基準に複数の仕組みを入れると、増減の原因を分けられなくなります。
EditorではなくWindows Playerで測る
Unity EditorのPlay Modeは、Editor自身のUI、Inspector、Scene Viewなどと同じプロセスやリソースを共有します。Unityのアプリケーションのプロファイリングでも、正確なタイミングは公開対象のプラットフォーム上で測り、Editorは素早い反復確認に使うよう案内されています。
今回はWindows向けゲームを想定し、正式値をWindows Standalone x64 Development Playerから取得しました。Editor再生は、Sceneが起動してEntityを描画できることを確認するスモークテストだけに使っています。
Development BuildにはProfilerが含まれるため、Release Buildと同じ性能ではありません。この記事の数値は、同じDevelopment Build内でEntity数を変えた相対比較として扱います。
計測条件
| 項目 | 条件 |
|---|---|
| Unity | 6000.5.1f1 |
| Entities | 6.5.0 |
| Player | Windows Standalone x64 / Development Build |
| 解像度 | 1920×1080 / windowed |
| VSync | 無効 |
| フレームレート上限 | 無制限 |
| バックグラウンド更新 | 有効 |
| Entity数 | 1,000 / 2,000 / 3,000 / 4,000 / 5,000 |
| seed | 固定 |
| 固定ステップ | 60 Hz |
| ウォームアップ | 10秒 |
| 計測時間 | 30秒 |
| 回数 | 各Entity数で3回、合計15回 |
計測したPCは、Core i7-13700H、GeForce RTX 4060 Laptop GPU、RAM 32 GB、Windows 11のノートPCです。絶対値はハードウェア、電源設定、温度、常駐処理、描画内容によって変わるため、異なる環境の値を同じ表で比較しません。
実行順は、1回目を1,000→5,000体、2回目を5,000→1,000体、3回目を1,000→5,000体としました。増加順だけを繰り返さず、時間経過や温度変化とEntity数が常に同じ向きにならないようにしています。
高レベル指標だけを取得する
各フレームで、次の値を取得しました。
FrameTiming.cpuMainThreadFrameTimeFrameTiming.cpuRenderThreadFrameTimeFrameTiming.gpuFrameTimeProfiler.GetTotalReservedMemoryLong()
FrameTimingManager.CaptureFrameTimings()の後に、完了済みの最新フレームをGetLatestTimings()から取得します。プラットフォームによって取得できないフレームがあるため、戻り値が0の場合は集計しません。GPU値もgpuFrameTime > 0のサンプルだけを平均します。
private readonly FrameTiming[] frameTimings = new FrameTiming[1];
private void Update()
{
FrameTimingManager.CaptureFrameTimings();
if (FrameTimingManager.GetLatestTimings(1, frameTimings) == 0)
{
return;
}
var timing = frameTimings[0];
cpuMainTotal += timing.cpuMainThreadFrameTime;
cpuRenderTotal += timing.cpuRenderThreadFrameTime;
if (timing.gpuFrameTime > 0d)
{
gpuTotal += timing.gpuFrameTime;
gpuSampleCount++;
}
var reservedMemoryBytes = Profiler.GetTotalReservedMemoryLong();
memoryTotal += reservedMemoryBytes;
memoryPeak = Math.Max(memoryPeak, reservedMemoryBytes);
sampleCount++;
}
UnityのFrameTimingでは、CPU Main、CPU Render、GPUの各値は、それぞれの処理区間として定義されています。これらは互いに並行して進むことがあり、単純に足した値が総フレーム時間になるわけではありません。
Profiler.GetTotalReservedMemoryLong()が返すのは、Unityが現在と将来の割り当て用に予約したメモリです。OSのprocess working setや、実際に使用中のメモリとは異なります。値の意味はUnityのScripting APIで確認できます。
Playerを段階実行する
計測用Sceneだけを含めるWindows Development Playerのビルド処理をBuildPipeline.BuildPlayerで用意し、Editor実行ファイルをバッチモードで一度だけ起動しました。
& $unityEditor `
-batchmode `
-nographics `
-projectPath $projectPath `
-buildTarget win64 `
-executeMethod '<プロジェクトのビルドメソッド>' `
-quit `
-logFile $buildLogPath
ビルドログの終了コードだけでなく、BuildReport.summary.resultがSucceededであることと、Player実行ファイルが生成されたことを確認します。
Player側には、Entity数、計測モード、ログの保存先を受け取る独自の起動引数を用意しました。PowerShellでは、Entity数と実行順だけを変え、同じPlayerを15回起動します。
$orders = @(
@(1000, 2000, 3000, 4000, 5000),
@(5000, 4000, 3000, 2000, 1000),
@(1000, 2000, 3000, 4000, 5000)
)
for ($run = 0; $run -lt $orders.Count; $run++) {
foreach ($entityCount in $orders[$run]) {
$logPath = Join-Path $resultRoot "$entityCount/run-$($run + 1).log"
$argumentList = "-entityCount $entityCount -measure -logFile `"$logPath`""
$process = Start-Process `
-FilePath $playerPath `
-ArgumentList $argumentList `
-PassThru `
-Wait
if ($process.ExitCode -ne 0) {
throw "Measurement failed: count=$entityCount run=$($run + 1)"
}
}
}
Start-Process -ArgumentListは渡した配列を空白区切りの文字列へ結合するため、空白を含み得るログパスは引用符で保護します。実際の引数名はプロジェクト側の実装に合わせます。重要なのは、Sceneや設定ファイルを書き換えず、同じPlayer、seed、解像度、ウォームアップ時間、計測時間を維持したままEntity数だけを変えることです。
各実行の最後には、条件、サンプル数、平均値、peakを1行のJSONとしてログへ出力しました。生ログは環境固有の情報や絶対パスを含む可能性があるため公開せず、比較に必要な集計値だけを残します。
1,000〜5,000体の結果
次の表は各Entity数の3回平均です。
| Entity数 | 参考FPS | CPU Main | CPU Render | GPU Frame | Total Reserved Memory 平均 / peak |
|---|---|---|---|---|---|
| 1,000 | 737.3 | 0.726 ms | 0.319 ms | 0.310 ms | 440.86 / 441.53 MiB |
| 2,000 | 718.3 | 0.736 ms | 0.315 ms | 0.357 ms | 440.20 / 441.53 MiB |
| 3,000 | 693.7 | 0.751 ms | 0.309 ms | 0.396 ms | 440.86 / 441.53 MiB |
| 4,000 | 670.9 | 0.755 ms | 0.312 ms | 0.434 ms | 441.53 / 441.53 MiB |
| 5,000 | 641.0 | 0.753 ms | 0.309 ms | 0.464 ms | 440.86 / 441.53 MiB |
参考FPSは30秒間の取得フレーム数から算出した処理量の目安であり、モニターに表示されたリフレッシュレートではありません。
結果は次のように読みました。
- CPU Main Threadは、1,000体で
0.726 ms、5,000体で0.753 msでした。Run間の変動に比べて増加傾向が小さく、今回の単純なBurst移動は体数増加による影響が目立ちませんでした。 - CPU Render ThreadとTotal Reserved Memoryは、1,000〜5,000体でほぼ横ばいでした。
- GPU Frameは
0.310 msから0.464 msへ増え、Entity数に対して最も一貫した変化を示しました。 - 5,000体時点の絶対時間はCPU Main Threadの方が長いため、現状の最初の制約候補はCPU Main Threadです。一方、今後さらに描画数を増やす場合の感度はGPU側にあります。
平均値だけでは一時的なスパイクやフレーム分布を判断できません。ゲームループが増えた段階ではpercentileや短時間のProfiler captureも必要ですが、最初の段階比較にはこの軽量な平均値を使います。
生Profiler captureが肥大化した失敗
最初は、Player起動時から-profiler-log-fileで生Profilerデータを保存しようとしました。しかし、VSyncとフレームレート上限を外した状態で全データを流し続けた結果、captureは約4.59 GBまで増え、実行ログに512 MBのProfiler送信バッファ上限到達が記録され、収集が停止しました。
UnityのProfiler.logFileには、出力が追いつかない場合にフレームをスキップすることが記載されています。また、Profiler.maxUsedMemoryは、Profilerが書き出しより速くデータを収集した場合、バッファ上限へ達すると収集を停止すると説明しています。
バッファを増やせば停止までの時間は延ばせますが、段階計測で毎回数GBのファイルを作る問題は残ります。そのため、用途を次のように分けました。
| 用途 | 手段 |
|---|---|
| Entity数ごとの傾向比較 | FrameTimingと予約メモリの軽量ログ |
| 特定Systemの原因調査 | Profiler markerを使った短時間capture |
| フレームスパイクの詳細調査 | 対象時間とProfiler areaを絞ったcapture |
大量の生データを常に保存するのではなく、まず軽量なカウンターで変化を見つけ、必要な箇所だけ詳細に掘る方が扱いやすい結果になりました。
次の計測で変えないもの
次は、Uniform spatial cellsと距離二乗による近傍候補の抽出を追加する予定です。比較を成立させるため、次の条件は変えません。
- 同じPlayer種別と解像度
- 同じEntity数とseed
- 同じ固定ステップ
- 同じウォームアップ時間と計測時間
- 同じ3回の実行順
- 同じ集計方法
変更するのは、候補抽出Systemの有無と処理内容です。比較対象以外を固定することで、新しい処理がCPU Main Threadや描画へ与えた影響を判断できます。
注意点
- この結果は、一台のPCと単純なMesh・Materialを使ったプロトタイプの値です。
- Development Buildのため、最終的なRelease Build性能を示すものではありません。
- 5,000体で問題がなかったことは、戦闘や索敵を含む5,000体のゲームが問題なく動くことを意味しません。
- 平均値はフレームスパイクを隠すため、操作感や安定性の確認には別の指標が必要です。
- GPU timingはプラットフォームやGraphics APIによって取得できない場合があります。計測開始時に機能の有効性と有効サンプル数を確認します。
- ログを公開するときは、絶対パス、ユーザー名、プロジェクト名、ライセンスや認証に関する情報を除去します。