0x00 前言
说起深度,朋友们一定都不陌生。为了解决渲染场景时哪部分可见,哪部分不可见的问题(即可见性问题,也被称为隐藏面移除问题,hidden surface removal problem,从术语这个角度看,技术的发展有时也会带动心态向积极的方向的变化),计算机图形学中常使用画家算法或深度缓冲的方式。
这也是在处理可见性问题时的两个大方向上的思路:Object space方式和Image space方式。在后文的描述中,各位应该能够体验到这两种方式的异同。
下图就是在Unity引擎中将深度缓冲的数据保存成的图片。
而利用深度图我们又可以实现很多有趣的视觉效果,例如一些很有科幻感的效果等等。
不过在说到这些有趣的效果之前,我们先来看看所谓的可见性问题和深度图的由来吧。
0x01 人类的本能和画家算法
在计算机图形学中,有一个很重要的问题需要解决,即可见性问题。因为我们要将一个3D模型投影到2D的平面上,这个过程中哪些多边形是可见的,哪些是不可见的必须要正确的处理。
按照人类的天性,一个最简单的解决方案就是先绘制最远的场景,之后从远及近,依次用近处的场景覆盖远处的场景。这就好比是一个画家画画一样。
(图片来自维基百科)
而计算机图形学中的画家算法的思想便是如此:
首先将待渲染的场景中的多边形根据深度进行排序。
之后按照顺序进行绘制。
这种方法通常会将不可见的部分覆盖,这样就可以解决可见性问题。
但是,世界上就怕但是二字,使用画家算法这种比较朴素的算法的确能解决简单的可见性问题,不过遇到一些特殊的情况就无能为力。例如下面这个小例子:
在这个例子中,三个多边形A、B、C互相重叠,那么到底如何对它们进行排序呢?此时我们无法确定哪个多边形在上,哪个多边形在下。在这种情况下,多边形作为一个整体进行深度排序已经不靠谱了,因此必须用一些方法对这些多边形进行切分、排序。
我们可以看到,这种方式是以场景中的对象或者说多边形为单位进行操作的。因而常常被称为Object space 方法或者称为Object precision 方法,我个人更喜欢后者这个称呼,因为这是一个关于操作精度的区别。这种方式主要是在对象或多边形这个级别的,即对比多边形的前后关系。除了画家算法之外,背面剔除也是Object Space的方法。它通过判断面的法线和观察者的角度来确定哪些面需要被剔除。
0x02 切分多边形的Newell算法
既然作为整体互相重叠导致难以排序,那么是否可以对多边形进行切分呢?Newell算法早在1972年就已经被提出了,所以算不得是什么新东西。但是它的一些思路还是很有趣的,倒也值得我们学习。
和画家算法一样,Newell算法同样会按照深度对场景内的对象进行排序并对排序后的多边形从远及近的依次绘制,不过有时会将场景内的多边形进行切割成多个多边形,之后再重新排序。
简单来说,首先我们可以将参与排序的结构定义为各个多边形上顶点的最大Z值和最小Z值[Zmax,Zmin]。
我们会以多边形上距离观察者最远的顶点的Z值对场景内的多边形进行一个粗略的排序(因为此时只是依据每个多边形距离观察者最远的那一个顶点的Z值进行排序),这样我们就获得了一个多边形列表。
之后,取列表中的最后一个多边形P(它的某个顶点是距离观察者最远的顶点)和P之前的一个多边形Q,之后通过对比来确定P是否可以被写入帧缓冲区。
这个对比简单的说就是是否符合下面这个条件:
多边形P的Zmin > 多边形Q的Zmax
如果符合该条件,则P不会遮盖Q的任何部分,此时可以将P写入帧缓冲区。
即便答案是否,P和Q也有可能不发生遮盖。例如它们在x、y上并无重叠。但是,Q还是有可能会被分割成若干个多边形{Q1,Q2...}。此时有可能会针对下面的几条测试结果,对最初的多边形列表进行重新排序(也有可能生成新的多边形,将新的多边形也纳入最初的列表中)并决定渲染的顺序。
多边形P和多边形Q在X轴上是否可区分?
多边形P和多边形Q在Y轴上是否可区分?
多边形P是否完全在多边形Q的后方?
多边形Q是否完全在多边形P的前方?
判断两个多边形的投影是否重叠?
如果这几条测试全部都没有通过,则需要对Q或P进行切割,例如将Q切割成Q1、Q2,则Q1和Q2将被插入多边形列表代替Q。
但是,我们可以发现,这种对深度进行排序后再依次渲染的方式会使得列表中多边形的每个点都被渲染,即便是不可见的点也会被渲染一遍。因此当场景内的多边形过多时,画家算法或Newell算法会过度的消耗计算机的资源。
0x03 有趣的Depth Buffer
正是由于画家算法存在的这些缺点,一些新的技术开始得到发展。而深度缓冲(depth buffer或z-buffer)就是这样的一种技术。Depth Buffer技术可以看作是画家算法的一个发展,不过它并非对多边形进行深度排序,而是根据逐个像素的信息解决深度冲突的问题,并且抛弃了对于深度渲染顺序的依赖。
因而,Depth Buffer这种方式是一种典型的Image space 方法,或者被称为Image precision方法,因为这种方式的精度是像素级的,它对比的是像素/片元级别的深度信息。
这样,除了用来保存每个像素的颜色信息的颜色缓冲区之外,我们还需要一个缓冲区用来保存每个像素的深度信息,并且两个缓冲区的大小显然要一致。
该算法的过程并不复杂:
首先,需要初始化缓冲区,颜色缓冲区往往被设置为背景色。而深度缓冲区则被设为最大深度值,例如经过投影之后,深度值往往在[0,1]之间,因此可以设置为1。
经过光栅化之后,计算每个多边形上每个片元的Z值,并和对应位置上的深度缓冲区中的值作比较。
如果z <= Zbufferx(即距离观察者更近),则需要同时修改两个缓冲区:将对应位置的颜色缓冲区的值修改为该片元的颜色,将对应位置的深度缓冲区的值修改为该片元的深度。即:Colorx = color; Zbufferx = z;
下面是一个小例子的图示,当然由于没有经过标准化,因此它的各个坐标和深度值没有在[0-1]的范围内,不过这不影响:
第一个多边形,深度都为5。
第二个多边形,它的三个顶点的深度分别为2、7、7,因此经过插值,各顶点之间的片元的深度在[2-7]之间,具体如右上角。我们还可以看到右下角是最后结果,紫色的多边形和橘色的多边形正确的互相覆盖。
0x04 来算算顶点的深度值
众所周知,渲染最终会将一个三维的物体投射在一个二维的屏幕上。而在渲染流水线之中,也有一个阶段是顶点着色完成之后的投影阶段。无论是透视投影还是正交投影,最后都会借助一个标准立方体(CVV),来将3维的物体绘制在2维的屏幕上。
我们就先来以透视投影为例,来计算一下经过投影之后某个顶点在屏幕空间上的坐标吧。
由于我们使用左手坐标系,Z轴指向屏幕内,因此从N到F的过程中Z值逐渐增大。依据相似三角形的知识,我们可以求出投影之后顶点V在屏幕上的坐标。
我们可以通过一个实际的例子来计算一下投影后点的坐标,例如在一个N = 1,v的坐标为(1,0.5,1.5),则v在近裁剪面上的投影点v'的坐标为(0.666,0.333)。
但是,投影之后顶点的Z值在哪呢?而在投影时如果没有顶点的深度信息,则两个不同的顶点投影到同一个二维坐标上该如何判定使用哪个顶点呢?
(v1,v2投影之后都会到同一个点v')
为了解决保存Z值的信息这个问题,透视变换借助CVV引入了伪深度(pseudodepth)的概念。
即将透视视锥体内顶点的真实的Z值映射到CVV的范围内,即[0,1]这个区间内。需要注意的是,CVV是左手坐标系的,因此Z值在指向屏幕内的方向上是增大的。
为了使投影后的z'的表达式和x’、y‘的表达式类似,这样做更易于用矩阵以及齐次坐标理论来表达投影变换,我们都使用z来做为分母,同时为了计算方便,我们使用一个z的线性表达式来作为分子。
之后,我们要做的就是计算出a和b的表达式。
在CVV中处于0时,对应的是透视视锥体的近裁剪面(Near),z值为N;
0 = (N * a + b) / N
而CVV中1的位置,对应的是视锥体的远裁剪面(Far),z值为F;
1 = (F * a + b) / F
因此,我们可以求解出a和b的值:
a = F / (F - N)
b = -FN / (F - N)
有了a和b的值,我们也就求出来视锥体中的Z值映射到CVV后的对应值。
0x05 Unity中的深度
最后来说说Unity中的Depth,它的值在[0,1]之间,并且不是线性变化的。
因此有时我们需要在Shader中使用深度信息时,往往需要先将深度信息转化成线性的:
float linearEyeDepth = LinearEyeDepth(depth);
或
float linear01Depth = Linear01Depth(depth);
我们根据Unity场景中的深度信息渲染成一张灰度图,就得到了本文一开头的深度图。
-分割线-
最后打个广告,欢迎支持我的书《Unity 3D脚本编程》