Stencil Test的应用总结

0

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的区域。

clipboard.png
图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
    ...

clipboard.png
图 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,只画出了第二张图片的所有像素。

clipboard.png

实现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因不同算法而异。

clipboard.png
图3 遮挡物在光源的延伸方向上形成的Shadow Volume

在构造Shadow Volume完成后,渲染过程大概如下:

  • 按无光照渲染整个场景,即所有物体都出于阴影中
  • 对于每个光源,执行以下步骤:

    1. 渲染构造好的Volume,利用深度信息构造出一个模板,使出于光照中的像素在模板上有不同的Stencil值
    2. 按有光照渲染整个场景,利用步骤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的反面

clipboard.png
图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等,留待慢慢消化。

你可能感兴趣的

载入中...