原文在此,下为译文
在我上篇博客——创建森林的美术建议中,我指出了创建3D森林的一些要点。那里我主要强调的是美术建议,资源应该有什么、如何布置。森林一个重要的事情是密度,随之而来的问题是性能优化。

问题
有很多方式优化树林,但如何来创建资源,通用的不多。然而,有一件事是通用的,那就是关注drawcall。

虽然面数也很重要,但不那么复杂。只需要设定一个合理的目标。下面是我们的资源的一些面数。如果你需要很多的透明插片来达到想要的效果,你需要提高贴图中叶子的占用率。

最后,是OverDraw。如果你有很多重叠的透明物体。我不太会关注这个,因为其实我也不知道怎么办。我会修改树的轮廓减少重叠。但是美术效果更加重要。但让树林真实还得考虑overdraw太难了。我只关注了面数和drawcall。

计划
如果你希望发现unity的一些隐藏参数,可以来优化,那么抱歉我没写这些。你需要一个计划来解决这个问题。很多需要在创建资源时记着优化策略。如果你的资源时来自不同的地方,那么就很难啊办了。有很多种策略减少drawcall,下面会展示我的。我的目标,简而言之,就是把LOD1的模型合并成大mesh,这样远离玩家的drawcall就会减少。
图集
第一步是让森林中所有距离近的植物用同一个材质。这样最终我的所有植被只需要采样一张贴图。

这样的原因是成组的植被就可以用一个drawcall了。如果树和草用独特的材质,那么就算合并了mesh还是很多drawcall。把所有资源合到一个贴图上不是很难,因为一开始就是这样设计的。我设计了一个2k的贴图,并为所有的资源分配了面积。
这是可以自动化的,用脚本改变uv就可以了
分组
最终的目标是合并,但首先我们需要知道如何合并。全部合并成巨大的mesh是不明智的。首先:unity用的16位的index buffer,所以面数最高64k。其次,那样就没法LOD每一组了,因为整个森林是一个mesh。尽管drawcall很低,但面数很高。我们负担得起那增加的几个drawcall。
用六边形组。基本上我写了个脚本,自动把植物分成六边形的组。不用四边形因为六边形更圆,这样分组距离检查更为准确。方块的角落离玩家更近。

后面你会发现分成格子有更多的好处。
跟上我了?还有一层让我很痛苦,六边形组分成了大六边形,用于LOD2.这些超六边形是一个合并版的LOD2组,更加减少了DC。如果你想知道为啥我不让第一个六边形组大一点,是因为我想要更惊喜的LOD之前的切换。当东西更远了,模型可以合并成更大的格子。这基本注意不到因为小的六边形都在最远的LOD了。
合并
最开始的时候我合并了LOD1,但我发现这太占内存了。这需要高模的每一个资源有独特的内存空间。并且,模型越大,裁剪的粒度就越大,也就会渲染越多的模型。玩家倾向与离LOD0更近,因此不精确的裁剪的问题就夸张了。我发现合并LOD1是一个完美的平衡。它面数比较低,而且非常节省因为大部分的东西都在LOD1状态。

不行的是unity里合并模型不直观,我需要写自己的脚本。因为assetstore上的资源不能处理submesh和顶点色。至于如何组织LOD,这取决于你。我倾向于不适用原生的LOD组件,因为那只是一个数据容器而已,我的六边形分组来处理LOD切换。简单地说我用了个monobehabior脚本,带着GO的应用来激活GO。这样我可以轻易获取他们的材质和submesh
用六边形组裁剪和LOD
需要记得一件事:原生的LOD组件很费,尤其是有很多的草的时候。距离检查发生在cpu上。分成六边形组的好处是你可以用分组来检查LOD,而不是一个个的子物体。这样整个LOD组是一个单元,我甚至加了alpha test的过渡渐变。

另外动态的遮挡裁剪也用了分组。我没用原生的遮挡裁剪,因为它好像和多场景结合起来不太好。分组很少,所以可以完全动态用raycast检查是否在视线中。我每帧都做raycast。只是保证越过汕头和墙角时它们及时出现。你不可能对每一个物体都这么做,那太费了。但raycast一组20-50个物体时值得的,尤其是每帧都检查。我只用terrain来做遮挡体,因为森林里的其它东西不是可靠的遮挡体。

