浅谈模板测试

538 阅读11分钟

开始之前 来个六角星的相框

在开始枯燥的概念设定之前,看看下面这个简单的效果。

现在,就搞一个六角星的模板, 写入模板缓冲区, 我希望模板区域内不显示原本的场景,而是显示另一个场景。

~~当然,这个用贴图也能做,但是uv就要提前算好了,你改动一个文字就需要重新计算一下uv。 如果,你看过我关于二维图形的文章,就可以这么理解,模版就是一个形状, 用了模板之后,我就能控制模板内显示什么颜色(纹理)。 PS的套索工具。 ~~

模板示意如下。

image.png

使用模板测试后。 image.png

模板测试和深度测试

模板测试和深度测试一样, 都是渲染管线中的一环。默认是不开启的, 要使用它需要手动开启。

gl.enable(gl.STENCIL_TEST);
gl.stencilFunc(gl.LESS, 0, 0b1110011);

webgl 默认绘制的时候,是只会向颜色缓冲区写入数据的, 如果开启深度测试就会写入深度缓冲区,模板也是这样的, 再开启模板测试之后的绘制, 就可能会将片元携带的模板值写入模板缓冲区

为什么是可能呢? 说到这里不得不再次提及深度测试的过程。

假如,你发现你的模板没哟生效,有可能是没通过深度测试。 测试是一个与门, 但凡有一个测试没通过,最终你就见不到这个片元。

再提一下深度测试

所谓深度缓冲区, 就是存储每个xy(二维)坐标对应的深度值(z)。 当开启了深度测试之后, 会先进行深度测试,尔后才更新深度缓冲区。

深度测试的默认规则是LESS, 也就是说,只有当绘制的片元的深度值小于深度缓冲区存储的值时,才能通过测试。 可以自行验证一下,开启深度测试, 绘制两个平面, 相同的深度, 看看谁遮挡谁。

下面是伪代码示意

let depthBuffer: number[] =[ ]
function depthTest(depth:number, index ) {
    let testResult = false ;
    if( depth < depthBuffer[index]){ 
        testResult =true ;
        depthBuffer[index] = depth ; 
    }
    return testResult ; 
}

深度测试的结果会影响到模板测试

模板测试的流程与深度测试相似, 也是先测试后更新缓冲区。 测试的机制和三个值相关, 一是模板缓冲区存储的模板值,姑且就 ·tencil为代号, 二是绘制的片元携带的ref值, 三是一个基准值(纯属个人理解, 因为mask一般不会去动它的)的mask

测试的计算规则是, 比较 (ref & mask) (stencil & mask)。 比较的方式如下:

image.png 比如说下面,就设置规则为 上面的两个值相等的时候才通过。 和深度测试一样, 模板测试通过之后才有可能绘制到画布, 没通过的话,绝对不会显示。

stencilFunc(gl.EQUAL, ref, mask)

模板缓冲区的更新

测试完了,下面就该去更新模板缓冲区了。 这里就和深度测试的结果有关了。

下面这个函数的意思是, 设置三种情况下,对模板缓冲区的处理。

stencilOp(fail, zfail, zpass)
  • fail 模板测试失败时进行的处理,默认值为 gl.KEEP, 即 模板缓冲区中的值保持不变 。

  • zfail 模板测试通过但深度测试失败时,进行的处理。默认值为 gl.KEEP 。

  • zpass 模板测试和深度测试都通过时,或者当模板测试通过且没有深度缓冲区或未开启深度测试时要做的处理。默认值为 gl.KEEP 。

模板测试的一般用法

下面来看一些相对直观的东西,那么模板是怎么生效的呢。或者说到底有什么用。

作用就是,让指定的片元通过或者不通过模板测试。回到开头说的那个例子。这里贴一下关键代码。

整个过程大致如下:

1.开启模板测试

2.清理缓冲区, 参数是可以写成这种 按位或 的形式的,就是这么设计的。

colormask 是用于开关颜色写入,参数是布尔值,这里偷懒写个0 。 表示我不希望这个六角形模板出现在画布上,仅仅用于模板。

3.定义模板测试的规则,以及当前要绘制的片元携带的 ref值,基准值mask 。 规则是永远通过,直接放行, 这样才好配置下一步。

4.定义模板测试成功或者失败的行为, 这里规定了, 只有当通过深度测试,且通过模板测试,才用携带的ref ,去替换模板缓冲区里原本的值。 效果就是, 六角形内部的模板值,现在全为 1 。

useMap是我用来设置是否使用纹理的开关,这里只是模板其实也可以使用纹理,但没必要。 后面这个值为1 就是要绘制纹理图片了。

这里一共12个顶点,前六个就组成了那个六角星, 后面六个就是一个全画布的矩形。

5.恢复颜色写入, 不然啥也看不见。

6.设置模板测试的规则及参数, 规则为只有相等时才通过, 携带片元值为1, mask仍为0xf 。

