管线

最近加班有点厉害,时隔一个多月才重拾 OpenGL 的学习。对着上次第 2 章的笔记,能快速回忆起之前的知识点,这让我感受到了笔记的好处。由此也勉励自己能更好的记录自己所学的内容。

第 3 章通读下来感觉是一个摘要,把各个管线阶段都一一简述了一下。也正因为是简述,所以实验起来略有困难,而且不解的地方也较多。

图 1 是涉及到的管线的概括,其中圆角框表示固定函数阶段,方角框表示可编程阶段。这章内容就是各个阶段的摘要概述。本篇文章针对其中的可编程阶段,跟着做了实验。

图 1 简化图形管线

向顶点着色器传递数据

从图 1 可以看到顶点着色器是管线中第一个可编程阶段。在此之前还有一个顶点获取阶段,该阶段自动向顶点着色器提供输入。

顶点属性表示顶点数据引入 OpenGL 管线的手段。若要声明一个顶点属性,则需要在顶点着色器中用 in 储存限定符声明一个变量。清单 3.1 中使用 in 声明的 offset 变量就是顶点属性,借此我们就可以在之前固定的顶点位置上加上输入可变的偏移数据。

原书清单 3.1 顶点属性声明
  • #version 450 core
  •  
  • // 'offset' is an input vertex attribute
  • layout (location = 0) in vec4 offset;
  •  
  • void main(void)
  • {
  •     const vec4 vertices[3] = vec4[3](vec4(0.25, -0.25, 0.5, 1.0),
  •                                         vec4(-0.25, -0.25, 0.5, 1.0),
  •                                         vec4(0.25, 0.25, 0.5, 1.0));
  •  
  •     // Add 'offset' to our hard-coded vertex position
  •     gl_Position = vertices[gl_VertexID] + offset;
  • }

可以用顶点属性函数 glVertexAttrib*() 多个变量中的一个来指示本阶段用什么来填充该变量。在本例中使用 glVertexAttrib4fv() 函数:

  • void glVertexAttrib4fv(GLuint index, const GLfloat *v);

第二个参数好理解,就是需要输入的 'float vector' 数据。而第一个参数需要看到清单 3.1 中的 layout (location = 0),这是一个布局限定符,此处将顶点属性的位置设置为 0。顶点属性位置也就是第一个参数 index,所以这边我们需要对照传入 0。

原书清单 3.2 更新顶点属性
  • // Our rendering function
  • virtual void render(double currentTime)
  • {
  •     const GLfloat color[] = { sin(currentTime) * 0.5f + 0.5f,
  •                                  cos(currentTime) * 0.5f + 0.5f,
  •                                  0.0f, 1.0f };
  •     glClearBufferfv(GL_COLOR, 0, color);
  •  
  •     // Use the program object we created earlier for rendering
  •     glUseProgram(rendering_program);
  •  
  •     GLfloat attrib[] = { (float)sin(currentTime) * 0.5f,
  •                            (float)cos(currentTime) * 0.6f,
  •                            0.0f, 0.0f };
  •  
  •     // Update the value of input attribute 0
  •     glVertexAttrib4fv(0, attrib);
  •  
  •     // Draw one triangle
  •     glDrawArrays(GL_TRIANGLES, 0, 3);
  • }

清单 3.2 使用 glVertexAttrib4fv 将每帧不断变化的 offset 传入清单 3.1 的顶点着色器。如例 1 中的视频所示,运行程序后就能得到一个以椭圆轨迹运行的三角形。

例1 运动的三角形

书中所给代码都是局部的。像例 1 中还使用上一章中的简单片段着色器(青色)。

同样,流程也是上一章的基本流程:基本的顶点着色器、片段着色器;编译着色器;创建和绑定顶点数组对象。

在阶段之间传递数据

在前面的例子中,我们已经能了解到数据输入输出的概念。接着肯定会有一个自然的想法:能否让颜色也逐帧变化?颜色数据可以通过顶点着色器传入,顶点着色器能否再将这个数据传出?

清单 3.3 实现了上述想法,内容基于清单 3.1,多声明了一个 color 输入变量。注意 color 变量的位置限定符指定属性位置为 1,后续通过 glVertexAttrib4fv 传入此属性时,index 参数也要设置为 1。再者,多声明了一个输出变量 vs_color,将颜色数据输入(color)传递给 vs_color(输出)。

