帧同步算得上是比较古老的技术了,甚至我一度认为这个技术可以完全被替代,因为技术本身存在太多的限制性,现如今特别是手游的网络环境流量并不是什么问题,反而网络的稳定性却是个大问题。不过,由于本身的技术架构特性,也存在一些优点。一般认为,做多人对战类(FPS除外,其实我觉得游戏单位多更准确)的游戏用帧同步,虽然我觉得状态同步也应该没问题,只是由于暴雪爸爸当时做war3和星际就是采用的严格帧同步,事实上据说dota2就是采用的状态同步。在手游上,这两年随着王者荣耀的大火,竞技游戏开始火起来,自然帧同步这项古老的技术又被人炒冷饭了。

帧同步的基本原理



unity 帧同步 框架 介绍 unity3d 帧同步_帧同步

理想帧同步

我说下我的个人理解,拿老师收作业来举例,老师相当于服务器,也有可能没有老师,而是有个课代表既当学生也做收作业和发作业的工作(这就是早期魔兽争霸和星际的局域网模式,有个人充当主机),学生可以在一天的任何时候提交作业,老师会在每天的晚上12点,清点一天的作业,并把所有同学的作业(电子版)转发给所有其他所有同学让他们自己批阅打分(如果老师自己批阅然后告诉同学们最终成绩就是状态同步)。所有同学和老师必须要有同样的打分标准,也就是说同样的作业,每个人批阅出来的成绩是必须要一样的(不然导致游戏不同步)。

严格帧同步:

老师在晚上12点,必须清点所有同学的作业,确认收齐才可以。不然,全班同学都得停下来等那些没交作业的同学,除非那个同学退出这个班级,或者超过时间把这个学生开除掉。这个就是为什么在玩魔兽争霸时,一人卡顿,全部人都卡主,然后那个人退了,或者等一分钟还没连上就让那个人强制退出。这个技术在现在来看是完全不合理的,只是因为在当时环境下的产物。

帧同步优化:

1,让所有人停下手中的工作去等一个人肯定是不合理的,不如就这样,老师还是在晚上12点清点作业,但是如果发现某个人没交作业,那么我就认为他今天没做作业,然后分发剩下同学的作业给所有人(也包括那个没交作业的同学)。服务器把这一帧收到的客户端发出的指令转发给所有客户端,不停下来卡主逻辑线程。这就是为什么后来又出现一些游戏,只有主机卡的时候,所有人才卡。



unity 帧同步 框架 介绍 unity3d 帧同步_帧同步_02

客户端A指令输入晚了,会放在第三帧执行

2,假设我们是一秒10帧(最低了,再低就没法玩了),手机上延迟大概60ms,也就是说,我传输指令需要花费1帧的一半时间。还是对应到交作业这个例子,我们和老师的办公室离得很远,需要用快递把作业送到老师的办公室,但是快递的时间得要12个小时。这就要了命了,假设我在下午1点完成了我的所有作业,发送快递到第二天凌晨1点老师才收到快递,老师要等到第二天晚上才会再一次把作业同步给所有人,这样就晚了一天了。在游戏中我们就会发现,一个指令的延迟会出现0.2秒以上。其实我的作业有一部分在中午1点前就做完了,那我能不能提前发快递,一天发好几个快递给老师,这样能保证在中午之前做的作业第二天能批到。但是快递费用也不便宜,所以一天到底发几个快递取决于你自己。客户端每过一段时间收集一次指令,向服务器发送,这个间隔时间没必要跟服务器的Tick一样,可以短也可以长(作业不多,快递费又贵,两天发一次快递)



unity 帧同步 框架 介绍 unity3d 帧同步_unity 匀速运动_03

客户端每0.5帧发送一次指令

3,客户端发送给服务器的指令掉包至多导致操作无效,但游戏能进行下去,但是服务器发送的统计到所有人指令是必须要确保所有客户端必须收到的,不然就会出现严重不同步,游戏进行不下去。所以,帧同步一般使用TCP。奈何顺丰快递虽然能保证不丢失,但费用太高,如果使用圆通费用会大大降低,只是丢包的问题,我们何不用圆通快递,如果出现丢包,我们再让快递员再送一次呢。有个KCP的开源库,就是可靠的UDP,但是这里要说一下,其实帧同步的架构本身就是支持解决这个问题的。因为我们只需要确保客户端接收到服务器的逻辑帧包是连续的就行,如果发现这一帧的逻辑帧不是上一帧的+1,那么我们就肯定他丢包了,就可以让服务器重新发送那帧的包。

4,传统帧同步是没法断线重连的,就是说老师把那个学生开除了,他就永远不能再重新加入到这个班级。但是,我们可以把这学期学生的作业都保存好,如果某个学生退学了,他过了一段时间又想重新加入,那么我们可以把这学期的所有作业都发给他,让他重新批阅推算一次,直到赶上当前教学进度。客户端在断线重连时(杀进程,非杀进程走上面第一条和第三条优化)服务器将游戏从开始到当前进度的所有帧都发送给客户端,客户端在本地执行所有帧跟上服务器进度。这就是为什么在玩王者荣耀断线重连时,有两个百分比,一个到100%了,又出现一个百分比进度条,第一个是资源加载第二个是帧加载。



unity 帧同步 框架 介绍 unity3d 帧同步_unity 匀速运动_04

A断线重连,执行所有帧

5,老师不要由课代表替代。课代表行驶老师的权利会带来很多问题,课代表是一个极其不稳定的因素,因为你没法确定课代表的物流能力(网络状况)以及是否会作弊。自己架设服务器还有很多深层次的好处,在后面讲帧同步工具的时候讲。

手感优化

手游的网络情况比较复杂,丢包,延迟以及帧同步本身存在的延迟情况,导致优化画面或手感存在很大的问题,我们只能极大程度地弱化网络导致的画面卡顿情况。

1,预测。对于一个10帧的帧同步来讲,最理想的情况是客户端应该每0.1s收到一次服务器发送过来的帧信息来执行逻辑的Update。如果超过一定的时间还没有收到服务器发送的帧信息,那么角色就会卡在原地,然后再突然来两三帧,角色又会瞬间移动。我们可以这样,在客户端超过一定的时间比如0.15S没收到帧,那么我们就先认为这一帧啥指令也没有,空跑一帧,比如:正在行走的人物,依然会继续以上一帧的速度往前走。大部分情况下,这种预测是对的,特别是你自己,因为一般网络卡顿,上行和下行都会卡,你收不到也就发不出指令,根据上面的优化,发不出指令,就认为这帧啥也没干,完全符合预测。而自己超控的角色又是自己最敏感的,所以这个能很大程度上提升手感。这里要注意的就是,预测之前,一定要记录一些数据,以便帧信息过来后回滚这些信息。最重要的是,没必要记录所有数据,因为整个战斗的数据是很庞大的,不可能都记录下来,预测所有逻辑,我们只需要预测跟运动相关的东西即可,战斗相关的信息,如:子弹释放,技能释放,buff效果这类的东西没必要预测。

2,矫正。前面提到,理想情况是每0.1S收到一次帧信息,在实际情况是完全不可能的,再加上上面说的预测功能,导致,实际位置跟显示位置存在很大的偏差,如果直接矫正的话,那么画面就会一卡一卡的,这个及其影响手感,这在状态同步方案也经常用到。矫正一定不要有SetPosition这种炒作,除非是闪现这种,全部用Tween。Tween也很有讲究,Tween有开始点,结束点以及时间,开始点一定是现在的显示位置,千万别设置成上一帧的实际位置,这就是典型的SetPosition操作,结束点是这一帧的实际位置没问题。关键是这个时间,不要粗暴地设置成0.1s,这样在卡顿时,人物的移动会出现一会快一会慢。这里一定要计算一下,按照角色当前移动速度移动到目的地需要多久,如果是小于0.11s(跟实际差不太多)或者大于0.5s(跟实际相差很大),设置成0.1s,其余情况,按速度来计算运动到终点需要多长时间减去适当时间(保证最后能追上),这样能保证大部分情况下是匀速运动。



