webgl笔记(六) ——— 深度测试与混合

1,221 阅读8分钟

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

摘要

上一篇文章提到了如何使用模版测试来限制绘图的区域,这里我们再来看一下深度测试,与模版类似类似,深度值也是单独一个缓冲作为储存,记录着每个像素点的深度值,在绘制过程中,会进行更新。

是什么

  • 记录着深度值的缓冲区,一个片元具有一个深度值。
  • 他决定WebGL上的图案离屏幕的远近,以及彼此的遮罩关系
  • 默认情况下,后绘制的图案会把之前绘制的图案覆盖,开启深度测试后,可以改变片元的覆盖顺序
  • WebGL的测试与混合阶段,参考1参考2
裁切测试 -> alpha测试 -> 模版测试 -> 深度测试 -> alpha混合
  • 裁切测试有点类似模版测试,也是限定一个绘图区域,通过gl.enable(gl.SCISSOR_TEST)启用
  • WebGL没有专门的alpha测试阶段
  • 混合(blend)是让透明的图片重叠时,展示出颜色重叠的效果,而不是简单的覆盖重写像素,通过gl.enable(gl.BLEND)启用

怎么用

  • 深度测试默认关闭,开启需要设置
gl.enable(gl.DEPTH_TEST);
  • 启用后就创建了一个储存着深度值的缓冲区,初始值默认都是1.0(不确定,没找到相关规范,但因为clear时默认值是1,所以这里猜测初始值也是1),可以通过clear重置整个缓冲区的深度值
gl.clearDepth(1.0);
gl.clear(gl.DEPTH_BUFFER_BIT);
  • 可以通过设置深度测试的方法来决定哪些片元可以通过测试
gl.depthFunc(func);

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 —— 永远通过
  • 可以看到深度测试其实和模版测试是非常类似的,都是为了把某些片元排除掉,来让最后的绘制效率更高
  • 这里注意到,这些测试的目的都是排除片元,没有通过测试的那些片元,在后续测试以及绘制过程中,都将被完全跳过
  • 那么如何修改深度值呢,由于WebGL没有提供类似stencilOp的方法,WebGL可以通过扩展gl.extentions(随着时代发展基于核心OpenGL扩展的一些功能,通常由硬件厂商推出,最后由OpenGL管理结构认可)的方式来修改深度值
gl.getExtension('EXT_frag_depth');

// 如果不支持上面返回null
  • EXT_frag_depth启用之后,就可以在片元着色器中,直接设置深度值
#extension GL_EXT_frag_depth : require
precision mediump float;
...

void main() {
  gl_FragColor = u_color;
  ...
  gl_FragDepthEXT = u_deep;
}
  • 由于默认情况下,着色器不认识gl_FragDepthEXT这个变量,所以需要在开头添加#extension GL_EXT_frag_depth : require来让着色器能顺利编译
  • 这个gl_FragDepthEXT就是当前这个片元的深度值,可以像设置颜色gl_FragColor一样设置他的值,从而改变该片元的深度

一个例子

  • 基于之前的一个例子,我们尝试实现改变图片的深度值,来让我们点击到的图片位于最上面,而不是后绘制的图片在最上面
  • 首先,改造shader
<script id="fragment-shader-2d" type="notjs">
    #extension GL_EXT_frag_depth : require
    precision mediump float;
    varying vec2 v_texCoord;
    uniform sampler2D u_image;
    uniform float u_deep;

    void main() {
      vec4 texture = texture2D(u_image, v_texCoord);
      gl_FragColor = texture;
      gl_FragDepthEXT = u_deep;
    }
</script>

<script id="fragment-shader-2d-pick" type="notjs">
    #extension GL_EXT_frag_depth : require
    precision mediump float;
    uniform vec4 u_color;
    uniform float u_deep;

    void main() {
      gl_FragColor = u_color;
      gl_FragDepthEXT = u_deep;
    }
</script>
  • 看到我们给用于拾取判断的片元着色器以及绘制图片的着色器都添加了gl_FragDepthEXT = u_deep语句,u_deep通过uniform变量设置
  • 接着启用深度测试以及支持设置深度值的扩展
