ob电竞·(中国)电子竞技平台

ob电竞·(中国)电子竞技平台

ob电竞

ob电竞

Steam热门游戏《ob电竞》分享:如何用虚幻引擎4打造大型开放世界生存沙盒网游?

ashkeling报道/“Unreal Open Day虚幻引擎技术开放日”是由 Epic Games 中国倾力打造的面向虚幻引擎开发者的技术分享活动,是引擎行业内规格最高、规模最大、阵容最强的年度盛会之一。

在经历了2020年疫情的影响改为线上后,“Unreal Open Day 2021 虚幻引擎技术开放日”时隔两年重新回归线下,将于12月2日至12月3日在上海阿纳迪酒店正式举办,此次活动精心准备了主旨演讲和技术演讲共50场以供前来的观众倾听学习,务求将最前沿的虚幻引擎技术与案例解析,直接带给全球虚幻引擎开发者与关注者。

在第二日(12月3日)的活动中,来自安琪拉游戏的两位引擎开发工程师张宇和郭嵩从产品研发的角度出发,向我们分享了他们使用虚幻引擎4进行游戏开发的过程,也让我们知道《ob电竞》这样一款开放世界沙盒游戏创作过程中的诸多故事。

《ob电竞》是由安琪拉游戏打造的一款以架空古代东方大陆为背景、高自由度玩法为核心的冷兵器战争沙盒网游,于今年的11月18日在Steam平台正式发售,当天便登上了国区、美区及全球热销榜第一,当目前为止这款游戏的最高在线人数已经达到了4.3万人,各方面表现都十分出色。

以下为演讲实录:

张宇:Hello,大家早上好,我是《ob电竞》项目组的引擎程序,我叫张宇。

《ob电竞》在两周之前刚刚发售,时间还比较短,在座的各位可能还不是很了解这款游戏,所以这里有一个我们游戏的宣传片,想让大家看一下,让大家简单了解一下这个游戏类型。

就像视频里看到的,《ob电竞》它是一款开放世界的沙盒生存网游。我从这个项目一开始就在这个项目里,主要负责的是前中期美术场景制作过程中流程和规范的制定、图形效果的研发以及后期的一些性能优化的工作。

今天主要想分享一下《ob电竞》里64平方公里的开放世界是如何制作的,主要分为植被、天气、水体还有角色表现几个部分,其中天气部分会再介绍一下里面的云、里面的雨,还有闪电几种效果的做法。

首先是植被的程序化生成和快速迭代。在整个游戏世界里我们做了8种的植被生态,比如有平原、沼泽、雨林、沙漠等等。

我们的实践方案是基于原生虚幻引擎4的,是基于原生虚幻引擎4的AProceduralFoliageVolume去实现的,它相比目前比较流行的Houdini的程序化生成方案,它整体比较简单。

它的优点在于,第一它是基于原生虚幻引擎4实现的,改动代码不多;第二,它的植被的规则也很容易去自由的拓展;第三它可以流式加载,比较适合大世界的项目。另外我们还是开发了相应的美术工具,美术在配置完成后的操作基本都是一键生成,可以比较快的迭代。

我们是这样做的:首先,美术会借助外部DCC去生成它指定的Mask分布图,像这样子,然后会去配置一下这张Mask分布图对应的游戏世界内,世界范围的生存范围,然后在实际生成每个Instance的时候,会根据这个Instance在世界范围内的生成位置,换算成Mask接头的UV坐标,然后就采样权重值来决定这个Instance是否生成。你们可以看到效果还是比较精确的,而且它相比原生虚幻引擎4自带的LandscapeLayerWeight的功能,它更加灵活,因为这张贴图美术可以反复的迭代修改。

然后下一步是去配置ProceduralFoliageSpawner的依赖关系。因为上一步我们得到的所有的Foliage它只考虑了自己的生存位置,现在我们希望它同时考虑到它周围的Foliage,这样当它们结合在一起的时候,能感觉到更加的错落有致一点。

