Unityでパフォーマンスの良いUIを作るためのTips

山村達彦氏(以下、山村):よろしくお願いします。僕は山村といいます。Unityのエバンジェリストをやっています。勉強会をはじめる前にお聞きします。この中で「UnityでUIをマスターしたぜ!」という方はいらっしゃいますか? いない? よかった。それでは今回は、みなさんにとって実のある話になるかもしれません。逆にUnityのUIシステムを「なんとなく使ったことがある」という人は?

参加者1:詳しくはないです。

山村:詳しくない? 大丈夫です。Imageを出せれば大丈夫です。それぐらいの人は?

(会場挙手)

だいたいできますね。

では、さっそく話していこうと思います。今回話す内容として、お品書きを用意しました。

最初に、UnityのUIシステムがどんなものかという話をしようと思います。そのあとは難易度が跳ね上がります。UnityのUIシステムというのはなんとなくは使えるんですが、なんとなく使うとなんとなくしか性能が出ないので、そういったところをなんとかしようという話をしようと思います。

UnityのUIシステムについては知っていても、なんとなくやり方がフワッとしている部分が多いと思います。ですので、最初はバッチングですね。そのあとにフィルレートの話をして、そのあとに、これが一番のボトルネックになるであろうリビルドの話。そしてその他です。このようなことを話していこうと思います。よろしくお願いします。

Unityのおさらい

最初にUnityのおさらいをしましょう。昔はUnityにUIシステムはありませんでした。Unity4.6の時にやっと登場したんですが、それまではNGUIという外部の高級なアセットが普通に採用されていました。これは非常に性能が良く、たぶん今も使われていると思います。そんなNGUIの中の人を雇って一緒に作ったのが、Unity UIシステム、通称uGUIです。

Unityは「Unity UI」とはいっていますが、内部では「uGUI」というコードネームがそのまま使われていたり、ネームスペースに「uGUI」とあるので、「uGUI」と覚えてもらえれば大丈夫です。自分もそう覚えていますし、検索するときはこれが一番わかりやすいです。「Unity UI」だと、ほかのものがいろいろ出てしまいます。

uGUIの機能にはどんなものがあるかというと、テキストやImageだったり、Raw Image、普通の画像をそのまま表示したり、インタラクションのいくつかの機能であったり。

あとはレイアウトシステムです。通常のレイアウトシステムと、自動レイアウトシステムがあります。これは「使いにくい」と評判の機能なので、今後補強される予定です。ほかにもいくつかありますが、こういった機能を持っています。

これ(uGUI)はどんな機能なのかといいますと、アンカーとポジションを使って、場合によってはピボットなど、レイアウトするシステムです。だいたいのレイアウトはこれでいけるんじゃないかと思います。複数の画面を変えるといった対応をするのに、これは便利な機能です。

私はそこそこ嫌いな自動レイアウトシステムなんですが、オブジェクトを足したり引いたりしたときに、レイアウトを破綻させないようにレイアウトしてくれるシステムです。これは理解できていると便利なんですが、難しいと評判です。

あと、普通にImageを使えるんですが、ここにシェーダーをぶちこむこともできます。これはよくできている機能です。例えばこれは、ディゾルブシェーダでUIのImageを消すことも可能になっています。

あとはインタラクションですね。ボタンを押したら玉が出る。「ボタンをなにかしたら」「テキストを変更したら」「スライドをちょっと調整したら」といったインタラクティブな表現は、UnityEventという機能を使って簡単に作ることができます。

やったことがある人はおわかりいただけると思いますが、なんとなくでそれなりのものは作れてしまうシステムです。それは非常にありがたいことですが、ただなんとなく作るシステムは、パフォーマンスも担保してくれているかというと、そうではありません。あまり担保してくれていないというか、フレキシブルさに振っているところがあります。残念ながら「全自動UI最適化ボタン」は存在しないと、そう思ってもらう必要があります。

どういうことかというと、重要なのはやはりバランスです。

柔軟性とパフォーマンス。やはりここはトレードオフになっていて、必ずどちらかに振らなければいけません。

uGUIはアーティストが使いやすいように柔軟性に振っているのですが、やはりパフォーマンスは欲しいという意見はあります。なので、どうすればパフォーマンスが出るのか、良い感じでUIを作れるのかを話していこうと思います。

