从零开始写游戏引擎(开发环境VS2022+OpenGL)之四 GPU简单作图的实现,代码与解释,保姆包教会系列

封面

今天我们来讨论一下如何在VS2022以及OpenGL库的配置环境中做出一个简单的模型。

本文的所有源代码都可以在这个链接找到:OpenGLVS2022从零开始做游戏引擎三角形图形生成源码资源-优快云文库

首先介绍一些基本的概念,这部份比较无聊,事实上,作为一个了立足于解决问题的工程师,我的建议是,可以跳过下面的一些概念,直接进入代码与解释部分,有了具体代码的帮助,我们可以更加快的进入今天的主题。

当然了,在了解了代码以及其所承担的任务和作用之后,再去看这些概念性的东西,你会觉得事半功倍,节省大把时间的!!!

着色器(shaders)

在OpenGL中,一切都是3D空间,但屏幕或窗口是2D像素数组,所以OpenGL的大部分工作是将所有3D坐标转换为适合屏幕的2D像素。三维坐标到二维像素的转换过程由OpenGL的图形管道管理。

图形管道可以分为两大部分:第一部分将3D坐标转换为2D坐标,第二部分将2D坐标转换为实际的彩色像素。

在本章中,我们将简要讨论图形管道,以及我们如何利用它来创建漂亮的像素。

图形管道将一组3D坐标作为输入,并将其转换为屏幕上的彩色2D像素。

图形管道可以分为几个步骤,其中每个步骤都需要前一步的输出作为其输入。

所有这些步骤都是高度专门化的(它们有一个特定的功能),可以很容易地并行执行。

由于它们的并行特性,今天的显卡有成千上万的小型处理核心,可以在图形管道中快速处理数据。处理内核在GPU上为流水线的每一步运行小程序。这些小程序被称为着色器。

其中一些着色器是由开发人员可配置的,这允许我们编写自己的着色器来取代现有的默认着色器。

这让我们对管道的特定部分有了更细粒度的控制,因为它们在GPU上运行,它们也可以节省宝贵的CPU时间。

着色器是用OpenGL着色语言(GLSL)编写的,我们将在下一章中深入研究。

下面你会发现图形管道的所有阶段的抽象表示。请注意,蓝色部分表示我们可以注入自己的着色器的部分。也就是顶点,几何以及片段着色器!

在这里插入图片描述

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

图形管道过程

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

[!NOTE]

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

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

顶点着色器阶段的输出可以选择性地传递给几何着色器。几何着色器将一组顶点作为输入,这些顶点形成一个原语,并且能够通过发出新的顶点来形成新的(或其他)原语来生成其他形状。在这个例子中,它从给定的形状中生成第二个三角形。

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

原语组装阶段的输出随后被传递到光栅化阶段(rasterization stage),在那里它将结果原语映射到最终屏幕上的相应像素,从而产生片段着色器使用的片段。在片段着色器运行之前,执行剪辑。剪贴会丢弃视图之外的所有片段,从而提高性能。

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

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

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

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

源码和解释

#include <glad/glad.h>
#include <GLFW/glfw3.h>

#include <iostream>

void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow* window);

// settings
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;

我们首先要做的还是将glad头文件glfw3头文件,都包括进去,当然这里要注意顺序,先包括glad头文件,然后才是glfw3头文件。原因参看我的上一篇博客。地址是从零开始写游戏引擎(开发环境VS2022+OpenGL)之三 HelloWorld窗口的实现,代码与解释,步步教会系列-优快云博客

接下来则是iostream头文件,这个原因是要在控制台输出字符串,所以必须用到这个头文件。

下面的两个函数声明:

void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow* window);

前面一个是关于处理窗口尺寸的回调函数。

它的原型是:

void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    // make sure the viewport matches the new window dimensions; note that width and 
    // height will be significantly larger than specified on retina displays.
    glViewport(0, 0, width, height);
}

后面一个是处理键盘鼠标输入信息的回调函数。

它的原型的:

void processInput(GLFWwindow* window)
{
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
}

显然,这里的意思就是按下ESC键的时候,窗口程序退出!

