0. 例行的啰嗦
上篇关于技术的文章已经是10月底的事情了,上个月写了半篇关于创业的文章,写到后来觉得无趣,就停下了——谁会愿意看一个无名小卒的无聊感慨呢?又是临近月底了,其实可写的内容不少,只是时间不多,就选最近花了大约半个星期时间搞的这个优化来聊聊Fast Shadow Receiver这个插件的使用吧。
1. 起因
关于Unity中的动态阴影,已经有挺多帖子聊过这个话题了,比如这篇《Unity移动端动态阴影总结》,还有钱康来博客里的这篇《利用Projector实现动态阴影》和《Planar Shadow》等等。
无论是最简单的基于Planar投影的方案还是稍微“老式”一些的Projector的方案,乃至目前比较主流的ShadowMap的方案其实都各有优劣和对应的应用场景,它们之间的原理和差异不是本文的重点,有兴趣的同学也可以很容易地找到相关的论文或者博客来看。
我们项目本着不要重复造轮子的想法,一直坚持使用Unity原生的ShadowMap的方案来做动态阴影。而且UWA也做过一些动态阴影方案效率的对比,自己的轮子能做得比有源码的官方好的并不多,更何况我们这种地表有起伏,高配需要支持多角色动态阴影的“大型”MMORPG游戏,ShadowMap已经是最适合的方案了。
然而!人生总会有然而,否则就太平淡无味了不是?……
大约1个多月前,我发现了这个问题——《Unity中静态合批与Shadowmap的宏设置冲突问题》,简单来说,静态合批首先对场景物体进行了排序,保证结果正确,但是当引入了动态阴影之后,会去修改物体接受阴影的宏(这也是一种优化,因为有采样和阴影计算的消耗,所以关闭掉宏着色器的效率更高),导致原本排序好的物体无法正常进行合批,因为着色器的宏不一样了,从而导致之前静态合批之后理论上可以做到很低的Batch数值增加了很多,使得场景渲染的效率大幅下降。
这个问题在想清楚原因之后,在依然想要使用Unity的ShadowMap的前提下感觉是没有什么特别简单的优化方案的,于是就暂时搁置下来,直到上周的时候对游戏各个效果对于帧率的影响在真机上做了一个定量的测试之后,才发现问题远比想象中的严重……
上面的测试是在中配机型小米Max2上进行的,可以看出阴影的开关与否导致一帧的时间消耗有9.5ms左右的差异,是所有效果中影响最大的!而ShadowMap自身渲染消耗不应该有这么大的差异才对,观察了下Batch数量的差异,单纯场景的Batch数量大约会从25增加到150左右,这有点超出我们之前制定的美术规范了。
在中配效果下,我们只有主角自己开启了动态阴影,因此最初的一个想法就是引入另外一套阴影绘制方案,比如Dynamic Shadow Projector,来专门针对主角进行阴影的绘制。虽然我个人很不喜欢同时使用两套技术方案,但目前看起来这似乎是在不降低效果的前提下唯一的选择了。
2. Dynamic Shadow Projector插件
This simple Unity asset provides a few components to render a shadow onto a render texture so that the render texture can be used with Blob Shadow Projector. Blob Shadow Projector is usually used for dropping a round blurry shadow which is not suitable for a skinned mesh object. This asset enables a projector to drop a dynamic shadow which is perfect for skinned mesh objects.
Dynamic Shadow Projector插件的原理比较简单,将角色的阴影绘制到一张rt上,然后使用Unity的Projector组件将这张rt作为绘制输入,再绘制一遍接受阴影的物体。阴影的rt是每帧更新的,也就做到了可以让带有动画的角色阴影是实时变化的。
试用了一下,还是比较简单易上手的,几个组件正确设置之后就可以看到效果了,由于是针对单个角色的,因此使用比较小的rt就可以做到比shadowmap更加精细的效果,但是如果想让一个projector处理多个角色,一旦扩大projector的范围,阴影效果质量的下降就比shadowmap的方法还要厉害。
上面两张图分别给出了模拟使用一个Projector针对单个角色进行投影和多个角色进行投影的效果对比图,在下面的那张图中,三个Cube的距离相隔并不远,但是即使使用了512*512的rt,明显可以看到其阴影已经有了锯齿感,距离更大的时候锯齿更加严重。
那我为什么纠结于一定想要使用一个Projector来进行多个角色的动态阴影绘制呢?因为对于每一个Projector来说,绘制阴影的时候都需要把接受阴影的模型完整重回一遍,从下面抓帧的截图可以看出,三个Cube分别使用三个不同的Projector,地表平面需要绘制三遍。这其实就是Projector的方法不太适合移动设备上多个物体都需要进行动态阴影绘制的原因。
我们的地表使用了Terrain制作,转为Mesh之后的三角形数量一般在大几千的水平,多遍绘制对于整体面数的增加还是很可观的,虽然在我们的中配下只有主角接受动态阴影,只需要多一遍地表模型的绘制,拿一次Draw Call和几千面的消耗换取100+次Batch的减少,理论上已经够划算了,但是我还有些不太甘心,于是想尝试下Dynamic Shadow Projector推荐配合“服用”的Fast Shadow Receiver插件。
3. Fast Shadow Receiver的试用
Fast Shadow Receiver插件是很久前我就关注过的一个插件,钱康来在他的博客里也有提到。我一直保持一个敬而远之的心态,一是因为从经验上来说ShadowMap没有接受阴影方需要重绘的问题,只是宏的改变,效率应该挺高的(没想到影响了Static Batching);二是对于运行时对mesh进行暴力重建一直心存怀疑,担心其对于CPU和内存的额外压力。
购买了插件,将其引入我自己本地的项目工程,玩了玩Demo之后,尝试将其和Dynamic Shadow Projector结合一起使用。和AssetStore上对于这个插件的评论一样,这个插件的文档的确有些晦涩,大约玩了三四个小时的时间才正式在游戏中跑通整个流程,过程不详述了,几个小坑记录一下:
- 可能是官方被吐槽文档太难读,所以做了一套Wizard,一步步走教你怎么配置,然而我按照步骤做完之后并没有得到正确的结果,反而因为Wizard隐藏了背后的部分设置步骤导致我无法正确理解过程,从而难以排查原因。而且Wizard是针对特定的需求,未必是我自己想要的效果。最终我还是按照Demo工程里的组件逐个对照配置实现的效果。
- LayerMask设定需要注意,为了优化效率,Projector组件上有Igore Layers的设定,在Draw Target Object上,也有Layer Mask的设定用于标识要绘制的节点下哪些Layer会被绘制,最终的ShadowReceiver组件也会属于某一个Layer,比如默认的Default。这几个Layer如果设定有问题,会导致最终没有影子被绘制出来。我因为这里的失误多花了1个小时的时间调试各种参数,如果你在使用中遇到了奇怪的问题,可以把自己设置的各种Layer梳理一遍,保证逻辑上的正确性。我当时的问题之一是把ShadowReceiver所在的GameObject归入到了Default Layer,而Projector又Igore掉了Default Layer,导致结果不正确。
- Fast Shadow Receiver的插件制作者估计没有经受过中国美术的洗礼,除了文档晦涩之外,代码中对于容错的兼容考虑得也不周全……我们场景中有几千个物体,在最初测试的时候没有花心思标记所有的地表接受阴影的物体,索性将所有物体都进行标注,结果MeshTree的生成一直存在问题,查了下是因为我们场景中存在一个Mesh对象为miss状态的GameObject导致的,做一下兼容就好了,当然根本上也要美术去修复掉mesh miss的问题。
总之,经过一系列的尝试,最终在我们自己的工程内使用正式的美术资源跑通了整个流程,也对于Fast Shadow Receiver的原理有了更深的理解:它使用Mesh Tree这样一个继承自Scriptable的类在离线阶段来预计算并存储需要接受阴影的地表网格信息,并且提供BinaryMeshTree、OctMeshTree和TerrainMeshTree三种类型来应对不同的场景。运行时,它提供MeshShadowReceiver这样的组件,根据Projector的设定实时计算出来接受阴影的地方需要覆盖的那些面片,生成一个新的网格作为阴影接收者的网格对象进行渲染,从而做到可以将原本几千面的模型只需要几十个面就可以绘制出来,因为毕竟需要绘制动态阴影的只有镜头前的部分区域。
4. 和ShadowMap的结合以及集成
在最初的设想中是针对单独的主角使用Projector方式的动态阴影,然后用Fast Shadow Receiver进行优化,在Demo中看到Fast Shadow Receiver支持ShadowMap的方案时也没有多想。后来在和同事讨论这个问题的时候聊到Projector的动态阴影方案和ShadowMap的动态阴影方案的优劣,被问到两种方案是不是有可能做一个结合,然后想起了在Demo中看到了使用Fast Shadow Receiver来优化ShadowMap的例子。正好也在纠结我们抽离式的战斗中在中等配置下的效果,如果使用Projector,需要多几张rt的绘制是否合算,那如果可以用Fast Shadow Receiver结合之前的Shadow Map方案,对于目前结构的改动是最小的,也不必引入第二套动态阴影的产生方案,只相当于用新的插件在中配下解决场景静态合批的问题,这似乎是非常理想的一个方案。
沿着这个思路,学习了一下Fast Shadow Receiver中关于ShadowMap的例子,看上去也非常简单。在理解了原理的情况下,只是让场景内的其他Render组件的Receive Shadow属性都更改为false,然后只让Fast Shadow Receiver生成的那样一个面片读取生成的ShadowMap进行阴影的绘制即可,这样额外增加1个Draw Call和几十个面的渲染消耗,就可以做到和之前相似的效果,中高配置的切换逻辑也更加简洁。
我们先来看一下最后经过修改敲定下来的制作步骤,然后再聊一些其中的设计细节。
-
统一将场景中的Mesh相关的组件放置到同一个GameObject下。这一条原本没有一条硬性的规定,完全看场编同学自觉,其实整理之后Unity中的Hierarchy面板也会更加干净整洁;
-
标记接受阴影的物体。这一步是一个有点琐碎的工作,需要美术标记出来哪些物体是接收阴影的,BinaryMeshTree是根据这些标记出来的物体来进行网格的预处理的。标记的物体过少会出现应当接受阴影的物体没有阴影效果,而过多会导致BinaryMeshTree的数据内容过多,加载变慢、检索速度降低,内存占用也会很多。由于我们目前只在中配下使用,所以对于这部分只要求地表和表现明显的物体加入到标记中。Fast Shadow Receiver只支持Layer和RenderType的过滤方式,在我们场景中有些物体已经被标记过了其他有逻辑意义的Layer,因此我针对这点进行了改造,增加了Tag的过滤,和Mask Layer取或的方式来进行处理,并且为美术提供了方便的快捷键进行快速标注。我自己测试,我们游戏内的场景,标注加上验证需要的耗时大约也就半个小时到2个小时不等。
-
创建BinaryMeshTree。我们最终选择使用BinaryMeshTree这种结构,它和OctMeshTree的区别见下图。其实这个步骤还需要更多的测试来做对比,因为官方也明说small和large的界限具体是什么。
创建BinaryMeshTree的过程也很简单,插件提供了右键Create菜单的支持:
-
生成Mesh Tree。在标注完接收阴影的物体之后,就可以选中创建好的BinaryMeshTree,填写其Root Object为场景的根节点,设置好Layer进行build。我们建议美术检查最后创建完毕之后给出的build信息中对于内存的占用要小于2M,这是一个编辑几个场景之后的经验值而已,还需要更多验证。
-
配置Projector和Mesh Tree信息。这部分为了简化美术的配置工作,大部分的配置逻辑都写在了代码中,只需要美术复制一份prefab出来,将新创建的Mesh Tree信息设置正确即可。需要注意这份prefab是不保留在场景内的,编辑完毕Apply后会从场景中删除掉。
这里一共只使用了两个组件,一个是图中LightProjector对象上的LightProjector组件,用于设置阴影使用的方向光对象以及一些Projector的参数,比如跟随的Target对象,扩展的Bound范围等;另外一个是MeshShadowReceiver组件,关联Mesh Tree数据,场景渲染物体的根节点和Projecter对象,一些Fast Shadow Receiver的裁剪、更新方式等属性也可以在这里进行设置。 -
在资源根节点上添加Shadow Receiver Controller组件,并进行配置。这一组件是我们自己实现的,用于控制Fast Shadow Receiver的开关,它会根据游戏配置在场景加载、游戏配置切换等逻辑中对Fast Shadow Receiver进行设置。并且基于这一组件实现对于Mesh Tree的懒加载功能。
在游戏运行状态下进行测试。上述配置完毕之后,就可以在游戏逻辑的中等配置下看到优化后的阴影效果了,可以跑跑游戏进行测试。
大部分细节已经在上述步骤中描述了,这里再说明以下几个地方:
a) Projector和MeshShadowReceiver组件是不默认放在场景里的。这是由于当地表物体较多的时候,Mesh Tree的加载是有时间消耗的(遇到过一个测试例子,Mesh Tree的大小有18M左右,在PC上需要5s以上的情况,具体原因没有细查),也会有额外的内存消耗,因此这里一方面建议美术确保这个文件不会特别大,另一方面通过Lazy Load的方式,在需要的时候才加载,来保证在高配和低配的情况下,不需要任何额外的CPU和内存开销。
b) 为美术提供更多便利的工具来标记信息。由于标记地表是一个相对琐碎的工作,验证标记是否合理也是一个件需要花费很多时间和精力的事情,除了前面提到的快捷键可以一键标注,还推荐通过Layer的显隐功能,以及我们自己开发的Tag显隐功能进行快速检查和问题定位。
5. 优化结果和代价
使用同样的测试方式,对比优化前后的游戏运行帧率和时间消耗:
可以看到,使用Fast Shadow Receiver在小米 Max2上有大约7.2ms的性能提升,帧率从26上升到33,这其中有Batch数量降低的功劳,应该也有场景物体不需要采样ShadowMap贴图带来的渲染性能提升,更加具体的数据就没有去测试了。剩余的1.5ms的时间消耗包括了ShadowMap的绘制以及Fast Shadow Receiver的更新消耗,这是后续的优化对象,但这次优化已经有很大的提升了,中配下整体效率提升了20%,已经是难得的“神级优化”了。当然,这建立在场景通过关闭Shadow接收的宏能够降低较大Batch数量的前提下。
这次优化的收益是很大的,但它也不全是一种无损优化,需要付出的代价有这么几点:
- 美术工作量。需要美术同学针对场景进行地表接收阴影物体的标注,虽然提供了快捷的工具,但是依然需要花费一些时间成本。
- 部分物体不再会受到动态阴影的影响。在之前基于ShadowMap的方案中,几乎所有的物体都可以标记为接收阴影,而且可以保证效果的正确性,但是目前这种方案如果要做到这点会导致Mesh Tree对于内存的占用较多,对于外部的大世界场景也不适应,因此会有出现一些小石头等物体不会接收角色阴影的问题,这是一些效果的降低,但目前看是可以接受的范围内。
-
和静态阴影的融合与ShadowMap的方案不同。ShadowMap的方案是在场景绘制的时候进行处理的,一次像素着色的过程中会采样lightmap和shadowmap两张贴图,这就可以判断出该像素点是否在静态阴影之中,这样可以做到比如在屋檐下或者树荫下这样的静态阴影中,角色的实时阴影可以和静态阴影做一个较好的融合,如下图所示。
而使用Fast Shadow Receiver方案之后,就比较难做融合的效果,除非在新生成的mesh中保存之前mesh的uv2信息以及使用的lightmap贴图信息,再做一次lightmap的采样。但这比较麻烦,性价比也不高,于是在静态隐形中的角色动态阴影的效果就变成了如下图所示的样子。
除了这些之外的代价就是程序这边花费了大约半个多星期的时间来学习和集成这套方案,但是从优化结果上看,还是收获很大,非常值得的~
6. 一个Projector同时处理多个角色的动态阴影
由于我们是类似回合制的抽离式战斗方式,即玩家进入战斗后整场战斗都会发生在一小块固定区域内,这里其实对于ShadowMap结合Fast Shadow Receiver的方案是一个非常合适的应用场景——只需要在进入战斗前生成一次阴影接收的面片,整场战斗中都不需要对其进行修改和变动!
我们将LightProjector的Target锁定为战斗的中心区域点,然后通过修改Bound的方式扩大其投射范围到整个战场。前面已经讨论过基于Projector的动态阴影方案的一个问题是当projector较大的时候rt的使用率较低,导致阴影质量骤降的问题,但因为我们使用的是ShadowMap的阴影方案,因此扩大Projector的范围并不会影响阴影精度,也不需要处理多个Projector带来rt数量、draw call增加等问题。
7. 总结和展望
Fast Shadow Receiver这种通过CPU的实时计算来换取GPU的渲染性能的方案,正好解决了我们场景静态合批被动态阴影打断的问题,大大提升了我们游戏在中配下的帧率,是近期所做的优化中效果最为显著的一个了,因此也记录一下详细的过程在这里分享出来。
对于这个插件的感觉,在这一周的逐渐熟悉、应用、修改的过程中,也从心存怀疑到由衷赞叹。目前针对这个插件的魔改还不多,除了前面提到的增加Tag的支持、建立Mesh Tree的时候缺少一些对于资源的错误兼容之外,只修改了部分Component的默认参数,更加适合我们项目的设定,让美术和程序可以更加方便地使用。它在运行时对于内存的分配和CPU的性能消耗也让我们满意,因此在这里也帮这个插件做一下广告——别被它的文档和使用过程吓到,用好之后,你的游戏效率可以获得很大的提升~
至于未来,当中配下的效果和效率都被验证可以接受之后,可能考虑优化一些它的效果,将它也应用到高配下,当然,对于贴花等需要处理高低不平地面效果的地方,也可以考虑使用这个插件进行效率的优化。
PS:从Fast Shadow Receiver的启发来思考场景静态合批被打断的问题,其实另外一个思路是自己来做哪些物体需要被接受阴影的判断。Unity内部肯定也是有这样的判定逻辑来设置各个场景Render的宏,由于Shadow的距离设定较大,Unity的判定范围也过广,导致了虽然我们在中配下只有角色渲染阴影,但是接收阴影的物体数量过多,从而导致Batch被频繁打断的问题。仿照Fast Shadow Receiver,使用一个跟随角色的投影,和场景物体相交来判断有哪些物体需要被设置为接收阴影,由于角色脚下的物体可能只会有几个,因此Batch的数量也只会增加几个。目前没有沿着这个思路来做的原因之一也是地表物体的面数实在是有点多,Fast Shadow Receiver对于面数的降低也是我们想要的优化之一。
2017年12月24日于杭州家中(圣诞快乐~ _)