技術ブログ

2025年2月4日

ゲーム エンジンとシェーダーのスタッタリング:Unreal Engine における解決策

皆さん、こんにちは。私たちは、Unreal Engine の PSO 事前キャッシュ システムの開発に携わったエンジニアの Kenzo ter Elst、Daniele Vettorel、Allan Bentham、Mihnea Balta です。 

Epic コミュニティでは、最近、シェーダーのスタッタリングとこの現象がゲーム デベロッパーのプロジェクトにもたらす影響について、さまざまな議論が交わされています。 

本日は、この現象が発生する理由、PSO 事前キャッシュがこの問題の解決にどのように役立つのか、そしてシェーダーのスタッタリングを最小限に抑えるうえで役立つ開発のベスト プラクティスについて詳しく説明します。また、PSO 事前キャッシュ システムに関する今後の計画についてもお知らせします。 

さらに詳しく知りたい方は、今週 2月7日(金) 4:00am (日本時間) から Twitch または YouTube で配信される Inside Unreal のライブ ストリームをご覧ください。


背景

シェーダー コンパイルのスタッタリングは、レンダリング エンジンが、何かを描画する直前に新しいシェーダーをコンパイルする必要があることを検出すると発生します。そのため、ドライバーがコンパイルの完了を待機している間はすべてが停止します。なぜこのような現象が発生するのかを理解するには、シェーダーがどのように GPU 上で実行されるコードに変換されるかを詳しく確認する必要があります。

シェーダーとは、GPU 上で実行され、トランスフォーム、変形、シャドウイング、ライティング、ポストプロセスなど 3D 画像のレンダリングに必要なさまざまなステップを実行するプログラムです。シェーダーは、通常、HLSL などのハイレベル言語で記述されているため、GPU が実行できるマシン コードにコンパイルする必要があります。このプロセスは CPU の場合も同様で、C++ などのハイレベル言語で記述されたコードがコンパイラに入力され、x64、ARM などの特定のアーキテクチャ用の命令を生成します。 

ただし、重要な違いがあります。各プラットフォーム (PC、Mac、Android など) は通常、CPU の命令セットは 1 つまたは 2 つを対象としますが、GPU はたくさんの種類があるため、命令セットも大きく異なります。10 年前に x64 PC 用にコンパイルされた実行可能ファイルは、現在 AMD やインテルが製造しているチップでも動作します。なぜなら、どちらのベンダーも同じ命令セットを使っており、優れた後方互換性が保証されているからです。対照的に、AMD 用にコンパイルされた GPU バイナリは NVIDIA では動作せず、NVIDIA 用にコンパイルされた GPU バイナリは AMD で動作しません。また、同じベンダーのハードウェアでも世代が異なると、命令セットが変わることがあります。

そのため、CPU プログラムを実行可能なマシン コードに直接コンパイルし、それを配布することは可能ですが、GPU プログラムでは別のアプローチを使用する必要があります。ハイレベルのシェーダー コードは、3D API (Direct3D 11の場合は DXBC、Direct3D 12 の場合は DXIL、Vulkan の場合は SPIR-V など) によって定義された抽象命令セットを使用する中間表現 (バイトコード) にコンパイルされます。 

ゲームは、これらのバイトコードのバイナリ ファイルをシッピングします。そのため、ゲームは実行される可能性のある GPU アーキテクチャごとに 1 つのシェーダー ライブラリではなく、単一のシェーダー ライブラリを備えています。ランタイム時に、ドライバーはバイトコードをマシンにインストールされている GPU 用の実行可能コードに変換します。このアプローチは CPU プログラムにも使用されることがあります。たとえば、Java ソース コードはバイトコードにコンパイルされるため、CPU に関係なく、Java 環境を備えるすべてのプラットフォームで同じバイナリを実行できます。

このシステムが導入された当時、ゲームは比較的シンプルでシェーダーの数も少なく、バイトコードから実行可能コードへの変換も簡単であったため、ランタイム時にこの処理を実行する際の負荷はごくわずかでした。GPU が強力になるにつれて、シェーダー コードが増え始め、ドライバーもより効率的なマシン コードを生成するために高度な変換を行うようになりました。そのため、ランタイムのコンパイルにかかる負荷が問題になるようになりました。Direct3D 11 で状況が限界に達したため、Direct3D 12 や Vulkan などの最新の API では、パイプライン状態オブジェクト (PSO: Pipeline State Object) の概念を導入することで、この問題を解決しようとしました。

