第8章 Tuning Practice - UI

Unity標準のUIシステムであるuGUIと、画面にテキストを描画する仕組みであるTextMeshProについて、チューニングプラクティスを紹介します。

8.1 Canvasの分割

uGUIではCanvas内の要素に変化があったとき、Canvas全体のUIのメッシュを再構築する処理(リビルド)が走ります。変化とは、アクティブ切り替えや移動やサイズの変更など、見た目が大きく変わるようなものから一見ではわからないような細かいものまで、あらゆる変更を指します。リビルドの処理のコストは高いため、実行される回数が多かったりCanvas内のUIの数が多かったりするとパフォーマンスに悪影響を及ぼします。

これに対して、ある程度のUIのまとまりごとにCanvasを分割することで、リビルドのコストを抑えることができます。たとえば、アニメーションで動くUIと何も動かないUIがあったとき、それらを別のCanvasの下に配置することで、アニメーションによるリビルドの対象となるものを最小限にできます。

ただし、Canvasを分割すると描画のバッチが効かなくなるため、どのように分割すればよいかに関しては注意深く考える必要があります。

Canvasの分割は、Canvasの配下にCanvasを入れ子で配置する場合でも有効です。子のCanvasに含まれる要素が変化しても、子のCanvasのリビルドが走るだけで親のCanvasのリビルドは走りません。ただし詳しく確認したところ、SetActiveによって子のCanvas内のUIをアクティブ状態に切り替えたときは事情が違うようです。このとき、親のCanvas内にUIが大量に配置されている場合は高負荷になる現象があるようです。なぜそのような挙動になるのかの詳細は分かりませんが、入れ子のCanvas内のUIのアクティブ状態を切り替えるときは注意が必要そうです。

8.2 UnityWhite

UIの開発をしていると、単純な長方形型のオブジェクトを表示したいということがよくあります。そこで注意するべきなのが、UnityWhiteの存在です。UnityWhiteは、ImageコンポーネントやRawImageコンポーネントで利用する画像を指定しなかったとき(図8.1)に使われるUnity組み込みのテクスチャです。UnityWhiteが使われている様子はFrame Debuggerで確認できます(図8.2)。この仕組みを使うと白色の長方形を描画できるため、これに乗算する色を組み合わせることによって単純な長方形型の表示を実現できます。

UnityWhiteの利用

図8.1: UnityWhiteの利用

UnityWhiteが使われている様子

図8.2: UnityWhiteが使われている様子

しかし、UnityWhiteはプロジェクトで用意したSpriteAtlasとは別のテクスチャであるため、描画のバッチが途切れてしまうという問題が起こります。これによって、ドローコールが増加し描画効率が悪化してしまいます。

そのため、SpriteAtlasに小さい(たとえば4 × 4ピクセルの)白色正方形の画像を追加し、そのSpriteを利用して単純な長方形を描画するようにするべきです。これによって、同じSpriteAtlasを使っていれば同一マテリアルになるため、バッチを効かせることができます。

8.3 Layoutコンポーネント

uGUIにはオブジェクトをきれいに整列させるための機能を持つLayoutコンポーネントが用意されています。たとえば縦方向に整列するならVerticalLayoutGroup、グリッド上に整列するならGridLayoutGroupが使われます(図8.3)。

左側が<code class="inline-code tt">VerticalLayoutGroup</code>、右側が<code class="inline-code tt">GridLayoutGroup</code>を使った例

図8.3: 左側がVerticalLayoutGroup、右側がGridLayoutGroupを使った例

Layoutコンポーネントを利用すると、対象のオブジェクトを生成したときや、特定のプロパティを編集したときにLayoutのリビルドが発生します。Layoutのリビルドも、メッシュのリビルドと同様にコストの高い処理です。

Layoutのリビルドによるパフォーマンス低下を避けるには、Layoutコンポーネントを極力使わないというのが有効です。

たとえば、テキストの内容に応じて配置が変わるといった動的な配置が必要ないのであれば、Layoutコンポーネントを使う必要がありません。本当に動的な配置が必要な場合も、画面上で多く使用される場合などは、独自のスクリプトで制御したほうがよい場合もあります。また、親のサイズが変わっても親から見て特定の位置に配置したいという要件であれば、RectTransformのアンカーを調整することで実現できます。プレハブを作成するときに配置に便利だからという理由でLayoutコンポーネントを使った場合は、必ず削除して保存するようにしましょう。

8.4 Raycast Target

ImageRawImageのベースクラスであるGraphicには、Raycast Targetというプロパティがあります(図8.4)。このプロパティを有効にすると、そのGraphicがクリックやタッチの対象になります。画面をクリックしたりタッチしたりしたとき、このプロパティが有効なオブジェクトが処理の対象となるため、できる限りこのプロパティを無効にすることでパフォーマンスを向上できます。

Raycast Targetプロパティ

図8.4: Raycast Targetプロパティ

このプロパティはデフォルトで有効ですが、実際のところ多くのGraphicではこのプロパティを有効にする必要がありません。一方、Unityではプリセット*1と呼ばれる機能があり、デフォルトの値をプロジェクトで変更することが可能です。具体的には、ImageコンポーネントとRawImageコンポーネントに対してそれぞれプリセットを作成し、それをProject Settingsのプリセットマネージャーからデフォルトのプリセットとして登録します。この機能を使ってRaycast Targetプロパティをデフォルトで無効にしてもよいかもしれません。

