背景知识:
从这个课程开始,每一个我们执行的效果和技术,都会用到着色器。着色器是当前开发3D图形的流行方式。从某种程度来说,这是一个退步,因为大多数3d功能函数都提供了固定功能管线,仅仅需要开发人员配置一些参数(比如光线属性,旋转值等),而现在必须都得由开发人员制定(通过着色器),然而,这种可编程性却能带来更多的灵活和创新。
可视化的OpenGl可编程管线如下图:
【顶点处理器】负责各个顶点着色器的执行,以及每个顶点通过管线(数量取决于用于draw call的参数)。顶点着色器对需要渲染什么图形是一无所知的。另外,顶点处理器中你不能丢弃顶点。每一个顶点恰好一次进入顶点过程,通过变换进入管线的下一阶段。
下一阶段是【几何处理器】。在这一阶段数据是作为完整的图元信息(即它的所有顶点)和相邻顶点提供给着色器的。这使技术必须考虑到除顶点本身之外的更多额外信息。几何着色器还能输出与之前draw call中选择的不同的拓扑结构。例如,你可以提供给一个点的列表,然后根据这些点生成2个三角形(比如一个正方形,这个技术叫广告牌)。此外,你可以给每个几何着色器传多个顶点,根据你选择的拓扑产成多种几何图元。
管线的下一阶段是【剪裁】。这是一个有明确任务的固定功能--在之前我们教程中看到的标准盒子中,剪裁图元。它会在近Z面和远Z面之间剪裁它们。也可以申请自定义剪裁面,有不那么做的剪发。在此阶段中,留下来的顶点的位置会被映射到屏幕空间坐标,接着光栅化根据它们的拓扑结构渲染它们到屏幕。例如,如果是三角形,这意味着把三角形内部所有的点找出来。对于每个点光栅化调用【片段处理器】。在这里你能用纹理采样或其他技术确定像素的颜色。
这三个可编程的阶段(顶点,几何,片段处理器)都是可选的。如果你没有对它们绑定着色器,那么某些默认的函数功能会被执行。
着色器管理程序跟C/C++程序的创建方式非常类似。首先你得写一段着色器代码,并使其可被你的程序获取。这步可以通过在源程序代码里简单的包含一个字符串数组文本,或者把一个额外的文本文件加载进来(然后一个字符串数组中)。接着你就能把着色器代码逐个翻译成着色器对象。之后你可以把着色器链接到一个单一的程序,而后把它加载进GPU。链接着色器使驱动有机会根据着色器之间的关系裁剪、优化它们。例如,你可能会有一个顶点着色器发起了法向量,匹配一个片段着色器忽视了法向量。在这种情况下,驱动中的GLSL编译器会移除着色器中法向量相关函数以便能够更快地执行顶点着色器。如果之后链接到另一程序的着色器又与一个用了法向量的片段着色器匹配,则会产生一个不同的顶点着色器。
代码实践:
GLuint ShaderProgram = glCreateProgram();
我们通过创建一个程序对象开始配置一个这个过程。我们会把所有着色器一起链接进这个对象中。
GLuint ShaderObj = glCreateShader(ShaderType);
我们用上面的调用创建了2额着色器对象。一个是GL_VERTEX_SHADER类型的着色器,另一个是GL_FRAGMENT_SHADER类型的。对于两者来说,指定着色器源程序和编译着色器的过程是一样的。
const GLchar* p[1];
p[0] = pShaderText;
GLint Lengths[1];
Lengths[0]= strlen(pShaderText);
glShaderSource(ShaderObj, 1, p, Lengths);
在编译着色器对象之前我们必须要指定它的源代码。函数glShaderSource把着色器对象当做一个参数以及在指定源程序上提供了灵活性。源程序可以分散在几个字符数组中,而你需要提供一个指针数组指向这些字符数组,同时还需要一个包含每个对应的字符数组的长度的整型数组。简单起见我们用只有一个字符串的数组作为整个着色器的源程序,同时对于指针和长度我们也仅仅用了一个元素。
glCompileShader(ShaderObj);
编译着色器倒是相当容易...
GLint success;
glGetShaderiv(ShaderObj, GL_COMPILE_STATUS, &success);
if (!success) {
GLchar InfoLog[1024];
glGetShaderInfoLog(ShaderObj, sizeof(InfoLog), NULL, InfoLog);
fprintf(stderr, "Error compiling shader type %d: '%s'\n", ShaderType, InfoLog);
}
然而,如预想的那样,你常常会获取一些编译错误。上面这一片代码获取编译状态和把编译中遇到的错误呈现出来。
glAttachShader(ShaderProgram, ShaderObj);
最终,我们把编译好的着色器连接到程序对象上。这非常像在makefile中为链接过程指定对象列表。由于我们在这没有makefile所以我们模拟它的编程行为。只有被连接的对象参与到链接过程。
glLinkProgram(ShaderProgram);
在编译了所有着色器对象以及把它们连接到程序后,最终我们可以链接它了。注意链接了程序后你可以调glDetachShader和glDeleteShader丢弃中间着色器对象。OpenGL驱动在大多数对象产生时维持着一个引用计数。如果一个着色器被创建后马上又删除,那么驱动会丢弃它,但是如果它被连接到一个程序了,调用glDeleteShader只会标记为删除,你需要调用glDetachShader使引用计数降到0,它才会被移除。
glGetProgramiv(ShaderProgram, GL_LINK_STATUS, &Success);
if (Success == 0) {
glGetProgramInfoLog(ShaderProgram, sizeof(ErrorLog), NULL, ErrorLog);
fprintf(stderr, "Error linking shader program: '%s'\n", ErrorLog);
}
注意我们检查程序相关错误(比如连接错误)跟着色器程序错误有所不同。我们用glGetProgramiv代替glGetShderiv,用glGetProgramInfoLog代替glGetShaderInfoLog.
glValidateProgram(ShaderProgram);
你可以问问你自己为什么我们在程序成功链接后还需要验证它。差别在于链接检查的是着色器间的错误,而上面的调用检查在当前管线状态中程序是否能运行。在一个更复杂的应用中,多种着色器以及各种状态在发送变化,最好在每一个draw call前验证一下。在我们的简单app中我们就检查了一次。当然了,你可能只是希望在开发过程中多做这种检查,而在最终的产品中要避免这种开销。
glUseProgram(ShaderProgram);
最后,用上面这个调用把已链接到程序中的着色器设置到管线状态中。现在这个程序会一直为所有draw calls起作用,知道你用另一个替换它或者调用glUseProgram(NULL)明确地禁止它使用(同时开启固定功能管线)。如果你创建了一个只包含一个类型的着色器程序,其他阶段操作系统会用默认固定管线。
我们已经完成了涉及到OpenGL着色器管理的调用的实践。剩下的课程涉及到顶点着色器和片段着色器的内容(源码在'pVS'和'pFS'中)。
#version 330
这告诉编译器我们在针对GLSL的3.3版本。如果编辑器不支持它会发出一个错误。
layout (location = 0) in vec3 Position;
这段声明出现在顶点着色器中。它声明了一个3元素浮点向量的特定顶点属性,在着色器中被认为是一个“位置”。“特定顶点”意味着每次在GPU中调用着色器,都会从缓冲中提供一个新的顶点属性。声明的第一小节,layout(location = 0),建立了属性名和缓冲中属性数据的绑定。在诸如我们顶点包含很多属性(位置、法向量、纹理坐标等等)的时候,这是必须的。我们必须让编译器知道缓冲中的哪一个顶点属性被映射成着色器程序中的声明属性。这时有两个方法。第一是像我们目前做的那样,明确地设置它的索引(为0)。在这种情况下我们在应用中写死一段代码(像我们之前在调用glVertexAttributePointer时第一个参数做的那样);第二是不要它了(只在着色器中简单的声明'in vec3 Position')接着用glGetAttribLocation在运行时询问location值。在这种情况下我们需要给glVertexAttributePointer一个返回值,而不是写死一段代码值。在这里我们选择了简单的方式,但是对于更加复杂的应用最好让编译器在运行时判断、询问属性索引。当遇到很多没有布局好的代码时,整合起来会更简单。
void main()
你能通过把各种着色器对象链接到一起来创建你的着色器。然而,只能有一个主函数作为每个着色器(顶点、集合、片段)的入口。例如,你可以用几个函数创建一个轻量级的库,带着你提供的着色器链接它,但这些都不是“main”函数。
gl_Position = vec4(0.5 * Position.x, 0.5 * Position.y, Position.z, 1.0);
这里我们把传来的顶点位置做一个写死的变换。我们让x和y值减半,留着y值不变。 'gl_Position'是一个内置变量用以提供包含同类(x,y,z,w分量)顶点位置。光栅化会寻找这个变量,然后把它用作在屏幕空间中的坐标(在一些转换之后)。把x和y缩减到一半意味着我们只能看到之前课程中四分之一大小的三角形。获取从3D到2D的投影实际上是由2个独立的阶段中完成的。首先,你需要把你所有的顶点乘以投影矩阵(我们再其他课程中会开发),然后GPU会在点属性到达光栅器之前对它们自动执行“透视分离”。这意味着它通过w分量选项把gl_Position的其他选项分离开。在这个课程中我们还未在顶点着色器中做任何投影变换,但却不能阻止透视分离运行。不论如何,gl_Position这个从顶点着色器输出的值都会被HW用w分量分割。我们需要记住这点,否则我们拿不到我们期待的结果。为了规避透视分离的影响,我们可以设w为1.0。1.0分离不会影响位置向量中的其他分量,而依然留在我们的标准盒子里。
如果一切正常工作,3个值为 (-0.5, -0.5), (0.5, -0.5) 和 (0.0, 0.5)的顶点将会到达光栅器。因为所有的顶点刚好都在标准盒子里,所以裁剪就不用做任何事了。 这些值被映射成屏幕空间坐标,然后光栅器开始跑遍所有三角形内部的点。对于每一个点,片段着色器都会执行。下面的代码来自片段着色器。
out vec4 FragColor;
片段着色器的工作常常是确定片段(像素)的颜色。此外,片段着色器可以一并丢弃像素,或者改变它的Z值(会影响到随后Z测试的结果)。输出颜色是通过声明上面的变量来做的。4个分量代表R,G,B,A。你设置进变量的值,会被光栅器接收到,最终写入帧缓冲。
FragColor = vec4(1.0, 0.0, 0.0, 1.0);
在之前几个课程中没有片段着色器,所以所有东西都默认用白色来绘制。在这里我们设置片段颜色为红色。
译者总结:
1.OpenGL渲染管线的顺序是:
顶点着色器--几何着色器--剪裁--光栅化(包含片段着色器)
2.创建并使用一个着色器的完整过程是:
1.创建一个着色器运行程序对象; GLuint ShaderProgram = glCreateProgram();
2.写一段类似于C/C++的着色器代码:
3.创建一个着色器对象(可以是顶点或片段),把代码赋予到对象上; glShaderSource(ShaderObj, 1, p, Lengths);
4.编译着色器对象; glCompileShader(ShaderObj);
5.把着色器对象链接到着色器运行程序对象中; glAttachShader(ShaderProgram, ShaderObj);
6.把着色器设置到管线状态中。 glUseProgram(ShaderProgram);
3.错误检测:
一种是用于捕获着色器程序的语法错误,如glGetShaderiv、glGetShaderInfoLog;
另一种用于捕获运行程序的逻辑错误,如glGetProgramiv、glGetProgramInfoLog,这种类似于assert。
4.顶点着色器的作用:输入顶点数据,输入几何图元。通过设置顶点着色器中的指令,实现投影变换、透视分离等。
几何着色器的作用:输出拓扑结构。
片段着色器的作用:输入几何图元,确定像素颜色。