UIコントロールの最適化

確認済のバージョン: 5.3

-

難易度: Advanced

この記事は、Unity UIの最適化に関するガイドの一部です。この章では、特定の種類のUI制御に関する問題に焦点を当てて解説しています。ほとんどのUIはパフォーマンス面では比較的類似していますが、ゲームの製品版に近い段階で起こるパフォーマンス問題の多くは、特定の2種類のUIに起因しています。

UI テキスト

Unity ビルトインのTextコンポーネントは、ラスタライズされたテキストグリフをUI内に表示するのに便利です。ただし、パフォーマンス面で頻繁に問題を起こす原因に、あまり知られていないものが数多く存在します。UIにテキストを追加するに当たって注意が必要なのは、テキストグリフは個別のクアッドとして1文字につき1つレンダーされるということです。これらのクアッドには(その形によって)かなりの大きさの空領域が含まれることが多く、テキストを配置する際に、意図せず他のUI要素のバッチ処理を妨げてしまうことがよくあります。

テキストメッシュのリビルド

主な問題のひとつは、UIテキストメッシュのリビルドです。UI Textコンポーネントに変更が加わるたびに、実際のテキストを表示するために使用されるポリゴンがそのテキストコンポーネントによって再計算される必要があります。この再計算は、テキストコンポーネントやその親ゲームオブジェクトのどれかが(変更されていなくても)単純に無効化されて再び有効化された場合にも行われます。

多数のテクスチャラベルを表示するUIの場合、この挙動が問題になります。この問題が最も頻繁に見られるのはランキング表示や統計画面です。Unity UIの表示・非表示を切り替えるために最もよく使用される方法は、そのUIが含まれるゲームオブジェクトの有効・無効を切り替えることです。そのため、テキストコンポーネントを多数含むUIの場合、表示されるたびにフレームがスキップしてしまうことがよくあります。

この問題の回避策は、次章の Canvas Rendererの無効化の項をご確認ください。

動的フォントとフォントアトラス

動的フォントは、表示される文字セットのサイズが大きい場合や、ランタイム前にテキストの内容が分からない場合のテキスト表示方法として便利です。Unityの仕組みでは、動的フォントはUI Textコンポーネント内で検知された文字を基にランタイムでグリフアトラスを作成します。

読み込まれたそれぞれのFontオブジェクトは、他のフォントと同じフォント系に属している場合も含め、個々にテクスチャアトラスを保持します。例えば、ある制御でArialを太字設定で使用し、別の制御でArial Boldを使用した場合、結果の表示は同じ形になりますが、UnityにはArial用とArial Bold用の2つのテクスチャアトラスが別々に保持されます。

パフォーマンスの観点から見て最も重要なのは、Unity UIの動的フォントが、フォントのサイズ・スタイル・文字の異なる組み合わせのそれぞれに対して1つづつテクスチャアトラスを保持するということです。つまり、あるUIに2つのTextコンポーネントが含まれていてその両方が‘A’という文字を表示する場合、以下のようになります。

  • 2つのTextコンポーネントのサイズが同じである場合、フォントアトラスに含まれるグリフは1つになります。
  • 2つのTextコンポーネントのサイズが異なる場合、([例]一方が16ポイントでもう一方が24ポイントの場合)、フォントアトラスには‘A’という文字のサイズ違いの2つの複製が含まれます。
  • Text コンポーネントの一方が太字でもう一方は太字でない場合、フォントアトラスには太字の'A'と通常の'A'の2つが含まれます。

動的フォントを持つUI Textオブジェクトが、そのフォントのテクスチャアトラスにまだラスタライズされていないグリフを発見すると、そのたびにそのフォントのテクスチャアトラスはリビルドされる必要があります。新しいグリフは、現在のアトラスに収まるものであれば、グラフィックデバイスに再アップロードされたアトラスに追加されます。しかし、現在のアトラスが小さすぎる場合、システムはそのアトラスのリビルドを試みます。このプロセスは以下の2ステップで行われます。

まず、アクティブなUI Textコンポーネント(1)によって現在表示されているグリフだけを使用して、同じサイズでアトラスがリビルドされます。現在使用中のグリフが全て新しいアトラスに収まった場合は、そのアトラスがラスタライズされます。この場合は次のステップは行われません。

次に、現在使用中のグリフ一式が現在のアトラスと同サイズのアトラスに収まらない場合は、より大きなアトラスが作成されます。具体的には、そのアトラスの短い方の長さが2倍になります。例えば512x512のアトラスは512x1024になります。

上記のようなアルゴリズムになっているため、動的フォントのアトラスは、一旦作成されるとサイズが大きくなる一方です。テクスチャアトラスのリビルドにかかるコストも踏まえて、このリビルドのコストを最小に抑える必要があります。その方法は以下の2つです。

可能な限り非動的フォントを使用し、希望のグリフセットへの対応を事前に設定する。この方法は、文字セットに厳しい制約を設けているUI([例]Latin/ASCII限定で、かつサイズの振り幅も狭くしてある場合)であれば通常うまく行きます。