结果就是只有在六角星内的图片绘制出来了。

因为六角形内的模板值为 1,外为0 ,绘制纹理携带的值为1,

我们来比较一下 stencil & mask 和 ref & mask

模板内, 1 & 0xff === 1& 0xff

模板外, 0 & 0xff ≠≠≠ 1& 0xff


function render(params) {
  // 开模板测试 
  gl.enable(gl.STENCIL_TEST);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);// 清理颜色缓冲区,以及模板缓冲区
 
  // //  绘制六角
  gl.colorMask(0,0,0,0);
  gl.stencilFunc(gl.ALWAYS, 1, 0xff);
  gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
  
  gl.uniform1i(useMap,0) ;
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  
  gl.colorMask(1,1,1,1);
  gl.stencilFunc(gl.EQUAL, 1, 0xff) ;

  gl.uniform1i(useMap,1) ;
  gl.drawArrays(gl.TRIANGLES,6,6) ;
}

完整代码看这里。

下面,来看一些经典案例。

轮廓线

轮廓线有多种绘制方式,使用模板测试是效果还可以,实现也相对简单的一种。

原理十分简单。

第一次绘制原物体, 打上标记。

第二次绘制外扩后的物体,之前标记过的地方全部舍弃,舍弃的方式,就是让其通不过模板测试。

如此一来,内部就被掏空,轮廓就绘制出来了。

如果希望模板只是单纯作为模板,不显示在屏幕上,那就要只写入模板缓冲区,而不写入颜色和深度缓冲区。

如果写入颜色缓冲区,就可能显示到屏幕上, 如果写入深度缓冲,就可能会影响到其他需要正常绘制的物体。 所以下面的做法就是排除干扰。

webgl写起来确实麻烦,下面都将是three.js的示例。

示例加载模型可能需要十秒钟。 主要代码就是getOutlineMesh函数,

   // 如果这里colorwrite改成true  那应该用这个替换原先的mesh
    const  stencilMat  = new  MeshBasicMaterial({
        side:FrontSide,
        colorWrite:false,//纯模板不需要写入颜色
        stencilWrite:true ,
        stencilRef: 1,
        stencilFunc: AlwaysStencilFunc, //  永远通过模板测试
        stencilFail: KeepStencilOp, // 不做改变
        stencilZFail: KeepStencilOp, // 不做改变
        stencilZPass: ReplaceStencilOp, // 替换模板缓冲区的值
        
    })
    // mask的值默认是0xff
    const outlineMat =  new ExpandMaterial({
        side:FrontSide,
        colorWrite:true,
        stencilWrite:	true ,
        stencilRef: 0,
        stencilFunc: EqualStencilFunc, //  只有相等时才通过
        stencilFail: KeepStencilOp,
        stencilZFail: KeepStencilOp,
        stencilZPass: ReplaceStencilOp,
        color , // 这里现在是直接能用的值没经过three转换
        offset
        
    })

二维的模板

我之所以想到这个问题,源自之前的错误认知, 认为模板缓冲具有空间(碰撞)检测,直接裁剪掉一个三维空间 ,就像月饼的模具那样。 因为我看到threejs的这个例子

后面,我就觉得不太可能,因为碰撞检测一直都很麻烦, 如果webgl的模版测试就自带碰撞检测,那还得了

后面又看到了一篇文章,大概看懂了一些, 那就是给片元打标记。 而且是二维的标记。 这个标记是一个数值, 一般都是0 1 ,因为这样好处理,实际上这个值可以是【0, 2^n -1】内的一个数。

在搞清楚这个标记是怎么打进去,以及怎么使用之前,先来看三个参数。 ref mask stencil , 这些值都是 0 - 2^n-1内的一个数, n是模板缓冲区的二进制位数, 好像是8位,有兴趣的可以查查,足够用了。

先说mask 默认值为 1 , 就可以理解为一个全局变量。

ref 默认值为 0 , 是绘制的片元携带的信息,可以理解为每个片元的私有变量。

stencil, (清空模板缓冲区后)默认值也是0 ,可以理解为每个二维坐标的私有变量。

stencilFunc(gl.ALWAYS, 1, 0b10)//always 就是说模板测试肯定通过stencilOp(gl.REPLACE,gl.REPLACE,gl.REPLACE) //  不管测试通过与否,都会将片元携带的值写入模板缓冲

先不管深度测试,也就是我认为深度测试都通过了,或者我直接关了。 现在我向模板缓冲区写入一个几何体, 这个几何体上每个片元都携带 ref = 1 ; 写入之后, 片元对应二维坐标的stencil值就会被替换为ref的值。

上面也是常用手法,或者说通用手法,在打标记的时候,让模版测试放行,写入行为设置为替换

截面

截面的绘制,之前一直不明白, 某天晚上, 想到绘制的时候,要绘制正反面, 我就想到了,截面,其实就是反面的可视部分啊, 这样就全通了。

