gracetory’s blog

東池袋にある合同会社グレストリのエンジニアブログです

Unityで負荷対策をしたいからレンダリングの仕組みを調べてみた

こんにちは、yamauraです。

最近はUnityを使ってスマホ向けのゲームアプリを作っています。 システム的には斜め見下ろし型の3Dアクションなのですが、スマホ向けの3Dということで負荷対策にいつも以上に注意していく必要があります。

グラフィックパフォーマンスについては以前調べたことがあったのですが、もう2年以上前の話なのでアップデートも兼ねて再度調査してみました。

Unityでの画面描画の仕組み、負荷対策としてよくあがる「ドローコール・セットパスコール」の意味を簡単なテストと合わせて解説していきます。

ちなみに環境は「Unity 2019.3.4f1 と macOS High Sierra(10.13.6)」です。



描画ステータスを確認する

まずは描画ステータスの確認方法から。 Unityには開発中のゲームのレンダリング負荷を確認できる「レンダリング統計ウインドウ」という機能があります。

docs.unity3d.com

ゲームViewの右上メニューからstatsを選択。 Statisticsというウインドウが表示され、この状態でプロジェクトを実行することでリアルタイムでフレーム毎の描画に関するステータスを確認することができます。

f:id:gre_yamaura:20200618123528p:plain

プロジェクトのレンダリング負荷が「FPS・CPU側の処理時間・レンダリング処理時間」といった項目で表示され、その下にはフレーム開始から実際に画面が描画されるまでの処理の内訳となる項目が並んでいます。

つまり負荷を軽減するためにはこれら内訳部分の数値を改善していけば良いことになりますが、そもそもの項目の意味がわかりづらい。

Unityで画面がレンダリングされる仕組み

ということで、項目の意味を理解するためにUnityが実際に画面を表示するまでに1フレーム内で行っている処理の流れを簡単にまとめます。

learn.unity.com

1.描画オブジェクトの判定

フレーム処理がはじまるとCPUは「シーンに配置された全オブジェクトを対象として、そのオブジェクトを描画するべきかどうか」の判定を行います。

2.ドローコールを蓄積(バッチング処理)

判定の結果、画面外に存在するなどで描画する必要がないオブジェクトを取り除き、描画しなければいけないオブジェクト1つ1つに対して「このオブジェクトを描画して」という命令(ドローコール)を蓄積していきます。

またこのドローコールを用意するのに必要な「対象オブジェクトの頂点情報や描画設定を入れたデータ群」のことを”バッチ”と呼び、このバッチを作成する処理のことを”バッチング”と呼びます。

3.セットパスコールとドローコールの送信

すべてのオブジェクトに対して描画チェックとドローコールの蓄積が完了すると以降、CPUは蓄積されたドローコールを順番にGPUに送信していきます。 その際レンダリング環境に変更がかかる場合のみ「この環境で描画して」という命令(セットパスコール)がドローコールの送信に先立って送信されます。

「この環境で」→「このオブジェクトを描画して」もしくは単純に 「このオブジェクトを描画して」という命令がCPUからGPUに送信される流れです。

GPUはセットパスコールを受け取ると描画環境を再設定し、ドローコールを受け取ると内包されたバッチを元にオブジェクトを描画していきます。

※レンダリング環境というとわかりづらいのですが、この環境の変化で最も多く発生するのが「マテリアルの変更」です。

4.頂点シェーダーの適用

以降はGPUの処理です。 GPUは渡されたドローコールに内包されたバッチから頂点情報を頂点シェーダーの規則と環境に合わせて最終的な値に変換します。

5.フラグメントシェーダーの適用

頂点の値が定まったところでフラグメントシェーダーの規則と環境に合わせて画面内1ピクセル毎に塗る色が決定されます。

6.描画完了

ドローコールがすべて処理されると結果的に画面の全ピクセルの色が決定することになり、これで1フレームの画面レンダリングが完了となります。

注目したいステータス

Unityで画面がレンダリングされる仕組みがわかるとレンダリング統計ウインドウの項目もなんとなくわかってくるかと思います。

TrisとVerts

画面に描画されるオブジェクトの頂点数の合計がVerts。 この頂点によって構成された三角ポリゴン数の合計がTris。

Batches

上述したバッチングを行った回数がBatches。 またバッチングは1オブジェクトに対して必ず1回行われるわけではなく、特定条件を満たした場合に複数オブジェクトのバッチがひとまとめに作成されます。 このバッチがまとめられたオブジェクトの数がSaved by batchingの値になります。

SetPass calls

上述した描画環境を変更する命令の数。

実際に試してみた

