游戏中的位置同步浅析

⽹络游戏⾥位置信息同步是最基础的数据同步之⼀。CAP原理告诉我们,分布式数据同步没有银弹;⽽游戏玩家之间的物理距离即使只在国内,按光速在理论值上也有许多⼈跑不到16ms(60FPS)的延迟,所以现实中也不存在可以忽略的同步时间,更不⽤说在东南亚连4G弱连接都不够普及的地⽅之间玩家同样需要进⾏互联。

本文中只考虑最简单的位置同步情形:当存在两个客户端(记为C0,C1)和⼀个服务器(S0),C0和C1分别⾄少存在⼀个可移动且可⻅的对象(记为P0,P1),当P0向P1逐渐靠近时,如何让P0实时的看到到P1朝它移动,并且也让P1看到P0在它的前⽅静⽌不动? 已知C0、C1之间⽆直连,但它们都连接到S0上,可以通过S0转发消息给彼

此。此时他们的拓扑结构如下所⽰:

<图1>

我们先约定⼀下位置同步的含义:

位置同步:所谓位置同步,指的是把主控端⼀个实体的在特定时间的位置和朝向通过⽹络传输的⽅式在多个模拟端进⾏重现的过程。

从位置的含义可知,完整的位置同步的⽬标是7维的:

<Position.x,Position.y,Position.z,Rotation.x ,Rotation.y ,Rotation.z ,TimeStamp>

以下为了⽅便讨论,我们略去朝向同步的相关信息,因为它处理模式和Position的处理模式可以等价互换。

1、Naive Solution

该问题的有⼀个直接的解法转发式位置同步: C0每帧把P0的位置通过服务器中转发给C1,同样的C1把

P1的位置发给C0。该⽅案的⽹络时序图如下所⽰:

<图2>

这个⽅案在理想的⽹络环境下可以很好的⼯作,这个理想的环境包含以下条件假设:

* ⽹络环境不存在⽹络的延迟抖动或抖动的范围很⼩,RTT + 抖动上限⼩于客户端单帧耗时。(条件1) * ⽹络环境不存在丢包的情形(TCP不存在丢包,其底层丢包会置换为增加延迟)。 (条件2) * 服务器收到转发的消息立刻转发给所有⽬标客户端,或服务器的运⾏帧率远⾼于客户端,且服务器单帧耗时 + RTT + 抖动上限 ⼩于客户端单帧耗时。(条件3) * 服务器带宽和QPS预算充⾜,因为1k⼈同服则位置转发的输入包QPS为60k。如果平均AOI 范围为10⼈,则发出的AOI位置包QPS为600k。(条件4) * 此⽅案还有⼀个⽣死悠关问题,那就是服务器⽆条件的信任客户端所给的位置,⽽忽视了客户端存在位置上可能会进⾏作弊,这对⼤部分PVP游戏来说很致命。(条件5)

当条件1不满⾜,⽹络RTT加抖动⼤于客户端单帧耗时时会发⽣什么呢?

<图3>

C1在第2帧收到了C0第0帧的位置,此时C1中的P0'位置落后P0 2帧。

C1在第4帧收到了C0第2帧的位置,此时C1中P0'位置落后P0 2帧。此时可以看出,P0'在第3帧时保持第2帧的位置不变。

C1在第5帧收到了C0第3帧的位置,此时C1中P0'位置落后P0 2帧。

C1在第8帧收到了C0第4帧的位置,此时C1中P0'位置落后P0 4帧。P0'在第6,7帧时保持第5帧的位置不变。

由此分析可以看出,C1中的P0'在第3,6,7帧均停住不动,这从从玩家的观感来说,C0观察P0在连续平滑的移动,但C1侧观察到的P0'则是⾛⾛停停的卡顿。 不满⾜条件2和条件3所触发C1侧的观感可以⽤类似的消息序列给出:不满⾜条件2,C1处观察到的P0'不光会出现卡顿,还会出现位置跳变,既玩家常说的位置拉扯;不满⾜

条件3的后果和1等价。

2、从位置同步到位移同步

既然转发式的位置同步有那些难以逾越的障碍,那么有没有什么改进⽅案呢?从上⾯的问题分析我们可以看出它问题的根源是只同步结果(位置)⽽不同步位置改变的原因。为了接下来讨论⽅案,我们先约定两个术语。

内插值:已确定的主控端过去位置P0和现在位置P1,模拟端执⾏从P0到P1之间的平滑插值(线性或非线性均可)以使渲染表现平滑的插值⽅法,我们叫它内插值。它的特点是插值的两端点都是已确定的历史位置。

