ジオメトリシェーダー

やぁおかえり。前章で私たちはピクセルパイプラインの終端まで到達した。この章ではパイプラインの真ん中に戻って、D3D10 でおそらく最も目立つ追加機能を見ていく: ジオメトリシェーダーである。ただまずは、このシリーズにおけるグラフィックスパイプラインの分解方法について、それが API が示す視点とどう異なるかを説明しよう。

パイプラインは複数ある/パイプラインステージの解剖

これは三章でも触れたことだが、繰り返しておくに値する: 例えば D3D10 のドキュメントを見ると、アクティブにできるステージが全て表示された “D3D10 パイプライン” の図がある。この “D3D10 パイプライン” にはジオメトリシェーダーが設定していなくても含まれるものとして描かれており、ストリームアウトに関しても同様である。D3D10 を純粋に機能モデルとして見るとき、ジオメトリシェーディングステージは必ず存在している。例えばジオメトリシェーダーを設定しなくても、それはジオメトリシェーディングステージの処理がたまたま非常に単純 (かつ退屈) になっただけである: このステージでデータは変更されず、そのまま次のパイプラインステージ (ラスタライズ/ストリームアウト) に渡される。

これは API を提示する方法としてなら正しいが、このシリーズの考え方としては間違いである。このシリーズでは API が示す機能モデルのハードウェアにおける実際の実装を説明しているのだった。これまでに見てきた二つのステージはどのようになっていただろうか? VS では、インプットアセンブラでシェーディング用のブロックを組み立てて、そのブロックをバッチとしてシェーダーユニットへディスパッチして (いくらかの時間を使って処理を行い)、しばらくすると返ってくる結果を正しい順序で (プリミティブアセンブリ用の) バッファに書き込み、そのバッファを次のパイプラインステージ (カリング/クリッピングなど) に渡していた。PS では、シェーディングされるべきクアッドをラスタライザから受け取り、バッチとしてまとめ、新しいバッチを実行できるシェーダーユニットが空くまでバッファしておき、シェーダーユニットにバッチをディスパッチして (いくらかの時間を使って処理を行い)、しばらくすると返ってくる結果を正しい順序で (ROP の) バッファに書きこみ、ブレンド/レイト Z を行ってメモリに結果を送っていた。二つは似ているように思えないだろうか?

実は、シェーダーユニットで何かをするときは必ずこのパターンが表れる: 先頭にバッファ、その後にディスパッチロジック (この部分はシェーダーの種類が違ってもほとんど変わらないので、シェーダー間で共有できる) があり、そこから広くなって大量のシェーダーが並列に実行され、最後に別のバッファとユニットを使って API が指定した順序に結果のデータがソートされる (シェーダーユニットからは順序を保たずに結果が返ってくる可能性がある) というパターンである。

シェーダーユニット (およびシェーダーの実行) とディスパッチは前に説明した。さらにピクセルシェーダーも (微分係数の計算、ヘルパーピクセル、ピクセルの破棄、アトリビュートの補間といった特殊な機能を含めて) 説明したから、特殊なタイプのバッファとアトミック操作を持つコンピュートシェーダーに到達するまでシェーダーユニットに追加される大きな機能はない。つまり、これから数章の間シェーダーユニットに関する話題は出てこない。これから見ていく様々なシェーダーで何が違うのかと言えば、入出力されるデータの形状と解釈である。IO と関係のないこと (算術、テクスチャサンプリングなど) を担当するシェーダーのモジュールは同じなので、それについては話さない。

シェーディングする三角形の形状

