WebGPUやWebGL(Three.js)を使い3Dを表現したい場合に、必ず出てくる3DCGの概念としてカメラがあります。
カメラを定義することで、頂点を画面描画用の正規化デバイス座標(NDC)空間へ変換し、更に立体的に見せることができます。
実際にVertex Shaderでは、カメラはProjection MatrixとView Matrixという行列で表現され、頂点座標に作用させます。
pos_clip = projectionMatrix * viewMatrix * modelMatrix * vec4f(position, 1);
Projection Matrixは、射影行列と呼ばれます。
射影行列とは、カメラから見た3D空間を、画面に表示するための座標空間へ変換する行列です。
一般的な3DCGを扱う数学ライブラリには、射影行列を得る関数が用意されていると思いますが、「その行列がどのようにして求められたのか」については追い求めず、ずっと射影行列を使ってきました。
なんとなく使っていたこの魔法の行列を、今回は求めてみようと思います。
概要
射影行列には、正射影行列(Orthographic Projection Matrix)と透視投影行列(Perspective Projection Matrix)があります。
正射影と透視投影の違いは、奥行き方向へのスケールの違いの有無です。
正射影では、奥行きによるスケールの違いがなく、言い換えると実際の寸法や比率が保たれるため、工業図面やCADなどで使用されます。
透視投影では、奥行きによるスケールの違いがあるため、人間の目で見た景色と同じような現実に近い表現ができます。
左: 正射影, 右: 透視投影
この両方の射影行列について求めていきます。
正射影行列(Orthographic Projection Matrix)
正射影行列は、3D空間の指定した直方体領域を、画面描画用のNDC空間へ変換する行列です。
正射影の変換
x, y, zは以下のように表されます。
x∈[left,right]y∈[bottom,top]z∈[−near,−far]
これを、以下の正規化デバイス座標(NDC)の範囲へ変換する行列を作成します。
x∈[−1,1]y∈[−1,1]z∈[0,1]
Z方向のNDC範囲WebGPUでは、「0 ~ 1」の範囲をとります。
WebGLでは、「-1 ~ 1」の範囲をとります。
最終的な形状
正射影行列は、最終的に以下のように定義されます。
P=r−l20000t−b20000n−f10l−rr+lb−tt+bn−fn1
r:right,l:left,t:top,b:bottom,n:near,f:far
導出
正射影では、遠近による縮小がないため、NDCの成分はそれぞれ独立して以下のように考えることができます。
xndc=Axx+Bxyndc=Ayy+Byzndc=Azz+Bz
したがって、求める行列は、最初から以下の形であることがわかります。
P=Ax0000Ay0000Az0BxByBz1
頂点を考えると、以下のようになります。
Ax0000Ay0000Az0BxByBz1xyz1
計算すると、
xclip=Axx+Bxyclip=Ayy+Byzclip=Azz+Bzwclip=1
wclip=1なので、
xndc=wclipxclip=Axx+Bxyndc=wclipyclip=Ayy+Byzndc=wclipzclip=Azz+Bz
1. X方向を求める
X方向では、xがleftの時に、
xndc=l→−1
xがrightの時に、
xndc=r→1
に変換したいです。この条件を(1)式に入れると、以下の連立方程式が得られます。
Axl+Bx=−1Axr+Bx=1(1.1)(1.2)
(1.2)−(1.1)から、
Ax(r−l)=2Ax=r−l2
(1.2)にAxを代入すると、
(r−l2)r+Bx=1Bx=1−r−l2r=r−lr−l−2r=l−rr+l
ここまでを行列で表すと、
P=r−l20000Ay0000Az0l−rr+lByBz1
2. Y方向を求める
同様に、Y方向では、
xndc=b→−1xndc=t→1
に変換します。この条件を(2)式に入れると、以下の連立方程式が得られます。
Ayb+By=−1Ayt+By=1(2.1)(2.2)
これを解くと、
Ay=t−b2By=b−tt+b
ここまでを行列で表すと、
P=r−l20000t−b20000Az0l−rr+lb−tt+bBz1
3. Z方向を求める
同様に、Z方向では、
zndc=−n→0zndc=−f→1
に変換します。
ここで、WebGPUにおけるzndcの範囲と、カメラの向きが通常-Zを向いていることに留意します。
zndc∈[0,1],z=−near,z=−far
この条件を(3)式に入れると、以下の連立方程式が得られます。
−Azn+Bz=0−Azf+Bz=1(3.1)(3.2)
これを解くと、
Az=n−f1Bz=n−fn
行列で表すと、
r−l20000t−b20000n−f10l−rr+lb−tt+bn−fn1
となり、最初に求めたかった行列Pと一致します。
コード上の記述
コード上では以下のように定義されます。
コード上の正射影行列[
2 / (r - l), 0, 0, 0,
0, 2 / (t - b), 0, 0,
0, 0, 1 / (n - f), 0,
(r + l) / (l - r), (t + b) / (b - t), n / (n - f), 1,
]
WebGPU(WebGL)では、Column-major order(列優先)(新しいタブで開く)となるため、求めた行列Pが転置した形になります。
透視投影行列(Perspective Projection Matrix)
透視投影行列は、3D空間を人間の目やカメラに近い遠近感を持った形で、NDC空間へ変換する行列です。
正射影とは異なり、カメラから近い物体ほど大きく、遠い物体ほど小さく表示されます。
透視投影の変換
遠近感を作るためには、Z方向(つまり深度方向)の値で、X, Yを割ります。
また、Vertex Shaderの戻り値として渡すpositionはクリップ空間になっていて、Vertex Shader以降のパイプライン上でW成分で除算する(これを透視除算(Perspective Divide)という)ことによって、正規化デバイス座標空間(NDC)が得られます。
透視除算を利用する形で、遠近感を得るために割る値をW成分に設定すると都合が良くなります。
wclip=−zなので、
xndc∝−zx=wclipxyndc∝−zy=wclipy
最終的な形状
透視投影行列は、最終的に以下のように定義されます。
P=aspectf0000f0000N−FF−100N−FNF0
f=tan(2π−2θ),θ=fieldOfViewYInRadiansN:near,F:far
導出
以下は、透視投影図をYZ平面で見たときの図です。
透視投影の範囲図をYZ平面で見た時の図
1. Y方向を求める
上図から、「あるzにおいて、画面の一番上にあたるyはいくつか?」を考えます。
三角関数の関係から、
tan(2fovY)=−zymax
ymax=−z×tan(2fovY)
fを以下のように定義すると、
f=tan(2fovY)1
yは、以下のように表せます。
ymax=−fz
yndcは、[−1,1]の範囲になるので、任意のyにおいてyndcを考えると、
yndc=ymaxy=−zfy
透視除算により、wclipは以下のように定義したので、
wclip=−z
yclipは以下のように求まります。
yclip=yndc×wclip=−zfy×−z=fy
ここまでを行列で表すと、
P=?0000f0000?−100?0
クリップ座標を求める計算は以下のようになり、求めたyclipとwclipが行列Pで表されていることがわかります。
rclip=Pxyzw=?0000f0000?−100?0xyzw
2. X方向を求める
画面のアスペクト比を以下のように置きます。
a=heightwidth
アスペクト比を用いると、任意のxに対するxclipは、yclipと同様の手順で以下のように導出されます。
xmaxについてアスペクト比を用いると、
ymax=f−zxmax=a×ymax=af−z
xndcについてxmaxを用いると、
xndc=xmaxxxndc=−azfx
以上より、xclipは以下のように求まります。
wclip=−zxclip=xndc×wclip=−azfx×−zxclip=afx
ここまでを行列で表すと、
P=af0000f0000?−100?0
3. Z方向を求める
Z方向では、以下のような変換を考えます。
z=−near→zndc=0z=−far→zndc=1
行列では以下のように表されます。
af0000f0000A−100B0xyz1
zclipは、以下のようになります。
zclip=Az+B
Bは任意の変数として、仮定されたものになります。
求めたいzndcは、以下のような式で表されます。
zndc=wclipzclip=−zAz+−zB=−A−zBs=−B,c=−Aと置くとzndc=zs+c
この未知数s, cについて求めていきます。
nearでは、以下のように変換したかったので、
z=−near→zndc=0
求める式は、
−nears+c=0(3.1)
farでは、以下のように変換したかったので、
z=−far→zndc=1
求める式は、
−fars+c=1(3.2)
したがって、(3.1)と(3.2)の連立方程式を解けば、未知数s, cを求めることができます。
ここで、以下のように置きます。
near=N,far=F
sについて、(3.2)−(3.1)をすると、
−Fs−−Ns=1−F×Ns×N−−N×Fs×F=1s−N×FN−F=1s=F−NNF
cについて、(3.1)にsを代入すると、
−Ns+c=0c=s×N1=F−NNF×N1c=F−NF
以上より、zndcは以下のようになります。
zndc=zs+czndc=F−NNF×z1+F−NF
zclipは、以下のように求まります。
zclip=zndc×wclip,wclip=−zzclip=(F−NNF×z1+F−NF)×−zzclip=N−FFz+N−FNF
行列に表すと、以下のようになり求めたかった透視投影行列と一致します。
P=af0000f0000N−FF−100N−FNF0
コード上の記述
コード上では以下のように定義されます。
コード上の透視投影行列const f = Math.tan(Math.PI * 0.5 - 0.5 * fieldOfViewYInRadians);
const rangeInv = 1 / (zNear - zFar);
[
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, zFar * rangeInv, -1,
0, 0, zNear * zFar * rangeInv, 0,
]