Shaderバリアントコレクションとパッケージコンパイルの最適化のアイデア


一、バリアントとは何ですか

Unityの公式ドキュメントからの説明:ShaderVariant

In Unity, many shaders internally have multiple “variants”, to account for different light modes, lightmaps, shadows and so on. These variants are indentified by a shader pass type, and a set of shader keywords.

UnityのShaderアセットには、GPUで実行されたシェーダーコードだけではなく、レンダリングのステート、属性の定義、異なるレンダリングパイプラインやステージに対応するシェーダーコードも含まれています。異なるレンダリング機能に対応するために、それそれ小さなコードスニペットに異なるコンパイルパラメータがあるかもしれません。

複数のバリアントがあるShaderコードスニペットに、一番顕著な特徴は、プリコンパイルスイッチがあることです。例えば:


#pragma multi_compile_fwdbase // unity 内蔵順方向パイプラインコンパイル設定コレクション、イルミネーション、シャドウを制御するなど多くの関連機能が備えています
#pragma shader_feature _USE_FEATURE_A // カスタム機能スイッチ
#pragma multi_compile _USE_FUNCTION_A _USE_FUNCTION_B // カスタムマルチコンパイル選択肢

これらのコンパイルスイッチマークがあれば、少ないShaderコードを書くのが可能です。従ってスケルトンコードにアタッチして、少し機能が違いバリアントShaderコードを実現します。当然ながら、機能が多いほど、バリアントの数量も指数関数的に増えていきます。如何にこれらのバリアントによって生み出された可能性がある数量を。コントロールするかは、かなり豊富な経験とスキルが必要でしょう。

二、なぜバリアントをコレクションしますか

ゲームを初期化する時、事前にレンダリング際に必要なShaderをすべてローディングする必要があります。それによって、ゲームが実行している時にタイムリーなローディングやコンパイルで引き起こされたフリーズを軽減できます。この時にShader.WarmupAllShadersをコールし、すでにメモリにローディングされたShaderの全部を一回にコンパイルして、すべてのバリアントが含まれます。

プロジェクトにおけるレンダリング效果が丰富になると、Shaderバリアントも多くになります。乱暴にすべてのローディングインターフェイスをコールすると、ゲームの起動時間が長くなり、ゲームへの体験に影響を及ぼします。

その後、Unityは上の乱暴なコール方法に代わって、バリアントコレクションShaderVariantCollectionに入って、必要に応じてローディングし、速度を高めます。

公式の説明には一番肝心なのは以下の内容です:

This is used for shader preloading (“warmup”), so that a game can make sure “actually required” shader variants are loaded at startup (or level load time), to avoid shader compilation related hiccups later on in the game.

つまり、バリアント記録するのはゲームで実際に使えられたバリアントコレクションです。それでは、必要に応じてローディングすると、ゲームのローディング速度を大いに高めます。

三、ほかの理解

公式ドキュメントから、バリアントコレクションはShaderをプリローディングするために用いられることがわかりますが、パッケージや公開する過程におけるコンパイルと、どのように実際に使用するコンパイルを選別してAssetBundleに入れるかは、一切言及されません。

実際に公開されたゲームパッケージにおいて、Shaderアセットにある必要なバリアントが欠けたら、得られたレンダリングの結果は正しくなくなります(Unityは複数のバリアントをローディングする時に、あるマッチ方法を使用するはずです。最適なマッチを探せなければ、他のkeywordマッチ数量が最も多いバリアントにfallbackします。従って、部分的に違いがあるまたは完全に間違った結果をレンダリングしまいました)。さらに怒りを覚えるのは、実際の実行デバイスには、バリアント欠如のな情報は見えない。一旦間違いになると、すべてをもう一度やり直すしかありません。

