Epic Games何骞分享:如何用虚幻引擎蓝图做多人游戏玩法设计?

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

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

在活动中,Epic Games资深游戏策划何骞分享了虚幻引擎蓝图在多人游戏设计时的经验。

以下是演讲实录(有删减):

何骞:谢谢大家,这是我第一次在UOD这个舞台上分享一些之前的经验以及在项目里面学到的东西。

我的演讲标题“与众乐乐”来自《孟子·梁惠王下》中锁说的“独乐乐不如众乐乐”。我们在做游戏的时候,特别是我之前十一年偏重于单机向游戏的时候,可能只要去关注单人玩家当前需要去体验到的东西。而到了Epic Games的这两年多时间,我大多数时候都是在做一些多人射击类的玩法,或者是像堡垒之夜最新推出的Party World这样的一个多人在线的社交空间,让大家可以自由在游戏中互动、交流。这部分的分享就主要基于我在Epic Games这两年半时间创作的东西,以及学习到的经验。

我今天演讲的主题主要是蓝图相关的内容,特别是网络模块相关的东西,我会先从一些基础的如虚幻引擎的蓝图、网络基本知识包括一些基本的GamePlay框架与网络模块的核心概念开始。

接下来还会分享一些实战性的内容,然后会带来一个比较简单的案例解析,希望大家能理解到我作为一个策划,当我需要去创作、迭代玩法以及新的游戏功能或模式的时候,应该如何着手,如何做到多人支持。最后会有一些蓝图的最佳实践,这部分根据项目需求因人而异,但我也会为大家介绍一些比较通用的推荐做法,帮助大家更好的开发。

第一部分是蓝图及网络基本知识,这部分其实在场的很多朋友都知道,蓝图本身是一个基于节点式的可视化脚本语言。对于策划而言,这是一个灵活而强大的工具,因为基本上开发团队都会允许策划甚至美术,访问到传统的游戏开发当中只有程序员才能使用的各种概念化工具与功能。而且这个功能是可以快速的迭代出来,不需要事事都要依赖于程序员的辛苦工作。

接下来是网络部分,做过网游或者多人游戏的开发者应该比较清楚,虚幻引擎里用到的是非常传统的客户端服务端的模式。服务器是唯一权威可靠的,服务器端所作的更新会根据实际需求,同步到所有或者选定的客户端上。

当客户端需要通信的时候,客户端A不能与客户端B直接进行通信,而必须是客户端A发一个同步信息到服务器端,再由服务器端去做对应的同步到客户端B或者其他的客户端上。

我们用一个非常基础的游戏逻辑为例,当玩家移动本地角色时,客户端会告诉服务器相关的角色即将移动,然后服务器端会做对应的验证这是不是一个有效操作,以及是否有作弊信息等等,并随之更新其他客户端的角色位置。

接下来主要说到Gameplay的核心框架以及网络模块的核心概念,这也是一个较为基础的信息。

虚幻引擎中的网络模式(Net Mode)实际是描述了一台主机与网络多人游戏会话的关系,如果去看编辑器中的选项其实可以很清楚的看到其中的几个模式,如单机模块Standalone,监听服务器Listen Server、客户端Client以及专用服务器Dedicated Server。专用服务器不会出现在选单上而是当选择以客户端模式运行时自动在后台启动。

接下来这张图是我从虚幻引擎4 Network Compendium中借用的图片,虚幻引擎里的各类对象基本上是这样的结构分布在网络框架里。比如Game Mode和游戏模式相关的,它只存在于服务器;同时存在于服务器与客户端里的为Game State、Player State以及Pawn;在服务器以及拥有连接所有权的客户端(Owning Client),我们是通过Play Controller来处理;最后只针对拥有连接所有权的客户端其实就是本地UI和HUD相关的东西。

