一、前言
在OpenGL中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组。3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线管理的。
图形渲染管线宏观方面:由两个主要部分组成,第一部分3D坐标转变为2D坐标,第二部分把2D坐标转变为实际的有颜色的像素。
图形渲染管线微观方面:被划分为几个阶段(如下图):每个阶段的输出作为下个阶段的输入,它们在GPU上为每个渲染阶段运行各自的小程序也称着色器
下面将会对各个阶段进行括性地解释一下,让你对图形渲染管线的工作方式有个大概了解(对应开发者来说,如上图中的绿色部分有对外提供灵活性的API,其它阶段类似于固定渲染管线编程)。在现代OpenGL中,我们必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器,其它阶段都有默认值)。在开始讲解各个阶段前说明下:首先,我们要有一个通识,在OpenGL中,图形的渲染都是由点、线、三角形(Android中)、四边形、多边形等拼凑出来的,好,下面开始BB
二、顶点数据
以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形,这个数组叫做顶点数据;顶点数据是一系列顶点的集合。一个顶点是一个3D坐标的数据的集合。而顶点的数据是用顶点属性表示的,它可以包含任何我们想用的数据,但是简单起见,我们还是假定每个顶点只由一个3D位置和一些颜色值组成的吧。
float vertices[] = {
//x, y, z, r, g, b, a
-0.5f, -0.5f, 0.0f, 123f, 234f, 132f, 0.5f
0.5f, -0.5f, 0.0f, 123f, 234f, 132f, 0.5f
0.0f, 0.5f, 0.0f, 123f, 234f, 132f, 0.5f
};
OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它最终呈现在屏幕上(标准化设备坐标),标准化设备坐标(如下图)接着会变换为屏幕空间坐标(其实中间还有各种坐标的换算),这是使用你通过glViewport函数提供的数据,进行视口变换完成的(后续博客说明OpenGL中的各个坐标空间,不急先往下看有个概念先)
三、顶点着色器
它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理。它会在GPU上创建内存用于储存我们的顶点数据,还要配置OpenGL如何解释这些内存,并且指定其如何发送给显卡。顶点着色器接着会处理我们在内存中指定数量的顶点。我们通过顶点缓冲对象(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存中储存大量顶点。
1.下面通过代码来说明下该过程:顶点着色器输入
//使用glGenBuffers函数和一个缓冲ID生成一个VBO对象
unsigned int VBO;
glGenBuffers(1, &VBO);
//把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上(之后的所有的配置都会绑定到目标GL_ARRAY_BUFFER对应的VBO上)
glBindBuffer(GL_ARRAY_BUFFER, VBO);
//把之前定义的顶点数据复制到缓冲的内存中
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData函数说明:第一个参数是目标缓冲的类型;第二个参数指定传输数据的大小(以字节为单位);第三个参数是我们希望发送的实际数据;第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
- GL_STATIC_DRAW :数据不会或几乎不会改变。
- GL_DYNAMIC_DRAW:数据会被改变很多。
- GL_STREAM_DRAW :数据每次绘制时都会改变。
//告诉OpenGL该如何解析顶点数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
//启用顶点属性,顶点属性默认是禁用的
glEnableVertexAttribArray(0);//这里的0是layout (location = 0)中的值
glVertexAttribPointer函数说明(结合上图说明理解):
- 第一个参数指定我们要配置的顶点属性。看***3.2***如下代码片段中顶点着色器中使用
layout(location = 0)
定义了position顶点属性的位置值(Location),它可以把顶点属性的位置值设置为0
。因为我们希望把数据传递到这一个顶点属性中,所以这里我们传入0
。 - 第二个参数指定顶点属性的大小。顶点属性是一个
vec3
,它由3个值组成,所以大小是3。 - 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中
vec*
都是由浮点数值组成的)。 - 第四个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
- 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个
GLfloat
之后,我们把步长设置为3 * sizeof(GLfloat)
。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。 - 最后一个参数的类型是
GLvoid*
,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。我们会在后面详细解释这个参数。
2.下面定义一个简单的 GLSL顶点着色器 (vertexShaderSource):
#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
3.编译着色器
unsigned int vertexShader;
//创建一个着色器对象,用ID引用
vertexShader = glCreateShader(GL_VERTEX_SHADER);
//第一个参数要编译的着色器对象;第二参数指定了传递的源码字符串数量,这里只有一个。第三个参数是顶点着色器真正的源码(如上第二步)
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
//编译着色器
glCompileShader(vertexShader);
//检测编译着色器是否成功
GLint success;
GLchar 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;
}
顶点数组对象(VAO)
如上过程绑定缓冲对象,若为每个物体配置所有顶点属性是一个麻烦的事,每当我们绘制一个物体的时候都必须重复这一过程。为此OpenGL的核心模式要求我们使用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(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
//4. 解绑VAO
glBindVertexArray(0);
// ..:: 绘制(游戏循环中) :: ..
[...]
// 5. 绘制物体
glUseProgram(shaderProgram);//下面会讲
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();
glBindVertexArray(0);
四、图元装配
将顶点着色器输出的所有顶点作为输入,并所有的点装配成指定图元的形状。为了让OpenGL知道我们的坐标和颜色值构成的到底是什么,OpenGL需要你去指定这些数据所表示的渲染类型。我们是希望把这些数据渲染成一系列的点?一系列的三角形?还是仅仅是一个长长的线?做出的这些提示叫做图元,任何一个绘制指令的调用都将把图元传递给OpenGL。这是其中的几个:GL_POINTS、GL_TRIANGLES、GL_LINE_STRIP
五、几何着色器
几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状(类似于数学中的几何变换操作)。
六、光栅化阶段
它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。
七、片段着色器
OpenGL中的一个片段是OpenGL渲染一个像素所需的所有数据。片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。
1.定义片段着色器
#version 330 core
out vec4 color;
void main()
{
color = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
片段着色器只需要一个输出变量,这个变量是一个4分量向量,它表示的是最终的输出颜色
2.编译片段着色器
GLuint fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, null);
glCompileShader(fragmentShader);
与顶点着色器类似,只不过我们使用GL_FRAGMENT_SHADER常量作为着色器类型
3.链接生成程序对象
//1、创建程序对象
GLuint shaderProgram;
shaderProgram = glCreateProgram();
//2、把之前编译的着色器附加到程序对象上
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
//3、链接
glLinkProgram(shaderProgram);
//4、检查是否链接成功
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
...
}
//5、激活程序对象
glUseProgram(shaderProgram);
//6、删除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
到此,我们已经把输入顶点数据发送给了GPU,并指示了GPU如何在顶点和片段着色器中处理它
八、Alpha测试和混合
这个阶段检测片段的对应的深度(和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。
// Setup OpenGL options
glEnable(GL_DEPTH_TEST);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
参考文献
LearnOpenGL中文官网