Try   HackMD

Projection Matrix

要把3D物體投射到電腦2D的螢幕上,要辦到這件事情就是要靠我們的投影矩陣來達成,基本上就是把原本在觀察座標(View coordinate)的頂點轉換到裁剪座標(Clip coordinate)。

投影矩陣分為兩種,一種是正交投影(Orthogonal)和透視投影(Perspective)兩種,差別在於正交投影裡面的平行線就是平行線永不相交;而透視投影比較貼近現實,越遠的東西會越小,且也會逐漸聚在中心成一個點。

因為裁剪坐標是齊次座標(Homogeneous coordinate)的一種,所以除了x, y, z之外還會多一個 w 分量,接著要轉回常見的三維座標,這時會做透視除法(Perspective division),將 x, y 與 z 都除以 w 分量,才會變成標準設備座標(Normalize Device Coordinate),然後再經過所謂的視口變換(Viewport transform),才會是最後的螢幕座標(Screen Coordinate)。

投影矩陣通常都會需要6個參數,分別是: left, right, bottom, top, near, far。

特別注意,我們假設在裁剪空間的一點

Pp(xp,yp,zp,wp),如果
xp,yp,zp
任數值大於
wp
或小於
wp
都會被裁剪掉,所以就不會呈現在畫面上了。

那至於投影矩陣是如何生成的,這裡將講解其原理過程:

以下投影矩陣將是以 OpenGL 為範例說明,想了解 DirectX 的話請看這裡。

Perspective

先來探討透視投影,因為這比較難。
我們假設6個參數分別為:

l,r,b,t,n,f,這些參數的意思就是告訴我們要把在x軸
[l,r]
映射到
[1,1]
;y軸
[b,t]
映射到
[1,1]
;z軸
[n,f]
映射到
[1,1]
的意思。
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

特別注意觀察空間是屬於右手座標系統,也就是說Z軸是朝攝影機(攝影機看向Z負軸),而NDC則是屬於左手座標系統,所以Z軸會是相反的,所以 near 和 far 要記得變負數。

可以知道的是說我們要把在觀察座標上的點映射到我們的近平面上,我們先不要管 y 的部分先看 z 軸,它的部分很簡單,就是統一變成

n,但 x 怎麼處理呢?我們可以利用相似三角形(Similar Triangles),下圖中有咖啡色和紫色框起來的三角形,這兩個三角形的三個角度都一樣,只是長度不一樣,所以根據性質這兩個三角形的三個邊彼此是有比例關係的,所以算法如下:
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

xpxe=nze=>xp=nzexe

所以說y軸的話同理,你只需要從不同角度去看(由x正軸向x負軸看去即可),得:

ypye=nze=>yp=nzeye

這時你會發現,

xp
yp
都是依賴於
ze
,因為它們都有除以
ze
,我們也知道當裁剪空間要轉換成 NDC 時也會除以
wp
,所以我們可以讓
wp=ze
,這樣也可推導出投影矩陣的第四列:
[0010]

而接下來換求

xndc
yndc
,因為
xp
yp
都是要映射至 NDC 的
1
1
之間,我們就採用線性轉換的方式(y=ax+b),記住
x
軸是
[l,r]
變成
[1,1]
y
軸是
[b,t]
變成
[1,1]

xndc=1(1)rlxp+β

帶入
(r,1)
進去
(xp,xndc)
化簡得:
1=2rrl+β

之後求
β

β=12rrl=rlrl2rrl=lrrl=r+lrl

所以我們得出式子:
xndc=2xprlr+lrl

y軸基本上也同理:

yndc=1(1)tbyp+β
帶入
(t,1)
進去
(yp,yndc)
化簡得:
1=2ttb+β

之後求
β

β=12ttb=tbtb2ttb=bttb=t+btb

所以我們得出式子:
yndc=2yptbt+btb

接著,代入我們剛剛算好的

xp
yp

xndc=2nxezerlr+lrl=2nxe(rl)(ze)r+lrl

=2nrlxeze+r+lrlzeze=(2nrlxe+r+lrlzexc)1ze


yndc=2nyezetbt+btb=2nye(tb)(ze)t+btb
=2ntbyeze+t+btbzeze=(2ntbye+t+btbzeyc)1ze

如此一來我們就可以推導出我們的投影矩陣的第一二列了:

[2nrl0r+lrl002ntbt+btb00010]

