1 引子
上次讀書筆記主要是學習了應用三維坐標變換矩陣對二維的圖形進行變換,並附帶介紹了GLSL語言的編譯、鏈接相關的知識,之后介紹了GLSL中變量的修飾符,着重介紹了uniform修飾符,來向着色器程序傳入輸入參數。
這次讀書筆記的內容相對有趣一些,主要是和園友們分享討論三維坐標變換矩陣在三維幾何體上的應用,以及介紹一下如何實現三維圖形與用戶操作的交互。這一次筆記在三維編程中也是非常重要的——我們最后開發的三維程序最終就是要和目標用戶進行交互的。
之前一直沒有在博客上放過gif格式的動畫圖片,這次因為涉及交互操作,所以想在博文上貼幾張gif格式的動畫圖片。為此,我得找一款好的錄屏軟件和格式轉換軟件,結果找了半天沒找到使用比較方便且制作出來效果比較好的軟件,還下到了流氓軟件,不免吐槽下——找PC好軟件還是得多長幾個心眼啊,最好是上官網去下載!最后找到了一個直接可以錄屏並生成gif,且支持編輯,在此推薦給大家——摳摳視頻秀。不過,最后生成的動畫圖片比較大,考慮到在手機上逛園子童鞋,博主就不上傳了,太多圖片還是很費流量的,我把代碼和可執行文件傳到網盤上了,需要的童鞋可以自行去下載。哎,瞎忙乎了一場~~~~(>_<)~~~~。
好,談正事,讓我們繼續踏上OpenGL學習之路——第四站。

