Unity シェーダーバリアントの最適化やトラブルシューティングのヒント

Unity でシェーダーを書く場合、1 つのソースファイルに複数の機能、パス、そして分岐ロジックを含めることができる便利な機能があります。ビルド時に、シェーダーソースファイルは 1 つ以上のバリアントを含むシェーダープログラムにコンパイルされます。バリアントとは、1セットの条件に従って実行される該当シェーダーのバージョンのことで、(ほとんどの場合)静的分岐条件なしのリニア実行パスとなります。
パスの分岐をすべて 1 つのシェーダーで管理せずにバリアントを使う理由は、GPU が予測可能で常に同じパスをたどるコードを並列化するのが得意なため、スループットが高くなるからです。コンパイルされたシェーダープログラムに条件分岐があると、GPU は予測タスクにリソースを使ったり、他のパスが完了するのを待ったりする必要があり、非効率になります。
これは動的分岐に比べると GPU パフォーマンスが大幅に向上する一方で、いくつかの欠点もあります。バリアントの数が増えれば増えるほど、ビルド時間は長くなり、1 つのビルドの所要時間が数時間以上伸びる場合もあります。また、シェーダーのロードと事前準備に時間がかかるため、ゲームの起動時間も長くなります。最後に、バリアントが適切に管理されていない場合、シェーダーによるランタイムのメモリ使用量が著しく多くなり、1 GB を超える場合もあります。
生成されるバリアントの数は、定義されたキーワードやプロパティ、品質設定、グラフィックス階層、有効なグラフィックス API、ポストプロセスエフェクト、アクティブなレンダーパイプライン、ライティングおよびフォグモード、XR が有効かどうかなど、さまざまな要因によって増加します。結果として多くのバリアントを生成するシェーダーは、しばしばウーバーシェーダーと呼ばれます。後ほど説明するように、Unity はランタイムにおいて、必要な設定とキーワードに一致するバリアントをロードします。
特に、100 以上のキーワードを持つシェーダーが頻繁に見られ、その結果、処理しきれないほどのバリアントが生成されること(「シェーダーバリアントの爆発」として知られる現象)を考えると、これは大きな影響があります。フィルタリングが適用される前の初期バリアント数が百万単位にのぼるシェーダーも珍しくありません。
これを軽減するために、Unity は数回のフィルタリングパスに基づき、生成されるバリアントの数を減らそうと試みます。例えば、XR が有効になっていない場合、XR で必要とされるバリアントは通常削除されます。次に Unity は、ライティングモードやフォグなど、シーンで実際に使われている機能を考慮します。このような問題に気づくことが特に難しいのは、開発者やアーティストが、一見安全に見える変更を加えることで、実際にはシェーダーのバリエーションを大幅に増加させてしまう可能性があり、デプロイメントパイプラインの一部として何らかの安全策を講じない限り、これを検出する明確な方法がないためです。
このプロセスは役に立ちますが、完璧なものではなく、ゲームのビジュアルクオリティに影響を与えずに、できる限り多くのバリエーションを削除するためにできることはたくさんあります。
ここでは、バリアントの扱い方、バリアントの発生源を理解する方法、そしてバリアントを減らす効果的な方法について、実践的なヒントをいくつかご紹介します。これらを活用すれば、プロジェクトのビルド時間とメモリフットプリントを大幅に改善できるでしょう。
シェーダーバリアントの除去の詳細については、Unity マニュアルの「Reducing shader variants」を参照してください。
シェーダーのバリアントは、シェーダーで使用されている shader_feature および multi_compile キーワードのすべての可能な組み合わせを含む、さまざまな要因に基づいて生成されます。multi_compile としてマークされたキーワードは常にビルドに含まれますが、shader_feature としてマークされたキーワードはプロジェクト内のマテリアルで参照されている場合にのみ含まれます。このため、可能な限り shader_feature を使うのが望ましいでしょう。
シェーダーで定義されているキーワードを確認するには、シェーダーを選択して Inspector をチェックします。

