Z/ステンシル
この章では (アーリー) Z パイプラインとそれがラスタライズとどう関わるかについて話をする。前章と同様、説明は実際のパイプラインと同じ順序では進まない: 最初に内部で使われるアルゴリズムを説明し、何が起こるかを理解してからパイプラインステージを (説明の都合により逆順で) 埋めていく。
補間される値
\(Z\) は三角形に渡って補間される。頂点シェーダーから出力される任意のアトリビュートも同様である。そこで少し紙面を割いて、この補間がどう動作するかを説明する。元々は補間で使われる数式の導出や透視補間が正しく動く理由についての節を一つ書くつもりだった。余談なので一つか二つの段落で説明しようと数時間にわたって取り組んだのだが、結局きちんと説明するにはもっと紙面が必要で、図も少なくとも一つか二つ必要ということが分かった。「百聞は一見に如かず」なのだが、きちんとした図を一つ描くのには千単語の文章を書くのと同じぐらいの時間がかかるので、私からするとあまり違いがない :) ともかくこれは脱線した話題なので、私の「いつかきちんとした記事を書くグラフィックス関係の話題」のリストに積んでおく。ここではエグゼクティブサマリを示そう:
色やテクスチャ座標といったアトリビュートをスクリーン空間の三角形に沿ってただ線形に補間しただけでは、正しい結果は得られない (補間モードが「非透視」になっていたときは例外で、そのときはこの一文を無視できる)。しかし、例えば 2D テクスチャ座標の組 \((s,t)\) を補間したいとしよう。このときは実は、\(\frac{1}{w}\), \(\frac{s}{w}\), \(\frac{t}{w}\) に対してはスクリーン空間における線形補間で正しい結果が得られることが分かっている (\(w\) は同次クリップ空間における頂点座標の w 成分)。よってこれらの値をスクリーン空間で線形補間しておけば、ピクセルごとに \(\frac{1}{w}\) の逆数を取って \(w\) を計算し、これを他の二つの値に乗じることで正しく補間された \(s\) と \(t\) が計算できる。実際の線形補間は平面の方程式をセットアップしてスクリーン空間座標を代入することで行われる。もしソフトウェアで透視テクスチャマッパーを書いているなら、これで話は終わりである。しかし補間すべき値が三つ以上あるときは、クリップ空間における三角形に関する重心座標系を今考えているピクセルに対して (透視補間を使って) 計算するアプローチが優れている──この座標系における座標を \((\lambda_0, \lambda_1)\) としよう。そうすれば実際の頂点アトリビュートは通常の線形補間を使って補間でき、そのとき \(w\) を最後に乗じる必要はない。
三角形セットアップの処理はどれくらい増えるだろうか? 三角形に対して \(\frac{\lambda_0}{w}\) と \(\frac{\lambda_1}{w}\) をセットアップするには、四つの逆数、三角形の面積 (これについては背面カリングで計算されている!)、数回の減算・乗算・加算を計算しなければならない。頂点アトリビュートを補間のためにセットアップする処理は重心座標系のアプローチを使うと非常にコストが低く、アトリビュートごとに二つの減算で済む (重心座標系を使わないと、さらに乗算と加算が必要になる)。...ここまでの説明に付いて来れているだろうか? 自分で実装したことがない限り、おそらく無理だろう。申し訳ない──分からないなら、この部分は全て飛ばしてしまっても大きな問題はない。
こんなことを考えている理由に戻ろう: 今まさに補間したい値は \(Z\) である。\(Z\) は射影 (五章を参照) によって頂点ごとに \(\frac{z}{w}\) として計算されるので、最初から \(w\) で割られている。よって \(Z\) はスクリーン空間で線形に補間できる。ナイスだ。この事実から、\(X\) と \(Y\) を代入すると \(Z\) が得られる平面の方程式 \(Z = aX + bY + c\) が最終的に得られる。つまり、ここまで数段落に渡る口角泡を飛ばした説明のオチはこれである: 任意の点における \(Z\) は二度の積和演算で補間できる (GPU が高速な積和ユニットを持っている理由が見えてきただろうか? あらゆる場所で積和演算が使われるのだ!)。
アーリー Z/ステンシル
グラフィックス API による伝統的な説明における Z/ステンシル処理の位置──アルファブレンドの直前、ピクセルパイプラインの一番後ろ──を信じているなら、少し困惑するかもしれない。Z の議論が今なのはどうしてだろう? ピクセルのシェーディングさえ始まっていないのに! 答えは簡単だ: Z テストとステンシルテストはピクセルを破棄するが、場合によっては大部分のピクセルを破棄することになる。複雑なマテリアルを持つ詳細なメッシュのシェーディングを完全に計算し、それから「メッシュの前に壁があるから、ほとんど見えなかった」と言って 95% のピクセルを捨てるのは、本当に、絶対にやってはいけないことである。帯域、処理能力、電力の馬鹿げた無駄使いでしかない。さらに大半のケースで、こうする必要は全くない: 大半のシェーダーは Z テストの結果に影響することをしないし、Z バッファやステンシルバッファに書き込んだりもしない。
実際の GPU が可能な場合に行う処理は「アーリー Z」と呼ばれる (これと反対なのが「レイト Z」で、これはパイプラインのもっと後ろの伝統的な API モデルが示すステージを指す)。アーリー Z は文字通り、Z テストとステンシルテストを早く、具体的には三角形がラスタライズされピクセルがシェーダーに送られる直前に実行する。こうすると破棄されるピクセルを全て、それらに対して計算を行う前に検出できる。ただし、この検出はいつでも行えるわけではない: ピクセルシェーダーは補間されたデプス値を無視して独自に計算した値 (例えばデプススプライト) を Z バッファに書き込むことができる。またディスカード、アルファテスト、アルファトゥカバレッジが使われる可能性もある。これらは全てピクセルシェーダーの実行中にピクセル/サンプルを "殺す" ので、使われていると Z バッファやステンシルバッファを早い段階で更新できなくなる。後からシェーダーで破棄されるサンプルに対するデプス値を更新することになるかもしれない!
そこで実際の GPU は Z テストのロジックを二つ持っている。一つ (アーリー Z) はラスタライザの直後、ピクセルシェーダーの直前で、もう一つ (レイト Z) はピクセルシェーダーの後に存在する (ステンシルテストも同様)。シェーダーがサンプルを殺す機構を使っていたとしても理論上はアーリー Z ステージでデプステストができることに注意してほしい。気を付ける必要があるのは書き込みだけである。アーリー Z テストが本当に行えなくなる唯一のケースはピクセルシェーダーがデプスバッファに出力を書き込むときであり、そのときアーリー Z ユニットは単純に何もできない。
伝統的に、API はこういった "早抜け" のロジックが一切存在しないものと決め込んでいた。オリジナルの API モデルにおいて Z/ステンシルは後ろの方にあるステージであり、アーリー Z のような最適化は必ず機能的に 100% このモデルと一貫する形で行う必要があった。言い換えれば、ドライバはアーリー Z が適用可能なケースを検出した上で観測可能な違いが存在しないときに限ってそれを有効化しなければならなかったのである。現在この隔たりは縮まっている: DX11 ではシェーダーがアーリー Z に関して "安全" でないプリミティブを使っていたとしても、そのパイプラインで「アーリー Z を強制する」と宣言できる。またデプスに書き込むシェーダーは補間された Z の値が保守的である (アーリー Z テストによる破棄を行ってよい) と宣言することもできる。
Z バッファ/ステンシルバッファへの書き込み: 全貌
よし、ちょっと落ち着こう。説明した通り、パイプラインには Z バッファ/ステンシルバッファへ書き込む部分が二つ──アーリー Z とレイト Z が──存在する。任意のシェーダーステートとレンダーステートの組み合わせ一つに注目する限り──つまり、定常状態でなら──、これは正しく動く。しかし現実の動作はそうなっていない。実際にはフレームごとに数百から数千のバッチが描画され、シェーダーステートとレンダーステートは何度も切り替わる。そういったシェーダーのほとんどではアーリー Z を行えるが、一部では行えない。アーリー Z を行うシェーダーからレイト Z を行うシェーダーに切り替わるのは問題ない。しかしレイト Z からアーリー Z への切り替えは、そのアーリー Z が一つでも書き込みを行うとき問題となる: アーリー Z は、当然、レイト Z より前にある──そのために追加したのだから! つまり、アーリー Z の処理を開始したシェーダーが笑顔でデプスバッファに書き込んだとしても、パイプラインの下流では古いシェーダーがレイト Z を処理中で、同じ瞬間同じ場所に書き込もうとするかもしれない──典型的な競合状態である。どうすればこれを修正できるだろうか? 選択肢はいくつかある:
- フレーム内 (あるいは最低でも同じレンダーターゲットに対する一連の操作内) でアーリー Z からレイト Z に移るときは、パイプラインをフラッシュしなければならない次の地点までレイト Z に留まる。これは正しく動作するが、アーリー Z が不必要に遠い場合シェーダーのサイクルが大量に無駄になる。
- レイト Z シェーダーからアーリー Z シェーダーに移るとき (ピクセル) パイプラインをフラッシュする。これも正しく動作するが、上手いやり方というわけでもない。この方法だとシェーダーサイクル (およびメモリ帯域) は無駄にならないが、代わりにストールが起こる──大きな改善ではない。
- しかし実際の実行で Z への書き込みが二か所で同時に起こるのは厄介な状況でしかない。もう一つの選択肢はアーリー Z で Z への書き込みを行わないことだ。この方法を使うときはアーリー Z で保守的な Z テストを行うよう注意しなければならない! こうすれば競合状態が避けられるが、現在ディスパッチされているピクセルの Z への書き込みが少し後になってから起こるので、アーリー Z テストの結果が古くなる可能性が生じる。
- Z への書き込みを見張って正しい順序を強制する個別のユニットを使う。アーリー Z とレイト Z は両方ともこのユニットを通過しなければならない。
どの方法でも上手く行くが、それぞれ利点と欠点がある。ここでも私は現代的なハードウェアがどれを使っているか正確なことを知らないが、後ろの二つの選択肢のどちらかであると信じるだけの確かな証拠を持っている。特に、後で (パイプラインの後ろで) 最後の選択肢を実装するのに適した機能ユニットについて触れる。
しかし、私たちは Z テストを全てピクセル単位で考えている。もっと効率良くできないだろうか?
階層的 Z/ステンシルテスト
ここでのアイデアはラスタライザで使ったタイルのトリックと同じである。ピクセルのレベルに降りることなくタイル全体を一度に Z テストに引っかかったとして破棄するということだ! ここでは厳密に保守的なテストを行う。つまりテストが「このタイルには Z/ステンシルテストを通過するピクセルがあるかもしれない」と判定したときでもそういったピクセルは存在しない可能性があるが、テストを通過するピクセルが存在するときに「全てのピクセルは破棄される」と判定することはない。
Z の比較モードに「less」「less-equal」「equal」のいずれかを使っているとする。この場合タイルごとにそのタイルへ書き込まれた Z の最大値を保存する必要がある。その上で三角形をラスタライズするとき、アクティブな三角形が現在のタイルへ書き込もうとしている Z の最小値を計算する (簡単で保守的な近似は、補間された Z を現在のタイルの四つ角で計算して最小値を取るというものだ)。このとき三角形内の最小の Z が現在のタイルに対する保存された最大の Z より大きいなら、その三角形は完全に隠れることが保証される。これを行うにはタイルごとに Z の最大値を管理して、新しいピクセルが書き込まれるたびにそれを更新しなければならない──しかしここでも、その値は完全に最新のものでなくても構わない。今考えている Z テストは「less」系統だから、Z バッファの値は小さくしかならない。タイルごとに保存される Z の最大値が古くなっていたとしても、破棄レートが最良より少し悪くなるだけで、他に問題は起こらない。
「greater」「greater-equal」「equal」の Z テストに対しても (最大と最小を入れ替えれば) 同じことが行える。簡単に行えないのは「less」ベースのテストから「greater」ベースのテストへの変更で、これはそこまでに管理してきた情報が役立たずになるためだ (less ベースでは Z の最大値が、greater ベースでは Z の最小値がタイルごとに必要となる)。デプスバッファ全体をループして全てのタイルに対して最大値/最小値を再計算しなければならないだろうが、実際の GPU は階層的 Z テストを (次のクリアまで) オフにすることで対処する。つまり: やらない方がいい。
ここで説明した階層的 Z テストのロジックと同様に、最近の GPU は階層的なステンシル処理も持っている。しかし階層的 Z テストと異なり、この話題に関する公開された文献を私はほとんど見たことがない (私がそのような文献に出会ったことがないという意味だ──もしかしたらあるかもしれないが、私は知らない)。私はゲーム開発者なので内部で使われるアルゴリズムが説明された低水準の GPU ドキュメントにアクセスできるのだが、正直に言って、分厚い NDA と共にやって来る様々な GPU のドキュメントだけが唯一のソースとなるような話題に関して書いてよいものかどうか、私はいまいち自信がない。そこで代わりに、制御された状況で特定の種類のステンシルテストを非常に効率良く行えるようにできる魔法の妖精の粉が存在するのだとぼかして記すだけに留める。もしどうしても気になるなら、自分で考えてもらいたい。そんな人がいるとはあまり思えないが──階層的ステンシルユニットに親を殺されて、仇を打つためにその弱点を探しているとかでもない限りは。
全てをまとめる
よし、これで必要なアルゴリズムと理論が揃った──この新しいオモチャを今までに作ってきたものに取り付ける方法を見ていこう!
最初に Z とアトリビュートの補間のために三角形セットアップで追加の処理が必要になる。これに関してできることはあまりない──三角形セットアップで仕事が増える、そういうものだ。その後には粗いラスタライズが続く。これに関しては前章で説明した。
それから階層的 Z テストがある (ここでは less スタイルの比較を使うとする)。これは粗いラスタライズと細かいラスタライズの間に実行したい。まず各タイルに対して Z の最小値の近似値を計算するロジックが必要である。またタイルごとの Z の最大値を保存する必要もあるが、これは正確でなくても構わない: 切り上げを使う限り、いくらでもビットを削ることができる! いつも通り、ここには早い段階での破棄の効率と空間量の間にトレードオフがある。また理論上は Z の最大値を通常のメモリに保存することもできるはずだが、階層的 Z テストでレイテンシが大きく増加するのは避けたいはずなので、実際にそうしている GPU は存在しないと思われる。もう一つの選択肢は階層的 Z テスト専用のメモリをチップに載せるというもので、通常はキャッシュを構成するのと同じ SRAM が使われる。Z が 24 ビットなら、実用上十分な精度を持つ Z の最大値は圧縮エンコードを使えばおそらく 10 ビットから 14 ビットで保存できるだろう。8x8 タイルを仮定すれば、このとき 1 メガビット (128 キロバイト) の SRAM で最大 2048x2048 の解像度をサポートできる──実現可能なオーダーの値に思える。この SRAM は固定サイズでありチップ全体で共有されるので、コンテキストスイッチで失われることに注意が必要である。また、このメモリに誤ったデプスバッファをいくつもアロケートすると、実際に利用されるデプスバッファで階層的 Z テストを使えなくなり、困った状況となる。これはこういうものなので仕方がない。ハードウェアベンダーがよく「重要なレンダーターゲットとデプスバッファを最初に作るように」と言っている裏には、こういった事情がある: この種のメモリは限られた量しか存在しないので、無くなったらそれまでだ (後述するように、こういったメモリは他にもある)。なお、この判断は必ずオールオアナッシングというわけでもない: 例えば非常に大きなデプスバッファを作成したときは、Z の最大値用のメモリが足りないために左上の 2048x1536 ピクセルにだけ階層的 Z が使われる可能性がある。これは理想的ではないが、階層的 Z テストを完全に無効化するよりはずっと優れている。
ところで、Real-Time Rendering にはこの話題に関して「GPU は二つより多いレベルを持つ階層的 Z バッファを使っているようである」とあるが、これは正しくないと私は思う。理由は多レベルのラスタライザが使われていないと私が考えたときと同様で、レベルを追加すれば簡単なケース (大きな三角形) はさらに高速になるものの、小さい三角形にはレイテンシと不要な処理が追加されるためだ。一つの 8x8 タイルに収まる三角形のレンダリングするとき 8x8 より粗い階層レベルでは自明な破棄 (または受理) が一度行われるだけなので、そのレベルは単なるオーバーヘッドでしかない。またここでも、ハードウェアでは階層が少なくても性能に大きな影響はない: メモリ帯域や貴重なリソースを追加で消費するのでない限り、厳密に必要な量よりも多く (常識的な程度の) 計算を行うのは大きな問題にならない。
階層的ステンシルテストも細かいラスタライズの前に行われるはずであり、その動作はほぼ間違いなく階層的 Z テストと同様である。この処理は雲の上で魔法の妖精の粉を使って愛を込めて実行されると前に説明したから、実際のハードウェアは使われずに正確に正しい結果が一瞬で計算されることだろう。さぁ次に進もう。
この後には細かいラスタライズがあり、それからアーリー Z がある。アーリー Z に関して言及しておくべき重要なポイントがさらに二つある。
API オーダーの逆襲
ここまでの部分では、プリミティブが送られる順序に関してあまり気にかけてこなかった。そもそもプリミティブの順番が意味を持つ話題はこれまでに存在しなかった: 頂点シェーディング、プリミティブアセンブリ、三角形セットアップ、ラスタライズはどれも、プリミティブの順序が動作に影響しない。しかし Z テストでは話が違う。例えば「less」や「lessequal」の Z 比較モードでは、ピクセルが届く順番が非常に重要な意味を持つ。ここで手違いが起こると、結果が変化してしまって非決定的な振る舞いが起こりかねない。さらに重要なこととして、仕様には、操作の実行順序を変更してよいのはアプリケーションからその変更が見えないときのみと定められている。このため Z プロセッシングでは順序が重要であり、私たちは三角形が正しい順序で Z プロセッシングに届けられることを保証しなければならない (このことはアーリー Z とレイト Z の両方で言える)。
こういったケースでは、パイプラインを後ろに見ていって正しい順序にプリミティブをソートできる場所がないかを探すことになる。現在のパスではプリミティブアセンブリがよさそうだ: つまりここでなら、頂点シェーディングを終えたブロックからプリミティブの組み立てを始めるときに、アプリケーションから API に送られたのと同じ順序になることを保証しながら組み立てを行える。これによってストールが少し多く起きるかもしれない (PA バッファ内の頂点ブロックに次のプリミティブが含まれないと、PA は組み立てを始めることができない) が、それは結果を正しくするための犠牲である。
メモリ帯域と Z 圧縮
二つ目のポイントは Z/ステンシルのテストが非常に帯域を食うことだ。これには理由がいくつかある。第一に、このテストはラスタライザが生成する本当に全てのサンプルに対して実行される (当然 Z/ステンシルが有効であることを仮定している)。シェーダーやブレンドといった処理は、早い段階で行われるこれらのテストから恩恵を受ける。しかしたとえ Z テストで破棄されるピクセルであっても、(階層的 Z テストで除かれない限り) Z バッファからの読み込みが一度は必要になる。この事実は変えようがない。もう一つの大きな理由は、マルチサンプリングが有効だと Z バッファ/ステンシルバッファがサンプルごとになることだ: ということは 4x MSAA なら Z テストが消費するメモリ帯域が四倍に? MSAA を使わない場合でさえ利用されるメモリ帯域は相当な量なのに、これは深刻なニュースだ。
これに対処するため GPU は Z バッファの圧縮を行う。アプローチは様々あるが、一般的なアイデアは共通している: 常識的な大きさの三角形をレンダリングするとき、多くのタイルに含まれる三角形は一つ (あるいは二つ) だけである。もしそうなら、タイル全体に対する Z の値を保存するのではなく、そのタイルを覆う三角形の平面方程式をそのまま保存すればよい。その平面方程式は実際の Z のデータよりも小さくなる (ことが期待される)。MSAA を使わないとき一つのタイルは 8x8 のピクセルブロックなので、タイルを完全に覆う三角形はそれなりに大きくなければならない。しかし 4x MSAA を使うとタイルは事実上 4x4 に縮むので、タイルを覆うのは簡単になる。また二つの三角形のサポートなどを追加する拡張手法も存在するものの、常識的なサイズのタイルに対しては、帯域は減らすために二つや三つよりずっと多い三角形に対応する必要はない: 平面の方程式とカバレッジマスクを追加するのにもコストはかかる!
ともかく、要点はこれだ: この圧縮は、正しく動作すれば、完全にロスレスである。しかし全てのタイルに適用できるわけではないので、タイルが圧縮されているかどうかを書き留めておく追加のスペースが必要になる。これは通常のメモリにも格納できるが、そうすると Z の値を読み込むのにメモリのラウンドトリップ二回分のレイテンシを待たなければならなくなる。これはまずい。そこでここでも、タイルごとに数 (1~3) ビットを格納する専用の SRAM 領域が用意される。一番単純なケースでは「圧縮されている」と「圧縮されていない」を表すフラグがそこに格納されるが、圧縮モードなどの高度な情報も追加される場合もある。Z 圧縮のナイスな副作用が、高速な Z クリアが可能になることである: 例えば Z=1 にクリアするなら、全てのタイルに「圧縮されている」フラグを設定して平面方程式を定数 Z=1 の三角形にすれば済む。
テクスチャサンプラーにおけるテクスチャ圧縮と同様、Z 圧縮はメモリアクセスとキャッシュのロジックに織り込むことができ、アクセス時にその存在を意識する必要はない。また Z メモリのアクセスブロックに平面方程式を送信 (あるいは補間ロジックを追加) したくないなら、Z データから推論するようにして適当な整数差分符号化スキームを使うようにもできる。通常こちらの種類のアプローチではサンプルごとに数ビットを追加しないとロスレスな復元が行えないが、データパスが単純になりユニット間のインターフェースが綺麗になる。これはハードウェア技術者が大好きな特徴だ。
この章はここまでだ! 次章ではピクセルシェーディングとその周りで起こることについて話をする。
後書き
先述したように、補間されたアトリビュートのセットアップに関してはそれだけで一つの立派な記事が書けてしまう。今はこの話題を飛ばすことにする──いつか埋め合わせをするかもしれない。分からない。
Z プロセッシングが 3D パイプラインに組み込まれて久しいが、Z プロセッシングはほぼ絶え間なく深刻な帯域の問題を引き起こしてきた。この問題について人々は長い時間をかけて取り組んできたので、"プロダクションクオリティ" の GPU 用 Z バッファリングで使える大小様々なトリックが大量に存在する。ここでも、私は表面をなぞっただけに過ぎない。グラフィックスプログラマーが知っておくと役立つ情報だけを抑え気味に書こうと努力した。これは階層的 Z 計算や Z 圧縮の詳細に時間を割いていない理由である。こういった情報はハードウェアの詳細に非常に固有なものであり、世代ごとに少しずつ変化する。また、そもそもこういったハードウェアの詳細を活用する実践的な方法はまず存在しない: ある Z 圧縮スキームがあなたのシーンと上手く噛み合えば、メモリ帯域がいくらか節約されて他のことに回せるだろう。しかし噛み合わなかったとして、何ができるというのか? Z 圧縮の効率を上げるようにカメラ位置とジオメトリを変更する? そんなことはできそうにない。ハードウェア設計者は様々なアルゴリズムを利用して世代が変わるときにハードウェアを改善できるが、プログラマーはハードウェアを変更できないので、そういうものだと思って取り組むしかない。
本章ではパイプラインの注目しているステージでメモリアクセスがどのように動作するかを細かく説明していないが、これは意図的である。ピクセルシェーディングやその他のピクセル/サンプルごとの処理では高いスループットを達成するカギとなる手法があるのだが、それはパイプラインのもっと後ろで登場するのでまだ説明できない。全てはやがて明らかとなる :)