記事一覧へ戻る

Unity でドローコールを 20,001 回から 153 回に減らしても、ほとんど速くならなかった理由

20,000 個のキューブを動かす Unity のシーンで、ドローコールを 20,001 回から 153 回に減らしました。 フレームタイムの中央値は 8.948 ms から 8.709 ms へ、改善はわずか 0.24 ms でした。

一方、キューブごとの GameObject を作らず、Graphics.RenderMeshInstanced で直接描くモードでは 1.678 ms になりました。 スレッド別の時間を比べると、GPU インスタンシングだけでは速くならなかった理由が見えてきます。 ただし、直接描画の改善幅を「ドローコールが減ったから」だけで説明することもできません。

比較した 3 つの描画モード

シーンは 3 モードとも共通です。 円盤状に並べた 20,000 個のキューブを進行する正弦波で動かし、カメラをその周りでゆっくり回します。 毎フレーム、すべてのキューブの位置を書き換えます。 同じビルド種別の中では実行ファイルも共通で、起動引数だけでモードを切り替えます。

  • NAIVE:キューブ 1 個につき GameObject と MeshRenderer を 1 つずつ持ち、マテリアルの GPU インスタンシングは無効
  • INSTANCED_RENDERER:GameObject の構成は同じで、マテリアルの GPU インスタンシングを有効化
  • INSTANCED_DIRECT:キューブごとの GameObject を作らず、1 つのスクリプトから Graphics.RenderMeshInstanced で描画

計測環境は次のとおりです。

  • Unity 6000.4.11f1、Built-in Render Pipeline のフォワードレンダリング、Mono、D3D12
  • RTX 4070 Ti、Core i5-13500、解像度 1920x1080
  • 全モードで vSync とシャドウを無効化
  • 動的バッチングと静的バッチングを無効化し、それぞれのバッチ数カウンターが 0 であることを確認

各モードで 15 秒のウォームアップ後、120 秒を計測しました。 時間は Release ビルドから、描画カウンターは Development ビルドから取得しています。 Development ビルドのプロファイリング負荷を時間の比較に持ち込まないためです。 したがって、以下の 2 つの表は同じフレームを同時に測ったものではありません。

値は、断りがなければ中央値の p50 です。 フレームタイムの p95 は、測ったフレームの 95% がその値以下に収まる境界を示します。 p95 が大きいほど、遅い側のフレームに時間がかかっています。

Release ビルドで FrameTimingManager を使う場合は、Player Settings の Frame Timing Stats を有効にします。設定は Unity の手順を参照してください。値が取得できない場合や 0 の場合に、処理負荷もないとは判断できません。

フレームタイムと描画カウンター

モード FPS フレームタイム フレームタイム p95 ゲームスレッド レンダースレッド GPU タイム メモリ
NAIVE 111.8 8.948 ms 10.644 ms 8.920 ms 0.480 ms 0.909 ms 169.0 MB
INSTANCED_RENDERER 114.8 8.709 ms 12.919 ms 8.680 ms 0.519 ms 0.703 ms 176.6 MB
INSTANCED_DIRECT 596.0 1.678 ms 2.774 ms 1.668 ms 0.091 ms 0.232 ms 56.6 MB

GPU インスタンシングだけでは、中央値はほとんど変わりませんでした。 ほかの指標も一様には改善していません。 平均 FPS は 111.2 から 109.0 に下がり、フレームタイムの p95 は 10.644 ms から 12.919 ms に、メモリは 169.0 MB から 176.6 MB に増えています。 p50 の小さな改善だけでは、安定して速くなったとは判断できません。

モード 標準ドローコール インスタンス化ドローコール インスタンスバッチ SetPass Calls 三角形
NAIVE 20,001 0 0 153 240,002
INSTANCED_RENDERER 1 152 149 153 240,002
INSTANCED_DIRECT 20 40 59 2 240,002

この記事の「ドローコール」は、標準とインスタンス化の合計です。 NAIVE は 20,001 回、INSTANCED_RENDERER は 1 + 152 で 153 回、INSTANCED_DIRECT は 20 + 40 で 60 回になります。 SetPass Calls にも 153 という値がありますが、これは別の指標です。 NAIVE の 20,001 回のうち 20,000 回がキューブ分で、残る 1 回はキューブ以外の描画です。

