乐趣区

Stencil Test的应用总结

0. 前言
一直以来,对 Stencil 的 Operation 知其然而不知其所以然,不太明白提供这些 Operation 更新 Stencil 有什么用。而 GPU 的 Stencil 更新机制其实是根据应用的需求才这么设计的,理解好 Stencil 的应用情况,才能理解好 Stencil Test 的更新机制。因此,本文将对其主要的应用做下梳理,增强对 Stencil Test 的认知。
1. Stencil Test 简介
在 OpenGL/Direct3D 的流水线中,Stencil Test 被归入 Pixel Shader 之后的 Output Merger Stage,其处理单位是像素(如果 MSAA 打开,则是 Sample)。Stencil Test 的有两个要点:

Stencil 值的测试,用于剔除像素
Stencil 值的更新,用于产生实现特定效果的 Stencil 值

Stencil 值的测试很简单——从 Stencil Buffer 里读出该像素的 Stencil 值(8bit 的 UINT)与参考值比较,满足比较条件则 pass 最终画出(假设能通过 Depth Test 或其他剔除),否则 fail 直接剔除。比较函数以及参考值都是通过 API 设定,例如 OpenGL 的 glStencilFunc(GLenum func, GLint ref, GLuint mask) 函数。与 Depth Test 的比较函数类似,Stencil Test 的比较函数包括 NEVER, LESS, LEQUAL, GREATER, GEQUAL, EQUAL, NOTEQUAL 和 ALWAYS。
通过 Stencil 值的测试我们可以限制渲染的区域,比如下面的例子把渲染区域限制为 Stencil 值等于 1 的区域。
图 1 给定中间图片中的 Stencil 值,将比较条件设为 EQUAL,参考值设为 1 时,左侧图片的 color 通过 Stencil Test 后。
我们看到,只要 Stencil Buffer 里存储了期望的 Stencil 值,我们就可以通过 Stencil Test 剔除像素来画出期望的区域,正如 Stencil 本身的含义(模板)。而事实上问题重点常在于如何构造出期望的 Stencil 值,除了少数应用使用特定已知的模板外,大部分是在渲染过程中产生需要的模板,这就是要讲的第二个要点——Stencil 值的更新,它是实现各种效果的关键。在 OpenGL 中,写 Stencil Buffer 的开启与否是通过函数 glStencilMask(GLuint mask) 设置的,这个函数的参数 mask 对应 Stencil 值的各个 bit 是否允许写入,当 mask 设为 0 表示完全关闭写 Stencil Buffer。在开启写 Stencil Buffer 的情况下,无论像素是否被 Stencil Test 或 Depth Test 剔除,GPU 都会执行 Stencil 值的更新。更新方式是跟 Stencil Test 和 Depth Test 的测试结果紧密联系的,OpenGL/D3D 把测试结果分为三种情况:

sfail: Stencil Test fail

dpfail: Stencil Test pass 但 Depth Test fail

dppass: Stencil Test pass 且 Depth Test pass

通过 API glStencilOp(GLenum sfail, GLenum dpfail, GLenum dppass) 可以分别为这三种测试结果指定更新该像素 Stencil 数值的方式,可选的方式包括

Action
Description

GL_KEEP
当前的 Stencil 值保持不变

GL_ZERO
将 Stencil 值更新为 0.

GL_REPLACE
将 Stencil 值替换为参考值

GL_INCR
若当前 Stencil 值小于最大值,则加 1

GL_INCR_WRAP
Stencil 值加 1,若超过最大值则 wrap 为 0

GL_DECR
若当前 Stencil 值大于最小值,则减 1

GL_DECR_WRAP
Stencil 值减 1,若小于 0 则 wrap 为最大值

GL_INVERT
按位反转当前 Stencil 值

GPU 在执行 Stencil Test 和 Depth Test(没有 Enable Depth Test 的话将一直 pass),按照测试结果(sfail,dpfail,dppass)对应的方式算出新的 Stencil 值,如有发生变化则写回 Stencil Buffer 里。正是有上面的多种更新方式,以及 Depth Test 和 Stencil Test 的紧密联系使得 Stencil Test 能通过多个 pass 实现多种效果。
2. Stencil Test 的应用
从上面可以看出,Stencil 应用的过程大概是这样:

开启写 Stencil Buffer
渲染物体,更新 Stencil Buffer 的内容
关闭写 Stencil Buffer
渲染(其他)物体,通过 Stencil Buffer 的内容把部分像素剔除掉。

