这个系列文章我们来介绍一位海外工程师如何探索 OpenGL 音视频渲染技术,对于想要开始学习音视频技术的朋友,这些文章是份不错的入门资料,这是第 8 篇:OpenGL 坐标系统。
—— 来自公众号“关键帧Keyframe”的分享
在上一章中,我们学习了如何利用变换矩阵的优势来变换所有顶点。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
范围内并不直观,我们指定自己的坐标集来工作,然后将其转换回 OpenGL 期望的 NDC。
为了将顶点坐标从视图空间变换到裁剪空间,我们定义一个所谓的投影矩阵,该矩阵指定例如 -1000
到 1000
的坐标范围。投影矩阵然后将此范围内的坐标转换为标准化设备坐标(-1.0
,1.0
)(不是直接的,中间有一个称为透视除法的步骤)。超出此范围的坐标将不会映射到 -1.0
到 1.0
范围内,因此将被裁剪。通过我们在投影矩阵中指定的范围,坐标 (1250, 500, 750)
将不可见,因为 x 坐标超出了范围,因此在 NDC 中转换为大于 1.0
的坐标,因此被裁剪。
请注意,如果一个基元(例如三角形)的一部分超出裁剪体积,OpenGL 将重建该三角形作为一个或多个新三角形,以使其适合裁剪范围。
这个投影矩阵创建的“视图盒”称为视锥体,任何最终进入此视锥体的坐标都将显示在用户的屏幕上。将指定范围内的坐标转换为 NDC 的整个过程称为投影,因为投影矩阵将 3D 坐标投影到易于映射到 2D 的标准化设备坐标。
一旦所有顶点都被变换到裁剪空间,就会执行一个称为透视除法的最终操作,我们将位置向量的 x
、y
和 z
分量除以向量的齐次 w
分量;透视除法将 4D 裁剪空间坐标变换为 3D 标准化设备坐标。这一步在顶点着色器步骤结束时自动执行。
在此阶段之后,结果坐标被映射到屏幕坐标(使用 glViewport
的设置),并转换为片段。
投影矩阵用于将视图坐标变换为裁剪坐标,通常有两种形式,每种形式定义其自己的视锥体。我们可以创建一个正交投影矩阵或透视投影矩阵。
5.1、正交投影
正交投影矩阵定义了一个立方体形状的视锥体,指定了裁剪空间,超出此范围的任何顶点都将被裁剪。当创建正交投影矩阵时,我们指定可见视锥体的宽度、高度和长度。此范围内的所有坐标在经过矩阵变换后都将位于 NDC 范围内,因此不会被裁剪。视锥体看起来像一个容器:

视锥体定义了可见坐标,并通过宽度、高度和近远平面来指定。近平面之前的任何坐标都会被裁剪,同样,远平面之后的坐标也会被裁剪。正交视锥体直接将范围内的所有坐标映射到标准化设备坐标,没有任何特殊效果,因为它不会修改变换向量的 w
分量;如果 w
分量保持等于 1.0
,透视除法不会改变坐标。
为了创建正交投影矩阵,我们使用 GLM 的内置函数 glm::ortho
:
glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);
前两个参数指定视锥体的左和右坐标,第三和第四个参数指定视锥体的底和顶部分。通过这四个点,我们定义了近远平面的大小,第五和第六个参数定义了近远平面之间的距离。此特定投影矩阵将 x
、y
和 z
范围内的所有坐标转换为标准化设备坐标。
正交投影矩阵直接将坐标映射到屏幕的 2D 平面,但在现实中,直接投影会产生不真实的结果,因为投影没有考虑透视。透视投影矩阵为我们解决了这个问题。
5.2、透视投影
如果你曾经欣赏过现实生活提供的图形,你会注意到远处的物体看起来更小。这种奇怪的效果就是我们所说的透视。透视在查看无限高速公路或铁路的末端时尤为明显,如下图所示:

