Search Unity

Unityのパフォーマンスの最適化に関するベストプラクティス

  • 開発者向け

<このページで学べる内容>

データ指向設計に対応するよう進化した Unity のアーキテクチャを受けて更新されたスクリプティングパフォーマンスと最適化に関するヒントを Ian Dundore が紹介します。  進化しつづける Unity。これまでのやり方は、今やゲームエンジンのパフォーマンスをフルに発揮するには最適なものではなくなっているかもしれません。この記事では、Unity(Unity 5.x から Unity 2019.x まで)の変更点の概要と、それらをどう利用できるかという点について説明します。

スクリプティングのパフォーマンス(パート 1)

最適化でのタスクの相当な難問として、ホットスポットが検出された後、どうやってコードの最適化方法を選択させるか、というのがあります。オペレーティングシステム、CPU、GPU のプラットフォーム固有の詳細な仕様、スレッディング、メモリアクセス、入力データの分布、スケーリングが必要なパラメーターなど、さまざまな要素が関与します。どの最適化方法が実際に導入した場で最大のメリットをもたらすか、ということを事前に把握するのは困難です。

一般的に、小規模なテストプロジェクトで最適化をプロトタイピングしておくのは、良い考えと言えます。繰り返し実行する時間を大幅に短縮できます。とはいえ、テストプロジェクトにコードを分離するということ自体が難題です。コードの一部を分離すると、それが実行される環境も変わるのですから。スレッドのタイミングも変わってしまうかもしれません。可能性としては、マネージヒープがより小さくなるまたはフラグメントがより少なくなることがあります。このため、テストを設計する際には徹底して注意を払うようにしましょう。

まずコードへの入力、およびその入力を変更したときにコードがどのように反応するかを検討します。

  • これはメモリ内に連続して配置されている高コヒーレントなデータにどのように反応しますか?これはキャッシュコヒーレンシがないデータをどのように処理しますか?
  • コードが実行されるループからどれくらいのコードを削除しましたか?演算器の命令キャッシュの処理を改変しましたか?
  • どのハードウェアで実行していますか?そのハードウェアは分岐予測をどの程度実装してくれますか?どれほどマイクロコードをランダムに実行することができますか?SIMDに対応していますか?
  • 多重マルチスレッドシステムを多コアのシステムで実行する場合と、少ない数でのコアのシステムで実行する場合とで、システムの反応はどういった違いがでますか?
  • コードのスケーリングパラメーターは何ですか?その入力セットが大きくなると、直線的に拡大されますか、それとも非直線的に行われますか?

実際のところ、テストハーネスで何を測定しているかについては、余すことなく考えておかなければなりません。

考察:サンプルテスト

例として、2 つの文字列を比較するシンプルな操作テストで考えてみましょう(上の画像を参照)。 

C# の API が 2 つの文字列を比較する際にはロケール固有の変換を行って、異なる文字を文化的差異において対応するものへ照合できるようにしますが、これにはかなりの時間がかかってしまうことがわかると思います。

C# 内のほとんどの文字列の API はカルチャーを区別する一方で、String.Equals は区別しません。

Unity の Mono GitHub から String.CS を開き、String.Equals を見てみると、非常にシンプルな関数であることがわかります。C# リフレクションなしに直接呼び出すことができないプライベート関数である EqualsHelper と呼ばれる関数に制御を渡す前に、いくつかのチェックを行います。

EqualsHelper はシンプルなメソッドです。文字列を一度に 4 バイトずつ処理し、入力文字列の raw バイトを比較します。不一致が見つかった場合、メソッドは停止され、false が返されます。

ただし、文字列が等しいかどうかをチェックする方法は他にもあります。ここで一番無難に取れる方法としては String.Equals のオーバーロードで、比較対象の文字列と、StringComparison と呼ばれる列挙型の 2 つのパラメーターを受け取ります。

String.Equals のシングルパラメーターオーバーロードは、EqualsHelper に制御を渡す前にほんの少しだけ処理を行います。では、2 パラメーターオーバーロードは何を行うのでしょうか?

