Unity 體積光

爱莉希雅發表於2024-03-13

目錄
  • 思路
  • 基礎的RayMarching
    • 獲取深度圖
    • 重建世界空間
    • 取樣陰影圖
    • RayMarching
    • 當前效果
  • Dual Blur最佳化塊狀感
    • Dual Blur
    • 疊加模糊後的體積光和原圖
  • 進化叭!RayMarching!
  • 效能最佳化
  • 最終效果
  • Reference

思路

  • 觀察下面這副圖可以發現,在明亮處光很明顯,暗處(陰影中)沒有明顯的光,且越暗光越不明顯

  • 為了還原這一現象,可以想到的是根據目標pixel的陰影值來計算亮度。但如何營造光的體積感呢?這就需要用到光線追蹤!的思想rayMarching(光線步進)

    與光追不同的是,光追是每個pixel,在場景中發射一根射線並不斷彈射,當彈射出場景或達到最大彈射次數時,累加每次彈射計算得到的顏色,最終該pixel返回該顏色值;而rayMarching特別之處在於,它不會彈射,而是每個pixel發射一根射線,該射線每次行走一定的距離step,每行走一次計算當前位置的陰影值並累加,當碰到遮擋物體或達到最大距離,就終止步進,最終得到的結果即為累加的陰影值

    如下圖所示,紅色虛線代表光線走到過的位置,當走到這些位置時就取樣陰影圖並得到對應的陰影值,最後累加

基礎的RayMarching

知道怎麼做了,現在就來實現叭!

獲取深度圖

  • 因為RayMarching中涉及到光線與物體的碰撞檢測,所以需要重建世界空間,而重建需要深度圖的幫助

  • 注意:記得在URP配置中啟用"Depth Texture"

  • 實現

    float GetEyeDepth(float2 uv)
    {
        float depth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, uv).r;
        float eyeDepth = LinearEyeDepth(depth, _ZBufferParams);
    
        return eyeDepth;
    }
    

重建世界空間

  • 沒什麼好說的很簡單

  • 實現

    o.positionSS = ComputeScreenPos(o.positionCS);
    
    float2 screenUV = i.positionSS.xy / i.positionSS.w;
    
    float3 ReConstructPosWS(float2 NDCuv)
    {
        half depth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, NDCuv);
        
        float3 rePosWS = ComputeWorldSpacePosition(NDCuv, depth, UNITY_MATRIX_I_VP);
    
        return rePosWS;
    }
    

取樣陰影圖

  • 在前面說過,得到的光照與陰影值有關,所以這裡需要取樣陰影圖

  • 實現

    #pragma multi_compile _ _MAIN_LIGHT_SHADOWS                    //接受陰影
    #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE            //產生陰影
    #pragma multi_compile _ _SHADOWS_SOFT                         //軟陰影 
    
    float GetShadow(float3 positionWS)
    {
        float4 shadowUV = TransformWorldToShadowCoord(positionWS);
        float shadow = MainLightRealtimeShadow(shadowUV);
    
        return shadow;
    }
    

RayMarching

  • 實現

    RayMarching

    half3 GetLightShaft(float3 rayOrigin, half3 rayDir, float maxDistance)
    {
        half step = maxDistance / _MaxDepth;              // 步長
        half currDistance = 0.h;         	// 當前已經步進的距離
        float3 currPos = rayOrigin;		 	// 當前步進到的位置
        half3 totalLight = 0.h;				// 總光照值
    
        UNITY_UNROLL(50);
        for(int i = 0; i < _MaxDepth; ++i)
        {
            currDistance += step;
            // 超出最大距離
            if(currDistance > maxDistance)  break;
    
            // 步進後新的位置
            currPos += rayDir * step;
    
            // 求當前pixel的陰影值
            totalLight += _Brightness * GetShadow(currPos);
        }
    
        Light mainLight = GetMainLight();
        half3 mainLightDir = mainLight.direction;
        
        // 白天夜晚的光顏色不同
        half3 result = totalLight * mainLight.color * lerp(_NightColor.rgb * _NightColor.a, _DayColor.rgb * _DayColor.a, saturate(mainLightDir.y));
        
        return result;
    }
    

    疊加RayMarching和原來的顏色

    // RenderFeature中將原圖複製給MainTex
    cmd.CopyTexture(RTID.targetBuffer, ShaderIDs.mainTexID);  // 將原RT複製給_MainTex
    
    // shader
    half3 sourceColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv).rgb;
    
    half3 lightShaft = GetLightShaft(rayOrigin, rayDir, totalDistance);
    half3 result = lightShaft + sourceColor;
    

當前效果

Dual Blur最佳化塊狀感

  • 存在的問題:仔細觀看上圖,雖然有體積光的感覺,但是有明顯的硬線,這是因為step的步長大小不夠小,得到的結果不夠精準(和光追一個道理,彈射次數越多越精準)。但是step步長小了開銷又很高,真是頭疼怎麼辦呢?
  • 解決方案:因為體積光屬於後處理,要用魔法打敗魔法,所以這裡可以採用模糊弱化硬線。出於效能考慮,這裡使用效能拔尖的Dual Blur
  • 因為Dual Blur在這篇提到過,所以此處僅僅簡單展示一下

Dual Blur

  • 實現:先只模糊體積光

    // 16個降取樣和升取樣的TextureID
    struct BlurLevelShaderIDs
    {
        internal int downLevelID;
        internal int upLevelID;
    }
    static int maxBlurLevel = 16;
    BlurLevelShaderIDs[] blurLevel;
    
    // 初始化降取樣和升取樣的Render Texture
    blurLevel = new BlurLevelShaderIDs[maxBlurLevel];
    for (int t = 0; t < maxBlurLevel; ++t)  // 16個down level id, 16個up level id
    {
    	blurLevel[t] = new BlurLevelShaderIDs
    	{
    		downLevelID = Shader.PropertyToID("_BlurMipDown" + t),
    		upLevelID = Shader.PropertyToID("_BlurMipUp" + t)
    	};
    }
    
    // 用於建立 render texture
    RenderTextureDescriptor descriptor = renderingData.cameraData.cameraTargetDescriptor;
    // 降取樣
    descriptor.width /= m_passSetting.m_downsample;
    descriptor.height /= m_passSetting.m_downsample;
    
     // 體積光影像作為down的初始影像
     RenderTargetIdentifier lastDown = RTID.targetBuffer;
     // 計算down sample
     for (int i = 0; i < m_passSetting.m_passLoop; ++i)
     {
         // 建立down、up的Temp RT
         int midDown = blurLevel[i].downLevelID;
         int midUp = blurLevel[i].upLevelID;
         cmd.GetTemporaryRT(midDown, descriptor, FilterMode.Bilinear);
         cmd.GetTemporaryRT(midUp, descriptor, FilterMode.Bilinear);
         // down sample
         cmd.Blit(lastDown, midDown, m_Material, 3);
         // 計算得到的影像複製給lastDown,以便下個迴圈繼續計算
         lastDown = midDown;
    
        // 每次迴圈都降低解析度
        descriptor.width = Mathf.Max(descriptor.width / 2, 3);
        descriptor.height = Mathf.Max(descriptor.height / 2, 3);
    }
    
    // 計算up sample
    // 將最後一個的down sample RT ID賦值給首個up sample RT ID
    int lastUp = blurLevel[m_passSetting.m_passLoop - 1].downLevelID;
    // 第一個ID已經賦值
    for (int i = m_passSetting.m_passLoop - 2; i > 0; --i)
    {
        int midUp = blurLevel[i].upLevelID;
        cmd.Blit(lastUp, midUp, m_Material, 4);
        lastUp = midUp;
    }
    
    cmd.Blit(lastUp, RTID.targetBuffer, m_Material, 4);
    
  • 效果

疊加模糊後的體積光和原圖

  • 實現:先儲存一張原貼圖在_SourceRT,再將blur得到的RT和SourceRT疊加即可

    cmd.CopyTexture(RTID.targetBuffer, ShaderIDs.sourceBufferID);  		// 將原RT複製給_SourceTex
    
    cmd.Blit(lastUp, ShaderIDs.tempBufferID, m_Material, 4);			// blur up sample
    cmd.Blit(ShaderIDs.tempBufferID, RTID.targetBuffer, m_Material, 5);	// 合併
    
    half4 AddPS(PSInput i) : SV_TARGET
    {
        half4 result = 0.h;
    
        half4 sourceTex = SAMPLE_TEXTURE2D(_SourceRT, sampler_SourceRT, i.uv);
        half4 blurTex = SAMPLE_TEXTURE2D(_TempBuffer, sampler_TempBuffer, i.uv);
        result += sourceTex + blurTex;
    
        return result;
    }
    
  • 效果

    可以看到塊狀感沒了

進化叭!RayMarching!

  • 存在的問題:觀察上圖得到的效果還是不錯滴,但是!目前的體積光模型是一種經驗模型,並沒有遵循現實,如光照是線性疊加,而現實中光照是會衰減的,且衰減是非線性

  • 解決方案:使用瑞利散射模型模擬光照衰減和散射

  • 結論推導

    出於效能考慮,這裡只討論和實現單次散射,多次散射很複雜

    如下圖,假設圓以外屬於真空領域,光線從點C進入大氣,當光線打中點P的顆粒時,有且只發生一次散射,隨後向A方向行進

    假設大氣以外不會發生散射,且不消耗能量,當光線到達點C時的強度為最大值\(I_{c}\),行進至點P時衰減為\(I_{p}\),那麼\(透光度T = \frac{I_{p}}{I{c}}\),可得:\(I_{p} = I_{c} * T(CP)\)

    • 散射公式

      光線在點P發生散射,散射公式:\(S(\lambda, \theta, h) = \beta(\lambda, h) * P(\theta)\),其中\(\beta\)表示對整個圓散射得到的結果,\(P\)表示沿視線方向散射的量

      這裡直接給結論\(\beta(\lambda, h) = \frac{8\pi^3 * (n^2 - 1)^2}{3} \frac{\rho(h)}{N} \frac{1}{\lambda^4}\)\(\rho(h) = exp^{\frac{-h}{H}}\),其中\(\rho(h)\)表示高度h處的相對大氣密度,H參考高度瑞利散射為8400,h為距離海平面高度,N表示標準大氣壓下的粒子濃度,n表示空氣折射率\(\lambda\)表示波長

      雖然這個結論可以直接用,但為了更簡便,這裡再簡化一下:定義常數項\(K = \frac{2\pi^2(n^2 - 1)^2}{3N}\),可得\(\beta(\lambda, h) = \frac{4\pi * K}{\lambda^4} \rho(h)\)

      \(P(\theta) = \frac{3}{16\pi}(1 + cos\theta^2)\),其中\(\theta\)表示光線反方向與視線方向夾角 ,可得\(S(\lambda, \theta, h) = \frac{4\pi * K}{\lambda^4} \rho(h) P(\theta)\)

      \(P(\theta)\)可以和\(4\pi\)抵消,可得\(P(\theta) = \frac{3}{4\pi}(1 + cos\theta^2)\)

      最終可得\(S(\lambda, \theta, h) = \frac{K\rho(h) P(\theta)}{\lambda^4}\)

    • 透光度

      因為只考慮單詞散射,這裡將每次光線步進看作一次散射,那麼每次步進光強衰減\((1 - \beta)\)。而步進n次,透光度為\(T = e^{-\beta}\)

  • 實現

    因為\(S(\lambda, \theta, h)\)是一個與相對高度和波長相關的函式,但光線步進的距離較小,對於高度幾乎沒有影響,可以省去,所以出於效能考慮,散射函式的係數由一個常量代替

    // 沿視線方向散射的量(密度函式)
    float GetP(float cosTheta)
    {
        return 0.0596831f * (1.f + Pow2(cosTheta));
    }
    
    // 高度h處的相對大氣密度
    float GetRho()
    {
        return exp(-_HeightFromSeaLevel / 8400.f);
    }
    
    // 散射函式
    float GetScatter(float cosTheta)
    {
        return GetP(cosTheta) * _ScatterFactor;
    }
    
    // 透光度
    float GetTransmittance(float distance)
    {
        return exp(-distance * _ScatterFactor * GetRho());
    }
    
    half3 GetLightShaft(float3 viewOrigin, half3 viewDir, float maxDistance)
    {
        Light mainLight = GetMainLight();
        half3 mainLightDir = mainLight.direction;
        
        half rayMarchingStep = maxDistance / _MaxDepth; // 步長
        half currDistance = 0.h;         // 當前已經步進的距離
        float3 currPos = viewOrigin;
        half3 totalLight = 0.h;
    
        float scatterFun = GetScatter(dot(viewDir, -mainLightDir));
    
        UNITY_UNROLL(50);
        for(int i = 0; i < _MaxDepth; ++i)
        {
            rayMarchingStep *= 1.02f;	// 速率逐漸變大
    
            currDistance += rayMarchingStep;
            if(currDistance > maxDistance) break;
    
            // 步進後新的位置
            currPos += viewDir * rayMarchingStep;
            float shadow = GetShadow(currPos);
            // 求當前pixel的陰影值
            totalLight += _Brightness * shadow * scatterFun * GetTransmittance(rayMarchingStep);
        }
        
        half3 result = totalLight * mainLight.color * lerp(_NightColor.rgb * _NightColor.a, _DayColor.rgb * _DayColor.a, saturate(mainLightDir.y));
        
        return result;
    }
    
  • 效果


    16次迴圈即可得到不錯的效果

效能最佳化

  • 為了得到一個能在移動端跑的體積光,還需進行一點效能最佳化,如何做呢?

    • 光的變化頻率不高,也就是說如果進行部分clip,也不會很容易被識別出來,這裡採用棋盤格重新整理的方式來更新

    • 降低計算光PASS的RT

  • 實現

    很簡單,在ps中clip即可

    clip(channel.y%2 * channel.x%2 + (channel.y+1)%2 * (channel.x+1)%2 - 0.1f);
    

    在RenderFeature降低RT解析度

    RTID.targetBuffer = renderingData.cameraData.renderer.cameraColorTarget;
    
    RenderTextureDescriptor descriptor = renderingData.cameraData.cameraTargetDescriptor;
    descriptor.width /= m_passSetting.m_downsample;
    descriptor.height /= m_passSetting.m_downsample;
    
    cmd.GetTemporaryRT(ShaderIDs.tempBufferID, descriptor, FilterMode.Bilinear);
    cmd.Blit(RTID.targetBuffer, ShaderIDs.tempBufferID);    // 將原RT複製給_SourceTex
    

最終效果

  • 下圖有少許黑點應該是gif錄製軟體的問題

Reference

遊戲開發相關實時渲染技術之體積光

Unity Shader - 根據片段深度重建片段的世界座標

Unity URP管線實現超簡單RayMarching體積光(3)

[Rendering] 基於物理的大氣渲染

URP管線下的高效能移動端體積光

相關文章