8.5 マスク

uGUIでマスクを表現するには、MaskコンポーネントかRectMask2dコンポーネントを利用します。

Maskではステンシルを利用してマスクを実現しているため、コンポーネントが増えるたびに描画コストが大きくなります。それに対してRectMask2dはシェーダーのパラメーターでマスクを実現しているため描画コストの増加が抑えられています。ただし、Maskは好きな形でくり抜ける一方、RectMask2dは長方形でしかくり抜けないという制約があります。

利用できるならRectMask2dを選択するべきだというのが通説ですが、最近のUnityではRectMask2dの利用にも注意が必要です。

具体的には、RectMask2dが有効のとき、そのマスク対象が増えるに連れ、それに比例して毎フレームカリングのCPU負荷が発生します。UIを何も動かさなくても毎フレーム負荷が発生するこの現象は、uGUIの内部実装のコメントを見る限りUnity 2019.3で入ったとあるissue*2の修正の副作用によるもののようです。

そのため、RectMask2dも極力使わないようにする、使ったとしても必要ない状態のときはenabledfalseにする、マスク対象は必要最低限にするなどの対策を取ることが有効です。

8.6 TextMeshPro

TextMeshProでテキストを設定する一般的な方法はtextプロパティにテキストを代入する方法ですが、それとは別にSetTextというメソッドを使う方法があります。

SetTextには多くのオーバーロードが存在しますが、たとえば文字列とfloat型の値を引数に取るものがあります。このメソッドを リスト8.1 のように利用すると、第2引数の値を表示できます。ただし、labelTMP_Text(もしくはそれを継承した)型、numberfloat型の変数であるとします。

リスト8.1: SetTextの利用例

 1: label.SetText("{0}", number);

この方法の利点は、文字列の生成コストを抑えられるという点です。

リスト8.2: SetTextを使わない例

 1: label.text = number.ToString();

リスト8.2 のようにtextプロパティを使う方法では、float型のToString()が実行されるのでこの処理が実行されるたびに文字列の生成コストが発生します。それに対してSetTextを使った方法は、文字列を極力生成しないような工夫が行われているため、とくに頻繁に表示するテキストが変わるような場合、パフォーマンス的に有利です。

またこのTextMeshProの機能は、ZString*3と組み合わせると非常に強力なものになります。ZStringは文字列生成におけるメモリアロケーションを削減できるライブラリです。ZStringはTMP_Text型に対する多くの拡張メソッドを提供しており、それらのメソッドを使うことで文字列の生成コストを抑えつつ柔軟なテキスト表示を実現できます。

8.7 UIの表示切り替え

uGUIのコンポーネントは、SetActiveによるオブジェクトのアクティブ切り替えのコストが大きいという特徴があります。これは、OnEnableで各種リビルドのDirtyフラグを立てたり、マスクに関する初期化を行ったりしていることが原因です。そのため、UIの表示非表示の切り替えの方法として、SetActiveによる方法以外の選択肢も検討することが重要です。

まず1つ目の方法は、Canvasenabledfalseにするという方法です(図8.5)。これによって、Canvas配下のオブジェクトがすべて描画されなくなります。そのためこの方法は、Canvas配下のオブジェクトを丸ごと非表示にしたい場合のみにしか使えないという欠点があります。

<code class="inline-code tt">Canvas</code>を無効にする

図8.5: Canvasを無効にする

もう1つの方法は、CanvasGroupを使った方法です。CanvasGroupには、その配下のオブジェクトの透明度を一括で調整できる機能があります。この機能を利用して、透明度を0にしてしまえば、そのCanvasGroup配下のオブジェクトをすべて非表示にできます(図8.6)。

<code class="inline-code tt">CanvasGroup</code>の透明度を0にする

図8.6: CanvasGroupの透明度を0にする

これらの方法はSetActiveによる負荷を避けることが期待できますが、GameObjectはアクティブ状態のままとなるため注意が必要な場合もあります。たとえばUpdateメソッドが定義されている場合には、その処理は非表示の状態でも実行され続けるため、思わぬ負荷の向上に繋がってしまうかもしれないことに気をつけましょう。

参考までに、Imageコンポーネントを付けた1280個のGameObjectに対して、それぞれの手法で表示非表示の切り替えをしたときの処理時間を計測しました(表8.1)。処理時間はUnityエディターで計測し、Deep Profileは用いていません。実際に切り替えを行ったまさにその処理の実行時間*4と、そのフレームでのUIEvents.WillRenderCanvasesの実行時間を足し合わせたものをその手法の処理時間としています。UIEvents.WillRenderCanvasesの実行時間を足し合わせているのは、この中でUIのリビルドが実行されるためです。

[*4] たとえばSetActiveなら、SetActiveメソッドを呼び出す部分をProfiler.BeginSampleProfiler.EndSampleで囲って計測しています。

表8.1: 表示切り替えの処理時間

手法処理時間(表示)処理時間(非表示)
SetActive323.79ms209.93ms
Canvasenabled61.25ms61.23ms
CanvasGroupalpha3.64ms3.40ms

表8.1の結果から、今回試したシチュエーションではCanvasGroupを使った手法が圧倒的に処理時間が短いことがわかりました。