GPU自动处理专题6-模板测试

11 阅读8分钟

圆形遮罩

先别背 API

如果你是第一次学模板测试,我建议先不要急着记:

  • gl.colorMask
  • gl.stencilMask
  • gl.stencilFunc
  • gl.stencilOp

因为一上来就背这些名字,通常只会觉得乱。

你先记住一句话:

  • 模板测试就是先在屏幕上“做标记”,再规定“只有被标记的地方才能画内容”。

这个 demo 做的事情,其实非常简单:

  1. 先用一个圆形,在屏幕上悄悄做标记
  2. 再画一张原本会铺满全屏的条纹
  3. 但只允许条纹出现在“被圆形标记过”的地方

所以最后你看到的是:

  • 条纹只显示在圆形内部
  • 圆形外面什么都没有

如果你脑子里已经有了这个画面,后面所有 API 都会容易很多。

这个例子到底想解决什么问题

假设你现在有一张内容,它本来会铺满整个屏幕。

但你不想让它全屏显示,你只想让它出现在一个圆形窗口里。

这时你有两种思路:

思路 1:直接把内容写成圆形

  • 这通常会把“内容逻辑”和“形状逻辑”耦合在一起。
  • 每换一种形状,你都要改内容的绘制方式。

思路 2:内容照常画,形状交给模板测试来限制

  • 内容仍然可以是全屏矩形。
  • 只是 GPU 会在最后一步帮你拦掉圆外的部分。

模板测试强大就强大在这里:

  • 它把“画什么内容”和“内容出现在哪个形状里”拆开了。

这个 demo 用的就是第 2 种思路。

什么是 stencil buffer

可以把它想成:

  • 屏幕背后还有一张和屏幕一样大的“记号纸”
  • 屏幕每个像素,对应记号纸上的一个小格子
  • 每个格子里放一个数字

一开始,这张记号纸上的所有数字都是:

0

然后你可以在绘制时告诉 GPU:

  • 画到哪里,就把那里的数字改成 1
  • 后面再画别的东西时,只允许数字等于 1 的地方通过

这就是模板测试最核心的思想。

所以你可以把 stencil buffer 理解成:

  • 一张专门用来记录“哪些屏幕位置被打过标签”的缓冲

先用一张脑内图理解整个流程

把屏幕简化成很多个小格子。

第 0 步:刚开始

模板缓冲全是 0:

0 0 0 0 0 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0

这表示:

  • 还没有任何地方被标记

第 1 步:画圆形遮罩

这一步不会真的把圆画给用户看,而是只在模板缓冲里做标记。

画完后,模板缓冲可以想象成这样:

0 0 0 1 0 0 0
0 0 1 1 1 0 0
0 1 1 1 1 1 0
0 0 1 1 1 0 0
0 0 0 1 0 0 0

这表示:

  • 圆形覆盖到的地方,值变成了 1
  • 圆外的地方,仍然是 0

第 2 步:画全屏条纹

注意,这一遍画的几何其实还是全屏矩形。

也就是说,从“几何范围”上看,它想画满整个屏幕。

但 GPU 会额外检查:

  • 当前位置的模板值是不是 1

如果是 1:

  • 允许条纹显示

如果不是 1:

  • 拦掉,不让显示

所以最后结果就是:

  • 条纹只显示在圆里面

这就是模板遮罩的完整直觉。

对应到这个 demo 的函数

这份示例代码已经被拆成了几个很适合学习的函数:

function clearFrame() {}
function writeMaskToStencil() {}
function drawContentInsideMask() {}
function render() {}

clearFrame()

  • 每一帧开始,先把屏幕擦干净
  • 同时把模板缓冲全部重置为 0

writeMaskToStencil()

  • 画一个圆
  • 但不把颜色真正显示出来
  • 只是在模板缓冲里把圆形区域记成 1

drawContentInsideMask()

  • 再画真正的条纹内容
  • 但只允许模板值等于 1 的地方显示

render()

  • 只是把这 3 个阶段按顺序串起来

如果你先只理解这 4 个函数的职责,其实就已经理解了这个 demo 的 80%。

为什么这个 demo 要准备两套几何

这个例子里有两组顶点数据:

1. 圆形 mask

  • 用来定义“允许显示的区域是什么形状”
  • 它只负责形状,不负责真正的内容

2. 全屏矩形 fullscreen

  • 用来承载真正的条纹内容
  • 它的几何范围是全屏

这里最值得你注意的不是“用了圆形和矩形”,而是这种职责分离:

  • 圆形负责定义区域
  • 矩形负责承载内容

这就是模板测试最有价值的地方:

  • “区域控制”和“内容绘制”可以分开

以后你把圆换成星形、文字、任意多边形,内容部分都可以不改。

Pass 1 在做什么

第一遍的目标只有一句话:

  • 先把圆形区域写成 stencil = 1

核心代码是:

gl.colorMask(false, false, false, false);
gl.stencilMask(0xff);
gl.stencilFunc(gl.ALWAYS, 1, 0xff);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
drawColor(maskBuffer, mask.length / 2, [1, 1, 1, 1]);

你现在先不用逐个死记,先看整体意思:

这几句合起来的效果

  • 先把颜色输出关掉
  • 所以用户不会看到这个圆
  • 但 GPU 仍然会处理这个圆覆盖到的片元
  • 这些片元最终会把模板值改成 1

所以第一遍结束后,得到的不是“一个显示出来的白色圆形”。

真正得到的是:

  • 模板缓冲里有一个圆形标签区域

为什么要关颜色写入

因为这一步的目的不是给用户看图。

它只是想做一件后台工作:

  • 告诉 GPU,圆形里面这些位置等会儿可以放行

所以:

gl.colorMask(false, false, false, false);

这句的作用就是:

  • 虽然画了圆
  • 但不要把圆的颜色写到屏幕上

你可以把这一步理解成:

  • 偷偷打标签,不给用户看

Pass 2 在做什么

第二遍的目标也只有一句话:

  • 画真正的内容,但只让圆形区域通过

核心代码是:

gl.colorMask(true, true, true, true);
gl.stencilMask(0x00);
gl.stencilFunc(gl.EQUAL, 1, 0xff);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
drawStripe();

这几句合起来的效果

  • 重新允许颜色写入
  • 不再修改模板
  • 只允许模板值等于 1 的地方通过
  • 然后画一张原本会铺满全屏的条纹

于是结果就变成:

  • 条纹几何虽然是全屏的
  • 但真正显示出来的只有圆形内部

这一步特别重要,因为它让你明白:

  • 模板测试裁切的不是“顶点”
  • 而是“最终片元能不能通过”

也就是说:

  • 你照样可以画一个全屏矩形
  • 只是有些片元在最后被 stencil 拦掉了

现在再回头看 3 个核心 API

到这里你已经知道整个故事了。

这时再看 API,会容易很多。

gl.stencilMask(writeMask)

它控制的是:

  • 这一遍能不能改模板值

常见写法:

  • gl.stencilMask(0xff):允许写模板
  • gl.stencilMask(0x00):禁止写模板,只读模板

所以在这个 demo 里:

  • 第 1 遍用 0xff
  • 第 2 遍用 0x00

gl.stencilFunc(func, ref, mask)

它控制的是:

  • 当前片元在什么条件下才算“模板测试通过”

在这个 demo 里:

  • 第 1 遍用 gl.ALWAYS
    • 表示先别筛选,先让圆形片元都能参与写标签
  • 第 2 遍用 gl.EQUAL, 1
    • 表示只有模板值等于 1 的位置才能显示内容

gl.stencilOp(fail, zfail, zpass)

它控制的是:

  • 模板测试之后,模板值到底怎么改

在这个 demo 里:

  • 第 1 遍用 gl.REPLACE
    • 表示通过后把模板值改成 ref
    • 这里 ref = 1,所以圆形区域被写成 1
  • 第 2 遍用 gl.KEEP
    • 表示这次只读模板,不再改模板

如果你觉得这 3 个函数还抽象,就先记成:

  • stencilMask:写不写
  • stencilFunc:怎么判定通过
  • stencilOp:通过后怎么改值

这样就够了。

为什么每一帧都要 clear stencil

示例里有这两句:

gl.clearStencil(0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);

这表示:

  • 每一帧开始前,把模板缓冲全部重置成 0

这是因为模板缓冲不是自动清空的。

如果你不清:

  • 上一帧留下的圆形标签还会留在里面
  • 下一帧继续绘制时,会被旧数据干扰

结果就会出现:

  • 遮罩残影
  • 模板污染
  • 画面越来越乱

所以模板测试几乎都有一个固定习惯:

  • 每帧开始,先把 stencil 清干净

初学者最容易卡住的 6 个点

1. 以为模板测试是在改顶点

  • 其实不是
  • 它更像是在片元阶段做“放行 / 拦截”

2. 以为第 1 遍画了圆,就应该看到圆

  • 不一定
  • 如果关了 colorMask,这一步只是在后台写模板

3. 以为第 2 遍画的是圆形内容

  • 其实画的是全屏矩形
  • 圆形效果是 stencil 裁出来的

4. 忘了申请 stencil 缓冲

  • 结果:模板相关 API 写了也没效果

5. 忘了每帧清空 stencil

  • 结果:旧标签残留,画面越来越乱

6. 把 gl.EQUAL 和 gl.NOTEQUAL 用反

  • EQUAL:保留标签内部
  • NOTEQUAL:保留标签外部

记忆模板

以后你再看到“模板遮罩”,脑子里直接套这两段就行。

第 1 段:先写标签

gl.colorMask(false, false, false, false);
gl.stencilMask(0xff);
gl.stencilFunc(gl.ALWAYS, 1, 0xff);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
drawMaskShape();
  • 我先画一个形状
  • 但不让它真的显示
  • 只是把它覆盖到的地方标成 1

第 2 段:再按标签显示内容

gl.colorMask(true, true, true, true);
gl.stencilMask(0x00);
gl.stencilFunc(gl.EQUAL, 1, 0xff);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
drawContent();
  • 现在开始画真正内容
  • 但只有标记为 1 的地方才允许显示

这就是最基础的模板遮罩套路。