rustum 发表于 2021-4-15 09:26

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地址

参考文档

主要参考了下面两篇文章
对于这一篇文章,我想说文章作者已经把这种阴影方案的技术点和需要注意点总结的很好了。对于本文可能没有讲清楚的问题,可以参考这篇文章。
对于第二篇文章,主要介绍了工作中经验。和修改
Dynamic Shadow Projector插件来实现这种阴影效果。也是值得借鉴思想的。

Doris232 发表于 2021-4-15 09:36

然后很完美地发现同质量下GPU没有可见区别,CPU上升了。
两种模式阴影精度和范围通常都没有调平,会有产生优化的幻视。

IT圈老男孩1 发表于 2021-4-15 09:42

1.这么多人投影这种方案效率明显低于ShadowMap,很明显地形多画了一遍,自投影也解决不了,14/15年用还能理解,毕竟就主角一个人有影子,R32低端机格式也许很多设备不支持,现在完全没有必要再用了。2.CommandBuffer批次小于直接用Camera渲染是因为Cmera视椎体没有Fit to view,区域太大导致的,做一个投影相机范围适配即可。其次用自带的相机会帮你处理合批裁剪,你用主相机的可见性判断,如果有一个房子投影到视椎体内的物体上,但是不在视椎体内怎么办?,其次也更好的兼容本身的多线程渲染框架。CommandBuffer在底层走的是自己的一套系统,实际测试真机上Bug较多,尤其是一些奇葩安卓机,比如常见的一个问题是会把主DepthBuffer清空。

BlaXuan 发表于 2021-4-15 09:49

作为第一篇参考文章的作者,我想说这个技术其实效率和基于深度的shadowmap性能差不多...这个几年前的技术,早已经被我抛弃了.还是用基于深度的shadowmap好,至于移动平台的浮点纹理问题,可以把0到1的32位浮点压缩到RGBA32.

XGundam05 发表于 2021-4-15 09:51

这么看着太费劲了,楼主不如发个demo吧。

mastertravels77 发表于 2021-4-15 09:54

有Demo啊

ChuanXin 发表于 2021-4-15 09:55

就是把做的时候认为需要注意的地方写个文档记录一下而已。

Zephus 发表于 2021-4-15 10:01

我们现在新项目主要就两种做法,一个planar shadow还有就是直接上shadowmap了,不过最近在考虑自己重头写一遍....

pc8888888 发表于 2021-4-15 10:05

嗯,写这边文章的时候看了你写的文章的。感觉未来手游还是会用自带的Shadowmap, 这种感觉都是过渡技术。

kyuskoj 发表于 2021-4-15 10:10

恩,性能上余量大了就不用折腾了
页: [1] 2 3
查看完整版本: Unity的投影阴影