[OpenGL入门教程] 8. 3D空间

为了简化考虑,此前的图形都是2D的,现在我们要准备进入3D空间了。

但是,在此之前,需要梳理下关于坐标空间的知识。

坐标空间

将坐标变换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步进行的。物体的顶点在最终转化为屏幕坐标之前还会被变换到多个坐标系统。将物体的坐标变换到几个过渡坐标系的优点在于,在这些特定的坐标系统中,一些操作或运算更加方便和容易。比较重要的总共有5个不同的坐标系统:

  • 局部空间(或者称为物体空间)
  • 世界空间
  • 观察空间(或者称为视觉空间)
  • 裁剪空间
  • 屏幕空间
    这就是一个顶点在最终被转化为片段之前需要经历的所有不同状态。

之所以将顶点变换到各个不同的空间的原因是有些操作在特定的坐标系统中才有意义且更方便。例如,当需要对物体进行修改的时候,在局部空间中来操作会更说得通;如果要对一个物体做出一个相对于其它物体位置的操作时,在世界坐标系中来做这个才更说得通,等等。

最开始,顶点坐标位于局部空间,在这里它称为局部坐标,接着它会变为世界坐标观察坐标裁剪坐标,并最后以屏幕坐标的形式结束:

  1. 局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。
  2. 下一步是将局部坐标变换为世界空间坐标,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。
  3. 接下来将世界坐标变换为观察空间坐标,使得每个坐标都是从摄像机或者说观察者的角度进行观察的
  4. 坐标到达观察空间之后,需要将其投影到裁剪坐标。裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上
  5. 最后,将裁剪坐标变换为屏幕坐标,这一步使用一个叫做视口变换的过程。视口变换将位于-1.0到1.0范围的坐标变换到由glViewport函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。

而为了将坐标从一个坐标系变换到另一个坐标系,需要用到几个变换矩阵,最重要的几个分别是:模型矩阵观察矩阵以及投影矩阵。从局部坐标到世界坐标,通过模型矩阵变换;从世界坐标到观察坐标,通过观察矩阵变换;从观察坐标到裁剪坐标,通过投影矩阵变换。

局部坐标

局部空间是指物体所在的坐标空间,即对象最开始所在的地方。你模型的所有顶点都是在局部空间中:它们相对于你的物体来说都是局部的。

之前的例子中,一直使用的三角形的顶点是被设定在-0.5到0.5的坐标范围中,(0, 0)是它的原点。这些都是局部坐标。

世界空间

世界空间中的坐标正如其名:是指顶点相对于(游戏)世界的坐标。表示的是模型与模型之间的位置关系。该变换是由模型矩阵实现的。

模型矩阵是一种变换矩阵,它能通过对物体进行位移、缩放、旋转来将它置于它本应该在的位置或朝向。

可以将上一篇博客中最后使用的那个变换看作是一种模型矩阵。

观察空间

观察空间是将世界空间坐标转化为用户视野前方的坐标而产生的结果。

因此观察空间就是从摄像机的视角所观察到的空间。而这通常是由一系列的位移和旋转的组合来完成,平移/旋转场景从而使得特定的对象被变换到摄像机的前方。

这些组合在一起的变换通常存储在一个观察矩阵里,它被用来将世界坐标变换到观察空间。

裁剪空间

在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个特定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就会被忽略,所以剩下的坐标就将变为屏幕上可见的片段。

因为将所有可见的坐标都指定在-1.0到1.0的范围内不是很直观,所以正常来说会指定自己的坐标集并将它变换回标准化设备坐标系。

为了将顶点坐标从观察变换到裁剪空间,需要定义一个投影矩阵,它指定了一个范围的坐标,比如在每个维度上的-1000到1000。投影矩阵接着会将在这个指定的范围内的坐标变换为标准化设备坐标的范围(-1.0, 1.0)。所有在范围外的坐标不会被映射到在-1.0到1.0的范围之间,所以会被裁剪掉。

注意,如果只是图元(例如三角形)的一部分超出了裁剪体积,则OpenGL会重新构建这个三角形为一个或多个三角形让其能够适合这个裁剪范围。

由投影矩阵创建的观察箱被称为平截头体,每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为投影,因为使用投影矩阵能将3D坐标投影到很容易映射到2D的标准化设备坐标系中。

一旦所有顶点被变换到裁剪空间,最终的操作——透视除法将会执行,在这个过程中将位置向量的x,y,z分量分别除以向量的齐次w分量;透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行

正射投影

正射投影矩阵定义了一个类似立方体的平截头箱,它定义了一个裁剪空间,在这空间之外的顶点都会被裁剪掉。创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度。

在使用正射投影矩阵变换至裁剪空间之后处于这个平截头体内的所有坐标将不会被裁剪掉。

要创建一个正射投影矩阵,可以使用GLM的内置函数glm::ortho

glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);

上面的平截头体定义了可见的坐标,它由由宽、高、近平面和远平面所指定。任何出现在近平面之前或远平面之后的坐标都会被裁剪掉。

正射平截头体直接将平截头体内部的所有坐标映射为标准化设备坐标,因为每个向量的w分量都没有进行改变;如果w分量等于1.0,透视除法则不会改变这个坐标。

