在 Unity 中实现帧同步

今年公司把重点放在手游上去了,我作为一个前端程序员,不得不紧跟节奏,于是开始了 Unity 的学习。不久前公司的游戏团队做了一款单人闯关的手游,老板在上线前想增加 PVP 的功能(类似于王者荣耀),但苦于团队没有做过网络游戏的经验,于是我又没有意外地接到了这个任务。在接下来的时间里,我经过各种查资料、写 Demo,最后决定使用帧同步技术来实现 PVP 对战功能。

概念

我们知道,在 PVP 网络游戏中,每个客户端都有可能操作一个或多个人物,可以移动、攻击、释放技能等等,在经过一系列计算处理后(比如打中了某个怪物),会在本客户端产生一个当前的状态,此时也需要知道其他客户端当前的状态,并且实时性要很高,这样才能保证此类游戏的可玩性。这种使得各个客户端表现一致的过程叫做同步,同步又分为状态同步和帧同步。

状态同步

状态同步顾名思义就是同步各个客户端的状态,可能过程不一定同步,但是能保证每一次操作后的状态是一致的。通过开发服务端程序,把用户的操作作为输入实时上传到服务端,服务端通过计算返回结果给各个客户端,这样的过程就是状态同步。

状态同步有几个特点:

  • 客户端只上传操作指令,但是接收所有客户端的状态信息。
  • 整个游戏的逻辑全都在服务端,客户端只是展示的功能。
  • 客户端反外挂能力强。
  • 由服务端统一下发状态,不可能会有不同步的情况。
  • 耗流量不稳定,数据传输量会随着游戏单位和单位信息的增多而增大。
  • 开发效率低,由于整个游戏的逻辑都需要在服务端重新开发,某些引擎(比如 Unity)就完全无用了。

帧同步

与状态同步不同的是,帧同步的服务端开发非常简单,只需要进行指令转发工作即可完成。客户端按照一定的帧速率(理解为逻辑帧,而不是客户端的渲染帧)去上传当前的操作指令,服务端将操作指令广播给所有客户端,当客户端收到指令后执行本地代码,如果输入的指令一致,计算的过程一致,那么计算的结果肯定是一致的,这样就能保证所有客户端的同步,这就是帧同步。

帧同步有几个特点:

  • 客户端只上传操作指令,也只接收操作指令。
  • 整个游戏的逻辑在客户端,服务端没有客户端的状态信息。
  • 客户端反外挂能力弱。
  • 对客户端程序要求高,很容易出现不同步的情况,而且不容易查找。
  • 耗流量低,数据传输量始终只有各个客户端的操作指令。
  • 开发效率高,对于已经开发好的单机客户端,只需要按照帧同步的策略去修改部分代码即可。

帧同步实现的过程

通过上面的概念了解到帧同步其实就是客户端接收操作指令自行计算,使得结果一致从而保证同步。要实现帧同步,关键是要控制每个客户端的计算过程完全一致,因为起始状态一致,那么每一帧计算的结果肯定一致。帧同步还需要注意两个概念:逻辑帧和渲染帧。我们说的帧同步都是指逻辑帧同步,也就是说所有计算都是基于逻辑帧,渲染帧(Update 等)只作显示用。

传统的做法是每个客户端在一定时间内收集操作指令,以一定时间间隔(PVP 游戏一般为 50ms)上传操作指令,并且携带当前的帧编号(帧编号不断增加),服务端必须在收集到所有客户端的当前帧数据后才会下发,也就是会等待所有客户端正常上传操作指令才认为这一帧数据是正常的。这样做有个非常大的缺点,就是网络延迟永远等于延迟最高的那位玩家,这对于 PVP 这种实时性要求非常高的游戏来说是致命的,就游戏性而言也是不公平的。尽管有些开发者做了一些改进措施,比如超时机制,当服务端超过一定时间没有收到某个客户端的帧数据时,便舍弃这个客户端,直接广播数据给所有客户端。这样虽然能使得延迟效果好一些,但对网络要求较高,如果有一个客户端网络环境很差,那么每一帧数据都要等到这个超时才会下发,游戏肯定不会平滑了。

