标签:
上次读书笔记主要是学习了应用三维坐标变换矩阵对二维的图形进行变换,并附带介绍了GLSL语言的编译、链接相关的知识,之后介绍了GLSL中变量的修饰符,着重介绍了uniform修饰符,来向着色器程序传入输入参数。
这次读书笔记的内容相对有趣一些,主要是和园友们分享讨论三维坐标变换矩阵在三维几何体上的应用,以及介绍一下如何实现三维图形与用户操作的交互。这一次笔记在三维编程中也是非常重要的——我们最后开发的三维程序最终就是要和目标用户进行交互的。
之前一直没有在博客上放过gif格式的动画图片,这次因为涉及交互操作,所以想在博文上贴几张gif格式的动画图片。为此,我得找一款好的录屏软件和格式转换软件,结果找了半天没找到使用比较方便且制作出来效果比较好的软件,还下到了流氓软件,不免吐槽下——找PC好软件还是得多长几个心眼啊,最好是上官网去下载!最后找到了一个直接可以录屏并生成gif,且支持编辑,在此推荐给大家——抠抠视频秀。不过,最后生成的动画图片比较大,考虑到在手机上逛园子童鞋,博主就不上传了,太多图片还是很费流量的,我把代码和可执行文件传到网盘上了,需要的童鞋可以自行去下载。哎,瞎忙乎了一场~~~~(>_<)~~~~。
好,谈正事,让我们继续踏上OpenGL学习之路——第四站。
既然是学习三维坐标的变换,总得有一个变为对象——三维场景(几何体)。在读书笔记(三)中我们用的是正方形——一个简单得不能再简单的正方形。这一次仍然使用一个最简单的三维图形——立方体。首先还是让我们来看看例子程序代码,其实和之前讲的几乎没什么区别,也就是那么几步:开辟缓冲区、上传顶点数据、设置顶点属性,最后渲染图形,具体程序代码如下:
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则使用顶点索引值来间接获取绘制图元的顶点来绘制几何体。
上一小节绘制出来的立方体是静止不动的,那么如何让它动起来呢?这就需要将变换矩阵应用到几何图像的顶点数据上,为此要修改源程序。首先,修改顶点着色器,在顶点着色器中将顶点数据传递给内置变量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中也需要传入单位矩阵作为初始的变换矩阵,否则一开始会没有图像。运行程序,通过键盘输入回调函数中给出的字符,可以得到下图所示的运行结果:
第二节中的操作均由键盘输入的,但在实际使用中,用户常常是用鼠标来操作。下面我们来瞅瞅,如何通过鼠标怎么驱动物体的平移、旋转和缩放的。
平移操作可以说是最简单的了,起始点和终止点相当于确定了平移向量。不过鼠标给出的是像素坐标,在求平移向量之前需要将像素坐标转换为世界坐标系,转换公式如下:
$$\left\{\begin{array}{c} x_{w} = \frac{2x_{s}}{W_s} - 1\\ y_{w} = 1 - \frac{2y_{s}}{H_s} \\ z_{w} = 0\end{array}\right.$$
特别注意的是,屏幕像素坐标系$x$轴与OpenGL的世界坐标系下的$x$轴是同向的,而$y$轴恰好相反——是反响的,所以上述变换公式从形式上看,$x$和$y$相差一个负号,将其封装为一个辅助函数,如下:
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节与缩放、旋转变换一并给出。
鼠标驱动缩放形式有许多种——只要将向量映射为一个数即可,这里我们采用的策略是住右下方向拖拽是缩小,往左上方向拖拽为放大,得到如下映射变换公式:
$$scale = \exp[(start.x - end.x) + (end.y - start.y)]$$
得到缩放系数之后,根据读书笔记(二)所介绍的知识便可求得缩放矩阵,客户端代码将在3.4节给出。
刚才主要阐述了通过鼠标实现物体的平移、旋转。平移只要得到平移向量就可以了,缩放只要得到缩放系数即可,但对于旋转,则没有那么简单了,下面就来具体学习一下吧!通过鼠标来驱动物体的旋转,本质就是通过屏幕上的两个点,确定旋转角度和旋转轴,确定这两个参数的算法称为ArcBall算法。此算法可以用下面示意图来分析:
已知:黑色的二维坐标系$Oxy$是屏幕坐标系,橙色的三维坐标系$Oxyz$是OpenGL的世界坐标系;$S$点是鼠标的起始点,$E$点为鼠标的终止点;确定S到E的旋转矩阵。ArcBall算法是按下面步骤执行的:
第一步:将屏幕看成一个"$z > 0$"的半球面(这样就能旋转了嘛!),将鼠标起点$S$和终点$E$往半球面上投影,投影方法很简单,$x$和$y$保持不变,$z$按下述规则求出:
$$ z = \left\{\begin{array}{cc} \sqrt{1-x^2-y^2} & x^2 + y^2 \leq 0 \\ 0 & \text{otherwise} \end{array}\right.$$
第二步:求旋转轴,也很简单,即向量$\vec{OS‘}$和向量$\vec{OE‘}$张成的平面的法向量,即:
$$\vec{r} = \vec{OS‘} \times \vec{OE‘} $$
第三步:求旋转角度,也不难,即向量$\vec{OS‘}$和向量$\vec{OE‘}$的夹角,即:
$$\theta = \arccos(\vec{OS‘}, \vec{OE‘}) = |\vec{OS‘}||\vec{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中作了说明。
在客户端的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以及头文件。
至此,我们陆陆续续学习了三维变换的一些知识,包括平移、旋转和缩放矩阵的理论推导与应用。在下一次读书笔记中,我们将继续三维变换的内容——投影变换矩阵的推导与应用,这应该是三维变换的最后一块内容了。五一三天假期马上结束了,又要开始上班了。
标签:
原文地址:http://www.cnblogs.com/lijihong/p/OpenGL.html