Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化

这是侑虎科技第612篇文章,感谢作者放牛的星星供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/niuxingxing,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!


本系列前两章相关内容之前已经推送,可戳此回顾,本文由第三章开始。

三、逻辑与表现分离

逻辑和表现分离有时候也叫业务和数据分离。在讲这部分内容的时候,我想先讲一讲网络同步这部分的内容(关联性还是挺大的),但是一展开就要跑题了。目前这个系列分为六章,最后看看要不要加一章跟ECS不相关的部分来讲状态同步和帧同步。

这个概念非常好理解,如果简单地描述,就想象一下主机和显示器的关系。所有的运算、输入、结果全都来自于主机,并且它完全不关心你用的是显示器还是电视机亦或是传真、打印机。谁适配了我的接口,谁就可以按照自己的意愿去输出自己个性的表现。

如果复杂地描述,就想象一下状态游戏里,服务器和客户端之间的状态同步(所以我想先讲网络同步)。服务器告知客户端,场景(0,0)位置有个玩家,穿着大裤衩,背着双肩包,一双洞洞鞋,并且发际线还很少。客户端根据服务器下达的指令加载了一个程序员。下一秒,一个策划走了过来,修改了一个需求,程序员走到位子上打开电脑开始工作。

在不影响逻辑的情况下,表现层可以自己发挥。比如,策划修改了一个需求之后,程序员可以先“呸”的一声吐口痰,然后再走到位子上。如果他“呸”的这一下会导致策划暴打程序,那么这个过程就必须交给逻辑层去控制,如果没有影响,就可以表现层自己发挥(这就跟我们在战斗里,逻辑层不使用RVO,而表现层可以添加一样)。

如果再往更复杂一点的地方想象,就是帧同步的游戏下,服务器完全不参与计算,只同步所有玩家的输入给客户端,客户端需要自己在内部去维护一个逻辑层来控制确保计算精准(不精准就会导致不同步),表现层根据逻辑层的状态来表现和展示。

1、为什么要做分离?

拿帧同步来讲,如果做逻辑表现分离,等于要在客户端额外写一套服务器出来,花这么大的代价,那么带来的收益是什么呢?

(1)解耦逻辑和表现分离的基本原则就是逻辑层能掌控一切。表现层受逻辑层驱动,在不影响逻辑的前提下自主表现,那么就要求逻辑层一定要能完全脱离表现层独立存在。如果我们把这个逻辑层放在服务器,那就是Client-Server模式;如果放在客户端,那就是帧同步的制作方式(王者荣耀就是这么做的)。

(2)安全既然能解耦了,那么就可以让业务更加安全。安全来自两个部分,一个是CS模式下对数据和外挂的安全,一个是帧同步模式下,表现层的Bug影响到逻辑的安全。

(3)倍速如果只操作逻辑层不考虑表现,那么就可以通过加快或者减慢逻辑帧率,快速实现0.5倍、2倍、4倍等变速运算,也可以进行秒算验证结果。

(4)回放数据是独立存在的,运算和输入也是固定的,那么只要保证逻辑计算一致,得到结果必然一致。所以只需要保存很少的初始变量和中间输入就可以完成整体回放,数据量还特别小。(想想War3一场战斗40分钟,录像文件只有200K)不过这里要提一个缺点,那就是版本和数据必须一致,否则计算就会不一致。

(5)移植表现层可以根据自己使用的开发引擎做快速移植,而不需要修改整体逻辑。

2、PVP和PVE架构

说架构之前,先说战斗需求。我们是一个SLG的游戏,PVP的战斗在于大地图的掠夺,是自动战斗无需玩家操控的。PVE的战斗是手动,当然也可以自动,区别也不大,就是英雄技能是手动释放还是自动释放。综合需求可以得出,PVP由服务器计算(涉及离线和安全),PVE由客户端计算,然后将技能释放的相关信息记录,一起发往服务器验证。

