OpenGL学习笔记4-Hello Triangle

Hello Triangle

在OpenGL中,所有东西都是在3D空间中,但屏幕或窗口是一个2D像素数组,所以OpenGL的大部分工作是关于将所有3D坐标转换为适合屏幕的2D像素。将三维坐标转换为二维像素的过程由OpenGL graphics pipeline 管理。图形管道可以分为两大部分:第一部分将3D坐标转换为2D坐标,第二部分将2D坐标转换为实际的彩色像素。在这一章中,我们将简要讨论图形管道,以及如何利用它来创建花哨的像素。

图形管道接受一组3D坐标作为输入,并将它们转换为屏幕上的彩色2D像素。图形管道可以分为几个步骤,每个步骤都需要前一个步骤的输出作为输入。所有这些步骤都是高度专门化的(它们只有一个特定的功能),可以很容易地并行执行。由于并行的特性,现在的显卡有成千上万的小处理核,可以在图形管道中快速处理数据。为流水线的每一步处理核在GPU上运行小程序。这些小程序叫做着色器(shaders)。

这些着色器中的一些是可由开发者配置的,这允许我们写我们自己的着色器来取代现有的默认着色器。这给了我们更细粒度的控制特定部分的管道,因为他们运行在GPU上,他们也可以节省我们宝贵的CPU时间。着色器是用OpenGL着色语言(GLSL)(OpenGL Shading Language (GLSL))编写的,我们将在下一章深入研究。

下面是图形管道所有阶段的抽象表示。请注意,蓝色部分表示我们可以注入自己的着色器的部分。

如您所见,图形管道包含大量的节,每个节处理将顶点数据转换为完全渲染像素的特定部分。我们将以一种简化的方式简要解释管道的每个部分,以便让您对管道如何运行有一个良好的概述。

作为图形管道的输入,我们传入一个包含三个3D坐标的列表,它们应该在一个名为顶点数据的数组中形成一个三角形;这个顶点数据是顶点的集合。顶点是每个3D坐标的数据集合。这个顶点的数据使用顶点属性表示,顶点属性可以包含我们想要的任何数据,但为了简单起见,让我们假设每个顶点只包含一个3D位置和一些颜色值。

为了让OpenGL知道如何利用你的坐标和颜色值集合,OpenGL需要你提示你想用数据形成什么样的渲染类型。我们希望将数据呈现为点的集合、三角形的集合还是仅仅是一条长线?这些提示被称为原语,并在调用任何绘图命令时被提供给OpenGL。其中一些提示是GL_POINTS、gl_triangle和GL_LINE_STRIP。

管道的第一部分是顶点着色器,它以单个顶点作为输入。顶点着色器的主要目的是将三维坐标转换为不同的三维坐标(稍后会详细介绍),顶点着色器允许我们在顶点属性上做一些基本的处理。

原语装配阶段将顶点着色器中构成原语的所有顶点(如果GL_POINTS被选择,则为顶点)作为输入,并将给定的原语形状中的所有点组装起来;在这个例子中是一个三角形。

原语组装阶段的输出被传递到几何体着色器。几何着色器接受一个顶点集合作为输入,它形成一个原语,并且有能力通过发射新的顶点来形成新的(或其他)原语来生成其他形状。在本例中,它从给定的形状生成第二个三角形。

几何体着色器的输出然后被传递到光栅化阶段,在那里它将产生的原始元素映射到最终屏幕上对应的像素,从而产生片段着色器使用的片段。在片段着色器运行之前,剪切被执行。剪切会丢弃视图之外的所有片段,从而提高性能。

OpenGL中的片段是OpenGL渲染单个像素所需的所有数据。

碎片着色器的主要目的是计算一个像素的最终颜色,这通常是所有高级OpenGL效果发生的阶段。通常片段着色器包含有关3D场景的数据,它可以用来计算最终的像素颜色(像光,阴影,光的颜色等等)。