一般的に、Shaderバリアントの損失はAssetBundleの分別パッケージにより引き起こされました。Unityの内部コレクションに実際の使用バリアントは、Shaderのマテリアル、及びシーンレンダラーのイルミネーションパラメータをスキャンすることで総合的に取得します。(内部レンダリングパイプラインにおける実際に使用されたShaderバリアントを記録します)。AssetBundleの更新を実現するために、Shaderを単独なアセットとして独立的なAssetBundleに置きます。これらのShaderを引用するマテリアルとシーンはAssetBundleの依存としてローディングします。一旦Shaderがキャリアを離れたら、Unityはパッケージする時にどのようなバリアントは実際に公開するかを全般的に考慮できず、バリアント損失现象がランダムに現れます。

ネット上のバリアント損失を解決する方法:

(1)Shaderとそれを使用するマテリアルを同じAssetBundleにパッケージします;
(2)エディターでプロジェクトシーンを丸ごとに実行すると、Unityは収集されたすべてのShaderとバリアントを記録し、この情報をバリアントのコレクションとして保存して、Shaderと一緒にパッケージ化します。

Unityは、Project Settingsパネルにおける一番下のところに、この最も重要な機能を非表示にします。


上記の方法はほとんどの場合正常に機能しますが:

方法(1)、マテリアルだけで全部のバリアント使用記録を取得できず、実際のレンダラーや所属シーンの全局イルミネーションにもかかわっています。

方法(2)、人工コレクションすは常に見逃した部分がある上に、Unityによって保存されたShaderバリアントはすべて一緒になっていたので、分割しないと、分別パッケージ策略にやや影響を及ぼします。

四、私の解决办法

1、プロジェクトにおいて、レンダリングに用いられるアセットは一般的に三種類しかありません

(1)シーン
(2)動的ローディングのモデル、キャラクター、特殊効果など
(3)UI & UIEffect

その中で一般的に、UIは直接的にUGUI内蔵のシェーダーを利用します。バリアントはmulti_compileによって提供されます。このようなコンパイルスイッチはマテリアルに用いられるかどうかにもかかわらず、バリアントはコンパイルされて公開された実機パッケージに入ってきます。UIに用いられるShaderは多くないので、簡単的に処理します。

したがって、最終的には、Shaderの2つの使用実態を考えるだけで十分です:シーンにおける動的ローディングとシーンの静的アセットです。

2、自動Shaderバリアントコレクターを実現するには、次の手順に従います:

(1)カレントパッケージする必要があるアセットのロードを収集します(プロジェクトに沿って設定を公開します。例えば:多言語、チャネル);
(2)依存関係を通して、動的ローディングされたPrefabのようなアセットの依存したマテリアルを収集します;
(3)新しい空のシーンを開いて、ゲームのシーンにおける動的イルミネーション環境を作ります。例えば:リアルタイム平行光;
(4)ShaderUtil.ClearCurrentShaderVariantCollectionを反射コールします。現在のプロジェクトで収集したバリアントをクリアしてから、もう一度収集します;
(5)シーンにおいて、レンダリングに用いられるカメラを作ります;
(6)シーンにおいて、若干のsphereジオメトリを作ってきちんと並列してから、全部見られるようにレンダリングカメラをsphereジオメトリに位置合わせします;
(7)これらのマテリアルアセットをsphereジオメトリに分割して与えます。それに一フレームをレンダリングします;
(8)レンダリングが済んだ後、順にシーンを開いて、パノラミックのカメラアングルを設定してからレンダリングします;
(9)このように、基本的にプロジェクトにおけるShaderバリアントはすでに収集し終わります。ShaderUtil.SaveCurrentShaderVariantCollectionを反射コールして、全体バリアントコレクションアセットの中に保存します;
(10)自動コレクションツールのタスクが完了しました。

3、このバリアントコレクションがあれば、すべてオーケーしましたか?

いいえ、タスクは半分しか完了されていません。カスタムShader、特にUsePassだけを通して引用されたShaderは、いかなるマチエールアセットにも現れないから、Unityでそれらのバリアントを収集できません(この点は確信します。実際のプロジェクトには、いくつかのマルチPassのShaderを使用しました。これらのPassはUsePassパッケージ(アーティストに公開していない内部Shaderも含めます)によって提供されています。アーティストは直接的に使用したShaderには実のコードはありません。これによって、コードの量を増すことなく、もっと機動性的により多くのマルチPassのShaderを組み合せるメリットがあります)。

