電腦圖學01-Transformation

帽捲
Maochinn
Published in
26 min readJul 10, 2020

<前一篇 電腦圖學00-OpenGL

藉由前一篇應該會知道電腦圖學的render pipeline。

我們就先回顧一下其中的geometric pipeline。

首先,在圖學原則上無論要畫什麼東西都必須定義頂點(Vertex),這個頂點可以是3D或是2D的,但是最終要被轉換到2D的整數點,也就是圖片的pixel,而這其中牽涉到一連串的轉換(Transformation),然後投影到2D空間,然後把超出畫面的部份clip掉,最後經過rasterization。這邊細心的人可能會發現有些clip跟projection的位置會相反,其實這就是實作上有人習慣先做完clip再project,但也有人習慣先project完再clip,但這不影響整個流程。

最簡單的渲染就是我們一開始定義的Vertices都在圖片的坐標系,那這就不需要做任何的轉換,例如我在1000*1000的圖片、視窗上畫一個500*500的紅色方形在中央,如果在OpenGL1.x中可以這麼做:

很直觀的來看我們可以藉由gluOrtho2D來定義整個視窗的X, Y軸的最大最小值,也就是圖片的左下角是(-500, -500),右上角是(500, 500),然後我們藉glBegin(GL_QUADS)告訴GL要畫一個方形,glColor3f(1.0f, 0.0f, 0.0f)表示紅色,然後指定四個Vertex並畫出來。

但實際上面的code至少已經定義了一個坐標系,就是利用gluOrtho2D定義了投影矩陣,也就是前面pipeline看到的Projector,怎麼說呢?首先,要再次提到OpenGL是一個pipeline,意思是很多東西它都已經定義好了,就像接下來要說的,NDC(Normalized Device Coordinate)

(right)NDC

原則上無論Vertex怎麼定義,最後都要轉換到NDC這個坐標系,也就是一個2*2*2的方塊,然後可以想像他會把Z軸整個壓扁變成X-Y的2D座標,然後把壓扁的「圖片」對齊到視窗畫面(viewport),也可以說是轉換到windows space(windows coordinate)。

也就是把(1, 1)對應到右上角(w, 0),(-1, -1)對應到左下角(0, h),(0, 0)對應到中間(w/2, h/2),以此類推,這個轉換是OpenGL pipeline內會幫我們處理的。所以回到我們的例子,為甚麼我們寫的座標並不是[-1, 1]之間的數字,是因為gluOrtho2D告訴GL我們的座標最小值是-500最大值是500,也就是(-500, -500)的Vertex會對應到NDC的(-1, -1),然後NDC的(-1, -1)又對應到window space的(0, 500)。

(-500, 500) //initial space

(-1.0, -1.0) //NDC

(0, 500) //window space

所以我們給的其中一個Vertex(-250, 250)。

(-250, 250) //initial space

(-0.5, -0.5) //NDC

(250, 750) //window space

經過兩次轉換最終就會到window space,而這邊NDC到window space的變換是OpenGL render pipeline已經幫妳寫好了,所以只需要定義Vertex在NDC。

所以其實我們也可以寫成這樣會有完全一樣的效果。

所以一開始定義的gluOrtho2D就是一個Project轉換,而通常我們會把轉換都用矩陣的方式來表示,把我們定義的Vertex轉換到NDC,當然,如果我們一開始的Vertex就定義在NDC那Projection就是Identity Matrix,也就是不做任何轉換。

藉由上面的例子可以發現Vertex會透過矩陣在不同的坐標系中轉換,那這也包含如果我們需要畫3D的點,事實上Projector的功能就是將我們定義在空間中的點轉換到NDC,然後壓扁變成2D,也就是把3D的點投影到2D平面,只是我們前面的例子都是2D的,但你可以想像,如果變成:

我利用glOrtho定義三個軸的最大最小去轉換到NDC,雖然projector仍然是把3D點轉換成3D點,但因為後面NDC到Windows space的GL已經幫你做好了,所以我們可以抽象的看成projector幫你把3D轉換到2D,儘管這個矩陣只是轉換到NDC。

所以這邊可以也可以寫成這樣,事實上glVertex2就是glVertex3第三個元素補0

那接下來就是如果我們要畫複雜一些的3D物件呢?首先,我們其實在Project之前通常還有三個坐標系,模型坐標系(model space)、世界坐標系(world space)、相機坐標系(view space),也就是還會經過兩個轉換,也就是兩個矩陣,通常稱為model matrix跟view matrix,那具體來說是怎樣呢?

首先,抽象的來說我們會先在model space定義我們的模型,比如說我們可以定義一個方塊一個自己的坐標系,然後我們方塊放在world space的任意位置,所以我們會需要Model Matrix來轉換,但我們知道因為我們需要「觀察」這個物體以便投影到畫面上,所以我們就模擬相機的方式建立了一個view space,將world space的vertex經由view matrix轉換到view space,然後再經由projection martrix轉換到NDC。

第一次看應該會看的一頭霧水,所以我們還是來看看例子吧。

我們把上面的例子加了gluPerspectivegluLookAt,首先可以注意到他們的開頭是glu不是gl,這兩個函式並不是gl原生,但是glu原則上就是利用gl給的函式實作的lib,所以要記得另外去載。首先是gluPerspective,你可以注意到它就是另一種projection matrix(GL_PROJECTION),我們可以直接看看它的參數,fovy表示相機的視角(或者說焦距),aspect表示我們viewport(相機畫面)的長寬比(w/h),near跟far會定義我們能看見的前後距離,因為我們不可能把無限遠的東西都包含進去,因為電腦的記憶體有限。

Perspective projection

gluPerspective其實是表示透視投影,如果不理解什麼叫透視可以查一下或是看我的筆記(主要是針對繪畫的),簡單來說就是透視投影會造成近大遠小的視覺效果,而我們前面使用的gluOrtho2D或是glOrtho都是屬於平行投影,但其實大多數我們都是使用透視投影,因為這也是我們的視覺經驗。

再來是gluLookAt,可以注意到它是使用GL_MODELVIEW,也就是它是model-view matrix,這邊我不確定為甚麼OpenGL要把model matrix 跟view matrix和在一起。總之gluLookAt的參數有3個,eye表示相機的位置,center表示相機對準的中心位置,up是world space朝天的向量,大部分情況下都會是y軸。

依據我們給的參數,目前的場景設定就會長這樣

Setting

所以整個來說,我們定義了相機的各種參數,到這邊可能會疑惑,為甚麼要分這麼多space,不把相機的參數寫在一起就好,這邊可以想像我們可能會要動態移動相機的外部參數(相機位置、相機對準位置),但是相機的內部參數(視角、長寬比)不會動態變化。

接下來你就可以試試看把相機的位置調遠,而render出來的方形會不會變小,這就是因為近大遠小的透視投影造成的效果。

接下來我們可以在做另一個變換,就是移動我們的方形

利用glTranslate我們可以讓方形移動,如例子所示,我們讓方形往正X軸移動0.5,所以render出來的方形就只占畫面的一半了。

依據上面的整個流程,我們其實已經把5種space都用到了,現在讓我們來看整個過程吧。直接來看我們現在定義了ABCD 4個點,這4個點可以彼此獨立平行去算的,例如:A點先被glTranslate 的model matrix變換,然後經過gluLooktAt的view matrix,再經過gluPerspective,最後到NDC,然後進入OpenGL的pipeline之中被畫出來,而BCD點也會經過一樣的變化,最終被畫出來。

這邊補充一點,事實上經過projection並不是到NDC,而是到clip space,這兩個space只差了一個計算,所以常常會搞混,那就是拿乘完projection的向量的w去做normalize,下面會提到。

事實上,如果是內部的數學運算會長得像這樣

要注意的是我們的矩陣式採取前乘(Pre-Multiplication)的方式,這個順序很重要,因為矩陣沒有交換率,所以順序不一樣,結果就會不一樣,然後我們的vertex實際上會是一個4*1的向量(x, y, z, w)(通常w=1或是w=0),所以最後的結果在clip space其實也是4*1的向量,最後要做normalize(x/w, y/w, z/w, w/w),然後就直接把w去掉變成(x, y, z)的3*1的向量,總之,這些計算如果需要自己做的話是需要注意的,但是OpenGL幫我們把這些動作都封裝起來了。

那我們就來講講6個space吧。

Model Space

也就是你的幾何物體、mesh所定義的空間,例如一個方塊、一片方形,以前面的示範為例,方形的4個vertex一開始被定義在model space的+0.5~-0.5之間。

World Space

世界坐標系表示你整個場景的全局坐標系,也就是所有要渲染的東西都要明確定義在這個坐標系,一樣以前面的示範為例,我們把方形的中心放在(0.5,0,0),也就是做了一個translate,那你可能會有個疑問,為甚麼我不直接在定義vertex座標的時候就直接在world space呢?當然可以,可是如果你有很多重複的東西(無數個相同的方形但位置不同)你就需要個別定義在世界坐標系,而比較好的做法就是只定義一次,然後利用translate來移動個別的方形。另外,常見的transformation除了translate還包含scale跟rotate。

