这个系列文章我们来介绍一位海外工程师如何探索 OpenGL 音视频渲染技术,对于想要开始学习音视频技术的朋友,这些文章是份不错的入门资料,这是第 4 篇:OpenGL 三角形入门。
—— 来自公众号“关键帧Keyframe”的分享
在 OpenGL 中,所有内容都位于 3D 空间中,但屏幕或窗口是一个 2D 像素数组,因此 OpenGL 的大部分工作是将所有 3D 坐标转换为适合显示在屏幕上的 2D 像素。这个转换 3D 坐标到 2D 像素的过程由 OpenGL 的图形管道管理。图形管道可以分为两个大部分:第一部分将你的 3D 坐标转换为 2D 坐标,第二部分将 2D 坐标转换为实际的彩色像素。在本章中,我们将简要讨论图形管道,以及如何利用它来创建漂亮的像素效果。
图形管道以一组 3D 坐标作为输入,并将这些坐标转换为屏幕上的彩色 2D 像素。图形管道可以分为几个步骤,每个步骤都需要前一步的输出作为其输入。所有这些步骤都非常专业化(它们有一个特定的功能),并且可以轻松并行执行。由于它们的并行性质,今天的显卡拥有数千个小型处理核心,可以在图形管道内快速处理你的数据。这些处理核心在 GPU 上为管道的每个步骤运行小型程序,这些小型程序称为着色器。
这些着色器中的一些可以由开发人员配置,这允许我们编写自己的着色器来替换现有的默认着色器。这为我们提供了对管道特定部分的更精细控制,并且由于它们在 GPU 上运行,还可以节省宝贵的 CPU 时间。着色器是用 OpenGL 着色语言(GLSL)编写的,我们将在下一章中更深入地讨论它。
下面是你将看到的图形管道所有阶段的抽象表示。请注意,蓝色部分代表我们可以注入自己的着色器的部分。