一つの例をあげましょう、三つのShaderがあります:

1、ABC.shader

2、InternalA.shader

3、InternalB.shader

ABCはInternalA、InternalBの内部におけるpassを使用し、ABCには実際のコードスニペットはありません。この状況では、Unityで収集したバリアントコレクションはABCに属しますが、InternalA、InternalBを区別していません。このUnityのエクスポート結果を直接的に取得すると、バリアントの損失を引き起こす可能性が高いです。

Unityによってエクスポートされたバリアントコレクションを一つずつ分散アセットに分割する必要があります。このようにして、二つのメリットがあります。一つは関連づけられたShaderバリアントコレクションを作成できます。もう一つのメリットはパッケージ粒度をたやすく分割できます。

4、続きます

続く前、いつかの準備をしましょう:
(1)ShaderUtil.OpenShaderCombinations(shader, usedBySceneOnly = true)の反射を通して、一つのUnityによってエクスポートされたLibrary/ParsedCombinations-xxx.shaderファイルを開けます。テキスト解析を通して、すべての効果的な三種類のkeyword: builtin、shader_features、multi_compiles及びコードsnippetsマークが得られます;
(2)反射の方法でShaderVariantCollectionにおける毎Shaderのバリアントコレクションを読み込みます
(3)後でこの情報を繰り返し取得できるように、キャッシュ作業を行ってください。

次のロジックをより明確に述べられるために、擬似コードで表します:


// 総コレクションを分割し始め、すべてのshaderに独立バリアントコレクションを作成します。
ShaderVariantCollection unityVAC; // unityによってエクスポートされた総コレクションン

foreach ( curSVC in unityVAC ) {

    // コレクションにおける現在のある子コレクションshader
    var cur_shader = curSVC.shader;
    // 現在のshaderにおけるすべてのバリアント
    var cur_shaderVariants = curSVC.variants;

    // 現在のために新しい独立バリアントコレクションを作成します
    var va = new ShaderVariantCollection();

    // これらのバリアントを新しいバリアントコレクションにコピーします
    foreach ( cur_v in cur_shaderVariants ) {
        try {
            var realSV = new ShaderVariantCollection.ShaderVariant( cur_v.shader, cur_v.passType, cur_v.keywords );
            va.Add( realSV );
        } catch ( ... ) {
            // このバリアントは現在に作成したshaderの指定pass類型に属しません
            // ここになって、unityによって収集されたバリアントは依存項に属します
        }
    }
    Save( va );

    // 依存関係を取得します。UsePass, Fallbackを通して入りました
    // それぞれ子shaderにバリアントコレクションを作成または更新します
    var child_shaders = GetDependencies( GetAssetPath( cur_shader ) );
    foreach ( child_shader in child_shaders ) {
        // 依存shaderは複数の異なるshaderによって何回も依存される可能性があるため、ここでキャッシュに注意してください
        var child_va = TryGet_New_ShaderVariantCollection( child_shader );
        //順にバリアントをテストに着信する同時にchild_shaderを依存しますか
        foreach ( cur_v in cur_shaderVariants ) {

            var _keywords = copy( cur_v.keywords );

            // このバリアントにおいてchild_shaderに属しないキーワードがあるかもしれません
            // この前に提供された解析ParsedCombinationsファイルを通して、それらを取り除きます
            RemoveInvalidKeyword( _keywords, child_shader );

            try {
                var realSV = new ShaderVariantCollection.ShaderVariant( child_shader, cur_v.passType, _keywords );
                // 重複排除に注意します
                if ( !child_va.Contains( realSV ) ) {
                    child_va.Add( realSV );
                }
            } catch ( ... ) {
                // ...
            }
        }
    }

    // 依存されたすべてのshaderバリアントコレクションを保存します
    // ...

    // ここにはいくつかの問題があります:
    // 1.バリアントの従属passは完全に取得できません、
    //(passNameもありません、passIndexもありません)
    // したがって、に依存項のためにバリアントを作成できません。上記のコードにはバリアントが適法である限り、使用したとします
    // 2.一つのshaderは直接的にマチエールに利用される、Unityにバリアントを収集するだけではなく
    // 他のShaderにも引用されます。それでは引用された部分のバリアントunityで収集できますか、
   // 更なテスト検証が必要となります
}

