談一談3D編程中的矩陣
常見約定
談論矩陣之前,要先明確一下使用的約定。約定不同,用法迥異。
行主(row major)和列主(column major)
圖形API或shader語言中常常把矩陣存儲爲一維數組,這就帶來了一個問題,按什麼順序將一維數組中的元素填入到矩陣中,以及要訪問矩陣的元素應該指定什麼樣的下標。
行主
如果遍歷一維數組的元素,然後依次填入矩陣的每一行,這就是行主。以CG語言爲例:
float3x3 mat = float3x3(1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 3.1, 3.2, 3.3);
以上的代碼構建出的矩陣是這樣的:
而如果要訪問第1行第2列的元素,應該使用mat[0][1]
, 下標指定時先行後列(索引從0開始)。
列主
按順序填入元素時,是按列填入的,則是列主。以glsl語言爲例:
mat3 mat = mat3(1.1, 2.1, 3.1, 1.2, 2.2, 3.2, 1.3, 2.3, 3.3);
以上代碼構建出的矩陣同樣是:
但是一維數組(或者說參數列表)中的順序和之前CG的完全不同,每個元素都是依次從第一列開始填入,填滿後填第二列。而在glsl中,要訪問第1行第2列的元素,則應該使用mat[1][0]
,下標指定時先列後行。
3D編程中的例子
不光是shader中需要分清楚行主列主的約定,在3D程序代碼中使用矩陣時,也是要注意這個約定,無論是使用3D引擎的數學庫的時候,還是自己寫3D程序時自行撰寫Matrix類。以筆者的開源項目mini3d.js中的Matrix4類爲例。這個矩陣類使用列主的約定,其中設置位移的方法:
setTranslate(x,y,z){
let e = this.elements;
e[0] = 1; e[4] = 0; e[8] = 0; e[12] = x;
e[1] = 0; e[5] = 1; e[9] = 0; e[13] = y;
e[2] = 0; e[6] = 0; e[10] = 1; e[14] = z;
e[3] = 0; e[7] = 0; e[11] = 0; e[15] = 1;
return this;
}
這是一個4x4齊次變換矩陣,因爲是列主約定,位置向量被設置到一維數組的12,13,14三個下標位置中。在後期從世界矩陣獲取世界座標時,遵循這一約定,從12,13,14三個下標位置取出世界座標。不過如果數學庫封裝的足夠好,對於一般的應用,倒是不會直接去獲取元素,但總有一些特殊的需求,需要自己構建矩陣,此時必須知道所使用引擎數學庫的約定。
行向量左乘矩陣和列向量右乘矩陣
矩陣和向量相乘時,也就是使用矩陣變換向量時,按照向量所在的位置,可以分爲左乘和右乘兩種約定。
左乘
向量在左邊,比較符合從左向右的閱讀順序,例如:
float3 vec2 = mul(vec1, someMat3);
如果vec1是[1,0,0]
, someMat3是上面的3x3矩陣,左乘就是這樣寫:
可以看到,左乘時,向量要寫成行向量的形式,才能滿足矩陣乘法的規則。
右乘
向量在右邊,例如:
float3 vec2 = mul(someMat3, vec1);
還是上面的向量和矩陣,右乘就是這樣寫:
可以看到,右乘時,向量要寫成列向量的形式。
爲什麼要約定左乘還是右乘?
在CG/HLSL/GLSL的shader中,向量和矩陣之間相乘既可以寫成左乘也可以寫成右乘。而一般的3D引擎會提供一個函數去用矩陣變換向量,或者對於一些C++引擎,會使用運算符重載,一般而言你沒有機會在調用時放錯向量的位置。但是是左乘還是右乘,決定了矩陣應該如何構造和串接。
對矩陣構造的影響
以上面的mini3d.js中的位移矩陣爲例,這個矩陣約定是要使用向量右乘,因此位移矢量位於矩陣的第四列,這個矩陣乘以列向量的時候是這樣的:
這樣正確的將齊次座標偏移了。
如果這個地方將放在矩陣的最後一行,結果就完全不對了。
有興趣的讀者可以自己乘一下試試。
那麼這兒可能有個誤解,就是列主矩陣用右乘,行主矩陣用左乘。其實行主列主和左乘右乘並沒有關係,不需要強行綁定。例如在CG中,矩陣是行主構造的,但是可以隨便你使用左乘還是右乘,並沒有限制。行主列主只是決定了矩陣如何存儲在內存中,而左乘右乘纔會決定矩陣的樣子,因爲要適應向量的位置。不過有意思的是,列主加右乘的約定和行主加左乘的約定,對於矩陣的一維數組存儲來說是會得到相同的一維數組。這是因爲同樣的變換,使用左乘約定和右乘約定的矩陣是互爲轉置的。而同一個一維數組按行主和按列主構建的矩陣也是互爲轉置的。
對矩陣串接的影響
使用矩陣往往需要把變換矩陣相乘,即串接起來,然後再使用相乘的結果去變換向量,這樣大大減少了矩陣向量乘法的次數。矩陣相乘是不可交換順序的,比如我們要把位移T,旋轉R和縮放S三個變換矩陣相乘。如何決定矩陣相乘的順序呢?這就要看向量的位置了。靠近向量的矩陣先起作用。例如,在左乘約定下:
對於點向量P先縮放,再旋轉,再平移。
而在右乘約定下,同樣是先縮放,再旋轉,最後平移,矩陣的串接順序是這樣的:
因此矩陣串接的順序取決與向量的位置。
座標系變換矩陣的構造
3D編程中座標系的變換特別常見,比如我們常用的物體空間到世界空間變換,世界空間到物體空間變換。那麼我們如何輕鬆構造出座標系變換矩陣,或者說給你一個變換矩陣,能從中得出什麼信息呢?先介紹一點數學知識。
向量空間和向量空間的基
- 線性無關和線性相關:
對於一組向量,如果不存在一組實數,其中至少有一個不爲零,使得下式成立:
則稱這組向量線性無關,否則稱這組向量線性相關
什麼意思呢?通俗的說,一組向量如果是線性無關的,那麼其中任何一個向量不能用剩下的向量通過線性運算(線性運算是加法和數量乘法)得到。比如3D座標系的x,y,z三個軸向量,就是線性無關的。 - 一個n維向量空間可以由n個線性無關的向量組成的向量集合進行線性組合來生成,這n個線性無關的向量組合成爲該n維空間的基。
還是以3D座標系的x,y,z三個軸爲例,它們是線性無關的,且可以組合得到3D空間下的任意向量,因此x,y,z軸是3D座標系的基。 - 正交基:如果一組基向量兩兩之間點積爲0,則該基爲正交基
3D座標系的x,y,z就是一組正交基。
座標空間的變換矩陣
假設我們有座標系A和座標系B,已知座標系A的三個座標軸在座標系B中的向量爲,座標系A的原點在座標系B中的座標爲求座標系A到座標系B的變換矩陣。注意:使用列向量右乘約定。
思考
座標之所以能被定義出來,就是因爲有個參考,對於座標系A,其所有向量是根據A的三個軸定義出來的,其實就是上面向量空間的概念,三個軸是基向量,所有的向量都能使用基向量線性表出。而點可以看成從原點出發的向量的端點,這也是一個線性運算,因此三個軸再加上原點就能線性表出座標系中的所有的點。當我們在B座標系中表示A座標系的三個軸和原點時,可以認爲B是A的父座標空間。座標系A的三個軸本身也是座標系B中的向量,是由座標系B的三個軸線性表出的。這其實具有傳遞性。子空間的向量由子空間的軸表示,而子空間的軸作爲父空間中的一個向量,又由父空間的軸表示。
推導
座標系A到座標系B的變換矩陣的作用,是將座標系A中的向量和點在座標系B中表示。我們先看向量。根據上面的數學知識,向量可以使用向量空間的基線性表出,設向量爲是座標系A中的向量,則:
其中是A座標系的x,y,z三個軸,即。
座標系A的三個座標軸在座標系B中的向量爲,將其代入上式,那麼在座標系B中的表示,可以用下式得到:
我們按分量展開,並且將寫到右邊:
上式的右邊可以寫成向量的點積:
因此可以轉換成矩陣:
那麼我們得到了,列向量右乘約定下,將A中的向量轉換到B的矩陣。這個矩陣是將A的在B中表示的三個座標軸按列填入矩陣得到。
爲了變換點,需要將原點加入,這需要使用齊次座標和4X4齊次變換矩陣,具體就不推導了,直接看結論。
結論
將分別填入矩陣的前三列,將填入矩陣的第4列,得到的4x4矩陣就是從座標系A變換到座標系B的矩陣。使用這個矩陣能將座標系A中的點和向量變換到座標系B中,也即將相對於座標系A的座標值變換成相對於座標系B的座標值。
驗證
將矩陣乘以單位向量,即將座標系A中的X軸變換到座標系B中。
得到的向量正是A座標系的X軸在B座標系中表示。
同樣如果我們用該矩陣變換和,會分別得到A座標系的Y軸和Z軸。而如果我們用該矩陣變換A座標系中的原點,得到的就是A座標系的原點在B中的座標點。
看透變換矩陣的小黑盒
知道了上面的結論之後,給你一個從A空間到B空間的變換矩陣,提取它的前三列並歸一化(因爲可能存在縮放)就得到了A空間的座標軸在B空間中的向量。提取它的第四列,就得到了A空間的原點在B空間中的座標。如此,矩陣不再是黑盒子。
向量變換矩陣
在3D中,我們經常需要變換各種向量,如光線方向,視角方向,法線,切線等等。在前面的推導過程中,我們得到了變換向量的座標空間3x3變換矩陣,只要將空間A在空間B中的三個軸按列填入矩陣,就得到了從A到B的變換矩陣。例如,下面的glsl shader代碼構建了一個從切線空間到世界空間的變換矩陣。
//TBN向量按列放入矩陣,構造出 TangentToWorld矩陣
//注意:glsl矩陣是列主的
mat3 tangentToWorld = mat3(worldTangent, worldBinormal, worldNormal);
填入矩陣的三列是世界空間下的TBN向量,因此該矩陣是從切線空間到世界空間的向量變換矩陣。
那麼我們如果想得到世界空間到切線空間的向量變換矩陣,應該怎麼辦呢?按照上面的思路,只要找到世界空間的座標軸在切線空間中的表示,然而世界空間是切線空間的父空間,所以除非首先有世界到切線空間的變換矩陣,否則無法得到世界空間的座標軸在切線空間中的表示,這顯然陷入了死循環。但是我們可以換個思路,只要求得tangentToWorld 矩陣的逆矩陣就可以了。而tangentToWorld 矩陣是一個正交矩陣,因此其逆矩陣等於其轉置。也就是說,將世界空間的TBN向量按行放入矩陣,構造出worldToTangent矩陣:
//將TBN向量按行放入矩陣,構造出worldToTangent矩陣
//注意glsl中mat3是列主的
mat3 worldToTangent = mat3(worldTangent.x, worldBinormal.x, worldNormal.x,
worldTangent.y, worldBinormal.y, worldNormal.y,
worldTangent.z, worldBinormal.z, worldNormal.z);
逆矩陣計算
上面討論到了逆矩陣,本節討論一下3D引擎和shader中常見的逆矩陣計算方法。
正交矩陣的逆矩陣
正交矩陣的逆矩陣等於其轉置矩陣。什麼樣的矩陣是正交矩陣呢?
- 首先,根據正交矩陣的定義,正交矩陣的和它的轉置矩陣的乘積是單位陣:。那麼計算這個等式是否成立就可以判斷了。
- 不過我們一般不會去判斷某矩陣是否是正交矩陣。因爲根據矩陣的構造方式,我們就可以知道它是否是正交矩陣。正交矩陣一般是由一組標準正交基構造。例如上面的TBN矩陣,它的三個軸是一組標準正交基。再比如旋轉矩陣,因爲座標軸只進行了旋轉,三個軸仍然是互相正交的,且長度爲1,所以也是正交矩陣。
矩陣乘積的逆矩陣
根據如下性質:
可計算矩陣乘積的逆矩陣,當A,B是正交矩陣或可根據含義推導出逆矩陣時,這個方法是可用的。例如我們推導camera的view矩陣時,view矩陣其實是camera的世界矩陣的逆矩陣,而camera本身只有旋轉和平移,因此其世界矩陣是:
其中是使用Camera本地座標系的三個軸UVN在世界空間的表示構造的矩陣(和我們上面的方法一樣),這是一個正交矩陣。而平移矩陣的逆矩陣可以簡單的把位移值取反得到。
因此可得:
一般矩陣的逆矩陣計算
上面計算視圖矩陣的方法雖然不錯,但是也只是寫寫簡單demo時能用。在開發引擎或渲染框架時,camera可能掛在某個節點下面,也就是存在一系列的父子座標系嵌套,這種情況下直接計算出上面UVN矩陣需要的向量並不方便。以N向量爲例:
需要世界空間中的camera座標和目標點的座標。雖然我們可以先計算出camera的物體到世界矩陣,然後從中獲取到世界座標。但是還有一個問題是,我們往往需要通過旋轉來控制camera,一般會使用到四元數。所以最後的結果往往是我們需要一個通用的逆矩陣計算方法。一般會使用標準伴隨陣去計算。具體不討論了,給出mini3d.js中的方法,有詳細的註釋:
/**
* Calculate the inverse matrix of source matrix, and set to this.
* @param {Matrix4} source The source matrix.
* @returns this
*/
setInverseOf(source){
let s = source.elements;
let d = this.elements;
let inv = new Float32Array(16);
//使用標準伴隨陣法計算逆矩陣:
//標準伴隨陣 = 方陣的代數餘子式組成的矩陣的轉置矩陣
//逆矩陣 = 標準伴隨陣/方陣的行列式
//計算代數餘子式並轉置後放入inv矩陣中
inv[0] = s[5]*s[10]*s[15] - s[5] *s[11]*s[14] - s[9] *s[6]*s[15]
+ s[9]*s[7] *s[14] + s[13]*s[6] *s[11] - s[13]*s[7]*s[10];
inv[4] = - s[4]*s[10]*s[15] + s[4] *s[11]*s[14] + s[8] *s[6]*s[15]
- s[8]*s[7] *s[14] - s[12]*s[6] *s[11] + s[12]*s[7]*s[10];
inv[8] = s[4]*s[9] *s[15] - s[4] *s[11]*s[13] - s[8] *s[5]*s[15]
+ s[8]*s[7] *s[13] + s[12]*s[5] *s[11] - s[12]*s[7]*s[9];
inv[12] = - s[4]*s[9] *s[14] + s[4] *s[10]*s[13] + s[8] *s[5]*s[14]
- s[8]*s[6] *s[13] - s[12]*s[5] *s[10] + s[12]*s[6]*s[9];
inv[1] = - s[1]*s[10]*s[15] + s[1] *s[11]*s[14] + s[9] *s[2]*s[15]
- s[9]*s[3] *s[14] - s[13]*s[2] *s[11] + s[13]*s[3]*s[10];
inv[5] = s[0]*s[10]*s[15] - s[0] *s[11]*s[14] - s[8] *s[2]*s[15]
+ s[8]*s[3] *s[14] + s[12]*s[2] *s[11] - s[12]*s[3]*s[10];
inv[9] = - s[0]*s[9] *s[15] + s[0] *s[11]*s[13] + s[8] *s[1]*s[15]
- s[8]*s[3] *s[13] - s[12]*s[1] *s[11] + s[12]*s[3]*s[9];
inv[13] = s[0]*s[9] *s[14] - s[0] *s[10]*s[13] - s[8] *s[1]*s[14]
+ s[8]*s[2] *s[13] + s[12]*s[1] *s[10] - s[12]*s[2]*s[9];
inv[2] = s[1]*s[6]*s[15] - s[1] *s[7]*s[14] - s[5] *s[2]*s[15]
+ s[5]*s[3]*s[14] + s[13]*s[2]*s[7] - s[13]*s[3]*s[6];
inv[6] = - s[0]*s[6]*s[15] + s[0] *s[7]*s[14] + s[4] *s[2]*s[15]
- s[4]*s[3]*s[14] - s[12]*s[2]*s[7] + s[12]*s[3]*s[6];
inv[10] = s[0]*s[5]*s[15] - s[0] *s[7]*s[13] - s[4] *s[1]*s[15]
+ s[4]*s[3]*s[13] + s[12]*s[1]*s[7] - s[12]*s[3]*s[5];
inv[14] = - s[0]*s[5]*s[14] + s[0] *s[6]*s[13] + s[4] *s[1]*s[14]
- s[4]*s[2]*s[13] - s[12]*s[1]*s[6] + s[12]*s[2]*s[5];
inv[3] = - s[1]*s[6]*s[11] + s[1]*s[7]*s[10] + s[5]*s[2]*s[11]
- s[5]*s[3]*s[10] - s[9]*s[2]*s[7] + s[9]*s[3]*s[6];
inv[7] = s[0]*s[6]*s[11] - s[0]*s[7]*s[10] - s[4]*s[2]*s[11]
+ s[4]*s[3]*s[10] + s[8]*s[2]*s[7] - s[8]*s[3]*s[6];
inv[11] = - s[0]*s[5]*s[11] + s[0]*s[7]*s[9] + s[4]*s[1]*s[11]
- s[4]*s[3]*s[9] - s[8]*s[1]*s[7] + s[8]*s[3]*s[5];
inv[15] = s[0]*s[5]*s[10] - s[0]*s[6]*s[9] - s[4]*s[1]*s[10]
+ s[4]*s[2]*s[9] + s[8]*s[1]*s[6] - s[8]*s[2]*s[5];
//計算行列式,選擇方陣的第一列,對該列中的四個元素S[0],s[1],s[2],s[3]分別乘以對應的代數餘子式,然後相加
let det = s[0]*inv[0] + s[1]*inv[4] + s[2]*inv[8] + s[3]*inv[12];
//注:選擇任意一行,例如第一行,也是可以的
//let det = s[0]*inv[0] + s[4]*inv[1] + s[8]*inv[2] + s[12]*inv[3];
if(det===0){
return this;
}
det = 1 / det;
for(let i=0; i<16; ++i){
d[i] = inv[i] * det;
}
return this;
}
矩陣變換法線
通用的正確變換方法:使用逆轉置矩陣
很多書上都講了,使用一個非正交矩陣去變換法線時,變換後的法向量最終不垂直於變換後的表面。變換矩陣需要使用原空間變換矩陣的逆轉置矩陣。
推導
因爲切線方向和法線方向是垂直的,所以同一頂點的切向量和法向量必須滿足等式。而變換後的切線和法線仍然滿足。設變換矩陣爲,則有。設變換法向量使用的矩陣爲,那麼有:
將這個點積寫成矩陣乘法,則有:
由於,則當時上式成立,因此可以得到
即,使用變換點的矩陣的逆矩陣的轉置矩陣,也就是逆轉置矩陣,可正確變換法線。
正交矩陣可直接使用原變換矩陣
因爲正交矩陣的逆矩陣就等於其轉置矩陣,所以其逆轉置矩陣就是其自身。因此如果確定矩陣是正交矩陣,就可以直接變換法線。例如我們在世界空間中計算法線貼圖時,計算得到的切線空間到世界空間變換矩陣就是一個正交矩陣,因此可以直接用其變換法線貼圖中解壓出來的切線空間的法線,將其變換到世界空間,在世界空間中進行光照計算。
3D場景中物體的法線變換
在shader中我們經常需要將物體的法線變換到世界空間,這樣就需要一個物體空間到世界空間的法線變換矩陣,這個矩陣是物體空間到世界空間變換矩陣的逆轉置矩陣,也就是世界空間到物體空間變換矩陣的轉置矩陣。由於從物體空間到世界空間的變換比較複雜,可能是很多層座標系變換的疊加,且可能包含非等比縮放,所以一般遊戲引擎都會計算出物體空間到世界空間變換矩陣的逆矩陣(使用上面的標準伴隨陣方法),得到世界空間到物體空間的變換矩陣。理論上,將這個矩陣轉置一下,就可以作爲法線變換矩陣傳入shader中。不過可以通過反轉矩陣向量乘法的順序,省掉轉置矩陣的計算,也這樣可以省掉法線矩陣的計算和uniform的傳入,直接使用世界空間到物體空間的變換矩陣
v_worldPos = u_object2World*a_Position;
v_worldNormal = normalize(a_Normal * mat3(u_world2Object));
在上面的glsl代碼中,我們看到計算世界空間的位置v_worldPos
時,使用的是向量右乘矩陣u_object2World
的方法,這說明我們在使用右乘約定。但是下面計算世界空間法線的時候,我們使用了向量左乘u_world2Object
矩陣,這並不是我們破壞了約定,而是因爲這樣等價於向量右乘該矩陣的轉置矩陣。這樣我們就可以直接使用世界空間到物體空間變換矩陣u_world2Object
來變換法線。這個矩陣還有其他作用,因此這麼使用一專多能,還省去了轉置計算法線矩陣並傳入。算是一個小技巧。