View Space

把所有物件都定義在world space之後,我們就要拿出相機來拍攝了,當然,相機的位置也已經被定義在world space,我們要以相機位置為原點來重新定義所有位置,因為我們必須定義出物體與相機的遠近,所以利用這樣的transform我們可以把所有vertex都轉換到view space,距離相機越近的就越靠近原點(0,0,0)。

Clip Space

這個就是做完投影的空間,它之所以稱做clip space是因為我們的vertex很有可能會超出camera能照到的範圍,-w <= x, y, z <= w. 以這個原則就可以把不在範圍的vertex clip掉,或是你也可以-1 <= x/w, y/w, z/w <= 1. ,也就是超出NDC範圍的就clip掉,所以原則上如果你是使用glVertex4f來傳,你的vertex可以在clip space,但是如果是glVertex3f,它會幫你預設w = 1,所以意思就是在NDC,所以一般來說我們會直接傳NDC的座標,因為NDC的座標再用w normalize還是在NDC。

如果w是負的,那所有vertex都會被clip掉,意思是所有點都再camera的背後,這個後面再講perspective projection matrix會再講到。

Normalized Device Coordinates

這個最開頭就有提過了,最終OpenGL所接受的Vertex必須定義在這個坐標系,這個就是OpenGL預先設計的,如果是自己要寫自己的GL也可以不用這麼做。

Window Space

這也是一開始提過,就是最後顯示在viewport上的坐標系,原則上我們不能干涉NDC到window space的過程,因為它被封裝在OpenGL的pipeline內,而且通常也沒有必要去干涉原則上我們也只能把結果取出來而已。

原則上這就是整個Vertex從定義在model space到最後畫出來會經過所有的transformation,透過這篇也可以注意一些專有名詞的使用,我刻意放了一些是同義的用詞,這樣以後看到就知道這些用詞是同個意思,例如world space跟world coordinate是一樣的東西。

事實上,大多數時候我們做的transformation都是在所謂的model matrix,也就是我們要設定已經簡建模好的物件要擺在world的location(位置)以及orientation(姿態)和大小size(大小),這對應到的就是三種transformation,translation, rotation, scaling,這三者都可以使用matrix來表示,這邊也可以順便解釋為甚麼我們會用4D的向量來表示3D位置。

在開始講這三種transformation以前,先來看看前面一直有出現的glPushMatrix跟glPopMatrix是什麼,這個是GL在實作transformation的系統設計上的一個蠻巧妙的資料結構,從命名上其實可以猜到是一個stack,這邊就來解釋一下。

首先,我們的vertex會在render的是後依序乘上modelview matrix跟projection matrix,而我這兩個matrix實際上都是一個stack,所有操作都會應用在stack的top,也就是我們的gluPerspective, gluLookAt, glTranslate這些東西都是會建出一個矩陣,然後把它前乘在stack最上面的矩陣(glLoadIdentity是把最上面的矩陣變成單位矩陣),而glPushMatrix就是把最上面的矩陣複製一份然後push到最上面,glPopMatrix就是把最上面的矩陣pop掉,會說這樣巧妙是因為我們就可以分成好幾個space,每個stack的矩陣都可以表是一個space,等到處理完就可已把他pop掉,其實就是local space的概念,這個我們後面在來解釋。

所以可以回去看一下前面的程式碼,其實你把glPushMatrix, glPopMatrix拿掉也沒差,那就回來介紹transformation吧。

Translation

位移,講白一點就是直接把vertex的座標位置直接加上某個向量。

Translation

這邊可以發現如果translate要用matrix來表示就必須使用4D(xyzw)向量才能做到,至於為甚麼一定要用matrix來表示而不直接作加法我猜測是因為GPU對於matrix的運算有做加速,而且所有的transformation都是用矩陣也會比較直覺。這邊第四個element還有另一個目的,就是區分point(點)跟vector(向量),數學應該有學過,向量就算位移了也不會改變,所以我們可以藉由把w改成0來讓translation無效。

對應到GL的話就是使用glTranslate,使用後他會建出一個矩陣然後前乘在目前選擇的stack最上方的矩陣。

Rotation

旋轉,首先無論是哪種旋轉我們會需要至少要定義兩種東西,rotation of axes(轉軸)、angle of rotation(旋轉角度),前者又要定義轉軸的位置以及方向,後者則可能是degree(角度)或是radian(弧度)這個要統一,而在國高中應該都看過2D的旋轉矩陣,而到了3D空間,旋轉變得非常複雜。