それではジオメトリシェーダーの IO バッファを見ていこう。まずは入力から始める。さて、これは比較的簡単だ──頂点シェーダーで書き込んだデータがそのまま入力になる! まぁ正確に言えば少し違う: ジオメトリシェーダーは頂点単位ではなくプリミティブ単位で処理を行うので、実際にはプリミティブアセンブリ (PA) からの出力が必要になる。なお、これを処理する方法は一つではない。PA は “広げた” (複数回参照される頂点を複製した) プリミティブを出力することもできるし、頂点ブロック (ここでも一つのブロックには 32 個の頂点が入るとする) と小さな “インデックスバッファ” を出力することもできる (一つのブロックには頂点が 32 個しか入らないから、インデックス一つにつき 5 ビットしか必要にならない)。どちらの方法でも正しく動作する: 前者は PA の後に議論したカリング/クリッピングにとって自然なフォーマットだが、後者だと GS を実行するときに必要なバッファがずっと小さくて済む。というわけで、ここでは後者のモデルを使って説明する。

GS でバッファの大きさを気にする必要がある理由の一つが、GS は非常に大きなプリミティブを扱う可能性があることである。GS では単なる直線や三角形 (それぞれプリミティブごとに 2 頂点と 3 頂点) だけではなく、隣接頂点に関する情報を持った直線/三角形もサポートされる (それぞれプリミティブごとに 4 頂点と 6 頂点)。D3D11 ではさらに太い入力プリミティブが追加された──GS は最大で 32 個の制御点を持つパッチを入力に受け取れる。例えば 16 個の制御点を持つパッチの頂点それぞれに最大 16 個 (D3D 11 なら 32 個) のベクトルアトリビュートがあるとすれば、全ての頂点を複製すると無駄が非常に大きい。よってここでは頂点の複製は起こらず、インデックスで参照された頂点が GS に渡されるとする。これでプリミティブのバッチを表す入力が定まった: VS の出力と、(比較的小さな) インデックスバッファである。

続いて、ジオメトリシェーダーがプリミティブごとに実行される。頂点シェーダーでは頂点をバッチとして集める必要があったので、バッチサイズを決めた上で単純な貪欲アルゴリズムを使ってプリミティブが複数のバッチに分かれることがないように可能な限り多くの頂点を一つのバッチに入れるという方法を採用していた──もっともである。そしてピクセルシェーダーではラスタライザからクアッドが大量に送られて来るので、それを詰めてバッチとしていた。ジオメトリシェーダーはこれより多少扱いやすい──入力のブロックが少なくとも一つのプリミティブを持つことは保証されている。しかしこの点を除くと、ブロックに含まれるプリミティブの数はキャッシュのヒットレートに完全に依存する。三角形を使っていてヒットレートが高ければ、一つのブロックに 40~43 個のプリミティブが含まれる可能性もある。しかし隣接情報を持つ三角形を使っていて運が悪いと、一つのブロックにたった 5 個しかプリミティブが含まれない可能性もある。

もちろん複数の入力ブロックからプリミティブを収集することもできるかもしれないが、それも不格好にしか行えない。そうすると単一の GS バッチのために複数の入力ブロックとインデックスバッファを管理しなければならない: 単一のバッチが複数のインデックスバッファを参照できることは、各バッチがインデックスと頂点の情報をどこから取得すべきかを知っておく必要があることを意味する──格納場所が増え、管理対象が増え、オーバーヘッドが増える。さらに美しくない。それから当然、入力ブロックが二つあってもそれらの頂点キャッシュヒットレートが低ければ使用効率は低いままである。もっと多くの入力ブロックをサポートすることもできるが、さらに多くのメモリが消費される──忘れてはいけないが、(後述するように) 出力のジオメトリに対する空間も必要になる。

よってこれが最初に片付けるべき問題である。VS では基本的にディスパッチするときのバッチサイズを決めて、必ず完全に埋まったバッチを生成するのを諦めることで PA (今考えているパイプラインでは GS、後に考えるパイプラインでは HS) での処理が楽になるようにしていた。PS では必ずクアッドでシェーディングを行っていて、かなり小さい三角形でも複数のクアッドにヒットするためにクアッドの個数と三角形の個数の比はいい感じになっていた。しかし GS ではパイプラインのどちらの端も制御できず (GS は中間にある!)、プリミティブごとに複数の入力頂点が必要になる (一つの入力三角形に複数のクアッドが含まれた PS と逆である)。よって入力を大量にバッファしておくのは (管理のオーバーヘッドと消費されるメモリの両方で) コストが高い。