2 パラメーターオーバーロードのコードは、大規模な switch ステートメントに入る前に、少しだけ追加チェックを行います。このステートメントが入力している StringComparison 列挙型の値をテストします。シングルパラメーターオーバーロードでパリティを探しているため、オーディナルの比較、つまりはバイトごとの比較を実行する必要があります。このケースでは、StringComparison.Ordinal ケースに到達する前に制御が過去 4 つのチェックをフローします。ここで、コードはシングルパラメーター String.Equals オーバーロードと非常に似たものになります。これはつまり、String.Equals の 2 パラメーターオーバーロードをシングルパラメーターオーバーロードの代わりに使用した場合、演算器は少数の比較演算を追加で実行します。時間はかかることが予想されますが、テストする価値はあります。

文字列の一致をあらゆる方法で比較したいと考えている場合には、String.Equals をテストするだけでは終わらせられません。オーディナルの比較を実行できる String.Compare のオーバーロードのほか、2 つの異なるオーバーロードを持つ String.CompareOrdinal と呼ばれるメソッドもあるのです。

続きはパート 2 をご覧ください。

スクリプティングのパフォーマンス(パート 2)

リファレンス実装として、シンプルなハンドコード例も書いてみましょう。2 つの入力文字列内の各文字に対して繰り返し処理を行って、各文字列長が等しいことをチェックするだけのちょっとした関数です。

これらのすべてのコードを確認した後は、すぐに活用できる 4 つの異なるテストケースが用意されています。

  • 2 つの同一の文字列。最悪のケースのパフォーマンスをテストします。
  • 文字が無作為に選ばれた、長さが同一の 2 つの文字列。文字列長のチェックを回避します。
  • 文字が無作為に選ばれた、長さが同一かつ最初の文字が同一の 2 つの文字列。String.CompareOrdinal のみで見つかる関係する最適化を回避します。
  • 文字が無作為に選ばれた、長さが異なる 2 つの文字列。最高のケースのパフォーマンスをテストします。

いくつかのテストを実行した結果としては、String.Equals が圧倒的です。これはプラットフォームやスクリプティングランタイムのバージョン、Mono または IL2CPP を使用しているかどうかに関係ありません。

String.Equals は文字列の等価演算子 == によって使用されるメソッドであるため、コード全体にわたって a == b a.Equals(b) に変更しないよう注意してください。

実際、結果を分析すると、ハンドコードのリファレンス実装の結果がこれほどまで悪いことに不思議に感じます。IL2CPP をよく見てみると、コードがクロスコンパイルされるときに Unity によって配列の上下限のチェックと null チェックが多数注入されることがわかります。

これらは無効にできます。Unity のインストールフォルダーで、IL2CPP サブフォルダーを探します。IL2CPP サブフォルダー内に IL2CPPSetOptionAttributes.cs があります。これをプロジェクト内にドラッグすると、Il2CppSetOptionAttribute にアクセスできるようになります。

この属性を使用して型とメソッドを装飾できます。これを設定して自動 null チェック、配列の上下限の自動チェック、またはその両方を無効にできます。これにより、ときにはかなりのコードの高速化が見込まれます。この特定のテストケースでは、ハンドコードの文字列比較メソッドが約 20% 高速になります。 

Transform

Transform コンポーネントは、アニメーション、Physics、UI、レンダリングなど、多数存在する Unity の他のシステムによって使用されるシステムの 1 つです。Unity 2017.4 と 2018.1 より、Transform システムは TransformHierarchyTransformChangeDispatch という 2 つの重要な考え方に基づいて構築されています。

ある Unity のシーンのメモリレイアウトを分析してみると、各ルート Transform は 1 つの連続したデータバッファに対応しています。TransformHierarchy と呼ばれるこのバッファには、ルート Transform 以下のすべての Transform のデータが含まれます。Transform データを TransformHierarchy にまとめておくことで、Transform の計算を効率的に実行できるようになっています。たとえば、あるゲームオブジェクトのワールド座標を計算しているときに、変換/回転/スケーリングに関するローカルの Transform データをすべて 1 つの整理されたデータ構造にまとめておくことで、キャッシュミスを最低限に抑え、パフォーマンスも向上させられます。

