Unity如何实现投影阴影效果

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和开发者在一起!