極めて多様な文字を広範に扱う必要がある場合([例]Unicodeの完全なセットが必要な場合)は、フォントをDynamicに設定する必要があります。予測可能なパフォーマンス問題を回避するためには、Font.RequestCharactersInTextureを使用して、起動時に適切な文字セットでフォントのグリフアトラスの下準備をしてください。

フォントアトラスのリビルドは、変更されたUI Textコンポーネントのそれぞれに対して別々にトリガーされます。極端に多数のTextコンポーネントを追加する場合は、そのTextコンポーネントのコンテンツ内にある固有の文字を全て集めてフォントアトラスの下準備を行うと効率的です。そうすることにより、グリフアトラスは、新しいグリフが発見されるたびにリビルドされる代わりに、一度リビルドされるだけで済みます。

また、フォントアトラスのリビルドがトリガーされると、アクティブなUI Textコンポーネントにその時点で含まれていない文字は、(それがFont.RequestCharactersInTextureの呼び出しの結果元々そのアトラスに追加されていた文字だとしても)新しいアトラスには入りません。。この制約に対処するには、Font.textureRebuiltデリゲートにサブスクライブしてFont.characterInfoをクエリーすると必要な文字が全て下準備されるようになります。

Font.textureRebuilt デリゲートは現段階ではマニュアルには記載されていません。これは単独引数のUnity Eventです。この引数は、テクスチャのリビルドされたフォントです。このイベントへのサブスクライバは次のシグネチャーの後に続きます。

public void TextureRebuiltCallback(Font rebuiltFont) { /* … */ }

専用のグリフレンダラー

頻繁に使用されているグリフで、各グリフ同士の相対的な位置がおおよそ固定されている場合は、それを表示しているスプライトを持つコンポーネントをカスタムで書くのが非常に効率的です。例えば、スコア表示などがその一例です。

スコアの場合、表示される文字は頻繁に使用されるグリフセット(0~9の数字)から描画され、位置によって変更されることもなく、相互間の距離が固定されています。整数を1桁づつに分解して各桁の適切なスプライトを表示するのは比較的自明(trivial)です。こういった桁表示専用の仕組みは、割り当て無しで、かつCanvasによるUI Textコンポーネントよりずっと速く計算やアニメーション化を行えるようにビルドすることが可能です。

代替フォントとメモリの使用

幅広い文字セットの扱いが必要なアプリケーションの場合、フォントインポーターの “Font Names”フィールドのリストに多数のフォントを載せたくなります。“Font Names”フィールドのリストに載せたフォントは、主要フォント内にグリフが見付からない場合の代替フォントとして使用されます。代替使用の優先順位は、このリストの順番通りになります。

しかし、この挙動に対応するために Unityは“Font Names”フィールドにリスト化されたフォントを全てメモリに読み込んだまま保持します。特定のフォントの文字セットが著しく大きい場合は、代替フォントのためにメモリが過剰に消費される可能性があります。これは、日本語の漢字や中国語の文字などが含まれる場合に多く見られます。

Best Fit とパフォーマンス

基本的には、UI TextコンポーネントのBest Fit設定は一切使用しないでください。

“Best Fit” はフォントのサイズを動的に調整します。具体的には、設定された最小・最大ポイントの範囲内で、Textコンポーネントのバウンディングボックス内からはみ出さずにせずに表示できる最大の(整数ポイント)サイズに調整されます。ただしUnityは、表示されるサイズ違いの文字のそれぞれに1つづつグリフをレンダーするため、Best Fitを使用すると、大量のサイズ違いのグリフによってアトラスがすぐに一杯になってしまいます。

Unity 5.3 の時点では、Best Fitの使用しているサイズ判定システムは最適化されていません。サイズのインクリメントのテスト1回ごとに1つフォントアトラスにグリフ生成されるので、フォントアトラスの生成に掛かる時間がさらに伸びます。またアトラスに入りきらない可能性も高く、その場合古いグリフがアトラスから削除されます。Best Fitの計算には膨大な量のテストが必要なので、これによって他のTextコンポーネントが使用しているグリフが削除されてしまい、適正なフォントサイズが算出された後に少なくとももう1回、フォントアトラスのリビルドが余儀なくされます。この問題はUnity 5.4では修正されており、Best Fitが不要にフォントのテクスチャアトラスを大きくしてしまうことはなくなりましたが、やはり静的にサイズ設定されたテキストよりは大幅に遅くなります。

フォントアトラスの頻繁なリビルドは、ランタイムのパフォーマンスを急激に劣化させるだけでなく、メモリの断片化も引き起こします。この問題は、Best Fitに設定されたテキストコンポーネントの量が多ければ多いほど悪化します。

Scroll View

ランタイムにおけるパフォーマンスの問題の最も多くはフィルレートの問題に起因するものですが、その次に多いのは Unity UIのScroll Viewに起因するものです。Scroll Viewでは通常、非常に多数のUI要素がコンテンツを表示する必要があります。Scroll Viewに要素を追加する方法は基本的に2通りあります。

  • Scroll View の全てのコンテンツを表示するのに必要な要素を、全て入れる。
  • 要素をプールし、必要に応じてその位置を再調整して可視コンテンツを表示する。

