Unity Pro Tips

オフィシャル記事詳細

Unity2018の性能を最大限に引き出すためのベストプラクティス

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

Unityの Engineering Managerの Ian Dundoreが、Unity2018のパフォーマンスを最大限に引き出すために知っておきたい4つの重要な変更点として、最適化方法の選択が難しいコードのテストのコツを実践的に例をふんだんに用いた「スクリプティングパフォーマンス」についての解説、「Transform」の大きな変更点の具体的な解説と最適化の手法、「オーディオシステム」で躓きやすいポイントとその対策、アニメーターシステムとアニメーションシステムの2種類ある「アニメーション」のそれぞれの比較と使い方によって異なる具体的なチューニングのアドバイスをします。 Unity5.xから Unity2018.xまでの変更点の概略とその活用方法について書かれたものですが、バージョンアップをする際のみならず新たに Unity2018.xあるいは Unity2019.xを使い始める際にぜひ参考にしていただきたい内容になっています。 本文中のプラクティスはすべて Ian Dundoreによる講演に基づいておりますので、気になる部分は講演の内容をすぐにチェックすることができます。

スクリプティングパフォーマンス

最適化における最も難しいタスクの 1 つは、ホットスポットが検出された後のコードの最適化方法を選択することです。オペレーティングシステム、CPU、GPU のプラットフォーム固有の詳細、スレッディング、メモリアクセスなど、さまざまな要素が関与します。どの最適化方法が実際に最大のメリットをもたらすかを事前に把握するのは困難です。

一般的に、小規模なテストプロジェクトで最適化をプロトタイピングすることをお勧めします。イテレーションの時間を大幅に短縮できます。ただし、テストプロジェクトにコードを分離するのも難題です。コードの一部を分離すると、それが実行される環境も変わるためです。スレッドのタイミングも変わる可能性があり、マネージドヒープがより小さくなったり、フラグメントが少なくなったりすることもあります。このため、テストをデザインする際には注意を払うことが重要です。

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

  • メモリ内に連続して配置されている高コヒーレントなデータにどのように反応するか?
  • キャッシュコヒーレンシがないデータをどのように処理するか?
  • コードが実行されるループからどれくらいのコードを削除したか?プロセッサーの命令キャッシュの使用法を変更したか?
  • どのハードウェアで実行されるか?そのハードウェアは分岐予測をどの程度実装しているか?マイクロ命令をどの程度順不同で実行しますか?SIMD に対応しているか?
  • スレッドを多数使うマルチスレッドシステムを実行するとき、コア数の大小によりシステムの反応はどのように異なるか?
  • コードをスケールさせるパラメーターは何か?その入力セットが大きくなったとき、コードは線形にスケールするか、それとも非線形にスケールするか?

実際、テストハーネスで何を測定しているかについて正確に考える必要があります。

例として、2 つの文字列を比較する簡単な操作テストについて考えてみましょう。

C# の API が 2 つの文字列を比較する際にはロケール固有の変換を行い、異なるカルチャからの異なる文字列を照合できるようにしますが、かなりの時間がかかることに気付きます。

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

その GitHub から String.CS を開き、String.Equals を見てみると、リフレクションなしに直接呼び出すことができない EqualsHelper と呼ばれる関数に制御を渡す前にいくつかのチェックを行う、非常に単純な関数があることがわかります。

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

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

String.Equals の 1 パラメーターのオーバーロードが EqualsHelper に制御を渡す前にほんの少しの作業しか行わないことがすでにわかっています。では、2 パラメーターのオーバーロードは何を行うのでしょうか?

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

文字列一致を検査するための他の方法にもご興味がある方は、String.Equals を使った検査だけでは物足りないでしょう。実際、順序も考慮した比較を実行できる String.Compare のオーバーロードのほか、2 つの異なるオーバーロードを持つ String.CompareOrdinal と呼ばれるメソッドも用意されています。

参照の実装として、単純なハンドコード例が用意されています。これは文字列長をチェックする小さな関数で、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(トランスフォーム)

これは単に Unity エディターでヒエラルキーを見るだけではわかりませんが、Transform コンポーネントは Unity 5 と Unity 2018 でかなり変わっています。また、これらの変更によりパフォーマンスを改善する新しい可能性がいくつか見込まれます。

