WebGL簡易教程(五):圖形變換(模型、視圖、投影變換)

  • 2019 年 10 月 4 日
  • 筆記

[toc]

1. 概述

通過之前的教程,對WebGL中可編程渲染管線的流程有了一定的認識。但是只有前面的知識還不足以繪製真正的三維場景,可以發現之前我們繪製的點、三角形的坐標都是[-1,1]之間,Z值的坐標都是採用的默認0值,而一般的三維場景都是很複雜的三維坐標。為了在二維視圖中繪製複雜的三維場景,需要進行相應的的圖形變換;這一篇教程,就是詳細講解WebGL的圖形變換的過程,這個過程同樣也適合OpenGL/OpenGL ES,甚至其他3D圖形介面。

可以用照相機拍攝照片來模擬這個圖形變換的過程,如果要對某個物體拍攝照片,大致過程如下:

  1. 準備物體,把物體放置在某個合適的位置;這個過程就是模型變換(model transform)。
  2. 準備照相機,把照相機移動到準備拍攝的位置;這個過程就是視圖變換(view transform)。
  3. 設置相機的焦距,或者調整縮放比例;這個過程就是投影變換(projection transform)。
  4. 對結果圖形進行拉伸或者擠壓,確定最終照片的大小;這個過程就是視口變換(viewport transform)。

而在WebGL/OpenGL中,具體的圖形變換流程如下所示[3]1 其中模型變換、視圖變換、投影變換是我們自己在著色器里定義和實現的,而視口變換一般是WebGL/OpenGL自動完成的。這就好像我們拍照的時候,需要自己去調整位置,相機鏡頭焦距,而成像的過程就交給相機。所以模型變換、視圖變換、投影變換這三者特別重要,另外附一張WebGL/OpenGL矩陣變換的流程圖[4]2

從上兩圖中可以發現,場景中的物體總是從一個坐標系空間轉換到另外一個坐標系空間。

  1. 局部坐標系(Local Space)指的是物體最初開始的坐標系;而世界坐標系(World Space)指的是物體與WebGL/OpenGL相機建立聯繫時的坐標系。這裡的局部坐標系與世界坐標系跟通常意義的不同,只有與WebGL/OpenGL相機建立了聯繫,才是這裡規定的世界坐標系。為了建立聯繫,經過的就是模型變換。
  2. 進入世界坐標系空間之後,物體與WebGL/OpenGL相機雖然建立了聯繫,但是並沒有進一步確定觀察物體的狀態。這個時候就需要調整相機位置姿態,也就是視圖變換,轉換成視圖坐標系(View Space),也可以簡稱為人眼坐標系(Eye Space),或者照相機坐標系(Camera Space)。
  3. 在人眼坐標系空間中,雖然物體就在眼前了,但是還需要進一步去確定可視空間。就像人眼是把水平視角大約200度左右的場景投影到視網膜中,人才能看清物體的那樣,WebGL/OpenGL需要經過投影變換,才能正確的顯示場景。這個過程通常還順帶進行了場景的裁剪,將可視空間範圍外的東西去掉,所以投影變換後的坐標系就是裁剪坐標系(Clip Space)。
  4. 最後一步就是通過視口變換,從裁剪坐標系轉換成螢幕坐標系,得到渲染結果。這一步由WebGL/OpenGL自動完成。

在參考文獻[2]中描述的WebGL/OpenGL整個圖形變換過程的坐標系和單位: 3

其流程與前文論述的基本一致,可以看到投影變換之後的過程不是那麼簡單,還需要將得到的齊次裁剪坐標做透視除法(除以w),做剪切和視口/深度範圍變換,光柵化等。

其中,用戶/著色器變換(也就是教程要具體詳述的模型變換、視圖變換和投影變換)包含坐標系和單位如下所示: 4

2. 詳論

在一個三維軟體中瀏覽一個三維物體時候,總是會提供給用戶平移、縮放和旋轉的交互操作,而這正是模型變換的內容。在圖形學的範疇當中,平移變換、旋轉變換屬於剛體變換,縮放和旋轉屬於線性變換,剛體變換和線性變換又屬於仿射變換,而仿射變換也可以看成投影變換的一種[5]5

