🔥C++和OpenGL实现3D游戏编程【目录】
1、本节实现的内容
上一节我们介绍了通过VBO、VAO和EBO怎样将顶点发送到GPU显存,利用GPU与显存之间的高效处理速度,来提高我们的图形渲染效率。那么在此过程中,我们又可以通过着色器(shader)进行可编程管线操作,极大的增加了编程自由度,那么我们就来了解一下着色器相关知识,并通过模型视图矩阵以及投影矩阵进行顶点变换,完成首次MVP操作。下图就是一个通过着色器(shader)显示的立方体。
2、着色器的重要性
OpenGL进化自SGI的早期3D接口IRIS GL。1992年7月,SGI公司发布了 OpenGL的1.0版本。微软在1995年发布Direct3D,Direct 3D最终成为OpenGL的主要竞争对手。2002年微软的DirectX 9提出了全新的Shader绘图功能以及高端着色语言(HLSL),OpenGL霸主地位开始被瓦解。这使得OpenGL官方了解到必须开发全新的OpenGL 2.0版本,最终加入支持着色器语言(GLSL),实现可编程着色,提供了巨大的灵活性,可以实现各种各样丰富的效果。随后,2008年和2010年推出OpenGL 3.0和4.0版本,截止目前2024年是4.6版本。在整个版本更迭过程中,着色器逐步成为OpenGL编程必不可缺少的部分。
2、核心模式与立即渲染模式(Core-profile vs Immediate mode)
早期的OpenGL使用立即渲染模式(Immediate mode,也就是固定渲染管线),这个模式下绘制图形很方便。OpenGL的大多数功能都被库隐藏起来,开发者很少有控制OpenGL如何进行计算的自由。而开发者迫切希望能有更多的灵活性。随着时间推移,规范越来越灵活,开发者对绘图细节有了更多的掌控。立即渲染模式确实容易使用和理解,但是效率太低。因此从OpenGL3.2开始,规范文档开始废弃立即渲染模式,并鼓励开发者在OpenGL的核心模式(Core-profile)下进行开发,这个分支的规范完全移除了旧的特性。
当使用OpenGL的核心模式时,OpenGL迫使我们使用现代的函数。现代函数的优势是更高的灵活性和效率,然而也更难于学习。立即渲染模式从OpenGL实际运作中抽象掉了很多细节,因此它在易于学习的同时,也很难让人去把握OpenGL具体是如何运作的。现代函数要求使用者真正理解OpenGL和图形编程,它有一些难度,然而提供了更多的灵活性,更高的效率,更重要的是可以更深入的理解图形编程。但要更好的进行核心模式编程,那就必须得了解并运用着色器。
3、什么是着色器
OpenGL着色器(Shader)是用着色器语言(OpenGL Shading Language, GLSL)写的,是一种在图形渲染管线中用于执行特定渲染计算的小程序。当今大多数显卡都有成千上万的小处理核心,我们可以简单的把这些小处理核心在进行图形渲染过程中运行的各阶段统称为一个个管线。着色器是为每一个渲染管线的各个阶段运行的独立的小程序,能在图形渲染管线中快速处理数据。那我们来深入了解一下图形渲染的基本过程和可编程渲染管线的概念。
4、图形渲染管线
图形渲染管线(Graphics Rendering Pipeline)是一个抽象概念,是一系列阶段,也就是一个用于将3D场景中的几何数据转换为最终的在电脑屏幕上显示的2D图像流程,注意是电脑屏幕上2D,但是我们其实看起来是3D,就像你在一张纸上画一个立体的正方体。一般的图形渲染管线划分的阶段:顶点数据->顶点着色器(无默认,须自定义)->形状(图元)装配->几何着色器(一般默认)->光栅化->片段着色器(无默认,须自定义)->测试与混合,每个阶段的输入是前一个阶段的输出。下图中蓝色背景图片是我们可以干预的,灰色背景则无需干预。(下图顶点数据从进入顶点着色器开始到测试与混合后输出,就好比一个单向通行的管道和流水线,这大概就是我们叫它管线的原因吧。)
-
顶点着色器(Vertex Shader):负责处理每个输入顶点的位置、颜色等属性。可以执行坐标变换、法向量变换等操作。
-
图元装配(Primitive Assembly):将顶点转换成图元(如三角形)。
-
几何着色器(Geometry Shader):处理图元,并可以生成新的顶点或丢弃不需要的图元。
-
光栅化(Rasterization):将图元映射到屏幕上的像素。
-
片段着色器(Fragment Shader):对每个屏幕上的像素进行处理,计算其最终颜色。
-
输出合并(Output Merger):将片元的颜色输出到帧缓冲,最终形成图像。
在可编程渲染管线中,上述阶段中的一些或全部都可以由开发者自定义。这就是着色器的作用所在。这里我们就着重先来看一下顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)这两个非常基础和重要的着色器。其中,顶点着色器常处理顶点的位置和属性,片段着色器常处理最终的像素颜色。我们经常用自己写的以上两个着色器来代替默认的,所以能够更细致地控制图形渲染管线中的特定部分。
着色器使用类似于 C 语言的 GLSL(OpenGL Shading Language)语言编写。这个我们后续可以看看着色器到底语言上的样子,开发者可以编写这些着色器,然后在运行时将它们传递给OpenGL,OpenGL会将它们编译并链接到渲染管线中。
5、着色器语言GLSL(OpenGL Shading Language)
OpenGL着色语言(OpenGL Shading Language)是用来在OpenGL中着色编程的语言,也即开发人员写的短小的自定义程序,他们是在图形卡的GPU(Graphic Processor Unit图形处理单元)上执行的,代替了渲染管线的一部分,使渲染管线中不同层次具有可编程性。我们接触最多的GLSL(GL Shading Language)的着色器代码分成2个部分:Vertex Shader(顶点着色器)和Fragment(片断着色器),有时还会有Geometry Shader(几何着色器)。GLSL其使用C语言作为基础高阶着色语言,避免了使用汇编语言或硬件规格语言的复杂性。
由于着色器是运行在GPU管线上的小程序,并非我们C++直接运行的程序,因此需要将对应的着色器小程序代码以字符串形式保存,以供后期编译、链接使用。着色器的开头是声明版本,接着是输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数,在这个函数里面处理所有的输入变量,将结果输出到输出变量。uniform是一种从CPU向GPU发送数据的方式。但是它和输入变量又不一样,因为它是全局的,每个着色器程序使用的都是同一个uniform值。
- 顶点着色器(实例)
const char *vertexShaderSource = "#version 330 core\n"
"//位置变量的属性位置值为0,由glVertexAttribPointer函数指定,获取顶点坐标\n"
"layout (location = 0) in vec3 position;\n"
"//位置变量的属性位置值为1,由glVertexAttribPointer函数指定,获取顶点颜色\n"
"layout (location = 1) in vec3 color;\n"
"//定义输出颜色\n"
"out vec3 vertexColor;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(position,1);\n"
" vertexColor = color;\n"
"}\0";
注意:顶点着色器使用layout指定输入变量,如layout(location=0),将顶点坐标作为输入值传给position变量。这样就可以根据上节课我们介绍的知识,在通过VBO、VAO和EBO向GPU显存发送数据过程中,由glVertexAttribPointer函数配置顶点坐标属性的对应关系,其中glVertexAttribPointer的第一个参数对应的就是layout标识值。同样,可以配置顶点颜色属性layout(location = 1),将颜色作为输入值传给color变量。
虽然每个着色器是独立的小程序,但每个着色器也是每个渲染管线整体的一部分,所以需要用输入和输出进行数据交流和传递。只要输出变量与下一个着色器的输入匹配上了(类型、名称完全一致即可匹配上),它就会传递下去,例如顶点着色器的vertexColor和片段着色器的vertexColor。
像gl_Position一样,GLSL有一些内置变量可以进行传递。比如gl_ProjectionMatrix(投影变换矩阵)、gl_ModelViewMatrix(视图变换矩阵)、gl_Vertex、gl_Color、gl_Frontcolor、gl_Normal等;而这些又是根据OpenGL应用程序传递诸如顶点位置、颜色、法线等信息。
- 片段着色器(实例)
const char *fragmentShaderSource = "#version 330 core\n"
"out vec4 fragColor;\n"
"in vec3 vertexColor;\n"
"void main()\n"
"{\n"
" //最终的输出颜色,变量名fragColor由关键字out指定\n"
" fragColor = vec4(vertexColor, 1.0f);\n"
"}\0";
通过以上这个两个最简单的着色器实例,我们大致完成了以下事项:通过我们前期VAO对顶点数据的传入,顶点数据进入渲染管线,首先到达自定义的顶点着色器,根据顶点着色器程序,我们将外部读取的顶点数据(x,y,z)转化为四维坐标(x,y,z,w)并且赋值给全局变量gl_Position,同时将读取的外部颜色处理后作为输出,经过图元装配(Primitive Assembly)、几何着色器(Geometry Shader)、光栅化(Rasterization)传递给片段着色器。然后,自定义的片段着色器收到传入的颜色后,没有进行任何深加工(当然根据你的需要,这里可以自行进行渲染加工),随即将传入颜色进行简单格式转换后做为输出颜色输出,然后进行下一阶段测试与混合(Output Merger)。通过以上无数个GPU渲染管线操作,最终在二维屏幕上以指定的顶点位置和顶点颜色显示我们的图形。
从具体操作来看,一个顶点shader和片段shader可以编写代码实现如下功能:使用模型视图矩阵以及投影矩阵进行顶点变换、法线变换及归一化、纹理坐标生成和变换、逐顶点或逐像素光照计算、颜色计算等等。程序员不一定要完成上面的所有操作,例如你的程序可能只进行顶点变换而不使用光照。但是,一旦你使用了shader,处理器shader部分对应阶段的所有固定管线功能都将被替换。比如你不能只编写顶点变换和法线变换的shader,而指望固定功能功能帮你完成光照计算,我们还需要在shader中自己编写光照效果的实现过程。
6、编译、链接着色器
在准备好顶点着色器和片段着色器小程序后,我们需要进行着色器进行编译、链接。不管着色器小程序内容千变万化,但编译链接的方法基本一致,我们这里将编译、链接过程用函数CreateShader()来统一处理。后期根据需要,我们可以自己编写着色器类,方便的从文本文件中读取着色器字符串内容,对着色器程序进行编译、链接,封装uniform值传递操作等。
//自定义着色器
unsigned int shaderProgram;
//创建一个着色器对程序
void CreateShader()
{
int iCompileResult;
char szCompileInfoLog[1024];
unsigned int vertexShader;
//创建着色器
vertexShader = glCreateShader(GL_VERTEX_SHADER);
//着色器源码附加到着色器对象上要编译的着色器对象作为第一个参数。第二参数指定了传递的源码字符串数量,这里只有一个。第三个参数是顶点着色器真正的源码,第四个参数我们先设置为NULL
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
//编译源码
glCompileShader(vertexShader);
//用glGetShaderiv检查是否编译成功
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &iCompileResult);
//检测是否成功
if (!iCompileResult)
{
glGetShaderInfoLog(vertexShader, sizeof(szCompileInfoLog), NULL, szCompileInfoLog);
MessageBox(NULL,szCompileInfoLog,"SHADER VERTEX COMPILATION FAILED",MB_OK);
}
//创建一个片段着色器对象,注意还是用ID来引用的
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
//编译源码
glCompileShader(fragmentShader);
//用glGetShaderiv检查是否编译成功
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &iCompileResult);
if (!iCompileResult)
{
glGetShaderInfoLog(fragmentShader, sizeof(szCompileInfoLog), NULL, szCompileInfoLog);
MessageBox(NULL,szCompileInfoLog,"SHADER FRAGMENT COMPILATION FAILED",MB_OK);
}
//创建一个着色器对程序
shaderProgram = glCreateProgram();
//把之前编译的着色器附加到程序对象上
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
//glLinkProgram链接它们
glLinkProgram(shaderProgram);
//用glGetProgramiv检查是否编译成功
glGetProgramiv(shaderProgram, GL_COMPILE_STATUS, &iCompileResult);
if (!iCompileResult)
{
glGetShaderInfoLog(shaderProgram, sizeof(szCompileInfoLog), NULL, szCompileInfoLog);
MessageBox(NULL,szCompileInfoLog,"SHADER PROGRAM COMPILATION FAILED",MB_OK);
}
//链接后即可删除
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
}
如果在编译链接过程中出现错误,对应的显示内容就无法正常的显示出来,我们可以根据系统提示错误进行修改后重新运行。
7、使用着色器显示图形
那么我们还用上节VBO、VAO和EBO的相关知识,自定义一个顶点数组和一个顶点索引数组,用于描述立方体的顶点数据和颜色信息。
//定义顶点对象
float vertices[] = {
//position //color
-0.5f, -0.5f, -0.5f, 1.0f,0.0f,0.0f,
0.5f,-0.5f, -0.5f, 0.0f,1.0f,0.8f,
0.5f, 0.5f, -0.5f, 0.0f,0.0f,1.0f,
-0.5f, 0.5f, -0.5f, 1.0f,0.8,0.0f,
//position //color
-0.5f, -0.5f, 0.5f, 1.0f,1.0f,0.0f,
0.5f,-0.5f, 0.5f, 1.0f,0.0f,1.0f,
0.5f, 0.5f, 0.5f, 0.0f,1.0f,1.0f,
-0.5f, 0.5f, 0.5f, 0.0f,0.0f,1.0f
};
unsigned int indices[]={
//四个顶点组成立方体的一个面
0,1,3,
1,3,2,
//四个顶点组成立方体的一个面
4,5,7,
5,7,6,
//四个顶点组成立方体的一个面
0,1,4,
4,5,1,
//四个顶点组成立方体的一个面
1,2,5,
5,6,2,
//四个顶点组成立方体的一个面
2,3,6,
6,7,3,
//四个顶点组成立方体的一个面
3,0,7,
7,0,4,
};
由于我们前边已经有一个简单的顶点着色器和片段着色器,要在程序运行后运行一次CreateShader()函数,对着色器进行编译和链接,以后我们就能进一步通过着色器显示立方体了。
记得运行一次CreateShader函数,比如在消息处理函数的WM_ONCREATE消息处理过程中,完成对着色器进行编译和链接。
//使用着色器
glUseProgram(shaderProgram);
//绑定VAO
glBindVertexArray(VAO);
//使用EBO绘制立方体,共绘制36个顶点
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT,0);
//解绑VAO
glBindVertexArray(0);
//绘制完物体还原默认对象
glUseProgram(0);
着色器使用完毕后,一定要使用glUseProgram(0)关闭,否则影响后续固定管线部分内容的显示。
进行以上操作后,我们就能通过着色器显示了,但我们并没有显示出我们立方体的立体效果。主要是我们在使用着色器过程中,顶点坐标必须最终转换成标准化设备坐标。标准化设备坐标是真正绘制在屏幕内顶点的坐标,标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标都会被丢弃、裁剪,不会显示在你的屏幕上。我们前边立方体的坐标均为-0.5至0.5之间,因此只能显示到屏幕的最中间位置。但你会发现我们的立方体被拉长了,而且并不能通过着色器之前固定管线的glTranslatef和glRotatef进行移动和旋转,主要是由于在着色器中,我们首先需要使用矩阵进行顶点变换,将各个定点的位置坐标转换至屏幕坐标。
8、使用矩阵进行顶点变换(MVP)
通过着色器显示出物体后,我们遇到第一个问题,也是最大的问题就是模型视图矩阵以及投影矩阵进行顶点变换。首先,顶点坐标开始于局部空间(Local Space),称为局部坐标(Local Coordinate)。我们通过VAO输入的顶点坐标就是局部坐标,我们需要在顶点着色器中对局部坐标进行顶点变换后,才能通过shader显示出正常位置的三维效果。为了将局部坐标从一个坐标系转换到另一个坐标系,我们需要用到几个转换矩阵,最重要的几个分别是模型(Model)、视图(View)、投影(Projection)三个矩阵,就是大家经常讲到的MVP转换。
记住我们需要从右往左阅读矩阵的乘法。模型(Model)、视图(View)、投影(Projection)三个矩阵的变换是诸多变换中最重要的部分,着色器中需要我们手动完成。其他诸如透视除法、视口变换是在顶点应该被赋值到顶点着色器中的gl_Position后OpenGL自动进行的。
那么我们就想要进行模型视图矩阵以及投影矩阵进行顶点变换,就需要通过uniform从着色器外传递现在正在使用着色器对应的模型视图矩阵和投影矩阵,当然这两个矩阵是4×4的齐次矩阵。最终我们将投影矩阵(ProjectionMatrix)、模型视图矩阵(ModelViewMatrix)和局部坐标(position)的齐次坐标相乘,就可以完成顶点变换,显示出正常的三维图像。其中,模型视图矩阵(ModelViewMatrix)是模型矩阵(Model Matrix) 和视觉矩阵(View Matrix)的组合。
Opengl固定管线模型视图矩阵、投影矩阵传导到着色器中使用时要注意矩阵转换顺序。
const char *vertexShaderSource = "#version 330 core\n"
"//位置变量的属性位置值为0,由glVertexAttribPointer函数指定\n"
"layout (location = 0) in vec3 position;\n"
"//位置变量的属性位置值为1,由glVertexAttribPointer函数指定\n"
"layout (location = 1) in vec3 color;\n"
"//定义输出颜色\n"
"out vec3 vertexColor;\n"
"//传入模型视图矩阵和投影矩阵\n"
"uniform mat4 ProjectionMatrix;\n"
"uniform mat4 ModelViewMatrix;\n"
"void main()\n"
"{\n"
" gl_Position = ProjectionMatrix*ModelViewMatrix*vec4(position,1);\n"
" vertexColor = color;\n"
"}\0";
上面我们通过uniform传递了模型视图矩阵和投影矩阵,通过矩阵相乘将物体局部空间坐标转换为我们需要的坐标。那么,现在问题又来了,模型视图矩阵和投影矩阵的传入值又从哪来呢?分两种情况。第一种情况,如前面使用的是固定管线操作,我们可以通过glGetFloatv函数直接获取当前的模型视图矩阵和投影矩阵,操作非常的简单。还有一种情况,如果项目自始至终使用自定义矩阵的话,就不需要用glGetFloatv函数获取以上两个矩阵,因为在可编程管线模型矩阵、视图矩阵和投影矩阵都是自己定义的,自己直接就能用。这里我们简单的使用一下第一种情况,通过glGetFloatv函数获取以上两个矩阵。
//定义矩阵数组
GLfloat ProjectionMatrix[16];
GLfloat ModelViewMatrix[16];
着色器显示部分
//进行矩阵位移和旋转
glPushMatrix();
glTranslatef(5,3,0);
glRotatef(30,0.0f,1.0f,0.0f);
//使用着色器
glUseProgram(shaderProgram);
//绑定VAO
glBindVertexArray(VAO);
//获取投影矩阵和模型视图矩阵
glGetFloatv(GL_PROJECTION_MATRIX, ProjectionMatrix);
glGetFloatv(GL_MODELVIEW_MATRIX, ModelViewMatrix);
//获得着色器中变换矩阵位置
unsigned int transformLoc0 = glGetUniformLocation(shaderProgram, "ProjectionMatrix");
glUniformMatrix4fv(transformLoc0, 1, GL_FALSE, ProjectionMatrix);
//传递uniform变量,将投影矩阵和模型视图矩阵传入着色器
unsigned int transformLoc1 = glGetUniformLocation(shaderProgram, "ModelViewMatrix");
//获得着色器中变换矩阵位置
glUniformMatrix4fv(transformLoc1, 1, GL_FALSE, ModelViewMatrix);
//使用EBO方式绘制立方体,
glDrawElements(GL_TRIANGLES,36,GL_UNSIGNED_INT,0);
//解绑VAO
glBindVertexArray(0);
//不在使用着色器
glUseProgram(0);
//返回之间矩阵空间
glPopMatrix();
运行后显示效果如下:
前面我们提到我们立方体回被拉伸变形,而矩阵转换后立方体就不会被拉伸变形。这主要是由于在顶点变换过程中,我们进行了投影矩阵变换,在投影矩阵转换过程中,我们将物体横宽比例锁定,相关知识见C++和OpenGL实现3D游戏编程【连载2】——了解并创建3D空间模型第5部分窗口显示比例的锁定内容,因此物体随窗口拉伸变形的问题就不在存在了。
我们发现,只要能把OpenGL固定管线矩阵操作中投影矩阵和模型视图矩阵传到shader中,就可以完成我们的顶点转换,这个过程也非常的简单方便,理论上讲就可以不用第三方的矩阵数学库了(当然后期我们还是会加入数学库已实现更多的功能)。通过以上操作,我们就能正常的在shader中显示三维物体了。那么大家可能会有疑问我们的模型视图矩阵和投影矩阵又是什么,我们虽然获取了矩阵,但是它们是什么原理?我们将在接下一节矩阵相关知识中详细的讨论描述。通过下一节课,我们把所有的矩阵知识融会贯后,你会对Opengl编程有更深刻的认识,站在更高层面去了解线性代数在Opengl的重要作用。
9、总结
从OpenGL 2.0起着色器编程逐步成为核心内容,大家在学扎实固定管线相关知识后,一定得熟悉VAO/VBO/FBO操作,要融汇贯通shader相关操作,这样才能才能真正地零距离接触到了pipeline管线,更好的了解游戏编程核心。当你后期去自己生成MVP、自己实现Phong Shading时,和简单的用一下glTranslatef,glLightfv这些API的感觉是不一样的。
【上一节】:🔥C++和OpenGL实现3D游戏编程【连载14】——VBO、VAO和EBO应用
【下一节】:🔥C++和OpenGL实现3D游戏编程【连载16】——矩阵操作实战