【ゲーム制作】DirectX11の定数バッファの値がシェーダーでずれる原因とは?|16バイト境界(パッキング)をC++とHLSLで実測して比べる

【ゲーム制作】DirectX11の定数バッファの値がシェーダーでずれる原因とは?|16バイト境界(パッキング)をC++とHLSLで実測して比べる ゲーム制作

C++で渡した色が、シェーダーの中では、別の色になっています。配列の1つ目だけが合っていて、2つ目以降は全部0。CreateBufferは、理由も言わずに失敗します。

原因は、HLSL側が決める16バイトの境界のルールと、C++の構造体の並び方が、食い違っていることです。実際に、C++のオフセットと、HLSLが決めた位置を並べて確かめ、ずれたときの画面の色や形を、画素の値で測りました。

16バイト境界のルールとは?

定数バッファは、GPUの中で、16バイト(float41つ分)を1つのまとまりとして扱います。HLSLでは、変数をこのまとまりに、次のルールで詰めます。

・1つの変数が、16バイトの境目をまたいではいけない。またぎそうなときは、次のまとまりの先頭から始まる

一方、C++の構造体は、そんな制約を知らずに、変数をぴったり詰めます。そのため、同じ順番で変数を書いても、C++とHLSLで、変数の位置が違うことがあります。

実験A:色と明るさを渡す

シェーダーの定数バッファは、次のようにしました。画面の左半分にtint(色)、右半分にintensity(明るさ、灰色)を出します。

// 定数バッファの16バイト境界の実験(シーンA)。
//   画面の左半分に tint(色)、右半分に intensity(明るさ)を出す。
//   float3 は 16バイトの境目をまたげない。そのため float time; float2 resolution; のあとの float3 tint は 16バイト目から始まる。
cbuffer Params : register(b7) {
    float  time;
    float2 resolution;
    float3 tint;
    float  intensity;
};

C++側は、同じ順番で、素直に構造体を書きました。

▼TroubleDemo.cpp(C++側の構造体)
// C++側で「素直に」並べた構造体(これがずれる)
struct CbA_natural {
    float    time;
    XMFLOAT2 resolution;
    XMFLOAT3 tint;
    float    intensity;
};

C++の構造体のオフセット(offsetof)と、HLSLが実際に決めた位置を、並べて出力しました。HLSL側は、シェーダーのリフレクション(D3DReflect)で取り出せます。

[cbuf-cpp] natural : time=0 resolution=4 tint=12 intensity=24 sizeof=28
[cbuf-cpp] pad     : time=0 resolution=4 tint=16 intensity=28 sizeof=32
[cbuf-cpp] alignas : time=0 resolution=4 tint=16 intensity=28 sizeof=32
[cbuf-hlsl] cbuffer Params size=32
[cbuf-hlsl]   time       offset= 0 size= 4
[cbuf-hlsl]   resolution offset= 4 size= 8
[cbuf-hlsl]   tint       offset=16 size=12
[cbuf-hlsl]   intensity  offset=28 size= 4
[cbuf-create] ByteWidth=28 -> hr=0x80070057 FAILED
[cbuf-create] ByteWidth=32 -> hr=0x00000000 OK
変数C++のオフセット(素直に並べた)HLSLの位置一致
time00○
resolution(float2)44○
tint(float3)1216×
intensity(float)2428×
どちらも、同じ定数バッファの変数。バイト単位。tintは、12バイト目から始めると16バイトの境目をまたぐので、HLSLは16バイト目から始める

tintは、C++では12バイト目から始まりますが、HLSLでは16バイト目から始まります。12バイト目から始めると、12〜24バイトとなり、16バイトの境目をまたいでしまうからです。これに引きずられて、intensityも、24バイト目と28バイト目でずれています。

実際に出た画面

定数バッファの16バイト境界で値がずれたときと、直したときの画像の比較。色と明るさ、棒グラフの高さ
上:実験A(左が色、右が明るさ) 下:実験B(棒グラフ) 左がずれた状態、右が直した状態

渡した値は、色が(0.20, 0.60, 0.90)、明るさが0.80です。画素値にすると、色が(51, 153, 229)、明るさが(204, 204, 204)のはずです。