パイプライン状態オブジェクト

オブジェクトのレンダリングには、通常、複数のシェーダー (頂点シェーダーとピクセル シェーダーの連携など) と、カリング モード、ブレンド モード、深度モード、ステンシル比較モードなど、GPU の他の多くの設定が関係します。これらの項目を組み合わせることで、GPU パイプラインの構成または状態が記述されます。 

Direct3D 11 や OpenGL のような古いグラフィック API では、状態の一部を個別に、任意のタイミングで変更することができます。つまり、ドライバーは、ゲームが描画リクエストを発行したときにのみ、完全な構成を認識します。一部の設定は実行可能なシェーダー コードに影響するため、描画コマンドが処理されたときにしかドライバーがシェーダーのコンパイルを開始できない場合があります。これには、単一の描画コマンドに数十ミリ秒以上かかることがあり、シェーダーが初めて使用されるときにフレームが非常に長くなります。この現象は、多くのゲーマーにヒッチングまたはスタッタリングとして知られています。

最新の API では、デベロッパーは、描画リクエストに使用するすべてのシェーダーと設定をパイプライン状態オブジェクト (PSO) にパッケージ化し、1 つのユニットとして設定する必要があります。重要なのは、PSO はいつでも作成できるということです。そのため、理論的には、エンジンは必要なものすべてを十分に早い段階 (ロード時など) で作成することで、レンダリング前にコンパイルが完了する時間を確保できます。

理論と実践

Unreal Engine は、強力なマテリアル オーサリング システムを備えており、アーティストはこのシステムを使用して視覚的に豊かで魅力的なワールドを作成します。また、多くのゲームには何千ものマテリアルが含まれています。これらそれぞれがたくさんの異なるシェーダーを生成する可能性があります。たとえば、スタティックメッシュ、スキン メッシュ、スプライン メッシュで 1 つのマテリアルをレンダリングするために個々の頂点シェーダーがあります。同じ頂点シェーダーを複数のピクセル シェーダーで使用することができますが、これもまた、さまざまな一連のパイプライン設定によってさらに増大します。その結果、数百万の異なる PSO が発生する可能性があります。すべての可能性に対応するために事前にコンパイルする必要がありますが、当然、時間とメモリの観点から実現は不可能です (レベルのロードに数時間かかります)。

ランタイム時には、これらの PSO のごく一部が使用されますが、マテリアルを単独で見るだけでは、使われる部分がどれであるかを判断することはできません。使われる部分はゲームのセッションによって変わる場合があります。動画設定を変更すると特定のレンダリング機能が切り替わり、エンジンは異なるシェーダーまたはパイプライン状態を使用するようになります。初期の Direct3D 12 エンジンの実装では、プレイテスト、自動化されたレベルのフライスルー、およびその他の検出方法を活用して、実際にどの PSO が発生するかを記録していました。このデータは最終的なゲームに含まれ、起動時またはレベルのロード時に既知の PSO を作成するために使用されていました。Unreal Engine では、これを「バンドルされた PSO キャッシュ」と呼んでおり、UE 5.2 まではこれが推奨されるベスト プラクティスでした。

バンドルされたキャッシュは、一部のゲームでは十分ですが、多くの制限があります。バンドルされたキャッシュの収集では多くのリソースを消費するうえに、コンテンツが変更された場合は常に最新の状態に保つ必要があります。きわめてダイナミックなワールドを持つゲームでは、記録プロセスですべての PSO を検出できない可能性があります。たとえば、プレイヤーのアクションに基づいてオブジェクトのマテリアルが変化するような場合です。 