如你所见,图形管道包含许多部分,每个部分处理将顶点数据转换为完全渲染的像素的一个特定部分。我们将简要解释管道的每个部分,以简化的方式给你一个管道如何工作的良好概述。
作为图形管道的输入,我们传递一个包含三个 3D 坐标的数组,这些坐标应该形成一个三角形,这里称为“顶点数据”;这个顶点数据是顶点的集合。顶点是每个 3D 坐标的集合。顶点的数据使用顶点属性表示,可以包含我们想要的任何数据,但为了简单起见,我们假设每个顶点仅由一个 3D 位置和一些颜色值组成。
为了让 OpenGL 知道如何处理你的坐标和颜色值集合,OpenGL 要求你提示想要用数据形成哪种渲染类型。我们是想要将数据渲染为点的集合、三角形的集合,还是仅仅是一条长线?这些提示称为原始图形,并在调用任何绘制命令时提供给 OpenGL。其中一些提示是 GL_POINTS、GL_TRIANGLES和 GL_LINE_STRIP。
管道的第一部分是顶点着色器,它以单个顶点作为输入。顶点着色器的主要目的是将 3D 坐标转换为不同的 3D 坐标(稍后详细讨论),并且顶点着色器允许我们对顶点属性进行一些基本处理。
顶点着色器阶段的输出可以选择性地传递到几何着色器。几何着色器以形成原始图形的一组顶点作为输入,并能够通过生成新顶点来生成其他形状,以形成新的(或其他)原始图形。在这个示例中,它从给定的形状生成第二个三角形。
原始图形组装阶段以顶点着色器(或几何着色器)输出的所有顶点(或单个顶点,如果选择 GL_POINTS)作为输入,并组装所有点到原始图形形状中;在这个例子中是两个三角形。
原始图形组装阶段的输出然后传递到光栅化阶段,该阶段将结果原始图形映射到最终屏幕上的相应像素,生成片段供片段着色器使用。在片段着色器运行之前,会执行裁剪。裁剪会丢弃视图外部的所有片段,从而提高性能。
在 OpenGL 中,片段是渲染单个像素所需的所有数据。
片段着色器的主要目的是计算像素的最终颜色,这通常是所有高级 OpenGL 效果发生的阶段。通常,片段着色器包含有关 3D 场景的数据,可用于计算最终像素颜色(如光照、阴影、光的颜色等)。
在确定所有相应的颜色值后,最终对象将通过我们称为 alpha 测试和混合阶段的另一个阶段。此阶段检查片段的相应深度(和模板)值(我们稍后会讨论这些),并使用这些值检查结果片段是位于其他对象的前面还是后面,并相应地丢弃。该阶段还检查 alpha 值(alpha 值定义对象的不透明度)并相应地混合对象。因此,即使在片段着色器中计算了像素输出颜色,当渲染多个三角形时,最终像素颜色可能完全不同。
如你所见,图形管道是一个相当复杂的整体,包含许多可配置的部分。然而,在几乎所有情况下,我们只需要处理顶点和片段着色器。几何着色器是可选的,通常保留其默认着色器。还有细分阶段和变换反馈循环没有在这里描述,但那是以后的内容。
在现代 OpenGL 中,我们 必须至少定义自己的顶点和片段着色器(GPU 上没有默认的顶点/片段着色器)。因此,学习现代 OpenGL 通常相当困难,因为在能够渲染第一个三角形之前需要大量的知识。一旦你在本章的最后终于渲染了三角形,你将对图形编程有更深入的了解。
1、顶点输入
要开始绘制内容,我们首先必须向 OpenGL 提供一些输入顶点数据。OpenGL 是一个 3D 图形库,因此我们在 OpenGL 中指定的所有坐标都是 3D 的(x、y和 z坐标)。OpenGL 并不简单地将 所有你的 3D 坐标转换为屏幕上的 2D 像素;OpenGL 只处理那些在特定范围内的 3D 坐标,即所有三个轴(x、y和 z)上的 -1.0到 1.0之间。所有在此所谓的标准化设备坐标范围内的坐标最终都会显示在屏幕上(而此范围外的坐标将被丢弃/裁剪)。
由于我们想要渲染一个单独的三角形,我们想要指定三个顶点,每个顶点都有一个 3D 位置。我们在标准化设备坐标(OpenGL 的可见区域)中定义它们,作为一个 float数组:
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
由于 OpenGL 在 3D 空间中工作,我们通过每个顶点的 z坐标设置为 0.0来渲染一个 2D 三角形。这样三角形的“深度”保持不变,使其看起来像是 2D 的。
标准化设备坐标(NDC)
一旦你的顶点坐标在顶点着色器中处理后,它们应该处于标准化设备坐标中,这是一个 x、y和 z值在 -1.0到 1.0之间的小空间。任何超出此范围的坐标都将被丢弃/裁剪,并且不会显示在屏幕上。下面你可以看到我们指定的三角形在标准化设备坐标内(忽略 z轴):

