NSDT工具推荐Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎 - Three.js虚拟轴心开发包 - 3D模型在线减面 - STL模型在线切割

OpenGL 期望在每个顶点着色器运行后,我们希望变得可见的所有顶点都处于标准化设备坐标中。 即每个顶点的x、y、z坐标应在-1.0到1.0之间; 在此范围之外的坐标将不可见。 我们通常做的是指定我们自己确定的范围(或空间)中的坐标,并在顶点着色器中将这些坐标转换为标准化设备坐标(NDC)。 然后将这些 NDC 提供给光栅化器,将其转换为屏幕上的 2D 坐标/像素。

将坐标转换为 NDC 通常是逐步完成的,我们将对象的顶点转换为多个坐标系,然后最终将它们转换为 NDC。 将它们转换为多个中间坐标系的优点是,某些操作/计算在某些坐标系中更容易,这一点很快就会变得明显。 对我们来说重要的一共有 5 个不同的坐标系:

  • 局部空间(或对象空间)
  • 世界空间
  • 视图空间(或眼睛空间)
  • 剪辑空间
  • 屏幕空间

这些都是不同的状态,在这种状态下,我们的顶点将在最终成为片元之前进行转换。

你现在可能对空间或坐标系实际上是什么感到非常困惑,因此我们将首先通过显示总体图片和每个特定空间代表的内容,以更高级的方式解释它们。

1、全景图

为了将坐标从一个空间变换到下一个坐标空间,我们将使用几个变换矩阵,其中最重要的是模型、视图和投影矩阵。 我们的顶点坐标首先在局部空间中作为局部坐标开始,然后进一步处理为世界坐标、视图坐标、剪辑坐标,最终作为屏幕坐标。 下图显示了该过程并显示了每个转换的作用:

  • 局部坐标是对象相对于其局部原点的坐标; 它们是你的对象开始的坐标。
  • 下一步是将局部坐标转换为世界空间坐标,这是相对于更大世界的坐标。 这些坐标相对于世界的某个全局原点,以及相对于该世界原点放置的许多其他对象。
    接下来,我们将世界坐标转换为视图空间坐标,使得每个坐标都是从相机或观察者的角度看到的。
  • 坐标位于视图空间后,我们希望将它们投影到剪辑坐标。 剪辑坐标被处理为 -1.0 和 1.0 范围,并确定哪些顶点将最终出现在屏幕上。 如果使用透视投影,投影到剪辑空间坐标可以添加透视。
  • 最后,我们将剪辑坐标转换为屏幕坐标,这个过程称为视口转换,将坐标从 -1.0 和 1.0 转换为 glViewport 定义的坐标范围。 然后将所得坐标发送到光栅器以将其转换为片段。

你可能稍微了解每个单独空间的用途。 我们将顶点变换到所有这些不同空间的原因是,某些操作在某些坐标系中更有意义或更容易使用。 例如,当修改对象时,在本地空间中执行此操作最有意义,而相对于其他对象的位置计算对象上的某些操作在世界坐标等中最有意义。 如果我们愿意,我们可以定义一个从局部空间到裁剪空间的变换矩阵,但这会降低我们的灵活性。

我们将在下面更详细地讨论每个坐标系。

2、局部空间

局部空间是对象本地的坐标空间,即对象开始的位置。想象一下,你已经在建模软件包(如 Blender)中创建了立方体。 你的立方体的原点可能位于 (0,0,0),即使你的立方体最终可能位于最终应用程序中的不同位置。 可能你创建的所有模型都将 (0,0,0) 作为其初始位置。 因此,模型的所有顶点都位于局部空间中:它们对于对象来说都是局部的。

我们使用的容器的顶点被指定为 -0.5 和 0.5 之间的坐标,以 0.0 作为原点。 这些是局部坐标。

3、世界空间

如果我们将所有对象直接导入到应用程序中,它们可能都位于世界原点 (0,0,0) 处彼此内部的某个位置,这不是我们想要的。 我们想要为每个对象定义一个位置,以将它们放置在更大的世界中。

世界空间中的坐标正如其听起来的那样:所有顶点相对于(游戏)世界的坐标。 这是你希望将对象转换到的坐标空间,使它们全部分散在该位置(最好以现实的方式)。 对象的坐标从本地空间转换为世界空间; 这是通过模型矩阵来完成的。

