基元处理 - 几何着色器

这篇文章我们开始学习几何着色器。几何着色器和之前学的曲面细分着色器有些类似:都能处理顶点,且顶点数据都是按基元批量传递的。但“体验”感受下来,感觉几何着色器的自由度更大,它能增删改基元顶点,甚至是更改基元类型。让我们开始接下来关于几何着色器的学习,看看是不是也有这种感觉。

传递几何着色器

传递几何着色器就是对基元各个顶点进行“透传”处理,不对属性进行更改。我们通过这个简单的例子了解如何使用几何着色器。

代码清单 1 简单几何着色器
  • #version 450 core
  • layout (triangles) in;
  • layout (triangle_strip, max_vertices = 3) out;
  •    
  • void main(void)
  • {
  •     int i;
  •     for (i = 0; i < gl_in.length(); i++)
  •     {
  •         gl_Position = gl_in[i].gl_Position;
  •         EmitVertex();
  •     }
  •     EndPrimitive();
  • }

如代码清单 1 所示,在几何着色器中,需要通过布局限定符设置输入和输出的基元模式,此例中输入是 triangles,输出为 triangle_strip。并通过 max_vertices 指定着色器应该生成的最大顶点数量。

接着我们可以在传递进来的输入信息 gl_in 上进行操作。这边的 gl_in 数组我们在学习曲面细分着色器的时候已经感受过它了。

设置的操作为:每当我们设置完顶点相关的属性后,就可以调用 EmitVertex() 函数,告诉着色器我们已经完成对该顶点的操作。这个操作就有点像 git add 之后 git commit 的过程;对顶点的属性设置完毕后,就告诉着色器你修改完毕了,并让着色器记录相关内容。

一般都是调用 EmitVertex() max_vertices 次。调用次数不足,就会删除这个基元,我们在下节例子中再看。

我们调用 EndPrimitive() 函数,表明顶点已经添加到基元末尾。因为多个顶点可能会生成多个基元,这种情况的例子后面也会介绍。EndPrimitive() 这个函数可以理解成基元与基元之间的“分隔符”。如果只操作生成一个基元,也可以不显式调用 EndPrimitive,最后都会隐式调用一次。

代码清单 1 中的例子就是将顶点进行了简单复制传递。

删除几何着色器中的几何

在上一节中我们已经提及,不对对应基元调用 EmitVertex() 的话,就不会绘制关于这个基元对应的内容,这就可以达到剔除的作用。

样例也非常好理解,我们只需要关注代码清单 2 中第 31 行开始的条件判断。通过点积判断法线是否远离观察者,如果远离的话就剔除相关基元。

代码清单 2 剔除几何着色器
  1. #version 410 core
  2.  
  3. layout (triangles) in;
  4. layout (triangle_strip, max_vertices = 3) out;
  5.  
  6. in Vertex
  7. {
  8.     vec3 normal;
  9.     vec4 color;
  10. } vertex[];
  11.  
  12. out vec4 color;
  13.  
  14. uniform vec3 vLightPosition;
  15. uniform mat4 mvpMatrix;
  16. uniform mat4 mvMatrix;
  17.  
  18. uniform vec3 viewpoint;
  19.  
  20. void main(void)
  21. {
  22.     int n;
  23.  
  24.     vec3 ab = gl_in[1].gl_Position.xyz - gl_in[0].gl_Position.xyz;
  25.     vec3 ac = gl_in[2].gl_Position.xyz - gl_in[0].gl_Position.xyz;
  26.     vec3 normal = normalize(cross(ab, ac));
  27.     vec3 transformed_normal = mat3(mvMatrix) * normal;
  28.     vec4 worldspace = gl_in[0].gl_Position;
  29.     vec3 vt = normalize(viewpoint - worldspace.xyz);
  30.  
  31.     if (dot(normal, vt) > 0)
  32.     {
  33.          for (n = 0; n < 3; n++)
  34.          {
  35.              gl_Position = mvpMatrix * gl_in[n].gl_Position;
  36.              color = vertex[n].color;
  37.              EmitVertex();
  38.          }
  39.          EndPrimitive();
  40.     }
  41. }

图 1 是这个例子的运行结果,可以看到模型的一个面被剔除了。

图1 剔除的几何体

修改几何着色器中的几何体

以上的例子是对基元顶点进行复制和剔除操作,当然,对顶点数据也能进行修改。

修改的例子也非常易懂,如代码清单 3 所示,我们把原先基元的各个顶点往法线方向移动一段距离,以此达到“分解”几何体的效果。