接下来会是一些补充的解释,各个游戏玩法类在Gameplay框架里都起到怎样的作用,比如第一个游戏实例(GameInstance),基本就是说每个游戏存在于引擎的会话阶段,在服务器端以及单个的客户端都有独立的实例,所以基本上是用来存储一些持久性的数据,比如和玩家生命周期相关的统计数据以及相关的内容,我们作为策划大多数时候不会接触到这一点。

而另外一个很关键的游戏玩法类,也是我们常常会进行拓展的就是游戏模式(Game Mode),游戏模式只存在于服务器上,用于存储与游戏相关的信息以及用来处理游戏核心的规则,比如这个模式里有多少人,胜利方式等,通常情况下客户端不需要明确去定义这些信息而是从服务器端同步下来。我们在做《堡垒之夜》的时候,其实我们对GameMode做了非常多的拓展,其中就包括了大钊在他后面的分享里提到的通过Gameplay Feature Plugins来拓展玩法的部分。

接下来要说的就是游戏状态类(GameState),它同时存在于服务器与客户端,可以用GameState上的复制变量(Replicated Variables)来保持所有客户端游戏数据的更新。

Pawn以及Character(玩家角色)是实际在游戏里玩家的具象化表现,用来执行游戏内角色的基本动作,动画和逻辑相关的同步等,它一般受到玩家控制器(PlayerController)的控制。

玩家控制器从游戏开始的时候就一直存在于游戏中,当需要控制的Pawn生成出来的时候可以Possess到Pawn身上去,那我们就可以同步一些相关的属性、操作等等。当然,实际游戏里面除了PlayerController还会有AIController,会针对AI控制的角色执行相关的逻辑,和PlayerController有很多相似之处,这里我们就不展开了。

最后一个游戏玩法类,则是玩家状态(PlayerState),存在于服务器和所有客户端上,支持复制变量与事件用于同步。

我们从一个比较简单的用户故事来了解当玩家客户端连接到游戏服务器的时候会发生什么。从客户端到服务器端的连接是多人游戏中一个非常基础的事件,在实际游戏项目中可能会有一些魔改掉的部分,,但大多数时候我们会有一个这样的标准流程:玩家客户端连接到服务器的时候,GameMode收到一个事件,这个事件的参数将是PlayerController的相关玩家的连接,然后GameMode就会产生一个Pawn,并且将Pawn分配给PlayerController,后者就将拥有新创建的Pawn来进行游戏。

所以我们在做网络同步的时候需要关注的一点就是,PlayerController在游戏过程中一直都存在,但是Pawn不一定,因为玩家的Pawn很有可能在游戏中死掉,然后再重生,特别是一些团队竞技类游戏中,此时前后两个Pawn其实是不一样的实例。比如说,玩家A在游戏中被击杀,然后切入到了一个旁观镜头,此时他并不存在一个可用的玩家Pawn,直到然后等到新的一局重来,或者被复活时生成新的Pawn,再由PlayerController去操控它。我们可以看到,在这整个过程中,PlayerController始终存在,但是在中间重生之前的过程中,Pawn不存在。

接下来分享一下作为玩法策划需要了解到的一些核心知识,以及如何应用到相关的蓝图逻辑里。

首先是网络复制REPLICATION,也就是服务器把对应的一些信息与数据传递给客户端,蓝图的话基本就是通过会根据相关的Actor的设置来执行复制,Actor可以很方便地打开复制功能,以及它里面需要复制的参数等内容,当有了这些设置以后我们就可以同步到其他的客户端去。

基本上我们之前提到的所有游戏玩法类,在某种意义上继承了Actor,因此当需要的时候它们都具有复制属性的能力。

接下来我们讲一讲怎么去打开REPLICATION。当选到Actor的时候,比如选到玩家Character的Actor并且勾上Replicate时,复制功能就直接打开了。这对于其他的Actor其实也是类似的,只要勾上了Replicates以及当这个Actor由服务器端来spawn的时候,就会复制到所有的客户端上去。

有的时候我们在去动态spawn Actor的时候一定要注意,如果是由客户端生成了这个Actor,那么这个Actor将只存在于这个客户端上,而不会被复制到其他的客户端。

