GPU自动处理专题7-深度测试

16 阅读14分钟

深度测试

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

  • 裁剪
  • 透视除法
  • NDC
  • 视口变换
  • 光栅化
  • 裁切测试
  • 模板测试

这一篇终于来到 3D 渲染里一个真正绕不开的主题:

深度测试,Depth Test

如果你刚接触 WebGL,几乎一定遇到过这些问题:

  • 明明后面的物体,怎么跑到前面来了
  • 为什么两个物体一重叠,显示结果和我想的不一样
  • 为什么 3D 场景不能简单理解成“后画的盖住先画的”
  • gl.enable(gl.DEPTH_TEST) 到底打开了什么能力
  • 为什么透明物体经常和深度测试纠缠不清
  • 什么是 z-fighting

这些问题其实都指向同一件事:

  • GPU 到底怎么判断,哪个片元应该显示在最前面

先建立一个直觉

先不要急着看 API。

你先想一个最简单的场景:

  • 一个红色立方体在前面
  • 一个蓝色立方体在后面

当它们投影到屏幕上时,有些像素位置会重叠。

这时 GPU 必须做一个判断:

  • 这个像素到底显示红色,还是显示蓝色

如果没有深度测试,GPU 最容易采用的办法就是:

  • 谁后画,谁覆盖前面

这种做法在 2D 里有时还勉强能用,但在 3D 里很快就不行了。

因为 3D 的真实规则不是:

  • 谁后画谁赢

而是:

  • 谁更靠近相机,谁应该显示在前面

深度测试做的,就是这件事。

你可以先把它记成一句很朴素的话:

  • 深度测试就是 GPU 用来判断“谁更靠前”的规则。

为什么 3D 不能只靠绘制顺序

很多初学者第一次学 WebGL,会下意识地把 3D 也当成 2D 图层去理解。

比如会想:

  • 先画远处物体
  • 再画近处物体
  • 不就行了吗

这在非常简单的场景里,偶尔看起来是对的。

但只要场景稍微复杂一点,就不行了。

例如:

  • 两个模型彼此穿插
  • 一个模型左边在前,右边在后
  • 多个物体相互遮挡,顺序很难排

这时候“整个物体先后顺序”已经不足以解决问题了。

GPU 必须做到的是:

  • 在屏幕的每一个像素位置单独判断
  • 这个位置到底是谁更近

所以深度测试从来都不是“逐物体”的。

它本质上是:

  • 逐片元判断前后关系

深度测试在渲染管线里的位置

把它放回整条流程里,会更容易理解:

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

这说明一件很重要的事:

  • 深度测试不是在顶点阶段做的
  • 也不是在模型矩阵、视图矩阵那里直接做的
  • 它发生在片元已经生成之后

也就是说,到这一步时,GPU 已经知道:

  • 这个片元落在屏幕哪个位置
  • 这个片元的颜色是什么
  • 这个片元的深度值是多少

然后 GPU 才去判断:

  • 这个片元有没有资格成为当前位置最终显示出来的那个

它到底在比较什么

深度测试比较的是:

  • 当前新片元的深度值
  • 和这个像素位置已经记录下来的深度值

如果新片元更符合规则,它就通过。

如果不符合规则,它就被挡掉。

所以你完全可以把深度测试理解成下面这句话:

  • 新来的片元,要和当前位置已经存在的“前排选手”比一比,谁更应该站在前面。

这个“已经存在的前排选手记录”,就放在:

  • 深度缓冲里

什么是深度缓冲

可以把深度缓冲理解成:

  • 一张和屏幕同样大小的“前后关系记录表”

颜色缓冲记录的是:

  • 这个像素最终显示什么颜色

深度缓冲记录的是:

  • 这个像素位置目前最靠前的深度值是多少

举个最简单的例子。

假设某个像素位置当前已经记录的是:

  • 颜色:红色
  • 深度:0.32

这时又来了一个新片元:

  • 颜色:蓝色
  • 深度:0.58

如果规则是“更近者通过”,那这个蓝色片元会失败,因为它更远。

最后显示结果仍然是:

  • 红色

所以深度缓冲本质上就是:

  • GPU 用来记住“这个像素当前位置谁最靠前”的那张表

你在很多资料里看到的 Z-Buffer,大多数时候说的也是它。

为什么很多资料也叫它 Z-Buffer

因为在最直觉的理解里:

  • 深度常常和 z 方向联系在一起

所以你会同时看到两个词:

  • Depth Buffer
  • Z-Buffer

在学习阶段,你可以先把它们当成同一个东西。

但最好顺手记住一件事:

  • GPU 实际拿来比较的,不一定是你建模时原始的 z
  • 它是经过投影、透视除法以及后续映射之后得到的深度值

所以更准确的理解应该是:

  • 它不是“原始 z 值记录表”
  • 而是“最终用于前后比较的深度记录表”

一个最简单的思考实验

假设你有两个重叠三角形:

  • 红三角形更近,深度是 0.2
  • 蓝三角形更远,深度是 0.7

并且你打开了:

gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);

情况一:先画蓝色,再画红色

第一步,画蓝色:

  • 深度缓冲一开始通常是清成 1.0
  • 0.7 < 1.0
  • 蓝色通过
  • 屏幕先写蓝色
  • 深度缓冲更新成 0.7

第二步,画红色:

  • 0.2 < 0.7
  • 红色通过
  • 屏幕改成红色
  • 深度缓冲更新成 0.2

最终结果:

  • 红色在前

情况二:先画红色,再画蓝色

第一步,画红色:

  • 0.2 < 1.0
  • 红色通过
  • 深度缓冲更新成 0.2

第二步,画蓝色:

  • 0.7 < 0.2
  • 不成立
  • 蓝色失败

最终结果仍然是:

  • 红色在前

这就是深度测试最有价值的地方:

  • 对不透明物体来说,它让“绘制顺序”不再是唯一决定结果的因素

最常用的几个 WebGL API

深度测试相关的核心 API 其实不多。

开启深度测试

gl.enable(gl.DEPTH_TEST);

关闭深度测试

gl.disable(gl.DEPTH_TEST);

设置比较规则

gl.depthFunc(gl.LESS);

设置是否写入深度缓冲

gl.depthMask(true);

设置清屏时的默认深度值

gl.clearDepth(1.0);

清空深度缓冲

gl.clear(gl.DEPTH_BUFFER_BIT);

如果你是第一次学,先不要一口气全记。

你只要先记住下面 4 个问题就够了:

  • 开没开深度测试
  • 用什么规则比较
  • 比赢了以后写不写深度
  • 每一帧有没有把深度缓冲清掉

最常见的默认配置

大多数普通 3D 不透明物体都会用这一组配置:

gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
gl.depthMask(true);
gl.clearDepth(1.0);

它们合起来的意思是:

  • 开启深度测试
  • 新片元更近时才通过
  • 通过后把新的深度写进去
  • 每一帧开始时,先把深度缓冲清成“最远”

这组配置你几乎可以直接理解成:

  • 普通 3D 场景里,不透明物体的基础配置

为什么 clearDepth(1.0) 通常设成 1

在 WebGL 的常见直觉里,你可以先把深度理解成:

0 = 更近
1 = 更远

所以每一帧开始时:

gl.clearDepth(1.0);

等于是在说:

  • 先假设所有像素位置都还没有被任何近处物体占据
  • 当前记录全部设成最远

这样第一个真正绘制到这个位置的片元,通常都能通过深度测试。

所以 1.0 的含义可以直觉地记成:

  • 把所有位置先当成“最远”

最常见的比较规则:gl.LESS

最常用的深度规则是:

gl.depthFunc(gl.LESS);

意思是:

  • 只有当新片元的深度值小于当前缓冲中的值时,测试才通过

直觉上就是:

  • 更近的片元获胜

例如当前某个像素位置已经记录的是:

0.45

现在来了一个新片元。

情况 1

新深度 = 0.30

因为:

0.30 < 0.45

成立,所以通过。

情况 2

新深度 = 0.80