原书清单 3.3 带有一个输出的顶点着色器
  • #version 450 core
  •  
  • // 'offset' and 'color' are input vertex attributes
  • layout (location = 0) in vec4 offset;
  • layout (location = 1) in vec4 color;
  •  
  • // 'vs_color' is an output that will be sent to the next shader stage
  • out vec4 vs_color;
  •  
  • void main(void)
  • {
  •     const vec4 vertices[3] = vec4[3](vec4(0.25, -0.25, 0.5, 1.0),
  •                                         vec4(-0.25, -0.25, 0.5, 1.0),
  •                                         vec4(0.25, 0.25, 0.5, 1.0));
  •  
  •     // Add 'offset' to our hard-coded vertex position
  •     gl_Position = vertices[gl_VertexID] + offset;
  •  
  •     // Output a fixed value for vs_color
  •     vs_color = color;
  • }

清单 3.4 是一个片段着色器,它接收顶点着色器的输入,并将其按原本值输出。

原书清单 3.4 带一个输入的片段着色器
  • #version 450 core
  •  
  • // Input from the vertex shader
  • in vec4 vs_color;
  •  
  • // Output to the framebuffer
  • out vec4 color;
  •  
  • void main(void)
  • {
  •     // Simply assign the color we were given by the vertex shader to our output
  •     color = vs_color;
  • }

同样我们使用 glVertexAttrib4fv 函数来指定 color 属性的值,如例 2 中我们就可以看到一个颜色变化的三角形。

例2 颜色变化的三角形

接口块

当需要传递大量数据时,接口块显得更加紧凑和统一。接口块的概念和结构体非常类似,所以我们直接看代码,从中学习如何使用。

从清单 3.5 中可以看到声明的接口块名称为 VS_OUT,可以类比结构体名称。接口的实例名称是 vs_out,可以类比结构体变量名称。这边的接口块使用 out 关键字进行声明的,因为其用作输出。

接口块数据像这样使用:vs_out.color。可以看到使用方法和结构体也一致,非常好懂。

原书清单 3.5 带有一个输出接口块的顶点着色器
  • #version 450 core
  •  
  • // 'offset' and 'color' are input vertex attributes
  • layout (location = 0) in vec4 offset;
  • layout (location = 1) in vec4 color;
  •  
  • // Declare VS_OUT as an output interface block
  • out VS_OUT
  • {
  •     vec4 color;  // Send color to the next stage
  • } vs_out;
  •  
  • void main(void)
  • {
  •     const vec4 vertices[3] = vec4[3](vec4(0.25, -0.25, 0.5, 1.0),
  •                                         vec4(-0.25, -0.25, 0.5, 1.0),
  •                                         vec4(0.25, 0.25, 0.5, 1.0));
  •  
  •     // Add 'offset' to our hard-coded vertex position
  •     gl_Position = vertices[gl_VertexID] + offset;
  •  
  •     // Output a fixed value for vs_color
  •     vs_out.color = color;
  • }

再看到清单 3.4 中使用接口块的片段着色器,接口块名称必须也是 VS_OUT,实例名称随意。这点也和结构体的惯用手段一致,想要数据内容一致,就使用同一名称的结构体,而声明的变量叫什么则没有关系。注意这边接口块使用 in 关键字声明,因为其获取的是输入数据。

原书清单 3.4 带一个输入的片段着色器
  • #version 450 core
  •  
  • // Declare VS_OUT as an input interface block
  • in VS_OUT
  • {
  •     vec4 color;  // Send color to the next stage
  • } fs_in;
  •  
  • // Output to the framebuffer
  • out vec4 color;
  •  
  • void main(void)
  • {
  •     // Simply assign the color we were given by the vertex shader to our output
  •     color = fs_in.color;
  • }

实验时将两边的接口块名称或其中的成员故意写成不一致,虽然没编译错误,但是运行时发现三角形没有显示。

细分曲面

关于细分曲面,书中对它的定义为:细分曲面是将高阶基元(OpenGL 中称为贴片)分解为许多更小的、更简单的基元进行渲染的过程。按照自己通俗一点的理解就是,在一种基元内再拆分成多个小基元。这有点像建模里高模的意思,细分数越多,模型越精致。

细分曲面控制着色器

再看到图 1,细分曲面是一个固定的过程,而细分曲面控制着色器在其前面。从名字中也可以推测,细分曲面控制着色器是控制细分曲面如何分解贴片的。这部分还是一知半解,我们直接看着色器代码。