さらに、TransformHierarchy にはそれに含まれる各 Transform についてのメタデータも格納されます。このメタデータには、Unity の他のシステムの中で、ある特定の Transform の変更について関心があるシステムを追跡するビットマスクが含まれます。また、ある Transform がある特定のシステムにとって「ダーティ(Dirty)」であるかどうか(つまり、対象の Transform がそのシステムによって最後に「クリーン」であるとマークされてから、その Transform の座標、回転、またはスケールに改めて変更があったのかどうか)を示すビットマスクも含まれます。

このデータを使用して、Unity はその他の内部システムのそれぞれのダーティな Transform の一覧を作成できるようになりました。ダーティな Transform に関するこれらのクエリをマルチスレッド方式で処理するこのシステムのことを、TransformChangeDispatch と呼びます。たとえば、Physics システムは TransformChangeDispatch に対してクエリを実行し、Physics システムが最後に FixedUpdate を実行してからデータが変更された Transform の一覧をフェッチします。

ただし、この変更された Transform の一覧を整理するために、TransformChangeDispatch をシーン内のすべての Transform にわたって反復処理しないでください。特に多くのケースで変更される Transform はほとんどないため、これが行われるとシーンに大量の Transform が含まれている場合に非常に低速になってしまいます。

これを修正するために、TransformChangeDispatchTransformHierarchy のダーティな構造の一覧を追跡します。Transform が変更されるときはいつでも、自身をダーティとマークし、その子をダーティとマークして、その後それが格納されている TransformHierarchyTransformChangeDispatch に登録します。Unity 内部の別のシステムが変更された Transform の一覧をリクエストすると、ダーティな TransformHierarchy の各構造内にある各 Transform にわたって、TransformChangeDispatch が反復処理されます。ダーティなビットが適切に設定されている Transform が一覧に追加され、リクエストを行っているシステムにこの一覧が返されます。

このアーキテクチャにより、ヒエラルキーを分割すればするほど、Unity が変更をより詳細なレベルで追跡できます。一切変更のない Transform の大規模なヒエラルキーを用意することを許容できる場合もありますが、ほぼ静的なヒエラルキー中に常に変更がある Transform が存在すると、ときにほとんど意味のないスキャンの実行を Unity へ強いることになります。

さらに、TransformChangeDispatch は Unity の内部マルチスレッディングシステムを使用して、TransformHierarchy 構造を点検する際に行わなければならない処理を分割します。作業項目の最小の単位は、ヒエラルキーごとになります。そのため、ヒエラルキーを適切な大きさのチャンクに分割することは、プログラムをよりマルチスレッド化することにもつながります。

複数の TransformHierarchy の構造にわたってダーティなフラグをスキャンすると、変更された Transform の一覧をシステムが TransformChangeDispatch にリクエストするたびに、ある程度オーバーヘッドが伴ってしまうことになります。Unity の内部システムのほとんどは更新をフレームごとに 1 回、それらが実行される直前にリクエストします。たとえば、アニメーションシステムはシーン内のアクティブなアニメーターのすべてを評価する直前に更新をリクエストします。同様に、レンダリングシステムは、視覚可能なオブジェクトの一覧のカリングを開始する前に、シーン内のすべてのアクティブなレンダラーの更新をリクエストします。

物理演算システムは、他のシステムとは動作が異なります。Unity 2017.2 より、Unity の物理演算システムは TransformChangeDispatch の上で動作します。レイキャストを実行するたびに、Unity では変更された Transform の一覧を TransformChangeDispatch に照会し、その上で物理演算が適用されたワールドへ適用する必要があります。Transform ヒエラルキーの大きさやコードでの物理演算 API の呼び出し方によりますが、この場合コストが高くなることがあります。しかし、TransformChangeDispatch の照会をスキップすると、レイキャストが古くなったデータで実行されてしまう恐れがあります。

