使用OpenGL渲染一个三角形
OplenGL的功能是什么?这里文中给出了介绍:In OpenGL everything is in 3D space, but the screen and window are a 2D array of pixels so a large part of OpenGL's work is about transforming all 3D coordinates to 2D pixels that fit on your screen。简单来说,就是把OpenGL中的3D坐标转换为我们屏幕对应的2D像素。这是由OpenGL的渲染管线实现的,它可以被分为两大部分:第一部分是把3D坐标转换为2D坐标,第二部分是把2D坐标转换为带有颜色的像素
注意:2D坐标和像素是不同的,2D坐标表示一个点在空间中的位置,而像素则是这个点的近似值,它受到你屏幕或者窗口的分辨率的限制。
那么问题来了,什么是shader呢?前文说过,OplenGL会接收一组3D坐标并把它转换为2D坐标,这个过程是在渲染管线(graphics pipeline)中实现的。其实渲染管线(graphics pipeline)可以看做一个流水线,它是由许多步骤组成的,但是每个步骤都要用到前一个步骤生成的数据。而这些步骤又是高度专门化的,并且非常容易并执行,正是因为这个特性,当今我们显卡上有成千上万的小处理核心,它们在GPU上每一个阶段都在运行着自己的小程序,这样能使它在图形渲染管线中快速处理我们的数据。而这个小程序就是我们所说的shader。
但是并非渲染管线中所有的shder我们都可以去编辑,有些是默认的不可被更改的,我们可以自己配置的只有部分shader。并且这些shader都是在GPU上面运行的,这样就可以帮我们节省宝贵的CPU的时间。
这个图片抽象的表示了我们渲染管线需要经过的步骤。其中背景为蓝色的阶段表示我们可以自己编辑shader的部分。
因为这里讲的是如何绘制一个三角形,那么我们就用三角形来简略的说明渲染管线中每个阶段所进行的操作。
首先,我们以数组的形式传入3个3D坐标作为输入,这三个点可以用来表示一个三角形,这个数组就被称为顶点数据(Vertex Data),顶点数据是一系列顶点的集合,一个顶点就是一个3D数据坐标的集合。而顶点数据是由顶点属性来表示的,它可以包含任何我们想用的数据(但是为了简单起见,我们可以理解为每一个定点数据由一个3D位置和一个颜色值组成)。
为了让OpenGL知道我们的坐标和颜色的值到底是什么,OpenGL需要我们去指定我们这些数据所要渲染的类型。比如我们要把它渲染成一系列的点,还是一个三角形,还是一条直线?而做出这些提示的就是图元(Primitive),任何一个绘制指令的调用都会把图元传递给OpenGL,下面是提示中的几个:GL_POINTS,GL_TRIANGLES,GL_LINE_STRIP.
图形渲染管线的第一部分就是顶点着色器(Vertex shader),它把一个单独的点作为输入。它主要的功能就是把3D坐标转换为另一种3D坐标(这个将在后面提到),同时顶点着色器允许我们对顶点属性做一些基本的处理。比如卡通渲染里面将角色的顶点膨胀一点点,就是角色外面的黑色描边。
图元装配(Shape Assembly)阶段将顶点着色器的输出作为输入,并将所有的点组装成指定图元的形状(在这个例子中我们绘制的是一个三角形)。
图元装配阶段传出的数据会被传给几何着色器(Geometry Shader),它可以通过产生新的顶点来构造出新的图元l来生成其它形状(在本例中它生成了另外一个三角形)。也就是说通过shader程序可以指定几何着色器对顶点信息进行删减。利用几何着色器可以自由的生成多边形,但是!但是!几何着色器并没有它描述的那么好,它的实际效益可能并不高,甚至是非常低。所以一般来说是不会去写到的。
几何着色器的输出会被传入到光栅化阶段(Rasterization Stage),这里它会把图元映射为屏幕上的最终的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment),在片段着色器之前会进行裁切(Clipping),裁切会丢弃掉你的视图外的所有像素,以此来提高效率
OpenGL中的一个片段(Fragment)是OpenGL渲染一个像素所需要的所有数据。
片段着色器(Fragment Shader)的主要作用是计算一个像素的最终颜色,这里也是OpenGL产生所有高级特效的地方,通常Fragment shader包含3D场景的数据(比如光照,阴影和光的颜色等等)。这些数据被用来计算像素的最终颜色。
在所有的颜色都被确定之后,对象会被传入到最后一个阶段。我们通常把它成为Alpha测试和混合(Blending)阶段,这个阶段用来检测片段对应的深度值,用它来判断这个像素是在其它像素的前面还是后面,决定这个像素时候应该被丢弃。这个阶段也会检测Alpha值(Alpha值表示了一个物体的透明度),并对物体进行混合。因此,即使在Fragment shader 中计算出来了每个像素的颜色,在渲染多个三角形的时候它们的颜色也有可能会不一样。
由此就可以看出来,渲染管线(Graphics pipeline)非常复杂,它有很多可以配置的部分。但是在大多数场合,我们只需要配置vertex shader和Fragment shader就可以了,这两个也是我们必须配置的,因为GPU中没有默认的vertex shader和Fragment shader。而Geometry shader在大部分情况下我们使用的是GPU默认的shader。
以上,就是对渲染管线(Graphics pipeline)每个阶段大致的介绍,总体来说还是比较清晰的。如果没有看明白的话可以参考https://blog.csdn.net/FancyVin/article/details/68062798这篇文章,是一位大佬翻译的一位日本作家西川善思的3D图形的概念和渲染管线,里面是关于Direct X渲染管线的介绍,每个过程都介绍的特别清楚。虽然不是OpenGL,但是它们渲染物体的步骤大同小异,可以帮助我们理解。
对graphics pipeline每个阶段有了大致的了解之后我们便可以开始着手渲染自己的三角形(我还是比较推荐大家看英文原版的内容,虽然直接搜索也有翻译过的OpenGL网站,但是建议还是以英文为主翻译为辅进行学习。)
Vertex Input
在开始介绍顶点输入(vertex input)之前,先做一个有趣的实验。如果大家电脑上有blender的话,可以新建一个三角形然后以obj格式导出,用文本打开这个obj文件,大家会看到这样的数据:
这里可以看到有四个坐标,其中第五行,第六行,第七行的三个坐标就是三角形三个顶点的坐标。有点不同的是,blender在导出文件的时候对坐标轴进行了替换,在blender视图里面的z轴在导出后就变成了y轴。因为我们创建的三角形是一个平面,它的z轴的坐标理应为0。但是我们在上图之中可以看到三个坐标中的y坐标都为0,这就是blender在导出后对坐标轴进行的替换,不过并没有什么影响。然后第八行,就是我们每个坐标对应的法向量。为什么三个坐标会对应一个法向量呢?看第11行 1//1 2//1 3//1的意思就是 第一个坐标对应的法向量为第一个法向量,第二个坐标对应的法向量为第二个法向量,第三个同理。如果我们在blender中创建的图形是个多面图形的话,导出来的obj文件在打开后vn的坐标就不止一个,此时坐标与各个法向量之间的关系就会改变。
现在,我们可以开始介绍顶点输入(vertex input)了。在开始绘图之前,我们必须给OpenGL来输入一些定点数据,之前也说过,OpenGL是一个3D的图形库,所以我们提供给OpenGL的坐标都是3D坐标。但是OpenGL并不是简单就把所有的3D坐标转换为屏幕2D的像素。只有当3D坐标中x,y,z的值在-1.0到1.0之间时,OpenGL才能够处理它。这样的坐标被称为标准化设备坐标(nomalized device coordinates)。只有在标准化设备坐标(normalized device coordinates)范围内的坐标才能最终展现在屏幕上。这里为了简单起见,我们提供的三角形的坐标就是标准化设备坐标(normalized device coordinates),它是一个float类型的数组:
float vertices[] = { -0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, 0.0f, 0.5f, 0.0f };
因为OpenGL是在3D中工作的,而这里我们要渲染一个2D的三角形,所以可以把z坐标置零,这样就能使得三角形每个坐标的深度都是一样的从而使它看上去像是2D的。
当然,在实际的项目当中并不可能每个3D坐标都是标准化设备坐标(normalized device coordinates)。而OpenGL会把落在标准化设备坐标(nomalized device coordinates)之外的坐标全都剔除掉,它们并不会显示在你的屏幕上。关于将坐标转换为标准化设备坐标(normalized device coordinates),这在之后会学到。至于我们传入的标准化设备坐标(normalized device coordinates)它会转化为屏幕空间坐标,这个是通过我们glViewPort提供的数据进行视口变换完成的。所得的屏幕坐标会被变换为片段输入到Fargment shader当中。
那么vertex data究竟是经过怎么样的处理才能输入到vertex shader呢?拿我们之前从blender之中导出的三角形的obj文件举例子,obj文件在经过一系列的序列化后生成一个vertex的数组,然后CPU将这个数组传送给GPU。GPU接收到CPU的传送过来的vertex数组之后怎么办呢?先存起来吧,这个时候GPU就会通过顶点缓冲对象(vertex buffer objects)即VBO来管理这个内存,它会在GPU的内存中(通常称为显存)储存大量的顶点。而使用VBO的好处就是我们可以一次发送大量的定点数据到GPU中,因为从CPU到GPU的数据传输是非常慢的,所以这样做能够极大的提高效率。同时当数据发送到显卡中的内存(GPU)之后,vertex shader几乎能够立即访问顶点,并且这是个非常快的过程。
现在VBO将是我们接触到的第一个OpenGL的对象,就像OpenGL中其它的对象一样,VBO也拥有一个独一无二的ID。而我们可以通过glGenBuffers这个函数和一个缓冲ID来生成一个VBO对象,代码如下:
unsigned int VBO; glGenBuffers(1, &VBO);
当然,我们可以生成不止一个VBO对象,只需要对代码稍微进行改动就行了:
unsigned int VBO[n]; glGenBuffers(n, VBO); //n为需要生成的VBO的数量
OpenGL中有很多不同种类的缓冲对象,其中VBO对应的缓冲类型为GL_ARRAY_BUFFER。且OpenGL允许我们同时绑定多个缓冲,只要这些缓冲是不同类型的。接着,我们就可以使用glBindBuffer方法把新生成的VBO绑定到GL_ARRAY_BUFFER目标上,代码如下:
glBindBuffer(GL_ARRAY_BUFFER, VBO);
在绑定之后,我们使用的任何缓冲调用(在GL_ARRAY_BUFFER目标上的)都会被配置到当前绑定的缓冲对象(VBO)中。接下来我们就可以调用glBufferData函数将之前CPU传过来的vertex data复制到缓冲内存中。代码如下:
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData是一个专门用来把用户传入的数据复制到当前绑定缓冲的函数,它的第一个参数是目标缓冲类型,第二个参数指明我们想要复制到缓冲的数据的大小(以字节为单位),这里使用sizeof()直接求出数据大小即可。第三个参数是我们要传入的数据,第四个参数是我们告诉显卡希望它如何处理我们所给的数据,这里有三种类型:
GL_STATIC_DRAW:指我们传入的数据基本上不变或者很少改变。
GL_DYNAMIC_DRAW:数据经常改变
GL_STREAM_DRAW:数据在每次绘制的时候都会改变
因为我们所绘制的三角形的顶点是不会改变的,所以这里用GL_STATIC_DRAW。但是,当我们知道我们传入的定点数据需要经常变化的时候,我们需要使用GL_DYNAMIC_DRAW或者GL_STREAM_DRAW。它们可以确保显卡能够把数据写入到能够高速读取的内存中。
现在我们把顶点数据存储在了显卡的显存之中,它由我们的VBO进行管理。那么,我们可以直接把这个东西拿给vertex shader使用吗?当然不行,它现在只是一堆定点数据,我们的GPU并不知道这些数据中哪些部分都是什么东西。比如我们的数据中有角色的UV,有3D坐标等等,GPU需要我们告诉它,每个部分的数据都代表着什么。这就需要我们给GPU提供一张表,就像之前打开的三角形的obj文件那样,标明哪些顶点是坐标,哪些是法线等等。此时就需要使用到VAO(Vertex Array objects)也就是顶点数组对象。
VAO可以像其他缓冲对象那样被绑定,而且随后的顶点属性配置都会被存储在VAO中。这样做的优点是,当配置顶点属性指针的时候,我们只需要将那些调用执行一次,之后绑定相应的VAO就行了。什么是顶点属性指针呢?这在之后会提到。因此当我们在不同的顶点数据和属性配置之间切换时,我们只需要绑定不同的VAO就行了。刚刚设置的状态都将被存储在VAO中。
OPENGL的核心功能要求我们使用VAO,但是当我们没有绑定VAO时,OPENGL将拒绝绘制任何东西。
一个VAO可以存储下列东西:
glEnableVertexAttribArray或者glDisableVertexAttribArray的调用(这两个函数将在后面介绍)
通过glVertexAttribPointer设置的顶点属性
通过glVertexAttribPointer来调用与定点属性相关联的VBO
每个VAO都有一个顶点属性列表,表中一共有15个顶点属性,他们存储着每个属性在VBO中的位置,如下图所示:
VAO的绑定与VBO非常相似,代码如下:注意VAO也可以和VBO做一样的操作来制造多个VAO。
unsigned int VAO; glGenVertexArrays(1, &VAO);
为了使用VAO,我们需要使用glBindVertexArray方法来绑定VAO。代码如下:
glBindVertexArray(VAO);
之前我们已经使用glBindBuffer方法将VBO与GL_ARRAY_BUFFER绑定了起来。其实这个顺序是错的,我们应该在绑定完VAO之后再去使用glBindBuffer方法绑定VBO,这样VAO和VBO之间才能关联起来。正确的顺序应该是下面这样的:
unsigned int VAO; glGenVertexArrays(1, &VAO); glBindVertexArray(VAO); unsigned int VBO; glGenBuffers(1, &VBO); glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
Vertex shader
这里给我们提供了一个Vertext shader,我们只需要直接使用就行了,至于每个shader如何编写,这在下面一节将会介绍。现在,我们只需要这样写入我们的程序当中:
const char* vertexShaderSource = "#version 330 core \n" "layout (location = 0) in vec3 aPos; \n" "void main(){ \n" " gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);} \n";
现在我们有了vertex shader,那么如何编译它呢?首先,我们需要创建一个shader对象,注意也是用ID来引用的。我们使用unsigned int来储存shader并且用glCreatShader方法来创建一个shader,代码如下:
unsigned int vertexShader; vertexShader = glCreateShader(GL_VERTEX_SHADER);
我们需要给glCreatShader方法传入我们需要创建shader的类型,这里为GL_VERTEX_SHADER。接着我们需要把shader的源码绑定到shader对象中并且编译shader,代码如下:
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL); glCompileShader(vertexShader);
glShaderSource把要编译的shader对象当作第一个参数,第二个参数为我们源码的字符串数量,这里是1。第三个为我们对应的shader的源码,第四个参数为NULL。
如果想知道自己的shader编译是否成功,可以参考https://learnopengl.com/Getting-started/Hello-Triangle的纠错,这里不做说明。
Fragment Shader
fragment shader是我们第二个也是最后一个我们打算用于渲染三角形的shader,fragment shader用于计算我们输出像素的颜色。为了简单起见,这里提供的shader只会渲染一个橙色的三角形。
计算机中图形的颜色被分为有四个元素的数组,这4个值分别为:red green blue 和 alpha。通常缩写为RGBA,当在OpenGL或者GLSL中定义颜色的时候,我们把颜色的每个分量设置为0.0和1.0之间。
接下来可以将下述代码加入到我们的程序中:
const char* fragmentShaderSource = "#version 330 core \n " "out vec4 FragColor; \n " "void main(){ \n " " FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);} \n ";
关于fragment shader的介绍同样留在之后,这里我们只是使用这个shader来渲染出我们的三角形。
编译fragment shader的步骤于vertex shader相似,但是这次我们使用GL_FRAGMENT_SHADER作为shader的类型,代码如下所示:
unsigned int fragmentShader; fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL); glCompileShader(fragmentShader);
现在两个shader都被编译完毕了,之后我们就需要把两个shaderd对象连接到一个用于渲染的shader program
Shader Program
shader Program对象是多个shader链接之后的最终版本,如果要使用刚刚编译的shader对象的话,我们需要把他们链接到一个shader program对象中,并且在渲染的时候激活这个shader program。已经被激活的shader program将会在我们发送渲染调用的时候被使用。
当我们把多个shaer链接到一个program的时候,上一个shader的输出会作为下一个shader的输入,当输入和输出不匹配的时候,你将会得到一个链接错误。
创建一个shader program很简单,代码如下:
unsigned int shaderProgram; shaderProgram = glCreateProgram();
glCreatProgram方法会创建一个shader program并返回一个新创建program的ID的引用。现在我们要把之前编译的shader附加到program对象上并用glLinkProgram链接它们。代码如下:
glAttachShader(shaderProgram, vertexShader); glAttachShader(shaderProgram, fragmentShader); glLinkProgram(shaderProgram);
这些操作当然也可以校验是否成功,这些内容在刚刚提供的链接里面也有,有兴趣的话可以看一下。
现在我们得到了一个program对象,然后我们可以调用glUseProgram函数,并用得到的program对象作为它的参数,这样就可激活这个对象,代码如下:
glUseProgram(shaderProgram);
在glUseProgram函数执行之后,每个shader调用和渲染调用都会使用这个program对象(也就是之前写的shader)了。
现在我们已经把我们vertex shader传送给了GPU并且告诉了GPU如何用vertex shader和fragment shader来处理它们。并且告诉了它们如何解释内存中的定点数据,剩下的就是告诉它如何把vertex data链接到vertex shader的顶点属性上了。
Linking Vertex Attributes
vertex shader允许我们指定任何以顶点属性为形式的输入。这使其有很强的灵活性的同时,它还意味着我们必须手动的指定vertex data的哪一部分对应着vertex shader的哪一个顶点属性。
我们的顶点缓冲数据会被解析为下面的形式:
*位置信息会被存储为32位(4字节)的浮点值
*每个位置包含三个这样的值
*这3个值之间没有空隙(或者其它值),它们在数组中紧密排列
*数值中第一个值开始的位置
通过这些信息我们就可以使用glVertexAttribPointer函数告诉OpenGL如何解析vertex data(应用到顶点属性上),代码如下:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); glEnableVertexAttribArray(0);
*第一个参数指明我们要配置的顶点属性,还记得我们在vertex shader中使用layout(location = 0)定义了position预顶点属性的位置(Location)嘛?它可以把顶点属性的位置值设置为0.我们希望把定点数据传入到这个顶点属性中,所以我们把它的值设置为0
*第二个参数指明了顶点属性(vertex attrib)的大小,因为我们的顶点属性是vec3所以它由3个值组成。
*第三个参数指明参数的类型是GL_FLOAT(GLSL中的vec*都是由浮点数组成的)
*第四个参数表示我们是否希望数据被标准化。如果我们设置的值是GL_TRUE,所有的值都会被映射到0(对于有符号型signed是-1)到1之间。这里我们并不需要,所以把它设置为GL_FALSE.
*第五个参数我们把它叫做stride(步长),它代表在连续顶点属性组之间的间隔。因为下一组的数据在3个float之后,所以我们这里设置的是3*sizeof(float)。因为我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙),我们也可以设置它为0让OpenGL来为我们决定(只有当定点数据是紧密排列的时候才能使用)。一旦我们拥有更多的定点数据,我们必须小心的设定每个顶点数据之间的间隔。在后面我们将看到更多的例子。(这个参数简单的说就是从这个属性第二次出现的地方到数组为0的位置一共有多少个字节)。
*第六个参数的类型是void*,我们需要进行强制的类型转换。它表示数据在缓冲中起始位置的偏移量。
每个顶点属性从VBO管理的内存中获取vertex data,而具体是从哪个VBO(我们可以拥有多个VBO)中获取则是通过调用glVertexAttribPointer函数时绑定到GL_ARRAY_BUFFER的VBO决定的,因为在调用glVertexAttribPointer之前绑定的是先定义的VBO对象,所以顶点属性0会链接到vertex data。
现在我们已经定义了OpenGL如何解释vertex data,我们需要以顶点属性位置作为glEnableVertexAttribArray函数的参数来启用顶点属性,它默认状态下是关闭的。
最后要想绘制我们想要的物体,OpenGL给我们提供了glDrawArrays函数,它使用当前激活的着色器,之前定义的顶点属性配置,和VBO的顶点数据(通过VAO间接绑定)来绘制图元。代码如下:
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawArrays函数第一个参数是我们打算绘制的OpenGL图元的类型。由于我们在一开始时说过,我们希望绘制的是一个三角形,这里传递GL_TRIANGLES给它。第二个参数指定了顶点数组的起始索引,我们这里填0。最后一个参数指定我们打算绘制多少个顶点,这里是3(我们只从我们的数据中渲染一个三角形,它只有3个顶点长)。
运行程序,结果如下所示:
以上任何步骤出错我们都不能得到这个三角形,如果三角形颜色不对的话,建议检查vertex shader和fragment shader的代码。