スレッド化したレンダリング

スレッド化したレンダラーを使用するプログラマー向けの情報

レンダリング スレッド

Unreal Engine では、レンダラー全体がゲーム スレッドより 1 または 2 フレーム遅れる独自のスレッドで動作します。

レンダリングを行う場合、すべてのメモリの読み書きに十分注意して、スレッドセーフティだけでなくビヘイビアの判断を確実にする必要があります。機能的な動作が 2 つのスレッドの実行速度の差に依存することを、レース コンディションといいます。レース コンディションは再現は非常に難しく、速度の違いによりマシン、プラットフォーム、デバッガー、コンフィギュレーション依存の場合があるので、回避することが重要です。このような種類のバグがデバッグできることは珍しく、修正には再現可能な一般的なバグの 10 倍の時間がかかります。

以下は、レース コンディション / スレッドのバグを分かりやすくしたサンプルです。

    /** FStaticMeshSceneProxy Actor is called on the game thread when a component is registered to the scene. */
    FStaticMeshSceneProxy::FStaticMeshSceneProxy(UStaticMeshComponent* InComponent):
        FPrimitiveSceneProxy(...),
        Owner(InComponent->GetOwner()) <======== Note:AActor pointer is cached
        ...

        /** DrawDynamicElements is called on the rendering thread when the renderer is doing a pass over the scene. */
        void FStaticMeshSceneProxy::DrawDynamicElements(...)
        {
            if (Owner->AnyProperty) <========== Race condition!The game thread owns all AActor / UObject state,
                // and may be writing to it at any time. (好きな時に書き込みができます。)The UObject may even have been garbage collected, causing a crash. (UObject はガーベジコレクションに処理される場合があり、クラッシュの原因となります。)
                // This could have been done safely by mirroring the value of AnyProperty in this proxy.(このプロキシの AnyProperty の値をミラーすると安全に行うことができます。)
        }

開発アプローチ

レース コンディションを発見するための専用テストというものはありません。推測して確認する方法や、遡及してバグを解決しても、信頼性のあるスレッド化コードは作成できないということを理解しておくことは重要です。最善策は、ゲーム スレッド、レンダリング スレッド、使用メカニズムのインタラクションを完全に理解し、確実な判断をすることです。それぞれのインタラクションを決定性のあるものにするイベントの順序を説明できるようにしておくべきです。そうでないと、確実にレース コンディションを引き起こすことになります。

スレッド特有のデータ構造

このような理由から、異なるスレッドで所有される別々の構造体にデータを置いて、変更した人と内容が一目で分かるようにしておくと良いでしょう。これは関数にも当てはまることです。複雑になるのを防ぐためにも、関数は常に同じスレッドから呼び出すべきです。ほとんどの Unreal Engine はこのような構造体をしています。例えば、UPrimitiveComponent は、レンダリングが可能な基本のゲーム スレッド クラスで、シャドウをキャストし、独自の可視性ステートを備えています。ゲーム スレッドはいつでもメンバーに書き込むことができるので、レンダリング スレッドが直接 UPrimitiveComponent のメモリに触ることはありません。レンダリング スレッドには、この機能を表す独自のクラス、FPrimitiveSceneProxy があります。ゲーム スレッドは作成および登録されると、FPrimitiveSceneProxy のメモリのメンバーには決して触りません。UActorComponent::RegisterComponent はコンポーネントをシーンに追加し、FPrimitiveSceneProxy を作成することによろいレンダラーに見えるようにします。コンポーネントを登録すると、表示に必要な FPrimitiveSceneProxy::DrawDynamicElements がそれぞれのパスに対して呼び出されます。

パフォーマンスへの配慮

ゲーム スレッドは、レンダリング スレッドが 1 あるいは 2 フレームの遅れに追いつくまで、各 Tick() の終わりでブロックします。レンダリング スレッドは今までのところ遅れているので、レンダリング スレッドが完全に追いつくまではゲーム中にゲーム スレッドをブロックすることは絶対に容認されません。Unreal Engine は非同期のストリーミング レベルをサポートしているので、ロード中のブロックや、各オブジェクトの GC は良くないです。様々な動作を非同期メカニズムにして、ブロックを回避しています。

スレッド間の通信

非同期式

2 つのスレッド間の通信は、主に ENQUEUE_UNIQUE_RENDER_COMMAND_XXXPARAMETER マクロを使って行われます。このマクロは、マクロに代入したコードを含む Execute 仮想関数をもったローカル クラスを作成します。ゲーム スレッドはレンダリング コマンド キューにコマンドを挿入し、レンダリング スレッドはそれに合わせて実行関数を呼び出します。

