Unityの新機能で開発した『IDOLY PRIDE』

渡邉俊光氏(以下、渡邉):「『IDOLY PRIDE』における描画最適化術」と題して、株式会社QualiArtsの渡邉が発表します。

まず簡単に自己紹介をします。2012年にサイバーエージェントに新卒入社し、『オルタナティブガールズ』等の開発を経て、現在は株式会社QualiArtsのTA室(Technical Artist室)で『IDOLY PRIDE』の3D実装全般を担当しています。アイプラ(アイドリープライド)は6月リリースしたスマホゲームで、次世代感があるグラフィックを目指して開発を行いました。

本日のアジェンダはこちらです。発表時間が短いので基本機能の説明を省略するのと、詰め込み過ぎて相当駆け足となってしまうのはご了承ください。

まず実行環境を紹介します。現在リリース済みのアプリの実行環境はUnity2020.3.4となっています。RenderingPipelineはLegacyパイプラインを使用しておらず、UniversalRenderPipeline10.5.0をカスタマイズして使用しています。LightmapはUnity組み込みのGPUベイクを使用していて、シーンのベイクを数分で完了させることが可能なため、UnityのLightmapを採用しました。

GPUベイク自体は、2021年に入って工夫すればクラッシュせずにベイクできるようになった程度なので、かなりギリギリでの採用でした。レンダリングフローはポストエフェクト含めすべてHDRで処理していて、Linear Renderingによって次世代感のある見た目を実現できていると思います。

対象プラットフォームですが、AndroidはVulkan、iOSはGPUfamily3以降のMetalとなっています。AndroidはOpenGLESの対応も入っていますが、一部端末で挙動が不安定で、大部分の端末はVulkanに対応されているので、念のために入れた程度になっています。動作目安として、4、5年前のハイエンドが低画質30fpsで動くものを目指しています。

これを見てわかるとおり、Unityの新機能はだいたい使っていて、相当攻めたものになったかなと思っています。

ホーム画面のシーンですが、このような見た目がモバイルで実現可能となりました。

Unityの新しい描画システム「ScriptableRenderPipeline」

次にScriptableRenderPipelineの紹介をします。UniversalRenderPipelineは、Unityが新しく用意した描画カスタマイズシステムの1つで、さまざまな端末で動作するようにチューニングが施されています。また、RendererFeatureを追加することで簡単にCommandBufferを追加できるようになるので、特殊な描画処理を簡単に追加でき、最適化も行いやすくなっています。

右の画像はアイプラのRendererFeatureの一覧です。10個のRendererFeatureが追加されていて、それぞれの描画処理を行っています。ポストエフェクトをRendererFeatureで追加すると、かなり非効率な処理になるのでPostProcessPassとForwardRendererクラスを複製して、直接処理を追加しています。

背景にはすべてPBRを用いて、ShaderGraphのUniversalTargetを追加して、反射面の対応や一部ライティング処理を変更しています。ShaderGraphで組んだシェーダはSRPBatchやチューニングが適用されるのでとても便利なのですが、そのまま使用すると非効率な箇所もあるので、生成コードの確認をして無駄な処理は効率化したほうが良さそうです。

UniversalTargetを追加する場合、ShaderGraphのバージョンが変わる度に仕様が変わるので、そこだけ注意が必要です。ちなみにこの仕様の変更は、Unity2019からUnity2020にするタイミングでShaderGraphの仕様がまったくの別物になったので泣きました。またUnity2021でも仕様が変わっています。

Targetというのが見えていますか? このActive Targetsというところに追加ができるのですが、ここに追加する場合は覚悟が必要です。

SRPBatchは、ScriptableRenderPipelineのメイン機能とも言える新しいバッチシステムで、モバイルではMetalまたはVulkanが対応しています。SetPassを最小限にして、DrawCallの発行効率を最大化してくれます。これによりCPUのDrawCall発行を1.5倍から2倍程度高速化できます。アイプラでは、ほぼすべてのシェーダをSRPBatchに対応させて、可能な限りバッチされるように工夫をしました。