2 三維變換應用
2.1 場景准備
既然是學習三維坐標的變換,總得有一個變為對象——三維場景(幾何體)。在讀書筆記(三)中我們用的是正方形——一個簡單得不能再簡單的正方形。這一次仍然使用一個最簡單的三維圖形——立方體。首先還是讓我們來看看例子程序代碼,其實和之前講的幾乎沒什么區別,也就是那么幾步:開辟緩沖區、上傳頂點數據、設置頂點屬性,最后渲染圖形,具體程序代碼如下:
1 #include <iostream>
2
3 #include "GameFramework/StdAfx.h"
4 #include "Resource/GPUProgram.h"
5 #include "AlgebraicEntity/Matrix.h"
6
7 GPUProgram program;
8
9 void initialize_04()
10 {
11 // --------------准備立方體頂點數據--------------
12 // 0/----------------/1
13 // /| /|
14 // / | / |
15 //4/--|-------------/5 |
16 // | | | |
17 // | | | |
18 // | | | |
19 // | 3/-------------|--/2
20 // | / | /
21 // |/ |/
22 //7/----------------/6
23 GLfloat cube_vertices[8][4] = {
24 { -0.5f, 0.5f, -0.5f, 1.0f },
25 { 0.5f, 0.5f, -0.5f, 1.0f },
26 { 0.5f, -0.5f, -0.5f, 1.0f },
27 { -0.5f, -0.5f, -0.5f, 1.0f },
28 { -0.5f, 0.5f, 0.5f, 1.0f },
29 { 0.5f, 0.5f, 0.5f, 1.0f },
30 { 0.5f, -0.5f, 0.5f, 1.0f },
31 { -0.5f, -0.5f, 0.5f, 1.0f }
32 };
33
34 // --------------准備頂點顏色數據--------------
35 GLfloat cube_colors[8][4] = {
36 { 1.0f, 1.0f, 1.0f, 1.0f },
37 { 1.0f, 1.0f, 0.0f, 1.0f },
38 { 1.0f, 0.0f, 1.0f, 1.0f },
39 { 0.0f, 1.0f, 1.0f, 1.0f },
40 { 0.0f, 0.0f, 1.0f, 1.0f },
41 { 0.0f, 1.0f, 0.0f, 1.0f },
42 { 1.0f, 0.0f, 0.0f, 1.0f },
43 { 0.0f, 0.0f, 0.0f, 1.0f }
44 };
45
46 // --------------創建緩沖區對象,分配空間,上傳數據--------------
47 GLuint buffer_ID;
48 glGenBuffers(1, &buffer_ID);
49 glBindBuffer(GL_ARRAY_BUFFER, buffer_ID);
50 glBufferData(GL_ARRAY_BUFFER,
51 sizeof(cube_vertices) + sizeof(cube_colors), NULL, GL_STATIC_DRAW);
52 glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(cube_vertices), cube_vertices);
53 glBufferSubData(GL_ARRAY_BUFFER, sizeof(cube_vertices), sizeof(cube_colors), cube_colors);
54
55 // --------------創建並設置頂點屬性對象--------------
56 GLuint VAO_ID;
57 glGenVertexArrays(1, &VAO_ID);
58 glBindVertexArray(VAO_ID);
59 glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, BUFFER_OFFSET(0));
60 glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0, BUFFER_OFFSET(sizeof(cube_vertices)));
61 glEnableVertexAttribArray(0);
62 glEnableVertexAttribArray(1);
63
64 // --------------准備頂點索引數據--------------
65 GLushort vertex_indices[] = {
66 1, 5, 0, 4, 3, 7, 2, 6,
67 7, 4, 6, 5, 1, 2, 0, 3
68 };
69 GLuint EBO_ID;
70 glGenBuffers(1, &EBO_ID);
71 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO_ID);
72 glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(vertex_indices), vertex_indices, GL_STATIC_DRAW);
73
74 program.AddShader(GL_VERTEX_SHADER, "F:/VC++游戲編程/OpenGLGuide/OpenGL05/cube.vert");
75 program.AddShader(GL_FRAGMENT_SHADER, "F:/VC++游戲編程/OpenGLGuide/OpenGL05/cube.frag");
76 glUseProgram(program.CreateGPUProgram());
77 }
78
79 void display_04()
80 {
81 glClear(GL_COLOR_BUFFER_BIT);
82
83 glDrawElements(GL_TRIANGLE_STRIP, 8, GL_UNSIGNED_SHORT, BUFFER_OFFSET(0));
84 glDrawElements(GL_TRIANGLE_STRIP, 8, GL_UNSIGNED_SHORT, BUFFER_OFFSET(8 * sizeof(GLushort)));
85 }
86
87 int main(int argc, char **argv)
88 {
89 glutInit(&argc, argv);
90 glutInitDisplayMode(GLUT_RGBA);
91 glutInitWindowSize(512, 512);
92 glutInitContextVersion(3, 3);
93 glutInitContextProfile(GLUT_CORE_PROFILE);
94 glutCreateWindow("學習之路(四)");
95
96 glewExperimental = TRUE;
97 if (glewInit())
98 {
99 std::cerr << "Unable to initialize GLEW... Exiting..." << std::endl;
100 std::exit(EXIT_FAILURE);
101 }
102
103 initialize_04();
104 glutDisplayFunc(display_04);
105
106 glutMainLoop();
107 return 0;
108 }
可以看出:程序的整體框架與之前是類似的,由初始化函數(initialize_04)和繪制函數(display_04)構成。其中初始化函數負責數據的准備和輸入給OpenGL,這里的數據包括立方體頂點數據(這里給出的是齊次坐標)、頂點顏色數據以及頂點索引數據(在頂點數組中的索引位置,從0開始)。在我們的示例中,數據准備是比較簡單的,但在實際項目開發時,數據的准備通常會很復雜。繪制函數則調用OpenGL繪制相關的API來實現幾何體的繪制,這里我們將三角形拆開為兩個三角形條帶(見下圖),這樣繪制立方體的過程就是繪制兩個三角形帶(triangle strip)。

當然,為了繪制,還需要加入頂點着色器和片元着色器。這兩個着色器都很簡單,其中頂點着色器是從傳遞輸入頂點數據到GLSL的內置變量gl_Position中,並將頂點顏色數據直接輸出,由OpenGL進行插值,然后傳到片元着色器;片元着色器得到光柵化(插值)之后的顏色值,直接輸出即可,兩個着色程序代碼如下:
頂點着色程序:
1 #version 330 core
2
3 layout(location = 0) in vec4 vertex_position;
4 layout(location = 1) in vec4 vertex_color;
5
6 out vec4 temp_color;
7
8 void main()
9 {
10 gl_Position = vertex_position;
11 temp_color = vertex_color;
12 }
片元着色程序:
1 #version 330 core
2
3 in vec4 temp_color;
4
5 out vec4 out_color;
6
7 void main()
8 {
9 out_color = temp_color;
10 }
下圖便是上述程序的執行結果,看上去像一個正方形,但其實它是立方體,稍后我們對它做了旋轉操作之后就可以看到廬山真面目了。

