《菜鸟 OpenGL 入门 – 你好,三角形(一》 中,我们介绍了有关图形编程的一些基础概念,相信大家已经磨刀霍霍想要赶紧写代码了吧。接下来我们便实际的创作第一个伟大的三角形。

顶点输入

通过上一节,我们知道,我们在开始绘图之前,必须给 OpenGL 提供一些顶点数据。在提供这些数据之前,我们需要了解 OpenGL 是一个 3D 图形库,他将所有的 3D 数据转换为 2D 的像素,但,它并不是对所有类型的顶点数据都进行转换,OpenGL 需要一个 标准化设备坐标,也就是说,我们提供的坐标的值必须在 -1.01.0 之间,只有在这个区间以内才会被显示,超出的都会被裁剪。

下图描述了 标准化设备坐标,以及我们要绘制的三角形对应的坐标。

标准化设备坐标

与通常的屏幕坐标不同,y轴正方向为向上,(0, 0)坐标是这个图像的中心,而不是左上角。最终你希望所有(变换过的)坐标都在这个坐标空间中,否则它们就不可见了。

还记得我们在 《菜鸟 OpenGL 入门 – 窗口(二)》 一节中使用的 glViewport() 函数吗?

gViewport() 做了一个视口转换的工作,它将标准设备坐标转化成屏幕坐标空间,屏幕坐标空间又被变换为片段输入到片段着色器中进行处理。

通过 gViewport() ,我们可以控制 OpenGL 绘图的范围。

下面便是我们定义的一组顶点数据。

1
2
3
4
5
float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

需要注意的是,我们将 z 轴都设置成了 0.0,所以最终的三角形更像一个平面。

发送到顶点着色器

在我们定义了顶点数据之后,下一步就是把这些数据传递到顶点着色器进行处理,在开始之前呢,我们最好先看看下面两个词汇的定义:

  • 顶点数组对象:Vertex Array Object,VAO
  • 顶点缓冲对象:Vertex Buffer Object,VBO

VBO

在我们将设置好的顶点数据发送到 GPU 让顶点着色器处理之前,我们必须来配置一下,告诉 OpenGL 如何管理这部分内存以及如何发送给显卡。

VBO 在这里的作用就是管理这部分内存的。VBO 会在显存中存储大量的顶点。之所以我们要用 VBO 来管理显存,是因为数据从 CPU发送到GPU速度相对较慢,使用 VBO 对象我们可以一次性发送一大批数据到显存,而不是一个一个发送,为了提升速度,我们每一次会尽可能多的发送数据到显存。当数据发送到显存的时候,顶点着色器几乎立马就可以访问并处理这些数据。

每一个 VBO 对象会有一个独一无二的 ID。我们可以使用 glGenBuffers() 函数生成一个 VBO 对象,并保存其缓冲ID。

1
2
uint32_t VBO;
glGenBuffers(1, &VBO);

VBO 对象的缓冲类型是 GL_ARRAY_BUFFER,因此,我们可以使用 glBindBuffer 函数将新创建的 VBO 对象绑定到 GL_ARRAY_BUFFER 目标上。

1
glBindBuffer(GL_ARRAY_BUFFER, VBO);  

从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。

请记住:OpenGL 是基于状态的。 对于没有接触过类似库或者框架的人来说可能有一些难以理解。在随后我们的学习当中,你会看到很多包含类似 bindactiveuse 之类的函数,这些就是用来改变当前某一目标的激活对象,一旦状态被改变,接下来的相同的操作将针对不同的对象,这一点随着我们学习 OpenGL 的深入会有更多的理解。

接下来,我们调用 glBufferData() 函数将顶点数据复制到缓冲内存中:

1
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glBufferData() 是专门将用户数据复制到缓冲区内存的,下面是他的参数,以及类型简介:

  • GLenum target 目标缓冲的类型,VBO 的类型是 GL_ARRAY_BUFFER
  • GLsizeiptr size 指定传输数据的大小
  • const void *data 发送的实际数据
  • GLenum usage 显卡如何管理给定的数据

GLenum usage 有三种形式:

  • GL_STATIC_DRAW :数据不会或几乎不会改变。
  • GL_DYNAMIC_DRAW:数据会被改变很多。
  • GL_STREAM_DRAW :数据每次绘制时都会改变。

三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是 GL_STATIC_DRAW 。如果,比如说一个缓冲中的数据将频繁被改变,那么使用的类型就是 GL_DYNAMIC_DRAWGL_STREAM_DRAW ,这样就能确保显卡把数据放在能够高速写入的内存部分。

现在我们已经把顶点数据储存在显卡的内存中

VAO

在说 VAO 之前,我们先来看一下顶点属性。

顶点着色器允许我们指定任何以 顶点属性 为形式的输入。我们必须在指定 OpenGL 渲染之前解释这些顶点数据,这样顶点着色器才会正确的识别。

比如,我们的顶点缓冲数据应该是下面这样子的:

顶点属性

  • 我们的位置数据被储存为32位(4字节)浮点值。
  • 每个位置包含3个这样的值。
  • 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
  • 数据中第一个值在缓冲开始的位置。

我们可以使用 glVertexAttribPointer() 函数将这些信息告诉 OpenGL,这样 OpenGL 就知道如何解析这些数据了。

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

glVertexAttribPointer函数的参数非常多,所以我会逐一介绍它们:

  • 第一个参数指定我们要配置的顶点属性。等到我们在顶点着色器中用到的时候大家就会明白了。
  • 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
  • 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)。
  • 下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
  • 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float)。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。
  • 最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。我们会在后面详细解释这个参数。

