Unity的投影阴影

Unity的投影阴影

前言

Unity引擎是自带阴影的,是效果较好的ShadowMap, 但是在用Unity开发大型手游的时候,一般不会使用Unity自带的影子,主要是效率问题,会导致帧率下降明显。为了在手机上角色也能有阴影效果,可以采用投影器阴影,兼顾效率和效果,参数调的好的话,也能有不错的效果。下面是Demo运行时候的视频。

https://www.zhihu.com/video/1014626947216781312

功能实现

  • 关闭主光源的投影投射

如上图所示,使用投影阴影的时候,应该关闭主光源投射阴影。

  • 设置投影器

如图所示,添加一个Projector组件,然后调整Projector的GameObject的方向

  • 核心代码编写

如上图所示,编写ProjectorShadow脚本

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的贴图,只有4M的内存。

然后antiAliasing设置为1, 也就是不开抗锯齿。

wrapMode设置为Clamp

最后运行是的参数如下图所示

对于图中的Depth Buffer, 虽然代码没有设置,但是默认是关闭的,这种投影阴影创建的RenderTexture不需要使用DepthBuffer, 所以应该关闭的。

2.设置Projector

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

这里主要是把投影器设置为正投影。同时设置投影器的尺寸,并设置投影器的忽略层,如下图所示

投影器尺寸设置为23,忽略层是Unit, 也就是游戏中创建的所有的单位。

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上。

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;
            }
        }
    }

a. 对于不使用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就是输出白色,同时关闭写入深度,不使用裁剪

b. 对于使用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中。

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图,可以把阴影边缘处理的比较好,阴影边缘出现会有淡入淡出的效果。

  • 运行游戏

效果图如下所示,同一视角下,切换是否使用CommandBuffer方式渲染,在同样的效果下,使用CommandBuffer的方式使用的Batch更好,性能相应的也就更好。(上图是不使用CommandBuf, 下图使用CommandBuf)

不使用CommandBuf渲染方式
使用CommandBuf渲染方式

项目Demo地址

xieliujian/UnityDemo_ProjectorShadowgithub.com图标

参考文档

主要参考了下面两篇文章

Unity3D手游开发日记(1) - 移动平台实时阴影方案blog.csdn.net图标

对于这一篇文章,我想说文章作者已经把这种阴影方案的技术点和需要注意点总结的很好了。对于本文可能没有讲清楚的问题,可以参考这篇文章。

http://qiankanglai.me/2016/11/14/unity-projector/qiankanglai.me

对于第二篇文章,主要介绍了工作中经验。和修改

Dynamic Shadow Projector插件来实现这种阴影效果。也是值得借鉴思想的。

编辑于 2018-08-22

文章被以下专栏收录