所以我们的做法是自定义了一些生成的规则,比如我们可以自定义Foliage之间的亲疏关系,在这个草的FoliageType面,它是配置了想靠近这个灌木2.5米,同时又想远离这个灌木1.5米,这里还可以加很多。所以最后的效果就是,这个草它希望生成在这棵灌木周围2.5米到1.5米的范围,然后这个生成规则会同时适用于笔刷模式还有程序化生成的模式。所以这里能看到用笔刷刷的时候,在左边超出灌木刚刚限定范围2.5米的范围,是生成不出来这个小草的。

美术配置完所有的FoliageType的依赖关系,下一步使用Spawne Manager去一键生成的到子关卡。

上一步所有的Foliage已经按照我们希望的生成规则,有了合适的生成位置,但是有个问题是:笔刷模式是可以默认生成到子关卡里,但是Procedures生成出来的默认都在永久关卡,所以这显然更适合大世界的项目,所以我们也需要它、希望把它放到子关卡来流入流出。

我们的修改是让美术是配置了每一个FoliageSpawner想生成的关卡的名字,然后我们在生成的时候会根据这个名字去找到、并把这个关卡可以设置成CurrentLevel,然后把这个Instance加入进去就可以。

另外这里额外会处理一些比如窗口的数量显示,还有植被选择等功能,能让它们也能支持,比如显示当前关卡的、还有显示整个大世界的区分来处理。最后效果是这样子的,美术只要点一下这个Spawner按钮,就可以一键生成所有指令。

实际的生成过程没有这么快,有减去一部分,但是因为整个过程是离线的,所以还可以接受。

下一步是昼夜循环和天气系统。《ob电竞》里目前实现了15种的动态天气,比如有晴天、多云、阴天、雨天。

我们这方面刚开始的需求是这样的:首先要支持这么多天气的无缝过渡,另外我们在实验的时候也需要考虑,每种天气元素它的性能预算,同时需要考虑它的应用性,因为我们希望我们尽量能够不用运行游戏,在编辑模式下就可以预览系统这样,方便美术编辑。另外因为我们做的是网游,还需要考虑这些闪电效果的同步,希望所有效果能在客户端保持一致。

这是我们游戏的昼夜循环,这是我们游戏的雷雨天气,这是实现这些天气效果我们用到的所有的天气元素。大部分都是虚幻引擎4自带的,特殊一点是我们会用的三个方向的光源,一个日光、一个月光、还有一个专门用来做闪电的光源。另外云也有两个,一个是高层云,一个是体积云,后面会介绍到,另外一个就是我们专门写了一个专门做用来做雨天表现的雨效管理器,后面也会介绍到。

然后为了实现天气的平滑过渡,我们给美术开发了一个工具,我们会让美术把他们所有用到的天气的属性都汇总起来,然后每个属性数据会去对应一条曲线,单值的会去对应一条FloatCurve,多值的比如VectorCurve它会去对应一条LinearColor。

然后每个天气会有一组24小时循环的数据,它包含了一天24小时所有用到的数据,比如天光、日光、月光、云雾等等都在这里。

然后每个天气还会有一组过渡的数据。因为比如说雨天开始的时候,地面逐渐变湿的强度、雨势逐渐变快速度,这些都是一天24小时会用到的属性,它们就会存放在这里。

美术需要配套的配置的就是这些,然后我们为了方便美术配置,我们修改了曲线使数据可以继承,主要是修改下面两个数。

第一处是让副类修改的时候,可以在子类的时候可以检查到,看看是否会有不一致;第二个是让副类的修改的时候可以通知到子类当中,然后这样修改的好处是说,美术可以就是像程序继承一样,它可以只修改特定的一部分来减少他的工作量。

程序实现方面我们会根据美术配置的Porperty名字在运行时间找到这个Porperty,缓存下面这个指针,然后随着游戏内时间的进行,会去读取美术配置的Curve里面的数据,直接设置到这个Porperty指向的Value。

然后在客户端天气切换,或者它去切换了下一个生态区域的时候,会去生成一份场景快照,其实就是把当前场景的所有天气元素,他们的属性给缓存下来。

然后在下一个天气开始的一段时间内,再去根据多个SnapShot、再根据它们的优先级进行插值,这样就基本实现了天气的平滑过渡。

下一个是云。《ob电竞》里面目前有三层云,这些是层云、积云还有高层云。

我们刚开始的需求是这样的:首先它需要形态上足够丰富,能够适应这么多的天气,而且对它们之间的动态过渡;第二个是同时能保证性能在2毫秒以内,因此虽然虚幻引擎4在这方面它的完成度已经很高,但其实想用好,满足这些需求,都需要参考业界的一些分享。

这是我们目前项目的体积云的材质。右边是闪电和着色的部分,左边是塑形的部分——是体积云最重要的部分,也是我想重点介绍一下部分。

塑形整体分为两部分:基础塑形和侵蚀,这方面也参考了《ob电竞》或者《ob电竞》的一些做法,因为要满足这些需求的话,其实还是只能按照这些套路来做。

第一步是去采样一些基础的NoiseTexture,配置不同类型的Perlin和Worly噪声,第二部会去采样HeightCurveAtlas,它是128*1的大小,它存储的是云层海拔高度,还有云层密度的映射。

第三步是做Height和Density的重映射,可以重新去调整、裁剪或者做拉伸,这个步骤可以反复几次。

然后就可以得到我们基础塑形的结果——ConservativeDensity,这就是基础塑形的输出。

然后在它的基础上再做DetailNoise的侵蚀,这一步调整的步骤和上面几个类似,但是因为现在我们得到的只能是一个3D的结果,所以采样的是一张VolumeTexture,就它之前使用不同的密度去采样了多次。

其实看虚幻引擎4的体积云的实现,能感觉到它已经对于他引擎能控制的部分做了很多的努力去优化,但是,他为了给予使用者足够高的自由度,其实它是把塑形的部分,主要是塑形的部分开放出来,这也给了我们使用者一些“犯错”的机会。

如果要考虑性能的话,一方面其实还是需要多参考一些,另外就是还有一些修复、优化细节需要注意。一般体积云来优化它会在BaseShape输出,如果是0的话,它会跳过后面DetailNoise等的采样。

对于虚幻引擎4来说这个ConservativeDensity是一个提前退出的条件,所以我们要保证,比如我们看到一个效果,这个地方是没有云,我们就要让他在BaseShape这个地方去输出云;二个是要注意采样数,尽量在提前去烘培一些不同类型、不同形状的Noise去下一些功夫,这样子在运行时少一些采样,一次采样出来的结果尽量去做出更多花样,避免做出多次采样;第三就是谨慎开启它的其他特性,他们都会成倍的增加去采样的次数。

另外一个就是刚刚提到那张HeightCurveAtlas,它默认是不能是128*1,它只有一个正方形,但它其实大部分像素都是没有用的,所以可以修改一下让它稍微小一点。

然后是高层云的实现,高层云的实现比较简单,其实就是采用了一个2D的Cloud纹理去叠加在天空盒上,因为我们天气有两个——日光和月光,所以我们这里叠加了两个Lumen。

然后整个天气系统实现其实有很多小的Trace,然后比如高层云实际的位置,星星和月亮其实是在体积云和高层云之间,因为高层云直接在天空盒上,但其实视觉上我们希望这些云都能遮挡住星星和月亮,所以我们用的方法是它们都会使用同一个MaskValue去做它们的剔除,只不过对于星星和月亮来说是被Mask区分,这样就能形成视觉上高层云遮挡住星星和月亮的效果。

然后是雨。《ob电竞》里雨即将开始的过程是这样子的:首先会地面会逐渐的变湿,然后再慢慢的产生积水和涟漪,然后随着雨变大,积水的增多,再开始积水开始流动,然后这种斜面的流水在慢慢增强,然后雨天结束的过程就是上面整个过程的反向。

然后实现的话,其实也参考了国内外的一些博客,包括国内《ob电竞》的一些分享,我们的做法是它也把它实现了,因为是在虚幻引擎4里面。

我们做法是在BasePass后增加了一个后处理的Pass让它们去修改GBuffer。潮湿是修改GBuffer的Roughness和BaseColor;积水是修改了UnpackNormal,在它的基础之上叠加混合了一张法线贴图;涟漪则是去实现随时间播放的Flip Book;流水是在世界空间内,将这样流水的贴图做XZ和YZ的双平面映射。

然后是雨丝,雨丝刚开始实际上我们也是通过后处理来做的,把一个纺锤体放在相机前,它的优点是性能比较稳定,但它的缺点是雨很大或者雨很小的时候就比较容易穿帮。

所以我们做后来还是换成了粒子的做法,就是在Camera周围去的生成,它的性能会随粒子的数量起伏,但它的效果更好,所以我们权衡之下还是采用了粒子的做法。

然后是遮挡。原生虚幻引擎4里面的StreamTexture用来拍摄深度的话,实在是有些浪费,所以我们新增了一个DepthOnlyRenderer专门去拍摄深度,同时新增了一个OcclusionPass去收集我们自定义规则。

然后运行是实时的在OB欧宝体育电竞官网头顶去拍摄这样一张256*256的DepthMap,这样现场拍摄出来的DepthMap是这样子的。

然后会去在CameraSpace去计算它的遮挡,为了遮挡边缘的柔滑去做了PCF。为了防止自遮挡,在深度判定的时候加一个Bias,这里能看到这种在房屋屋檐破损的地方也能形成比较自然效果。

然后是ObjectFlag标记。在实现过程中我们还有一个需求是,因为是在后处理Pass去修改的GBuffer,但是其实这个阶段大部分相对信息其实是丢失的,它属于一个正在移动的对象,我们此时就不太希望去修改它的Normal,但是这些它是否可移动属性,首先它只有在BasePass才有,所以我们在GBuffer把这些信息存下来。

为了能存储这些,比如说我们评估了这些位置,它们要么已经被虚幻引擎4基本都占满了,去替换或者怎样的,其实代价比较大,要么比如它还有一些比特未使用,但是考虑到之后版本的升级和维护,因为官方也有可能再去接着使用这些比特,所以都不是很合适。

我们最后的方案是在把它Type到了Specular里,原来的Specular它会占8个比特,我们给它做Pack和Unpack,只留了2个比特给原来的Specular,其他6个比特都可以用来做我们的标记位,比如我们把第3个比特用来标记这个像素,是不是属于正在磨合的对象。

然后闪电的实现,有两种情况,一种是云中的闪电,它只在云层里;另外一个是云地闪电,它会从云层连接到地面。

闪电过程的实现,实际上梯级先导是用Niagara粒子做的,然后回击是让在飞行结束之后让主干以更高的亮度闪烁的同时去照亮场景,并且从闪电位置去投射一个方向阴影,能看到它闪电发生的同时瞬间的投影方向是根据闪电位置来计算的,同时整个过程会伴随有雷声,包括轰鸣声和雷击声。

然后是水体。当时项目升级到4.26之后,其实我们面临一个抉择,就是项目的水体是否要换新的虚幻引擎4的WaterPlugin,,因为它当时的效果确实是比我们原来的水要好的。但是因为它还是一个实验性的插件,所以看到它实验之后发现它有些限制。

第一个是它的River,它不影响Landscape的话,它只有纯平面,而且不能去调整缩放。这是因为我们当时到了项目后期,地形其实已经固定,不太好去变更,所以River太依赖于地形的HeightMap,所以我们可以想象它地形就只有纯平;另外它的实现,它是根据不同WaterPlugin去计算去填充,所以它的整个的缩放也是不精确的;第二个是它默认只有3*3平方公里的大小,不能满足大世界的需求,但这个可以改。但是还有个问题是,它目前的整体的架构的设计,还有它运行时合并MeshActor的效率,不太适合流式加载。

所以我们最终的方案是只使用SingleLayerWater的ShadingModel功能还是原来的,也就是只用它的表现。另外我们去移植了Gerstner波,但是考虑到LOD的接缝问题还有性能问题,我们目前暂未启用。另外就是我们同样使用DepthOnlyRenderer去拍摄精确一个的形状,优化原来只有点状的波纹。

下一个是人物角色的表现。《ob电竞》里的角色是可以纹身的,最多也可以有18层的纹身,身体主干可以有6层,胳膊腿每个部位都可以有3层。

实现的话,首先要在角色分UV的时候就要保障这些UV是连续的,然后会在连续的UV空间内去做这些纹身贴图的平移、缩放还有旋转,然后我们会把它转成HLSL,因为考虑到性能的话,它材质里面或者是没有节点去支持动态分支的,所以我们把它转成HLSL写到了Custom里面,而且我们也会为参数做Type,因为原生的虚幻引擎4,一个纹身会占非常多,18层的话计算机都会爆炸,所以我们才做了参数Type。

然后是Morphing和Clothing,原生的虚幻引擎4里面因为布料和它的Morphing是不能结合的,所以我们修改了是在CreateClothingActor时,使用经过Morphing的顶点去做MeshCook,然后再使用Morphing的权重值对PhysicsAsset去做缩放,用于ClothSimulation。

效果是这样子的,能看到不同高、矮、胖、瘦的角色能够共用同一套的布料资源。

时间有限,我的分享就到这里,关于布料其实我们还有一些优化,比如布料的异步Cook,MorphTarget的流式加载,这会交给我的同事来给大家介绍一下。

郭嵩:大家好,我是郭嵩,来自安琪拉游戏公司,我今天给大家带来的主题是:漫漫优化路,突破原生引擎的性能屏障。

主要是包括两部分内容:服务器端和客户端,其中服务器端主要包括并行化的属性复制和NPC的移动,NavigationSystem的内存控制;客户端主要就包括布料实例的异步化创建、MorphTarget和Mesh的流式加载。

我们先来看并行化的属性复制,这里涉及到几个基本概念:NetDriver、NetConnection、ActorChannel。相信大家对这些都已经比较熟悉,所以就不详细介绍这几个概念。

然后是属性复制,对虚幻引擎4中的属性复制,其实就是要把Server和Client的属性重复下去,注意下面这个流程图其实就是要把Server所有的“Actor 1”同步给所有的NetConnection。从这个流程图上可以看到,主要其实就包括两部分:一个进行比较的过程,然后转到修改后的数据,另外一个就是把这些修改后的数据,然后序列化,然后发送给所有的NetConnection。

通过下面的分析,其实我们很容易就显示回答,主要是属性比较以及序列化修改后的属性这两部分。对属性比较,因为是我们的Server上存在大量的Actor和可复制的这些属性,所以说这一块是非常耗时的,还有序列化,因为它要频繁的分配和创立,所以这一块整体耗时都是非常大的。

在我们的测试时间中发现耗时将近有72毫秒左右,出现上述这种性能问题,那么我们该如何来解决呢?我们其实可以去考虑对一个比较耗时的这种任务,如果让一个人去做,可能花费的时间是比较长的,如果我们能把这个任务给分解一下,分解成多个互相关联的这种任务,然后非常多的人群,这样的耗时其实就是会大大的减少。

这其实就是一种并行化的思想,在这里我们可以用这种思想来解决这个问题,要面对的第一个问题就是怎样对并行人物的粒度划分,在这里很明显,我们可以NetConnection作为并行任务的单位,每个线程然后处理一个或者是多个NetConnection。

然后是我们可以看右边这个图例,可以看到就是线程1可以处理Connect1和Connect2,线程2可以处理Connect3和Connect4,然后就以此类推,然后可以把所有的NetConnection分配到对应的线程上去。

理论上并行任务所消耗的时间,就等于所有复制线程里面用时最长的一段时间,也等于原来复制线程的总时间除以复制线程的总数量。

然后我们来看一下并行复制的这个步骤,我们既然说要用并行去复制属性,所以说第一步肯定要创建一个Event任务,在这个任务里面肯定有同步事件,还有NetConnection列表以及进行这个复制的函数了。

