几乎所有目前的 3D 显示晶片都有 Z buffer 或 W buffer。不过,还是常常可以看到有人对 Z buffer 和 W buffer 有一些基本的问题,像是 Z buffer 的用途、Z buffer 和 W buffer 的差别、或是一些精确度上的问题等等。这篇文章的目的就是要简单介绍一下 Z buffer 和 W buffer。
Z buffer 和 W buffer 是做什么用的呢?它们的主要目的,就是去除隐藏面,也就是 Hidden surface elimination(或是找出可见面,Visible surface detemination,这是同样意思)。在 3D 绘图中,只要有两个以上的三角面,就可能会出现某个三角面会遮住另一个三角面的情形。这是很明显的现象,因为近的东西总是会遮住远的(假设这些三角面都是不透明的)。所以,在绘制 3D 场景时,要画出正确的结果,就一定要处理这个问题。
不过,这个问题是相当困难的,因为它牵扯到三角面之间的关系,而不只是某个三角面本身而已。所以,在做去除隐藏面的动作时,是需要考虑场景中所有的三 角面的。这让问题变得相当的複杂。而且,三角面往往并不是整个被遮住,而常常是只有一部分被遮住。所以,这让问题变得更複杂。
要做到去除隐藏面的最简单方法,就是「画家演算法」(Painter's algorithm)。这个方法的原理非常简单,也就是先画远的东西,再画近的东西。这样一来,近的东西自然就会盖住远的东西了。因为油画的画家通常会用这样的方法,所以这个方法被称为「画家演算法」。下图是一个例子:
上图中,红色的圆形最远,所以最先画。然后是黄色的三角形,最后是灰色的方形。照远近的顺序来画,就可以达到去除隐藏面的效果。所以,只要把 3D 场景中的三角面,以对观察者的距离远近排序,再从远的三角面开始画,应该就可以画出正确的结果了。
不过,实际上并没有这么理想。在 3D 场景中,一个三角面可能有些地方远,有些地方近,因为三角面有三个顶点,而这三个顶点和观察者的距离,通常都是不同的。所以,要以哪个顶点来排序呢?或是以三角面的中心来排序?事实上,不管以什么为依据来排序,都可能会有问题。下图是一个「画家演算法」无法解决的情形:
上图中,三个三角面互相遮住对方,所以不管用什么顺序去画,都无法得到正确的结果。另外,这个方法也无法处理三角面有交叉的情形。
当然,如果相当确定场景中不会出现这么奇怪的情形,那「画家演算法」一般还是可以用的。不过,它还有一个很大的问题,就是效率不佳。首先,画家演算法需要对场景中,在视角范围内所有的三角面做一个排序的动作。最好的排序演算法也需要 O(n log n) 的时间。也就是说,(大致上来说)如果三角面的数目从一千个变一万个,排序需要的时间会变成约 13.3 倍。而且,因为这需要对场景中所有的三角面来做,因此也不适合用特别的硬体来做加速。另外,这个方法还有一个很大的问题,就是它会花很多时间去画一些根本就会被遮住的部分,因为每个三角面的每个 pixel 都需要画出来。这也会让效率变差。
如果场景是静态(不动)的,只有观察者会变动的话,那是有方法可以加快排序的速度。一个很常用的方法是 binary space paritioning(BSP)。这个方法需要事先对场景建立一个树状结构。建立这个结构后,不管观察者的位置、角度是如何,都可以很快找出正确的绘制顺序。而且,BSP 会视需要切开三角面,以处理像上图那样,三个三角面互相遮住对方的情形。
不过,BSP 结构在建立时,要花很多时间,所以不太可能即时运算。因此,通常只能用在场景中的静态部分,而会动的部分还是需要另外排序。而且,BSP 常会需要切开三角面,也会让三角面的数目增加。另外,BSP 仍然无法解决需要画出那些被遮住的 pixel 的问题。
另一种去除隐藏面的方法,是直接以 pixel 为单位,而不是以三角面为单位,来考虑这个问题。其中最简单的方法是由 Catmull 在 1974 年时提出来的,也就是 Z buffer(或称 depth buffer)。这个方法非常简单,又容易由特别设计的硬体来执行,所以在记忆体容量不再是问题后,就变得非常受欢迎。
Z buffer 的原理非常简单。在绘制 3D 场景时,除了存放绘制结果的 frame buffer 外,另外再使用一个额外的空间,也就是 Z buffer。Z buffer 记录 frame buffer 上,每个 pixel 和观察者的距离,也就是 Z 值。在开始绘制场景前,先把 Z buffer 中所有的值先设定成无限远。然后,在绘制三角面时,对三角面的每个 pixel 计算该 pixel 的 Z 值,并和 Z buffer 中存放的 Z 值相比较。如果 Z buffer 中的 Z 值较大,就表示目前要画的 pixel 是比较近的,所以应该要画上去,并同时更新 Z buffer 中的 Z 值。如果 Z buffer 中的 Z 值较小,那就表示目前要画的 pixel 是比较远的,会被目前 frame buffer 中的 pixel 遮住,所以就不需要画,也不用更新 Z 值。这样一来,就可以用任意的顺序去画这些三角面,即可得到正确的绘制结果。下图是一个例子:
上图中,红色的三角面虽然先画出来,但是因为使用了 Z buffer,所以后画的黄色方块还是只会遮住适当的部分,而不会连较近的部分都遮住。这就显示出 Z buffer 的效果。
实际上 Z buffer 中能存放的数字当然会有一定的限度,所以通常会把 Z 值缩小到 0 ~ 1 的范围。因此,在绘制 3D 场景时,就会需要把可能出现的 Z 值限制在某个范围内。通常是用两个和投影平面平行的平面,把所有超出这两个平面范围的三角面都切掉。这两个平面通常分别称为 Z near 和 Z far,分别表示较近的平面和较远的平面。而在 Z near 平面的 Z 值为 0,在 Z far 的 Z 值为 1。
在效率上 Z buffer 并不一定会比「画家演算法」要快。但是,它比较简单。而且,它的效率和三角面的数目并没有太大的关系,而是和绘制的 pixel 数目有关。所以,而且可以很容易设计出特定的 3D 硬体来做这个动作,而不需要由 CPU 来做。而 Z buffer 所需要的额外记忆体,在今天已经显得不是很重要。所以现在几乎所有的 3D 显示晶片都是使用 Z buffer。
不过,Z buffer 并非全无问题。一个很大的问题是在於精确度上。如果有两个三角面很靠近,而其中一个完全在另一个之前,那应该只能看到一个三角面才对。但是,如果 Z buffer 的精确度不够,那这两个三角面每个 pixel 的 Z 值可能会很接近。再加上计算出来的 Z 值一定会有误差,所以,很可能会造成应该被遮住的三角面,却有一些 pixel 没有被遮住。这种情形称为 Z fighting。下图中,球在地面上的影子就是一个例子:
要避免这类问题,就要避免在场景中出现太过靠近,且接近平行的三角面。一般的场景不太会出现这个情形。不过,Z buffer 的精确度问题并不只是这样而已。在下一部分会对这个问题有更详细的说明。
前面把 Z buffer 的原理做了一个大概的说明,听起来 Z buffer 似乎是个很理想的技术。但是,实际上 Z buffer 有一个很大的问题,就是精确度的问题。
在前一页后面所提到的,两个非常接近的平面所出现的 Z fighting 情形,其实是相当少见,而且很容易避免的。当然,遇而还是会看到有一些游戏会出现这种情形。不过,Z buffer 最严重的问题是在离观察者较远的部分。如果 Z buffer 的精确度不够,而场景又很远的的话,那远处的东西就会出现一些非常奇怪的现象。下图是一个例子:
Z aliasing
无 Z aliasing
当然,上面的例子是比较极端的情形。实际上一般情形下并不会有这么夸张的 Z aliasing 现象。不过,我相信大家多少都在一些场景较大的游戏中,看过类似的情形。
为什么会有这样的现象呢?这就要从 Z buffer 的结构谈起了。如果前一页所说的,一般的显示晶片,是把 Z 值限制在 0 ~ 1 的范围,再用一个定点数去表示它。例如,一个 16 位元的 Z buffer,可能会用 0 ~ 65535(一个 16 位元数字可表示的范围)来表示这个 0 ~ 1 之间的 Z 值。
如果 Z buffer 的分布在 eye space 中是线性的,也就是它的每个数字之间的间隔都相等的话,那这样的精确度应该是蛮高的才对。因为,假设观察者可以看到一公里远的东西,那每个间隔就是约 1.5 公分。如果用更高精确度的数字来表示的话(像是 24 位元数字),那精确度还会更高。然而,Z buffer 在 eye space 中并不是线性的。它是在 projection space 中为线性。
如果你觉得这些听起来像是外星话的话,现在就要来「翻译」这些外星话。首先,先来看一张示意图:
上图是一个眼睛在透视投影的情形下,观看场景中的一个红色平面的情形。靠近眼睛的平面(上面有黄色点的)是代表投影平面,也就是 3D 绘图中的屏幕。黄色的点红色平面投影到屏幕上的 pixel,他们当然是等间距的。但是,注意看这些「等间距」的 pixel,他们所对映的 Z 值(也就是 Z 轴上的那些灰色的点),并不是等间距的。实际上,离眼睛愈远的 pixel,其 Z 轴上的间距就愈大。
这其实透视投影的一个明显的性质。因为在透视投影的情形下,愈远的东西看起来愈小,所以,在屏幕上同样的间距,在比较远的地方,就会变得比较大。因此,虽然三角面是平面,但是它在每个 pixel 上的 Z 值却不是线性的变化。因此,就无法用线性内插来计算三角形内部的 pixel 的 Z 值。但是,要正确计算出每个 pixel 上的 Z 值,会需要一个除法的动作,而除法是很讨厌、很花时间的动作。
早期的显示芯片无法花费一个除法器在 Z buffer 上面。所以,一个方法是在 Z buffer 中,不要存放 eye space 的 Z 值,而改成存放 projection space 的 Z 值。这样一来,Z 值在 projection space 就会变成是线性的,就可以简单地用线性内插来计算三角形内部的 pixel 的 Z 值了。这也是目前几乎所有显示芯片的 Z buffer 的设计。
不过,在 projection space 中的 Z 值,就像上面的图所显示的一样,有一个很重要的特性:它所对映的 eye space 的 Z 值间隔,在愈远的地方就愈大。所以,Z buffer 的精确度,如果以 eye space 来看的话(这样看才有意义),就会变成不平均的分布。离观察者愈近的地方,其精确度会比远的地方更高。而这个精确度的变化,会取决于 Z near 平面和 Z far 平面的位置。Z near 平面离观察者愈近,且 Z far 平面离观察者愈远,则精确度的变化就会愈大,也就是远的地方的精确度会愈差。
在这一页最前面的两张图中,其 Z far 平面的位置是一样的,但是左图的 Z near 平面的位置,比右图的 Z near 平面的位置近了一千倍。所以,在左图中就出现了严重的 Z aliasing 现象,但是右图就没有出现这种现象。
所以,要尽可能避免 Z aliasing 现象,就要尽可能把 Z near 平面拉远,而把 Z far 平面拉近。但是,实际上很多情形下,是无法允许这样的设计的。比如说,在一个场景中,玩家可能会看到 50 公分远的桌子上的东西,而同时看到窗外在一公里外的一座大基地。因此,Z near 平面不能设得比 50 公分要远,但是 Z far 平面又得到一公里远。以 16 位 Z buffer 来看,那最远处的间隔(也就是一公里远的地方)会达到 30 公尺,也就是如果两个 pixel 的间距小于 30 公尺的话,Z buffer 将无法分辨出正确的顺序!而它在 Z near 处(也就是 50 公分的地方)的精确度则高达 0.0000076 公尺。这显示出精确度分布是如此的不平均和不适当。如果改用 24 位 Z buffer 的话,那情形会有相当程度的改善,在一公里远处的精确度会提高到约 12 公分。这也是为什么 24 位 Z buffer 很少会显示出 Z aliasing 的情形。
不过,即使是 24 位 Z buffer 也不见得是完全理想。以上面的例子来说,如果 Z near 平面移到 10 公分处,在远处的精确度就会从 12 公分降低为 60 公分。有些人可能会觉得,在一公里远的地方,又有谁能分辨 60 公分,或是 12 公分呢?但是,问题在于,当两个大的平面的距离小于 60 公分时,因为 Z buffer 无法分辨出正确的顺序,就可能会在这一框是某个平面被画出来,而在下一框却变成是另一个平面被画出来。如果这两个平面的颜色差别很大,就会产生闪烁的现象,任何人都会很容易就注意到的。
有些显示芯片采取一些方法来解决这个问题。一个简单的想法是在 Z buffer 中使用浮点数,而不使用定点数。经过适当的设计,浮点数可以在某个特定的数字附近,提供更大的精确度范围(一般情形是在 0 附近)。而一般的 Z buffer 在 Z far 附近会需要更高的精确度,所以可以把 Z buffer 在 Z far 平面以 0 表示,而 Z near 平面以 1 表示。这样就可以得到更高的精确度。不过,浮点数在计算上比较麻烦。特别是 Z buffer 的运算中,常需要做加法和比较的运算,这都会比定点数的运算要麻烦很多。
另外一个方法是用非线性的 Z buffer。例如,可以把 Z buffer 切成很多个小区间,而每一个小区间中都是一般的线性 Z buffer。但是,可以在远方分配更多的小区间,让它的精确度可以提高。这也是一种解决精确度问题的方式。
其实,要解决 Z buffer 精确度问题,最简单的方法就是在 eye space 中做线性内插。但是,前面已经说过,在 eye space 中的线性,在 projection space 并不一定是线性,所以它会需要额外的除法器。不过,有一个方法可以避免使用除法器,而只需要「倒数器」,「倒数器」比完整的除法器要简单一些。这个方法就是先以较高的精确度,在 projection space 中,对 Z 做线性内插。对每个内插得到的结果,再用倒数器算出其倒数,也就是所谓的 W 值。这个 W 值的精确度可以较低,因为它在 eye space 中的分布是平均的。最后,再把这个 W 值和 "W buffer" 里面的值做比较,就可以得到正确的顺序。这个方法,相信有些人已经猜到了,就是W buffering。
当然,另外还有一些别的方法可以实作出 W buffer。不过,不管是用什么方法实作 W buffer,其最重要的性质就是在 eye space 中为线性分布。因此,16 位的 W buffer 在远处的精确度是非常理想的。以前面的例子来说,即使是 24 位 Z buffer,在一公里远处的精确度也只能到 12 公分。但是 16 位 W buffer 则是每个地方的精确度都是 1.5 公分。因此,在这个例子中,16 位 W buffer 在远处的表现,甚至比 24 位 Z buffer 更好。
而且,W buffer 还有一个好处,就是其 W near 平面(相对于 Z buffer 中的 Z near 平面)的位置是不重要的。也就是说,W buffer 可以同时兼顾眼前的桌子,和数公里外的巨大基地。而用 Z buffer 的话,如果想要能正确显示出数公里外的巨大基地,那可能就得牺牲眼前的桌子了。
不过,因为 W buffer 的精确度是平均分布,它在 Z near 处的精确度就明显不如 Z buffer 了。虽然说 Z buffer 在 Z near 处的精确度是过于高了(像是 0.0000076 公尺),但是,W buffer 却可能会过于低。比如说,1.5 公分的精确度对于远处的物体是绝对足够的,但是对于靠近观察者的物体,则是明显的不足。比如说,桌上可能有一本厚度小于 1.5 公分的书。这时,1.5 公分的精确度是完全不够的。
这样听起来,好像 W buffer 也无法解决问题嘛!其实并不是这样的。如果有 24 位的 W buffer,同时可以看到十公里外的东西(这应该算是非常的远了),那它的精确度还是有约 0.6 公厘左右。这样的精确度一般来说是相当足够的了。而且 W buffer 也很容易使用,不需要对程序有什么重大的修改。
目前 W buffer 最大的问题就是支持度不够。有些显示芯片根本就不支持 W buffer,而有些则只支持 16 位的 W buffer。不过,目前很多显示芯片都已经开始支持 W buffer,所以将来应该会有更多游戏使用吧!
Depth-Buffer(深度缓存)有两种:Z-Buffer 和 W-Buffer,这里讨论这两种深度缓存的区别,以及如何在两者之间转换。
w 的含义
3D空间点的坐标是(x,y,z),为了使矩阵乘法具有平移变换的功效,我们用4D空间中的点(x,y,z,w)来表示3D空间中的点(x',y',z'),这两个不同空间点之间的关系是:
x' = x / w
y' = y / w
z' = z / w
像这样用四维空间点表示三维空间点,或者说用 n + 1 维空间点表示 n 维空间点的方法叫做 “齐次坐标表示法”。
实际使用中,在模型->世界转换、世界->视图转换过程中,w 通常保持不变,总是等于一,这样,齐次坐标的前三个分量就是对应3D空间点的三个坐标分量。但是,经过投影变换后,w 将得到一个比例值,比如,一般的透视投影变换矩阵是:
| W 0 0 0 |
| 0 H 0 0 |
| 0 0 Q 1 |
| 0 0 -QZn 0 |
其中 Zn = 近裁剪面 z 坐标
Zf = 远裁剪面 z 坐标
W = 2 * Zn / 视口宽度
H = 2 * Zn / 视口高度
Q = Zf / (Zf - Zn)
将点(x,y,z,1)乘以此矩阵,w 便不再是一,而对应的3D空间点坐标(x / w,y / w,z / w)
将出现一个缩放效果。同时,因为 w 的值通常与 z 坐标成正比(比如经过上面这个矩阵的变换,w 的值其实就是 z 坐标的值),
所以经过投影变换,物体会产生近大远小的效果。
Z-Buffer 与 W-Buffer 的区别
简单的说,z-buffer 与 w-buffer 的区别就是前者保存的是点的 z 坐标,而后者保存的是点的 w 坐标。
具体的说,两者因为保存的值有不同的含义,所以表现出来的实际效果也会有差别。
z-buffer 保存的是经过投影变换后的 z 坐标,前面说过,投影后物体会产生近大远小的效果,所以距离眼睛比较近的地方,z 坐标的分辨率比较大,而远处的分辨率则比较小,换句话说,投影后的 z 坐标在其值域上,对于离开眼睛的物理距离变化来说,不是线性变化的(即非均匀分布),这样的一个好处是近处的物体得到了较高的深度分辨率,但是远处物体的 深度判断可能会出错。
w-buffer 保存的是经过投影变换后的 w 坐标,而 w 坐标通常跟世界坐标系中的 z 坐标成正比,所以变换到投影空间中之后,其值依然是线性分布的,这样无论远处还是近处的物体,都有相同的深度分辨率,这是它的优点,当然,缺点就是不能用 较高的深度分辨率来表现近处的物体。
从硬件实现角度来说,几乎所有的硬件3D加速卡都支持 z-buffer,而 w-buffer 的支持没有 z-buffer 那么广泛。另外,早期的 Direct3D 版本看起来也不支持 w-buffer。
Z-Buffer 与 W-Buffer 之间的转换
根据上面的矩阵变换,可以很容易的导出将 w-buffer 转换成 z-buffer 的公式:
zDepth = Q * ( wDepth - Zn ) / wDepth
= Zf / ( Zf - Zn ) * ( wDepth - Zn ) / wDepth
这个转换公式有什么用处?举个例子:3DS MAX 使用的是 w-buffer,如果从 3DS MAX 中导出深度信息到 Direct3D 中,作为预渲染的背景使用,就有可能用到上面这个转换。当然,如果在 D3D 中使用 w-buffer,问题就不大了,但是如果使用 z-buffer,不经过这样的转换,渲染结果就会出错。