延迟补偿在C/S架构游戏协议设计和优化中的应用

1 分钟读完

1.综述

第一人称角色网络游戏的设计是一项很有挑战性的工作。网络环境下的健壮性,是动作游戏能否成功的一个重要因素。另外,PC上面的开发者需要考虑到玩家层次不齐的机器配置以及网络状况,很多用户的硬件配置跟网络跟当前最好的配置跟网络有一定差距。

宽带网络的出现有利于在线游戏开发,但是开发者还是需要考虑网络延迟和其它网络特性。而且宽带网络在美国被广泛采用还需要一段时间,在世界上其它国家可能需要更长的一段时间。另外,很多宽带网络质量很差,用户虽然偶尔能够享受到高带宽,但更多的时候他们不得不面对高延迟和高丢包率。

我们应该提供给玩家良好的游戏。本篇文章讨论了如何提供给玩家顶尖的操作体验;介绍了很多在线动作游戏中采用的C/S架构背景。此外,我们还讨论了如何通过一个预测模型来掩饰延迟带来的影响。文章的最后描述了一个叫做延迟补偿的机制,弥补了因为网络质量不好带来的负面影响

2.C/S游戏的基本架构

网络上可玩的大部分动作游戏都是C/S结构游戏基础上修改完成的,比如半条命以及其修改版反恐精英、军团要塞,以及一些基于quake3引擎和虚幻引擎的游戏。这类游戏都有一个用来执行游戏逻辑的服务器以及连接到这个服务器的多个客户端。客户端仅仅是用来接收玩家的操作并发给服务器,服务器对这些操作作出响应,移动玩家周围物体,并将游戏世界的信息发给客户端显示出来。当然世界的游戏系统有更多组件,我们这样简化有利于分析预测和延迟补偿。

基于这种考虑,典型的C/S游戏引擎通常看起来是这样的

General Client / Server Architecture

为了便于讨论,我们假定客户端跟服务器之间已经建立连接;客户端的每一帧循环如下:

  1. 获取帧开始时间

  2. 采集用户输入

  3. 根据模拟时间将移动命令打包发送给服务器

  4. 获取处理服务器传过来的数据包

  5. 根据服务器数据包的内容决定可见物体及其状态

  6. 渲染场景

  7. 获取帧结束时间

  8. 结束时间减去开始时间就是下一帧的模拟时间

客户端每完成一个帧循环,就用“frametime”来决定下一帧需要多少时间,如果帧率恒定,“frametime”就是准确的,否则就没办法获得准确的“frametime”(因为在没一帧开始你不可能知道这一帧需要多长时间)

服务器的循环大同小异:

  1. 获取帧开始时间

  2. 读取客户端发过来的操作信息

  3. 根据客户端操作执行逻辑运算

  4. 采用上一个循环得到的模拟时间来模拟服务器控制的物体移动状态

  5. 对每一个连接的客户端,发送打包相应的物体/世界状态

  6. 获取帧结束时间

  7. 结束时间减去开始时间就是下一帧的模拟时间

在这个模型中,非玩家物体完全由服务器控制其状态,每个玩家根据服务器发过来的数据包控制自己的移动。这是一种很自然的方法,当然还有其它的方法也可以完成这个功能。

3.用户消息的内容

基于half-life引擎的游戏用户消息都很简单,只需要封装在一个包含几个关键成员的结构中:

typedef struct usercmd_s
{
    // Interpolation time on client
    short lerp_msec;  
    // Duration in ms of command
    byte msec;    
    // Command view angles.
    vec3_t viewangles;  
    // intended velocities
    // Forward velocity.
    float forwardmove;  
    // Sideways velocity.
    float sidemove;   
    // Upward velocity.
    float upmove;  
    // Attack buttons
    unsigned short buttons; 
    //
    // Additional fields omitted...
    //
} usercmd_t;