以下は各モードの画面です。 オーバーレイの数字はキャプチャした 1 フレームの値で、表の中央値とは異なります。

波打つ円盤状に並んだ 20,000 個のキューブ。左上のオーバーレイに NAIVE、FPS 125、FRAME TIME 8.01 ms、DRAW CALLS 20,001、OBJECTS 20,000 と表示されている
NAIVE。キューブごとに GameObject を持ち、この画面ではドローコールは 20,001 回。
同じキューブの円盤。左上のオーバーレイに GPU INSTANCING ONLY、FPS 118、FRAME TIME 8.44 ms、DRAW CALLS 153、OBJECTS 20,000 と表示されている
INSTANCED_RENDERER。GPU インスタンシングでドローコールは 153 回。キューブごとの GameObject は残っています。
同じキューブの円盤。左上のオーバーレイに RenderMeshInstanced、FPS 654、FRAME TIME 1.53 ms、DRAW CALLS 60、OBJECTS 20,000 と表示され、下部の帯に No GameObjects: 60 draw calls - 5x FPS とある
INSTANCED_DIRECT。キューブごとの GameObject を外すと同時に、描画経路も変えています。画面下の「FPS は 5 倍」は、このキャプチャの比較であり、GameObject だけの効果ではありません。

スレッド別の時間から何がわかるか

NAIVE のゲームスレッドは 8.920 ms で、フレームタイムの 8.948 ms とほぼ同じでした。 レンダースレッドは 0.480 ms、GPU は 0.909 ms です。 ここでいうゲームスレッドは、Unity のメインスレッドを指します。 処理時間は重なるため、3 つを足してフレームタイムを求めることはできません。 また、各指標の p50 を並べても、特定の 1 フレームの内訳にはなりません。

この比較では、まずメインスレッドを調べるのが妥当です。 INSTANCED_RENDERER でも、ドローコールが大幅に減った一方でゲームスレッドは 8.680 ms のままでした。 レンダースレッドは 0.480 ms から 0.519 ms と、どちらも約 0.5 ms です。 GPU タイムは 0.909 ms から 0.703 ms に下がりましたが、それに見合うフレームタイムの短縮にはつながりませんでした。

自分のシーンでは、次に調べる場所の目安として使えます。

  • メインスレッドがフレームタイムに近い:スクリプト、Transform の更新、メインスレッド上の描画準備を調べる
  • レンダースレッドがフレームタイムに近い:描画コマンドの発行や描画状態の切り替えを調べる
  • GPU タイムがフレームタイムに近い:シェーダー、オーバードロー、描画パスなどの GPU 処理を調べる

ただし、最も大きい値だけで原因は確定しません。 フレームレート制限、画面表示待ち、スレッド間の同期も関係します。 時間の定義は Unity の FrameTiming リファレンスを確認し、処理ごとの負荷は Profiler の Timeline で追います。 メインスレッドの時間すべてがゲームロジックというわけではなく、ドローコール削減が複数のスレッドに影響する場合もあります。

キューブごとの GameObject を外して変わったこと

INSTANCED_DIRECT では、キューブごとの GameObject、Transform、MeshRenderer を作りません。 1 つのスクリプトで行列の配列を毎フレーム更新し、描画します。 このベンチマークは 20,000 個の行列を 1,023 個ずつに分けて渡しているため、C# API の呼び出しは 20 回です。 この回数と、表の描画カウンターは一対一には対応しません。

private void RenderInstanced()
{
    for (int start = 0; start < _objectCount; start += MaxInstancesPerCall)
    {
        int chunk = Mathf.Min(MaxInstancesPerCall, _objectCount - start);
        Graphics.RenderMeshInstanced(_renderParams, _cubeMesh, 0, _matrices, chunk, start);
    }
}

1,023 という分割サイズは、このベンチマークの実装値です。 Unity が示す上限は 1,023 インスタンスですが、実際の上限はインスタンスデータとシェーダー設定に依存し、行列を 2 つ持つ標準構成では 511 になります。 コードを転用する際は RenderMeshInstanced の API リファレンスを確認してください。