假如没有截面的阻挡,我们是不是就能直接看到反面了,看不见的反面部分是被正面遮挡了。

请看下面两张图,我切开了猴头, 然后正面用绿色,反面用粉色,这是第一张图,材质都是pbr。

第二张图,正面材质不变, 而反面材质是自发光(相当于three的Basic材质),是不是有了切面的感觉。

clip1.png

clip2.png

当然,这种切面,没有法线,要让人眼真的觉得这个切面就在那个位置上, 需要法线,阴影等。

那么,我们就给它一个平面, 这个平面用pbr材质。

先绘制猴头背面,也带上模板信息,就比如ref为1, 全部通过,这样粉色区域就被 1 填充了。 模板的初始值为0,就得到了如下的模板缓存区 。

image.png

也就是说,我们通过模板测试把一个平面裁剪为切面的形状 ,但是并没有实际获得切面的顶点信息。

下面看代码。 简单封装了一下, 也是基于three的那个例子改的。 这里和上面说的不太一样,上面的想法比较简单,实际实行起来是有问题的。

这里先绘制背面,模板测试会一直通过,通过之后呢模板值递减,已经是最小值了,继续递减会变成最大值, 也就说现在本来是0 ,还要减, 那么减完之后就是255了。 深度测试已关闭,不会被影响到。

再绘制背面,,模板测试会一直通过,通过之后呢模板值递增,如果已经是最大值了,继续递增会变成最小值。 也就说现在本来是1 ,递增之后,就是1。

正反两面一个递增一个递减,那么结果就是正反两面重合的区域,其模板值没有变化,只有正面或者只有反面的区域,模板值会和之前不同。

这个重合的区域就是上面布满了0的区域,截面的区域只有反面,没有正面,对应上面的1区域,当然,现在模板值应该是255。

最后,绘制一个平面,这个就是截面了,平面携带的模板值是0 , 模板测试通过的条件是不等,也就是只有背面露出的那一部分的片元能通过测试。

到这里,截面就绘制出来了。

function getStencilMesh ( geometry, plane,renderOrder =1 ){ 

    const mat0 = stencilMat.clone();
    const group  = new Group()
				mat0.side = BackSide;
				// mat0.clippingPlanes = [ plane ];
				mat0.stencilFail = IncrementWrapStencilOp;  //  模版测试失败     模板缓冲区的值
				mat0.stencilZFail = IncrementWrapStencilOp; // 模板测试通过 ,深度测试未通过   递减
				mat0.stencilZPass = IncrementWrapStencilOp; // 模板测试通过 ,深度测试通过或未开的 行为
                mat0.clippingPlanes = [plane]
				const mesh0 = new Mesh( geometry, mat0 );
                mesh0.name = '背面模板'
				mesh0.renderOrder = renderOrder;
				group.add( mesh0 );

				// front faces
				const mat1 = stencilMat.clone();
				mat1.side = FrontSide;
				mat1.clippingPlanes = [ plane ];
				mat1.stencilFail = DecrementWrapStencilOp;// 递增    反面递减,正面递增
				mat1.stencilZFail = DecrementWrapStencilOp;
				mat1.stencilZPass = DecrementWrapStencilOp;

				const mesh1 = new Mesh( geometry, mat1 );
                mesh1.name = '正面模板'
				mesh1.renderOrder = renderOrder;

				group.add( mesh1 );
                return group ;
}


/**
 * 
 * @param {BufferGeometry} geometry 单个几何体
 * @param { Plane} plane  截面对应的裁剪面 
 * @param {number} renderOrder  如果有多个截面 需设置,不一样的renderorder 递增值不小于1
 * @param {number} size  截面的大小,如果模型很大,需要加大截面,默认10x10
 * @return {Array<Mesh>} 
 */
function getSectionMesh(object ,geometry, plane, renderOrder =1,size = 10){
const planeGeo = new PlaneGeometry(size,size);


    const section = new Mesh( planeGeo , new MeshBasicMaterial({
        color: 0xfaffa9, side:DoubleSide,
        stencilWrite:	true ,
        stencilRef: 0,
        stencilFunc: NotEqualStencilFunc,
        stencilFail: ReplaceStencilOp,
        stencilZFail: ReplaceStencilOp,
        stencilZPass: ReplaceStencilOp,
    
    }));
    //  这里算是特解了 因为法线的初始位置就是z轴
    section.lookAt(plane.normal);
    section.position.copy(plane.normal).multiplyScalar(plane.constant);
    // 截面这里设置的模板测试之后的处理,似乎无意义, 不管是通过还是失败,都要清理了
    section.renderOrder = renderOrder + .1 ;
    section.onAfterRender = ( r)=> { r.clear(false, false, true)}

    const stencilMesh = getStencilMesh( geometry, plane, renderOrder) ;

    return [stencilMesh, section];

}

小结

本文简单讲述了模板测试的基本规则。

以及常用场景,任意形状裁剪 ,轮廓线,截面。