「ドローコールを減らせば速くなる」は、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 モードの画面を並べておきます。
オーバーレイの数字は、キャプチャした瞬間の 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_ms、game_thread_ms、render_thread_ms、gpu_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 つの内訳を並べて、どれがいちばん近いかを見る。 削るのはそのあとです。
- 計測に使ったメトリクスの定義はデータモデルにまとまっています。
- SDK の導入手順は Unity SDK ガイドにあります。