シェーダーは二度呼ばれる
一言でいうと
頂点シェーダーは、頂点ごとに1回呼ばれて、クリップ座標と補間する値を出し、フラグメントシェーダーは、ピクセルごとに1回呼ばれて、色を出します。その間で、ラスタライザーが値を重心座標で混ぜます。
なぜ必要なのか
昔のグラフィックスハードウェアは、固定機能でした。照明モデルもテクスチャの合成方式も、ハードウェアに埋め込まれていて、開発者は、決められたつまみしか回せませんでした。新しい表現をするには、ハードウェアが変わるのを待つ必要がありました。
シェーダーは、その2つの地点を、プログラムできるように開いたものです。頂点をどこへ送るか、ピクセルを何色で塗るかを、開発者が書いたコードが決めます。重要なのは、どこでも開いたわけではないという点です。パイプラインの構造そのものはそのままにして、頂点の段階とフラグメントの段階だけを開きました。そうしてはじめて、残り(ラスタライズ、深度判定、ブレンド)を、ハードウェアが引き続き並列で処理できます。
どう動くのか
頂点シェーダーは、頂点1つを受け取って、頂点1つを出します。入力は2種類です。次のコードブロックの韓国語は、attributeが頂点ごとに異なる値(位置、法線、UV、頂点の色)、uniformがドローコール全体で同じ値(MVP行列、光源の方向、時間)という意味です。
attribute 정점마다 다른 값 — 위치, 법선, UV, 정점 색
uniform 드로우 콜 전체에서 같은 값 — MVP 행렬, 광원 방향, 시간
出力には、必ずクリップ座標(gl_Position)を含める必要があり、そのほかに、フラグメントの段階へ渡す値を、varying(今の呼び名ではout)として出します。頂点シェーダーは、頂点を作ったり消したりすることはできません。1つ入れば、1つ出ます。そうしてはじめて、ハードウェアが、いくつ処理するかを先に知って、並列に分けられます。
ラスタライザーは、プログラムできない固定の段階です。三角形を受け取って、その中のピクセルを選び出し、各ピクセルで、3つの頂点のvaryingを重心座標で混ぜて、フラグメントシェーダーに渡します。前のコースで自分で作ったのが、まさにこの段階です。
フラグメントシェーダーは、ピクセル1つを受け取って、色1つを出します。入力は、補間されたvaryingとuniform、そしてテクスチャです。ここでできる特別なことが1つあり、破棄(discard)です。このフラグメントを、まったく使わないと宣言すれば、深度バッファーにも色バッファーにも、何も残りません。木の葉や金網のように、穴の開いた表面を表現するときに使います。
1つ押さえておくことは、フラグメントとピクセルは違うという点です。フラグメントは、「ある三角形がこのピクセルの位置に作り出した候補」で、同じピクセルに、フラグメントが複数生じることがあります。そのうち、どれが最終的なピクセルになるかは、深度判定とブレンドが決めます。
段階が、この2つだけというわけではありません。頂点とフラグメントの間に、ジオメトリシェーダーとテッセレーションを挟めますが、これらは、頂点を増やしたり減らしたりできます。その代わり、個数が実行中に決まるので、ハードウェアが先に分けて配れず、はるかに遅くなります。そのため、実務では、どうしても必要な所にだけ使います。パイプラインの外側に、コンピュートシェーダーもあり、それは、ラスタライズと無関係に、任意の計算をGPUで回す通り道です。
もう1つ、知っておくことは、ユニフォームとアトリビュートを渡すコストです。ユニフォームは、ドローコールごとに1回設定すればよいですが、ドローコール自体が高価なので、同じシェーダーで物体1000個を描くとき、1000回ユニフォームを変えながら1000回描くのは、悪い設計です。インスタンシングは、物体ごとに異なる値を、アトリビュートのように渡して、1回のドローコールで処理する方法で、木や草を数万本描くシーンは、そうして作られます。
現場での姿
性能を見るときに、頂点の数とフラグメントの数を別々に数える理由が、ここにあります。頂点シェーダーは、頂点の数だけ、フラグメントシェーダーは、画面を覆った面積の分だけ呼ばれます。画面いっぱいを埋める三角形1つは、頂点3つに、フラグメント数十万個です。そのため、重い計算は、できるだけ頂点シェーダーに移して、補間に任せます。ただし、補間は線形なので、正規化が必要な値(法線のようなもの)は、フラグメント側で、もう一度正規化する必要があります。
もう1つは、破棄がタダではないという点です。discardがあるシェーダーは、深度を先に判定する最適化を使えなくします。色を計算してみないと、このフラグメントが生き残るかどうかがわからないからです。そのため、穴の開いた表面は、完全に不透明なものより高価です。
そして、シェーダーの間の契約は、名前と型でできています。頂点シェーダーがout vec2 uvで出せば、フラグメントシェーダーはin vec2 uvで受けます。この名前がずれると、つながりが切れますが、コンパイルは通って、値だけが0で入ってくる場合があり、見つけにくいです。最近のGLSLは、位置を数字で固定する方法(layout(location = 0))を提供して、この問題を減らします。
もう1つ、知っておくことは、分岐のコストです。GPUは、数十個のフラグメントを1つのまとまりとして、同じ命令を一緒に実行します。まとまりの中で、ifの結果が分かれると、両方の分岐をすべて実行して、不要な結果を捨てる方式で処理するので、条件文があっても速くならず、むしろ両方回るコストがかかります。まとまり全体が同じ側へ行けば、そのときは、実際に飛ばします。そのため、シェーダーの条件文は、画面で広くまとまって分かれるように設計するほうが、良いです。
次のラボですること
頂点シェーダーとフラグメントシェーダーを、Pythonの関数として書き、その間にラスタライザーを置いて、三角形を1つ描きます。uniformだけを変えて、同じ形状で異なる絵が出ることを確認し、discardで穴を開けます。最後に、頂点数・三角形数・フラグメント数を数えて、どの段階が何回呼ばれたかを、数字で見ます。