模型矩阵是一个变换矩阵,可以平移、缩放和/或旋转你的对象,以将其放置在世界中它们所属的位置/方向。 可以将其视为通过缩小房屋(在当地空间中有点太大)来改造房屋,将其转换为郊区城镇并在 y 轴上向左旋转一点,以便它与邻近的城镇完美契合 房屋。 你可以将上一章中将容器放置在整个场景中的矩阵视为一种模型矩阵; 我们将容器的局部坐标转换到场景/世界中的某个不同位置。

4、视图空间

视图空间就是人们通常所说的OpenGL的相机(有时也称为相机空间或眼睛空间)。 视图空间是将世界空间坐标转换为用户视图前面的坐标的结果。 因此,视图空间是从相机的角度看到的空间。 这通常是通过平移和旋转的组合来平移/旋转场景来完成的,以便某些项目变换到相机的前面。 这些组合变换通常存储在将世界坐标变换到视图空间的视图矩阵内。 在下一章中,我们将广泛讨论如何创建这样的视图矩阵来模拟相机。

5、剪辑空间

在每次顶点着色器运行结束时,OpenGL 期望坐标在特定范围内,并且任何超出该范围的坐标都会被剪掉。 被剪裁的坐标将被丢弃,因此剩余的坐标最终将作为屏幕上可见的片段。 这也是剪辑空间名称的由来。

因为将所有可见坐标指定在 -1.0 和 1.0 范围内并不直观,所以我们指定要使用的自己的坐标集,并将它们转换回 NDC,正如 OpenGL 所期望的那样。

为了将顶点坐标从视图转换为剪辑空间,我们定义了一个所谓的投影矩阵,它指定了一系列坐标,例如 每个维度为 -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 的设置)并转换为片段。

将视图坐标转换为剪辑坐标的投影矩阵通常采用两种不同的形式,其中每种形式定义其自己独特的视锥体。 我们可以创建正交投影矩阵或透视投影矩阵。

6、正交投影

正交投影矩阵定义了一个类似立方体的体块,这个体块定义了剪裁空间,剪裁该框外的每个顶点。 创建正交投影矩阵时,我们指定可见体块的宽度、高度和长度。 该体块内的所有坐标经过其矩阵变换后最终都会在 NDC 范围内,因此不会被裁剪。 体块看起来有点像一个容器:

体块定义可见坐标并由宽度、高度以及近平面和远平面指定。 近平面前面的任何坐标都会被剪裁,这同样适用于远平面后面的坐标。 正交投影直接将裁剪体块内的所有坐标映射到标准化设备坐标,没有任何特殊的副作用,因为它不会触及变换后的向量的 w 分量; 如果 w 分量保持等于 1.0 透视除法不会改变坐标。

为了创建正交投影矩阵,我们使用 GLM 的内置函数  glm::ortho

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

前两个参数指定裁剪体块的左右坐标,第三个和第四个参数指定底部和顶部。 通过这 4 个点,我们定义了近平面和远平面的大小,第 5 个和第 6 个参数定义了近平面和远平面之间的距离。 该特定投影矩阵将这些 x、y 和 z 范围值之间的所有坐标转换为标准化设备坐标。

正交投影矩阵直接将坐标映射到屏幕的 2D 平面,但实际上,直接投影会产生不切实际的结果,因为投影不考虑透视。 这是透视投影矩阵为我们解决的问题。

7、透视投影

如果你曾经享受过现实生活中的图形,会发现距离较远的物体看起来要小得多。 这种奇怪的效果就是我们所说的透视效果。 当俯视无限的高速公路或铁路的尽头时,透视尤其明显,如下图所示:

正如你所看到的,由于透视的原因,这些线似乎在足够远的距离处重合。 这正是透视投影试图模仿的效果,它使用透视投影矩阵来实现。

投影矩阵将给定的视锥体范围映射到裁剪空间,但也以这样的方式操纵每个顶点坐标的 w 值,即顶点坐标距离观察者越远,该 w 分量就变得越高。 一旦坐标转换为裁剪空间,它们就会在 -w 到 w 的范围内(任何超出该范围的内容都会被裁剪)。 OpenGL 要求可见坐标落在 -1.0 和 1.0 范围之间作为最终顶点着色器输出,因此一旦坐标位于剪辑空间中,透视除法就会应用于剪辑空间坐标:

