Geometry Shader 几何着色器
Advanced-OpenGL/Geometry-Shader
在顶点和片段着色器之间有一个可选的着色器舞台称为几何着色器。一个几何着色器以一组顶点作为输入,这些顶点形成一个单一的原语,例如一个点或一个三角形。几何着色器可以在将它们发送到下一个着色器阶段之前转换这些顶点。使几何着色器有趣的是它能够转换原始的原语(顶点集)到完全不同的原语,可能产生比最初给定的更多的顶点。
我们将向你展示一个几何着色器的例子,让你进入深刻:
#version 330 core
layout (points) in;
layout (line_strip, max_vertices = 2) out;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();
}
在一个几何体着色器的开始,我们需要声明我们从顶点着色器接收的原始输入的类型。我们通过在in关键字前面声明一个布局说明符来实现这一点。这个输入布局限定符可以取以下任何原始值:
points
:在绘制GL_POINTS原语(1)时。- lines:当绘制GL_LINES或GL_LINE_STRIP(2)时。
- lines_adjacency:gl_lines_adjacency或gl_line_strip_adjacency(4)。
triangles
:gl_triangle, GL_TRIANGLE_STRIP或GL_TRIANGLE_FAN(3)。- triangles_adjacency:gl_triangles_adjacency或gl_triangle_strip_adjacency(6)。
这些几乎是我们能够给予像glDrawArrays这样的渲染调用的所有渲染原语。如果我们选择将顶点绘制为gl_triangle,我们应该将输入限定符设置为triangle。括号内的数字表示单个原语包含的顶点的最小数目。
我们还需要指定几何着色器将输出的原始类型,我们通过在out关键字前面的布局说明符来做到这一点。与输入布局限定符一样,输出布局限定符可以取几个基本值:
points
- line_strip
- triangle_strip
通过这3个输出说明符,我们可以从输入原语创建几乎任何形状。例如,为了生成单个三角形,我们将指定triangle_strip作为输出并输出3个顶点。
几何着色器还要求我们设置它输出的顶点的最大数量(如果超过这个数量,OpenGL就不会绘制额外的顶点),我们也可以在out关键字的布局限定符中这样做。在这个特殊的例子中,我们将输出一个最大顶点数为2的line_strip。
如果你想知道什么是线带:线带将一组点绑在一起,在它们之间形成一条至少有2个点的连续的线。每一个额外的点都会在新的点和之前的点之间产生一条新的线,你可以在下图中看到5个点的顶点:
为了生成有意义的结果,我们需要一些方法来检索上一个着色器阶段的输出。GLSL给了我们一个叫做gl_in的内置变量,它的内部(可能)看起来像这样:
in gl_Vertex
{vec4 gl_Position;float gl_PointSize;float gl_ClipDistance[];
} gl_in[];
这里它被声明为一个接口块(如前一章所讨论的),它包含了一些有趣的变量,其中最有趣的是gl_Position,它包含了我们设置为顶点着色器输出的向量。
注意,它被声明为数组,因为大多数渲染原语包含一个以上的顶点。几何体着色器接收一个原语的所有顶点作为输入。
使用顶点着色器阶段的顶点数据,我们可以用两个几何着色器函数EmitVertex和EndPrimitive来生成新的数据。几何着色器要求你生成/输出至少一个你指定作为输出的原语。在本例中,我们希望至少生成一条线带原语。
#version 330 core
layout (points) in;
layout (line_strip, max_vertices = 2) out;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();
}
每次调用EmitVertex时,当前设置为gl_Position的向量被添加到输出原语中。每当调用EndPrimitive时,该原语的所有发出的顶点都被组合到指定的输出呈现原语中。在一个或多个EmitVertex调用之后,通过重复调用EndPrimitive,可以生成多个原语。在这个特殊的情况下,会产生两个顶点,这两个顶点与原始顶点的位置有一个小的偏移,然后调用EndPrimitive,将这两个顶点合并成一个包含两个顶点的单线带。
现在你(有点)知道几何着色器如何工作,你可能会猜这个几何着色器做什么。这个几何着色器以一个点原语作为输入,并创建一个以输入点为中心的水平线原语。如果我们渲染它,它看起来是这样的:
这还不是很令人印象深刻,但有趣的是,这个输出是使用下面的渲染调用生成的:
glDrawArrays(GL_POINTS, 0, 4);
虽然这是一个相对简单的例子,但它确实向你展示了我们如何使用几何着色器(动态)在飞行中生成新的形状。在本章的后面,我们将讨论一些有趣的效果,我们可以创建几何着色器,但现在我们将从一个简单的例子开始。
Using geometry shaders
为了演示几何着色器的使用,我们将渲染一个非常简单的场景,在z平面上以标准设备坐标绘制4个点。点的坐标为:
float points[] = {-0.5f, 0.5f, // top-left0.5f, 0.5f, // top-right0.5f, -0.5f, // bottom-right-0.5f, -0.5f // bottom-left
};
顶点着色器需要在z平面上绘制点,所以我们将创建一个基本的顶点着色器:
#version 330 core
layout (location = 0) in vec2 aPos;void main()
{gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
}
我们将为所有的点输出绿色,这些点是我们直接在fragment shader中编码的:
#version 330 core
out vec4 FragColor;void main()
{FragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
为点的顶点数据生成一个VAO和一个VBO,然后通过glDrawArrays绘制:
shader.use();
glBindVertexArray(VAO);
glDrawArrays(GL_POINTS, 0, 4);
结果是一个带有4个(很难看到的)绿点的黑暗场景:
但是我们不是已经学会了做这些吗?是的,现在我们要给这个小场景添加一些几何材质的魔法。
为了学习的目的,我们首先要创建一个所谓的传递几何着色器,它以一个原始点作为输入,并传递给下一个未修改的着色器:
#version 330 core
layout (points) in;
layout (points, max_vertices = 1) out;void main() { gl_Position = gl_in[0].gl_Position; EmitVertex();EndPrimitive();
}
现在这个几何着色器应该相当容易理解。它只是简单地发送接收到的未修改的顶点位置作为输入,并生成一个点原语。
一个几何着色器需要编译和链接到一个程序,就像顶点和片段着色器,但这次我们将使用GL_GEOMETRY_SHADER作为着色器类型创建着色器:
geometryShader = glCreateShader(GL_GEOMETRY_SHADER);
glShaderSource(geometryShader, 1, &gShaderCode, NULL);
glCompileShader(geometryShader);
[...]
glAttachShader(program, geometryShader);
glLinkProgram(program);
着色器编译代码与顶点和片段着色器相同。一定要检查编译或链接错误!
如果你现在编译和运行,你应该看到的结果看起来有点像这样:
它完全一样没有几何着色器!这有点乏味,我承认,但事实是,我们仍然能够画点,这意味着几何着色器工作,所以现在是时候让更多时髦的东西!
Let's build houses
画点和线不是那么有趣,所以我们要用一些创造性的几何体着色器在每个点的位置为我们画一个房子。我们可以通过设置几何体着色器的输出为triangle_strip来完成这个任务,并总共绘制三个三角形:两个用于正方形房屋,一个用于屋顶。
在OpenGL中的三角形条带是一种更有效的方法来绘制具有更少顶点的三角形。绘制第一个三角形后,每个随后的顶点在第一个三角形旁边生成另一个三角形:每3个相邻的顶点将形成一个三角形。如果我们有6个顶点组成一个三角形带,我们会得到以下三角形:(1,2,3),(2,3,4),(3,4,5)和(4,5,6);总共形成了4个三角形。一个三角形带至少需要3个顶点,并将生成N-2个三角形;有6个顶点,我们创建了6-2 = 4个三角形。下图说明了这一点:
使用一个三角形条带作为几何着色器的输出,我们可以很容易地创建我们想要的房子形状,用正确的顺序生成3个相邻的三角形。下面的图片显示了我们需要绘制哪些顶点来得到我们需要的三角形,蓝色的点是输入点:
这转化为以下几何着色器:
#version 330 core
layout (points) in;
layout (triangle_strip, max_vertices = 5) out;void build_house(vec4 position)
{ gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0); // 1:bottom-leftEmitVertex(); gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0); // 2:bottom-rightEmitVertex();gl_Position = position + vec4(-0.2, 0.2, 0.0, 0.0); // 3:top-leftEmitVertex();gl_Position = position + vec4( 0.2, 0.2, 0.0, 0.0); // 4:top-rightEmitVertex();gl_Position = position + vec4( 0.0, 0.4, 0.0, 0.0); // 5:topEmitVertex();EndPrimitive();
}void main() { build_house(gl_in[0].gl_Position);
}
这个几何着色器生成5个顶点,每个顶点是点的位置加上一个偏移量,形成一个大的三角形带。然后产生的原始元素被栅格化,碎片着色器运行在整个三角形上,对于我们渲染的每个点产生一个温室:
你可以看到每个房子都是由3个三角形组成的——都是用空间中的一个点绘制的。绿色的房子看起来确实有点乏味,所以让我们给每个房子一个独特的颜色来活跃一下它。为了做到这一点,我们将在顶点着色器中添加一个额外的顶点属性,每个顶点的颜色信息,并将其指向几何着色器,进一步将其转发到片段着色器。
更新后的顶点数据如下:
float points[] = {-0.5f, 0.5f, 1.0f, 0.0f, 0.0f, // top-left0.5f, 0.5f, 0.0f, 1.0f, 0.0f, // top-right0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // bottom-right-0.5f, -0.5f, 1.0f, 1.0f, 0.0f // bottom-left
};
然后我们更新顶点着色器,使用接口块将颜色属性转发到几何着色器:
#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[];
因为几何着色器作用于一组顶点作为它的输入,它的输入数据从顶点着色器总是表示为顶点数据的数组,即使我们现在只有一个顶点。
我们没有必要使用接口块来传输数据到几何着色器。我们也可以写成
in vec3 outColor[];
如果顶点着色器将颜色矢量转发为out vec3 outColor,这就可以工作了。然而,界面块更容易工作与着色器,如几何着色器。在实践中,几何着色器输入可以变得相当大,并分组他们在一个大的接口块阵列,使更多的意义。
我们还应该为下一个fragment shader阶段声明一个输出颜色矢量:
out vec3 fColor;
因为fragment shader只需要一个(插值的)颜色,所以转发多个颜色是没有意义的。因此,fColor向量不是一个数组,而是一个单独的向量。当发射一个顶点时,该顶点将在fColor中存储最后存储的值作为该顶点的输出值。对于房屋,在第一个顶点着色之前,我们可以用顶点着色器的颜色填充一次fColor:
fColor = gs_in[0].color; // gs_in[0] since there's only one input vertex
gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0); // 1:bottom-left
EmitVertex();
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0); // 2:bottom-right
EmitVertex();
gl_Position = position + vec4(-0.2, 0.2, 0.0, 0.0); // 3:top-left
EmitVertex();
gl_Position = position + vec4( 0.2, 0.2, 0.0, 0.0); // 4:top-right
EmitVertex();
gl_Position = position + vec4( 0.0, 0.4, 0.0, 0.0); // 5:top
EmitVertex();
EndPrimitive();
所有发出的顶点都将最后一个在fColor中存储的值嵌入到它们的数据中,它等于我们在其属性中定义的输入顶点的颜色。现在所有的房子都有了自己的颜色:
为了好玩,我们也可以假装是冬天,给最后一个顶点涂上一层雪:
fColor = gs_in[0].color;
gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0); // 1:bottom-left
EmitVertex();
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0); // 2:bottom-right
EmitVertex();
gl_Position = position + vec4(-0.2, 0.2, 0.0, 0.0); // 3:top-left
EmitVertex();
gl_Position = position + vec4( 0.2, 0.2, 0.0, 0.0); // 4:top-right
EmitVertex();
gl_Position = position + vec4( 0.0, 0.4, 0.0, 0.0); // 5:top
fColor = vec3(1.0, 1.0, 1.0);
EmitVertex();
EndPrimitive();
结果是这样的:
您可以在这里here. 将源代码与OpenGL代码进行比较
你可以看到,使用几何着色器,你可以变得非常有创意,即使是最简单的原语。因为图形是在你的GPU的超快硬件上动态生成的,这比你自己在顶点缓冲中定义这些图形要强大得多。几何体着色器对于简单的(经常重复的)形状来说是一个很好的工具,比如体素世界中的立方体或者户外大场地上的草叶。
Exploding objects
虽然画房子很有趣,但我们不会用那么多。这就是为什么我们现在要把它上升一个缺口和爆炸物体!这也是我们可能不会经常用到的东西,但做起来确实很有趣!
当我们说爆炸一个对象时,我们实际上不会炸掉我们宝贵的捆绑顶点集,但我们会在一段时间内沿着它们的法向量的方向移动每个三角形。效果是,整个物体的三角形似乎爆炸。爆炸三角形在背包模型上的效果看起来有点像这样:
这样的几何体着色器效果的伟大之处在于它适用于所有对象,不管它们的复杂性如何。
因为我们要把每个顶点转换成三角形法向量的方向我们首先需要计算这个法向量。我们需要做的是计算一个垂直于三角形表面的向量,只使用我们可以访问的3个顶点。你可能还记得在变换那一章里,我们可以用叉乘得到一个垂直于另外两个向量的向量。如果我们要找出平行于三角形表面的两个向量a和b我们可以通过对这两个向量做叉乘来找出它的法向量。下面的几何体着色器函数使用3个输入顶点坐标来获取法向量:
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。两个向量相减得到两个向量之差的向量。因为这三个点都在三角形平面上,彼此减去任意一个向量就得到一个平行于该平面的向量。请注意,如果我们在交叉函数中交换a和b,我们将得到一个指向相反方向的法向量——顺序在这里很重要!
现在我们知道了如何计算法向量,我们可以创建一个爆炸函数,它将这个法向量和一个顶点位置向量结合在一起。函数返回一个新的向量,这个向量沿着法向量的方向平移位置向量:
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到1.0之间的值。因为我们不想内爆对象,所以我们将sin值转换为[0,1]范围。然后使用结果值缩放法向量,并将结果方向向量添加到位置向量中。
爆炸效果的完整几何着色器,而绘制模型加载使用我们的模型加载器model loader,看起来有点像这样:
#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;vec4 explode(vec4 position, vec3 normal) { ... }vec3 GetNormal() { ... }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();
}
注意,我们在发射顶点之前也输出了适当的纹理坐标。
别忘了在你的OpenGL代码中设置时间统一
shader.setFloat("time", glfwGetTime());
结果是一个3D模型似乎会随着时间的推移不断地爆炸它的顶点,之后它又回到正常状态。虽然不完全超级有用,它确实向你展示了一个更高级的使用几何着色器。您可以在这里here. 将您的源代码与完整的源代码进行比较。
Visualizing normal vectors
为了改变我们现在要讨论一个使用几何着色器的例子,这是非常有用的:可视化任何对象的法向量。当编程照明着色器时,你最终会遇到难以确定原因的怪异视觉输出。照明错误的一个常见原因是不正确的法向量。要么是由于加载顶点数据不正确,要么是由于不正确地指定它们为顶点属性,或者是由于在着色器中不正确地管理它们。我们想要的是检测我们提供的法向量是否正确的方法。确定法向量是否正确的一个好方法是将它们可视化,碰巧的是,几何着色器是一个非常有用的工具。
这个想法是这样的:我们首先画一个没有几何着色器的普通场景,然后我们绘制第二次场景,但这一次只显示通过几何着色器生成的法向量。几何着色器以一个三角形原语作为输入,并从它们的法线方向上生成3条线——每个顶点有一个法线向量。在代码中它看起来像这样:
shader.use();
DrawScene();
normalDisplayShader.use();
DrawScene();
这次我们使用模型提供的顶点法线来创建一个几何着色器,而不是自己生成它。为了适应缩放和旋转(由于视图和模型矩阵),我们将用一个法线矩阵变换法线。几何着色器接收到它的位置向量作为视图-空间坐标,所以我们也应该转换法向量到相同的空间。这些都可以在顶点着色器中完成:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;out VS_OUT {vec3 normal;
} vs_out;uniform mat4 view;
uniform mat4 model;void main()
{gl_Position = view * model * vec4(aPos, 1.0); mat3 normalMatrix = mat3(transpose(inverse(view * model)));vs_out.normal = normalize(vec3(vec4(normalMatrix * aNormal, 0.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.4;uniform mat4 projection;void GenerateLine(int index)
{gl_Position = projection * gl_in[index].gl_Position;EmitVertex();gl_Position = projection * (gl_in[index].gl_Position + vec4(gs_in[index].normal, 0.0) * MAGNITUDE);EmitVertex();EndPrimitive();
}void main()
{GenerateLine(0); // first vertex normalGenerateLine(1); // second vertex normalGenerateLine(2); // third vertex normal
}
像这样的几何着色器的内容现在应该是不言自明的了。注意,我们将法向量与一个大小向量相乘,以限制显示的法向量的大小(否则它们会有点太大)。
由于可视化法线主要用于调试目的,我们可以在fragment shader的帮助下将它们显示为单色线(或者超级花哨的线,如果你喜欢的话):
#version 330 core
out vec4 FragColor;void main()
{FragColor = vec4(1.0, 1.0, 0.0, 1.0);
}
现在先用法线着色器渲染你的模型,然后用特殊的法线可视化着色器,你会看到这样的效果:
除了我们的背包现在看起来有点吓人之外,它给我们提供了一个非常有用的方法来确定模型的法向量是否真的正确。你可以想象像这样的几何着色器也可以用于添加毛皮到对象
你可以在这里你可以在这里找到OpenGL的源代码。找到OpenGL的源代码。