mipmap 生成で簡単 Wave Intrinsics 入門
はじめに
Shader Model 6 から Wave Intrinsics が使用可能になりました。
Wave Intrinsics という言葉をAAAタイトルのゲームのGDC資料などで知って以来、私はそれが何なのか気になって仕方がありませんでした。
何かわからないけど実行速度が「速く」なるらしい。
Wave Intrinsics とは Wave というスレッドをまとめたグループ単位で様々な演算やデータ取得を効率よく行うことができる機能です。
今回は、Wave Intrinsics を理解するための仕組みと、具体的な使い方の例について説明します。
Wave Intrinsics の詳しい仕様を知りたい方は DirectX の仕様のページ DirectX-Specs(英語サイト)をご参照下さい。日本語のページですと shikihuiku さんの記事「HLSL の Wave Intrinsics について」が親切丁寧でわかりやすいです。
今回のブログ記事では実行速度の比較測定までは行いません。
将来的にシェーダの最適化をする際の必須知識になるかもしれない Wave Intrinsics について少し触っておきたいという方のためのヒントになれば幸いです。
私とコンピュートシェーダとの格闘
新しい API が出るたびに GPU のハードウェアの仕組みを徐々に知っていくことは面白いものです。
私は最初、OpenGL を使っていました。その時に体験した数々のエラーが伏線だったとしたら、DirectX12 や Vulkan などのよりハードウェア層に近い Low Level API を使い始めてわかったことが伏線回収のようで爽快な気分になります。
たとえば、OpenGL を使っているとき「よくわからないけど 画面真っ暗だったのが、glFlushCommand() という関数を書き足したら無事に描画されるようになった」という体験が、後に DirectX12 の勉強を進めて「コマンドリストを作成して、一通り描画命令などをあらかじめ登録したのち、しかるべきタイミングでコマンドを実行するという仕組みになっていたのか!」とわかって謎が解けた気分になる、といった具合です。
Wave Intrinsics を使用すると、インデックスでテクスチャフェッチするよりも実行速度が速くなることがあります。
ただ、その関数の挙動は1スレッドの実行のみを考えている時とは異なります。
私は Wave 系関数の挙動の感覚を掴むまで少々苦戦しました。
今回は、私なりに新しいグラフィックス API の挙動を感覚的に掴むまでの試行錯誤の話をします。
対象読者は手を動かしながら理解していきたい方、家電を買ったら説明書を読むよりまずは使ってみる派のエンジニアの方です。
私は今までコンピュートシェーダのコードを書く時、西遊記の孫悟空の毛から何千もの小さい分身が飛び出して働きにいくイメージを持っていました。
各分身達(thread)は小さいから掛け算や足し算と繰り返しなら問題なくやってくれる。しかし if 分岐があると少々てこずるといった具合です。
ところが Wave Intrinsics のことを学ぶうちに、本当は小さい分身達は32人乗りのバスに乗っていたとわかったのです。
コンピュートシェーダを触り始めたきっかけは「画面全体にエフェクトをかけるならピクセルシェーダよりもコンピュートシェーダを使うと速いらしい」という期待からでした。
仕様を読むも、よくわからないからグループスレッドなるものを1スレッドずつにしてみようとします。
numthread[1,1,1]
おかしいな、全然速くならないなぁ。。。
グループスレッドを1個にするということは、32人乗りのバスに働きに行く人を1人ずつ乗せて運ぶようなものだったのかもしれません。
32人乗りのバスに例えた Wave の配置はスレッドグループ数によって変わるようです。
カラーパレットを使ってコンピュートシェーダ上の Wave の配置を調べる
ピクセルシェーダでの Wave の配置の様子を色で確認したい場合は、Microsoft 公式のサンプル「D3D12SM6WaveIntrinsics」を利用して自分で少し HLSLコードを変えてみるなどして実行してみるのがわかりやすいと思います。
では、コンピュートシェーダの Wave はスレッドグループ内で具体的にどうやって配置されているのでしょうか?ランダムな色パレットを使って可視化してみます。
パレットテクスチャとしては以下のような16x16pixelで14色横に並んだパレットを使用しました。

