Unity を検索

UIのバックエンドを高速化する

2015年9月7日 カテゴリ: テクノロジー | 9 分 で読めます
シェア

Is this article helpful for you?

Thank you for your feedback!

Unity 4.6 / 5.0では、レンダリングで行うUIシステムのバッチングの生成はとても遅いものでした。これにはいくつかの要因がありましたが、究極的にはエンジンを予定通り出荷するための限られた時間のなかでの優先度の問題で、UIシステムについては速度よりも使いやすさやAPIを優先せざるをえなかったのが要因でした。

とはいえ、UIシステムを仕上げる最後の開発スプリントで幸運な事に最適化を行うための助力を得ることが出来ました。UIシステムのリリース後、わたしたちはまとまった時間を取り、何がUIを遅くしていて、どうすれば改善できるかを分析・理解することにしました。

この記事は長いので、ザックリ要約するとこういう話です:私たちはUnity 5.2で(ジョブスケジューリング以外の)すべてをメインスレッドから外して、バッチング用のソートアルゴリズムをそれに合わせて大幅に修正し、UIのパフォーマンスが大幅に向上しました。

パフォーマンス評価プロジェクト

まず、私たちはUIのパフォーマンスを調べるためのテストシーンをいくつか作り、パフォーマンスの変更を把握できるようにしました。これらのテストは、UIシステムにさまざまな方法で負荷をかけます。キャンバスのソート/バッチングの生成にもっとも効果的だったテストは、キャンバスを一面中ボタンで埋め尽くすものでした。テキストとボタン、およびボタンの背景が重ね合わさっているので、どれがバッチング可能かの計算に必ずオーバーヘッドが発生します。テストはUIの要素をつねに変更しつづけるので、毎フレーム再バッチングが発生します。

テストはUIの要素を順番に並べて位置関係を最適化に利用しやすいようにも、ランダムに並べて潜在的にソートアルゴリズムにより負荷をかけるようにも設定可能にしました。バッチングのソート処理はこのどちらのケースでも高速に動作する必要があるのは分かっていましたが、4.6 / 5.0 の実装ではどちらのケースでも速くはありませんでした。

さらに、これらのパフォーマンステストでは、大体〜10000個のUI要素が使われていることも記しておきます。これは実際には現実のプロジェクトに即した要素数より非常に多い構成です。私たちが現実のプロジェクトで目にするUI要素数は多くてキャンバスあたり300程度のUI要素で構築されています。

さらに、この記事における全てのパフォーマンス・プロファイリングに関する数値・グラフは、わたしのMacBook Air(13-inch, Mid 2013)での数値です。

テストシーンの一部:

unnamed

元の状態(4.6以前、比較情報なし)

4.6のベータの時に、キャンバスに多くのUI要素があるとバッチングのソートが非常に遅くなるというフィードバックをいただいていました。これはバッチングの描画順をきめる処理の実装が短にアホで何の工夫もなかったことが原因でした。この時は、キャンバス内のUI要素を短に列挙して、たがいにぶつかる要素を調べながら一定のルールでそれぞれの要素にデプスを設定するという方法を採っていました。この方法では新しいUI要素が加わる度に計算速度的には O(N^2) の効率でガンガン遅くなっていきます。パフォーマンスの観点から見ると、こりゃ〜イヤな雰囲気が漂うやつです。

4.6 / 5.0 リリース時(比較対象)

幸いUIシステムのリリース前に最適化をする機会を得られたので、ソート処理の改善に取り組みました。この時活用したアイディアは「順番に描画される要素はおおむね、互いに画面上の近い位置関係にある」というものでした。この方法ではまず、N個の要素を持つUI要素のグループについて、バウンディングボックスを作成し、新しい要素が追加されたときには個別のUI要素ではなく、まずこのバウンディングボックスと衝突するかを調べ、それから必要に応じて個々のUI要素との関係を調べるようにします。この方法ではUI要素の位置がある程度固まっているようなシーンについては真っ当なパフォーマンスの改善が見られたのですが、UI要素がランダムに並んだシーンや、UI要素同士が遠く離れているシーンについては、パフォーマンスの改善効果はほとんどありませんでした。

このバージョンでUI要素がランダムに並んだシーンのバッチングについてパフォーマンスを見てみると、ソートと描画でだいたい100ms もかかってしまっています。うーん、こりゃ〜ダメだ。遅いですね。

unnamed (1)