随着游戏技术的发展,帧同步也有了变化,出现了一种叫乐观帧的模式,它的核心流程和传统的帧同步完全相反,由服务端来控制帧速率(比如 50ms 增加一帧),客户端有了操作指令即时上传,空闲时间不用上传,服务端在一帧时间内把收集到的所有操作指令广播下去,客户端根据操作指令执行本地代码。这种模式的好处有几点:

  • 客户端不用在无操作指令的时候上传。
  • 客户端不用控制帧速率(在不同平台客户端要想控制相同的速率也不是简单的事)。
  • 不会因为某个客户端延迟而影响没有延迟的客户端,这点对于 PVP 游戏尤其重要。

有了乐观帧同步,我们可以写一些实际代码来感受一下,下面是我写的 Demo 中的一些代码,功能就是仿照王者荣耀的玩法,对战双方的移动、攻击、扣血和复活,Demo 写的不是很精细,重点是做到客户端同步,而且还带了重连功能。

如何接收帧指令

乐观帧同步是由服务端控制帧速率的,但由于网络抖动等原因,不可能保证在客户端也能以相同时间间隔接收操作指令,我的做法就是把接收到的操作指令保存起来,然后在本地去以一定间隔取操作指令,执行完成后则删除。下面是我封装了一个处理帧指令的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
using SimpleJson;
using System;
using System.Reflection;

public class LogicFrame : MonoBehaviour
{
// 接收到的帧指令集合
private ArrayList frameOrderList = new ArrayList ();
// 记录当前执行的帧编号
private long frame = -1;
// 控制时间间隔
private float lastFrameTime = 0;
private float nextFrameWaitTime = 0.05f;
// 需要分发逻辑帧的脚本
private static ArrayList methodList = new ArrayList ();
private static ArrayList methodListTemp = new ArrayList ();
private static ArrayList methodListDelete = new ArrayList ();
// 保存当前帧的玩家指令
private static Dictionary<int, JsonObject> playerOrder = new Dictionary<int, JsonObject> ();

void Update ()
{
// 每隔一段时间执行一次 Loop() 函数,根据操作指令集合中剩余的数量来调整速度
float now = Time.time;
int frameCount = frameOrderList.Count;
if (now - lastFrameTime >= nextFrameWaitTime && frameCount > 0) {
lastFrameTime = now;
Loop ();
if (frameCount == 1) {
nextFrameWaitTime = 0.05f;
} else if (frameCount == 2) {
nextFrameWaitTime = 0.038f;
} else if (frameCount <= 4) {
nextFrameWaitTime = 0.016f;
} else if (frameCount <= 16) {
nextFrameWaitTime = 0.0f;
} else {
for (int i = 0; i < 10; i++) {
Loop ();
}
nextFrameWaitTime = 0.0f;
}
nextFrameWaitTime = Mathf.Max (0, nextFrameWaitTime - Time.deltaTime / 2);
}
}

void Loop ()
{
// 总是取第一条帧指令
JsonObject frameOrder = (JsonObject)frameOrderList [0];
// 记录当前执行的帧编号
frame = Convert.ToInt64 (frameOrder ["frame"]);
// 保存每个玩家的当前帧指令
JsonArray datas = (JsonArray)frameOrder ["datas"];
playerOrder = new Dictionary<int, JsonObject> ();
foreach (JsonObject data in datas) {
int uid = Convert.ToInt32 (data ["uid"]);
JsonObject userData = (JsonObject)data ["data"];
playerOrder.Add (uid, userData);
}
// 分发逻辑帧
Dispatch ();
// 删除取出的指令
frameOrderList.RemoveAt (0);
}

void Dispatch ()
{
// 先删除注销过的脚本
foreach (Method method in methodListDelete) {
if (methodList.Contains(method)) {
methodList.Remove (method);
}
}
methodListDelete.Clear ();
// 遍历集合,反射调用
methodList.AddRange(methodListTemp);
methodListTemp.RemoveRange (0, methodListTemp.Count);
foreach (Method method in methodList) {
if (method.methodInfo == null) {
continue;
}
method.methodInfo.Invoke (method.mono, null);
}
}

public static JsonObject getFrameOrder (int uid)
{
if (playerOrder.ContainsKey (uid)) {
return playerOrder [uid];
} else {
return null;
}
}

public static void Register (MonoBehaviour mono)
{
Method method = new Method (mono, mono.GetType ().GetMethod ("FrameUpdate", BindingFlags.NonPublic | BindingFlags.Instance));
if (methodList.Contains (method)) {
return;
}
methodListTemp.Add (method);
}

public static void Unregister (MonoBehaviour mono)
{
foreach (Method method in methodList) {
if (method.mono.Equals (mono)) {
methodListDelete.Add (method);
break;
}
}
}

private class Method
{
public MonoBehaviour mono;
public MethodInfo methodInfo;

public Method (MonoBehaviour mono, MethodInfo methodInfo)
{
this.mono = mono;
this.methodInfo = methodInfo;
}
}

}