与通常的屏幕坐标不同,正 y 轴指向上方,(0,0)坐标位于图表的中心,而不是左上角。最终,你希望所有(转换后的)坐标最终进入这个坐标空间,否则它们将不可见。
然后,你的 NDC 坐标将通过使用 glViewport提供的数据转换为屏幕空间坐标。得到的屏幕空间坐标然后作为输入传递给你的片段着色器,转换为片段。
定义顶点数据后,我们希望将其作为输入发送到图形管道的第一个过程:顶点着色器。这是通过在 GPU 上创建内存来完成的,我们将顶点数据存储在那里,配置 OpenGL 应如何解释内存,并指定如何将数据发送到显卡。顶点着色器然后从其内存中处理尽可能多的顶点。
我们通过所谓的顶点缓冲区对象(VBO)来管理这种内存,它可以在 GPU 的内存中存储大量顶点。使用这些缓冲区对象的优势在于,我们可以一次性发送大量数据到显卡,并在内存允许的情况下保留它,而无需一次发送一个顶点。从 CPU 发送数据到显卡相对较慢,因此我们尽可能一次性发送尽可能多的数据。一旦数据在显卡的内存中,顶点着色器几乎可以立即访问顶点,使其非常快速。
顶点缓冲区对象是我们讨论的 OpenGL 对象的第一个实例。与 OpenGL 中的任何对象一样,此缓冲区具有一个唯一的 ID,对应于该缓冲区,因此我们可以使用 glGenBuffers函数生成一个带有缓冲区 ID 的缓冲区:
unsigned int VBO;
glGenBuffers(1, &VBO);
OpenGL 有许多类型的缓冲区对象,顶点缓冲区对象的缓冲区类型是 GL_ARRAY_BUFFER。只要它们具有不同的缓冲区类型,OpenGL 允许我们同时绑定到多个缓冲区。我们可以使用 glBindBuffer函数将新创建的缓冲区绑定到 GL_ARRAY_BUFFER目标:
glBindBuffer(GL_ARRAY_BUFFER, VBO);
从那时起,我们对 GL_ARRAY_BUFFER目标所做的任何缓冲区调用都将用于配置当前绑定的缓冲区,即 VBO。然后我们可以调用 glBufferData函数,将之前定义的顶点数据复制到缓冲区的内存中:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData是一个专门用于将用户定义的数据复制到当前绑定缓冲区的函数。它的第一个参数是我们想要复制数据的缓冲区类型:当前绑定到 GL_ARRAY_BUFFER目标的顶点缓冲区对象。第二个参数指定我们要传递到缓冲区的数据大小(以字节为单位);对顶点数据的 sizeof调用就足够了。第三个参数是我们想要发送的实际数据。
第四个参数指定我们希望显卡如何管理给定的数据。这可以有三种形式:
GL_STREAM_DRAW:数据只设置一次,并且 GPU 最多使用几次。GL_STATIC_DRAW:数据只设置一次,并且多次使用。GL_DYNAMIC_DRAW:数据经常更改,并且多次使用。
三角形的位置数据不会更改,经常使用,并且在每次渲染调用中保持不变,因此其使用类型最好是 GL_STATIC_DRAW。例如,如果有一个数据可能经常更改的缓冲区,GL_DYNAMIC_DRAW的使用类型确保显卡将数据放置在允许更快写入的内存中。
现在,我们将顶点数据存储在由顶点缓冲区对象 VBO 管理的显卡内存中。接下来,我们想要创建一个顶点和片段着色器,实际处理这些数据,让我们开始构建这些着色器。
2、顶点着色器
顶点着色器是我们可以编程的着色器之一。现代 OpenGL 要求我们至少设置一个顶点和片段着色器,如果我们想要进行一些渲染,因此我们将简要介绍着色器,并配置两个非常简单的着色器来绘制我们的第一个三角形。下一章我们将更详细地讨论着色器。
首先,我们需要用着色语言 GLSL(OpenGL 着色语言)编写顶点着色器,然后编译此着色器,以便在我们的应用程序中使用它。下面是你将看到的一个非常基本的顶点着色器的 GLSL 源代码:
#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
如你所见,GLSL 看起来与 C 类似。每个着色器都以声明其版本开始。由于 OpenGL 3.3 及更高版本,GLSL 的版本号与 OpenGL 的版本号匹配(例如,GLSL 版本 420 对应于 OpenGL 版本 4.2)。我们还明确提到我们使用的是核心配置文件功能。
接下来,我们使用 in关键字声明顶点着色器中的所有输入顶点属性。目前我们只关心位置数据,所以我们只需要一个顶点属性。GLSL 有一种向量数据类型,根据其后缀数字包含 1 到 4 个浮点数。由于每个顶点都有一个 3D 坐标,我们创建一个名为 aPos的 vec3输入变量。我们还特别通过 layout (location = 0)设置输入变量的位置,稍后你会看到为什么需要这个位置。
向量
在图形编程中,我们经常使用数学中的向量概念,因为它可以整齐地表示任何空间中的位置/方向,并具有有用的数学属性。GLSL 中的向量最大尺寸为 4,每个值可以通过 vec.x、vec.y、vec.z和 vec.w分别获取,其中每个分别代表空间中的一个坐标。注意 vec.w分量不用于空间中的位置(我们处理的是 3D,而不是 4D),而是用于所谓的透视除法。我们将在后面的章节中更深入地讨论向量。
要设置顶点着色器的输出,我们必须将位置数据分配给预定义的 gl_Position变量,它在后台是一个 vec4。在主函数的末尾,我们将 gl_Position设置为的任何内容都将用作顶点着色器的输出。由于我们的输入是一个大小为 3 的向量,我们必须将其转换为大小为 4 的向量。我们可以通过在 vec4构造函数中插入 vec3值并将其 w分量设置为 1.0f来实现(我们将在后面的章节中解释原因)。
当前的顶点着色器可能是我们能想象的最简单的顶点着色器,因为我们根本没有对输入数据进行任何处理,只是简单地将其转发到着色器的输出。在实际应用中,输入数据通常不是已经处于标准化设备坐标中,所以我们首先必须将输入数据转换为落在 OpenGL 可见区域内的坐标。
3、编译着色器
我们将顶点着色器的源代码存储在一个 const C 字符串中,暂时放在代码文件的顶部:
const char *vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}const char *vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
";
为了让 OpenGL 使用着色器,它必须在运行时从其源代码动态编译它。首先,我们需要创建一个着色器对象,同样通过 ID 引用。因此,我们将顶点着色器存储为一个 unsigned int,并使用 glCreateShader创建着色器:
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
我们向 glCreateShader提供我们想要创建的着色器类型作为参数。由于我们正在创建一个顶点着色器,我们传入 GL_VERTEX_SHADER。
接下来,我们将着色器源代码附加到着色器对象并编译着色器:
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
glShaderSource函数接受要编译的着色器对象作为第一个参数。第二个参数指定我们传递了多少个源代码字符串,这里只传递一个。第三个参数是顶点着色器的实际源代码,第四个参数可以留为 NULL。
你可能希望在调用 glCompileShader后检查编译是否成功,如果没有成功,则找到错误以便修复。检查编译时错误如下:
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
首先,我们定义一个整数来指示成功与否,并为错误消息(如果有)准备一个存储容器。然后我们使用 glGetShaderiv检查编译是否成功。如果编译失败,我们应该使用 glGetShaderInfoLog检索错误消息并打印错误消息:
if (!success) {
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}
如果在编译顶点着色器时没有检测到错误,则该着色器现在已编译。
4、片段着色器
片段着色器是我们将创建的第二个也是最后一个用于渲染三角形的着色器。片段着色器是关于计算像素的输出颜色。为了保持简单,片段着色器将始终输出一种橙色。
在计算机图形中,颜色表示为 4 个值的数组:红色、绿色、蓝色和 alpha(不透明度)分量,通常缩写为 RGBA。当在 OpenGL 或 GLSL 中定义颜色时,我们将每个分量的强度设置为 0.0到 1.0之间的值。例如,如果我们将红色设置为 1.0,绿色设置为 1.0,我们将得到两种颜色的混合,得到黄色。通过这三种颜色分量,我们可以生成超过 1600 万种不同的颜色!
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
片段着色器只需要一个输出变量,这是一个大小为 4 的向量,定义最终的输出颜色,我们需要自己计算。我们可以通过 out关键字声明输出值,这里我们将其命名为 FragColor。接下来,我们简单地将一个 vec4分配给颜色输出,作为橙色,alpha 值为 1.0(1.0表示完全不透明)。
编译片段着色器的过程与顶点着色器类似,只是这次我们使用 GL_FRAGMENT_SHADER常量作为着色器类型:
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
两个着色器现在都已编译,唯一剩下的事情是将两个着色器对象链接到一个着色器程序中,以便我们用于渲染。确保在这里也检查编译错误!
4.1、着色器程序
着色器程序对象是多个着色器链接后的最终版本。要使用最近编译的着色器,我们必须将它们链接到一个着色器程序对象中,然后在渲染对象时激活这个着色器程序。激活的着色器程序的着色器将在我们发出渲染调用时使用。
当将着色器链接到程序中时,它将每个着色器的输出链接到下一个着色器的输入。这也是如果你的输出和输入不匹配时会得到链接错误的地方。
创建程序对象很简单:
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glCreateProgram函数创建一个程序并返回新创建的程序对象的 ID 引用。现在我们需要将之前编译的着色器附加到程序对象中,然后通过 glLinkProgram链接它们:
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
代码应该非常直观,我们将着色器附加到程序中并通过 glLinkProgram链接它们。
与着色器编译类似,我们也可以检查着色器程序的链接是否失败并检索相应的日志。然而,这次我们不是使用 glGetShaderiv和 glGetShaderInfoLog,而是使用:
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
...
}
结果是一个程序对象,我们可以通过调用 glUseProgram并将新创建的程序对象作为参数来激活它:
glUseProgram(shaderProgram);
从 glUseProgram调用之后的所有着色器和渲染调用现在都将使用这个程序对象(以及因此着色器)。
哦,是的,不要忘记在将着色器链接到程序对象后删除着色器对象;我们不再需要它们了:
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
现在,我们将输入顶点数据发送到 GPU,并指示 GPU 如何在顶点和片段着色器中处理顶点数据。我们几乎完成了,但还没有完全完成。OpenGL 还不知道如何解释内存中的顶点数据,以及如何将顶点数据连接到顶点着色器的属性。我们将告诉 OpenGL 如何做到这一点。
5、链接顶点属性
顶点着色器允许我们以顶点属性的形式指定任何输入,这虽然提供了极大的灵活性,但意味着我们必须手动指定输入数据的哪部分进入顶点着色器的哪个顶点属性。这意味着我们必须指定 OpenGL 应如何解释顶点数据,然后才能渲染。
- 我们的顶点缓冲区数据格式如下:

- 位置数据存储为 32 位(4 字节)浮点值。
- 每个位置由 3 个这样的值组成。
- 每组 3 个值之间没有空间(或其他值)。值在数组中紧密打包。
- 数据中的第一个值位于缓冲区的开头。
有了这些知识,我们可以使用 glVertexAttribPointer告诉 OpenGL 如何解释顶点数据(每个顶点属性):
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer函数有很多参数,所以我们仔细看一下:
- 第一个参数指定我们想要配置的顶点属性。记住我们在顶点着色器中用
layout (location = 0)指定了位置顶点属性的位置。这将顶点属性的位置设置为0,因为我们希望将数据传递到这个顶点属性,所以我们传入0。 - 下一个参数指定顶点属性的大小。顶点属性是一个
vec3,所以它由3个值组成。 - 第三个参数指定数据的类型,这里是
GL_FLOAT(GLSL 中的vec*由浮点值组成)。 - 下一个参数指定我们是否希望数据被归一化。如果我们输入的是整数数据类型(int、byte)并且将此设置为
GL_TRUE,整数数据在转换为浮点数时会被归一化到0(或-1对于有符号数据)和1之间。这对我们不相关,所以我们将其保留为GL_FALSE。 - 第五个参数称为步长,它告诉我们连续顶点属性之间的间距。由于下一个位置数据正好在 3 个
float的大小处,我们指定该值作为步长。注意,由于我们知道数组是紧密打包的(顶点属性值之间没有空间),我们也可以将步长指定为0,让 OpenGL 确定步长(这仅在值紧密打包时有效)。每当我们有更多的顶点属性时,我们必须小心定义每个顶点属性之间的间距,但稍后我们会看到更多示例。 - 最后一个参数是
void*类型,因此需要进行奇怪的强制转换。这是位置数据在缓冲区中开始的偏移量。由于位置数据在数据数组的开头,此值仅为0。我们将在以后更详细地探讨此参数。
每个顶点属性从 VBO 管理的内存中获取其数据,而它从哪个 VBO 获取数据(你可以有多个 VBO)由在调用 glVertexAttribPointer时当前绑定到 GL_ARRAY_BUFFER的 VBO 决定。由于在调用 glVertexAttribPointer之前之前定义的 VBO 仍然绑定,顶点属性 0现在与其顶点数据相关联。
现在我们已经指定了 OpenGL 应如何解释顶点数据,我们还应该使用 glEnableVertexAttribArray启用顶点属性,将顶点属性位置作为其参数;顶点属性默认是禁用的。从那时起,我们已经完成了一切:我们使用顶点缓冲区对象在缓冲区中初始化了顶点数据,设置了顶点和片段着色器,并告诉 OpenGL 如何将顶点数据链接到顶点着色器的顶点属性。在 OpenGL 中绘制对象现在看起来像这样:
// 0. 将顶点数组复制到 OpenGL 可以使用的缓冲区中
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 然后设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 2. 当我们想要渲染对象时使用着色器程序
glUseProgram(shaderProgram);
// 3. 现在绘制对象
someOpenGLFunctionThatDrawsOurTriangle();
每次我们想要绘制对象时都必须重复这个过程。看起来可能不多,但想象一下,如果你有超过 5 个顶点属性,甚至可能有数百个不同的对象(这并不罕见)。为每个对象绑定适当的缓冲区对象并配置所有顶点属性很快就会变得繁琐。如果有一种方法可以将所有这些状态配置存储到一个对象中,只需绑定这个对象即可恢复其状态,那该多好。
5.1、顶点数组对象
顶点数组对象(也称为 VAO)可以像顶点缓冲区对象一样绑定,从那时起,任何后续的顶点属性调用都将存储在 VAO 中。这使得在配置顶点属性指针时,你只需调用这些函数一次,每当我们想要绘制对象时,只需绑定相应的 VAO 即可。这使得在不同的顶点数据和属性配置之间切换变得像绑定不同的 VAO 一样简单。所有我们刚刚设置的状态都存储在 VAO 中。
核心 OpenGL 要求我们使用 VAO,以便它知道如何处理我们的顶点输入。如果我们未能绑定 VAO,OpenGL 很可能会拒绝绘制任何内容。
顶点数组对象存储以下内容:
- 对
glEnableVertexAttribArray或glDisableVertexAttribArray的调用。 - 通过
glVertexAttribPointer进行的顶点属性配置。 - 通过
glVertexAttribPointer调用与顶点属性关联的顶点缓冲区对象。