顶点坐标的每个分量除以其 w 分量,顶点距离观察者越远,顶点坐标越小。 这是 w 分量很重要的另一个原因,因为它可以帮助我们进行透视投影。 所得坐标位于标准化设备空间中。 如果你有兴趣了解正交和透视投影矩阵的实际计算方式(并且不太害怕数学),我可以推荐 Songho 撰写的这篇精彩文章

可以在 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 渲染以及一些我们不希望顶点因透视而扭曲的建筑或工程应用。 用于 3D 建模的 Blender 等应用程序有时会使用正交投影进行建模,因为它可以更准确地描绘每个对象的尺寸。 下面你将看到 Blender 中两种投影方法的比较:

你可以看到,使用透视投影时,远处的顶点看起来要小得多,而在正交投影中,每个顶点到用户的距离相同。

8、整合在一起

我们为上述每个步骤创建一个变换矩阵:模型、视图和投影矩阵。 然后将顶点坐标转换为剪辑坐标,如下所示:

请注意,矩阵乘法的顺序是相反的(请记住,我们需要从右到左读取矩阵乘法)。 然后应将生成的顶点分配给顶点着色器中的 gl_Position,然后 OpenGL 将自动执行透视分割和裁剪。

顶点着色器的输出要求坐标位于剪辑空间中,这就是我们刚刚对变换矩阵所做的操作。 然后,OpenGL 对剪辑空间坐标执行透视除法,将其转换为标准化设备坐标。 然后,OpenGL 使用 glViewPort 中的参数将标准化设备坐标映射到屏幕坐标,其中每个坐标对应于屏幕上的一个点(在我们的例子中是 800x600 屏幕)。 这个过程称为视口变换。

这是一个很难理解的主题,因此如果你仍然不确定每个空间的用途,也不必担心。 下面你将看到我们如何真正充分利用这些坐标空间,并且在接下来的章节中将提供足够的示例。

9、走向 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 轴负方向平移来实现此目的。 这给人的印象是我们正在倒退。

右手系统


按照惯例,OpenGL 是右手系统。 这基本上说明的是,正 x 轴在您的右侧,正 y 轴在上方,正 z 轴在您的后方。 假设您的屏幕是 3 个轴的中心,正 z 轴穿过屏幕朝向您。 坐标轴绘制如上图所示。

要理解为什么它被称为右手,请执行以下操作:

- 沿正 y 轴伸展右臂,手向上。
- 让你的拇指指向右侧。
- 让你的食指向上。
- 现在将中指向下弯曲 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

现在我们的顶点坐标通过模型、视图和投影矩阵进行转换,最终的对象应该是:

  • 向后倾斜至地板。
  • 离我们远一点。
  • 以透视方式显示(顶点越远,它应该越小)。

让我们检查一下结果是否确实满足这些要求:

这架飞机看起来确实像是一架停在某个想象的地板上的 3D 飞机。 如果你没有得到相同的结果,请将代码与完整的源代码进行比较。

10、更多3D

到目前为止,我们一直在使用 2D 平面,甚至在 3D 空间中也是如此,所以让我们采取冒险的路线,将 2D 平面扩展到 3D 立方体。 为了渲染立方体,我们总共需要 36 个顶点(6 个面 * 2 个三角形 * 每个顶点 3 个)。 36 个顶点需要总结很多,因此你可以从这里检索它们。

为了好玩,我们让立方体随时间旋转:

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 不保证渲染的三角形的顺序(在同一绘制调用内),因此某些三角形会在彼此之上绘制,即使一个三角形显然应该在另一个三角形的前面。

幸运的是,OpenGL 将深度信息存储在称为 z 缓冲区的缓冲区中,该缓冲区允许 OpenGL 决定何时在像素上绘制以及何时不绘制。 使用 z 缓冲区,我们可以配置 OpenGL 来进行深度测试。

11、Z缓冲区

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 现在是否执行深度测试,视频

我们开始吧! 一个完全纹理化的立方体,经过适当的深度测试,随着时间的推移而旋转。 在这里检查源代码。

12、更多立方体!

假设我们想在屏幕上显示 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 次,但这次每次在发出绘制调用之前都会向顶点着色器发送不同的模型矩阵。 我们将在渲染循环中创建一个小循环,每次使用不同的模型矩阵渲染对象 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 个奇怪旋转的立方体的世界:

完美! 看来我们的容器找到了一些志同道合的朋友。 如果你遇到困难,请查看是否可以将你的代码与源代码进行比较。


原文链接:Coordinate Systems

BimAnt翻译整理,转载请标明出处