ただし適用にはさまざまな条件があります。ここで話すと長くなってしまうので省略しますが、工夫しないと「なんかあまりバッチされない」ということになります。わりと最近まで挙動が不安定でしたが、2020.3以降であればリリースできるレベルかと思います。

仕組みについては公式ブログがあるので、こちらをご覧ください。

結果がこちらです。もともと283から走っていたSetPassが155になっています。CPUの負荷もだいたいこれに比例して削減されているので、だいたい1.5倍から2倍程度、DrawCallのCPU負荷は減らせています。対応すればするほどお得な機能なので、今後のプロジェクトでは対応するべきかなと思います。

CPUの負荷はBurstコンパイルで軽量化

最後に描画APIの紹介をします。まず軽量化にあたってですが、開発者のみなさんは十分わかっていると思うのですが、数値計算を含めたりするとC#は泣けるくらい遅いです。さらにTransform等のUnityAPIと組み合わせるとかなり遅くて、100個くらいのオブジェクトがあるうえで直列処理すると、低スペックモバイルでは無視できない負荷となります。

そこで便利なのが、みなさんが大好きなBurstコンパイルです。ParallelForTransformと組み合わせると爆速になります。さらに速いのがComputeShaderで、CPUではできない数の並列処理を高速に実行できて、ComputeBufferと組み合わせて頂点シェーダに渡すことで直接描画にも使えます。

ただし、このComputeBufferを頂点シェーダに渡すためにはShader Storage Buffer ObjectにGPUが対応していないとダメで、実質Vulkanが必須となります。仕様的にはOpenGLES3.1でも対応しているはずですが、Androidの一部端末で正常に動作せず、Vulkanで動作させないとダメだったので、実質Vulkanが必須となっています。

実装方法がちょっと特殊ですが、対応自体はわりと簡単なのでVulkan世代の端末は積極的に使っていったほうが良さそうです。

次にレンズフレアの種類ですが、アイプラではProFlareというアセットを使用しています。UniversalRenderPipeline10.5.0にはレンズフレアの処理が無いため、何かしらの実装が必要なのでこれを使用しています。ちなみに2021.2のベータにはフレアの実装が若干追加されています。ただし描画・頂点更新処理はほぼすべて書き換えており、パフォーマンス改善を行っています。

右のように分割して載せたのですが、C#では座標系、画面内にあるかどうかの判定を行い、Burstで値を更新しつつComputeBufferの型に整形。ComputeShaderでDepthTextureと遮蔽判定を行って、この結果をもとにDrawProcedural APIで描画しています。これにより約5倍以上の高速化ができました。DrawProcedural APIのリンクはこちらに公式のUnityのページを貼っておきます。

このAPIはメッシュが不要で、頂点のつなぎ方をGraphicsBufferで送り、つなぐ数を指定することで動的に描画のポリゴン数を制御できます。これにより毎フレームで変動するレンズフレアの描画の追加負荷をゼロにすることが可能となっています。極論このAPIを使えば何でもできると言えるので、今後も活用したいなと思っています。

次にボリュームライトの処理です。この画像を見てもらうとわかるのですが、こういった軌跡となっているライトですね。これはVolumetricLightBeamというアセットを使用しています。このアセット自体はInstancing機能に対応されているのですが、実際に複雑なシーンに配置するとあまりバッチされず、100台近く配置されていると一つひとつの負荷が積み重なり、モバイルで動かすのはほぼ不可能でした。

そこで更新処理をほぼ書き換えて、MeshRendererを廃止してカリングを自前にすることで高速化を実現しました。ここは何度もパフォーマンスチューニングを行い、モバイルで現実的に動くところまで持っていきました。速度比較が厳密にはできないのですが、動かなかったものがCPUの負荷をほぼかけずに実行できるところまで、チューニングを行うことができました。