结构中最关键的变量时msec,viewangles,forward,side,upmove和buttons。msec表示这个命令执行对应的毫秒数(也就是上面提到的“frametime”)。viewangles是一个三维向量,表示玩家的朝向。forward,side和upmove表示玩家是否通过键盘、鼠标或控制杆控制移动。最后,buttons这个字段包含一个或多个比特,标志玩家是否按着某些按键。

基于C/S架构的游戏采用以上数据结构运行如下:客户端创建命令并发送到服务器,服务器响应这些命令并把更新了的世界和物体位置信息发回客户端,客户端收到以后进行渲染。这种方式非常简单,但是在实际应用中效果差强人意,用户会感觉到网络连接带来的明显延迟。这主要是由于客户端完全没有逻辑操作,发出消息以后就等待服务器响应。如果客户端跟服务器有500ms的延迟,客户端执行了操作到看到操作的结果就需要500ms,这种延迟在局域网通常可以接受(因为通常延迟比较小),但在因特网上是没法接受的

4.客户端预测

有一种方法可以改善这种情况:客户端本地即时执行移动操作,假定服务器即时通知客户端可以执行操作,这种方法可以称为客户端预测。

采用客户端运动预测以后,客户端就不再是一个“小型客户端”,不再单单响应服务器命令;但也不是说客户端可以像没有中央服务器的p2p游戏完全自治。服务器仍然在运行并保证在客户端跟服务器运行结果不一致的情况下纠正客户端错误的模拟。由于网络延迟,修正在一个网络传输周期以后才会执行,这个时候纠正信息通常已经过期,这样会导致明显的位置漂移,因为客户端收到的修正信息是过去某个时间的。

为了使客户端运动预测有效,我们采用以下方法:还是客户端采样并生成命令发送到服务器,但是每个包含生成时间的命令在客户端本地存起来并在预测算法中使用。

预测的过程中,我们把服务器确认的移动信息作为开始,这样客户端就可以确定服务器执行上次命令以后游戏中玩家的准确信息(比如位置)。如果网络有延迟,这个确认命令也会有一定延迟。假设客户端运行帧率为50fps,网络延时为100ms,这样在客户端收到服务器的确认命令的时候,本地命令队列中已经有5条信息,这5条信息被用来执行客户端预测。假设执行完全预测【1】客户端在收到来自服务器的最新信息后,就开始按照与服务器相同的逻辑执行本地消息队列中的5个命令。这些命令执行以后得到当前状态(最重要的是位置),然后根据玩家的状态信息渲染当前帧。

在半条命这个游戏中,客户端跟服务器采用相同的代码来计算移动,这样可以减小客户端预测跟服务器之间的误差。这些代码位于HLSDK中的pm_shared/(意思是“player movement shared”)。这段代码的输入是玩家操作和客户端的初始状态,输出是玩家操作以后的状态。客户端算法大致如下:

"from state" <- state after last user command acknowledged by the server;

"command" <- first command after last user command acknowledged by server;

while (true)
{
    run "command" on "from state" to generate "to state";
    if (this was the most up to date "command")
    break;

    "from state" = "to state";
    "command" = next "command";
};

玩家的初始状态和预测结果用来渲染场景。命令的执行过程就是:将玩家状态复制到共享数据结构中,执行玩家操作(执行hlsdk中pm_shared中的共用代码),然后将结果复制到目标状态(to state)

这个系统中有几个需要注意的地方,首先,由于网络延迟,客户端又在不停地以一定速度(客户端帧率)生成命令,一个命令通常会被客户端多次执行,知道得到服务器的确定以后将其从命令列表中删除(这就是半条命中的滑动窗口)。首先要考虑的是如何处理共享代码中生成的声效和动画效果。因为命令可能会被多次执行,预测位置的过程被多次执行的时候要注意避免重声等不正确的效果。另外,服务器也要避免客户端意见预测的效果。然而,客户端必须重新运行旧的命令,否则就没法根据服务器来纠正客户端的预测错误。解决方法很简单:客户端将没有执行的客户端命令进行标记,如果这些命令在客户端第一次执行,则播放相应的效果。

