GPU自动处理专题5-裁切测试

237 阅读12分钟

裁切测试

前面的专题里,我们已经一路讲过:

  • 裁剪,Clipping
  • 透视除法
  • NDC
  • 视口变换
  • 光栅化

到了这里,很多人会在 WebGL API 里又看到一个名字很像的东西:

gl.enable(gl.SCISSOR_TEST);
gl.scissor(x, y, width, height);

这时候就很容易懵:

  • 这个“裁切测试”到底是什么
  • 它和前面讲的“裁剪”是不是一回事
  • 它和模板测试有什么区别
  • 它到底裁掉了什么
  • 为什么很多 UI、分屏、局部清屏都要用它

先给最短答案

如果你只想先记一句最核心的话,可以先记这个:

  • 裁切测试 = 用一个屏幕空间里的矩形区域,限制哪些片元允许继续写入,哪些片元必须被丢弃

再压缩一下,可以记成:

裁切测试不是裁几何,而是裁“屏幕上的可写区域”。

这句话非常关键。

因为它直接点出了裁切测试和“裁剪”的本质区别:

  • 裁剪关注的是几何图元有没有超出可见体
  • 裁切测试关注的是片元当前是否落在一个矩形写入区域里

它在整条管线里的位置

先把它放回整个渲染流程里:

顶点着色器
-> 图元装配
-> 裁剪
-> 透视除法
-> NDC
-> 视口变换
-> 光栅化
-> 片元着色器
-> 裁切测试
-> 模板测试
-> 深度测试
-> 混合
-> 帧缓冲

如果你只看学习阶段的直觉,可以把它理解成:

  • 顶点阶段已经结束
  • 三角形也已经被光栅化成片元了
  • 现在 GPU 先问一句:
  • 这个片元落在我允许写入的矩形区域里吗

如果不在:

  • 这个片元就会被丢弃
  • 后面的模板、深度、混合都没必要继续了

所以裁切测试本质上是:

  • 片元级别的矩形区域过滤

裁切测试到底在裁什么

这是整篇里最重要的问题。

答案是:

  • 它裁的不是顶点
  • 也不是三角形的一部分几何结构
  • 它裁的是落在矩形区域外的片元写入机会

换句话说:

  • 一个三角形本来可能覆盖整块屏幕
  • 经过光栅化后产生很多片元
  • 裁切测试只允许矩形内部那些片元继续活下去
  • 矩形外的片元直接丢弃

所以更准确地说:

裁切测试裁的是屏幕空间里的片元,不是几何空间里的图元。

先和“裁剪 Clipping”彻底分清

这两个名字很像,但本质完全不是一个东西。

裁剪,Clipping

  • 发生在裁剪空间阶段
  • 针对的是点、线、三角形这些图元
  • 目的是把超出可见体的几何部分切掉
  • 它决定“哪些几何还值得进入后续流程”

裁切测试,Scissor Test

  • 发生在片元相关阶段
  • 针对的是屏幕上的片元
  • 目的是限制最终可写入的矩形区域
  • 它决定“这些片元有没有资格写进当前目标区域”

你可以这样记:

  • 裁剪 = 几何空间问题
  • 裁切测试 = 屏幕矩形区域问题

这是最重要的区分。

再和模板测试分清

很多人学完模板测试以后,会觉得:

  • 模板测试能限制区域
  • 裁切测试也能限制区域
  • 那它们是不是差不多

它们确实都能控制“哪里能画”,但控制方式差别很大。

裁切测试

  • 只能用一个轴对齐矩形
  • 规则非常简单
  • 不需要额外写模板值
  • 开销低,适合快速限制矩形区域

模板测试

  • 可以做任意形状区域
  • 需要 stencil buffer
  • 一般要先写模板,再按模板筛选
  • 适合遮罩、描边、镜子、传送门等复杂区域控制

所以:

  • 如果你只想限制一个简单矩形区域,用裁切测试更直接
  • 如果你想限制圆形、文字、不规则洞口,用模板测试更合适

裁切测试的 API 非常少

WebGL 里和它直接相关的核心 API 就两个:

gl.enable(gl.SCISSOR_TEST);
gl.scissor(x, y, width, height);

关闭就是:

gl.disable(gl.SCISSOR_TEST);

它们分工很清楚。

gl.enable(gl.SCISSOR_TEST)

  • 开启裁切测试

gl.scissor(x, y, width, height)

  • 指定裁切矩形
  • 只有这个矩形里的片元允许继续写入

这个矩形的含义是:

  • 左下角起点:(x, y)
  • 宽:width
  • 高:height

也就是说:

gl.scissor(100, 50, 300, 200);

表示:

  • 只允许绘制到从左下角 (100, 50) 开始
  • 宽 300
  • 高 200

的那个矩形区域里。

注意坐标原点:它是左下角

这点非常容易弄错。

WebGL 的 gl.scissor() 使用的是:

  • 视口/窗口坐标
  • 原点在左下角

不是浏览器 DOM 事件那种:

  • 原点在左上角

