[OpengGL] Hello三角形[5]

英文原文:https://learnopengl.com/Getting-started/Hello-Triangle

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

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

  其中一些着色器可以由开发人员配置,这允许我们编写自己的着色器来替换现有的默认着色器。这让我们能够更细粒度地控制管道的特定部分,并且因为它们运行在GPU上,所以它们也能够为我们节省宝贵的CPU时间。着色器是用OpenGLshading语言(GLSL)编写的,我们将在下一章深入研究。

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

在这里插入图片描述
  正如您所看到的,图形管道包含大量的部分,每个部分处理将顶点数据转换为完全渲染的像素的特定部分。我们将以一种简化的方式简要解释管道的每个部分,让您对管道的操作方式有一个很好的概述。

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

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

  管道的第一部分是顶点着色器,它接受一个单一顶点作为输入。顶点着色器的主要目的是将3D坐标转换为不同的3D坐标(后面会详细介绍),顶点着色器允许我们对顶点属性进行一些基本处理。

图元组装阶段将来自顶点着色器的所有顶点(或顶点,如果选择了 GL_POINTS)作为输入,形成一个图元,并在给定的图元形状中组装所有点; 在这种情况下是一个三角形。

  图元组装阶段的输出被传递给几何着色器。 几何着色器将形成图元的顶点集合作为输入,并能够通过发射新顶点以形成新的(或其他)图元来生成其他形状。 在这个例子中,它根据给定的形状生成第二个三角形。

  然后,几何着色器的输出被传递到光栅化阶段,在那里它将产生的基元映射到最终屏幕上的相应像素,形成fragments供片元着色器使用。在片元着色器运行之前,会进行剪裁。剪切会丢弃所有在你的视野之外的片段,从而提高性能。

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

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

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

  正如你所看到的,图形管道是一个相当复杂的整体,包含许多可配置的部分。然而,在几乎所有的情况下,我们只需要处理顶点和片元着色器。几何着色器是可选的,通常留给其默认着色器。还有我们在这里没有描述的 tessellation 阶段和变换反馈环(transform feedback loop),但这是以后的事情。

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

顶点输入

  要开始画东西,我们必须先给OpenGL一些输入顶点数据。OpenGL是一个3D图形库,所以我们在OpenGL中指定的所有坐标都是3D的(x、y和z坐标)。OpenGL不会简单地将所有的3D坐标转化为屏幕上的2D像素;OpenGL只处理3D坐标,当它们在所有3个轴(x、y和z)上处于-1.0和1.0之间的特定范围时。在这个所谓的归一化设备坐标范围内的所有坐标最终都会在你的屏幕上显示出来(而在这个区域之外的所有坐标则不会)。

  因为我们要渲染一个三角形,所以我们要指定总共三个顶点,每个顶点都有一个三维位置。我们在一个浮点数组中以归一化设备坐标(OpenGL的可见区域)来定义它们。

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

  因为OpenGL是在3D空间工作的,所以我们渲染一个2D的三角形,每个顶点的Z坐标为0.0。这样一来,三角形的深度就保持不变,使它看起来像是2D的。

归一化设备坐标(NDC)
  一旦你的顶点坐标在顶点着色器中被处理,它们应该是归一化的设备坐标,这是一个小空间,其中x、y和z值从-1.0到1.0变化。任何超出这个范围的坐标都会被丢弃/剪掉,并且在屏幕上不可见。下面你可以看到我们在归一化设备坐标中指定的三角形(忽略z轴)。
在这里插入图片描述
  与通常的屏幕坐标不同,正Y轴指向向上的方向,(0,0)坐标位于图形的中心,而不是左上方。最终你希望所有的(转换后的)坐标都能在这个坐标空间中结束,否则它们就不可见了。
  然后,你的NDC坐标将通过视口转换,使用你用glViewport提供的数据转换为屏幕空间坐标。然后,产生的屏幕空间坐标将被转换为片段,作为你的片段着色器的输入。

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

  我们通过所谓的顶点缓冲对象(VBO)来管理这些内存,这些对象可以在GPU的内存中存储大量的顶点。使用这些缓冲对象的好处是,我们可以一次将大批量的数据发送到显卡上,如果有足够的内存剩余,就将其保留在那里,而不需要一次发送一个顶点的数据。从CPU向显卡发送数据的速度相对较慢,所以只要有可能,我们就尽量一次发送尽可能多的数据。一旦数据进入显卡的内存,顶点着色器几乎可以立即访问顶点,因此速度非常快。

  顶点缓冲区对象是我们第一次出现的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的顶点缓冲器对象管理。接下来,我们要创建一个顶点和片段着色器来实际处理这些数据,所以让我们开始构建这些数据。