另外需要注意的是服务器不处理,只有客户端才有的一些数据;如果没有这种类型的数据,我们可以如上面所述,以服务器第一条消息作为起点进行预测得到下一帧状态(包括用来渲染的位置信息)。然而,如果有些逻辑是纯客户端的,服务器不会处理(比如玩家蹲下来眼睛的位置-然而这也不是纯客户端信息,因为服务器也会处理这个数据),这种情况下我们需要将预测的中间结果存起来。可以用一个滑动窗口完成这项工作,其中“开始状态”是开始,以后每次执行一个玩家命令预测完成后,填写窗口中的下一个状态;当服务器通知某个命令被接受并执行以后,从窗口中查找服务器处理的是哪条命令并将相应的数据传到下一个帧的“起始状态”

到此为止,我们描述了客户端的运动预测。quakeworld2中采用了这种类型的预测

5.开火过程中的客户端预测

上面描述的系统可以很自然地用于武器开火效果预测。客户端玩家需要记录一些状态,比如身上有哪些武器,正在使用的是哪一个,每把武器都还剩多少弹药。有了这些信息,开火逻辑可以建立在运动逻辑上面,只需要在客户端和服务器使用的命令里面加上玩家开火的按键信息。在半条命中,为了简单,武器开火逻辑代码也跟运动代码一样也作为“共享代码”。所有会影响到武器状态的变量,比如弹药、下次可开火时间、正在播放那个武器动画,都作为服务器的状态,这些状态会通知给客户端用来预测武器状态。

客户端武器开火预测包括预测武器切换、部署、手枪皮套。这样,玩家会感觉游戏中的移动和武器状态100%受他控制。这在减小网络延迟给玩家带来的不爽上面迈出了一大步。

6.一些工作

服务器需要将必要的字段发给客户端,并且处理很多中间状态,有人可能有这样的疑问,为什么不把服务器逻辑取消,让客户端广播自己的位置,也就是将所有的移动、开火逻辑放在客户端。这样,客户端就会给服务器发送类似这样的结果报告:“我在X位置,我爆了玩家2的脑袋”。如果客户端可信的话,这样做是可以的,很多军方仿真系统就是这样做的(他们是一个封闭系统,所有客户端都可信)。点对点的游戏也是这么做的。对于半条命来说不可以这样做,因为客户端可能“欺骗”服务器。如果我们以这种方法封装状态数据,就会诱导玩家破解客户端【3】。对于我们的游戏来说这样做奉献太大,我们还是选择采用服务器模式来做校验。

客户端进行运动和武器效果预测是非常可行的。例如quake3就支持这样的预测。这个系统需要注意一点,在判断目标的时候需要考虑到延迟(比如即时射击武器)。换句话说,虽然你看到自己用\即时\武器进行了射击,你自己的位置也是最新的,射击结果仍然跟延迟有关。例如,如果你射击一个玩家,这个玩家沿与你实现垂直的方向奔跑,假设你客户端延迟为100ms,玩家奔跑速度是500单位每秒,这样你需要瞄准玩家前方50单位才能准确击中。延迟越大,就需要更大的提前量。靠感觉弥补延迟太困难了。为了减轻这种效果,quake3对你的射击播放一个短音来进行确定。这样,玩家可以算出快速发射武器的时候需要多大的提前量,同时调整提前量直到听到稳定的音调串。如果延迟比较大,而你的对手又在不断躲避,就很难获得足够的反馈判断。如果延迟也不断变化,就更难了。

7.目标的显示

影响玩家游戏体验的另一个重要方面是客户端如何渲染其它玩家。两种基本的判断机制是:外推法和内插法【4】

外推法把其它玩家/物体看作一个点,这个点开始的位置、方向、速度已知,沿着自己的弹道向前移动。因此,假设延时是100ms,最新的协议通知客户端这个玩家奔跑速度是500单位每秒,方向垂直于玩家视线,客户端就可以假设事实上这个玩家当前实际的位置已经向前移动了50个单位。客户端可以在这个外推的位置渲染这个玩家,这样本地玩家就差不多可以正确瞄准。

