在Unity裏使用光線步進(Raymarching)

圖中光滑球爲光線步進產生,粗糙球爲Unity的場景物體 

概念

光線步進和光線投射類似,都是從屏幕發射射線,然後求射線和物體的焦點,但是光線投射是一次性算出交點,而光線步進是一步步的前進,不斷的向交點趨近,光線步近中的物體使用一種距離場函數來表示(SDF,Signed-distance-field 有向距離場)。通過這個函數你可以知道當前點的位置位置和物體的最近距離,如果距離趨向於0,就說明到達了交點,

每次前進的步長等於計算的距離,這樣可以更快的趨近與交點。

Raymarching variable step

如圖所示,射線一步步向前,最後到達近似於交點的位置。


光線步進可以看做一個屏幕特效,怎麼讓shader應用一個屏幕特效我就忽略了,另外shader創建一個Image Effect Shader即可,

直接在上面改就好了,基本的設置幾乎不需要變。


射線方向

首先我們需要得到每個像素的射線發射方向,這裏我共看到了兩種

第一種比較方便,但是可能會比較耗性能,因爲每個像素都要計算一次。 思路看這裏

            Ray CreateCameraRay(float2 uv){
                float2 p=uv*2.0f-1.0f;
                //內置的矩陣unity_CameraToWorld 左右手座標系需要切換,所以要修改一下
                //tips:外部傳入的_camera.cameraToWorldMatrix就是反的
                float4x4 negativeMat=float4x4(
                1,0,0,0,
                0,1,0,0,
                0,0,-1,0,
                0,0,0,1
                );
                float4x4 n_CameraToWorld=mul(unity_CameraToWorld,negativeMat);
                float3 origin=mul(n_CameraToWorld,float4(0.0f,0.0f,0.0f,1.0f)).xyz;
                float3 direction=mul(unity_CameraInvProjection,float4(p.xy,1.0f,1.0f)).xyz;
                direction=mul(n_CameraToWorld,float4(direction,0.0f)).xyz;
                direction=normalize(direction);
                return CreateRay(origin,direction);
            }

值得一提的是在實際使用中我發現shader中內置的相機世界矩陣和外界傳入的相機世界矩陣有所不同,內置的並沒有包含左右手座標系的轉換,所以用的時候要麼用外面傳入的,要麼修改一下內置的。如果直接用內置的,你以爲的正面其實是背面。

第二種需要shader外的配合,核心思路就是預先計算好屏幕空間四個頂點的發射向量,然後通過插值器得到每個像素點的發射方向

在c#部分,我們預先計算好四個頂點的向量,打包成矩陣傳入shader;

    //返回一個矩陣,分別表示四個點的向量,在shader裏插值後可以得到各像素點的方向
    Matrix4x4 CamFrustum()
    {
        Matrix4x4 mat=Matrix4x4.identity;
        float fov = Mathf.Tan(_camera.fieldOfView * 0.5f* Mathf.Deg2Rad) ;
        //得到向上向右的位移偏亮 進而推出屏幕面片四個點的發射方向 
        Vector3 up = Vector3.up * fov;
        Vector3 right = Vector3.right * _camera.aspect * fov;
        Vector3 TL = (-Vector3.forward + up - right);
        Vector3 TR = (-Vector3.forward + up + right);
        Vector3 BL = (-Vector3.forward - up - right);
        Vector3 BR = (-Vector3.forward - up + right);
        //順序爲左下,右下,左上,右上 不要亂
        mat.SetRow(0,BL);
        mat.SetRow(1,BR);
        mat.SetRow(2,TL);
        mat.SetRow(3,TR);
        return mat;
    }
...
raymarchMat.SetMatrix("_CamFrustum",CamFrustum());
...

在shader中我們通過需要獲取每個頂點的向量,我們知道uv左下爲(0,0)右上爲(1,1)通過這一點我們讓x乘1,y乘2,兩者相加就可以得到我們想要的序列 即左下=0,右下=1,左上=2,右上=3。在頂點函數中計算好,通過插值我們就可以在片元函數中得到每個像素的方向了。(_CamToWorld是外界傳入的相機-世界矩陣,原因方法一提到)

            v2f vert (appdata v){
                v2f o;
                int index=(int)dot(v.uv,float2(1,2));
                v.vertex.z=0;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                o.rayDir= _CamFrustum[index].xyz;
                o.rayDir=mul(_CamToWorld,o.rayDir);
                return o;
            }

