http://www.opengpu.org/forum.php?mod=viewthread&tid=418&highlight=shadow%2Bvolume
即時 3D 繪圖的陰影效果
Chen Ping-Che
中華民國國立臺灣大學資訊工程學系
著於民國捌拾玖年陸月捌日至拾壹月貳拾捌日
planar shadow
在目前的即時 3D 繪圖中,要做出真實的陰影效果,是很不容易的。因為陰影是因物體遮住光源所產生的,因此,要做出正確的陰影效果,就需要對整個場景做處理,這樣才能判斷出哪些物體被哪些物體遮住了。
不過,目前的 3D 硬體,並不容易進行這類的測試,因為資料量和工作量都太大了。不過,這並不表示使用現在的 3D 硬體就無法做出陰影。現在已經有很多方法是適合用在目前的 3D 硬體上面,可以產生效果不錯的陰影。本文會就一些常用的方法,做簡單的介紹。
目前常用的方法,幾乎都是把陰影看成是「物體投射到其它表面」來處理。在光源是平行光的時候(例如,太陽光),可以看成是物體把陰影「投射」到另一個表面上,如下圖所示:
如果場景中只有一個重要的光源(即最強的光源),那可以假設只有這個光源會產生明顯的陰影。以平行光源來說,要把陰影「投射」到一個平面上,就是一件相當容易的事。
設空間有一點 V,平行光源的方向是 L,要投射陰影的平面是 P,那麼,存在一常數 k 使
成立,如下圖(V、L、和 P 均為四維向量,表示一個三維的homogeneous coordinate):
解上式得
空間中的點 V 投影到平面 P 的位置是 V+kP。對一個 3D model 的每個頂點都代入這個式子,就可以得到投影的結果了。不過,因為目前的 3D 硬體都是以 4×4 的矩陣來做變換,所以如果能把投影的動作寫成一個矩陣,就會方便很多。
設 V = <Vx,Vy, Vz, 1>,L = <Lx, Ly, Lz, 0>,P = <a, b, c, d>;展開前面的式子,會發現 k 有一個分母:
因此這個式子會有點複雜。不過,因為在 OpenGL 中的向量都是 homogeneous coordinate,所以可以先把分母提出來,放到 w 中。對 Vx 展開得到:
整理一下得到
這樣就變成向量內積的形式,可以放到矩陣中。對 Vy 和 Vz 做同樣的動作,再加上放到 w 的分母部分,就可以得到下面的矩陣:
因此,理論上,要畫陰影時,把 MODELVIEW 矩陣設成上面的矩陣,畫出物體,就會是陰影的樣子。
上面討論的是以平行光源為主。如果光源是點光源,也可以用類似的方法來做。
這個方法可以對任何平面做出陰影的效果。如果有多個平面,可以分別對每個平面都做一次。不過,這個方法顯然是沒辦法把陰影投影到曲面上。所以,這個方法通常稱為 planarshadow。
理論的部分已經討論完了。不過,實作的時候,還有一些細節部分是需要特別注意的。
首先,如果真的直接用上面的方法來做,那做出來的結果可能會像這樣:
這是因為畫了圖中的地板之後,再畫黑色的陰影時,會和地板產生所謂的 Zfighting 現象,也就是陰影的一部分的 Z 值較地板的Z 值小,所以會畫出來,但是有些部分的 Z 值則可能比地板的Z 值大,所以就沒畫出來了。這種現象會使得陰影變得破碎不完整。
現在的 3D 硬體通常提供一個叫 polygon offset的功能,用來解決 Z fighting 的問題。Polygonoffset 的原理,是在畫一個物體時,要求把它的 Z 值進行一個小的調整。例如,在上面的例子中,我們可以把陰影的 Z 值減一個小的數字,這樣就可以避免 Z fighting 的現象。
處理掉 Z fighting 後,再來是第二個問題:
上圖中,陰影跑到地板的外面去了。這裡需要一個方法,把陰影切到地板的範圍內。這個例子中,地板的邊界是直線,所以可以用 user defined clip planes 來做。不過,如果地板是奇怪的形狀,就需要別的方法。
而且,光是把陰影切到地板的範圍內是不夠的。通常陰影是用 blending 的方式畫上去的。如果一個物體的形狀比較複雜,那有些地方可能會 blend 兩次或更多次。這樣會讓陰影看起來不是同一個顏色,即有些地方顏色較深而有些地方較淺。
現在的 3D 硬體多半支援 stencil buffer。Stencil buffer 是一個用來「做記號」的 buffer。通常 stencil buffer 可以存放 1 bits 到 8 bits 不等的數字,不同的硬體支援的大小會不一樣。目前的 3D 硬體通常把 stencil buffer 和 Z buffer 放在一起。例如,它可能支援 15 bits 的 Z buffer 加上 1 bit 的 stencil buffer;或是支援 24 bits 的 Z buffer 加上 8 bit 的 stencil buffer。因此,通常 stencil buffer 的測試是和 Z test 一起做的,也就是在使用 Z buffer 時,stencil buffer 可說是「免費」的。
在我們的例子中,可以先把 stencil buffer 全清為0。在畫地板之前,把 stencil buffer 設定為「當Z test 通過時,把 stencil 的值設為 1」。這樣一來,畫完地板之後,地板所佔有的那些 pixels 的 stencil 值就都會是 1,其它的地方還是 0。現在,把stencil buffer 設定成「當 Z test 通過時,若 stencil 的值為 1 才畫,且將stencil 的值設為 0」。然後開始畫陰影。這樣畫出來的陰影,一定會在地板的範圍內,而且每個 pixel 只會被畫一次,不會出現 blend 兩次的情形。
如果有多個平面要投影,可以為每個平面指定不同的 stencil 值。不過,如果 stencil buffer 只有 1 bit,那就沒辦法了。這時,可能就需要在畫下一個平面之前,先把 stencil buffer 清掉,這樣會花很多時間。
下圖是一個完整的例子:
參考程式可以在這裡下載。這個程式需要至少 2 bits 的 stencil buffer 支援。
Planar shadow 就差不多是這個樣子了。Planar shadow 的好處是簡單、容易做,而且在投影面不多的時候,速度很快。但是,當投影面變多時,或是物體很複雜時,速度很快就會變得很慢,因為對每個平面都需要把投影的物體再畫一次。而且,它只能投影在平面上,對於不規則的表面則完全沒辦法。
還有一些別的方法可以產生陰影的效果。後面還會再討論一些產生 shadow 的方式,都可以投影在任何不規則的表面上。
volumetric shadow
前面所介紹的方法,即 planar shadow,只適用於平面上。但是,除了少數的情形之外,絕大多數的情形下,根本無法預測陰影會被投射在什麼樣的表面上。所以,我們需要自由度更高的方法。
在這裡介紹一個較為靈活的方法,它可以將陰影投射在不規則的表面上。這個方法稱為 volumetricshadow。這個方法的動點在於,它並不是利用「把物體投影到表面」的方式來產生陰影,而是去找出場景中,有哪些 pixel 是在陰影中。也就是說,想像一個物體擋住光時,在物體的後面會形成一個大的「陰影錐」。很明顯的,若一個 pixel 在「陰影錐」之中,那它就是在陰影之中。如下圖所示:
上圖中的紅色球體,在受光照後,在後方產生一個「陰影錐」,即 shadow volume,而這個「陰影錐」和灰色平面的交集,就是陰影會出現的地方。
所以,基本上 volumetric shadow 的原理是很簡單的。不過,要真正實作又是另一回事。為了簡單起見,這裡先以一個簡單的三角形開始。目前的 3D 繪圖幾乎都是以三角形為基礎,所以從三角形開始,應該是很適當的。
現在,假設有一個已經繪製完成的 3D 場景。因為使用 Zbuffer 的關係,對每一個 pixel 而言,都有一個相對的 Z 值,即表示該 pixel 和觀察者的距離的值。如果現在有一個三角面,把陰影投射到這個 3D 場景中,並畫出這個三角面的「陰影錐」。因為物體是一個三角形,所以它的「陰影錐」也是一個三角錐。這時,要如何知道 3D 場景中,有哪些 pixel 是和這個三角錐有交集?
其實方法很簡單。想像許多射線,由觀察者射向每個 pixel。如果射線和「陰影錐」完全沒有交集,它所對應的 pixel 當然就不會和「陰影錐」有交集。不過,即使是射線和「陰影錐」有交集,並不一定表示該 pixel 就一定和「陰影錐」有交集,因為射線可能會射入「陰影錐」後又射出。所以,只有在射線射入「陰影錐」之後,在離開「陰影錐」之前就遇到其對應的 pixel 時,才表示這個 pixel 和「陰影錐」有交集。下圖顯示出各種不同的情形:
上圖中的 (1) 和 (2) 都是面對觀察者的面,所以它們所涵蓋的pixel,就是「射線會射入陰影錐」的 pixel。而 (3) 則是背對觀察者的面,所以它所涵蓋的 pixel 是「射線會離開陰影錐」的 pixel。所以,會和陰影錐有交集的 pixel,就是 (1) + (2) - (3) 的那些 pixel,也就是陰影所在的位置。
不過,要怎麼在一般的 3D 繪圖硬體中,得到 (1) +(2) - (3) 的結果呢?和 planar shadow一樣,這需要 stencil buffer。在 OpenGL 和 Direct3D 中的 stencil buffer 都可以讓它進行「加一」和「減一」的動作。所以,只要把 stencil buffer 設定成:在繪製 (1) 和 (2) 的面時,讓 stencil buffer 加一;而在繪製 (3) 的面時,讓 stencil buffer 減一。這樣一來,在畫完 (1) ~ (3) 時,那些 stencil 值不為 0 的 pixel 就是陰影了。最後,把所有 stencil 不為 0 的pixel 利用 alpha blending 的方式,使其亮度降低,就可以達到繪製陰影的效果。
上面的例子是用一個三角面。對於比較複雜的物體,其原理還是一樣的。當物體是由許多三角面組成時,可以把所有面對光源的三角面都進行上面的動作,就可以產生陰影。不過,這樣有個缺點:因為很多三角面的邊是接在一起的,所以這樣做會十分浪費時間。要提高效率其實也很容易。在繪製「陰影錐」的時候,若有一個邊是被兩個三角面所共用,那就表示這是一個「內部」的邊,在繪製「陰影錐」的時候,就可以不用去畫這個邊。這樣就可以省下不少的時間。
這個方法適用於非常複雜的物體。不過,它還是可能會遇上一些問題。一個情形是,如果觀察者在「陰影錐」的內部,會發生一些麻煩的情形。不過,對大部分的情形來說,只要將 stencil buffer 設定成「減到 0 就停止」,即 0 - 1 = 0,就可以解決。當然這無法解決所有的問題,不過通常已經夠好了。另外,如果物體不是 convex(即「凸」的),那可能會出現「射線重覆進入陰影錐」的情形。這種情形並不會有問題,不過 stencil buffer 就需要比較多 bit 才不會出錯。一般來說,4 bits 就已經可以處理絕大多數的物體的。
下面的畫面是 volumetric shadow 的結果,是由 DirectX 8 SDK 中的一個示範程式所產生的。這個程式的結構並不複雜,所以有興趣的話,可以自行參考它的原始碼。
這個方法比 planar shadow更能適用於不同的場景。不過,它當然也有缺點。最主要的缺點是在於它的複雜度。要做出有效率的「陰影錐」,需要對物體做相當麻煩的處理,基本上就是要找出物體在某個方向的「外緣」(即 silhouette)。雖然這並不太難做,但是還是需要花費相當的 CPU 時間去處理。另外,為所有的物體繪製出其「陰影錐」,需要相當大量的 fill rate 和記憶體頻寬。若是 deferred renderer(例如 tile renderer)則影響不會這麼大,特別是 tile renderer 可以支援一些特別的功能,來加速volumetricshadow 的動作。
基本上,volumetric shadow 的效果,一般來說都不錯。最主要的缺點則是在效率方面,特別是當物體的複雜度和數量增加時,CPU 需要的工作量會大增,是較為不理想的。後面會再介紹一些速度更快的方法。