平滑的LOD切换
LOD0和1中平滑的过渡是很重要的,我做的是LOD0用一些instance的树枝,然后直接用低模的树枝instance替换作为LOD1.

地形
地形本来不是本文的重点,但我要指出的是不要用unity原生的terrain系统。我的所有植物都是gameobject。这有三个原因:1. 原生terrain性能很差,负担不起那么多的drawcall。 用普通的mesh可以控制哪里需要更多的面数。其次,原生terrain的shader系统限制很多,并且文档很少。最后,我有很多山洞和悬空物。我的地形shader很简单:三个顶点色通道控制混合,还有一个整体的叠加与法线。

着色
植物的shader需要优化好,完全延迟渲染会比较省,但我曾经用的前向渲染,但当我明白如何用延迟渲染时,省了30%的时间。创建一个完全延迟的植物shader,还带有透明度,不是那么直白。这需要采样光的衰减,而在延迟中不是正常拿到的。我意识到下面的部分会深入unity的细节。但满足你的好奇心,我用了一个surface shader,自定义光照模型,半透明蒙版放进GBuffer没用的两位里,然后在Internal-DeferedShading.shader里加了一个处理半透明的函数。我花了快一年了才搞明白怎么做。这篇文章帮助我理解了。我最终花了两年才明白怎么弄,我很愿意帮助没搞懂的人明白这是怎么回事。
光线烘焙
全部烘焙很占存储空间,而且烘焙时间很长。并且还不一定好看。因为透明的物体不总是有很好的烘焙效果。我为小东西用了用了light probe,为树用了Light Probe Proxy Volume,这样就会有到树冠上的颜色过渡。因为树都布置静止的,lightmapper看不到它们,所以一个方法来让lightprobe看上去暗一些。我写了个脚本给probe染了一个颜色。

杂项技巧
LOD1一半的树。很多树很高,上面树冠用低模就行了。因此我把LOD的贴图弄到一个上了。
让森林变得更密?可以只加一些树干
大片平地上可以让草整合成一个物体,减少drawcall。
GPU Instancing的笔记
unity5.4引入了instancing,用脚本来渲染才能用到它。这和结合mesh不太一样,这更好因为很多mesh可以直接整合到一个drawcall里,也不用担心整合mesh带来的内存负担。但也有些缺点。因为是用脚本渲染,需要维护那些模型是可见的。这你都得自己写,记住C#可能会比较费,而且把很大的东西传到gpu也会很费。
我试过GPU Instancing,甚至把我的分组系统替换成gpu instancing版本,但没有我的合并版本性能好。并且还有很多限制,比如lightprobe
还有一个新方法,DrawMeshInstanceIndirect,可以用compute buffer生成实例表,性能更好。但没有文档,我不是个好的程序员,我没试出来。
结论
- drawcall是一个大问题,要有计划地减少
- 面数比较简单,只要保证资源的面数合理就行
- 我忽略了overdraw,因为要保证美术效果
- 所有的植物贴图合成了一个材质,保证合并的mesh用一个drawcall
- 用分组系统,合并了LOD1的mesh
- 没有合并LOD0因为内存太大
- 分组不能太大,要不然遮挡剔除会失效
- 我用了自己的LOD脚本,因为分组做LOD而不是用单个物体
- 我用了自己的合并mesh脚本,因为要处理submesh和顶点色
- 我自己做的植物资源,做的时候记住LOD1的处理
- 可以用Alpha Cutoff做平滑切换
- 我用了普通mesh而不是unity的地形系统
- 我写了延迟渲染的植物shader,保证像素填充率比较低
- 为植物烘焙灯光不太可行,我只用了lightprobe
译者后注:
- Atlas是必选方案,不过如果场景变化比较多,如何规划和严格执行分区是个问题
- 合并mesh是必选方案,尤其是要分块加载的话,不可能动态static batching,必须预先合并和分组
- unity的terrain面数可以控制,但是drawcall太高了,不能使用,如何转mesh并保证低面数和高精度是个问题
- 树叶烘焙的话,表面积太大太耗时,译者也选择了放弃
- DrawMeshInstanceIndirect要用compute shader,效率比DrawMeshInstance好多了。不过似乎ios上用metal才行。高通骁龙不确定,反正麒麟好像阉割了一个buffer,不能用。DrawMeshInstance限定一个batch1023,还有CBuffer大小限制。