光線步進算法

maxDist是射線的最遠距離,maxItera是射線最多走幾步。

bool  RayMarching(Ray ray,float maxDist,int maxItera,inout float3 p){
                float t=0.0f;//光線走的長度
                for(int i=0;i<maxItera;i++){
                    //最大邊界
                    if(t>maxDist) return false;
                    p=ray.origin+t*ray.direction;//現在的位置
                    float d=DistanceField(p);//當前點和物體的距離(要注意不一定是交點的距離,不然直接一步就到了)
                    if(abs(d)<0.01) {
                        return true;//找到了交點
                    }
                    t+=d;
                }
                return false;
            }

距離場

前面的DistanceField(),其中就包含了多個物體的距離場  不同形狀的距離場,以及形狀之間的組合操作請看iq的這篇文章

我們可以嘗試畫一個最簡單的球

float sdSphere( float3 p, float s ){

  return length(p)-s;
}
float4 DistanceField(float3 p){
    return sdSphere(p,3);
}
            fixed4 frag (v2f i) : SV_Target {
                float2 uv=i.uv;
                float3 result=0.0f;
                Ray ray= CreateRay(_WorldSpaceCameraPos,normalize(i.rayDir));
                float3 hitPosition;
                bool hit=RayMarching(ray,_MaxDistance,_MaxIterations,hitPosition);
                if(hit){
                    result=1.0f;
                }else{
                    result=0.0f;
                }
                return float4(result,1.0f);
            }


法線計算

我們可以在此利用距離場計算交點位置的法線,法線也就是它的梯度。

            float3 calcNormal( in float3 pos ){
                            float2 e = float2(1.0,-1.0)*0.5773*0.0005;
                            return normalize( e.xyy*DistanceField( pos + e.xyy ).x +
                                    e.yyx*DistanceField( pos + e.yyx ).x +
                                    e.yxy*DistanceField( pos + e.yxy ).x +
                                    e.xxx*DistanceField( pos + e.xxx ).x );
                            /*
                             float3 eps = float3( 0.0005, 0.0, 0.0 );
                             float3 nor = float3(
                             DistanceField(pos+eps.xyy).x - DistanceField(pos-eps.xyy).x,
                             DistanceField(pos+eps.yxy).x - DistanceField(pos-eps.yxy).x,
                             DistanceField(pos+eps.yyx).x - DistanceField(pos-eps.yyx).x );
                             return normalize(nor);
                             */
            }

讓輸出顏色爲法線,結果如圖

if(hit){
    result=calcNormal(hitPosition);
}


光照

有了法線就可以計算光照了。ps:文中所有"_"開頭的都是外界傳入的變量,自己按意思設置即可。

if(hit){
    float3 normal=calcNormal(hitPosition);
    result=_LightCol*saturate(dot(normal,-_LightDir));
}else{
    result=float3(0.2,0.2,0.3);
}


調整場景

我們先把渲染的部分合併到到單獨的一個shade函數中

float3 Shade(float3 p,float3 normal){
            
                float3 diffuse=_LightCol*(saturate(dot(normal,-_LightDir))*0.5f+0.5f);
                return diffuse;
}
...
if(hit){
    float3 normal=calcNormal(hitPosition);
    result=Shade(hitPosition,normal);
}

然後調整場景物體,下面就是文章開頭圖片的距離場 。opSmoothUnion是一個結合兩個距離場的操作,他可以平滑的合併兩者,更多操作請看前面提到的那篇文章。

float4 opSmoothUnion( float d1, float d2, float k ) {
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return lerp( d2, d1, h ) - k*h*(1.0-h); }

float DistanceField(float3 p){
   float d1=sdSphere(p-_Sphere01.xyz+float3(0,fmod(_Time.y*6, 10),0),_Sphere01.w*0.5);
   float d2=sdSphere(p-_Sphere01.xyz,_Sphere01.w);
   float plane=sdPlane(p,_Plane01);
   float comb01=opSmoothUnion(plane,opSmoothUnion(d1,d2,1),1);
   
   float d3=sdSphere(p-_Sphere02.xyz+float3(0,fmod(_Time.y*6, 9),0),_Sphere02.w*0.5);
   float d4=sdSphere(p-_Sphere02.xyz,_Sphere02.w);
   float comb02=opSmoothUnion(d3,d4,1);
   return opSmoothUnion(comb01,comb02,1);
}

 


陰影

陰影可以看這篇文章,可以分爲硬陰影和軟陰影。

硬陰影思路很簡單,就是朝光線方向再來一次光線步進,如果撞到了物體就說明光線被該物體擋住了,自身位於陰影中。

float HardShadow(float3 ro,float3 rd,float mint,float maxt){
    for( float t=mint; t < maxt; )
    {
        float h = DistanceField(ro + rd*t);
        if( h<0.001f)
            return 0.0f;
        t += h;
    }
    return 1.0f;
}

軟硬陰影則是在硬陰影基礎上進一步拓展,就是讓陰影附近能有一層過渡

float SoftShadow(float3 ro,float3 rd, float mint, float maxt, float k ){
    float res = 1.0f;//確保陰影衰減值不會大於1
    for( float t=mint; t < maxt; )
    {
        float h = DistanceField(ro + rd*t);
        if( h<0.001f )
            return 0.0f;
        res = min( res, k*h/t );
        t += h;
    }
    return res;
}

mint和maxt是最近和最遠的陰影距離。 

 

如圖所示,t表示射線到目標步數走的路場,而h是每走一步和物體的距離,很明顯,當兩者垂直時,h/t的值最小。而隨着光線的原理,h和t之間的差距越來越小,h/t趨向於1,1就是沒有陰影的情況,k值是陰影的軟化程度,在這裏我們也可以知曉其實他就是加速h/t趨向於1,值越大,k*h/t就會越快的趨近1,陰影也就越銳利。

float3 Shade(float3 p,float3 normal){
    //控制在[0.5,1.0],這樣可以用pow(,_ShadowIntensity)的方式對陰影濃度做進一步的調整
    float shadow=SoftShadow(p,-_LightDir,_ShadowDistance.x,_ShadowDistance.y,_ShadowPenumbra)*0.5+0.5;
    shadow=max(0.0,pow(shadow,_ShadowIntensity));
    float3 diffuse=_LightCol*(saturate(dot(normal,-_LightDir))*0.5f+0.5f);
    return diffuse*shadow;
}

 

硬陰影和軟陰影


環境光遮蔽

float calcAO(float3 p,float3 normal){
    float step=_AoStepSize;//每次前進的步長,這裏使用固定步長
    float ao=0.0;
    float dist;
    for(int i=0;i<=_AoIterations;i++){
        dist=step*i;
        //如果附近沒有其他物體 dist<DistanceField 最終結果爲負數並截爲0 返回值=1 即無環境光遮蔽
        //如果附近有物體,那麼法線步進就會靠近該物體 從而dist>DistanceField 結果大於0,返回值<1
        ao+=max(0.0f,(dist-DistanceField(p+normal*dist))/dist);
    }
    return (1.0f-ao*_AoIntensity);
}

 

如圖所以,同等步長下,越是犄角疙瘩的地方,d值越有可能步dist值小,從而得到更高的ao值,因爲ao值高的地方應該越暗,所以最後的返回值是1-ao,讓高ao值的趨向於0.


和原有場景的合併

帶目前爲止我們都是完全丟棄了Unity原本渲染的內容,在這裏我們要把它補回了,思路很簡單,我們通過深度貼圖得到深度,如果光線步進的長度超過這個值,就沒必要繼續計算了,直接返回false,因爲就算在這個距離之後碰撞到了距離場物體,它按常理也是應該被Unity場景中的物體所遮擋的。

我們首先要利用深度貼圖計算深度

float depth=LinearEyeDepth(tex2D(_CameraDepthTexture,uv).r);

同時raymarching函數新增一個深度參數。

bool  RayMarching(Ray ray,float depth,float maxDist,int maxItera,inout float3 p){
    float t=0.0f;//光線走的長度
    for(int i=0;i<maxItera;i++){
        //注意這裏,現在長度既不能超過規定的最大值也不能超過深度值
        if(t>maxDist||t>depth) return false;
        p=ray.origin+t*ray.direction;
        float d=DistanceField(p);
        if(abs(d)<0.01) {
            return true;
        }
        t+=d;
    }
    return false;
}

在frag函數中,如果是false,就返回原屏幕貼圖的顏色, _MainTex一般你用Graphics.Blit()會默認傳入原圖像。