現在我們只要找出第三列即可,但與先前不一樣的是,因為我們把觀察空間中的

z 值統一映射到近平面
n
上,但我們還是需要獨一的
z
值來進行後續的深度測試(Depth test)。這邊
z
值並不依賴於
x
y
值,所以我們只能借助
w
來取得
zndc
ze
之間的關係:
[xcyczcwc]=[2nrl0r+lrl002ntbt+btb000AB0010][xeyezewe],zndc=zc/wc=Aze+Bweze

在觀察空間,

we等於1,所以說等式會變成這樣:
zndc=Aze+Bze

現在我們只需要找出A跟B的值就好,我們代入

(ze,zndc)=(n,1)(f,1),我們會得到以下方程式:
An+Bn=1=>An+B=nAf+Bf=1=>Af+B=f

我們藉由等式1可知:
B=Ann

代入等式2後可得:
Af+(Ann)=f=>(fn)A=f+n

A=f+nfn

然後再把A代回等式1求B:
f+nfnn+B=n=>B=nf+nfnn=(1+f+nfn)n=fn+f+nfnn=2fnfn

恭喜,我們將整個透視矩陣求出來啦:

[2nrl0r+lrl002ntbt+btb000f+nfn2fnfn0010]

然而我們可以再化簡,通常投射矩陣的方錐體(Frustum)通常是對稱的,所以說

r=l,
t=b
,所以我們可以得出以下結論:
r+l=0,rl=2r;t+b=0;tb=2t;

所以最終的透視矩陣為:

[nr0000nt0000(f+n)fn2fnfn0010]

特別注意

ze
zndc
的關係,它們彼此之間是一個非線性的關係,所以說當一個物體很近時它的
z
精度會很高,但距離一拉長精度卻會大幅降低。

Field Of View

而現今的透視投影需要的是四個參數:視野廣度(FOV)、畫面比例(Aspect Ratio)、近平面

n 以及遠平面
f
。視野廣度可以代表人眼睛的可見範圍,通常指的是【垂直視野的夾角】,通常設定為
45
,因為看起來比較正常,FOV越小可見範圍就會越小,所以畫面看起來就像是放大(Zoom In)的感覺,而數值越大看起來畫面就會開始變形。注意:FOV不可大於等於180度。

人的垂直視野大概是

40,最大可到
70

所以說知道了FOV我們就可以求出投影矩陣需要的

t
b
,我們使用三角函數的方式來推算:
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

我們如果要求
t
值,就需要知道
BC
長度為何,而我們可以發現,它是
CAB
的對邊(Opposite),所以
AB
為鄰邊(Adjacent)、
AC
為斜邊(Hypotenuse);而我們知道
tan
是對邊除以鄰邊,那我們只要將
tan
與鄰邊相乘幾可求出對邊長,至於鄰邊長為何,我們可以知道它就是
n
,公式如下:
tan(FOV2)=BCAB=tn=>t=tan(FOV2)n

因為是相呼對稱,所以
b=t
,如此一來我們垂直高度已經求出了!。

再來,就是要求水平寬度,如果你的畫面(攝影機、螢幕、視窗大小)是正方形的,那恭喜你很簡單,寬度等於高度,所以:

r=t,l=r
但現今的螢幕通常是長方形的,比例也不完全相同,所以我們需要長寬比(Aspect Ratio),透過它來推算我們的寬應該要是多少:
r=tAspect,l=r;

注意我們這邊所講的長寬比,預設都是寬除以高哦!

所以說,我們的透視矩陣變為:

[ntan(FOV/2)nAspect0000ntan(FOV/2)n0000(f+n)fn2fnfn0010]
然後化簡最終就變成:
[1tan(FOV/2)aspect00001tan(FOV/2)0000(f+n)fn2fnfn0010]

因為 FOV 度數會除以 2,所以當 FOV 為

180 時,會變成
tan90
,注意這在數學是無意義的,而當度數大於180時,
tan
出來的數值都會是負的,這也不合邏輯。

Code

程式碼如下(Implement on C++):

glm::mat4 GetPerspectiveProjMatrix(float fovy, float ascept, float znear, float zfar) { glm::mat4 proj = glm::mat4(1.0f); proj[0][0] = 1 / (tan(fovy / 2) * ascept); proj[1][0] = 0; proj[2][0] = 0; proj[3][0] = 0; proj[0][1] = 0; proj[1][1] = 1 / (tan(fovy / 2)); proj[2][1] = 0; proj[3][1] = 0; proj[0][2] = 0; proj[1][2] = 0; proj[2][2] = -(zfar + znear) / (zfar - znear); proj[3][2] = (-2 * zfar * znear) / (zfar - znear); proj[0][3] = 0; proj[1][3] = 0; proj[2][3] = -1; proj[3][3] = 0; return proj; }

