Post

移动同步--MMO服务器开发技术点记录

移动同步--MMO服务器开发技术点记录

联想这样的场景,在联机游戏中,其他玩家会在它们的场景中看见我的角色,我会我的场景中操纵我的角色。如果要实现两个客户端之间的移动同步,我们需要在服务器也创建一个同样的角色。这样同一个角色就存在三个不同的实例,任意一个实例的状态变化都需要同步到另外两个实例。这个思路类似于UE引擎中的NetRole。我们假设这三个实例的角色分别为:

1
2
3
4
5
enum class Role {
    authority, // 服务器端角色
    autonomous, // 客户端自主角色
    simulate // 客户端模拟角色
}

authority肯定就是服务端的角色,autonomous则是在本地客户端被我们控制的角色,simulate则是其他玩家经由服务器同步给我们本地客户端的角色。

接下来要定义移动状态信息:

1
2
3
4
message Movement {
    Vector3f position = 1; // 角色在世界中的位置
    Vector3f rotation = 2; // 角色的朝向
}

移动状态包含的信息越多,移动质量更好,但是带来的网络开销也会更大。因此在设计移动状态时,需要在移动质量和网络开销之间找到一个平衡点。

移动同步策略实际上也就是这三个角色的状态同步策略。

autonomous 向 authority 的同步

  • 客户端定时同步自己的移动状态到服务端;
  • 客户端将移动状态的变更及时反馈给服务端;

客户端设定一个更新间隔 update_interval,单位为毫秒。客户端每隔 update_interval 毫秒向服务端发送一次移动状态更新。在update方法中判断是否需要发送更新。

服务端接收到客户端的更新包后,对更新包进行校验,然后将更新应用到authority角色上。

authority 向 simulate 的同步

  • 同样是服务端定期向客户端推送玩家实体的MoveComponent移动状态。

实现比较简单,就是把位置信息打包发给simulate角色。

插值

想象一下,你在玩一个在线游戏。你控制的角色(autonomous)在你自己的电脑上运行,而其他玩家的角色(simulate)是通过网络从服务器接收到的信息来显示的。

由于网络延迟,你看到的其他玩家的动作并不是实时的。服务器会定期发送其他玩家的位置信息,但这些信息到达你的电脑需要时间。这意味着,你看到的其他玩家的位置是过去某个时刻的位置,而不是他们现在的位置。

如果没有插值,simulate 角色会直接跳到服务器发送的最新位置。这会导致非常生硬和不自然的移动,看起来就像角色在瞬间移动一样。

插值的作用:

插值的目的是平滑这些不连续的移动。它的基本思想是,根据过去的位置信息,预测角色在当前时刻应该在的位置。

插值的基本思路是根据已知的位置信息和时间戳,推算出当前时刻的角色位置。具体实现可以使用线性插值或更高级的插值算法。目前我们的客户端示例实现的是线性插值,比较简单,因为我们的目的是开发服务器,客户端表现上就需要我们的客户端同学去优化了。这里简单介绍一下线性插值的实现。

线性插值:

线性插值是最简单的插值方法。它假设角色在两个已知位置之间以恒定的速度移动。

具体来说,线性插值需要以下信息:

  • 起始位置 (_startPos):角色开始移动时的位置。
  • 目标位置 (_endPos):角色要到达的位置(从服务器接收到的最新位置)。
  • 插值时间 (MovementInterpolateInterval):完成插值所需的时间。
  • 已过去的时间 (_lerpTimePass):从插值开始到现在经过的时间。

然后,它使用以下公式计算角色在当前时刻的位置:

当前位置 = _startPos + (_endPos - _startPos) * (已过去的时间 / 插值时间)

这个公式的含义是,角色从起始位置移动到目标位置的距离,与已过去的时间和插值时间的比例成正比。

代码示例解释:

1
2
3
4
_lerpTimePass += Time.deltaTime;
float t = Mathf.Min(1f, _lerpTimePass / MovementInterpolateInterval);
transform.position = Vector3.Lerp(_startPos, _endPos, t);
transform.rotation = Quaternion.Slerp(_startRot, _endRot, t);
  • _lerpTimePass += Time.deltaTime;:更新已过去的时间。
  • float t = Mathf.Min(1f, _lerpTimePass / MovementInterpolateInterval);:计算插值因子 tt 的值在 0 到 1 之间,表示插值进度。
  • transform.position = Vector3.Lerp(_startPos, _endPos, t);:使用线性插值计算当前位置,并将其设置为角色的位置。
  • transform.rotation = Quaternion.Slerp(_startRot, _endRot, t);:使用球面线性插值计算当前旋转,并将其设置为角色的旋转。

预测

我们知道网络存在延迟性,客户端收到位置信息时,这个位置信息至少已经是一个RTT周期前发生的事情了,并且由于插值的存在,需要经过一个插值周期,客户端的位置状态才会更新到这个落后的状态,也就是这个状态至少落后了1个RTT + 1个插值周期。在一些对于位置判定严苛的游戏来说,这样的落后是不被允许的。

所以我们需要去预测,根据收到的位置状态去推测发送位置状态的角色在一个RTT和一个插值周期后的位置状态是怎样的。但是预测就必然存在错误,如果在一个RTT和一个插值周期之间,远程的客户端发生的输入导致位置信息发生另一个变化,就会导致预测错误。

如何去检测和修正预测的错误呢?检测比较简单,就是在客户端接收到新的位置信息时,将其与当前预测的位置进行对比。如果发现预测的位置与实际的位置存在较大偏差,就需要进行修正。如何进行修正呢?其实不用我们自己去实现,已经有现成的成熟的算法,可以平滑的过渡。这里提供参考:Projective Velocity Blending

到这里移动同步基本就实现完成了。

动画同步

在MMO中,动画也是需要同步的。与移动同步类似,三个角色直接的动画状态也是需要同步的。动画状态的改变可以放在位置信息中一起同步,因为动画的改变通常伴随着移动的发生。

在unity中,动画状态的改变需要通过动画状态机来实现。于是我们动画状态的同步也只能同步这些状态机的状态变量。状态变量是触发状态发生转移的条件,一般是bool类型。

对于bool类型的变量,我们可以将其保存在二进制数上,使用位运算来进行状态的压缩和解压。例如,我们可以使用一个整型变量的每一位来表示一个动画状态的开关。这样,我们就可以在网络传输时,将多个动画状态合并成一个整数进行传输,减少网络开销。

我们将这个状态变量放在MoveComponent中,也就是MoveComponent中的mode字段。

对于人物移动速度和动画对不上的问题,其实比较好解决,因为我们的movement状态中包含了速度信息,加速度信息,我们可以根据速度信息来调整动画的播放速度,从而实现动画与移动的同步。

服务端的实时计算

TODO

This post is licensed under CC BY 4.0 by the author.