如你所见,由于透视效果,线似乎在足够远的距离处汇聚。这正是透视投影试图模仿的效果,它通过透视投影矩阵实现。投影矩阵将指定范围内的坐标映射到裁剪空间,同时还操纵每个顶点坐标的 w
值,使得顶点坐标离观众越远,此 w
分量就越大。一旦坐标被变换到裁剪空间,它们就在 -w
到 w
范围内(超出此范围的任何内容都将被裁剪)。OpenGL 要求可见坐标在顶点着色器的最终输出中落在 -1.0
到 1.0
范围内,因此一旦坐标进入裁剪空间,就会应用透视除法:
out=(x/wy/wz/w)
顶点坐标的每个分量都除以其 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 度,但对于更 Doom 风格的结果,你可以将其设置为更高的值。第二个参数设置宽高比,这是通过将视口的宽度除以其高度来计算的。第三个和第四个参数设置视锥体的近平面和远平面。我们通常将近距离设置为 0.1
,将远距离设置为 100.0
。近平面和远平面之间的所有顶点(在视锥体内)都将被渲染。
当你设置的透视矩阵的近值过高(比如 10.0
)时,OpenGL 会裁剪靠近相机的坐标(介于 0.0
和 10.0
之间),这可能会导致你在游戏中看到的视觉效果,比如当你过于靠近某些物体时可以看到它们的内部。
在正交投影中,每个顶点坐标都直接映射到裁剪空间,没有任何奇特的透视除法(它仍然进行透视除法,但 w
分量未被操纵(保持为 1
),因此没有效果)。由于正交投影不使用透视投影,远处的物体看起来不会更小,这会产生奇怪的视觉效果。因此,正交投影主要用于 2D 渲染,以及某些建筑或工程应用,其中我们更希望顶点不受透视失真影响。下面你将看到 Blender 中两种投影方法的比较:

你可以看到,在透视投影中,远处的顶点看起来小得多,而在正交投影中,每个顶点与用户的距离相同。
6、综合应用
我们为上述每个步骤创建一个变换矩阵:模型矩阵、视图矩阵和投影矩阵。顶点坐标随后按以下方式变换为裁剪坐标:
Vclip=Mprojection⋅Mview⋅Mmodel⋅Vlocal
请注意,矩阵乘法的顺序是相反的(记住我们需要从右到左阅读矩阵乘法)。结果顶点随后应分配给顶点着色器中的 gl_Position
,OpenGL 将自动对裁剪空间坐标执行透视除法和裁剪。
接下来呢?
顶点着色器的输出要求坐标处于裁剪空间,这正是我们刚刚通过变换矩阵完成的操作。OpenGL 然后对裁剪空间坐标执行透视除法,将其变换为标准化设备坐标。OpenGL 使用 glViewPort
的参数将标准化设备坐标映射到屏幕坐标,其中每个坐标对应屏幕上的一个点(在我们的情况下是 800×600 屏幕)。这个过程称为视口变换。
如果此时你对每个空间的用途仍不太清楚,不要担心。下面你将看到如何实际应用这些坐标空间,并且在接下来的章节中会有足够的示例。
7、进入 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);
// 注意我们正在将场景向与希望相机移动的相反方向平移
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()
{
// 注意我们从右到左阅读乘法
gl_Position = projection * view * model * vec4(aPos, 1.0);
...
}
我们还应该将矩阵发送到着色器(这通常在每帧中完成,因为变换矩阵往往会频繁更改):
int modelLoc = glGetUniformLocation(ourShader.ID, "model");
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
... // 同样为视图矩阵和投影矩阵
现在我们的顶点坐标通过模型、视图和投影矩阵变换,最终对象应该:
- 向后倾斜到地板上。
- 离我们稍远一些。
- 以透视方式显示(离观众越远的顶点看起来越小)。
让我们检查结果是否符合这些要求:
看起来确实像一个放在某个想象中的地板上的 3D 平面。如果你没有得到相同的结果,请将你的代码与完整源代码进行比较。
8、更多 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 进行深度测试。
8.1、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 现在是否执行深度测试:
太好了!一个完全纹理化的立方体,随着时间的推移进行适当的深度测试和旋转。查看源代码。
8.2、更多立方体!
假设我们想在屏幕上显示 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 个奇怪旋转的立方体的世界:
太棒了!看起来我们的容器找到了一些志同道合的朋友。如果你卡住了,看看能否将你的代码与源代码进行比较。
9、练习
- 尝试使用 GLM 的
projection
函数中的FoV
和aspect-ratio
参数进行实验。看看你是否能弄清楚它们如何影响透视视锥体。 - 通过在多个方向上平移视图矩阵来玩转它,看看场景如何变化。将视图矩阵视为一个相机对象。
- 尝试使用模型矩阵仅让每第三个容器(包括第一个)随时间旋转,而让其他容器保持静止:解决方案。
音视频方向学习、求职,欢迎加入我们的星球
丰富的音视频知识、面试题、技术方案干货分享,还可以进行面试辅导

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。