ご覧のように、キーワードは Overridable(オーバーライド可)と Not Overridable(オーバーライド不可)に分かれています。グローバルスコープを持つローカルキーワード(実際のシェーダーファイルで定義されたもの)は、一致する名前を持つグローバルシェーダーキーワードによってオーバーライドすることができます。代わりに(multi_compile_local または shader_feature_local を使用して)ローカルスコープで定義されている場合、それらはオーバーライドできず、その下の Not Overridable のセクションに表示されます。グローバルシェーダーキーワードは Unity エンジンによって提供されており、オーバーライド可能です。これらのキーワードはビルドプロセスのあらゆる段階で追加できるため、すべてのグローバルキーワードがこのリストに表示されるとは限りません。
キーワードは、同じディレクティブで定義することによって、セットと呼ばれる互いに排他的なグループに定義することができます。こうすることで、同時に有効になることのないキーワードの組み合わせ(2 つの異なる種類のライトやフォグなど)に対するバリアントの生成を回避できます。
#pragma shader_feature LIGHT_LOW_Q LIGHT_HIGH_Qプラットフォームごとのキーワードの数を減らすには、プリプロセッサーマクロを使用して、関連するプラットフォームに対してのみキーワードを定義することが可能です。以下にその例を示します。
#ifdef SHADER_API_METAL
#pragma shader_feature IOS_FOG_FEATURE
#else
#pragma shader_feature BASE_FOG_FEATURE
#endif
マクロを使ったこれらの式は、対象のビルドターゲット以外に関連するキーワードや機能に依存してはいけないことに注意してください。
特定のパスにキーワードを限定し、可能な組み合わせの数を減らすこともできます。そのためには、以下のいずれかのサフィックスをディレクティブに追加してください。
- _vertex
- _fragment
- _hull
- _domain
- _geometry
- _raytracing
例えば以下のようにします。
#pragma shader_feature_fragment FRAG_FEATURE_1 FRAG_FEATURE_2これは、使用しているレンダラーによって挙動が異なる場合があります。例えば、OpenGL、OpenGL ES、Vulkan では、サフィックスは無視されます。
特定のシェーダーのバリアントを生成する際に除外したいキーワードを定義するには、#pragma skip_variants ディレクティブを使用できます。プレイヤービルドを作成する際、これらのキーワードのいずれかを含むシェーダーのバリアントはすべてスキップされます。
また、任意で #pragma dynamic_branch ディレクティブを使ってキーワードを定義することもできます。このディレクティブにより、Unity は動的分岐に依存し、これらのキーワードのバリアントを生成しなくなります。これは結果的にバリアントの数を減らしますが、シェーダーやゲームコンテンツによっては GPU のパフォーマンスを低下させる可能性があるため、使用する場合は適切にプロファイルすることをお勧めします。
シェーダーキーワードの詳細については、Unity マニュアルの「Changing how shader works using keywords」を参照してください。
通常、シェーダーバリアントは実際にゲームをビルドするまでコンパイルされません。このオプションを使用すると、特定のビルドプラットフォームまたはグラフィックス API 用のシェーダーバリアントを確認できます。これによって、事前にエラーを確認することができます。また、生成されたコードを PVRShaderEditor などの GPU シェーダー性能分析ツールに貼り付けて、さらに最適化することもできます。

一番下には、現在開いているシーンに存在するマテリアルに基づき、スクリプタブルストリッピングが適用されていない状態で含まれるバリアントの数が表示されています。「Show」ボタンを押すと、頂点ステージのバリアントの数など、さまざまなプラットフォームで使用されたまたは削除されたキーワードに関する追加のデバッグ情報が含まれた一時ファイルが表示されます。
上部の「Preprocess only」チェックボックスを使用すると、コンパイル済みのシェーダーコードと前処理済みのシェーダーソースを切り替えて、デバッグを簡単かつ迅速に行うことができます。
使用中のパイプラインがビルトインレンダーパイプラインで、サーフェスシェーダーを扱っている場合、ビルド時に Unity が簡略化されたシェーダーソースコードを置き換えるために使用する生成コードを確認することができます。出力を変更したい場合は、任意でシェーダーのソースを生成されたコードに置き換えることができます。
詳細については、Unity マニュアルの「Check many shader variants」を参照してください。

