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 <= Zbuffer[x][y](即距離觀察者更近),則需要同時修改兩個緩沖區:將對應位置的顏色緩沖區的值修改為該片元的顏色,將對應位置的深度緩沖區的值修改為該片元的深度。即:Color[x][y] = color; Zbuffer[x][y] = 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腳本編程》