1ピクセルしか拾わないので小さい画像でよいのですが、ファイルエクスプローラーからある程度みつけやすいようにあえて16x16pixelにしました。
コンピュートシェーダ上の各 Wave 毎に同じ色に塗りつぶすのに使用した HLSLコードは以下になります。
//個々の数字を変えるとwaveの配置が変わります。2,8,16,32で違いを見てみます
#define NUM_GROUP 32
//↓C++側からも同じhlslファイルを#includeして同じ名前のマクロNUM_GROUPが便利に使えるようにしています
#ifndef AS_INCLUDE_FROM_CPP
Texture2D<float4> palette : register(t0);
RWTexture2D<float4> gOutput : register(u0);
//Hash Functions for GPU Rendering
// https://www.pcg-random.org/
uint pcg(uint v)
{
uint state = v * 747796405u + 2891336453u;
uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
return (word >> 22u) ^ word;
}
//return random value range 0.0-1.0
float random1D(uint seed)
{
return pcg(seed) * (1.0 / float(0xffffffffu));
}
[numthreads(NUM_GROUP ,NUM_GROUP, 1)]
void main(uint2 xy : SV_DispatchThreadID)
{
uint2 coord = xy;
uint serialID = xy.y*NUM_GROUP*NUM_GROUP+xy.x;
uint colorIndex = pcg(serialID) % 14;
float4 color = palette[uint2(colorIndex*16+1, 1)];
gOutput[xy] = WaveReadLaneFirst(color);
}
#end
上記 HLSLコードの中から使用した Wave 系命令はまず WaveReadLaneFirst
です。
Microsoft の仕様のページによると WaveReadLaneFirst
は
「最小のインデックスを持つ現在の Wave の active lane の式の値を返す」
と書いてあります。active lane とは何でしょうか?
まず lane とは Wave を構成する各スレッドのことです。Nvidia の GPU の場合は1Waveあたり32個の lane があります。AMD の場合は1Waveあたり64laneです。今回このブログ記事で行った実験では NVIDIA の GPU を使用しています。
lane には inactive lane と active lane の2種類があります。コンピュートシェーダで1グループあたりのスレッド数が32より少ない数、たとえば30で Dispatch 命令を CPU からしたとすると、30個は active lane となり2個は inactive lane になります。
「最小のインデックスを持つ」という部分は Wave 内の一番先頭の active lane という意味です。先ほどのソースコードから一部を抜粋します。
float4 color = palette[uint2(colorIndex*16+1, 1)];
gOutput[xy] = WaveReadLaneFirst(color);
WaveReadLaneFirst(color)
という部分では、同じ Wave 内の lane はすべて最初の active lane の color
の結果になるという挙動になります。
numthread[32,32,1]
にするとこんな具合になります。わかりやすいように最初の一つのスレッドグループだけ黒線で囲ってみます。

*スクリーンショット画像は見やすいように拡大したものです。
numthread[8,8,1]
にすると8×4ピクセルのタイルが縦に2個並びました。

1グループ内のスレッド数が32より少なくした時はどうなるのでしょうか?
私は最初 Wave が隣のスレッドグループ領域までまたがるのではないか?と思っていました。結果としては、1Wave につき2×2の 4lane になりました。2×2=4 個が active lane で、残りの 32-4 = 28個が inactive lane になったということです。