我们看下不同的更新机制如何实现特定需求的。
2.1 轮廓
给物体添加轮廓的思路很简单——把同一个物体画两遍,其中第一遍正常地渲染物体,第二遍将原物体做微小拉伸(比原来多出轮廓),并让 Pixel Shader 输出轮廓颜色。同时要使第一遍所画的像素位置上在第二遍渲染中不会再被画出新的像素,即需要使用一种剔除方法,使第二次渲染时只保留两次渲染物体的非重叠部分。一开始我们可能会想到用 Depth Test——第一次渲染时打开 Depth Write,在第二遍渲染时在 Vertex Shader 给构成网格的每个顶点设一个足够大的深度值,这样第二次渲染时重叠部分会在 GPU 的 Depth Test 中因为遮挡而被剔除。然而,当场景里存在其他背景物体时,轮廓也会被遮挡住。因此,Depth Test 并不是过滤像素区域的好方法,而这样的需求场景,本来就是 Stencil Test 的舞台。
利用 Stencil Test 画轮廓的大概步骤是这样的:1)将 sfail, dfail, dpass 的更新方式分别设为 KEEP,KEEP,REPLACE
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
2)关闭写 Stencil Buffer,按正常方式渲染背景。
glStencilMask(0x00);
//draw the background

3)开启写 Stencil Buffer,比较函数为 ALWAYS,Stencil Test 参考值设为 1。渲染物体,这样渲染后物体每个像素的 Stencil 值将等于 1
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilMask(0xFF);
//draw the object

4)关闭写 Stencil Buffer,比较函数设为 NOTEQUAL,关闭 Depth Test。将物体做微小拉伸并渲染物体,Pixel Shader 输出轮廓颜色
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00);
glDisable(GL_DEPTH_TEST);
//draw the scaled object

图 2 轮廓渲染
这个方法的思想很简单:第一次渲染物体后,最终所有画出的像素对应的 Stencil 值均为 1,而第二次渲染时只画出 Stencil 值不等于 1 的轮廓,从而实现了期望的效果。图 2 是用 learnopengl 教程在 Stencil 这一章中画出的例子,个人觉得这个网站的教程很适合初学 OpenGL,里面对第三方库怎样 build 和使用有详细的解释,并且从最基本的例子开始展开循序渐进,最重要的是每个例子都有代码可参考。
2.2 Dissolve
在 Graphics 或 Video 领域,Dissolve 用于描述一种过渡效果——一张图片渐渐地褪去,在同时另一张图片替换原来的图片。Dissolve 可使用 Stencil Buffer 实现,在一开始将 Stencil Buffer 清零,通过设置不同的比较函数,使第一张图片全部画出,而第二张图片全部不画。接着逐帧改变 Stencil Buffer,逐渐增加 1 的个数,并以同样的方式画两张图片,直到最后 Stencil Buffer 全为 1,只画出了第二张图片的所有像素。

实现 Dissolve 的其中一帧的过程大概是 1)开启 Stencil,并将 stencil 比较函数设为 GL_NEVER,参考值设为 1,将 sfail 的更新方式设为 GL_REPLACE,
glStencilFunc(GL_NEVER, 1, 1)
glStencilOp(GL_REPLACE, GL_KEEP, GL_KEEP)
2)通过画几何体或 glDrawPixels 函数往 Stencil Buffer 里写入特定的 Dissolve 样式,由于 Stencil Test 一直 fail,所有这些像素不会被画出 3)关闭写 Stencil Buffer,将比较函数设为 GL_EQUAL,参考值设为 0,并画第一张图片,这样只有模板上为 0 值的地方才画出这张图片的像素
glStencilFuncGL_EQUAL, 0, 1(GL_EQUAL, 0, 1).
//draw the 1st image

4)改变比较的参考值为 1,并画第二张图片
glStencilFuncGL_EQUAL, 1, 1(GL_EQUAL, 1, 1).
//draw the 2nd image

2.3 Shadow Volume
以上的更新机制比较简单,这里我们继续看一个相对较复杂的应用——Shadow Volume,Shadow Volume 最早是 Frank Crow 于 1977 年提出的一种为 3D 场景添加阴影的算法,后来也有其他研究者独立地提出一些变种算法。The Theory of Stencil Shadow Volumes 给出了 Shadow Volume 的详细介绍。Shadow Volume 算法旨在光栅化的渲染中,确认出所渲染物体上那些受遮挡影响未能被光源照到像素,生成一个模板,然后剔除对应的像素不做 lighting,从而实现阴影效果。该算法的第一步是构造一个 Shadow Volume(这里不是指算法名字了,而是一个图 3 那样的 Volume),其基本步骤是

以光源为视点,找出遮挡物的所有轮廓边(那些同时被正面三角形和反面三角形包含的边)
将轮廓边上的每一点向光源与其连线的方向延伸,所有边构成的多边形形成一个立体(即 Shadow Volume,图 3 的阴影部分)的四周表面。
另外可能要加上 Front Cap 或 Back Cop,从而形成封闭的 Shadow Volume。加何种 Cap 因不同算法而异。