看完了繪制結果,讓我們繼續學習上述程序中用到的新的OpenGL API。第一個新的API就是往緩存中分段上傳數據,例子中我們往緩存中分別上傳了頂點坐標數據和頂點顏色數據。我們之前用glBufferData來分配並上傳數據,但該函數只能一次性輸入所有數據,如果客戶端的數據保存在不同數據結構中,但想上傳到同一個目標緩沖區對象時,就得使用下面這個新的API了:
void glBufferSubData(GLenum target offset, GLintptr offset, GLsizeiptr size, const GLvoid *data) 函數功能:將data指針所指向的、大小為size個字節的數據上傳到當前綁定的緩沖區對象所管理的、偏移量為offset的內存區域。 target ——目標緩沖區對象類型 offset ——緩沖區對象所管理內存的偏移量 size ——上傳數據的字節數 data ——指向數據的指針
用示意圖更形象的表達如下:

通過上述接口,就可以將頂點坐標數據、頂點的其它屬性(如顏色)一些數據分開存放,分段上傳至緩沖區對象管理的顯存中。
之前博文中,我們都是使用glDrawArray來繪制幾何體的。這個繪制API的頂點數據是從綁定到目標為GL_ARRAY_BUFFER緩沖區對象得到。除此之外,OpenGL還支持通過頂點的索引值來間接得到頂點數據來繪制幾何體,這樣做的好處很顯然的——避免頂點數組中的重復項。所使用的OpenGL繪制API為glDrawElements(),使用這種繪制方式,除了需要提供頂點數組數據外,還需要需要告訴OpenGL,繪制時所使用的頂點索引數組,這時就要使用另一個綁定目標——GL_ELEMENT_ARRAY_BUFFER(見程序71~72行)——的緩沖區對象了,具體使用方法與綁定目標為GL_ARRAY_BUFFER的緩沖區對象是一模一樣的。好了,到此為止,初始化部分已經完成了,個人感覺有了讀書筆記(一)作為基礎,這里理解起來就很簡單了。下面,來看看剛才提到的、使用索引來繪制幾何體的API,其函數簽名為:
void glDrawElement(GLenum mode, GLsizei count, GLenum type, const GLvoid *indices) 函數功能:使用count個索引值來繪制一個mode類型的圖元,索引值類型為type,索引值數據保存在GL_ELEMENT_ARRAY_BUFFER綁定的緩沖區對象中,繪制使用的索引數據為偏移量indices開始的count個索引值 mode ——繪制圖元的類型 count ——用到的索引值個數 type ——索引值的類型 indices ——索引值在緩沖區所管理內存中的偏移量
這個命令的功能和glDrawArray一樣,區別在於所獲取頂點數據的方式不同:glDrawArray直接使用頂點數組來繪制圖元;glDrawElements則使用頂點索引值來間接獲取繪制圖元的頂點來繪制幾何體。
2.2 變換矩陣的使用
上一小節繪制出來的立方體是靜止不動的,那么如何讓它動起來呢?這就需要將變換矩陣應用到幾何圖像的頂點數據上,為此要修改源程序。首先,修改頂點着色器,在頂點着色器中將頂點數據傳遞給內置變量gl_Position變量前,對它做一次矩陣變換,改變之后的着色器程序如下(改動部分已由紅色字符標出):
1 #version 330 core
2
3 uniform mat4 mat_transform;
4
5 layout(location = 0) in vec4 vertex_position;
6 layout(location = 1) in vec4 vertex_color;
7
8 out vec4 temp_color;
9
10 void main()
11 {
12 gl_Position = mat_transform * vertex_position;
13 temp_color = vertex_color;
14 }
着色器程序中用到的矩陣數據要由客戶端輸入,本文中立方體體的變換方式由用戶決定,客戶端修改包括兩部分:
1. 這需要在main函數中向GLUT注冊接收鍵盤輸入回調函數來接收用戶的輸入,如下(紅色標出部分):
1 int main(int argc, char **argv)
2 {
3 glutInit(&argc, argv);
4 glutInitDisplayMode(GLUT_RGBA);
5 glutInitWindowSize(512, 512);
6 glutInitContextVersion(3, 3);
7 glutInitContextProfile(GLUT_CORE_PROFILE);
8 glutCreateWindow("學習之路(四)");
9
10 glewExperimental = TRUE;
11 if (glewInit())
12 {
13 std::cerr << "Unable to initialize GLEW... Exiting..." << std::endl;
14 std::exit(EXIT_FAILURE);
15 }
16
17 initialize_04();
18 glutDisplayFunc(display_04);
19 glutKeyboardFunc(display_keydown);
20
21 glutMainLoop();
22 return 0;
23 }
2. 定義鍵盤輸入回調函數display_keydown,如下:
1 void display_keydown(unsigned char key, int x, int y)
2 {
3 // --------------准備矩陣數據--------------
4 Matrix4X4 mat;
5 if (key == 'x') // 繞x軸順時針旋轉
6 {
7 mat = Matrix4X4::CreateRotateMatrix(PI / 12, Vector3D(1.0, 0.0, 0.0));
8 }
9 else if (key == 'y') // 繞y軸順時針旋轉
10 {
11 mat = Matrix4X4::CreateRotateMatrix(PI / 12, Vector3D(0.0, 1.0, 0.0));
12 }
13 else if (key == 'z') // 繞z軸順時針旋轉
14 {
15 mat = Matrix4X4::CreateRotateMatrix(PI / 12, Vector3D(0.0, 0.0, 1.0));
16 }
17 else if (key == '+') // 放大
18 {
19 mat = Matrix4X4::CreateScaleMatrix(1.1);
20 }
21 else if (key == '-') // 縮小
22 {
23 mat = Matrix4X4::CreateScaleMatrix(0.9);
24 }
25 else if (key == 'l') // 左平移
26 {
27 mat = Matrix4X4::CreateTranslateMatrix(Vector3D(-0.1, 0.0, 0.0));
28 }
29 else if (key == 'r') // 右平移
30 {
31 mat = Matrix4X4::CreateTranslateMatrix(Vector3D(0.1, 0.0, 0.0));
32 }
33 mat_transform = mat * mat_transform;
34
35 // --------------上傳矩陣數據--------------
36 glUniformMatrix4fv(mat_location, 1, GL_TRUE, mat_transform._m);
37
38 // --------------繪制圖像--------------
39 glutPostRedisplay();
40 }
各輸入字符所代表的含義在代碼注釋中已經詳細說明了,在此不再贅述。回調函數中,根據不同的用戶輸入,生成讀書筆記(二)中介紹的旋轉、平移、縮放矩陣;然后讓它與原有的變換矩陣(保存在全局變量mat_transform中)相乘,需要注意的是:新的變換矩陣一定要放在乘號(*)的左邊——矩陣乘法不具有交換性;通過函數glUniformMatrix4fv向OpenGL上傳新的復合變換矩陣,最后調用glutPostRedisplay()函數重新繪制圖像即可。另外,在初始化函數initialize_04中也需要傳入單位矩陣作為初始的變換矩陣,否則一開始會沒有圖像。運行程序,通過鍵盤輸入回調函數中給出的字符,可以得到下圖所示的運行結果:

3 鼠標驅動操作
第二節中的操作均由鍵盤輸入的,但在實際使用中,用戶常常是用鼠標來操作。下面我們來瞅瞅,如何通過鼠標怎么驅動物體的平移、旋轉和縮放的。
3.1 鼠標驅動平移
平移操作可以說是最簡單的了,起始點和終止點相當於確定了平移向量。不過鼠標給出的是像素坐標,在求平移向量之前需要將像素坐標轉換為世界坐標系,轉換公式如下:
特別注意的是,屏幕像素坐標系xx軸與OpenGL的世界坐標系下的xx軸是同向的,而yy軸恰好相反——是反響的,所以上述變換公式從形式上看,xx和yy相差一個負號,將其封裝為一個輔助函數,如下:
1 Point3D Point2DHelper::ConvertPointFromScreenToWorld( const Point2D& pt2D )
2 {
3 double dWorldX = 2 * pt2D.x / m_iScreenWidth - 1.0;
4 double dWorldY = 1.0 - 2 * pt2D.y / m_iScreenHeight;
5 double dWorldZ = 0.0;
6 return Point3D(dWorldX, dWorldY, dWorldZ);
7 }
這里,m_iScreenWidth和m_iScreenHeight分別為窗口的寬和高。得到起始點和終止點的坐標后,根據讀書筆記(二)中所講的知識點就可以求得平移變換矩陣。客戶端的代碼將在3.4節與縮放、旋轉變換一並給出。
3.2 鼠標驅動縮放
鼠標驅動縮放形式有許多種——只要將向量映射為一個數即可,這里我們采用的策略是住右下方向拖拽是縮小,往左上方向拖拽為放大,得到如下映射變換公式:
得到縮放系數之后,根據讀書筆記(二)所介紹的知識便可求得縮放矩陣,客戶端代碼將在3.4節給出。
3.3 鼠標驅動旋轉
剛才主要闡述了通過鼠標實現物體的平移、旋轉。平移只要得到平移向量就可以了,縮放只要得到縮放系數即可,但對於旋轉,則沒有那么簡單了,下面就來具體學習一下吧!通過鼠標來驅動物體的旋轉,本質就是通過屏幕上的兩個點,確定旋轉角度和旋轉軸,確定這兩個參數的算法稱為ArcBall算法。此算法可以用下面示意圖來分析:

已知:黑色的二維坐標系OxyOxy是屏幕坐標系,橙色的三維坐標系OxyzOxyz是OpenGL的世界坐標系;SS點是鼠標的起始點,EE點為鼠標的終止點;確定S到E的旋轉矩陣。ArcBall算法是按下面步驟執行的:
第一步:將屏幕看成一個"z>0z>0"的半球面(這樣就能旋轉了嘛!),將鼠標起點SS和終點EE往半球面上投影,投影方法很簡單,xx和yy保持不變,zz按下述規則求出:
第二步:求旋轉軸,也很簡單,即向量OS′→OS′→和向量OE′→OE′→張成的平面的法向量,即:
第三步:求旋轉角度,也不難,即向量OS′→OS′→和向量OE′→OE′→的夾角,即:
第四步:根據旋轉軸和旋轉角度,求得旋轉矩陣(具體方法見讀書筆記(二))。
將上述步驟封裝在ArcBall類中,具體程序代碼如下:
1 Matrix4X4 ArcBall::CreateRotateMatrix(
2 const Point3D& ptPlaneStart, const Point3D& ptPlaneEnd )
3 {
4 // ----------------相等,直接返回單位矩陣----------------
5 if (ptPlaneStart == ptPlaneEnd)
6 {
7 Matrix4X4 mat;
8 return mat;
9 }
10
11 // ----------------投影至半球----------------
12 Point3D ptSphereStart = ProjectPointToSemiSphere_i(ptPlaneStart);
13 Point3D ptSphereEnd = ProjectPointToSemiSphere_i(ptPlaneEnd);
14
15 // ----------------求旋轉軸----------------
16 Point3D ptOrigin(0.0, 0.0, 0.0);
17 Vector3D vOriginToStart(ptSphereStart - ptOrigin);
18 Vector3D vOriginToEnd(ptSphereEnd - ptOrigin);
19 Vector3D vRotate = vOriginToEnd.CrossProduct(vOriginToStart);
20 vRotate.Normalize();
21
22 // ----------------求旋轉角度----------------
23 double dTheta = std::acos(std::max(std::min(vOriginToStart.DotProduct(vOriginToEnd), 1.0), -1.0));
24
25 // ----------------生成旋轉矩陣----------------
26 return Matrix4X4::CreateRotateMatrix(dTheta, vRotate);
27 }
28
29 Point3D ArcBall::ProjectPointToSemiSphere_i(const Point3D& ptPlane)
30 {
31 double dSquare = ptPlane.x * ptPlane.x + ptPlane.y * ptPlane.y;
32 double dSphereZ = dSquare >= 1.0 ? 0.0 : std::sqrt(1 - dSquare);
33
34 return Point3D(ptPlane.x, ptPlane.y, dSphereZ);
35 }
代碼實現不是很難,在此不作過多解釋。有一點說明一下,輸入的頂點是變換后世界坐標系下的坐標點。關於怎么把屏幕像素坐標點轉換為OpenGL坐標系下的點我們已經在3.1中作了說明。
3.4 客戶端代碼和實現效果
在客戶端的main函數中需要向OpenGL注冊鼠標狀態改變時的回調函數和鼠標按住時移動的回調函數,如下:
glutMouseFunc(display_mouse_state_changed); glutMotionFunc(display_mouse_move);
在鼠標狀態改變回調函數中,鼠標左鍵按住表示旋轉,鼠標中鍵按住表示平移,鼠標右鍵按住表示縮放。兩個回調函數的具體代碼如下(紅色是左鍵旋轉的邏輯、綠色是中鍵平移的邏輯、藍色為右鍵縮放的代碼邏輯):
1 void display_mouse_state_changed(int button, int state, int x, int y)
2 {
3 ptStart = Point2DHelper::ConvertPointFromScreenToWorld(Point2D(x, y));
4 if (GLUT_LEFT_BUTTON == button)
5 {
6 if (state == GLUT_DOWN)
7 {
8 bIsRotate = true;
9 }
10 else if (state == GLUT_UP)
11 {
12 bIsRotate = false;
13 }
14 }
15 else if (GLUT_MIDDLE_BUTTON == button)
16 {
17 if (state == GLUT_DOWN)
18 {
19 bIsMove = true;
20 }
21 else if (state == GLUT_UP)
22 {
23 bIsMove = false;
24 }
25 }
26 else if (GLUT_RIGHT_BUTTON == button)
27 {
28 if (state == GLUT_DOWN)
29 {
30 bIsScale = true;
31 }
32 else if (state == GLUT_UP)
33 {
34 bIsScale = false;
35 }
36 }
37 }
38
39 void display_mouse_move(int x, int y)
40 {
41 if (!bIsMove && !bIsRotate && !bIsScale)
42 {
43 return;
44 }
45
46 // ------像素坐標點的XY坐標轉換為世界坐標點的XY坐標------
47 ptEnd = Point2DHelper::ConvertPointFromScreenToWorld(Point2D(x, y));
48
49 // --------------准備矩陣數據--------------
50 Matrix4X4 mat;
51 if (bIsRotate)
52 {
53 mat = ArcBall::CreateRotateMatrix(ptStart, ptEnd);
54 }
55 else if (bIsMove)
56 {
57 mat = Matrix4X4::CreateTranslateMatrix(ptEnd - ptStart);
58 }
59 else if (bIsScale)
60 {
61 double dLength = (ptStart.x - ptEnd.x) + (ptEnd.y - ptStart.y);
62 double dScale = std::exp(dLength);
63 mat = Matrix4X4::CreateScaleMatrix(dScale);
64 }
65 mat_transform = mat * mat_transform;
66
67 // --------------上傳矩陣數據--------------
68 glUniformMatrix4fv(mat_location, 1, GL_TRUE, mat_transform._m);
69 glutPostRedisplay();
70
71 ptStart = ptEnd;
72 }
本想在貼上交互效果動畫的,無奈圖片較大,上傳屢次不成功,且考慮到一些園友流量有限,所以就把這些實驗結果放到百度網盤上,以供園友們學習借鑒~地址為:http://pan.baidu.com/s/1hsbcGK0,這是讀書筆記(一)~讀書筆記(四)的所有代碼。如果想直接運行的話,需要解壓至 F:\VC++游戲編程 路徑下,否則會導致着色器程序找不到。下次博主將會更新,解壓后可以存放在任意位置。
解壓后有兩個文件夾:
OpenGLGuide:用於存放源代碼;
Package:用於存放編譯出來的lib、dll以及頭文件。
4 總結
至此,我們陸陸續續學習了三維變換的一些知識,包括平移、旋轉和縮放矩陣的理論推導與應用。在下一次讀書筆記中,我們將繼續三維變換的內容——投影變換矩陣的推導與應用,這應該是三維變換的最后一塊內容了。五一三天假期馬上結束了,又要開始上班了。