在所有相应的颜色值被确定之后,最终的对象将会通过一个我们称为alpha测试和混合阶段的阶段。这个阶段检查片段的相应的depth(和stencil)值(我们将稍后得到这些),并使用这些值来检查产生的片段是在其他对象的前面还是后面,并且应该相应地被丢弃。这个阶段还会检查alpha值(alpha值定义了一个对象的透明度)并相应地混合这些对象。因此,即使一个像素输出的颜色是在fragment shader中计算出来的,当渲染多个三角形时,最终的像素颜色仍然可能是完全不同的。

可以看到,图形管道是一个相当复杂的整体,包含许多可配置的部分。然而,几乎所有的情况下,我们只需要使用顶点和碎片着色器。几何着色器是可选的,通常留给它的默认着色器。还有镶嵌阶段和转换反馈回路,我们没有描绘在这里,但那是以后的东西。

在现代OpenGL中,我们需要定义至少一个我们自己的顶点和片段着色器(GPU上没有默认的顶点/片段着色器)。由于这个原因,开始学习现代OpenGL通常是相当困难的,因为在能够渲染第一个三角形之前需要大量的知识。一旦你在本章结束时渲染你的三角形,你将会了解更多关于图形编程的知识。

顶点的输入(Vertex input)

要开始绘制一些东西,我们必须首先给OpenGL一些输入顶点数据。OpenGL是一个3D图形库,所以我们在OpenGL中指定的所有坐标都是3D的(x, y和z坐标)。OpenGL不会简单地将你所有的3D坐标转换为屏幕上的2D像素;OpenGL只处理3个坐标轴(x, y, z)上在-1.0和1.0之间的特定范围内的3D坐标。在这个所谓的标准化设备坐标(normalized device coordinates)范围内的所有坐标将最终在你的屏幕上可见(而在这个区域之外的所有坐标将不可见)。

因为我们想渲染一个单独的三角形,所以我们想指定总共三个顶点,每个顶点都有一个3D位置。我们在一个浮点数组的规范化设备坐标(OpenGL的可见区域)中定义它们:

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

标准化设备坐标(NDC)(Normalized Device Coordinates (NDC))

一旦你的顶点坐标在顶点着色器中被处理,它们应该在标准化设备坐标中(normalized device coordinates ),这是一个很小的空间,x, y和z值从-1.0变化到1.0。任何超出这个范围的坐标都将被丢弃/剪切,在你的屏幕上不可见。下面你可以看到我们在标准化设备坐标中指定的三角形(忽略z轴):

与通常的屏幕坐标不同,正y轴在向上的点和(0,0)坐标位于图形的中心,而不是左上角。最终,您希望所有(转换的)坐标都在这个坐标空间中,否则它们将不可见。

然后,使用glViewport提供的数据,通过viewport转换,您的NDC坐标将被转换为屏幕空间坐标。所产生的屏幕空间坐标然后转换为片段作为你的片段着色器的输入。

定义了顶点数据后,我们想把它作为输入发送到图形管道的第一个过程:顶点着色器。这是通过在存储顶点数据的GPU上创建内存来完成的,配置OpenGL应该如何解释内存,并指定如何发送数据到显卡。顶点着色器然后处理我们从它的内存中告诉它的顶点数量。

我们通过所谓的顶点缓冲对象(VBO)来管理这个内存,它可以在GPU的内存中存储大量的顶点。使用这些缓冲区对象的好处是,我们可以一次性将大量数据发送到图形卡,如果还有足够的内存,就可以将其保存在那里,而不必一次只发送一个顶点的数据。从中央处理器向显卡发送数据相对较慢,所以我们尽可能地一次发送尽可能多的数据。一旦数据在图形卡的内存中顶点着色器几乎立即访问顶点使它非常快

顶点缓冲对象是我们在OpenGL一章中讨论的第一个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目标的顶点缓冲区对象。第二个参数指定要传递给缓冲区的数据的大小(以字节为单位);一个简单的顶点数据大小就足够了。第三个参数是我们想要发送的实际数据。

第四个参数指定我们希望图形卡如何管理给定的数据。这可以采取三种形式:

  • GL_STREAM_DRAW:数据只设置一次,GPU最多使用几次。
  • GL_STATIC_DRAW:数据只设置一次,并被多次使用。
  • GL_DYNAMIC_DRAW:数据更改很多,使用了很多次。

三角形的位置数据没有改变,使用了很多,并且在每次渲染调用中保持不变,所以它的使用类型最好是GL_STATIC_DRAW。例如,如果一个缓冲区中有可能经常更改的数据,那么使用GL_DYNAMIC_DRAW类型可以确保显卡将数据放在内存中,从而允许更快的写入。

目前,我们将顶点数据存储在图形卡的内存中,由一个名为VBO的顶点缓冲区对象管理。接下来我们要创建一个顶点和片段着色器来处理这些数据,所以让我们开始构建它们。

顶点着色器(Vertex shader)

顶点着色器是我们这样的人可以编程的着色器之一。现代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)专门设置了输入变量的位置,稍后您将看到为什么需要该位置。

向量(Vector

在图形编程中,我们经常使用向量的数学概念,因为它整齐地表示任何空间中的位置/方向,并且具有有用的数学特性。GLSL中的向量最大大小为4,每个向量的值分别可以通过向量-x、向量-y、向量-z和向量-w进行检索,每个向量代表空间中的一个坐标。请注意,矢量-w组件不是用作空间中的位置(我们处理的是3D,不是4D),而是用于所谓的透视分割。我们将在后面一章更深入地讨论矢量。

为了设置顶点着色器的输出,我们必须将位置数据分配给预定义的gl_Position变量,它是一个后台的vec4。在主函数的最后,无论我们将gl_Position设置为什么,都将被用作顶点着色器的输出。因为我们的输入是一个大小为3的向量我们必须将它转换为大小为4的向量。我们可以将vec3的值插入到vec4的构造函数中,并将其w组件设置为1.0f(我们将在后面的章节中解释原因)。

当前的顶点着色器可能是我们能想象到的最简单的顶点着色器,因为我们没有对输入数据进行任何处理,只是将其转发到着色器的输出。在真实的应用程序中,输入数据通常还没有在规范化的设备坐标中,所以我们首先必须将输入数据转换为OpenGL可见区域中的坐标。

编译着色器(Compiling a shader)

我们将顶点着色器的源代码以常量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"
    "}\0";

为了让OpenGL使用着色器,它必须在运行时从源代码中动态编译它。我们需要做的第一件事是创建一个着色器对象,同样由一个ID引用。所以我们将顶点着色器存储为一个无符号整数,并使用glCreateShader创建着色器:

unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

我们提供了想要创建的着色器的类型作为glCreateShader的参数。因为我们正在创建一个顶点着色器,所以我们传入了GL_VERTEX_SHADER。

接下来,我们将着色器的源代码附加到着色器对象并编译着色器:

glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

glShaderSource函数将要编译的着色器对象作为它的第一个参数。第二个参数指定我们传递多少字符串作为源代码,只有一个。第三个参数是顶点着色器的实际源代码,我们可以让第四个参数为空。

您可能想要检查在调用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;
}

如果在编译顶点着色器时没有检测到错误,现在就编译。

片段着色器(Fragment shader)

fragment着色器是我们为渲染三角形而创建的第二个也是最后一个着色器。fragment shader是关于计算你的像素输出的颜色。为了保持简单,片段着色器总是输出橙色。

计算机图形中的颜色由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);
} 

fragment shader只需要一个输出变量,这是一个大小为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);

现在两个着色器都已经编译好了,唯一要做的就是把两个着色器对象链接到一个着色器程序中,我们可以用它来渲染。请务必在这里检查编译错误!

着色器程序(Shader program)

着色器程序对象是多个着色器组合的最终链接版本。为了使用最近编译的着色器,我们必须将它们链接到一个着色器程序对象,然后在渲染对象时激活这个着色器程序。激活着色程序的着色器将在我们发出渲染调用时使用。

当链接着色器到一个程序它链接每个着色器的输出到下一个着色器的输入。如果输出和输入不匹配,也会在这里出现链接错误。

创建一个程序对象很容易:

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怎么做。

连接顶点属性(Linking Vertex Attributes)

顶点着色器允许我们以顶点属性的形式指定我们想要的任何输入,虽然这允许了很大的灵活性,但这意味着我们必须手动指定输入数据的哪一部分进入顶点着色器中的哪个顶点属性。这意味着我们必须在渲染之前指定OpenGL应该如何解释顶点数据。

我们的顶点缓冲区数据的格式如下:

  • 位置数据存储为32位(4字节)浮点值。
  • 每个位置由3个这样的值组成。
  • 在每组3个值之间没有空格(或其他值)。这些值被紧密地封装在数组中。
  • 数据中的第一个值位于缓冲区的开头。

有了这些知识,我们可以告诉OpenGL它应该如何解释顶点数据(每个顶点属性)使用glVertexAttribPointer:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

函数glVertexAttribPointer有很多参数,所以让我们仔细浏览一下:

  • 第一个参数指定我们要配置的顶点属性。记住,我们在布局的顶点着色器中指定了位置顶点属性的位置(location = 0),这将顶点属性的位置设置为0,因为我们想要传递数据给这个顶点属性,所以我们传递了0。
  • 下一个参数指定顶点属性的大小。顶点属性是一个vec3,所以它由3个值组成。
  • 第三个参数指定数据类型为GL_FLOAT (GLSL中的vec*由浮点值组成)。
  • 下一个参数指定我们是否希望数据被规范化。如果我们输入的是整数数据类型(int、byte),并且我们将其设置为GL_TRUE,那么整数数据将被规范化为0(对于有符号数据,则为-1),在转换为浮点数时为1。这与我们无关,所以我们将它保留在GL_FALSE。
  • 第五个参数称为stride,它告诉我们连续顶点属性之间的空间。由于下一组位置数据的位置正好是一个浮点数的3倍,所以我们指定该值作为stride。注意,因为我们知道数组是紧密打包的(下一个顶点属性值之间没有空格),我们也可以将stride指定为0,让OpenGL决定stride(这只在值紧密打包时有效)。当我们有更多的顶点属性时,我们必须仔细地定义每个顶点属性之间的间距,但是我们将在后面看到更多的例子。
  • 最后一个参数的类型是void*,因此需要进行奇怪的类型转换。这是位置数据在缓冲区中开始位置的偏移量。由于位置数据位于数据数组的开始位置,因此该值为0。稍后我们将更详细地讨论这个参数

每个顶点属性从一个VBO管理的内存中获取数据,它从哪个VBO获取数据(你可以有多个VBO)由当前绑定到GL_ARRAY_BUFFER的VBO决定,当调用glVertexAttribPointer时。因为之前定义的VBO在调用glVertexAttribPointer之前仍然被绑定,所以顶点属性0现在与它的顶点数据相关联。

既然我们已经指定了OpenGL应该如何解释顶点数据,我们也应该启用顶点属性glEnableVertexAttribArray,赋予顶点属性位置作为它的参数;默认情况下,顶点属性是禁用的。从那时起,我们已经建立了一切:我们初始化的顶点数据在缓冲区使用一个顶点缓冲区对象,建立一个顶点和片段着色器,并告诉OpenGL如何链接顶点数据到顶点着色器的顶点属性。在OpenGL中绘制一个对象现在看起来像这样:

// 0. copy our vertices array in a buffer for OpenGL to use
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. then set the vertex attributes pointers
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  
// 2. use our shader program when we want to render an object
glUseProgram(shaderProgram);
// 3. now draw the object 
someOpenGLFunctionThatDrawsOurTriangle(); 

我们必须重复这个过程,每次我们想要画一个对象。它看起来可能没有那么多,但是想象一下,如果我们有超过5个顶点属性,也许有100多个不同的对象(这很常见)。绑定适当的缓冲区对象和配置每个对象的所有顶点属性很快成为一个麻烦的过程。如果有某种方法可以将所有这些状态配置存储到一个对象中,并简单地绑定该对象以恢复其状态,会怎么样呢?

顶点数组对象(Vertex Array Object)

一个顶点数组对象(也称为VAO)可以像一个顶点缓冲区对象一样被绑定,从那个点开始的任何后续顶点属性调用都将存储在VAO中。这样做的好处是,当配置顶点属性指针时,您只需调用一次,并且当我们想绘制对象时,我们只需绑定相应的VAO。这使得在不同的顶点数据和属性配置之间切换就像绑定不同的VAO一样简单。我们刚刚设置的所有状态都存储在VAO中。

Core OpenGL要求我们使用VAO,这样它就知道如何处理顶点输入。如果我们没有绑定一个VAO, OpenGL很可能会拒绝绘制任何东西。

顶点数组对象存储如下内容:

  • 调用glEnableVertexAttribArray或glDisableVertexAttribArray。
  • 顶点属性配置通过glVertexAttribPointer。
  • 通过调用glVertexAttribPointer将顶点属性关联起来的顶点缓冲对象。

生成VAO的过程看起来类似于VBO:

unsigned int VAO;
glGenVertexArrays(1, &VAO); 

要使用VAO,您所要做的就是使用glBindVertexArray绑定VAO。从这一点开始,我们应该绑定/配置相应的VBO和属性指针,然后解除对VAO的绑定,以便以后使用。一旦我们想绘制一个对象,我们只需在绘制对象之前将VAO与首选设置绑定,就这样了。在代码中,这看起来有点像这样:

// ..:: Initialization code (done once (unless your object frequently changes)) :: ..
// 1. bind Vertex Array Object
glBindVertexArray(VAO);
// 2. copy our vertices array in a buffer for OpenGL to use
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. then set our vertex attributes pointers
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

  
[...]

// ..:: Drawing code (in render loop) :: ..
// 4. draw the object
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();   

就是这样!我们在过去的几百万页中所做的一切导致了现在,一个VAO存储了我们的顶点属性配置和使用哪个VBO。通常,当您有多个对象想要绘制时,您首先生成/配置所有的VAOs(以及所需的VBO和属性指针),并将其存储起来供以后使用。当我们想要绘制其中一个对象时,我们取相应的VAO,绑定它,然后绘制对象并再次解除VAO的绑定。