再下面的几行则是设定窗口的大小。

// settings
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;

以上部分都是同之前一篇博客文章一致的内容。下面就是新的内容了。

又见着色器

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";
const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
"   FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";

有人会说,这是啥?不就是用两个字符串包含了两段文字吗?这个要做什么?

你瞧,这里就是OpenGL编程的难点了,事实上,将里面的内容分别放进代码块,你会发现这就是两段c语言代码!

#version 330 core
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

前面一段是顶点着色器的GLSL(OpenGL Shading Language)后面一段则是片段着色器的GLSL,也可以理解为被OpenGL理解的C语言代码段。

我们要让GPU工作,首先是至少准备上述两个着色器,然后让GPU编译这两个着色器,成功之后才可以进行下面一步。

正如你所看到的,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来检索。vec.x,vec.y,vec.Z和vec.W分别表示空间中的一个坐标。注意,vec.w分量不是用作空间中的位置(我们处理的是3D,而不是4D),而是用于所谓的透视分割。我们将在后面的章节中更深入地讨论向量。

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

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

所以我们在这里才会用两个常数字符串来保存这段编码,用于之后的编译。

main函数的开头

int main()
{
    // glfw: initialize and configure
    // ------------------------------
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

#ifdef __APPLE__
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

这一部分与之前的写法一样,解释可以参考博主的上一篇文章,地址如下:从零开始写游戏引擎(开发环境VS2022+OpenGL)之三 HelloWorld窗口的实现,代码与解释,步步教会系列-优快云博客

OpenGL中文乱码问题

// glfw window creation
// --------------------
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, u8"你好!三角形!!!", NULL, NULL);

下面就是创建窗口了。

这里有个问题,就是如何在窗口标题显示中文。

如果直接这么写,你就会在最后的结果中,看到标题栏是一堆乱码。

// glfw window creation
// --------------------
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "你好!三角形!!!", NULL, NULL);

在这里插入图片描述

正确的做法是在字符串"你好!三角形!!!"前面加上标识符u8,就像我们之前所做的那样,这样我们就可以得到正确的中文显示了。

在这里插入图片描述

之后的代码,又是我们比较熟悉的啦。

if (window == NULL)
{
    std::cout << "Failed to create GLFW window" << std::endl;
    glfwTerminate();
    return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

// glad: load all OpenGL function pointers
// ---------------------------------------
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
    std::cout << "Failed to initialize GLAD" << std::endl;
    return -1;
}

这里我们就不一一解释了,可以参看我们的上一篇博文。从零开始写游戏引擎(开发环境VS2022+OpenGL)之三 HelloWorld窗口的实现,代码与解释,步步教会系列-优快云博客

接下来就是新的内容了,也是我们这篇博文的重点。

顶点着色器的新建和编译

// vertex shader
unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

首先说一说这段代码做的事情。

第一行,就是使用glCreateShader函数创建了一个顶点着色器,GL_VERTEX_SHADER这个参数,就是指明,创建了一个顶点着色器。

然后,将这个顶点着色器的地址放在变量vertexShader里面。

接下来就是将vertexShaderSource这个字符串中的glsl语言代码通过glShaderSource函数载入到变量vertexShader对应的顶点着色器中。

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

最后一步就是编译了。这里使用glCompileShader函数就可以了。

检查顶点着色器编译是否正确

你可能想要在调用glCompileShader之后检查编译是否成功,如果没有,发现了什么错误,这样你就可以修复这些错误。检查编译时错误的方法如下:

// check for shader compile errors
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}

首先,我们定义一个整数来表示成功,并定义一个存储错误消息的容器(如果有的话)。然后我们用glGetShaderiv检查编译是否成功。如果编译失败,我们应该使用glGetShaderInfoLog检索错误消息并打印错误消息。

片段着色器的编译与检查

// fragment shader
unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
// check for shader compile errors
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
    glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}

下面就是片段着色器的编译和检查了。

这一个部分同顶点着色器的编译与检查是一致的。

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

