还原《塞尔达-旷野之息》的草地

Last modified date

探索大批量动态草地的渲染和数据管理方式

说到引擎里种草,无非就是GPU instancing。但想要在目前的移动端实现塞尔达的草地,还是有一定的挑战的。有以下几个问题要攻克:

  • 草的动态和碰撞交互
  • 草的光照渲染
  • 如何种植这么大范围的草,数据如何管理?

草的摆动

做草叶摆动有2种常见的方式:

  1. 以草的根部的pivot为圆心旋转
  2. 先做XZ轴的摆动,然后用直角三角形边长公式算出y轴的偏离,保持草的总长度不变。

虽然方法1更符合逻辑,但是我个人觉得方法2计算起来更直观一点,就用了方法2。

先算好草的XZ轴的摆动偏移,然后再算出Y轴的重力影响。Y轴上的mesh分了2段,可以做一点系数偏移,让草的摆动看起来软一点,有惯性。

其实这个算法的缺点就是草XZ轴的偏移越大,Y轴的偏移幅度就越大。不过最终效果看起来也挺自然的。(对,我就是这种以结果美观为导向的不讲究的人。)

最后别忘了给草的摇摆幅度加一个限制,不然摇摆角度太大会穿到地里去。

然后顺便把风向和碰撞体对草的影响也加进去。具体可查看之前的一篇:Dynamic Grass – 动态草地制作

动态交互我暂时用的这种简单的方式,以后可以试试看做成《战神》那种基于RT的风场。

按照距离把草分成3种类型

在反复研究《旷野之息》的草地后,确定了它的草地是由3部分组成的:

  • 远处的草是单片的贴图草,而且很稀疏。
  • 中间的草是3片一组的贴图草。
  • 最近处的草是一根一根的,并且很密集。

三种距离的草之间通过shader做顶点偏移,把草逐渐插入地下的方式做Fade in/out过渡。

这有点像LOD的概念,只不过两层LOD之间必须要有一段重合区域做过渡效果。

我觉得草往地下插还是蛮显眼的,不是很美观,所以做了点改进。近处的草就用XZ轴的缩放来做fade out,让每根草越来越窄,逐渐消失。

中间和远处的草通过调整alpha clip(距离越远,Clip越强),让每根草看起来也是越来越细的消失掉,效果跟近处的Mesh草一致。

这样3层草可以完美的融合在一起了。

光照渲染

然后是美化。草需要有整体成片的感觉,而且要和terrain融合起来,所以在种草的时候可以把草的法线修改成terrain的法线,并且用跟terrain一样的PBR算法。这样草的光照就跟地形的光照完全一致了。

此外还要给草加上Fresnel和SSS等效果,并且利用草的Y轴高度作为Mask,让草的上半段逐渐有光线透射和高光反射的效果。这样高低坡落差的地方和麦浪起伏时可以看到草的层次感。

麦浪效果是额外采了一张uv滚动的mask贴图,作为Offset加到草的摇摆算法中。

大批量种草

最后是最难的部分了:如何在移动端大世界的terrain上种出这么多的草呢?

常见的植被优化的方式是做静态合批,按照某种算法把靠近的草打成一组Mesh。但是这样会产生很多合批的Mesh,而我们这种草的mesh很密集,模型面数太多,最终包体会变很大。

当然也可以在开始运行时初始化草地的时候产生合批Mesh,但这同样会占很大内存。而且初始化多时候会卡顿或者延迟。

以上方案都不太适用于我们这个种草的需求,所以要另想一个合理的方案。

我的做法是这样:

  • 把草的数据分为:Terrain > Chunk > Grid > Grass 这样的层级。
  • 根据角色的世界坐标,算出他附近的4个Chunk。然后得到这4个Chunk的Grid List。
  • 通过Camera裁剪和距离剔除,得到最终需要渲染的Grid的列表。一个256×256的Chunk里大概有几千个Grid,这一步在Cpu里做也不是很耗。
  • 最后用GPU instance逐个画出每个Grid里的草。

数据管理是这样的,每个Chunk里存2个List数据:

  • Grass List:存储这个Chunk里所有的草的数据,ID从0开始,是一个很长的百万级别的List。
  • Grid List:是这个Chunk里的格子的列表,存储了每个格子的草的起始ID和长度。
  • 运行时以Grid为单位做Camera剔除,最后得到需要画的Grid,通过Grid List里存储的Grass List的起始ID和长度,就可以得到这个Grid里的草的数据了。

Chunk的数据可以离线生成,存成Json文件。也可以在运行时单独用个进程去通过Density Map和Height Map初始化。

由于Unity 的DrawMeshInstanced方法一次只能画1023个,我就把一个Grid的里的草数量上限设成1023,每个Grid做一次DrawMeshInstanced。

最终运行效果是渲染直径100米范围的草,大概有10~15万个顶点,12~15个drawCall。以下视频是现在demo做到的效果。

另一个种草的方法

还是以上面的数据管理方法为基础,修改以下最后画草的方式:

  • 把一个Grid的草做成一个通用的Mesh,把里面的每根草相对于Mesh pivot的偏移刷在顶点色的RGB上。(因为顶点色精度低,所以只能做2×2米以内)运行时直接把这个通用Mesh当作GPU instance的绘制单位。
  • 然后在shader里通过顶点色存储的Offset偏移和Mesh本身的坐标,可以把每根草的世界坐标再反算出来。
  • 但是渲染时shader里要额外采一次Terrain的Height map和草的Density Map。(采Height可算出Y轴坐标和terrain法线,采Density Map可算出草的Scale和显示与否)

这样做可以省不少GPU instance的开销,但是shader里要多采2张贴图,计算量会高不少。目前我测试的运行结果和之前的方法性能开销差不多,顶点数是一样的,Draw Call只有3个。当然具体怎么做还是看项目实际需求。

目前我就研究到这一步了,应该还有其他更优化到方法,以后再慢慢研究。