ホーム / ブログ / パフォーマンス
公開日: 2026年4月16日 約 10 分 パフォーマンス
モバイルゲーム最適化:ドローコールとオーバードローの実測でAndroid実機のフレームレートを17FPS改善した記録

モバイルゲーム最適化:ドローコールとオーバードローの実測でAndroid実機のフレームレートを17FPS改善した記録

ミドルレンジのAndroid端末で、タワーディフェンスのシーンが41FPSから動きませんでした。ポリゴン数には一切触れず、ドローコール、マテリアル、オーバードローを実機で計測しながら同じシーンを58FPSまで引き上げた手順と、それぞれの変更で実際に取り戻せたミリ秒、そして測定の順番までまとめて記録しています。

41FPSで頭打ちになったタワーディフェンスのシーン

昨シーズンの冬、あるタワーディフェンス案件の最終最適化を担当しました。Redmi Note 10(Snapdragon 678、Adreno 612)では、ウェーブ12に入るころにフレーム時間が24.3msまで伸び、平均41FPSしか出ていません。最初の見立てはありがちなもので、画面上の敵が多すぎるのだからAIと物理が重いのだろうと考えました。二日かけてタスクプランナーをプロファイルしましたが何も出てこず、ゲームロジックのスレッドは6.1msで終わっていました。

Unity Profilerがレンダースレッド14.8ms、GPU 26msと表示した時点で状況がはっきりしました。Frame Debuggerは780本を超えるドローコールを数えており、そのうちおよそ400本はタワーの射程リング、ダメージ数値、地面のデカール、煙のパーティクルです。つまり画面の大部分が半透明のレイヤーで何重にも覆われ、同じ領域が毎回塗り直されていたわけです。敵の数を半分にしてもフレーム時間は1.2msしか縮まらず、原因はそこにありませんでした。

本当の間違いは、私自身のモバイルゲーム最適化の定義にありました。長年これを「ポリゴン数を減らすこと」だと思い込んでいたのです。シーン全体の三角形は21万で、Adreno 612はこのジオメトリを難なく描き切っていました。負荷は別々の二つの方向から来ていました。ドローコールごとのCPUセットアップコストと、ピクセルごとにメインメモリへ書き出される帯域です。以下は、その二つをどう切り分け、どの変更で何ミリ秒を取り戻したかの記録です。

どのバッチングがいつ効くのか

仕組みは三つあり、それぞれ役割が違います。SRP Batcher はドローコールの本数を減らしません。同じシェーダーバリアントを共有する描画のマテリアル定数を永続的なGPUバッファに置き、呼び出しごとのCPUセットアップを安くするものです。つまり780本は780本のまま、ただし一本ずつが目に見えて軽くなります。重要なのはマテリアルの数ではなくシェーダーバリアントの数で、ここを見落としていたせいで長い間まとめる対象を間違えていました。

本数そのものを減らすのはGPU instancingです。同じメッシュと同じマテリアルなら、一回の呼び出しで最大1023インスタンスまで描けます。落とし穴はここで、URPではシェーダーがSRP Batcher互換だとinstancingの経路はそもそも通らず、SRP Batcherが優先されます。タワーの土台を Graphics.DrawMeshInstanced で自分で描くまで気づきませんでした。Frame Debuggerに「SRP Batch」の行が並ぶのを見て、instancingが働いていると思い込んでいたのです。

Static batchingはビルド時にメッシュを一つの大きな頂点バッファへ統合します。効果は本物ですが代償もあり、今回のシーンでは38MBの追加メッシュメモリと、カリング粒度の喪失を招きました。統合されたグループは丸ごと描かれるか、まったく描かれないかのどちらかだからです。まったく動かず、カメラ内でどうせ一緒に映る地面パーツにだけ有効のまま残しました。木や岩でオフにすると、メモリも実際に送られる三角形の数も減りました。

もう一つ、静かにバッチを壊すものがあります。MaterialPropertyBlock です。タワーの階級を色分けするために各レンダラーへ付けていたのですが、それだけでそれらのオブジェクトはSRP Batcher互換から外れていました。色のバリエーションをインスタンスデータと頂点カラーへ移したところ、レンダースレッドは14.8msから11.0msに下がりました。シェーダーは一行も書き換えていません。

BatchingSetup.cs
using UnityEngine; using UnityEngine.Rendering; // Tower bases share one atlas material, so they can be submitted as one // instanced call. URP prefers the SRP Batcher over instancing here. public sealed class BatchingSetup : MonoBehaviour { private const int MaxPerBatch = 1023; [SerializeField] private Mesh _baseMesh; [SerializeField] private Material _atlasMaterial; [SerializeField] private Transform[] _slots; private readonly Matrix4x4[] _matrices = new Matrix4x4[MaxPerBatch]; private int _count; private void Awake() { _count = Mathf.Min(_slots.Length, MaxPerBatch); for (int i = 0; i < _count; i++) _matrices[i] = _slots[i].localToWorldMatrix; } private void Update() { // One draw call for up to 1023 bases instead of one per renderer. Graphics.DrawMeshInstanced(_baseMesh, 0, _atlasMaterial, _matrices, _count, null, ShadowCastingMode.Off, receiveShadows: false); } }
csharpBatchingSetup.cs

マテリアル数とテクスチャアトラスの設計

ドローコール数を実際に押し上げていたのはマテリアルの数でした。シーンには34種類あり、その多くは512x512のテクスチャが違うだけです。これらを2048x2048のテクスチャアトラス三枚にまとめました。環境、タワー、エフェクトとUIです。一つのマテリアルを共有するオブジェクトが多いほど、instancingとバッチングに使える母集団は大きくなります。

アトラス化は見た目ほど機械的な作業ではありません。UVを詰め直す際、タイリングに依存するメッシュはすべて除外する必要がありました。アトラスの中ではrepeatの wrap mode が効かず、隣の島のピクセルがにじみ込んでくるからです。床タイル用には別マテリアルを一つ残し、そちらはinstancingで描きました。ミップマップのにじみを防ぐため各島に8ピクセルのパディングを取りました。4ピクセルでは遠景で細い色の筋が見えてしまいます。

圧縮はETC2からASTC 6x6へ切り替えました。同じメモリ予算でも明らかにきれいで、特にアトラス内のグラデーションで差が出ます。アトラス化の後、マテリアルは34から6へ、ドローコールは780から210へ減りました。レンダースレッドは11.0msから7.4msです。この工程が今回の最適化で最大の一手でしたが、シェーダーは一行も書いていません。

半透明レイヤーが払うオーバードローの代償

オーバードローとは、同じピクセルが一フレームに何回書き込まれるかということです。半透明オブジェクトは ZWrite がオフなので深度テストが誰も弾かず、奥にあるものも手前にあるものも等しくシェーディングされます。Rendering Debuggerのオーバードロー表示では、シーン中央が11倍を示していました。つまり一部のピクセルは一フレームに十一回塗られていたわけです。GPU側の26msの大半はここにありました。

レイヤーを一枚ずつ数えました。射程リング、地面のデカール、煙、火花、ダメージフラッシュ、そして一番上にフルスクリーンのビネット用クアッドです。ビネットは最終のポストプロセスシェーダーへ畳み込み、独立したクアッドは削除しました。それだけで1080pのフルスクリーン一枚分になります。射程リングはタワーを選択している間だけ描くようにしました。煙のパーティクルは240から90へ減らし、一粒ずつを大きくしています。見た目の密度は同じで、フィルコストは三分の一です。

アルファテストを解決策だと思わないでください。葉やフェンスのマテリアルで clip() を使うと、タイルベースGPUではearly-zと隠面消去が壊れます。計測した二例とも、アルファブレンドに戻すほうが0.6ms速くなりました。同じ理由で半透明を手前から奥へ並べ替えても何も得られません。どれも深度を書かないからです。効果はレイヤーの枚数と、それが覆う画面面積を減らすことからしか生まれません。

タイルベースGPUで本当の上限になるのは帯域

モバイルGPUはフレームをタイルに分割し、各タイルをチップ上の小さく速いメモリで処理し、結果をメインメモリへ書き出します。高くつくのはシェーダーの計算ではなく、この書き込みと読み出しのトラフィックです。1080pではRGBA32のターゲット一枚がフレームあたり約8MB、60FPSなら500MB/sになります。しかもこれは一パス分です。端末が熱を持つとまずメモリクロックが落とされるので、帯域は熱の問題でもあります。

だからフレーム途中でレンダーターゲットを切り替えるのは高くつきます。切り替えのたびにタイルメモリをメインメモリへリゾルブする必要があるからです。今回のポストプロセスチェーンには独立したブリットが三つあり、bloomのダウンサンプルとカラーグレーディングを一パスにまとめたところGPU時間が1.7ms減りました。同じ理由で、直前の内容がどうでもいいターゲットに RenderBufferLoadAction.DontCare を渡すのはただの得です。不要なロードは、ターゲット全体をメインメモリからタイルへ読み戻すことを意味します。

デプスプリパスはモバイルではたいてい裏目に出ます。デスクトップではオーバードローを削るこの手法が、タイルアーキテクチャではジオメトリを二度処理し余分な深度トラフィックを生むため、今回のシーンでは0.9msの損でした。Adreno自身の低解像度Zカリングが、すでに似た仕事をただでやっています。試して、測って、戻しました。デスクトップの反射神経がそのまま通用しない場所の一つです。

半透明レイヤーに対する本当の解決策は、パーティクルのハーフ解像度描画でした。パーティクルを半分のサイズのレンダーターゲットへ描き、深度を考慮したアップサンプルでシーンへ合成します。シェーディングされるピクセル数は四分の一になり、合成に0.4msかかって、正味3.1msの得です。輪郭には軽いジャギーが出ますが、煙や埃のような低周波のエフェクトでは見えません。火花や弾道のような細く鋭いエフェクトはフル解像度のままにしました。

ミドルレンジAndroid実機で計測した結果

同じ60秒のウェーブ12の記録を、同じRedmi Note 10で回した結果です。フレーム時間は24.3msから16.4msへ、平均は41FPSから58FPSへ上がりました。ドローコールは780から190、マテリアルは34から6、計測した最大オーバードローは11倍から4倍です。15分のセッションでバッテリー温度は44℃ではなく39℃で落ち着き、端末はサーマルスロットリングに入らず、最後の五分でFPSが落ちていく現象も消えました。

この改善の内訳を私は重視しています。次の案件でどこから手を付けるかを決めるのがそれだからです。およそ40%がマテリアルとアトラスの作業、35%がオーバードロー削減とハーフ解像度パーティクル、15%が帯域まわりの調整、残りがSRP Batcher互換の回復から来ました。ポリゴン削減はこのリストのどこにもありません。メッシュには一切触れず、LOD設定もそのままです。

自分のプロジェクトで進めるなら、この順番を勧めます。まずFrame Debuggerでマテリアル数とバッチが壊れる理由を書き出し、次にオーバードロー表示で最悪のレイヤーを三つ見つけ、シェーダーに触れるのはその後です。各ステップを実機で、同じ温度、同じ録画シーンで計測してください。エディタで200FPSに見えた変更が実機で0.2msの損だった例はいくらでもあります。そして一つの数字だけを見ないこと。ドローコール、オーバードロー、帯域はそれぞれ別の上限で、片方を直すともう片方が簡単に壊れます。

← すべての記事