このステージでは、ジオメトリシェーダーに送るプリミティブが詰まったブロックを作るのに入力の頂点ブロックをいくつマージするかを基本的に実装者が選択できる。この値はメモリ要件により小さい場合が多い (4 を超えていたら私はかなり驚く)。GS の処理をどれだけ重要とみなすかに応じて、この値を 1 にする、つまり入力ブロックを全くマージせず、GS シェーディングのブロック/Warp/Wavefront の利用率が低くなるのを受け入れることもできる。このとき三角形を使うと効率が悪く、それより多くの頂点を持つプリミティブでは大きく効率が落ちる。しかし GS の使い道が点をクアッド (点スプライト) に広げたりキューブシャドウマップをたまにレンダリングしたりするぐらいなら、あまり問題にならない。

GS の出力: ここにもバラの花園はない

では出力はどうなっているだろうか? こちら側も、普通の VS のデータフローより複雑に、正直に言えばずっと複雑になっている。VS では出力が一つ (シェーディングされた頂点) だけで、シェーディング前後の頂点の間には一対一の対応関係がある。しかし GS では出力が可変個の頂点 (最大個数はコンパイル時に指定) で、D3D11 では複数の出力ストリームを持つこともできる──ただし、以降のグラフィックスパイプラインに送ることができるストリームは一つだけであり、ここではこのパスについて話をする。GS からのデータが流れる他のパス (ストリームアウト) は次の章で扱う。

GS は可変サイズの出力を生成するが、実行時に利用できるメモリには制限がある (例えば、バッファとして利用可能なメモリの量は並列にジオメトリシェーディングできるプリミティブの個数を決定する)。出力される頂点の最大個数をコンパイル時に固定しなければならないのはこのためである。この値 (および出力が持つアトリビュートの個数) がアロケートされるバッファの大きさを定めるので、間接的に並列実行できる GS の個数も定める。並列実行できる GS の個数が少なすぎるとレイテンシを完全に隠すことができなくなり、GS は実行時間の一部でストールすることになる。

また GS の入力がプリミティブ (点、直線、三角形、パッチ、あるいはこれに隣接情報を加えたもの) であるのに対して、GS の出力は──すぐにラスタライザに渡されるにもかかわらず!──頂点であることにも注意しなければならない。出力プリミティブが点なら処理は簡単だが、直線あるいは三角形の場合は出力の頂点をプリミティブとして組み立てる必要がある。プリミティブへの組み立ては出力される頂点がそれぞれ直線ストリップおよび三角形ストリップを構成すると定めることで処理される。この方法を使えば、おそらく最も重要な三つのケースを処理できる: 単一の直線、単一の三角形、単一のクアッドである。しかし、GS が本格的な押し出し (extrusion) やその他の “複雑な” ジオメトリを出力する場合は「リスタートストリップ」のマーカーがいくつか必要になる (最終的には、現在のストリップが継続されるか新しいストリップが開始されたかを表すビットが各頂点に追加される) ので、この方法は大して便利とも言えない。とすれば、なぜ制限があるのだろうか? API のレベルで見れば、これは恣意的な制限に思える──GS が頂点リストと小さなインデックスバッファを出力できないのはなぜだろう?

答えは一言で: プリミティブアセンブリ。ここでしようとしているのは、たくさんの頂点を受け取って完全なプリミティブに組み立て、それをパイプラインに渡すという処理である。しかしこの機能を持つブロックは今考えているデータパスにおいて GS の直前で既に使っている。よって GS には二つ目のプリミティブアセンブリステージが必要になる。これは単純に保ちたいが、実は三角形ストリップの組み立てなら非常に簡単に行える: 三角形は必ず出力バッファから頂点を三つ順番に取ればできるので、現在のワインディングオーダーを管理する簡単なグルーロジックだけが必要になる。言い換えれば、ストリップのサポートは間違いなく最も扱いやすいプリミティブ (インデックスを持たない直線/三角形) と比べて格段に複雑になることはなく、それでいてクアッドのような典型的なプリミティブに対して出力バッファの大きさを節約できる (よって並列実行のポテンシャルが高まる)。

