多人联机之研究

时间:2022-09-18 16:55:08

原文链接

.  虽说多人联机技术已经存在很多年,众多上古游戏就已经支持多人联机,但随着业务复杂度提高,多人联机仍然有许多挖掘空间。从业很多年,参与的项目清一色都是状态同步,相比帧同步,状态同步在同步这件事上并没有多少技术难点,因为实现简单,适用场景众多,很多游戏会采用状态同步,但它并非全能,曾经的MMORPG游戏几乎全是状态同步,因受限于状态同步对于高频率交互实在无能为力,所以只能在游戏设计上彻底放弃了高频率交互。但是行业在发展,玩家的期望值在提升,高频率交互这道坎始终是要迈过去,所以帧同步这项比状态同步更古老的技术近年出现越来越频繁。


帧同步

.  所谓帧同步,通俗解释就是,严格要求所有客户端每一帧都是同步的(这里的帧指的是逻辑帧而非渲染帧),如何保证这一点呢,原理并不复杂,只要每一帧的输入是一样的,处理过程是一样的,那么结果必然也是一样的。再具体一点,客户端的输入发送到服务端,服务端标记该输入属于哪一帧,随后转发给所有客户端,客户端拿到输入后用同样的逻辑执行,从而得到一样的结果,这就是一帧的处理过程,然后一直重复这一过程。

总结一下,帧同步就是每一帧,输入一致,执行一致,结果一致,不停重复。

浓缩一下,帧同步就是输入一致,执行一致,结果一致。

这看起来非常简单,尝试一下逐个击破。

  • 输入一致

.  输入是客户端产生的,所有客户端都发送到同一个服务端,服务端维持了一个帧列表,里面记录了每一帧都有哪些输入,然后按需转发给所有客户端,客户端收到的帧数据一致,所以输入也都是一致的。

  • 执行一致

.  所有客户端都是同一份代码,同样的逻辑,所以执行必然都是一致的。

  • 结果一致

.  前两项都一致,那么结果是否能一致呢?就好比,输入是【1,1】,处理过程是【加法】,那么【1+1=2】必然成立么,根据常识来看,这必然成立,在计算机中这也必然成立,但是!(“但是”不负众望出现了)如果把【1,1】换成【1.0,1.0】,就变成了【1.0+1.0=2.0】,这在计算机中不一定成立,因为计算机中整数和浮点数的存储方式不一样,计算方式也不一样,浮点数计算是有误差的,误差多少取决于你的CPU,甚至同一个CPU,计算浮点数时也可能有误差,这表面看只是一个【1+1=2】的问题,实际上是个大隐患,误差会像雪球一样越滚越大,从而导致帧同步的结果一团乱。


如何保证结果一致

.  没有完美的方案可以解决这个问题,要为每一个游戏专门定制,才能达到最优效率。

.  例如《王者荣耀》,它的互动频率很高,作为一个竞技游戏,需要客户端严格同步,如何正确帧同步呢?因为浮点数计算精度误差,但整数计算没有精度误差,完全可以把所有的计算都换成整数,把所有浮点数小数点往右挪4位,【1.2345】变成【12345】这不就把问题解决了么?从表现上看,《王者荣耀》的计算逻辑并不会太复杂(其实也有点复杂,只是相对而言),它的计算都在同一个平面,不涉及3D计算,需要计算的内容大致是角色移动,技能命中,涉及到的几何形状大概是直线,圆形,矩形,简单多边形(可能没有),这些几何计算完全可以自己实现,把计算用到的数字全换成整数,从而结果不一致的问题就解决了。

.  上述的解决办法只限于所有的计算过程都由自己掌控的前提下,假设计算过程更复杂,那就不得不使用现成的几何计算库,物理引擎等等外部工具,幸好这种问题早就有人遇到并提供了基于整数计算的物理引擎,或者使用一些开源物理引擎自行换掉浮点数计算的部分。看起来问题已经有完美答案了,但其实不是,设想一下,从此以后数学中只有整数没有小数,这是不是特别扯淡,因为小数是数学不可缺少的一部分,你能用整数取代一部分,但不能完全取代,对物理引擎来说,全部采用整数计算很不科学,性能还会下降,主流物理引擎全部都是浮点数计算,如果坚持采用整数计算,这意味着要放弃所有主流物理引擎,这条道虽然可行,但槽点多多,我觉得这是一条邪道,不归路。

.  现在又陷入了死局,似乎帧同步遇上复杂的数学计算就等于行不通,这就意味着高频率互动的多人游戏没法有复杂的数学计算,但市面上确确实实存在这样的游戏而且非常多,所以其中必然还是有解决方案的,这个方案就是回滚机制,由服务端派发正确的结果,客户端检测到与本地不一致时触发回滚,这样就可以彻底解决结果不一致问题。如何实现回滚机制,这套机制说起来短短几句话,做起来想破脑壳,因为没有一个完美的方案能实现回滚机制,所以针对不同的游戏方案也会有所不同。

.  现在很少有只用帧同步的了,通常都是帧同步为主,状态同步为辅,方可发挥其强大的威力。


研究成果

.  多人联机没有完美统一的实现方案,会随业务变化而变化,不同的同步频率,不同的同步人数都会直接影响多人联机的落地方案,现在越来越多游戏使用主帧同步辅状态同步的方案,因为帧同步的计算都在客户端,使得客户端对细节可以完全掌控,这一点很重要。

.  在研究帧同步的时候,原本计划实现一个简单的帧同步Demo,结果越写越想写更多,最终做了个动作游戏……(有夸张的成分)

