以下の各セクションでは、アプリケーションのパフォーマンスに影響を与える可能性がある状況の概要について説明し、発生した問題の対処に役立つ代替アプローチや回避策に関するガイドラインを提供します。
始める前に
Unreal Engine でのパフォーマンス プロファイリングに慣れていない場合は、以下のセクションを読み進める前に「パフォーマンスのプロファイリングと構成の概要」を読み、このトピックに関する基礎知識を身に付けておくことを強くお勧めします。
マネージド オブジェクト、ガベージ コレクション、および処理スパイク
Unreal Engine では、UObjects とそこから派生したクラス (アクタやデータ アセットなど) は、エンジンのガベージ コレクターによって管理されます。 ガベージ コレクターはワールド内で削除された UObjects を定期的にクリーンアップし、そのオブジェクトへの既存の参照もクリーンアップします。
一方、標準の C++ オブジェクトは管理されていません。 つまり、オブジェクトのコピーを削除したり、null にしたりすると、そのオブジェクトへの参照を手動でクリーンアップする必要があります。 これは、慎重に処理しないとリスクが生じます。クリーンアップ ロジックに抜けがあると、メモリ リーク (オブジェクトがクリーンアップされていない場合) や無効な参照 (オブジェクトが削除されても参照が残っている場合) が発生することがあります。
マネージド オブジェクトをサポートすると、メモリ使用量が増加します。 UObjects には FName
や Outer
参照などの追加メタデータが含まれているため、メモリを多く消費します。 ガベージ コレクターが定期的に実行され、オブジェクトを自動的にクリーンアップするため、バックエンド システムでは、オブジェクトが参照されている場所をすべて監視する必要があります。 ガベージ コレクターの実行中、特にアプリケーションで大量のオブジェクトが破棄された直後は、処理スパイクがフレーム内に発生しやすくなります。
[Project Settings (プロジェクト設定)] > [Engine (エンジン)] > [Garbage Collection (ガベージ コレクション)] で、ガベージ コレクションの実行間隔、指定したタイミングでクリーンアップできるオブジェクトの最大数、その他の処理方法について設定できます。 プロジェクトの初期段階で微調整が必要になることはほぼありませんが、これらのオプションを使用して、Unreal Engine のガベージ コレクターをプロジェクト固有のニーズに合わせて調整することができます。
この自動ガベージ コレクションの使用をお勧めします。 必要に応じて、ブループリントの Collect Garbage ノードか C++ の UObjectGlobals::CollectGarbage
関数を使用して、手動でガベージ コレクターを呼び出すこともできます。
これにより処理スパイクが発生しますが、ガベージ コレクションを手動で呼び出すことで、バックグラウンドにガベージが累積することを防ぎ、後から自動で実行されるときに発生する大きなスパイクを抑えることができます。
手動によるガベージ コレクションは、次のような状況に適しています。
ユーザー エクスペリエンスの観点で、プログラムがフレームのスパイクを許容しやすい状態の場合 (ロード画面中など)。 これにより、目立ちやすい、または許容できない状況で発生する可能性が減少します。
操作に大量のメモリを割り当てる前 (テストの際に、その操作の直前にガベージ コレクションを実行しないとメモリ不足によるクラッシュが発生したり、ページ スワッピングのヒッチが発生したりすることを発見した場合)。
オブジェクトの作成、破棄と オブジェクト プーリング
オブジェクトを作成するには、コンピュータでオブジェクトのコピーを保持するための新しいメモリ ブロックを割り当てて、必要なサブオブジェクトとともに初期化する必要があります。 オブジェクトを破棄する場合、そのオブジェクトの情報を削除し、割り当てを解除して、アプリケーションのコード内の別の場所に存在している可能性があるそのオブジェクトへの参照をすべて消去する必要があります。
これらの操作はどちらも、特に初期化が他のシステムと連動している場合、かなりコストがかかる可能性があります。 ほとんどの場合、Unreal Engine ではこれらの操作を効率的に処理するため、PC やコンソールで安全に使用することができますが、CPU での処理能力に余裕がないプロジェクトでは、代わりにオブジェクト プーリングを使用することをお勧めします。 オブジェクト プーリングは、必要なオブジェクトのコピーを事前に作成してメモリに割り当ててから、必要になるまで無効または非表示にしておくものです。
オブジェクトのレベルが高くなるほど、作成と破棄にかかるコストも高くなります。 プーリングはアクタに最適ですが、コンポーネントでも役立ちます。また、その他の UObject よりもコンポーネントでの使用のほうが適しています。 これは、アクタの作成にかかるコストには、ワールドのアクタ リストへの追加、コンポーネントの作成、レンダリングや物理などの追加インフラストラクチャへのアクタとそのコンポーネントの登録も含まれるためです。 作成や破棄の際に追加クラスとの連携がない C++ 構造体の場合は、プーリングするよりも、システム アロケータで生メモリをリサイクルするほうが効率的なことがあります。
たとえば、何かを発射する武器について考えてみます。 多くの場合、武器が発射されると投射物をスポーンし、それが別のオブジェクトに衝突すると自壊します。
オブジェクト プーリングを使用すると、武器を発射するたびに新しい投射物をスポーンする代わりに事前に最大数の投射物をスポーンしておき、必要なタイミングでそれらを有効にし、その後、非表示にして無効にします。 この無効にされた投射物のグループを「オブジェクト プール」と言います。 武器が投射物を発射すると、投射物がプールから選択されて武器の端まで移動し、非表示が解除されて有効になり、適切な方向で初期化されます。 次に、その投射物はターゲットに衝突すると非表示になって無効化され、再度使用されるまでプールに戻されます。
オブジェクト プールのメリットは、オブジェクトを作成または破棄する必要がないため、オブジェクトの初期化とクリーンアップに費やす処理時間を大幅に削減できることです。 このトレードオフは、プール内にあるオブジェクトが非アクティブのときも、本来は使用されないはずのメモリを消費してしまうことです。 したがって、多くの場合、プールに必要な最大数のオブジェクトに対応できるように、容量を確保しておく必要があります。 また、これらのオブジェクトのメモリは、小さなチャンクではなく大きなチャンク単位で割り当ててクリーンアップすることで、より安定した状態を保ち、メモリの断片化を抑えることができます。
On-Tick ロジックと コールバック、タイマー、スケジュールされたロジック
ティック可能な UObject およびアクタで Tick イベントを使用すると、フレームごとに繰り返し実行されるロジックを作成することができます。 これはリアルタイムな動作を処理する場合に役立ちます。 ただし、継続的ではなく断続的なルーチンに Tick を使用すると、CPU を無駄に消費する可能性があります。
特に、次の例のような場合に、フレームごとに変数が変更されたかどうかをチェックするロジックは最適ではありません。 あるクラスが Tick を使用して、別のクラスの変数がいつ変更されたかを何度も確認しています。
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AChildActor* ChildActor;
protected:
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
int32 getMyInt(){
return MyInt;
}
private:
int32 myInt = 0;
ティックを使用して値を監視する代わりに、変数を変更する演算をラップするカスタムのセッター関数を作成し、その値を変更する場合にのみ必要なロジックを実行する別の関数またはイベントを呼び出すことができます。
次の例には先ほどの例にあったクラスが含まれていますが、今回はコールバックを使用し、変数が変更されたときにのみ処理を実行します。
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
void OnChildIntChanged(int32 NewValue)
{
if (newValue > 3)
{
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AMyActor* ParentActor;
これにより、フレームごとに値をクエリするのではなく、変数が変更されたときにのみロジックが実行されるようになります。
ただし、イベント駆動型のアプローチでは、条件の変更頻度によっては最適ではない場合があります。 イベントがフレーム内で複数回実行される場合や、関数が同じフレーム内で変更される可能性がある複数のイベントにアタッチされている場合は、Tick を使用するか、「コマンド パターン」を使用すると、より効率的です。 これにより、レンダリングされる前に上書きされてしまう計算結果を回避できます。
一定の時間が経過した後にイベントが発生するようにスケジュールを設定したい場合は、タイマーを起動します。これにより、イベントが終了するまでの経過時間を一時的に追跡し、その後、タイマー自体がクリーンアップされます。 代わりに、ブループリント イベント グラフの Delay ノードを使用することもできます。
ロジックを頻繁に再実行する必要があるが、すべてのフレームで実行する必要はない場合、特定のフレーム間隔や秒数ごとに実行する方法を検討してください。 これは、個々のオブジェクトやアクタ コンポーネントで、ティック間隔を指定の秒数に設定することで実行できます。 代わりに、Tick 関数でロジックのサブセットごとに間隔を作成することもできます。 これを実行するためには、変数を累積してリセットする必要がありますが、フレームごとにロジックを実行するよりもコストが低くなります。
非同期ロジックと 同期ロジック
同期ロジックは、アクションを最初から最後まで順番に完了させます。 ブループリントや C++ で記述するロジックのほとんどは、デフォルトで同期的に動作します。 たとえば、ブループリントでイベントを作成して、Delay ノード、タイマー、ゲームプレイ タスクのいずれも追加しない場合は、そのブループリント イベントから発生するすべてのロジックは、同じフレーム内で一度に実行されます。 フレームは、そのロジックの実行が完了するまで処理を終了することができません。 特にメモリのロードやアンロードが必要になる大規模なデータセットや大きなオブジェクトに対して、大量の処理を実行すると、急激な処理スパイクが発生する可能性があります。
非同期ロジックは、アクションを同時に完了させます。これは、文字どおり同時に (別の CPU コア上で) 処理される場合と、論理的に同時に (低レベルで同期的に実行される小さなチャンクにインターレースされる形で) 処理される場合があります。 非同期処理は完了するまで実行されますが、メインのプログラムは、その処理を待たずに実行し続けます。 通常、非同期処理は完了を通知するためにコールバックを使用します。
World Partition システムやさまざまなオンデマンド コンテンツ配信システムなど、Unreal Engine 内のいくつかのフレームワークでは、すでに非同期ロジックを採用しています。 作成するプロジェクトでは、非同期ロジックを実装し、一定期間をわたって処理を分散することを検討してください。これにより、単一の処理やフレームに負荷がかかりすぎるのを防ぐことができます。
たとえば、ウェーブベースの防御ゲームで、30 体以上の敵をロードしてインスタンス化する必要があるとします。 ランタイム時に新しいアクタを作成することだけでもコストがかかるため、すべてを同じフレーム上で処理しようとすると、非常に手間がかかります。 代わりに、非同期処理を作成し、指定した上限数に達するまで、または指定したスポーン地点がすべて使われるまで、フレームごとに最大 5 体の敵をスポーンさせることができます。これにより、30 体すべての敵が 6 フレームでスポーンされ、その時点で大量のスポーン処理が完了したという通知が行われます。 この処理によって、大量の敵をスポーンさせる際の負荷が大幅に軽減されます。スポーンは 0.1 秒から 0.2 秒で完了するため、多くのプレイヤーはそれにかかる時間に気づきません。
Unreal Engine での同時処理
同時処理は、アクションが同じコンピュータ内の異なるスレッドや CPU コア上で処理される非同期処理の一種です。 Unreal Engine で行われる同時処理の例には、次のようなものがあります。
ソフト ポインタを解決する。
バックグラウンドでレベルやアセットをロードする。
オンライン コンテンツ配信システムから非同期的にアセットをロードする。
スレッドとは、CPU や GPU 上で、命令を処理するための専用パスです。 ほとんどの CPU には複数のコアがあり、それぞれが独立したプロセッサとして機能し、各コアでは複数のスレッドを実行することができます。 同時処理のメリットを活用することは、特により複雑なタスクや大量のデータを処理する場合に、プログラムが CPU 処理のボトルネックにならないようにするための重要な鍵となります。
重要な処理スレッド
Unreal Engine には、次のような専用スレッドがあります。
スレッド名 | 説明 |
---|---|
ゲーム | C++ やブループリントの UObject やアクタ ロジック、UI ロジックを処理します。 プログラミングのほとんどは、このスレッドで実行されます。 |
レンダリング | シーン構造体を描画コマンドに変換します。 |
RHI | 描画コマンドを GPU に送信します。 |
タスク プール | 再利用可能なスレッドでさまざまなタスクを処理します。 |
オーディオ | サウンドと音楽を処理します。 |
ロード | データのロードとアンロードを処理します。 |
これらのスレッドは、Unreal Insights の [Timing Insights (タイミング インサイト)] ウィンドウで確認できます。
ゲーム スレッドでは大量のロジックを処理するため、コードは慎重にプロファイリングして最適化することが重要です。
独自のスレッド ロジックを作成する
UE には、独自の同時処理ロジックを追加するためのリソースがいくつか用意されています。
Tasks System は、ロジックをタスクに分割して個別のスレッドで同時処理できる、堅牢で比較的軽量なフレームワークを提供します。
FRunnable は、任意のスレッドで関数を実行するための最も直接的な低レベルのインターフェースを提供します。 これは、何をしているか十分に理解し、スレッド プールではなく専用スレッドを使用する正当な理由がある場合にのみ選択し、それ以外の場合は避けてください。
独自のスレッド ロジックを作成する際は注意が必要です。予期していない順序で処理が実行されると、競合状態が発生し、エラーが生じる可能性があります。
また、「スレッド化したレンダリング」ページでは、レンダリング固有のスレッド ロジックについて詳しく説明しています。
シェーダー コンパイル、フレームレート ヒッチ、PSO キャッシュ
Unreal Engine では、マテリアル命令をシェーダーにコンパイルして、GPU で実行する準備を行います。 コンパイルが完了すると、マテリアルの全体的なパフォーマンスは大幅に向上しますが、シェーダーのコンパイル処理によって大きな処理スパイクが生じます。これは瞬間的なものですが、フレームレートのヒッチはある程度、目立ちます。
これに対処するため、UE では PSO キャッシュが導入されています。 アプリケーションをプレイしてテストすることによって手動で PSO を収集するか、PSO の事前キャッシュを使用して自動的に生成することができます。 どちらの場合も、アプリケーションの実行中にグラフィック カードでレンダリングされる可能性があるすべての状態を記録し、そのデータをキャッシュしてバンドルすることで、後のビルドで使用するという考え方です。 これにより、ランタイム時に発生するシェーダー コンパイルの量が大幅に削減され、事前にそのほとんどをロードすることで、ユーザーが新しい領域やマテリアルをロードする際に経験するヒッチの発生を抑えることができます。