左半分(色)右半分(明るさ)
期待した値(51, 153, 229)(204, 204, 204)
素直に並べたとき(153, 229, 204)(0, 0, 0)
パディングを入れたとき(51, 153, 229)(204, 204, 204)
alignas(16)を使ったとき(51, 153, 229)(204, 204, 204)
画面の左右それぞれ1点の画素値(0〜255)

素直に並べたときの左半分は、(153, 229, 204)でした。これは偶然ではなく、計算で説明できます。HLSLはtintを16バイト目から3つ読みます。ところが、C++がそこに置いていたのは、tintの2番目と3番目、そしてintensityでした。つまり、(0.60, 0.90, 0.80)で、255倍すると、まさに(153, 229, 204)になります。右半分は、HLSLが28バイト目を読みますが、C++はそこに何も入れていない(0)ので、真っ黒になりました。

直し方は2つ

1つ目は、C++側で、自分でパディング(穴埋め)を入れる方法です。

▼TroubleDemo.cpp(直し方1)
// 直し方1: 手でパディングを入れる
struct CbA_pad {
    float    time;
    XMFLOAT2 resolution;
    float    pad0;
    XMFLOAT3 tint;
    float    intensity;

2つ目は、alignas(16)で、変数を16バイトの境界に置く方法です。

▼TroubleDemo.cpp(直し方2)
// 直し方2: alignas(16) で16バイト境界に置く
struct CbA_align {
    float    time;
    XMFLOAT2 resolution;
    alignas(16) XMFLOAT3 tint;
    float    intensity;
};

どちらも、オフセットは、HLSLと同じ(tintは16、intensityは28)になり、画素値は期待どおりでした(上の表)。

もう1つの罠:バッファの大きさは、16の倍数

素直に並べた構造体のサイズは、28バイトです。そのまま定数バッファを作ろうとすると、どうなるかを試しました(実行ログの、下の2行です)。

[cbuf-create] ByteWidth=28 -> hr=0x80070057 FAILED
[cbuf-create] ByteWidth=32 -> hr=0x00000000 OK

ByteWidthが28のときは、0x80070057(E_INVALIDARG)で失敗し、16の倍数の32にすると成功しました。定数バッファの大きさは、16の倍数でなければなりません。

実験B:配列で渡す

次は、float weights[4]という配列です。4本の棒グラフの高さを、0.2、0.4、0.6、0.8で渡します(ぼかしフィルターの重みなどで、よく使う形です)。

#ifdef PACKED
cbuffer Params : register(b7) {
    float4 weights;
};
#else
cbuffer Params : register(b7) {
    float weights[4];
};
#endif

C++側は、素直にfloat weights[4]の構造体にしました。

▼TroubleDemo.cpp(シーンBの構造体)
// シーンB
struct CbB_array  { float weights[4]; };            // float[4] のまま(ずれる)
struct CbB_f4arr  { XMFLOAT4 weights[4]; };         // 要素ごとに16バイトを使う
struct CbB_packed { XMFLOAT4 weights; };            // float4 に4つまとめる
[cbuf-hlsl] cbuffer Params size=64
[cbuf-hlsl]   weights    offset= 0 size=52

HLSLでは、配列は要素ごとに16バイトを使います。float weights[4]の大きさが、4バイト×4=16バイトではなく、52バイト(16バイトを3つと、最後の要素の4バイト)になっているのは、そのためです。C++側が、4バイトずつ詰めて渡すと、2番目以降の要素は、まったく別の場所を読むことになります。

1本目2本目3本目4本目
期待した高さ0.20.40.60.8
float weights[4]のまま0.20.00.00.0
float4の配列にした0.20.40.60.8
float41つにまとめた0.20.40.60.8
画面の高さに対する、棒の高さの割合(実測)

ずれたときは、1本目だけが正しく(0.2)、2〜4本目は0でした(上の画像の下段・左)。直し方は2通り試しました。

  • 要素ごとにfloat4にする(XMFLOAT4 weights[4]、使うのは.xだけ):4本とも、期待した高さになった
  • float41つに4つ詰める(HLSL側もfloat4 weightsにして、weights[i]で読む):4本とも、期待した高さになった。必要なメモリが、4分の1で済む

【重要】罠:エラーが出ず、「それらしい別の値」になる

今回の実験で、いちばん困ったのは、何のエラーも出ず、一見それらしい値で動くことでした。