ゲームをビルドする際、Unity は各シェーダーの機能、エンジン設定、およびその他の要因のすべての可能な組み合わせに基づいて、各シェーダーのバリアントスペースを決定します。これらの組み合わせは、複数のストリッピングパスのためにプリプロセッサーに渡されます。これを IPreprocessShaders コールバックを使用して拡張し、ビルドからより多くのバリアントを削除するためのカスタムロジックを作成することが可能です。
Always-included のシェーダーリスト(「Project Settings」 > 「Graphics」)に含まれているシェーダーは、そのすべてのバリアントがビルドに含まれます。大量のバリアントが生成される原因となり得るため、確実に必要とされる時にのみ使うのがベストです。
最後に、ビルドパイプラインは重複排除と呼ばれるプロセスを経て、同じ Pass 内の同一のバリアントを識別し、それらが同じバイトコードを指すようにします。これによりディスク上のサイズは小さくなりますが、同一のバリアントの存在はビルド時間やロード時間、実行時のメモリ使用量に悪影響を及ぼすため、正式なバリアントストリッピングの代替にはなりません。
ビルドが成功したら、Editor.log ファイルを見ることで、ビルドにどのシェーダーバリアントが含まれたかについての有益な情報を集めることができます。これを行うには、ログファイルで「Compiling shader」の文言とシェーダー名を検索します。以下がその例です。
Compiling shader "GameShaders/MyShader" pass "Pass 1" (vp)
Full variant space: 608
After settings filtering: 608
After built-in stripping: 528
After scriptable stripping: 528
Processed in 0.00 seconds
starting compilation...
finished in 0.02 seconds. Local cache hits 528 (0.16s CPU time), remote cache hits 0 (0.00s CPU time), compiled 0 variants (0.00s CPU time), skipped 0 variants
プロジェクトで XR が有効になっている場合など、状況によっては、設定のフィルタリングステップの後にバリアントの数が増加するかもしれません。
ゲームが複数のグラフィックス API をサポートしている場合、サポートされている各レンダラーの情報も表示されます。
Serialized binary data for shader GameShaders/MyShader in 0.00s
gles3 (total internal programs: 290, unique: 193)
vulkan (total internal programs: 290, unique: 193)最後に、特定のグラフィックス API におけるシェーダーの最終的なディスク上のサイズを示す、このような圧縮ログが表示されます。
Compressed shader 'GameShaders/MyShader' on vulkan from 1.35MB to 0.19MBユニバーサルレンダーパイプライン(URP)を使用している場合、SRP シェーダーからのみログを生成するか、すべてのシェーダーからログを生成するか、またはログを無効にするかを選択できます。これを行うには、「Project Settings」>「Graphics」>「URP Global Settings」から「Log Level」を選択します。

さらに、下の「Export Shader Variants」オプションを選択すると、ビルド後にシェーダーバリアントのコンパイルのレポートを含む JSON ファイルが生成されます。これは Unity 2022.2 以降のバージョンで利用可能です。
ランタイムで実際に GPU 用にコンパイルされるシェーダーを把握するために、「Project Settings」 > 「Graphics」で「Log Shader Compilation」オプションを有効にすることができます。

これにより、プレイ中にシェーダーがコンパイルされると、都度プレイヤーログに表示されるようになります。ツールチップの説明にあるように、これは開発ビルドとデバッグモードでのみ動作します。
フォーマットはこのようになります。
Compiled Shader: Folder/ShaderName, pass: PASS_NAME, stage: STAGE_NAME, keywords ACTIVE_KEYWORD_1 ACTIVE_KEYWORD_2Android など、一部のプラットフォームでは、コンパイルされたシェーダーがキャッシュされることにご注意ください。このため、すべてのコンパイル済みシェーダーを取得するためには、テストパスを実行する前にゲームのアンインストールと再インストールを行う必要があるかもしれません。
最後に、実行中のゲームのスナップショット取得するには、Memory Profiler パッケージを使用できます。これにより、現在メモリにロードされているシェーダーとそのサイズの概要を把握できます。多くの場合、サイズでソートすることで、どのシェーダーが最も多くのバリアントを生成しており、最適化する価値があるかを判断できるでしょう。

