Unity 特有のパフォーマンス劣化の落とし穴 2018歳末ふりかえり - part 1

今年、Unty製プロジェクトのパフォーマンス改善をやる機会があったんですが、世の中のかっこいい事例に出てくるような、ハードウェアやVM/コンパイラの気持ちになったミクロなチューニング、フレームワークの制限を回避するための大胆な再実装…… そういうかっこいい作業、には思ったよりならず、なによりもまず先に、Unityを使っているが故の落とし穴から這い出る一本道の作業が多めになってしまった。

それというのも、Unityは非常に、そこそこのものを最小手順で 確認/動作できる、誰でもかんたんにモノをつくれる、という部分を大事にしているから、「パフォーマンスを考えると普通はこうなっていてほしいよね」といった部分が犠牲になっている、あるいは手が回っていない、という部分が実際まだまだあるように思えた。

simple よりも easy を取っているというやつだろうか。

仕事でやっていたプロジェクトは、まずコードベースが大きく、動かすものもリッチで数も豊富、そして走らせる対象はモバイル。と、速く動かすのが難しい環境ではあったものの、決してそれは Unityの考えるユースケースとして少数派ではないように思っていた。それ故に、たくさんの落とし穴にはまってしまっていたことがちょっと驚きだった。

なかでも穴だらけだったのが「AssetBundle」この機能にまつわる周辺。 不思議なことに、このAssetBundle という 機能、別にeasyというわけではなく、むしろ、かなりの部分をアプリケーション部分で実装しなければまともに使えないはずだ。にも関わらず、非常にパフォーマンスを落とす要因になりやすい穴が点在していた。

この穴の気づきにくさから推測して、おそらく同じ問題に一度は嵌りそして抜け出した経験を持つ人は、日本に軽く1,000人はいるのではないかと思っているのだが、不思議とあまり大きな声で注意が叫ばれていないように思える。 (このとき、大きく助けになったのは 中国語のブログでした)

世界中の人々は、意外とAssetBundleを使っていないんだろうか。それとも、遅いけど死にはしないんだろうか。あるいは、僕以外のAssetBundleを使っている人は、みんな友達なんだろうか。そんな謎の孤独感を覚えてしまうほどであった。

ここでは、自分が経験した、直感に反してパフォーマンスのボトルネックをつくってしまうUnityの落とし穴について振り返ってまとめてみた。

最初から知っていれば穴を避けて設計できていたことも多かったと思う。
しかし、これはあくまで、マイナスをゼロにもっていくための知識であって、そういう意味では、Unityの猛者たちにとっては基礎編なのかもしれない。 来年からは、ゼロをさらにプラスに持っていくための作業にもっと時間を遣えたら良いなあ。と思う。というかもうちょっと実装したことを書きたいなあ。

1. AssetBundle のビルドに無駄な Unityのデフォルトアセットが大量に含まれる

さて、1つめは、数ある AssetBundle の罠のなかでも一番思い出にのこってるやつです。

AssetBundleとはなんぞや

AssetBundle というのは、Unityにおける、ゲームのパッケージとは別にアセットを分離・配布できるようにする仕組みのことを言います。 この機能をつかってこしらえた、アセットをかためたファイルのことをまた「AssetBundle」と呼びます。

スマートフォンのアプリにおいては、非Wi-fi環境におけるアプリのダウンロードサイズの上限が決まっていた関係から、よく使われている機能だと思います。 ゲームのアセットは大容量になりやすいので、フレキシブルに配信できるようになっていると、更新の敷居かなり下がり、ユーザにも優しい。そういった機能です。

公式ドキュメントのシリーズ: アセットバンドルとリソースに関するガイド - Unity

AsetBundle のファイルの中身は、Unity独自のフォーマットになっていて、仕様や実装が公開されていないため、基本的には、AssetBundleをビルドしたり、実行中にAssetBundleをダウンロード/展開する 部分は、Unity組み込みの実装を使う以外の選択肢はありません。(僕の知る限り)

とはいえ、実際のゲームで必要なワークフローや、ロードの管理を実現していくにあたっては、プロジェクトごとに実装しなければいけない部分が多いです。 ある意味自由が提供されているといえるのですが、Unityドキュメント特有の、前提知識がないと理解が難しい書き方などもあいまって、断片的な情報を全貌を把握し、正しくAssetBundleを管理するなにかをつくるのが非常に面倒です。 結果として、世の中のAssetBundleユーザたちは、参考実装とかを元にかなり似たようなものを自作していると噂されています。

AssetBundle は面倒、AssetBundle は大変、といった話が色々な視点で語られがちなようですが、 しかし、僕がここで問題にしたいのは、面倒とかそういうことではなく、AssetBundleをビルドした結果の奇妙な振舞い、そしてそれが生み出す著しいパフォーマンス劣化についてです。

AssetBunleの重複コピー問題点

AssetBundle化したいアセットをビルドするとき、もし、そのアセットが AssetBundleじゃないアセットを参照していたとしても、エラーが出ずにビルドが成功します。

AssetBundleは、さも依存関係が解決できたような顔をして、「できました!!」と言ってくるのです。 「そうかーできたかー」と思って、しばらく使っていたのですが、しかし、実はできていなかった。彼は、期待するほど完璧にはできていなかった。

AssetBundleの中のアセット A が、AssetBundleじゃないアセット B に依存している場合、できあったAssetBundleに入っているのは、 Aと、 Bの重複コピーです

つまり、たくさんのAssetBundleが、AssetBundleじゃないアセットBに依存している場合、その数ぶんだけの、おびただしい量のBの重複がプロジェクトに含まれてしまう結果になります。というか、なりました。 どうも、 AssetBundleは、AssetBundleの中だけで必要なアセットが揃っている、という状態をつくろうとするらしく、それを実現するために容赦なく重複コピーをつくります。

僕達のプロジェクトは、UI を細かい単位でAssetBundleにしていた上、当初は、UnityのデフォルトのUIのMaterialをそのまま使ってる部分が大半だったため、結果として、プロジェクト内にすさまじい量の UIのデフォルトMaterial、ついでに UI/Default シェーダが ロードされるという参事が発生してびびりました。

とくにシェーダのロードはコストが高い上、GPU側の処理を待つ間、メインスレッドがブロックされるため、ロード時間の激しい劣化を引き起こします。同じものを重複してロードするなんて命取りです。 また、Material&シェーダが別々になっていることによる実行時の性能劣化もまったく無視できません。

この問題についてもっともくわしい説明は、以下の UWA ブログの記事(中国語)なので、くわしくは以下を。

Unity 5.x AssetBundle零冗余解决方案 - UWA Blog

追記: Materialの指定のないUIのコンポーネントについては、単純にデフォルトのMaterialが重複する挙動をしているわけではないようです。 Twitter にて 椿 (@tsubaki_t1) さんに教えていただきました。

対策

  1. AssetBundle を使わない
    • AssetBundle から重複を完全にとりのぞくのは非常にむつかしい
    • Unity は、AssetBundleにかわる新しい API、「Addressable Asset System 」を準備しています
      • こっちでは重複問題が解決される、かもしれない
  2. プロジェクト内のアセットは全てAssetBundle化する
    • あらゆるアセットがAssetBundle化されていれば、AssetBundleは、重複を自分に抱えることはない
    • AssetBundle 同士の依存関係は、期待どおり解決してくれるため
    • 外部からダウンロードする必要のないアセットもすべてAssetBundleにしておき、パッケージにAssetBundleを含める方法があります。
      • この方法は公式ドキュメントでも紹介されています。
  3. 重複の自動検知、自動修正の仕組みをつくる

