見切り発車

とりあえずかきとめたい

Direct3D 12 でのシェーダーの動的リンク

解決したこと

Direct3D 12 のシェーダーで関連要素の組み合わせ(法線マップの有無、PBR パラメータの有無など) ごとのシェーダーを生成する方法としてコンパイル済みシェーダーをアプリケーション実行時にリンクする方法について調べました。

動機

いろいろな入力データに対応する場合にコンパイル済みシェーダーバイナリの数が爆発的に増えてしまう問題への対策として動的リンクが使えるのではないかと考えました。

たとえばピクセルシェーダーで法線を扱う場合、頂点ごとの法線から計算する方法と、法線マップから取得する方法があります。また物理ベースレンダリングのラフネスやメタルネス(メタリック) などのパラメータを、テクスチャがあればテクスチャから、なければ定数バッファから取得するといった場合分けも考えられます。

いろいろな入力データに対応したい場合、その方法もいくつかあります。

通常のプログラムであれば要素ごとにif 文などで分岐します。hlsl でも条件分岐は可能ですが通常は使用しません。シェーダープログラムでの条件分岐はパフォーマンスが悪く、また分岐を判定するための値を定数バッファなどで渡す手間もあります。

よく使用されているのは、要素ごとの入力をプリプロセッサの#if ~ #else ~ #endif 命令によって選択するなどして入力方法の組み合わせごとのシェーダーバイナリをコンパイルして生成する方法です。ドローコールの時に入力データに合わせたシェーダーを選択して実行するためシェーダープログラムでは条件分岐が不要となります。

この方法の問題として、シェーダーで扱う要素の組み合わせが指数オーダーで増えてしまいます。例えば分岐が10 ある場合の組み合わせは2 ^ 10 = 1024 通りです。シェーダープログラムを少し修正すると組み合わせ分のコンパイルを待たなければいけなかったり、多くのシェーダーバイナリファイルを管理しなければならなくなります。

動的リンクを導入することで、組み合わせる処理ごとに最低限のシェーダーバイナリファイルを用意し、実際に必要となった組み合わせのシェーダーバイナリだけを実行時に生成することでコンパイルの待ち時間とファイル数が抑えられると期待できます。

動的リンクについて

この記事では動的リンクという言葉を使用していますが、厳密な使い方ではありません。事前にシェーダーコードをコンパイルしておき、実行時にリンクして実行可能なシェーダーバイナリにすることを動的リンクと呼んでいます。

マイクロソフトのhlsl のドキュメントを見ると動的リンクを行う方法が二つ見つかります。一つはシェーダーリンク、もう一つはダイナミックリンクです。

シェーダーリンクはシェーダーをコンパイルして関数のライブラリを作成し、実行時に関数を組み合わせて実行可能なシェーダーとする方法です。

ダイナミックリンクはC++ のクラスの仮想関数のような仕組みで、呼び出される関数を動的に切り替える方法です。

それぞれの使用方法についてはリンク先のマイクロソフトのドキュメントを順番に読み進めていくのをおすすめし、ここでは細かい説明は省きます。

ダイナミックリンク

まず、ダイナミックリンクについて触れます。

// 法線を得る方法を提供するインターフェースを定義
interface iNormal
{
    float3 GetNormal(float3 normal, float2 uv);
};
// 頂点からの法線を選択するクラス
class NormalFromVertex : iNormal
{
    float3 GetNormal(float3 normal, float2 uv)
    {
        return normal;
    }
};
// 法線マップからの法線を選択するクラス
class NormalFromTexture : iNormal
{
    float3 GetNormal(float3 normal, float2 uv)
    {
        return g_normalMap.Sample(g_sampler, uv).xyz;
    }
}
// インターフェースインスタンスを宣言
iNormal g_abstractNormal;
// クラスインスタンスの宣言
cbuffer instances : register(b0)
{
    NormalFromVertex g_normalFromVertex;
    NormalFromTexture g_normalFromTexture;
};

