现代OpenGL教程 03 —— 矩阵,深度缓冲,动画
在本文中,我们将把静止的2D三角形替换为旋转的3D立方体,你将看到如下效果:
现在我们终于可以在屏幕上实现一些有趣的内容了,这里有更多的动图展示:http://imgur.com/a/x8q7R
为了生成旋转的立方体,我们需要学习一些关于矩阵的数学知识,用于创建透视投影、旋转、平移以及“相机”概念。此外,我们还需要了解深度缓冲,以及典型的随时间变化的3D应用,例如动画。
获取代码
所有示例代码的zip打包可以从这里获取:https://github.com/tomdalling/opengl-series/archive/master.zip 。
这一系列文章中所使用的代码都存放在:https://github.com/tomdalling/opengl-series 。你可以在页面中下载zip包,如果你熟悉git,也可以克隆该仓库。
本文的代码可以在 source/03_matrices 目录里找到。使用OS X系统的用户,可以打开根目录里的 opengl-series.xcodeproj,选择本文对应的工程。使用Windows系统的用户,可以在Visual Studio 2013里打开 opengl-series.sln,选择相应工程。
工程里已包含所有依赖,所以你不需要再安装或者配置额外的东西。如果有任何编译或运行上的问题,请联系我。
矩阵原理
本文将重点介绍3D中的矩阵,因此在编写代码之前,我们先来了解一下矩阵的原理。我们不会过多关注数学细节,网上有很多优秀的相关资源。我们将使用GLM库来实现相关运算,重点关注那些应用在我们3D程序中的矩阵。
矩阵用于进行3D变换,可能的变换包括(点击可查看动画):
- 旋转
- 缩放(变大和变小)
- 平移(移动)
- 透视/正交投影(后面会详细解释)
矩阵是一个数字表格,例如:
矩阵英文 matrix 的复数形式是 matrices。不同的数值能产生不同类型的变换,上面的矩阵会绕着Z轴旋转90°。我们将使用GLM来创建矩阵,因此无需理解如何计算这些数值。
矩阵可以有任意的行和列,但3D变换通常使用4×4矩阵,后续提到的“矩阵”均指4×4矩阵。在代码实现中,一般使用浮点数组来表示矩阵,我们使用 glm::mat4 类来表示4×4矩阵。
两个最重要的矩阵操作是:
matrix × matrix = combined matrixmatrix × coordinate = transformed coordinate
矩阵×矩阵
当对两个矩阵进行相乘时,它们的乘积是一个包含两者变换的新矩阵。例如,将一个旋转矩阵乘以一个平移矩阵,得到的结果就是“组合”矩阵,即先旋转然后平移。下面的例子展示了这类矩阵相乘:
与普通乘法不同,矩阵乘法中顺序非常重要。例如,A和B是矩阵,A * B 不一定等于 B * A。下面我们使用相同的矩阵,但改变乘法顺序:
注意不同的顺序会导致不同的结果,下面的动画说明了顺序的重要性。相同的矩阵,不同的顺序,两个变换分别是沿Y轴上移和旋转45°。
在编码时,如果发现变换出错,请检查矩阵运算的顺序是否正确。
矩阵 × 坐标
当用矩阵乘以一个坐标时,它们的乘积是一个变换后的新坐标。例如,使用上面提到的旋转矩阵乘以坐标 (1, 1, 0),结果是 (-1, 1, 0),即原始坐标绕着Z轴旋转90°。下面是该乘法的图例:
为何使用4D坐标
你可能注意到上面的坐标是4D的,而非3D,其格式如下:
我们使用4D坐标的原因是,需要用4x4的矩阵完成所有的3D变换。矩阵乘法要求左边矩阵的列数等于右边矩阵的行数,因此4x4矩阵无法与3D坐标相乘,因为矩阵有4列,而坐标只有3行。
一些变换,如旋转、缩放,只需要3x3矩阵,对于这些变换,3D坐标就可以进行运算。但透视投影矩阵需要4x4矩阵,为了统一,我们强制使用4D坐标。
这些被称为齐次坐标,在后续的教程中,我们会在讲解有向光照时学习有关“W”维度的表示。在这里,我们只需将3D坐标转换为4D坐标,即将第四维坐标“W”设为1。例如,坐标 (22, 33, 44) 转换为:
当需要将4D坐标转换为3D坐标时,如果“W”维度的值为1,可以直接忽略它,使用X、Y、Z的值。如果“W”的值不为1,则需要进行额外处理,或者可能存在bug。
构造一个立方体
代码上的第一个变动是用立方体替换之前的三角形。我们使用三角形来构造立方体,每个面由两个三角形组成。在旧版本的OpenGL中,可以使用一个正方形(GL_QUADS)来替代两个三角形表示每个面,但在现代版本的OpenGL中,GL_QUADS 已被移除。
X、Y、Z坐标的值域为 -1 到 1,这意味着立方体的边长为两个单位,中心点位于原点(坐标为 (0, 0, 0))。我们将使用256×256的贴图为立方体的每个面贴上纹理,后续文章都会使用这些数据,无需进行太多更改。以下是立方体的数据:
GLfloat vertexData[] = {
// X Y Z U V
// bottom
-1.0f,-1.0f,-1.0f, 0.0f, 0.0f,
1.0f,-1.0f,-1.0f, 1.0f, 0.0f,
-1.0f,-1.0f, 1.0f, 0.0f, 1.0f,
1.0f,-1.0f,-1.0f, 1.0f, 0.0f,
1.0f,-1.0f, 1.0f, 1.0f, 1.0f,
-1.0f,-1.0f, 1.0f, 0.0f, 1.0f,
// top
-1.0f, 1.0f,-1.0f, 0.0f, 0.0f,
-1.0f, 1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f,-1.0f, 1.0f, 0.0f,
1.0f, 1.0f,-1.0f, 1.0f, 0.0f,
-1.0f, 1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
// front
-1.0f,-1.0f, 1.0f, 1.0f, 0.0f,
1.0f,-1.0f, 1.0f, 0.0f, 0.0f,
-1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
1.0f,-1.0f, 1.0f, 0.0f, 0.0f,
1.0f, 1.0f, 1.0f, 0.0f, 1.0f,
-1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
// back
-1.0f,-1.0f,-1.0f, 0.0f, 0.0f,
-1.0f, 1.0f,-1.0f, 0.0f, 1.0f,
1.0f,-1.0f,-1.0f, 1.0f, 0.0f,
1.0f,-1.0f,-1.0f, 1.0f, 0.0f,
-1.0f, 1.0f,-1.0f, 0.0f, 1.0f,
1.0f, 1.0f,-1.0f, 1.0f, 1.0f,
// left
-1.0f,-1.0f, 1.0f, 0.0f, 1.0f,
-1.0f, 1.0f,-1.0f, 1.0f, 0.0f,
-1.0f,-1.0f,-1.0f, 0.0f, 0.0f,
-1.0f,-1.0f, 1.0f, 0.0f, 1.0f,
-1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
-1.0f, 1.0f,-1.0f, 1.0f, 0.0f,
// right
1.0f,-1.0f, 1.0f, 1.0f, 1.0f,
1.0f,-1.0f,-1.0f, 1.0f, 0.0f,
1.0f, 1.0f,-1.0f, 0.0f, 0.0f,
1.0f,-1.0f, 1.0f, 1.0f, 1.0f,
1.0f, 1.0f,-1.0f, 0.0f, 0.0f,
1.0f, 1.0f, 1.0f, 0.0f, 1.0f
};
我们需要更改 Render 函数中的 glDrawArrays 调用,之前该调用用于绘制三角形。立方体有6个面,每个面由2个三角形组成,每个三角形有3个顶点,因此需要绘制的顶点数为:6 × 2 × 3 = 36。新的 glDrawArrays 调用如下:
glDrawArrays(GL_TRIANGLES, 0, 6*2*3);
最后,我们使用新的贴图 wooden-crate.jpg,需要更改 LoadTexture 中的文件名,如下:
tdogl::Bitmap bmp = tdogl::Bitmap::bitmapFromFile(ResourcePath("wooden-crate.jpg"));
至此,我们已经提供了绘制带贴图立方体所需的所有数据。运行程序后,会发现存在两个问题。第一,立方体看起来非常“2D”,因为我们只能看到一个面,需要“移动相机”以不同角度观察立方体。第二,立方体的宽和高应该相等,但从截图来看,宽度明显比高度大。为了解决这两个问题,我们需要学习更多的矩阵知识,并将其应用到3D程序中。
裁剪体 - 默认相机
为了理解3D中的“相机”,我们首先需要了解裁剪体。裁剪体是一个立方体,位于裁剪体内的物体将显示在屏幕上,而位于裁剪体之外的物体则不会显示。裁剪体的X、Y、Z坐标值域同样为 -1 到 +1,-X 表示左边,+X 表示右边,-Y 表示底部,+Y 表示顶部,+Z 表示远离相机,-Z 表示朝着相机。
由于我们的立方体和裁剪体大小相同,因此只能看到立方体的正面。这也解释了为什么立方体看起来比较宽,窗口会显示裁剪体内的所有物体,窗口的左右边缘对应X轴的 -1 和 +1,底部和顶部边缘对应Y轴的 -1 和 +1。裁剪体被拉伸以适应窗口的可视大小,因此立方体看起来不是正方形。
固定住相机,让世界移动起来
我们需要移动相机,以便从不同角度观察物体或进行放大缩小操作。但裁剪体的大小和位置是固定不变的,因此我们可以通过移动3D场景,使其正确地出现在裁剪体中,来替代移动相机的操作。例如,若要让相机往右旋转,我们可以将整个世界往左旋转;若要让相机靠近玩家,我们可以将玩家移到相机前方。这就是3D中“相机”的工作方式,通过变换整个世界,使其出现在裁剪体中并呈现出正确的效果。
无论我们如何移动,都会感觉是自己在移动而世界静止不动。但我们也可以想象自己静止,而世界在脚下移动,就像在跑步机上一样。这就是“移动相机”和“移动世界”的区别,对于观察者来说,这两种方式的效果是相同的。
那么,如何对3D场景进行变换以适应裁剪体呢?这就需要用到矩阵。
实现相机矩阵
首先,我们来实现相机矩阵。在3D中,“相机”可以看作是对3D场景的一系列变换,由于相机本身就是一种变换,因此可以用矩阵来表示。
我们需要包含GLM头文件,用于创建不同类型的矩阵:
#include <glm/glm.hpp>
接着,我们需要更新顶点着色器。创建一个相机矩阵变量 camera,并将每个顶点乘以该相机矩阵,从而实现对整个3D场景的变换。新的顶点着色器如下:
#version 150
uniform mat4 camera; // 新增变量
in vec3 vert;
in vec2 vertTexCoord;
out vec2 fragTexCoord;
void main() {
// 将纹理坐标直接传递给片段着色器
fragTexCoord = vertTexCoord;
// 使用相机矩阵变换输入顶点
gl_Position = camera * vec4(vert, 1);
}
现在,我们需要在C++代码中设置 camera 着色器变量。在 LoadShaders 函数的末尾添加以下代码:
gProgram->use();
glm::mat4 camera = glm::lookAt(glm::vec3(3, 3, 3), glm::vec3(0, 0, 0), glm::vec3(0, 1, 0));
gProgram->setUniform("camera", camera);
gProgram->stopUsing();
这个相机矩阵在本文中不会再改变,在所有着色器创建完成后,只需设置一次。需要注意的是,只有在着色器处于使用状态时才能设置着色器变量,因此我们使用了 gProgram->use() 和 gProgram->stopUsing()。
我们使用 glm::lookAt 函数创建相机矩阵。在旧版本的OpenGL中,可以使用 gluLookAt 函数达到相同的目的,但该函数在最近的OpenGL版本中已被移除。glm::lookAt 的第一个参数 glm::vec3(3, 3, 3) 是相机的位置,第二个参数 glm::vec3(0, 0, 0) 是相机观察的点,由于立方体的中心位于 (0, 0, 0),因此相机将朝着该点观察。最后一个参数 glm::vec3(0, 1, 0) 是“向上”的方向,我们将相机垂直摆放,因此设置“向上”方向为沿着Y轴的正方向。如果相机颠倒或倾斜,该值将不同。
生成相机矩阵后,我们使用 gProgram->setUniform("camera", camera); 设置 camera 着色器变量,setUniform 方法属于 tdogl::Program 类,它会调用 glUniformMatrix4fv 来设置变量。
至此,我们已经实现了一个可运行的相机。但运行程序后,会发现屏幕全黑,这是因为立方体的顶点经过相机矩阵变换后,超出了裁剪体的范围,位于裁剪体之外的物体不会显示。为了再次看到立方体,我们需要设置投影矩阵。
实现投影矩阵
需要注意的是,裁剪体的宽、高和深均为2个单位,假设1个单位等于我们3D场景中的1米,这意味着相机只能看到正前方2米的范围,不太方便。我们需要扩大裁剪体的视野,以便看到更多的3D场景,但裁剪体的大小无法改变,因此可以通过缩小整个场景来实现。由于缩小是一种变换,因此可以用矩阵来表示,投影矩阵的作用就在于此。
我们在顶点着色器中加入投影矩阵变量,更新后的代码如下:
#version 150
uniform mat4 projection; // 新增变量
uniform mat4 camera;
in vec3 vert;
in vec2 vertTexCoord;
out vec2 fragTexCoord;
void main() {
// 将纹理坐标直接传递给片段着色器
fragTexCoord = vertTexCoord;
// 对顶点应用相机和投影变换
gl_Position = projection * camera * vec4(vert, 1);
}
注意矩阵相乘的顺序:projection * camera * vert,相机变换首先应用,投影矩阵变换其次。在矩阵乘法中,变换顺序从右向左,从顶点的角度来看,是从最近的变换到较早的变换。
现在,我们在C++代码中设置 projection 着色器变量,方式与设置 camera 变量相同。在 LoadShaders 函数中添加如下代码:
glm::mat4 projection = glm::perspective(glm::radians(50.0f), SCREEN_SIZE.x/SCREEN_SIZE.y, 0.1f, 10.0f);
gProgram->setUniform("projection", projection);
在旧版本的OpenGL中,可以使用 gluPerspective 来设置投影矩阵,但该函数在最近的版本中已被移除,幸运的是,我们可以使用 glm::perspective 来替代。
glm::perspective 的第一个参数是“可视区域”参数,以弧度为单位,表示相机的视野宽度。我们可以使用 glm::radians 函数将50度转换为弧度。较大的可视区域意味着相机可以看到更多的场景,看起来像是缩小了;较小的可视区域意味着相机只能看到场景的一小部分,看起来像是放大了。
第二个参数是“纵横比”,表示可视区域的纵横比率,通常设置为窗口的 width/height。倒数第二个参数是“近平面”,即裁剪体的前面,0.1 表示近平面距离相机0.1个单位,任何距离相机小于0.1个单位的物体都不可见,近平面的值必须大于0。最后一个参数是“远平面”,即裁剪体的后面,10.0 表示相机显示的物体距离相机必须在10个单位之内,任何距离相机大于10个单位的物体都不可见。我们的立方体距离相机3个单位,因此可以被看到。
glm::perspective 对于将可视锥体映射到裁剪体非常有用。可视锥体类似于一个被砍掉顶端的金字塔,金字塔的底部是远平面,顶部是近平面,可视区域决定了锥体的胖瘦。位于锥体内的物体将被显示,而位于锥体之外的物体将被隐藏。
通过相机矩阵和投影矩阵的组合,我们终于可以看到立方体了。运行程序后,会发现立方体看起来基本正常,它已经呈现为正方形,而不是矩形,这是因为 glm::perspective 中的“纵横比”参数会根据窗口的宽和高进行正确的比例调整。但不幸的是,截图显示立方体的背面渲染并覆盖到了前面,这显然不是我们想要的效果,我们需要开启深度缓冲来解决这个问题。
深度缓冲
OpenGL默认会将最新绘制的内容覆盖到之前绘制的内容上。如果一个物体的背面在前面之后绘制,就会出现背面遮挡前面的情况。深度缓冲的作用就是防止背景层覆盖前景层的物体。
当深度缓冲开启时,每个绘制的像素到相机的距离是可知的,该距离将以一个数值的形式保存在深度缓冲中。当在已存在的像素上绘制新像素时,OpenGL会查询深度缓冲,以确定哪个像素离相机更近。如果新像素离相机更近,则该像素点将被重写;如果之前的像素离相机更近,则新像素将被丢弃。因此,只有当新像素离相机更近时,已存在的像素才会被重写,这就是“深度测试”。
实现深度缓冲
在 AppMain 函数中,调用 glewInit 之后,添加如下代码:
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
这两行代码告诉OpenGL开启深度测试,并指定当新像素离相机的距离小于之前像素的距离时,新像素将被重写。
最后一步,我们需要在渲染每帧之后清理深度缓冲。如果不清理,旧的像素距离将保留在缓冲中,会影响新帧的绘制。在 Render 函数中,修改 glClear 函数如下:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
旋转立方体
如果你完成了上述示例,恭喜你取得了很大的进展!接下来,我们将实现立方体的旋转动画。
如何实现旋转呢?答案是使用另一个矩阵。与之前的矩阵不同的是,这个矩阵每帧都会发生变化,而之前的矩阵都是常量。
我们需要新建一个“模型”矩阵。在常见的3D引擎中,每个物体都有一个模型矩阵,相机和投影矩阵对于整个场景是相同的,但模型矩阵因物体而异。模型矩阵用于将每个物体放置在正确的位置(平移)、设置正确的朝向(旋转)或改变物体的大小(缩放)。由于当前3D场景中只有一个物体,因此我们只需要一个模型矩阵。
我们在顶点着色器中添加 model 矩阵变量,就像添加相机和投影矩阵变量一样。最终版本的顶点着色器如下:
#version 150
uniform mat4 projection;
uniform mat4 camera;
uniform mat4 model; // 新增变量
in vec3 vert;
in vec2 vertTexCoord;
out vec2 fragTexCoord;
void main() {
// 将纹理坐标直接传递给片段着色器
fragTexCoord = vertTexCoord;
// 对顶点应用所有矩阵变换
gl_Position = projection * camera * model * vec4(vert, 1);
}
同样要注意矩阵相乘的顺序,模型矩阵是对顶点进行的最近一次变换,因此应该首先应用,其次是相机矩阵,最后是投影矩阵。
现在,我们需要设置新的 model 着色器变量。与相机和投影变量不同,模型变量需要每帧都进行设置,因此我们将其放在 Render 函数中。在 gProgram->use() 之后添加如下代码:
gProgram->setUniform("model", glm::rotate(glm::mat4(), glm::radians(45.0f), glm::vec3(0, 1, 0)));
我们使用 glm::rotate 函数创建一个旋转矩阵。第一个参数是一个已存在的需要进行旋转的矩阵,这里我们不需要对已存在的矩阵进行旋转,因此传递一个新的 glm::mat4 对象。下一个参数是旋转的角度,这里设置为45°。最后一个参数是旋转轴,可以想象将物体插在叉子上,然后转动叉子,叉子就是旋转轴,转动的角度就是旋转角度。在我们的例子中,使用垂直的旋转轴,因此立方体将在一个平台上旋转。
运行程序后,会看到立方体已经被旋转,但它并没有转动,因为矩阵没有发生变化,始终保持旋转45°。最后一步是让立方体每帧都旋转一定的角度。
动画
首先,添加一个新的全局变量 gDegreesRotated:
GLfloat gDegreesRotated = 0.0f;
每帧我们将稍微增加 gDegreesRotated 的值,并使用该值计算新的旋转矩阵,从而实现动画效果。我们需要不断地更新、绘制,循环这个过程。
创建一个 Update 函数,用于每次增加 gDegreesRotated 的值:
void Update() {
// 每次旋转1度
gDegreesRotated += 1.0f;
// 避免角度超过360度
while(gDegreesRotated > 360.0f) gDegreesRotated -= 360.0f;
}
我们需要每帧调用一次 Update 函数,将其添加到 AppMain 的循环中,在调用 Render 之前:
while(glfwGetWindowParam(GLFW_OPENED)){
// 处理待处理的事件
glfwPollEvents();
// 更新旋转动画
Update();
// 绘制一帧
Render();
}
现在,我们需要根据 gDegreesRotated 变量重新计算模型矩阵。在 Render 函数中修改相关代码来设置模型矩阵:
gProgram->setUniform("model", glm::rotate(glm::mat4(), glm::radians(gDegreesRotated), glm::vec3(0, 1, 0)));
与之前的代码唯一的不同是,我们使用 gDegreesRotated 替换了45°常量。
运行程序后,会看到一个漂亮、平滑转动的立方体动画。但存在一个问题,立方体的转动速度与FPS帧率有关。如果FPS较高,立方体旋转得就快;如果FPS降低,立方体旋转得就慢。这显然不是理想的效果,一个程序应该能够正确更新,而不依赖于运行的帧率。
基于时间的动画
为了使程序的运行更加准确,不依赖于FPS,动画应该以每秒为单位进行更新,而不是每帧更新。最简单的方法是对时间进行计数,并根据上次更新的时间进行正确的更新。我们修改 Update 函数,增加一个变量 secondsElapsed:
void Update(float secondsElapsed) {
const GLfloat degreesPerSecond = 180.0f;
gDegreesRotated += secondsElapsed * degreesPerSecond;
while(gDegreesRotated > 360.0f) gDegreesRotated -= 360.0f;
}
这段代码使得立方体每秒旋转180°,而与帧率无关。
在 AppMain 循环中,我们需要计算自上次更新以来经过的秒数。新的循环如下:
double lastTime = glfwGetTime();
while(glfwGetWindowParam(GLFW_OPENED)){
// 处理待处理的事件
glfwPollEvents();
// 根据自上次更新以来经过的时间更新场景
double thisTime = glfwGetTime();
Update((float)(thisTime - lastTime));
lastTime = thisTime;
// 绘制一帧
Render();
}
glfwGetTime 函数返回从程序启动开始到现在所经过的时间。我们使用 lastTime 变量记录上次更新的时间,每次迭代时,获取最新的时间并存储在 thisTime 变量中,thisTime - lastTime 即为自上次更新以来经过的时间。更新完成后,将 lastTime 设置为 thisTime,以便下次循环正常工作。
这是基于时间更新的最简单方法,还有更好的更新方法,但目前我们不需要过于复杂的实现。
下篇预告
下一篇,我们将使用 tdogl::Camera 类来实现通过键盘操作第一人称射击类型的相机移动,还可以使用鼠标观察不同方向,或者使用鼠标滚轮进行放大缩小。
更多资源
- Tutorial 3 : Matrices:来自
opengl-tutorial.org的优秀矩阵解释。 - Scaling, rotation, translation, and transformation matrices:维基百科上关于缩放、旋转、平移和变换矩阵的介绍。
- Basic 3D Math: Matrices:基础3D数学:矩阵。
- Homogeneous coordinates:由Lawrence Kesteloot撰写的关于齐次坐标的文章。
- Viewing:OpenGL红宝书中的“Viewing”章节,代码示例使用的是旧版本的OpenGL,但理论仍然适用。
- GLM code samples and manual (pdf):GLM的代码示例和手册(PDF格式)。
- Z-buffering (depth buffering):维基百科上关于Z缓冲(深度缓冲)的介绍。
- Overlap and Depth Buffering:《Learning Modern 3D Graphics Programming》一书中关于重叠和深度缓冲的章节。
- Fix Your Timestep!:由Glenn Fiedler撰写的关于修复时间步长的文章。