3については、そのアセットが依存しているアセットのパスを調べることで、AssetBundleからそれ以外への参照を検出することができるので、

        var assetBundleNames = AssetDatabase.GetAllAssetBundleNames();
        foreach (var assetBundleName in assetBundleNames)
        {
            var assetPaths = AssetDatabase.GetAssetPathsFromAssetBundle(assetBundleName);
            foreach (var assetPath in assetPaths)
            {
                var assets = AssetDatabase.LoadAllAssetsAtPath(assetPath);
                var dependedObjects = EditorUtility.CollectDependencies(assets);
                foreach (var dep in Dependencies)
                {
                    var path = AssetDatabase.GetAssetPath(dep);
                    if (path.Contains("/Resources/") || path.Contains("unity default"))
                    {
                        // ResourcesかUnityデフォルトを参照している!
                    }
                }
            }

ビルド時に自動で参照を差し替えてしまうなり、なんなりする仕組みをつくってがんばることが可能です。

追記: はてブコメントより。 Unity公式のツールは重複が考慮されているそうです。

Unity 特有のパフォーマンス劣化の落とし穴 2018歳末ふりかえり - part 1 - @hadashiA

今だとAssetBundleBrowser(重複は警告が出る)なりAssetBundleGraph(重複を自動で纏める)を使うのがお勧めかなぁ(どちらもUnity公式アセット)。何も知らないでAssetBundle系機能を自作するとこの問題にハマる

2018/12/31 12:34
b.hatena.ne.jp

2. AssetBundle に無駄に重複した Resource のアセットが大量に含まれる

さて、まだまだ問題のAssetBundleです。

AssetBundle が重複を抱えてしまう対象は、Unityのデフォルトリソースだけではありません。 AssetBundle化されていないものすべてです。

Resources/ 内のアセットも例外ではありません

Resources とはなんぞや

Unity において、 Resources というフォルダにおいたアセットは特別扱いされます。
この名前のフォルダにあるファイルだけは、実行中に、Resources.Load* などのAPIをつかって動的にロードすることができるようになっています。

また、裏を返せば、Unityにおいては、実行中に非同期にアセットをファイルから読み込むためには、

  • AssetBundleとしてかためておく
  • Resourcesにいれておいてパッケージに含める

この2択しかありません。

しかし、既にUnity公式ドキュメントで、Resources の機能そのものが 非推奨扱いになっているので、注意してください。

The Resources folder - Unity

> 2.1. Best Practices for the Resources System
> Don't use it.

非推奨の理由として、ドキュメントでは以下のような点が挙げられています。

  • フレキシブルにLoad/Unload をする方法がない
  • すべてをResourcesで管理することはできないゲーム起動が遅くなる

Unityは、ゲーム起動時に、Resourcesと名のつくすべてのフォルダに入れたすべてのアセットの索引のようなものをつくるようです。 そのため、Resources のなかにあるアセットが増えれば増えるほど、ゲームの起動が目にみえて遅くなっていきます。大規模プロジェクトにおいてすべてのアセットを Resources で管理することは想定されていないようですね。

問題点

しかし、僕としては、非推奨であるいちばんの理由は、 AssetBundle からResources を参照していると、大量に重複ができる問題を引き起こす点だと思います。

( ちなみに、Resources の中から AssetBundle を参照している場合も、Resources のほうに重複が含まれます)

「非推奨なら使わなければいいじゃん」と、思いますが、TextMesh Pro などに代表されるように、3rdパーティのプラグインが、 Resources/ 内にシェーダなどを入れていることがかなり多く、これも落とし穴です。

当然の結果として、Resources にあるText Mesh Pro のシェーダを AssetBundleから参照していると、AssetBundleひとつにつき一回、シェーダがいちいち重複してしまいます。

シェーダの重複は怖いです。ロード時間の劣化、そして実行時間の劣化を引き起こします。

対策

  1. Resources を使わない。
  2. AssetBundle から Resources への参照の検出を行ない、Resources 内のアセットをAssetBundle化して対応する

3. Shaderのコンパイルが実行時に走ることによってメインスレッドが長時間ブロックされる

プロファイラなどをがんばって眺めていると、Unity は、基本的には必要になったタイミングではじめてシェーダの初期化をはじめることがわかります。

グラフィックスAPIの種類によって、プロファイラに計上される名称などは変わってきますが、たとえば Metal であれば Unityプロファイラ上で 、 Shader.Parse といった名前でかなりの時間を消費していることがあります。 これは、まだ初期化されいないシェーダがはじめて描画される場合にのみ発生します。

f:id:hadashia:20181216122421p:plain

この画像は、実際のプロジェクトで採取した、XcodeGPUプロファイラの結果のグラフです。
一定のフレームレートで描画が実行されていたのに、あるフレームにくると、突然、数百ミリ秒の時間が消費され、描画が止まっています。 このフレームで起こっていることは、表示によれば「Shader Compilation」、シェーダのコンパイルのカテゴリということになります。

これを見てもわかるとおり、シェーダの初期化、コンパイルGPUにアップロードし、使える状態にする処理は非常に高価です。
とくにMetal は、OpenGLと比較して、初期化に時間がかかる傾向があります。

シェーダの初期化というやつは、可能な限り、事前にやっておきたいところなのですが、GPUも絡むためやっかいです。 期待とは裏腹に、Material がくっついた Unityのオブジェクトを Instantiate しただけでは、シェーダの初期化処理は行えていません。 あくまでCameraにはじめて映った時点で Shaderは初期化されるため、 ロード画面などでCameraに映さずに Instantiate をしたオブジェクトのShaderは初期化されていないのです。 (これは プロファイリング結果から判断してます)

もうひとつ、シェーダのコンパイル(RenderState)は、OSによってキャッシュされるような挙動をするため、問題に気がつきにくいことがあります。 アプリを終了してもこのキャッシュが生き続けていて、問題が観測できないんだけど、端末を再起動すると、キャッシュが消えてロードがめっちゃ遅い状況が再現したりしました。

対策

対策は、シェーダを使う前に初期化を実行することです。 幸い、現在のUnityには、シェーダの初期化を事前におこなう方法が提供されています。

  1. Shader.WarmupAllShaders を使う
    • Unity - Scripting API: Shader.WarmupAllShaders
    • このメソッドを叩くと、Unity がメモリ上にもっているShaderオブジェクトの、全variant を同期的にすべてロードします。
      • AssetBundleを使っている場合は、AssetBundle内のShaderをメモリに読み込んでから実行しないと意味がないので注意です。
    • 全variant なので、プロジェクト内のシェーダのキーワード数が多い場合、現実的な時間で処理を完了させるのはむずかしいです。
  2. ShaderVariantCollection.WarmUp を使う
    • ShaderVariantCollection は、読み込みたい シェーダと、そのvariant(同じShaderでも、キーワードのOn/Offによって実際には複数のシェーダとしてコンパイルされる)の一覧を定義したアセットです
    • これをあらかじめ作成しておくと、 そこに含まれているシェーダのみを WarmUp で事前ロードすることができます
    • 僕のプロジェクトでは、ビルド時にプロジェクトに含まれる Shader や Material を全てクロールし、ありえる全組み合わせの ShaderVariantCollection を自動生成する仕組みをつくりました
  3. AssetBundle を使わない場合は、Graphics Settings で事前ロードするシェーダを設定しておくこともできるようです。
    • 追記: Graphics Settings にある Always Includes に指定しただけでは、 シェーダの事前ロード迄やってくれるわけではありませんでした。
      • この設定は、ビルド時に参照が なかたっとしても必ずパッケージにシェーダを含める設定でした。
      • @piti6 さんに教えてもらいました。
    • 参考: [シェーダーのロード時間の最適化 - Unity マニュアル https://docs.unity3d.com/ja/2018.1/Manual/OptimizingShaderLoadTime.html]

ちなみに、何故これが落とし穴だと思ったかというと、こういった手法の情報がすくないこと、これを知らず、不用意にシェーダのvariantを増やしてしまい、事前ロード時間が爆発して困った。といったことが起きたためです。

また、AssetBundleの落とし穴によって シェーダが重複していた場合、 WarmUp をしても、重複コピーのほうはWarmUp されていない、といったことが起きるため、やはりAssetBundleの 重複はおおきな落とし穴といえるでしょう。

つづく

長くなってきてしまったので続きます。 次は c# 関連の話になる予定です。

思うに、Unityの挙動についての説明は、推測のような言い回しが多くなってしまったりして、なんというかとってもわかりずらい。書くのもなんかむつかしい。 オープンソースのソフトウェアであれば、ここまでこういう挙動をしてるっぽいとか、検証してみないとわからない、みたいなことってあんまりない気がするのですが。

追記: part2