3D rotation matrix

最常見的方式就是將旋轉分成X, Y, Z軸的旋轉因為這樣最符合直覺,但是就像前面所說的,矩陣沒有交換率,所以先轉X軸跟先轉Y軸的結果會不一樣,所以才會有人使用quaternion(四元數)來表示一個物體的orientation,但這有點超出本篇範圍,所以我們回到GL,glRotate一樣會建出矩陣然後前乘在stack的top,但值得注意的是他的參數只需要定義轉軸的方向跟旋轉方向,而轉軸的位置就在原點,其實大多數的旋轉矩陣的轉軸都是在原點的。

Scaling

縮放,這個就很簡單了,就是一樣以原點為中心做縮放,也就是直接對XYZ個乘上一個值,如果大於1就是放大,小於1就是縮小,當然,GL也是要用矩陣的方式來表示。

glScale也是一樣,就是建出矩陣然後前乘在stack的top。

這就是最常見的三種transformation,而不厭其煩地說,矩陣是沒有交換率的,所以先translate再rotate跟先rotate再translate是不一樣的。

但這邊可以給個通常情況下我們會用的順序,就是先scale再rotate最後再translate,如果後面還是不理解的話可以直接背起來。

這邊讓我來解釋一下,我們通常會想要以物件為中心先進行縮放,然後一樣以物件為中心進行旋轉,然後把物件放到某個指定位置,這些步驟其實在我們自己的操作中並沒有先後順序,但是因為是矩陣乘法,若是以純粹數學的角度來看,後面的矩陣會被前面的矩陣影響,所以如果我們想要把物體放到指定位置,就應該把translate擺在後面,否則如果先做translate再做rotate就會讓translate被rotate影響。

事實上,會造成這樣固定的順序是因為我們的旋轉矩陣的轉軸都是在原點,所以我們沒有辦法想把東西translate到A點,然後以A點為中心做rotate,因為我們都是以原點為中心,所以如果要達到預期效果就要依照這個順序下去做。

事實上,上面的範例跟前面的所有transformation都暗示了一件事,就是經過了一個矩陣,我們就會轉變到一個coordinate space,所以也可以用一個抽象的角度來看這個問題,就是經過了translate到了A space,然後在A space做rotate跟經過了rotate到了B space,然後在B space做translate結果當然會不一樣。

View Matrix

現在知道了三種transformation,其實就有辦法來時做view matrix了,也就gluLookAt幫我們做的矩陣,事實上view matrix就是由兩個矩陣相乘罷了。

首先,可以先複習view matrix到底是做甚麼的。

因為我們要把東西投影到相機上,所以我們需要把所有在world space的物件transform到camera space而這個space的原點就是相機的位置,然後-Z軸則是相機的朝向,這邊這樣設計的目的可能是因為要保持向右是+X,因為如果+Z軸是相機的朝向,那+X就會向左,可是這樣的後果是在相機面前的所有點的Z座標是負的。這邊還有另一個疑問是,為甚麼是用Z軸當作朝向而不是X軸,我認為應該是因為要拿Z當做深度,如果是2D的時候可以直接把Z值省略用XY就好。

如上圖,view matrix其實就是透過一個translate跟rotate來達成,先來講translate的向量就是負的相機位置(in world space),因為view space的原點是相機位置,所以我們把相機位置減掉相機位置這樣就會變成原點,也就是經過這個translate的space就是以相機位置為原點的space。

接下來是rotate,其實rotate matrix觀察一下你可以發現他的行列式值為1,也就表示他不會造成縮放,當然這非常直覺,而另一點你會發現它的3個column互相垂直,事實上這3個column就是三個軸,如果你把X軸(1, 0, 0)乘上這個旋轉矩陣,你會發現會變成最左邊的那個column,也就是rotate matrix從另個角度來看,它就是重新定義你的三個軸。所以依據這個特性,假設我們已知相機的三軸,我們就可以直接把rotate matrix填出來。

那最後就是順序了,這邊到底要先translate還是先rotate呢?

答案是先做translate再做rotate,跟前面講得通常順序不同,因為我們要以相機的位置為中心旋轉,所以我們的rotate必須先被translate影響,這個具體上怎麼回事也可以自己好好思考一下。

這邊可以稍微提一下gluLookAt是怎麼算的,它的參數要提供center, location and up,前兩者可以得到相機的朝向跟位置,那up是做什麼的?