然后第二步就是要把对应的NetConnection分配到对应的复制线程上去,其实就是在客户端连接服务器的时候,然后是要从所有的复制线程里面找到一个最合适的复制线程,然后把当前的NetConnection放入到该复制线程的NetConnection列表里面,然后在链接的时候将其移除。

第三步其实就是在原本的复制函数的地方,然后原本是直接复制的,然后在这里就不能这样来做了,其实首先要派发这个任务,然后然后是在结束的时候,我们再确保所有任务完成,然后整个流程就完成了。

然后我们来看一下并行任务复制前后的项目对比图,我们并行前可以看到耗时大概有72毫秒左右,这个时间其实是比较长的,然后并行后呢,在这里多开了4个并行线程,并行后耗时大概只有20多毫秒了。

然后看一下需要注意的事项。虚幻引擎4中,默认的内存分配器为了解决线上安全问题是通过加速的方式来实现的,但是对于我们并行化属性复制过程中,会频繁的分配和切换内存,从而导致频繁进出互斥锁,这就会大大影响到我们并行的效率。大家可以通过知乎上的这篇文章,这里有详细的可行性分析,大家可以看一下。

然后其实对RemoteRole的处理,RemoteRole在虚幻引擎4中,对不同的OB欧宝体育电竞官网有不一样的含义,虚幻引擎4中它的复制过程是先经过降级处理,然后再去复制的。因为我们当前是在异步任务中执行的,所以说就不能这样来处理了,其实可以通过NetOwner和bisActor这两个变量来判定是否要进行修改。

还有事注意内存的控制,虚幻引擎4中GC对带有AsynFlag的UObject不进行回收,所以这里会导致内存泄漏的风险。

还有注意对Net Driver中公用变量的处理,比如说Change List Mgr这种变量,为了避免加锁,在这里使用线程单例,也是可以用来提高并行的效率。

然后下面来看NPC的移动。原生虚幻引擎4中其实NPC的移动表现效果很好,但是在NPC多的时候,性能开销是非常高的,我们以PhyWalking为例来看一下为什么性能开销是非常高的。

首先它会根据移动的参数,比如速度、加速度进行移动。移动不是直接达到目标位置,而是要经过多次迭代。然后移动时会检测地板以及行走的坡度,还会处理移动前后的碰撞,以及化解穿透等这些比较复杂的操作。

那么为了不明显牺牲移动效果的前提下尽可能的增加NPC的数量,该怎么来做呢?

其实这里我们也是选择使用并行化来处理。在这里我们可以以单个Character为单位,并行NPC的移动。在虚幻引擎4中开启MovementComponent其实是很简单的,只需要在注册之前设置画面中的标记即可。实际上代码很简单,但是它的逻辑联系其实是比较紧密的,并且在默认情况下,虚幻引擎4并不支持MovementComponent的并行,所以要修改掉TickComponent中的部分代码。

我们来看一下,并行化移动NPC前后的对比图。

对于并行化前,然后可以看到NPC其实是一个接一个移动的,当移动到第3个的时候我们会发现,是因为N1发生了碰撞,所以它可能直接移动到其他人的位置。并行化后,因为他们可以直接全部并行的移动,所以有可能需要到下一步它才可以化解穿透。所以可以在这里可以看到对移动前后可能会有少数的差异,但这种差异对我们以前来说是完全可以接受的,应该是对大部分游戏都是可以接受的。

然后来看一下NPC移动前后的这种性能对比图,在我们的测试环境中,并行化移动前,耗时大概是52毫秒,然后在这里也是开启了4个并行任务,耗时大概是27毫秒。可能有人说看到我们耗时是27毫秒,又开启了4个并行任务,觉得并不是特别理想,觉得这个也没少多少时间,一半时间都不到。其实在这个27毫秒里面还包括了ParallelAnimationEvaluation这些任务。

然后起来看一下并行移动NPC需要注意的一些事项。