上面代码首先在 Update 里面做了一个循环,会根据当前操作指令集合中剩余的数量调整间隔时间。可以看出数量越多速度会越快,设置出现了连续执行 10 次的情况,这是为了对抗网络延迟,如果某段时间网络延迟了一下,Loop() 函数执行速度会增加(游戏中的表现就是加速),直至让集合清空,由于服务端是定时下发操作指令,这样处理不至于让操作指令集合越堆越多,导致客户端表现明显的延迟。还可以看到如果集合为空,将不会执行 Loop() 函数。由于帧同步是由逻辑帧驱动的,如果逻辑帧停止那么游戏表现也是停止,逻辑帧的间隔时间也会影响游戏的速度。

关于 Loop() 函数,应该可以说是帧同步的核心了,我一开始的做法是直接在里面进行计算,哪个地方需要,就调用哪个函数。但受到 Unity 的影响,发现它的每个过程都是以回调函数实现的,比如 Update,而且不带任何参数,如果需要取得某个参数,通过其他静态函数获取,比如获取鼠标按键。之后我也效仿这个设计,通过反射获取脚本的 FrameUpdate() 函数,然后在 Loop() 函数中调用,在调用 FrameUpdate() 函数前会把当前帧的操作指令保存起来,可以通过 getFrameOrder() 函数获取某个玩家当前帧的操作指令。

移动

