帧同步和状态同步区别
TCP | UDP | |
---|---|---|
连接 | 面向连接 | 无连接 |
可靠性 | 可靠,有序 | 不可靠,丢包,无序 |
数据 | 多个数据包转成字节流,有粘包问题 | 每个数据包视为一个独立的报文进行传输,没有粘包问题 |
流量 | 控制 | 手动 |
速度 | 慢 | 快 |
状态同步一般使用 TCP,帧同步一般使用 UDP
状态同步:发操作,收状态
帧同步:发操作,收操作
帧同步需要保证计算的一致性,包括随机数,容器遍历,浮点数,时间
随机数算法
为了保证每个客户端的表现相同,需要使用相同的随机数种子,比如以帧号为种子,还需要一个随机数容器,里面的随机数是固定的,这样相同的容器,相同的种子就可以得到相同的结果。
逻辑严格排序
经常会有需要排序的列表或者数组,比如攻击距离自己最近的敌人,这时候就需要将身边的敌人进行距离排序,一般来说只要排序距离就行了,但是如果出现两个敌人距离一样的时候,就会导致在不同的机器上选择的敌人是不同的。所以排序一定要排到id为止,才能避免出现条件相同顺序不同的问题。
浮点数的精度问题
浮点数:精度为7位到8位小数,有一定的误差,不同的硬件软件平台也会有些许差异,随着游戏进行,这一点点误差会逐渐放大,导致不同客户端的计算结果不一致。
常见的处理办法有:定点数,查表法,放大截断法
定点数
定点数:把整数部分和小数部分拆分开来,都用整数的形式表示,小数点位置固定,缺点是占用空间更大。可以直接使用网上的定点数库,也可以使用自定义的定点数
实现自定义定点数
using System;
/// <summary>
/// 自定义64位定点数
/// </summary>
[Serializable]
public struct Fixed64 : IEquatable<Fixed64>, IComparable, IComparable<Fixed64>
{
public long value;
//小数部分位数
private const int FRACTIONALBITS = 12;
private const long ONE = 1L << FRACTIONALBITS;
public static Fixed64 Zero = new Fixed64(0);
/// <summary>
/// ֱ直接对value赋值
/// </summary>
Fixed64(long value)
{
this.value = value;
}
/// <summary>
/// 传入具体数字的构造函数
/// </summary>
public Fixed64(int value)
{
this.value = value * ONE;
}
/// <summary>
/// 重载运算符
/// </summary>
public static Fixed64 operator +(Fixed64 a, Fixed64 b)
{
return new Fixed64(a.value + b.value);
}
public static Fixed64 operator -(Fixed64 a,Fixed64 b)
{
return new Fixed64(a.value - b.value);
}
public static Fixed64 operator*(Fixed64 a,Fixed64 b)
{
//直接相乘后面会多出0,所以这里要右移
return new Fixed64((a.value >> FRACTIONALBITS) * b.value);
}
public static Fixed64 operator/(Fixed64 a,Fixed64 b)
{
return new Fixed64((a.value << FRACTIONALBITS) / b.value);
}
public static bool operator ==(Fixed64 a,Fixed64 b)
{
return a.value == b.value;
}
public static bool operator !=(Fixed64 a,Fixed64 b)
{
return !(a == b);
}
public static bool operator>(Fixed64 a,Fixed64 b)
{
return a.value > b.value;
}
public static bool operator <(Fixed64 a, Fixed64 b)
{
return a.value < b.value;
}
public static bool operator >=(Fixed64 a, Fixed64 b)
{
return a.value >= b.value;
}
public static bool operator <=(Fixed64 a, Fixed64 b)
{
return a.value <= b.value;
}
/// <summary>
/// 显式强制类型转换,Fixed64转换为long类型
/// </summary>
public static explicit operator long(Fixed64 value)
{
return value.value >> FRACTIONALBITS;
}
public static explicit operator Fixed64(long value)
{
return new Fixed64(value);
}
public static explicit operator float(Fixed64 value)
{
return (float)value.value / ONE;
}
public static explicit operator Fixed64(float value)
{
return new Fixed64((long)(value * ONE));
}
public int CompareTo(object obj)
{
return value.CompareTo(obj);
}
public int CompareTo(Fixed64 other)
{
return value.CompareTo(other.value);
}
public bool Equals(Fixed64 other)
{
return value == other.value;
}
public override int GetHashCode()
{
return value.GetHashCode();
}
public override bool Equals(object obj)
{
return obj is Fixed64 && ((Fixed64)obj).value == value;
}
public override string ToString()
{
return ((float)this).ToString();
}
}
实现自定义定点数向量
using System;
/// <summary>
/// 自定义定点数三维向量
/// </summary>
public struct Fixed64Vector3 : IEquatable<Fixed64Vector3>
{
public Fixed64 x;
public Fixed64 y;
public Fixed64 z;
public Fixed64Vector3(Fixed64 x, Fixed64 y, Fixed64 z)
{
this.x = x;
this.y = y;
this.z = z;
}
public Fixed64 this[int index]
{
get
{
if (index == 0)
return x;
else if (index == 1)
return y;
else
return z;
}
set
{
if (index == 0)
x = value;
else if (index == 1)
y = value;
else
y = value;
}
}
public static Fixed64Vector3 Zero
{
get { return new Fixed64Vector3(Fixed64.Zero, Fixed64.Zero, Fixed64.Zero); }
}
public static Fixed64Vector3 operator +(Fixed64Vector3 a, Fixed64Vector3 b)
{
Fixed64 x = a.x + b.x;
Fixed64 y = a.y + b.y;
Fixed64 z = a.z + b.z;
return new Fixed64Vector3(x, y, z);
}
public static Fixed64Vector3 operator -(Fixed64Vector3 a, Fixed64Vector3 b)
{
Fixed64 x = a.x - b.x;
Fixed64 y = a.y - b.y;
Fixed64 z = a.z - b.z;
return new Fixed64Vector3(x, y, z);
}
public static Fixed64Vector3 operator *(Fixed64 d, Fixed64Vector3 a)
{
Fixed64 x = a.x * d;
Fixed64 y = a.y * d;
Fixed64 z = a.z * d;
return new Fixed64Vector3(x, y, z);
}
public static Fixed64Vector3 operator *(Fixed64Vector3 a, Fixed64 d)
{
Fixed64 x = a.x * d;
Fixed64 y = a.y * d;
Fixed64 z = a.z * d;
return new Fixed64Vector3(x, y, z);
}
public static Fixed64Vector3 operator /(Fixed64Vector3 a, Fixed64 d)
{
Fixed64 x = a.x / d;
Fixed64 y = a.y / d;
Fixed64 z = a.z / d;
return new Fixed64Vector3(x, y, z);
}
public static bool operator ==(Fixed64Vector3 lhs, Fixed64Vector3 rhs)
{
return lhs.x == rhs.x && lhs.y == rhs.y && lhs.z == rhs.z;
}
public static bool operator !=(Fixed64Vector3 lhs, Fixed64Vector3 rhs)
{
return lhs.x != rhs.x || lhs.y != rhs.y || lhs.z != rhs.z;
}
/// <summary>
/// 进行左移和右移是为了增加哈希值的混淆性和分布性,参考Vector3中的写法
/// </summary>
public override int GetHashCode()
{
return x.GetHashCode() ^ (y.GetHashCode() << 2) ^ (z.GetHashCode() >> 2);
}
public override bool Equals(object obj)
{
return ((Fixed64Vector3)obj) == this;
}
public override string ToString()
{
return string.Format("x:{0} y:{1} z:{2}", x, y, z);
}
public bool Equals(Fixed64Vector3 other)
{
return this == other;
}
}
查表法
查表计算是一种通过预先计算并存储特定值的方法,来避免实时计算中可能出现的精度问题。例如,在计算三角函数等复杂函数时,可以预先将度数对应的值存储在数据表中,然后在需要时直接查表获取结果。
放大截断法
放大截断法是一种使用整数替代浮点数进行计算的方法。首先确认放大因子(如1000),将浮点数乘以放大因子后转换为整数,进行计算,最后再将结果除以放大因子转换回浮点数。这种方法通过舍去小数部分来减少精度损失,但需要注意选择合适的放大因子以避免溢出。
帧同步流程
逻辑帧:游戏被划分为连续的游戏帧,每一帧就是一个时间片段,帧同步的目标是把每一帧中所有客户端的操作同步起来
渲染帧:设置物体位置,客户端需要做一些平滑处理,一般使用 Lerp 函数
游戏中按固定的时间间隔更新,在逻辑帧中计算角色位置,旋转,碰撞,有碰撞就修正位置,计算子弹的移动,修正和销毁,然后在渲染帧中进行同步,保证逻辑和渲染分离
客户端-服务端架构:每个客户端把自己的一个或多个操作发给服务端,服务端把这些操作应用到游戏状态中,并将结果发给所有客户端,保持一致性,上图中方框表示服务端
- 严格帧同步:每个关键帧只有当服务器集齐了所有玩家的操作指令,才可以进行转发,进入下一个关键帧,否则就要等待最慢的玩家。
上图表示严格帧同步流程,服务器和客户端约定每5帧同步一次,这里每一帧的时间间隔由服务器决定,UPDATE 0表示服务器发的第0帧数据,CTRL 0表示客户端发的第0帧操作,服务器在第5帧开始前收到了客户端A,B的数据,进行打包再转发给A,B。服务端在第10帧开始时没有收到所有客户端的数据,开始等待,直到收到所有客户端的数据。 - 乐观帧同步:服务器定时广播操作指令,而不必等待慢的客户端,如果转发时某个客户端没有提交帧数据,认为进行了空操作。
第一帧严格锁帧,保证所有客户端从第一帧提交操作后同步
帧同步服务端代码编写相对简单,只是转发数据
对抗网络延迟
1.前向冗余纠错
在发送第N帧数据时,同时包含N-1到N-k(k为动态调整的冗余帧数)帧的数据。这样,即使部分帧丢失,接收端也能通过接收到的冗余数据重构出完整的帧序列。冗余帧可以动态调整,网络不好的时候多发几帧,好的时候少发几帧。当客户端发现有丢帧的情况要发送重传请求,冗余帧可以减少重传次数。
2.预表现
玩家推动摇杆移动,发送数据,客户端做预表现,逻辑帧不动,只做渲染层移动,收到服务器数据后修改逻辑帧。点技能攻击其他玩家,先播放动作,等收到服务器数据再计算伤害。
预表现只能对玩家自己做优化,不能优化其他角色。
3.预测,回滚,追帧
预测:当网络不好时,没收到服务端转发的信息,客户端需要对其他玩家进行预测,比如根据当前的速度和方向进行移动
回滚:收到服务端信息进行逻辑帧运算,如果预测不准确,需要回到过去某个时间点的状态,修正一些东西,客户端需要进行一些插值运算来平滑过渡
追帧:回滚后快速向前模拟到当前时间
录像回放
保存每一帧数据,使用 Update 来播放,通过 Time.timeScale 来调整播放速率
短线重连
重新进入游戏后,检测对局是否存在,获取缺失的帧数据,从第一帧开始追帧
反外挂
服务器收集数据后,运行部分客户端逻辑,作为权威,比如服务器计算玩家移动和物理碰撞