// ピクセルシェーダー
float4 PSMain(PS_INPUT input) : SV_TARGET
{
    // コード省略

    // g_abstractNormal にはg_normalFromVertex かg_normalFromTexture を
    // C++ コード側で割り当てる
    float3 normal = g_abstractNormal.GetNormal(input.normal, input.uv);

    // コード省略
}

ピクセルシェーダーのPSMain() のコードはほぼ省略していますが、機能を切り替えたい個所だけを差し替える形となっています。この仕組みはダイナミックリンクを使用しない通常の場合の延長線上にあり分かりやすいのではないかと思います。

しかしDirect3D 12 での使用方法で解決できない点がありました。

ダイナミックリンクではインターフェースインスタンスにクラスインスタンスを割り当てますが、そのためにID3D11ClassLinkage, ID3D11ClassInstance を使用します。C++ コード上で、まずID3D11ClassLinkage のインスタンスを作成しID3D11Device::CreatePixelShader() の引数として渡しておきます。それからID3D11ClassInstance インスタンスの配列を用意して実際に使用するクラスインスタンスを指定し、ID3D11DeviceContext::PSSetShader() にシェーダーとともに渡します(それぞれピクセルシェーダーの場合)。

Direct3D 12 ではパイプラインステートオブジェクトの作成時にコンパイル済みのシェーダーバイナリを渡す形で、ID3D11ClassLinkage, ID3D11ClassInstance をセットする方法が不明でした。

シェーダーリンク

前置きが長くなりましたが、今回採用したシェーダーリンクについて説明します。

と言っても、使用方法についてはシェーダーリンクの使用 のページに順を追って説明されており、そちらを読むだけで分かる人には分かるのではないかと思います。ここではそちらのページに明記されていない細かなポイントについて解説します。

シェーダーリンクの考え方

シェーダーリンクの中心となる仕組みとして、関数リンクグラフ(Function Linking Graph, FLG) があります。関数リンクグラフは、シェーダーライブラリに含まれる関数の引数と戻り値についてそれぞれどこから受け取ってどこに渡すのかを指定するものです。

個人的に関数リンクグラフの理解に時間がかかったので、それについて説明します。

動的リンクを用いて処理を切り替えるというのを、最初はC++ の仮想関数やダイナミックリンクのイメージでとらえていました。

// 法線の取得方法を切り替える例
floa4 PSMain(PS_INPUT input) : SV_TARGET
{
    // コード省略

    float3 normal = /* ここで呼び出す関数を差し替える */;

    // コード省略
}

一方で、関数リンクグラフで出来ることは関数の戻り値または入力ノードを、別の関数の引数もしくは出力ノードに渡すことだけです。PSMain 関数の中から法線を取得する関数にuv を渡したり戻り値を関数内の変数に代入する方法が不明でした。

関数リンクグラフを使用する場合には考え方を変える必要があり、ノードベースのシェーダーをイメージします。以下はライティング計算で使用する法線を頂点から取得する場合と法線マップから取得する場合の関数リンクグラフの図です。

頂点の法線を使用する場合 頂点の法線を使用

法線マップを使用する場合 法線マップを使用

図の青い囲みは入力ノード・出力ノードで、赤い囲みはシェーダーライブラリに含まれる関数を表しています。ライティング計算関数内で必要に応じて関数を呼び出して値を取得するのではなく、先に関数を呼び出して値を取得しておいてライティング計算関数の引数として渡す形です。

このように、シェーダーリンクでは動的に切り替えたい要素は関数の引数・戻り値として扱うような構成にします。

シェーダーライブラリのhlsl

シェーダーリンクで使用するhlsl で、通常とは違う点について説明します。

まず、シェーダーリンクで使用する関数にはexport キーワードをつけておきます。

export float3 GetNormalFromTexture(float2 uv)
{
    return g_texture.Sample(g_sampler, uv).xyz;
}

また、エントリーポイントとなる関数は必要ありません。関数リンクグラフを構築するときに入力シグネチャ、出力シグネチャを直接指定します。

シェーダーライブラリのコンパイル