バッチングについて

最初に話す内容です。まずはみんな大好き、ドローコール関連のバッチングです。バッチングというのは、複数のスプライトを1回で表示するようなアプローチについてよく言われています。

描画処理がどうなっているかというと、基本的に後ろからペタペタとスタンプを貼るような感じでテクスチャを貼っていくイメージです。透明のテクスチャをどんどん貼っていく。基本的にUIはこのように作られることが多いと思います。それが一番楽ですし、担保も利きます。

基本的な描画の流れは、昨今のエンジンはみんなこの機能を使っているとは思いますが、基本的にマテリアルがあって、そこにテクスチャなどのパラメータを設定して、それにポリゴンをくっつけて描画するという流れになっています。基本的にはどうがんばってもこの流れになります。

今は1体だけを表示するUIですが、もしこれをたくさん表示したい場合、「たくさん表示すれば良いじゃん?」と繰り返すのはちょっと効率が悪いです。

同じパラメータ・同じ設計などで何度も描画の命令を発行し直すのは、やはり効率が悪いです。

なので、uGUIでは、いったんCanvasにすべての情報をまとめてバッファを作ってしまって、バッファにマテリアルをバコッと突っ込むだけで全部描画ができるシステムを採用しています。これを使うことによって、描画のコストが非常に安くなります。

1回に描画できるのは、基本的には同じテクスチャや同じマテリアルなど、そういったルールになっています。

入れ子になるとバッチングが解除される

ここまでは簡単です。つまり、キャラクターとマテリアルがいくつかあって、そのキャラクターごとにバッチングすれば良いと(いうことです)。しかし問題は、いくつかの要因によってバッチングが解けることがあるということです。

例えばこういう状態です。

3枚のテクスチャを表示しました。これは真ん中が入れ子になっています。アーチャーが2体いて、ライダーが真ん中にいます。

(会場笑)

この状態だと、残念ながらバッチングはできません。「アーチャーを2体先に表示したあとに、ライダーを挟む」ということは残念ながらできなくて、奥から貼っていくので、「アーチャー・ライダー・アーチャー」という順番での描写になります。

uGUIはけっこう賢くて、もし入れ子になっていなかった場合、つまりアーチャーとライダーの位置がちょっと離れていた場合は、その描画順番においてもソートして1回のバッチングで済むようにやってくれます。ですが、残念ながら入れ子になってしまっていた場合は、バッチングは解けてしまいます。

これは似たような状況です。マテリアルが切り替わっている場合……例えば、独立にシェーダを作ってそこに適用した場合、描画するルールがまったく違うものを間に描画してしまったら、バッチングは当然解けてしまいます。基本的には、同じマテリアルで一気に描画できる状態のみ、バッチングは可能です。

これをちゃんとルールに直してみると、このようになります。

1つ目、同じCanvasに所属していること。2つ目、同じマテリアルを使用していること。3つ目、同じテクスチャを参照していること。4つ目、これはちょっと特殊なルールなんですけれども、座標のZの値が同じであることが必要です。これがズレているとバッチングできません。

よくあるのが、UIをちょっと手前に置くといったことですね。タイトルの導入のUIみたいなものを手前に置いておくと、バッチングが解けてしまいます。

あとは、同じマスクでクリップされていることというのが、もう1つの条件につきます。これはRectMask2Dの場合ですね。最近、マスクの場合はバッチングしてくれるようになっていました。

UI Profilerを使用して観察

このルールを知っていて、これをやれば良いよという話なんですが、そうはいっても解けるものは解けるし、気づいたら解けてしまうというのはよくあることです。注意してもやっぱり解けてしまうことはある。「じゃあこれ、どうすれば良いのか?」というと、やはり解けている部分を探す必要があります。どうやって探せば良いのか?

「2017」からUI Profilerが追加されました。今回の答えにはだいたいこれが関わってきます。Frame Debuggerでも良いんですが、今回はUI Profilerを使っていこうと思います。

これを使うと、バッチングが解けているものや、どのようにバッチングされているのかを非常にわかりやすく取ることができます。

例えば、UI Profilerの画面が右側にあって、左側に実際のゲーム画面のシーンビューが出ています。

