一、图形渲染管线
图形渲染管线(Graphics Pipeline,实际上指的是一堆原始图形数据途径一个输送管道,期间经过各种处理最终出现在屏幕的过程)接受一组3D坐标,然后转变为屏幕上的有色2D像素输出。图形渲染管线可以被分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。在每个阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据,这些小程序叫做着色器(Shader)。
下图是一个图形渲染管线的每个阶段的抽象展示,蓝色部分代表的是可以注入自定义的着色器:
- 第一部分是顶点着色器(Vertex Shader),主要的目的是把3D坐标转为另一种3D坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理;
- 图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入,并将所有的点装配成指定的图元形状;
- 几何着色器(Geometry Shader)把图元形式的一系列顶点的集合作为输入,可以通过产生新顶点构造出新的图元来生成其他形状;
- 光栅化阶段(Rasterization Stage)会把图元映射为最终屏幕上相应的像素,生成片段着色器使用的片段。在片段着色器运行之前会执行裁切,裁切会丢弃超出视图以外的所有像素,用来提升执行效率。
- 片段着色器(Fragment Shader)主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等),这些数据被用来计算像素最终的颜色。
- 测试与混合(Tests And Blending)阶段检测片段对应的深度值,用它们来判断这个像素是在其它物体的前面还是后面,决定是否应该丢弃;也会检查alpha值(alpha值定义一个物体的透明度)并对物体进行混合。
图形渲染管线非常复杂,它包含很多配置的部分,然而,大多数场合,我们只需要配置顶点和片段着色器就行;几何着色器是可选的,通常使用它的默认着色器就行。
二、绘制三角形
先编写一个绘制三角形的程序,然后对程序进行讲解。更改上一节程序中的myopenglwidget.cpp文件中的代码如下:
#include "myopenglwidget.h"
#include <QDebug>
//全局的顶点数组对象
unsigned int VAO;
//全局的着色器程序
unsigned int shaderProgram;
MyOpenGLWidget::MyOpenGLWidget(QWidget *parent) : QOpenGLWidget(parent)
{
}
void MyOpenGLWidget::initializeGL()
{
//初始化OpenGL函数
initializeOpenGLFunctions();
//1.创建VBO,并赋予ID
unsigned int VBO;
glGenBuffers(1, &VBO);
//绑定VBO对象
glBindBuffer(GL_ARRAY_BUFFER, VBO);
//顶点数据
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
//把顶点数据复制到显存中
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
//2.创建VAO对象,并赋予ID
glGenVertexArrays(1, &VAO);
//绑定VAO对象
glBindVertexArray(VAO);
//告知显卡如何解析缓冲里的属性值
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
//开启VAO管理的第一个属性值
glEnableVertexAttribArray(0);
//解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
//解绑VAO
glBindVertexArray(0);
//3.创建顶点着色器并赋予ID
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
//顶点着色器程序
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"
"}\n";
//将着色器源码附加到着色器对象上,然后编译它
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
//如下代码检测glCompileShader是否编译成功;如果编译失败,打印编译错误信息
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if(!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
qDebug() << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog;
}
//4.创建片段着色器并赋予ID
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
//片段着色器程序
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";
//将着色器源码附加到着色器对象上,然后编译它
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
//检测glCompileShader是否编译成功;如果编译失败,打印编译错误信息:
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if(!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
qDebug() << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog;
}
//5.创建一个程序对象,并分配ID;
shaderProgram = glCreateProgram();
//着色器附加到程序对象上,然后用glLinkProgram链接它们:
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
//检测链接着色器是否成功:
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success)
{
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
qDebug() << "ERROR::PROGRAM::COMPILATION_FAILED\n" << infoLog;
}
//删除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
}
void MyOpenGLWidget::resizeGL(int w, int h)
{
Q_UNUSED(w);
Q_UNUSED(h);
}
void MyOpenGLWidget::paintGL()
{
//设置墨绿色背景
glClearColor(0.2f, 0.3f, 0.3f, 1.0f); //状态设置
glClear(GL_COLOR_BUFFER_BIT); //状态使用
//6.绘制三角形
glUseProgram(shaderProgram); //激活程序对象
glBindVertexArray(VAO); //绑定VAO
glDrawArrays(GL_TRIANGLES, 0, 3); //绘图
}
运行结果如下:
三、顶点缓冲对象(Vertex Buffer Objects, VBO)
OpenGL是一个3D图形库,指定的所有坐标都是3D坐标(x、y和z)。标准化设备坐标(Normalized Device Coordinates,NDC):是一个x,y,z值在-1.0和1.0的一小段空间,此范围内的坐标最终显示在屏幕上。
我们希望渲染一个三角形,需要指定三个顶点,每个顶点都有一个3D位置,为了方便将以标准化设备坐标的形式定义一个float数组:
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
为了渲染一个2D三角形,将顶点的z坐标设置为0.0,这样三角形每个点的深度(Depth,通常深度可以理解为z轴,代表一个像素在空间中和你的距离)都是一样的,从而是它看起来像是2D三角形。
使用顶点缓冲对象是可以一次性的发送大批数据到显存上。从CPU把数据发送到显卡上相对较慢,所以我们要尝试一次性发送尽可能多的数据。当数据发送至显存后,顶点着色器几乎能立即访问到顶点数据。
使用glGenBuffers函数和一个缓冲ID生成一个VBO对象:
unsigned int VBO;
glGenBuffers(1. &VBO);
OpenGL有多种缓冲对象类型,OpenGL 允许我们同时绑定多个不同缓冲出类型的缓冲对象。顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER,可以使用glBindBuffer函数把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上:
glBindBuffer(GL_ARRAY_BUFFER, VBO);
使用glBindBuffer函数把新创建的缓冲(VBO)绑定到GL_ARRAY_BUFFER上之后,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲都会用来配置当前绑定的缓冲(VBO)。然后我们调用glBufferData函数,它会把之前定义的顶点数据复制到显存中:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData是一个专门用来把用户定义的数据复制到绑定缓冲的函数。它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位):用一个简单的sizeof计算出顶点数据的大小就行。第三个参数是我们希望发送的实际数据。
第四个参数指定我们希望显卡如何管理给定的数据。它有三种形式:
- GL_STATIC_DRAW:数据不会或几乎不会改变;
- GL_DYNAMIC_DRAW:数据会被改变多次:
- GL_STREAM_DRAW:数据每次绘制时都会改变。
三角形的位置数据不会改变,每次渲染调用时保持原样,所以它的使用类型最好是GL_STATIC_DRAW。如果,一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW或GL_STREAM_DRAW,这样就能够确保显卡把数据放在能够高速写入的内存部分。
四、顶点数组对象(Vertex Array Object,VAO)
使用顶点数组对象向OpenGL解释该如何使用顶点数据;
创建一个VAO:
unsigned int VAO;
glGenVertexArray(1, &VAO);
要想使用VAO,只需要使用glBindVertexArray绑定VAO:
glBindVertexArray(VAO);
顶点着色器允许我们指定任何以顶点属性为形式的输入,这使其具有很强的灵活性的,但是必须手动指定输入数据的哪一部分对应顶点着色器的哪一个顶点属性,我们必须在渲染前指定OpenGL该如何解释顶点数据。
我们的顶点缓冲数据会被解析为下面这样子:
- 位置数据被存储为32位(4字节)浮点值;
- 每个位置包含3个这样的值;
- 在这3个值之间没有空隙(或其他值),这几个值在数组中紧密排列;
- 数据中第一个值在缓冲开始的位置。
有了这些信息我们就可以告诉OpenGL该如何解析顶点数据了:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0); //开启VAO管理的第一个属性值
glVertexAttribPointer函数中每个参数含义如下:
- 第一个参数指定我们要配置的顶点属性。在顶点着色器中使用layout(location = 0)把顶点属性的位置设置为0,我们希望把数据传递到这个顶点属性中,所以我们这里传入0;
- 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由三个值组成,所以大小是3;
- 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的);
- 第四个参数定义我们是都希望数据被标准化。如果我们设置为GL_TRUE,所有数据都会被映射到-1到1之间。我们已经设置为-1到1之间了所以设置为GL_FALSE;
- 第五个参数叫做步长,它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3*sizeof(float)。
- 最后一个参数类型是void*,它表示位置数据在缓冲中起始位置的偏移量。由于位置数据在数组的开头,所以这里是0。
使用一个顶点缓冲对象将顶点数据初始化至缓冲中,使用一个顶点数组对象告诉OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。
使用下面的两个函数来分别解绑VBO和VAO,因为顶点数据已经复制到了显存中,所以不在需要VBO了,解绑VAO是因为暂时用不到VAO了。在paintGL函数绘图之前再次绑定VAO就可以绘制了,所以为了能再次使用VAO,在myopenglwidget.cpp文件中VAO被定义成了全局变量。
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
五、顶点着色器
使用着色器语言(OpenGL Shading Language,GLSL)编写顶点着色器,然后编译着色器,这样就可以在程序中使用它了。下面是一个非常基础的顶点着色器源代码:
#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
第一行是版本声明。OpenGL3.3以及和更高版本中,GLSL版本号和OpenGL的版本号是匹配的(比如,GLSL330版本对应于OpenGL3.3)。
第二行,使用in关键字,在顶点着色器中声明所有的输入顶点属性(Input Vertex Attribute)。每个顶点都有一个3D坐标,所以创建一个vec3类型的输入变量aPos。通过layout (location = 0)设定输入变量的位置值。
为了设置顶点着色器的输出,必须把位置数据赋值给预定义的gl_Position变量,它是vec4类型的,由于我们输入的是一个3分量的向量,我们必须把它转换为4分量的,可以把vec3的数据作为vec4的前三个参数,再把第四个参数(w分量)设置为1.0f。
创建一个顶点着色器对象,并分配ID;GL_VERTEX_SHADER为顶点着色器类型:
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_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"
"}\n";
下一步,将着色器源码附加到着色器对象上,然后编译它
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
glShaderSource函数把要编译的着色器对象作为第一个参数;第二个参数指定了传递的源码字符串数量,这里只有一个;第三个参数是顶点着色器的源码;第四个参数先设置为NULL;
使用如下代码检测glCompileShader是否编译成功;如果编译失败,打印编译错误信息:
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if(!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
qDebug() << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog;
}
六、片段着色器
片段着色器所做的是计算像素最后的颜色输出。为了让事情更简单,我们的片段着色器将会一直输出橘黄色。
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
片段着色器只需要一个输出变量,这个变量是一个4分量向量(RGBA,红色、绿色、蓝色和alpha(透明度)),它表示的是最终的输出颜色,我们应该自己将其计算出来。声明输出变量可以使用out关键字,这里我们命名为FragColor,并给它赋值一个透明度为1.0(1,0代表完全不透明)的橘黄色颜色的vec4。
编译片段着色器的过程与顶点着色器类似,只是使用GL_FRAGMENT_SHADER常量作为着色器类型:
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
使用如下代码检测glCompileShader是否编译成功;如果编译失败,打印编译错误信息:
if(!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
qDebug() << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog;
}
两个着色器都编译完了,剩下的事情就是把两个着色器对象链接到一个用来渲染的着色器程序中。
七、着色器程序
着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。如果要使用刚才编译的着色器我们必须把它们链接为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。
当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个链接错误。
创建一个程序对象,并分配ID(paintGL函数中也需要使用shaderProgram,所以shaderProgram定义成全局变量):
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glCreateProgram函数创建一个程序,并返回新创建程序对象的ID引用。现在我们需要把之前编译的着色器附加到程序对象上,然后用glLinkProgram链接它们:
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
检测链接着色器是否成功:
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success)
{
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
qDebug() << "ERROR::PROGRAM::COMPILATION_FAILED\n" << infoLog;
}
激活程序对象(initializeGL函数中不需要这行代码,在paintGL函数中绘图之前使用这行代码)
glUseProgram(shaderProgram);
在把着色器对象链接到程序对象以后,记得删除着色器对象,我们不再需要它们了:
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
如上内容initializeGL函数中的初始化准备工作已经完成,下一步开始绘制三角形。
八、绘制三角形
OpenGL提供了glDrawArray函数,它使用当前激活的着色器、之前定义的顶点属性和VBO的顶点数据(通过VAO间接绑定)来绘制图元。
在paintGL函数中使用如下代码
glUseProgram(shaderProgram); //激活程序对象
glBindVertexArray(VAO); //绑定VAO
glDrawArrays(GL_TRIANGLES, 0, 3); //绘图
glDrawArrays函数第一个参数是绘制的OpenGL图元的类型,由于我们打算绘制的是三角形,所以这里使用GL_TRIANGLES;第二个参数指定了顶点数组的起始索引;第三个参数指定我们打算绘制多少个顶点。
注:观看OpenGL中文官网(https://learnopengl-cn.github.io/)和阿西拜的现代OpenGL入门(https://ke.qq.com/course/3999604#term_id=104150693)学习OpenGL