記事一覧へ戻る

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

「ドローコールを減らせば速くなる」は、Unity の最適化でいちばん最初に出てくる話です。 20,000 個のキューブを波打たせるシーンで、それを実際にやってみました。 ドローコールは 20,001 回から 153 回まで減りました。 それでもフレームタイムは、8.948 ms から 8.709 ms にしかなりません。 p50 で 0.24 ms の差です。 ここまで小さいと、動かしてみても違いはわかりません。

同じ絵を、キューブごとの GameObject を作らずに描き直すと、フレームタイムは 1.678 ms になりました。 FPS でいえば 111.8 から 596.0 です。 このときのドローコールは 60 回でした。

153 回と 60 回は、20,001 回から見ればどちらも同じくらい小さい数字です。 それでも片方は 8.709 ms のままで、もう片方は 1.678 ms になりました。 この落差を生んでいたのは、ドローコールの回数ではありませんでした。

計測した 3 つのモード

シーンは共通です。 円盤状に並べた 20,000 個のキューブに、進行する正弦波を掛けています。 毎フレーム、20,000 個すべての位置を書き換え、カメラはその周りをゆっくり回ります。 違うのは、キューブの描き方と、キューブごとに GameObject を持たせるかどうかの 2 点だけです。 3 モードとも実行ファイルは同じで、起動時の引数だけを変えています。

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

計測環境は Unity 6000.4.11f1、Built-in Render Pipeline(フォワードレンダリング)、スクリプティングバックエンドは Mono、グラフィックス API は D3D12 です。 GPU は RTX 4070 Ti、CPU は Core i5-13500、解像度は 1920x1080、vSync はオフ、シャドウは全モードで無効にしました。 動的バッチングと静的バッチングは明示的に切り、バッチ数のカウンターが 0 であることを確認しています。 各モードとも 15 秒のウォームアップののち 120 秒を計測しました。 以下の値は、断りがなければ p50 です。

Unity では Player Settings の Frame Timing Stats を有効にしないと、レンダースレッドと GPU タイムは 0 のままです。今回の切り分けは、この 1 つのチェックボックスに依存しています。

結果

モード 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

描画カウンターの内訳はこうです。

モード 標準ドローコール インスタンス化ドローコール インスタンスバッチ 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 回です。 同じ 153 という数字が SetPass Calls の列にも並んでいますが、これは別の指標です。 NAIVE の 20,001 回のうち 20,000 回がキューブ分で、残る 1 回はキューブ以外の描画です。

3 モードの画面を並べておきます。

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

オーバーレイの数字は、キャプチャした瞬間の 1 フレームの値です。 表に載せた p50 とは一致しません。

INSTANCED_RENDERER については、都合の悪い数字も並べておきます。 p50 では 111.8 から 114.8 へ上がった FPS も、平均で見ると 111.2 から 109.0 へ下がります。 フレームタイムの p95 は 10.644 ms から 12.919 ms へ悪化しました。 メモリも 169.0 MB から 176.6 MB へ増えています。 ドローコールを 20,001 回から 153 回に減らして得られたものは、p50 の 0.24 ms でした。

ボトルネックはレンダースレッドではなくゲームスレッド

答えは NAIVE の内訳に最初から出ています。 フレームタイムは 8.948 ms でした。 これに対して、並行して走る 3 つの内訳はゲームスレッドが 8.920 ms、レンダースレッドが 0.480 ms、GPU タイムが 0.909 ms です。 ここでいうゲームスレッドは、Unity の Profiler でメインスレッドとして見えるものです。 ドローコールを積むのは、レンダースレッドの仕事です。 仮にレンダースレッドを 0 ms にできたとしても、フレームタイムはゲームスレッドの 8.920 ms で決まります。

この内訳の読み方は単純です。 フレームタイムにいちばん近い値が、そのフレームを決めている場所です。

  • ゲームスレッドがフレームタイムとほぼ等しい:CPU のロジック側がボトルネック
  • レンダースレッドがフレームタイムに迫る:CPU の描画側がボトルネック
  • GPU タイムがフレームタイムに迫る:GPU がボトルネック

ドローコール削減が最も効きやすいのは、2 つめのケースです。 このシーンは 1 つめでした。 ゲームスレッドの 8.920 ms とフレームタイムの 8.948 ms が、ほぼ一致しています。

INSTANCED_RENDERER は、その読みを検証するために測ったモードです。 標準ドローコールは 20,001 回から 1 回に落ち、152 回のインスタンス化ドローコールと 149 バッチに置き換わりました。 レンダースレッドは 0.480 ms から 0.519 ms へ、むしろわずかに増えています。 GameObject を持つ 2 つのモードでは、レンダースレッドは 0.5 ms 前後から動きませんでした。 D3D12 のこの構成では、ドローコールの発行はフレームタイムに影響するほどの負荷ではありませんでした。 GPU タイムのほうは 0.909 ms から 0.703 ms へ下がっています。 効いてはいるのですが、GPU はもともとボトルネックではないので、フレームタイムには出てきません。

効いたのは、GameObject を 20,000 個つくるのをやめたこと

INSTANCED_DIRECT では、ドローコールの出し方だけでなく、シーンの構成そのものも変えています。 キューブごとの GameObject、Transform、MeshRenderer を作らず、行列の配列を毎フレーム書き換えて 1 つのスクリプトから描きます。 Graphics.RenderMeshInstanced は 1 回の呼び出しで最大 1,023 インスタンスまでしか描けません。 そこで 20,000 個を 1,023 個ずつに分割して渡しています。 呼び出しは 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);
    }
}

ゲームスレッドは NAIVE で 8.920 ms、INSTANCED_RENDERER でも 8.680 ms でした。 それが 1.668 ms になりました。 消えたのは、20,000 個の GameObject が毎フレーム要求していた CPU 時間です。 コンポーネントの更新、GameObject 単位のカリング、Transform の同期が、その中身です。

ただし、このモードは 2 つのことを同時に変えています。 ゲームスレッドが 8.680 ms から 1.668 ms へ落ちた理由を、私たちは主に GameObject 側の寄与と読んでいます。 GameObject を残したまま描画経路だけを変えた INSTANCED_RENDERER で、ゲームスレッドが動かなかったからです。 とはいえ、この 3 モードだけで 2 つの要因を完全に切り分けられるわけではありません。 SetPass Calls が 153 回から 2 回へ、GPU タイムが NAIVE の 0.909 ms から 0.232 ms へ下がったのは描画経路側の効果で、GameObject の有無とは別の話です。

メモリの減り方も、同じく GameObject をやめたことの結果です。 169.0 MB から 56.6 MB へ、112 MB 減りました。 GameObject 1 個あたり約 5.6 KB ですが、この値は差を個数で割ったものにすぎません。 SDK が送っているのは、プロセス全体の確保済みメモリ(Profiler.GetTotalAllocatedMemoryLong)です。 INSTANCED_DIRECT 側は、20,000 個分の Matrix4x4 として約 1.28 MB を新たに確保しています。 それでも、この 112 MB はメッシュや頂点といった描画データが減った分ではありません。 三角形の数は 3 モードとも 240,002 個のままでした。

自分のシーンがどれに当たるかを見分ける

見るのはフレームタイムと 3 つの内訳だけです。 ゲームスレッド、レンダースレッド、GPU タイムのどれがフレームタイムに最も近いかを確かめます。 Framedash の Unity SDK は、この 4 つを perf_heartbeat として自動で送ります(frame_time_msgame_thread_msrender_thread_msgpu_time_ms)。

今回の計測で Framedash が担当したのは、その収集と比較です。 モードごとに BeginAutomatedSession の build_id を変えて 3 回の実行を区別し、CLI で差分を取りました。

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%」

上の表の p50 と少しずれるのは、集計しているサンプルが違うからです。 サーバー側は、ウォームアップ後の自動セッション 120 秒間に 1 秒ごとに送ったイベントと、SDK が 10 秒ごとに送る perf_heartbeat を合わせて集計しています。 これで各モード 133 サンプルです。 一方、表の p50 はフレームごとに取った値です。 サーバー側の出力をそのまま載せているため、桁数も表とは異なります。 CI から同じコマンドを叩けば、リリース前にビルド間の差をこの粒度で確認できます。

どこまでが今回の話か

ここまでの値は、1 つの構成で測った 1 通りのワークロードのものです。 D3D12、Built-in Render Pipeline、Mono、デスクトップ GPU という前提が変われば、内訳の比率も変わります。 ドライバー側のオーバーヘッドが大きいモバイル向けのグラフィックス API、SRP Batcher が働く URP や HDRP、IL2CPP ビルドで、レンダースレッドの比率が今回と同じである保証はありません。 シャドウを有効にすればパスが増え、ドローコール 1 回の重みも変わります。

計測の作りにも注意点があります。 描画カウンターの値は Development ビルドから、時間の値は Release ビルドから取りました。 プロファイラーのオーバーヘッドを時間の側に混ぜないためです。 カウンターはフレームごとのばらつきが大きく、最小値と最大値は当てになりません。 カウンターについて p50 しか載せていないのは、そのためです。 20,000 個すべての位置を毎フレーム書き換えるワークロードである点も、自分のシーンに当てはめる前に確認してください。

測ってから削る

ドローコール削減が最も効きやすいのは、レンダースレッドがフレームタイムに迫っているときです。 測っておけば、ドローコールを削る作業が今回のような 0.24 ms の仕事なのかどうかを、手を付ける前に判断できます。 ゲームスレッドがフレームタイムとほぼ同じなら、次に疑うのは GameObject の数です。

順番はこうです。 まず Frame Timing Stats を有効にする。 次にフレームタイムと 3 つの内訳を並べて、どれがいちばん近いかを見る。 削るのはそのあとです。