いま、実際このキャラクター3体を選択しているんですが、このバッチングが解けてしまっているんですね。でも、これは画面を見ただけだとわからない。なので、このUI Profilerで追っていこうと思います。

見るべきはどこかというと、バッチの一覧です。ここにそのバッチのどういう部位に描画されているのかが出てきます。

もしバッチングがされていた場合は、同時に表示されるように、確認できるようになっています。バッチがちゃんとできていると、選択したときにバッチされているものを一括でシーンビューに表示してくれるようになっています。同時に、どのオブジェクトがバッチされているのかを確認することができます。

今、いくつバッチされているのかを確認してみました。バッチされていれば複数選択されるし、UIなどもいっぱいでてきます。これはけっこう気に入っている機能で、自分も好きな機能の1つです。

バッチできない理由を表示してくれる機能もあります。先ほど紹介したルールに沿って、どんな理由でバッチが途切れたのかを紹介してくれています。

これを見てみると、4・5・6・7がキャラクターなんですが、バッチが途切れてしまっている理由として「Different Texture」と書いてあります。つまり、テクスチャが違うということですね。

先ほどのルールを思い出してもらいたいんですが、テクスチャをバッチをするためには、参照しているテクスチャが同じである必要があります。これはどうすれば良いのか? わかる人はいますか?

参加者2:Atlas化してまとめる。

山村:すばらしい! そのとおりです。

ほかにもAtlas化機能はいっぱいありますが、このSprite Atlasの機能でテクスチャを1つにまとめてしまうというアイデアがあります。

Sprite Atlasはちょっと前に追加された新しいパッキングの仕組みですね。これを使うとSpriteを別々にテクスチャを用意するんですが、それを1つのテクスチャにレイアウトを維持したまままとめることができます。

これをまとめてしまって、あとは描画するわけですが、先ほどと違い、1個のテクスチャからすべてのキャラクターを参照できているので、すべてのキャラクターを全部1回でバッチングして描画することができます。マテリアルやテクスチャ設定を切り替える必要がありません。メッシュのUVはちょっと変わっていますが、それは大した問題ではありません。

これをそのまま実行すると、ちゃんとすべてのキャラクターが1回で描画され、バッチングされました。

タイトパッキングを回避する方法

ただ、よく見るとちょっと問題があります。

なにかが違うんです。(スライドを指して)これが元絵です。どこが違うでしょう?

(スライドを指して)答えはこれです。なんか変なパーツが混じっています。

これはSprite Atlasの機能で、みっちりパッキングしてしまうんですね。もともとSprite Atlasは、Sprite Renderer、つまり2Dのキャラクター向けのUIシステムで、Unityの2Dのキャラクターは、ポリゴンでくっきりと区切ってしまうんですね。だから、テクスチャをみっちり詰めることができるんです。

UnityのUIシステムは矩形で取るので、矩形にそのまま取ってしまった場合、このように謎の遺影が出てしまいます。これは非常によくない。ですが、バッチングは減らしたい。

対策はこうです。

1つ目、タイトパッキングを止める。先ほど「みっちり詰める」といいましたが、これにはみっちり詰めるのをやめるオプションがあります。タイトパッキングという仕組みです。これを止めることによって、矩形のスペースを維持したままパッキングしてくれます。

これによってテクスチャのみっちり詰める具合がちょっと下がってしまいますが、その代わりに安定して、隙間のある、余裕のあるパッキングをやってくれます。

もう1個、最近できるようになった機能があります。

今までUnityのImageは基本的に矩形でした。それがやっとメッシュを使うことができるようになりました。これによって、Unityの2D Spriteと同じような仕組みがメッシュでそのまま使えるので、ちゃんとクリップされた状態で表現できます。

これは非常にうれしいことが多いのではないかと思います。おそらく「Vector GraphicをUnity UIで使いたい」という要望から来ていると思いますが、「タイトにパッキングしたい」という要望の場合にも、うれしいことが多いのではないかなと思います。

ということで、バッチングに対してはバッチの条件を満たしたり、テクスチャをパックする必要があります。

あとは、先ほどすこし紹介したとおり、バッチングが途切れる理由に「同じマスクに所属しているもの」というのがあるので、たくさんマスクを使ってしまうとどんどんバッチングが途切れるという現象が起こります。なので、マスクはできるだけ控えましょう。

フィルレートについて

バッチングはたぶんみなさんすでに完璧にマスターされたと思うので、次にいきましょう。フィルレートです。どちらかというと、この場合はオーバードローというのが正しいですね。

フィルレートについて、どういうものかを話していきましょう。先ほど、「Unity UIはポリゴンに色を塗って表現している」といいました。これはグラフィックドライバに沿った、非常に高速な手法です。

ポリゴンを用意して1ピクセルずつ塗っていきます。透明な部分も含めてピクセルを塗っていくわけですね。これがマテリアル単位でGPUで一斉に行われます。ここで1つだけ考えるべきことがあります。それは「透明に塗られる」という観点です。

たくさんのUIが重なってあった場合、「色が塗られているか」「完全に透過しているか」を気にせずに色は塗られます。

昔は透明を完全に描画しないというオプションがあったんですが、最近はそれをやったほうが遅くなることが多いので、透明はちゃんと透明を塗ることが多いです。

そうなると、やはり何度も塗ってしまう箇所がでてきます。「透明を塗って、その上に透明を塗って、その上に透明を塗って」という場合です。

このアーチャーの場合、ほとんど重なっている部分はないように見えますが、透明を塗るという観点から見ると、けっこうな部分を重ね塗りしていることがわかります。これはオーバードローといわれ、やはり何度も塗り直しているのはよくありません。とくにモバイルだとけっこうきついです。

じゃあ、これをどうやって確認すれば良いのか? UI Profilerを登場させましょう。UI Profilerの機能の1つにオーバードローを確認するものがあります。

次の画面で動画を紹介しますが、色が青いほうが大丈夫で塗られていない状態ですね。赤くなっているとまずい状態です。いくつか赤いところがあるので、そこそこまずい。

ただ、面積が小さければ大した問題ではないです。問題はこれが広かった場合。画面いっぱいに何度もオーバードローをしていた場合、非常に問題です。これを確認できるのがオーバードローのチェックの機能ですね。

実際にどういう画面になるかといいますと、試しにこのキャラクターを量産してオーバードローをさせます。下の画面にあるように、今ヒートマップ並みに赤くなっています。

こんな感じで、何度も同じところを描画しているとまずいです。では、この対策はどうすれば良いのか?

基本的には「UIを表示しない」のが一番手っ取り早くて楽な方法です。見えていない部分を削る。必要ないものは削る。よくあるのが、1枚絵の裏に背景がある場合、こういったものを削れば良い。

あと、重なる部分はできるだけ削る。例えば、先ほど紹介したSprite Meshだったり、「Fill Center」といって真ん中をくり抜く機能があるんですが、それを使って9 Sliceをやった場合、真ん中をくり抜くことができます。これを使ってオーバードローの範囲を削るのが良いアイデアです。

もしくは、単純にオーバードローしても大丈夫なシェーダーに差し替えてしまうのも、良いアイデアです。何度も描画するごとに負荷が高くなるということは、つまり1回の描画の負荷を下げてしまえば、実はそれほど問題にはならないという意味でもあるので、非常に軽量なシェーダに差し替えることも有効です。

例えば、マスクの機能はいらないからマスクの機能を抜いたり、頂点カラーがいらないから頂点カラー抜いたり。そういったシェーダに差し替えてやることによって、オーバードローをやってしまっても負荷を抑えることが期待できます。

ただ、これは小話なんですが、AssetBundleで配信した場合……これはアセットに格納して外部でダウンロードして使えるようにする仕組みなんですけれども、これで配信した場合はちゃんとAssetBundleの依存関係を考える必要があります。シェーダが破棄されないように、ちゃんとAssetBundleのシェーダを使えるようにしないと、シェーダコンパイラが何十回も走って、ロードさせるのにガックンガックン止まるというUIができあがります。そんなことになってしまうので、ちょっと考える必要はあります。

残念ながら現状は、Unityでシェーダの負荷を確認する機能がありません。自分のおすすめとしてはXcodeですね。安定して動くかどうかはともかくとして、GPUを提供しているメーカーがプロファイラーを提供しているので、それを見るのが一番きれいに確認できる方法です。

例えばXcodeの場合は、そのシェーダの描写がどのぐらい時間がかかっているのかを表示しています。これは非常にありがたい機能です。