hlsl をシェーダーライブラリとしてコンパイルする方法はシェーダーライブラリのパッケージ化 のページに記載があり、サンプルコードではD3DCompile の第7 引数・pTarget が"lib" + m_shaderModelSuffix となっていて、m_shaderModelSuffix の内容が不明です。ライブラリとしてコンパイルする場合に指定するシェーダーモデルはlib_5_0, lib_6_0 のようになり、頂点シェーダーのvs_5_0, ピクセルシェーダーのps_5_0 などと同様の形式となります。

シェーダーリンクのC++ 側コード例

/**
 * @brief シェーダーライブラリからピクセルシェーダーをリンクして返す関数
 * @param[in] module D3DLoadModule() でロードされたライブラリモジュール
 * @param[in] moduleInstance module->CreateInstance() で作成し、BindResource(), BindSampler(), BindConstantBuffer() でリソースを割り当てたライブラリのインスタンス
 * @param[in] useNormalMap true:法線無し、法線マップあり false:法線あり、法線マップ無し
 * @return リンクされたシェーダーバイナリ
 */
ID3DBlog * CreatePixelShader(ID3D11Module * module, ID3D11ModuleInstance * moduleInstance, bool useNormalMap)
{
    // 関数リンクグラフを作成
    ComPtr<ID3D11FunctionLinkingGraph> flg;
    D3DCreateFunctionLinkingGraph(0, &flg);
    // 入力ノードを作成
    ComPtr<ID3D11LinkingNode> inputNode;
    // 法線マップを使用する場合は頂点に法線が含まれない想定
    if (useNormalMap) {
        D3D11_PARAMETER_DESC paramDescs[2] {
            { "inPos",  "SV_POSITION", D3D_SVT_FLOAT, D3D_SVC_VECTOR, 1, 4, D3D_INTERPOLATION_UNDEFINED, D3D_PF_IN, 0, 0, 0, 0 },
            { "inUv",   "TEXCOORD",    D3D_SVT_FLOAT, D3D_SVC_VECTOR, 1, 2, D3D_INTERPOLATION_UNDEFINED, D3D_PF_IN, 0, 0, 0, 0 },
        };
        flg->SetInputSignature(paramDescs, 2, &inputNode);
    }
    else {
        D3D11_PARAMETER_DESC paramDescs[2] {
            { "inPos",    "SV_POSITION", D3D_SVT_FLOAT, D3D_SVC_VECTOR, 1, 4, D3D_INTERPOLATION_UNDEFINED, D3D_PF_IN, 0, 0, 0, 0 },
            { "inNormal", "NORMAL",      D3D_SVT_FLOAT, D3D_SVC_VECTOR, 1, 3, D3D_INTERPOLATION_UNDEFINED, D3D_PF_IN, 0, 0, 0, 0 },
            { "inUv",     "TEXCOORD",    D3D_SVT_FLOAT, D3D_SVC_VECTOR, 1, 2, D3D_INTERPOLATION_UNDEFINED, D3D_PF_IN, 0, 0, 0, 0 },
        };
        flg->SetInputSignature(paramDescs, 3, &inputNode);
    }
    // 法線マップから法線を取得する関数ノード
    ComPtr<ID3D11LinkingNode> normalFunc;
    if (useNormalMap) {
        // 法線マップがある場合のみ、関数のノードを作成
        flg->CallFunction("", module, "GetNormalFromTexture", &normalFunc);
        // 入力ノードの[1] をGetNormalFromTexture() の引数に渡す
        flg->PassValue(inputNode.Get(), 1, normalFunc.Get(), 0);
    }
    // ライティング関数ノード
    ComPtr<ID3D11LinkingNode> lightingFunc;
    // ライティング関数のシグネチャは
    // float4 PSMain(float4 pos, float3 normal, float2 uv) : SV_TARGET
    flg->CallFunction("", module, "LightingMain", &lightingFunc);
    if (useNormalMap) {
        // 法線マップがある場合はPSMain() の引数normal に法線マップ関数の戻り値を渡す
        flg->PassValue(inputNode.Get(), 0, lightingFunc.Get(), 0);
        flg->PassValue(normalFunc.Get(), D3D_RETURN_PARAMETER_INDEX, lightingFunc.Get(), 1);
        flg->PassValue(inputNode.Get(), 1, lightingFunc.Get(), 2);
    }
    else {
        // 法線マップがない場合は入力ノードをそのままPSMain() の引数に渡す
        flg->PassValue(inputNode.Get(), 0, lightingFunc.Get(), 0);
        flg->PassValue(inputNode.Get(), 1, lightingFunc.Get(), 1);
        flg->PassValue(inputNode.Get(), 2, lightingFunc.Get(), 2);
    }
    // 出力ノードを作成
    ComPtr<ID3D11LinkingNode> outputNode;
    D3D11_PARAMETER_DESC outParamDescs[1] {
        { "outTarget", "SV_TARGET", D3D_SVT_FLOAT, D3D_SVC_VECTOR, 1, 4, D3D_INTERPOLATION_UNDEFINED, D3D_PF_OUT, 0, 0, 0, 0 },
    };
    flg->SetOutputSignature(outParamDescs, 1, &outputNode);
    // ライティング関数の戻り値を出力ノードに渡す
    flg->PassValue(lightingFunc.Get(), D3D_RETURN_PARAMETER_INDEX, outputNode.Get(), 0);
    // シェーダーをリンク
    ComPtr<ID3D11Linker> linker;
    D3DCreateLinker(&linker);
    linker->UseLibrary(moduleInstance);

    ComPtr<ID3D11ModuleInstance> flgInstance;
    flg->CreateModuleInstance(&flgInstance, nullptr);
    ID3DBlob * shaderBlob;
    linker->Link(flgInstance.Get(), "PSMain", "ps_5_0", 0, &shaderBlob, nullptr);
    return shaderBlob;
}

