片段处理与帧缓冲 - 片段着色器、单片段测试、颜色输出

从这篇文章开始,我们学习平时代码中连接的最后着色器 —— 片段着色器。它是管线中由着色器代码确定每个片段的颜色然后组合发送给帧缓冲的一个阶段。

插值与存储限定符

之前的实验中我们已经看到,如果赋予三角形三个顶点不同的颜色,那么基元呈现的颜色会是三种颜色由外到内的平滑过渡。这就是插值的结果。

使用 flat 存储限定符可以指定片段着色器不进行插值。我们参考以下代码段来进一步说明:

  • flat in INPUT_BLOCK
  • {
  •     vec4 foo;
  •     int  bar;
  •     smooth mat3 baz;
  • };

从以上看出,flat 存储限定符可以应用于输入块,输入块内各元素会继承这个限定符。需要说明的是,整数无法进行插值,所以就必须禁用插值。

在以上这种情况下,如果某个元素想使用插值,可以显式使用 smooth 限定符。浮点数输入在默认情况下就是 smooth 可插值的。

这个例子还隐含了一层含义:片段着色器针对(可插值的)输入,就会进行插值,不仅仅是我们之前实验中的 color 这个输入。这可以解释我们在第 8 章最后遗留的问题,我们自定义的基元传入的 uv 是可插值的,所以也能根据 uv 生成平滑的颜色。

在片段着色器中,可插值的输入都会进行插值。只是输入值的范围目前还不清楚。

无透视校正的插值

使用 noperspective 存储限定符,可以指定使用无透视校正的插值,即平面空间线性插值。

  • in VS_OUT
  • {
  •     vec2 tc;
  •     noperspective vec2 tc_np;
  • } fs_in;

这背后的原理目前不清楚,所以我们就从实验结果里简单感受一下,不同的插值是如何影响到纹理坐标的。图 1 是默认使用透视校正的结果,它符合我们正常情况下想要的观察结果;图 2 是无透视校正的结果,可以看到纹理发生了倾斜。

图1 透视校正
图2 线性插值

单片段测试

在片段着色器运行之后,OpenGL 还可以对片段进行进一步可选择的测试,以确定是否以及如何将其写入帧缓存。这些测试按照逻辑顺序为剪裁测试、模板测试以及深度测试。

剪裁测试

使用 GL_SCISSOR_TEST 参数启用裁剪测试。

  • glEnable(GL_SCISSOR_TEST);

剪裁测试和第 8 章中讲的多视口转换非常类似。全局裁剪矩阵的设置函数为:

  • void glScissor(GLint x,
  •                GLint y,
  •                GLsizei width,
  •                GLsizei height);

同样的,如果想要设置多个剪裁矩阵,只要多指定一个索引参数即可。其设置函数为:

  • void glScissorIndexed(GLuint index,
  •                       GLint left,
  •                       GLint bottom,
  •                       GLsizei width,
  •                       GLsizei height);

我们还是使用之前基于几何着色器实现的多视口例子,在其基础上进行修改。如代码清单 1.1 所示,我们设置了四个剪裁矩阵。

代码清单 1.1 设置剪裁矩阵
  • // Four rectangles - lower left first...
  • glScissorIndexed(0,
  •     0, 0,
  •     scissor_width, scissor_height);
  • // Lower right...
  • glScissorIndexed(1,
  •     info.windowWidth - scissor_width, 0,
  •     scissor_width, scissor_height);
  • // Upper left...
  • glScissorIndexed(2,
  •     0, info.windowHeight - scissor_height,
  •     scissor_width, scissor_height);
  • // Upper right...
  • glScissorIndexed(3,
  •     info.windowWidth - scissor_width,
  •     info.windowHeight - scissor_height,
  •     scissor_width, scissor_height);

在着色器代码中,和指定视口一样,仍然使用 gl_ViewportIndex 内置变量指定裁剪矩阵索引。

运行结果如视频 1 所示,一个旋转的立方体被剪裁出了四个区域。

视频 1 使用 4 个裁剪矩阵渲染

模板测试

下一步测试阶段是模板测试。这个阶段有点像绘图或者建模软件中的蒙版或者是遮罩操作。我们可以指定一个遮罩,后续的绘图操作就可以不影响到遮罩区域。

