Unity 体积光

思路

  • 观察下面这副图可以发现,在明亮处光很明显,暗处(阴影中)没有明显的光,且越暗光越不明显

  • 为了还原这一现象,可以想到的是根据目标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管线下的高性能移动端体积光

热门相关:诱惑:朋友的妈妈   女神的味道   恶爷粤语   情侠追风剑国语   吉人天相国语