注意要此程式要 include glm,另外 OpenGL 採用的矩陣是 Column-Major Order(指的是記憶體的 Layout)。

Orthogonal

正交投影相對來說就簡單了許多,它就像是一個長方體你直接擷取,裡面所有東西都要投射到近平面上,但完全不會產生越遠的物體越小這種視覺效果,平行線就是平行,原理如下:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

所以說三軸基本上都是簡單的線性轉換,

x 軸是
[l,r]
變成
[1,1]
y
軸是
[b,t]
變成
[1,1]
z
軸是
[n,f]
變成
[1,1]

xndc=1(1)rlxe+βyndc=1(1)tbye+βzndc=1(1)f(n)ze+β

基本上就是各別帶入

(r,1)
(t,1)
(f,1)
去計算,這邊過程就不特別贅述了,最後求出的式子為:
xndc=2xerlr+lrlyndc=2yetbt+btbzndc=2zefnf+nfn

w
值對於正交投影也不用有什麼特殊計算,就繼續等於 1 即可,所以正交投影矩陣為:
[2rl00r+lrl02tb0t+btb002fnf+nfn0001]

那如果說是相互對稱的話,

r=l,
t=b
,所以我們可以得出以下結論:
r+l=0,rl=2r;t+b=0;tb=2t;

所以最終的正交投影矩陣為:

[1r00001t00002fnf+nfn0001]

Code

程式碼如下(Implement on C++):

glm::mat4 GetOrthoProjMatrix(float left, float right, float bottom, float top, float near, float far) { glm::mat4 proj = glm::mat4(1.0f); proj[0][0] = 2 / (right - left); proj[1][0] = 0; proj[2][0] = 0; proj[3][0] = - (right + left) / (right - left); proj[0][1] = 0; proj[1][1] = 2 / (top - bottom); proj[2][1] = 0; proj[3][1] = - (top + bottom) / (top - bottom); proj[0][2] = 0; proj[1][2] = 0; proj[2][2] = -2 / (far - near); proj[3][2] = - (far + near) / (far - near); proj[0][3] = 0; proj[1][3] = 0; proj[2][3] = 0; proj[3][3] = 1; return proj; }

Inverse Depth buffer

我們可以在片段著色器中獲取深度值,該值介於0到1之間:

gl_FragCoord.z

一般來說我們的投影矩陣就會把物品的

z 值映射到近平面與遠平面之間,預設就是非線性的,如果想要將深度值改為線性模式,我們就必須要先反轉掉投影矩陣的轉換。
首先我們先將深度值從 Screen coordinates 轉為 NDC,這邊只是做做簡單的域值轉換,
[0,1]
[1,1]
:
zndc=2zw1;

目前我們只是從 Window Coordinates 轉換成 Normalized Device Coordinates,而我們必須逆轉換投影矩陣對

ze 的非線性轉換,我們知道它的公式為:
zndc=(f+nnfze+2fnnf)1ze

化簡後得:
zndc=ze(f+n)+2fnze(nf)

接著開始逆向推倒:

zndcze(nf)=(ze(f+n)+2fn)=>zndczenzndczef=zefzen2fn=>zndczenzndczef+2fn=zefzen=>2fn=zefzenzndczen+zndczef=>2fn=ze(f+n+zndcnzndcf)=>2fn(f+n+zndcnzndcf)=ze=>ze=2fn(f+nzndc(fn))

最後,我們所求出來的

ze 是在觀察世界,相機面向負
z
軸,所以
ze
為負,但用於計算深度時,我們要將其乘上
1
,所以實作於 code 時語法如下:

float z = gl_FragCoord.z * 2.0 - 1.0; float linearDepth = (2.0 * far * near) / (far + near - z * (far - near));

Reference

http://www.songho.ca/opengl/gl_projectionmatrix.html
https://www.scratchapixel.com/lessons/3d-basic-rendering/perspective-and-orthographic-projection-matrix/opengl-perspective-projection-matrix
https://learnopengl.com/Advanced-OpenGL/Depth-testing

tags: OpenGL