外插值:已确定的主控端过去位置P0和速度V等,模拟端使⽤P0和V等来模拟主控端的运动轨迹以使渲染表现平滑的⽅法,我们叫它外插值。它的特点是起始端点已知,运⾏的终点已越过已确定值的边界(外插值叫法的理由)。

从基础物理中我们知道对于匀速直线运动有如下路径公式:

Pt = MoveFunction(P0,V ,t) = P0 + S = P0 + V * t

所以如果我们在做位置同步的时候不只是同步位置,我们转⽽同步如下数据元组:

<位置P, 速度V, 时间戳T>

那么我们就可以在C1上使⽤P0的同步数据⾃⾏进⾏位移模拟。我们继续来考察形如图3的这种⽹络波动, 使⽤<位置P, 速度V, 时间戳T>元组同步后P0'可以如何模拟P0的位置:

C1在T2时刻收到C0的T0时刻的<位置Pt0,速度V0,时间戳T0>,此时P0'先把⾃⼰的位置瞬移或快速内插值到"位置P"。

Pt0' = 快速插值到P0 T0时刻的位置Pt0
V = V0
C0T = T0

C1在T3时刻没收到C0的消息,但因为它拥有T0时间的速度V0,所以P0'的位置可以使⽤该速度进⾏外插值⽽不需要停下来。

Pt1' = Pt0' + V * (T3 - T2)

C1在T4时刻收到C0的T1时刻的<位置Pt1,速度V1,时间戳T1>,此时检验当前预测结果Pt1'是否等价于Pt1,如果相等,则⽆需做任何操作,⽽只要更新模拟使⽤的速度V1即可,否则执⾏和T2时刻相同的内插值。

