在OpenGL中,所有要素都是三维的,但屏幕却是二维的,因此在渲染过程中,需要将3D坐标转换为适应屏幕的2D坐标,其处理过程由图形渲染管线
(Graphics Pipeline
)管理。包括将3D坐标转换为2D坐标;将2D坐标转换为实际的有颜色的像素两个部分。(2D坐标是指点在二维空间的位置,而2D像素是这个点的近似值,受屏幕分辨率的限制)。
其中图形渲染管线可以被划分为几个阶段,每个阶段会把前一阶段的输出作为输入。在每个阶段快速处理数据的小程序称之为着色器
(shader)。着色器在GPU中运行,使用OpenGL着色器语言
(OpenGL Shading Language,GLSL)编写。
下图是一个图形渲染管线每个阶段的抽象展示,蓝色部分可以自定义着色器
内容。
首先以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形。这个数组叫做顶点数据
(Vertex Data);顶点数据是一系列顶点的集合。一个顶点
(Vertex)是一个3D坐标,而顶点数据用顶点属性
(Vertex Attribute)表示,可以包含位置和颜色
等信息。在OpenGL渲染过程中,需要指定数据表示的渲染类型,即是点,还是三角形等,即图元
(Primitive),任何一个绘制指令的调用都需要将图元传递给OpenGL。
(1)顶点着色器(Vertex Shader)
作为第一个阶段,将单独的顶点作为输入。顶点着色器运行对顶点属性进行一些基本处理。
(2)图元装配(Primitive Assembly)
将顶点着色器输出的所有顶点作为输入,并将所有的点装配为指定图元的形状。
(3)几何着色器(Geometry Shader)
将图元的顶点集合作为输入,可以通过产生新顶点构造出新的图元来生成其他形状。
(4)光栅格化(Rasterization Stage)
将图元映射为最终屏幕上相应的像素,生成供片元着色器使用的片元(Fragment)
。在片元着色器运行之前会进行裁剪(Clipping),将超出视图范围外的像素丢弃,以提升效率。
(5)片元着色器(Fragment Shader)
用于计算每个像素的最终颜色。
(6)测试和混合(Test and blending)
阶段,用来检测片元的深度,判断物体的前后位置关系。
在OpenGL中,必须至少定义一个顶点着色器和一个片元着色器(GPU中没有默认的),几何着色器可选,通常直接使用默认的。
在开始绘制图形之前,需要先给OpenGL输入一些顶点数据,这些顶点都是3D坐标格式(x,y,z),坐标值在-1.0至1.0范围内才会被处理。即在标准化设备坐标
(Normalized Device Coordinate)范围内的坐标才会最终呈现在屏幕上(在范围外的都不会显示)。
如一个三角形的顶点数组(三个顶点的z坐标均为0,即深度一致):
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
标准化设备坐标:
当顶点坐标在顶点着色器中处理之后,就会形成标准设备坐标(三个坐标值都在-1.0到1.0
之间)。在之后的处理过程中,标准化设备坐标会变换为屏幕空间坐标(Screen-sapce coordinates),即glViewport
函数通过视口变换(Viewport Transform)完成的。得到的屏幕坐标将会被输入到片元着色器中。
顶点数据在输入顶点着色器之前,需要在GPU上存储,通过顶点缓冲对象
(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(显存)中存储大量的顶点,可以一次性将大批量的顶点数据发送到显卡,减少时间消耗,其中CPU和显卡之间的通信较慢,顶点着色器访问显存中的顶点数据非常快。
顶点缓冲对象有唯一的ID,可以使用glGenBuffers
函数和一个ID生成一个VBO对象。
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_STATIC_DRAW
:数据不会或者几乎不会改变GL_DYNAMIC_DRAW
:数据会被改变很多GL_STAREAM_DRAW
:数据每次绘制时都会改变顶点着色器(Vertex Shader)作为可编程的着色器之一,使用着色器语言GLSL(OpenGL Shading Language)编写,如下:
#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
表示OpenGL的版本号及使用核心模式。(对输入数据,只传输未处理)。
in
关键字,在顶点着色器中声明所有的输入顶点属性
(Input Vertex Attribute)。顶点都为3D坐标,因此创建vec3
类型的输入变量aPos
。
顶点着色器需要将数据赋值给预定义的gl_Position
变量(顶点着色器的输出,vec4
类型,w
分量设置为1.0f
)。
首先将顶点着色器编码在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进行引用。将顶点着色器存储为unsigned int
类型,然后使用glCreateShader
创建,参数设置为顶点着色器(GL_VERTEX_SHADER
)
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
然后将着色器源码附加到着色器对象上,进行编译。
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
glShaderSource
函数第一个参数为着色器对象;第二个参数为传递的源码字符串数量;第三个参数是着色器的源码。
在调用glCompileShader
编译着色器后,检查是否编译成功,并输出错误信息。定义一个整型变量用来表示是否编译成功,使用glGetShaderiv
检查是否编译成功,若编译失败,则使用glGetShaderInfoLog
获取错误信息。
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;
}
片元着色器(Fragment Shader)用于计算像素最后的颜色输出,OpenGL中颜色使用vec4
类型表示,即RGBA
,每个分量的值在0.0至1.0
之间。
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
该片元着色器只有一个输出变量,便是最终的输出颜色。声明输出变量使用out
关键字。编译片元着色器的过程与顶点着色器类似,只是需要使用GL_FRAGMENT_SHADER
常量作为着色器类型:
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
当两个着色器都编译完成后,需要将两个着色器对象链接到一个用来渲染的着色器程序(Shader Program)中。
着色器程序对象(Shader Program Object)
是多个着色器合并之后并最终链接的版本。若需要使用刚才编译的着色器,则需要将其链接(Link)
为一个着色器程序对象,然后在渲染对象时激活这个着色器程序。
当链接着色器至一个程序时,它会将每个着色器的输出链接到下个着色器的输入,当输出和输入不匹配时,将会发生连接错误。
创建程序对象:
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glCreateProgram
函数创建一个程序,并返回新创建程序对象的ID引用。并将之前编译的着色器附加到程序对象上,然后使用glLinkProgram
链接。
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
使用glGetProGramiv
和glGetProgramInfoLog
检查链接着色器程序是否成功:
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
...
}
使用glUseProgram
函数激活程序对象:
glUseProgram(shaderProgram);
激活后每个每个着色器的调用和渲染调用都会使用这个程序对象,即将着色器链接到程序对象后,就可以删除原来的着色器对象了。
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
在渲染前,需要指定输入数据中的哪一个部分对应顶点着色器的哪一个顶点属性。
32位(4字节)
浮点值x,y,z共3个值
,且在数组中紧密排列(Tightly Packed)然后可以使用glVertexAttribPointer
函数,用于在OpenGL中解析顶点数据。
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer
函数参数介绍:
顶点属性
,在上文使用了layout(location = 0)定义了Position顶点属性的位置值,因此参数值设置为0。大小
。顶点属性类型是vec3,因此大小是3。类型
,这里是GL_FLOAT(GLSL中vec*都是浮点类型)是否希望数据被标准化
(Normalize),若设置为GL_TRUE,则所有数据会被映射到0-1之间(对于有符号的signed数据是-1至1之间),这里设置为GL_FALSE。步长(Stride)
,即连续的顶点属性组之间的间隔。下组顶点数组在3个float之后,因此步长设置为3*sizeof(float)。也可以设置为0,让OpenGL决定具体步长(只有当数值紧密排列时才能使用)。void*
,它表示未知数据在缓冲中起始位置的偏移量。由于位置在数组的开头,因此设置为0。每个顶点属性从一个VBO管理的内存中获得其数据,在调用glVertexAttribPointer
之前绑定预先定义的VBO对象,顶点属性0会链接到它的顶点数据。
定义了OpenGL如何解释顶点数据后,然后使用glEnableVertexAttribArray
启用顶点属性(默认禁用),即在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();
所有物体绘制都必须重复上述过程,必然会造成繁琐的步骤,因此可以使用顶点数据对象进行优化。
顶点数组对象(Vertex Array Object,VAO)
会将顶点属性的调用存储。当配置顶点属性指针时,只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就可以了。这使得不同顶点数据和属性配置之间的切换非常简单,值需要绑定不同的VAO,VAO中存储了上文设置的所有状态。
一个VAO会存储:
glEnableVertexAttribArray
和glDisplayVertexAttribArray
的调用glVertexAttribPointer
设置的顶点属性配置glVertexAttribPointer
调用与顶点属性关联的顶点缓冲对象unsigned int VAO;
glGenVertexArrays(1, &VAO);
使用glBindVertexArray
绑定VAO之后才能使用。
// ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..
// 1. 绑定VAO
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,在绘制物体过程中绑定相应的VAO,绘制完成后再解绑。
当需要绘制一个矩形时,可以通过绘制两个三角形来组成一个矩形(OpenGL主要处理三角形),则顶点集合如下:
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, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
虽然矩阵只需要4个顶点,但上文顶点集合存储了6个顶点,造成了资源的浪费。更好的解决方案是只存储不同的顶点,并设定这些顶点的绘制顺序,即索引缓冲对象(Element Buffer Object,EBO)
。
EBO专门存储索引,OpenGL调用这些顶点的索引来决定该绘制哪个点,即索引绘制(Index Drawing)
。即首先定义不重复的顶点和绘制索引:
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开始!
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
上文只定义了4个顶点,然后需要创建索引缓冲对象:
unsigned int EBO;
glGenBuffers(1, &EBO);
然后绑定EBO,并把索引复制到缓冲里,类型设置为GL_ELEMENT_ARRAY_BUFFER
。
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
最后使用glDrawElements
替换glDrawArrays
函数,即从索引缓冲渲染。第一个参数指定了绘制的模式;第二个参数指定了绘制顶点的数量;第三个参数是索引的类型,这里是GL_UNSIGNED_INT
;最后一个参数指定EBO中的偏移量。
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glDrawElements
函数从当前绑定到GL_ELEMENT_ARRAY_BUFFER
目标的EBO中获取索引。即在每次使用索引渲染时需要绑定相应的EBO,不过顶点数组对象同样可以保存索引缓冲对象的绑定状态。VAO绑定时正在绑定的索引缓冲对象会被保存为VAO的元素缓冲对象,绑定VAO时会自定绑定EBO。
当目标是GL_ELEMENT_ARRAY_BUFFER
时,VAO会存储glBindBuffer
的函数调用,即也会存储解绑调用,所以确保不能在解绑VAO之前解绑EBO,否则会没有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);