1. 程式人生 > >Signed Distance Field Shadow in Unity

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

相關推薦

Signed Distance Field Shadow in Unity

0x00 前言 最近讀到了一個今年GDC上很棒的分享,是Sebastian Aaltonen帶來的利用Ray-tracing實現一些有趣的效果的分享。 其中有一段他介紹到了對Signed Distance Field Shadow的改進,主要體現在消除SDF陰影的一些artifact上。 第一次看到Sig

SDF(Signed-distance-field: 有向距離場)(12): Shadow And AO(WebGL實現)增強立體感

AO(Ambient Occlusion) 中文翻譯為環境遮蔽, 這個功能可以在渲染中有效增強立體感。可以簡單的這麼理解AO: 一個區域(可以是一個點)損失環境光的程度。試想,一個球體從一個盒子裡面拿出的過程,就是環境光照射量增加的過程,而盒子遮蔽這個球體的環

SDF(Signed-distance-field: 有向距離場)(14): 形體變形方法(WebGL實現):Blend(混合過渡)

Demo1:http://www.artvily.com/sdf?sample=codeDemo&clip=blendExample3 Demo2:http://www.artvily.com/sdf?sample=codeDemo&clip=blendExample2 De

SDF(Signed-distance-field: 有向距離場)(13): 形體變形方法(WebGL實現):Displacement(置換)

Demo: http://www.artvily.com/sdf?sample=codeDemo&clip=displaceExample Displacement(置換), 是"變形" 相關操作非常重要的工具,它使用一個因子(可能是個sdf或者其他函式)對一個sdf函式

SDF(Signed-distance-field: 有向距離場)(3): 空間劃分原理(原始碼解釋)

下面這是SDF常用的三個函式: // intersect(求交) vec2 mult(vec2 tA, vec2 tB) { if(tA.x > tB.x) return tA; return tB; } // union(合併) vec2 add(v

Signed Distance Fields in Real-time Rendering

ref: https://zhuanlan.zhihu.com/p/35098986 題圖: UE4 Distance Field Ambient Occlusion Shadow 最基本的製作陰影的方法就是從光源發射射線, 然後判斷著色的點和光源之間是否被物體遮擋, 有遮擋是陰影內,

Distance Field Technique

cat src alt fec ID test cto cati 圖片 【Distance Field Technique】   一種小紋理高清放大的技術。    參考:《Improved Alpha-Tested Magnification for Vector T

transparent shadow caster unity

-s threads emit gad unit thread ast cas http https://forum.unity.com/threads/semitransparent-shadows.276490/ semitransparent shadows di

【翻譯】Motion Blur for mobile devices in Unity

采樣 nim 模糊效果 步驟 深度 put ++ 足夠 函數 原文鏈接:https://tech.spaceapegames.com/2018/09/06/motion-blur-for-mobile-devices-in-unity/ 什麽是運動模糊?-What is M

SpringBoot Field aFeign in xxxx required a bean of type 'xxx' that could not be found.

Field aFeign in xxxx required a bean of type ‘xxx’ that could not be found. SpringBoot 同一個專案,多模組之間呼叫 報錯 APPLICATION FAILED TO START

MachineLearning in UNITY(windows環境下)

本文分享如何安裝機器學習代理工具(Unity Machine Learning Agents),並通過官方範例3D小球,瞭解整個環境的安裝過程。 在本文中使用的案例是3D平衡小球(3D Balance Ball)的執行環境。該環境包含多個平板和小球。這些平板可以

Mybatis與springboot專案啟動時出現Field XXX in com.xxx that could not be found

Mybatis與springboot專案,啟動時報錯,詳細的錯誤如下: Field xxx in com.xxxl required a bean of type ‘xxx’ that could no

Field userService in com.wuji.controller.UserController required a bean of type 'com.wuji.service.UserService' that could not be found

Field userService in com.wuji.controller.UserController required a bean of type 'com.wuji.service.UserService' that could not be found. 原因:說明IOC裡沒有建立

Springboot啟動application報錯:Field userMapper in xxx required a bean of type xx that could not be found

2018-07-24 15:38:07.647 INFO 20368 --- [ main] c.e.playspring.PlayspringApplication : Starting PlayspringApplication on LAP

DiFi: Fast 3D Distance Field Computation Using Graphics Hardware

ref: http://gamma.cs.unc.edu/DIFI/ DiFi: Fast 3D Distance Field Computation Using Graphics Hardware   by Avneesh Sud, Miguel A. Otaduy, and

springboot 啟動報錯 Field roleMapper in ‘’ required a bean of type '' that could not be found.

其實在報錯資訊上就能看出來問題是什麼,但是因為現在文件少,不容易找到解決方案。特此記錄 意思是掃描不到mapper層,我們需要在Application.java這個啟動檔案上加一個註解告訴spri

unity(使用了NGUI) 在Build時遇見DontSave but is included in the build

clu fonts 自動生成 tsa included image sources ext lag 錯誤提示: An asset is marked with HideFlags.DontSave but is included in the build:Asset: ‘L

Maximum Distance in Array

example find sce ati for define amp view size Given m arrays, and each array is sorted in ascending order. Now you can pick up two inte

[CF161D]Distance in Tree-樹狀dp

bottom colspan gnu -i author pac gin 服務 轉化 Problem Distance in tree 題目大意 給出一棵樹,求這棵樹上有多少個最短距離為k的點對。 Solution 這個題目可以用點分治來做,然而我到現在還是沒有學會點分治,

Android java.lang.NoSuchFieldError: No static field xxx of type I in class Lcom/XX/R$id; or its superclasses

activity oid 返回 反射 分享 -c lar 進行 是否 項目開發快到尾聲,突然發現之前一個模塊莫名其妙的奔潰了,我的內心也是奔潰的。以前一直都是好好的,也沒去動過它,為啥會出現這樣的問題呢? 下面我會根據自己的理解來看待問題 android是怎麽根據id查找