Unity には Physics.autoSyncTransforms 設定を使用してエディターが選択すべき動作をユーザーが選択できる機能が備わっています。これは、Unity エディターの物理演算の設定で指定することも、ランタイムに Physics.autoSyncTransforms プロパティを設定することで指定することもできます。

Unity 2017.2 から Unity 2018.2 では、Physics.autoSyncTransforms はデフォルトで true に設定されます。この場合、Unity は自動的に物理演算が適用されたワールドを同期し、RaycastSpherecast などの物理演算クエリ API を呼び出すたびに Transform が更新されます。

Unity 2018.3 以降では、Physics.autoSyncTransforms はデフォルトで false に設定されます。この場合、物理演算システムは、物理演算シミュレーションが実行される FixedUpdate を実行する直前と、(RigidbodyInterpolation を実行しているリジッドボディがある場合は)補間された物理演算シミュレーションの結果が Unity のシーンに書き戻される Update の前の、2 回の特定の時点における TransformChangeDispatch システムの変更についてのみ照会します。

Physics.autoSyncTransformsfalse に設定すると、Physics クエリからの TransformChangeDispatch および Physics シーンの更新によるスパイクを排除します。ただし、コライダーに対する変更は次に FixedUpdate を実行するまで Physics シーンに同期されません。これは、autoSyncTransforms を無効にし、コライダーを移動してからそのコライダーの新しい位置にレイを照射する Raycast を呼び出すと、そのレイキャストがコライダーに当たらない場合があることを意味します。これは、コライダーの新しい位置に関する情報がまだ更新されていない、最後に更新されたバージョンの物理演算シーンでレイキャストが動作しているからです。

これは、プロジェクトにいくつかの問題をもたらす場合があります。Raycast などの物理演算システムのクエリを実行する前に、Physics.SyncTransforms を呼び出して、物理演算システムに物理演算が適用されたワールドを Unity のシーンと同期するよう強制できます。推奨されるアプローチは、Physics.SyncTransforms を 1 回呼び出してから物理演算クエリをすべて一括で実行する方法です。

上の例は、物理演算クエリを分散して実行した場合と一括で実行した場合の差を示しています。

Transform:物理演算クエリを分散して実行した場合と一括で実行した場合のパフォーマンスの比較

これらの 2 つの例のパフォーマンスの差異は顕著であり、シーンに含まれる Transform のヒエラルキーが小規模なもののみであるときはより顕著になります(上の例を参照)。

さらに、プロジェクトにレイキャストに関連する大量のタスクがある場合は、RaycastCommand API など、物理演算クエリを 1 つのジョブで通して実行されるバッチにまとめることを検討してください。これにより、コードをメインスレッド外で並列に実行できます。ただし、それでも Physics.autoSynctransformfalse に設定されている場合は、RaycastCommand ジョブをスケジュールする前に、Physics.SyncTransforms を呼び出してシーンが最新であることを確認する必要があります。

スクリプトから TransformChangeDispatch にアクセスする必要がある C# サブシステムがある場合、その唯一の方法は、Transform.hasChanged プロパティを使用することです。このプロパティは、TransformChangeDispatch システムの上に内部的に構築されています。この方法でメインスレッドから変更された Transform の一覧を収集したら、IJobParallelForTransform API を使用して、ジョブを通じてそれらの Transform を見直すことができます。

オーディオシステム

内部的に、Unity はオーディオクリップの再生に FMOD と呼ばれるシステムを使用します。FMOD は独自のスレッドで処理を行っていて、オーディオのデコードとミキシングを担っています。とはいえ、オーディオ再生は完全に独立して処理が行われる存在というわけではありません。シーン内において、アクティブな各オーディオソースのメインスレッドでいくつかの処理が実行されてもいます。また、コア数が少ないプラットフォーム(旧型の携帯電話など)上では、FMOD のオーディオスレッドと Unity のメインおよびレンダリングスレッドが演算器のコアを競い合う場合もあります。