代码清单 3 沿着法向量推动平面
  • #version 410 core
  •  
  • layout (triangles) in;
  • layout (triangle_strip, max_vertices = 3) out;
  •  
  • in VS_OUT
  • {
  •     vec3 normal;
  •     vec4 color;
  • } gs_in[];
  •  
  • out GS_OUT
  • {
  •     vec3 normal;
  •     vec4 color;
  • } gs_out;
  •  
  • uniform float explode_factor = 0.2;
  •  
  • void main(void)
  • {
  •     vec3 ab = gl_in[1].gl_Position.xyz - gl_in[0].gl_Position.xyz;
  •     vec3 ac = gl_in[2].gl_Position.xyz - gl_in[0].gl_Position.xyz;
  •     vec3 face_normal = -normalize(cross(ab, ac));
  •     for (int i = 0; i < gl_in.length; i++)
  •     {
  •          gl_Position = gl_in[i].gl_Position + vec4(face_normal * explode_factor, 0.0);
  •          gs_out.normal = gs_in[i].normal;
  •          gs_out.color = gs_in[i].color;
  •          EmitVertex();
  •     }
  •     EndPrimitive();
  • }

这个例子的运行结果如图 2 所示,可以看到,因为基元往外移动了一段距离,所以我们可以看到各个分离的基元。

图2 使用几何着色器分解模型

在几何着色器中生成几何体

以上的例子只是“小操作”,在几何着色器中还能生成新的顶点。这节的例子是把原先的四个面都“拉出”一个尖峰形状,效果如图 3 所示。

代码清单 4 在几何着色器中生成新顶点
  1. #version 410 core
  2.  
  3. layout (triangles) in;
  4. layout (triangle_strip, max_vertices = 12) out;
  5.  
  6. uniform float stretch = 0.7;
  7.  
  8. flat out vec4 color;
  9.  
  10. uniform mat4 mvpMatrix;
  11. uniform mat4 mvMatrix;
  12.  
  13. void make_face(vec3 a, vec3 b, vec3 c)
  14. {
  15.     vec3 face_normal = normalize(cross(c - a, c - b));
  16.     vec4 face_color = vec4(1.0, 0.4, 0.7, 1.0) * (mat3(mvMatrix) * face_normal).z;
  17.     gl_Position = mvpMatrix * vec4(a, 1.0);
  18.     color = face_color;
  19.     EmitVertex();
  20.  
  21.     gl_Position = mvpMatrix * vec4(b, 1.0);
  22.     color = face_color;
  23.     EmitVertex();
  24.    
  25.     gl_Position = mvpMatrix * vec4(c, 1.0);
  26.     color = face_color;
  27.     EmitVertex();
  28.  
  29.     EndPrimitive();
  30. }
  31.  
  32. void main(void)
  33. {
  34.     int n;
  35.     vec3 a = gl_in[0].gl_Position.xyz;
  36.     vec3 b = gl_in[1].gl_Position.xyz;
  37.     vec3 c = gl_in[2].gl_Position.xyz;
  38.  
  39.     vec3 d = (a + b) * stretch;
  40.     vec3 e = (b + c) * stretch;
  41.     vec3 f = (c + a) * stretch;
  42.  
  43.     a *= (2.0 - stretch);
  44.     b *= (2.0 - stretch);
  45.     c *= (2.0 - stretch);
  46.  
  47.     make_face(a, d, f);
  48.     make_face(d, b, e);
  49.     make_face(e, c, f);
  50.     make_face(d, e, f);
  51.    
  52.     EndPrimitive();
  53. }

要“拉出”一个尖峰,实际上是从一个三角面变成了四个三角面,即需要设置 12 个顶点。这就是代码清单 4 中设置 max_vertices 为 12 的依据。

make_face() 函数用于生成面,注意不要忘记调用 EndPrimitive()。因为正如之前所说,现在涉及到多个基元了,所以需要 EndPrimitive 进行“定界”。

通过计算得到各个面的顶点位置,最后再调用 make_face() 四次即可。

图3 使用几何着色器的基本曲面细分

书中对这个例子使用到了“曲面细分”的字眼。可以看到这个例子和我们之前学习的曲面细分着色器中的山脉例子类似,都是增加点,同时点的高度信息发生了变化。这也给到了一个提示,虽然几何着色器自由度高,但是可能不会产生最佳性能,需要思考一下有没有别的更好的实现方式。

修改几何着色器中的基元类型

几何着色器另一个让人觉得强大的地方是可以改变基元类型。

虽然听着感觉比较复杂,但是相关着色器代码并不复杂。如代码清单 5 所示,只是通过位置限定符把基元从输入的三角形 triangles,变成了线 line_strip。接着指定法向量线段的顶点位置即可。最终的效果如图 4 所示。