Unity 4 や Unity 5.0 では、Transform を作成すると、オブジェクトが Unity のネイティブメモリヒープのどこかに割り当てられていました。そのオブジェクトはネイティブメモリヒープ内であればどこでも存在する可能性がありました。連続して割り当てられた 2 つの Transform が互いに近くに割り当てられる保証はありませんでした。また、子の Transform がその親の近くに割り当てられる保証もありませんでした。

これはつまり、Transform のヒエラルキーを直線的に反復処理するときに、メモリの隣接するリージョンを直線的に反復処理していなかったことを意味します。Transform のデータが L2 キャッシュまたはメインメモリからフェッチされるのを待機するときにプロセッサーが繰り返し停止するのはこれが原因でした。

Unity のバックエンドで、Transform のポジション、回転、スケールが変わるたびに、その Transform は OnTransformChanged を送信していました。このメッセージは、独自にデータを更新できるよう、および Transform の変更と関係があるその他すべてのコンポーネントに通知できるよう、すべての子 Transform が受け取る必要がありました。たとえば、コライダーがアタッチされた子 Transform は、子 Transform または親 Transform が変更されるときは常に Physics system を更新する必要があります。

このメッセージの発行は回避することができず、特に中身のないメッセージの発行を防ぐ方法が組み込まれていなかったため、パフォーマンスに多くの問題を引き起こしました。Transform を変更する場合、それに伴い子も変更されることはわかっていましたが、Unity が各変更の後に OnTransformChanged を送信するのを回避する方法は用意されていませんでした。これは多くの CPU 時間の無駄になりました。

このような内容から、古いバージョンの Unity で最も一般的なアドバイスの 1 つは、Transform の変更をバッチで処理することです。つまり、フレームの開始時に Transform のポジションと回転を一度にキャプチャし、そのフレームにわたってそれらのキャッシュされた値を使用して更新することです。ポジションや回転に対する変更は、フレームの最後に 1 回のみ適用します。これは Unity 2017.2 に至るまで有効なアドバイスです。

幸いなことに、Unity 2017.4 と 2018.1 では OnTransformChanged が無効になりました。新しいシステムでは TransformChangeDispatch に置き換えられています。

TransformChangeDispatch は Unity 5.4 で初めて導入されました。このバージョンでは、Transform は Unity のネイティブメモリヒープ内のあらゆる場所に配置される可能性があった孤立したオブジェクトではなくなりました。代わりに、シーン内の各ルート Transform は隣接するデータバッファーによって表現されます。TransformHierarchy 構造と呼ばれるこのバッファーにはルート Transform 以下のすべての Transform のすべてのデータが含まれます。

さらに、TransformHierarchy にはその内部の各 Transform に関するメタデータも格納されます。このメタデータにはビットマスクが含まれます。これは、指定の Transform が「ダーティ」であるかどうか、つまり、Transform が最後に「クリーン」とマークされてからポジション、回転、スケールが変更されているかどうかを示します。また、Unity のその他のどのシステムが特定の Transform の変更に関係するかどうかを追跡するビットマスクも含まれます。

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

ただし、この変更された Transform の一覧を構築するために、TransformChangeDispatch システムにシーン内のすべての Transform を反復処理させてはいけません。シーンに大量の Transform が含まれている場合、特にほとんどのケースで変更される Transform はほとんどないため、これは非常に低速になる可能性があります。

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

このアーキテクチャにより、ヒエラルキーを分割すればするほど、Unity が変更をより詳細なレベルで追跡できます。シーンのルートに存在する Transform の数が多いほど、変更を探すときに調査する必要がある Transform が少なくなります。

しかし、もう 1 つの意味合いもあります。TransformChangeDispatch は Unity の内部マルチスレッディングシステムを使用して、TransformHierarchy 構造を調査するときに行う必要がある作業を分割します。この分割、および結果のマージにより、システムが TransformChangeDispatch から変更の一覧をリクエストする必要があるときに毎回少しのオーバーヘッドを追加します。

Unity の内部システムのほとんどは更新をフレームごとに 1 回、それらが実行される直前にリクエストします。たとえば、アニメーションシステムはシーン内のアクティブなアニメーターのすべてを評価する直前に更新をリクエストします。同様に、レンダリングシステムは、視覚可能なオブジェクトの一覧のカリングを開始する前に、シーン内のすべてのアクティブなレンダラーの更新をリクエストします。