来看两个流程图:

 

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第1张图片

 

 

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第2张图片

 

这种实现方式,战斗完全在客户端写,又高效又安全,查Bug简单,还节省服务器人力。

3、逻辑帧和表现帧

帧的概念大家都很清晰了,逻辑帧的意思就是逻辑层的帧率,表现帧就是在表现层的帧率,那么为什么要区分它们呢?因为设备的性能存在差异,同时逻辑帧的一致性才能确保计算准确。

一般设备能流畅跑游戏的帧率是24帧,但是大部分时候我们会以30帧作为标准。高端机型会开60帧或者不设上限(高帧率意味着计算量更大,耗电和发热量也会更大)。

而逻辑帧往往是用不了这么高的,士兵攻击频率1秒/次,是不用每16ms(60帧)去计算一次的,我的项目设置为15帧已经可以满足了,那么表现层其实是需要对某些表现做插值处理的,最明显的就是移动。

移动速度假如是60m/s,逻辑15帧、每帧跨度4m,如果不补帧看起来就像是卡顿。所以表现层是要根据自己的帧率对移动进行插值,保证平滑。

逻辑帧是独立驱动的,所以它有自己的核心逻辑。

代码如下:

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第3张图片

 

TotalPassTime是当前已经过去的总时间,下面接着是一个while循环,循环的判定条件就是:当前pass的总时间只要大于下一帧的时间就执行逻辑帧。这样的目的就是为了解决“因为某些帧的间隔过大而导致逻辑帧的波动”问题,简单来说就是追帧。战斗到现在已经过去10秒了,理论上有下一帧是151帧,然而这个时候,因为某些原因才计算到100帧,那么接下来会在while里循环直到追平当前帧。这也是服务器能够秒算(给一个初始非常大的pass值),以及客户端能够实现倍速战斗的原因。看下面的代码:

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第4张图片

 

UpdateLogic的客户端逻辑是由deltaTime和clientFrameDelta两个部分来控制的,deltaTime*控制倍率能控制每次补偿的时间差(实现倍率播放),clientFrameDelta则是初始化帧的进度值(实现秒算)。

战斗最核心最难的地方已经讲完了。下面来看看实战代码里的部分优化。


四、实战ECS架构和优化

设计思想和插件介绍完了,那么就需要看看实际项目怎么去使用它。

根据策划和服务器大佬的评估,正常情况下每秒发生的战斗约2000场,我们的服务器预估为8核,如果每个核起一个战斗线程,就可以同时并发8场战斗。如果每场战斗花费50ms,那么一台服务器一秒只能计算160场,就需要13台服务器,花销比较大。如果每场战斗花费20ms,那么一台服务器一秒能计算400场,需要5台服务器即可,似乎就能接受了。

所以我们的目标就是让服务器(逻辑)每场战斗的耗时保证在20ms左右(现在也基本达到了)。

1、客户端编程与服务器编程

虽然都是码代码,但服务器的编程思想和客户端还是有一些差异的,服务器更多是无“我”概念(和客户端帧同步的处理有些相似),而客户端则以“我”为核心。比如,一个联盟的功能系统有人晋升了,它给所有的联盟成员推送的协议都是一样的,但是在客户端,你需要跟自己的ID进行比对,如果是别人晋升了,那我只要改变一下别人的显示头衔;如果是自己晋升了,那不仅要改变显示头衔,还需要处理自己的各种权限按钮,甚至是一些特殊权限才能查阅的功能界面。