プロファイラーのタイムラインを見てみると、他にも心配なことが浮かび上がってきました。UIシステムの処理が完全に他のことをブロッキングしてしまっているのです。特に処理に時間がかかっているバッチング処理はUIの描画の直前に行われる設計でした。これはLateUpdate() の後に発生するのですが、しばしばシーンに配置されたカメラのいくつかが描画されたあとになっています。バッチング処理はLateUpdate()の直後に始まるようにし、シーンが他のものを描画しているあいだに並行して行われるようにした方がよさそうです。

unnamed (2)

ソートの改善(1回目)

取り急ぎ、まずはソートの改善に取り組んでみました。この時はまだ前述の「UI要素が偏在する」というアイディアに基づいていましたが、さらにいくつか知恵を投入しました。上記のソート処理の中で作ったグループを可能な限りバッチ可能な状態で保持するようにし、バッチ処理についてグループ単位で投入/除外ができるようにしました。これは高速化につながりましたが、広い空間に散らばったシーンでは効果が薄く、またUI要素の増加数に対しても改善効果がうまくスケールしませんでした。

空間的にグループ化できないUI要素のシーン

unnamed (3)
空間的にグループ化できるUI要素のシーン
unnamed (4)

うーん、こりゃアカンですな…。結果を見ると別のアプローチが必要なことが分かります。

ソートの改善(2回目)

先に書いたように、ソートはUI要素が離れたところに散らばっているシーンで遅くなる傾向があります。そこで、一歩下がって別のアプローチを考えてみることにしました。色々検討した結果、最終的にキャンバスにグリッド構造を実装する方法を採ることにしました。この方法では、キャンバスのそれぞれの四角形グリッドが「バケツ(bucket)」として機能をし、このグリッドに接するUI要素はバケツに追加されます。この方法なら新しいUI要素が追加されたときに、その要素が接するグリッドを調べてバッチングが可能かどうかをチェックするだけでよくなるわけです。この方法はランダムなシーンでも非常によいパフォーマンスの改善をもたらしました。

空間的にグループ化できないUI要素のシーン

unnamed (5)

空間的にグループ化できるUI要素のシーン

unnamed (6)
両方の設定で同じように改善されています!いい感じですね。

ジオメトリジョブ

次に、UIシステムの処理をメインスレッドから引きはがして別のスレッドに移す作業を行うことにしました。このためには Unity 5 から導入されたジオメトリジョブシステムを利用することにしました。ジオメトリジョブシステムはUnityの内部的な機能で、頂点バッファ/インデックスバッファの生成を別のスレッドで行うことが出来るようにするものです。これを使う事で、先のプロファイラーのタイムラインでメインスレッドにドカンと乗っかっていた処理を他のスレッドに移すことが出来るようになります。メインスレッドにはジオメトリジョブ・およびジョブインストラクションを管理するための処理が若干残っていますが、導入前にメインスレッドにかかっていたコストに比べたらなんてことはないものです。

unnamed (7)
 

バッチソートの単純化

この最適化のプロセスを通して、プロファイラーの情報を元に他にも沢山の小さな改善を行いました。もっとも大きなパフォーマンスの改善は、ソート中に矩形の重なりをチェックするコードをベクトル化したことです。基本的には、内部のデータ構造について、丁寧にDOD(Data Oriented Design)に沿って重なりチェックのために最適なメモリレイアウトに変更し、1回の関数コールで全部チェックできるようにしました。これでC++プロファイラーの方で出ていたホットスポットを除去し、処理の60%くらいの時間を削減する事ができました。これはソートのパフォーマンスを大きく改善しました!

・・・が、まだです。まだ、メインスレッドに残っている7msを除去したいのです。

 ベクトル化したコード

unnamed (8)

メインスレッドから全部の処理を引っぺがす

次のステップはUIの生成処理自体をメインスレッドから削除して他に移すことです。これを行うには、Unity内部のジョブシステムを使っていくつかのタスクを発行します。いくつかの処理は依存関係があるので順々に行われますが、それ以外はバーンと並列化できます。以下がその詳細です:

1) UIの描画を複数の描画命令(renderable instructions)に分割します。1つのUI描画は複数のマテリアルと複数のサブメッシュによって構成され得るので、沢山のドローコールによって構成されることがあります。このタスクは分散可能です。まず、このタスクは論理的に最大の描画処理を扱うために必要なメモリを確保します。次に、発行されてくる描画命令を並列で実行し、それぞれの結果を出力バッファに書き込んでいきます。最後にこの出力バッファを隣接するメモリに結合するジョブが実行されて、一つの有効な描画命令が作成されます。