原书清单 3.7 我们的第一个细分曲面控制着色器
  • #version 450 core
  •  
  • layout (vertices = 3) out;
  •  
  • void main(void)
  • {
  •     // Only if I am invocation 0 ...
  •     if (gl_InvocationID == 0)
  •     {
  •          gl_TessLevelInner[0] = 5.0;
  •          gl_TessLevelOuter[0] = 5.0;
  •          gl_TessLevelOuter[1] = 5.0;
  •          gl_TessLevelOuter[2] = 5.0;
  •     }
  •  
  •     // Everybody copies their input to their output
  •     gl_out[gl_InvocationID].gl_Position =
  •          gl_in[gl_InvocationID].gl_Position;
  • }

首先看到细分曲面控制着色器的清单 3.7,有一句 layout (vertices = 3) out:之前我们提及到细分曲面会将基元分解成贴片,而贴片又是由多个控制点组成的。这边的 vertices = 3 指定每个贴片的控制点数量为 3。细分曲面控制着色器负责将各种控制信息传递给细分曲面,所以这边使用 out 关键字声明。每个贴片的控制点数量可通过 glPatchParameteri() 进行设置,其中 pname 设置为 GL_PATCH_VERTICES 以及 value 设置为构建每个贴片将用到的控制点数量:

  • void glPatchParameteri(GLenum pname, GLint value);

针对四个控制点,本地没有实验成功。留作一个问题。

细分曲面控制着色器还可以控制内外细分等级 gl_TessLevelInner 和 gl_TessLevelOuter,值设置的越高,细分的越细。我们可以更改它们的值,主观了解一下这两个变量的概念:图 2 是清单 3.7 中指定参数生成的结果;图 3 是在图 2 基础上仅加大 gl_TessLevelInner 值的结果,可以看到内部点变多了;图 4 是在图 2 基础上仅加大 gl_TessLevelOuter 值的结果,可以看到边上的点变多了,同时可以看到边上的段数就是 gl_TessLevelOuter 指定的值。

图 2 细分曲面
图 3 加大 gl_TessLevelInner
图 4 加大 gl_TessLevelOuter

细分曲面评估着色器

下面说一下自己目前对细分曲面评估着色器的理解:细分曲面控制着色器将控制参数传递给细分曲面,细分曲面计算得出顶点作为细分曲面评估着色器的输入。所以细分曲面评估着色器的工作是评估这些输入顶点,以决定如何将这些顶点再次以基元的方式渲染出来。这边同样是直接关注到代码。

原书清单 3.8 我们的第一个细分曲面评估着色器
  • #version 450 core
  •  
  • layout (triangles, equal_spacing, cw) in;
  •  
  • void main(void)
  • {
  •     gl_Position = (gl_TessCoord.x * gl_in[0].gl_Position +
  •                      gl_TessCoord.y * gl_in[1].gl_Position +
  •                      gl_TessCoord.z * gl_in[2].gl_Position);
  • }

layout (triangles, equal_spacing, cw) in 指定选择模式为三角形,equal_spacing 和 cw 等其他限定符表示应沿细分多边形边缘等距生成新的顶点,以及应使用顺时针顶点环绕顺序生成三角形。在指定顶点位置时,可以看到使用到了 gl_TessCoord 变量,含义未知,待后续了解。

为了查看如图 2 至图 4 的这种细分曲面,我们需要仅绘制三角形的轮廓,否则颜色填充上去就看不到细节了。我们使用如下代码:

  • glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

在这篇文章中,我们第一次接触细分曲面相关的着色器,需要注意 glCreateShader 创建着色器的参数类型指定。细分曲面控制着色器为 GL_TESS_CONTROL_SHADER,细分曲面评估着色器为 GL_TESS_EVALUATION_SHADER。同时因为着色器变多了,所以自己把着色器编译部分的代码重写了一下,后续着色器的变动只需要修改 TShader 数组里的内容即可。

  • struct TShader
  • {
  •     GLenum type;
  •     std::string codePath;
  • };
  •  
  • GLuint CompileShaders(void)
  • {
  •     GLuint program;
  •     TShader tShaders[] = {
  •          { GL_VERTEX_SHADER,          "VertexShader.vert" },
  •          { GL_TESS_CONTROL_SHADER,    "3_7_TessControlShader.tesc" },
  •          { GL_TESS_EVALUATION_SHADER, "3_8_TessEvaluationShader.tese" },
  •          { GL_FRAGMENT_SHADER,        "FragmentShader.frag" }
  •     };
  •  
  •     program = glCreateProgram();
  •     for (int i = 0; i < sizeof(tShaders) / sizeof(tShaders[0]); i++)
  •     {
  •          TGLcharPtr shaderSource = ReadShaderText(tShaders[i].codePath);
  •          const GLchar* shaderSources[] = { shaderSource.get() };
  •  
  •          GLuint shader = glCreateShader(tShaders[i].type);
  •          glShaderSource(shader, 1, shaderSources, NULL);
  •          glCompileShader(shader);
  •  
  •          // Compile Result
  •          GLint result = GL_FALSE;
  •          static GLchar log[10240];
  •          glGetShaderInfoLog(shader, sizeof(log), NULL, log);
  •          printf("%s\n", log);
  •          glGetShaderiv(shader, GL_COMPILE_STATUS, &result);
  •          assert(result == 1);
  •  
  •          glAttachShader(program, shader);
  •          glDeleteShader(shader);
  •     }
  •  
  •     glLinkProgram(program);
  •     return program;
  • }

