前言

在三维场景中,经常会出现物体互相遮挡的情况,在不考虑半透明因素的情况下,肯定是先渲染后面被遮挡的物体,再渲染前面的物体叠上去,类似油画的逐层绘制一样。

这种渲染前先根据前后关系为模型进行排序,再按顺序进行渲染的做法可以解决这类的遮挡问题,确实很符合直觉。

但对于计算机来说,只有最前面被绘制的一层像素才会被看到,后面被遮挡住的绘制是没有任何意义的,同一个像素的多次重复绘制只会浪费性能,也就是所谓的OverDraw问题。

并且在虚拟的三维场景中,模型之间是可以穿插的,当三角形的遮挡关系十分复杂,出现了循环遮挡的时候,就无法通过排序渲染出正确的效果了。

因此就引入了Z-depth buffer(深度缓存)的概念。

 

ZDepth buffer

提到ZDepth buffer(深度缓存),就会想到frame buffer(帧缓存),帧缓存是用来记录每一个像素的颜色的,而深度缓存则是用来记录每一个像素的深度的,如下图所示。

屏幕上的每个像素可能对应多个不同深度的片元(三角形光栅化后的类似像素的点),那么就可以通过遍历每个片元的z坐标,找到距离摄像机最近的片元,也就是深度最小的片元,将其输出。

在ZDepth buffer图中,颜色的灰度代表点距离摄像机的远近,黑色代表近,白色代表远。

在《渲染管线基础》一文中有说过,经过顶点着色器以及屏幕映射后,顶点坐标从模型空间转换到屏幕空间,实现了从三维到二维平面的映射,但z坐标并未被去掉,只是被压缩到了[0, 1]的范围,可以用来表示距离摄像机的远近,也就是深度。

通过深度值实现遮挡渲染的算法就叫做Z-Buffer算法,主要有3步。

  1. 为像素点维持一个深度数组记为zbuffer,其每个位置初始值置为无穷大(即离摄像机无穷远)。
  2. Z-Test:遍历每个三角形面上的每一个像素点[x,y],Z-Test的参数决定了通过深度测试的条件,默认LEqual模式下,如果该像素点的深度值z小于等于zbuffer[x,y]中的值,则通过深度测试,可以进行下一步。
  3. Z-Write:深度测试通过后,会检查深度测试的状态Z-Write,此状态决定是否将当前深度值写入zbuffer。默认Z-Write On,更新zbuffer[x,y]值为该点深度值z,并更新frame buffer该像素点[x,y]的颜色为该三角形面上的该点的颜色;当Z-Write Off时,zbuffer[x,y]值不改变,只更新frame buffer该像素点[x,y]的颜色为该三角形面上的该点的颜色。

其中Z-Test可以配置为以下参数:

  • Greater——深度大于当前深度缓存则通过
  • GEqual——深度大于等于当前深度缓存则通过
  • Less——深度小于当前深度缓存则通过
  • LEqual——深度小于等于当前深度缓存则通过,默认值
  • Equal——深度等于当前深度缓存则通过
  • NotEqual——深度不等于当前深度缓存则通过
  • Always(Off)——总是通过
  • Never——总是不通过

下图就表示Z-Test为Less,Z-Write为On时的深度测试过程,其中R表示无穷大。

这样,最后保留下来的每个像素对应的就是距离摄像机最近的三角形面上的颜色了,也就得到了正确的遮挡顺序。

当Z-Test和Z-Write使用其他参数,再配合渲染队列等手段,就可以实现一些奇特的渲染效果,例如轮廓透视材质、简易粒子材质等。

另外前文中有说过SSAA等通过增加采样点进行反走样的技术,这种情况下一个像素会对应多个采样点,此时的Z-buffer深度缓存需要对每一个采样点进行计算才能得到正确的反走样效果。

 

深度值精度

前面说过,深度值是通过顶点在屏幕空间的Z坐标得到的,数值范围是[0, 1],精度一般为24位float。

顶点的z坐标和相机距离的关系通常是线性的,而考虑到在摄像机视角下近处的物体通常需要更高的精度进行区分,远处的物体则不需要仔细对比分辨。

因此实际使用中有必要对深度值进行非线性化,牺牲部分远处的精度让近处的精度更高,让深度与1/z成正比,类似于Gamma2.2校正的原理,让图形学的计算更符合人眼的直觉。

这一个非线性化的步骤通常是内嵌在投影矩阵中的,也就是说经过MVP矩阵后得到的Z坐标已经是一个非线性校正后的值。

 

深度冲突

当两个平面非常紧密地平行排列在一起时,经常会出现奇怪的闪烁和条纹,这是因为深度缓冲没有足够的精度来判断两个平面的先后顺序,结果就是这两个平面不断地在切换前后顺序,这个现象就叫做深度冲突(Z-fighting)。

要想解决深度冲突问题可以尝试以下三个方案:

  1. 不要把多个物体摆得太靠近,以至于它们的一些三角形重叠。通过在两个物体之间设置一个无法注意到的偏移值,就可以完全避免这两个物体之间的深度冲突。
  2. 尽可能将近平面设置远一些。深度值精度在近处是非常高的,所以如果我们将近平面远离观察者,就会让目标物体精度更高。然而,将近平面设置太远将会导致近处的物体被裁剪掉,所以这通常需要不断微调来决定最适合的场景的近平面距离。
  3. 使用更高精度的深度缓冲。大部分深度缓冲的精度都是24位float的,而现在大部分的显卡都支持32位float的深度缓冲,所以,牺牲掉一些性能,就能获得更高精度的深度测试,减少深度冲突。

 

Early-Z技术

深度测试这一阶段位于片元着色器之后的逐片元操作阶段,没有通过深度测试的片元会被直接舍弃,意味着会有很多无用计算。

因此,为提高性能,在片元着色器之前先进行一次深度测试,如果深度测试失败,就直接舍弃不必进行片元着色器计算了,从而提升渲染性能,这种提前进行深度测试的技术被称为Early-Z技术。

在使用Early-Z时,虽然已经提前进行了深度测试,但在逐片元操作阶段仍然需要再进行一次深度测试,这样才能保证最终遮挡效果的正确。

此时前面的深度测试被称为Z-Cull深度裁剪,后面的深度测试被称为Z-Test深度检查。

需要注意的是,以下几种情况下可能导致Early-Z出现错误的效果。

  1. Alpha Test或者Clip/discard等丢弃片元的情况——例如最前面的片元通过了Early-Z测试,却被其他这些操作丢弃,导致前后所有的片元都被丢弃。
  2. 开启Alpha Blend透明度混合——例如最前面的物体为透明,需要渲染后面的片元,但后面的片元却被Early-Z丢弃,因此开启Alpha Blend的物体需要关闭深度写入(ZWrite Off)。

Early-Z技术听起来很高效,但其实优化效果并不稳定,最理想的情况下所有物体都是按由近及远顺序进行渲染,那么early-z可以完全避免过度绘制;但是相反的排序下,则会起不到任何效果。

所以有些时候为了完全发挥early-z的功效,会在每帧绘制时对场景的物体按照到摄像机的距离由近及远进行排序。但这样会导致无法搭配合批操作,并且排序会在cpu端进行,当场景复杂到一定程度,频繁的排序将会占用cpu的大量计算资源。

为了优化这个问题,就出现了Z-Prepass的技术。

 

Z-Prepass

Z-Prepass的做法是将场景中物体的shader做两个pass。

第一个pass仅写入深度,不做任何复杂的片元计算,不输出任何颜色。

第二个pass关闭深度写入,并将深度比较函数设为“相等”

场景中每个物体都会计算这两个pass,第一个pass由于只写入深度,不在片元做任何计算,所以即便之后会被丢弃,浪费的性能也很少。无论场景中的物体以怎样的顺序绘制,都可以以很小的代价提前绘制好当前场景的深度缓存,也就是让GPU的深度计算代替CPU的排序。

那么在第二个pass时,early-z就可以用这个深度缓存中的值和当前深度值进行比较,只绘制深度相等的片元,任何其他的片元都可以直接丢弃,因此第二个pass要把深度比较函数设为Equal。同时当前的深度缓存已经是完全正确的结果了,因此第二个pass也不需要对深度缓存做任何更新,便可以关闭深度写入。

z-perpass必须配合early-z才能发挥效果,如果没有early-z的话,第二个pass的深度测试依旧在片元后,因此所有片元都会在片元阶段进行大量计算。

但这样也会带来另一个问题,拥有多个pass的shader的的物体是无法动态批处理的,结果就是DrawCall翻倍,这种情况下可以采用将两个pass分离成两个shader单独计算的方式进行优化。

Z-PrePass也被用来解决一些透明渲染模型穿插的问题。

当然,想要得到双面的效果的话,需要再单独渲染一遍物体的背面。

即使进行了这样的优化,Early-Z也不是一定能带来性能提升的,甚至很多情况下反而会造成性能降低,在使用时要根据场景的实际情况考虑如何使用。

 

本文参考自闫令琪老师的《GAMES101-现代计算机图形学入门》、霜狼_may的技术美术百人计划】图形 3.1 深度与模板测试图形 3.5 Early-z和Z-prepass以及LearnOpenGL-CN等,感谢。


说是人生无常,
却也是人生之常。

《记忆像铁轨一样长》
——余光中