有的时候Actor上的组件(Component)本身的属性或事件也可以支持对应REPLICATION,有的时候是在游戏过程中动态的加载spawn用户身上的组件,这个时候我们就可以通过Component相关的配置支持它,以及在游戏过程中,我们可以通过SetlaReplicated来实现相应的逻辑。

接下来就是绝大多数时候常用的变量复制,当有一个Actor被标记为Replicates时,我们就可以把他对应的一些变量复制到其他的客户端里。在Actor上对相关参数的Replication其实也很容易来实现,就是在下拉菜单中默认的属性是None,并不会复制到其他客户端里,但是可以把它选择成为Replicated或者是RepNotify,此时就可以支持网络复制了。需要注意的是,所有的变量复制都是可信(Reliable)的,这些变量复制就是说无论网络上遇到多少丢包的情况,最终我也能保证能复制到到对应的客户端上,所以我们常常通过变量复制来同步像玩家血量、护盾值这类关键的属性。

与刚刚的说的一样,当我们选择了RepNotify的时候会自动生成一个“OnRep_变量名”的函数,这会允许我们的变量值发生变化而被复制的时候,运行一些定制的游戏逻辑。作为常见的就是玩家的血量发生变化的时候,要去更新一些HUD相关的信息。

在Replicated Variable上还有一些选项就是条件属性复制Replication Condition。因为我们当某一个变量或者参数被设为Replicated的时候是不能取消的,但它又是在游戏中经常会被改变从而有大量的Replication发生,占用游戏的网络带宽。通过条件属性复制我们就可以选择,这个属性的复制是在什么情况下才被允许的。这个属性可能大多数时候都不会被策划直接用到,但我们可以针对项目本身的需求做一些标准的优化配置:比如说对玩家名字这种游戏过程中不发生改变的属性,我们只需要在游戏开始时同步一次即可

接下来是一个比较重要的话题就是RPC(Remote Procedure Calls),简单说来,RPC就是我们能够控制的、唯一的,由客户端去调用服务器端相关的函数逻辑,它允许客户端或服务器相互发送信息,主要用于不可靠(Unreliable)的游戏事件:比如客户端发一个信息告诉服务器端播放某一个特定的声音或者产生粒子,以及这个粒子的位置应该发出来那个事件的客户端所在的位置。需要注意的是,RPC只能通过可以复制的Actor,就是Replicated Actor调用,如果这个Actor本身不是Replicated的,那服务器端就接收不到的相关的RPC事件。

打开RPC的时候会发现都会有这样的选单,默认的函数是Not Replicated也就是不会调用,接下来的选项包括Multicast,当服务器端调用这样的事件时,所有的客户端以及服务器端都会执行对应的逻辑,再者就是只在服务器端Run on Server以及Run on Owning Client只在当前操作的客户端上的相关逻辑,这些都需要在游戏中以具体的玩法来驱动。

我们常常在游戏中会遇到RPC失败的问题,这个时候往往是由于当时所有权(Ownership)而引发的,因为RPC需要确认哪个客户端要执行RPC,或者Actor本身是不是支持Replication,以及在涉及到Owning Client上Actor复制的条件,这些都是在Ownership中有所控制。

在游戏里面对于客户端与服务器端基本上都会“拥有”一个Actor,网络游戏里面的Pawn被PlayerController所拥有,当Pawn调用一个只属于客户的函数,任何时候无论哪台及其调用,它都将只指向拥有该Pawn的机器。

关于这点,一个常见的做法是,游戏里面有很多有交互的元素和Actor,基本上都会被服务器端所拥有来处理相关逻辑并且复制到所有的客户端上。最典型的就是门,我们在游戏中需要知道门是否打开、是否关上,而这些属性都不应该在某个特定的客户端里拥有,而是客户端从服务器端复制下来的时候,服务器会告知这个门是开着的还是关着的。