我们通过一个增加边框的例子来说明模板测试。首先我们画一个物体,并将此区域设置为遮罩区域;接着将此物体放大一点,并设置颜色为纯色让模板测试“遮住”遮罩区域;那么最终就会在原本物体边缘增加一个边框。

我们先了解一下用到的相关函数。首先是开启模板测试:

  • glEnable(GL_STENCIL_TEST);

glStencilFuncSeparate() 函数用于指定模板测试的通过条件和比较值。其原型为:

  • void glStencilFuncSeparate(GLenum face,
  •                            GLenum func,
  •                            GLint ref,
  •                            GLuint mask);

face 参数指定模板的作用范围,是正向还是背向。可能的值为 GL_FRONT、GL_BACK、GL_FRONT_AND_BACK。

ref、mask 参数指定模板测试时的参考值和掩码。

func 参数指定测试的通过条件,比如有 GL_ALWAYS 和 GL_GREATER 等等。GL_ALWAYS 表示测试永远通过;GL_GREATER 表示只有当 (ref & mask) > (stencil & mask) 时,测试才通过。

glStencilOpSeparate() 函数用于指定测试通过或不通过时,采取何种操作更新模板缓冲内容。其原型为:

  • void glStencilOpSeparate(GLenum face,
  •                          GLenum sfail,
  •                          GLenum dpfail,
  •                          GLenum dppass);

其中,face 参数和 glStencilFuncSeparate 的含义一样。

后续三个事件名称有点“讲究”。sfail 指示模板测试失败时采取的操作;dpfail 指示深度测试失败时采取的操作,这时候其实暗含的条件是模板测试成功。因为如之前所讲,按照顺序深度测试在模板测试之后;同样的,dppass 指示模板测试通过,深度测试也通过时采取的操作。

采取的操作有 GL_KEEP 和 GL_REPLACE 等等。GL_KEEP 表示保持当前值,不修改模板缓冲中的值;GL_REPLACE 表示使用 ref 参考值替换模板缓冲中的值。

用到的函数都讲解完毕了,现在让我们看代码清单 1.2 中所示的例子。首先第 1 至 2 行,将模板缓冲内容设置为 0。

接着看到第 4、5 行中的 glStencilFuncSeparate 函数,它将通过条件设置为 GL_ALWAYS,即无条件通过。第 6、7 行中的 glStencilOpSeparate 函数,模式测试失败时保持原值(也不会失败),模板测试成功时使用参考值(上述指定为 1)替换缓冲中的内容。

设置完以上模板测试参数,并且画出第一个立方体之后,模板缓冲内立方体所占的位置就变成了 1,其余位置还是初始值 0

代码清单 1.2 模板边框装饰
  1. GLint zero = 0;
  2. glClearBufferiv(GL_STENCIL, 0, &zero);
  3. // 绘制物体
  4. glStencilFuncSeparate(GL_FRONT_AND_BACK,
  5.     GL_ALWAYS, 1, 0xff);
  6. glStencilOpSeparate(GL_FRONT_AND_BACK,
  7.     GL_KEEP, GL_REPLACE, GL_REPLACE);
  8.  
  9. vmath::mat4 mv_matrix =
  10.     vmath::translate(0.0f, 0.0f, -1.5f) *
  11.     vmath::rotate(45.0f, 0.0f, 1.0f, 0.0f) *
  12.     vmath::rotate(45.0f, 1.0f, 0.0f, 0.0f) *
  13.     vmath::rotate(45.0f, 0.0f, 0.0f, 1.0f);
  14. glUniformMatrix4fv(mvp_location, 1, GL_FALSE, proj_matrix * mv_matrix);
  15.  
  16. glUniform1i(boder_flag_location, 0);
  17. glDrawArrays(GL_TRIANGLES, 0, 36);
  18. // 绘制边框
  19. glStencilFuncSeparate(GL_FRONT_AND_BACK,
  20.     GL_GREATER, 1, 0xff);
  21. glStencilOpSeparate(GL_FRONT_AND_BACK,
  22.     GL_KEEP, GL_KEEP, GL_KEEP);
  23.  
  24. mv_matrix =
  25.     vmath::translate(0.0f, 0.0f, -1.45f) *
  26.     vmath::rotate(45.0f, 0.0f, 1.0f, 0.0f) *
  27.     vmath::rotate(45.0f, 1.0f, 0.0f, 0.0f) *
  28.     vmath::rotate(45.0f, 0.0f, 0.0f, 1.0f);
  29. glUniformMatrix4fv(mvp_location, 1, GL_FALSE, proj_matrix * mv_matrix);
  30.  
  31. glUniform1i(boder_flag_location, 1);
  32. glDrawArrays(GL_TRIANGLES, 0, 36);