上記の方法にはそれぞれに問題点があります。

1つ目の方法は、表示が必要なアイテムが増えるにしたがって、全てのUI要素をインスタンス化するための時間も長くなります。またScroll Viewのリビルドに掛かる時間も長くなります。Scroll View内に含まれる要素の数が少ない場合(表示が必要なTextコンポーネントの数が少ないScroll Viewなど)場合は、この単純な方法が好まれます。

2つ目の方法は、現段階のUIおよびレイアウトのシステムでは、大量のコードの正確な実装が求められます。これを行うための2つの方法を、下記に詳しく解説しています。複雑なスクロールUIの場合は通常、パフォーマンスの問題を回避するために何らかの形でプーリングを行うアプローチが必要になります。

これらの問題に関わらず、全てのアプローチはRectMask2DコンポーネントをScroll Viewに追加することで向上できます。このコンポーネントを使用すると、Scroll Viewのビューポートの外にあるScroll View要素は、Canvasのリビルドの際にジオメトリの生成・分析が必要な描画要素のリストから確実に外されるようになります。

Scroll View の要素の簡単なプーリング方法

Unity ビルトインのScroll Viewコンポーネントの利点をできる限り活かしながらScroll Viewでオブジェクトのプーリングを最も簡単に行う方法は、ハイブリットなアプローチです。

UI に要素を配置するに当たって、レイアウトシステムがScroll Viewのコンテンツのサイズを正確に算出してスクロールバーが正常に機能するようにするには、UIの可視要素の「プレースホルダー」としてLayout Elementコンポーネントを持ったGameObjectを使用してください。

その上で、Scroll Viewの可視部分を埋めるのに充分な可視UI要素のプールをインスタンス化し、これらを位置プレースホルダーの親として設定します。Scroll Viewのスクロールに応じて表示されてくるコンテンツは、UI要素を再利用して表示します。

上記を行うことで、バッチ処理が必要な UI要素の数が大幅に削減されます。これは、バッチ処理のコストの増加が、Rect Transformの数に応じてではなくCanvas内のCanvas Rendererの数だけに応じて起こるためです。

単純なアプローチの問題点

現段階では、UI要素のどれかの親子関係が変更された場合やその兄弟の順序が変更された場合、その要素およびその全てのサブ要素が「ダーティ」としてマークされ、そのCanvasは強制的にリビルドされます。

これはなぜかというと、Unityでは、トランスフォームの親子関係の変更のコールバックと、その兄弟の順序の変更のコールバックを分けていないからです。これらのイベントは両方ともOnTransformParentChangedコールバックを実行します。このコールバックはUnity UIのGraphicクラスのソース内(ソース内のGraphic.csをご確認ください)に実装されており、SetAllDirtyメソッドを起動します。Graphicがダーティ化されることで、そのGraphicは、次のフレームがレンダーされる前にそのレイアウトと頂点を確実にリビルドするようになります。

Scroll View 内の各要素のルートRectTransformにCanvasを割り当てることも可能です。これにより、Scroll Viewのコンテンツ全体ではなくリペアレントされた要素だけがリビルドされるようになります。ただし、これを行うと、そのScroll Viewのレンダーに必要なドローコールの数が増える傾向にあります。さらに、Scroll View内の個々の要素が複雑でGraphicコンポーネントが10個以上あるような場合、特に、各要素のLayoutコンポーネントの数が多い場合は、リビルドのコストが上昇して低性能デバイスでフレームレートが低下する可能性が高くなります。

Scroll View のUI要素のサイズが不変である場合は、こうしたレイアウトや頂点の再計算は不要です。ただし、この挙動を回避するためには、親の変更や兄弟の順序の変更ではなく位置の変更に基づいてオブジェクトをプールするソリューションの実装が必要になります。

位置ベースのScroll Viewプール

上記の問題は、オブジェクトをプールする Scroll Viewを作成することで回避できます。これは、Scroll Viewに含まれるUI要素のRectTransformを移動するだけで簡単に行えます。移動されたRectTransformの寸法が変更されていなければ、そのコンテンツをリビルドする必要がなくなり、その結果Scroll Viewのパフォーマンスが大幅に向上します。

これを行う方法として通常最も推奨されるのは、Scroll Viewのカスタムのサブクラスを書くか、カスタムのLayout Groupコンポーネントを書くことです。基本的には後者の方が簡単で、Unity UIのLayoutGroup抽象ベースクラスのサブクラスを実装すればこれを行えます。

カスタムのLayout Groupは、ソースデータを解析して表示が必要なデータ要素の数を特定したり、Scroll ViewのContent RectTransformを適切なサイズに調整することができます。その上でScroll View変更イベントにサブスクライブし、それを使用して可視要素の位置を適切に調整できます。

脚注

  1. これには、親Canvasが有効になっていてCanvas Rendererが無効になっているUI Textコンポーネントも含まれます。