我们在优化的时候会有很多网络相关性(Relevancy)或者网络更新优先权(NetPriority)相关的设置,这决定了我们在复制某个Actor的时候可以用怎样的频率去Replicated。通常情况下,PlayerController设为3拥有最高优先级,基础的Actor设为1,如果根据实际情况有具体需求,我们的官方文档其实讲解的会更为详细,这里就不再多展开了。

接下来是RPC的一些注意事项,比如在用NetMulticast的时候,它是怎样在服务器端执行的,从服务器端调用RPC,如何Replicated到客户端上去等。

需要补充的一点是,RPC在蓝图与C++的代码里可能会有些不同,首先在蓝图逻辑时,NetMulticast在所有客户端以及服务器端执行,但是当我们在代码里定义对应RPC的时候其实默认它只在客户端上执行,除非你在代码里明确定义这个操作需要在服务器端也执行。

接下来通过两个简单的案例看一下处理Replication需要怎样操作。

第一个案例是虚幻引擎里官方的第一人称玩法。刚才有提到,第一人称玩法里,默认是不支持Replication,如果打开蓝图会发现,所有东西都只发生在本地客户端。

我之前做了一个很简单的案例,把相关的逻辑从客户端上转移到服务器端上。我们在实际游戏里就能看到,所有的这些相关信息就已经正常通过网络进行了复制,但这里做了小的调整,比如我不希望玩家一直发射抛射物,所以我做了一个额外变量,当发射完一次以后要等到先有的抛射物消失才能使用第二次。

第二个案例分析是一个受到吃豆人99影响的案例,这个例子和之前不大一样,在游戏过程中,我们去Replicated的时候,不用去管本地端的一些操作逻辑或者位置信息同步,而只需要关心一些关键事件的同步处理。游戏的一些基本规则,是通过其他的一些特殊的蓝图类上的方法来进行Replicate。

在游戏实际的逻辑中,我是在服务器端生成一个定制的GameMode Manager来去同步相关数据,本地端所有的操作,其实其他玩家都是看不到的,因为我们并不需要同步这样的信息。我们可以很容易地通过这种方法来支持类似《马里奥35》或者《俄罗斯方块99》那样的异步吃鸡的玩法。游戏过程中需要做的所有同步,其实不需要同步玩家本地操作到另外一个客户端上去,而在需要的时候,比如玩家吃到一个很特别的道具时,通过RPC的方式,把对应的一些信息发过去,而且这个时候RPC可能会需要去设置成Reliable,因为我们不希望在实际游戏中丢掉信息。

对于在Epic Games工作的策划,无论是玩法策划还是技术策划,蓝图的使用都是一项非常基础的工作技能。而在最后面向玩家发布的玩法和模式中,只要不出现严重的性能或者是反作弊相关的问题,我们也尽可能保留蓝图驱动的逻辑。

最后一点会谈到蓝图的最佳实践,这部分知识因项目而异,我尽量分享将一些比较通用性的知识分享给大家。

第一个点,是蓝图逻辑的组织。而最重要一点我们会叫做可读性为王。就是要去写蓝图的时候,建议大家多活用蓝图的函数来封装脚本的一些功能,然后去管理一个良好、清晰的蓝图布局,在使用注释的时候也多注意一下,比如说可以多用统一的注释区块,通过Route节点来保持简洁的布局。

最后一点在多人合作的中大型项目中比较常见,我们写脚本需要尊重别人的劳动成果,我们在去更新别的开发者写的蓝图逻辑的时候,最好是保持相似或统一的可读性,否则等你改完别人回来,那个人又完全不知道蓝图变成什么样了,反而会带来更严重的问题。

第二点,在组织蓝图脚本的时候有一个针对网络逻辑相关的建议。当给一个网络同步相关函数命名的时候,建议大家命名成非常清晰的独立函数,比如说可以明确的前缀来标记RPC类函数,“Server”、“Client”、“Multicast”,对于某些只在服务器端或者客户端上运行的非RPC函数,也可以考虑在函数名称前面加一个“ServerOnly”或者“ClientOnly”的前缀。