まとめると、グループスレッド数の x*y*z が32よりも大きいと適当にタイル化して、グループスレッド数の x*y*z が32より小さいとその分 Wave の中の active lane 数も少なくなることがわかりました。
WaveIntrinsics を使って mipmap を生成してみる
次は Wave Intrinsics を使ってコンピュートシェーダで mipmap 生成をしてみたいと思います。
mipmap は、広い遠くまで見渡せる地形などにテクスチャを貼って描画する際には欠かせない機能です。
同じ解像度のテクスチャをカメラから遠い位置のポリゴンに貼って使用していると、ポリゴンがビューポートに投影された時のピクセルサイズが小さくなり、テクスチャのサンプリング間隔がとびとびになります。カメラを少し動かすたびにサンプリングされるテクスチャの画素が変わりチラつく印象を受けます。
人間の眼は特に周辺視野におけるちらつきに対して敏感で不快に感じるように出来ています。素早く外敵の検知をするための生来備わった目の仕組みだと思います。不快なだけでなく、テクスチャアクセスの効率も悪くなります。
そこで、遠くのテクスチャには低解像度のテクスチャが使用されるように、近くのテクスチャには高解像度のテクスチャが使用されるようにします。そのためにはあらかじめ何段階かの解像度のテクスチャを作っておいてビデオメモリに格納しておく必要があります。例えば1Kのテクスチャ、1024×1024ピクセルがオリジナルサイズだったとしたら、各辺のピクセル数を1/2ずつ縮小したテクスチャをテクスチャサイズが1×1になるまで繰り返して作ります。1024×1024,512×512,256×256,128×128,..中略..16×16,8×8,4×4,2×2,1×1 といった具合です。
mipmapの計算では自分が今いるピクセルとその右、下、右下の平均をとります。
Wave Intrinsics を使用せずに、半解像度の mipmap を1レベル生成するシンプルなコンピュートシェーダは以下のようになります
Texture2D<float4> gInputMip : register(t0);
RWTexture2D<float4> gOutputMip : register(u0);
cbuffer Parameter : register(b0)
{
uint miplevel;
}
[numthreads(32, 32, 1)]
void main(uint2 xy : SV_DispatchThreadID)
{
uint2 coord = xy * 2;
float4 c0 = gInputMip.Load(int3(coord, miplevel));
float4 c1 = gInputMip.Load(int3(coord + uint2(1, 0), miplevel));
float4 c2 = gInputMip.Load(int3(coord + uint2(0, 1), miplevel));
float4 c3 = gInputMip.Load(int3(coord + uint2(1, 1), miplevel));
gOutputMip[xy] = (c0 + c1 + c2 + c3) * 0.25f;
}
SRV は1個で複数のミップレベルにアクセスできますが、UAV はミップレベル毎に別で分けてバインドする必要があります。
SRV, UAV とはそれぞれ Shader Resource View, Unordered Access View の略です。どのテクスチャをどのシェーダで使用するかといった情報が格納されています。どのテクスチャをどのシェーダで使用するか関連付けることを「バインドする」と呼んでいます。
上記のコードを WaveActiveSum
関数を使って一気に加算して、WaveIsFirstLane()
が true のとき4で割って平均化出来るようにしようと思います。
WaveActiveSum 関数は同じ Wave 内の active lane の結果を加算した結果が返ってきます。
WaveIsFirstLane 関数では自分が今いる lane が Wave 内の最初の active lane だった場合のみ true が返ってきます。
スレッドグループ数はいっぺんに4ピクセルを加算させるために numthreads[2,2,1
] にします。
Texture2D<float4> gInputMip : register(t0);
RWTexture2D<float4> gOutputMip : register(u0);
cbuffer Parameter : register(b0)
{
uint miplevel;
}
[numthreads(2, 2, 1)]
void main(uint2 xy : SV_DispatchThreadID)
{
uint2 coord = xy;
float4 c0 = gInputMip.Load(int3(coord, miplevel));
float4 sum = WaveActiveSum(c0);
if (WaveIsFirstLane())
{
gOutputMip[xy/2] = sum / 4.0;
}
}
できあがった mipmap の様子です。

実験に使用したC++側のコードは非常に巨大で煩雑なため今回は共有を控えます。
おわりに
今回の mipmap 生成は、あくまで Wave Intrinsics の使い方を理解するためのもので、本来の目的である高速化のためのシェーダではありません。はじめて Wave Intrinsics にチャレンジする方の練習用タスクとして丁度良いのではないかと思います。