Signed Distance Field Shadow in Unity
來自專欄 Runtime
0x00 前言
最近讀到了一個今年GDC上很棒的分享,是Sebastian Aaltonen帶來的利用Ray-tracing實現一些有趣的效果的分享。
其中有一段他介紹到了對Signed Distance Field Shadow的改進,主要體現在消除SDF陰影的一些artifact上。相比較而言,Unity中的陰影實現效果就簡單並且死板了許多。
下面我們就在Unity中來實現RayMarching,並利用SDF繪製一些簡單的物體,最後實現一下陰影的效果。
0x01 在Unity中實現SDF
首先,RayMarching演算法處理的是屏幕上的每一個像素,因此在Unity中我們自然而然會想到利用屏幕後處理的方式來實現RayMarching。
所以,RayMarching的主要邏輯都在Fragment Shader內實現,而Vertex Shader則主要用來獲取頂點屬性中所保存的射線信息,之後經過插值傳入Fragment Shader中,供每一個Fragment來使用。此時整個屏幕是一個四邊形,一共有4個頂點,這4個頂點就可以用來記錄屏幕上的4根射線,而這4根射線的方向就可以直接取攝像機的平截頭體的4條邊的方向,之後再經過插值生成射向某個片元的射線。
這裡我們可以直接調用Unity提供的Camera.CalculateFrustumCorners方法。下面是這個方法的簽名:
這裡是相關文檔。
其中作為我們需要的4個outCorners也是作為參數傳入這個方法的。不過需要注意的是該方法獲取的平截頭體的4條邊是在local space的,所以我們需要將它們轉移到world space,以供Fragment Shader中使用。
這樣我們就得到了4個向量,但是這4個向量要怎麼向Shader中傳遞效率才高呢?如果每一個向量傳遞一次,則效率並不高。所以這裡我們使用一個矩陣來保存這4個向量,而向shader中傳送數據就只需要傳送一個矩陣。
射線的數據準備好了,向shader中傳送數據在Unity中也十分簡單,只需要調用SetMatrix就好。
但是這裡又出現了一個新的問題,那就是shader如何正確的確定它所處理的是哪根射線呢?如果不能確定頂點所對應的射線,那麼之後的插值結果就不會正確。所以在Vertex Shader中我們需要一個Index來從傳入的矩陣中正確的取出射線方向。
那麼Index要如何確定呢?
聰明的你一定想到了,對一個四邊形來說,它的UV數據是很有規律的。所以我們就可以在Vertex Shader中利用UV數據來確定正確的射線:
OK,之後只要在Fragment Shader中使用經過插值的ray數據,就能獲取當前Fragment所對應的射線方向了。到此,我們已經將射線引入了Shader中。
接下來我們來定義一個SDF,使用SDF來定義我們將要渲染的內容。我們可以在Inigo Quilez的博客上獲取很多常見物體的SDF定義。
下面我們就在Unity中利用SDF渲染一個六稜體:
針對不同的物體定義都需要一個SDF來描述該物體,但是如果在我們的RayMarching演算法中每次想要渲染不同的形狀時都要修改一下SDF的話似乎十分不方便,所以通常我們還會定義一個更高層的抽象——也可以叫做SDF函數——這個函數常常被稱作map,它的輸入是一個點坐標,輸出則是該點距離SDF所定義的物體表面的最近距離。
而有了map這個高層的抽象,我們可以很方便的在map的內部實現中按照自己的需求修改SDF,例如將一些基礎的物體進行合併、拆分等等。從這個角度講,map其實定義了我們要渲染的整改場景,因此整個場景的信息我們是已知的,這一點在之後渲染陰影的時候會用到。
不過,我們還是先來看一個簡單的例子,下面就是我們畫六稜體的例子中所使用的map的定義:
之後我們在Fragment Shader中實現該Fragment上的RayMarching邏輯,在引入SDF之後,RayMarching的每一次Marching的距離就可以根據SDF的結果來設定了,我想大家應該都見過類似這樣的圖解:
可以看到,每一次marching的距離就是當前採樣點到SDF定義的表面的最近距離,直到採樣點和表面重合,即光線和表面相交了。
所以我們只需要在Fragment Shader中跑一個for循環,每一次迭代都調用一次map來確認當前採樣點距離SDF的最近距離surfaceDistance,如果surfaceDistance不為0,則下一次marching的距離就是surfaceDistance;如果為0,則證明光線和表面相交,我們只需要確定這點的顏色就好了。
除此之外,我們需要相機的位置rayOrigin做為射線的起點,這個值我們可以通過在腳本中調用SetVector將相機的位置傳給GPU。此外我們還需要該Fragment上的射線方向rayDirection,我們可以直接獲取,因為它就是頂點屬性中的ray經過插值之後的結果。
所以這是一個很簡單的邏輯:
OK,光線和表面相交之後,輸出一個紅色。
我們來看一下實際的結果:
可以看到,場景的Hierachy中空空如也,但是屏幕上卻出現了一個純色的六稜體。0x02 梯度、法線和光照
當然,這個效果並不吸引人,因此我們顯然要加入一些光照效果來提升表現力。那麼求表面的法線就是必須要做的一件事情了。
milo的《用 C 語言畫光(四):反射 》這篇文章中也有相關的內容,即距離場變化最大的方向便是法線方向。根據矢量微積分(vector calculus),一個純量場(scalar field)的最大變化方向就是其梯度(gradient),所以這個問題就轉化為求形狀邊界位置的 SDF 梯度——即求各個方向的變化率,也就是要求導了。
不過我們顯然沒有必要真正的計算求導,只需要找一個能夠得到近似效果的方式就好了。我們常常使用這個下面這個算式來近似SDF梯度,即在這一點的表面法線:
代碼也就十分簡單了:
我們可以把法線信息輸出成顏色,就得到了下圖中的結果。
而實現一個簡單的漫反射也是一件十分簡單的事情:
這樣我們就獲得一個有簡單光照效果的六稜體了。
0x03 陰影
六稜體上有了簡單的漫反射效果,接下來就要在此基礎上實現基於SDF的陰影效果了。SDF的一個優勢就在於場景內的距離信息全都是可知的,因此可以很方便地用來實現類似陰影這樣的效果,並且可以根據距離來更自然地實現陰影的衰減,從而生成一個更加真實的陰影。
不過在此之前,我會將場景修改的稍微複雜一點,當然,這裡我只是增加了3個物體的SDF的定義——Sphere、Plane和Cube,並且簡單的修改下map函數,重新組織了一下整個場景。
這樣,整個場景就變成了這個樣子,由2個球體和1個正方體以及一個平面組成。
接下來我們來實現陰影,其實陰影的形成本身也很簡單。沿著光線的方向,如果光線被某個表面遮擋則會在後面的表面上生成陰影。
那麼在代碼中,一個簡單的基於SDF的陰影實現就很簡單了:針對到達物體表面的採樣點,以該點為起點,沿著光線來的方向,發射另一根射向光源的射線。如果這根射線也擊中了某個物體的表面,則證明該採樣點處於陰影之中——其實還是raymarching。
下面我們來完成一個最簡單的陰影實現,即陰影中是統一的黑色。
當然這裡需要注意的是,第一次迭代時不要直接把採樣點傳入到map中,否則的話會直接return。
ok,這樣一個很硬的陰影就創建好了,沒有多餘的pass,沒有多餘的貼圖,使用SDF創建陰影就是這麼簡單。
大家都知道,陰影通常是由所謂的本影和半影組成的,其中本影主要指的是物體表面上那些沒有被光源直接照射的區域,呈現全黑的狀態,而所謂的半影則是那些半明半暗的過渡部分。可以看到我們實現的這種陰影其實只包括本影,而沒有半影的效果。
所以在這個純黑的本影的基礎上,再增加一些不是純黑的半影效果,那麼最後的陰影會更加真實。所以接下來我們就要考慮,黑色本影之外的表面上的那些點的顏色了。
這時我們把距離的因素考慮進去:
可以看到,這樣一來在之前純黑的本影之外,不再是像最初的實現中將影子直接截斷,而是多了一圈模糊的半影來過渡。
不過,我相信眼尖的你一定發現了一些問題。那就是Cube的半影部分出現了條帶狀的artifact。
這主要是由於在計算陰影的RayMarching的過程中,採樣出現了問題。
在今年的GDC上,Sebastian Aaltonen分享了一個新的方案來解決這個問題:
即採樣兩次,根據上一次的採樣D-1和這一次的採樣D的數據,來計算或者是估算一個這條射線上距離SDF表面最近的點E,並用E來計算半影。
在分享中Sebastian也給出了他修改後的半影計算公式:
事實上Inigo也已經根據Sebastian的分享,改進了他的SDF陰影的效果。下面我們就根據Inigo和Sebastian的實現,在Unity中解決掉這個半影部分的條帶狀的artifact吧。
其中ph是上一次採樣時的圓形的半徑,h是當前這次的採樣的圓形半徑。
修改後的陰影效果:0x04 後記
這樣,我們就在Unity中實現了SDF渲染以及基於SDF的陰影渲染,並且解決了討厭的條帶狀的artifact。
本文的項目可以在這裡獲取:
chenjd/Unity-Signed-Distance-Field-ShadowRef:
- 《GPU-based clay simulation and ray-tracing tech in Claybook》
- 《raymarching distance fields》
- 《Raymarching Distance Fields: Concepts and Implementation in Unity》
-EOF-
最後打個廣告,歡迎支持我的書《Unity 3D腳本編程:使用C#語言開發跨平台遊戲》(陳嘉棟)【摘要 書評 試讀】- 京東圖書歡迎大家關注我的公眾號慕容的遊戲編程:chenjd01推薦閱讀: