Unity如何實現投影陰影效果

這是侑虎科技第442篇文章,感謝作者謝劉建供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣465082844)

作者主頁:https://zhuanlan.zhihu.com/p/42433900
作者也是U Sparkle活動參與者,UWA歡迎更多開發朋友加入U Sparkle開發者計劃,這個舞臺有你更精彩!


前言

Unity引擎自帶的陰影功能是效果較好的ShadowMap, 本文介紹陰影的另外一種實現,使用投影器來生成陰影。下面是Demo運行時候的視頻。查看高清視頻請點擊此處。


一、功能實現

1. 關閉主光源的陰影投射

請輸入圖片描述

如上圖所示,使用投影陰影的時候,應該關閉主光源投射陰影。

 

2. 設置投影器
如圖所示,添加一個Projector組件,然後調整Projector的GameObject的方向。

 

請輸入圖片描述

 

3. 核心代碼編寫
如上圖所示,編寫ProjectorShadow腳本

3.1 首先創建一個RenderTexture

        // 創建render texture
        mShadowRT = new RenderTexture(mRenderTexSize, mRenderTexSize, 0, RenderTextureFormat.R8);
        mShadowRT.name = "ShadowRT";
        mShadowRT.antiAliasing = 1;   // 關閉抗鋸齒
        mShadowRT.filterMode = FilterMode.Bilinear;
        mShadowRT.wrapMode = TextureWrapMode.Clamp;     // wrapmode要設置爲Clamp

注意首先這個RenderTexture的格式是R8, 這個格式創建的貼圖內存佔用是最小的。

在運行時查看貼圖:

請輸入圖片描述

 

對於創建2048x2048的貼圖,只有4MB的內存。然後AntiAliasing設置爲1, 也就是不開抗鋸齒。WrapMode設置爲Clamp。

最後運行時的參數如下圖所示,對於圖中的Depth Buffer, 雖然代碼沒有設置,但是默認是關閉的,這種投影陰影創建的RenderTexture不需要使用DepthBuffer, 所以應該關閉的。

請輸入圖片描述

 

3.2 設置Projector

        //projector初始化
        mProjector = GetComponent<Projector>();
        mProjector.orthographic = true;
        mProjector.orthographicSize = mProjectorSize;
        mProjector.ignoreLayers = mLayerIgnoreReceiver;
        mProjector.material.SetTexture("_ShadowTex", mShadowRT);

這裏主要是把投影器設置爲正投影。同時設置投影器的尺寸,並設置投影器的忽略層,如下圖所示:

請輸入圖片描述

投影器尺寸設置爲23,忽略層是Unit, 也就是遊戲中創建的所有的單位。

3.3 創建投影Camera

         //camera初始化
        mShadowCam = gameObject.AddComponent<Camera>();
        mShadowCam.clearFlags = CameraClearFlags.Color;
        mShadowCam.backgroundColor = Color.black;
        mShadowCam.orthographic = true;
        mShadowCam.orthographicSize = mProjectorSize;
        mShadowCam.depth = -100.0f;
        mShadowCam.nearClipPlane = mProjector.nearClipPlane;
        mShadowCam.farClipPlane = mProjector.farClipPlane;
        mShadowCam.targetTexture = mShadowRT;

創建的Camera的ClearFlags 設置爲清理顏色;
Camera的清理顏色BackgroundColor 設置爲黑色;
Camera也應該是正投影的, 同時正投影尺寸也應該和Projector的尺寸一致;
Camera的Depth設置爲-100, 也就是比主攝像機提前渲染;
Camera的近裁剪面和遠裁剪面設置的和投影器的近裁剪面和遠裁剪面一致;
Camera的TargetTexture設置爲創建的RenderTexture, 也就是說,攝像機渲染所有的對象到這張RenderTexture上。

3.4 渲染方式選擇

這裏感覺是本文的重點了。參考好幾篇文章,最後總結了2種方式,其中使用CommandBuffer的方式本人認爲更適合實際項目,可以提高渲染效率。

首先看一下代碼實現:

 private void SwitchCommandBuffer()
    {
        Shader replaceshader = Shader.Find("ProjectorShadow/ShadowCaster");

        if (!mUseCommandBuf)
        {
            mShadowCam.cullingMask = mLayerCaster;

            mShadowCam.SetReplacementShader(replaceshader, "RenderType");
        }
        else
        {
            mShadowCam.cullingMask = 0;

            mShadowCam.RemoveAllCommandBuffers();
            if (mCommandBuf != null)
            {
                mCommandBuf.Dispose();
                mCommandBuf = null;
            }
            
            mCommandBuf = new CommandBuffer();
            mShadowCam.AddCommandBuffer(CameraEvent.BeforeImageEffectsOpaque, mCommandBuf);

            if (mReplaceMat == null)
            {
                mReplaceMat = new Material(replaceshader);
                mReplaceMat.hideFlags = HideFlags.HideAndDontSave;
            }
        }
    }