以下是Demo演示

  • 基于UDP帧同步
  • 渲染帧率:60
  • 逻辑帧率:30
  • 同步帧率:10

Demo演示-输入延迟

Demo演示-追帧

Demo演示-同步

Demo演示-完整演示


题外话

  • 帧同步的质疑

.  不止一次听人质疑帧同步流量消耗大,这大概是帧同步的名字让人产生误解,帧同步每一帧同步的只是输入,数据量极小,讲究高频率,低流量。

  • 技能制作

.  在制作这个Demo的初期,在如何实现技能这个问题上卡壳了很久,arpg游戏比rpg游戏技能表现复杂的多,在rpg游戏中,技能通常是一个动画几个特效,而arpg中,技能远不止如此,它可以复杂到难以想象,经过深思熟虑,我觉得把技能节点化是一个不错的主意,把功能封装到节点中,节点可接收自定义参数,将节点链接起来就是一个技能。我用Mermaid语法描述节点,因为Mermaid本身就是用于描述流程图的语法,这跟节点化不谋而合(节点连接起来就是流程图),其次Mermaid语法简洁能直接嵌入Markdown实时预览流程图。

以下是技能Atk1的描述信息

Mermaid描述

* 方法: Atk1
* 描述: 平A第一刀
```mermaid
graph TD
转化角色 ==>
播放动画 ==>
等待0[等待] ==>
冲刺 ==>
等待1[等待] ==>
同步 ==>
设置筛选参数 ==>
设置攻击参数 ==>
命中特效 ==>
播放特效 ==>
矩形攻击 ==>
等待2[等待]

播放动画_名字[Atk1] --名字--> 播放动画
播放特效_ID[0] --ID--> 播放特效
命中特效_ID[1] --ID--> 命中特效
等待0_时长[0.2f] --时长--> 等待0
等待1_时长[0.3f] --时长--> 等待1
等待2_时长[0.7f] --时长--> 等待2
冲刺_距离[5.0f] --距离--> 冲刺
冲刺_时长[0.3f] --时长--> 冲刺
设置筛选参数_标识[不同阵营 I 攻方不停顿] --标识--> 设置筛选参数
设置筛选参数_范围[0.5f 0.0f 1.0f 2.2f] --范围--> 设置筛选参数
设置攻击参数_硬直[10] --硬直--> 设置攻击参数
设置攻击参数_血量[20] --血量--> 设置攻击参数

Markdown预览


  • 方法: Atk1
  • 描述: 平A第一刀
graph TD 转化角色 ==> 播放动画 ==> 等待0[等待] ==> 冲刺 ==> 等待1[等待] ==> 同步 ==> 设置筛选参数 ==> 设置攻击参数 ==> 命中特效 ==> 播放特效 ==> 矩形攻击 ==> 等待2[等待] 播放动画_名字[Atk1] --名字--> 播放动画 播放特效_ID[0] --ID--> 播放特效 命中特效_ID[1] --ID--> 命中特效 等待0_时长[0.2f] --时长--> 等待0 等待1_时长[0.3f] --时长--> 等待1 等待2_时长[0.7f] --时长--> 等待2 冲刺_距离[5.0f] --距离--> 冲刺 冲刺_时长[0.3f] --时长--> 冲刺 设置筛选参数_标识[不同阵营 I 攻方不停顿] --标识--> 设置筛选参数 设置筛选参数_范围[0.5f 0.0f 1.0f 2.2f] --范围--> 设置筛选参数 设置攻击参数_硬直[10] --硬直--> 设置攻击参数 设置攻击参数_血量[20] --血量--> 设置攻击参数

Runtime生成

public static IEnumerator Atk1(Actor actor, Config.Behavior config)
{
    // 0: 转化角色
    var self = actor as Role;
    var select = self.GetState<Behavior.ParamSelect>();
    var attack = new Behavior.ParamAttack() { SelectParam = select };
    // 1: 播放动画
    Behavior.MetaRoleAnim(self, "Atk1", 0.1f, false);
    // 2: 等待0
    yield return Tools.Time2Frame(0.2f);
    // 3: 冲刺
    Behavior.MetaRoleNextF(self, 5.0f, 0.3f);
    // 4: 等待1
    yield return Tools.Time2Frame(0.3f);
    // 5: 同步
    Behavior.MetaSyncMove(attack);
    // 6: 设置筛选参数
    select.Area = Mathm.Quad.New(0.5f, 0.0f, 1.0f, 2.2f);
    select.Flags = Const.BehaviorFlag.kSelectXor | Const.BehaviorFlag.kStopMotionNotSender;
    // 7: 设置攻击参数
    attack.Attack = 20;
    attack.Stable = 10;
    attack.ForceDuration = 0;
    attack.ForceDistance = 0;
    attack.StopMotionTime = 0.3f;

    // 8: 命中特效
    Behavior.MetaEffectHit(attack, config.ID * 10 + 1);
    // 9: 播放特效
    Behavior.MetaEffect(config.ID * 10 + 0, attack);
    // 10: 矩形攻击
    {
        var __ret__ = Behavior.MetaAttackQuad(attack);
        if (__ret__.HasFlag(Const.MetaAttackResult.kAbort))
        {
            yield break;
        }
        if (__ret__.HasFlag(Const.MetaAttackResult.kStop))
        {
            yield return 1;
        }
    }
    // 11: 等待2
    yield return Tools.Time2Frame(0.7f);
}

Github-地址

Demo下载地址-阿里云盘

再补充一个-空降我最喜欢的一段