セッション間で大きな変化がある場合、たとえばマップが多数ある場合や、プレイヤーがたくさんのスキンの中から 1 つを選択できる場合など、キャッシュがプレイ セッション中に必要なサイズよりもはるかに大きくなる可能性があります。フォートナイトは、これらすべての制限に該当するため、バンドルされたキャッシュが適合しない代表的な例です。さらに、フォートナイトにはユーザー生成コンテンツがあるため、体験ごとの PSO キャッシュを使用する必要があり、これらのキャッシュを収集する責任がコンテンツ クリエイターに課せられます。

PSO precaching script in Unreal Engine.

PSO 事前キャッシュ

大規模で多様なゲーム ワールドとユーザー生成コンテンツをサポートするため、Unreal Engine 5.2 では、PSO 事前キャッシュ を導入しました。これは、ロード時に発生する可能性のある PSO を特定する技術です。オブジェクトがロードされると、システムはそのマテリアルを調べ、メッシュの情報 (スタティックかアニメーションかなど) とグローバルな状態 (動画品質設定など) を使用して、オブジェクトのレンダリングに使用される可能性のある PSO のサブセットを計算します。 

このサブセットは、最終的に使用されるものよりは依然として大きいものの、可能性の全範囲を網羅するよりははるかに小さいため、ロード時にコンパイルできます。たとえば、フォートナイト バトルロイヤルでは、マッチごとに約 30,000 個の PSO をコンパイルし、そのうち約 10,000 個を使用しますが、これは数百万個を含む全組み合わせの空間のごく一部にすぎません。

マップのロード中に作成されたオブジェクトは、ロード画面が表示されている間に PSO を事前キャッシュします。ゲームプレイ中にストリーミングまたはスポーンするものは、レンダリングされる前に PSO の準備が整うまで待つか、コンパイル済みのデフォルトのマテリアルを使用することができます。これは、ほとんどの場合、ストリーミングが数フレーム遅れるだけで、目立つことはありません。このシステムにより、マテリアルの PSO のコンパイル時のスタッタリングが解消され、ユーザー生成コンテンツでもシームレスに動作するようになりました。

すでに表示されているメッシュのマテリアルを変更するのは、より困難になります。なぜなら、新しい PSO のコンパイル中に、メッシュを非表示にしたり、デフォルトのマテリアルでレンダリングしたりすることは避ける必要があるからです。私たちは、ゲーム コードとブループリントが事前にシステムにヒントを提供し、追加の PSO も事前にキャッシュできるようにする API の開発に取り組んでいます。また、新しいマテリアルをコンパイルしている間も前のマテリアルを引き続きレンダリングできるようにエンジンを変更したいと考えています。

Unreal Engine には、マテリアルとは関係のない別のクラスのシェーダーがあります。これらはグローバル シェーダーと呼ばれ、モーション ブラー、アップスケーリング、ノイズ除去など、さまざまなアルゴリズムやエフェクトを実装するためにレンダラが使用するプログラムです。事前キャッシュ メカニズムはグローバル コンピュート シェーダーにも対応していますが、UE 5.5 時点では、グローバル グラフィック シェーダーには対応していません。これらのタイプの PSO は、初めて使用するときにまれに 1 回限りのヒッチを引き起こす可能性があります。事前キャッシュの範囲におけるこの残りのギャップを埋めるための対応が現在も進められています。

バンドルされたキャッシュは事前キャッシュと併用できるため、これは、特定のゲームにメリットをもたらす可能性があります。バンドルされたキャッシュが、ゲームプレイ時ではなく起動時にコンパイルされるように、いくつかの一般的なマテリアルをバンドルされたキャッシュに含めることができます。また、バンドルされたキャッシュは、グローバル グラフィック シェーダーにも役立ちます。検出プロセスでグローバル グラフィック シェーダーが検出され、記録されるからです。

ドライバー キャッシュ

ドライバーはコンパイルされた PSO をディスクに保存するため、後続のゲーム セッションで再び PSO が発生したときに直接ロードすることができます。これは、どのエンジンと PSO コンパイル戦略を使用するかに関係なく、ゲームに役立ちます。PSO 事前キャッシュを使用する Unreal Engine タイトルの場合、2 回目の実行時にロード画面の表示が明らかに短くなります。ドライバー キャッシュが空の場合、フォートナイトではバトルロイヤルのマッチのロードに約 20~30 秒かかります。キャッシュは新しいドライバーがインストールされるとクリアされるため、ドライバーの更新後に初めてゲームを実行すると、ロード画面が長く表示されるのは一般的です。

