webgl笔记(五) ——— 模版测试

351 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情

摘要

起因:最近在尝试将canvas的api用webgl的形式实现,遇到了clip这个api,其作用是裁剪绘图区域,后续的绘制都只在规定的范围里生效,范围外的像素将会被舍弃,其中一种实现方式,就是利用webgl的模版测试,正巧上一篇文章提到过模版测试,但没认真学习过,这一篇文章就来看看模版测试究竟是个什么东西,能实现什么功能,怎么用。

是什么

  • 模版测试直观上可能比较难理解,但若把它称为遮罩,就好理解多了,模版测试可以实现遮罩,但不仅限于遮罩
  • 模版测试通俗的讲,就是通过你定义的一套规则,他可以控制渲染区域内哪些像素将被绘制,哪些像素不被绘制,也即你定义了一个模具,符合模具规范的像素才允许被通过,输出到屏幕上
  • 模版测试依靠的WebGL提供的模版缓冲,是区别于帧缓冲的另一种缓冲(帧缓冲中的颜色缓冲可能保存RGBA四个值,模版缓冲只需要保存一个值),在页面上的一个像素对应模版缓冲里的一个数据(单个像素对应模版缓冲中的值称模版值),这个值可以决定当前像素是否被绘制到平面上,而要改变模版缓冲,需要通过WebGL的绘制方法,如drawArrays实现

怎么用

  • 首先需要启用模版测试
// 仅相当于创建了一个模版缓冲,实际并未开启模版测试
const gl = canvas.getContext('webgl', { stencil: true });

// 此时才正式开启模版测试
gl.enable(gl.STENCIL_TEST)
  • 启用了之后,相当于创建了一个模版缓冲,缓冲中的数据总量为canvas的长乘以宽,而每一个数据的默认值都是0
  • 接着需要介绍两个函数,gl.stencilFuncgl.stencilOp
  • gl.stencilFunc用于指定哪些像素可以通过测试,他的定义如下
stencilFunc(func, ref, mask)

其中
*ref
参考值,将被用于后续的模版测试计算,不能取负数(规定)

*mask
用于后续处理模版值以及参考值,默认值是1,不能取负数(规定)

*refmask的计算逻辑为
将refmask按位与(AND)运算,结果记为A
讲模版值与mask按位与(AND)运算,结果记为B
对比AB值判断是否绘制,对比方式通过下面参数决定


*func可以取以下值:
gl.NEVER —— 无论模版值是什么,都不绘制
gl.LESS —— A小于B,则绘制,否则不绘制
gl.EQUAL —— A等于B,则绘制,否则不绘制
gl.LEQUAL —— A小于等于B,则绘制,否则不绘制
gl.GREATER —— A大于B,则绘制,否则不绘制
gl.NOTEQUAL —— A不等于B,则绘制,否则不绘制
gl.GEQUAL —— A大于等于B,则绘制,否则不绘制
gl.ALWAYS —— 无论模版值是什么,都绘制
  • 按上述的方法,则可以筛选出可以通过模版测试,并进行绘制的像素
  • 若希望改变已有的模版值,可以使用gl.stencilOp,该方法定义绘制操作后,通过模版测试的模版值可采取的操作
stencilOp(fail, zfail, zpass)

*fail —— 模版测试失败时,对模版值的处理
*zfail —— 模版测试通过,但深度测试(depth test)失败,模版值的处理
*zpass —— 模版测试,深度测试都通过时,模版值的处理
  • 一般来说,没有启用深度测试的时候,深度测试都是默认通过的,上述三个参数的取值可以为:
gl.KEEP —— 保持现有的模版值
gl.ZERO —— 将模版值设为0
gl.REPLACE —— 将模版值设置为stencilFunc中的ref值,默认值是0
gl.INCR —— 将模版值加一
gl.INCR_WRAP —— 模版值加一,如果超过最大值,设为0
gl.DECR —— 将模版值减一
gl.DECR_WRAP —— 模版值减一,如果小于最小值,设为最大值
gl.INCR —— 模版值按位取反

