摘錄自 馮樂樂的《Unity Shader入門精要》
笛卡爾坐標系
1)二維笛卡爾坐標系
在游戲制作中,我們使用的數學絕大部分都是計算位置、距離、角度等變量。而這些計算大部分都是在笛卡爾坐標系下進行的。
一個二維的笛卡爾坐標系包含了兩個部分的信息:
一個特殊的位置,即原點,它是整個坐標系的中心。
兩條過原點的互相垂直的矢量,即X軸和Y軸。這些坐標軸也被稱為是該坐標的矢量。
OpenGL 和 DirectX 使用了不同的二維笛卡爾坐標系。如下圖所示:
2)三維笛卡爾坐標系
在三維笛卡爾坐標系中,我們需要定義三個坐標軸和一個原點。如下圖所示:
這三個坐標軸也被稱為是該坐標軸的基矢量。通常情況下,這個三個坐標軸之間是相互垂直的,且長度為1,這樣的的基矢量被稱為標准正交基,但這並不是必須的。例如,在一些坐標系中坐標軸之間相互垂直但長度不為1,這樣的基矢量被稱為正交基。如非特殊說明,后續默認使用的都是標准正交基。
正交的意思是相互垂直。
三維坐標系可以大致分為左手坐標系和右手坐標系。
三維坐標系並不都是等價的。因為就出現了不同的三維坐標系:左手坐標系和右手坐標系。如果兩個坐標系具有相同的旋向性,那么我們就可以通過旋轉的方法來讓它們的坐標軸指向重合。但是如果它們具有不同的旋向性,那么就無法達到重合的目的。下圖分別為左手坐標系和右手坐標系。
對於一個需要可視化虛擬的三維世界的應用(如Unity)來說,它的設計者就要進行一個選擇。對於模型空間和世界空間,Unity使用的是左手坐標系。
但對於觀察空間來說,Unity使用的是右手坐標系。觀察空間,通俗來講就是以攝像機為原點的坐標系。在這個坐標系中,攝像機的前向是z軸的負方向,這與在模型空間和世界空間中的定義相反。也就是說,z軸坐標的減少意味着場景深度的增加。
點和矢量
點是n維空間中的一個位置,它沒有大小和寬度這類概念。在笛卡爾坐標系中,我們可以使用2個或3個實數來表示一個點的坐標。
矢量的定義則復雜一些。矢量存在的意義更多是為了和標量區分開來。通常的講,矢量是指n維空間中一種包含了模和方向的有向線段,我們通常講到的速度就是一種典型的矢量。
具體來講。
矢量的模指的是這個矢量的長度,一個矢量的長度可以是任意的非負數。
矢量的方向則描述了這個矢量在空間中的指向。
下圖簡單描述了點和矢量之間的 關系。
1)矢量和標量的乘法/除法
只能是矢量被標量除,而不能是標量被矢量除。
2)
矢量的加減法
需要注意的是,一個矢量不能和一個標量相加或相減。矢量的加減法遵守三角法則。
3)矢量的模
4)單位矢量
單位矢量指的是那些模為1 的矢量。也被稱為歸一化矢量。
5)矢量的點積
矢量的乘法有兩種最常用的種類:點積(內積)和叉積(外積)
公式一
點積滿足交換律。
性質一:點積可結合標量乘法
性質二:點積可結合矢量加法和減法,和性質一類似。
性質三:一個矢量和本身進行點積的結果,是該矢量的模的平方。
公式二
6)矢量的叉積
叉積不滿足交換律,也不滿足結合律。
矩陣
1)基礎概念
矩陣是由m*n個標量組成的長方形數組。
矩陣由行列之分。如下是一個3*4矩陣。
M(i,j) 表明了這個元素在矩陣M的第i行,第j列。
矢量可以看成n*1的列矩陣或1*n的行矩陣
2)基礎運算
矩陣與標量的乘法。
矩陣與矩陣的乘法,它們的結果會是一個新的矩陣,並且這個矩陣的維度和兩個原矩陣的維度都有關系。
一個 r*n 的矩陣A和一個n*c 的矩陣B相乘,它們的結果AB將會是一個 r*c 大小的矩陣。
第一個矩陣的 列數必須和第二個矩陣的行數相同,它們相乘得到的矩陣行數是第一個矩陣的行數,而列數是第二個矩陣的列數。
如果不滿足規定,就不能相乘。
性質一:矩陣乘法並不滿足交換律。
性質二:矩陣乘法滿足結合律
3)特殊矩陣。
方塊矩陣(方陣),是指行和列數目相等的矩陣。
如果一個方陣除了對角元素之外的所有元素都為0,那么這個矩陣就叫做對角矩陣。如下圖所示:
單位矩陣:對角矩陣中對角元素值都為1。任何矩陣和它相乘結果還是原來的矩陣。
轉置矩陣:實際上是對原矩陣的一種運算,即轉置運算。轉置矩陣的計算非常簡單,只需要將原矩陣翻轉一下即可。原矩陣的第i行變成了第i列。而第j列變成了第j行。
如下所示。
性質一:矩陣轉置的轉置等於原矩陣。
性質二:矩陣串聯的轉置,等於反向串接各個矩陣的轉置。
逆矩陣
並不是所有的矩陣都有逆矩陣,第一個前提改矩陣是一個方陣。
逆矩陣的性質:該矩陣和逆矩陣相乘,得到一個單位矩陣。即
如果一個矩陣由對應的逆矩陣,我們就說這個矩陣是可逆的,或者說是非奇異的。
如何判斷一個矩陣是否可逆呢?簡單來說,如果一個矩陣的行列式不為0,那么它就是可逆的。
性質一:逆矩陣的逆矩陣是原矩陣本身。即
性質二:單位矩陣的逆矩陣是它本身。即
性質三:轉置矩陣的逆矩陣是逆矩陣的轉置,即
性質四:矩陣串接相乘后的逆矩陣等於反向串接各個矩陣的逆矩陣,即
如果一個方陣和它的轉置矩陣的乘積是單位矩陣的話,我們就說這個矩陣是正交的。反過來也成立。
在Unity中,常規做法是把矢量放在矩陣的右側,即把矢量轉換為列矩陣來進行運算。
變換
變換指的是我們把一些數據,如點,方向矢量甚至是顏色等,通過某種方式進行轉換的過程。
最常見的是線性變換。線性變換指的是那些可以保留矢量加和標量乘的變換。如下:
類似縮放和旋轉都是一個線性變換。還有錯切,鏡像,正交投影等,都是線性變換。
平移變換滿足標量相乘,但是不滿足矢量加法。
仿射變換是合並線性變換和平移變換的變換類型。仿射變換可以使用一個4*4的矩陣來表示,這就是齊次坐標空間。
下表給出了圖形學常見變換矩陣的名稱和它們的特性。
我們知道,由於3*3矩陣不能表示平移操作,我們就把其擴展到了4*4的矩陣。為此,我們還需要把原來的三維矢量轉換成四維矢量,也就是齊次坐標。
對於一個點,轉換為齊次坐標就是把其w分量設為1.對於方向矢量來說,需要把其w分量設為0.這樣的設置就會導致,當用一個4*4矩陣對一點進行變換時,平移、旋轉、縮放都會施加於該點。但是如果是用於變換一個方向矢量,平移的效果就會被忽略。
我們已經知道,可以使用一個4*4的矩陣來表示平移、旋轉和縮放。我們把表示純平移、純旋轉、純縮放的變換矩陣叫做基礎變換矩陣、這些矩陣具有一些共同點,我們可以把一個基礎變換矩陣分解成4個組成部分:
其中左上角的矩陣M(3*3)用於表示旋轉和縮放,右上角的t(3*1)表示平移,左下角的 0(1*3) 是零矩陣,右下角的元素是標量1。
我們可以使用矩陣乘法來表示對一個點進行平移變換:
從結果來看我們可以很容易看出為什么這個矩陣有平移效果,點的x,y,z分量分別增加了一個位置平移。
有趣的是,如果我們隊一個方向矢量進行平移變換,結果如下:
可以發現,平移變換不會對方向矢量產生任何影響。
平移矩陣的逆矩陣就是反向平移得到的矩陣,即
可以看出,平移矩陣並不是一個正交矩陣。
我們可以對一個模型沿空間的x軸、y軸和z軸進行縮放。同樣,我們可以使用矩陣乘法來表示一個縮放變換。
對方向矢量可以使用同樣的矩陣進行縮放。
如果三個縮放系數相等,我們把這樣的縮放稱為統一縮放,否則為非統一縮放。
縮放矩陣的逆矩陣是使用原縮放系數的倒數來對點或方向矢量進行縮放。即
縮放矩陣一般不是正交矩陣。
旋轉是三種常見的變換矩陣中最復雜的一種。
如果我們需要把點繞着x軸旋轉θ度,可以使用下面的矩陣:
y軸的可以使用如下矩陣:
z軸的:
旋轉矩陣的逆矩陣是旋轉相反角度得到的交換矩陣。旋轉矩陣是正交矩陣,而且多個旋轉矩陣之間的串聯同樣是正交的。
我們可以把平移、旋轉和縮放組合起來,來形成一個復雜的變換過程。例如,可以對一個模型先進行大小為(2,2,2)的縮放,再繞y軸旋轉30度,最后向z軸平移4個單位,復合變換可以通過矩陣的串聯來實現。上面的變換可以使用下面的公式進行計算。
在絕大多數情況下,我們約定的變換的順序就是先縮放,再旋轉,最后平移。
坐標空間
我們知道,要想定義一個坐標空間,必須指明其原點位置和3個坐標軸的方向。而這些數值實際上是相對於另一個坐標空間的。也就是說,坐標空間會形成一個層次結構——每個坐標空間都是另一個坐標空間的子空間,反過來說,每個空間都有一個父坐標空間。對坐標空間的變換實際上就是在父空間和子空間之間對點和矢量進行變換。
假設。現有父坐標空間P以及一個子坐標空間C。我們知道在父坐標空間中子坐標空間的原點位置以及3個單位坐標軸。我們一般會有兩種需求:一種需求是把子坐標空間下表示的點或矢量轉換到父坐標空間下。另一個修是反過來,即把福坐標空間下表示的點或矢量轉換到子坐標空間下。我們可以使用下面的公式來表示這兩種需求。
其中,表示的是從子坐標空間變換到父坐標空間的變換矩陣,而
是其逆矩陣。式子如下:
變換為矩陣得到:
其中“|”符號表示是按列展開的。上面的式子實際上就是使用了我們之前所學的公式。但這個最后的表達式還不夠漂亮,因為還存在加法表達式,即平移變換,我們把上面的式子擴展到齊次坐標空間中,得
所以的矩陣就是
一旦求出來,
就可以通過求逆矩陣的方式求出來,因為從坐標空間C變換到坐標空間P 與 從坐標空間P變換到坐標空間C是互逆的兩個過程。
可以看出來,變換矩陣實際上可以通過坐標空間C在坐標空間P的原點和坐標軸的矢量表示來構建出來:把3ge坐標軸一次放入矩陣的前3列,把原點矢量放到最后一列,再用0和1填充最后一行即可。
我們可以利用反向思維,從這個變換矩陣反推來獲取子坐標空間的元點和坐標軸方向!例如,我們已知從模型空間到世界空間的一個4*4的變換矩陣,可以提取它的第一列再進行歸一化后來得到模型空間的x軸在世界空間下的單位矢量表示。同樣的方法可以提取y軸和z軸。
另一個有趣的是,對方向矢量的坐標空間變換。我們知道,矢量是沒有位置的,因此坐標空間的原點變換是可以忽略的。也就是說,我們僅僅平移坐標系的原點是不會對矢量造成任何影響的。
在Shader中,我們常常會看到截取變換矩陣的前3行前3列來對法線方向、光照方向來進行空間變換,這正是原因所在。
前面說到,可以通過求的逆矩陣的方式求解出來反向變換
。但有一種情況我們不需求求解逆矩陣就可以得到
,這種情況就是
是一個正交矩陣。如果它是一個正交矩陣的話,
的逆矩陣就是等於它的轉置矩陣。這意味着我們不需要進行復雜的求逆操作就可以得到反向變換。也就是說:
而現在,我們不僅可以根據變換矩陣反推出子坐標空間的坐標軸方向在父坐標空間中的表示,還可以反推出父坐標空間的坐標軸方向在子坐標空間的表示,這些坐標軸對應的就是
的每一行!也就是說,如果我們只打坐標空間變換矩陣是一個正交矩陣,那么我們可以提取它的第一列來得到坐標空間A的x軸在坐標空間B下的表示,還可以提取它的第一行來得到坐標空間B的x軸在坐標空間A下的表示。反過來,如果我們知道坐標空間B的x軸、y軸和z軸在坐標空間A下的表示,就可以把它們依次放在矩陣的每一行就可以得到從A到B的變換矩陣了。
模型空間,如它的名字一樣,是和某個模型或者說是對象有關的。有時候模型空間也被稱為對象空間或局部空間。每個模型都有自己獨立的坐標空間,當它移動或旋轉的時候,模型空間也會跟着它移動和旋轉。把我們自己家當成游戲中的模型的話,當我們在辦公室里移動時,我們的模型空間也在跟着移動,當我們轉身時,我們本身的前后左右方向也在跟着改變。
模型空間的原點和坐標軸通常是由美術人員在建模軟件里確定好的。當導入到Unity中后,我們可以在頂點着色器中訪問到模型的頂點信息,其中包含了每個頂點的坐標。這些坐標都是相對於模型空間中的原點定義的。
世界空間是一個特殊的坐標系,因為它建立了我們所關心的最大的空間。時間空間可以被用於描述絕對位置。
在Unity中,世界空間同樣使用了左手坐標系。但它的x軸,y軸,z軸是固定不變的。在Unity中,我們可以通過調整Transform組件中的Position屬性來改變模型的位置,這里的位置值是相對於這個Transform的父節點的模型坐標空間中的原點定義的。如果一個Transform沒有任何父節點,那么這個位置就是在世界坐標系中的位置。
頂點變換的第一步,就是將頂點坐標從模型空間變換到世界空間中。這個變換通常叫做模型變換。
我們可以對妞妞的鼻子進行模型變換。如下圖
根據Transform 組件上的信息,我們知道在世界空間中,妞妞進行了(2,2,2)的縮放,又進行了(0,150,0)的旋轉,以及(5,0,25)的平移。注意這里的變換順序是不能互換的,即先進行縮放,再進行旋轉,最后是平移。據此我們可以構建出模型變換的變換矩陣:
現在我們可以用它來對妞妞的鼻子進行模型變換了:
也就是說,在世界空間下,妞妞鼻子的位置是(9,4,18.072).注意,這個的浮點數都是近似值。實際數值和Unity采用的浮點值精度有關。
觀察空間也被稱為攝像機空間。觀察空間可以認為是模型空間的一個特例。在所有的模型中有一個非常特殊的模型,就是攝像機。這個模型空間就是觀察空間。
攝像機決定了我們渲染游戲所使用的視角。在觀察空間中,攝像機位於原點,同樣,其坐標軸的選擇可以是任意的,但本文以Unity為主,而Unity中觀察空間的坐標軸選擇是:+x軸指向右方,+y軸指向上方,而+z軸指向的是攝像機的后方。Unity在模型空間和世界空間中選用的都是左手坐標系,而在觀察空間中使用的是右手坐標系。
這種左右手坐標系之間的改變很少會對我們再Unity中的編程產生影響,因為Unity為我們做了很多渲染的底層工作,包括很多坐標空間的轉換。但是,如果我們需要調用類似Camera.cameraToWorldMatrix、Camera.worldToCameraMatrix等接口自行計算某模型在觀察空間中的位置,就喲啊小心這樣的差異。
觀察空間和屏幕空間是不同的,觀察空間是三維的,而屏幕空間是二維的。
頂點變換的第二步,就是將頂點坐標從世界空間變換到觀察空間中,這個變換通常叫做觀察變換。
現在我們需要把妞妞的鼻子從世界空間變換到觀察空間中。為此,我們需要知道世界坐標系下攝像機的變換信息。這同樣可以通過攝像機面板的Transform 組件得到。如下圖。
為了得到定在在觀察空間中的位置,有兩種方法。一種是計算觀察空間的3個坐標軸在世界空間下的表示,構建出從觀察空間變換到世界空間的變換矩陣,再對該矩陣求逆來得到從世界空間變換到觀察空間的變換矩陣。我們還可以使用另一種方法,即想象平移整個觀察空間,讓攝像機原點位於世界坐標的原點,坐標軸與世界空間中的坐標軸重合即可。這兩種方法的變換矩陣都是一樣的。
這里我們使用第二種方法:由Transform 組件可以知道,攝像機在世界空間中的變換是先按(30,0,0)進行旋轉,然后按(0,10,-10)進行了平移。那么,為了把攝像機重新移回到初始狀態,我們需要進行逆向變換,即先按(0,-10,10)平移,以便攝像機回到原點,再按(-30,0,0)進行旋轉,以便讓坐標軸重合。因此,變換矩陣就是:
但是,由於觀察空間使用的是右手坐標系,因此需要對z分量進行取反操作。我們可以通過乘以另一個特殊的矩陣來得到最終的觀察變換矩陣:
現在我們可以用它來對妞妞的鼻子進行頂點變換了:
這樣我們就得到了觀察空間中妞妞鼻子的位置——(9,8.84,-27.31)。
頂點接下來要從觀察空間轉換到裁剪空間(也稱為齊次裁剪空間)中,這個用於變換的矩陣叫做裁剪矩陣,也被稱為投影矩陣。
裁剪空間的目標是能夠方便地對渲染圖元進行裁剪:完全位於這塊空間內部的圖元將會被保留,完全位於這個空間外部的圖元將會被剔除,而與這塊空間邊界相交的圖元就會被裁剪。這塊空間由視錐體來決定。
視錐體指的是空間中的一塊區域,這塊區域決定了攝像機可以看到的空間。視錐體由留個平面包圍而成,這些平面也被稱為裁剪平面。視錐體有兩種類型,這涉及兩種頭像類型:一種是正交投影,一種是透視投影。
下圖顯示了從同一個位置、同一角度渲染同一個場景的兩種攝像機的渲染結果。(左圖為透視投影,右圖為正交投影)
從圖中可以發現,在透視投影中,地板的平行線並不會保持平行,離攝像機越近網格越大,離攝像機越遠網格越小。而在正交投影中,所有的網格大小都一樣,而且平行線會一直保持平行。可以注意到,透視投影模擬了人眼看世界的方式,而正交投影則完全保留了物體的距離和角度。因此在追求真實感的3D游戲中我們往往會使用透視投影,而在一些2D游戲或渲染小地圖等其他HUD元素時,我們會使用正交投影。
在視錐體的6塊裁剪平面中,有兩塊裁剪平面比較特殊,它們分別稱為近裁剪平面和遠裁剪平面。它們決定了攝像機可以看到的深度范圍。正交投影和透視投影的視錐體如下圖所示。
由上圖可以看出,透視投影的視錐體是一個金字塔形,側面的4個裁剪平面將會在攝像機處相交。它更符合視錐體這個詞語。正交投影的視錐體是一個長方體。前面講到,我們希望根據視錐體圍城的區域對圖元進行裁剪,但是,如果直接使用視錐體定義的空間來進行裁剪,那么不同的視錐體就需要不同的處理過程,而且對於透視投影的視錐體來說,想要判斷一個頂點是否處於一個金字塔內部是比較麻煩的,因此,我們相擁一種更加通用、方便、整潔的方式來進行裁剪的工作,這種方式就是通過一個投影矩陣把頂點轉換到一個裁剪空間中。
投影矩陣有兩個目的。
首先是為投影做准備。這是個迷惑點,雖然投影矩陣的名稱包含了投影二字,但是它並沒有記性真正的投影工作,而是在為投影做准備。真正的投影發生在后面的齊次除法過程中。而經過投影矩陣的變換后,頂點的w分量將會具有特殊的意義。
齊次是對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值將越大。
正交投影
首先,我們看下正交投影中的6個裁剪平面是如何定義的。和透視投影類似,在Unity中,它們也是由Camera組件中的參數和Game視圖的橫縱比共同決定,如下圖:
正交投影的視錐體是一個長方體,因此計算上比透視投影來說更為簡單。由上圖可以看出,我們可以通過Camera組件的Size屬性來改變視錐體豎直方向上的高度的一半,而Clipping Planes 中的Near 和 Far 參數可以控制視錐體的近裁剪平面和遠裁剪平面距離攝像機的遠近。這樣,我們可以求出視錐體近裁剪平面和遠裁剪平面的高度,也就是:
現在,我們還缺乏橫向的信息,同樣,我們可以通過攝像機的橫縱比得到,假設,當前攝像機的橫縱比為Aspect,那么:
現在,我們可以根據已知的Near、Far、Size和Aspect 的值來確定正交投影的裁剪矩陣。如下:
上面公式的推導部分可以參見本章的擴展閱讀部分。同樣,這里的投影矩陣是建立在Unity對坐標系的假定上面的。
一個頂點和上述投影矩陣相乘后的結果如下:
注意到,和透視投影不同的是,使用正交投影的投影矩陣對頂點進行變換后,其w分量仍然為1,本質是因為投影矩陣的最后一行不同,透視投影的投影矩陣的最后一行是[0,0,-1,0],而正交投影的投影矩陣的最后一行是[0,0,0,1]。這樣的選擇是由原因的,是為了為其次除法做准備。
判斷一個變換后的頂點是否位於視錐體內使用的不等式和透視投影中的一樣,這種通用性也是為什么要使用投影矩陣的原因之一。下圖顯示了經過上述投影矩陣后,正交投影的視錐體的變化。
同樣,裁剪矩陣改變了空間的旋向性。可以注意到,經過正交投影變換后的頂點實際已經位於一個立方體內了。
現在,我們要計算妞妞鼻子在裁剪空間的位置。
我們使用了透視攝像機,攝像機參數和Game視圖的橫縱比如下圖所示:
據此,我們可以知道透視投影的參數:FOV為60°,Near 為 5,Far 40,Aspect 為4/3 =1.333。
那么,對應的投影矩陣就是:
然后,我們用這個投影矩陣來把妞妞的鼻子從觀察空間轉換到裁剪空間中。如下:
我們就求出了妞妞鼻子在裁剪空間中的位置(11.691,15.311,23.692,27.31)。接來下,Unity會判斷妞妞的鼻子是否需要裁剪,通過比較得到,妞妞的鼻子滿足下面的不等式:
由此,我們判斷出,妞妞的鼻子位於視錐體內,不需要被裁剪。
屏幕空間
經過投影矩陣的變換后,我們可以進行裁剪操作。當完成了所有的裁剪工作后,就需要進行真正的投影了,也就是說,我們需要把視錐體投影到屏幕空間中。經過這一步變換,我們會得到真正的像素位置,而不是虛擬的三維坐標。
屏幕空間是一個二維空間,因此,我們必須把頂點從裁剪空間投影到屏幕空間中,來生成對應的2D坐標。這個過程可以理解成兩個步驟。
首先,我們需要進行標准齊次除法,也被稱為透視除法。雖然這個步驟聽起來很陌生,但是它實際上非常簡單,就是用齊次坐標的w分量去除以x、y、z分量。在OpenGL中,我們把這一步得到的坐標叫做歸一化的設備坐標。經過這一步,我們可以把坐標從齊次裁剪坐標空間轉到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分量被用於深度緩沖。一個傳統的方式是把的值直接存進深度緩沖中,但這並不是必須的。通常驅動生產商會根據硬件來選擇最好的存儲格式。此時clipw也並不會被拋棄,雖然它已經完成了它的主要工作——在齊次除法中作為分母來得到NDC,但它仍然會在后續的一些工作中起到重要的工作,例如進行透視校正插值。
在Unity中,從裁剪空間到屏幕空間的轉換是由Unity幫我們完成的。我們的頂點着色器只需要把頂點轉換到裁剪空間即可。
現在我們可以確定妞妞的鼻子在屏幕上的像素位置了,假設屏幕像素寬度為400,高度為300。十一選不我們需要進行齊次除法,把裁剪空間的坐標投影到NDC中。然后,再映射到屏幕空間中。這個過程如下:
由此,我們就知道了妞妞鼻子在屏幕上的位置——(285.617,234.096).
法線變換
法線,也被稱為法矢量。在游戲中,模型的一個頂點往往會攜帶額外的信息,而頂點法線就是其中的一種信息。當我們變換一個模型的時候,不僅需要變換它的頂點,還需要變換頂點法線,以便在后續處理中計算光照。
一般來說,點和絕大部分方向矢量都可以使用同一個4*4或3*3的變換矩陣把其從坐標空間A變換到坐標空間B中。但在變換發現的 時候,如果使用同一個變換矩陣,可能就無法確保維持法線的垂直線。
切線,也被稱為切矢量與法線類似,切線往往也是模型頂點攜帶的一種信息,它通常與紋理空間對其,而且與法線方向垂直。如下圖:
由於切線是兩個頂點之間的差值計算得到的,因此我們可以直接使用用於變換頂點的變換矩陣來變換切線。假設,我們使用3*3的變換矩陣來變換頂點,可以由下面的式子直接得到變換后的切線。
其中T(a)和T(b)分別表示在坐標看空間A下和坐標空間B下的切線方向。但如果直接使用來變換法線,得到的新的法線方向可能就不會與表面垂直了。下圖給出了一個例子:
我們知道同一個頂點的切線T(a)和法線N(a)必須滿足垂直條件,T(a)·N(a) = 0.給定變換矩陣,我們已經知道了
。現在我們想要找到一個矩陣G來變換法線N(a),使得變換后的法線仍然與切線垂直。即
對上式進行一些推導可以得到:
由於,因此如果
,那么上式即可成立。也就是說,如果
,即使用原變換矩陣的逆轉置矩陣來變換法線就可以得到正確的結果。
Unity Shader 的內置變量
使用Unity 寫 Shader 的一個好處在於,它提供了很多內置的參數,這使得我們不再需要自己手動計算一些值。本節將給出Unity內置的用於空間變換和攝像機以及屏幕參數的內置變量。這些內置變量可以在UnityShaderVariables.chnic文件中找到定義和說明。
下表給出了Unity5.2 版本提供的所有內置變換矩陣,下面所有的矩陣都是float4×4類型的。
其中有一個矩陣比較特殊,即UNITY_MATRIX_T_MV矩陣。
下表給出了Unity5.2版本提供的攝像機和屏幕參數信息
對於線性變換來說(例如旋轉和縮放),僅適用3×3的矩陣就足夠表示所有的變換了。但如果存在平移變換,我們就需要使用4×4的矩陣,因此,在對頂點的變換中個,我們通常使用4×4的變換矩陣。當然,在變換前我們需要把點坐標轉換成齊次坐標的表示會,即把頂點的w分量設為1。而在對方向矢量的變換中,我們通常使用3×3的矩陣就足夠了,這是因為平移變換對方向矢量是沒有影響的。
我們通常在Unity Shader中使用CG作為着色器編程語言。在CG中變量類型有很多種。
在CG中,矩陣類型是由float3×3、float4×4等關鍵詞進行聲明和定義的。而對於float3、float4等類型的變量,我們即可以把它當成一個矢量,也可以把它當成是一個1×n的行矩陣或者一個n×1的列矩陣。這取決於運算的 種類和它們在運算中的位置。例如,當我們進行點積操作時,兩個操作數就被當成矢量類型,如下:
- float4 a = float4(1.0,2.0,3.0,4.0);
- float4 b = float4(1.0,2.0,3.0,4.0);
- //對兩個矢量進行點積操作
- float result = dot(a, b);
但在進行矩陣相乘時,參數的位置將決定是按列矩陣還是行矩陣進行乘法。在CG中,矩陣乘法是通過mul函數實現的。例如:
- float4 v = float4(1.0, 2.0, 3.0, 4.0);
- float4×4 M = float4×4(1.0, 0.0, 0.0, 0.0,
- 0.0, 1.0, 0.0, 0.0,
- 0.0, 0.0, 1.0, 0.0,
- 0.0, 0.0, 0.0, 1.0);
- //把v當成列矩陣和矩陣M進行右乘
- float4 column_mul_result = mul(M, v);
- //把v當成行矩陣和矩陣M進行左乘
- float4 row_mul_result = mul(v, M);
因此,參數的位置會直接影響結果值。通常在變換頂點時,我們都是使用右乘的方式來按列矩陣進行乘法。這是因為,Unity提供的內置矩陣(如UNITY_MATRIX_MVP等)都是按列存儲的。但有時,我們也會使用左乘的方式,這是因為可以省去對矩陣的轉置的操作。
需要注意的一點是,CG對矩陣類型中元素的初始化和訪問順序。在CG中,對float4×4等類型的變量是按行優先進行填充的。假設我們使用數字(1,2,3,4,5,6,7,8,9)去填充一個3×3的矩陣,如果是按照行優先的方式,得到的矩陣是:
如果是按列優先的話,得到的矩陣是:
CG使用的是行優先的方法,即使一行一行地填充矩陣的
類似地,當我們再CG中訪問一個矩陣中的元素時,也是按行來索引的。例如:
- //按行優先的方式初始化矩陣M
- float3×3 M = float3×3(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0);
- //得到M的第一行,即(1.0, 2.0, 3.0)
- float3 row = M[0];
- //得到M的第2行第一列的元素,即4.0
- float ele = M[1][0]
之所以Unity Shader中的矩陣類型滿足上述規則,是因為使用的是CG語言,換句話說,上面的特性都是CG的規定。
在頂點/片元着色器中,有兩種方式來獲得片元的屏幕坐標。
一種是在片元着色器的輸入中聲明VPOS或WPOS語義。VPOS是HLSL中對屏幕坐標的語義,而WPOS是CG中對屏幕坐標的語義,兩者在Unity Shader都是等價的。我們可以在HLSL/CG中通過語義的方式來定義頂點/片元着色器的默認輸入,而不需要自己定義輸入輸出的數據結構。使用這種寫法,可以在片元着色器中這樣寫:
- fixed4 frag(float4 sp : VPOS) : SV_TARGET
- {
- //用屏幕坐標除以屏幕分辨率_ScreenParams.xy,得到視口空間中的坐標
- return fixed4(sp.xy/_ScreenParams.xy, 0.0, 1.0);
- }
得到的效果如下圖所示:
VPOS/WPOS語義定義的輸入是一個float4類型的變量。我們已經知道它的xy值代表了在屏幕空間中的像素坐標。如果屏幕的分辨率為400×300,那么x的范圍就是[0.5,400.5],y的范圍就是[0.5,300.5]。注意,這里的像素坐標並不是整數值,這是因為openg 和DirectX 10 以后的版本認為像素中心對應的是浮點值中的0.5。在Unity中,VPOS/WPOS的z分量范圍是[0,1],在攝像機的近裁剪平面處,z值為0,在遠裁剪平面處,z值為1.對於w分量,我們需要考慮攝像機的投影類型。如果是透視投影, 那么w分量的范圍是,Near和Far對應了Camera組件中設置的近裁剪平面和遠裁剪平面矩陣攝像機的遠近;如果使用的是正交投影,那么w分量的值恆為1.這些值是通過對經過投影矩陣變換后的w分量取倒數后得到的。在代碼的最后,我們把屏幕空間除以屏幕分辨率來得到的視口空間中的坐標。視口坐標很簡單,就是把屏幕坐標歸一化,這樣屏幕左下角就是(0,0),右上角就是(1,1)。如果已知屏幕坐標的話,我們只需要把xy值除以屏幕分辨率即可。
另一種方式是通過Unity提供的ComputeScreenPos函數。這個函數在UnityCGcginc里被定義。通常的用法需要兩個步驟,首先在頂點着色器中將ComputeScreenPos的結果保存在輸出結構體中,然后在片元着色器中進行一個齊次除法運算后得到視口空間下的坐標。例如:
- struct vertOut
- {
- float4 pos : SV_POSITION;
- float4 scrPos : TEXCOORD0;
- }
- vertOut vert(appdata_base v)
- {
- vertOut o;
- o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
- //第一步:把ComputeScreenPos的結果保存到scrPos中
- o.scrPos = ComputeScreenPos(o.pos)
- return 0;
- }
- fixed4 frag(vertOut i) : SV_Target
- {
- //第二步,用scrPos.xy除以scrPos.w得到視口空間中的坐標
- float2 wcoord = (i.scrPos.xy / i.scrPos.w);
- return fixed4(wcoord, 0.0, 1.0);
- }
上面代碼的實現效果和上面的代碼一樣。這種方法實際上是手動實現了屏幕映射的過程,而且它得到的坐標直接就是視口空間中的坐標。我們已經知道了如何將裁剪坐標空間中的點映射到屏幕坐標中。據此,我們可以得到視口空間中的坐標,公式如下:
上面公式的思想就是,首先對裁剪空間下的坐標進行齊次除法,得到閥內在[-1,1]的NDC,然后再將其映射到范圍在[0,1]的視口空間下的坐標。