Signed Distance Field Shadow in Unity

0x00 前言

最近讀到了一個今年GDC上很棒的分享,是Sebastian Aaltonen帶來的利用Ray-tracing實現一些有趣的效果的分享。
WechatIMG111.jpeg
其中有一段他介紹到了對Signed Distance Field Shadow的改進,主要體現在消除SDF陰影的一些artifact上。
image.png
第一次看到Signed Distance Field Shadow是在大神Inigo Quilez的博客上,較傳統的陰影實現方式,例如shadow map,視覺效果要好很多。可以看到下圖中物體的陰影隨着距離由近到遠也逐漸由清晰漸漸過渡到模糊的效果,表現更加自然而真實。
image.png
相比較而言,Unity中的陰影實現效果就簡單並且死板了許多。
屏幕快照 2018-06-10 下午6.32.40.png

下面我們就在Unity中來實現RayMarching,並利用SDF繪製一些簡單的物體,最後實現一下陰影的效果。

0x01 在Unity中實現SDF

首先,RayMarching算法處理的是屏幕上的每一個像素,因此在Unity中我們自然而然會想到利用屏幕後處理的方式來實現RayMarching。
所以,RayMarching的主要邏輯都在Fragment Shader內實現,而Vertex Shader則主要用來獲取頂點屬性中所保存的射線信息,之後經過插值傳入Fragment Shader中,供每一個Fragment來使用。此時整個屏幕是一個四邊形,一共有4個頂點,這4個頂點就可以用來記錄屏幕上的4根射線,而這4根射線的方向就可以直接取攝像機的平截頭體的4條邊的方向,之後再經過插值生成射向某個片元的射線。
1528627667019.jpg

