祖龙技术总监:我们是怎么做“开放世界”的?

2020-11-29 18:49
来源:手游那点事

为期两天的Unreal Open Day Online于今天圆满落幕。

在昨天下午的主会场上,祖龙娱乐引擎技术总监王远明以「祖龙娱乐使用UE研发开发大世界的实践」为主题,分享了研发开放大世界可能会遇到的难题,对应的解决方案以及后续优化。

以下是手游那点事整理的部分演讲实录,为提升阅读体验,内容又删减和调整:

我是祖龙娱乐引擎技术总监王远明,在游戏行业里有10余年的工作经验;加入祖龙娱乐之后,负责开发了《六龙争霸》和《梦幻诛仙手游》的底层架构;之后使用UE4开发了《龙族幻想》的底层架构;现在在《诺亚之心》项目组为引擎开发各种训练效果以及优化工作。

今天我要分享的主要内容是如何使用UE4开发移动端的无缝大世界,重点是如何使用UE4和Houdini配合的工作流,以及大世界的运行加载体制和优化。

超大世界的MMORPG有很多优势,包括更加真实的游戏体验,相比场景比较小的游戏,不需要频繁的场景加载,在减少Loading的情况下,让策划有更多空间设计更加丰富的游戏玩法,带给玩家无与伦比的真实体验。

一、研发开放大世界需要解决的问题

大世界需要解决一些很实际的问题。

首先是场景的创建、编辑和开发迭代。

大世界不同于小的世界,后者可以用美术手工去拉地形、刷纹理、铺植被,但前者的工作量非常大,并且对真实感的要求也非常高,使用这种方法是非常低效的,并且不会带来很真实的体验。

另外,大的世界我们一般采用分块的方式来制作,这就涉及到多人协同的问题。以前每个场景都是独立的,那么由美术的不同人员来做的话,彼此间的工作是独立的。但大世界不同的分块有相连的关系,这样就要求我们要处理好接缝的问题。

还有就是光照。小的场景光照比较小,烘焙的方式也比较简单,单独的场景加载进来之后就可以做一个LightingMap的烘焙;大型场景由于分成不同的区域,采用这样的方式就会产生一些问题,比如同样一棵树,它可能会投影到两个或者更多的场景块里面,这样在烘焙单一的地形地块的时候,就需要做一些额外的特殊处理。

大世界研发还涉及到怎样迭代开发和二次编辑的问题。对大场景分块来讲,如果编辑之后需要二次修改,要怎样去解决工作流,怎样方便地进行更新,使游戏最终的配置包尽可能地小,这也是一个需要解决的问题。

大的场景还会对大范围植被编辑与渲染功能有很高的需求。如果你的场景比较大,植被范围却比较小,那它也是很不真实的。

此外,场景的实时加载和卸载也是大场景游戏需要解决的一个问题。

玩家在大场景游戏中运动会涉及到场景的加载和卸载,因为加载量非常巨大,所以会造成一些卡顿。并且当需要加载特效或者模型时,可能会出现加载不及时的问题。

因为模型、贴图、地块这些都非常占内存,导致内存变大了,那么怎样去解决这些内存问题也十分关键。

另外还有远景。场景大了之后,为了营造一个逼真的感观,视野必须要放得无限大,而不能把远处的山或者其它什么通过一个视距的Culling裁剪掉。这样做的话,当人物向前跑动的时候,远处的山会逐渐进入到远平面以内,这样会有一种远处的山在慢慢生长出来的错觉。

还有一个亟待解决的问题就是我们的运行效率。大场景意味着大的模型数量、大的面数以及很高数量的DrawCall,还有CPU的消耗,场景管理的逻辑会变得更加复杂。

二、解决方案

接下来,针对刚才提到的每一个问题,我为大家逐一介绍一下我们研发大世界的解决方案。

1.Houdini创建真实地貌

首先是我们怎样去创建一个真实的地貌。

我们现在采用的是Houdini这样一个第三方软件工具,基于节点模式工作,也就是说它的所有修改都是一个节点,可以串行化、并行化操作,经过层层的节点的不断计算形成一个最终形态。

那么当你修改了节点时,地形的计算结果会实时发生变化。因为当你保存时,它并不是一个最终的形态,这样在源头上改变最初的输入,变化也会反映到最初的节点。

其次,所有的操作、模型地形,都是通过扩展名为hda的文件来保存的。我们的解决方案都是基于创建一个一个的hda文件。

我们在用Houdini创建出大世界之后,最终的目的是要把整个游戏场景拆分成很多的小块,这样才能做到逐一加载、逐一卸载。有了分块之后,我们的解决方案就包含了分块的加载和每一块的单独编辑。

当我们单独编辑每一块的时候,并且是多人协同的时候,相邻的块有可能被不同的美术编辑到,这就会遇到一个边界融合的问题,我们也提出一个相应的解决方案。

还有很多对象是跨很多分块的,比如说路、河流等,这就要求我们要有一种方法能够对这种跨块的对象进行编辑。