所以如果你在浏览器里拿鼠标坐标、UI 坐标去算 gl.scissor(),经常需要做 Y 轴翻转。

例如 canvas 高度是 canvas.height,DOM 给你的 top 坐标是:

const domY = 20;

那对应的 WebGL scissor Y 往往要写成:

const glY = canvas.height - domY - rectHeight;

这里之所以还要减 rectHeight,是因为:

  • DOM 常用的是“矩形左上角”
  • gl.scissor() 需要的是“矩形左下角”

最直接的直觉例子

假设整个画布是 800 x 600,你设置:

gl.enable(gl.SCISSOR_TEST);
gl.scissor(200, 150, 400, 300);

这表示只有中间这块矩形区域允许写入:

屏幕宽 800,高 600

左边空 200
右边空 200
下边空 150
上边空 150
中间 400 x 300 可以绘制

此时即使你画一个覆盖全屏的大矩形:

  • GPU 也只会保留中间 400 x 300 区域里的结果
  • 其他区域像被“切掉”一样

这就是裁切测试最直观的行为。

它最常见的用途有哪些

裁切测试虽然简单,但非常实用。

最典型的用途有这些:

  • 局部 UI 面板绘制
  • 分屏渲染
  • 小地图窗口
  • 画中画
  • 局部重绘
  • 局部清屏
  • 多视图编辑器
  • 把后处理限制在某个矩形区域

你会发现它们有个共同点:

  • 这些需求都只需要一个矩形区域
  • 不需要复杂不规则形状

这正是裁切测试最合适的使用场景。

为什么它特别适合“局部清屏”

这是裁切测试最值得单独记住的一点。

很多人第一次学它,都是在这里真正意识到它的价值:

  • gl.clear() 也会受 scissor 影响

也就是说,如果你先设置:

gl.enable(gl.SCISSOR_TEST);
gl.scissor(0, 0, 300, 300);
gl.clearColor(1, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);

结果不是整屏变红,而是:

  • 只有左下角 300 x 300 这块区域被清成红色

这个特性在工程里非常有用,因为它意味着你可以:

  • 不重刷整张画布
  • 只重置某个局部区域

这对编辑器、多面板渲染、局部更新都很重要。

完整示例 1:只清除一块矩形区域

先看一个最短、最能说明问题的例子:

// 先把整屏清成深灰色
gl.disable(gl.SCISSOR_TEST);
gl.clearColor(0.1, 0.1, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 开启裁切测试,只让左下角 200x200 区域可写
gl.enable(gl.SCISSOR_TEST);
gl.scissor(0, 0, 200, 200);
// 这次 clear 只会影响左下角这一小块(红色)
gl.clearColor(1.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 关闭,避免影响后续绘制
gl.disable(gl.SCISSOR_TEST);

运行结果可以理解成:

  • 全屏先被清成深灰色
  • 然后左下角一个 200 x 200 矩形被重新清成红色

这个例子虽然简单,但已经把裁切测试的核心能力完整体现出来了。

完整示例 2:分两块区域分别绘制不同内容

再看一个更像实际工程的例子。

我们把画布分成左右两半:

  • 左边清成绿色
  • 右边清成蓝色

代码如下:

const width = canvas.width;
const height = canvas.height;

// 开启裁切测试
gl.enable(gl.SCISSOR_TEST);

// 左半屏清成绿色
gl.scissor(0, 0, Math.floor(width / 2), height);
gl.clearColor(0.0, 1.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 右半屏清成蓝色
gl.scissor(Math.floor(width / 2), 0, Math.floor(width / 2), height);
gl.clearColor(0.0, 0.0, 1.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 关闭裁切测试
gl.disable(gl.SCISSOR_TEST);

这段代码里并没有复杂 shader,但你已经能做出:

  • 分栏背景
  • 分屏预览
  • 左右两个工作区

如果再配合不同的相机和真正的 draw call,就能做:

  • 左边俯视图
  • 右边透视图

这就是很多编辑器、多视图工具最基础的矩形区域管理方式。

完整示例 3:只在某个面板区域里画内容

下面给一个更接近“真正绘制”的示例。

假设你有一个全屏矩形 shader,本来会把内容铺满全画布:

图片.png

即使这个 quad 覆盖的是整屏:

  • 真正能写入的也只有 (100, 80, 300, 220) 这个矩形

所以裁切测试的一个很常见使用方式就是:

  • 让绘制逻辑保持简单
  • 再用 scissor 控制输出区域
// 先清成背景颜色
gl.clearColor(0.06, 0.07, 0.09, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 开启裁切测试
gl.enable(gl.SCISSOR_TEST);
// 设置裁切矩形
gl.scissor(100, 80, 300, 220);

// 绘制 quad
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.enableVertexAttribArray(loc.aPosition);
gl.vertexAttribPointer(loc.aPosition, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);

// 关闭裁切测试
gl.disable(gl.SCISSOR_TEST);

scissor 和 viewport 到底有什么区别

这是一个非常经典的面试/学习问题。

因为很多人第一次看到这两个 API 时,都会觉得它们都像“控制区域”的:

gl.viewport(...)
gl.scissor(...)

但它们控制的根本不是一件事。

gl.viewport()

  • 控制 NDC 到屏幕像素的映射区域
  • 本质是“坐标映射规则”
  • 它影响顶点最终落到屏幕哪里

gl.scissor()

  • 控制哪些屏幕像素区域允许写入
  • 本质是“矩形写入许可”
  • 它不改变几何位置,只限制最后能不能落盘

你可以这样理解:

  • viewport 像是在说“把世界投到哪块屏幕上”
  • scissor 像是在说“只有这块矩形区域准许真正写进去”

两者经常一起配合使用,但职责不同。

举个 viewport 和 scissor 配合的例子

比如你要做一个右上角的小地图窗口。

你可能会这样做:

// 小地图区域
const miniX = 550;
const miniY = 350;
const miniW = 200;
const miniH = 200;

gl.viewport(miniX, miniY, miniW, miniH);
gl.enable(gl.SCISSOR_TEST);
gl.scissor(miniX, miniY, miniW, miniH);

gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

renderMiniMapScene();

gl.disable(gl.SCISSOR_TEST);

这里:

  • viewport 负责把小地图相机的结果映射到右上角这块区域
  • scissor 负责保证清屏和写入都只发生在这块区域里

如果你只设 viewport 不设 scissor,常见问题是:

  • clear() 仍可能清整屏

所以在多面板渲染里,这两个 API 往往是一对出现的。

为什么说它是“矩形版的区域控制”

你可以把区域控制手段大致分成三类:

1. 裁切测试

  • 形状:轴对齐矩形
  • 优点:简单、直接、便宜
  • 适合:面板、小地图、分屏、局部 clear

2. 模板测试

  • 形状:任意
  • 优点:灵活
  • 适合:遮罩、描边、镜子、传送门

3. 在 shader 里手动 discard

  • 形状:任意,甚至可程序化
  • 优点:最灵活
  • 缺点:一般更重,也更复杂

所以如果你的需求只是一个普通矩形,通常优先想到的就是:

scissor test

裁切测试为什么不适合做圆形遮罩

因为它天生只能表达一个轴对齐矩形:

gl.scissor(x, y, width, height);

没有:

  • 半径
  • 多边形点集
  • 曲线
  • 任意轮廓

所以你做:

  • 圆形头像裁切
  • 星形区域裁切
  • 不规则洞口

这些场景时,裁切测试就不够用了,通常应该上:

  • 模板测试
  • 或 shader discard

这也是为什么:

  • 裁切测试简单
  • 但它的表达能力有限

裁切测试会不会影响片元着色器

从学习角度你可以先这么理解:

  • 它不会改变你的几何数据
  • 不会改变顶点着色器输出
  • 不会改变模型矩阵、视图矩阵、投影矩阵
  • 它只是在“最后能不能写进目标区域”这件事上加一个矩形筛选条件

所以你可以把它看成:

  • 不改渲染内容本身,只改内容最终允许落在哪

这也是它非常适合做工程级区域管理的原因。

如果和 clear 一起用,它到底限制了什么

很多人最初接触 scissor 的时候会疑惑:

  • 明明 clear 又不是画三角形
  • 为什么它也受 scissor 影响

因为从 GPU 的角度看:

  • clear 本质上也是在对某个目标缓冲做写入

而 scissor test 的职责正是:

  • 限制当前写入操作能发生在哪个矩形区域里

所以:

  • 不只是 draw call
  • 连 clear 这种缓冲写操作

也会受 scissor 控制。

和 depth、stencil 一起使用时要怎么理解

你可以把这些测试理解成不同维度的筛选器:

裁切测试

  • 看这个片元是不是落在允许的矩形区域里

模板测试

  • 看这个片元所在位置的 stencil 值是否符合规则

深度测试

  • 看这个片元是不是比已有内容更靠前

所以一个片元可能要连续过很多关:

先过矩形区域关
再过模板规则关
再过前后遮挡关
最后才可能写入颜色

从直觉上讲:

  • scissor 像门口大框
  • stencil 像区域密码锁
  • depth 像前后排队规则

最容易踩坑的几个点

1. 忘了关闭 SCISSOR_TEST

  • 结果后面的 draw call 也被限制在旧矩形里
  • 这是最常见的 bug

2. 坐标系搞反了

  • 浏览器 UI 习惯左上角原点
  • WebGL scissor 用左下角原点
  • Y 很容易算错

3. 只设置了 viewport,没设置 scissor

  • 结果 draw 位置对了
  • 但 clear 或某些写操作越界污染别的面板

4. 想用 scissor 做不规则遮罩

  • 做不到
  • 它只能是轴对齐矩形

5. resize 后忘了重算矩形

  • 画布尺寸变了,但 scissor 还是旧值
  • 面板区域就会错位

总结

  • 裁切测试,Scissor Test,本质上是 GPU 在片元写入阶段增加的一道“矩形区域门禁”,它不会裁几何,只会限制哪些屏幕矩形区域允许继续写入,因此特别适合局部绘制、局部清屏、分屏视图、小地图和多面板渲染。