服务器的大部分更新和逻辑都是由驱动完成的,而客户端在线时期,往往都是即时计算和展示的。这些驱动可能来自某个玩家的操作请求,某些定时器(这个功能不太常用)的触发。比如,一个联盟如果100天都没有人上线,那么它就会被自动解散。你可能会认为,这些100天没有上线的联盟一到100天就会被服务器清除,但其实并不是。大部分时候,它们会静静地躺在数据库里,直到这个僵尸联盟里的一个成员诈尸上线,服务器一检测,你们联盟200天没人上线了,立马解散,然后给所有成员发送邮件,从数据库清除。当然那些挂机或者放置类的游戏也是一样的,用玩家上线的事件驱动来完成逻辑。客户端在这部分上要求就比较高一些。比如,一个界面打开的时候,如果数据发生变化了,都要求即时体现在逻辑和表现上。

二者在添加变量以及类型上面也会有所区别。客户端可能为了展示方便,在某个功能系统里随便加几个long或者double类型的变量。但是服务器不行,它必须得考虑必要性,假如一个系统我加了一个long类型,我同时在线有多少人,那么我的内存就要消耗多少。

客户端总是以服务器的数据作为准确输入,而服务器原则上除了操作和交互的请求类型之外,不相信一切客户端数据,总是以自己为准。如果有些服务器的开发人员为了省事不去自己的库里捞数据,让客户端带给他,然后用这个值去判定,就......

前面的铺垫其实是为了引出我们多线程理解的差异。正如开头所说,服务器的多线程其实都用在了开辟每条线程做并发服务器了,而客户端的多线程大多是认为自己独占了所有核,避免出现一核有难,多核围观的情况。鉴于我们的战斗要跑在这样的服务器类型下,多线程的编程反而是个累赘、负优化。

2、逻辑部分拆离

ECS中的逻辑都在System里去处理,但是Entitas里的System是需要注册才能用的,所以就出现了一个用来管理System的System,为了区分我们叫它Feature。Feature用宏来决定是从Entitas.DebugSystem中继承还是从Entitas.System中继承,如果是前者就会看到第二篇里的各种调试信息。

Feature可以拥有多个,每个之间是独立逻辑,这里用来处理战斗的不同阶段。如下图所示:

 

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第5张图片

 

对于服务器的需求,优化要精确到ms级别。所以每一个能够挖掘的部分都必须深入挖掘。像Entitas的Entity初始化就需要10ms的地方,我们自然要将它从Battle部分拆离出来。所以结合实际的战斗流程,我们拆离了3个Feature(System),一个是用来做初始化的,一个是用来做阵型操作的,最后一个才用来做战斗计算。

可以简单看一下EntityCacheSystem:

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第6张图片

 

很简单,初始化500个Entity,然后销毁,这样池里就有500个缓存,避免了战斗进行过程中创建Entity的时间开销。

DataSystem其实是管理战斗所需要的各种表格数据。比如,兵种属性、技能、英雄、阵型等等。这部分因为要放在服务器计算,所以必须要完备。如果依赖外部,数据结构和处理就会变得异常复杂,所以我们会将跟战斗相关的数据分开加载,确保战斗模块的数据独立。

ReadySystem(Feature)就是战前布阵阶段,这个阶段还没参与正式的战斗,但是已经有单位和阵型表现了,并且单位会延伸到战斗过程中(布阵好了之后点开始,战斗会直接接管布阵的兵力和阵型),AI的行为会在这个时候进行筛选。

几百个独立单位的AI是非常耗时的,我们不可能把这部分放到战斗内去(不然20ms的高压线肯定完不成)。所以,我们其实知道对面是什么阵型,知道自己现在什么阵型,每次变阵或者调整兵力的时候,扫描一遍AI,把每个单位的初始AI放在这个部分去完成(如果AI的目标单位死亡,寻找下一个的时候就没办法了,只能从算法和规则去优化)。

这部分的最后,可以给大家展示几个简单但是作用非常关键的System。

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第7张图片

 

逻辑帧记录器,记录当前执行的逻辑帧数,用于同步技能释放,估算当前总耗时等等,逻辑非常简单,每帧+1。

 

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第8张图片

 

战斗结束监听,每帧根据指定条件判定是否结束,大致分为以下几种方式:

1、时间到了;

2、某一方全部死完了;

