还原《塞尔达-旷野之息》的草地
探索大批量动态草地的渲染和数据管理方式
WebGL Demo:
说到引擎里种草,无非就是GPU instancing。但想要在目前的移动端实现塞尔达的草地,还是有一定的挑战的。有以下几个问题要攻克:
- 草的动态和碰撞交互
- 草的光照渲染
- 如何种植这么大范围的草,数据如何管理?
草的摆动
做草叶摆动有2种常见的方式:
- 以草的根部的pivot为圆心旋转
- 先做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个。当然具体怎么做还是看项目实际需求。
目前我就研究到这一步了,应该还有其他更优化到方法,以后再慢慢研究。