有了上面封装的类,我们可以实现一个关于物体移动的类,由于客户端的所有表现都需要逻辑驱动,所以不只是人物移动依赖 FrameUpdate() 函数,游戏中的飞行道具(如远程英雄攻击)也需要依赖 FrameUpdate(),我们可以写一个通用的类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class LogicFrameMove
{
// 要移动的对象
public GameObject mover;
// 移动速度
public float speed;
// 当前帧的起始位置
public float currPosition;
// 目标位置
private float targetPosition;
// 上一次的目标位置
private float lastTargetPosition;
// 预测的位置
private float forecastPosition;
// 当前帧的人物移动的真实速度
private float speedReal;
// 是否是预测阶段
private bool isForecast = false;
// 是否正在移动
private bool isMove = false;
// 当前帧的移动是否是新的位置
private bool isNewPosition = false;
// 移动动画相关
private Animation animation;
private string idleAnimationName;
private string moveAnimationName;
private bool isNormalAnimation = false;

public LogicFrameMove(GameObject mover, float speed, Vector3 currPosition)
{
this.mover = mover;
this.speed = speed;
this.currPosition = currPosition;
targetPosition = Vector3.zero;
lastTargetPosition = Vector3.zero;
forecastPosition = Vector3.zero;
speedReal = speed;
}

public void StartMove(float vx, float vy, float vz)
{
// 计算下一步应该要走的位置
Vector3 moveVector = new Vector3 (vx, vy, vz);
targetPosition = currPosition + moveVector * 0.05f * speed;
currPosition = targetPosition;
// 调整真实的移动速度
speedReal = Vector3.Distance(mover.transform.position, targetPosition) / 0.05f;
// 计算一个预测的位置
forecastPosition = targetPosition + moveVector * 0.05f * speed;
// 正在移动的标志
isMove = true;
}

public void Move()
{
if (targetPosition == Vector3.zero) {
return;
}
// 判断是否是新的位置
if (lastTargetPosition == targetPosition) {
isNewPosition = false;
} else {
isNewPosition = true;
}
lastTargetPosition = targetPosition;
// 正常移动并判断是否需要预测位置
if (isNewPosition) {
if (!isForecast) {
mover.transform.LookAt (targetPosition);
}
mover.transform.position = Vector3.MoveTowards (mover.transform.position, targetPosition, Time.deltaTime * speedReal);
isForecast = false;
} else {
if (!isForecast) {
mover.transform.LookAt (targetPosition);
mover.transform.position = Vector3.MoveTowards (mover.transform.position, targetPosition, Time.deltaTime * speedReal);
}
if (mover.transform.position == targetPosition) {
isForecast = true;
}
}
// 预测阶段需要走到预测的位置
if (isForecast) {
if (forecastPosition != Vector3.zero) {
// 只有移动状态才会走预测位置,如果在预测阶段停止移动则马上停
if (isMove) {
mover.transform.LookAt (forecastPosition);
mover.transform.position = Vector3.MoveTowards (mover.transform.position, forecastPosition, Time.deltaTime * speedReal);
}
}
}
// 移动动画
MoveAnimation();
}

void MoveAnimation()
{
if (animation == null) {
return;
}
if (isMove) {
isNormalAnimation = true;
animation.Play (moveAnimationName);
} else {
if (isNormalAnimation) {
animation.Play (idleAnimationName);
isNormalAnimation = false;
}
}
}

public void StopMove()
{
isMove = false;
}

public void SetAnimation(Animation animation, string idleAnimationName, string moveAnimationName)
{
this.animation = animation;
this.idleAnimationName = idleAnimationName;
this.moveAnimationName = moveAnimationName;
}

}

上面的代码关键是 StartMove() 函数,计算目标位置和预测位置,这个函数在逻辑帧中执行。目标位置是根据给定和速度朝着给定的方向朝前走 50ms 计算出来的,而预测位置则是朝前走 100ms。为什么要有个预测位置?我们知道网络延迟肯定是有的,那么不可能保证在这一帧执行完之前下一帧数据会按时到达,对于其他操作比如攻击影响倒是不大,但对于移动这种连续性的动作,如果严格按照逻辑帧驱动可能表现得不是很平滑,那么我们就需要一个预测位置。在新的移动位置没有计算出来时先朝着预测位置走去,如果此时新的位置计算出来,再朝新位置走去,这样也不会导致结果不一致。

剩下的 Move() 函数就是真正的移动操作了,这个方法需要放在渲染帧中执行,它与逻辑帧无关,它总是朝着计算好的位置走就对了。还有一个 StopMove() 方法停止移动和 SetAnimation() 方法来控制移动过程中的动画,这个函数应该根据项目使用的动画系统有所变化。

有了上面的代码,我们可以对任意物体进行移动,比如现在要对玩家射出的子弹进行移动,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using SimpleJson;