事實上,如果我們只有朝向也就是我們定義了forward(-z)軸,那我們仍然有無限多種space(只要left, up, forward彼此垂直就可以),你可以想像繞著forward軸轉的up跟left都會是合法的space,但我們的視覺習慣上會讓天空在上方,所以up會希望朝向天空,但是我們要怎麼得到這個up呢?我們可以先給定up of world space,通常就是Y軸,我們可以先讓Y軸跟forward軸做外積就可以得到left(-x),然後用forward軸跟left軸做外積就可以得到up軸。

這邊給個例子來解釋view matrix是怎麼回事。

假設白色座標表示world space(x, y, z),紅色表示view space(u, v, w),那現在假設camera在world space是(1, 0, 2),那在view space是(0, 0, 0),也就是view space的原點,那在view space的w軸就是<0, 0, 1>,而在world space的w軸則是<1, 0, 0>,這邊注意我用<>跟()來區分direction跟position。如果剛好我在view space(0, 0, 1)定義一個點,而world space則是(2, 0, 2),我們的目標是找到world to view的matrix,但不妨我們先找view to world的matrix。

如果是針對view space(0, 0, 0) →world space(1, 0, 2),這邊只需要做一個translation,針對view space<0, 0, 1> →world space<1, 0, 0>,這邊只需要做一個rotation,那這個rotation其實就是把第三個column改成<1, 0, 0>就可以了。

那綜合以上,我們要找view space(0, 0, 1) →world space(2, 0, 2)就像前面提到的先做rotation再做translation,所以只要把上面的矩陣湊起來,我們就能得到一個view to world的matrix。

但我們最終目的是找到world to view的matrix,那這邊只要我們只接找反矩陣就行了,這邊比較特別的一點是rotation matrix的反矩陣就是轉置矩陣,所以只要我們有view space的三軸,我們可以直接把rotation matrix填出來,總之,這樣就可以得到world to view的matrix,或者說,view matrix。

Projection Matrix

主要分成兩種,orthogonal跟perspective,但這邊就來提提比較複雜但比較常用的perspective projection(透視投影)。

perspective projection matrix

這邊就不做詳細的推導,主要就是從view space到clip space,這邊可以注意到若拿(x, y, z, w)去乘,w會等於-z,也就是說w若大於0,那就代表這個vertex在view space的z是負的,也就表示它在camera面前(因為camera的朝向是-z軸),反之如果w小於0,那就表示vertex在camera背後,所以在clip space會直接clip掉。

這邊可以補充,由於perspective projection是非線性的transformation,這會導致不能應用一些線性變化的性質,所以就有提出了

weak perspective projection

簡單來說,就是把只做平行投影,但是為了模擬近大遠小的效果,我們會直接給它一個scale,這麼一來整體就會是線性transformation了

最後這邊提一個重要的概念,Local space

Local Space(local coordinate system)

有人會翻成在在地坐標系,通常會講它是相對於world space,你也可以說world space是global space,local space並不是具體上某一個坐標系,而是一種相對的space,舉例來說,model space就是一種local space,就像前面所說的我們的旋轉通常是在物件的中心,也就是model space的原點,而不是在world space的原點,所以這個時候我們做的rotation就是在一個local space,那這個東西有甚麼用呢?它通常用在hierarchical的物件,例如機器人的手,你可以想像機器人的手掌應該在手臂的local space,如果手臂旋轉了,那手掌應該會跟著旋轉,但是手掌旋轉並手臂並不會跟著旋轉。

所以我們可以透過glPushMatrix跟glPopMatrix來做到這件事,也就是我們會先定義出手臂的model matrix,然後把手掌定義在手臂的object space,所以手掌在手臂的local space,我們可以透過glPushMatrix把手臂的model matrix存起來進一步定義手掌的model matrix,依照這樣順序如果手臂做了任何transformation就會影響到手掌,而手掌的則不會影響到手臂。總的來說,我們不直接把手掌轉換到world space,而是先轉到手臂的object space,然後再用手臂的model matrix轉換到world space,所以等於是我們把手掌的位置定義在手臂local space,而不直接定義位置在global space。

總之,我們現在知道GL透過各種矩陣來實現transformation,將vertex從一個space轉換到另一個space,而通常我們會將模型定義再object space,這個space的原點就是這個模型的中心,也就是旋轉、縮放的中心,然後我們再依照需求將他transform到world,然後經過view matrix到view space,最後經過projection matrix到NDC,然後交給GL畫出來。

--

--