模板测试、位掩码与物体描边
搞懂 GPU 怎么靠一张「镂空模板」逐片段决定留谁去谁——模板缓冲、模板测试、glStencilFunc/glStencilOp/glStencilMask 三件套,以及用「两遍法」给物体描一圈边。
学习目标
- 能解释模板比较、深度比较与颜色写入的逻辑顺序,以及实现何时可提前执行逐片段测试
- 能分别配置
glStencilFunc的比较掩码、glStencilOp的三种结果和glStencilMask的写入位 - 能推导模板写回公式,并说明
glStencilMask(0x00)为什么保持旧值而不是写入 0 - 能实现“清除 → 正常物体盖章 → 放大物体描边 → 完整恢复”的多 pass 状态机
- 能诊断清除失败、模板附件缺失、前后面状态不一致和描边穿透场景等问题
为什么深度之外还需要一张可编程镂空模板
上一章那条往画布上贴色块的流水线,刚学会用「比远近」自动决定谁挡谁。这一章再给它加一道工序:在传送带末端架一张镂空的蜡纸模板——就像喷漆师傅在墙上贴一张刻好图案的纸板,朝整面墙喷漆,漆只能从镂空的地方落到墙上,其余被纸板挡住。
有了这张模板,流水线就能干一件新事:只让画面里某一块特定区域被涂到,其余一律挡掉。区域的形状不必是规规矩矩的矩形——它可以正好是「上一个物体占的那一圈」「某个不规则窗口的内部」。这章要解决的就是:怎么让流水线按一张「自定义形状的镂空模板」逐块色块地放行或拦截。
没有它会怎样?你就只能靠「比远近」决定遮挡,没法表达「只画这个奇形怪状区域里的东西」。而有了它,最经典的玩法立刻就有了——给选中的物体描一圈漂亮的轮廓边(游戏里点选单位那种高亮边框),靠的正是这张模板。
模板缓冲:每个像素再偷偷存一个「整数」
那张镂空模板,存在哪?答案和上一章的深度缓冲是同一个套路——再开一块和屏幕一样大的缓冲。这块就是 ↡显存里一块和颜色缓冲一样大的内存,为每个像素额外存一个整数(通常 8 位、0~255)。它像一张盖在画布上的镂空模板:屏幕显示的是颜色缓冲,模板缓冲只在幕后记账,供模板测试拿这个整数做比较、决定片段去留。(stencil buffer):它为每个像素额外存一个整数(通常 8 位,所以一个像素能有 0~255 共 256 种模板值)。
你已经认识两个孪生缓冲了:颜色缓冲存「什么颜色」、深度缓冲存「多远」。模板缓冲是第三个孪生兄弟,存的是一个没有固定物理含义、由你自己定义用途的整数——你想拿它标记「这块像素属于哪个物体 / 要不要保护起来 / 是不是边界」都行。它同样不直接显示,只在幕后记账。下面这张图把它当成喷漆模板来理解最直观:
每个像素存的那个数,就是它的 ↡模板缓冲里每个像素存的那个整数(0~255)。它没有固定物理含义,含义由你自己规定(比如约定『物体所在的像素记 1,其余记 0』)。模板测试时,新片段拿一个参考值和这个数比较,来决定自己能不能通过。(stencil value)。它的含义完全由你规定——本章用得最多的约定就是「物体覆盖到的像素记 1,其余记 0」,用这个 0/1 的形状当镂空模板。
模板测试:拿参考值和模板值比一比
有了这块缓冲,那道「掩模」工序就能干活了。↡GPU 对候选片段执行的整数位比较:先把参考值 ref 与已存模板值分别和比较掩码按位与,再按 glStencilFunc 判断。逻辑上模板结果先于深度结果,用于选择 sfail/dpfail/dppass;实现可在结果不变时提前执行测试。(stencil test)做的事,一句话:参考值和已存模板值先按比较掩码筛位,再决定片段是否进入后续深度测试,并选择对应的模板更新操作。
从结果依赖看,先判断模板是否通过,再判断深度是否通过,才能在 sfail、dpfail、dppass 三个操作中选一个;两关都通过后颜色才有资格写入。但和上一章 Early-Z 一样,硬件不一定等片段着色器结束:只要不改变可见结果,模板/深度测试可提前执行。正确说法是“逻辑顺序为模板 → 深度 → 写入”,而不是把所有测试固定描述成 shader 之后的单一时间点。
注意图右侧标的三处 sfail / dpfail / dppass——它们对应「模板测试失败 / 模板过但深度失败 / 两关都过」三种情形,每种情形下模板缓冲该怎么更新,由后面要讲的 glStencilOp 控制。先记住这条主干顺序:模板测试 → 深度测试 → 写颜色。
glStencilFunc:怎样算「通过」
模板测试「怎样算通过」由 ↡设置模板测试比较规则的函数,原型 glStencilFunc(func, ref, mask):func 是比较算子(GL_EQUAL/GL_NOTEQUAL/GL_ALWAYS 等八种),ref 是你给的参考值,mask 是比较掩码(先和 ref、缓冲值做按位与再比较,默认 0xFF 表示全部参与)。(glStencilFunc)设定,它有三个参数 glStencilFunc(func, ref, mask):
func:比较算子,和深度函数那八个同名——GL_ALWAYS(永远通过)、GL_EQUAL(相等才通过)、GL_NOTEQUAL(不相等才通过)、GL_LESS/GL_GREATER等。它决定「ref 和模板值满足什么关系算通过」。ref:你给的参考值,拿它去和缓冲里的模板值比。mask:比较掩码——比较前先把ref和模板值都和它做一次按位与(AND),再比较。默认0xFF(全 1,相当于整数原样参与比较)。
举几个本章会用到的:
glStencilFunc 调用 | 含义 |
|---|---|
glStencilFunc(GL_ALWAYS, 1, 0xFF) | 永远通过(不管模板值是几,这片段都过) |
glStencilFunc(GL_EQUAL, 1, 0xFF) | 模板值等于 1 才通过 |
glStencilFunc(GL_NOTEQUAL, 1, 0xFF) | 模板值不等于 1 才通过(描边第二遍的关键) |
记住 func 比的是「ref 和已存的模板值」的关系,它只管「这个片段过不过」,不负责往模板缓冲里写东西。写东西是下面 glStencilOp 的活。
glStencilOp:通过或失败时,模板值怎么更新
glStencilFunc 决定片段去留,↡设置『何时把模板缓冲更新成什么』的函数,原型 glStencilOp(sfail, dpfail, dppass):三个参数分别对应『模板测试失败 / 模板通过但深度失败 / 两个测试都通过』三种情形下,该像素的模板值要怎么更新。常用动作:GL_KEEP(保持不变,默认)、GL_REPLACE(替换成 glStencilFunc 里的 ref)、GL_ZERO(清 0)等。(glStencilOp)则决定「在三种不同结局下,模板缓冲里这个像素的值要不要变、变成什么」。它的三个参数 glStencilOp(sfail, dpfail, dppass) 一一对应上面流程图里标的三处:
sfail:模板测试失败时怎么更新;dpfail:模板测试通过、但深度测试失败时怎么更新;dppass:两个测试都通过时怎么更新。
每个参数可填的动作有:GL_KEEP(保持原值不变)、GL_REPLACE(替换成 glStencilFunc 里那个 ref)、GL_ZERO(清 0)、GL_INCR/GL_DECR(加一/减一)等。默认是 glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP)——三种情形都「保持不变」,也就是默认根本不动模板缓冲。描边第一遍要把物体像素「盖章成 1」,用的就是 glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE):只在「两关都过」(dppass)时把模板值替换成 ref(也就是 1)。
glStencilMask:让模板缓冲变「只读」
最后一件套件回答「哪些 bit 可以改」。↡模板写入位掩码。操作得到候选结果后,掩码为 1 的位采用新结果,掩码为 0 的位保留旧模板值:new=(old & ~mask)|(result & mask)。0xFF 允许全部 8 位更新,0x00 禁止任何位变化。它也会影响模板清除。(glStencilMask)控制候选操作结果怎样与旧值合并:
glStencilMask(0xFF):掩码全 1,8 个 bit 都允许采用操作结果;glStencilMask(0x00):掩码全 0,8 个 bit 都保留旧值,模板缓冲变成只读;glStencilMask(0x0F):只允许低 4 位变化,高 4 位继续保留旧值。
它和 glStencilOp 的分工别搞混:glStencilOp 产生候选结果,glStencilMask 决定候选结果的哪些位能覆盖旧值。0x00 不是写入数字 0,而是完全不修改。描边第二遍用它保护第一遍写好的形状;清除模板前则必须恢复 0xFF,因为 glClear(GL_STENCIL_BUFFER_BIT) 也受模板写掩码影响。
单步走一遍:物体描边的「两遍法」
模板测试最经典的应用是给物体描一圈轮廓边(↡一种用模板缓冲给物体画一圈轮廓边的经典技法,分两遍:第一遍正常画物体、把它覆盖的像素模板值写成 1;第二遍画一个放大一圈的同物体、用 GL_NOTEQUAL 只在『模板值≠1』的外圈上画边框色。这样物体周围正好留下一圈描边,常用于游戏里高亮选中的单位。,object outlining)——就是游戏里点选一个单位,它周身亮起一圈高亮边的效果。它用的就是上面三件套配合的两遍法。
猜一猜:第一遍我们把物体所在的像素在模板缓冲里盖章成
1;第二遍画一个放大一圈的同一个物体,并规定「只在模板值不等于 1 的地方才上边框色」。那么这个放大物体,最后只有哪一部分会被真正画出来——是整个放大物体,还是只有比原物体大出来的那一圈外环?先想一想,再单步走下面三步。
下面用三步走完整套两遍法。每一步左边画画布上看到的画面、右边画模板缓冲此刻的样子,盯着两边怎么对上,就懂了:
① 第一遍:正常画物体,把它的模板值盖章成 1
先设
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE) 和
glStencilFunc(GL_ALWAYS, 1, 0xFF),再开模板写入
glStencilMask(0xFF),正常画物体。GL_ALWAYS
让物体每个片段都通过模板测试,GL_REPLACE
把这些像素的模板值替换成
1。结果:画布上物体照常出现,模板缓冲里物体区域全标成了
1(右图高亮区),其余仍是 0。
走完这一遍就明白:两遍法的精髓是用第一遍在模板缓冲里「刻」出物体的形状(标成 1),第二遍再用这个形状当模板,靠 GL_NOTEQUAL 把放大物体里「与原物体重叠的部分」全挡掉,只留下外圈那一环。这正是 §2 那张「镂空模板」在干的事。
对比一下:描边前 vs 描边后
猜一猜:把下面这个滑块从左拖到右,画面会从「光秃秃一个物体」变成「物体镶了一圈边」。那圈边是直接画在物体身上的吗,还是长在物体外侧的一环?拖动看看,再回想 §5 第 ② 步——它正是「只在模板≠1 的外环上色」的结果。
左边是没做描边的原物体,右边是跑完两遍法后的描边效果。拖动中间的滑块左右对比,那圈轮廓边只出现在物体外缘一圈,物体本身一点没被盖掉:
看出来了吧——描边那一圈是长在物体外侧的(来自第二遍那个放大物体里「模板≠1」的外环),并没有覆盖物体本身。所以选中高亮看起来既清楚又不挡住物体细节,这正是模板测试在游戏里最常见的用法。
代码对照:启用 + 描边两遍法
先看最基础的两件事:启用模板测试 + 每帧清模板缓冲。和深度测试一个套路——开关默认是关的,缓冲每帧要清。
// 初始化时开一次:默认是关的
glEnable(GL_STENCIL_TEST);
glClearStencil(0);
// 渲染循环每帧开头:颜色 + 深度 + 模板三个缓冲一起清
glStencilMask(0xFF); // glClear 也受模板写掩码影响
glClear(GL_COLOR_BUFFER_BIT
| GL_DEPTH_BUFFER_BIT
| GL_STENCIL_BUFFER_BIT);// 初始化时开一次:默认是关的
gl.enable(gl.STENCIL_TEST);
gl.clearStencil(0);
// 渲染循环每帧开头:颜色 + 深度 + 模板三个缓冲一起清
gl.stencilMask(0xff); // clear 也受模板写掩码影响
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);逻辑两端一字对应。但 WebGL2 比桌面多一道前置手续——默认的画布根本没有模板缓冲,得在创建上下文时显式申请:
接着是描边两遍法的关键调用——把 §5 三步翻成代码。这段两端完全一样(纯状态设置,无 API 差异),故只列一份逻辑,桌面把 gl. 去掉、常量加 GL_ 前缀即可:
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); // dppass=REPLACE:通过就把模板写成 ref
// —— 第一遍:正常画物体,把它的模板值盖章成 1 ——
glStencilFunc(GL_ALWAYS, 1, 0xFF); // 永远通过;ref=1
glStencilMask(0xFF); // 开模板写入
DrawObject(); // 物体所在像素模板值 → 1
// —— 第二遍:放大物体,只在模板≠1 处画边框色 ——
glStencilFunc(GL_NOTEQUAL, 1, 0xFF); // 只在模板值≠1 处通过
glStencilMask(0x00); // 模板只读,别再改它
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); // 第二遍明确不产生模板修改
glDisable(GL_DEPTH_TEST); // 关深度,边框不被遮挡
DrawScaledUpObject(); // 放大物体 → 只剩外圈一环上色
// —— 恢复状态 ——
glStencilFunc(GL_ALWAYS, 0, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
glStencilMask(0xFF);
glEnable(GL_DEPTH_TEST);gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE); // dppass=REPLACE:通过就把模板写成 ref
// —— 第一遍:正常画物体,把它的模板值盖章成 1 ——
gl.stencilFunc(gl.ALWAYS, 1, 0xff); // 永远通过;ref=1
gl.stencilMask(0xff); // 开模板写入
drawObject(); // 物体所在像素模板值 → 1
// —— 第二遍:放大物体,只在模板≠1 处画边框色 ——
gl.stencilFunc(gl.NOTEQUAL, 1, 0xff); // 只在模板值≠1 处通过
gl.stencilMask(0x00); // 模板只读,别再改它
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); // 第二遍明确不产生模板修改
gl.disable(gl.DEPTH_TEST); // 关深度,边框不被遮挡
drawScaledUpObject(); // 放大物体 → 只剩外圈一环上色
// —— 恢复状态 ——
gl.stencilFunc(gl.ALWAYS, 0, 0xff);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
gl.stencilMask(0xff);
gl.enable(gl.DEPTH_TEST);逐行对着 §5:第一遍只在模板和深度都通过的可见像素写 1;第二遍使用 NOTEQUAL 与只读掩码,只让放大模型的外环上色。关闭深度会让轮廓始终可见、甚至透过前景物体;若目标是“只描可见部分”,应保留深度测试并根据几何膨胀处理深度偏差。代码必须恢复 func/op/mask/depth 全套状态;真实渲染器最好由 pass/state cache 保存调用前状态,而不是无条件假定深度原本开启。
容易踩的坑
小结
- 模板缓冲和颜色 / 深度缓冲一样大,为每个像素存一个整数(通常 8 位),像一张幕后的镂空模板
- 模板与深度的逻辑结果顺序决定
sfail/dpfail/dppass;实现可在不改变结果时提前执行逐片段测试 - 三件套各管一层:
glStencilFunc负责比较与读掩码,glStencilOp选择候选结果,glStencilMask按位合并新旧值 - 物体描边用两遍法:第一遍正常画物体 + 把它模板值写成 1;第二遍画放大物体 +
GL_NOTEQUAL只在模板≠1 的外圈上描边色 - 清除前写掩码必须允许更新;描边结束要恢复模板 func/op/mask 与 depth,是否关闭深度取决于轮廓要不要穿透前景
练习
问题 1(排错 / 改两遍法代码题) 某人照着 §6 写物体描边,却发现第二遍的边框色覆盖了整个放大模型,正常材质看不见了。检查发现第一遍画物体前是 glStencilMask(0x00),第二遍是 glStencilFunc(GL_NOTEQUAL, 1, 0xFF)。请指出哪一行错了、为什么整个放大模型都会通过、怎么改。
问题 2(问答题) 模板测试和深度测试的逻辑结果谁先谁后?如果模板比较失败,这对 glStencilOp 的三个参数(sfail / dpfail / dppass)意味着什么?硬件是否必须等片段着色器结束才执行比较?
问题 3(问答题) 描边第二遍为什么要画一个放大一圈的物体、而不是原尺寸的?如果第二遍画的是原尺寸物体(用同样的 GL_NOTEQUAL, 1),画面会出现什么结果?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 模板缓冲
显存里一块和颜色缓冲一样大的内存,为每个像素额外存一个整数(通常 8 位、0~255)。它像一张盖在画布上的镂空模板:屏幕显示的是颜色缓冲,模板缓冲只在幕后记账,供模板测试拿这个整数做比较、决定片段去留。详见本章「模板缓冲」一节。
- 模板值
模板缓冲里每个像素存的那个整数(0~255)。它没有固定物理含义,含义由你自己规定(比如约定「物体所在像素记 1、其余记 0」)。模板测试时,新片段拿一个参考值和这个数比较,决定自己能不能通过。详见本章「模板缓冲」一节。
- 模板测试
GPU 对参考值与已存模板整数做的逐片段位比较。逻辑上模板结果先于深度结果,用于选择
sfail/dpfail/dppass;实现可在结果不变时提前执行测试。它让程序按自定义形状逐片段放行或拦截。详见本章「模板测试」一节。- 模板函数
设置模板测试比较规则的函数,原型
glStencilFunc(func, ref, mask):func是比较算子(GL_EQUAL/GL_NOTEQUAL/GL_ALWAYS等八种),ref是你给的参考值,mask是比较掩码(比较前先和ref、模板值做按位与,默认0xFF表示整数原样参与)。它只管「片段过不过」,不负责写模板。详见本章「glStencilFunc」一节。- 模板操作
设置「何时把模板缓冲更新成什么」的函数,原型
glStencilOp(sfail, dpfail, dppass):三个参数分别对应「模板测试失败 / 模板通过但深度失败 / 两个测试都通过」三种情形下该像素的模板值怎么更新。常用动作:GL_KEEP(保持不变,默认)、GL_REPLACE(替换成ref)、GL_ZERO(清 0)等。详见本章「glStencilOp」一节。- 模板写入掩码
原型
glStencilMask(mask)。掩码为 1 的位允许采用模板操作结果,为 0 的位保留旧值:new=(old & ~mask)|(result & mask)。0xFF允许全部位更新,0x00让全部位只读;模板清除同样受它影响。详见本章「glStencilMask」一节。- 物体描边
用模板缓冲给物体画一圈轮廓边的经典技法,分两遍:第一遍正常画物体、把它覆盖的像素模板值写成 1;第二遍画一个放大一圈的同物体、用
GL_NOTEQUAL只在「模板值≠1」的外圈上画边框色。这样物体周围正好留下一圈描边,常用于游戏里高亮选中的单位。详见本章「物体描边」一节。