画边框之前需要设置第二套模板参数。第 19、20 行的 glStencilFuncSeparate 函数,它设置的通过条件为 GL_GREATER,即参考值 1 需要大于模板缓冲内的值,才能通过。这时候只有第一次画的立方体位置外的地方才满足这个条件。第 21、22 行的 glStencilOpSeparate 函数,全将操作设置为 GL_KEEP,因为是否对测试缓冲更改已经不要紧了。

注意比较的前后参数。

GL_GREATER : Passes if ( ref & mask ) > ( stencil & mask ).

第二次画立方体:自己这边是将立方体往前移了一点,以达到增大的目的。同时着色器中使用了 boder_flag_location 统一变量来区分颜色,边框绘制使用纯色。

因为第二次渲染有遮罩“遮着”,所以就能呈现如图 3 所示的边框效果。

图3 边框绘制

深度测试

完成模板测试之后,就是最后的深度测试环节。深度测试在之前的学习代码中经常看见,从代码层面上来看,操作并不复杂。

使用 GL_DEPTH_TEST 参数使能深度测试。

  • glEnable(GL_DEPTH_TEST);

深度测试与模板测试类似,如果通过了深度测试,则会使用该片段的深度值更新深度缓存;如果深度测试失败,则删除该片段并且不将该片段传递给后续片段操作。

可以通过 glDepthFunc() 函数指定比较运算。常用的参数为 GL_LEQUAL,其代表如果新片段的深度值小于或等于旧片段的值,则通过深度测试。

深度测试这边还有一个之前实验中没有遇到过的一个概念——深度夹紧。OpenGL 用 0~1 的有限数字表示各片段的深度。深度为 0 的片段与近平面相交,深度为 1 的片段位于最远的可描述深度。如果超出这个范围,即超出近平面和远平面这个范围,画面就会被裁剪掉。

深度夹紧就是为了解决这个裁剪问题。如图 4 所示,如果有物体超出了近平面,则超出的部分会投射到该平面上。虽然这改变了原始物体的样子,但是不会出现视觉异常(因为裁剪可能会导致黑洞等效果)。

图4 近平面深度夹紧的效果

开启深度夹紧也很简单,传递 GL_DEPTH_CLAMP 参数即可。

  • glEnable(GL_DEPTH_CLAMP);

颜色混合

OpenGL 对颜色进行输出时,还可以进行混合操作。混合操作可以简单理解成对原本输出颜色 RGB 分量的进一步计算更改。

因为是“混合”,所以计算过程会涉及到除了原本输出颜色的其他数据源。混合函数中可以使用到 4 中数据源:第一数据源、第二数据源、目标颜色和常量混色。

第一数据源和第二数据源就是片段着色器中的原本输出颜色,平时都只有一个输出,如何设置双源输出后面会介绍。

目标颜色就是颜色缓冲中的数据,这边可以简单理解成背景颜色。

常量混色就是只一个常量颜色,它可以通过 glBlendColor() 函数指定。其函数原型为:

  • void glBlendColor(GLfloat red,
  •                   GLfloat green,
  •                   GLfloat blue,
  •                   GLfloat alpha);

最终的混合输出颜色可以理解成源数据和目标数据参加计算的结果。源数据和目标数据再进行什么操作(比如源数据使用第一数据源还是第二数据源,目标数据是否变为常量混色等等),可以通过 glBlendFunc() 函数指定。其函数原型为:

  • void glBlendFunc(GLenum sfactor, GLenum dfactor);

glBlendFunc() 函数的参数为枚举值,其各个值的含义可以参考 OpenGL 手册。在代码清单 2.1 中我们将这些枚举值都设置了一遍,可以通过实验效果大致了解一下混合的概念。图 5 为混合函数所有组合的运行效果。