正射投影矩阵直接将坐标映射到2D平面中,即你的屏幕,但实际上一个直接的投影矩阵会产生不真实的结果,因为这个投影没有将透视(Perspective)考虑进去。所以还需要透视投影矩阵来解决这个问题。

透视投影

透视投影矩阵将给定的平截头体范围映射到裁剪空间,除此之外还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。被变换到裁剪空间的坐标都会在-ww的范围之间(任何大于这个范围的坐标都会被裁剪掉)。

glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);

glm::perspective所做的其实就是创建了一个定义了可视空间的大平截头体,任何在这个平截头体以外的东西最后都不会出现在裁剪空间体积内,并且将会受到裁剪。一个透视平截头体可以被看作一个不均匀形状的箱子,在这个箱子内部的每个坐标都会被映射到裁剪空间上的一个点。

  • 第一个参数定义了fov的值,它表示的是视野(Field of View),并且设置了观察空间的大小。如果想要一个真实的观察效果,它的值通常设置为45.0f。
  • 第二个参数设置了宽高比,由视口的宽除以高所得。
  • 第三和第四个参数设置了平截头体的近和远平面。通常设置近距离为0.1f,而远距离设为100.0f。所有在近平面和远平面内且处于平截头体内的顶点都会被渲染。

当使用正射投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何精细的透视除法(它仍然会进行透视除法,只是w分量没有被改变(它保持为1),因此没有起作用)。因为正射投影没有使用透视,远处的物体不会显得更小,所以产生奇怪的视觉效果。由于这个原因,正射投影主要用于二维渲染以及一些建筑或工程的程序,在这些场景中我们更希望顶点不会被透视所干扰。某些如 Blender 等进行三维建模的软件有时在建模时也会使用正射投影,因为它在各个维度下都更准确地描绘了每个物体。

深度测试

OpenGL存储它的所有深度信息于一个Z缓冲中,也被称为深度缓冲。GLFW会自动为你生成这样一个缓冲(就像它也有一个颜色缓冲来存储输出图像的颜色)。

深度值存储在每个片段里面(作为片段的z值),当片段想要输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较,如果当前的片段在其它片段之后,它将会被丢弃,否则将会覆盖。这个过程称为深度测试,它是由OpenGL自动完成的。

glEnable(GL_DEPTH_TEST);

可以通过glEnable函数来开启深度测试。glEnableglDisable函数允许我们启用或禁用某个OpenGL功能。这个功能会一直保持启用/禁用状态,直到另一个调用来禁用/启用它。

这里通过指定GL_DEPTH_TEST来开启深度测试。

由于使用了深度测试,在每次渲染迭代之前也需要清除深度缓冲(否则前一帧的深度信息仍然保存在缓冲中)。就像清除颜色缓冲一样通过在glClear函数中指定DEPTH_BUFFER_BIT位来清除深度缓冲:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

3D绘图

现在,知道了如何将3D坐标变换到2D空间,就可以开始真正的3D绘图了。

首先,需要先定义一个3d物体,这里就以正方体为例,也就是把之前的面扩展成体了。

float vertices[] = {
    -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,
     0.5f, -0.5f, -0.5f,  1.0f, 0.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,

    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
    -0.5f,  0.5f,  0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,

    -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  1.0f, 1.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,

    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f,  0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f
};
glm::mat4 model;
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));

glm::mat4 view;
// 注意,将矩阵向要进行移动场景的反方向移动。
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));

glm::mat4 projection;
projection = glm::perspective(glm::radians(45.0f), screenWidth / screenHeight, 0.1f, 100.0f);

int Loc = glGetUniformLocation(ourShader.ID, "model"));
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
Loc = glGetUniformLocation(ourShader.ID, "projection"));
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(projection));
Loc = glGetUniformLocation(ourShader.ID, "view"));
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(view));

下一步创建一个模型矩阵。这个模型矩阵包含了位移、缩放与旋转操作,它们会被应用到所有物体的顶点上,以变换它们到全局的世界空间。这里定义了一个将其绕着x轴的旋转,使它看起来像放在地上一样。

接下来需要创建一个观察矩阵。在场景里面稍微往后移动,以使得物体变成可见的(当在世界空间时,位于原点(0,0,0))。将摄像机向后移动,和将整个场景向前移动是一样的。

那么以相反于摄像机移动的方向移动整个场景,这就是观察矩阵所做的事。OpenGL是一个右手坐标系,所以是沿着z轴的正方向移动。这里通过将场景沿着z轴负方向平移来实现,它会给我们一种我们在往后移动的感觉。注意在标准化设备坐标系中OpenGL实际上使用的是左手坐标系(投影矩阵交换了左右手)。

然后定义一个投影矩阵,这里声明了一个透视投影矩阵。

接着,将定义好的变换作为uniform分别传入shader,并在shader代码中对应更改。

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 注意乘法要从右向左读
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    ...
}

最后记得将绘图时的设置改为绘制数组,然后需要绘制36个顶点

glDrawArrays(GL_TRIANGLES, 0, 36);

最终效果如下:

参考资料

[1] https://learnopengl-cn.readthedocs.io/zh/latest/01%20Getting%20started/03%20Hello%20Window/

[2] http://bit.ly/2lt7ccM