このように、上記の一連の操作の後、最も完全なブライアントの使用の記録を作成できると思っています。次の段階を始めましょう。

5、コンパイル時間のパフォーマンス

バリアントコレクションがあってからも、Shaderをパッケージ化する時に相変わらず長時間のコンパイルを行ったことに気づきました。たとえmulti_compile数量の見積もりを加えても、バリアントコレクションの宣言の数量を大いに超えていました。そのため、公式ドキュメントにおけるバリアントコレクションに対する説明から、バリアントコレクションはプリローディングする場合とShaderバリアントの使用の子コレクションを指定する場合のみ使用できると推測します。コンパイルに関しては、別のアセット処理階段であるため、自分で選別して取り除く必要があります。

Unity 2018.2には、プログラム可能なShaderバリアントを除去するパイプが導入されました:IPreprocessShaders.OnProcessShader、このインターフェースがあれば、UnityがShaderをコンパイルする際にコールバックのお知られを受け取れました。それに自分のShaderバリアント削除ロジックを実現できますし、コンパイル時間をさらに短縮できます。

プロジェクトには、多数のIPreprocessShadersインターフェース对象を実現させます。UnityはShaderをコンパイルしている時、これらのプロセッサ実例を自動的に作成し、コールバックインターフェースを実行します。このコールバックで、着信したパラメータを排除する必要があります。

例:


/// 簡単的にUnity内蔵バリアントコンパイルを排除するプロセッサ
class BuiltinShaderPreprocessor : IPreprocessShaders {
    static ShaderKeyword[] s_uselessKeywords;
    public int callbackOrder {
        get { return 0; } // 複数のプロセッサ間のコールバックの順序を指定できます
    }
    static BuiltinShaderPreprocessor() {
        s_uselessKeywords = new ShaderKeyword[] {
            new ShaderKeyword( "DIRLIGHTMAP_COMBINED" ),
            new ShaderKeyword( "LIGHTMAP_SHADOW_MIXING" ),
            new ShaderKeyword( "SHADOWS_SCREEN" ),
        };
    }
    public void OnProcessShader( Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data ) {
        for ( int i = data.Count - 1; i >= 0; --i ) {
            for ( int j = 0; j < s_uselessKeywords.Length; ++j ) {
                if ( data[ i ].shaderKeywordSet.IsEnabled( s_uselessKeywords[ j ] ) ) {
                    data.RemoveAt( i );
                    break;
                }
            }
        }
    }
}

より詳細で正確なコンパイル排除ロジック(不完全なコードスニペット)を作成する必要があります。


