二つのシェーダーとその間
目標
頂点シェーダーとフラグメントシェーダーを、Pythonの関数として書き、その間にラスタライザーを置いて、実際に絵を取り出します。このラボが終われば、GLSLのコードを読むとき、各値がどこから来て、どこへ行くかが、見えるようになります。
なぜ重要なのか
シェーダーは、パイプラインのどこでも開いたものではありません。構造はそのままにして、頂点の段階とフラグメントの段階だけを開きました。そうしてはじめて、残り(ラスタライズ、深度判定、ブレンド)を、ハードウェアが引き続き並列で処理できます。
その制約が、シェーダーのルールを作ります。頂点シェーダーは、頂点を作ったり消したりできず、フラグメントシェーダーは、隣のピクセルの結果を見られません。その代わり、2つの段階の間の通り道が明確です。頂点シェーダーがvaryingで出せば、ラスタライザーが重心座標で混ぜて、フラグメントシェーダーに渡します。この構造を、手で一度作ってみれば、GPUプログラミングの制約が、なぜその形なのかが、理解できます。
ステップ
/root/stagesに、ツールボックスを置きます。/root/stages/vs.pyに、頂点シェーダーを作ります。/root/stages/varying.pyに、値の補間を作ります。/root/stages/fs.pyとpipeline.pyに、フラグメントシェーダーを作り、out/uv.pngを作ります。/root/stages/out/u1.png、u2.png、u3.pngに、ユニフォームだけを変えた絵を書きます。/root/stages/out/discard.pngに、フラグメントを破棄した絵を書きます。/root/stages/out/final.pngとout/07-counts.txtに、呼び出し回数を書きます。
参考
- 絵は、
nohup python3 -m http.server 8080 -d /root/stages/out &で立てて、Webプレビューでhttp://localhost:8080/を開きます。 - 1枚を描くのに、数秒かかります。フラグメントシェーダーが、2万回以上呼ばれるからで、その遅さ自体が、このラボで見るものの1つです。
- よくあるミスの1つ目は、
varyingsを補間せずに、最初の頂点の値をそのまま使うことです。そうすると、三角形が1色で塗られます。 - よくあるミスの2つ目は、画面座標に移すとき、yの反転を抜かすことです。絵が上下反転で出ます。
描画のツールボックスを置く
/root/stages/gfxlib.pyを例のとおりに保存し、/root/stages/check.pyでテストパターンを描いて、/root/stages/out/00-check.pngを作ってください。パターンは、64x64の黒い背景に、(0,0)から(63,63)まで、白(255,255,255)の対角線を引き、そのあとに、(0,32)から(63,32)まで、赤(255,0,0)の横線を重ねて引いたものです。
このラボからは、PNGエンコーダーを作り直しません。最初のラボで手で作ったものと同じコードを、ツールとして配ります。ここで学ぶのは、ファイル形式ではないからです。
ラボのPodにはボリュームがないので、前のラボで作ったファイルが残っていません。そのため、ラボごとに、ツールボックスを置き直すことから始めます。
Canvas(w, h, bg)を作り、line(x0, y0, x1, y1, rgb)で2本の線を引いたあと、write_png(path)で保存してください。交差点(32,32)が赤になるには、横線をあとで引く必要があります。
このラボでは、シェーダーが作ったピクセルを、絵として書き出すのに使います。
頂点シェーダー
/root/stages/vs.pyにvertex_shader(attr, uniforms)を作ってください。attrは{"position": (x,y,z), "uv": (u,v)}、uniformsは{"mvp": 4x4 행렬, "scale": 실수}です(プレースホルダーは、4x4の行列と実数です)。位置にscaleを掛けたあと、mvpを適用した4つの成分をgl_Positionとして、uvをそのまま入れた辞書をvaryingsとして、{"gl_Position": ..., "varyings": {...}}を返します。
頂点シェーダーは、頂点1つを受け取って、頂点1つを出します。作ったり消したりできません。そうしてはじめて、ハードウェアが、いくつ処理するかを先に知って、並列に分けられます。
attributeは、頂点ごとに異なる値で、uniformは、ドローコール全体で同じ値です。この区別が、GPUが値をどこに置くかを決めます。
行列の積は、m[r][0]*x + m[r][1]*y + m[r][2]*z + m[r][3]*1を、r=0..3について計算すれば済みます。点なので、wは1です。
varyingsは、フラグメントの段階へ渡す値です。ここでは、uv1つだけですが、実際のシェーダーでは、法線・色・接線などが、あわせて載ります。
ラスタライザーが値を混ぜる
/root/stages/varying.pyにinterpolate(bary, v0, v1, v2)を作ってください。baryは(u, v, w)の3つの実数、v0・v1・v2は、同じキーを持つvaryingsの辞書です。値がタプルなら成分ごとに、実数ならそのまま、u*v0 + v*v1 + w*v2を計算して、同じ形の辞書を返します。
この段階は、シェーダーではなく、ラスタライザーの仕事です。プログラムできない固定の段階ですが、私たちが自分で作ってみる理由は、頂点シェーダーの出力が、フラグメントシェーダーの入力になる通り道が、ここだからです。
値の種類を見分ける必要があります。isinstance(x, (tuple, list))で分ければ済みます。
重心座標の和が1なので、3つの頂点の値がすべて同じなら、結果も同じ値が出ます。この性質で、実装を確認できます。
フラグメントシェーダーと最初の絵
/root/stages/fs.pyにfragment_shader(varyings, uniforms)を作ってください。u, v = varyings["uv"]、k = uniforms["brightness"]とおいて、(255*(0.2+0.8*u)*k, 255*(0.2+0.8*v)*k, 255*(0.2+0.8*(1-u))*k)を返します。そして、/root/stages/pipeline.pyで、頂点3つの(-0.8,-0.8,0)/uv(0,0)、(0.8,-0.8,0)/uv(1,0)、(-0.8,0.8,0)/uv(0,1)を、単位行列のmvp、scale 1、brightness 1で描いて、/root/stages/out/uv.png(256x256、黒い背景)を作ってください。画面座標は、sx=(x*0.5+0.5)*256、sy=(1-(y*0.5+0.5))*256です。
パイプラインの順序は、次のとおりです。頂点ごとに頂点シェーダーを呼び、クリップ座標を画面座標に移し、三角形のバウンディングボックスを走査しながら重心座標を求め、内側ならvaryingを補間して、フラグメントシェーダーを呼びます。
フラグメントシェーダーは、ピクセルごとに呼ばれます。この三角形は、画面の3分の1近くを覆うので、2万回以上呼ばれます。頂点シェーダーは、3回だけです。この差が、性能の話の出発点です。
色の計算に0.2を足してある理由は、uvが0の場所でも、色が0にならないようにするためです。次のステップで、明るさを変えながら比べるときに必要です。
gl_Positionのwが1なので、ここでは除算が恒等です。遠近が入ると、その除算が生きてきます。
ユニフォームだけを変える
形状はそのままにして、brightnessだけを1.0、0.6、0.3に変えて、/root/stages/out/u1.png、u2.png、u3.pngの3枚を作ってください。
頂点データも三角形もそのままです。ユニフォーム1つだけが変わります。
3つの絵で、色が塗られた場所(黒くないピクセルの集合)は、完全に同じでなければなりません。形状が変わっていないからです。異なっていたら、頂点シェーダーやラスタライザーが、ユニフォームの影響を受けています。
一方、色は、目に見えて異なる必要があります。これが、ユニフォームの使い道です。同じメッシュを、別の材質や別の照明で何度も描くとき、頂点データを送り直さずに、ユニフォームだけを変えて描きます。
色の計算に0.2が足されているので、明るさ0.3でも、ピクセルが黒になることはありません。
フラグメントを破棄する
同じ三角形を描きますが、補間されたuvがu*u + v*v > 0.36であるフラグメントを、破棄して、/root/stages/out/discard.pngを作ってください(brightnessは1.0)。
破棄は、フラグメントシェーダーだけができることです。このフラグメントを、まったく使わないと宣言すれば、色バッファーにも深度バッファーにも、何も残りません。
木の葉や金網のように、穴の開いた表面を表現するときに使います。半透明とは違います。半透明は、後ろが透けて見えるように混ぜることで、破棄は、その位置に何も描かないことです。
破棄はタダではありません。discardがあるシェーダーは、色を計算してみないと、生き残るかどうかがわからないので、深度を先に判定する最適化を使えなくします。そのため、穴の開いた表面が、完全に不透明なものより高価です。
破棄された場所は、背景(黒)がそのまま残ります。
どの段階が何回呼ばれたか
破棄をオンにした状態で描き直して、/root/stages/out/final.pngを作り、/root/stages/out/07-counts.txtに、vertices=、triangles=、fragments_in=、fragments_out=の4行を書いてください。fragments_inは、三角形の内側に入ってきたフラグメントの数、fragments_outは、破棄して残った数です。
頂点シェーダーは、頂点の数だけ、フラグメントシェーダーは、画面を覆った面積の分だけ呼ばれます。この三角形は、頂点が3つなのに、フラグメントは2万個を超えます。
そのため、重い計算は、できるだけ頂点シェーダーに移して、補間に任せます。ただし、補間は線形なので、正規化が必要な値は、フラグメント側で、もう一度正規化する必要があります。
fragments_outは、最終的な絵で黒くないピクセルの数と、ちょうど同じでなければなりません。採点ツールが、絵を開いて数えて、突き合わせます。異なっていれば、どちらかが間違っています。
数字をでっち上げず、ループの中で数えてください。