文字だけだとわかりづらいので実際にオブジェクトを置いたりしてレンダリングステータスの変化を見てみます。

新規プロジェクト

まずは3Dプロジェクトを新規で作成したままの状態。 ヒエラルキーには初期設定されたカメラとライトのみで画面に表示されるオブジェクトは何もありません。

f:id:gre_yamaura:20200618123554p:plain

Batches: 2
Saved by batching: 0
Tris: 1.7k
Verts: 5.0k
SetPass calls: 2

オブジェクトが何もないはずなのにバッチングやセットパスコールが発生し、頂点や三角ポリゴン数もカウントされています。 これはデフォルトで設定されているSkyboxが影響しています。 Skyboxはゲームシーンを内包するような大きな球体のオブジェクトの内側にテクスチャを貼り付けることで、360度全方向にわたって背景を表示する仕組みです。 球体の3Dモデルはそもそも頂点数の多い形状のため、上記のようなステータスになります。

Skyboxを非表示にする

試しにskyboxではなく指定色で背景を表示する仕組みにすると……

f:id:gre_yamaura:20200618123611p:plain

Batches: 1
Saved by batching: 0
Tris: 2
Verts: 4
SetPass calls: 1

ステータスががっつり落ちましたが、まだ各項目がカウントされています。 この原因をUnityの「フレームデバッガ」という機能で確認してみます。

上部メニューのWindow -> Analysis -> FrameDebuggerを選択

f:id:gre_yamaura:20200618123624p:plain

FrameDebuggerではドローコールを送信された順に確認することができるのですが、それによるとどうやらレンダリングバッファ(オブジェクト同士の奥行きなどを判定する情報)をクリアする処理が毎フレームの開始時に発生することが原因のようです。

オブジェクトを配置していく

状況がわかったところで、次はオブジェクトとステータスの変化をわかりやすくするため影を表示しないように設定した状態でQuadを配置してみます。

f:id:gre_yamaura:20200618123638p:plain

Batches: 2
Saved by batching: 0
Tris: 4
Verts: 8
SetPass calls: 2

Quadは4つの頂点と2つの三角ポリゴンからなるオブジェクトですので、Trisが2、Vertsが4、Batchesが1とそれぞれ加算され、デフォルトのマテリアルにより描画環境の変更が必要になったためSetPass callsも1上がっています。

そのまま同じマテリアルを指定したCubeを配置

f:id:gre_yamaura:20200618123651p:plain

Batches: 3
Saved by batching: 0
Tris: 16
Verts: 32
SetPass calls: 2

CubeはQuadを6枚組み合わせたような構造で24の頂点と12の三角ポリゴンを持つため、その分のステータスがそれぞれ加算されました。 ただ先程のQuadとCubeで同じマテリアルが指定されているため描画環境の変更が発生せず、SetPass callsの値は2のまま変わっていません。

追加でもう1つ同じCubeを配置してみます。

f:id:gre_yamaura:20200618123706p:plain

Batches: 4
Saved by batching: 0
Tris: 28
Verts: 56
SetPass calls: 2

1つ目のCubeを追加したときと同様にSetPass callsの値は変わらず、その他の値が同じ分だけ増加しました。

動的バッチングを使ってみる

このタイミングでBatchesで出てきた「特定の条件を満たした場合に複数オブジェクトのバッチがまとめて作成される仕組み」を確認します。

上部メニューのEdit -> Project Settingsを選択。 開いたウインドウからPlayerを選択し、中盤にあるDynamic Batchingにチェックを入れる

f:id:gre_yamaura:20200618123958p:plain

そうしたらもういちど描画ステータスを確認。

f:id:gre_yamaura:20200618124008p:plain

Batches: 3
Saved by batching: 1
Tris: 28
Verts: 56
SetPass calls: 2

2つ目のcubeのバッチが最初のcubeのバッチにまとめられたためBachesが1下がり、Saved by batchingが1になりました。 一般的にバッチングと比較してドローコール送信のほうが負荷が高いため、できるだけ多くのオブジェクトをまとめてバッチングすることがCPUの負荷を軽減するキモとなります。

ちなみに動的バッチングが可能になるオブジェクトの条件は複雑です。

  • 同じマテリアルのインスタンスを利用している
  • 同じスケール
  • トータル頂点数が900以下
  • シェーダーが頂点位置や法線や一つのUV情報を使っている場合 トータル頂点数 300以下
  • 頂点位置,法線,UV0,UV1,タンジェントを使っている場合 トータル頂点数 180以下
  • ライトマップを持つ場合は対象外
  • マルチパスシェーダの場合は対象外
  • リアルタイムシャドウを投影されるオブジェクト の場合は対象外