API オーダー再び

ただし、いくつか問題がある。通常の頂点シェーディングパスでは、バッチに含まれるプリミティブとその場所が、シェーディングされた頂点が PA バッファに到着する前から分かっていた──この情報はシェーディング用のバッチをセットアップした瞬間から変わらない。そのため例えばカリング/クリッピング/三角形セットアップを実行するユニットが複数あるなら、それらは全て並列に実行を開始できる: 各々は自身が使う頂点データをどこから読むべきかを知っており、さらに三角形の「シーケンス番号」も事前に分かるので、その番号を使って事前に三角形を順番通りに並べることもできる。

GS では、一般に生成されるプリミティブの個数は出力が返ってくるまで分からない──そもそもプリミティブは生成されないかもしれない! しかしそれでも API が指定する順序は守る必要がある: 最初に起動された GS が生成するプリミティブが最初で、その次に起動された GS が生成するプリミティブが次で、以下バッチの最後まで同様となる (当然バッチも順序通りに処理される必要がある。この点は VS と同様である)。つまり GS では結果が返ってきたときに出力データを一度スキャンして完全なプリミティブが始まる位置を調べなければならない。カリング/クリッピング/三角形セットアップを (並列に) 行えるのはそれからとなる。仕事がさらに増えた!

VPAI と RTAI

これら二つの機能は GS と同じタイミングで追加されたもので、ジオメトリシェーダーの実行に直接影響するわけではない。ただ以降の処理にいくらか影響するので、ここで言及しておく: ビューポート配列インデックス (viewport array index, VPAI) とレンダーターゲット配列インデックス (render target array index, RTAI) である。まず少し簡単な RTAI について。読者も知っていると思うが、D3D10 でテクスチャ配列のサポートが追加された。RTAI はテクスチャ配列に対する書き込みのサポートである: テクスチャ配列をレンダーターゲットに設定して、GS でプリミティブごとにどの配列インデックスに行くべきかを選ぶことが可能になる。なお GS はプリミティブではなく頂点を書き込むので、プリミティブごとに RTAI を指定する頂点を選択する必要があることに注意してほしい (VPAI でも同様)。これは必ず「先頭頂点 (leading vertex)」、つまりプリミティブに含まれる頂点の中で最初に指定されたものとなる。RTAI の使用例の一つにキューブマップのワンパスレンダリングがある: プリミティブが送られるべきキューブ面を GS でプリミティブごとに指定する手法である (複数の面を指定することもできる)。VPAI は RTAI と直交する機能で、複数 (最大 15 個) のビューポートシザーレクトを設定し、使うべきビューポートをプリミティブごと指定できるようになる。これを使うと、例えばカスケードシャドウマップで複数のカスケードの単一パスレンダリングが可能になる。VPAI は RTAI と混ぜて利用できる。

最初に言ったように、どちらの機能も GS の処理に大きく影響しない──プリミティブにデータが追加され、後で使われるだけである。VPAI はビューポート変換で消費されるが、RTAI はピクセルパイプラインまで生き残る。

ここまでのまとめ

まとめると、入力側にはいくつか厄介な点がある──入力データのフォーマットを自由に選択できないので、追加のバッファ処理が必要になる。しかしバッファ処理を用意したとしても入力プリミティブの個数が可変なので、効率に優れる大きいバッチに必ず分割できるわけでもない。さらに出力側では、可変個数のプリミティブをもう一度組み立てなければならず、どの GS がいくつのプリミティブを生成するかは事前に分からず、出力データを三角形セットアップに送る前に少し時間を使ってパースしなければならない (ただし一部の GS では出力されるプリミティブの個数が静的に決定する。例えば全ての頂点エミットがフロー制御の外あるいは回数が既知のループの中にあり、かつ早期リターンが存在しない場合など)。