现在我们已经定义了OpenGL该如何解释顶点数据,我们现在应该使用 glEnableVertexAttribArray ,以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的。

现在,我们已经完成了顶点属性的设置,但,顶点数据多的时候,由于每一次绘制之前都需要绑定顶点属性是一件非常繁琐的事情,所以这个时候就需要 VAO(顶点数组对象)来管理这些顶点属性,它可以向 VBO 一样被绑定,随后任何对顶点属性的操作都会保存在 VAO 当中。这样的好处是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。

OpenGL 核心模式要求使用 VAO,如果绑定失败,是渲染不出东西的。

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

  • glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
  • 通过glVertexAttribPointer设置的顶点属性配置。
  • 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。

创建一个 VAO 的过程和 VBO 很相似,我们修改之前的代码,添加 VAO:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
uint32_t VAO, VBO;
// 生成 VAO
glGenVertexArrays(1, &VAO);
// 生成 VBO
glGenBuffers(1, &VBO);

// 绑定 VAO
glBindVertexArray(VAO);

// 绑定 VBO,并将顶点数据发送到顶点缓存
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// 链接顶点属性(这个操作会保存到上面 bind 的 VAO)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void *)0);
glEnableVertexAttribArray(0);

顶点着色器

OK,现在我们有了 VBO,并且将顶点数据传输到了顶点缓存之中,另外,我们创建了 VAO,并且设置了顶点属性,同时告诉了 OpenGL 如何处理我们的顶点缓存。

现在,我们将要创建一个 顶点着色器 来对顶点数据进行处理。

创建着色器需要使用 着色器语言GLSL(OpenGL Shading Language) 来编写。我们这里写的非常简单。

1
2
3
4
5
6
7
#version 410 core
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

每个着色器都起始于一个版本声明。OpenGL 3.3以及和更高版本中,GLSL版本号和OpenGL的版本是匹配的(比如说GLSL 410版本对应于OpenGL 4.1)。我们同样用 core 明确表示我们会使用核心模式。

下一步,使用 in 关键字,在顶点着色器中声明所有的输入顶点属性(Input Vertex Attribute)。现在我们只关心位置(Position)数据,所以我们只需要一个顶点属性。GLSL有一个向量数据类型,它包含1到4个float分量,包含的数量可以从它的后缀数字看出来。由于每个顶点都有一个3D坐标,我们就创建一个vec3输入变量aPos。我们同样也通过layout (location = 0)设定了输入变量的位置值(Location)。在这里的 0 就是上面我们使用 glVertexAttribPointer() 函数所指定的第一个参数,同时也是 glEnableVertexAttribArray() 所指定的参数。 如果我们需要向顶点着色器传递更多顶点属性,则需要指定不同的位置值。后面我们就会用到。

为了设置顶点着色器的输出,我们必须把位置数据赋值给预定义的gl_Position 变量,它在后台是vec4类型的。在main函数的最后,我们将gl_Position设置的值会成为该顶点着色器的输出。由于我们的输入是一个3分量的向量,我们必须把它转换为4分量的。我们可以把vec3的数据作为vec4构造器的参数,同时把w分量设置为1.0f(我们会在后面解释为什么)。