外推法的最大缺点是玩家的移动并不是完全弹道的,而是不确定的并且高”jerk”【5】。大部分FPS游戏采用非现实的玩家系统,玩家可以随时转弯,可以在任意角度作用不现实的加速度,因此外推法得到的结果经常是错误地。开发者可以通过限制外推时间来减轻外推误差(比如quake限制不能超过100ms)。这种限制使得在客户端收到玩家正确位置以后,纠错不至于太大。当前大部分玩家的网络延迟高于150ms,玩家必须对游戏中的其他玩家进行外推以便正确击中。如果别的玩家因为外推错误,被服务器拉回,游戏体验将非常差。

另一种方法叫插值法。插值法可以这样理解:客户端物体实际移动位置总是滞后一段时间。举个例子,如果服务器每秒同步10次世界信息,客户端渲染的时候会有100ms滞后。这样,每一帧渲染的时候,我们通过最新收到的位置信息和前100ms的位置信息(或者上一帧渲染位置)进行差值得到结果。我们每收到一个物体位置的更新信息,(每秒10个更新意味着每100ms收到一个更新)接下来的100ms我们就可以朝这个新的位置移动。

如果一个更新包没有收到,有2种处理方法:第一、用上面介绍的外推法(有可能产生较大误差);第二、保持玩家位于当前位置直到收到下一个更新包(会导致玩家移动顿挫)

内插法的大致过程如下:

  1. 每个更新包包含生成的服务器时间戳【6】

  2. 根据客户端当前时间,客户端通过减去时间差(100ms)计算 一个目标时间

  3. 如果计算得到的目标时间在上一个更新时间和上上个更新时间之间,这些时间戳可以决定目标时间在过去的时间间隙中的情况

  4. 目标时间情况用来通过插值计算结果(如位置、角度)

上面提到的插值法,本质上是客户端缓存了接下来100ms的数据。对于每一个周围的玩家,他们都位于过去某个时间的位置,根据每一个具体的时间点进行插值。如果偶尔发生丢包,我们就将插值时间延长到200ms。这样我们就可以忽略一次更新(假设同步频率还是10次每秒),玩家还可以移动到合理的目标位置,这样进行插值通常不会有什么问题。当然,插值多少时间需要权衡,因为这种方法是用延时(玩家更难击中)来换取平滑。

另外,上述插值方法(客户端通过2个更新信息插值并且朝最新更新位置移动)需要服务器更新信息间隔固定。对于所谓的“视觉效果因素”,这种方式很难处理,“视觉效果因素”是这样的:假设我们插值的物体是弹球(这种模型可以准确描述某些玩家)。极端情况下,球或者在空中,或者正在碰地板。然而,通常情况下球在这两种状态之间。如果我们只插值上一个位置,这个位置可能既不在地面上,也不是最高点,这样,弹球弹的效果就被平滑掉了,好像永远没有弹到地面一样。这是一个经典问题,增加采样率可以减轻这种影响,但是仍然有可能我们采样不到球在地面的点跟最高点,这些点会给平滑掉。

另外,不同用户网络状况不同,强迫每个用户都以固定速度更新(比如每秒10次)效果不是很好,在半条命中,用户每秒可以请求任意数量的更新包(没有限制)。这样,高速网络用户可以每秒更新50次,只要用户愿意。半条命的默认设置是每秒每个用户(以及游戏中其它物体)发送20次更新,以100ms为时间窗口进行插值。【7】

为了避免“反弹球”平滑问题,我们在插值的过程中采用了一个不同的算法,这种算法中我们对每一个可能插值的物体记录了一个完整的“历史位置”信息。

历史位置信息记录了物体的时间戳、远点、角度(以及其它我们需要插值计算的数据)。我们每收到一个服务器的更新,我们就创建一条包含时间戳的记录,其中包含原始位置、角度信息。在插值过程中,我们用上面的方法计算目标时间,然后搜索位置历史信息,找到包含目标时间的记录区间。然后用找到的信息插值计算当前帧的位置。这样我们就可以平滑跟踪到包含所有采样点的曲线。如果客户端帧率比服务器更新频率大,我们就可以将采样点平滑处理,减小上面提到的平滑处理带来的问题(当然没法避免,因为采用频率限制,而世界本身是连续的)。