bool hit=RayMarching(ray,depth,_MaxDistance,_MaxIterations,hitPosition);
if(hit){
    float3 normal=calcNormal(hitPosition);
    result=Shade(hitPosition,normal);
}else{
    float3 texCol=tex2D(_MainTex,uv).rgb;
    result=texCol;
}


反射

1.場景物體的反射

場景物體的反射我們利用反射探針來完成,因爲是imageEffect,所以內置的unity_SpecCube0無法正常配置,我們需要手動把光照探針的貼圖傳進去。

public ReflectionProbe ReflectionProbe;
...
raymarchMat.SetTexture("_SkyBox",ReflectionProbe.texture);

shader中就是簡單的採樣疊加

if(hit){
    float3 normal=calcNormal(hitPosition);
    result=Shade(hitPosition,normal);
    float3 reflectDir=reflect(ray.direction,normal);
    result=lerp(result,texCUBE(_SkyBox,reflectDir).rgb,_ReflectIntensity);
}

紅黃色的球是場景中的靜態物體,可以看到已經被渲染進了光照探針的立方體貼圖中。 

2. 距離場物體的反射

在開始計算距離場反射之前我們先讓距離場物體能夠有自己的顏色。

思路很簡單,我們讓距離場函數返回floa4類型,xyz存儲顏色,w存儲z

這裏只展示主要的幾個函數,要改動的地方其實有很多,首先所有調用DistanceField地方取值都要球改,還有距離場的各個操作函數也要適應flaot4類型。

 float3 Shade(float3 p,float3 normal,float3 hitColor){
     ...
     float3 diffuse=_LightCol*hitColor*(saturate(dot(normal,-_LightDir))*0.5f+0.5f);
     ...
 }
 
 bool  RayMarching(Ray ray,float depth,float maxDist,int maxItera,inout float3 p,inout float3 col){
    float t=0.0f;
    for(int i=0;i<maxItera;i++){
        if(t>maxDist||t>depth) return false;
        p=ray.origin+t*ray.direction;
        float4 d=DistanceField(p);
        if(abs(d.w)<0.01) {
            return true;
        }
        t+=d.w;
        col=d.rgb;
    }
    return false;
}
 
 fixed4 frag (v2f i) : SV_Target {
     ...
     float3 hitPosition,hitColor;
     bool hit=RayMarching(ray,depth,_MaxDistance,_MaxIterations,hitPosition,hitColor);
     if(hit){
         float3 normal=calcNormal(hitPosition);
         result=Shade(hitPosition,normal,hitColor);
     ...
 }

距離場物體的反射思路就是對反射方向進行光線步進。

//反射
result=lerp(result,texCUBE(_SkyBox,reflectDir).rgb,_ReflectIntensity);
if(_ReflectBounces>0){
    Ray rRay=CreateRay(hitPosition+0.01*normal,reflectDir);
    hit=RayMarching(rRay,_MaxDistance,_MaxDistance*0.5,_MaxIterations/2,hitPosition,hitColor);
    if(hit){
        normal=calcNormal(hitPosition);
        reflectDir=reflect(ray.direction,normal);
        //第一次的反射結果
        result+=Shade(hitPosition,normal,hitColor)*0.5f*_ReflectIntensity;
        
        if(_ReflectBounces>1){
            rRay=CreateRay(hitPosition+0.01*normal,reflectDir);
            hit=RayMarching(rRay,_MaxDistance,_MaxDistance*0.25,_MaxIterations/4,hitPosition,hitColor);
            if(hit){
                normal=calcNormal(hitPosition);
                reflectDir=reflect(ray.direction,normal);
                //第二次的反射結果
                result+=Shade(hitPosition,normal,hitColor)*0.25f*_ReflectIntensity;
            }
        }
        
    }
}

畫圈部分就是綠色球對黃色球的反射

參考內容:

https://www.youtube.com/watch?v=oPnft4z9iJs&list=PL3POsQzaCw53iK_EhOYR39h1J9Lvg-m-g(推薦看這個系列的視頻)

https://www.gamasutra.com/blogs/DavidArppe/20170405/295240/How_to_get_Stunning_Graphics_with_Raymarching_in_Games.php

http://9bitscience.blogspot.com/2013/07/raymarching-distance-fields_14.html

 

 

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