移动同步
unity/c++ mmo大型多人在线游戏开发系列教程,本节代码在此。
在之前的实现里,我们已经可以让每个玩家互相看见对方了,但可惜,只能看到对方静止在初始出生点,而看不到对方的移动。 本节我们就来实现移动的同步。
NetRole
同一个角色,在多人游戏中会被同步到不同的地方,在不同的地方,他的身份、权限并不一样。典型的,我们以角色A登录服务器,在我们本地的游戏客户端进程会创建一个角色A,服务端进程也会创建一个角色A,而且服务端的角色A会同步给场景内的其他人,因此在其他人的游戏客户端进程里,也会存在一个角色A。
那么,当我在说角色A的时候,到底是指哪一个A呢?鬼知道。
而移动同步就发生在这三个A之间,因此,在开始之前,我们需要先定义一个概念:NetRole
,不然后面的描述会不清不楚。简单来说,A在不同的地方会有不同的NetRole
:
enum class ENetRole
{
// 服务端
authority,
// 主控端
autonomous,
// 模拟端
simulate,
}
基本上就是从UE中抄的,而且会比UE中更好理解,毕竟我们的客户端和服务端代码是分开的,也没有纯单机版本的游戏,所以authority
肯定就是服务端的角色,autonomous
则是在本地客户端被我们控制的角色,simulate
则是其他玩家经由服务器同步给我们本地客户端的角色。
移动数据
然后,我们要定义移动数据的结构体,同步的就是这个东西。数据可以很简单,例如仅包含位置信息。也可以复杂一点,把当时的位置、速度、朝向全放进去。总的来说,信息越多,同步的质量越好,但代价是资源的消耗也越多:
message Movement {
Vector3f position = 1;
Vector3f rotation = 2;
}
autonomous与authority同步
客户端定期上传autonomous
的移动数据给服务端:
public class NetworkComponent : MonoBehaviour
{
[Tooltip("movement upload interval in second")]
public float MovementUploadInterval = 0.03333f;
private float _nextMovementUploadTime = 0f;
private CharacterController _characterController;
// Start is called before the first frame update
void Start()
{
_nextMovementUploadTime = Time.time;
_characterController = GetComponent<CharacterController>();
}
// Update is called once per frame
void Update()
{
if (_nextMovementUploadTime < Time.time)
{
UploadMovement();
_nextMovementUploadTime += MovementUploadInterval;
}
}
public void UploadMovement()
{
Vector3 velocity = _characterController.velocity;
SpaceService.Movement movement = new SpaceService.Movement
{
Position = new SpaceService.Vector3f { X = transform.position.x, Y = transform.position.y, Z = transform.position.z },
Rotation = new SpaceService.Vector3f { X = transform.rotation.eulerAngles.x, Y = transform.rotation.eulerAngles.y, Z = transform.rotation.eulerAngles.z },
};
NetworkManager.Instance.Send("client_upload_movement", movement.ToByteArray());
}
}
服务端收到后,可以做一定的校验,校验通过后应用到对应的authority
身上。我们先不做校验,无条件信任客户端:
void SpaceService::upload_movement(TcpConnection* conn, const std::string& msg_bytes)
{
Player* player = find_player(conn);
if (!player)
return;
space_service::Movement movement;
movement.ParseFromString(msg_bytes);
player->set_position(movement.position().x(), movement.position().y(), movement.position().z());
player->set_rotation(movement.rotation().x(), movement.rotation().y(), movement.rotation().z());
}
authority与simulate同步
剩下的就是authority
与simulate
之间的同步,服务端定期把authority
的移动数据广播给所有客户端:
Space::Space(size_t w, size_t h) : _width(w), _height(h)
{
_update_timer = G_Timer.add_timer(100, [this]() {
this->update();
}, true);
}
void Space::update()
{
space_service::PlayerMovements player_movements;
for (Player* p : _players) {
Vector3f cur_position = p->get_position();
Rotation cur_rotation = p->get_rotation();
Vector3f cur_velocity = p->get_velocity();
space_service::PlayerMovement* data = player_movements.add_datas();
data->set_name(p->get_name());
space_service::Movement* new_move = data->mutable_data();
space_service::Vector3f* position = new_move->mutable_position();
position->set_x(cur_position.x);
position->set_y(cur_position.y);
position->set_z(cur_position.z);
space_service::Vector3f* rotation = new_move->mutable_rotation();
rotation->set_x(cur_rotation.pitch);
rotation->set_y(cur_rotation.yaw);
rotation->set_z(cur_rotation.roll);
}
for (Player* p : _players) {
send_proto_msg(p->get_conn(), "sync_movement", player_movements);
}
}
插值
由于服务端是定期同步移动数据的,所以客户端收到的移动数据是离散的,如果直接应用到simulate
上,玩家会看到simulate
的位置在跳变,并且同步的间隔越长跳变越明显。
就算同步的间隔非常短,极限到每帧同步一次,由于网络的不稳定性,我们依然不可能保证每帧就收到最新的移动数据,所以依然会跳变。
一般来说会对移动数据进行插值补帧,以达到平滑的目的:
_lerpTimePass += Time.deltaTime;
float t = Mathf.Min(1f, _lerpTimePass / MovementInterpolateInterval);
transform.position = Vector3.Lerp(_startPos, _endPos, t);
transform.rotation = Quaternion.Slerp(_startRot, _endRot, t);
其中MovementInterpolateInterval
是插值时长,如果设得太短,那插值完成了下一个数据包还没来,会卡顿。设太长了会增大延迟,所以设成跟服务端定期同步的间隔时长一样即可。
PS. 按目前的策略,其实autonomous
同步给authority
也有一样的问题,只不过没有人在观察服务端的世界罢了,一般来说,这种离散跳变也不会影响到服务端的逻辑,如果真有逻辑需要每帧更新authority
的位置,那就需要另外的机制了。
预测
插值的最大问题是,延迟。插值需要时间,本身在不插值的情况下,simulate
的状态会落后于authority
半个RTT,当使用插值时,simulate
要再经过一个插值周期才能到达最新状态,所以也就延迟了半个RTT + 一次插值周期
。
因此,一些依赖位置的游戏逻辑,两端的判定就可能产生不一致。例如写技能的时候,客户端自己判定打中了别人,但真的是打中了吗?或许在这延迟期间,别人已经离开了攻击范围。如果此时告知玩家其实你没打中,那玩家就很不爽了。
所以就有了预测这一个功能,客户端收到移动数据后,立马往前预测半个RTT,以对齐服务端的时间线,后续每帧更新时往前预测一帧。这么一来,simulate
的状态就是最新的,没有延迟。而为了预测,我们同步的移动数据还得加上速度信息:
message Movement {
Vector3f position = 1;
Vector3f rotation = 2;
optional Vector3f velocity = 3;
optional Vector3f acceleration = 4;
optional Vector3f angular_velocity = 5;
}
世界没有免费的午餐,没有延迟的代价是,这个状态可能完全是错的,毕竟预测嘛,也不一定成功。
那如何判定预测成功与否呢?其实就是判定当前simulate
与authority
的状态是否一致。但无论怎么做,我们都无法获知authority
当前的状态,我们得到的移动数据包永远是服务端在半个RTT前的过去发出的。所以,唯有再一次预测,把最新收到的authority
的状态再往前预测半个RTT,以此作为authority
当前的状态,然后跟simulate
的状态进行作对比。道理上也是说得通的,毕竟如果我们的预测是正确的,那预测的路径会经过authority
半个RTT之前的位置,而从这个位置重新预测半个RTT,必然也会等于当前预测的结果。
结果一致时自然皆大欢喜,结果不一致时就要进行修正了。修正的原则是要让本地状态慢慢过渡到最新的状态,不然会产生跳变。不用过于烦恼修正的算法,已经有现成的了,叫做Projective Velocity Blending
,参考链接:
private void PredicteMovement()
{
_positionBlendingTime += Time.deltaTime;
float rate = Mathf.Min(1.0f, _positionBlendingTime / MovementInterpolateInterval);
_curVelocity = _localVelocity + (_serverVelocity - _localVelocity) * rate;
if (!_positionBlending)
{
// 加了一个误差范围,误差比较小时不做blending
_characterController.Move(_curVelocity * Time.deltaTime);
}
else
{
// 就是参考链接里的算法,ForwardPredictePosition就是一个匀加速直线运动的位移公式
Vector3 P0 = ForwardPredictePosition(_localPosition, _curVelocity, _serverAcceleration, _positionBlendingTime);
Vector3 P1 = ForwardPredictePosition(_serverPosition, _serverVelocity, _serverAcceleration, _positionBlendingTime);
Vector3 newPosition = P0 + (P1 - P0) * rate;
Vector3 movement = newPosition - transform.position;
_characterController.Move(movement);
}
}
另一个问题是,RTT
如何估算。最简单的做法是实现一个ping-pong协议,不断计算收发协议的时间差,然后取个比例更新即可:
public void Ping()
{
SpaceService.Ping ping = new SpaceService.Ping();
ping.T = Time.time;
NetworkManager.Instance.Send("ping", ping.ToByteArray());
}
public void Pong(float t)
{
float curRtt = Time.time - t;
float rate = 0.8f;
NetworkManager.Instance.RTT = NetworkManager.Instance.RTT * rate + (1 - rate) * curRtt;
}
小结
现在我们可以看到联机的其他单位的移动了,并且可以选择插值或预测不同的同步方式。但是,仅仅是处理走和跑两种模式而已,连最起码的跳跃都还没支持。同时,现在看到的联机角色并没有动画。这些都跟动画同步有点关系,打算放到下一节一并处理。