需要注意的是,上面提到的插值方法使用的时候,物体有时候会被服务器拉回,而不是快速移动。当然我们也可以平滑地将物体移动一段较长的距离,这样看起来物体移动很快。更新的过程中我们可以设一个标志表示不插值或清除历史记录,或者如果起始点与目标点距离过长,我们就认为数据不正常。这种情况我们就将物体直接拉过去。并以这个位置为起始点进行插值。

8.延迟补偿

插值也会带来延迟,所以考虑延迟补偿的过程中需要理解插值过程。玩家看到的别的物体是经过插值计算出来的,所以插值过程中需要考虑在服务器上玩家的目标是否正确。

延迟补偿是服务器执行的一种策略,当服务器收到客户端命令并执行的过程中,根据客户端的具体情况进行归一。延迟补偿可以看做服务器处理用户命令的时候回退一段时间,退到客户端发送命令时候的准确时间。算法流程如下:

  1. 服务器执行客户端命令之前执行以下操作:
    1. 计算玩家正确的延迟
    2. 对每个玩家,从服务器历史信息中找到发送给玩家信息和收到玩家响应的信息。
    3. 对于每一个玩家,将其拉回到这个更新时间(插值得到的精确时间)中执行用户命令。这个回退时间需要考虑到命令执行的时候的网络延时和插值量【8】
  2. 执行玩家命令(包括武器开火等。)

  3. 将所有移动的、错位的玩家移动到他们当前正确位置。

注意:我们把时间往后推算的时候,需要考虑那个时候玩家的状态,比如玩家是或者还是已经已经死掉,玩家是否处于躲避状态。执行运动补偿以后,玩家就可以直接瞄准目标进行设计,而不需要计算一个提前量。当然,这种方案是游戏中的权衡设计。

9.游戏涉及中延迟补偿的使用

采用延迟补偿以后,每个玩家游戏的过程中感觉不到明显延迟。在这里需要理解可能会产生一些矛盾和不一致。当然,验证服务器和无逻辑的客户端老系统也会有自相矛盾的情况。最后,这个这种事游戏设计决定的。对于半条命,我们相信采用延迟补偿是正确的游戏决定。

老系统的一个问题是,由于网络延迟,目标需要有一个提前量。瞄准敌人进行射击几乎总是不能击中。这种不一致导致射击很不真实,响应也不可控制。

采用延迟补偿以后带来的是另一种形式的不一致。对于大部分玩家,他们只需要专注于得到更多的射击技能来武装他们(当然他们也是需要瞄准的)。延时补偿使得玩家只需要直接瞄准他的目标并按下开火按钮即可(对于即时击中武器【9】)。不一致也时有发生,但是是在击中以后。

例如,如果一个延时比较大的玩家击中一个延时比较小的玩家并且得到一分,低延时的玩家会感觉高延时玩家“在角落里被击中”【10】。这种情况下,低延迟玩家可能已经从角落里冲出,而高延时玩家看到的是过去的信息。每一个有延迟的玩家都有一个朝向别的玩家的直的视线,直的视线指向一个瞄准点然后开火。这个时候,低延时的玩家可能已经跑到角落里并且蹲在一个箱子后面,如果高延迟玩家延迟比较大,比如500ms,这是经常发生的;这样当高延时玩家的命令传到服务器的时候,已经隐藏起来的玩家需要取一个历史位置并计算是否击中,在这种极端情况下,低延时玩家会觉得他再角落里被击中了。然而,对于高延时玩家来说,他是正对着别的玩家开火的。从游戏设计的角度来讲,我们需要这样决定:让每个玩家即时与世界交互并开火。

此外,在正常战斗中,上面提到的不一致并不明显。对于第一人称射击游戏,有两种典型情况。第一、考虑两个玩家直线跑向对方并且开火;这种情况下,延时补偿只会把玩家在移动直线上往后拉。被击中的玩家看他的射击者在前方,这样就不会有“子弹拐到角落里”的情况发生。