也就是說這些圖形變換,本質上可以看成是同一種變換;在數學上,可以使用矩陣來描述這種變換。並且,為了兼容各種變換的特殊性,會在3維的基礎上再加一維,使用4維的向量和矩陣。4維向量表述一個點(x,y,z,w)等價於三維向量(x/w,y/w,z/w),這就是前面提到的齊次坐標。

具體來說,對於空間某個點v0(x0,y0,z0,1),經過空間影像變換後得到新的點v1(x1,y1,z1,1),那麼存在這樣一個4行4列的矩陣M:

[ M= left[ begin{matrix} a & b & c & d \ e & f & g & h\ i & j & k & l\ m & n & o & p\ end{matrix} right] ]

滿足:

[ M*V0=V1 ]

展開這個式子,有式(1):

[ left[ begin{matrix} a & b & c & d \ e & f & g & h\ i & j & k & l\ m & n & o & p\ end{matrix} right] * left[ begin{matrix} x0\y0\z0\1\ end{matrix} right] = left[ begin{matrix} x1\y1\z1\1\ end{matrix} right] tag{1} ]

根據矩陣乘法,有方程組式(2): $$begin ax0 +by0 +cz0 + d =x1 ex0 +fy0 +gz0 +h =y1 ix0 +jy0 +kz0 + l =z1 mx0 +ny0 +oz0 + p =1 end tag{2}

