本文中,我会将不会动的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 matrix
  • matrix × coordinate = transformed coordinate

矩阵 × 矩阵

当你要对两个矩阵进行相乘时,它们的乘积是一个包含两者变换的新矩阵。

比如,你将一个旋转矩阵乘以一个平移矩阵,得到的结果就是“组合”矩阵,即先旋转然后平移。下面的例子展示这类矩阵相乘。

不像普通的乘法,矩阵乘法中顺序很重要。 比如,AB是矩阵,A*B不一定等于B*A。下面我们会使用相同的矩阵,但改变下乘法顺序:

注意不同的顺序,结果也不同。下面动画说明顺序有多重要。相同的矩阵,不同的顺序。两个变换分别是沿Y轴上移,和旋转45°。

当你编码的时候,假如看到变换出错,请回头检查下你的矩阵运算是否是正确的顺序。

矩阵 × 坐标

当你用矩阵乘以一个坐标时,它们的乘积就是一个变换后的新坐标。

比如,你有上面提到的旋转矩阵,乘上坐标(1,1,0),它的结果就是(-1,1,0)。变换后的坐标就是原始坐标绕着Z周旋转90°。下面是该乘法的图例:

为何我们会使用4D坐标

你可能注意到了上面的坐标是4D的,而非3D。它的格式是这样的:

为何我们会使用4D坐标?因为我们需要用4x4的矩阵完成所有我们需要的3D变换。不管怎样,矩阵乘法需要左边的列数等于右边的行数。这就意味着4x4矩阵无法与3D坐标相乘,因为矩阵有4列,但坐标只有3行。我们需要使用4D坐标,因为4x4的矩阵需要用它们来完成矩阵运算。

一些变换,比如旋转,缩放,只需要3x3矩阵。对于这些变换,我们不需要4D坐标,因为3D坐标就能运算。但无论如何,变换需要至少是4x3的矩阵,而透视投影矩阵需要4x4矩阵,而我们两者都会用到,所以我们强制使用4D。

这些被称为齐次坐标。在后续的教程里,我们会讲到有向光照,那里我们会学到有关“W”维度的表示。在这里,我们只需要将3D转换为4D。3D转换为4D只要将第四维坐标“W”设为1即可。比如,坐标(22,33,44)转换为:

当需要将4D坐标变为3D时,假如“W”维度是1,你可以直接忽略它,使用X,Y,Z的值即可。如果你发现“W”的值不为1,好吧,你就需要做些额外处理,或者这里出了个bug。

构造一个立方体

代码上第一个变动就是用立方体替换之前的三角形。

我们用三角形来构造立方体,用两个三角形表示6个面的每个面。在旧版本的OpengGL中,我们可以使用1个正方形(GL_QUADS)来替代2个三角表示每个面,但GL_QUADS已经被现代版本的OpenGL给移除了。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/gtc/matrix_transform.hpp>

接着,我们需要更新顶点着色器。我们创建一个相机矩阵变量叫做camera,并且每个顶点都会乘上这个相机矩阵。这样我们就将整个3D场景进行了变换。每个顶点都会被相机矩阵所变换。新的顶点着色器看上去应该是这样的:

#version 150

uniform mat4 camera; //this is the new variable

in vec3 vert;
in vec2 vertTexCoord;

out vec2 fragTexCoord;

void main() {
    // Pass the tex coord straight through to the fragment shader
    fragTexCoord = vertTexCoord;

    // Transform the input vertex with the camera matrix
    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函数来达到相同目的,但gluLookAt已经在最近的OpenGL版本中被移除了。第一个参数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; //this is the new variable
uniform mat4 camera;

in vec3 vert;
in vec2 vertTexCoord;

out vec2 fragTexCoord;

void main() {
    // Pass the tex coord straight through to the fragment shader
    fragTexCoord = vertTexCoord;

    // Apply camera and projection transformations to the vertex
    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来设置投影矩阵,同样gluPerspective函数在最近版本的OpenGL中也被移除了。幸运的是你可以使用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开启深度测试。调用glDepthFunc是表明假如像素离相机的距离小于之前的像素距离时应该被重写。

最后一步我们需要在渲染每帧之后清理深度缓冲。假如我们不清理,旧的像素距离会保存在缓冲中,这样会影响到绘制新的一帧。在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; //this is the new variable

in vec3 vert;
in vec2 vertTexCoord;

out vec2 fragTexCoord;

void main() {
    // Pass the tex coord straight through to the fragment shader
    fragTexCoord = vertTexCoord;

    // Apply all matrix transformations to vert
    gl_Position = projection * camera * model * vec4(vert, 1);
}

还是要注意矩阵相乘的顺序。模型矩阵是vert变量最近的一次变换,意味着模型矩阵应该第一个被使用,其次是相机,最后是投影。

现在我们需要设置新的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() {
    //rotate by 1 degree
    gDegreesRotated += 1.0f;

    //don't go over 360 degrees
    while(gDegreesRotated > 360.0f) gDegreesRotated -= 360.0f;
} 

我们需要每帧都调用一次Update函数。让我们把它加入到AppMain的循环中,在调用Render之前。

while(glfwGetWindowParam(GLFW_OPENED)){
    // process pending events
    glfwPollEvents();

    // update the rotation animation
    Update();

    // draw one frame
    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)){
    // process pending events
    glfwPollEvents();

    // update the scene based on the time elapsed since last update
    double thisTime = glfwGetTime();
    Update((float)(thisTime - lastTime));
    lastTime = thisTime;

    // draw one frame
    Render();
}

glfwGetTime返回从程序启动开始到现在所逝去的时间。

我们使用lastTime变量来记录上次更新时间。每次迭代,我们获取最新的时间存入变量thisTime。从上次更新到现在的差值就是thisTime - lastTime。当更新结束,我们设置lastTime = thisTime以便下次循环迭代的时候很正常工作。

这是基于时间更新的最简单方法。这里还有更好的更新方法,但我们还不需要搞得这么复杂。

下篇预告

下一篇,我们会使用tdogl::Camera类来实现用键盘操作第一人称射击类型的相机移动,可以用鼠标观察不同方向,或者用鼠标滚轮来放大缩小。

更多资源