这样一个程序我们可以将其保存到一个单独的文件,并将其后缀改为 .vs 或者 .vert 文件中,其实后缀并不重要,顶点着色器并没有一个标准的后缀,OpenGL 需要的只是字符串形式的代码。因此,我们可以自己写工具读取文件并存储到字符串当中,或者直接保存到一个字符串变量中。因此我们定义这样一个字符串:

1
2
3
4
5
6
7

// 创建顶点着色器
string vertex_shader_code = "#version 410 core\n"
						"layout (location = 0) in vec3 aPos;\n"
						"void main() {\n"
						"   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
						"}\n";

当前这个着色器程序是最简单的一个顶点着色器的例子了,我们将传递进来的坐标信息原封不动的输出出去了,但在真实程序中,输入数据通常都不是标准化设备坐标,所以我们首先必须先把它们转换至OpenGL的可视区域内。

编译着色器

我们要想使用着色器,必须编译它,我们需要做的第一步是创建一个着色器对象:

1
2
uint32_t vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

glCreateShader() 接受一个着色器类型作为参数。这里我们使用 GL_VERTEX_SHADER 创建顶点着色器。

下一步我们把这个着色器源码绑定到着色器对象上,然后编译它:

1
2
3
const GLchar *source(vertex_shader_code.c_str());
glShaderSource(vertexShader, 1, &source, NULL);
glCompileShader(vertexShader);

glShaderSource 函数把要编译的着色器对象作为第一个参数。第二参数指定了传递的源码字符串数量,这里只有一个。第三个参数是顶点着色器真正的源码,需要 const GLchar* 类型,由于我们的源码使用 string 来保存,所以做一下类型转换,第四个参数我们先设置为NULL。

如果想要判断是否编译成功,可以使用下面的代码来实现:

1
2
3
4
5
6
7
8
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
		glGetShaderInfoLog(vertexShader, sizeof(infoLog), NULL, infoLog);
		cout << "VERTEX SHADER COMPILE ERROR: " << infoLog << endl;
}

首先我们定义了整型变量 success 来表示是否编译成功。使用 glGetShaderiv() 函数获取这一状态,如果失败的话,glGetShaderInfoLog() 可以获取相关错误信息。非常简单,不再细说。

可以编译一下试试。如果没有错误,顶点着色器就创建 OK 了。

片段着色器

为了显示图像,我们还需要最后创建一个 片段着色器,片段着色器定义了最终输出的颜色。在这里,我们直接让所有顶点显示浅蓝色。

在 OpenGL 中使用 RGBA 的模式定义一个颜色,每个颜色分量的取值范围是 0.0~1.0。因此浅蓝色应该用 0.5f, 0.8f, 1.4f, 1.0f 标识。

下面是片段着色器的源码(字符串形式):

1
2
3
4
5
6
// 创建片段着色器
string frag_shader_code = "#version 410 core\n"
				"out vec4 FragColor;\n"
				"void main() {\n"
				"   FragColor = vec4(0.5f, 0.8f, 1.4f, 1.0f);\n"
				"}\n";

片段着色器只需要一个输出变量,这个变量是一个4分量向量,它表示的是最终的输出颜色。我们可以用out关键字声明输出变量,这里我们命名为FragColor。下面,我们将一个alpha值为1.0(1.0代表完全不透明)的浅蓝色的vec4赋值给颜色输出。

编译片段着色器的过程与顶点着色器基本一致,只不过我们使用GL_FRAGMENT_SHADER 常量作为着色器类型:

1
2
3
4
5
uint32_t fragShader;
fragShader = glCreateShader(GL_FRAGMENT_SHADER);
const GLchar *fragSource(frag_shader_code.c_str());
glShaderSource(fragShader, 1, &fragSource, NULL);
glCompileShader(fragShader);

同样,我们可以验证一下编译是否成功:

1
2
3
4
5
6
glGetShaderiv(fragShader, GL_COMPILE_STATUS, &success);
if (!success)
{
		glGetShaderInfoLog(fragShader, sizeof(infoLog), NULL, infoLog);
		cout << "FRAG SHADER COMPILE ERROR: " << infoLog << endl;
}

着色器程序

着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。如果要使用刚才编译的着色器我们必须把它们链接(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。

当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个连接错误。

下面我们创建一个着色器程序:

1
2
3
// 创建着色器程序
uint32_t shaderProgram;
shaderProgram = glCreateProgram();

接下来我们将着色器附加到着色器程序上,并且链接它们。

1
2
3
4
// 链接着色器
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragShader);
glLinkProgram(shaderProgram);

结果校验,校验的结果和上面的编译着色器程序很相似,只不过我们使用 glGetProgramivglGetProgramInfoLog

