在讀第五章第三節Unity內置文件和變量之前,建議細讀第四章。嗯,重修過之前的線代和圖形學基礎后,這一章能看得稍微輕松一些。
一、知識點匯總
1.正交基和標準正交基
該知識點出現在4.2.2節。
3個坐標軸互相垂,且長度為1,這樣的基矢量被稱為標準正交基,但這并不是必須的。在一些坐標系中坐標軸之間互相垂直,但長度不為1,這樣的基矢量被稱為正交基。正交,可以理解為互相垂直的意思。
2.正交矩陣
在圖形學筆記二 正交矩陣、轉置矩陣和旋轉中已經了解過正交矩陣的重要性質:正交矩陣是指其轉置等于逆的矩陣。在《入門精要》的4.4節有詳細介紹:
在三維變換中,我們經常會使用逆矩陣來求解反向的變換。但逆矩陣的求解往往計算量很大,而如果我們可以確定這個矩陣是正交矩陣的話,就可以直接通過轉置矩陣得到逆矩陣。
那么如何判斷的一個矩陣是否是正交矩陣呢,當然可以通過公式M右乘M的轉置矩陣是否為單位矩陣來判斷,但這仍然需要一定的計算量,這些計算量可能和直接求解逆矩陣無異。而且,如果我們判斷出來這不是一個正交矩陣,那么這些花在驗證是否是正交矩陣的計算就浪費了。因此,我們更想不需要計算,而僅僅根據一個矩陣的構造過程來判斷這個矩陣是否是正交矩陣。為此,我們需要了解正交矩陣的幾何意義。
這樣,我們就有了9個等式:
可以得到如下結論:
- 矩陣的每一行(即c1,c2,c3)都是單位矢量,因為它們與自己的點積為1
- 矩陣的每一行(即c1,c2,c3)都互相垂直,因為它們互相的點積為0(參考點積的公式|a||b|cosθ)
- 上述的兩條結論對每一列也同樣適用。因為M是正交矩陣的話,MT也是正交矩陣。
也就說如果一個矩陣滿足上面的條件,那么它就是一個正交矩陣。讀者可以注意到, 一組標準正交基(定義詳見4.2.2 節〉可以精確地滿足上述條件。在4.6.2 節中,我們會使用坐標空間的基矢量來構建用于空間變換的矩陣。因此,如果這些基矢量是一組標準正交基的話(例如只存在旋轉變換),那么我們就可以直接使用轉置矩陣來求得該變換的逆變換。
讀者: 我被標準正交、正交這些概念搞混了,可以再說明一下是什么意思嗎?
我們:讀者應該已經知道, 一個坐標空間需要指定一組基矢量,也就是我們理解的坐標軸。如果這些基矢量之間是互相垂直的,那么我們就把它們稱為是一組正交基( orthogonal basis ) .但是,它們的長度并不要求一定是1 。如果它們的長度的確是1 的話,我們就說它們是一組標準正交基( orthonormal basis )。
因此,一個正交矩陣的行和列之間分別構成了一組標準正交基。但是, 如果我們使用一組正交基來構建一個矩陣的話,這個矩陣可能就不是一個正交矩陣,因為這些基矢量的長度可能不為1 ,也就是說它們不是標準正交基。
3.行矩陣還是列矩陣
假設有一個矢量v=(x,y,z),我們可以把它轉換成行矩陣(x,y,z)或列矩陣(x,y,z)T。在Unity 中,常規做法是把矢量放在矩陣的右側, 即把矢量轉換成列矩陣來進行運算。因此,在本書后面的內容中, 如無特殊情況, 我們都將使用列矩陣。這意味著, 我們的矩陣乘法通常都是右乘,例如:CBAv = (C(B(Av)))
使用列向量的結果是,我們的閱讀順序是從右到左,即先對v使用A進行變換,再使用B進行變換,最后使用C進行變換。
4.平移矩陣不是一個正交矩陣
5.縮放矩陣一般不是正交矩陣
如果縮放系數kx=ky=kz,我們把這樣的縮放稱為統一縮放( uniform scale ), 否則稱為非統一 縮放( nonuniform scale )。
從外觀上看,統一縮放是擴大整個模型,而非統一縮放會拉伸或擠壓模 型。更重要的是,統一縮放不會改變角度和比例信息,而非統一縮放會改變與模型相關的角度 和比例。例如在對法線進行變換時,如果存在非統一縮放,直接使用用于變換頂點的變換矩陣的話,就會得到錯誤的結果。正確的變換方法可參見4 .7 節。
縮放矩陣的逆矩陣是使用原縮放系數的倒數來對點或方向矢量進行縮放,即
縮放矩陣一般不是正交矩陣。
上面的矩陣只適用于沿坐標軸方向進行縮放。如果我們希望在任意方向上進行縮放,就需要使用一個復合變換。其中一種方法的主要思想就是,先將縮放軸變換成標準坐標軸,然后進行沿坐標軸的縮放,再使用逆變換得到原來的縮放軸朝向。
6.旋轉矩陣是正交矩陣
旋轉矩陣的逆矩陣是旋轉相反角度得到的變換矩陣。旋轉矩陣是正交矩陣,而且多個旋轉矩陣之間的串聯同樣是正交的。
7.復合變換順序是先縮放,再旋轉,最后平移
在絕大多數情況下,我們約定變換的順序就是先縮放,再旋轉,最后平移。
讀者:為什么要約定這樣的順序,而不是其他順序呢?
我們:因為這樣的變換順序是我們需要的。想象我們對奶牛妞妞進行一個復合變換。如果我們按先平移、再縮放的順序進行變換,假設初始情況下妞妞位于原點,我們先按(0, 0, 5)平移它,現在它距離原點5 個單位。然后再將它放大2 倍,這樣所有的坐標都變成了原來的2倍,而這意味著妞妞現在的位置是(0, 0, 10),這不是我們希望的。正確的做法是,先縮放再平移。也就是說,我們先在原點對妞妞進行2 倍的縮放,再進行平移,這樣妞妞的大小正確了,位置也正確了。
8.旋轉順序
如果我們需要同時繞著3 個軸進行旋轉,是先繞x 軸、再繞y 軸最后繞z 軸旋轉還是按其他的旋轉順序呢?
當我們直接給出( θx, θy, θz)這樣的旋轉角度時,需要定義一個旋轉順序。在Unity 中,這個旋轉順序是zxy,這在旋轉相關的API 文檔中都有說明。這意味著,當給定(θx, θy, θz)這樣的旋轉角度時,得到的組合旋轉變換矩陣是:
一些讀者會有疑問:上面的公式書寫順序是不是反了?不是說列矩陣要從右往左讀嗎?這樣 一來順序不就顛倒了嗎?實際上,有一個非常重要的東西我們沒有說明白,那就是旋轉時使用的 坐標系。給定一個旋轉順序(例如這里的zxy ),以及它們對應的旋轉角度(θx, θy, θz),有兩種坐標 系可以選擇。
- 繞坐標系E下的z 軸旋轉θz,繞坐標系E 下的y 軸旋轉θy,繞坐標系E 下的x 軸旋轉θx,即進行一次旋轉時不一起旋轉當前坐標系。
- 繞坐標系E下的z 軸旋轉θz,在坐標系E 下在繞z 軸旋轉θz 后的新坐標系E’下的y 軸旋轉θy , 在坐標系E’下再繞y 軸旋轉θy 后的新坐標系E”下的x 軸旋轉θx,即在旋轉時,把坐標系一起轉動。
很容易知道,這兩種選擇的結果是不一樣的。但如果把它們的旋轉順序顛倒一下,它們得到的結果就會是一樣的!說得明白點,在第一種情況下,按zxy 順序旋轉和在第二種情況下,按yxz順序旋轉是一樣的。而Unity 文檔中說明的旋轉順序指的是在第一種情況下的順序。
和上面不同類型的變換順序導致的問題類似,不同的旋轉順序得到的結果也可能是不一樣的。我們同樣可以通過對比不同旋轉順序得到的變換矩陣來理解為什么會出現這樣的不同。而這個驗證過程留給讀者作為練習。
這里也可以參考圖形學筆記三 復數 四元數
9.坐標空間的變換
作者在4.6節舉了一個例子,其實用基變換的思路更快,例子需求如下:
現在,我們已知坐標空間C的三個坐標軸在父坐標空間P下的表示Xc,Yc,Zc,以及原來的原點Oc。當給定一個子坐標空間中的一點Ac=(a,b,c),我們同樣可以依照上面4個步驟來確定其在父坐標空間下的位置Ap
應用基變換,就是直接把子空間的Xc,Yc,Zc直接往父空間進行變換,直接就到這里了:
然后轉化為齊次坐標:
一旦求出來Mc->p,Mp->c就可以通過求逆矩陣的方式求出來,因為從坐標空間C變換到坐標空間P與從坐標空間P變換到坐標空間C是互逆的兩個過程。
可以看出來,變換矩陣Mc->p實際上可以通過坐標空間C在坐標空間P中的原點和坐標軸的矢量表示構建出來:把3個坐標軸依次放入矩陣的前三列,把原點矢量放到最后一列,再用0和1填充最后一行即可。
需要注意的是,這里我們并沒有要求3個坐標軸Xc、Yc和Zc是單位矢量,事實上,如果存在縮放的話,這三個矢量值很可能不是單位矢量。
更加令人振奮的是,我們可以利用反向思維,從這個變換矩陣反推來獲取子坐標空間的原點和坐標軸方向。例如,當我們已知從模型空間到世界空間的一個4×4的變換矩陣,可以提取它的第一列再進行歸一化后(為了消除縮放的影響)來得到模型空間的x軸在世界空間下的單位矢量表示。同樣的方法也可以提取y軸和z軸。我們可以從另一個角度來理解這個提取過程。因為矩陣Mc->p可以把一個方向矢量從坐標空間C變換到坐標空間P中,那么,我們只需要用它來變換坐標空間C中的x軸(1,0,0,0),即使用矩陣乘法M->p[1 0 0 0]T,得到的結果正是Mc->p的第一列。
另一個有趣的情況是,對方向矢量的坐標空間變換。我們知道,矢量是沒有位置的,因此坐標空間的原點變換是可以忽略的。也就是說,我們僅僅平移坐標系的原點是不會對矢量造成任何影響的。那么,對矢量的坐標空間變換就可以使用3×3矩陣來表示,因為我們不需要平移變換。那么變換矩陣就是:
在shader中,我們常常會看到截取變換矩陣的前3行前3列來對法線方向、光照方向來進行空間變換,這正是原因所在。
現在,我們再來關注Mp->c。我們前面講到,可以通過求Mc->p的逆矩陣方式求解出來反向變換Mp->c。但有一種情況我們不需要求解逆矩陣就可以得到Mp->c,這種情況就是Mc->p是一個正交矩陣。
如果它是一個正交矩陣的話,Mc->p的逆矩陣就等于它的轉置矩陣。這意味著我們不需要進行復雜的求逆操作就可以得到反向變換。也就是說,如果我們知道坐標空間變換矩陣Ma->b是一個正交矩陣,那么我們可以提取它的第一列來得到坐標空間A的x軸在坐標空間B下的表示,還可以提取它的第一行來得到坐標空間B的x軸在坐標空間A下的表示。反過來,如果我們知道坐標空間B的x軸、y軸和z軸(必須是單位矢量,否則構建出來的就不是單位矩陣了)在坐標空間A下的表示,就可以把它們依次放在矩陣的每一行就可以得到A到B的變換矩陣了。
6.讓人發暈的例子
當你不知道把坐標軸的表示是按行放還是按列放的時候,不妨先選擇一種擺放方式來得到變換矩陣。例如,現在我們想把一個矢量從坐標空間A 變換到坐標空間B,而且我們已經知道坐標空間B 的x 軸、y 軸、z 軸在空間A 下的表示,即X B 、Y B 和Z B 。那么想要得到從A 到B 的變換矩陣M A→B ,我們是把它們按列放呢還是按行放呢?如果讀者實在想不起來正確答案,我們不妨先隨便選擇一種方式,例如按列擺放。那么
這里馮樂樂的解釋,我是真的看暈了。但是我仍然可以用基變換的思路來解釋:
我想得到A到B的變換矩陣,只需要把A的基坐標變到B就行了,但問題就是,已知條件是反的,是知道B的基坐標在A中的表示。所以我改改思路,先求B到A的變換矩陣,再求逆即可。
B到A的基坐標變換,根據上面說的,在Unity 中,常規做法是把矢量放在矩陣的右側, 即把矢量轉換成列矩陣來進行運算。也就是:
然后對這個矩陣變換求逆,因為是正交矩陣,直接轉置即可得到最終答案:
二、MVP實例
在圖形學筆記四 MVP中,已經了解了MVP基本流程。原書舉了一個例子,講得很細,就是有個奶牛叫妞妞,她有自己的坐標空間即模型空間,在這個空間里,她的鼻子坐標是(0,2,4),最后如何顯示在屏幕上呢?
1.模型變換(model transform)
首先,轉化為齊次坐標(0,2,4,1)。
頂點變換的第一步就是將頂點坐標從模型空間變換到世界空間,這個變換通常叫做模型變換(model transform)。根據Transform的信息,妞妞進行了(2,2,2)的縮放,(0,150,0)的旋轉以及(5,0,25)的平移。根據之前的知識,要先縮放再旋轉再平移:
2.觀察變換(view transform)
觀察空間(view space)也被稱為攝像機空間(camera space)。觀察空間可以認為是模型空間的一個特例——在所有的模型中有一個非常特殊的模型,即攝像機(雖然通常來說攝像機本身是不可見的),它的模型空間值得我們單獨拿出來討論,也就是觀察空間。
攝像機決定了我們渲染游戲所使用的視角。在觀察空間中,攝像機位于原點,同樣其坐標軸的選擇可以是任意的,但由于我們是以unity為主,而unity中觀察空間的坐標軸選擇是:+x指向右方,+y指向上方,而正z軸指向攝像機的后方。在這里,讀者可能會覺得奇怪,我們之前討論的模型空間和世界空間中+z軸指的都是物體的前方,為什么這里不一樣了呢?這是因為Unity在模型空間和世界空間選用的都是左手坐標系,而觀察空間中使用的是右手坐標系。這是復合OpenGL的傳統的,在這樣的觀察空間中,攝像機的正前方指向的是-z軸方向。
這種左右手坐標系之間改變很少會對我們在unity中的編程產生影響,因為unity為我們做了許多渲染底層的工作。但是如果讀者需要調用類似Camera.cameraToWorldMatrix、Camera.WorldToCameraMatrix等接口自行計算某模型在觀察空間中的位置上,就要小心這樣的差異。
最后提醒讀者的一點是,觀察空間和屏幕空間是不同的。觀察空間是一個三維空間,而屏幕空間是一個二維空間。從觀察空間到屏幕空間需要一個操作,那就是投影(projection)。我們后面會講到。
頂點變換的第二步,就是將頂點坐標從世界空間變換到觀察空間中。這個變換叫觀察變換(view transform)
在圖形學筆記四 MVP中有提到:
上面的截圖說的是,考慮到運動的相對性,如果相機和物體一起移動,那么拍出來的照片是相同的。沿著這種思路,把相機放在世界坐標的原點,并讓坐標軸與世界空間重合,然后再讓物體移動,就能達到同樣的效果。
這里也介紹一下正常的思路,根據基變換的思路。要得到世界坐標的物體在相機空間的坐標,可以把世界坐標的基轉換到相機空間。以Unity舉例,我們更容易獲得的是攝像機在世界空間中的坐標,所以需要對這個變換進行求逆,才能得到我們的目標矩陣。
這兩種思路,最終選擇的是把相機移到原點的思路。視頻中說到這樣做的好處:會讓操作得到簡化
這里說一下自己的理解,以我的眼睛舉例,當我向前方進行移動時,會發現所有物體都在向后方移動。那么在游戲中,以世界坐標系為參考,一個攝像機的坐標發生變化時,就模擬了我的眼睛在移動,此時在我的眼睛坐標系內,所有物體的頂點坐標全部反向移動了。也就是,要考慮攝像機和物體的相對關系,以攝像機為參考系,把物體的頂點坐標轉換到攝像機坐標系內。這個移動的矩陣如何計算出來呢?正常的思路就是,把世界坐標系的基轉換到相機坐標系即可,但是這樣計算卻比較繁瑣,因為已知條件里,更容易得到的是攝像機在世界空間中的坐標和其它屬性。其實這里就有個簡化的轉換方式,就是把相機的坐標和其它屬性,還原到世界坐標系與其重合,然后把所有物體的頂點也這么操作一遍,顯然它們的相對關系是不變的。而這個還原過程,正是我們要求出的移動矩陣,還原計算非常簡單,之前攝像機怎么變換的,現在逆回去就行了。
回到我們的農場游戲。現在我們需要把妞妞的鼻子從世界空間變換到觀察空間中。為此我們需要知道世界坐標系下攝像機的變換信息。這同樣可以通過攝像機面板中的Transform組件得到:(1,1,1)的縮放,(30,0,0)的旋轉,(0,10,-10)的平移。
為了把攝像機重新移回到初始狀態(這里指攝像機原點位于世界坐標原點、坐標軸與世界空間中的坐標軸重合),我們需要進行逆向變換,即先按(0,-10,10)進行平移,以便攝像機回到原點,再按(-30,0,0)進行旋轉,以便讓坐標軸重合。因此變換矩陣就是:
注意,這里繞X軸旋轉的公式很容易寫出,而如果繞任意軸,這個矩陣會復雜很多。具體參考圖形學筆記二 正交矩陣、轉置矩陣和旋轉
但是,由于觀察空間使用的是右手坐標系,因此需要對z分量進行取反操作。我們可以通過乘以另一個特殊矩陣來得到最終的觀察變換矩陣:
3.裁剪空間
頂點接下來要從觀察空間轉換到裁剪空間(clip space,也被稱為齊次裁剪空間)中,這個用于變換的矩陣叫做裁剪矩陣(clip matrix),也被稱為投影矩陣(projection matrix)。
裁剪矩陣的目標是能夠方便的對渲染圖元進行裁剪:完全位于這塊空間內部的圖元會被保留,完全位于這塊空間外部的圖元將會被剔除,而與這塊空間相交的圖元將會被裁剪。那么這塊空間是如何決定的呢?答案是由視椎體(view frustum)來決定。
視椎體是指空間中的一片區域,這塊區域決定了攝像機可以看到的空間。視椎體由6個平面包圍而成,這些平面也被稱為裁剪平面(clip planes)。視椎體有兩種類型,這涉及到兩種投影類型:一種是正交投影(orthographic projection),一種是透視投影(perspective projection)。
在視椎體的6塊裁剪平面中,有兩塊裁剪平面比較特殊,它們分別被稱為近裁剪平面(near clip plane)和遠裁剪平面(far clip plane)。它們決定了攝像機可以看到的深度的范圍。正交投影和透視投影的視椎體如下圖所示。
從上圖可以看出,透視投影的視椎體是一個金字塔形,側面的4個裁剪平面會在攝像機處相交。它更符合視椎體這個詞語。正交投影的視椎體是一個長方體。前面講到,我們希望根據視椎體圍成的區域對圖元進行裁剪,但是如果直接使用視椎體定義的空間來進行裁剪,那么不同的視椎體就需要不同的處理過程,而且對于透視投影的視椎體來說,想要判斷一個頂點是否處于一個金字塔內部是比較麻煩的。因此我們想要一種更加通用、方便和整潔的方式來進行裁剪的工作,這種方式就是通過一個投影矩陣把頂點轉換到一個裁剪空間中。
投影矩陣有兩個目的:
(1)首先為投影做準備。這是個迷惑點,雖然投影矩陣的名稱包含了投影2字,但它并沒有進行真正的投影工作,而是在為投影做準備。真正的投影發生在后面的齊次除法(homogeneous division)過程中。而經歷過投影矩陣的變換后,頂點w的分量會具有特殊的意義。
讀者:投影到底是什么意思呢?
我們:可以理解成是一個空間的降維,例如從四維空間投影到三維空間中。而投影矩陣實際上并不會真的進行這個步驟,它會為真正的投影做準備工作。真正的投影會在屏幕映射時發生,通過齊次除法來得到二維坐標。
(2)其次是對x、y、z分量進行縮放。我們上面講過直接使用視椎體的6個裁剪平面進行裁剪會比較麻煩。而經過投影矩陣的縮放后,我們可以直接使用w分量作為一個范圍值,如果x、y、z分量都位于這個范圍內,就說明該頂點位于裁剪空間內。
在裁剪空間之前,雖然我們使用了齊次坐標來表示點和矢量,但它們的第四個分量都是固定的:點的w分量是1,方向矢量的w分量是0。經過投影矩陣變換后,我們會賦予齊次坐標的第四個坐標更加豐富的含義。下面,我們來看一下兩種投影類型使用的投影矩陣具體是什么。
4.透視投影
視椎體的意義在于定義了場景中的一塊三維空間。所有位于這塊空間內的物體都會被渲染,否則就會被剔除或裁減。我們已經知道這塊區域由6個裁剪平面定義,那么這6個裁剪平面又是怎么決定的呢?在Unity中,它們由Camera組件中的參數和Game視圖的橫縱比共同決定,如圖所示。
由圖可以看出,我們可以通過Camera組件的Field of View(簡稱FOV)屬性來改變視椎體豎直方向的張開角度,而Clipping Planes中的Near和Far參數可以控制視椎體的近裁剪平面和遠裁剪平面距離攝像機的遠近。這樣我們可以求出視椎體近裁剪平面和遠裁剪平面的高度,也就是:
現在我們還缺乏橫向信息。這可以通過攝像機的橫縱比得到。在Unity中,一個攝像機的橫縱比由Game視圖的橫縱比和Viewport Rect中的W和H屬性共同決定(實際上,Unity允許我們在腳本里通過Camera.aspect進行更改,但這里不做討論)。假設,當前攝像機的橫縱比為Aspect,我們定義:
現在,我們可以根據已知的Near、Far、FOV和Aspect的值來決定透視投影的投影矩陣。如下:
上面公式的推導部分可以參見本章的擴展閱讀部分。需要注意的是,這里的投影矩陣是建立在Unity對坐標系的假定上面,也就是說,我們針對的是觀察空間為右手坐標系,使用列矩陣在矩陣右側進行相乘,且變換z分量范圍將在[-w,w]之間的情況。而在類似DirectX這樣的圖形接口中,它們希望變換后z分量范圍將在[0,w]之間,因此就需要對上面的透視矩陣進行更改。這不在本書的討論范圍內。
而一個頂點和上述矩陣相乘后,可以由觀察空間變換到裁剪空間中,結果如下:
從結果可以看出,這個投影矩陣本質就是對x、y和z分量進行了不同程度的縮放(當然,z分量還做了一個平移),縮放的目的是為了方便裁剪。我們可以注意到,此時頂點的w分量不再是1,而是原先z分量的取反結果。現在,我們就可以按如下不等式來判斷一個變換后的頂點會否位于視錐體內,如果一個頂點在視錐體內,那么它變換后的坐標必須滿足:
任何不滿足上述條件的圖元都需要被剔除或裁減。下圖顯示了經過上述投影矩陣后,視椎體的變化:
從上圖還可以注意到,裁剪矩陣會改變空間的旋向性:空間從右手坐標系變換到了左手坐標系,這意味著離攝像機越遠,z值將越大。
注:這一部分我看得有點崩,說一下個人理解,為了模擬人眼遠小近大的透視效果,在把3D物體投影到一個平面上時,需要把生成的平面圖進行壓縮變形,這樣最終看起來才會有立體效果。壓縮變形這個操作,正是我們要推導的矩陣。Games101的課程中的方法,分三步:擠壓、正交投影、組合矩陣。在圖形學筆記四 MVP中,閆令琪是先講了正交投影,然后基于正交投影又用了不少時間推出了透視投影。
5.正交投影
這個在圖形學筆記四 MVP講得是很清楚的,簡單很多,先平移,再縮放:
在Unity中,引入了Aspect參數,比起閆令琪講的稍有變化,但原理是共通的。
由圖可以看出,我們可以通過Camera組件的Size屬性來改變視椎體豎直方向高度的一半,而Clipping Planes中的Near和Far參數可以控制視椎體的近裁剪平面和遠裁剪平面距離攝像機的遠近。這樣,我們可以求出視椎體近裁剪平面和遠裁剪平面的高度,也就是:
現在我們還缺乏橫向的信息。同樣我們可以通過攝像機的縱橫比得到。假設,當前攝像機的縱橫比為Aspect,那么:
現在,我們可以根據已知的Near、Far、Size和Aspect的值來確定正交投影的裁剪矩陣。如下:
同樣,這里的投影矩陣是建立在Unity對坐標系的假定上面的。一個頂點和上述投影矩陣相乘后的結果如下:
注意到,和透視投影不同的是,使用正交投影的投影矩陣對頂點進行變換后,其w分量仍然為1。本質是因為投影矩陣的最后一行不同,透視投影的投影矩陣的最后一行是[0,0,-1,0],而正交投影的投影矩陣的最后一行是[0,0,0,1]。這樣的選擇是有原因的,是為了齊次除法做準備,在后面我們會講到。
判斷一個變換后的頂點是否位于視椎體內使用的等式和透視投影中一樣,這種通用性也是為什么要使用投影矩陣的原因之一。下圖顯示了經過上述投影矩陣后,正交投影的視椎體變化。
同樣,裁剪矩陣改變了空間的旋向性。可以注意到,經過正交投影變換后的頂點實際已經位于一個立方體內了。
6.繼續來看我們的農場游戲
在上面,我們已經幫妞妞確定了它的鼻子在觀察空間中的位置——(9,8。84,-27.31)。現在,我們要計算它在裁剪空間中的位置。
首先,我們需要知道農場游戲中使用的攝像機類型。由于農場游戲是一個3D游戲,因此這里我們使用了透視攝像機。攝像機參數和Game視圖的縱橫比如圖所示:
據此,我們可以知道透視投影的參數:FOV為60度,Near為5,Far為40,Aspect為4/3=1.33。那么對應的投影矩陣是:
然后,我們用這個投影矩陣來把妞妞的鼻子從觀察空間轉換到裁剪空間中。如下
接下來Unity會判斷妞妞的鼻子是否需要裁剪。通過比較得到,妞妞的鼻子滿足下面的不等式:
由此,我們可以判斷,妞妞的鼻子位于視椎體內,不需要被裁減。
7.屏幕空間
經過投影矩陣的變換后,我們可以進行裁剪操作。當完成了所有的裁剪操作后,就需要進行真正的投影了,也就是說我們需要把視椎體投影到屏幕空間(screen space)中。經過這一步變換,我們會得到真正的像素位置,而不是虛擬的三維坐標。
屏幕空間是一個二維空間,因此我們必須把頂點從裁剪空間投影到屏幕空間中,來生成對應的2D坐標。這個過程可以理解成有兩個步驟。
首先,我們需要進行齊次除法(homogeneous division),也被稱為透視除法(perspective division)。雖然這個步驟聽起來很陌生,但實際上它非常簡單,就是用齊次坐標的w分量去除以x,y,z分量。在OpenGL中,我們把這一步得到的坐標叫做歸一化的設備坐標(Normalized Device Coordinates,NDC)。經過這一步,我們可以把坐標從齊次裁剪坐標空間轉換到NDC中。經過透視投影變換后的裁剪空間,經過齊次除法后會變到一個立方體內。按照OpenGl傳統,這個立方體的x,y,z分量的范圍都是[-1,1]。但是在DirectX這樣的API中,z的分量范圍會是[0,1]。而Unity選擇了OpenGL這樣的裁剪空間,如下圖所示:
而對于正交投影來說,它的裁剪空間實際上已經是一個立方體了,而且由于經過正交投影矩陣變換后的頂點的w分量是1,因此齊次除法并不會對頂點的x,y,z坐標產生影響,如下圖所示:
經過齊次除法后,透視投影和正交投影的視椎體都變換到相同的立方體內,現在我們可以根據變換后的x和y坐標來映射輸出窗口的對應像素坐標。
在Unity中,屏幕空間左下角的像素坐標是(0,0),右上角的像素坐標是(pixelWidth,pixelHeight),由于當前x和y坐標都是[-1,1],因此,這個映射的過程就是一個縮放的過程。
齊次除法和屏幕映射的過程可以使用下面的公式來總結:
上面的式子對x和y分量都進行了處理,那么z分量呢?通常,z分量會被用于深度緩沖。一個傳統的方式是把clipz/clipx的值直接存進深度緩存中,但這并不是必須的。通常驅動產商會根據硬件來選擇最好的存儲格式。此時clipw也并不會被拋棄,雖然它完成了它的主要工作——在齊次除法中作為分母來得到NDC,但它仍然會在后續的一些工作中起到重要作用,例如進行透校正插值。
在Unity中,從裁剪空間到屏幕空間的轉換是由底層幫我們完成的,我們的頂點著色器只需要把頂點轉換到裁剪空間即可。
在上一步中,我們知道了裁剪空間中妞妞鼻子的位置——(11.691,15.311,23.692,27.31)。現在我們終于可以確定妞妞鼻子在屏幕上像素的位置。假設,當前屏幕的寬度為400,高度為300。首先我們要進行齊次除法,把裁剪的坐標投影到NDC中,然后再映射到屏幕空間中。這個過程如下:
由此,我們知道了妞妞鼻子在屏幕上的位置——(285.617,234.096)
注:關于NDC坐標系至屏幕坐標系的計算,可以參考
https://www.bilibili.com/video/BV1jC4y1k71o
在上面的例子中,可以理解為使用11.691/27.31得到NDC坐標系中的X分量,然后再去計算11.691/27.31*(400/2)+400/2
8.總結
以上就是一個頂點如何從模型空間變換到屏幕坐標的過程,下圖總結了這些空間和用于變換的矩陣:
頂點著色器最基本的任務就是把頂點坐標從模型空間轉換到裁剪空間中。這對應了圖中前三個頂點變換過程。而在片元著色器中,我們通常也可以得到該片元在屏幕空間的像素位置。我們會在以后的講解中看到如何得到這些像素的位置。
在Unity中,坐標系的旋向性也隨著變換發生了改變。下圖總結了Unity各個空間使用的坐標系旋向性。
從圖中可以發現,只有在觀察空間中Unity使用了右手坐標系。
需要注意的是,這里給出的僅僅是一些重要的坐標空間。還有一些空間在實際開發中也會遇到,例如切線空間(tangent space)。切線空間通常用于法線映射,在后面我們會說到。
注:4.8節的內置變量,放在第5章一起學習。
二、threejs中關于MVP的代碼
參考
three.js+WebGL踩坑經驗合集(4.1):THREE.Line2的射線檢測問題(注意本篇說的是Line2,同樣也不是閾值方面的問題)
three.js+WebGL踩坑經驗合集(4.2):為什么不在可視范圍內的3D點投影到2D的結果這么不可靠
1.THREE.Vector3 的project方法
THREE.Vector3.project 方法的主要作用是將一個三維向量投影到屏幕坐標系中。具體來說,它會將三維空間中的點轉換為一個標準化的屏幕坐標,其范圍通常為 -1 到 1。
// 創建一個向量對象
const vector = new THREE.Vector3(0, 0, 1); // 示例點在Z軸上
// 假設我們有一個相機和場景
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const scene = new THREE.Scene();
// 設置相機位置
camera.position.z = 5;
// 創建一個光源
const light = new THREE.DirectionalLight(0xffffff);
light.position.set(1, 1, 1).normalize();
scene.add(light);
// 創建一個網格對象作為物體
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 渲染循環
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
// 更新相機位置
camera.position.z = 5 + Math.sin(cube.rotation.x) * 5;
// 渲染場景
renderer.render(scene, camera);
// 獲取屏幕坐標
const screenPosition = new THREE.Vector3();
screenPosition.setFromMatrixPosition(camera.matrixWorld);
screenPosition.project(camera); // 使用相機進行投影
console.log('Screen Position:', screenPosition);
}
animate();
在這個示例中,我們通過調用 project 方法將相機的世界坐標轉換為屏幕坐標,并輸出到控制臺。
打開Vector3.js的源碼:
project(camera) {
return this.applyMatrix4(camera.matrixWorldInverse).applyMatrix4(camera.projectionMatrix);
}
applyMatrix4(m) {
const x = this.x, y = this.y, z = this.z;
const e = m.elements;
const w = 1 / (e[3] * x + e[7] * y + e[11] * z + e[15]);
this.x = (e[0] * x + e[4] * y + e[8] * z + e[12]) * w;
this.y = (e[1] * x + e[5] * y + e[9] * z + e[13]) * w;
this.z = (e[2] * x + e[6] * y + e[10] * z + e[14]) * w;
return this;
}
將源碼與上文的計算示例對照,可以這樣理解(注意threejs的matrix數組是列主序):
易見,源碼中的w變量,是為了方便計算,將除法中的分母寫成1/上述圖片中的w