我们一直在等待的三角形(The triangle we've all been waiting for)

为了绘制我们选择的对象,OpenGL为我们提供了glDrawArrays函数,它使用当前活动的着色器、之前定义的顶点属性配置和VBO的顶点数据(通过VAO间接绑定)来绘制原体。

glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

glDrawArrays函数的第一个参数是我们想要绘制的OpenGL原始类型。因为我在一开始就说过我们想要画一个三角形,而且我不想对您说谎,所以我们传入了GL_TRIANGLES。第二个参数指定了我们要绘制的顶点数组的起始索引;我们让它保持在0。最后一个参数指定我们想要绘制多少个顶点,即3个(我们只渲染数据中的一个三角形,它恰好有3个顶点长)。

现在尝试编译代码,如果出现任何错误,请反向工作。一旦你的应用程序编译,你应该看到以下结果:

完整程序的源代码可以在这里here 找到。

如果您的输出看起来不一样,那么您可能在这个过程中做错了什么,所以请检查完整的源代码,看看是否遗漏了什么。

元素缓冲区对象(Element Buffer Objects)

渲染顶点时,我们最后要讨论的是元素缓冲对象缩写为EBO。为了解释元素缓冲区对象是如何工作的,最好给出一个例子:假设我们想要画一个矩形而不是三角形。我们可以用两个三角形画一个矩形(OpenGL主要使用三角形)。这将生成以下的顶点集:

float vertices[] = {
    // first triangle
     0.5f,  0.5f, 0.0f,  // top right
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f,  0.5f, 0.0f,  // top left 
    // second triangle
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f, -0.5f, 0.0f,  // bottom left
    -0.5f,  0.5f, 0.0f   // top left
};

可以看到,指定的顶点有一些重叠。我们指定了右下和左上两次!这是50%的开销,因为同样的矩形也可以指定只有4个顶点,而不是6个。这只会变得更糟,当我们有更复杂的模型,有超过1000个三角形,那里会有大块重叠。更好的解决方案是只存储唯一的顶点,然后指定绘制这些顶点的顺序。在这种情况下,我们只需要为矩形存储4个顶点,然后指定绘制它们的顺序。如果OpenGL提供给我们这样的特性,不是很好吗?

幸运的是,元素缓冲区对象就是这样工作的。EBO是一个缓冲区,就像一个顶点缓冲区对象,它存储OpenGL用来决定绘制什么顶点的索引。这种所谓的索引绘制正是我们的问题的解决方案。首先,我们必须指定(唯一的)顶点和索引,以绘制矩形:

float vertices[] = {
     0.5f,  0.5f, 0.0f,  // top right
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f, -0.5f, 0.0f,  // bottom left
    -0.5f,  0.5f, 0.0f   // top left 
};
unsigned int indices[] = {  // note that we start from 0!
    0, 1, 3,   // first triangle
    1, 2, 3    // second triangle
};

你可以看到,当使用索引时,我们只需要4个顶点而不是6个。接下来我们需要创建元素缓冲区对象:

unsigned int EBO;
glGenBuffers(1, &EBO);

与VBO类似,我们绑定EBO并使用glBufferData将索引复制到缓冲区中。同样,就像VBO一样,我们希望将这些调用放置在bind调用和unbind调用之间,尽管这次我们指定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作为缓冲区目标。剩下要做的最后一件事是用glDrawElements替换glDrawArrays调用,以表示我们希望呈现来自索引缓冲区的三角形。当使用glDrawElements时,我们将使用当前绑定的元素缓冲区对象中提供的索引来绘制:

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时绑定的最后一个元素缓冲区对象存储为VAO的元素缓冲区对象。绑定到一个VAO,然后也会自动绑定那个EBO。

当目标是GL_ELEMENT_ARRAY_BUFFER时,VAO存储glBindBuffer调用。这也意味着它存储了它的unbind调用,因此请确保您没有在解除绑定VAO之前解除元素数组缓冲区的绑定,否则它没有配置EBO。

得到的初始化和绘图代码现在看起来像这样:

// ..:: Initialization code :: ..
// 1. bind Vertex Array Object
glBindVertexArray(VAO);
// 2. copy our vertices array in a vertex buffer for OpenGL to use
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. copy our index array in a element buffer for OpenGL to use
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. then set the vertex attributes pointers
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

[...]
  
// ..:: Drawing code (in render loop) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);

运行该程序应该会得到如下所示的图像。左边的图像应该看起来很熟悉,右边的图像是在线框模式下绘制的矩形。线框矩形表明该矩形确实由两个三角形组成。

线框模式

要在线框模式下绘制三角形,可以配置OpenGL如何通过glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)绘制原语。第一个参数说我们要把它应用到所有三角形的前后,第二条线告诉我们要把它们画成直线。任何后续绘图调用都将以线框模式呈现三角形,直到我们使用glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)将其设置为默认值为止。

如果有任何错误,倒着看是否遗漏了什么。您可以在这里here找到完整的源代码。

如果您像我们一样绘制了一个三角形或矩形,那么恭喜您,您已经完成了现代OpenGL中最困难的部分之一:绘制您的第一个三角形。这是一个困难的部分,因为在绘制第一个三角形之前需要大量的知识。幸运的是,我们现在越过了这个障碍,接下来的章节将更容易理解。

Additional resources

Exercises

To really get a good grasp of the concepts discussed a few exercises were set up. It is advised to work through them before continuing to the next subject to make sure you get a good grasp of what's going on.

  1. Try to draw 2 triangles next to each other using glDrawArrays by adding more vertices to your data: solution.
  2. Now create the same 2 triangles using two different VAOs and VBOs for their data: solution.
  3. Create two shader programs where the second program uses a different fragment shader that outputs the color yellow; draw both triangles again where one outputs the color yellow: solution.

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值