[ 通過以上式子,就可以求得各種不同圖形變換矩陣。 ## 1) 模型變換 模型變換包括平移變換、縮放變換和旋轉變換。從內容上來講,這幾種變換正好應對的三維交互操作的平移、變換和縮放。通過滑鼠操作調整模型變換矩陣就可以實現一種簡單三維交互操作。 ### (1) 平移變換 對於一個點(x,y,z,1),平移之後,得到的點就是(x+Tx,y+Ty,z+Tz,1),其中Tx、Ty、Tz分別表示點在X軸、Y軸、Z軸方向上移動的距離。那麼將其代入方程組式(2)的兩邊,有: $$begin{cases} a*x +b*y +c*z + d =x+Tx\ e*x +f*y +g*z +h =y+Ty\ i*x +j*y +k*z + l =z+Tz\ m*x +n*y +o*z + p =1 end{cases} ]

那麼根據多項式相等的原理,可以求得每個多項式係數,繼而可得平移矩陣T:

[ T= left[ begin{matrix} 1 & 0 & 0 & Tx \ 0 & 1 & 0 & Ty\ 0 & 0 & 1 & Tz\ 0 & 0 & 0 & 1\ end{matrix} right] ]

(2) 縮放變換

對於一個點(x,y,z,1),以原點為中心縮放,在X方向縮放Sx倍,在Y方向縮放Sy倍,在Z方向縮放Sz倍,那麼新的坐標值為(x*Sx,y*Sy,z*Sz,1)。將其代入方程組式(2)的兩邊,有: $$begin ax +by +cz + d =xSx ex +fy +gz +h =ySy ix +jy +kz + l =zSz mx +ny +o*z + p =1 end

[ 同樣根據多項式相等的原理,求得縮放矩陣S: ]

S= left[ begin Sx & 0 & 0 & 0 0 & Sy & 0 & 0 0 & 0 & Sz & 0 0 & 0 & 0 & 1 end right]

[ ### (3) 旋轉變換 旋轉變換就稍微複雜一點,對旋轉變換而言,必須知道旋轉軸、旋轉方向和旋轉角度。可以繞X軸,Y軸和Z軸旋轉,所以一般都會有三個旋轉矩陣。以繞Z軸旋轉為例,在Z軸正半軸沿著Z軸負方向進行觀察,如果看到的物體是逆時針旋轉的,那麼就是正旋轉,旋轉方向就是正的,旋轉值就是正數;反之如果旋轉值為負數,說明旋轉方向就是負的,沿著順時針旋轉。用更加通用的說法來說,正旋轉就是右手法則旋轉:右手握拳,大拇指伸直並使其指向旋轉軸的正方向,那麼右手其餘幾個手指就指明了旋轉的方向。 對於一個點p(x,y,z,1),繞Z軸旋轉,因為旋轉後的Z值不變,所以可以忽略Z值的變換,只考慮XY空間的變化。此時設r為原點到點p的距離,α是X軸旋轉到該點的角度。如圖所示: ![6](https://img2018.cnblogs.com/blog/1000410/201910/1000410-20191004221203875-186783337.png) 那麼p點的坐標表示為式(3): $$begin{cases} x=r*cosα\ y=r*sinα\ end{cases} tag{3} ]

同樣的繞Z軸旋轉後,得到新的點p』,X軸旋轉到該點的角度為(α+β),其坐標值為: $$begin x’=rcos⁡(α+β) y’=rsin⁡(α+β) end

[ 根據三角函數兩腳和公式,可得式(4): $$begin{cases} x’=r*(cos⁡α*cosβ-sinα*sinβ)\ y’=r*(sin⁡α*cosβ+cosα*sinβ)\ end{cases} tag{4} ]

將式(3)代入到式(4),可得式(5): $$begin x’=xcosβ-ysinβ y’=xsinβ+ycosβ z’=z end tag{5}

[ 將式(5)代入到方程組式(2)的兩邊,有: $$begin{cases} a*x +b*y +c*z + d =x*cosβ – y*sinβ\ e*x +f*y +g*z +h = x*sinβ + y*cosβ\ i*x +j*y +k*z + l = z\ m*x +n*y +o*z + p =1\ end{cases} ]

同樣根據多項式相等的原理,求得繞Z軸旋轉β角度時的旋轉矩陣Rz:

[ Rz= left[ begin{matrix} cosβ & -sinβ & 0 & 0 \ sinβ & cosβ & 0 & 0\ 0 & 0 & 1& 0\ 0 & 0 & 0 & 1\ end{matrix} right] ]

用同樣的方式可以推導,繞X軸旋轉β角度時的旋轉矩陣Rx:

[ Rx= left[ begin{matrix} 1 & 0 & 0 & 0 \ 0 & cosβ & -sinβ & 0\ 0 & sinβ & cosβ & 0\ 0 & 0 & 0 & 1\ end{matrix} right] ]

繞Y軸旋轉β角度時的旋轉矩陣Ry:

[ Ry= left[ begin{matrix} cosβ & 0 & sinβ & 0 \ 0 & 1 & 0 & 0\ -sinβ & 0 & cosβ & 0\ 0 & 0 & 0 & 1\ end{matrix} right] ]

(4) 組合變換

使用矩陣來描述圖形變換的好處之一就是能夠將以上所有的變換組合起來,例如如下式(6):

[ v1=S*(R*(T*v0)) tag{6} ]

表達的圖形變換是對於點v0,首先經過平移變換,再經過旋轉變換,最後再進行縮放,得到新的點v1。 根據矩陣乘法的結合律,式(6)可以寫成:

[ v1=(S*R*T)*v0 ]

那麼模型矩陣M就可以表示為:

[ M=S*R*T ]

注意上述模型矩陣的SRT順序並不是固定的,需要根據實際的情況採取合適的矩陣,否則會達不到想要的效果。一個重要的原則就是記住縮放變換總是基於原點的,旋轉變換總是基於旋轉軸的,在進行縮放變換和旋轉變換之前往往需要先平移變換至原點位置(不是絕對)。

2) 視圖變換

(1) 原理

視圖變換其實就是模型變換的逆變換。試想一下,拿一個物體給相機拍攝,其實也就是拿相機去拍攝一個物體,視圖變換和模型變換的結果並沒有顯著的區別,有些情況下兩者甚至可以合併成一個模型-視圖變換(model-view transform)。兩者之所以需要分開進行完全是由實際的交互操作決定的:旋轉、縮放到合適的位置其實是很難設置的,很多交互操作需要在視空間/攝像機空間中設置才比較合適,這個時候就需要視圖變換了。

視圖變換其實就是構建一個視空間/攝像機空間,需要三個條件量:

  1. 視點eye:也就是觀察者/攝像機的位置;
  2. 觀察目標點at:被觀察者目標所在的點,確定了視線方向;
  3. 上方向up:最終繪製在螢幕上的影像中的向上的方向,通俗來講,就是用來控制是正著拍、橫著拍還是斜著拍。

通過上述三個條件量,就可以構建一個視圖矩陣。這個矩陣一般可以通過圖形矩陣庫的LookAt()函數進行設置,例如在WebGL的cuon-matrix.js中,其設置函數為: 7

(2) 推導

由前文得知,視圖變換構建了一個視空間/攝像機空間坐標系,為了對應於世界坐標系的XYZ,可以將其命名為UVN坐標系,它由之前提到的三個條件量構建而成:

  1. 選取視線的方向為N軸:N = eye–at;並歸一化N。
  2. 選取up和N的叉積為U軸: U= up×N,並歸一化U。
  3. 選取N和U叉積得到V軸:V = N×U,並歸一化V。

如圖所示[7]8 由於視圖變換是模型變換的逆變換,以上視圖變換的效果,等價於進行一個旋轉變換,再進行一個平移變換。故有視圖矩陣V:

[ V=M^{-1}=(TR)^{-1}=R^{-1}T^{-1} ]

根據之前平移矩陣的定義,那麼有:

[ T^{-1}= left[ begin{matrix} 1 & 0 & 0 & -Tx \ 0 & 1 & 0 & -Ty\ 0 & 0 & 1 & -Tz\ 0 & 0 & 0 & 1\ end{matrix} right] ]

這裡的(Tx,Ty,Tz)就是視點eye(eyeX, eyeY, eyeZ)。經過平移變換之後,相機的原點就和世界原點重合,剩下的操作就是通過旋轉矩陣R,將世界坐標系XYZ的點轉換到成UVN坐標繫上的點。令:

[ X=(1,0,0),Y=(0,1,0),Z=(0,0,1)\ U=(Ux,Uy,Uz),V=(Vx,Vy,Vz),N=(Nx,Ny,Nz) ]

則有:

[ left[ begin{matrix} U & V & N \ end{matrix} right] = left[ begin{matrix} X & Y & Z \ end{matrix} right] * R = left[ begin{matrix} X & Y & Z \ end{matrix} right] * left[ begin{matrix} Ux & Vx & Nx \ Uy & Vy & Ny \ Uz & Vz & Nz \ end{matrix} right] ]

又由旋轉矩陣R為正交矩陣,所以有:

[ R^{-1} = left[ begin{matrix} Ux & Uy & Uz \ Vx & Vy & Vz \ Nx & Ny & Nz \ end{matrix} right] ]

最後即可得視圖矩陣:

[ V=R^{-1} T^{-1}= left[ begin{matrix} Ux & Uy & Uz & 0 \ Vx & Vy & Vz & 0 \ Nx & Ny & Nz & 0 \ 0 & 0 & 0 & 1 \ end{matrix} right] * left[ begin{matrix} 1 & 0 & 0 & -Tx \ 0 & 1 & 0 & -Ty\ 0 & 0 & 1 & -Tz\ 0 & 0 & 0 & 1\ end{matrix} right] = left[ begin{matrix} Ux & Uy & Uz & -U·T \ Vx & Vy & Vz & -V·T \ Nx & Ny & Nz & -N·T \ 0 & 0 & 0 & 1 \ end{matrix} right] ]

3) 投影變換