计算机图形中的颜色表示为4个值的数组:红色,绿色,蓝色和alpha(不透明度)组件,通常缩写为RGBA。当在OpenGL或GLSL中定义颜色时,我们将每个组件的强度设置为0.0到1.0之间的值。例如,如果我们将红色设置为1.0,将绿色设置为1.0,我们将得到两种颜色的混合物,并得到黄色。有了这3种颜色组件,我们可以生成超过1600万种不同的颜色!

片段着色器只需要一个输出变量,这是一个大小为4的矢量,它定义了我们应该自己计算的最终颜色输出。我们可以使用out关键字声明输出值,这里我们立即将其命名为FragColor。接下来,我们简单地将vec4分配给颜色输出,作为alpha值为1.0的橙色(1.0是完全不透明的)。

编译片段着色器的过程类似于顶点着色器,尽管这次我们使用GL_FRAGMENT_SHADER常量作为着色器类型。

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

着色器程序的组装

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

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

创建程序对象很容易:

// link shaders
unsigned int shaderProgram = glCreateProgram();

glCreateProgram函数创建一个程序,并返回新创建的程序对象的ID引用。现在我们需要将之前编译的着色器附加到程序对象上,然后使用glLinkProgram链接它们:

glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

就像着色器编译一样,我们也可以检查链接着色器程序是否失败并检索相应的日志。然而,我们现在使用的不是glGetShaderiv和glGetShaderInfoLog:

// check for linking errors
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}

结果是一个程序对象,我们可以通过使用新创建的程序对象作为参数调用glUseProgram来激活它,当然,我们也要注意一下使用的位置,一般都是在while循环中使用:

while (!glfwWindowShouldClose(window))
{
    //.....

    // draw our first triangle
    glUseProgram(shaderProgram);
    //......
}

我们将在之后的代码中遇到这个函数,到了那里再来提。

不要忘记在我们将着色器对象链接到程序对象后删除它们;我们不再需要它们了。

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

顶点数据输入

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

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

// set up vertex data (and buffer(s)) and configure vertex attributes
// ------------------------------------------------------------------
float vertices[] = {
    -0.5f, -0.5f, 0.0f, // left  
     0.5f, -0.5f, 0.0f, // right 
     0.0f,  0.5f, 0.0f  // top   
};

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

归一化设备坐标(NDC)

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

在这里插入图片描述

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

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

定义了顶点数据后,我们想把它作为输入发送到图形管道的第一个进程:顶点着色器。这是通过在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目标的顶点缓冲区对象。第二个参数指定要传递给缓冲区的数据的大小(以字节为单位);一个简单的顶点数据大小就足够了。第三个参数是我们想要发送的实际数据。

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

GL_STREAM_DRAW:数据只设置一次,GPU最多使用几次。

GL_STATIC_DRAW:数据只设置一次,使用多次。

GL_DYNAMIC_DRAW:数据被大量更改并被多次使用。

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

连接顶点属性

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

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

在这里插入图片描述

[!NOTE]

位置数据存储为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。稍后我们将更详细地探讨这个参数。

[!NOTE]

每个顶点属性从一个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个不同的对象(这并不罕见)。绑定适当的缓冲区对象并为每个对象配置所有顶点属性很快成为一个繁琐的过程。如果有一种方法可以将所有这些状态配置存储到一个对象中,并简单地绑定该对象以恢复其状态,那会怎么样?

顶点数组对象

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

[!WARNING]

核心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();   

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

我们期待已久的三角型

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


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

glDrawArrays函数的第一个参数是我们想要绘制的OpenGL原语类型。因为我在一开始就说过我们想画一个三角形,我不想对你撒谎,所以我们传入gl_三角形。第二个参数指定我们想要绘制的顶点数组的起始索引;让这个等于0。最后一个参数指定我们想要绘制多少个顶点,也就是3个(我们只从我们的数据中渲染1个三角形,正好有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调用。这也意味着它存储了它的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);

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

在这里插入图片描述
在这里插入图片描述

好了,今天就到这里了,谢谢大家,创作不易,希望大家多多点赞支持。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

金沙阳

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值