ゲームスレッドは 1.668 ms になりました。 NAIVE の 8.920 ms、INSTANCED_RENDERER の 8.680 ms と比べると、キューブごとのオブジェクトを維持して更新する負荷を調べる根拠になります。 候補はコンポーネントの処理、Transform の同期、カリングなどですが、今回の集計値からそれぞれの寄与は測れません。

直接描画のモードは描画経路も変えているため、「GameObject を外したことだけによる短縮量」は切り分けられません。 SetPass Calls は 153 回から 2 回に、GPU タイムは NAIVE の 0.909 ms から 0.232 ms に下がっています。 この結果は、オブジェクトの持ち方と描画方式を合わせて変えた効果です。

メモリは 169.0 MB から 56.6 MB へ、約 112 MB 減りました。 差を 20,000 個で割るとキューブ 1 個あたり約 5.6 KB ですが、GameObject 一般のメモリ消費量ではありません。 SDK が使う Profiler.GetTotalAllocatedMemoryLong は、Unity 内部のアロケーターが使用しているメモリを返します。 プロセス全体のメモリ使用量とは範囲が異なります。

直接描画でも、20,000 個の Matrix4x4 に約 1.28 MB を確保しています。 三角形の数は 3 モードとも 240,002 個で、画面に描く形状を減らさずにメモリは減りました。 ただし、三角形の数が同じというだけでは、どの割り当てが減ったかまではわかりません。

Framedash で実行結果を比較する

今回の実験では、Framedash で時間を収集し、実行結果を比較しました。 Unity SDK は perf_heartbeat に frame_time_ms、game_thread_ms、render_thread_ms、gpu_time_ms を含めて送信します。 モードごとに BeginAutomatedSession の build_id を変えて記録し、CLI で NAIVE と INSTANCED_DIRECT を比較しました。

次のコマンドは、2 つの変数に対応するビルド ID を設定してから実行します。

framedash perf-diff --baseline "$NAIVE_BUILD_ID" --candidate "$INSTANCED_DIRECT_BUILD_ID" \
  --threshold 5 --fail-on-regression

記録した出力は次のとおりです。

  • フレームタイム p50:9.011 ms から 1.701 ms(-81.12%)
  • GPU タイム:0.9257 ms から 0.2406 ms(-74.00%)
  • メモリ:175.7 MB から 59.4 MB(-66.20%)
  • 各モード 133 サンプル、判定は「No performance regression beyond 5%」

表と値が異なるのは、集計するサンプルが違うためです。 サーバー側では、ウォームアップ後の自動セッション 120 秒間に毎秒送ったイベントと、10 秒ごとの perf_heartbeat を合わせています。 各モード 133 サンプルで、表のようなフレームごとの集計ではありません。 小数の桁数は、記録したサーバー出力に合わせています。

この比較はリリース前に CI からも実行できますが、一定間隔で集めたサンプルの p50 で、フレームごとの遅延分析を代用することはできません。 判定の対象は、比較した指標と閾値です。 これだけでボトルネックを特定したり、全フレームの改善を示したりはできません。

この実験でわかる範囲

今回のワークロードでは、全キューブを毎フレーム動かしました。 計測環境は 1 つです。 この条件では、GPU インスタンシングだけによるフレームタイム中央値の改善は小さく、直接描画のモードは大きく改善しました。 ほとんどのオブジェクトが静止しているシーンや、描画の負荷が異なるシーンでの改善幅を示すものではありません。

モバイル端末、別のグラフィックス API、SRP Batcher を使う URP や HDRP、IL2CPP、シャドウの有無によって、処理時間の比率は変わり得ます。 描画カウンターもフレーム間のばらつきが大きかったため、最小値や最大値に頼らず中央値を載せました。 ここに示した実行結果だけでは、繰り返し計測したときのばらつきはわかりません。 0.24 ms のような小さな差を安定した改善とみなす前に、ベースラインを再計測する必要があります。

ドローコールを減らす前に確認すること

  1. 実際の利用に近いビルドで、メインスレッド、レンダースレッド、GPU の時間を取得する。解釈する前にフレームレート制限や待機時間を確認する
  2. Profiler で重い処理を特定する。メインスレッドの時間が長ければオブジェクトの更新を調べるが、それだけで GameObject を削除する判断はしない
  3. できるだけ 1 つの要因を変え、同じワークロードを繰り返して、中央値、遅いフレーム、メモリを比較する