Unity はストリッピングパスの一環として、ゲームが使用していないグラフィックス機能に関連するシェーダーバリアントを削除します。このプロセスは、ビルトインレンダーパイプラインまたは URP を使用している場合、若干異なるものとなります。
これらを定義するには、「Project Settings」>「Graphics」を開きます。ビルトインレンダーパイプラインを使用している場合、ここからゲームがサポートするライトマップとフォグモードを選択できます。

「Automatic」に設定すると、ビルドに含まれるシーンに基づいて、どのバリアントを削除するかを Unity が決定します。
使用中の機能がわからない場合、「Import from Current Scene」ボタンを使用して、必要な機能を Unity に判断させることも可能です。もちろん、これはすべてのシーンが同じ設定を使用している場合にのみ有効です。このオプションを使用する場合は、必ず代表的なシーンを選択するようにしてください。
URP を使用している場合、これらのオプションの一部は非表示になります。代わりに、パイプライン設定アセットで、ゲームに必要な機能を直接定義することができます。
例えば「Terrain Holes」を無効にすると、「Terrain Holes」のすべてのシェーダーバリアントが削除され、ビルド時間が短縮されます。
URPでは、ゲームに含める機能をより細かく制御できるため、未使用のバリアントの数が少ない、より最適化されたビルドを作成できる可能性があります。
注意:これはビルトインレンダーパイプラインの使用時のみ適用されます。URP のようなスクリプタブルレンダーパイプラインを使用する場合、これらの設定は無視されます。
グラフィックスティアは、ゲームが実行されているハードウェアに基づいて異なるグラフィックス設定を適用するために使用されます(品質設定と混同しないようにご注意ください)。ゲームが開始されると、Unity はハードウェアの性能、グラフィックス API、およびその他の要因に基づいてデバイスのグラフィックスティアを決定します。
これは「Project Settings」>「Graphics」>「Tier Settings」にて設定できます。

これらに基づき、Unity は以下の 3 つのキーワードをすべてのシェーダーに追加します。
UNITY_HARDWARE_TIER1
UNITY_HARDWARE_TIER2
UNITY_HARDWARE_TIER3
その後、定義された各グラフィックスティアのシェーダーバリアントを生成します。グラフィックスティアを使用しておらず、関連するバリアントの生成を避けたい場合は、Unity がこれらのバリアントをスキップするように、すべてのグラフィックスティアをまったく同じ設定にする必要があります。
前述のとおり、Unity は同じバリアントの重複を排除しようとするため、例えば 3 つのティアのうち 2 つが同じ設定であれば、すべてのバリアントが生成されたままであっても、ディスク上のサイズは小さくなります。任意で、以下のように hardware_tier_variants を使用して、指定されたシェーダーとグラフィックスレンダラー API に対してティアバリアントを生成するように Unity に強制することができます。
// Direct3D 11/12
#pragma hardware_tier_variants d3d11 詳細については、Unity マニュアルのビルトインレンダーパイプラインのグラフィックス階層を参照してください。
Unity はビルドに含まれるそれぞれのグラフィックス API に対して 1 セットのシェーダーバリアントをコンパイルするので、状況によっては手動で API を選択し、不要なものを除外することが有益です。
これを行うには、「Project Settings」>「Player」に移動します。デフォルトでは「Auto Graphics API」が選択されており、Unity はビルトイングラフィックス API のセットを含んで、ランタイムにてデバイスの性能に応じて 1 つ選択します。例えば、Android では、Unity はまず Vulkan の使用を試み、デバイスが Vulkan をサポートしていない場合、エンジンは GLES3.2、GLES3.1、または GLES3.0 にフォールバックします(ただし、これらの GLES バージョンにおいては全く同じバリアントが使用されます)。
代わりに、該当するプラットフォームの Auto Graphics API を無効にし、手動で含める API を選択しましょう。これにより、Unity はリストの中で最初に指定されている API を優先します。

この方法には、ゲームをサポートするデバイスが限られてしまうというデメリットがあります。変更する際は、変更内容を理解し、さまざまなデバイスでテストするようにしてください。
Unity は通常、ランタイムで完全一致するバリアントが見つからない場合や、プレイヤーのビルドから削除されている場合、要求されたキーワードのセットに最も近いバリアントをロードしようとします。これは便利である一方、シェーダーキーワードの設定における潜在的な問題を隠してしまうことにもなります。
Unity 2022.3 以降のバージョンでは、「Project Settings」>「Player」から Strict Shader Variant Matching を選択することで、Unity が必要なローカルキーワードとグローバルキーワードの組み合わせに完全に一致するバリアントのみロードを試みるようになります。