Unreal Engine は、ロード時に PSO を作成し、コンパイルが完了するとすぐに PSO を破棄することで、ドライバー キャッシュを活用します。このため、この手法は事前キャッシュと呼ばれています。後でレンダリングに PSO が必要になった場合、エンジンはコンパイル リクエストを発行しますが、事前キャッシュ システムによってキャッシュが存在することが確認されているため、ドライバーはキャッシュから PSO を返すだけです。PSO が描画に使用されると、その PSO を使用するすべてのプリミティブがシーンから削除されるまで PSO はロードされたままになるため、フレームごとにドライバーに PSO を要求し続ける必要はありません。

事前キャッシュ後に破棄することで、未使用の PSO がメモリに残らないというメリットがあります。欠点は、必要なときにドライバー キャッシュから PSO をフェッチするのに引き続き時間がかかることです。PSO をコンパイルするよりもはるかに速いものの、マテリアルを初めてレンダリングするときにわずかにスタッタリングが発生する可能性があります。 

シンプルな解決策の一つは、事前キャッシュされた PSO を破棄することなく、保持することです。ただし、これはメモリ使用量が 1GB 以上増加する可能性があるため、十分な RAM を搭載したマシンでのみ実行する必要があります。私たちは、メモリへの影響を軽減し、事前キャッシュされた PSO を有効に維持するタイミングを自動的に決定するソリューションに取り組んでいます。

実行可能な PSO コードに影響するのは一部の状態だけです。つまり、同じシェーダーを持ちながら、パイプライン設定が異なる 2 つの PSO を作成した場合、最初の PSO だけで負荷の大きいコンパイル プロセスを実行し、2 番目の PSO はキャッシュからすぐに返すことができます。 

残念ながら、コード生成に重要な状態のセットは GPU ごとに異なり、ドライバーのバージョンによっても異なる可能性があります。Unreal Engine では、実用的な知識を活用して、事前キャッシュ プロセス時に複数の順列をスキップできるようにしています。ドライバー キャッシュのおかげで冗長なリクエストは短縮されますが、エンジンは依然としてリクエストを生成する処理を実行する必要があります。この作業は積み重なっていくため、プルーニング プロセスはロード時間を短縮し、メモリ使用量を削減するうえで役立ちます。

モバイル プラットフォームおよびコンソール

モバイル プラットフォームでは同じデバイス上のシェーダー コンパイル モデルが使用されており、Unreal Engine の事前キャッシュ システムはモバイル プラットフォームでも効果的です。一般的に、モバイル レンダラが使用するシェーダーの数はデスクトップよりも少ないものの、CPU が低速で PSO のコンパイルに大幅に時間が掛かるため、実現可能にするためにプロセスに調整を加える必要がありました。 

そのため、めったに使用されない順列を省略しました。つまり、事前キャッシュ セットが保守的ではなくなり、まれな状態の 1 つがレンダリングされると、場合によってはヒッチが発生する可能性があります。また、ロード画面が長時間表示されないように、マップのロード時に事前キャッシュのタイムアウトも設けています。つまり、未完了のコンパイル タスクがある場合でもゲームを開始できます。そのため、保留中の PSO の 1 つがすぐに必要になった場合は、スタッタリングが発生します。そこで、PSO が必要な場合に優先度ブースト システムを使用してタスクをキューの先頭に移動し、このようなヒッチを最小限に抑えています。

コンソールはターゲットの GPU が 1 つしかないため、この問題を解決する必要はありません。個々のシェーダーが実行可能コードに直接コンパイルされ、ゲームに搭載されます。同じ頂点シェーダーを複数のピクセル シェーダーと併用することや、パイプラインの状態によって、組み合わせ爆発は発生しません。なぜなら、これらの要因により、再コンパイルは発生しないからです。シェーダーと状態は、大きな負荷を発生させることなく、ランタイム時に PSO にアセンブルできるため、これらのプラットフォームでは PSO のヒッチは発生しません。