各フレームで、Unity はアクティブなすべてのオーディオソースをループします。各オーディオソースで、Unity はそのオーディオソースとアクティブなオーディオリスナー間の距離、およびその他いくつかのパラメーターを計算します。このデータはボリュームの減衰、ドップラーシフト、その他個々のオーディオソースに影響を及ぼす可能性のある効果を計算するために使用されます。

よくある問題はオーディオソースの「Mute」チェックボックスに起因するものです(上の画像を参照)。「Mute」を true に設定するとミュートされたオーディオソースに関連する演算が排除されると考えるかもしれませんが、実際には排除されているわけではありません。

代わりに、「Mute」設定は距離チェックなど、その他すべての音量関連の計算が実行された後に、単純に音量パラメーターをゼロに固定します。また、Unity はミュートされたオーディオソースを FMOD に送信しますが、FMOD はそれを無視します。オーディオソースのパラメーターの計算やオーディオソースの FMOD への送信は、Unity プロファイラーに AudiosSystem.Update として表示されます。

その Profiler のマーカーに多くの時間が割り当てられていることに気づいた場合は、ミュートされたアクティブなオーディオソースが大量にあるかどうかを確認してみましょう。もしそうであった場合は、ミュートされたオーディオソースコンポーネントをミュートする代わりに無効化するか、対象のゲームオブジェクトを無効にすることを検討してみてください。AudioSource.Stop を呼び出して再生を停止することもできます。

もう 1 つの対応としては、Unity のオーディオ設定で音声カウントを固定する方法があります。これを行うには、AudioSettings.GetConfiguration を呼び出します。これにより、仮想音声カウントと実音声カウントの 2 つの値が含まれる構造が返されます。

仮想音声の数を減らすと、FMOD が実際に再生するオーディオソースを決定するときに確認するオーディオソースの数が減ります。実音声カウントを減らせば、FMOD が実際にゲームのオーディオを生成するためにミキシングするオーディオソースの数が減ることになります。

FMOD が使用する仮想音声と実音声の数を変更するには、AudioSettings.GetConfiguration によって返される AudioConfiguration 構造の該当する値を変更し、その後 AudioConfiguration 構造を AudioSettings.Resetのパラメーターとして渡すことで、新しい設定でオーディオシステムを再設定します。これによりオーディオの再生が中断されるため、ローディング画面中や起動時など、プレイヤーが変化に気付かないタイミングで行うことをお勧めします。

アニメーション

Unity でアニメーションの再生に使用できるシステムは、アニメーターシステムとアニメーションシステムの 2 つです。

「アニメーターシステム」とは Animator コンポーネントが関与するシステムのことで、ゲームオブジェクトにアタッチしてアニメーション化するものです。AnimatorController アセットは 1 つもしくは、複数でのアニメーターによって参照されるようになっています。従来、このシステムは Mecanim と呼ばれていました。

アニメーターコントローラーでは、状態を定義します。このような状態というものはアニメーションクリップかブレンドツリーのどちらかで扱うことができます。状態はレイヤーで整理可能です。各フレームで、各レイヤーのアクティブな状態が評価され、各レイヤーからの結果はブレンドされてアニメーション化されたモデルへ適用されます。2 つの状態間で遷移するときは、両方の状態が評価されます。

他方のシステムは「アニメーションシステム」と呼ばれ、Animation コンポーネントで表されます。各フレームとアクティブな各 Animation コンポーネントが、アタッチされたアニメーションクリップのすべてのカーブで直線的に反復され、それらのカーブを評価し、その結果を適用します。

これらの 2 つのシステムは機能だけではなく、基礎となる実装の詳細も異なります。

アニメーターシステムは大量のスレッドでマルチスレッド化されています。さらに、アニメーターシステムはアニメーションクリップを「ストリームされたアニメーションクリップ」にベイクします。そこでは複数のカーブが同じストリームに格納され、すべてのカーブのキーが時間でソートされており、キャッシュミスを減らすように設計されています。一般に、アニメーションクリップ内のカーブの数が増えるのに合わせて問題なく拡大されます。このため、カーブの数が多い複雑なアニメーションを評価するときに優れたパフォーマンスを発揮します。ただし、オーバーヘッドコストはかなり高くなってしまいます。

