DirectXの話 第193

頂点バッファのエンコード・デコード

24/02/07 up

今回は小さめのお話ということで、頂点バッファのエンコード・デコードについて主にパフォーマンスの観点です。

頂点バッファのエンコード

通常、多くのゲームエンジンでは頂点バッファを素直に32ビット浮動小数点で作成することはありません。
32ビット浮動小数点ではどうしてもバッファサイズが増えてしまい、結果としてVRAMへのアクセスが多くなります。
処理する頂点数が増えればバッファアクセスが大きな問題になることもあります。

そこでゲームエンジンでは頂点バッファをエンコードし、シェーダでデコードして利用します。
エンコード方法はエンジンごとに違いがあるのですが、記事では今回の実装のフォーマットを紹介します。

ノーマルとタンジェントは-1~1の範囲内に収まり、多くの場合でノーマルマップを利用することからそこまで精度が必要にないと考えて8ビットにしています。
テクスチャ座標はタイリングを考慮するとUNORMやSNORMは使えませんので16ビット浮動小数点にしています。

座標は-1~1のSNORMを利用しています。さすがに8ビットでは精度が足りないので16ビットです。
しかし、座標は-1~1の範囲で収まりません。だからといってそのまま16ビット浮動小数点にエンコードすると精度が問題になります。
そこで、バウンディングボックスの範囲に正規化することで-1~1の範囲に収めるようにしました。
レンダリング時はLocalToWorldの行列にBoxToLocalの行列を乗算して対応します。

今回のエンコードの方針としては以下のようになります。

・4バイトアラインメントを要素単位で考慮する
・InputLayoutで利用できる

InputLayoutで利用できるようにするにはDXGI_FORMATで表現できなければならないため、例えば座標にはAチャンネルが不要なのですが、R16G16B16フォーマットが存在しないためこのようにしています。
フォーマットはR16G16B16A16を利用してストライドを6バイトにすればよいのかもですが、4バイトアラインメントのルールを設定していたためにAチャンネルも含めてストライドは8バイトにしました。
InputLayoutを利用せずにByteAddressBufferを利用して参照する方法ももちろんありますし、VisibilityBufferにおいてはそれでもいいとは思うのですが、基本的なVsPsパイプラインを比較対象として保持したいという考えのもとにこのようなフォーマットにしました。
もし業務で利用して、しかもVisibilityBufferに1本化するのであれば、よりアグレッシブなエンコードを利用したかもしれませんね。

VsPsパイプラインでの頂点バッファのデコード

前述した通り、今回はVsPsパイプラインにおいてはInputLayoutを利用していますが、頂点インデックスのみ取得してByteAddressBufferやStructuredBufferから取得する方法もあります。
InputLayoutを利用するとハードウェアが自動的にデコードしてくれるので簡単ですが、ByteAddressBufferなどで取得する場合はデコード処理を自前で実装する必要があります。

MeshShaderパイプライン、及びVisibilityBufferでの頂点バッファのデコード

このパイプラインの場合、InputLayoutが使用できないので自前のデコードを実行する必要があります。
このコードは visibility_buffer.hlsli のインクルードファイル内で、GetVertex~関数にあります。

テクスチャ座標のR16G16は浮動小数点値なので、組み込み関数のf16tof32関数を利用すればOKで簡単です。
ただし、ByteAddressBufferのLoad命令は4バイトごとにしかロードできないため、ロードしたuintにビット演算を実行して2バイトに分けてからf16tof32を利用します。

座標・ノーマル・タンジェントの3つはSNORMなので、符号付き整数に対してビットに合わせたスケール値を乗算します。
ただこのときにちょっと面倒なのが符号付き整数が4バイト符号なし整数にパックされているという点です。
座標の場合、4バイトuintの前2バイトと後2バイトが符号付き整数となりますので、以下のようなコードにする必要があります。

4バイトの先頭2バイトだけを有効にし、それをasint命令で符号付きに変更、その後に16ビットのビットシフトを行います。
後2バイトの場合は16ビットのビットシフト後にasintして16ビットのビットシフトをするという形になります。
座標の場合はこれが3要素ですが、タンジェントは4要素でビット演算が入るので、ビット演算がどうしても多くなってしまいます。

実際、エンコード前と後の実行ファイルでプロファイルを行ってみたのですが、エンコード後のほうが占有率が低めでした。

パフォーマンスについて

エンコードしたけどパフォーマンスが下がった!なんてことになったら困るので、前述の通りエンコード前と後をプロファイルして比較してみました。

まず、VsPsパイプラインでのGBufferパスの比較。

1stがエンコード前、2ndがエンコード後です。

Occupancyにはさほどの差が出ていません。
しかし、Throughputについては特にL2とVRAMが顕著に下がっていて、VRAM Readも下がっています。
若干SM Throughputが上がっているので、頂点バッファアクセスが高速になったことでSMが動きやすくなったのかもしれませんが、パフォーマンスには大きな影響は出ていないと思います。
実際の実行時間としては0.01ms程度しか高速になっておらず、あまり効果は出ていないと感じます。

次にVisibilityBufferのMaterialTileパスの比較です。

MaterialTileパスはほぼPixel Shaderなのですが、SM Warp OccupancyのPixel Warpsがエンコード後のほうが低くなっています。
デコード処理のビット演算が多くなってしまっていて、これが原因で占有率が下がっている可能性がありそうです。

Throughputを見るとSMが高くなっていて、VRAMが低くなっています。
SMが効率良く動くようになっていると考えることができるので、比較的良い傾向とも思えます。
実行時間は0.03msほど高速化しており、VsPsパイプラインより最適化が進んでいると言えるでしょう。
よりポリゴン数が多いシーンであればさらなる改善が期待できるかもしれませんね。

最後に

これまでの頂点バッファのエンコードはInputLayoutを考慮したものになっていたかと思います。
しかし、今後はMesh Shaderの普及やVisibilityBufferの実装、また、Naniteのようなソフトウェアラスタライザを使うエンジンも増えるかもしれません。
そのような場合、InputLayoutを考慮しないエンコード方法が用いられることもあるのではないかと思います。
実際、そんな記事もありましたしね。

私のサンプル用ライブラリでは通常のパイプラインを比較対象としたいために可能な限りInputLayoutで利用できるようにしていますが、ダブルバッファにしてInputLayout用とVisibilityBuffer用で分けることも検討したいとは思っています。