unity 帧同步 框架 介绍 unity3d 帧同步_unity 匀速运动_05

预测和矫正

3,提前执行显示相关。客户端在按下某个技能,需要等到服务器接收,然后转发到自己接收,才能执行,这期间大概有0.1-0.2s的时间,可以针对你操控的角色在按下命令时做一些起手动作,转身动作,播放音效等。这个优化,闹闹使用得很少,只有移动时的朝向是随手按下的,因为牵扯太多其他系统最后不了了之。

本来以为三言两语就能说完,在写博客的过程中发现,帧同步能讲的好像还挺多。这篇博客暂时只讲了一点帧同步的原理和比较抽象的优化方案。实际怎么制作的,以及遇到的各种问题在下一篇继续吧。预告一下,大概有,逻辑与显示分离,定点数,录像,不同步问题解决等。

通过评论,知道了帧同步的另一分支(优化方案)

Gordon:六:帧同步联机战斗(预测,快照,回滚)zhuanlan.zhihu.com/p/38468615

unity 帧同步 框架 介绍 unity3d 帧同步_unity一秒多少帧_06

拜读了这篇文章后,对比了一下我所在项目使用的技术,双方都存在各自的优缺点。其实帧同步不存在孰强孰弱,只是说你的项目更适合哪种,从作者的博客来看,他的项目使用的技术更像是帧同步和状态同步(快照)的结合,应该是更适合守望先锋这类延迟要求严格的射击游戏。那种技术的延迟手感体验确实要比本文所述好很多,如果使用在Moba类游戏,还是存在很多弱点的。首先,moba类的指令操作是要比射击游戏多,根据原博的技术,这会带来频繁的回滚,这就意味着每帧都得执行快照和回滚操作。其次,Moba类的游戏,游戏逻辑本身是要比射击游戏复杂很多,意味着效率要求要比射击游戏高很多。最后,Moba类战斗逻辑会经常修改,几乎做不到只更新数据,不更新战斗逻辑,也就意味着战斗逻辑必须要使用Lua。结合上面这三点,就意味着,每帧都得进行Lua的快照和回滚,这点在Moba类游戏是完全无法接受的。原博也说了



unity 帧同步 框架 介绍 unity3d 帧同步_unity 匀速运动_07

事实上,王者荣耀就是采取的类似于本文所述的帧同步方案,当然王者肯定做了更多的优化。

上面讲了帧同步的基本原理和优化思路,比较偏理论一点。本篇更侧重实践,到底怎样在Unity上做一款帧同步的Moba手机游戏。

逻辑和显示分离

我们上大学的时候,天天说MVC结构,基本上所有通过代码衍生的产品几乎都有这样的思路。所谓MVC,是指数据,逻辑,显示的分离。而一般对于游戏来讲,特别是帧同步的战斗模块,逻辑和显示是最重要的,也是必须的。因为逻辑是需要在服务器上运行的,而服务器是没有显示功能的,并且战斗服务器的代码和客户端是一样的,这就决定服务器在脱离显示模块,也能正常运行。

那么对于Unity来讲,怎样做到逻辑显示分离呢。这里以闹闹项目为例,用Lua作为脚本语言。lua和Unity的交互这里就不细讲了,具体参考XLua。首先,Unity作为游戏引擎,承担了输入和输出的责任,对于闹闹来讲,战斗中游戏的输入主要是JoyStick,既玩家的移动和释放技能指令。输出则是动画,模型,特效等显示相关以及声音等。针对JoyStick,模型动画,特效,声音,我们分别定义好Lua和C#的接口。剩下的就全都是基础战斗逻辑,对于客户端和服务器来讲,就没有任何区别。闹闹项目考虑到Lua的效率问题,将消耗比较大的移动,碰撞,寻路放入C++中。Lua和C++都是跨平台的,在客户端和服务器都能使用。



unity 帧同步 框架 介绍 unity3d 帧同步_unity一秒多少帧_08

特别要注意一下C#和Lua层的箭头,除了JoyStick外,不允许有任何C#层的东西去影响Lua层的战斗逻辑,而lua和C++层则可以自由调用(考虑效率情况下)。

