Coordinate Systems(坐标系)
Getting-started/Coordinate-Systems(开始/坐标系统)
在上一章中,我们学习了如何利用矩阵来转换所有的顶点。OpenGL期望在每个顶点着色器运行后,我们想要变得可见的所有顶点都在规范化的设备坐标中。也就是说,每个顶点的x、y、z坐标应该在-1.0到1.0之间;超出此范围的坐标将不可见。我们通常做的是,指定我们自己确定的范围(或空间)中的坐标,并在顶点着色器中将这些坐标转换为标准化设备坐标(NDC)。然后将这些NDC交给光栅化器来将它们转换为屏幕上的2D坐标/像素。
将坐标转换为NDC通常是一步一步地完成的,在最终将对象的顶点转换为NDC之前,我们先将它们转换为几个坐标系统。将它们转换成几个中间坐标系统的好处是,某些操作/计算在某些坐标系统中会更容易,这一点很快就会变得很明显。总共有5个不同的坐标系对我们很重要:
- Local space (or Object space) 局部空间(或对象空间)
- World space 世界坐标系
- View space (or Eye space) 视图空间(或眼睛空间)
- Clip space 投影矩阵
- Screen space 屏幕空间
这些都是不同的状态我们的顶点会在其中进行转换在最终变成片段之前。
现在你们可能对空间或坐标系统是什么感到很困惑,所以我们将用更高级的方式来解释它们首先通过展示总体图以及每个具体空间代表什么。
The global picture 全局图片
为了将坐标从一个空间转换到下一个坐标空间,我们将使用几个转换矩阵,其中最重要的是模型、视图和投影矩阵。我们的顶点坐标首先在本地空间作为本地坐标开始,然后进一步处理为世界坐标,视图坐标,剪辑坐标,最后以屏幕坐标结束。下面的图片显示了这个过程以及每个转换所做的事情:
- 局部坐标是物体相对于其局部原点的坐标;它们是你的对象开始的坐标。
- 下一步是将局部坐标转换为世界-空间坐标,这是一个更大的世界的坐标。这些坐标与世界的某个全局原点相关,还有许多其他物体也与这个世界的原点相关。
- 接下来,我们将世界坐标转换为视图-空间坐标,使每个坐标都能从摄像机或观众的视角中看到。
- 在视图空间中的坐标之后,我们想要将它们投影到剪辑坐标中。剪辑坐标被处理到-1.0和1.0范围,并决定哪个顶点将最终出现在屏幕上。如果使用透视投影,投影到剪贴空间坐标可以添加透视。
- 最后,我们在一个称为viewport transform的过程中将剪辑坐标转换为屏幕坐标,该过程将坐标从-1.0和1.0转换为glViewport定义的坐标范围。得到的坐标然后被发送到光栅化器将它们转换成片段。
你可能对每个独立空间的用途有了一点了解。我们把顶点转换到所有这些不同的空间的原因是有些操作更有意义或者在某些坐标系中更容易使用。例如,当修改对象时,在局部空间中这样做是最有意义的,而根据其他对象的位置计算对象上的某些操作在世界坐标中是最有意义的。如果我们愿意,我们可以定义一个变换矩阵它可以一次从局部空间到剪辑空间,但是这样就没有那么灵活了。
我们将在下面更详细地讨论每个坐标系。
Local space (局部空间)
局部空间是你的对象的局部坐标空间,也就是你的对象开始的地方。假设您已经在建模软件包(如Blender)中创建了多维数据集。即使您的多维数据集可能在最终应用程序中的不同位置结束,但它的原点可能是(0,0,0)。可能您创建的所有模型的初始位置都是(0,0,0)。因此,模型的所有顶点都在局部空间中:它们都在对象的局部。
容器的顶点被指定为-0.5到0.5之间的坐标,原点为0.0。这些是局部坐标。
World space
如果我们在应用程序中直接导入所有对象,它们可能都位于彼此内部的某个位置,位于世界的原点(0,0,0),这不是我们想要的。我们希望为每个对象定义一个位置,以便在更大的世界中定位它们。世界空间中的坐标就是它们听起来的那样:你所有顶点相对于(游戏)世界的坐标。这是坐标空间,你希望你的对象转换到这样一种方式,他们都分散在周围的地方(最好在一个现实的方式)。物体的坐标从局部空间转换到世界空间;这是通过模型矩阵完成的。
模型矩阵是一个转换矩阵,它可以转换、缩放和/或旋转你的对象,以使其在世界中处于它们所属的位置/方向。可以把它想象成把房子缩小(在当地空间里它有点太大了),把它变成一个郊区的小镇,在y轴上向左旋转一点,这样它就能整齐地与邻近的房子相匹配。你也可以把前一章中的矩阵作为模型矩阵来放置容器;我们将容器的本地坐标转换到场景/世界的不同位置。
View space
视图空间是人们通常所指的OpenGL的相机(有时也称为相机空间或眼睛空间)。视图空间是将世界空间坐标转换为用户视图前的坐标的结果。因此,视点空间就是从相机的角度所看到的空间。这通常是通过平移和旋转的组合来完成的,以平移/旋转场景,以便某些项目被转换到相机的前面。这些组合的转换通常存储在转换世界的视图矩阵中
Clip space
在每个顶点着色器运行的最后,OpenGL期望坐标在一个特定的范围内,任何超出这个范围的坐标都会被裁剪。被裁剪的坐标将被丢弃,因此剩余的坐标将作为在屏幕上可见的片段结束。这也是clip space名字的来源。
因为指定所有可见的坐标在-1.0和1.0范围内并不是很直观,所以我们指定我们自己的坐标集来工作,并按照OpenGL的期望将它们转换回NDC。
为了将顶点坐标从视图转换到剪贴空间,我们定义了一个所谓的投影矩阵,它指定了一个坐标范围,例如-1000和1000在每个维度。然后,投影矩阵将此指定范围内的坐标转换为规范化的设备坐标(-1.0,1.0)。这个范围之外的所有坐标都不会在-1.0到1.0之间映射,因此会被裁剪。使用我们在投影矩阵中指定的这个范围,坐标(1250,500,750)将是不可见的,因为x坐标超出了这个范围,因此在NDC中被转换为高于1.0的坐标,因此被裁剪。
注意,如果一个原始元素的一部分(比如三角形)在裁剪卷之外,OpenGL会将这个三角形重建为一个或多个三角形,以适应裁剪范围。
投影矩阵创建的这个查看框称为锥体,在该锥体内结束的每个坐标将最终出现在用户的屏幕上。将指定范围内的坐标转换为可以轻松映射为2D视图空间坐标的NDC的整个过程称为投影,因为投影矩阵将3D坐标投影到易于映射为2D的规范化设备坐标。
一旦所有的顶点被转换为剪切空间,最后的操作叫做透视分割,我们将位置向量的x, y和z分量除以向量的齐次w分量;透视分割是将4D剪辑空间坐标转换为3D标准化设备坐标的方法。这一步在顶点着色器的最后自动执行。
在这一阶段之后,生成的坐标被映射到屏幕坐标(使用glViewport的设置)并转换为片段。
将视图坐标转换为剪辑坐标的投影矩阵通常采用两种不同的形式,每一种形式都定义了自己唯一的截锥。我们可以创建一个正投影矩阵或透视投影矩阵。
Orthographic projection 正射投影
一个正射影矩阵定义了一个立方体样的截锥体盒,它定义了裁剪空间,该空间中该盒外的每个顶点都被裁剪。当创建一个正射投影矩阵时,我们指定可视截锥的宽度、高度和长度。这个截锥体内的所有坐标在经过它的矩阵变换后最终都在NDC范围内,因此不会被裁剪。圆锥台看起来有点像一个容器:
截锥体定义了可见的坐标,并由宽度、高度和远近平面指定。近平面前的任何坐标都被裁剪,远平面后的坐标也是如此。正射截锥直接将截锥内的所有坐标映射为归一化设备坐标,没有任何特殊的副作用,因为它不会接触到变换后的向量的w分量;如果w分量保持等于1.0透视分割不会改变坐标。
我们利用GLM的内置函数GLM::ortho:来创建一个正投影矩阵。
glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);
前两个参数指定圆锥台的左右坐标,第三和第四个参数指定圆锥台的底部和顶部。通过这四个点,我们定义了近平面和远平面的大小第5和第6个参数定义了近平面和远平面之间的距离。这个特定的投影矩阵将这些x、y和z范围值之间的所有坐标转换为标准化的设备坐标。
正投影矩阵直接将坐标映射到屏幕上的2D平面,但实际上,直接投影会产生不现实的结果,因为投影没有考虑透视。这是透视投影矩阵为我们修复的东西。
Perspective projection 透视投影
如果你曾经享受过现实生活提供的图形,你会注意到更远的物体看起来小得多。这种奇怪的效果我们称之为透视。当向下看无限的高速公路或铁路的尽头时,视角尤其引人注目,如下图所示:
正如你所看到的,由于透视,这些线似乎在足够远的距离上重合。这正是透视投影试图模仿的效果,它使用透视投影矩阵来做到这一点。投影矩阵将给定的截锥体范围映射到剪辑空间,同时也操作每个顶点坐标的w值,这样顶点坐标离观察者越远,这个w分量就变得越高。一旦坐标被转换为剪辑空间,它们就在-w到w的范围内(任何超出这个范围的都被剪辑)。OpenGL要求最终顶点着色器输出的可见坐标在-1.0和1.0之间,因此一旦坐标在剪辑空间中,透视分割应用到剪辑空间坐标:
顶点坐标的每个组成部分除以它的w组成部分,从而使一个顶点离观察者越远,它的顶点坐标就越小。这是w组件重要的另一个原因,因为它帮助我们进行透视投影。由此产生的坐标将被归一化设备空间。如果您对如何计算正投影和透视投影矩阵感兴趣(并且不太害怕数学),我可以推荐Songho的这篇优秀文章this excellent article 。
在GLM中可以创建一个透视图投影矩阵,如下所示:
glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);
glm::perspective所做的是再次创建一个定义可见空间的大截锥,任何在截锥之外的东西都不会结束在剪辑空间中,因此会被剪辑。透视视锥台可以可视化为一个非均匀形状的盒子,盒子内的每个坐标将映射到剪辑空间中的一个点。一个透视圆锥台的图像如下所示:
它的第一个参数定义了fov值,它代表视场,并设置了视场的大小。对于一个真实的视图,它通常设置为45度,但是对于更多的末日风格的结果,你可以设置一个更高的值。第二个参数设置了高宽比,它是通过视口的宽度除以高度计算出来的。第三和第四个参数设置了圆锥台的近平面和远平面。我们通常将近距离设置为0.1,远距离设置为100.0。近面和远面之间以及锥体内部的所有顶点都将被渲染。
每当你的视角矩阵的值附近设置过高(如10.0),OpenGL将剪辑所有坐标接近相机(在0.0和10.0之间),可以给一个视觉效果在游戏你也许已经见过你可以看到通过某些对象在移动不安地接近他们。
当使用正交投影时,每个顶点坐标都被直接映射到剪辑空间,而不需要任何复杂的透视分割(它仍然进行透视分割,但是w组件没有被操作(它保持1),因此没有效果)。因为正投影没有使用透视投影,更远的物体看起来并不小,这产生了奇怪的视觉输出。由于这个原因,正投影主要用于2D渲染和一些建筑或工程应用程序,我们不希望顶点被透视扭曲。
您可以看到,在透视投影中,距离更远的顶点看起来要小得多,而在正射投影中,每个顶点与用户的距离是相同的。
Putting it all together 将所有的东西放在一起
我们为前面提到的每个步骤创建一个转换矩阵:模型、视图和投影矩阵。将顶点坐标转换为剪切坐标,如下图所示:
注意,矩阵乘法的顺序是颠倒的(记住,我们需要从右到左读取矩阵乘法)。得到的顶点应该被分配到顶点着色器的gl_Position,然后OpenGL会自动执行透视分割和裁剪。
然后呢?
顶点着色器的输出要求坐标在剪贴空间中,这就是我们刚刚对变换矩阵所做的。然后OpenGL对剪贴空间坐标执行透视分割,将它们转换为标准设备坐标。然后OpenGL使用来自glViewPort的参数将规范化设备坐标映射到屏幕坐标,其中每个坐标对应于屏幕上的一个点(在我们的示例中是一个800x600的屏幕)。这个过程称为视窗转换。
这是一个很难理解的主题,所以如果您仍然不确定每个空间用于什么,您不必担心。下面你将看到我们如何真正地使用这些坐标空间,在接下来的章节中会有足够的例子。
Going 3D
既然我们知道了如何将3D坐标转换为2D坐标,我们就可以开始渲染真正的3D对象,而不是我们目前所展示的蹩脚的2D平面。
开始在3D中绘制,我们将首先创建一个模型矩阵。模型矩阵由平移、缩放和/或旋转组成,我们想要应用这些来将所有对象的顶点转换到全局世界空间。我们通过在x轴上旋转来对平面做一点变换这样它看起来就像躺在地板上一样。模型矩阵是这样的:
glm::mat4 model = glm::mat4(1.0f);
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));
通过将顶点坐标与模型矩阵相乘,我们将顶点坐标转换为世界坐标。我们的平面稍微在地板上,因此代表了全球的平面。
接下来,我们需要创建一个视图矩阵。我们希望在场景中稍微向后移动,这样物体就可以看到(在世界空间中,我们位于原点(0,0,0))。要在场景中移动,请考虑以下问题:
- 将摄像机向后移动,与将整个场景向前移动相同。
这就是视图矩阵的作用,我们将整个场景反向移动到我们想让摄像机移动的地方。因为我们想向后移动因为OpenGL是一个右手系统我们必须在正z轴上移动。我们通过将场景向负z轴平移来做到这一点。这给人一种我们正在倒退的印象。
Right-handed system
按照惯例,OpenGL是一个右手系统。它的基本意思是正的x轴在你的右边,正的y轴在上面正的z轴在后面。假设屏幕是3个轴的中心正z轴穿过屏幕向你这边移动。坐标轴绘制如下:
要理解它为什么被称为右手,请做以下事情:
- Stretch your right-arm along the positive y-axis with your hand up top.沿着正y轴伸展你的右臂,手向上。
- Let your thumb point to the right. 让你的拇指指向右边。
- Let your pointing finger point up. 让你的手指指向上方。
- Now bend your middle finger downwards 90 degrees. 现在,中指向下弯曲90度。
如果你做对了,拇指应该指向正x轴,食指指向正y轴中指指向正z轴。如果你用左臂来做这个你会看到z轴反转了。这被称为左撇子系统,通常被DirectX使用。注意,在规范化的设备坐标中,OpenGL实际上使用了一个左手系统(投影矩阵改变了左右手性)。
我们将在下一章更详细地讨论如何在场景中移动。现在的视图矩阵是这样的:
glm::mat4 view = glm::mat4(1.0f);
// note that we're translating the scene in the reverse direction of where we want to move
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
最后需要定义的是投影矩阵。我们想在场景中使用透视投影,所以我们将这样声明投影矩阵:
glm::mat4 projection;
projection = glm::perspective(glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f);
现在我们已经创建了转换矩阵,我们应该将它们传递给着色器。首先让我们在顶点着色器中声明变换矩阵为uniform,并将它们与顶点坐标相乘:
#version 330 core
layout (location = 0) in vec3 aPos;
...
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
// note that we read the multiplication from right to left
gl_Position = projection * view * model * vec4(aPos, 1.0);
...
}
我们还应该将矩阵发送到着色器(这通常是在每一帧中完成的,因为变换矩阵往往会有很大的变化):
int modelLoc = glGetUniformLocation(ourShader.ID, "model");
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
... // same for View Matrix and Projection Matrix
现在我们的顶点坐标已经通过模型、视图和投影矩阵进行了转换,最终的对象应该是:
- Tilted backwards to the floor. 向后倾斜到地板上。
- A bit farther away from us. 离我们稍微远一点。
- Be displayed with perspective (it should get smaller, the further its vertices are). 用透视图显示(顶点越远,它应该越小)。
让我们来看看结果是否真的满足这些要求:
这个平面看起来确实像一个三维平面它坐落在一个假想的地板上。如果您没有得到相同的结果,请将您的代码与完整的源代码进行比较。source code.
More 3D
到目前为止,我们一直使用的是2D平面,甚至是在3D空间中,所以让我们采取冒险的路线,将2D平面扩展为3D立方体。要渲染一个立方体,我们总共需要36个顶点(6个面* 2个三角形*每个3个顶点)。36个顶点加起来太多了,所以你可以从这里here. 取回它们。
为了好玩,我们让立方体随着时间旋转:
model = glm::rotate(model, (float)glfwGetTime() * glm::radians(50.0f), glm::vec3(0.5f, 1.0f, 0.0f));
然后我们将使用glDrawArrays(因为我们没有指定索引)来绘制立方体,但是这次是36个顶点。
glDrawArrays(GL_TRIANGLES, 0, 36);
你应该得到类似如下的结果:
它确实有点像一个立方体,但有一点不对劲。立方体的一些边被画在立方体的其他边上。这是因为当OpenGL一个三角形一个三角形、一个片段一个片段地绘制你的立方体时,它会覆盖之前可能已经绘制的任何像素颜色。由于OpenGL不能保证呈现的三角形的顺序(在同一个draw调用中),一些三角形被绘制在彼此之上,尽管其中一个应该在另一个前面。
幸运的是,OpenGL将深度信息存储在一个名为z-buffer的缓冲区中,它允许OpenGL决定何时绘制一个像素,何时不绘制。使用z缓冲区,我们可以配置OpenGL来进行深度测试。
Z-buffer
OpenGL将其所有的深度信息存储在z缓冲区中,也称为深度缓冲区。GLFW自动为您创建这样的缓冲区(就像它有一个存储输出图像颜色的颜色缓冲区一样)。深度存储在每个片段中(作为片段的z值),每当片段想输出它的颜色时,OpenGL就会将它的深度值与z缓冲区进行比较。如果当前片段在另一个片段后面,那么它将被丢弃,否则将被覆盖。这个过程称为深度测试,由OpenGL自动完成。
但是,如果我们想确保OpenGL实际执行深度测试我们首先需要告诉OpenGL我们要启用深度测试;默认情况下是禁用的。我们可以使用glEnable启用深度测试。glEnable和glDisable函数允许我们启用/禁用OpenGL中的某些功能。然后该功能被启用/禁用,直到另一个调用被禁用/启用。现在,我们想通过启用GL_DEPTH_TEST来启用深度测试:
glEnable(GL_DEPTH_TEST);
因为我们使用的是深度缓冲区,所以我们还需要在每次渲染迭代之前清除深度缓冲区(否则前一帧的深度信息会留在缓冲区中)。与清除颜色缓冲区一样,我们也可以在glClear函数中指定DEPTH_BUFFER_BIT位来清除深度缓冲区:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
让我们重新运行我们的程序,看看OpenGL现在是否执行深度测试:
我们走吧!一个带有适当深度测试的完全纹理立方体,可以随时间旋转。在这里here. 检查源代码。
More cubes! 更多的方块!
假设我们想在屏幕上显示10个立方体。每个立方体看起来都是一样的,但不同之处在于它在世界中的位置。多维数据集的图形布局已经定义,因此在呈现更多对象时不必更改缓冲区或属性数组。对于每个对象,我们唯一需要更改的是将立方体转换为世界的模型矩阵。
首先,让我们为每个立方体定义一个平移向量,指定它在世界空间中的位置。我们将在一个glm::vec3数组中定义10个立方体位置:
glm::vec3 cubePositions[] = {
glm::vec3( 0.0f, 0.0f, 0.0f),
glm::vec3( 2.0f, 5.0f, -15.0f),
glm::vec3(-1.5f, -2.2f, -2.5f),
glm::vec3(-3.8f, -2.0f, -12.3f),
glm::vec3( 2.4f, -0.4f, -3.5f),
glm::vec3(-1.7f, 3.0f, -7.5f),
glm::vec3( 1.3f, -2.0f, -2.5f),
glm::vec3( 1.5f, 2.0f, -2.5f),
glm::vec3( 1.5f, 0.2f, -1.5f),
glm::vec3(-1.3f, 1.0f, -1.5f)
};
现在,在渲染循环中,我们想要调用glDrawArrays 10次,但这一次在我们发出draw调用之前,每次发送一个不同的模型矩阵到顶点着色器。我们将在渲染循环中创建一个小循环,它将用不同的模型矩阵渲染我们的对象10次。注意,我们还向每个容器添加了一个小的惟一旋转。
glBindVertexArray(VAO);
for(unsigned int i = 0; i < 10; i++)
{
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, cubePositions[i]);
float angle = 20.0f * i;
model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
ourShader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
每次绘制一个新的立方体时,这段代码将更新模型矩阵,总共执行10次。现在,我们应该看看这个由10个奇怪旋转的立方体组成的世界:
完美!看起来我们的集装箱找到了一些志同道合的朋友。如果卡住了,看看能否将代码与源代码source code. 进行比较。
Exercises
- Try experimenting with the
FoV
andaspect-ratio
parameters of GLM'sprojection
function. See if you can figure out how those affect the perspective frustum. - Play with the view matrix by translating in several directions and see how the scene changes. Think of the view matrix as a camera object.
- Try to make every 3rd container (including the 1st) rotate over time, while leaving the other containers static using just the model matrix: solution.