TT Lab
はじめる
学ぶ 学習パス コース

3D数学とソフトウェアラスタライザ

点一つが画面に届くまで

TT Labで続きを見る

一言でいうと

頂点は、モデル座標から始まって、ワールド、ビュー、クリップ座標を経て、wで割られてはじめて、画面座標になります。遠近感は、この除算1回から生まれます。

なぜ必要なのか

物体を作った人は、原点の近くに置いてモデリングします。シーンを飾る人は、その物体を世界のどこかに置きます。カメラを持つ人は、自分の位置と向きを基準に世界を見ます。画面に描く側は、ピクセル座標しか知りません。この4人の基準がすべて異なるので、座標系を乗り換える段階が必要です。

ここで、各段階を行列1つで表現できることが決定的です。モデル行列、ビュー行列、投影行列を先に掛けてMVP1つにしておけば、頂点ごとに4x4の乗算1回で済みます。頂点シェーダーがすることは、事実上この一行です。

どう動くのか

ビュー行列は、カメラを原点に、カメラが見る方向を-z軸に移す変換です。カメラを動かすのではなく、世界を逆に動かすと考えればよいです。作る方法は、カメラの3つの軸を求めて、それで行ベクトルを立てることです。

f = normalize(target - eye)      # 보는 방향
s = normalize(cross(f, up))      # 오른쪽
u = cross(s, f)                  # 진짜 위쪽 (up 은 대충 준 값이라 다시 구한다)

view = |  s.x   s.y   s.z   -dot(s, eye) |
       |  u.x   u.y   u.z   -dot(u, eye) |
       | -f.x  -f.y  -f.z    dot(f, eye) |
       |   0     0     0          1      |

3番目の行にマイナスが付く理由は、右手座標系でカメラが-zを見ると約束したからです。この約束を破ると、物体がカメラの後ろに置かれて、何も見えません。

投影行列は、錐台(frustum)の中の空間を、立方体に押し込みます。透視投影行列の最後の2つの行が、この仕事の核心です。

t = 1 / tan(fov/2)

proj = | t/aspect  0        0                 0             |
       | 0         t        0                 0             |
       | 0         0   -(f+n)/(f-n)   -2*f*n/(f-n)          |
       | 0         0       -1                 0             |

最後の行が(0, 0, -1, 0)であることを見てください。この行のために、結果のw成分が-z、つまりカメラからの距離になります。そのあと、x、y、zをこのwで割ると、遠くにある点ほど、画面の中央に引き寄せられます。これがパースペクティブ除算(perspective divide)で、遠近感は、すべてここから生まれます。行列の積だけでは、遠近は生じません。行列は除算を表現できないので、wに距離を載せておき、あとで割るという迂回路を使うのです。

除算のあとの座標を、正規化デバイス座標(NDC)と呼び、範囲は-1から1です。最後に、ここにビューポート変換をかけて、ピクセル座標に移します。yを反転させるのも、このときです(このあとのコードブロックの韓国語は、画面の幅と画面の高さを表すプレースホルダーです)。

頂点が、モデル座標からワールド、ビュー、クリップ座標を経て、錐台の外を先に切り落とし、そのあとwで割られてNDCになり、最後のビューポート変換で画面のピクセルになる順序を描いた図

sx = (ndc_x * 0.5 + 0.5) * 화면폭
sy = (1 - (ndc_y * 0.5 + 0.5)) * 화면높이

平行投影は、wに触れません。最後の行が(0, 0, 0, 1)なので、除算が恒等になり、そのため、距離と無関係に大きさが同じです。図面や2D UIが、これを使います。

除算の前に切り落とす必要がある理由も、ここから出てきます。wが0なら除算が破綻し、wが負なら、カメラの後ろにある点です。後ろにある点をそのまま割ると、符号が反転して、画面の反対側に、正常な座標として現れます。カメラの前後にまたがる三角形が、画面を横切って長く伸びるバグが、これです。そのため、パイプラインは、除算の前に、クリップ座標の状態で、錐台の外を切り落とします。クリップ座標という名前は、ここから来ました。

ビューポート変換には、深度もあわせて入ります。NDCのzを、深度バッファーが使う範囲に移す段階ですが、OpenGLは既定が-1..1で、Direct3DとVulkanは0..1です。この違いのために、投影行列の3番目の行がAPIごとに異なり、他人のコードをそのまま持ってくると、深度の判定が反転したり、半分が切り落とされたりします。

現場での姿

near平面を小さくしすぎると(0.001のような値)、深度バッファーの精度が近くに集まって、遠い物体どうしでZファイティングが生じます。深度の値が1/zに比例して分布するからで、この事実は、上の行列の3番目の行から、そのまま読み取れます。実務での解決法は、farを小さくすることではなく、nearを大きくすることです。

もう1つは、アスペクト比です。ウィンドウのサイズが変わったのに、投影行列のaspectを更新しないと、物体が引き伸ばされます。ウィンドウのサイズ変更のコールバックで、投影行列を作り直す必要がある理由です。

次のラボですること

ビュー行列と、透視・平行投影の行列を自分で作り、パースペクティブ除算とビューポート変換を経て、立方体の12本の辺を画面に描きます。同じ立方体を、透視と平行でそれぞれ描いて比べ、視野角を変えながら、物体が画面でどれだけ広く捉えられるかを、数字と絵であわせて確認します。