...
const gl = canvas.getContext('webgl');
gl.enable(gl.DEPTH_TEST);
gl.getExtension('EXT_frag_depth')
...
  • 接着在读取图片的时候给一个默认的深度值,设为0.8(0 ~ 1.0随便设,这里是随便取的值)
  function onImgLoad (img, index, name) {
      ...
      const imgInfo = {
        src: img,
        posArr: positionArr,
        uid,
        name,
        depth: 0.8,
        originClick: { x: 0, y: 0 },
        translate: { x: 0, y: 0 }
      };

      imgList.push(imgInfo);
      ...
    }
  • 然后在绘制的时候设置u_deep
    function drawImage (framebuffer, info) {
      ...
      // 顶点数据
      ...
      // 颜色uid
      ...
      // 平移矩阵
      ...
      // 深度
      const uDepth = gl.getUniformLocation(program, 'u_deep');
      gl.uniform1f(uDepth, info.depth);

      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }
  • 到这里,图片还是会按照导入的顺序进行覆盖的,因为他们的深度值是一样的,那么我们把改变深度值的时机,放在点击事件里,即每次点击的图片都置于最上层
  canvas.addEventListener('mousedown', (e) => {
      // 拾取判断 
      ...
      if (pick) {
        console.log('pick up:', pick.name);
        ...
        // 重置所有图片的深度值,同时令当前点击的图片深度值为0.5
        imgList.forEach(img => img.depth = 0.8);
        pick.depth = 0.5;
        updateDepth();
      }
    });
    
    function updateDepth () {
      render({ canvas: true })
    }
  • 可以看到我们减少了当前点击图片的深度值,那按照深度测试,深度小的会覆盖在最上层,同时注意改变深度值前,先把所有图片深度值重置一遍,否则多次点击时深度值相同看不出效果,另外改变深度值之后,需要重新绘制一次canvas,否则也看不到更新
  • 那么我们来看看效果 Kapture 2023-02-15 at 20.22.22.gif
  • 可以看到我们点击的那张图片,确实已经被重新绘制在最上层了,但是还有个问题,就是当两张图片重叠的时候,拾取判断还是按照导入的顺序判断那张图片在上层的,所以我们需要把深度也写进帧缓冲中
    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.DEPTH_COMPONENT16, gl.canvas.width, gl.canvas.height); 
      gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, renderBuffer);
      gl.dep

      return framebuffer;
    }
  • 与模版缓冲类似,深度缓冲也是通过createRenderbuffer创建,绑定后通过renderbufferStorage声明大小,最后绑定到帧缓冲上
  • 这时,我们就可以在帧缓冲的绘制过程中,修改深度值了
    function drawPick (framebuffer, info) {
      ...
      // 顶点数据
      ...
      // 颜色uid
      ...
      // 平移矩阵
      ...
      // 深度
      const uDepth = gl.getUniformLocation(pickProgram, 'u_deep');
      gl.uniform1f(uDepth, info.depth);
      console.log('drawpick', info.name, info.depth);

      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }
  • 运行看看效果,可以看到现在点击只会判断最上层的图片被拾取,而不会拾取下方被覆盖的图片了 Kapture 2023-02-17 at 11.19.07.gif
  • 然后我们又发现一个新的问题,透明的图片在绘制时,透明的部分会直接清除下方图片的像素,而不是透过透明部分看到下层图片,如下 image.png
  • 这就需要我们用到WebGL的混合功能

混合

  • 混合功能默认关闭,可以通过下述命令打开
gl.enable(gl.BLEND);
  • 可以设置混合参数,混合参数的参考说明可以看这里,简单讲,就是第一个参数是源像素因子,第二个是目标像素因子,系统会按参数规定的运算规则,算出一个因子,然后分别乘以源与目标的像素值,最后相加,就得到叠加后该像素新的像素值
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  • 开启后运行看看效果 Kapture 2023-02-17 at 12.01.42.gif
  • 很完美对不对,但试多几次我们就会发现有点问题 Kapture 2023-02-17 at 12.00.49.gif
  • 为什么有时候会有部分位置不透明,打印输出后我们发现,这和图片的绘制顺序有关,如下图,5.png就是demo里右下角那个具有透明背景的图片,只要当5.png不是最后一个绘制的时候,就会导致其后绘制的元素,被透明背景遮盖的现象 企业微信截图_82d60f95-be0a-413d-aa9b-f97fafb0f47d.png
  • 这是因为深度测试在起作用,当2.webp绘制的时候,他可以完美的通过深度测试,然后5.png绘制进行覆盖,同时混合发生作用,所有左上角没有被覆盖,其后,4/3.png绘制的时候,因为其深度值大于5.png的深度值,通过不了深度测试,这个时候4/3.png的部分像素就不会被绘制到屏幕上,最终的结果就是demo里部分像素被透明背景遮挡了
  • 要解决这个问题,有以下几个途径
    • 在shader处理,透明部分直接不绘制,但这个方法首先因为要进行判断,会消耗性能,其次本质其实连混合都跳过了,因为最终绘制出来的部分压根就没有透明像素
    <script id="fragment-shader-2d" type="notjs">
    #extension GL_EXT_frag_depth : require
    precision mediump float;
    varying vec2 v_texCoord;
    uniform sampler2D u_image;
    uniform float u_deep;
    
    void main() {
      vec4 texture = texture2D(u_image, v_texCoord);
      if (u_image.a == 0)
      {
          discard;
      }
      gl_FragColor = texture;
      gl_FragDepthEXT = u_deep;
    }
    </script>
    
    • 屏蔽深度测试,数组手动维护图片绘制顺序,这可能是最靠谱的实现方式,因为混合的效果是基于绘制顺序的,所以不可避免会与深度测试发生冲突
    • 网上还有种实现方式,是通过gl.depthMask(false)暂时关闭深度测试,然后绘制透明图片进行混合,这样透明图片的深度值就不会被写到缓冲里,后续绘制不透明图片时,再通过gl.depthMask(true)把深度测试打开即可,但这也有问题,就是他默认了透明图片一定是位于最上层的,无法实现后绘制的透明图片被不透明图片遮盖的情况,另外如何判断图片透明也是一个问题

写在最后

  • 这篇文章介绍了深度缓冲以及深度测试,还提及了如何在帧缓冲里绑定深度缓冲
  • 但实际上,很少有需求需要直接去修改深度值,这里只是为了了解深度测试的运作规律,所以才实现修改深度值的demo
  • 更有意义的是混合的开启,以及产生问题时,可以先考虑是否混合和深度测试发生了冲突
  • 一般来说,控制绘制顺序已经可以实现彼此覆盖的需要,更推荐通过队列来实现这类需求