如下:

1
2
3
4
5
6
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success)
{
		glGetProgramInfoLog(shaderProgram, sizeof(infoLog), NULL, infoLog);
		cout << "SHADER PROGRAM LINK ERROR: " << infoLog << endl;
}

把着色器连接到着色器程序之后就可以删除着色器了。

1
2
3
// 删除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragShader);

当我们需要使用着色器程序的时候,就可以使用 glUseProgram(shaderProgram); 来激活程序。

万众瞩目的三角形!

现在,万事具备了,我们可以渲染三角形了,在我们窗口的渲染循环中加入下面的代码:

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

glDrawArrays 函数第一个参数是我们打算绘制的 OpenGL 图元的类型。我们希望绘制的是一个三角形,这里传递 GL_TRIANGLES 给它。第二个参数指定了顶点数组的起始索引,我们这里填0。最后一个参数指定我们打算绘制多少个顶点,这里是3(我们只从我们的数据中渲染一个三角形,它只有3个顶点长)。

现在编译,你应该看到下面的一个窗口:

你好,三角形

如果你遇到了任何的错误,请参考下面的源码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
#include <iostream>
#include <string>
using namespace std;

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

// 窗口尺寸变化回调函数
void framebuffer_size_callback(GLFWwindow *window, int width, int height);

// 处理用户输入
void process_input(GLFWwindow *window);

int main(int argc, char const *argv[])
{
    // 初始化环境
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    // Mac os
#ifdef __APPLE__
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

    GLFWwindow *window = glfwCreateWindow(800, 600, "Hello OpenGL", NULL, NULL);
    if (window == NULL)
    {
        cout << "Failed to create window" << endl;
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);
    glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

    // 加载 glad
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        cout << "Glad init faild!!!" << endl;
        return -1;
    }

    // 创建顶点着色器
    string vertex_shader_code = "#version 410 core\n"
                                "layout (location = 0) in vec3 aPos;\n"
                                "void main() {\n"
                                "   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
                                "}\n";

    uint32_t vertexShader;
    vertexShader = glCreateShader(GL_VERTEX_SHADER);
    const GLchar *source(vertex_shader_code.c_str());
    glShaderSource(vertexShader, 1, &source, NULL);
    glCompileShader(vertexShader);

    int success;
    char infoLog[512];
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(vertexShader, sizeof(infoLog), NULL, infoLog);
        cout << "VERTEX SHADER COMPILE ERROR: " << infoLog << endl;
    }

    // 创建片段着色器
    string frag_shader_code = "#version 410 core\n"
                              "out vec4 FragColor;\n"
                              "void main() {\n"
                              "   FragColor = vec4(0.5f, 0.8f, 1.4f, 1.0f);\n"
                              "}\n";
    uint32_t fragShader;
    fragShader = glCreateShader(GL_FRAGMENT_SHADER);
    const GLchar *fragSource(frag_shader_code.c_str());
    glShaderSource(fragShader, 1, &fragSource, NULL);
    glCompileShader(fragShader);
    glGetShaderiv(fragShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(fragShader, sizeof(infoLog), NULL, infoLog);
        cout << "FRAG SHADER COMPILE ERROR: " << infoLog << endl;
    }

    // 创建着色器程序
    uint32_t shaderProgram;
    shaderProgram = glCreateProgram();

    // 链接着色器
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragShader);
    glLinkProgram(shaderProgram);
    glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
    if (!success)
    {
        glGetProgramInfoLog(shaderProgram, sizeof(infoLog), NULL, infoLog);
        cout << "SHADER PROGRAM LINK ERROR: " << infoLog << endl;
    }

    // 删除着色器对象
    glDeleteShader(vertexShader);
    glDeleteShader(fragShader);

    // 数据准备
    float vertices[] = {
        -0.5f, -0.5f, 0.0f,
        0.5f, -0.5f, 0.0f,
        0.0f, 0.5f, 0.0f};

    uint32_t VAO, VBO;
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);

    glBindVertexArray(VAO);

    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    // 链接顶点属性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void *)0);
    glEnableVertexAttribArray(0);

    // 渲染循环
    while (!glfwWindowShouldClose(window))
    {
        process_input(window);
        glClearColor(0.3f, 0.3f, 0.3f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

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

        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    glfwTerminate();
    return 0;
}

void framebuffer_size_callback(GLFWwindow *window, int width, int height)
{
    glViewport(0, 0, width, height);
}

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