見つからない場合は Error Shader が使用され、シェーダー、サブシェーダーインデックス、実際のパス、要求されたキーワードを含むエラーがコンソールに表示されます。これは、実際に必要なバリアントが見つからない場合に大変便利です。通常、ストリッピングはプレイヤーでのみ機能し、エディターには影響しません。
エディターでゲームをプレイしている間、Unity はシーンで使用されているシェーダーやバリアントを追跡し、それをコレクションにエクスポートすることができます。これを行うには、「Project Settings」>「Graphics」に移動します。一番下には「Shader Loading」のセクションがあり、現在アクティブとしてトラッキングされているシェーダーの数が表示されます。
より正確なサンプルを得るため、「Clear」を押しておきます。それから再生モードに入り、特定のシェーダーを必要とするすべてのゲーム要素が表示されていることを確認しながら、シーンに取り組んでください。これにより、トラッキングされたカウンターが増加します。その後「Save to asset...」ボタンを押し、それらをすべてコレクションアセットに保存します。
詳細については、『Unity Manual』の「Create a shader variant collection」を参照してください。

シェーダーバリアントコレクションは、シェーダーと関連するバリアントのリストを含むアセットです。一般的には、ビルドに含めるバリアントを事前に定義したり、シェーダーの事前準備を行ったりするのに使われます。

プロジェクトによって使用されるアプローチのひとつとして、ゲームのすべてのレベルに対してこれを実行し、それぞれのコレクションを保存し、IPreprocessShaders スクリプト(次のセクションで説明します)を使用してそれらのリストのいずれにも存在しないすべてのバリアントを取り除くことが挙げられます。これは便利である一方、経験上、エラーもそれなりに発生します。一度のプレイスルーで必要なすべてのバリアントを確実に発見するのは難しいですし、一部の機能はデバイス上でのみ、または特定の場合にのみロードされる可能性があり、その結果、リストの正確性が保証できなくなります。また、ゲームが変化し、レベルに新しい要素が追加されたりマテリアルが変わったりした場合、コレクションを更新する必要があります。このため、ビルドパイプラインに直接組み込むのではなく、主にデバッグや調査の目的で使用するのがベストでしょう。
詳細については、『Unity Manual』の「Create a shader variant collection」を参照してください。
シェーダーがゲームビルドにコンパイルされる直前に、Unity はコールバックのディスパッチを行います。これはプレイヤーとアセットバンドルの両方のビルドで発生します。IPreprocessShaders.OnProcessShader と IPreprocessComputeShaders.OnProcessComputeShader (コンピュートシェーダー用)を使ってこれらをリッスンし、カスタムロジックを追加して不要なバリアントを取り除くのが便利です。こうすることで、ビルド時間、ビルドサイズ、そしてビルドに含まれるバリアントの総数を大幅に削減できます。
そのためには、IPreprocessShaders インターフェースを実装するスクリプトを作成し、OnProcessShader 内にストリッピングロジックを書きます。例えば、以下のスクリプトはリリースビルドで DEBUG シェーダーキーワードを含むすべてのバリアントをストリップするものになります。
public class StripDebugVariantsPreprocessor : IPreprocessShaders
{
public int callbackOrder => 0;
ShaderKeyword keywordToStrip;
public StripDebugVariantsPreprocessor()
{
keywordToStrip = new ShaderKeyword("DEBUG");
}
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data)
{
if (EditorUserBuildSettings.development)
{
return;
}
for (int i = data.Count - 1; i >= 0; i--)
{
if (data[i].shaderKeywordSet.IsEnabled(keywordToStrip))
{
data.RemoveAt(i);
}
}
}
}コールバックオーダーによって、どの前処理スクリプトを最初に実行するかを定義できるため、多段階のストリッピングパスを作成することが可能になります。優先度の低いスクリプトから順に実行されます。
さらに知りたい方は、Graphics-Shaders フォーラムのディスカッションをご覧ください。
詳細については、Unity マニュアルの以下のセクションを参照してください。
