立方体を画面に立てる
目標
カメラ行列と投影行列を自分で作り、パースペクティブ除算とビューポート変換を経て、立方体を画面に立てます。このラボが終われば、「頂点シェーダーがすること」を、一行で説明できるようになります。
なぜ重要なのか
3次元の点1つがピクセル座標になるまでに、4つの座標系を通ります。モデル、ワールド、ビュー、クリップです。各段階が行列1つなので、先に掛けてMVPにまとめることができ、そのためGPUは、頂点ごとに乗算1回だけを行います。
ここで最も重要なのは、遠近感が行列ではなく除算から生まれるという事実です。行列の積は線形の演算なので、除算を表現できません。そのため、投影行列は、最後の行を(0,0,-1,0)にして、結果のwにカメラからの距離を載せておき、そのあとにx・y・zをそのwで割ります。この迂回路を理解すれば、深度バッファーの精度がなぜ近くに集まるのか、near平面をなぜ軽々しく小さくしてはいけないのかも、あわせて解けます。
ステップ
/root/pipelineに、ツールボックスのgfxlib.pyとmat3d.pyを置きます。/root/pipeline/view.pyに、look_atを作ります。/root/pipeline/proj.pyに、perspectiveを作ります。/root/pipeline/screen.pyに、to_screenを作り、/root/pipeline/out/04-divide.txtを書きます。/root/pipeline/out/cube.pngに、立方体のワイヤーフレームを描きます。orthographicを加えて、/root/pipeline/out/ortho.pngを作ります。- 視野角3つで描いて、
/root/pipeline/out/07-fov.txtを書きます。
参考
- 絵は、
nohup python3 -m http.server 8080 -d /root/pipeline/out &で立てたあと、Webプレビューでhttp://localhost:8080/を開いて見ます。cube.pngとortho.pngを並べて見ると、違いがはっきりします。 - よくあるミスの1つ目は、投影行列の
m[3][3]を1にしてしまうことです。そうすると、wが距離と無関係になって、遠近が消えます。 - よくあるミスの2つ目は、
look_atの3番目の行のマイナスを抜かしてしまうことです。そうすると、カメラが反対を向いて、画面が空っぽになります。
描画のツールボックスを置く
/root/pipeline/gfxlib.pyを例のとおりに保存し、/root/pipeline/check.pyでテストパターンを描いて、/root/pipeline/out/00-check.pngを作ってください。パターンは、64x64の黒い背景に、(0,0)から(63,63)まで、白(255,255,255)の対角線を引き、そのあとに、(0,32)から(63,32)まで、赤(255,0,0)の横線を重ねて引いたものです。このラボは、行列のツール/root/pipeline/mat3d.pyもあわせて使います。
このラボからは、PNGエンコーダーを作り直しません。最初のラボで手で作ったものと同じコードを、ツールとして配ります。ここで学ぶのは、ファイル形式ではないからです。
ラボのPodにはボリュームがないので、前のラボで作ったファイルが残っていません。そのため、ラボごとに、ツールボックスを置き直すことから始めます。
Canvas(w, h, bg)を作り、line(x0, y0, x1, y1, rgb)で2本の線を引いたあと、write_png(path)で保存してください。交差点(32,32)が赤になるには、横線をあとで引く必要があります。
mat3d.pyは、前のラボで自分で作ったベクトル・行列の演算と同じものです。ここでは材料として受け取って使い、学ぶのは、カメラと投影です。
カメラを原点に引き寄せる
/root/pipeline/view.pyにlook_at(eye, target, up)を作って、4x4のビュー行列を返すようにしてください。3つの軸は、f = normalize(target-eye)、s = normalize(cross(f, up))、u = cross(s, f)で、3番目の行には、-fを入れます。
行列の各行がカメラの軸で、最後の列は、その軸の方向に、カメラの位置を戻す値です。つまり、row0 = (s.x, s.y, s.z, -dot(s, eye))です。
3番目の行だけ符号が逆なのは、右手座標系で、カメラが-z方向を見ると約束したからです。そのため、その行は、(-f.x, -f.y, -f.z, dot(f, eye))になります。
確認は簡単です。eye=(0,0,5)、target=(0,0,0)、up=(0,1,0)のとき、原点は(0, 0, -5)へ行く必要があります。カメラの前に5だけ離れた位置という意味です。
ベクトル演算は、mat3dのnormalize、cross、dot、vsubを使ってください。
錐台を立方体に押しつぶす
/root/pipeline/proj.pyにperspective(fov_deg, aspect, near, far)を作ってください。t = 1/tan(fov/2)とおいて、m[0][0]=t/aspect、m[1][1]=t、m[2][2]=-(far+near)/(far-near)、m[2][3]=-2*far*near/(far-near)、m[3][2]=-1、残りは0です。
最後の行が(0, 0, -1, 0)であることが、この行列のすべてだと言ってもよいです。この行のために、結果のw成分がカメラからの距離(-z)になり、次のステップの除算が、遠近感を作ります。
m[3][3]は0です。1にすると、wが距離と無関係になって、遠近が消えます。
t = 1/tan(radians(fov)/2)です。fovをラジアンに変換するのを抜かすと、視野角が見当違いになりますが、エラーは出ません。
wで割って画面座標へ
/root/pipeline/screen.pyにto_screen(clip, w, h)を作ってください。クリップ座標(x,y,z,w)を受け取り、ndc = (x/w, y/w)で割ったあと、sx = (ndc_x*0.5+0.5)*w_px、sy = (1-(ndc_y*0.5+0.5))*h_pxを、実数2つで返します。そして、perspective(60,1,0.1,100)で、ビュー座標の点A(1,0,-2)とB(1,0,-8)を、256x256の画面に移した値を、/root/pipeline/out/04-divide.txtに、A=sx,sy、B=sx,syの2行で書いてください(小数点以下6桁)。
2つの点は、xがどちらも1で、深度だけが異なります。除算を正しく行ったなら、遠いほうのBが、画面の中央(128)により近く出る必要があります。それが遠近感です。
syで、1から引いているのが、yの反転です。画像のyは下に向かって増えるからです。
mat3d.apply(P, (1,0,-2))は、w=1として掛けて、4つの成分を返します。その結果を、to_screenに入れてください。
立方体の12本の辺
/root/pipeline/cube.pyで、/root/pipeline/out/cube.png(256x256、黒い背景、白い線)を作ってください。立方体は、8つの頂点(±1,±1,±1)と、それをつなぐ12本の辺で、カメラはlook_at((2.5,2.5,2.5),(0,0,0),(0,1,0))、投影はperspective(60, 1, 0.1, 100)です。
辺は、2つの頂点の座標が1つの軸だけ異なる組です。8つの中からそのような組を選ぶと、ちょうど12個になります。
MVPは、mat3d.mul(P, V)です。モデル行列が単位行列なので省略したもので、掛ける順序は、投影が左です。
各頂点を1回ずつだけ変換して、画面座標のリストを作っておき、辺は、そのリストから取り出して、cv.lineでつないでください。頂点ごとに計算し直すと、同じことを2回行います。
平行投影と見比べる
/root/pipeline/proj.pyにorthographic(l, r, b, t, n, f)を加え、同じカメラで/root/pipeline/out/ortho.pngを作ってください。境界は、l=-2.5, r=2.5, b=-2.5, t=2.5, n=0.1, f=100です。
平行投影行列の最後の行は、(0, 0, 0, 1)です。wが常に1なので、除算が恒等になり、そのため、距離と無関係に大きさが同じです。
対角成分は、2/(r-l)、2/(t-b)、-2/(f-n)で、最後の列は、-(r+l)/(r-l)、-(t+b)/(t-b)、-(f+n)/(f-n)です。範囲を-1から1に移す、平行移動と拡大縮小を合わせたものです。
2つの絵を並べて見ると、違いが見えます。透視では、近い面が大きく、遠い面が小さいですが、平行では、向かい合う2つの面の大きさが同じなので、六角形のように見えます。
視野角を変えると何が変わるか
視野角60度・75度・90度で、同じ立方体を描いて、/root/pipeline/out/fov60.png、fov75.png、fov90.pngを作り、各絵で立方体が占める横幅(8つの頂点の画面のx座標の最大値から最小値を引いた値)を、/root/pipeline/out/07-fov.txtに、fov60=、fov75=、fov90=の3行で書いてください(小数点以下3桁)。
カメラの位置はそのままにして、視野角だけを変えます。視野角が広がると、より多くのものが画面に入るので、同じ物体は、むしろ小さくなります。3つの値が、小さくなっていく順序で出る必要があります。
幅は、絵の上で数えず、変換した頂点の座標から、直接求めてください。8つのsxのうち、最大値から最小値を引けば済みます。
前のステップのコードを関数にまとめて、視野角だけを引数で受け取るようにすれば、3回繰り返す必要がありません。