因为UPrimitiveComponent里面的OnUpdateTransform这个函数会调用Send PhysicsTransform,这个函数不是线程安全的。所以我们的做法是把这些Component指针缓存下来,然后在PhysScene_PhysX的TickPhysScene的时候再去改变,把这些应用到刚体上面。

有对某些串行的逻辑,例如Movement产生的一些动画事件,还有NPC造成了伤害的,这些也是很难去并行的。这时候要专门设计一个DeferredCommandQueue来延迟处理这些,可以看一下下面的迭代。

然后是NavigationSystem的内存控制。我们的游戏特点,首先它是一个沙盒类型的游戏,然后它还需要动态的构建房屋,然后并且允许NPC在动态搭建好的房屋进行寻路。所以首先我们要开启NavigationSystem中的动态构建。

然后因为我们的游戏是比较大的,64平方公里的,所以说开启这个动态构建之后我们发现,NavigationSystem会占用大量的内存,可以在这里看到占用了将近2G的内存,那么该怎么来解决?

后来我们通过一个代码,开启了LazyGatherGeometry这个功能,也就是在面板上可以开启LazyGather Geometry和异步GatherGeometry,所谓的LazyGather Geometry其实就是在FNavigationOctree::AddNode的时候,并没有立即去提取Geometry的信心,而是等到在BulidTile的时候,再按需来提取Geometry的信息,并将其缓存在Octree的节点上。

原生虚幻引擎4里如果开启了LazyGatherGeometry那么就要求FRecastTileGeneratorTask一次只能执行一个,从下面这个代码中其实就可以看到,如果我们只开启一个的话,性能可能就比较低,所以需要开启多个,那就需要改掉一些不安全的代码。

当使用LazyGatherGeometry的情况下,默认情况下,只要加入了Octree,它就会常驻内存,如果每次加载以后,使用完后就要Release掉,有可能会很快再次Gather到同一个Element,这样对性能的影响也是比较大的,但这也不是我们想要的。

后来我们想到可以创建一个Manager,使用内存式管理对它们进行管理。下面就是创建一个Manager来管理这些内存,首先要分配所需要的预算,然后是用LRU算法淘汰部分Data。

然后在这里可以看到,使用FNavigationCacheManager前后,使用前占用内存12.98G,使用后能降到10.1G。

然后是需要注意的事项,GatherNavigationDataGeometry的线程安全的问题,首先看ElementData,我们发现它是ThreadSafe类型,乍一看我们觉得这个肯定是线程安全的,是不是可以放心大胆的使用,但是我们再仔细来看发现这个ThreadSafe其实表示它的系统指针是安全的,ElementData本身并不是线程安全,同一个Element可以在多个线程中同时Gather到,所以我们要加锁进行保护。

然后是布料实例的异步化创建。在我们这个游戏的后期测试的时候,我们发现每次在创建角色的时候都会有明显的卡顿,后来经过分析发现,在创建角色的时候要创建布料实例,创建布料实例的过程中它需要Cook布料所需的数据,对应代码就是这一块。

那么该如何解决这一问题呢?在虚幻引擎4中常用的处理办法就是把耗时的任务放到BackgroundTask里面,把这些耗时的计算放在后台异步线程中完成,避免主线程发生长时间的阻塞。

来看一下实现的步骤。第一步我们要创建一个可异步执行的任务,在异步任务里面去处理这些部分任务;第二步在原本执行Cook的地方,然后开启一个异步任务。

第三步就是在每帧检查这个BackgroundTask是否已经完成,如果完成的话就要设置一个完成标记。

在这里有一点需要注意的,因为可能会有多个布料实例同时要求创建,这时候因为我们这里只有一个Task,所以我们需要一个Sinulation来缓存这些需要创建的布料事件,然后在前一个任务完成以后,然后再才能进行下一个任务。

第四步其实就在异步创建完成以后,通过判断ShouldSimulate,是否是可以开始模拟了。然后需要注意的事项,在工作线程中创建完成布料实例之后,有可能RenderProxy已经创建,需要MarkRenderStateDirty,然后重新创建RenderProxy进行渲染。

MorphTarget的流式加载。对于现存方面,由于我们使用了大量的高精度贴图,导致显存也是捉襟见肘的,特别对于低端显卡不是特别的友好。然后在测试中我们发现MorphTarget占用了将近500M的显存,为了提升现存的利用率,我们参考了Texture和Mesh的流式加载,然后来继续解决。

流式加载的思想,然后其实就是根据LOD的等级划分出常驻内存和可流式加载的资源,然后是每帧进行测量,根据离摄像机的远近来进行加载或者释放资源。

首先我们面对第一个问题就是,MorphTarget的内存并不是单独存放的,但是为了流式加载,必须要让它单独存放。这个其实就是在Cook的时候,在这个FMorphTargetLODModel的序列化函数里面,去把MorphTargetData放在BulkData函数上,它就可以自动的去单独存放。

然后是第二步,就是MorphTargetLODData要跟随着SkeletaMesh一起进行流式加载,就是让MorphTargetLODData在Mesh LOD的IO任务里面,执行完成以后返回到其中的第一个任务里面,这些这些任务完成以后就是去创建RenderBuffer。这些任务全部都完成后就跟之前的流程一样了,就可以开始渲染。

在这里看到它其实就是一个任务链,然后前一个任务完成以后再开始执行下一个任务。最终在RenderProxy中使用的时候,计算出期待的LOD等级,使用跟当前期待的LOD最接近的已加载的LOD来进行渲染。

需要注意的事项,因为我们对原始资源进行了修改,并且还有可能还会再次升级之类的,所以做好加上自定义版本号,加以控制,还有注意该功能和异步创建布料的时候,因为他们之前是有冲突的,所以要非常小心的去处理代码。

需要注意的事项,因为我们对原始资源进行了修改,并且有可能还可以去再次升级之类的,所以最好还要加上自定义的版本号来对资源进行控制,要兼容之前的版本,还有对该功能和异步创建的时候,因为他们之间是有冲突的,所以要非常小心的处理这一块的代码。

然后在这里可以看到优化前后的流式加载前后的内存对比图,优化前大概是500M多,优化后降到了不到2M。

Mesh的流式加载。默认情况下,StaticMesh和SkeletalMesh占用的显存也是比较大的,各个等级的LOD它全部都会加载,这些要创建对应的Buffer,放在显存上,因为资源量比较大,对LoadMap的时间影响也是比较大的。

在我们的测试中,StaticMesh占用了大概1G多显存,SkeletalMesh占用400多兆的显存。

原生虚幻引擎4中其实已经支持了Mesh和Mesh的流式加载,但是首先是一个实验性质的功能,并且它和TextureStreaming是共用同一个显存池的。在这里可以看到在这里可以开启。

对于MeshStreaming,不论Mesh高等级的LOD是否被使用到,都会加载到显存中StaticMesh的Streaming,其实是可以在DefaultEngine.ini可以去配置这些LOD Group,在LOD Group你可以指定它是否需要流式加载,或者选择它的LOD等级。StaticMesh的Streaming其实在代码里面写“死”了,只保留最高等级的LOD。

为了进一步减少显存的占用,我们对该功能是允许全部的LOD等级进行流出,可以通过右边的栏进行控制。然后创建SceneProxy,就是要判断是否有LOD加载,因为我们这里允许全部流出,所以要判断InlinedLOD,当它为0的话就不要去创建SceneProxy。

然后是对其他的一些变量,比如InLineLOD、NumMips这些变量都要进行保护。可以看到在使用MeshStreaming前后,然后使用前大概是1.5G,使用后大概是800多兆。

再次非常感谢我们公司的游戏引擎技术专家吕文伟,技术总监杨利平,《ob电竞》的制作人孟亮,还有安琪拉的所有游戏同事。

我们公司位于中国园林之城——人间天堂苏州,公司致力于高品质游戏的研发,目前已经有两款端游产品上线,公司内部也成立了新项目组,当前有游戏研发、引擎研发和人工智能游戏方向等岗位空缺,欢迎大家投简历。

如若转载,请注明出处:http://www.ashkeling.com/2021/12/464229