*默认值是gl.KEEP
  • 所以想要改变模版值,除了初始化的时候设置初始值,只能通过执行一遍模版测试来改变(对于canvas本身的模版值,初始值就是0,无法改变;对于帧缓冲,可以通过新建缓冲数组时设置初始值来改变模版值)
  • 那么如何执行模版测试呢,就是上面提到过的,执行一遍绘制方法,这里以drawArrays为例
// 设置模版测试,所有值都可以通过
// 0xFF与所有数进行与运算都得到这个数本身
gl.stencilFunc(
  gl.ALWAYS,
  1,
  0xFF,
);
// 模版测试通过后,将ref(即1)赋值到对应的模版位上
gl.stencilOp(
  gl.KEEP,
  gl.KEEP,
  gl.REPLACE,
);

// 执行绘制
gl.drawArrays(gl.TRIANGLE_FAN, 0, array1.length / 2)
// 上面接受后,绘制区域的模版值已由0变为1

// 重新设置模版测试,等于1的模版才通过
gl.stencilFunc(
  gl.EQUAL,
  1,
  0xFF,
);
// 通过后不采取所有操作,保持原值
gl.stencilOp(
  gl.KEEP,
  gl.KEEP,
  gl.KEEP,
);

// 如此再绘制一遍图案
// 由于只有在第一次绘制图案区域内的像素才能通过模版测试
// 所以只有这片区域内能像素,区域外没有任何图案
gl.drawArrays(gl.TRIANGLE_FAN, 0, array2.length / 2)
  • 上面是模版测试的一个简单例子,值得注意的是,第一次绘制相当于绘制了一个窗口,限制了绘制的范围,但drawArrays除了执行模版测试,也会在canvas上执行像素绘制,所以如果不希望这个窗口被填充颜色,可以在执行绘制时设置颜色为透明,下面将基于上一节拾取判断的例子进行改造,看看具体实现的效果

clip的WebGL实现

  • clip方法在WebGL中,本质就是绘制一个看不见的限定区域
function clip (posArr, framebuffer) {
  const array = posArr;
  // 限定区域实际不显示在canvas上,所以设定为透明
  const colorArr = [0, 0, 0, 0];
  // simpleProgram中的fragment shader就是简单的接受一个颜色值进行渲染
  gl.useProgram(simpleProgram);
  // 不传framebuffer的时候,以canvas渲染时的默认帧缓冲作为操作对象
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer || null);
  // 开启模版测试
  gl.enable(gl.STENCIL_TEST);
  // 定义下面绘制的内容全部通过模版测试
  // 且绘制区域的模版值由于gl.REPLACE,将被替换成1
  gl.stencilFunc(
    gl.ALWAYS,
    1,
    0xFF,
  );
  gl.stencilOp(
    gl.KEEP,
    gl.KEEP,
    gl.REPLACE,
  );

  // 顶点数据
  const posData = new Float32Array(array);
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, posData, gl.STATIC_DRAW);
  const aPos = gl.getAttribLocation(simpleProgram, 'a_position');
  gl.enableVertexAttribArray(aPos);
  gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

  // 颜色
  const uColor = gl.getUniformLocation(simpleProgram, 'u_color');
  gl.uniform4fv(uColor, new Uint8Array(colorArr));

  // 平移矩阵
  const transMat = createTranslateMat(0, 0);
  const uTrans = gl.getUniformLocation(simpleProgram, 'u_translate');
  gl.uniformMatrix4fv(uTrans, false, transMat);

  // 此时通过绘制看不见的区域,修改了该区域内的模版值
  gl.drawArrays(gl.TRIANGLE_FAN, 0, array.length / 2);
}
  • 以上就实现了clip的内容,后续只需要正常绘制图案,就会发现图案只在上述限定的区域内绘制
function drawImage (...) {
  // textureProgram使用纹理进行渲染
  gl.useProgram(textureProgram);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  // 修改模版测试,仅模版值等于1的像素可以通过测试
  // 通过测试后不更改模版值
  gl.stencilFunc(
    gl.EQUAL,
    1,
    0xFF,
  );
  gl.stencilOp(
    gl.KEEP,
    gl.KEEP,
    gl.KEEP,
  );
  // 设置顶点数据/纹理坐标/绑定纹理
  ...
  // 绘制图案
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}