V = V1
C0T = T1
if not equal(Pt1' , Pt1)
     Pt1' = 快速插值到P0 T1时刻的位置Pt1

C1在T5时刻收到C0的T2时刻的<位置Pt2,速度V2,时间戳T2>,此时如果插值如果结束,则更新Pt'到Pt2,否则把插值终点修改点Pt2,继续执⾏速内插值。

V = V2
C0T = T2
if not equal(Pt1' , Pt1)
Pt2' = 快速插值到P0 T2时刻的位置Pt2
Pt2' = Pt2


C1 在 T6,T7时刻没有收到C0的消息,执⾏和T3时刻相同的外插值操作以更新P0'的位置。

C1在T8时刻收到C0的T3时刻位置<位置Pt3,速度V3,时间戳T3>,执⾏和C1在T5时刻相同的操作。

再者,此⽅案可以对抗⽹络丢包吗?

<图5>

如图5所⽰,如果C0在T2时刻发出的数据包出现丢包现象,这时C1的更新逻辑会和未发⽣丢包情形下的T6、T7 时刻⼀样,执⾏外插值来模拟P0'的位置变化。以结果⽽⾔,丢包不影响它的最终数据⼀致性。

但这个⽅案依然有⾃⼰的问题:

折反跑 : 如果出现丢包或延迟增⼤时,则会出现以下问题:

状态错误:如下图所⽰,⾓⾊可能⾛进障碍、或⾛进空中、⾛进⽔中,或者极端⼀点死在了P点

3、基于缓冲的位移同步

如果我们仔细分析2中所描述的位移同步,我们不难得出结论:所有的问题均来源于外插值, 因为外插值等同于模拟端对主控端的⾏为预测。在同步数据到达前,主控端的⾏为主动或被动的改变,就会带来模拟端的错误结果

既然如此,我们去掉外插值不就⾏了吗?当然只是从2去掉外插值不够,因为这样⼜会出现我们在Naive Solution中出现的⾛⾛停停的情况。所以问题是:我们去掉了外插值之后,如何保证我们既只执⾏的内插值,⼜避免⾛⾛停停的情形出现? Solution:我们在模拟端为每个需要模拟的⾓⾊准备⼀个位移缓冲区,只有在缓冲区有 充⾜ 的移动操作之后,我们才开始移动,就能保证我们始终做的是内插值。

伪代码如下所⽰:

function ExecuteMove(DeltaTime)
     if(MoveBuffer.Size < RequiredMoveOpSize())
         RequireMoveOps()
     if equal(CurrentPosition ,EndPosition)
          if(MoveBuffer.Size() > 0 )
              MovOp = MoveBuffer.Pop()
              EndPosition = MoveOp.Position V = (VCurrent + MoveOP.V) * 0.5
               V = 0
      CurrentPosition = CurrentPosition + V * DeltaTime


这⼉的MoveBuffer的产⽣和消耗可以看作⼀个简单的 ⽣产者--消费者 模型,其⼤⼩⼀般来需要根据移动速度、⽹络延迟、消耗速度来 动态调整⼤⼩ 。简单粗暴的先Cache 5秒的MoveSeq当然可以满⾜⼏乎所有的⽹络条件,但这个延迟下我们就已经不是在做⽹络同步,⽽可以说只是做⼀套⽹络回放了。Buffer⽹络包的做法⼴泛 地⽤在流媒体处理中,在⾳视频领域这个缓冲区叫JitterBuffer。

使⽤基于缓冲的位移同步,它可以保证多端的运动轨迹⼏乎完全⼀致,Buffer Size的选择往往是基于实际

测试数据、⽹络延迟、同步频率等相关信息来评估和预测。如《COD现代战争》虽尝试⽤ACF/PSD函数来做延迟预测以做Buffer⼤⼩决策,但其实很难量化的评估ACF/PSD等预测函数功⽤⼏何,因其Buffer⼤⼩的最终结果依旧是依赖不同地区不同平台的测试数据,在PC平台上⼏乎接近于0。

使⽤基于缓冲的策略的时候还需要考虑丢包问题,即当某个状态改变的数据包丢失的时候,是重发该包或是在下⼀个数据包中使⽤冗余信息顺带传输丢包的信息。

总结: 基于Buffer的位移同步可以准确的模拟主控端在空间上的移动轨迹,但其准确性在<位置,朝向,时间> 这个维度上来说,准确的是空间中的六维,代价是在时间维度带来更⼤的不准确性,即带来额外的⽹络延迟 。如果处理不当,其带来的延迟将⼗分显著,这些延迟会带来技能命中判断上的困难,进⽽影响⼿感和游戏体 验,这对PVP游戏来说,会造成严重的游戏平衡性的问题。

除了上述⼀些位置同步⽅案外,还有以下⼀些可⽤的⼿段:

移动预测:模拟端重放最后⼀条收到的主控端的指令,直到它⾃⼰收到新的同步消息再去修正⾃⼰的错误。

环境适应:模拟端执⾏部分和不变的环境相关的逻辑判断⽽不是粗暴的外插值,执⾏诸碰撞检测、攀爬登顶、进入⽔体,坠入空中、是否从空中落地等判断,从⽽规避掉环境所导致的外插值错误,改善插值结果。过外插值造成的错误可分为三类:<1>环境适应所导致的主控端位移状态变化或数值变化,如被障碍挡住所导致的移动停⽌,攀爬触发登顶操作、进入⽔体⽽切换到游泳状态等。<2>.主动操作引起主控端的状态或数值变化,如改变⽅向,由移动变为跳跃等。<3>.场景中其它游戏实体或技能等导致的主控端状态或数值变化,如被击退、击杀、击退、被抓取等。这⼉的环境适应可以解决错误<1>的问题, 从⽽提⾼插值的准确性。

同步路径:如技能主动/被动操作可以同步[起点,终点],固定路径巡逻信息可以同步[起点,路径ID],任务寻路可以同步⼀⼩截路径List等等。甚⾄所有的位移都可以使⽤该模式⼀⼩截⼀⼩截进⾏同步——原来的同步信息 [位置,速度,开始时间戳] 增加⼀个终点信息,变成 [起始位置、结束位置、开始时间戳,结束时间戳]这个四元组。这相当于在主控端先做⼀个基础的移动预算,再同步给模拟端。

游戏中的位移同步与其说是数据⼀致性问题,毋宁说其即时性和可⽤性重要度更⾼。且因为位移数据存在相互影响的可能性(任意两个主控端可能会影响彼此的真实位置),故其在实际应⽤时是既要数据⼀致性,⼜要保证其操作反馈的即时性。因为基于缓冲的位移同步容易陷入拉⻓Buffer会导致“延迟”过⾼,⽽减⼩Buffer⼜容易导致Buffer⽤尽的⾛⾛停停的⽭盾之中。这对延迟不敏感的游戏来说,⾃然可以选择更⼤的Buffer,⽽对于PVE\GVE等对延迟影响⼿感乃⾄平衡性的游戏来说,更可靠的选择则是折中的组合型的⽅案: 动态Buffer⼀⼩段移动同步消息 + 带环境适应的外插值 + 内插值 + 强制位置校正 。⼤概可以做如下表达:

function ExecuteMove(DeltaTime)
        if(MoveBuffer.Size < RequiredMoveOpSize())
            RequireMoveOps()
        if equal(CurrentPosition ,EndPosition)
            if(MoveBuffer.Size() > 0 )
                MovOp = MoveBuffer.Pop()
                EndPosition = MoveOp.Position
                V = (VCurrent + MoveOP.V) * 0.5
                //位置校正相关
                if(NeedPositionCorrect(CurrentPosition,MovOp.Position,MovOp.TimeStamp))
                    CurrentPosition = Position ;
                    return ;
                //位置校正相关           
            因为需要外插值,所以这儿V不置为0