最后一点其实没有强制的要求,但是我们所总结出比较容易满足可读性,就是如果跟网络逻辑相关的逻辑脚本,使用一个彩色的注释,Run On Server、Multicast等时候用不同的语言,在打开蓝图脚本的时候第一眼就能看出这个逻辑应该是跑在哪一块,如果出问题的可能会出现在什么地方上。

接下来是与蓝图相关的网络同步功能使用建议的DOS和DONTS,有些规则希望大家能够有所理解。

我们所谓的铁规第一条,即是始终认为客户端本身是不可信任的,因为有些玩家的操作可能会频繁向服务器端发起同步,同时也可能会有一些作弊等问题,所以我们在蓝图上通过RPC对应函数的时候,需要去验证客户端调用是否是实际有效的调用,确保没有作弊或者调用时去大量占用服务器的带宽。

在实际过程中也会通过IsValid检查或Validated Get来验证变量/所有者的可用性。还有一点是只有在必要的时候向客户端发送RPC,因为RPC很有可能会阻塞整个同步的带宽,切记发送RPC的时候不要扰乱实际网络使用,也不要直接发送大量的包括数组在内数据。

最后一点,我们在游戏过程中可能需要考虑的,尤其中途加入到游戏进程的时候考虑的一些同步问题,就是游戏中途加入(Join-in-Progress/JIP)。任何在JIP之前发生的复制事件,特别是RPC事件是不会对新玩家执行的,我们这个时候可能就需要通过变量Replicated Variable同步游戏关键的一些重要数据,包括玩家的血量护盾等,实际在项目开发的时候,也可以自己去定义另外的一个Component来实现同步功能。

最后会说一个跟性能相关的,这个可能说也是很多的同学,在使用蓝图的时候会比较担心的一个问题。在查阅官方文档时,我们可能会发现里面提到过蓝图的实际执行效率只有C++的十分之一不到,甚至可能更低,当然这也取决于实际使用什么样的计算方式。

基本上我们在实际使用时,会尽量避免在蓝图里面使用Tick或高频率事件的更新,以及在蓝图里面做一些很复杂数据计算或者很复杂的函数逻辑。

最下面三个图是大概一年半以前我自己在项目内部做的性能比较。这些数据是在客户端跑了很多遍执行很多次以后所取的均值,所以应该能有相当高的参考性。

大家可以看到,在做一些空循环或者是一些遍历操作的时候,蓝图的性能相较于C++代码确实很低,但当我通过蓝图去调用某一个具体的函数调用的时候,其实性能不见得比C++或者其他的脚本语言低,所以我们会建议大家把一些性能敏感的遍历或者排序操作放在代码里去实现,而其他的一些玩法逻辑则完全可以通过蓝图来处理。

针对上面的Profile结果,有时候做蓝图性能测试需要注意,如果你在编辑器里面,去看蓝图性能的时候,最好不要去打开蓝图的编辑器,否则这会自动加载蓝图脚本的debug信息,使得性能相比cooked版本而言大幅度降低。

最后一部分和性能相关的最佳实践,也是其实在游戏中会需要注意到的优化知识。

首先第一个点是避免在蓝图类库或蓝图宏中引用复杂的数据类型,否则每次打开蓝图时都会花费更多的事件。比如说如果你在蓝图类库里直接引用了一个data table,那很有可能在打开蓝图的时候会增加10来秒的读取事件。

另外一点则是尽量避免过度的使用Casting,而应该要通过蓝图的接口和Gameplay Tags来处理相关的判断逻辑。

最后在结束之前,我想给引擎商务团队以及Epic Games总部的Billy Bramer致谢,非常感谢他为我审阅这次分享的PPT以及提供的一些宝贵意见,同时我这里分享的很多内容也来自于跟他一起合作进行游戏开发的经验。谢谢大家!

如若转载,请注明出处:http://www.gamelook.com.cn/2021/12/464705

关注微信