帧同步

what

什么是同步

  • 在不同的客户端上表现相同。
  • 目前市面上的游戏,一般分为帧同步和状态同步。

什么是帧同步

  • 是一种网络同步技术。
  • 由客户端A发送请求到服务端,再轮播给所有客户端各自在本地进行计算。
  • 帧同步需要确保不同平台运算结果统一。

什么是状态同步

与帧同步最大的区别就是,它不需要按逻辑帧(固定时间间隔)去同步数据,而是根据数据发生改变后才进行同步数据。

why

  • 王者荣耀就是用帧同步方式实现的网络同步。另外LOL用的是状态同步。

帧同步vs状态同步

  • 战斗逻辑。帧同步是在客户端(或移到服务端)进行计算;状态同步一般只在服务端进行计算。
  • 开发压力。帧同步因为在客户端算,要解决不同平台浮点数的差异,需要额外开发定点数替换浮点数;状态同步是在服务端算,相对安全,但对服务端负载要求更为高。
  • 战斗重连。帧同步,玩家重连只能从服务端获取操作序列帧加速运算来恢复重连;状态同步可以随时获取到一个时间点的状态,所以mmo采用这个为主,可以瞬间恢复。
  • 更新频率。帧同步,服务端必须保持定时推送同步信息给客户端,即使没有任何变更;状态同步,可以等某个状态发生变化了才推送同步信息给客户端。

how

  • 各种平台的浮点精度不一,计算结果不统一怎么办?通过 定点数运算数学库 替换 浮点运算。
  • Unity碰撞环境也是基于浮点运算实现的,无法确保计算结果统一,怎么办?自己实现移动和物理碰撞,自己计算确定性物理碰撞。
  • 不使用浮点数而使用定点数,或限定各客户端所运行的硬件及操作系统从而浮点数的运算是一致的;

以下是完整定义

  • 确定性的随机数机制;
  • 确定性的容器及算法(增加、移除、排序等);
  • 隔离和封装逻辑层,以防止其他不确定性的调用;
  • 如需,则也须做到确定性的物理机制、导航机制、动画骨骼机制等;
  • 排查所有引起异常(exception)的逻辑。

下面只对项目使用的帧同步进行分析,不再涉及状态同步的讨论。

实时对战

实现难点与解决

Q:帧同步需要高频率的网络通信,TCP因为复杂的确认机制导致速度慢而无法满足要求怎么办?
A:要实现可靠的UDP通信。

Q:服务器每秒需要转发大量逻辑帧来驱动客户端表现,因此需要大量通信。我使用15帧,也就是每66ms广播一次。哪怕是15帧也很低,客户端播放看上去很卡怎么办?
A:实现逻辑与显示分离,客户端的显示性能拉满不限制帧数,数据则完全是以15帧的逻辑层为准。

Q:逻辑与显示是分离了,但是显示的空缺帧怎么补?
A:客户端进行运动行为预测与插值,来平滑运动轨迹。

下面一章按照上面的几个回答,一个一个展开。

项目实现

整体框架图

实现可靠的UDP通信

Q:帧同步需要高频率的网络通信,TCP因为复杂的确认机制导致速度慢而无法满足要求怎么办?
A:要实现可靠的UDP通信。

逻辑帧与显示帧分离

A:实现逻辑与显示分离,客户端的显示性能拉满不限制帧数,数据则完全是以15帧的逻辑层为准。

  • 逻辑帧15帧(66ms),表现帧60帧(16.5ms)的情况,用图表现如上。

  • 数据层,依赖于逻辑帧,按照每秒15帧为准,需要完全脱离Unity的框架(甚至可以直接在服务器上跑)。

  • 显示层,直接继承MonoBehaviour,按照每秒60帧甚至更高为准,补逻辑帧不够的帧。

运动预测

Q:逻辑与显示是分离了,但是显示的空缺帧怎么补?
A:客户端进行运动行为预测与插值,来平滑运动轨迹。

运动预测

  • 用算法计算出后续运动量,补齐表现帧:
  • Position预测偏移 = 逻辑帧速度 * 逻辑帧方向 * 时间。
  • Rotation不进行预测。转向的预测不可靠。

逻辑帧驱动

  • 服务器推送回来逻辑帧后,立刻更新表现帧。

预测误差

  • 如果出现和上面那张图一样,运动预测错误的情况,属于正常范畴,但是尽可能避免与修复。

网络误差

  • 网络波动,导致可能不止66ms更新一次逻辑帧,那样就会一直预测跑动,等拉回来后人物会出现瞬移,解决办法是限制补帧的上限值,即两次逻辑帧之间最多只补x帧。

平滑算法