图 3 遮挡物在光源的延伸方向上形成的 Shadow Volume
在构造 Shadow Volume 完成后,渲染过程大概如下:

按无光照渲染整个场景,即所有物体都出于阴影中

对于每个光源,执行以下步骤:

渲染构造好的 Volume,利用深度信息构造出一个模板,使出于光照中的像素在模板上有不同的 Stencil 值
按有光照渲染整个场景,利用步骤 1 构造的模板区分阴影区域,使用额外的 Blending 把渲染结果添加到已有场景中

按照构造模板方法分类,Shadow Volume 算法可分为两类

Depth pass
Depth fail

Depth pass 和 Depth fail 分别在 dppass 和 dpfail 两种测试结果更新 Stencil 值。Wiki 里还提到 Exclusive-or 的方法,这种方法也是在 Depth pass 时更新 Stencil 值,但它只采用了 1bit 的 Stencil 值,更新方式为 INVERT,因此并不适用于有多个 Shadow Volume 重叠的情况。下面着重看戏这两种方法对于 Stencil Buffer 的使用,对两者的优缺点暂不做讨论。
2.3.1 Depth pass
Depth pass 的思路是分两次分别渲染 Shadow Volume 的正面和反面,并用 Stencil 值记录位于物体前方的次数。如果正面和反面的次数相等,那么该位置出于光照中。如果正面的次数比反面多,那么该位置出于阴影中。因为 Stencil 值是在通过 depth 测试时更新的,所以这种方法较 Depth pass。Depth pass 构造应用模板的步骤为:

关闭写 Depth Buffer 和 Color Buffer,设置 back-face culling,将 dppass 的更新方式设为 GL_INCR.
渲染 Shadow Volume,由于 Culling,只画了 Shadow Volume 的正面.
设置 front-face culling,将 dppass 的更新方式设为 GL_DECR
渲染 Shadow Volume,由于 Culling. 只画了 Shadow Volume 的反面

图 4 Depth pass Shadow volume
如图 4,箭头末端的数字分别对应每个位置经过以上步骤后最终在 Stencil Buffer 里的数值,可以看到,出于阴影中的位置最终为 1,因为它出于 Shadow Volume 的正面和反面之间,正面未被物体遮住 depth pass 之后 Stencil 值增 1,而反面被物体遮住 depth 测试失败未能将 Stencil 值减 1。当一个位置与眼睛的连线未闯过 Shadow Volume(从左到右的第 1 条连线)或者穿过正反面(第 2 和第 4 条连线),那么意味着该位置在光照中。
2.3.2 Depth fail
另一种方法 Depth fail 通过在 dpfail 时更新 Stencil 值来构造模板,Depth fail 的步骤为:

关闭写 Depth Buffer 和 Color Buffer,设置 front-face culling,将 dpfail 的更新方式设为 GL_INCR.
渲染 Shadow Volume,由于 Culling,只画了 Shadow Volume 的正面.
设置 back-face culling,将 dpfail 的更新方式设为 GL_DECR
渲染 Shadow Volume,由于 Culling. 只画了 Shadow Volume 的反面

Depth fail 其实是 depth pass 的一个“翻转版本”——depth pass 算出正面和反面在物体前方的次数,而 depth fail 则算反面和正面在物体后方的次数。这种差异导致了两者在实际应用中有各自的优势和不足,这些超出本文范围,就不深入了。这里是一个提供代码的 depth pass 例子:Shadow Volume。
2.3.3 Two-Sided Stencil
以上 Shadow Volume 的正反面是分两次渲染的,这无疑增加了 Vertex Shader 的带宽。事实上可以利用 Two-Sided Stencil 功能,对于 OpenGL 可通过下面两个函数分别为 Front 和 Back 设置不同的更新方式,那么整个 Shadow Volume 实际上只需要画一次,同时画正面和背面,由 GPU 根据三角形的 Face 去选择更新 Stencil 值的方式。
void glStencilFuncSeparate(GLenum face​, GLenum func​, GLint ref​, GLuint mask​);
void glStencilOpSeparate(GLenum face​, GLenum sfail​, GLenum dpfail​, GLenum dppass​);

2.3.4 总结
Shadow Volume 算法是将 Stencil Buffer 的数值当做计数器来使用,用于统计物体每个位置的正面和反面的数量,以之判断物体与 Shadow Volume 的关系。本质上,Stencil Buffer 使用来记录物体与 Shadow Volume 两个面的遮挡关系,这也解释了 Stencil 值的更新为什么要跟 Depth Test 的结果绑定在一起。
2.4 其他
除了上述提到的应用外,Wiki 中提到的 Stencil Test 其他应用还有 Decaling,portal rendering,Reflections,intersection highlighting 等,留待慢慢消化。

退出移动版