上一篇着重记录了战斗常用的核心业务逻辑模块。
这一篇,将重点记录帧同步技术,以及我的项目中对帧同步技术的使用,以及后续可改进的地方。
帧同步技术
卡牌类游戏如果仅仅是单人游戏,那便也没什么技术含量了。从拉用户的角度,如果没有实时多人游戏玩法便也失去了游戏一大块吸引力。
通过这些年对卡牌类游戏的积淀,帧同步是这类游戏最常用的多人游戏同步方案。如果是回合类型游戏,那么状态同步便大概率是首选项了,但它的实现复杂度在这类游戏中却又不及帧同步技术了。所以这一章,就记录我所开发的几个项目所设计的帧同步方案与细节。
定点数的实现
我们将项目中所有的浮点数类型全都统一成了定点数,这样就确保我们的逻辑运算不会因平台的不同而导致计算结果的差异。之所以不同平台对浮点数的运算有差异,则是因为各厂商底层硬件对浮点数的运算不一致导致。虽然大家计算结果都差不多,但是到了小数点最后几位总会有所不同。如果采用double进行计算,虽然double精度更高,但依旧免不了会在最后几位出现不一致的情况,只是这时已经是小数点后二三十位的事情了。
正是基于这个原因,我们最开始先使用double作为浮点数据类型,后又使用定点数进行替换。因为我的定点数实现的有效精度只能到小数点后5位,所以即便底层硬件会对浮点数运算产生误差,却依旧会被定点数强制统一而不必担心更小精度运算不一致所导致的问题。
定点数方案在网上可以找到一大堆,而且也对定点数方案的利弊进行过多方分析,此处不作赘述,只是网上多用10000作为定点数的放大倍数,这是我的定点数实现与网上所不同的地方。
在我实现的定点数中,采取了一个特殊的放大倍数,即2^16
。这样一个放大倍数,最大的好处就是定点数之间的大多数运算,都可以采用位运算的形式进行放大缩小,于性能上更合适一些。此外,2的16次方作为放大倍数,可以使小数的有效位保持到小数点后5位,从计算精度的角度考虑,这样一个范围也是满足我们项目开发需求的。至于不同项目的放大倍数,可以根据需要可以调整为其他的2的n此幂均可。
采用2的整数次幂,会在定点数的乘、除、开方、比较大小运算中提高计算性能,远比乘除10000效率来得高。特别说明的是,这里我们的开方直接调用了C#数学库中的开方操作,不同的是在开方前我们就需要对待开方的数左移16位,这样开方得到的结果才是满足定点数放大倍数的值。
基于以上几点,我们才会使用long作为定点数内部的数据存储类型,而定点数所表示数字的范围,与int类型一致。如果使用定点数存储了一个大于int最大值的数,那么很可能就会在后续的计算中因移位而产生计算错误。
当实现了自己的定点数方案之后,就需要根据它的精度去生成三角函数查询表。这里我仅生成了sin查询表,而计算其他三角函数时根据sin值就可求得具体值。
在实现定点数的过程中,还有很多值得参考的内容:
Github上有一个FixedMath的定点数库是我后来发现的,里面有很多更安全的实现;
定点数绝对值的实现,参考这篇文章Optimized abs function,相对于使用
?:
运算可以提高性能,尤其在求负数的绝对值时可以得到近一倍的性能提升。
KCP与TCP的选择
采用帧同步方案后,网络方案就是一个需要关注的议题。
帧同步方案下,客户端和服务器之间无论每帧都同步信息,还是有数据变动时再同步信息,都需要频繁地使用网络进行数据交互。传统TCP方案下,TCP本身发送机制有延时,所以就需要使用KCP来实现高实时性,但是代价(按照官方说法)就是额外消耗10%-20%的带宽。
但是需要说明的是,在丢包率上升的情况下,KCP因其底层数据包的组织与发送形式,会导致流量变大,这种情形下反而不如使用TCP的综合效率。这是选用KCP方案的一个缺陷。
我的项目中,最终设计了两套网络结构,KCP与TCP。这里就需要客户端采集与服务器的网络状态数据(ping值)。当丢包率与延迟不严重时(可根据项目实际情况设定阈值),网络采用KCP;一旦网络状态超过阈值,则将底层网络方案切换为TCP。这种方案的设计目的,主要就是为了解决丢包率上升时KCP流量过大的问题,但是具体情况需要根据项目类型与具体需求而定。
再谈定帧的实现
战斗逻辑处,我们已经讲过定帧的概念了,但是那里的定帧主要针对的是逻辑的定帧执行。
帧同步也需要定帧的概念。只有当所有客户端与服务器帧率统一时,大家的行为才能被约束一致。因此,我们就需要将帧同步的定帧与逻辑的定帧加以区分,下面我就用“逻辑帧”指代逻辑的帧,而使用”服务帧“指代帧同步的帧。
最简易的方案,就是设计之初就令逻辑帧与服务帧的帧率统一。这样做的好处,就是免去了两个帧速率不一所导致的匹配问题,服务器每驱动一帧,逻辑帧就运行一帧,实现起来简单方便。
但是,服务帧的设计一般是要从通讯性能出发的,天生帧率会比较低,而逻辑帧因项目需求不同而不稳定,大概率是比较高的帧率。所以,服务帧与逻辑帧的帧率不匹配是常态,这就需要用到一些转接件来实现我们的需求了。
还记得第一章我曾提到的接口ILogicMgr
吗,它的派生类的作用之一,就是提供一个“帧率差速器”,以使得游戏逻辑能够在服务帧与逻辑帧不同的帧率下平稳运行。帧率差速器以帧率最快的一方的速率为基础速率进行心跳,每次心跳时返回本次心跳结果。帧率差速器的心跳结果分为三种:
- 逻辑帧:即本次心跳后需执行逻辑帧的逻辑(直接执行逻辑心跳);
- 服务帧:即本次心跳后需要执行服务帧的逻辑(我的项目在此处需要执行玩家操作对应的逻辑);
- 空:即本次心跳没有要处理的逻辑内容,也作为差速器本次心跳的终止状态。
因为我们的项目是锁帧方案,服务帧先行,所以差速器心跳流程图如下:
差速器是锁帧模式下的一个重点,因为它的存在,才能够让逻辑帧与服务帧按照顺序执行。也因为差速器内部有时间管理,所以才能够实现服务帧的锁帧功能。这部分内容依靠的就是服务器同步的当前最大帧号数据,以最大帧号作为限制。如果网络出现波动,那么客户端逻辑就会卡在最大帧号对应的时间点,直到收到了服务器最新的最大帧号数据。
追帧也是在此处完成,即如果客户端发现落后服务器帧数比较多,就需要在此处设计算法加速逻辑的运行,使得一次心跳执行更多的逻辑帧。但是需要注意不要一次把所有逻辑帧都跑完,这样会造成客户端显示效果上的撕裂感。
逻辑集成
上文在讲帧率差速器时,已说明了服务帧与逻辑帧的概念,而且我们也明确了差速器是需要用在接口ILogicMgr
的派生类中的,所以逻辑部分自然也是集成在这里的。
当差速器每次心跳结果是逻辑帧时,就是我们调用逻辑的心跳的时机。以此便完成了帧同步方案下逻辑的集成。也因为逻辑的心跳是在ILogicMgr
中被驱动的,所以无论追帧或是心跳时间控制,都可以在此处根据需求进行开发。
可改进细节
服务器优先
我在项目中采用的帧同步方案已在上文进行了较为详细的说明。
需要指出的是,在服务帧概念下,我们的方案是以服务器领先客户端实现锁帧效果的。这种实现方案最为简单也最为有效。当我们面对的项目是一个对实时性要求不高的项目时,这种锁帧方案可以简单有效地实现我们的需求,也不会对玩家造成太多的感受上的负面影响。
如果项目实时性要求高怎么办?
类似ACT或MOBA类游戏,玩家需要能够及时获得技能释放的反馈,否则延迟的技能释放会撕裂快节奏的游戏体验。这种情况下就需要我们设计全新的思路。而在此之前,我们需要明确快照的概念。
快照
快照,即当前数据的拷贝。我们之所以需要快照,是因为我们后续会需要在某个时间点保存所有的数据,然后在另一个时间点通过保存的数据还原战场的数据。
基于战斗框架,快照我们其实只需要逻辑的数据快照即可,这样即便逻辑通过快照进行了数据回滚,也可以将数据同步给显示层进行还原。ECS架构本身因为对数据与逻辑进行了完整的分离,所以快照与数据回滚的实现方案可以参考第二章内容,此处不作赘述。
下文我们将介绍一个可以改进的帧同步策略,就需要用到数据快照与回滚。
客户端优先
还有一种帧同步策略,即在服务帧概念下,客户端时间领先于服务器时间。这种情况下,客户端所有的逻辑可以看做是一种预演,而服务器下发的则是过去某帧的信息。
假设客户端是第n+2
帧,服务器下发的是第n
帧。
客户端将玩家输入数据上传服务器,并标记该数据是第
n+2
帧数据,由服务器跑到第n+2
帧时向客户端下发;服务器下发第
n
帧输入数据如果与客户端预测结果一致,则客户端继续进行预演;服务器下发第
n
帧输入数据如果与客户端预测的第n
帧输入数据不一致,就需要客户端将数据回滚至第n-1
帧,再利用服务器下发第n
帧的输入数据去计算到第n+2
帧的效果。
以上就是客户端优先的帧同步策略,但是这里也需要考虑几个实现的问题:
- 数据快照与回滚的效率,尤其是快照,会被频繁调用,那么其性能就是制约该策略的矛盾之一;
- 数据快照的时机,如果客户端预测都是按照空数据去模拟,那么每次服务器下发了玩家输入数据之前一帧就应当是快照点;但是客户端本身又优先与服务器时间,所以这个点是无法预知的,大概率的解决方案也只能每帧快照,但是又需要考虑增量快照的可行性以提升快照效率;可以确认的是,快照点一定对应到服务帧而不是逻辑帧;
- 数据回滚后显示层的追帧表现问题;
- 如果服务器卡顿怎么办?长时间未收到服务器下发的消息,如果我们一直进行预测,就意味着需要将所有预测数据进行快照,从内存的角度考虑也是不可行的,大概率需要一个阈值以代表最大可预测时间,超过该值则客户端需要卡顿以等待与服务器的通讯恢复正常;
- 快照与输入数据的数据结构设计,需要能够建立关联,并及时与服务器下发数据数据相比较。
以上就是我对帧同步相关的记录与思考,下一篇便聚焦开发工具进行说明。