本章では、Physics(物理演算)の最適化について紹介します。
ここでPhysicsとはPhysXによる物理演算を指します。ECSのUnity Physicsは扱っていません。
また本章では、3D Physicsをメインで取り上げていますが、2D Physicsでも参考になる箇所は多いでしょう。
Unity標準では、たとえシーン上に1つも物理演算に関するコンポーネントが配置されていなかったとしても、物理エンジンによる物理演算の処理は毎フレーム必ず実行されます。そのため、ゲーム内で物理演算を必要としない場合は、物理エンジンをオフにしておくとよいでしょう。
物理エンジンの処理は、Physics.autoSimulationに値を設定することでオン・オフを切り替えることができます。たとえば、インゲーム中のみ物理演算を用いて、それ以外では利用しない場合は、インゲーム中のみこの値をtrueに設定しておくとよいでしょう。
MonoBehaviourのFixedUpdateはUpdateとは違い、固定時間で実行されます。
物理エンジンは前フレームの経過時間に対して、1フレーム内でFixed Updateを複数回呼び出すことで、ゲームの世界の経過時間と物理エンジンの世界の時間をあわせます。そのためFixed Timestepの値が小さいと、より多くの回数Fixed Updateが呼び出され、負荷の原因となります。
この時間は、図6.1の示すように、Project SettingsのFixed Timestepで設定できます。この値の単位は秒となります。デフォルトでは0.02、つまり20ミリ秒が指定されています。
図6.1: Project SettingsのFixed Timestep項目
また、スクリプト中からはTime.fixedDeltaTimeを操作することで変更できます。
Fixed Timestepは一般に、小さいほど物理演算の精度が上がり、コリジョン抜けなどの問題が発生しにくくなります。そのため、精度と負荷のトレードオフにはなりますが、ゲームの挙動に不備が発生しない範囲でこの値をターゲットFPSに近い値にすることが望ましいです。
前節のとおり、Fixed Updateは前フレームからの経過時間をもとに複数回呼び出されます。
「あるフレームでレンダリング処理が重たい」などの理由で前フレームの経過時間が大きくなった場合、そのフレームでは通常より多くの回数Fixed Updateが呼び出されることになります。
たとえば、Fixed Timestepが20ミリ秒で前フレームに200ミリ秒かかったとき、Fixed Updateは10回呼び出されることになります。
つまり、あるフレームで処理落ちした場合は次のフレームでの物理演算のコストが高くなります。それが原因でそのフレームも処理落ちするリスクが高くなることで、次フレームでの物理演算も重くなる、といった負のスパイラルに陥る現象が物理エンジンの世界では知られています。
この問題を解決するためにUnityでは図6.2に示すように、Project SettingsからMaximum Allowed Timestepという、1フレーム内で物理演算が利用する時間の最大値が設定できます。この値はデフォルトで0.33秒が設定されていますが、ターゲットFPSに近い値にしてFixed Updateの呼び出し回数を制限し、フレームレートを安定させたほうがよいでしょう。
図6.2: Project SettingsのMaximum Allowed Timestep項目
衝突判定の処理コストは、コリジョンの形状やその状況に左右されます。そのコストは一概には言えないですが、目安として、判定コストを低い順に並べるとスフィアコライダー、カプセルコライダー、ボックスコライダー、メッシュコライダーと覚えておくとよいでしょう。
たとえば、人型のキャラクター形状の近似にカプセルコライダーをよく利用しますが、ゲームとして身長が仕様に影響しない場合、スフィアコライダーに置き換えたほうが当たり判定のコストは小さくなります。
また、これらの形状の中でもとくに、メッシュコライダーは負荷が高くなる点に注意しましょう。
まずはスフィアコライダーかカプセルコライダー、ボックスコライダーとその組み合わせでコリジョンを用意できないかを検討します。それでも不都合がある場合にメッシュコライダーの利用しましょう。
Physicsには、どのゲームオブジェクトのレイヤー同士が衝突可能かを定義する「衝突マトリックス」という設定があります。この設定は図6.3に示すように、Project SettingsのPhysics > Layer Collision Matrixから変更できます。
図6.3: Project SettingsのLayer Collision Matrix項目
衝突マトリックスは、2つのレイヤーが交わる位置のチェックボックスにチェックが入っていれば、それらのレイヤーは衝突することを示します。
衝突しないレイヤー同士はブロードフェーズと呼ばれるオブジェクトの大雑把な当たり判定を取る前計算からも除外されるため、この設定を適切に行うのが、衝突する必要がないオブジェクト同士の計算を省くのに最も効率的です。
パフォーマンスを考慮すると、物理演算には専用のレイヤーを用意し、衝突する必要のないレイヤー間のチェックボックスはすべてオフにすることが望ましいです。
レイキャストは、飛ばしたレイと衝突したコライダーの衝突情報を取得できる便利な機能ですが、その反面、負荷の原因にもなるので注意が必要です。
レイキャストは、線分との衝突判定を取るPhysics.Raycast以外にも、Physics.SphereCastなどの、その他の形状で判定を取るメソッドが用意されています。
ただし判定を取る形状が複雑になるほど、その負荷が高くなります。パフォーマンスを考慮すると、可能な限りPhysics.Raycastの利用のみに留めるのが望ましいです。
Physics.Raycastは、レイキャストの始点と向きの2つのパラメーター以外に、パフォーマンスの最適化に関わるパラメーターとして、maxDistanceとlayerMaskがあります。
maxDistanceはレイキャストの判定を行う最大の長さ、つまりレイの長さを指定します。
このパラメーターを省略すると既定値としてMathf.Infinityが渡され、非常に長いレイで判定を取ろうとします。このようなレイは、ブロードフェーズに対して悪影響を与えたり、そもそも当たりを取る必要のないオブジェクトと当たり判定を取る可能性があるので、必要以上の距離を指定しないようにします。
またlayerMaskも、当たりを取る必要のないレイヤーのビットは立てないようにします。
衝突マトリックスと同様に、ビットが立っていないレイヤーとはブロードフェーズからも除外されるため、計算コストを抑えることができます。このパラメーターを省略すると、既定値としてPhysics.DefaultRaycastLayersという、Ignore Raycast以外のすべてのレイヤーと衝突する値が指定されるため、こちらも必ず指定します。
Physics.Raycastでは、衝突したコライダーのうち1つの衝突情報が返却されますが、Physics.RaycastAllメソッドを利用すると、複数の衝突情報を取得できます。
Physics.RaycastAllは衝突情報を、RaycastHit構造体の配列を動的に確保して返却します。そのため、このメソッドを呼び出すたびにGC Allocが発生し、GCによるスパイクの原因になります。
この問題を回避するために、確保済みの配列を引数に渡すと、結果をその配列に書き込んで返却するPhysics.RaycastNonAllocというメソッドが存在します。
パフォーマンスを考慮するとFixedUpdate内では、可能な限りGC Allocを発生させないようにすべきです。
リスト6.1に示すように、結果を書き込む配列をクラスのフィールドやプーリングなどの機構で保持し、その配列をPhysics.RaycastNonAllocに渡すことで、配列の初期化時以外のGC.Allocを回避できます。
リスト6.1: Physics.RaycastAllNonAllocの利用方法
1: // レイを飛ばす始点
2: var origin = transform.origin;
3: // レイを飛ばす方向
4: var direction = Vector3.forward;
5: // レイの長さ
6: var maxDistance = 3.0f;
7: // レイが衝突する対象のレイヤー
8: var layerMask = 1 << LayerMask.NameToLayer("Player");
9:
10: // レイキャストの衝突結果を格納する配列
11: // この配列を初期化時に事前に確保したり、
12: // プールに確保されているものを利用する
13: // 事前にレイキャストの結果の最大数を決める必要がある
14: // private const int kMaxResultCount = 100;
15: // private readonly RaycastHit[] _results = new RaycastHit[kMaxResultCount];
16:
17: // すべての衝突情報が配列で返ってくる
18: // 戻り値は衝突個数
19: var hitCount = Physics.RaycastNonAlloc(
20: origin,
21: direction,
22: _results,
23: layerMask,
24: query
25: );
26: if (hitCount > 0)
27: {
28: Debug.Log($"{hitCount}人のプレイヤーとの衝突しました");
29:
30: // _results配列には順に衝突情報が格納される
31: var firstHit = _results[0];
32:
33: // 個数を超えたインデックスは無効な情報なので注意
34: }
UnityのPhysicsには、スフィアコライダーやメッシュコライダーなどの衝突について扱うColliderコンポーネントと、物理シミュレーションを剛体ベースで行うためのRigidbodyコンポーネントがあります。これらのコンポーネントの組み合わせとその設定によって、3つのコライダーに分類されます。
Colliderコンポーネントがアタッチされ、Rigidbodyコンポーネントがアタッチされていないオブジェクトは、静的コライダー(Static Collider)と呼ばれます。
このコライダーは常に同じ場所に留まる、動くことのないジオメトリにのみ使用することを前提に最適化が行われます。
そのため、ゲーム中に静的コライダーの有効・無効を切り替えたり、移動やスケーリングを行なうべきではありません。これらの処理を行うと、内部のデータ構造の変更に伴う再計算が行われ、パフォーマンスを著しく低下させる原因となります。
ColliderコンポーネントとRigidbodyコンポーネントの両方がアタッチされているオブジェクトは、動的コライダー(Dynamic Collider)と呼ばれます。
このコライダーは、物理エンジンによって他のオブジェクトと衝突できます。また、スクリプトからRigidbodyコンポーネントを操作することによって適用される衝突や力に反応できます。
そのため、物理演算が必要なゲームでは、もっともよく利用されるコライダーになります。
ColliderコンポーネントとRigidbodyコンポーネントの両方がアタッチして、かつRigidbodyのisKinematicプロパティを有効にしたコンポーネントは、キネマティックな動的コライダー(Kinematic Dynamic Collider)として分類されます。
キネマティックな動的コライダーは、Transformコンポーネントを直接操作することで動かせますが、通常の動的コライダーのようにRigidbodyコンポーネントを操作することで衝突や力を加えて動かせません。
物理演算の実行を切り替えたい場合や、ドアなどのたまに動かしたいが大半は動かない障害物などにこのコライダーを利用することで、物理演算を最適化できます。
物理エンジンでは最適化の一環として、Rigidbodyコンポーネントをアタッチしたオブジェクトが一定時間動かない場合、そのオブジェクトは休止中と判断して、そのオブジェクトの内部状態をスリープ状態に変更します。スリープ状態に移行すると、外力や衝突などのイベントによって動かない限りは、そのオブジェクトにかかる計算コストが最小限に抑えられます。
そのため、Rigidbodyコンポーネントがアタッチされたオブジェクトのうち動く必要のないものは、可能な限りスリープ状態に遷移させておくことで物理演算の計算コストを抑えることができます。
Rigidbodyコンポーネントがスリープ状態へ移行すべきかを判定する際に利用されるしきい値は図6.4に示すように、Project SettingsのPhysics内部のSleep Thresholdで設定できます。また、個別のオブジェクトに対してしきい値を指定したい場合は、Rigidbody.sleepThresholdプロパティから設定できます。
図6.4: Project SettingsのSleep Threshold項目
Sleep Thresholdはスリープ状態に移行する際の質量で正規化された運動エネルギーを表します。
この値を大きくすると、オブジェクトはより早くスリープ状態へ移行するため、計算コストを低く抑えられます。しかし、ゆっくり動いている場合にもスリープ状態へ移行する傾向にあるため、オブジェクトが急停止したように見える場合があります。この値を小さくすると、上記の現象は発生しにくくなりますが、一方でスリープ状態へは移行しづらくなるため、計算コストを抑えられにくい傾向になります。
Rigidbodyがスリープ状態かどうかは、Rigidbody.IsSleepingプロパティで確認できます。シーン上でアクティブなRigidbodyコンポーネントの総数は図6.5に示すように、プロファイラーのPhysics項目から確認できます。
図6.5: ProfilerのPhysics項目。アクティブなRigidbodyの個数だけでなく、物理エンジン上のそれぞれの要素数が確認できる。
また、Physics Debuggerを用いると、シーン上のどのオブジェクトがアクティブ状態なのかを確認できます。
図6.6: Physics Debugger。シーン上のオブジェクトが物理エンジン上どのような状態か、色分けされて表示される。
RigidbodyコンポーネントはCollision Detection項目にて、衝突検出で利用するアルゴリズムが選択できます。
Unity 2020.3時点で、衝突判定には下記の4つがあります。
これらのアルゴリズムは大きく分けて離散的衝突判定と連続的衝突判定の2つに分類されます。Discreteは離散的衝突判定で、それ以外は連続的衝突判定に属します。
離散的衝突判定は名前のとおり、1シミュレーションごとにオブジェクトが離散的にテレポート移動し、すべてのオブジェクトが移動後に衝突判定を行います。そのため、とくにオブジェクトが高速に移動している場合に、衝突を見逃してすり抜けを起こす可能性があります。
一方で連続的衝突判定は、移動前後のオブジェクトの衝突を考慮するために、高速に動くオブジェクトのすり抜けを防ぎます。その分、計算コストは離散的衝突判定と比べて高くなります。パフォーマンスを最適化するには、可能な限りDiscreteを選択できるようにゲームの挙動を作ります。
もし不都合がある場合は、連続的衝突判定を検討します。Continuousは動的コライダーと静的コライダーの組み合わせにのみ連続的衝突判定が有効になり、Conitnuous Dynamicは動的コライダー同士でも連続的衝突判定が有効になります。計算コストはContinuous Dynamicのほうが高くなります。
そのためキャラクターがフィールドを走り回る、つまり動的コライダーと静的コライダーの衝突判定のみを考慮する場合はContinuousを選択し、動くコライダー同士のすり抜けも考慮したい場合はContinuous Dynamicを選択します。
Continuous Speculativeは動的コライダー同士で連続的衝突判定が有効にもかかわらずContinuous Dynamicより計算コストが低いですが、複数のコライダーが密接している箇所で誤衝突してしまうゴースト衝突(Ghost Collision)と呼ばれる現象が発生するため、導入には注意が必要です。
これまでに紹介した設定以外で、とくにパフォーマンスの最適化に影響するプロジェクト設定の項目を紹介します。
Unity 2018.3より前のバージョンでは、Physics.Raycastなどの物理演算に関するAPIを呼び出すたびに、Transformと物理エンジンの位置が自動的に同期されていました。
この処理は比較的重たいので、物理演算のAPIを呼び出したときにスパイクの原因になります。
この問題を回避するために、Unity 2018.3以降、Physics.autoSyncTransformsという設定が追加されています。この値にfalseを設定すると、上記で説明した、物理演算のAPIを呼び出したときのTransformの同期処理が行われなくなります。
Transformの同期は物理演算のシミュレーション時の、FixedUpdateが呼び出された後になります。つまり、コライダーを移動してから、そのコライダーの新しい位置に対してレイキャストを実行しても、レイキャストがコライダーに当たらないことを意味します。
Unity 2018.3より前のバージョンでは、OnCollisionEnterなどのColliderコンポーネントの衝突判定を受け取るイベントが呼び出されるたびに、引数のCollisionインスタンスを新たに生成して渡されるため、GC Allocが発生していました。
この挙動は、イベントの呼び出し頻度によってはゲームのパフォーマンスに悪影響を及ぼすため、2018.3以降では新たにPhysics.reuseCollisionCallbacksというプロパティが公開されました。
この値にtrueを設定すると、イベント呼び出し時に渡されるCollisionインスタンスを内部で使い回すため、GC Allocが抑えられます。
この設定は2018.3以降ではデフォルト値としてtrueが設定されているため、比較的新しいUnityでプロジェクトを作成した場合には問題ないですが、2018.3より前のバージョンでプロジェクトを作成した場合、この値がfalseになっている場合があります。もしこの設定が無効になっている場合は、この設定を有効にしたうえでゲームが正常に動作するようコードを修正すべきです。