草海是开放大世界渲染的必不可少的因素,Unity 原生的 Terrain 草海效率较低,而且无法与 RVT 结合起来,无法在移动端上实现。因此我们自己搓出来一套草海系统,使用 C# 多线程辅助运算,并能支持割草、烧草等进阶玩法。草的位置、密度、类型还需要根据美术和地形的信息进行自适应。
最终效果可以看这个演示视频*(虽然这个视频美术表现力不行,但是已经可以看到 GpuTerrain基础下的实时运算的草海效果了。对于游戏开发者而言,显然能明白这个演示已经实现了既定目标)*:
方案设计
这里我按照 1*1 的范围将地图进行分块,每一块视为一整片草,进行整体剔除和计算。
如上图的示例,将草分块之后,进行AOI剔除,最后得到所有可能在视野中的草(注意:这里没有进行视锥体剔除),之后再使用生成式算法得到最终的草数据(精确到每一株草的实体)。然后再直接将数据打包传入 GPU,使用之前提到过的 GPU 驱动的渲染上屏,完成草海渲染。
这里就涉及到之前的一些实现过的架构了:
- 需要得到地面的高度和法线,与 开放大世界的 GpuTerrain + RVT-CSDN博客 共用一张高度法线图。
- 上屏方案 : GPU驱动的大规模静态物件渲染-CSDN博客
除此之外,草海还需要一张密度图(表示每个格子的草的密度和类型),和 GpuTerrain 的高度法线图每个像素一一对应。
有了上述数据之后,草海就能正常运转了。在项目前期,我们是将草海的生成式算法放到C#多线程中的,目的是为了方便调试和交互。后续功能稳定了,可以将这个算法移动到 ComputeShader 中,效率可以更高(这也是最终成型方案)。
框架与数据管理
制作地图时,生成棋盘格,每个格子 1*1 米,每512 *512 个棋盘生成视为一个地块。统一棋盘格的尺寸,对应高度图、植被密度图的数据。
一般来讲,对于视野远端的地块,不需要生成格子,也不需要计算。对于近处的地块,才需要生成草海,当然距离越近性能越好。按照一些文章的说法,80米的视野范围就能满足需求,如果加上视锥体剔除,视角设置为60度,那么算下来大概需要 3696 个格子参与生成计算(后面按照4000个格子计算)。
4000 个格子,每个格子生成 64 棵草(密度最大),每颗草的数据(Transfrom数据)大约为 30 byte,算下来总共占用约 7.5 MB 内存,即便是移动端也可以接受。(为了减少对GPU的数据传输,我们其实使用了4个80*80的区域,内存占用更多,原因下文有解释)
草海系统的生命周期管理
对于 Patch 而言,最高运算的部分在生成(以及根据玩法对数据的修改部分),因此在运行时并没有复杂逻辑,就只是在原地不动。生成之后最大的性能瓶颈就是在玩法(例如割草)上对实体的遍历。但因为能实现限制 Patch 进行刷新,所以遍历的量并不会很大。
草海编辑工具
因为草海已经脱离了 Unity 的Terrain,所以需要一编辑工具。
- 根据 GpuTerrain + RVT 数据直接还原地形的工具,方便美术开发。
- 类似于 Terrain 的刷子工具,可以绘制草海密度图。
- 草海类型配置:配置不同类型的草,以及其出现频率,可以实时预览。
- 运行时调试工具,方便运行时查找问题。
草海密度图示例
其他细节问题
这里记录一些细节问题,可以帮助大家实现时做参考。
面片草还是网格草?
- 面片草顶点数少,但是依赖 AlphaTest,这一步在很多移动设备上非常昂贵。
- 网格草顶点数高,但因为是 实体渲染,性能表现远远优于 AlphaTest。
此外,对于某些表现效果(例如高光、碰撞弯曲)上,面片制作起来困难;在视角上,面片草存在穿帮死角,大致上只适合固定视角。所以还是建议使用网格草。
草海密度图存的什么数据?
草海密度图我这里将每个像素的 rgba 各设置为一种类型的草的密度,每一个草的密度取值从 0~256表示其密度。在生成草海数据时,查询到某个位置有颜色,则根据通道(例如 R通道 表示杂草,G表示花、B表示地衣、小石头)密度算出需要生成特定类型、特定数量的实体。生成完成之后,将数据收集起来统一传给 GPU。
实际上,每一个通道的256的值对于密度还是太多了,本来一个地块最多64个实体,精度肯定超标了。后续如果要优化,完全可以将密度的精度降低到8,再将其他位记录其他地图数据。
生成式算法
成式算法其实是根据项目相关,所以不必去网上特意查找。一般来讲,只需要设置一个随机方式(噪声图、或者固定随机种子),根据算法进行随机。只要满足一个要求就行:每一次生成的位置、旋转、缩放都是相同的结果。
同时,在生成时可能需要根据地形信息有所变化,这点也需要考虑到。
草海实时重建的频率
在计算哪些草块(Pitch)需要计算时,为了减少对 GPU 交互的频率,改为周围 80m 的棋盘格都计算了一起扔给 GPU。因此这样占用的内存大了4倍,但数据交互频率显著降低,就不用每帧都生成一次数据了。否则,一旦加上视锥体剔除,那么一旦相机旋转了,每帧都要实时计算一次、传值,数据大了还是很不划算的(实际上,视锥体剔除我们也试过,数据量少了很多,但传输数据的频率就大幅提升了)。
当然这是妥协,如果在 GPU 里计算草海就没这个问题了。所以,最终的完整版本还是要在 ComputeShader 里生成草海。
图片数据读取
GPU端直接通过 Texture.GetPixel 也太蠢了,效率还低。这里我们的做法是将图片转换成 NativeArray 然后再在多线程读取。这里需要注意的时,如果是多线程读取 NativeArray ,需要一点点操作:
cs
NativeArray<Color32> HeightMap = texture.GetPixelData<Color32>(0);//获取图片的原始数据
//根据已有的数据生成不安全的只读 NativeArray,否则多线程无法使用。
NativeArray<Color32> ConvertedHeightMap= NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray<Color32>(HeightMap.GetUnsafeReadOnlyPtr(), HeightMap.Length, Allocator.Invalid);
#if ENABLE_UNITY_COLLECTIONS_CHECKS
//只有编辑器下才存在 SetAtomicSafetyHandle
NativeArrayUnsafeUtility.SetAtomicSafetyHandle(ref ConvertedHeightMap, AtomicSafetyHandle.Create());
#endif
按照上图示例,就可以在多线程中使用 Unity 的 NativeArray 了。要注意的是,SetAtomicSafetyHandle 只在编辑器下使用,在真机上是会报错的。参考:NativeArrayUnsafeUtility seems to not work in builds - Unity Engine - Unity Discussions
割草烧草如何实现
类似于塞尔达的割草、烧草效果,如果是在 CPU 端是比较好弄的。直接可以将技能(或者玩家交互)的计算结果记录下来,然后调用一次草海刷新,然后就能根据新的数据计算出结果,再传给 GPU 即可。
如果是在 GPU 端实现,有一个简单的方案:从头顶照一个相机下来使其能覆盖所有的草。然后所有的技能一般都会有一些特效显示,这个相机就专门照这个特效。然后 GPU 直接就能读取到这个相机生成的图片,就可以直接拿来计算了,根本不用写 CPU 和 GPU 的交互,非常地方便。
模型上种草如何实现
目前只实现了地形上种草,而模型上种草没有实现。
模型上种草其实也有过设计,需要提前将模型进行烘培,且需要让美术指定模型的哪些地方是可以种草的(类似于刷 UV 图)。然后在渲染模型时,根据模型的草皮图、法线等直接生成草海,后续的步骤和地皮生成草海类似了。
参考文章
移动端草海的渲染方案(一)_terrain detail 交互-CSDN博客
移动端草海的渲染方案(二)_nature renderer-CSDN博客