深度测试
前面的专题里,我们已经讲过:
- 裁剪
- 透视除法
- 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 BufferZ-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:测试开,写入开
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 会影响深度效果
这是特别重要的工程问题。
投影矩阵通常和这两个参数有关:
nearfar
如果你把它们设置得很夸张,比如:
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_TESTdepthMaskBLEND- 排序
这 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 场景正确遮挡关系的核心机制。