代码清单 5 显示几何着色器面法向量
  • #version 410 core
  •  
  • layout (triangles) in;
  • layout (line_strip, max_vertices = 4) out;
  •  
  • uniform mat4 mv_matrix;
  • uniform mat4 proj_matrix;
  •  
  • in VS_OUT
  • {
  •     vec3 normal;
  •     vec4 color;
  • } gs_in[];
  •  
  • out GS_OUT
  • {
  •     vec3 normal;
  •     vec4 color;
  • } gs_out;
  •  
  • uniform float normal_length = 0.2;
  •  
  • void main(void)
  • {
  •     mat4 mvp = proj_matrix * mv_matrix;
  •     vec3 ab = gl_in[1].gl_Position.xyz - gl_in[0].gl_Position.xyz;
  •     vec3 ac = gl_in[2].gl_Position.xyz - gl_in[0].gl_Position.xyz;
  •     vec3 face_normal = normalize(cross(ab, ac));
  •  
  •     vec4 tri_centroid = (gl_in[0].gl_Position +
  •                          gl_in[1].gl_Position +
  •                            gl_in[2].gl_Position) / 3.0;
  •     gl_Position = mvp * tri_centroid;
  •     gs_out.normal = gs_in[0].normal;
  •     gs_out.color = gs_in[0].color;
  •     EmitVertex();
  •  
  •     gl_Position = mvp * (tri_centroid + vec4(face_normal * normal_length, 0.0));
  •     gs_out.normal = gs_in[0].normal;
  •     gs_out.color = gs_in[0].color;
  •     EmitVertex();
  •     EndPrimitive();
  •  
  •     gl_Position = mvp * gl_in[0].gl_Position;
  •     gs_out.normal = gs_in[0].normal;
  •     gs_out.color = gs_in[0].color;
  •     EmitVertex();
  •  
  •     gl_Position = mvp * (gl_in[0].gl_Position + vec4(gs_in[0].normal * normal_length, 0.0));
  •     gs_out.normal = gs_in[0].normal;
  •     gs_out.color = gs_in[0].color;
  •     EmitVertex();
  •  
  •     EndPrimitive();
  • }
图4 在几何着色器中绘制模型法向量

多次视口转化

前面的例子都是指定 gl_Position 内置变量,以达到顶点位置信息的修改。但是顶点的信息不只有 gl_Position,还有比如这节介绍的视口信息 gl_ViewportIndex

设置过程如代码清单 6 所示,几何着色器会被调用 4 次,gl_ViewportIndex 依据 gl_InvocationID 确定,所以每次调用的几何体都会渲染到各自的视口。

为什么会调用 4 次,书中没有讲,不过实验下来得知,是 invocations 属性确定的。

代码清单 6 渲染到几何着色器的多个视口
  • #version 420 core
  •  
  • layout (triangles, invocations = 4) in;
  • layout (triangle_strip, max_vertices = 3) out;
  •  
  • layout (std140, binding = 0) uniform transform_block
  • {
  •     mat4 mvp_matrix[4];
  • };
  •  
  • in VS_OUT
  • {
  •     vec4 color;
  • } gs_in[];
  •  
  • out GS_OUT
  • {
  •     vec4 color;
  • } gs_out;
  •  
  • void main(void)
  • {
  •     for (int i = 0; i < gl_in.length(); i++)
  •     {
  •          gs_out.color = gs_in[i].color;
  •          gl_Position = mvp_matrix[gl_InvocationID] * gl_in[i].gl_Position;
  •          gl_ViewportIndex = gl_InvocationID;
  •          EmitVertex();
  •     }
  •     EndPrimitive();
  • }

使用 glViewportIndexedf()函数设置有索引的视口信息,其原型为:

  • typedef void glViewportIndexedf(GLuint index,
  •                                 GLfloat x,
  •                                 GLfloat y,
  •                                 GLfloat w,
  •                                 GLfloat h);

可以看到 glViewportIndexedf 和 glViewport 类似,只是多了一个索引参数指定。

如代码清单 7 所示,我们将整个窗体分为两行两列的四个。同时我们在渲染函数中传递不同的变换矩阵,就可以看到如图 5 的结果,各个视口里有不同状态的立方体。

代码清单 7 设置多个视口
  • // Each rectangle will be 7/16 of the screen
  • float viewport_width = (float)(7 * info.windowWidth) / 16.0f;
  • float viewport_height = (float)(7 * info.windowHeight) / 16.0f;
  •  
  • // Four rectangles = lower left first
  • glViewportIndexedf(0, 0, 0, viewport_width, viewport_height);
  • glViewportIndexedf(1,
  •     info.windowWidth - viewport_width, 0,
  •     viewport_width, viewport_height);
  • glViewportIndexedf(2,
  •     0, info.windowHeight - viewport_height,
  •     viewport_width, viewport_height);
  • glViewportIndexedf(3,
  •     info.windowWidth - viewport_width, info.windowHeight - viewport_height,
  •     viewport_width, viewport_height);
图5 渲染到多个视口的结果

总结

通过这篇文章,我们了解了几何着色器的基本概念,几何着色器对基元顶点可以进行“增删改”操作,甚至能直接改变基元的类型。

本节还有一个“通过几何着色器引入新基元类型”的例子没有说明。因为对其原理还不是很了解,感觉跟片段着色器的内容有点关联。正好马上就要开始片段着色器的学习了,学好了之后再回过头来看看是否能理解这个例子。

做个标记,等片段着色器学好了再来看这个例子。