動画にはDrawMeshInstancedProcedural APIを使用しています。APIのリンクはこちらです。このAPIを簡単にいうと、指定Meshを指定回数分instancingで複製するだけの、Draw系でいうと一番シンプルなメソッドです。ComputeBufferをMaterialPropertyBlockに入れて、頂点シェーダからバッファを参照することでinstancingごとに色や形状を変更できます。

単純にinstancingを自前でやりたくなったらこれを使うといいと思います。

観客の描画にはDrawMeshInstancedIndirectを使用

最後に観客です。ゲームの仕様上、動員人数を一人単位で変動させる必要がありました。例えば3万人の箱に2,000人だけ動員といったことを実現する必要があります。これを可能にしたのがComputeShaderです。観客一人ひとりの数値を管理して、個別で動作させることが可能です。一人ひとりの身長や、流すアニメーションも変えているので、リアルなライブ感がかなり再現できていると思います。

もしゲームをやっている方がいたら、ぜひ観客にも目を向けてもらえると幸いです。座席データには優先度が割り振られているので、アリーナ席から順に人が入るということもしています。この人数をすべて描画すると、負荷が無視できないため、一人ひとり境界球カリングをComputeShaderで行うことで、描画数を最小限にしています。描画にはDrawMeshInstancedIndirectを使用しています。

DrawMeshInstancedIndirectはIndirectで初期化したComputeBufferを使用し、複製個数や描画超点数をすべてGPU完結で制御できるAPIです。カリングにより、画面内に何も映っていない場合は、描画負荷をゼロにすることが可能です。

頂点シェーダで普通にinstancing数値を取得できるので、特殊な処理は必要ありません。

本日のまとめです。今後はVulkan世代をベースにできるので、さまざまな最適化が可能となり、今までできなかった表現が可能となっていきそうです。また、Burstコンパイル対応をすることでCPU問題はかなり解決できます。ただしBurstできない箇所が重すぎるのが今後の課題です。ComputeShaderは爆速ですが、Androidを過信し過ぎないようにしましょう。

当初はiPhone 7で確認をしていたのですが、AndroidだとComputeShaderが重いということがあります。実装にはけっこう工夫が必要なので、そこだけ注意が必要です。また世代が上がっても、未だに超ローエンド端末がリリースされているので、そこをどこまで担保するかが今後の課題となりそうです。ご清聴ありがとうございました。

司会者:渡邉さん、発表ありがとうございました。コメントにあった質問をいくつか拾っていきたいと思います。1つ目が「Unity2020.3はけっこう新しいのですが、途中でバージョンアップをしたのですか?」という質問です。

渡邉:プロジェクトをいつから開発したかは言えないのですが、かなり昔のUnityから使っているので、常に最新を追い続けてギリギリ2020.3で動くようになったとイメージをしてもらえれば大丈夫です。

司会者:ありがとうございます。次が「描画まわりで新しい技術を採用するにあたって、検証期間などをどれぐらい設けているんでしょうか?」という質問です。

渡邉:基本的にAndroidの描画APIがどこまで対応しているかをできる限り信じて実装しています。VulkanとOpenGLES3.2に対応している端末ですが、ES3.2だと挙動がバグっているところがあるので、そこら辺は実際にテストして問題が起きたものは随時直していきました。

司会者:次が「数万人の座席データを作るだけでもCPU負荷が高そうですが、どうやって対応をしているのでしょうか?」という質問です。

渡邉:座席データは、Maya上で別のツールがあって、それで座席データの配置行って、 ScriptedImporterで事前に座標自体は確定させています。境界球カリングの情報も全部入れておくという感じです。事前に全部計算しておいて、あとはランタイムでソート済みのデータをどこまで描画するかということを行っています。

司会者:ありがとうございます。最後に「全体のデータ設計を先にするのでしょうか? それとも先に描画まわりを固めてから設計していますか?」という質問です。

渡邉:データ設計がどういったことかがちょっとわからないのですが、基本的にはこういうのをやりたいなと思ったら、描画まわりで新しい機能がないかを探しつつ、それを基に設計をしていくという感じです。

司会者:ありがとうございます。