// 绘制一个矩形限制区域
clip([100, 100, clientWidth - 100, 100, clientWidth - 100, clientHeight - 100, 100, clientHeight - 100]);
// 绘制图片
drawImage(...)
  • 注意绘制前要设定好模版测试,效果如下 Kapture 2023-02-01 at 11.17.10.gif

  • 可以看到只有屏幕中间才有图案,超过设定区域的部分都没有绘制,同时发现,虽然没有得到绘制,但点击不可见部分的时候,还是可以命中拾取判断,从而使图片可以拖动,如果希望只在设定区域内的点击才判断命中,需要给帧缓冲也添加模版测试

  • 要给帧缓冲添加模版测试,首先要给帧缓冲添加模版缓冲,canvas渲染时默认的帧缓冲可以通过canvas.getContext('webgl', { stencil: true })自动添加模板缓冲,帧缓冲则需要自己手动添加,具体如下

function createFramebuffer() {
  // 创建帧缓冲
  const framebuffer = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  // 创建纹理
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, clientWidth, clientHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
  // 绑定纹理
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
  // 创建渲染缓冲,这个渲染缓冲可以用来储存模版缓冲或深度缓冲
  const renderBuffer = gl.createRenderbuffer();
  // 声明渲染缓冲是作为模版缓冲还是深度缓冲
  // 同时设定其数量,系统会自动为该缓冲分配内存空间
  gl.bindRenderbuffer(gl.RENDERBUFFER, renderBuffer);
  gl.renderbufferStorage(gl.RENDERBUFFER, gl.STENCIL_INDEX8, gl.canvas.width, gl.canvas.height); 
  // 绑定渲染缓冲
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.STENCIL_ATTACHMENT, gl.RENDERBUFFER, renderBuffer)

  return framebuffer;
}
  • 渲染缓冲(这里是模版缓冲)新建后,帧缓冲具有了模版测试的能力,接着只需要在帧缓冲绘制前,在帧缓冲上绘制同样的限定区域,并且在帧缓冲绘制过程中,加上模版测试的设定,即可让拾取判断的范围也受限定区域的限制,具体如下
function drawPick (...) {
  gl.useProgram(simpleProgram);
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  // 设置模版测试
  gl.stencilFunc(
    gl.EQUAL,
    1,
    0xFF,
  );
  gl.stencilOp(
    gl.KEEP,
    gl.KEEP,
    gl.KEEP,
  );
  // 顶点数据
  ...

  // 颜色
  ...

  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}

// 注意这里的clip比前面的clip多传了一个framebuffer
// 这是因为这里修改的模版值是帧缓冲上的模版值
clip([100, 100, clientWidth - 100, 100, clientWidth - 100, clientHeight - 100, 100, clientHeight - 100], framebuffer);
drawPick(...);
  • 效果如下

Kapture 2023-02-01 at 11.45.52.gif

  • 源码参考
  • 关于模版值的清除,默认的模版值,由浏览器决定何时清除,一般浏览器清除颜色缓冲事的时候会把模版缓冲和深度缓冲一并清理;而帧缓冲的模版值和深度值则需要手动清除:
// COLOR_BUFFER_BIT颜色缓冲
// DEPTH_BUFFER_BIT深度缓冲
// STENCIL_BUFFER_BIT模版缓冲
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);

写在最后

  • 模版测试主要还是要弄懂stencilFuncstencilOp两个方法的作用
  • 同时要记得改变模版值需要进行一遍绘制,模版缓冲是区别于颜色缓冲的另一种数据储存空间,绘制的时候会进行一次模版测试(stencilFunc),模版测试后的回调(stencilOp)提供了修改模版值的能力,所以绘制操作可以一次性修改颜色缓冲和模版缓冲
  • 模版测试默认不开启,开启后需要创建新的储存空间(默认的模版缓冲自动创建,帧缓冲需手动创建),会有一定性能消耗
  • 帧缓冲的模版测试需要手动给帧缓冲绑定渲染缓冲,同时手动设定渲染缓冲的大小,否则即使是用了gl.enable(gl.STENCIL_TEST)也不会进行模版测试。