因为:

0.80 < 0.45

不成立,所以失败。

这就是最常见的深度判断方式。

depth test 和 depth write 不是一回事

这是非常容易混淆的一点。

你经常会看到这两句:

gl.enable(gl.DEPTH_TEST);
gl.depthMask(true);

它们都和深度有关,但职责完全不同。

DEPTH_TEST

决定的是:

  • 要不要做深度比较

depthMask(true / false)

决定的是:

  • 比较通过以后,要不要把新的深度值写回深度缓冲

所以深度系统里其实有两层逻辑:

  1. 比不比
  2. 比赢了以后写不写

这两件事是可以分开控制的。

三种最常见的组合

组合 1:测试开,写入开

gl.enable(gl.DEPTH_TEST);
gl.depthMask(true);

含义:

  • 会比较前后关系
  • 通过后也会更新深度记录

这是不透明物体最常见的配置。

组合 2:测试开,写入关

gl.enable(gl.DEPTH_TEST);
gl.depthMask(false);

含义:

  • 仍然遵守已有遮挡关系
  • 但自己不去改后续片元参考的深度记录

这常见于:

  • 透明物体
  • 某些特效层

组合 3:测试关

gl.disable(gl.DEPTH_TEST);

含义:

  • 不再按前后关系筛选
  • 更像 2D 图层叠加

这常见于:

  • UI
  • 全屏后处理
  • 调试辅助层

深度值到底从哪里来

你前面已经学过:

  • 投影矩阵
  • 透视除法
  • NDC
  • 视口变换

深度测试和这些是连着的。

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

  • 顶点先经过投影
  • 再经过透视除法
  • 后续再映射成可用于比较的深度值
  • 光栅化之后,每个片元会带着自己的深度值

所以深度测试拿来比较的,并不是模型空间里最原始的 z。

而是:

  • 经过整条投影链路后得到的深度值

这也是为什么你以后调 near / far,会直接影响深度表现。

为什么 near / far 会影响深度效果

这是特别重要的工程问题。

投影矩阵通常和这两个参数有关:

  • near
  • far

如果你把它们设置得很夸张,比如:

near = 0.0001;
far = 100000;

通常就会带来麻烦:

  • 深度精度变差
  • 某些位置前后关系判断不稳定
  • 更容易出现闪烁和 z-fighting

所以一个很实用的经验是:

  • near 不要小得离谱
  • far 不要大得离谱
  • 场景可见范围尽量收敛到合理区间

这往往比你后面补救 bug 更重要。

什么是 z-fighting

z-fighting 是深度测试里最经典的问题之一。

你可以把它理解成:

  • 两个表面太接近了
  • 它们的深度值几乎一样
  • GPU 很难稳定判断到底谁更靠前

于是画面上会出现:

  • 这一帧像是 A 在前
  • 下一帧像是 B 在前
  • 表面看起来一闪一闪

这就像两个面在“争谁站前排”。

所以它才叫:

Z-Fighting

最常见的触发场景有:

  • 两个面几乎共面
  • 贴花和底面贴得太紧
  • near 太小、far 太大
  • 重复绘制了几乎相同的位置

怎么缓解 z-fighting

最常见的办法有这些:

  • 合理设置 near / far
  • 尽量避免完全共面
  • 把上层表面略微抬高一点
  • 在合适的地方使用 polygonOffset
  • 不要重复绘制几乎一样的面

例如做 decal 或贴花时,常见会这样写:

gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(1.0, 1.0);

它不是万能解法,但在某些共面场景里确实很有帮助。

为什么透明物体总和深度测试纠缠不清

这也是初学者最容易困惑的地方之一。

不透明物体很好理解:

  • 前面的完全挡住后面的

透明物体不一样。

透明物体通常意味着:

  • 前面的颜色和后面的颜色要混合

这时如果你对透明物体也像不透明物体那样:

  • 开深度测试
  • 还开深度写入

就容易出问题。

因为前面一个半透明片元一旦把深度写进去了,后面本来应该参与混合的片元,可能直接就过不了深度测试。