真实的场景要有一套比较完善的真实的地貌系统,那么我们改进了UE的植被系统,使它的工具可以和Houdini进行结合生成最终的地表植被系统。

场景是需要有LOD的,这对我们节省内存、节省DrawCall、提高渲染效率是非常重要的。

当我们编辑好场景之后,也要有相应的解决方案来发布场景,之后还要考虑怎样去迭代它。因为场景不可能是一成不变的,我们需要在后续的资料片或者更新包中修改地形。

所有的这一切做完之后,它其实就是一个完整的工作流。

2.大世界的加载机制

大世界分块之后并非静态,而是由动态的加载机制加载到游戏里面去,它有一个很重要的因素,就是视距要无限远,这样才能有一个一望无际的真实体验。在这样的情况下,我们怎样去设计它的加载机制?

我们要加载不同的LOD,因为可以节省内存。在远处的地方,加载最差一级的LOD,在较近的地方加载精度稍微低一点的地形块,在最近的地方加载全精度的。

然后我们并没有使用World Composition引擎自带的加载逻辑,而是实现了一整套全Lua加载逻辑。

3.运行效率

运行效率是十分重要的问题。大场景需要加载很多分块,DrawCall数量自然也多,我们主要采用Hierarchical Instance的方式。每一个分块最多有一种类型的Hierarchical Instance,保证以这种渲染的物体,一个分块只贡献一个DrawCall。

对于较远处的小物体、低精度物体,我们是不投射阴影的。此外我们会分类别设置不同的Culling Distance。

光照我们采用动态和静态相结合的方式,主要是考虑到运行的消耗。因为我们想尽可能创建一个真实的世界,所以会有很多的树木、山体等,如果采用全动态的光照系统,DrawCall可能会扛不住。

在比较远的地方,我们采用完全的ShadowMap,在近处采用动态阴影。因为没有采用LightingMap的方式,所以我们的产品在户外是没有间接光照信息的。

4.大世界的创建和拆分

大世界的创建和拆分,我们分为三个层次的体系结构。我们先想象一下刚开始的工作流:

当我们要创建一个大世界的时候,肯定要先把整个世界的轮廓创建出来,此时我们会创建一个大世界hda文件,这里面会有基本形状和地貌特征。

然后我们再把这个大世界继续拆分成不同的区域,比如雪地、沙漠、绿洲等,这样就能对区域的整体风格有一个比较好的把控。

当我们拆分出很多不同的区域后,对同一个区域还会继续拆分最小的一个编辑单位Tile.hda,也就是我们最小的加载单元。上图每一个分块中的Tile.hda,最终将会对应到UE的一个ULevel。

三、实际操作

上面粗略地列举了一些我们的解决方案,接下来我会重点介绍每一个解决方案的具体实施过程。

1.分块加载和编辑

刚刚提到,最终的Tile.hda是最小的编辑单元和最小的加载单元,它在拆分之后会对应UE4的一个level。在level里面有一个叫做UHoulandProxy的Actor,作为一个UE对象,起到和hda共同交互的桥梁作用。

在它的Proxy身上,我们会利用Houdini的各种插件提供的API来编辑当前的Tile.hda文件。编辑好之后,通过Houdini的API产生最终的程序化的数据,再用这个数据来刷新UE4的地形,最终通过UE4引擎将地形地貌渲染出来。

这里需要注意的是,我们每次的编辑依然是以节点的方式保存修改,每一个节点就是一个以hda文件的形式保存的一个Houdini文件。而在level里,我们按常规的方式编辑里面的其它对象。

2.边界融合

因为多人编辑多个相邻地块,之间会有一些不一样的地方,那么我们就需要使用工具来做一些边界的融合,主要分为两个方面。

一个是Tile之间的融合,比较简单,分为高度融合和地表纹理的融合。高度主要由工具进行自动对接,之后再做一些随机的处理;地表纹理的过自动过渡,就是相邻地块做一个混合系数的从0到1的渐变。

另一个是区域之间的融合,比较特殊,因为风格差异较大。我们在Tile高度缝合之后,会在高度上做一个比较大的扭曲(区别上面Tile较小的高度调整),这样会给人一种比较真实的感觉。

还有一个需要注意的是,因为不同区域之间的风格差异比较大,我们也要增加纹理的过渡范围。下面给出两个示意图演示Tile之间的过渡情况。

可以看到,左边的图其实是一个简单的高度融合,但没有做高度的调整,可以看到很明显的绿色边界;右边的图做了高度的调整,也做了纹理的渐变,所以看起来就比较真实。

3.跨分块对象

河流、海、路这些对象都不是只布置在一个分块里面的,那么我们怎样去编辑这些东西呢?

首先还是离不开hda文件。我们在UE里面有一个对应的Actor,作为UE编辑器里提供编辑方式的图形化工具,你可以在这个对象上加入很多的节点,然后用贝塞尔曲线连出一条曲线,然后分别对不同的河流、海、路这些提供不同的参数。