Direct3D 11 に戻すべきか

Direct3D 11 にはこのような問題がなかったという誤解が一部にあり、古いコンパイル モデルや古いグラフィック API に戻すべきだという意見を時折耳にします。前段で説明したように、当時もヒッチは発生していましたし、API の設計方法により、エンジンにはヒッチを防ぐ方法がありませんでした。ゲームのシェーダーがシンプルで数が少なく、レイトレーシングなどの機能が一切なかったため、ヒッチの発生頻度が低いか、発生しても短時間であることがほとんどでした。 

ドライバーもまた、スタッタリングを最小限に抑えるためにさまざまな手法を活用しましたが、完全に回避することはできませんでした。Direct3D 12 では、PSO を導入することで、ヒッチが悪化する前に解決しようと試みましたが、既存のマテリアル システムを改良するのが難しいことや、ゲームの複雑さが増すにつれて初めて明らかになった API の欠陥などにより、エンジンが PSO を効果的に使用できるまでに時間がかかりました。 

Unreal Engine は、多くのユースケースと多くの既存のコンテンツとワークフローを備えた汎用エンジンであるため、この問題に対処するのは特に難しかったのですが、ようやく実行可能な解決策の段階に至る目途がついてきました。また、グラフィックス パイプライン ライブラリ Vulkan 拡張機能など、API の欠点を解決するための優れた取り組みも進めています。

PSO precaching stats in Unreal Engine.
Console commands for PSO profiling and debugging in Unreal Engine.

今後の取り組み

事前キャッシュ システムは 5.2 で実験的機能として導入されて以来大きく進化しており、ほとんどの種類のシェーダー コンパイルのスタッタリングが回避できるようになりました。ただし、依然としてカバーできていない部分やその他の制限があるため、さらに改善するための取り組みが継続されています。また、ハードウェア ベンダーおよびソフトウェア ベンダーと協力して、ゲームが実際にこれらのシステムを使用する方法に合わせてドライバーとグラフィック API を適応させています。

私たちの最終的な目標は、事前キャッシュを自動的かつ最適に処理し、ゲーム デベロッパーがヒッチを防ぐ方策を何も取らなくていいようにすることです。システムが完成するまでは、ライセンシーがスムーズなゲームプレイを確保するために実行できる次のような対策があります。

  • 最新バージョンのエンジンを使用する。事前キャッシュは改良が続けられているため、より新しいエンジン バージョンでは動作が改善されます。完全なアップグレードが不可能な場合でも、ほとんどの改善点をカスタム エンジン バージョンにバックポートできます。
  • ゲームで PSO のヒッチのプロファイリングを行う。ドキュメントの説明に沿って、r.PSOPrecache.Validation=2 を使用して、PSO のミスや遅延を特定し、原因を把握します。
  • プレイテストの前にドライバー キャッシュをクリアする。プレイテスト時にコマンドライン引数 -clearPSODriverCache を使用すると、ゲームを初めて実行したときや、ドライバーを更新した後のプレイヤー体験がどのようになるかを確認できます。このモードではヒッチが発生する可能性があるので注意し、前述のプロファイリングおよびデバッグ ツールを使用してヒッチを解決します。
  • このプロセスを定期的に繰り返す。コンテンツやゲーム コードの変更により、新たなヒッチが発生したり、システムのバグが発生したりする可能性があります。ライセンシーの皆様には、自動テスト手順の一環として PSO の統計情報をモニタリングすることを強くお勧めします。
  • 他のタイプのトラバース スタッタリングにも注意する。PSO のコンパイルはゲームプレイ時にヒッチが発生する唯一の原因ではなく、インストルメンテーションなしでは根本原因を特定することは困難です。開発時やテスト時に定期的にゲームのプロファイリングを行い、同期ローディング、過剰なスポーンやストリーミング、動きによってトリガーされるシーン キャプチャなど、フレーム タイムのスパイクの原因となる他のコストの大きなプロセスを追跡してください。

なお、このトピックの詳細については、今週 2月7日(金) 4:00am (日本時間) から Twitch または YouTube で配信される Inside Unreal にご覧ください。