3.4.1 對於不使用CommandBuffer的情況下,主要是下面2行代碼

mShadowCam.cullingMask = mLayerCaster;

mShadowCam.SetReplacementShader(replaceshader, "RenderType");

設置Camera應該渲染那些層的GameObject,同時Camera渲染可以使用哪個Shader來替換。

如下圖所示,Camera只渲染所有創建的Unit。

請輸入圖片描述

 

對於Camera使用的Shader, 可以用一個普通頂點/片元Shader來處理。

Shader "ProjectorShadow/ShadowCaster"
{
    Properties
    {
        _ShadowColor("Main Color", COLOR) = (1, 1, 1, 1)
    }
    
    SubShader
    {
        Tags{ "RenderType" = "Opaque" "Queue" = "Geometry" }

        Pass
        {
            ZWrite Off
            Cull Off

            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag
            
            struct v2f
            {
                float4 pos : POSITION;
            };
            
            v2f vert(float4 vertex:POSITION)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(vertex);
                return o;
            }

            float4 frag(v2f i) :SV_TARGET
            {
                return 1;
            }
            
            ENDCG
        }
    }
}

這個Shader就是輸出白色,同時關閉寫入深度,不使用裁剪。

3.4.2 對於使用CommandBuffer的情況,主要是如下的代碼

           mShadowCam.cullingMask = 0;

            mShadowCam.RemoveAllCommandBuffers();
            if (mCommandBuf != null)
            {
                mCommandBuf.Dispose();
                mCommandBuf = null;
            }
            
            mCommandBuf = new CommandBuffer();
            mShadowCam.AddCommandBuffer(CameraEvent.BeforeImageEffectsOpaque, mCommandBuf);

            if (mReplaceMat == null)
            {
                mReplaceMat = new Material(replaceshader);
                mReplaceMat.hideFlags = HideFlags.HideAndDontSave;
            }

Camera的CullingMask 設置爲0,也就是Camera不會渲染任何物體,所有的渲染走CommandBuffer。然後創建CommandBuffer, 添加到Camera的CommandBuffer列表中。

創建CommandBuffer渲染需要的Material, Material需要用到的Shader就是上面的"ProjectorShadow/ShadowCaster"。

在每幀刷新的時候:

     private void FillCommandBuffer()
    {
        mCommandBuf.Clear();

        Plane[] camfrustum = GeometryUtility.CalculateFrustumPlanes(mShadowCam);

        List<GameObject> listgo = UnitManager.Instance.UnitList;
        foreach (var go in listgo)
        {
            if (go == null)
                continue;

            Collider collider = go.GetComponentInChildren<Collider>();
            if (collider == null)
                continue;

            bool bound = GeometryUtility.TestPlanesAABB(camfrustum, collider.bounds);
            if (!bound)
                continue;

            Renderer[] renderlist = go.GetComponentsInChildren<Renderer>();
            if (renderlist.Length <= 0)
                continue;

            // 是否有可見的render
            // 有可見的則整個GameObject都渲染
            bool hasvis = false;
            foreach (var render in renderlist)
            {
                if (render == null)
                    continue;

                RenderVis rendervis = render.GetComponent<RenderVis>();
                if (rendervis == null)
                    continue;

                if (rendervis.IsVisible)
                {
                    hasvis = true;
                    break;
                }
            }

            foreach(var render in renderlist)
            {
                if (render == null)
                    continue;

                mCommandBuf.DrawRenderer(render, mReplaceMat);
            }           
        }
    }

遍歷遊戲中所有創建的單位,首先通過視錐體剔除,剔除投影Camera看不到的Unit, 主要是下面兩行代碼:

Plane[] camfrustum = GeometryUtility.CalculateFrustumPlanes(mShadowCam);

bool bound = GeometryUtility.TestPlanesAABB(camfrustum, collider.bounds);

計算得到投影Camera的視錐體, 然後通過函數,判斷單位的Collider是否在視錐體範圍內。這樣就可以篩選出當前幀攝像機可以看到的Unit。

