本章主要解决1个问题:
如何在OpenGL使用几何着色器?
引言
除了顶点着色器和片元着色器,OpenGL还提供了几个额外的着色器可供使用,本章讲的几何着色器(geometry shader)就是其中之一。几何着色器位于片元着色器之前(甚至位于图元装配之前),接收一系列顶点组成的图元作为输入。在几何着色器中进行处理之后,输出的顶点可以比输入的顶点少(裁剪),也可以比输入的顶点多(细分),取决于具体的实现。
下面先来介绍一下几何着色器的使用方法。
使用方法
作为一段独立的着色器,它和顶点着色器一样需要设置输入和输出数据的类型:
layout (points) in;
layout (line_strip, max_vertices = 2) out;
第一行设置输入图元类型为顶点。也就是说,几何着色器会对每一个需要进行渲染的顶点都会执行一次。第二行声明了几何着色器输出的图元类型为直线条带,输入的最大顶点数量为2。条带是一种可以复用顶点的结构,比如说:如果你有3个顶点分别为A、B、C,A和B组成一条直线,B和C也会组成一条直线,再加一个顶点D就可以和C再组成一条直线。所以,直线条带会输出n-1条直线。所有的输入类型和图元绘制类型的对应关系包括:
输入图元类型 | 绘制命令模式 |
---|---|
points | GL_POINTS、GL_PATCHES |
lines | GL_LINES、GL_LINE_STRIP、GL_LINE_LOOP、GL_PATCHES |
triangles | GL_TRIANGLES、GL_TRIANGLE_STRIP、GL_TRIANGLE_FAN |
lines_adjacency | GL_LINES_ADJACENCY、GL_LINE_STRIP_ADJACENCY |
triangles_adjacency | GL_TRIANGLES_ADJACENCY、GL_TRIANGLE_STRIP_ADJACENCY |
几乎所有使用glDrawArrays函数调用的参数都包括进去了。如果绘制的是三角形,我们就要指定输入的类型为triangles。
要产生有意义的结果,我们还要用到GLSL内置的输入块结构gl_Vertex。它的完整定义是这样的:
in gl_Vertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
} gl_in[];
注意没,这个内置的输入变量是一个数组。哪怕你就输入一个顶点,它也必须以数组的形式去调用(gl_in[0])。有了输入数据之后,我们就可以进行计算了:
void main() {
gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4( 0.1, 0.0, 0.0, 0.0);
EmitVertex();
EndPrimitive();
}
上面的代码对输入的顶点往左移0.1的距离,然后调用EmitVertex()函数生成一个新顶点。再将顶点单往右移0.1的距离,再调用EmitVertex()函数生成一个新顶点。每次调用EmitVertex()函数时,几何着色器都会向当前条带末尾添加一个新顶点。EndPrimitive()函数的作用是中断当前条带,并且通知OpenGL在下一次调用EmitVertex()的时候开始一组新条带。如果你要输出的是顶点,调用EndPrimitive()不会有什么影响但它也是正确的。养成好习惯,在输出结束的时候调用EndPrimitive()。
好了,几何着色器的原理就是这样简单。我们来创建一个新工程看看几何着色器的使用效果:
float points[] = {
-0.5f, 0.5f, // 左上角
0.5f, 0.5f, // 右上角
0.5f, -0.5f, // 右下角
-0.5f, -0.5f // 左下角
};
使用上面的顶点数据,新建一个顶点着色器配合这些顶点:
#version 330 core
layout (location = 0) in vec2 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
}
再新建一个简单的片元着色器,直接输出一个固定颜色值,例如绿色:
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
重要的一项工作来了,我们要修改Shader类使其能编译几何着色器,我们直接在Shader的构造函数末尾添加上几何着色器的路径,在构造函数中添加对几何着色器的解析和编译:
Shader::Shader(const GLchar* vertexPath, const GLchar* fragmentPath, const GLchar* geometryPath) {
//!1、读取着色器的代码
...
std::string geometryCode;
...
std::ifstream gShaderFile;
//确保文件流会输出异常
...
gShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
try {
//打开文件
...
gShaderFile.open(geometryPath);
std::stringstream vShaderStream, fShaderStream, gShaderStream;
//读取文件到流中
...
gShaderStream << gShaderFile.rdbuf();
//关闭文件
...
gShaderFile.close();
//将流转换为字符串
...
geometryCode = gShaderStream.str();
}
catch (std::ifstream::failure e) {
std::cout << "错误:读取文件失败,请检查文件是否存在!" << std::endl;
}
//!2、编译着色器
...
const char* gShaderCode = geometryCode.c_str();
unsigned int vertex, fragment, geometry;
...
//顶点着色器
...
//片元着色器
...
//几何着色器
geometry = glCreateShader(GL_GEOMETRY_SHADER);
glShaderSource(geometry, 1, &gShaderCode, NULL);
glCompileShader(geometry);
glGetShaderiv(geometry, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(geometry, 512, NULL, infoLog);
std::cout << "编译几何着色器失败,错误信息:" << infoLog << std::endl;
}
//着色器程序
...
glAttachShader(ID, geometry);
glLinkProgram(ID);
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(ID, 512, NULL, infoLog);
std::cout << "连接着色器程序失败,错误信息:" << infoLog << std::endl;
}
//删除着色器程序
...
glDeleteShader(geometry);
}
添加完加载和编译几何着色器的接口之后,我们使用的方法就和使用顶点着色器(片元着色器)没有什么区别了:
Shader shader("shader.vs", "shader.fs", "shader.gs");
如果几何着色器无法通过编译,它一样会报错,按照错误提示去修正代码即可。
最后,绘制的代码非常简单,简单到令人发指的地步:
//绘制4个顶点
shader.use();
glBindVertexArray(VAO);
glDrawArrays(GL_POINTS, 0, 4);
编译运行,如果没有出错,你看到的窗口应该是这样的:
虽然我们绘制的是点,但最后输出的结果却是四条线段,这就是几何着色器的功劳。(突然灵光一闪,着色器代码在应用上很像是一个脚本语言,只有当程序运行的时候才能知道对不对,而且无法调试,这样在查找问题的时候就非常困难了。)
附上源码以供参考。
绘制一些白顶房子
光绘制几条直线显然不能让我们觉得有啥兴奋的,我们需要一些创造性的绘制。这节里我们就用几何着色器来绘制一些房子,房子本身是一种颜色,并且它还有一个白色的屋顶。直接用绘制三角形的方式也非常容易,但是我们是有自尊心的图形程序员,怎么能用这么简单的方式呢,我们必须要用顶点着色器!
这里我们用到的输出格式是三角形条带(triangle_strip)。三角形条带的原理是将输出的所有的顶点组成n-2个三角形(n为顶点个数),每一个三角形都与另一个三角形共用两个顶点,形成如下的条带状:
6个顶点按照(1,2,3),(2,3,4),(3,4,5),(4,5,6)这种方式组合成4个三角形,这些三角形形成的整体形状就是一个条状,所以称为三角形条带。我们要弄的房子要有2个三角形做房体,一个三角形做屋顶,像这样:
根据这幅原理图,我们就能写几何着色器了:
#version 330 core
layout (points) in;
layout (triangle_strip, max_vertices = 5) out;
void main() {
gl_Position = gl_in[0].gl_Position + vec4(-0.2, -0.2, 0.0, 0.0); // 左下
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4( 0.2, -0.2, 0.0, 0.0); // 右下
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4(-0.2, 0.2, 0.0, 0.0); // 左上
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4( 0.2, 0.2, 0.0, 0.0); // 右上
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4( 0.0, 0.4, 0.0, 0.0); // 屋顶
EmitVertex();
EndPrimitive();
}
上面的代码只是输出了需要的三角形,但这时候没有各自的颜色信息,如果就此编译运行,得到的房子颜色都是一样的。这时候,我们需要在顶点数据中添加颜色的数据了:
float points[] = {
-0.5f, 0.5f, 1.0f, 0.0f, 0.0f, // 左上
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // 右下
-0.5f, -0.5f, 1.0f, 1.0f, 0.0f // 左下
};
相应地,修改顶点着色器的实现,接受输入的颜色,并将其作为输出直接传递到下一阶段的着色器:
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;
out VS_OUT {
vec3 color;
} vs_out;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
vs_out.color = aColor;
}
在几何着色器中添加输入和输出数据结构:
in VS_OUT {
vec3 color;
} gs_in[];
out vec3 fColor;
从顶点着色器中接受颜色的输入,并且输出颜色给片元着色器使用。
当然,这里我们不一定要使用输入/输出块,用平常的输入结构也可以:
in vec3 vColor[];
要注意的是必须定义成数组形式。这是使用块是为了养成一种习惯,在实际应用中,几何着色器接收的输入通常是一大块的数据。
因为我们要让每个屋子的颜色都不同,所以每个顶点输出的时候都必须带有颜色信息,前面四个顶点颜色都一样,最后一个顶点(屋顶)要设置成白色。当使用EmitVertex函数生成一个顶点的时候,顶点信息中也包含fColor的信息,所以我们需要在EmitVertex之前将需要传递到片元着色器的颜色信息赋值好:
fColor = gs_in[0].color;
gl_Position = gl_in[0].gl_Position + vec4(-0.2, -0.2, 0.0, 0.0); // 左下
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4( 0.2, -0.2, 0.0, 0.0); // 右下
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4(-0.2, 0.2, 0.0, 0.0); // 左上
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4( 0.2, 0.2, 0.0, 0.0); // 右上
EmitVertex();
fColor = vec3(1.0, 1.0, 1.0);
gl_Position = gl_in[0].gl_Position + vec4( 0.0, 0.4, 0.0, 0.0); // 屋顶
EmitVertex();
EndPrimitive();
注意在屋顶的颜色设置成白色!
最后,修改一下片元着色器使其接受颜色输入,修改一下顶点的属性。编译运行代码, 我们就能看到可爱的雪顶屋子了!
哈哈,一切尽在掌握之中!超出掌握的东西下载这里的源码比对一下。
对模型搞事情
更加刺激的事情来了,我们要对前面的模型搞动作啦。这次先搞两个动作,第一个是实现一个炸成碎片的效果,第二个是将每个顶点的法向量显示出来。听上去很爽是不是,立即出发!
爆炸效果
爆炸效果有一个简单的实现方法,将每个面片都沿着法线移动一段距离,这段距离可以根据时间来确定。这样,随着时间的变化,模型就会出现爆炸后收缩然后在爆炸的过程。实现的关键就是如何获得片面的法向量!
根据我们之前学到的知识,当我们拥有了一个面片的三个顶点后,我们很容易就能计算出该面片的法向量,使用的方法就是叉乘。通过三个顶点的减法操作计算出两个方向向量,然后将这两个向量叉乘,得出法向量,最后将法向量规范化后使用。
vec3 GetNormal() {
vec3 a = vec3 (gl_in[0].gl_Position) - vec3(gl_in[1].gl_Position);
vec3 b = vec3 (gl_in[2].gl_Position) - vec3(gl_in[1].gl_Position);
return normalize(cross(a, b));
}
注意在叉乘时不能把a和b交换,否则得到的结果会很诡异。
有了法向量后,我们再创建一个函数用于计算顶点沿着法向量方向移动一定距离后的位置。我们把这个函数命名为explode:
vec4 explode(vec4 position, vec3 normal)
{
float magnitude = 2.0;
vec3 direction = normal * ((sin(time) + 1.0) / 2.0) * magnitude;
return position + vec4(direction, 0.0);
}
sin计算值有正有负,我们不希望看到它往里面缩的样子,所以将它加上了1.0确保其永远为正数。除以2.0是不希望它一下子爆炸的太厉害,要让我们看到爆炸过程才行。magnitude是爆炸的范围,这个值越大,爆炸的范围就越大。回头记得试试改变这些数值看看效果。
最终的几何着色器代码就是:
#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices = 3) out;
in VS_OUT{
vec2 texCoords;
}gs_in[];
out vec2 TexCoords;
uniform float time;
vec3 GetNormal() {
vec3 a = vec3 (gl_in[0].gl_Position) - vec3(gl_in[1].gl_Position);
vec3 b = vec3 (gl_in[2].gl_Position) - vec3(gl_in[1].gl_Position);
return normalize(cross(a, b));
}
vec4 explode (vec4 position, vec3 normal) {
float magintude = 2.0;
vec3 direction = normal * ((sin(time) + 1.0) / 2.0) * magintude;
return position + vec4(direction, 0.0);
}
void main() {
vec3 normal = GetNormal();
gl_Position = explode(gl_in[0].gl_Position, normal);
TexCoords = gs_in[0].texCoords;
EmitVertex();
gl_Position = explode(gl_in[1].gl_Position, normal);
TexCoords = gs_in[1].texCoords;
EmitVertex();
gl_Position = explode(gl_in[2].gl_Position, normal);
TexCoords = gs_in[2].texCoords;
EmitVertex();
EndPrimitive();
}
别忘了在渲染的时候设置时间值:
shader.setFloat("time", glfwGetTime());
编译运行代码,就可以看到类似下面这种酷酷的爆炸效果了:
附上 源码以供参考。
显示法向量
显示法向量的操作可以说非常有实践意义。当我们实现光照效果时,有时会陷入到看到了诡异的效果却对原因一筹莫展的境地。由于加载顶点出错导致的顶点法向量出错是一个较为常见的原因。这时候,如果能显示出物体的法向量,对我们排查错误必然有非常大的帮助。
要绘制法向量,我们就必须获取到顶点数据。使用这些顶点数据来计算新的顶点作为输出,这样就无法完成原有的绘制工作。此时,我们可以先进行一遍正常的绘制,然后进行一遍法向量的绘制,这样我们有能看到物体,又能看到顶点上的法向量。
这次用的法向量是直接从模型中传入的(模型中一般都会有法向量信息,如果不这样,每个片面还要计算法向量实在是太费事了。)。进行了适当的变换之后,将法向量传递到几何着色器中。由于几何着色器工作在裁剪空间中,所以我们的法向量也需要变换成裁剪空间的坐标,具体的变换原理可以参考之前的文章:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
out VS_OUT {
vec3 normal;
} vs_out;
uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
mat3 normalMatrix = mat3(transpose(inverse(view * model)));
vs_out.normal = normalize(vec3(projection * vec4(normalMatrix * aNormal, 1.0)));
}
传递到几何着色器中后,我们要对每个顶点生成一条直线。这是,最好的方法就是将生成直线的操作封装成一个函数,这个函数就只生成一个点的法向量线段:
#version 330 core
layout (triangles) in;
layout (line_strip, max_vertices = 6) out;
in VS_OUT {
vec3 normal;
} gs_in[];
const float MAGNITUDE = 0.05;
void GenerateLine(int index)
{
gl_Position = gl_in[index].gl_Position;
EmitVertex();
gl_Position = gl_in[index].gl_Position + vec4(gs_in[index].normal, 0.0) * MAGNITUDE;
EmitVertex();
EndPrimitive();
}
void main()
{
GenerateLine(0); // 顶点0
GenerateLine(1); // 顶点1
GenerateLine(2); // 顶点2
}
MAGNITUDE变量用来设置显示向量的长短,在笔者的机子上设置成0.05就够了,再大就像是长毛的猴子。
片元着色器非常简单,只需输出一个固定颜色值(绿色)就行:
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
为啥选绿色?不是有句话叫:要想生活过得去,身上就得带点绿。
哈哈,开个玩笑。是时候编译运行我们的代码了,记得先去掉爆炸效果:
太帅了!!!来来来,源码拿走。
总结
在这章中,我们学到了如何使用几何着色器,并且使用它绘制了简单的线条、复杂一点的房子、更加复杂的爆炸和法向量效果。使用了这么多次,对其原理和使用方法也非常了解了:一个着色器无非就是输入和输出。几何着色器的输入是一系列顶点着色器的输出以及内置的全局输入变量,输出可以设置输出的格式以及输出顶点数量,通过EmitVertex和EndPrimitive函数控制输出的数量以及组成的形状。
下一篇
目录
上一篇
参考资料
OpenGL编程指南(第8版)(有点难的书,建议入门后再看)
www.learnopengl.com(非常好的网站,建议学习)