Unreal Engine的地形草管理系统的缺陷分析报告
hism是一个unreal engine中常见的组件,它专门负责对大规模实例化物体进行管理和渲染。大家应该相当熟悉了,而且也应该知晓了它内部的运行机制。我这里就不单独对它进行系统的分析,而是把问题聚焦在地形上的grass模块中。虽然landscape的grass系统听名字感觉好像是专门负责管理草的,但其实并不尽然,它里面可以放置任何你想在地形上按照混合权重图分区域进行布局的任何物体,例如地面上的小石块。当然常见的还是类似草的低矮植被,主要起到的是视觉装饰的作用,一般不与玩法发生什么交互。那它与hism有什么关系呢?由于草是一种实例化友好的物件,而且通常数量也很多,所以使用hism组件来管理它们是一件非常直觉的事情。可是草的生成区域与其位置都是由地形决定的,因此hism会与landscape component产生一一对应的关系。为了方便管理,unreal engine给每个landscape component都分配了一个专属的hism。如果某个landscape component不能生成草的实例,那么就不会创建hism。grass的hism是在游戏运行时构建的,并不会序列化到资产文件里。所以每帧系统都会轮询世界中所有的landscapeproxy,判断landscapeproxy中的每一个landscape component是否在预设的渲染范围之内。如果这个在范围之内的component没有创建过hism,就会开启一个异步任务用来构建hism的cluster tree,并且形成对应的instance buffer。每个instance的位置会重新进行随机,但是为了保证随机的一致性,构建时使用的随机种子都是和landscape component一一对应的,所以同个位置上的hism里的instance每次重新生成,都会出现在一模一样的地方。
为了节省内存的占用量,整个地形中的grass的hism不会都加载到内存里,而是像streaming level那样按照距离来流式生成或卸载。每次系统都会检查landscape component里面是否存在grass data,如果有,则再去判断当前的component离玩家的距离是否在设定的阈值之内,如果也在范围之内就尝试加载它。前文说到,我们需要重新生成hism里的所有instance,因为grass data里并没有记录着instance的信息,而是grass type相关的weightmap,即instance的分布图。另外grass data里还会包含一张heightmap,它负责确定instance的位置,让instance不会掉到地下,或者是浮在空中。通过计算某个样点的梯度,它也可以帮助instance对齐地形的表面,否则所有的instance都只能竖直向上了。其实一种grass type不仅对应一种mesh,我们知道weightmap的一个通道用来标记一种grass type的区域位置,但是如果grass type只对应一种mesh的话,那么这个区域的instance就会显得比较单调,于是unreal engine还提供了一个基于grass type的变体功能。所谓的变体指的是每一个grass type都可以对应多个不同的mesh,这些mesh的造型一般会比较接近,同属于一个类别,但略微有些差异。例如高低不同的草。每一种变体还有自己的放置密度和jitter偏移,可以避免相同grass type的不同变体重叠在一起。
由于hism需要动态的build,所以会消耗一定的cpu时间。上文提到hism的instance也需要运行时生成,为什么不提前把instance的数据序列化到landscape component里面呢?显然这是为了减少内存的占用量,如果实例的数量特别多的话,光是transform信息就会消耗很多的内存。因此单纯cache不同grass type的weightmap还是比较划算的,当然heightmap也必不可少。我们知道hism不会一直放在内存里,它是需要做streaming处理的,为了降低内存的压力,当hism远离玩家时就会被卸载掉。于是反复的流入流出,带来了另外的副作用,那就是重新生成hism。虽然这个过程是异步的,并不会阻塞游戏线程,它们会被投递到多个工作线程里并行处理。但是我认为离线生成好这些instance的数据还是很有必要的,至少不会浪费运行时宝贵的cpu资源。而且grass data会一直与landscape component共同存在,看来这也是一个问题。因为无论这个landscape component是否已经超出了grass的可见范围,grass data都不会删除,虽然超出距离的hism会被释放掉。因此可见原生的方案没有那么的合理,照理grass data并不需要常驻内存。由于landscape component的流入距离往往大于grass的流入距离,所以导致某些grass data长期处于无人问津的状态。
我的开发哲学是,如果某种渲染数据是静态的,那么它就应该被离线处理好,并把这些数据序列化到文件中,等运行时再按需把它们读取出来,因为时至今日,硬盘的成本实在太低了,但是cpu计算资源却是十分宝贵的,轻易不要浪费。如果担心instance数据过大,影响安装包的大小,我们可以对每个地块上的grass数据进行压缩,甚至是有损编码也不为过,毕竟位置信息不需要那么精确。我们也注意到,原生的grass data是以landscape component为单位进行存储的,这个规则并不那么的合理。草块的粒度不需要与地块的粒度保持一致,尤其是landscape component被设置成过大或过小的尺寸时。因为如果landscape component的尺寸过大,就会导致单个hism里的instance变多,这显然增加了build的成本,grass从build到被渲染可能会间隔较长的时间,这取决于cpu的处理速度。而landscape component尺寸过小时,hism又太零碎了,影响流入流出的效率,实例化的好处也会消失殆尽。
landscape grass的系统还有一个毛病,就是streaming的管理比较低效,因为它会轮询所有已经生成好的hism,只有发现某个hism超出视距一定时间后,这个hism才会被删除,既所谓的流出。那些正在异步build的hism也需要进行轮询处理,每帧都要查看它们的构建状态,因为构建完成后,还必须将instance buffer投递到渲染线程,通过api上传到显存中。上文已经分析过流入的逻辑,它也需要每帧遍历所有的landscape component,然后再遍历所有的变体类型,显然这种轮询的数量就会相当的庞大,而且最后为了由近及远的流入,还会对其进行排序。一般项目里,world中的landscape component数量可能会高达几千个,而草的变体数量也会有上百个,那么这时遍历的复杂度将超过十几万次,整体的开销还是相当可观的。除非我们把草体的流入距离设置得比较近,才能将运行成本降下来。但是这样的话,我们就看不到远处地面上的草了。
最后做个简单的总结,landscape grass的hism尽量离线构建好是一个不错的选择,而且每个区块的大小并不一定与landscape component保持一致,完全可以解耦。另外同一个区块内的不同类型的变体可以共享同一个instance buffer,因为它们的格式是相同的。多个独立的buffer不太利于内存的管理,而且容易产生内存碎片,由于采用单一buffer的方案,今后还可以方便做multidraw的改进。streaming的逻辑也需要调整,按照均匀网格的方式进行管理,只更新差异区域的草块,减少遍历的开销。
页:
[1]