FRenderCommandFence で、ゲーム スレッドのレンダリング スレッドの進捗を追跡しやすくなります。ゲーム スレッドは、フェンスを開始するために FRenderCommandFence::BeginFence を呼び出します。そしてゲーム スレッドは、レンダリング スレッドがフェンスを処理するまで FRenderCommandFence::Wait を呼び出してブロックしたり、あるいは GetNumPendingFences を確認することでレンダリング スレッドの進捗をポーリング することができます。GetNumPendingFences が 0 を返すと、レンダリング スレッドはフェンスを処理していることになります。

ブロッキング

FlushRenderingCommands は、レンダリング スレッドが追いつくまで、ゲーム スレッドをブロックする標準的な方法です。この方法は、レンダリング スレッドからアクセスされているメモリをオフラインで修正する場合に便利です。

レンダリング リソース

FRenderResource は基本的なレンダリング リソース インターフェースを提供し、初期化およびリリースのためのフックを提供します。FRenderResource (FVertexBufferFIndexBuffer など) から派生したものはすべて、レンダリングの使用前に初期化し、削除前にリリースされなければなりません。FRenderResource::InitResource はレンダリング スレッドからのみ呼び出せるので、ゲーム スレッド上にヘルパー関数 (BeginInitResource) を呼び出し FRenderResource::InitResource を呼び出すためにレンダリング コマンドをキューに入れることができます。RHI 関数はレンダリング スレッドからのみ呼び出せます (デバイス、ビューポートを作成する場合は例外です)。

UObjects とガーベジ コレクション

Garbage Collection GC はゲーム スレッド上で発生し、UObjects 上で動作します。レンダリング スレッドは UObject を参照するコマンドを処理しますが、ゲーム スレッドは UObject を削除する場合があります。そのため、レンダリング スレッドは、レンダリング スレッドがもうそれを参照しなくなるまで絶対に UObject が削除されないという仕組みが機能しない限り、UObject ポインタの参照先の値は絶対に取得しません。その例が UPrimitiveComponent です。 レンダリング スレッドがデタッチ コマンドを処理する前に、GC が Uobject を削除しないように DetachFence という FRenderCommandFence を使用します。

ゲーム スレッド FRenderResource の処理

ゲーム スレッドレンダリング スレッド リソースのインタラクションには、静的リソース (インデックス バッファのような、ロード時およびエディタ内での修正のみ) とゲーム スレッド シミュレーションの際品結果でフレームごとに更新する必要のある動的視ソースの、一般的な 2 種類のシナリオが考えられます。

静的リソース

静的リソースのインタラクションが Unreal Engine でどのように処理されるのか、USkeletalMesh を例にして説明します。

  • USkeletalMesh::PostLoad はロード時に呼び出され、それにより InitResources が呼び出されます。これにより、インデックス バッファのように、静的な FRenderResources 上に BeginInitResource を呼び出します。BeginInitResource は FRenderResource::InitResource を呼び出すために、レンダリングコマンドをキューに入れます。このポイントから、ゲーム スレッドは、オーナーシップを取り戻すための操作を行わない限り、インデックス バッファ メモリを修正することができなくなります。

  • どれが USkeletalMesh のインデックス バッファでレンダリングを開始するかを、コンポーネントが登録します。

  • GC は、コンポーネントがある時点 (アンロードまたは参照されなくなったレベル) で参照されなくなったと判断し、コンポーネントからデタッチします。この時点では、レンダリング スレッドはまだデタッチを所有しておらず、インデックス バッファでレンダリングしている場合があるので、ゲーム スレッドはインデックス バッファ メモリを削除します。

  • GC は USkeletalMesh::BeginDestroy を呼び出し、これによりゲーム スレッド オブジェクトはレンダリング リソースをリリースするためのコマンドをキューに入れることができ、 BeginReleaseResource(&IndexBuffer) を行います。レンダリング スレッドは必ずしもリリース処理をしているわけではないので、 ゲーム スレッドはまだ IndexBuffer のメモリを削除することはできません。レンダリング スレッドがキャッチアップするまで、ゲーム スレッドをブロックすることができますが、これにより処理落ちが生じ、処理が遅くなるので、非同期式のメカニズムを使います。レンダリング スレッドによるリリース コマンド処理の進捗追跡のために、フェンスを開始します。

  • GC は USkeletalMesh::IsReadyForFinishDestroy を呼び出し、関数が true を返すまで UObject を破壊しません。レンダリング スレッドによりフェンスがパスされると、関数は true のみを返します。これは、ゲーム スレッドから安全にインデックス バッファ メモリを削除できるという意味です。

  • GC は最後にセントラル ロケーションでメモリをリリースするために使用する UObject::FinishDestroy を呼び出します。インデックス バッファの場合、USkeletalMesh デストラクタが FRawStaticIndexBuffer のデストラクタを呼び出すとメモリがリリースされ、それによりインデックス バッファ メモリを保有する TArray のデストラクタを呼び出し、メモリをリリースします。