接着進行下面的判斷:

            Renderer[] renderlist = go.GetComponentsInChildren<Renderer>();
            if (renderlist.Length <= 0)
                continue;

            // 是否有可見的render
            // 有可見的則整個GameObject都渲染
            bool hasvis = false;
            foreach (var render in renderlist)
            {
                if (render == null)
                    continue;

                RenderVis rendervis = render.GetComponent<RenderVis>();
                if (rendervis == null)
                    continue;

                if (rendervis.IsVisible)
                {
                    hasvis = true;
                    break;
                }
            }

對於在視錐體內的Unit, 遍歷它所有的Render, 判斷Render是否可以,只有當這個Unit有一個Render可見的情況下,然後渲染這個單位(這裏爲什麼不根據Render是否可見,單獨渲染每個Render, 主要是因爲我們希望渲染的Unit是完整的,不想Unit是部份被渲染出來的。要麼整個渲染出來,要麼就是不渲染)。

那麼問題來了,Unit什麼時候可見,什麼時候不可見,我們是怎麼知道的。可以看下下面的代碼片段。

    private bool mIsVisible = false;

    public bool IsVisible
    {
        get { return mIsVisible; }
    }

    void OnBecameVisible()
    {
        mIsVisible = true;
    }

    void OnBecameInvisible()
    {
        mIsVisible = false;
    }

每個Render下面都會掛這個腳本,當這個Render被攝像機看見,Unity引擎就會調用OnBecameVisible函數,當這個Render攝像機不可見,就會調用OnBecameInvisible函數。

目前在這個Demo中,在投影Camera使用CommandBuffer的情況下,Camera是不渲染任何物體的,只有Main Camera會渲染所有的Render, 所以就可以理解爲當Visible可見的時候,這個Render就出現在屏幕上,當Visible不可見的時候,這個Render在屏幕上不可見。

總結一下,在每幀刷新的時候,首先通過投影Camera篩選出需要的投影Camera能夠渲染的Unit, 然後判斷這個對象是否也同時被Main Camera可見。都滿足的情況下,再使用mCommandBuf.DrawRenderer(render, mReplaceMat);函數來渲染對象到創建的RenderTexture中。

3.5 投影器Shader是怎麼實現的?

投影Shader其實是一個陰影接收Shader, 具體實現如下所示:

            ZWrite Off
            ColorMask RGB
            Blend DstColor Zero
            Offset -1, -1

            v2f vert(float4 vertex:POSITION)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(vertex);
                o.sproj = mul(unity_Projector, vertex);
                UNITY_TRANSFER_FOG(o,o.pos);
                return o;
            }

            float4 frag(v2f i):SV_TARGET
            {
                half4 shadowCol = tex2Dproj(_ShadowTex, UNITY_PROJ_COORD(i.sproj));
                half maskCol = tex2Dproj(_FalloffTex, UNITY_PROJ_COORD(i.sproj)).r;
                half a = shadowCol.r * maskCol;
                float c = 1.0 - _Intensity * a;

                UNITY_APPLY_FOG_COLOR(i.fogCoord, c, fixed4(1,1,1,1));

                return c;
            }

在Vert中,計算出投影位置 o.sproj = mul(unity_Projector, vertex);在Frag中,通過UNITY_PROJ_COORD(i.sproj)計算出投影紋理座標。然後混合出最終的顏色。

如下圖所示:

請輸入圖片描述


添加了一張Mask圖,通過這張Mask圖,可以把陰影邊緣處理的比較好,陰影邊緣出現會有淡入淡出的效果。

4. 運行遊戲
效果圖如下所示,同一視角下,切換是否使用CommandBuffer方式渲染,在同樣的效果下,使用CommandBuffer的方式使用的Batch更好,性能相應的也就更好。(上圖是不使用CommandBuf, 下圖使用CommandBuf)

請輸入圖片描述
不使用CommandBuf渲染方式

 

 

請輸入圖片描述
使用CommandBuf渲染方式

 


二、項目Demo地址

https://github.com/xieliujian/UnityDemo_ProjectorShadow


三、參考文檔

主要參考了下面兩篇文章

1. Unity3D手遊開發日記(1) - 移動平臺實時陰影方案

對於這一篇文章,我想說文章作者已經把這種陰影方案的技術點和需要注意點總結的很好了。對於本文可能沒有講清楚的問題,可以參考這篇文章。

2. 利用Projector實現動態陰影

對於第二篇文章,主要介紹了工作中經驗和修改Dynamic Shadow Projector插件來實現這種陰影效果,也是值得借鑑思想的。

文末,再次感謝謝劉建的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:465082844)。
也歡迎大家來積極參與U Sparkle開發者計劃,簡稱"US",代表你和我,代表UWA和開發者在一起!

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