上記コードの戻り値となっているシェーダーバイナリ(ID3DBlob インスタンス) は、パイプラインステートオブジェクトを初期化するときの設定構造体D3D12_GRAPHICS_PIPELINE_STATE_DESC のPS に設定して使用します。

注意点として、ID3D11FunctionLinkingGraph のCallFunction() は、シェーダー内での実行順に呼び出す必要があります。実行順はある関数の戻り値を別の関数の引数として渡す場合に、戻り値を提供する側の関数が先になります。呼び出し順に問題がある場合PassValue() が失敗します。ID3D11FunctionLinkingGraph::ID3D11FunctionLinkingGraph() でエラーメッセージを取得すると、error X9010: ID3D11FunctionLinkingGraph::PassValueWithSwizzle: source node must preceed destination node in FLG となっています。

複数の戻り値を扱いたい場合

関数から複数の要素を返したい場合にはreturn による戻り値ではなく、関数の引数にキーワードout をつけます。

void TransformPosition(in float4 inPos, out float4 outPos, out float4 outWorldPos)
{
    outWorldPos = mul(inPos, g_modelMtx);
    outPos = mul(outWorldPos, g_viewProjMtx);
}

関数リンクグラフのPassValue() では入力側ノードのパラメータ番号としてD3D_RETURN_PARAMETER_INDEX ではなく引数のインデックスを渡します。

flg->PassValue(posFunc.Get(), 1, outputNode.Get(), 0);
flg->PassValue(posFunc.Get(), 2, outputNode.Get(), 1);

hlsl の関数の引数についてはこちらのページ で説明されています。

最後に

Direct3D 12 でのシェーダーリンクによるプログラム実行時のシェーダーの動的なリンクの説明は以上です。

いろいろとググったり試行錯誤した結果のプログラムは公式のシェーダーリンクの説明 を順番に読み進めた通りになったのでやっぱり公式ドキュメント大事だなというところですが、関数のノードを接続していく使い方は気付かないと詰まってしまうポイントではないかと思います。

同じところで詰まっている人の役に立てば幸いです。