この方法は効率的 (いずれのスレッドもブロックせず、フレームごとに初期化が必要か確認せずにセントラル ロケーションで初期化する) であり、決定性があります。

動的リソース

動的リソースの更新の良い例は、フレームごとにゲーム スレッド アニメーションで作成されたスケルタル メッシュ ボーン変形です。シェーダー定数として設定できるレンダリング スレッド上の配列の中にそれぞれのアニメーションを更新した後で、ゲーム スレッドから変形を取得することが目標です。各フレームのインデックスや頂点バッファを更新した場合も、同じことが言えます。操作の順序は以下の通りです。

  • USkinnedMeshComponent::CreateRenderState_ConcurrentUSkinnedMeshComponent::MeshObject をアロケートします。これ以降、ゲーム スレッドが書き込めるのは MeshObject ポインタのみとなり、FSkeletalMeshObject のメモリへは書き込めなくなります。

  • 1 フレームにつき最低 1 回、コンポーネントの移動を更新するために、USkinnedMeshComponent::UpdateTransform が呼び出されます。GPU スキニングの場合は、FSkeletalMeshObjectGPUSkin::Update が呼び出されます。この時点で、ゲーム スレッド上には最新の変形があり、それらをレンダリング スレッドに渡す必要があります。その方法は、まずヒープ (FDynamicSkelMeshObjectData) 上にメモリをアロケートし、ボーン変形をそこへコピーして、 ENQUEUE_UNIQUE_RENDER_COMMAND_TWOPARAMETER を使ってそのコピーをレンダリング スレッドへパスします。レンダリング スレッドはコピーを所有するようになり、その削除を担当します。ENQUEUE_UNIQUE_RENDER_COMMAND_TWOPARAMETER マクロには変形を最終目的地へコピーするコードが含まれているので、シェーダー定数として設定することができます。頂点位置を更新する場合、頂点バッファのロックと更新はここで行います。

  • どこかのタイミングで、コンポーネントはデタッチします。ゲーム スレッドは動的な FRenderResources をすべてリリースするレンダリング コマンドをキューに入れて、 MeshObject ポイントを NULL にすることができますが、実際のメモリはレンダリング スレッドから参照されているので削除することはできません。ここで遅延型の削除方法を使います。FDeferredCleanupInterface から派生したクラスは、スレッドセーフな非同期型の方法で削除することが可能です。FSkeletalMeshObject がこのインターフェースを実行します。ゲーム スレッドは FSkeletalMeshObject の遅延型削除を開始するために、BeginCleanup(MeshObject) を呼び出します。メモリは、安全が確認され、クリーンアップが完了すると、最終的に削除されます。

ステータスの更新 vs レンダリング シーンのトラバース

更新およびレンダリング操作が明確なシステムを開発する場合、DrawDynamicElements に 2 つを組み合わせようとしますが、これでは設計の質が落ちてしまいます。更新とレンダリング トラバースを分けると良いでしょう。例えば、更新コマンドをゲーム スレッド ティック内からキューに入れるのです。

DrawDynamicElements が、プリミティブ コンポーネントのエレメントをドローするために、ハイレベルなレンダリング コードで呼び出されます。ハイレベルなコードとは RHI ステートがまったく変更されず、シェーディング パス、ビューの数、シーン内のシーン キャプチャに応じて、フレーム毎に必要に応じて何回でも呼び出せることを前提とします。DrawDynamicElements も呼び出されることがありますが、様々な理由で基本的なドロー ポリシーにより結果は破棄されます (例えば、デプス パス中にサブミットされた透過の FMeshElement は破棄されます)。プリミティブ コンポーネントは実際は表示されませんが、オクルージョン システムは使用されているヒューリスティックに応じて、実際に DrawDynamicElements を呼び出したりしなかったりします。これらの要因はすべて、フレームごとに 1 回ずつ発生するステートの更新とコンフリクトします。

更新をレンダリング トラバースから分けると良いでしょう。ゲーム スレッド ティックは、更新作業のためにレンダリング コマンドをキューに入れることができます。ユースケースで容認されれば、レンダリング コマンドはプリミティブ シーン情報の LastRenderTime を使って可視性に応じてオプションで更新をスキップすることができます。更新作業をこの方法で別にしてキューに入れれば、 RHI 関数は異なるレンダリング ターゲットの設定にも使うことができます。

ステートキャッシュ (更新の反対) は、このルールでは例外です。ステートキャッシュは、レンダリング トラバースの中間結果を最適化として格納します。トラバースと密接に結びついていますが、RHI ステートは変更しないので、前述した短所を心配する必要はありません。

Unreal Engine のドキュメントを改善するために協力をお願いします!どのような改善を望んでいるかご意見をお聞かせください。
調査に参加する
キャンセル