もしこれが VS だけのケースよりも複雑に思えたなら、それは実際に複雑だからである。この章の最初で GS が必ず実行されると考えるのは間違いだと指摘した理由はここにある──非常に単純な GS があるだけでも、全ての三角形はバッファを追加で二つ通り抜け、プリミティブアセンブリがもう一度行われる。さらに GS が実行されるときのシェーダーユニットの使用率が非常に低い可能性もある。こういった処理には全てコストがかかり、しかもそれは積み重なる傾向にある: 以前 D3D10 ハードウェアがまだ新しかったころに確認したのだが、AMD と NVIDIA ので、純粋なパススルーの GS (ジオメトリシェーディングによる負荷が低いシナリオ) でさえ何もないときと比べて 3 倍から 7 倍の実行時間がかかった。この実験を最近のハードウェアで行ってはいないが、今では状況が多少改善したと思われる (私が計測したのは GS を実装する最初の世代のハードウェアであり、新しい機能は初めて実装される GPU でパフォーマンスが優れないことが多い)。ただ要点は変わらない: GS パイプに頂点を通すだけで、そこで何もしなくても、決して無視できないコストがかかる。

それから、GS がプリミティブをストリップとして逐次的に生成するという事実は状況を改善しない。頂点シェーダーは一つの頂点ごとに一度起動され、一つの頂点を読んで一つの頂点を書き込んでいた (素晴らしい)。しかし GS では、一つのバッチで (入力バッファにプリミティブが十分になかったために) 11 個の GS しか実行されず、それぞれが長い時間をかけて 8 個かそこらの頂点を生成するかもしれない。低い使用率で長く実行される! (シェーダーユニットを効率的に使用するには、16~64 個の独立したジョブからなるバッチをディスパッチする必要がある。) GS が主にループから構成されているとさらに状況は悪化する──例えば RTAI の使用例として示した「キューブマップにレンダリングする」というシナリオでは、立方体の六つの面をループして三角形がそこから視えるかどうかを判定し、もし視えるならその三角形を出力する。六つの面に対する計算はそれぞれ独立であり、可能なら並列に実行するべきである!

ボーナス: GS インスタンシング

では GS インスタンシングに入ろう。これは D3D11 で追加されたもう一つの機能である──ドキュメントはほとんどない、残念ながら (SDK にちょうどいい例が含まれているかどうか私には分からない)。ただ説明は簡単にできる: 入力のプリミティブそれぞれに対して、GS が一度だけではなく複数回実行されるようになる (何回実行されるかはコンパイル時に静的に指定される)。これはシェーダー全体を次のブロックで包むのに等しい:

for (int i = 0; i < N; i++)
{
    // ...
}

ただしループの管理はシェーダーの外側で行われ、実際には入力のプリミティブごとに複数の GS が起動される。この仕組みがあるとバッチサイズが大きくなり、シェーダーユニットの使用率が向上する。i はシステムが生成する値 (D3D11 では SV_GSIncstanceID というセマンティクス) としてシェーダーにエクスポートされる。よって上記のような GS を書いたら、外側のループを削除して宣言に [instances(N)] を加え、i を正しいセマンティクスに変更することで面倒な手間をかけずに実行を高速化できる──大規模並列計算機に独立したジョブを追加したときにおこる魔法である!

というわけで、ジオメトリシェーダーについては以上となる。ストリームアウト (SO) は飛ばしたが、この章は長くなり過ぎているし、SO は一つの記事を占めるに値する大きな (そして GS とは独立した!) 話題だから、次章で詳しく説明することにしよう。それでは!


関連書籍 (Amazon アソシエイト)
Jim Blinn's Corner: A Trip Down the Graphics Pipeline
[増補改訂] GPUを支える技術 ── 超並列ハードウェアの快進撃
マンガとイラストでわかる! GPU最適化入門