3、GM强制输赢。

比较简单,注意Group是在构造函数里做的,所以不会有额外消耗。

 

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第9张图片

 

战斗日志,可以看到我们使用了一个GameServices统一管理了需要输入和输出的部分,下面会讲一下这里实现。现在大体就是监控到有新日志就输送出去,用的是ReactiveSystem类型,所以没有日志的时候没有消耗。

 

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第10张图片

 

DestroySystem监控需要销毁的Entity。比如,死掉的士兵、客户端表现的弹道、特效、已经输出过的日志等等。这里就涉及到了Collector了,注意它和之前BattleOver的Group的使用区别。

 

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第11张图片

 

记得我们之前讲Entitas的时候,用的是接口对外或者事件对外。这两种我们评估下来都有些复杂,最主要的是服务器战斗要接入服务器框架还需要按照格式去写,所以我们就用了一种最简单原始的方式delegate。服务器也不用关注interface类型,自己写一个delegate参数对了,设置进来就好。

 

Unity手游实战:从0开始SLG——逻辑与表现分离以及实战ECS架构和优化_第12张图片

 

最后一个就是GameServices怎么初始化代理,一般在初始化战斗模块的时候,设置好代理,逻辑写在自己代理器里就可以了。

这里服务器运行的时候,给一个非常大的逻辑帧补偿就可以秒算结果了。(代码是客户端的战斗设定,服务器按照步骤设置参数完成初始化,然后每次需要战斗的时候调用start就会接收到战斗结果的回调)。

3、录像和回放

我们知道,War3的录像文件,必须版本一致才能播放,那是因为不一致的版本逻辑和数据都不对,计算结果也一定不对。而且网游基本都很少会提供录像播放器,那么我的需求就是需要一个内部的录像播放来检测战斗的逻辑Bug。

我们的SLG和王者荣耀不一样,不一样的地方在于,王者10个人同时跑一个战斗,并且跨度几十分钟,服务器每隔几秒就可以收集关键数据比对不同客户端之间的计算是否一致,如果有人不一致,立马可以检测到,然后踢他下线重连恢复正常。但是我们是自动战斗,并且是秒算结果,不可能依靠多人采集关键数据的方式验证逻辑。不过我们的优势就是验证时间非常短,按照20ms一场战斗,1秒就能50场,所以我们可以在时间片段上验证。输入同样的数据跑1万次,如果有5场以上结果不一致(对标王者荣耀不同步率要求在万五以下),那么就必须解决之后才能上线。

另外服务器要保存一定时间内玩家的战斗录像,用于被举报时随时校验玩家数据。

4、未来的优化方向

Entitas是基于Unity的框架,用的是C#,那自然就有IL那一套东西,在Linux上虽然也可以使用.netCore来支撑,但是在效率和内存上仍然有比较大的性能问题。所以第一个优化方向是将C#转为C++代码,提高性能和内存管理。

因为是基于Unity的,所以开发的时候为了快,用了一些Unity的数据结构和数学库,那么一方面服务器得引用Unity引擎代码,一方面性能也没有定点库来的高效。第二个优化方向就是脱离Unity引擎,完全独立工程,独立编译。

多线程的优化虽然在服务器上没有效果,但是客户端还是有必要的,所以第三个方向会区分客户端和服务器,在客户端上独立开启多线程计算,当然这会比较难,不一致的计算很可能带来不一致的结果。

战斗本身逻辑上其实还是有很多可以优化的点,比如:技能、弹道、AI等等,第四个优化方向是保障计算正确的情况下,优化逻辑。

封面图来源:
GDC 2017上暴雪的演讲:Overwatch Gameplay Architecture and Netcode


文末,再次感谢放牛的星星的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

也欢迎大家来积极参与U Sparkle开发者计划,简称“US”,代表你和我,代表UWA和开发者在一起!

你可能感兴趣的:(U,Sparkle,精华来稿)