比如拐弯行为,会频繁出现 预测误差 导致人物动画很毛糙,所以需要平滑处理。

线性插值函数Lerp

public static Vector3.Lerp(Vector3 a, Vector3 b, float t)

插值,等于 a + (b - a) * t。

移动平滑

  • Position平滑的方法是,使用线性插值
1
transform.position = Vector3.Lerp(transform.position,viewTargetPos,Time.deltaTime * viewPosAccer);

旋转平滑

  • Rotation平滑的方法仍然是使用线性插值,但是和Position不同的是,需要一个增量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float threshold = Time.deltaTime * viewDirAccer;
float angle = Vector3.Angle(RotationRoot.forward, viewTargetDir);
// 增量:
float angleMult = (angle / 180) * AngleMultiplier * Time.deltaTime;

if (viewTargetDir != Vector3.zero)
{
Vector3 interDir = Vector3.Lerp(RotationRoot.forward, viewTargetDir, threshold + angleMult);
RotationRoot.rotation = CalcRotation(interDir);
}

/// 算出旋转角度
protected Quaternion CalcRotation(Vector3 targetDir)
{
return Quaternion.FromToRotation(Vector3.forward, targetDir);
}

这个angleMult其实就是两者唯一的区别,为什么需要一个增量呢?

// TODO 考虑到角度的偏转可能大可能小,所以做一个根据角度偏转大小成正比的角度变化加成量。但是实际效果需要在理解Vector3.Lerp后才能计算出。

FPS、Ping计算

画面FPS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float frameTimeCount;
int frameCounter;
void UpdateFPS(float delta) {
frameTimeCount += delta;
++frameCounter;
// 每2秒更新一次FPS数值
if(frameTimeCount >= 2) {
txtFPS.text = "FPS " + frameCounter / 2;
frameTimeCount -= 2;
frameCounter = 0;
}
}

// 外部调用
void Update(){
...
UpdateFPS(Time.DeltaTime);
}

Ping

通过心跳机制来计算ping值。

心跳机制

① 从 客户端连接服务器 成功开始,就定期从客户端发送请求到服务端,频率不用太高(我这里设置每5s一次),在请求包中记录这一次心跳请求的ID。

② 服务端接收到心跳专用的请求包后,直接发回给对应的客户端,回应包中附带本次心跳请求的ID。

③ 客户端接收到回应包,里面包含着本次心跳请求的ID(当然需要其他数据也可以附带)。

基于心跳机制计算ping

在NetSvc中,维护这样一个List(这里用字典,哈希更快):

1
2
// uint 是 心跳请求ID, DateTime是请求发送时间
Dictionary<uint, DateTime> pingDic = new Dictionary<uint, DateTime>();
  1. 在上述 心跳机制① 中,每次发送包时都顺带进行pingDic.Add(sendPingId++, DateTime.Now);

  2. 在上述 心跳机制③ 中,每次收到回应包时,都对 pingDic 进行查找,可以找到同ID的请求时间,再用DateTime.Now减去它,就可以获得一次完整的心跳请求需要的时间。这就是ping了,之后只要修改UI显示Ping就可以了。

连接公网服务器

使用阿里云服务器 ECS。

测试配置

腾讯云4核8G轻量型服务器

系统 2019 Windows Server

带宽 10m

发布

使用文件夹形式发布,然后拷贝到云服务端。

这里遇到了个坑,修了一晚上,就是不要勾选 裁剪未使用的程序集!!!我不知道是导致dll的引用没了还是动态加载的Assembly缺失,总之就是被坑惨了。

配置端口白名单

在服务器配置的 “安全组”的出、入方向中,添加udp和对应的端口号。

反作弊

刚好看到有人聊这个,觉得不错记录一下。

• 服务器计算关键逻辑。例如一些MMO战斗逻辑泡在服务端
• 服务器验证客户端逻辑。包括通过完整战斗逻辑验证和数值范围验证,例如早期的酷跑(可能误杀牛逼的玩家)
• 包体加密加签名加验证,防止破解包体。例如一些第三方加固,Unity的MonoDll加密,代码混淆等
• 加密本地保存的一些文件。例如对加密PlayerPrefs文件
• 加密和扰动运行时内存中关键数据(例如血量数据等)。例如崩3等
• 防注入检测(杀死注入进程或者发现注入之后杀死自己)
• 虚拟机加密
• 加速检测,防止修改本地时间的变速齿轮
• 穿墙检测,客户端对关键碰撞做校验
• 集成守护进程以及守护进程的自我加密更新
• 鼠标宏,按键精灵等进程检测,防止玩家使用这些工具
• 增加举报系统查证封号