投影變換定義的是一個可視空間,決定了哪些物體顯示,哪些物體不顯示,以及物體如何顯示。常用的可視空間有兩種:

  • 四棱椎/金字塔可視空間,由透視投影產生;
  • 長方體可視空間,由正射投影產生。

(1) 透視投影

a) 原理

投影投影模擬的就是人眼成像或者攝像機成像的過程,試想一下,攝像機拍攝的總是取景器方位內的物體,並且呈現近大遠小的效果。在WebGL/OpenGL中,透視投影就決定了一個視點、視線、近裁剪面、遠裁剪面組成的四棱椎可視空間。如圖所示: 9 在實際使用中,圖形矩陣庫(我這裡用的WebGL的cuon-matrix.js)一般都會提供類似setPerspective()的函數,具體定義如下: 10

b) 推導

如圖所示,已知視空間坐標系XYZ,坐標系原點(視點)為O,視椎體近截面與視點距離為n,遠平面與視點的距離為f。已知視椎體空間中有一點為P(x0,y0,z0),那麼要求的就是射線OP與近截面的投影點P1(x1,y1,z1)。如圖所示: 13 近截面與平面XOY平行,那麼z1 = -near,那麼問題可以簡化為:已知空間上點P的坐標,存在點P與坐標O連線上一點P1,P1的Z值已知,求P1坐標。如圖所示: 14 顯然這是一個三角形相似的問題,P1點在視空間坐標系的XY坐標為:

[ begin{cases} x1’=-n/z0*x0\ y1’=-n/z0*y0\ end{cases} ]

根據前文論述,投影變換得到的4維度齊次坐標(x1,y1,z1,w1),會除以w1使得x1和y1的值歸一化到-1到1之間。那麼可設l和r分別為近截面左、右邊框的x坐標,那麼就是l映射到-1,r映射到1。這是一個線性變換問題:存在兩組點(l,-1)(r,1)滿足方程y=kx+b。

[ begin{cases} kl+b=-1\ kr+b=1\ end{cases} ]

解方程組:

[ begin{cases} k=frac{2}{r-l}\ b=-frac{r+l}{r-l}\ end{cases} ]

那麼P1歸一化後的x坐標xn為:

[ xn=frac{2}{r-l}*x1′-frac{r+l}{r-l}=-frac{1}{z0}*(frac{2n}{r-l}*x0+frac{r+l}{r-l}*z0) ]

同理可得,P1歸一化之後y 坐標yn為:

[ yn=-frac{1}{z0}*(frac{2n}{t-b}*y0+frac{t+b}{t-b}*z0) ]

可以發現,歸一化的坐標xn、yn都存在一個乘數因子(-1/z0),那麼可以令投影變換後的w1=-z0,這樣就可以滿足歸一化之後的wn=1,並且滿足上面xn、yn的表達式。即有裁剪坐標系的點P1(x1,y1,z1,w1):

[ begin{cases} x1= frac{2n}{r-l}*x0+frac{r+l}{r-l}*z0 \ y1= frac{2n}{t-b}*y0+frac{t+b}{t-b}*z0 \ w1= -z0 \ end{cases} ]

代入到式(2)中,得:

[ left[ begin{matrix} frac{2n}{r-l} & 0 & frac{r+l}{r-l} & 0 \ 0 & frac{2n}{t-b} & frac{t+b}{t-b} & 0 \ I & J & K & L \ 0 & 0 & -1 & 0 \ end{matrix} right] * left[ begin{matrix} x0 \ y0 \ z0 \ 1 \ end{matrix} right] = left[ begin{matrix} x1 \ y1 \ z1 \ w1 \ end{matrix} right] ]

繼續求上式的投影矩陣的第三行。投影轉換後得到的z1是一個深度值,它是一個與x0,y0無關的值,所以I=0,J=0。並且在歸一化之後,z1會成為一個-1到1之間的值:當z0=-n時(近截面),z1=-1;當 z0=-f時(遠截面),z1=1。代入上式,有:

[ begin{cases} (K*(-n)+L)/n=-1 \ (K*(-f)+L)/f=1 \ end{cases} ]

得到:

[ begin{cases} K=(f+n)/(n-f) \ L=2fn/(n-f) \ end{cases} ]

綜合,可得透視投影矩陣P:

[ P= left[ begin{matrix} frac{2n}{r-l} & 0 & frac{r+l}{r-l} & 0 \ 0 & frac{2n}{t-b} & frac{t+b}{t-b} & 0 \ 0 & 0 & frac{f+n}{n-f} & frac{2fn}{n-f} \ 0 & 0 & -1 & 0 \ end{matrix} right] ]

注意,通過類似setPerspective()的函數定義的矩陣是對稱的視錐體,視點在近截面的投影點為近截面的中心,因而有:

[ begin{cases} r=-l \ t=-b \ t-b=height \ width= height*aspect \ tan⁡(frac{fovy}{2})=frac{height/2}{n} end{cases} ]

代入透視投影矩陣P,得到對稱透視投影矩陣P:

[ P= left[ begin{matrix} frac{1}{aspect*tan⁡(frac{fovy}{2})} & 0 & 0 & 0 \ 0 & frac{1}{tan⁡(frac{fovy}{2})} & 0 & 0 \ 0 & 0 & frac{f+n}{n-f} & frac{2fn}{n-f} \ 0 & 0 & -1 & 0 \ end{matrix} right] ]