有了这些参数之后,这个Actor会去负责生成Houdini的一些hda文件,有了这些之后,我们再调用Houdini的API,最终影响到大地形的生成。

比如路,其实不是一个新的对象,不是一个Mesh,那么它其实影响的我们是我们的地表生成,通过地表的贴图的混合,模拟出一条路。这个是通过我们连的线,还有各种参数来影响地表生成的。河流和海也一样。

4.大范围植被

大范围植被能非常强的增加场景的真实感。

我们通过Houdini的程序化生成工具来生成植被信息,然后由插件根据这些信息来修改UE的Landscape里面的Grass Type数据。有了这些数据之后,用UE自身原有的功能就可以生成每一个地表上的植被系统。

在UE编辑器里我们还需要设置一些grassArea,这是我们自己加入的一种类型,用来决定哪里生长草,哪里不生长草。

5.场景发布&场景迭代

在经过了这些比较复杂的操作之后,最终需要把场景发布出去。前面我们讲了,一个可编辑的Tile.hda最终对应的是一个level,这个level实际操控的是一个hda文件,并不是一个可以用于发布到最终游戏包的资源,这就涉及到一个发布的过程。

发布的过程是将我们在Tile.hda上面所做的基于节点的修改应用,从而产生最终的效果,然后用这些效果,比如说地表上混合的地貌、形状,用这些数据生成UE相关的Uasset。这些Uasset就可以完全脱离Houdini的API,被最终的UE引擎加载运行。

发布过程还涉及到计算Tile的各个LOD的问题。因为一个Tile就是一个ULevel,那么它需要计算两级精度的LOD(1和2),LOD1用于较近地块的显示,LOD2就是一个纯粹的远景。然后我们还需要Cook每一个Tile的光照信息。做完之后,最终每一个ULevel生成一个对应的发布版本。

发布之后,我们肯定还需要对场景做一些修改,那么就涉及到场景的迭代。在迭代时,尽量不要修改整个世界的地貌。如果你修改了大世界.hda,就要涉及到重新批分、批拆,拆成不同的Area,那么整个的工作流就会很长,影响面也会比较大。

如果确实有修改需要的话,我们的供应链也是可以支持的,因为我们编辑的时候,所生成的节点都是基于操作的,那么我们在场景迭代的时候,对大世界的地形地貌的修改,其实也是可以反映到最后的Tile.hda上。所以这也是我们基于节点的方式保存操作,而不是保存最终修改状态值的好处

6.DrawCall

好的效果、逼真的场景,它的DrawCall无疑会非常,那么我们怎么减少DrawCall呢?

首先我们大量使用了Hierarchical Instance这样的组件,既能够省内存也能够省DrawCall。

然后基于分块的优化策略。因为我们加载的最小单位是一个Tile.hda,也可以说是一个单独的ULevel,那么我们必须基于分块来进行优化,尽量减少这一块里面的静态模型类型数量。

我们还开发了自动合批工具,因为在美术的开发过程中,未必会完全把一个模型通过Hierarchical Instance的方式合批。

在较远的地方,我们加载LOD1的模型,通过Culling掉大多数的模型来减少DrawCall;LOD2的分块模型和地形有的时候可以进行合并,这样就只有一个DrawCall。贴图同样需要合并,才能达到节省DrawCall的目的。

7.优化

由于在游戏过程中会实时加载分块,这样就有可能会导致卡顿。

因为场景非常多,导致我们无法预先加载过多的内容,如果采用实时同步加载的话,会造成很严重的卡顿情况,所以需要采用更多的异步加载方式,需要哪个加载哪个。

大世界另一个问题是内存压力变大,因为我们要达到一个无限视距,所以所有的块都要加载进来,至少也得加载一个LOD2的精度,所以我们有更多的静态模型,内存压力也很大。

我们的解决方案是做一个Mipmap的剔除,减少模型的种类。贴图的内存占用量是非常大的,当我们做了Mipmap的剔除后,内存得到了很大的改善。

另外将资源分级,设置到不同的Streaming Level里面,占用内存大,Streaming Level的量要多一些;某些精度比较底的我们就不加载,这样能够达到省内存的作用。

还有为每一个分块制作不同的LOD级别,通过改变我们的加载距离起到节省内存的作用。比如当内存小的时候,本来应该加载LOD0,我们就加载LOD1。

分块多了之后,我们的CPU也是有开销的,我们要Disable不必要的对象的更新,同时减少网络协议buffer的拷贝次数。

这里做一个总结:大世界会带来内存激增,可以通过分不同的LOD、减少模型类型、分多层Streaming Level、制作简化的碰撞体、Strip掉多级MipMap等方式节省内存。

今天的分享就到这里,谢谢各位。

Ben

Ben

线上线下专访、稿件发布合作请联系QQ或微信:328624956

评论已关闭!

相关资讯