- 生成VAO 的过程与 VBO 类似:
unsigned int VAO;
glGenVertexArrays(1, &VAO);
要使用 VAO,你只需使用 glBindVertexArray绑定它。从那时起,我们应该绑定/配置相应的 VBO(s)和属性指针(s),然后解绑 VAO 供以后使用。每当我们想要绘制对象时,我们只需绑定带有首选设置的 VAO 即可。在代码中,这可能如下所示:
// ..:: 初始化代码(仅执行一次(除非你的对象频繁更改)) ::..
// 1. 绑定顶点数组对象
glBindVertexArray(VAO);
// 2. 将顶点数组复制到 OpenGL 可以使用的缓冲区中
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 然后设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
[...]
// ..:: 绘制代码(在渲染循环中) ::..
// 4. 绘制对象
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();
就这样!我们之前所做的所有工作都为这一刻做准备,一个 VAO 存储了我们的顶点属性配置以及使用哪个 VBO。通常,当你有多个想要绘制的对象时,你首先生成/配置所有 VAO(以及因此所需的 VBO 和属性指针),并将它们存储起来以备后用。当我们想要绘制我们的对象之一时,我们只需获取相应的 VAO,绑定它,然后绘制对象,再次解绑 VAO。
5.2、我们期待已久的三角形
为了绘制我们选择的对象,OpenGL 为我们提供了 glDrawArrays函数,该函数使用当前活动的着色器、之前定义的顶点属性配置以及 VBO 的顶点数据(通过 VAO 间接绑定)来绘制原始图形。
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawArrays函数的第一个参数是我们想要绘制的 OpenGL 原始图形类型。由于一开始我就说我们想要绘制一个三角形,我不喜欢对你撒谎,我们传入 GL_TRIANGLES。第二个参数指定我们想要绘制的顶点数组的起始索引;我们将其保留为 0。最后一个参数指定我们想要绘制多少个顶点,这里是 3(我们只从数据中渲染一个三角形,这正好是 3 个顶点)。
现在尝试编译代码,如果出现任何错误,请回溯检查。一旦你的应用程序编译成功,你应该看到以下结果:

完整程序的源代码可以在这里找到。
如果你的输出看起来不一样,可能是在过程中哪里出错了,所以检查完整源代码,看看你是否遗漏了什么。
6、元素缓冲区对象
在渲染顶点时,我们还希望讨论的最后一个内容是元素缓冲区对象,简称 EBO。为了说明元素缓冲区对象是如何工作的,最好举一个例子:假设我们想要绘制一个矩形而不是三角形。我们可以通过两个三角形绘制一个矩形(OpenGL 主要使用三角形)。这将生成以下顶点集:
float vertices[] = {
// 第一个三角形
0.5f, 0.5f, 0.0f, // 右上
0.5f, -0.5f, 0.0f, // 右下
-0.5f, 0.5f, 0.0f, // 左上
// 第二个三角形
0.5f, -0.5f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f // 左上
};
如你所见,指定的顶点有一些重叠。我们两次指定了 右下和 左上!这是一个 50% 的开销,因为同样的矩形也可以只用 4 个顶点而不是 6 个来指定。这在我们有更复杂的模型时会变得更糟,这些模型有数千个三角形,其中会有大量重叠的部分。一个更好的解决方案是只存储唯一的顶点,然后指定绘制这些顶点的顺序。在这种情况下,我们将只需要为矩形存储 4 个顶点,然后只需指定我们想要绘制它们的顺序。如果 OpenGL 提供了这样的功能,那该多好?
幸运的是,元素缓冲区对象的工作原理正是如此。EBO 是一个缓冲区,就像顶点缓冲区对象一样,它存储索引,OpenGL 使用这些索引来决定绘制哪些顶点。这种所谓的索引绘制正是我们问题的解决方案。要开始,我们首先必须指定(唯一)顶点和绘制它们的索引:
float vertices[] = {
0.5f, 0.5f, 0.0f, // 右上
0.5f, -0.5f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f // 左上
};
unsigned int indices[] = { // 注意我们从 0 开始!
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
你可以看到,当使用索引时,我们只需要 4 个顶点而不是 6 个。接下来,我们需要创建元素缓冲区对象:
unsigned int EBO;
glGenBuffers(1, &EBO);
与 VBO 类似,我们绑定 EBO 并使用 glBufferData将索引复制到缓冲区中。同样,就像 VBO 一样,我们希望将这些调用放在绑定和解绑调用之间,这次我们指定 GL_ELEMENT_ARRAY_BUFFER作为缓冲区类型。
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
注意我们现在将 GL_ELEMENT_ARRAY_BUFFER作为缓冲区目标。剩下的最后一件事是将 glDrawArrays调用替换为 glDrawElements,以指示我们希望从索引缓冲区渲染三角形。当使用 glDrawElements时,我们将使用当前绑定的 EBO 中的索引进行绘制:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
第一个参数指定我们想要绘制的模式,与 glDrawArrays类似。第二个参数是计数或我们想要绘制的元素数量。我们指定了 6 个索引,所以我们想要绘制总共 6 个顶点。第三个参数是索引的类型,这里是 GL_UNSIGNED_INT。最后一个参数允许我们指定 EBO 中的偏移量(或传递索引数组,但这是在你不使用元素缓冲区对象时的情况),但我们将其保留为 0。
glDrawElements函数从当前绑定到 GL_ELEMENT_ARRAY_BUFFER目标的 EBO 中获取其索引。这意味着每次我们想要使用索引绘制对象时,都必须绑定相应的 EBO,这又有点繁琐。碰巧,顶点数组对象还跟踪元素缓冲区对象的绑定。在绑定 VAO 时绑定的最后一个 EBO 将被存储为 VAO 的元素缓冲区对象。绑定到 VAO 时还会自动绑定该 EBO。

VAO 存储了当目标是 GL_ELEMENT_ARRAY_BUFFER时的 glBindBuffer调用。这也意味着它存储了其解绑调用,因此请确保在解绑 VAO 之前不要解绑元素数组缓冲区,否则它将没有配置 EBO。
现在初始化和绘制代码看起来像这样:
// ..:: 初始化代码 ::..
// 1. 绑定顶点数组对象
glBindVertexArray(VAO);
// 2. 将顶点数组复制到顶点缓冲区供 OpenGL 使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 将索引数组复制到元素缓冲区供 OpenGL 使用
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. 然后设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
[...]
// ..:: 绘制代码(在渲染循环中) ::..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
运行程序应该给出下图所示的图像。左图应该看起来很熟悉,右图是以线框模式绘制的矩形。线框矩形显示矩形确实由两个三角形组成。
线框模式
要以线框模式绘制三角形,你可以通过 glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)配置 OpenGL 如何绘制原始图形。第一个参数表示我们希望将其应用于所有三角形的正面和背面,第二个参数告诉我们将它们绘制为线条。任何后续的绘制调用将以线框模式渲染三角形,直到我们将其设置回默认值 glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)。
如果你遇到任何错误,请回溯检查是否遗漏了什么。你可以在这里找到完整源代码。
如果你成功绘制了一个三角形或矩形,就像我们所做的那样,恭喜你,你成功地完成了现代 OpenGL 中最困难的部分之一:绘制你的第一个三角形。这是一个困难的部分,因为在能够绘制第一个三角形之前需要大量的知识。幸运的是,我们现在已经跨越了这个障碍,希望接下来的章节将更容易理解。
7、附加资源
- antongerdelan.net/hellotriangle: Anton Gerdelan 对渲染第一个三角形的看法。
- open.gl/drawing: Alexander Overvoorde 对渲染第一个三角形的看法。
- antongerdelan.net/vertexbuffers: 关于顶点缓冲区对象的一些额外见解。
- learnopengl.com/In-Practice/Debugging: 本章涉及许多步骤;如果你卡住了,阅读一些关于 OpenGL 调试的内容(直到调试输出部分)可能值得一试。
8、练习
为了真正掌握所讨论的概念,我们设置了一些练习。建议在继续下一个主题之前完成这些练习,以确保你对所发生的事情有一个良好的理解。
- 尝试通过向数据中添加更多顶点并使用
glDrawArrays绘制两个相邻的三角形:解决方案。 - 现在使用两个不同的 VAO 和 VBO 为它们的数据创建相同的两个三角形:解决方案。
- 创建两个着色器程序,其中第二个程序使用一个不同的片段着色器输出黄色;再次绘制两个三角形,其中一个输出黄色:解决方案。
音视频方向学习、求职,欢迎加入我们的星球
丰富的音视频知识、面试题、技术方案干货分享,还可以进行面试辅导

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