class ShaderPreprocessor : IPreprocessShaders {
    public void OnProcessShader( Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data ) {
        // 処理システムshaderをスキップし、処理しない
        // return;

        // 対応するshaderのバリアントコレクションを読み取ります:
        // 前のステップでは、使用するshaderごとに独立のコンパイルコレクションを作成しました
        // 指定されたshaderのコンパイル情報を取得します

        var comb = ShaderUtils.ParseShaderCombinations( shader, true );
        //他のshaderを完全にUseし、独自のコードを含まない一部のshaderをスキップし、それらを処理しない
        // return;

        //リバーストラバーサル、操作の削除に役立ちます
        for ( int i = data.Count - 1; i >= 0; --i ) {
            // 現在のコンパイルユニットのバリアントキーワードのリスト
            var _keywords = data[ i ].shaderKeywordSet.GetShaderKeywords();
            //キーワードのあるケースのみカリングして、コードの複雑さを軽減します
            //実際、キーワードのないバリアントも破棄される可能性があります。このカリング操作を破棄するだけでは、コンパイルの負担はそれほど大きくなりません。
            if ( _keywords.Length > 0 ) {
                var keywordList = new HashSet<String>();
                for ( int j = 0; j < _keywords.Length; ++j ) {
                    var name = _keywords[ j ].GetKeywordName();
                    fullKeywords.Add( name );
                    if ( snippetCombinations.multi_compiles != null ) {
                        if ( Array.IndexOf( snippetCombinations.multi_compiles, name ) < 0 ) {
                            // multi_compilesコンパイルマクロを排除します、これらは使用する必要がありますから、カリングすることができない
                            // ここではmulti_compileのキーワードを含まないことのみを追加します
                            keywordList.Add( name );
                        }
                    }
                }
                if ( keywordList.Count > 0 ) {
                    // このバリアントのキーワードはカリングやコンパイルできることを説明しています
                    // 更なる判定をします:
                    // このキーワードシーケンスによって形成されたバリアントは、事前に保存したバリアントコレクションアセットに現れましたか

                    //すでに使用されたバリアントコレクションをトラバースして判別するときは、multi_compile項を含む
                    // キーワードを削除して、ランダム比較に完全にマッチできると、現在のサブコンパイルの
                    // shaderバリアントが使用されるを説明します。そうではないと、カリングします
                    // ...

                    var matched = false;
                    //プロジェクトから収集されたすべてのバリアントをトラバースします
                    for ( int n = 0; n < rawVariants.Count; ++n ) {
                        var variant = rawVariants[ n ];
                        var matchCount = -1;
                        var mismatchCount = 0;
                        var skipCount = 0;
                        if ( variant.shader == shader && variant.passType == snippet.passType ) {
                            matchCount = 0;

                            // 説明する必要があります:
                             //マッチしたバリアントを探すときに、multi_compilesキーワードを排除する必要があります
                             // snippetCombinationsデータは、ParsedCombinations-XXX.shaderの手動解析から取得されます
                             // ShaderUtil.GetShaderVariantEntriesを直接的にコールすると、すべてのバリアントの数が多すぎるため、メモリが爆発する可能性があります

                            for ( var m = 0; m < variant.keywords.Length; ++m ) {
                                var keyword = variant.keywords[ m ];
                                if ( Array.IndexOf( snippetCombinations.multi_compiles, keyword ) < 0 ) {
                                    if ( keywordList.Contains( keyword ) ) {
                                        ++matchCount;
                                    } else {
                                        ++mismatchCount;
                                        break;
                                    }
                                } else {
                                    ++skipCount;
                                }
                            }
                        }
                        if ( matchCount >= 0 && mismatchCount == 0 && matchCount + skipCount == keywordList.Count ) {
                            matched = true;
                            break;
                        }
                    }
                    if ( !matched ) {
                        data.RemoveAt( i );
                    }
                }
            }
        }
    }
}

6、おわりに

上記の一連の操作を経て、Shaderバリアントコレクションのプロセスとコンパイル時間が最適化されました。ただし、このプロセスでは、Unityで一般的に使用されない多くのエディターAPIが使用されました。プロセスの一部で取得された情報が不完全なため、最終的な結果に検出できないエラーが発生する可能性があります。この方法もさらに研究して改善する必要があります。


UWA Technologyは、モバイル/VRなど様々なゲーム開発者向け、パフォーマンス分析最適化ソリューション及びコンサルティングサービスを提供している会社でございます。

今なら、UWA GOTローカルツールが15日間に無償試用できます!!
よければ、ぜひ!

UWA公式サイト:https://jp.uwa4d.com
UWA GOT OnlineレポートDemo:https://jp.uwa4d.com/u/got/demo.html
UWA公式ブログ:https://blog.jp.uwa4d.com