顶点着色器

  顶点着色器是我们这样的人可以编程的着色器之一。现代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个浮点数。由于每个顶点都有一个三维坐标,我们创建了一个名为aPos的vec3输入变量。我们还特别通过布局设置了输入变量的位置(location = 0),你以后会看到为什么我们会需要这个位置。

Vector
在图形编程中,我们经常使用矢量的数学概念,因为它能整齐地表示任何空间中的位置/方向,并具有有用的数学特性。在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可见区域内的坐标。

编译一个着色器

  我们采取顶点着色器的源代码,并将其存储在代码文件顶部的一个常量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来引用。所以我们将顶点着色器存储为一个无符号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个值组成的数组表示的:红、绿、蓝和α(不透明度)分量,通常缩写为RGBA。在OpenGL或GLSL中定义颜色时,我们将每个分量的强度设置为0.0和1.0之间的值。例如,如果我们将红色设置为1.0,绿色设置为1.0,我们就会得到两种颜色的混合,得到黄色。考虑到这3个颜色成分,我们可以产生超过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);

  现在两个着色器都已经编译完成了,唯一要做的就是将两个着色器对象链接到我们可以用于渲染的着色器程序中。请确保在这里也检查一下编译错误!

着色器程序

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

  当把着色器链接到一个程序中时,它把每个着色器的输出链接到下一个着色器的输入。如果你的输出和输入不匹配,这也是你会得到链接错误的地方。

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

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个值之间没有空格(或其他值)。这些数值在数组中是紧密排列的。
  • 数据中的第一个值是在缓冲区的开始。

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

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)是由调用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个顶点属性,也许有100多个不同的对象(这并不罕见)。绑定适当的缓冲区对象并为每个对象配置所有的顶点属性很快就会成为一个繁琐的过程。如果我们有办法将所有这些状态配置存储到一个对象中,并简单地绑定这个对象来恢复它的状态,会怎么样呢?

顶点数组对象(Vertex Array Object)

  一个顶点数组对象(也被称为VAO)可以像一个顶点缓冲区对象一样被绑定,从那以后的任何顶点属性调用都将被存储在VAO中。这样做的好处是,在配置顶点属性指针时,你只需要进行一次这些调用,而且无论何时我们想绘制对象,我们都可以直接绑定相应的VAO。这使得在不同的顶点数据和属性配置之间的切换就像绑定一个不同的VAO一样容易。我们刚刚设置的所有状态都存储在VAO里面。

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

一个顶点数组对象存储以下内容:

  • 对glEnableVertexAttribArray或glDisableVertexAttribArray的调用。
  • 通过glVertexAttribPointer的顶点属性配置。
  • 通过调用glVertexAttribPointer与顶点属性相关的顶点缓冲器对象。

在这里插入图片描述
产生VAO的过程看起来与VBO相似:

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

  要使用一个VAO,你所要做的就是使用glBindVertexArray来绑定VAO。从那时起,我们应该绑定/配置相应的VBO(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的绑定。

我们一直在等待的三角形

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

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

  glDrawArrays函数的第一个参数是我们想要绘制的OpenGL原生类型。因为我一开始就说我们要画一个三角形,我不喜欢对你撒谎,所以我们传入GL_TRIANGLES。第二个参数指定了我们想要绘制的顶点数组的起始索引;我们把它留在0处。最后一个参数指定了我们想要绘制的顶点数量,是3个(我们只从我们的数据中渲染一个三角形,它正好是3个顶点)。

  现在试着编译代码,如果有任何错误出现,就向后努力。一旦你的应用程序编译成功,你应该看到以下结果:

在这里插入图片描述

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

  如果你的输出看起来不一样,你可能做错了什么,所以检查完整的源代码,看看你是否错过了什么。

元素缓冲器对象

  在渲染顶点时,我们还想讨论最后一件事,那就是元素缓冲对象,缩写为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一样,我们要把这些调用放在一个绑定和一个解绑的调用之间,尽管这次我们指定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的调用。这也意味着它存储了它的解除绑定调用,所以要确保你在解除绑定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中最难的部分之一:画出第一个三角形。这是一个困难的部分,因为在能够画出第一个三角形之前,需要有一大块知识。值得庆幸的是,我们现在已经闯过了这一关,接下来的章节希望能更容易理解。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值