アニメーションシステムは機能が比較的少ないため、ほとんどオーバーヘッドがありません。そのパフォーマンスは再生されるアニメーションクリップ内のカーブの数に合わせて問題なく縮小されます。

同一のアニメーションクリップを再生するときに 2 つのシステムを比較すると、その差異はより顕著になります(上の画像を参照)。

アニメーションクリップの再生を行う場合には、コンテンツの緻密さだけでなく、ゲームが実行される想定のハードウェアに合うシステムを選択するようにしてください。使用するアニメーションを、使える限りの最もローエンドなハードウェアでテストしてみるようにしましょう。

Generic リグと Humanoid リグの比較

デフォルトでは、Unity はアニメーション化されたモデルを Generic リグでインポートしますが、開発者がキャラクターをアニメーション化するときは Humanoid リグに切り替えることがよくあります。ただし、Humanoid リグの使用にはコストがかかります。

Humanoid リグは、アニメーターシステムにインバースキネマティクス(IK)とアニメーションリターゲティング(異なるアバター間でアニメーションを再利用できる)という、 2 つの機能をもたらしてくれます。

ただし、IK やアニメーションリターゲティングを使用していない場合でも、Humanoid リグが設定されたキャラクターのアニメーターは各フレームで IK とリターゲティングのデータを計算します。これは、こういった計算を行わない Generic リグと比較すると約 30% から 50% 多くの CPU 時間を消費します。

Humanoid リグ固有の機能を利用する必要がないのであれば、Generic リグを使用するようにしましょう。

アニメーターのリバインド

ゲームプレイ中のパフォーマンスのスパイクを防ぐため、オブジェクトプールの実行をしておくことは対策として欠かせません。とはいえ、これまでアニメーターはオブジェクトプールの実行が困難でした。アニメーターのゲームオブジェクトが有効になっている場合は、常にアニメーターがアニメーション化している、そのプロパティのメモリアドレスへのポインターの一覧を構築していなければなりません。これはつまり、あるコンポーネントの特定のフィールドに対してヒエラルキーを照会することを意味し、これによりコストが高くなる可能性があるのです。このプロセスはアニメーターのリバインドと呼ばれ、Unity プロファイラーに Animator.Rebind として表示されます。

アニメーターのリバインドは、あらゆるシーンで不可避であり一度は必ず行わなければならないものです。これには、そのアニメーターがアタッチされているすべての子を再帰的に走査すること、その対象の名前のハッシュを取得すること、さらにそのハッシュそれぞれを各アニメーションカーブのターゲットパスのハッシュと比較することが伴います。そのため、ヒエラルキーがアニメーション化していない子の存在により、バインディングプロセスに追加コストがかかります。アニメーション化をしていない莫大な数の子が存在しているゲームオブジェクト上のアニメーターを避けておけば、それがリバインドのパフォーマンス改善を後押ししてくれます。

MonoBehaviour のリバインドは、Transform などの組み込みのクラスのリバインドよりもコストがかかります。Animator コンポーネントは MonoBehaviour 上のフィールドをスキャンして、それらのフィールド名のハッシュによってインデックスが付けられたソート済みの一覧を作成します。その後、その MonoBehaviour のフィールドをアニメーション化している各アニメーションカーブについて、そのソート済みの一覧でバイナリ検索が実行されます。そのため、普段からアニメーション化している MonoBehaviour 内のフィールドを簡潔に保ち、大規模にネストされたシリアライズ可能な構造を回避しておけば、リバインドにかかる時間がさらに減ることになります。

不可避である最初のリバインド後に、ゲームオブジェクトをプールするようにもしておきましょう。ゲームオブジェクト全体を有効化/無効化する代わりに、これで Animator コンポーネントを有効化/無効化してリバインドを回避することができます。

この記事はいかがでしたか?