背后发生了什么
OpenGL的渲染管线能够将3D空间中的物体渲染在2D屏幕上,总体来说,渲染管线会先把3D坐标转化为2D坐标,再将2D坐标转化为屏幕上某个位置的像素。换句话说,渲染管线接受一系列顶点的数据,并最终为屏幕中的像素填充颜色。
进一步讲,渲染管线可以分为数个连续的阶段,每个阶段从上一个阶段获取输入,执行一段高效的、较简短的代码,并将结果输出给下一个阶段。这一过程很适合并行化,即将这些短代码交给一个个GPU的处理单元,使得屏幕上的大量像素颜色可以大规模并行地被计算。这些短代码被称为着色器,它们是使用OpenGL着色器语言GLSL写成的(SL指Shading Language)。OpenGL渲染管线的各个阶段如下图所示,其中标蓝的阶段的着色器可以被开发者自定义。
顶点数据与缓冲
在图示中,三个顶点的数据(Vertex Data)被传入顶点着色器(Vertex Shader)。顶点不仅可以有位置属性,还可以有颜色、法线等各种可能用到的属性。
OpenGL所接受的顶点坐标并不是任意的,它xyz坐标必须都在-1.0~1.0之间。因此顶点着色器会将xyz坐标空间转化为OpenGL接受的坐标空间。这种坐标被称为标准化设备坐标(Normalized Device Coordinates, NDC)。例如,下面的三角形就处于标准化设备坐标空间中:
// 简便起见,z坐标(深度)被统一设为0,
// 使得三角形所在平面与屏幕平行
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
前面提到,着色器程序是由GPU来运行的,因此我们要把顶点数据传入CPU中。我们需要在GPU中创建内存,配置OpenGL如何解释这些内存,并且将其发送到显卡。但是CPU发送数据到GPU是一个相对较慢的过程,最好能够一次发送大量数据,而不是少量多次地发送。我们通过VBO(Vertex Buffer Objects,顶点缓冲对象)来实现这件事。
unsigned int VBO;
// 创建缓冲
glGenBuffers(1, &VBO);
// 绑定缓冲
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 传入数据
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glGenBuffers()
的第一个参数是创建缓冲的个数,第二个参数是用来存储缓冲ID的数组(此处只有一个缓冲所以可以使用变量)。这个函数并不真正地声明一块内存,而是类似于给缓冲起一个名字,或是声明一个将被分配内存的缓冲指针。
在解释glBindBuffer()
前,有必要了解一下OpenGL使用这些缓冲的方式。OpenGL有许多种目标,在渲染管线中扮演着各种各样的角色。例如,GL_ARRAY_BUFFER
被OpenGL定义为用于存储顶点数据的缓冲区,GL_UNIFORM_BUFFER
用于存储着色器需要使用的统一变量等。这些目标是逻辑上的,而不是真正的内存空间。它们是用于指定对象用途的绑定点。例如我们通过glGenBuffers()
声明一个叫做buf
的缓冲区对象,然后通过glBindBuffer(GL_ARRAY_BUFFER, buf)
将其绑定到GL_ARRAY_BUFFER
目标,那么此时buf
就是用来存储顶点数据的VBO,在渲染管线中向VBO传入顶点数据时,就会传入到buf
中;需要获取顶点数据的时候,就会从此时与GL_ARRAY_BUFFER
绑定的对象,即buf
中获取。
因此我们可以理解,为什么OpenGL只允许一个对象绑定到某个目标上,否则在输入/输出时就无法确定输入到哪个对象中。但是OpenGL允许多个目标同时被(不同的对象)绑定,并且对象之间互不干扰。
glBindBuffer()
用于绑定缓冲对象(即缓冲的ID)到目标(即第一个参数target)。在上述代码中,就是把VBO绑定到GL_ARRAY_BUFFER
这个目标上。之后在向GL_ARRAY_BUFFER
这个目标中传入数据的时候,就会自动为绑定的缓冲区对象在GPU内存中分配空间并存储数据。
glBufferData()
用于向任何类型的缓冲区中传入数据。从代码中可以看到其第一个参数并不是某个缓冲区对象,而是一个目标。第二个参数以字节为单位指定输入数据的大小,第三个则是指向数据的指针。第四个参数指定了我们希望显卡如何管理输入的数据。它可以是:
GL_STATIC_DRAW
:数据几乎不会被改变(类似于静态图片)GL_DYNAMIC_DRAW
:数据会被大幅度改变(类似于幻灯片)GL_STREAM_DRAW
:数据每一帧都会改变(类似于视频)
显卡将会根据我们指定的处理方式选择合适的内存管理策略。如果数据会被非常频繁地改变,显卡可能就会将其存储在读写速度快的区域。
顶点着色器
要使用OpenGL渲染,至少要有一个顶点着色器和一个片段着色器。以下是一个简单的顶点着色器的代码:
// 声明版本
#version 330 core
// 绑定位置(将vec3类型的变量aPos绑定到位置0)
layout (location = 0) in vec3 aPos;
void main()
{
// 将一个3D vector转变为4D vector(其实约等于什么都没做)
// 在实际操作中还需要先转化为标准化设备坐标
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
GLSL的语法类似于C,它将会在运行时被动态编译。而gl_Position
是一个OpenGL的内置变量,由OpenGL预定义,专门用于顶点着色器输出顶点的最终位置。它的存在和用途是固定的,开发者无需手动声明,直接赋值即可。类似的预定义变量还有gl_Pointsize
等。简便起见,我们可以先把这段代码硬编码在代码的开头,并使用它创建着色器对象。
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";
...
unsigned int vertexShader;
// 类似glCreateBuffers
vertexShader = glCreateShader(GL_VERTEX_SHADER);
// 将源代码注入着色器对象中
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
// 编译着色器
glCompileShader(vertexShader);
...
glShaderSource()
是从源代码创建着色器的函数,它支持一次性获取多个字符串,并将它们拼接成一份着色器代码。第一个参数是着色器对象,第二个参数是将要传入的源代码字符串的数量,第三个参数是一个字符串数组的地址,存储若干源代码片段。第四个参数则是指向整数数组的指针,用于指定每一个字符串的长度。这样设计的原因是源代码中可能存在\0
这样的字符,此时则需要显式地指定每个字符串的长度防止截断;使用NULL
则默认整个着色器代码以\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"
"}\0";
...
// 创建着色器对象
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
// 注入源代码
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
// 编译
glCompileShader(fragmentShader);
...
从源代码中可以看到,OpenGL的颜色是RGBA格式,即最后一个参数是透明度。
链接着色器程序——管线的组装
着色器代码编译后需要链接到一个着色器程序,它可以在渲染过程中被激活并运行。着色器程序会把每一个链接到它的着色器在依次相连(即将前一个着色器的输出作为下一个着色器的输入)。着色器程序也是通过创建对象来做的,在附着并连接好之后,着色器对象就可以删除了,只需要保留着色器程序对象。
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
...
// 附加并链接着色器对象
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
// 激活着色器程序对象
glUseProgram(shaderProgram);
// 删除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
...
链接顶点属性——对准填料口
我们知道,OpenGL获取数据的来源是目标而不是对象。但是顶点可能有很多不同的属性,这时OpenGL并不知道如何解析这些数据(例如一个顶点有xyz三个位置坐标和RGB三个颜色属性,这时候OpenGL并不知道前三个是位置后三个是颜色),需要手动指定:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
// 顶点属性默认是禁用的,需要手动启用
glEnableVertexAttribArray(0);
glVertexAttribArray()
用于将顶点属性绑定到着色器,即把VBO和着色器联系起来。它的参数如下所示:
void glVertexAttribPointer(
GLuint index, // 属性索引(对应着色器中的 location=0)
GLint size, // 每个顶点的分量数量(x, y, z)
GLenum type, // 数据类型
GLboolean normalized, // 是否标准化到[-1,1]
GLsizei stride, // 步长(到下一个顶点的字节数)
const void *pointer // 偏移量(位置数据的起始位置)
)
index
:记得我们在GLSL的顶点着色器开头有一行layout (location = 0) in vec3 aPos;
,这一行是用来指定输入数据的分布的。每个location相当于顶点着色器的一个输入口,将顶点属性绑定到输入口,之后在使用时则直接使用location来索引。这样设计的好处是可以减少用属性名进行索引的开销,并且可以解耦属性与着色器代码,使操作更灵活,代码复用率更高。size
、type
、stride
、pointer
:这四个参数用于从可能很复杂的顶点属性中分割出当前location需要绑定的数据。如果使用了VBO,pointer
就是当前所需的数据在一个顶点属性中的偏移量,size
和type
共同决定从pointer
开始要获取多少字节的数据,stride
则指明了到下一个顶点属性的同一个位置需要的字节数。
与现在正在写的绘制三角形代码不同,假定我们的每个顶点有xyz
三个位置数据和RGB
三个颜色属性:
float vertices[] = {
// 位置 (x, y, z) // 颜色 (r, g, b)
0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 顶点1
-0.5f,-0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 顶点2
0.5f,-0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 顶点3
};
在着色器代码中,我们指定两个属性,即位置和颜色:
// 顶点着色器
layout(location = 0) in vec3 aPos; // index=0 对应位置
layout(location = 1) in vec3 aColor; // index=1 对应颜色
在主代码中,我们通过glVertexAttribPointer()
将顶点属性与着色器绑定并启用:
// 配置位置属性(location=0)
glVertexAttribPointer(
0, // index = 0
3, // size = 3(xyz)
GL_FLOAT, // type = float
GL_FALSE, // 不标准化
6 * sizeof(float), // stride = 6个float的字节数
(void*)0 // offset = 0(位置从开头开始)
);
glEnableVertexAttribArray(0); // 启用 location=0
// 配置颜色属性(location=1)
glVertexAttribPointer(
1, // index = 1
3, // size = 3(rgb)
GL_FLOAT, // type = float
GL_FALSE, // 不标准化
6 * sizeof(float), // stride = 6个float的字节数
(void*)(3 * sizeof(float)) // offset = 前3个float的字节数(跳过位置)
);
glEnableVertexAttribArray(1); // 启用 location=1
这些数据配置规则将会被记录在VAO(VertexArrayObject,顶点数组对象)中,使得数据配置规则也可以灵活切换。
上图显示了我们如何使用glVertexAttribPointer()
设置数据配置的规则。这些规则(即glVertexAttribPointer()
的参数等)需要被记录在VAO中,以便之后的配置切换。声明并绑定VAO的操作与VBO很相似,绑定了VAO后就可以在渲染循环中使用着色器程序来绘制图形了!(注意,即使我们只有一套数据配置规则,也必须存储在VAO中,否则OpenGL将不会绘制任何东西):
unsigned int VAO;
glGenVertexArrays(1, &VAO);
// 绑定VAO
glBindVertexArray(VAO);
// ...设置顶点属性指针(使用glVertexAttribPointer)
// 绘制代码(渲染循环中)
// 绘制物体
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
// 绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3);
万事俱备,编译运行
glDrawArrays()
函数用来基于顶点数组绘制图形,第一个参数是图元类型,第二个参数是从顶点数组的第几个顶点开始绘制,第三个参数是绘制几个顶点。编译运行后我们将得到以下结果:
添加更多的三角形
我们成功地绘制出了一个最基本的三角形。现在我们可以考虑多加入几个三角形。但是如果有两个三角形有公共顶点,我们将会重复声明这个顶点。3D模型被分解为三角形后,将会有大量的公共顶点,导致很大的空间浪费。但是如果在顶点数组中去除重复顶点,又会导致glDrawArrays()
无法画出我们需要的三角形。因此我们需要把图形信息提取出来,用一个额外的缓冲区来指定哪几个顶点形成一个三角形,重复利用顶点即可绘制有公共顶点的三角形,对应使用的绘制函数也从glDrawArrays()
变为glDrawElements()
。这个缓冲区叫做EBO(Element Buffer Object,元素缓冲对象),对应的目标是GL_ELEMENT_ARRAY_BUFFER
。EBO与VBO的创建和使用是类似的:
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, 1, 3, // 第一个三角形由索引为0、1、3的三个顶点组成
1, 2, 3 // 第二个三角形由索引为1、2、3的三个顶点组成
};
...
unsigned int EBO;
glGenBuffers(1, &EBO);
// 将缓冲区对象绑定到目标并传入数据
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
...
// 渲染循环中
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
// 使用glDrawElements代替glDrawArrays
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
编译运行后,我们可以得到这样的矩形:
我们也可以使用glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
和glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
来切换线框模式和填充模式。线框模式下我们可以更清楚地看到两个三角形: