圆形遮罩
先别背 API
如果你是第一次学模板测试,我建议先不要急着记:
gl.colorMaskgl.stencilMaskgl.stencilFuncgl.stencilOp
因为一上来就背这些名字,通常只会觉得乱。
你先记住一句话:
- 模板测试就是先在屏幕上“做标记”,再规定“只有被标记的地方才能画内容”。
这个 demo 做的事情,其实非常简单:
- 先用一个圆形,在屏幕上悄悄做标记
- 再画一张原本会铺满全屏的条纹
- 但只允许条纹出现在“被圆形标记过”的地方
所以最后你看到的是:
- 条纹只显示在圆形内部
- 圆形外面什么都没有
如果你脑子里已经有了这个画面,后面所有 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的地方才允许显示
这就是最基础的模板遮罩套路。