  • 実験Aでは、色が、緑がかった水色になっただけ((153, 229, 204))で、画面は普通に出る。エラーも警告もない
  • 実験Bでは、1本目だけが正しい。そのため、「値は渡せている」と判断して、見落としやすい

「2番目以降だけおかしい」「3つ目の変数だけおかしい」という症状は、16バイト境界を疑うサインです。そのときは、C++のoffsetofと、HLSLのリフレクション(D3DReflect)の位置を、並べて出力すると、どこから食い違うかが、すぐに分かります。

▼TroubleDemo.cpp(HLSLの配置を取り出す)
void TroubleDemo::ReflectLog(const char* path, const char* entry, const char* cbName) {
    wchar_t wpath[512];
    MultiByteToWideChar(CP_ACP, 0, path, -1, wpath, 512);
    D3D_SHADER_MACRO defs[] = { { "PACKED", "1" }, { nullptr, nullptr } };
    ID3DBlob* blob = nullptr;
    ID3DBlob* err = nullptr;
    HRESULT hr = D3DCompileFromFile(wpath, (scene == 1 && cas == 2) ? defs : nullptr, D3D_COMPILE_STANDARD_FILE_INCLUDE,
                                    entry, "ps_5_0", 0, 0, &blob, &err);
    if (FAILED(hr) || !blob) { if (err) err->Release(); return; }
    ID3D11ShaderReflection* refl = nullptr;
    if (SUCCEEDED(D3DReflect(blob->GetBufferPointer(), blob->GetBufferSize(), __uuidof(ID3D11ShaderReflection), (void**)&refl))) {
        ID3D11ShaderReflectionConstantBuffer* cb = refl->GetConstantBufferByName(cbName);
        D3D11_SHADER_BUFFER_DESC bdsc = {};
        if (cb && SUCCEEDED(cb->GetDesc(&bdsc))) {
            DemoLog("[cbuf-hlsl] cbuffer %s size=%u", cbName, bdsc.Size);
            for (UINT i = 0; i < bdsc.Variables; i++) {
                D3D11_SHADER_VARIABLE_DESC vd = {};
                cb->GetVariableByIndex(i)->GetDesc(&vd);
                DemoLog("[cbuf-hlsl]   %-10s offset=%2u size=%2u", vd.Name, vd.StartOffset, vd.Size);
            }
        }
        refl->Release();
    }
    blob->Release();
}

定数バッファのよくある失敗例と対処法

①float3のあとに、float以外を置く

float3の直後にfloatを置くのは、16バイトに収まるので、ずれません(今回のtintとintensityの関係)。ただし、float3のあとにfloat2やfloat3を置くと、またぐので、次のまとまりに送られます。

②floatの配列を、そのまま渡す

配列の要素は、すべて16バイトを使います。floatの配列は、float4の配列にするか、float4にまとめます。

③構造体のサイズが16の倍数になっていない

CreateBufferがE_INVALIDARGで失敗します。alignas(16)や、パディングで、構造体全体を、16の倍数に整えます。

注意点

  • HLSLは、変数が16バイトの境目をまたがないように詰める。C++は、そうしない
  • 配列は、要素ごとに16バイトを使う(float weights[4]は52バイト)
  • 定数バッファの大きさは、16の倍数(28バイトだとE_INVALIDARGで失敗した)
  • ずれてもエラーは出ない。「2番目以降だけおかしい」は、このずれを疑う
  • offsetofとD3DReflectで、C++とHLSLの位置を並べて確認できる
  • 今回の検証は、DirectX11、HLSL(SM 5.0)、GeForce RTX 3050 Laptop。行列(matrix)などの、他の型の配置は、今回は調べていない

結論

  • HLSLの定数バッファは、16バイト単位で、境目をまたがないように詰める。C++の構造体は、そうならない
  • float3の位置が、C++では12、HLSLでは16とずれ、色が(153, 229, 204)、明るさが0(真っ黒)になった
  • 配列は要素ごとに16バイト。ずれたときは、1本目だけが正しく、残りは0だった
  • 直し方は、パディング・alignas(16)・float4にまとめる。確認はoffsetofとD3DReflect

定数バッファのずれは、エラーが出ないぶん、気づきにくい不具合です。ただ、位置を並べて出力するだけで、原因がはっきり見えます。「渡したはずの値がおかしい」ときは、まずオフセットを並べてみてください。

関連記事