Physics の場合は少し異なります。

Unity 2017.1(およびそれ以前)では、Physics の更新は同期的に行われていました。コライダーがアタッチされた Transform を移動または回転すると、Physics のシーンが即座に更新されました。これにより、Raycast やその他の Physics のクエリが正確になるように、コライダーのポジションや回転の変更が Physics の世界に確実に反映されました。

Unity 2017.2 で TransformChangeDispatch を使用するように Physics を移行しました。これは必要な変更であった一方、問題も発生していた可能性がありました。Raycast を実行するときはいつでも、変更された Transform の一覧を取得するのに TransformChangeDispatch に対してクエリを実行し、それらを Physics の世界に適用する必要がありました。これは、Transform のヒエラルキーの規模およびコードが Physics API を呼び出す方法によっては、コストがかかる可能性がありました。

この動作は新しい設定 Physics.autoSyncTransforms によって制御されています。Unity 2017.2 と Unity 2018.2 より、この設定はデフォルトで「true」になり、Unity は自動的に Physics の世界を同期し、RaycastSpherecast などの Physics のクエリ API を呼び出すたびに Transform が更新されます。

この設定は Unity エディターの Physics の設定か、実行時に Physics.autoSyncTransforms プロパティを設定することで変更できます。これを「false」に設定して Physics の自動同期を無効にすると、Physics システムは、特定の時点(FixedUpdate を実行する直前)で変更を取得するクエリを TransformChangeDispatch システムに対してのみ実行します。

Physics のクエリ API の呼び出し時にパフォーマンスの問題がみられる場合は、さらに 2 つの方法で対応できます。

1 つ目は、Physics.autoSyncTransforms を「false」に設定する方法です。これにより、Physics のクエリからの TransformChangeDispatch および Physics シーンの更新によるスパイクを排除します。

ただし、これを行うと、コライダーに対する変更は次の FixedUpdate まで Physics のシーンに同期されません。これはつまり、autoSyncTransforms を無効にし、コライダーを移動してその後そのコライダーの新しいポジションに向かって Ray が指定された状態で Raycast を呼び出すと、Raycast がコライダーをヒットしない可能性があります。これは、その Raycast が最後に更新されたバージョンの Physics シーンで運用されており、その Physics シーンがまだそのコライダーの新しいポジションに更新されていないことが原因です。

これにより奇妙なバグが発生することがあるため、Transform の自動同期を無効にすることが問題の原因とならないようゲームを慎重にテストしてください。Physics に Physics シーンにおける Transform の変更を強制する必要がある場合は、Physics.SyncTransforms を呼び出すことができます。この API は低速であるため、フレームごとに複数回呼び出すことはお勧めしません。

Unity 2018.3 以降では、Physics.autoSyncTransforms はデフォルトで「false」に設定されます。

TransformChangeDispatch に対してクエリを実行する時間を最適化する 2 つ目の方法は、Physics シーンに対してクエリを実行して更新する順序を新しいシステムに合わせて調整する方法です。

Physics.autoSyncTransforms を「true」に設定すると、Physics のすべてのクエリは TransformChangeDispatch の変更をチェックします。ただし、TransformChangeDispatch がチェックするダーティな TransformHierarchy 構造体がなく、Physics system に Physics シーンに適用する更新された Transform がない場合、Physics のクエリに追加されるオーバーヘッドはほとんどありません。

このため、Physics のすべてのクエリを 1 つのバッチで実行し、その後 Transform のすべての変更を 1 つのバッチで適用できます。実際、Transform の変更と Physics クエリ API の呼び出しを混在させないでください、

この例はその違いを示します。

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

オーディオシステム

内部的に、Unity はオーディオクリップの再生に FMOD と呼ばれるシステムを使用します。FMOD は独自のスレッドで実行され、それらはオーディオのデコードとミキシングを担います。ただし、オーディオの再生は完全に無料ではありません。シーン内でアクティブな各オーディオソースのメインスレッドでいくつかの処理が実行されます。また、コア数が少ないプラットフォーム(旧型の携帯電話など)上では、FMOD のオーディオスレッドと Unity のメインおよびレンダリングスレッドがプロセッサーのコアを奪い合うことがあります。

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

一般的な問題はオーディオソースの「Mute」チェックボックスによるものです。Mute を true に設定するとミュートされたオーディオソースに関連する演算が排除されると考えるかもしれませんが、実際には排除されません。

代わりに、「Mute」設定は距離チェックなど、その他すべての音量関連の計算が実行された後に、単純に音量パラメーターをゼロに固定します。また、Unity はミュートされたオーディオソースを FMOD に送信しますが、FMOD はそれを無視します。オーディオソースのパラメーターの計算やオーディオソースの FMOD への送信は、Unity Profiler に AudioSystem.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 つのシステムは機能だけではなく、基礎となる実装の詳細も異なります。

アニメーターシステムは大量のスレッドでマルチスレッド化されています。そのパフォーマンスは CPU やコア数によって大幅に変わります。一般的に、アニメーションクリップ内のカーブの数が増えるほど、より直線的にスケールされなくなります。このため、カーブの数が多い複雑なアニメーションを評価するときに優れたパフォーマンスを発揮します。ただし、オーバーヘッドコストは比較的高くなります。

アニメーションシステムはシンプルであればオーバーヘッドはほとんどありません。そのパフォーマンスは再生されるアニメーションクリップ内のカーブの数に合わせて直線的にスケールされます。

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

アニメーションクリップを再生するときは、コンテンツの複雑さやゲームが実行されるハードウェアに合ったシステムを選択するようにしてください。

もう 1 つの一般的な問題は、アニメーターコントローラーでレイヤーを使い過ぎることです。アニメーターは実行中にフレームごとにアニメーターコントローラー内のすべてのレイヤーを評価します。これにはレイヤーウェイトがゼロに設定されている(最終的なアニメーションの結果に目に見える貢献をしない)レイヤーも含まれます。

追加の各レイヤーは、各フレームの各アニメーターコントローラーに追加の演算を追加します。このため、一般的にレイヤーの使用は控えめにしてください。アニメーターコントローラー内にデバッグ、デモ、またはシネマティックレイヤーがある場合は、それらをリファクタリングしてみてください。既存のレイヤーにマージするか、ゲームをリリースする前に排除してください。

Generic リグと Humanoid リグの比較

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

Humanoid リグは、アニメーターシステムにインバースキネマティクスとアニメーションリターゲットの 2 つの機能を追加します。アニメーションリターゲットは優れた機能で、異なるアバター間でアニメーションを再利用できます。

ただし、IK やアニメーションリターゲットを使用していない場合でも、Humanoid リグが設定されたキャラクターのアニメーターは引き続き各フレームで IK およびリターゲットデータを計算します。これは、これらの計算を行わない Generic リグよりも 30%から 50%多くの CPU 時間を消費します。

Humanoid リグの機能を使用していない場合は、Generic リグを使用することをお勧めします。

アニメータープールの実行

オブジェクトプールの実行は、ゲームプレイ中のパフォーマンスのスパイクを防ぐ主な方法です。ただし、アニメーターは歴史的にオブジェクトプールの実行が困難でした。アニメーターのゲームオブジェクトが有効になるときはいつでも、アニメーターのアニメーターコントローラーを評価するときに使用する中間データのバッファーを再構築する必要があります。これはアニメーターのリバインドと呼ばれ、Unity Profiler に Animator.Rebind と表示されます。

Unity 2018 より前では、ゲームオブジェクトではなく Animator コンポーネントを無効にするのが唯一の回避策でした。これには副作用がありました。キャラクターに MonoBehavior、Mesh Collider、Mesh Renderer が設定されていた場合、それらも同様に無効にする必要があります。こうすることで、キャラクターによって使用されていたすべての CPU 時間を節約できます。ただし、コードが複雑になり、中断が発生しやすくなります。

Unity 2018.1 では、Animator.KeepControllerStateOnEnable API が導入されました。このプロパティはデフォルトで false に設定され、アニメーターは今までと同じように動作します。つまり、無効にすると中間データのバッファーの割り当てが解除され、有効にすると再度割り当てられます。

ただし、このプロパティを true に設定すると、アニメーターは無効になっている間にそれらのバッファーを保持します。つまり、そのアニメーターを再度有効にしても Animator.Rebind がないことを意味します。最終的にはアニメーターをプールできます。

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