2) 描画命令のソート。デプス、重なりなどを調べて処理していきます。基本的な方針として、描画時に描画ステートの変更量が最小になるようにコマンドバッファのソートを行います。

3) バッチの生成

  1. 描画のためのコマンドバッファを生成します。また、バッチ、およびサブバッチのドローコールを作成します。
  2. ジオメトリジョブが利用出来る、トランスフォーム命令を生成します。

このジョブはLateUpdate() の直後にスケジューリングされます。これによってUI以外のシーンがUIより前に描画されているあいだにこの処理を実行しておくことができます。これらのジョブがスケジュールされるときに、メインスレッドによってフェンスが設定されます。このフェンスの働きによって、ジオメトリジョブとキャンバスのレンダリングは、それぞれ必要なデータが作成されるまで適切に待つ形になります。

下記の例では、ジオメトリジョブがバッチ生成が終了しおわるまでストールしていることが分かるかと思います。このストールについてはさらに注意深くテストしていく必要がありますが、基本的にはテストシーンにUI以外に描画する要素がないのでこれが発生していて、シーンの複雑度が上がればおのずとこのストールの問題性は下がっていくはずだと考えられます。

unnamed (9)
MacBook Airよりもうちょっとコア数の多いパソコンで実行した結果
unnamed (10)
 
やりました!これで非常に高価な(覚えてますか?テストシーンには10000個も要素があります)UIで、メインスレッドのコストが0.4msまで下がりました。

その他に行ったパフォーマンス改善事項

  • 2D 矩形クリッピングの追加。現実的には、ほとんどのUIはステンシルバッファが要らないということに気がつきました。2D Rect でクリッピングできれば、ドローコールも描画ステートの変更も削減できます。
  • 2D 矩形カリングの追加。UI要素が描画バウンダリの外なら…ま、カリングしたいですよね。
  • スマートキャンバスコマンドバッファ
    • テキストや通常の要素の間でシェーダーやマテリアルを共有
    • set pass callを劇的に削減
    • UIに関連するデータをマテリアルプロパティブロックに押し込む
    • 通常のケースではUIでは1回のSet pass callのみが実行され、そして複数のドローコールが呼ばれる
  • * UIを1つのメッシュ/インデックスバッファに結合
    • DrawIndexRangeを使った描画
    • 1つのVBO/インデックスバッファを使い、必要に応じてリサイズする
    • 2^16'(65536) を超えるインデックス数になったらドローコールを分割する

次のステップ

現在、ソートやバッチングの生成処理はそれなりにいい感じで動作しているようです。これをさらに改善するために出来ることはありますが、現在の一番大きな問題はジオメトリジョブを発行するためにかかっている時間です。すでにメインスレッドから引きはがされ、独立したジョブとして動作してはいますが、パフォーマンスを改善するならこの処理の改善はよい検討項目です。この一連の作業のなかで、まだわたしには自分が色々とアホなことをやっているのではないかという確信があります - 例えば重要度の高いループの中で分岐処理をしてしまっているとか、例えばそういうやつです。他にも現在通常の計算処理で対応してしまっているが、ベクトル化することでいい感じに高速化できるだろうという部分もあると思います。
ほかにも、もっと高い観点から考えていくと、現在再バッチングが発生しているものの、これを最小化するための工夫というのも考えられるでしょう。これからもやれることは沢山ありますが、とりあえず今日、この記事で紹介した改善内容はUnity 5.2 ですでに実装されていて、大きなパフォーマンスの改善を果たしています。

まとめ

Unity 5.2 の新機能は結構すごいです。これらの新機能がUIシステムのメインスレッド上のコストを最小化しましたし、バッチング処理の最適化にも役立ちました。私たちが今回の仕事をするにあたって、プロファイラーを非常に重要な指標として使い、問題がどこにあるのかを確認してきました。1つ2つの問題については古いやり方が適切でない事がわかったので、すでにリリース済のコードであっても古いアプローチを捨てて新しくやりなおすことにしました。Unityの内部では、こういった仕事を他にも沢山やっていて、皆さんが報告して下さるダメなところを特定して、Unityを誰にとってもより良くするために奮闘しています。
皆さんのバグレポート、そしてあわせて問題を調査できる実際のプロジェクトを頂けることが本当に助かっています。いつも協力してくださってありがとうございます!

-UIチーム

2015年9月7日 カテゴリ: テクノロジー | 9 分 で読めます

Is this article helpful for you?

Thank you for your feedback!