几何着色器

几何着色器提供了操作每个顶点的能力,我们可以结合“片段着色器提供操作每个像素的能力”,加以对比理解。

同时几何着色器还可以改变管线的中间基元模式。我们看到几何着色器代码:

原书清单 3.9 我们的第一个几何着色器
  • #version 450 core
  •  
  • layout (triangles) in;
  • layout (points, 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();
  •     }
  • }

清单 3.9 中,第一个配置限定符表明几何着色器期望输入数据为三角形。第二个配置限定符指示 OpenGL 此着色器将生成点,且每个着色器产生的点数最多为 3 个。为了能看清点,我们还需要使用 glPointSize() 来将点的大小设大。在细分着色器的基础上,最终生成的效果如图 5 所示,可以看到基元已经变成了点。

图 5 几何着色器

片段着色器

片段着色器是管线最后一个可编程阶段,这个着色器我们一开始就接触过了,通俗的讲就是和颜色相关。在几何着色器中,我们也提及到了片段着色器,说它提供操作每个像素的能力,让我们结合代码看看它如何可以操作每个像素。

原书清单 3.10 从片段位置得出颜色
  • #version 450 core
  •  
  • out vec4 color;
  •  
  • void main(void)
  • {
  •     color = vec4(sin(gl_FragCoord.x * 0.25) * 0.5 + 0.5,
  •                    cos(gl_FragCoord.y * 0.25) * 0.5 + 0.5,
  •                    sin(gl_FragCoord.x * 0.15) * cos(gl_FragCoord.y * 0.15),
  •                    1.0);
  • }

从清单 3.10 中,我们可以看到使用到了 gl_FragCoord 内置变量。它提供每个片段的位置,代码中根据它为每个片段单独生成颜色。最终的效果如图 6 所示,可以看到网格的样式。

图 6 操作每个像素

OpenGL 在渲染片段着色器阶段会进行插值处理,为此我们更改代码,做以下的实验:

原书清单 3.11 带有一个输出的顶点着色器
  • #version 450 core
  •  
  • // 'vs_color' is an output that will be sent to the next shader stage
  • out vec4 vs_color;
  •  
  • void main(void)
  • {
  •     const vec4 vertices[3] = vec4[3](vec4(0.25, -0.25, 0.5, 1.0),
  •                                         vec4(-0.25, -0.25, 0.5, 1.0),
  •                                         vec4(0.25, 0.25, 0.5, 1.0));
  •     const vec4 colors[] = vec4[3](vec4(1.0, 0.0, 0.0, 1.0),
  •                                      vec4(0.0, 1.0, 0.0, 1.0),
  •                                      vec4(0.0, 0.0, 1.0, 1.0));
  •  
  •     gl_Position = vertices[gl_VertexID];
  •  
  •     // Output a fixed value for vs_color
  •     vs_color = colors[gl_VertexID];
  • }
原书清单 3.12 从片段位置得出颜色
  • #version 450 core
  •  
  • // 'vs_color' is the color produced by the vertex shader
  • in vec4 vs_color;
  •  
  • out vec4 color;
  •  
  • void main(void)
  • {
  •     color = vs_color;
  • }

清单 3.11 中定义了一个新的顶点着色器,它依据顶点输出不同的颜色,因此会输出 3 种不同的颜色。清单 3.12 简单的设置顶点着色器传来的颜色。实验结果如图 7 所示,可以看到图中不止 3 种颜色,三角形中间的颜色会有一个平滑的过渡。

图 7 颜色插值

记得去掉中间的细分着色器。如何跨越多个阶段传递数据是目前遗留的问题。

总结

至此图 1 中的每个可编程的阶段都已经介绍完毕,所以我在文章开头就说这章就像是摘要,书籍的后续内容应该就是针对各个着色器详细的铺开。

本章还讲解了视口、计算着色器等其余概念,笔记里没有记录(我也没看),因为书中没有对应的实验,等后续用到了再回过头来看。