第二种情况是两个玩家中的一个射击,另外一个玩家在垂直于第一个玩家视线的方向冲锋。这种情况下的解决问题的原理与刚才不同。刚才提到的冲锋的玩家视野差不多是90°(至少第一人称射击游戏是这样),因此,这个玩家看不到正在射击他的那个人。因此他被击中也不会感觉奇怪或者错误(谁让你在空旷区域狂奔呢,活该)。当然,如果你开发的是一个坦克游戏,或者在你的游戏中玩家朝一个方向跑的时候可以看到别的方向,错误可能就会比较明显,你可能发现玩家设计方向不对。

10.总结

延迟补偿是当前动作游戏改善延迟影响的一种方法。是否采用这种方法取决于游戏设计者,因为如何设计直接影响到游戏的体验。对于把那条命、军团要塞、cs这样的游戏,延迟补偿所带来的效果提升显著大于其带来的错误。

脚注

【1】在半条命引擎中,预测的过程中允许一定的延迟,但不能容忍实际网络延迟这么大的延迟。通过调整参数,我们可以控制预测过程中的延迟,这个参数pushlatency是一个负数,以毫秒为单位表示预测过程中的延迟。如果这个值大于(绝对值)实际网络延迟,这时预测就是完全的预测(译注:客户端服务器完全同步)。这种情况下玩家感觉不到任何延迟。实际应用中,一些人错误地认为参数pushlatency应该设为实际网络延迟的一半,这种情况下玩家移动仍然有网络延迟一半的延迟(感觉类似于冰面移动)。基于这个原因,实际应用总应该总是采用完全预测,pushlatency这个变量应该从半条命引擎中移除

【2】http://www.quakeforge.net/files/q1source.zip (Return)

【3】关于作弊和反作弊的问题超出了本篇文章讨论的范围

【4】虽然混合纠正方法也可以使用

【5】“jerk”用来度量使玩家改变加速度的作用的快慢

【6】本文假设计算连接延时的时候客户端与服务器完全同步,也就是说,每次更新的时候客户端收到服务器发过来的时间被直接当做客户端的时间使用。这样,客户端跟服务器完全匹配,只是客户端稍微晚一点(晚多少取决于延时多少)。平滑客户端时钟差值可以有很多方法。

【7】更新时间间隔没必要是固定的。因为对于剧烈运动的游戏,如果带宽不够,很有可能客户端发过来的数据超过了处理能力。如果采用固定更新间隔,在发完一个更新包以后就需要等待一个固定更新周期时间以后再发下一个包。这种逻辑不能很好地使用带宽。因此,服务器发给每个客户端数据包以后,应该自己决定下一个包什么时候发,决定的依据是用户的带宽、用户设置的每秒更新频率。如果用户要求更新20次每秒,那么需要等待50ms以后下个更新包才能发送。如果激活了带宽限制(而服务器帧率又足够高),我们可能就需要等待比如61ms(或其他值)以后发送下一个更新包。因此,半条命游戏数据包发送间隔是随机的。基于服务器的这种情况,将启动点作为一个变量,移动到最新目标点进行插值这种方法效果欠佳。

【8】半条命代码中usercmd_t结构中变量lerp_msec前面描述过。

【9】对于发射导弹的武器,延迟补偿有更多需要解决的问题。假如\导弹是由服务器处理的,那么导弹应该位于哪个时间区间?每次导弹准备发射的时候,是否需要把每个玩家往后拉一段时间的?如果是这样,那么需要往后拉多少?这些问题是需要考虑的。在半条命中,为了避免这种问题,我们对导弹不进行延迟补偿(这并不意味着客户端不进行声音预测,只是实际的导弹不进行延迟补偿)。

【10】用户社区通常采用这种情况来描述不一致性。

  • https://developer.valvesoftware.com/wiki/Latency_Compensating_Methods_in_Client/Server_In-game_Protocol_Design_and_Optimization

分类:

更新时间:

留下评论