這裏我們可以直接調用Unity提供的Camera.CalculateFrustumCorners方法,這裏是相關文檔(https://docs.unity3d.com/ScriptReference/Camera.CalculateFrustumCorners.html)。
下面是這個方法的簽名:

public void CalculateFrustumCorners(Rect viewport, float z, 
              Camera.MonoOrStereoscopicEye eye, Vector3[] outCorners);

其中作爲我們需要的4個outCorners也是作爲參數傳入這個方法的。不過需要注意的是該方法獲取的平截頭體的4條邊是在local space的,所以我們需要將它們轉移到world space,以供Fragment Shader中使用。
這樣我們就得到了4個向量,但是這4個向量要怎麼向Shader中傳遞效率才高呢?如果每一個向量傳遞一次,則效率並不高。所以這裏我們使用一個矩陣來保存這4個向量,而向shader中傳送數據就只需要傳送一個矩陣。

    Transform camtr = cam.transform;
    Vector3[] frustumCorners = new Vector3[4];
    cam.CalculateFrustumCorners(new Rect(0, 0, 1, 1), 
        cam.farClipPlane, cam.stereoActiveEye, frustumCorners);
    var bottomLeft = camtr.TransformVector(frustumCorners[0]);
    var topLeft = camtr.TransformVector(frustumCorners[1]);
    var topRight = camtr.TransformVector(frustumCorners[2]);
    var bottomRight = camtr.TransformVector(frustumCorners[3]);

    Matrix4x4 frustumCornersArray = Matrix4x4.identity;
    frustumCornersArray.SetRow(0, bottomLeft);
    frustumCornersArray.SetRow(1, bottomRight);
    frustumCornersArray.SetRow(2, topLeft);
    frustumCornersArray.SetRow(3, topRight);
    return frustumCornersArray;

射線的數據準備好了,向shader中傳送數據在Unity中也十分簡單,只需要調用SetMatrix就好。但是這裏又出現了一個新的問題,那就是shader如何正確的確定它所處理的是哪根射線呢?如果不能確定頂點所對應的射線,那麼之後的插值結果就不會正確。所以在Vertex Shader中我們需要一個Index來從傳入的矩陣中正確的取出射線方向。
那麼Index要如何確定呢?
聰明的你一定想到了,對一個四邊形來說,它的UV數據是很有規律的。所以我們就可以在Vertex Shader中利用UV數據來確定正確的射線:

    index = v.uv.x + (2 * o.uv.y);
    o.ray = _Corners[index].xyz;

OK,之後只要在Fragment Shader中使用經過插值的ray數據,就能獲取當前Fragment所對應的射線方向了。到此,我們已經將射線引入了Shader中。

接下來我們來定義一個SDF,使用SDF來定義我們將要渲染的內容。我們可以在Inigo Quilez的博客上獲取很多常見物體的SDF定義,鏈接在這裏:(http://.org/www/articles/distfunctions/distfunctions.htm)。
下面我們就在Unity中利用SDF渲染一個六棱體:

float sdHexPrism( float3 p, float2 h )
{
    float3 q = abs(p);
    return max(q.z-h.y,max((q.x*0.866025+q.y*0.5),q.y)-h.x);
}

針對不同的物體定義都需要一個SDF來描述該物體,但是如果在我們的RayMarching算法中每次想要渲染不同的形狀時都要修改一下SDF的話似乎十分不方便,所以通常我們還會定義一個更高層的抽象——也可以叫做SDF函數——這個函數常常被稱作map,它的輸入是一個點座標,輸出則是該點距離SDF所定義的物體表面的最近距離。
而有了map這個高層的抽象,我們可以很方便的在map的內部實現中按照自己的需求修改SDF,例如將一些基礎的物體進行合併、拆分等等。從這個角度講,map其實定義了我們要渲染的整改場景,因此正個場景的信息我們是已知的,這一點在之後渲染陰影的時候會用到。
不過,我們還是先來看一個簡單的例子,下面就是我們畫六棱體的例子中所使用的map的定義:

        float map(float3 rp)
        {
            float ret = sdHexPrism(rp, float2(4, 5));

            return ret;
        }

之後我們在Fragment Shader中實現該Fragment上的RayMarching邏輯,在引入SDF之後,RayMarching的每一次Marching的距離就可以根據SDF的結果來設定了,我想大家應該都見過類似這樣的圖解:

ref:adrian's soapbox

可以看到,每一次marching的距離就是當前採樣點到SDF定義的表面的最近距離,直到採樣點和表面重合,即光線和表面相交了。
所以我們只需要在Fragment Shader中跑一個for循環,每一次迭代都調用一次map來確認當前採樣點距離SDF的最近距離surfaceDistance,如果surfaceDistance不爲0,則下一次marching的距離就是surfaceDistance;如果爲0,則證明光線和表面相交,我們只需要確定這點的顏色就好了。
除此之外,我們需要相機的位置rayOrigin做爲射線的起點,這個值我們可以通過在腳本中調用SetVector將相機的位置傳給GPU。此外我們還需要該Fragment上的射線方向rayDirection,我們可以直接獲取,因爲它就是頂點屬性中的ray經過插值之後的結果。

所以這是一個很簡單的邏輯:

        fixed4 raymarching(float3 rayOrigin, float3 rayDirection)
        {

            fixed4 ret = fixed4(0, 0, 0, 0);

            int maxStep = 64;

            float rayDistance = 0;

            for(int i = 0; i < maxStep; I++)
            {
                float3 p = rayOrigin + rayDirection * rayDistance;
                float surfaceDistance = map(p);
                if(surfaceDistance < 0.001)
                {
                    ret = fixed4(1, 0, 0, 1);
                    break;
                }

                rayDistance += surfaceDistance;
            }
            return ret;
        }

OK,光線和表面相交之後,輸出一個紅色。
我們來看一下實際的結果:
屏幕快照 2018-06-11 下午3.44.55.png
可以看到,場景的Hierachy中空空如也,但是屏幕上卻出現了一個純色的六棱體。

0x02 梯度、法線和光照

當然,這個效果並不吸引人,因此我們顯然要加入一些光照效果來提升表現力。那麼求表面的法線就是必須要做的一件事情了。
milo的《用 C 語言畫光(四):反射 》這篇文章中也有相關的內容,即距離場變化最大的方向便是法線方向。根據矢量微積分(vector calculus),一個純量場(scalar field)的最大變化方向就是其梯度(gradient),所以這個問題就轉化爲求形狀邊界位置的 SDF 梯度——即求各個方向的變化率,也就是要求導了。
不過我們顯然沒有必要真正的計算求導,只需要找一個能夠得到近似效果的方式就好了。我們常常使用這個下面這個算式來近似SDF梯度,即在這一點的表面法線:
屏幕快照 2018-06-11 下午5.10.10.png
代碼也就十分簡單了:

        //計算法線
        float3 calcNorm(float3 p)
        {
            float eps = 0.001;

            float3 norm = float3(
                map(p + float3(eps, 0, 0)) - map(p - float3(eps, 0, 0)),
                map(p + float3(0, eps, 0)) - map(p - float3(0, eps, 0)),
                map(p + float3(0, 0, eps)) - map(p - float3(0, 0, eps))
            );

            return normalize(norm);
        }

我們可以把法線信息輸出成顏色,就得到了下圖中的結果。
屏幕快照 2018-06-11 下午5.36.24.png

而實現一個簡單的漫反射也是一件十分簡單的事情:

          ret = dot(-_LightDir, calcNorm(p));
          ret.a = 1;

這樣我們就獲得一個有簡單光照效果的六棱體了。
屏幕快照 2018-06-11 下午5.44.59.png

0x03 陰影

六棱體上有了簡單的漫反射效果,接下來就要在此基礎上實現基於SDF的陰影效果了。SDF的一個優勢就在於場景內的距離信息全都是可知的,因此可以很方便地用來實現類似陰影這樣的效果,並且可以根據距離來更自然地實現陰影的衰減,從而生成一個更加真實的陰影。
不過在此之前,我會將場景修改的稍微複雜一點,當然,這裏我只是增加了3個物體的SDF的定義——Sphere、Plane和Cube,並且簡單的修改下map函數,重新組織了一下整個場景。

        float sdSphere(float3 rp, float3 c, float r)
        {
            return distance(rp,c)-r;
        }

        float sdCube( float3 p, float3 b, float r )
        {
          return length(max(abs(p)-b,0.0))-r;
        }

        float sdPlane( float3 p )
        {
            return p.y + 1;
        }

        float map(float3 rp)
        {
            float ret;
            float sp = sdSphere(rp, float3(1.0,0.0,0.0), 1.0);
            float sp2 = sdSphere(rp, float3(1.0,2.0,0.0), 1.0);
            float cb = sdCube(rp+float3(2.1,-1.0,0.0), float3(2.0,2.0, 2.0), 0.0);
            float py = sdPlane(rp.y);
            ret = (sp < py) ? sp : py;
            ret = (ret < sp2) ? ret : sp2;
            ret = (ret < cb) ? ret : cb;
            return ret;
        }

這樣,整個場景就變成了這個樣子,由2個球體和1個正方體以及一個平面組成。
屏幕快照 2018-06-12 下午2.28.17.png

接下來我們來實現陰影,其實陰影的形成本身也很簡單。沿着光線的方向,如果光線被某個表面遮擋則會在後面的表面上生成陰影。
那麼在代碼中,一個簡單的基於SDF的陰影實現就很簡單了:針對到達物體表面的採樣點,以該點爲起點,沿着光線來的方向,發射另一根射向光源的射線。如果這根射線也擊中了某個物體的表面,則證明該採樣點處於陰影之中——其實還是raymarching。
下面我們來完成一個最簡單的陰影實現,即陰影中是統一的黑色。

        float calcShadow(float3 rayOrigin, float3 rayDirection)
        {
            int maxDistance = 64;

            float rayDistance = 0.01;

            for(rayDistance ; rayDistance < maxDistance;)
            {
                float3 p = rayOrigin + rayDirection * rayDistance;
                float surfaceDistance = map(p);
                if(surfaceDistance < 0.001)
                {
                    return 0.0;
                }

                rayDistance += surfaceDistance;
            }
            return 1.0;
        }

當然這裏需要注意的是,第一次迭代時不要直接把採樣點傳入到map中,否則的話會直接return。
ok,這樣一個很硬的陰影就創建好了,沒有多餘的pass,沒有多餘的貼圖,使用SDF創建陰影就是這麼簡單。
屏幕快照 2018-06-12 下午3.41.36.png
大家都知道,陰影通常是由所謂的本影和半影組成的,其中本影主要指的是物體表面上那些沒有被光源直接照射的區域,呈現全黑的狀態,而所謂的半影則是那些半明半暗的過渡部分。可以看到我們實現的這種陰影其實只包括本影,而沒有半影的效果。
所以在這個純黑的本影的基礎上,再增加一些不是純黑的半影效果,那麼最後的陰影會更加真實。所以接下來我們就要考慮,黑色本影之外的表面上的那些點的顏色了。
這時我們把距離的因素考慮進去:

      ret = min(ret, 10 * surfaceDistance /rayDistance );

屏幕快照 2018-06-12 下午4.15.06.png
可以看到,這樣一來在之前純黑的本影之外,不再是像最初的實現中將影子直接截斷,而是多了一圈模糊的半影來過渡。
不過,我相信眼尖的你一定發現了一些問題。那就是Cube的半影部分出現了條帶狀的artifact。
WX20180612-162614@2x.png
這主要是由於在計算陰影的RayMarching的過程中,採樣出現了問題。
在今年的GDC上,Sebastian Aaltonen分享了一個新的方案來解決這個問題:
屏幕快照 2018-06-12 下午5.23.03.png
屏幕快照 2018-06-12 下午5.32.51.png

根據上一次的採樣D-1和這一次的採樣D的數據,來計算或者是估算一個這條射線上距離SDF表面最近的點E,並用E來計算半影。
在分享中Sebastian也給出了他修改後的半影計算公式:

Triangulation formula: res = min(res, 
(r2*sqrt(4*(r1*r1)-h*h))*rcp(2*hprev)/(t-h*h*rcp(2*hprev))) 

事實上Inigo也已經根據Sebastian的分享,改進了他的SDF陰影的效果。下面我們就根據Inigo和Sebastian的實現,在Unity中解決掉這個半影部分的條帶狀的artifact吧。

        //Adapted from:iquilezles
        float calcSoftshadow( float3 ro, float3 rd, float mint, float tmax)
        {

            float res = 1.0;
            float t = mint;
            float ph = 1e10;
            
            for( int i=0; i<32; i++ )
            {
                float h = map( ro + rd*t );
                float y = h*h/(2.0*ph);
                float d = sqrt(h*h-y*y);
                res = min( res, 10.0*d/max(0.0,t-y) );
                ph = h;
                
                t += h;
                
                if( res<0.0001 || t>tmax ) 
                    break;
                
            }
            return clamp( res, 0.0, 1.0 );
        }

其中ph是上一次採樣時的圓形的半徑,h是當前這次的採樣的圓形半徑。
修改後的陰影效果:
屏幕快照 2018-06-12 下午5.49.57.png

0x04 後記

這樣,我們就在Unity中實現了SDF渲染以及基於SDF的陰影渲染,並且解決了討厭的條帶狀的artifact。

本文的項目可以在這裏獲取:
https://github.com/chenjd/Unity-Signed-Distance-Field-Shadow

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章