變換(Transformations)
我們可以嘗試著在每一幀改變物體的頂點并且重設緩沖區從而使他們移動,但這太繁瑣了,而且會消耗很多的處理時間。然而,我們現在有一個更好的解決方案,使用(多個)矩陣(Matrix)對象可以更好的變換(Transform)一個物體。
向量(Vector)
向量可以在任意維度(Dimension)上,但是我們通常只使用2至4維。如果一個向量有2個維度,它表示一個平面的方向(想象一下2D的圖像),當它有3個維度的時候它可以表達一個3D世界的方向。
由于向量表示的是方向,起始于何處并不會改變它的值。下圖我們可以看到向量v和w是相等的,盡管他們的起始點不同:
我們通常設定這個方向的原點為(0,0,0),然后指向對應坐標的點,使其變為位置向量(Position Vector)來表示(你也可以把起點設置為其他的點,然后說:這個向量從這個點起始指向另一個點)。位置向量(3, 5)的在圖像中起點是(0, 0),指向(3, 5)。我們可以使用向量在2D或3D空間中表示方向與位置.
向量與標量運算(Scalar Vector Operations)
標量(Scalar)只是一個數字(或者說是僅有一個分量的矢量)。當把一個向量加/減/乘/除一個標量,我們可以簡單的把向量的每個分量分別進行該運算。
對于加法來說會像這樣:
向量取反(Vector Negation)
對一個向量取反會將其方向逆轉。我們在一個向量的每個分量前加負號就可以實現取反了(或者說用-1數乘該向量):
向量加減
向量的加法可以被定義為是分量的(Component-wise)相加,即將一個向量中的每一個分量加上另一個向量的對應分量:
就像普通數字的加減一樣,向量的減法等于加上第二個向量的相反數。
長度(Length)
我們使用勾股定理(Pythagoras Theorem)來獲取向量的長度/大小。
有一個特殊類型向量叫做單位向量(Unit Vector)。
我們可以用任意向量的每個分量除以向量的長度得到它的單位向量:
我們把這種方法叫做一個向量的標準化(Normalizing)。
向量相乘(Vector-vector Multiplication)
當需要乘法時我們可以從中選擇:一個是點乘(Dot Product),另一個是叉乘(Cross Product)。
- 點乘(Dot Product)
點乘在計算光照的時候會很有用。
- 叉乘(Cross Product)
叉乘只在3D空間有定義,它需要兩個不平行向量作為輸入,生成正交于兩個輸入向量的第三個向量。如果輸入的兩個向量也是正交的,那么叉乘的結果將會返回3個互相正交的向量。
下面你會看到兩個正交向量A和B叉乘結果:
注:用考研時老湯(湯家鳳)教的那種方法可以方便的寫出這個公式。
矩陣(Matrix)
下面是一個2×3矩陣的例子:
矩陣可以通過(i, j)進行索引,i是行,j是列,這就是上面的矩陣叫做2×3矩陣的原因(3列2行,也叫做矩陣的維度(Dimension))。這與你在索引2D圖像時的(x, y)相反,獲取4的索引是(2, 1)(第二行,第一列)(譯注:如果是圖像索引應該是(1, 2),先算列,再算行)。
矩陣的加減
矩陣與標量的加減如下所示:
矩陣與矩陣之間的加減就是兩個矩陣對應元素的加減運算
矩陣的數乘(Matrix-scalar Products)
和矩陣與標量的加減一樣,矩陣與標量之間的乘法也是矩陣的每一個元素分別乘以該標量。
矩陣相乘(Matrix-matrix Multiplication)
矩陣乘法基本上意味著遵照規定好的法則進行相乘。當然,相乘還有一些限制:
- 只有當左側矩陣的列數與右側矩陣的行數相等,兩個矩陣才能相乘。
- 矩陣相乘不遵守交換律(Commutative),。
矩陣與向量相乘
為什么我們關心矩陣是否能夠乘以一個向量?有很多有意思的2D/3D變換本質上都是矩陣,而矩陣與我們的向量相乘會變換我們的向量。
單位矩陣(Identity Matrix)
單位矩陣是一個除了對角線以外都是0的N × N矩陣。
縮放(Scaling)
當我們對一個向量進行縮放的時候就是對向量的長度進行縮放,而它的方向保持不變。如果我們進行2或3維操作,那么我們可以分別定義一個有2或3個縮放變量的向量,每個變量縮放一個軸(x、y或z)。
我們可以嘗試去縮放向量
v=(3,2)。我們可以把向量沿著x軸縮放0.5,使它的寬度縮小為原來的二分之一;我們可以沿著y軸把向量的高度縮放為原來的兩倍。我們看看把向量縮放(0.5, 2)所獲得的s是什么樣的:
記住,OpenGL通常是在3D空間操作的,對于2D的情況我們可以把z軸縮放1這樣z軸的值就不變了。我們剛剛的縮放操作是不均勻(Non-uniform)縮放,因為每個軸的縮放因子(Scaling Factor)都不一樣。如果每個軸的縮放都一樣那么就叫均勻縮放(Uniform Scale)。
我們下面設置一個變換矩陣來為我們提供縮放功能。如果我們把縮放變量表示為(S1,S2,S3)我們可以為任意向量(x, y, z)定義一個縮放矩陣:
注意,第四個縮放的向量仍然是1,因為不會縮放3D空間中的w分量。w分量另有其他用途,在后面我們會看到。
平移(Translation)
平移(Translation)是在原來向量的基礎上加上另一個的向量從而獲得一個在不同位置的新向量的過程,這樣就基于平移向量移動(Move)了向量。
和縮放矩陣一樣,在4×4矩陣上有幾個特別的位置用來執行特定的操作,對于平移來說它們是第四列最上面的3個值。如果我們把縮放向量表示為(Tx, Ty, Tz)我們就能把平移矩陣定義為:
這樣是能工作的,因為所有的平移值都要乘以向量的w列,所以平移值會加到向量的原始坐標上(想想矩陣乘法法則)。而如果你用3x3矩陣我們的平移值就沒地方放也沒地方乘了,所以是不行的。
齊次坐標(Homogeneous coordinates)
向量的w分量也叫齊次坐標。想要從齊次坐標得到3D坐標,我們可以把x、y和z坐標除以w坐標。我們通常不會注意這個問題,因為w分量通常是1.0。使用齊次坐標有幾點好處:它允許我們在3D向量上進行平移(如果沒有w分量我們是不能平移向量的),下一章我們會用w值創建3D圖像。
如果一個向量的齊次坐標是0,這個坐標就是方向向量(Direction Vector),因為w坐標是0,這個向量就不能平移(譯注:這也就是我們說的不能平移一個方向)。
有了平移矩陣我們就可以在3個方向(x、y、z)上移動物體,它是我們的變換工具箱中非常有用的一個變換矩陣。
旋轉(Rotation)
如果你想知道旋轉矩陣是如何構造出來的,我推薦你去看可汗學院線性代數視頻。
首先我們來定義一個向量的旋轉到底是什么。2D或3D空間中點的旋轉用角(Angle)來表示。角可以是角度制或弧度制的,周角是360度或2 PI弧度。我個人更喜歡用角度,因為它們看起來更直觀。
大多數旋轉函數需要用弧度制的角,但是角度制的角也可以很容易地轉化為弧度制:
- 弧度轉角度:角度 = 弧度 * (180.0f / PI)
- 角度轉弧度:弧度 = 角度 * (PI / 180.0f)
PI約等于3.14159265359。
轉半圈會向右旋轉360/2 = 180度,向右旋轉1/5圈表示向右旋轉360/5 = 72度。這表明2D空間的向量v是k由向右旋轉72度得到的:
在3D空間中旋轉需要一個角和一個旋轉軸(Rotation Axis)。物體會沿著給定的旋轉軸旋轉特定角度。
使用三角學就能把一個向量變換為一個經過旋轉特定角度的新向量。這通常是使用一系列正弦和余弦各種巧妙的組合得到的(一般簡稱sin和cos)。當然,討論如何生成變換矩陣超出了這個教程的范圍。
旋轉矩陣在3D空間中每個單位軸都有不同定義,這個角度表示為theta:
沿x軸旋轉:
沿y軸旋轉:
沿z軸旋轉:
利用旋轉矩陣我們可以把我們的位置向量(Position Vectors)沿一個或多個軸進行旋轉。也可以把多個矩陣結合起來,比如先沿著X軸旋轉再沿著Y軸旋轉。
但是這會很快導致一個問題——萬向節死鎖(Gimbal Lock,可以看看這個視頻(優酷)來了解)。
我們不會討論它的細節,但是一個更好的解決方案是沿著任意軸比如(0.662, 0.2, 0.7222)(注意,這是個單位向量)旋轉,而不是使用一系列旋轉矩陣的組合。這樣一個(超級麻煩)的矩陣是存在的,下面(Rx,Ry,Rz)代表任意旋轉軸:
在數學上討論如何生成這樣的矩陣仍然超出了本節內容。但是記住,即使這樣一個矩陣也不能完全解決萬向節死鎖問題(盡管會極大地避免)。避免萬向節死鎖的真正解決方案是使用四元數(Quaternion),它不僅安全,而且計算更加友好。有關四元數會在后面的教程中討論。
矩陣的組合
我們有一個頂點(x, y, z),我們希望將其縮放2倍,然后用位移(1, 2, 3)來平移它。我們需要一個平移和縮放矩陣來完成這些變換。結果的變換矩陣看起來像這樣:
- 注意,當矩陣相乘時我們先寫平移再寫縮放變換的。矩陣乘法是不可交換的,這意味著它們的順序很重要。當矩陣相乘時,在最右邊的矩陣是第一個乘以向量的,所以你應該從右向左讀這個乘法。
- 我們建議您在組合矩陣時,先進行縮放操作,然后是旋轉,最后才是平移,否則它們會(消極地)互相影響。比如,如果你先平移然后縮放,平移的向量也會同樣被縮放(譯注:比如向某方向移動2米,2米也許會被縮放成1米)!
將我們的矢量左乘最終的變換矩陣會得到以下結果:
不錯!向量先縮放2倍,然后平移了(1, 2, 3)個單位。
實踐
OpenGL沒有任何自帶的矩陣和向量形式,所以我們必須自己定義數學類和方法。在這個教程中我們更愿意抽象所有的數學細節,使用已經做好了的數學庫。幸運的是有個使用簡單的專門為OpenGL量身定做的數學庫,那就是GLM。
GLM
GLM是OpenGL Mathematics的縮寫,它是一個只有頭文件的庫,也就是說我們只需包含合適的頭文件就行了;不用鏈接和編譯。GLM可以從他們的網站上下載。(或者從項目托管網站,sourceforge下載)把頭文件的根目錄復制到你的includes文件夾,然后你就可以使用這個庫了。
我們需要的GLM的大多數功能都可以從下面這3個頭文件中找到:
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
我們來看看是否可以利用我們剛學的變換知識把一個向量(1, 0, 0)平移(1, 1, 0)個單位(注意,我們把它定義為一個glm::vec4類型的值,其中齊次坐標我們設定為1.0):
glm::vec4 vec(1.0f, 0.0f, 0.0f, 1.0f);
glm::mat4 trans;
trans = glm::translate(trans, glm::vec3(1.0f, 1.0f, 0.0f));
vec = trans * vec;
std::cout << vec.x << vec.y << vec.z << std::endl;
我們先用GLM內建的向量類定義一個叫做vec的向量。接下來我們定義一個mat4類型的trans,默認是4×4單位矩陣。接下來我們創建一個變換矩陣,我們是把單位矩陣和一個平移向量傳遞給glm::translate函數來完成這個工作的(然后用給定的矩陣乘以平移矩陣就能獲得最后需要的矩陣)。
這個代碼片段將會輸出210,所以這個平移矩陣是正確的。
我們來做些更有意思的事情,讓我們來旋轉和縮放之前教程中的那個箱子。首先我們把箱子逆時針旋轉90度。然后縮放0.5倍,使它變成原來的二分之一。我們先來創建變換矩陣:
glm::mat4 trans;
trans = glm::rotate(trans, 90.0f, glm::vec3(0.0, 0.0, 1.0));
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));
首先,我們把箱子在每個軸縮放到0.5倍,然后沿Z軸旋轉90度。注意有紋理的那面矩形是在XY平面上的,我們需要把它繞著z軸旋轉。因為我們把這個矩陣傳遞給了GLM的每個函數,GLM會自動將矩陣相乘,返回的結果是一個包括了多個變換的變換矩陣。
有些GLM版本接收的是弧度而不是角度,這種情況下你可以用glm::radians(90.0f)將角度轉換為弧度。(我使用的版本glm-0.9.6.3需要用這個函數來轉換才可以。)
下一個大問題是:如何把矩陣傳遞給著色器?我們在前面簡單提到過GLSL里的mat4類型。所以我們改寫頂點著色器來接收一個mat4的uniform變量,然后再用矩陣uniform乘以位置向量:
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 color;
layout (location = 2) in vec2 texCoord;
out vec3 ourColor;
out vec2 TexCoord;
uniform mat4 transform;
void main()
{
gl_Position = transform * vec4(position, 1.0f);
ourColor = color;
TexCoord = vec2(texCoord.x, 1.0 - texCoord.y);
}
在把位置向量傳給gl_Position之前,我們添加一個uniform,并且用變換矩陣乘以它。我們的箱子現在應該是原來的二分之一大小并旋轉了90度(向左傾斜)。當然,我們仍需要把變換矩陣傳遞給著色器:
GLuint transformLoc = glGetUniformLocation(ourShader.Program, "transform");
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));
我們首先請求uniform變量的地址,然后用有Matrix4fv后綴的glUniform函數把矩陣數據發送給著色器。-
- 第一個參數你現在應該很熟悉了,它是uniform的地址(Location)。
- 第二個參數告訴OpenGL我們將要發送多少個矩陣,目前是1。
- 第三個參數詢問我們我們是否希望對我們的矩陣進行置換(Transpose),也就是說交換我們矩陣的行和列。OpenGL開發者通常使用一種內部矩陣布局叫做以列為主順序的(Column-major Ordering)布局。GLM已經是用以列為主順序定義了它的矩陣,所以并不需要置換矩陣,我們填GL_FALSE。
- 最后一個參數是實際的矩陣數據,但是GLM并不是把它們的矩陣儲存為OpenGL所希望的那種,因此我們要先用GLM的自帶的函數value_ptr來變換這些數據。
我們創建了一個變換矩陣,在頂點著色器中聲明了一個uniform,并把矩陣發送給了著色器,著色器會變換我們的頂點坐標。最后的結果應該看起來像這樣:
我們現在做些更有意思的,看看我們是否可以讓箱子隨著時間旋轉,我們還會重新把箱子放在窗口的右下角。要讓箱子隨著時間推移旋轉,我們必須在游戲循環中更新變換矩陣,因為它需要在每一次渲染迭代中被更新。我們使用GLFW的時間函數來獲取不同時間的角度:
glm::mat4 trans;
trans = glm::translate(trans, glm::vec3(0.5f, -0.5f, 0.0f));
trans = glm::rotate(trans,(GLfloat)glfwGetTime() * 50.0f, glm::vec3(0.0f, 0.0f, 1.0f));
要記住的是前面的例子中我們可以在任何地方聲明變換矩陣,但是現在我們必須在每一次迭代中創建它,從而保證我們能夠更新旋轉矩陣。這也就意味著我們不得不在每次迭代中中重新創建變換矩陣。通常在渲染場景的時候,我們也會有多個在每次渲染迭代中都用新的值重新創建的變換矩陣
在這里我們先把箱子圍繞原點(0, 0, 0)旋轉,之后,我們把旋轉過后的箱子平移到屏幕的右下角。記住,實際的變換順序應該從下向上閱讀:盡管在代碼中我們先平移再旋轉,實際的變換卻是先應用旋轉然后平移的。明白所有這些變換的組合,并且知道它們是如何應用到物體上的并不簡單。只有嘗試和實驗這些變換你才能快速地掌握它們。
如果你做對了,你將看到下面的結果:
如果你沒有得到正確的結果,或者你有哪兒不清楚的地方。可以看工程文件。
下個教程中,我們會討論怎樣使用矩陣為頂點定義不同的坐標空間。這將是我們進入實時3D圖像的第一步!
練習
- 使用應用在箱子上的最后的變換,嘗試將其改變成先旋轉,后平移。看看發生了什么,試著想想為什么會發生這樣的事情:
glm::mat4 trans;
trans = glm::rotate (trans, glm::radians ((GLfloat)glfwGetTime() * 50.0f), glm::vec3 (0.0, 0.0, 1.0));
trans = glm::translate (trans, glm::vec3 (0.5, -0.5, 0.0));
我的理解:(沒有解釋到本質上)代碼中,我們先旋轉,后平移,然而,實際的變換卻是先平移后旋轉,所以,會先將箱子向右下角平移,再繞著z軸旋轉。不同于,修改前的箱子先繞著z軸旋轉,再向右下角平移。
答案:
/* Why does our container now spin around our screen?:
== ===================================================
Remember that matrix multiplication is applied in reverse. This time a translation is thus
applied first to the container positioning it in the bottom-right corner of the screen.
After the translation the rotation is applied to the translated container.
A rotation transformation is also known as a change-of-basis transformation
for when we dig a bit deeper into linear algebra. Since we're changing the
basis of the container, the next resulting translations will translate the container
based on the new basis vectors. Once the vector is slightly rotated, the vertical
translations would also be slightly translated for example.
If we would first apply rotations then they'd resolve around the rotation origin (0,0,0), but
since the container is first translated, its rotation origin is no longer (0,0,0) making it
looks as if its circling around the origin of the scene.
If you had trouble visualizing this or figuring it out, don't worry. If you
experiment with transformations you'll soon get the grasp of it; all it takes
is practice and experience.
*/
- 嘗試著再次調用glDrawElements畫出第二個箱子,但是只能使用變換將其擺放在不同的位置。保證這個箱子被擺放在窗口的左上角,并且會不斷的縮放(而不是旋轉)。使用sin函數在這里會很有用;注意使用sin函數取到負值時會導致物體被翻轉: