前言
在就計算機視覺圖形學中,矩陣是十分常見的計算單位。那么在OpenGL的學習中,矩陣的運算肯定是必不可少,因此本文將稍微總結一下OpenGL中使用矩陣來完成一些稍微復雜一點效果。
通過前面幾篇文章的學習,大致已經明白了OpenGL的基本開發流程。了解OpenGL如何繪制,但是更多復雜的效果不可能通過如此之多的紋理,頂點去完成,我們需要一個更好的工具去處理圖片效果,這個工具就是數學上的矩陣和向量。
如果遇到問題可以在這里http://www.lxweimin.com/p/4b7c0d59c87c找到本人,歡迎討論
正文
我將不會再一次花大量的篇幅重新介紹向量和矩陣,這里僅僅只是把常用的向量和矩陣操作過一遍。
向量
向量最基本的定義是一個方向,往哪里走,走到哪里。更加正式的來說,向量包含一個方向一個大小,如下:
這里有三個向量,能看到w,n,v都從各自的起點指向各自的終點,并且能夠很輕易的算出其長度。
一個向量可以表示:
向量的計算
向量加減
向量加減,實際上就是對向量中每個分量進行加減
向量之間的加:
向量之間的加減在幾何上意義如下:
減法可以看成加一個負數:
向量的長度
我們使用勾股定理(Pythagoras Theorem)來獲取向量的長度(Length)/大小(Magnitude)。如果你把向量的x與y分量畫出來,該向量會和x與y分量為邊形成一個三角形:
我們可以依據勾股定理把v向量的長度計算出來:
向量的乘法
向量的乘法有兩部分,點乘(內積),叉乘(外積)。
點乘:
如果用分量來表示其運算,就是每個分量之間相乘最后相加:
幾何意義:
假設有兩個向量a,b.如果這兩個向量做點乘:
因為點乘符合乘法交換律:可以擴起后面,能從涂上看到后面括號那部分實際上就是b在a上的投影,也就是上圖的。
所以和a是一個方向上的,因此將會符合乘法交換律等基礎性質。這些不多展開論述。
叉乘:
如果用分量來計算的就是如下:
假設有向量A和B,從左到右的排開每個向量的分量,每一行代表一個向量:
實際上,我高中第一次學的時候,感覺不太好記叉乘。其實把上面那個行列式寫出來,就很好記住了。計算那一列的數據,就獲取另外兩列分量,做交叉相乘以及相減。
幾何上的意義:
從上圖能夠明白實際上向量a叉乘b,就是找一個向量同時垂直于向量a和向量b的向量。實際上這個向量就是垂直于a和b構成的平面。
這里就不再贅述推導過程。
矩陣
簡單來說矩陣就是一個矩形的數字、符號或表達式數組。矩陣中每一項叫做矩陣的元素.(最初的誕生是為了解決多元方程式)
下面是一個2×3矩陣的例子:
矩陣可以通過(i, j)進行索引,i是行,j是列,這就是上面的矩陣叫做2×3矩陣的原因(3列2行,也叫做矩陣的維度(Dimension))。這與你在索引2D圖像時的(x, y)相反,獲取4的索引是(2, 1)(第二行,第一列)(譯注:如果是圖像索引應該是(1, 2),先算列,再算行)。
矩陣的加減法
矩陣和標量相加
矩陣之間相加,必須是矩陣行列數相等才能互相相加:
減法也是類似。
矩陣的乘法
矩陣乘法分為2部分,數乘和相乘。
數乘
矩陣和標量相乘,矩陣與標量之間的乘法也是矩陣的每一個元素分別乘以該標量
現在我們也就能明白為什么這些單獨的數字要叫做標量(Scalar)了。簡單來說,標量就是用它的值縮放(Scale)矩陣的所有元素
矩陣的乘法
矩陣之間的乘法不見得有多復雜,但的確很難讓人適應。矩陣乘法基本上意味著遵照規定好的法則進行相乘。當然,相乘還有一些限制:
1.只有當左側矩陣的列數與右側矩陣的行數相等,兩個矩陣才能相乘。
2.矩陣相乘不遵守交換律(Commutative),也就是說A?B≠B?A。
直接來看矩陣相乘的例子:
實際上計算過程就是矩陣的第1個元素就是第一行乘以第一列每個元素積的和。擴展一下就是如下公式:
矩陣還有除法也就是矩陣的逆,本文沒有涉及,就不多介紹。
單位矩陣
實際上就是一個斜對角全是1,其他都是0的矩陣,數學上叫做
在OpenGL中,由于某些原因我們通常使用4×4的變換矩陣,而其中最重要的原因就是大部分的向量都是4分量的
這個矩陣的特性很有趣,任何矩陣乘以單位矩陣都等于原來的矩陣
縮放
對一個向量進行縮放(Scaling)就是對向量的長度進行縮放,而保持它的方向不變。由于我們進行的是2維或3維操作,我們可以分別定義一個有2或3個縮放變量的向量,每個變量縮放一個軸(x、y或z)。
假如我們嘗試縮放,沿著x軸方向縮小0.5倍數,沿著y軸放大2倍。
由于OpenGL通常在3d空間內操作,那么我們只要把z軸縮放設置為1就沒有任何影響。
這種x軸和y軸縮放比例不一致的叫做不均勻縮放,而一致稱為均勻縮放。實際上這個過程能夠通過矩陣去完成。
還記得上面的單位矩陣吧。只要把縮放系數放到矩陣中對應1的位置就能控制對應軸的縮放系數。
最后一個是w是構造3d模型,透視時候用的。暫時沒涉及,就不細說。
位移
位移(Translation)是在原始向量的基礎上加上另一個向量從而獲得一個在不同位置的新向量的過程,從而在位移向量基礎上移動了原始向量。我們已經討論了向量加法,所以這應該不會太陌生。
和縮放矩陣一樣,在4×4矩陣上有幾個特別的位置用來執行特定的操作,對于位移來說它們是第四列最上面的3個值。如果我們把位移向量表示為(Tx,Ty,Tz),我們就能把位移矩陣定義為:
旋轉
上面幾個的變換內容相對容易理解,在2D或3D空間中也容易表示出來,但旋轉(Rotation)稍復雜些。
旋轉對于剛入門的人來說是比較新鮮的東西。這里稍微寫一下旋轉的證明,我也花了點時間,證明了一遍,這邊也算是一次總結。
引入復數
為了證明旋轉,我們會引入復數作為輔助。復數是什么?復數包含兩個部分,一個實數部分,一個虛數部分,寫法如下:
a是一個實數,bi是一個虛數。i是什么?定義
為什么使用復數來輔助,以前我剛學習的時候不懂。實際上在我們常用的物理學,數學,需要保留二維的信息的時候,往往需要復數來計算,因為復數本身性質決定的,復數本身相加,相乘只允許實數和虛數分開計算,舉個例子:
,
這樣就能保留兩個不同的信息了。實際上也像極了向量/矩陣相加。
復數和矩陣的關系
從上面的公式,直覺上告訴我們復數的計算一定和矩陣元算相關,讓我們探索一下復數和矩陣之間的關系。就以復數乘法為例子:
,
如果我們把這個結果看成矩陣運算將會是如下一個矩陣運算,把矩陣第一行運算看成實部,第二行運算看成虛部:
就不難看出,實際上復數的元算就是對 下面這個矩陣做變換運算
旋轉的證明
先給出一個復數在復平面中表現:
能看到在這個復平面中復數z的表示就是,這個向量的長度根據勾股定理很容易就求出來。
我們嘗試著對復數的矩陣進行一次變形,目的就是為創造出一個角度和復數之間的關系,把每一項都除以,提取出可以勾股定理創造出來的角度:
根據勾股定理,可以把元算中每一項轉化如下:
很有趣,這樣就構建出了角度的關系了。有了這些還不足。
矩陣的左側還是有冗余的東西,我們想辦法干掉它。此時很巧的是,矩陣右側剛好就是這個復平面向量的模(長度)。
因此可以化簡如下:
又因為單位矩陣I乘以任何矩陣還是原來的矩陣:
實際上這個結果就是3d的旋轉縮放矩陣。不信?我們試試兩個在復平面上的向量(0,1),(1,0)。
當復平面上的z長度為1時候,如下圖:
因此下面這個矩陣是旋轉時候的縮放矩陣:
下面這個是旋轉矩陣:
用復數表示如下:
那么我們可以由2d往3d推,可以很輕易得到如下三種情況:
當沿著x軸旋轉,下面矩陣稱為:
當沿著y軸旋轉,下面矩陣稱為:
當沿著z軸旋轉,下面矩陣稱為:
有了這三個基礎矩陣之后,我們可以做任意變化,比如先旋轉z軸,再旋轉x軸,最后旋轉y軸。也就是把這三個矩陣從右到左乘起來,但是又因為可以轉為復數,而復數符合乘法交換律,因此先轉動哪一個都沒問題。
換句話說就是,
因此可以得到如下這個復合矩陣:
是不是很討厭,很麻煩。更麻煩的在后面,這種基于歐拉角變換的旋轉很容易就出現了萬向節死鎖。如果是做游戲動畫的人一定對這個不陌生。
使用這種方式連續變換的時候,當出現x,y,z其中兩個坐標系在同一水平面時候,另外一個軸的旋轉范圍就被限制住了。
如下:
如何解決呢?這個時候就需要四元數了。本文將不涉及四元數,因此不做更多的詳解,后面將會和大家聊聊。不過記住了,上面引入復數進行推到旋轉公式的方式將會運用到四元數的推導中。本文先做一個鋪墊。
有了這些理論基礎之后,我們可以嘗試編寫代碼。
實戰演練
為了實踐上面問題,我們這邊繼續沿用上一篇文章的笑臉箱子的代碼,來實現三種效果,位移,旋轉,縮放。
首先,我們要稍微改造一下原來的頂點著色器。開放一個uniform來操作頂點著色器中位置。
#version 330 core
layout(location = 0)in vec3 aPos;
layout(location = 1)in vec3 aColor;
layout(location = 2)in vec2 aTexCoord;
out vec3 ourColor;
out vec2 TexCoord;
uniform mat4 transform;
void main(){
gl_Position = transform*vec4(aPos,1.0);
ourColor = aColor;
TexCoord = aTexCoord;
}
通過transform乘法來對位置進行一次矩陣變換。因為在GLSL中已經確定好了是mat4.因此在外面也要創造一個4維的矩陣。
操作一
先縮小一半,再繞著z軸90度旋轉。
根據上面的公式,無論是位移,旋轉還是縮放,我們只需要對著原矩陣依次做矩陣乘法即可。
先準備一個單位矩陣:
GLfloat mat4[4][4] = {
{1.0f,0.0f,0.0f,0.0f},
{0.0f,1.0f,0.0f,0.0f},
{0.0f,0.0f,1.0f,0.0f},
{0.0f,0.0f,0.0f,1.0f}
};
準備一個縮放的矩陣:
GLfloat vec3[] = {
//x //y //z
0.5f,0.5f,1.0f
};
根據公式,4維矩陣縮放操作:
void scaleMat4(GLfloat dst[4][4],GLfloat src[4][4],GLfloat* vec){
dst[0][0] = src[0][0] * vec[0];
dst[1][1] = src[1][1] * vec[1];
dst[2][2] = src[2][2] * vec[3];
dst[3][3] = src[3][3];
}
根據公式的乘積結果,我直接寫出沿著z軸旋轉的方法
void rotationZ(GLfloat dst[4][4],GLfloat src[4][4],double degree){
double angle = PI * degree / 180.0;
dst[0][0] = src[0][0]*cos(angle) - src[1][0]*sin(angle);
dst[0][1] = src[0][1]*cos(angle) - src[1][1]*sin(angle);
dst[0][2] = src[0][2]*cos(angle) - src[1][2]*sin(angle);
dst[0][3] = src[0][3]*cos(angle) - src[1][3]*sin(angle);
dst[1][0] = src[0][0]*sin(angle) + src[1][0]*cos(angle);
dst[1][1] = src[0][1]*sin(angle) + src[1][1]*cos(angle);
dst[1][2] = src[0][2]*sin(angle) + src[1][2]*cos(angle);
dst[1][3] = src[0][3]*sin(angle) + src[1][3]*cos(angle);
dst[2][0] = src[2][0];
dst[2][1] = src[2][1];
dst[2][2] = src[2][2];
dst[2][3] = src[2][3];
dst[3][0] = src[3][0];
dst[3][1] = src[3][1];
dst[3][2] = src[3][2];
dst[3][3] = src[3][3];
}
此時我們在進入渲染loop之前依次調用:
scaleMat4(result,mat4, vec3);
rotationZ(dst,result,90.0);
GLuint transformLoc = glGetUniformLocation(shader->ID,"transform");
glUniformMatrix4fv(transformLoc,1,GL_FALSE,&dst[0][0]);
讀取uniform,并且把變換之后的矩陣首地址賦值給transform。由于GLSL中也是4維float型矩陣,剛好能夠正常解析。
這樣就能看到了沿著x,y軸縮小了一般,同時沿著z軸順時針旋轉了90度。
其實我這么寫旋轉還是有問題,因為我直接計算變換后的矩陣,直接賦值。并沒有很好的泛用性。每一次都要自己寫這么麻煩的矩陣計算,對于開發來說不是很友好。
glm
還好有一個glm庫,專門輔助計算矩陣,向量。而且全是頭文件,不需要編譯直接引入即可。稍微閱讀了源碼,實際上是挺簡單的一個庫,抽象了mat以及vec類,并且復寫里面的操作符。
用法很簡單,同樣的,我們要引入如下頭文件:
#include"glm/glm.hpp"
#include "glm/gtc/matrix_transform.hpp"
#include "glm/gtc/type_ptr.hpp"
初始化一個4維單位矩陣:
glm::mat4 trans = glm::mat4(1.0f);
接著做著一樣的縮放之后旋轉代碼:
trans = glm::rotate(trans, glm::radians(90.0f),glm::vec3(0.0,0.0,1.0));
trans = glm::scale(trans, glm::vec3(0.5f,0.5f,1.0f));
GLuint transformLoc = glGetUniformLocation(shader->ID,"transform");
glUniformMatrix4fv(transformLoc,1,GL_FALSE,&trans[0][0]);
scale縮放的api需要傳入一個向量,分別指的是x,y,z軸分別縮小放大多少,rotate旋轉api,需要傳遞一個旋轉的角度以及圍繞哪幾個軸旋轉。
此時是沿著z軸,倍數為1的旋轉??s放為x,y縮小一般,軸不變
這樣就有如此結果
誒?奇怪了?怎么根據公式計算出來的是相反的呢?一個順時針旋轉了90度,一個逆時針旋轉了90度。
讓我們翻翻旋轉的源碼,實際上很簡單:
template<typename T, qualifier Q>
GLM_FUNC_QUALIFIER mat<4, 4, T, Q> rotate(mat<4, 4, T, Q> const& m, T angle, vec<3, T, Q> const& v)
{
T const a = angle;
T const c = cos(a);
T const s = sin(a);
vec<3, T, Q> axis(normalize(v));
vec<3, T, Q> temp((T(1) - c) * axis);
mat<4, 4, T, Q> Rotate;
Rotate[0][0] = c + temp[0] * axis[0];
Rotate[0][1] = temp[0] * axis[1] + s * axis[2];
Rotate[0][2] = temp[0] * axis[2] - s * axis[1];
Rotate[1][0] = temp[1] * axis[0] - s * axis[2];
Rotate[1][1] = c + temp[1] * axis[1];
Rotate[1][2] = temp[1] * axis[2] + s * axis[0];
Rotate[2][0] = temp[2] * axis[0] + s * axis[1];
Rotate[2][1] = temp[2] * axis[1] - s * axis[0];
Rotate[2][2] = c + temp[2] * axis[2];
mat<4, 4, T, Q> Result;
Result[0] = m[0] * Rotate[0][0] + m[1] * Rotate[0][1] + m[2] * Rotate[0][2];
Result[1] = m[0] * Rotate[1][0] + m[1] * Rotate[1][1] + m[2] * Rotate[1][2];
Result[2] = m[0] * Rotate[2][0] + m[1] * Rotate[2][1] + m[2] * Rotate[2][2];
Result[3] = m[3];
return Result;
}
這里面實際上就是上面復合旋轉的公式。Rotate實際上是根據當前傳進來的向量對復合旋轉矩陣處理之后,再通過這個復合旋轉矩陣計算結果。
我們注意到一點,所有關于z軸的計算全部從顛倒為負。這樣的話,我上面的公式實際上等效glm下面這份代碼:
trans = glm::rotate(trans, glm::radians(90.0f),glm::vec3(0.0,0.0,-1.0));
沿著z軸的負半段進行旋轉。
至于為什么這么做,下一篇文章會揭曉。主要是因為在OpenGL是右手坐標,向左邊旋轉才是在OpenGL的正向旋轉方向。
實戰演練二
我們嘗試著把它轉動起來,只需要讓uniform讀取的數據根據時間變化而變化。
engine->loop(VAO, VBO, texture, 1,shader, [](Shader* shader,GLuint VAO,
GLuint* texture,GLFWwindow *window){
glm::mat4 trans = glm::mat4(1.0f);
trans = glm::translate(trans, glm::vec3(0.5f,-0.5f,0.0f));
//旋轉根據時間來
trans = glm::rotate(trans, (float)glfwGetTime(), glm::vec3(0.0f,0.0f,1.0f));
GLuint transformLoc = glGetUniformLocation(shader->ID,"transform");
glUniformMatrix4fv(transformLoc,1,GL_FALSE,glm::value_ptr(trans));
//箱子
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D,texture[0]);
//笑臉
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D,texture[1]);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_INT,0);
});
如下:
如果我們把旋轉和位移的順序變換了,會如何?
glm::mat4 trans = glm::mat4(1.0f);
trans = glm::rotate(trans, (float)glfwGetTime(), glm::vec3(0.0f,0.0f,1.0f));
trans = glm::translate(trans, glm::vec3(0.5f,-0.5f,0.0f));
//旋轉根據時間來
GLuint transformLoc = glGetUniformLocation(shader->ID,"transform");
glUniformMatrix4fv(transformLoc,1,GL_FALSE,glm::value_ptr(trans));
如下:
為什么會這樣,原本我們把整個笑臉繪制在原點區域,先位移到左下角再旋轉現象和我們料想的一樣。
當我們先旋轉再移動,實際上矩陣的叉乘本質是一個基變換的過程。基變換是什么東西?本文就不多討論。我們可以想象旋轉矩陣并不是旋轉圖片本身,而是旋轉圖片后面的坐標系,構成一個這個圖片上所有新的坐標點,在這里就是給整個坐標旋轉了90度。
經過基變換后的坐標系再次移動相同方向當然出現完全不一樣的。這也是為什么矩陣的乘法,有左右順序可言。
實戰演練三
當我們需要花兩個不同的笑臉,做不同的行為。比如說另一個笑臉跑到左上角,做縮放。
實際上還是一樣對著原來的圖片做一次矩陣變換,在調用一次glDrawElements繪制方法。
engine->loop(VAO, VBO, texture, 1,shader, [](Shader* shader,GLuint VAO,
GLuint* texture,GLFWwindow *window){
// changeMixValue(window);
// shader->setFloat("mixValue", mixValue);
glm::mat4 trans = glm::mat4(1.0f);
trans = glm::translate(trans, glm::vec3(0.5f,-0.5f,0.0f));
trans = glm::rotate(trans, (float)glfwGetTime(), glm::vec3(0.0f,0.0f,1.0f));
GLuint transformLoc = glGetUniformLocation(shader->ID,"transform");
glUniformMatrix4fv(transformLoc,1,GL_FALSE,glm::value_ptr(trans));
//箱子
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D,texture[0]);
//笑臉
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D,texture[1]);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_INT,0);
trans = glm::mat4(1.0f);
trans = glm::translate(trans, glm::vec3(-0.5f,0.5f,0.0f));
float scale = sin(glfwGetTime());
trans = glm::scale(trans, glm::vec3(scale,scale,scale));
glUniformMatrix4fv(transformLoc,1,GL_FALSE,glm::value_ptr(trans));
glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_INT,0);
});
總結
本文只是介紹了一部分基礎的矩陣變換知識。實際上,要深刻的理解計算機圖形學,線性數學是一個很重要的工具。你可以看到我之前寫的那一篇人工智能梯度下降推導,矩陣在計算機領域中是一個很基礎且通用工具。不求掌握精通,但是至少能夠各種熟悉操作,才能讓我們的學習更加輕松。
寫這篇文章和OpenCV的文章其實比起寫Android底層源碼分析還要痛苦。哈哈,很多數學工具都丟到爪哇國了。只是下意識知道怎么用,怎么回事,但是真的要提煉成文字,我真的必須翻閱很多數學資料,重新過一遍,證明一遍,才敢寫出文章。