この他にも細かい制約があったり、頂点数にいたっては適宜変更していくという公式アナウンスがあります。

静的バッチングを使ってみる

動的バッチングでは複雑な条件を満たす必要がある一方、動きのないオブジェクト限定ですが静的バッチングという仕組みもあります。

対象オブジェクトのInspector右上のstaticにチェックを入れる (厳密にはBatching Staticにチェックをつければok)

f:id:gre_yamaura:20200618124658p:plain

Project SettingsからStatic Batchingにチェックが入っていることを確認。 (Dynamic Batchingにチェックを入れた際のその1つ上の項目でデフォルトで有効になっていると思います)

f:id:gre_yamaura:20200618125015j:plain

プレビューを実行すると上のように対象オブジェクトのMesh Filterが「Combined Mesh」となり、複数オブジェクトのバッチが1つにまとめられます。

f:id:gre_yamaura:20200618125051p:plain

Batches: 2
Saved by batching: 2
Tris: 28
Verts: 56
SetPass calls: 2

Quadと2つのCubeを静的バッチングしたところBatchesとSaved by batchingの値が変化してドローコールがさらに減少していますね。

異なるマテリアルを追加する

バッチングに続いてセットパスコールを見ていきたいと思います。 各オブジェクトのstaticチェックを外して静的バッチングを無効にした後、適当に用意したマテリアル(standardシェーダー)をアタッチしたcubeをゲームシーンに配置しました。

f:id:gre_yamaura:20200618125102p:plain

Batches: 4
Saved by batching: 1
Tris: 40
Verts: 80
SetPass calls: 3

だいたい予想できたと思いますが、赤色のCubeは動的バッチングできずにBatchesが1増加し、マテリアルの変更が発生したためSetPass callsも1増加しました。 次に赤色のcubeをもう1つ追加

f:id:gre_yamaura:20200618125113p:plain

Batches: 4
Saved by batching: 2
Tris: 50
Verts: 104
SetPass calls: 3

赤色Cube同士は動的バッチングされてマテリアルの変更も発生しないためBatchesとSetPass calls以外の値がそれぞれ増加しました。 ちなみにこの状態ですべてのオブジェクトのstaticにチェックを入れて静的バッチングを有効にすると以下のステータスになります。

Batches: 3
Saved by batching: 3
Tris: 50
Verts: 104
SetPass calls: 3

静的バッチングも動的バッチングと同様に「同じマテリアルインスタンスである」という条件があるため、白色と赤色のオブジェクトが別々にまとめられた状態です。

マテリアルを共有する

f:id:gre_yamaura:20200618130738j:plain

最後に上のような4色で構成された画像を用意して同じマテリアルを共有した色の異なるQuadとCubeを配置します。

f:id:gre_yamaura:20200618125145p:plain

Batches: 2
Saved by batching: 6
Tris: 76
Verts: 152
SetPass calls: 2

レンダリングバッファのクリア処理を考慮すると、QuadとすべてのCubeが1つにバッチングされ、セットパスコールもひとまとめにされていることがわかります。

再調査してみて

当時と比べてみるとUnityのバージョンアップに伴ってデフォルトの設定が異なる、以前はなかった設定項目が増えているなどありましたが、レンダリングの仕組み自体にはほとんど変更がなかったです。

また今回は説明を単純にするため影を非表示にしていますが、影を表示すると「マテリアル数 x メッシュ数 x スポットライト数」の分さらにバッチが増加し、動的バッチングの条件からはずれてしまうオブジェクトが出てくる可能性があります。

注意!

Unityのレンダリングについて、負荷対策としてよく話題にあがる「ドローコールとセットパスコール」に焦点を当てて本記事を書きました。 注意点として、両者の削減が有効なのはCPU側のレンダリング処理の範囲に限ります。

画面はCPUとGPUの処理両方が完了してはじめて描画されます。 ゲームがもっさりしているのは以下のようなGPU側に起因する原因があるかもしれません。

  • フィルレート(1フレームで描画できる限界を超えたピクセル数を描画しようとしている)
  • メモリ帯域幅(GPUで素早く処理できないような大きなテクスチャを使っている)
  • 頂点処理(オブジェクトの頂点が多くてシェーダー処理に時間がかかっている)

でも大丈夫!

Unityにはこういった原因を探るためのProfilerというパフォーマンス分析ツールがありますし、見つかったボトルネックを解消するための便利なアセットがたくさん存在します。

私自身の知識がいかんせん古いため、Profilerやアセットについても少しずつアップデートしながら紹介できたらと思います。 まずは本記事の基本を抑えて、出来ることを1つずつ増やしていきましょう!

※もし間違いなどありましたらご指摘いただけると幸いです。