为了方便理解,这里举个例子。

玩家通过摇杆JoyStick(C#层)产生MoveMsg,发送给Lua层InputControl,InputControl在输入Tick的时候,处理消息(去重,加密,压缩)发送给服务器,当客户端收到服务器发送回来的MoveMsg时,FightObject的Tick会通过lua层的MoveControl,调用C++层的MoveControl,设置移动方向。在C++的Tick时,MoveControl再执行移动,当产生位移时,通知Lua层的MoveControl,Lua设置逻辑上的真实位置,Lua在发生位置改变时,通知C#层的AvatarControl。按照这样一个流程,角色就能够真实移动起来,并且在服务器上,只需要去掉通知C#层的AvatarControl(lua的FakeClass),完全不影响整体战斗逻辑。服务器某些类似乎需要重写,必须设置没有任何功能的AvatarControl,创建FightObject的流程等,可以通过Lua的DoFile重新写一份,不过这里一定要特别小心,别影响战斗逻辑,这个地方也是出现不同步的高发地段。

浮点的处理

帧同步的另一核心,必须保证在所有端跑出来的结果是一样的。但是我们都知道,浮点的运算在不同操作系统甚至不同机器上算出来的结果都是有精度差异的。浮点处理一般有两种办法,一种分数,一种定点数。据说王者荣耀是用分数(不确定)。我们觉得用分数处理起来很不方便,就选用定点数方案。

定点数,说简单简单,说不简单也不简单。因为定点数的原理很简单,就是用整数存,计算的时候用整数计算,再进行转换,这里不做过多阐述定点数是怎么实现的了,网上也有很多定点数的现成库。但是,从上面我贴出的架构来看,首先最大的难点是Lua需要支持定点数,lua中的小数是double,需要把Lua源码中的基础小数全部替换成定点数。项目分为C++,Lua,C#三层,这三层的小数接口全都要改成定点数,这一步实际上是要比上一步难,因为需要处理xlua的一些东西。再一个难点便是Box2D,物理系统一般也没有定点数,需要自己修改,Box2D我认为还算比较好修改,如果使用physic3D的话,我估计这个工作量就很大了。如果项目用到其他跟战斗相关的库,必须全部改成定点数。

浮点的处理整体工作都不是特别大的技术活,但一定需要很严谨的人花很多的时间,这里边都是细致功夫。这也是bug多发地段,必须早早稳定下来(非常重要),不然出现不同步或其他bug了,都没法确定到底是浮点本身出问题了,还是其他战斗逻辑代码所致。

定点数,如果用8位(256)存小数部分,那么小数的精度是1/256,整数最大24位(800w左右)。这个在战斗逻辑一定要考虑进去,可能会出现很多问题,比如:0.1*0.1*10 ≠0.1。遇到这样的问题,最好绕过去或者接受。

随机函数

伪随机是必须的,伪随机的算法网上也到处都是。基本功能只需要,设定一个种子,取随机只跟取出的次数有关,然后就是保证随机性。即:我在AB两个机器上,设置同一个种子,取出第一个数都是10,第二个数都是23,以此类推。伪随机也必须是定点数哦,所以也需要稍微修改一下网上找下来的库。每局战斗开始,由服务器产生一个随机种子同步给所有客户端,之后遇到随机取出来的数就一样了。

随机数也是不同步的高发地段,并且这个出现不同步会带来非常严重的不同步。出现的情况有多种,但是总结就是调用次数不一致,一般出现场景有以下几种。lua有些显示相关的代码只在客户端上运行,但是需要做随机,这里必须使用unity自己的Random函数。为了节省效率,我们的AI只在服务器上运行,生产指令后同步给各个客户端,这里也是禁止使用随机的,但是AI有时候免不了用随机咋办,我一般用当时的位置或者当前帧数(当对来说是随机的)。其实AI只运行在服务器上就说明,所有影响战斗逻辑的代码都不应该存在,比如:在行为树上设置属性等。

为了验证随机函数没有出问题,可以在每帧信息中生成一个随机数,各自客户端进行对比,查看是否相同。这样方便查看出现不同步时,定位是否是随机数的问题。

帧同步工具

在整体框架搭建好后,一定要赶紧完成这几个比较重要的工具。不同步信息查看工具,录像播放工具,自动战斗工具。

不同步一定是帧同步项目在制作过程中出现最多的一个词,如何定位问题是解决问题的重中之重。我们需要制作一个工具,能过查看具体是哪个属性,在第几帧出现不同步的,并通过前后的一些信息,诸如使用哪些技能,做了哪些操作导致出现不同步。这就需要先统计信息,客户端和服务器都要统计各自的属性,操作,buff,子弹等尽可能多的跟战斗相关的信息。客户端统计完,发送给服务器,然后服务器保存在本地。这里涉及到统计和发送两个问题,统计需要消耗效率,在lua上做这个功能是很耗时的,再一个发送会增大流量。这两个点都是在线上所不能容忍的,不过我们可以设置一个选项,在正式上线时关闭,平常测试时在内网或者PC上时,可以打开。尽管如此,我们也可以做一些优化,比如:每个单位的属性有很多,我们可以只记录改变了的属性,然后推演出来。服务器在收到调试信息后,不要每帧都进行IO操作,可以等战斗结束,或怕战斗没结束就崩溃,可以每过一段时间保存一次。服务器处理好数据后,就需要做一个可视化的工具来展示这些数据了。

仅仅制作不同步信息展示工具,只能查看到大致的方向,一些比较疑难的不同步问题还是很难查找,我们就需要制作录像播放工具。录像播放对于帧同步的意义非常大,本身帧同步的一点非常大的优势在于能很容易做到完全重现战斗录像。能播放录像,就能解决好多问题,一旦发现bug,只需要重播一次就好了,之前做游戏最头疼的就是无法复现的bug。重播录像也能提供给策划查看游戏的玩法生态,或者重现一些策划数据达到预料之外的情况。最后,几乎所有的帧同步游戏都有观战系统,这个系统能很好的提升玩家生态。录像有这么多好处,其他类型游戏为什么没做呢,主要问题还是太困难。而对于帧同步来说,要实现这个非常简单,帧同步的架构本身就支持这项工作,我们只需要保存每帧各个客户端的输入消息。事实上,服务器本身就已经有这写信息,我们只需要把这些信息保存起来,然后再在客户端读取这些信息,这些信息量也比较少,一场战斗就几兆大小,自然而然,录像系统就完成了。

自动战斗工具是有天我看到测试,在不断开房间挂机,查看是否有崩溃或报错。那么,弄一个自动战斗的系统,只需几行代码,就能让无数机器24小时为我们测试,这样极大地减少了策划的工作量。既然产生了这么多战斗场次,一些数据对我们来讲相当宝贵,一方面能筛选出出问题的场次(不同步,崩溃,报错),利用上面两个工具解决问题。另一方面,策划也能通过这些场次的TLog(战斗信息)粗略地验证战斗数值的合理性。

不同步问题总结

浮点和随机上面说了,这里不做过多叙述。

代码不要出现“我”这样的逻辑,对于不同客户端和服务器“我”是不同的,但是据我观察,这个是避免不了的,特别对于界面显示来讲,所在在使用“我”的时候一定要注意,不要影响战斗逻辑。

服务器有一些自己的Lua代码,一部分是战斗服的逻辑,还有一部分则是重载了客户端的一些方法,因为有些逻辑,服务器确实是不一样的,这些地方一定要特别谨慎。

原则上除了Input是不让C#调用lua代码的,但是也是不可避免有些显示相关的需要回调到lua,这里也千万不要改变战斗逻辑,建议从C#回调lua的战斗代码统一写到一个地方,方便查阅和检查。

不确定顺序的容器的遍历要严令禁止,lua表的遍历,for pairs是必须要禁止的,可以使用for ipairs。C++的Map等,总之在使用容器的遍历时,一定要查阅是否是确定顺序的。

不同平台的long型是不一样的,long这个类型最好是不要使用,要么用int,要么用longlong。

先写到这把,可能还有些补充,想着需要写很多,但是写起来就老忘,以后想到啥再作补充吧。