public class Projectile : MonoBehaviour
{
// 飞行道具的飞行速度
public float speed;
// 移动相关
private LogicFrameMove mover;

void Start ()
{
mover = new LogicFrameMove (gameObject, speed, transform.position);
// 注册逻辑帧分发的回调函数
LogicFrame.Register (this);
}

void OnDestroy ()
{
// 注销逻辑帧分发的回调函数
LogicFrame.Unregister (this);
}

void FrameUpdate ()
{
// 按照当前方向计算目标位置和预测位置
Vector3 moveVector = new Vector3 (transform.forward.x, transform.forward.y, transform.forward.z);
mover.StartMove (moveVector.x, moveVector.y, moveVector.z);
}

void Update ()
{
// 物体移动
mover.Move ();
}

}

这段代码就非常简单了,由于子弹产生后方向不会改变,所以只需要在 FrameUpdate() 里面不停地按照当前的方向计算位置就对了,然后在 Update 去移动,实现逻辑帧与渲染帧的分离。其他类型的移动大体一样,只不过方向可能随时会发生变化,比如人物移动摇杆不停变化,这里不再贴代码。

攻击

攻击的实现比起移动就非常简单了,只需要在 FrameUpdate() 中接收操作指令,进行相应的处理即可,由于所有客户端在接到攻击指令时是同一逻辑帧,那么只要代码一样,产生的结果肯定也一致。

但我在开发过程中遇到一个问题,就是我在攻击后会有一些判断,如果有敌方玩家在攻击范围内,近程英雄会对其造成伤害,远程英雄会放出飞行道具,这里我是使用协程实现的。一开始觉得协程没有问题,后来测试出来在某些情况下可能不同步,比如如果游戏速度变化较大(根据网络情况自动调整,前面有说到),会造成受伤害的帧编号不一致,最后会导致死亡时间不一致,这样会由于蝴蝶效应产生更多意想不到的不同步。

所以针对这类和时间相关的(比如协程)在帧同步中应该禁止使用,应该严格按照逻辑帧来判断,比如攻击出手后 1s 放出飞行道具,那么就应该是在接收到攻击指令的那一帧开始停顿 20 帧(逻辑停顿)再放出飞行道具,针对这点我写了几个函数,可以实现延迟调用某个函数,这个函数也必须在 FrameUpdate() 函数里面使用,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 延迟调用函数相关
private static ArrayList methodDelayList = new ArrayList ();
private static ArrayList methodDelayListTemp = new ArrayList ();

public static void InvokeDelay (MonoBehaviour mono, float delay, string methodName, object[] parameters)
{
int frame = (int)(delay / 0.05f);
InvokeDelayByFrame (mono, frame, methodName, parameters);
}

public static void InvokeDelayByFrame (MonoBehaviour mono, int frame, string methodName, object[] parameters)
{
Method method = new Method (mono, mono.GetType ().GetMethod (methodName, BindingFlags.NonPublic | BindingFlags.Instance));
methodDelayListTemp.Add (new MethodDelay (method, parameters, frame));
}

private class MethodDelay
{
public Method method;
public object[] parameters;
public int currFrame;
public int frame;

public MethodDelay (Method method, object[] parameters, int frame)
{
this.method = method;
this.parameters = parameters;
this.currFrame = 0;
this.frame = frame;
}
}

void Invoke ()
{
ArrayList deleteList = new ArrayList ();
methodDelayList.AddRange (methodDelayListTemp);
methodDelayListTemp.RemoveRange (0, methodDelayListTemp.Count);
foreach (MethodDelay methodDelay in methodDelayList) {
if (methodDelay.method.methodInfo == null) {
deleteList.Add (methodDelay);
continue;
}
// 集合中的函数每一帧计数器加一,达到条件后反射调用
if (methodDelay.currFrame == methodDelay.frame) {
methodDelay.method.methodInfo.Invoke (methodDelay.method.mono, methodDelay.parameters);
deleteList.Add (methodDelay);
} else {
methodDelay.currFrame++;
}
}
foreach (MethodDelay delete in deleteList) {
if (methodDelayList.Contains (delete)) {
methodDelayList.Remove (delete);
}
}
}