于是就会看到:

  • 透明层次不对
  • 透明叠加断掉
  • 前后关系很怪

透明物体的常见处理方式

一个非常常见的工程顺序是:

先画不透明物体

gl.enable(gl.DEPTH_TEST);
gl.depthMask(true);

再画透明物体

gl.enable(gl.DEPTH_TEST);
gl.depthMask(false);
gl.enable(gl.BLEND);

然后通常还要注意:

  • 透明物体尽量按从远到近绘制

也就是说,透明物体最麻烦的地方从来不是一句“开不开深度测试”能解决的。

真正要一起考虑的是:

  • DEPTH_TEST
  • depthMask
  • BLEND
  • 排序

这 4 件事是连着的。

如果忘了清深度缓冲,会发生什么

这是非常常见的 bug。

如果你每帧只清颜色,不清深度:

gl.clear(gl.COLOR_BUFFER_BIT);

那上一帧的深度记录还会留着。

这会导致:

  • 当前这一帧有些片元莫名其妙通不过深度测试
  • 画面看起来像缺块、残影、破碎

因为 GPU 还以为:

  • 那些位置已经被更近的东西占住了

所以在普通 3D 渲染里,一个非常基础的习惯就是:

gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

如果上下文没有深度缓冲,会怎样

很多人会误以为:

gl.enable(gl.DEPTH_TEST);

写了这句,深度测试就一定能正常工作。

其实前提是:

  • 当前默认 framebuffer
  • 或你绑定的 FBO

必须真的有 depth attachment。

所以初始化默认上下文时,常见会写:

const gl = canvas.getContext("webgl", { depth: true });

如果你使用的是离屏 FBO,也要确保它真的挂了:

  • depth renderbuffer
  • 或 depth texture

否则深度测试就没有可靠的记录基础。

深度测试和模板测试有什么区别

这两个测试都发生在后面阶段,所以很多人会混。

其实它们关注的问题完全不同。

深度测试

关心的是:

  • 谁更靠近相机

它解决的是:

  • 前后遮挡关系

模板测试

关心的是:

  • 这个位置逻辑上允不允许画

它解决的是:

  • 区域规则
  • 屏幕权限

所以可以这样记:

  • 深度测试是“前后关系判断”
  • 模板测试是“区域准入判断”

深度测试和裁切测试有什么区别

也很值得顺手区分一下。

裁切测试

判断的是:

  • 这个片元是不是落在某个矩形区域里

深度测试

判断的是:

  • 这个片元在当前位置是不是更靠前

所以你可以用一句比较口语化的话记:

  • scissor 像门禁区域
  • depth 像排前后队

为什么天空盒经常要特别处理深度

天空盒的特点很特别:

  • 它应该始终在最远处
  • 但又应该稳定地作为背景出现

所以工程里经常会针对它做一些特殊处理,比如:

gl.depthFunc(gl.LEQUAL);
gl.depthMask(false);
drawSkybox();
gl.depthMask(true);
gl.depthFunc(gl.LESS);

你不必死记这几句,但要理解背后的目的:

  • 天空盒可以参与深度判断
  • 但通常不希望它污染普通物体的深度写入逻辑

初学者最容易踩的几个坑

1. 开了 DEPTH_TEST,但没清 DEPTH_BUFFER_BIT

结果:

  • 上一帧的深度残留影响当前帧

2. 透明物体也在写深度

结果:

  • 透明叠加很怪

3. near 太小,far 太大

结果:

  • 深度精度差,容易闪

4. 以为深度测试比较的是原始 z

结果:

  • 很多前后关系会理解错

5. 把“是否比较”和“是否写入”混成一件事

结果:

  • 状态调不清楚

6. 以为开了深度测试就完全不用管顺序

结果:

  • 对透明物体仍然会踩坑

总结

  • 深度测试的本质,就是 GPU 在每个屏幕位置比较新旧片元谁更靠前,并决定当前片元能不能显示;它依赖深度缓冲记录每个像素目前最靠前的深度值,所以是 3D 场景正确遮挡关系的核心机制。