(2) 正射投影

a) 原理

正射投影一個很常見的應用就是地圖。無論是紙質地圖還是Google地圖,甚至於室內設計的戶型圖、工程設計的工程圖,無一例外全部都是正射投影。正射投影能夠很方便的比較場景中物體的大小,並且每個地方的所代表的大小都是一樣的(解析度一致)。當然,在這種投影下是沒有深度感的,就像你在衛星地圖上是看不出一座山有多高的。

正射投影同樣也是近裁剪面和遠裁剪面組成的可視空間,只不過這個可視空間是個長方體,如圖所示: 11 同樣的,可以使用類似setOrtho()函數來設置正射投影: 12

b) 推導

在正射投影的盒狀可視空間中,XYZ三個方向上都是等比例的。設盒狀可視空間中某一物體點P(x0,y0,z0),那麼P點在近截面的投影點為P1(x0,y0,z0』),僅僅只是Z值不同。

同透視變換的推導一樣,將P1的X、Y坐標(x0,y0)映射到-1到1的範圍(xn,yn)。即有兩組點(l,-1)和(r,1)滿足式子(線性關係y=kx+b):

[ Xn=Kx*x0+Bx ]

有兩組點(b,−1)和(t,1)滿足式子(線性關係y=kx+b):

[ Yn=Ky*y0+By ]

分別代入解方程組,可得:

[ begin{cases} xn=2/(r-l)*x0-(r+l)/(r-l) \ yn=2/(t-b)*y0-(t+b)/(t-b) \ end{cases} ]

同樣的,在Z方向上,將z0映射成-1到1直接的值:當點在近截面時,映射成-1;當點在遠截面時,映射成1。故也有兩組點(-n,-1)和(-f,1)滿足線性關係y=kx+b,同理可求得:

[ zn=(-2)/(f-n)*z0-(f+n)/(f-n) ]

對於正射變換而言,w變數是不必要的,可直接令w=1。那麼裁剪坐標P1(x1,y1,z1,w1)就是經過透視除法的標準化設備坐標(xn,yn,zn,1)。故有:

[ begin{cases} x1=2/(r-l)*x0-(r+l)/(r-l) \ y1=2/(t-b)*y0-(t+b)/(t-b) \ z1=(-2)/(f-n)*z0-(f+n)/(f-n) \ w1=1 \ end{cases} ]

代入到式(2)的兩邊,可得正射投影矩陣:

[ O = left[ begin{matrix} frac{2}{r-l} & 0 & 0 & -frac{r+l}{r-l} \ 0 & frac{2}{t-b} & 0 & -frac{t+b}{t-b} \ 0 & 0 & -frac{2}{f-n} & -frac{f+n}{f-n} \ 0 & 0 & 0 & 1 \ end{matrix} right] ]

3. 綜合運用

綜上所述,模型矩陣M,視圖矩陣V,投影矩陣P,同時作用於物體的頂點,使得最終的物體能後被看見或者進行UI操作。根據之前教程內容,逐頂點的操作可以將其放入到頂點著色器。一般而言,先進行模型變換,再進行視圖變換,最後進行投影變換:

[ v1=P*V*M*v0 ]

根據矩陣乘法的結合律:

[ v1=(P*V*M)*v0 ]

這個P*V*M矩陣合併得到的模型視圖投影矩陣(model view projection matrix),簡稱為MVP矩陣。在實際使用過程中,只需要將這個MVP矩陣傳入到頂點著色器,就能根據設置的矩陣得到想要的渲染效果:

gl_Position = u_MvpMatrix * a_Position;  

這一篇教程是純理論知識,相對來說不太容易理解。如果是初次接觸,至少應該先做大致的了解,後續會大量用到這裡的知識。

4. 參考

[1]《WebGL編程指南》 [2]《OpenGL編程指南》第八版 [3] OpenGL學習腳印: 投影矩陣和視口變換矩陣(math-projection and viewport matrix) [4] OpenGL矩陣變換的數學推導 [5] 基本影像變換:線性變換,仿射變換,投影變換 [6] 旋轉變換(一)旋轉矩陣 [7] 視圖矩陣的推導