void Loop ()
{
...
// 延迟调用函数
Invoke ();
...
}

上面代码很简单,首先根据传入的停顿时间计算应该停顿的帧数,然后将相关信息加入集合,在 Loop() 函数中遍历集合,每一次调用会增加当前帧数,当达到延时条件则反射调用,调用后即刻删除。

有了这几个函数,可以很轻松地实现攻击一段时间后作出反应,比如进程英雄在打中敌方英雄时使其受到伤害,使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void FrameUpdate ()
{
// 取当前帧的指令,并进行相应的计算
JsonObject frameOrder = LogicFrame.getFrameOrder (uid);
if (frameOrder != null) {
if (type == Global.Frame_Attack) { // 攻击
...
// 一秒后会对 aimId 玩家造成伤害
float delay = 1.0f;
LogicFrame.InvokeDelay (this, delay, "AttackHit", new object[]{aimId});
...
}
}
}

void AttackHit (int aimId)
{
// 对 aimId 玩家造成 200 点伤害
Damage (aimId, 200);
}

死亡、重生

当某个玩家血量为 0 时,则判断其死亡,进入死亡倒计时,倒计时结束后重生,这些过程由于是不通知服务端的,完全靠客户端执行本地代码,所以也要严格按照逻辑帧驱动的方法写代码,否则容易造成不同步。比如重生倒计时,需要结合上面写的延时函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private int rebornTime = 0;

public void Damage (int aimId, int damageHp)
{
// 被伤害的人
Player aimPlayer = Global.entity [aimId].GetComponent<Player> ();
// 扣血
aimPlayer.blood.currValue -= damageHp;
// 死亡
if (aimPlayer.blood.currValue <= 0) {
aimPlayer.blood.currValue = 0;
// 开始重生倒计时
LogicFrame.InvokeDelayByFrame(aimPlayer, 0.0f, "RebornCountDown", new object[]{time, aimId});
}
}

void RebornCountDown (int time, int aimId)
{
Reborn (time, aimId);
if (time > 0) {
rebornTime = time - 1;
// 每隔一秒调用自身,当倒计时完成后退出
LogicFrame.InvokeDelay (this, 1.0f, "RebornCountDown", new object[]{rebornTime, aimId});
}
}

void Reborn (int time, int aimId)
{
// 重生完成
if (time == 0) {
...
}
}

死亡和重生也需要保证严格的同步,即在哪一帧死亡,在哪一帧重生,只要严格按照逻辑帧驱动,就不会出问题。

关于定点数

如果使用帧同步开发手游,还要注意在不同平台(Android 和 IOS)下浮点数的精度差异,可能会造成计算的结果不同,要解决这个问题也很简单,使用定点数代替浮点数进行计算,关于定点数我在 Github 上找到一个库很好用,附上地址:https://github.com/Prince-Ling/LogicPhysics/,使用方法就不再赘述。

效果图

写了这么多代码,我把我写的 Demo 效果贴个动态图感受一下。

总结

使用帧同步技术是开发 PVP 必备的,但要使用好也是有一定难度的,要保证完全的同步是不太可能的(王者荣耀都有不同步的情况),但我们在开发中应当尽量避免不同步的操作,做到以下几点就能很好地避免不同步产生:

  • 游戏中关于计算都需要在 FrameUpdate() 函数中,只有渲染时才使用 Unity 自带的回调函数,如 Update()、FixedUpdate() 等。
  • 禁止使用协程
  • 禁止使用和时间相关的函数计算,比如 Time.time、Time.deltaTime 等,应该使用当前帧编号*帧间隔时间来计算。
  • 游戏在不同平台运行时可能会由于浮点数的精度差异造成不同步,可以使用定点数解决这一问题。
坚持原创技术分享,您的支持将鼓励我继续创作!