代码清单 2.1 所有混合函数的渲染
  • static const GLfloat orange[] = { 0.6f, 0.4f, 0.1f, 1.0f };
  • static const GLfloat one = 1.0f;
  •  
  • static const GLenum blend_func[] =
  • {
  •     GL_ZERO,
  •     GL_ONE,
  •     GL_SRC_COLOR,
  •     GL_ONE_MINUS_SRC_COLOR,
  •     GL_DST_COLOR,
  •     GL_ONE_MINUS_DST_COLOR,
  •     GL_SRC_ALPHA,
  •     GL_ONE_MINUS_SRC_ALPHA,
  •     GL_DST_ALPHA,
  •     GL_ONE_MINUS_DST_ALPHA,
  •     GL_CONSTANT_COLOR,
  •     GL_ONE_MINUS_CONSTANT_COLOR,
  •     GL_CONSTANT_ALPHA,
  •     GL_ONE_MINUS_CONSTANT_ALPHA,
  •     GL_SRC_ALPHA_SATURATE,
  •     GL_SRC1_COLOR,
  •     GL_ONE_MINUS_SRC1_COLOR,
  •     GL_SRC1_ALPHA,
  •     GL_ONE_MINUS_SRC1_ALPHA
  • };
  •  
  • static const int num_blend_funcs = sizeof(blend_func) / sizeof(blend_func[0]);
  • static const float x_scale = 20.0f / float(num_blend_funcs);
  • static const float y_scale = 16.0f / float(num_blend_funcs);
  • const float t = (float)currentTime;
  •  
  • glViewport(0, 0, info.windowWidth, info.windowHeight);
  • glClearBufferfv(GL_COLOR, 0, orange);
  • glClearBufferfv(GL_DEPTH, 0, &one);
  •  
  • glUseProgram(program);
  • vmath::mat4 proj_matrix = vmath::perspective(50.0f,
  •     (float)info.windowWidth / (float)info.windowHeight,
  •     0.1f,
  •     1000.0f);
  • glUniformMatrix4fv(proj_location, 1, GL_FALSE, proj_matrix);
  •  
  • glEnable(GL_BLEND);
  • glBlendColor(0.2f, 0.5f, 0.7f, 0.5f);
  • for (j = 0; j < num_blend_funcs; j++)
  • {
  •     for (i = 0; i < num_blend_funcs; i++)
  •     {
  •         vmath::mat4 mv_matrix =
  •             vmath::translate(9.5f - x_scale * float(i),
  •                 7.5f - y_scale * float(j),
  •                 -18.0f) *
  •             vmath::rotate(t * -45.0f, 0.0f, 1.0f, 0.0f) *
  •             vmath::rotate(t * -21.0f, 1.0f, 0.0f, 0.0f);
  •         glUniformMatrix4fv(mv_location, 1, GL_FALSE, mv_matrix);
  •         glBlendFunc(blend_func[i], blend_func[j]);
  •         glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, 0);
  •     }
  • }
图5 混合函数的所有组合

上面还遗留一个问题是如何设置双源输出。我们看到代码清单 2.2 开头的布局限定符,对应关系不难理解。

代码清单 2.2 设置双源混合
  • #version 410 core
  •  
  • layout (location = 0, index = 0) out vec4 color0;
  • layout (location = 0, index = 1) out vec4 color1;
  •  
  • in VS_OUT
  • {
  •     vec4 color0;
  •     vec4 color1;
  • } fs_in;
  •  
  • void main(void)
  • {
  •     color0 = vec4(fs_in.color0.xyz, 1.0);
  •     color1 = vec4(fs_in.color1.xyz, 1.0);
  • }

还有需要说明的是,前面提及最终的混合输出颜色可以理解成源数据和目标数据参加计算的结果。具体如何计算,即混合方程是什么,可以通过 glBlendEquation()glBlendEquationSeparate() 函数指定,这边就不再赘述了。

总结

从这篇文章,我们初步了解了片段着色器的插值特性。同时更加详细的了解了各个片段测试过程:剪裁测试、模板测试和深度测试。最后我们还了解了输出的颜色可以进行混合,并可以进行一系列配置。