Unity帧同步游戏极简框架及实例(附客户端服务器源码)


阅前提示:
此框架为有帧同步需求的游戏做一个简单的示例,实现了一个精简的框架,本文着重讲解帧同步游戏开发过程中需要注意的各种要点,伴随框架自带了一个小的塔防sample作为演示.


哪些游戏需要使用帧同步

如果游戏中有如下需求,那这个游戏的开发框架应该使用帧同步:

  • 多人实时对战游戏
  • 游戏中需要战斗回放功能
  • 游戏中需要加速功能
  • 需要服务器同步逻辑校验防止作弊

LockStep框架就是为了上面几种情况而设计的.


如何实现一个可行的帧同步框架

主要确保以下三点来保证帧同步的准确性:

  • 可靠稳定的帧同步基础算法
  • 消除浮点数带来的精度误差
  • 控制好随机数

帧同步原理

相同的输入 + 相同的时机 = 相同的显示

客户端接受的输入是相同的,执行的逻辑帧也是一样的,那么每次得到的结果肯定也是同步一致的。为了让运行结果不与硬件运行速度快慢相关联,则不能用现实历经的时间(Time.deltaTime)作为差值阀值进行计算,而是使用固定的时间片段来作为阀值,这样无论两帧之间的真实时间间隔是多少,游戏逻辑执行的次数是恒定的,举例:
我们预设每个逻辑帧的时间跨度是1秒钟,那么当物理时间经过10秒后,逻辑便会运行10次,经过100秒便会运行100次,无论在运行速度快的机器上还是慢的机器上均是如此,不会因为两帧之间的跨度间隔而有所改变。
而渲染帧(一般为30到60帧),则是根据逻辑帧(10到20帧)去插值,从而得到一个“平滑”的展示,渲染帧只是逻辑帧的无限逼近插值,不过人眼一般无法分辨这种滞后性,因此可以把这两者理解为同步的.

画面卡顿的原因:如果硬件的运行速度赶不上逻辑帧的运行速度,则有可能出现逻辑执行多次后,渲染才执行一次的状况,如果遇到这种情况画面就会出现卡顿和丢帧的情况.


帧同步算法

基础核心算法

下面这段代码为帧同步的核心逻辑片段:

m_fAccumilatedTime = m_fAccumilatedTime + deltaTime;

//如果真实累计的时间超过游戏帧逻辑原本应有的时间,则循环执行逻辑,确保整个逻辑的运算不会因为帧间隔时间的波动而计算出不同的结果
while (m_fAccumilatedTime > m_fNextGameTime) {

    //运行与游戏相关的具体逻辑
    m_callUnit.frameLockLogic();

    //计算下一个逻辑帧应有的时间
    m_fNextGameTime += m_fFrameLen;

    //游戏逻辑帧自增
    GameData.g_uGameLogicFrame += 1;
}

//计算两帧的时间差,用于运行补间动画
m_fInterpolation = (m_fAccumilatedTime + m_fFrameLen - m_fNextGameTime) / m_fFrameLen;

//更新渲染位置
m_callUnit.updateRenderPosition(m_fInterpolation);

渲染更新机制

由于帧同步以及逻辑与渲染分离的设置,我们不能再去直接操作transform的localPosition,而设立一个虚拟的逻辑值进行代替,我们在游戏逻辑中,如果需要变更对象的位置,只需要更新这个虚拟的逻辑值,在一轮逻辑计算完毕后会根据这个值统一进行一轮渲染,这里我们引入了逻辑位置m_fixv3LogicPosition这个变量.

// 设置位置
// 
// @param position 要设置到的位置
// @return none
public override void setPosition(FixVector3 position)
{
    m_fixv3LogicPosition = position;
}

渲染流程如下:

只有需要移动的物体,我们才进行插值运算,不会移动的静止物体直接设置其坐标就可以了。

//只有会移动的对象才需要采用插值算法补间动画,不会移动的对象直接设置位置即可
if ((m_scType == "soldier" || m_scType == "bullet") && interpolation != 0)
{
    m_gameObject.transform.localPosition = Vector3.Lerp(m_fixv3LastPosition.ToVector3(), m_fixv3LogicPosition.ToVector3(), interpolation);
}
else
{
    m_gameObject.transform.localPosition = m_fixv3LogicPosition.ToVector3();
}

插值动画参数计算公式详解

m_fInterpolation = (m_fAccumilatedTime + m_fFrameLen - m_fNextGameTime) / m_fFrameLen;

插值参数这段公式不是很容易理解,这里进行一下解释:
m_fAccumilatedTime : 真实累计的运行时间
m_fNextGameTime : 理论累计运行时间(以逻辑帧时间为跨度)
m_fFrameLen : 每逻辑帧的时间间隔

我们可以试着对上面的公式进行一次分解:
先使用m_fAccumilatedTime - m_fNextGameTime看看结果
断点调试后会发现这里会得到一个负值,因为在上面的while循环中理论累计运行时间多run了一个逻辑帧的跨度,因此这里应该把多的那一次逻辑帧时间扣除出去才能得到正确的真实累计运行时间与理论累计运行时间的差值,为了便于理解,把上述公式改为如下形式则更容易理解:

m_fAccumilatedTime - (m_fNextGameTime - m_fFrameLen)

把公式进行进一步转换后可以得到如下代码:

float timeInterval = m_fAccumilatedTime - (m_fNextGameTime - m_fFrameLen);
m_fInterpolation = timeInterval / m_fFrameLen;

为什么得到的时间插值还要再除以每帧的时间间隔来得到插值参数?我们需要补充说明一下插值动画的函数接口Vector3.Lerp:
我们先看看官方文档的说明:

Vector3.Lerp
Linearly interpolates between two vectors.
Interpolates between the vectors a and b by the interpolant t. The parameter t is clamped to the range [0, 1]. This is most commonly used to find a point some fraction of the way along a line between two endpoints (e.g. to move an object gradually between those points).
When t = 0 returns a. When t = 1 returns b. When t = 0.5 returns the point midway between a and b.

这里需要注意,t是插值参数,而不是时间,很多同学看到t都错误的认为这个参数是时间,Vector3.Lerp的作用是让物体按照一定的百分比从a点移动到b点,当t为0时,物体在a点原地不动,当t为0.5时,物体移动到两点的中间点,当t为1时物体移动到终点b点.
t的取值范围是[0, 1]代表从起始位置移动到目标位置的过程百分比,不是时间!!
理解了Vector3.Lerp,就方便我们更好的理解为什么还要除以每帧的时间间隔来得到插值参数了,我们需要的是移动到目标位置的百分比,有了这个百分比,物体的真实位置就能按照时间的差值,平滑的无限逼近理论位置,从而得到我们想要的平滑移动的效果.

可以试着把m_fInterpolation 置为恒等于1试试,看看没有插值动画的效果是什么样的.


定点数

定点数和浮点数,是指在计算机中一个数的小数点的位置是固定的还是浮动的,如果一个数中小数点的位置是固定的,则为定点数;如果一个数中小数点的位置是浮动的,则为浮点数。定点数由于小数点的位置固定,因此其精度可控,相反浮点数的精度不可控.

对于帧同步框架来说,定点数是一个非常重要的特性,我们在在不同平台,甚至不同手机上运行一段完全相同的代码时有可能出现截然不同的结果,那是因为不同平台不同cpu对浮点数的处理结果有可能是不一致的,游戏中仅仅0.000000001的精度差距,都可能在多次计算后带来蝴蝶效应,导致完全不同的结果
举例:当一个士兵进入塔的攻击范围时,塔会发动攻击,在手机A上的第100帧时,士兵进入了攻击范围,触发了攻击,而在手机B上因为一点点误差,导致101帧时才触发攻击,虽然只差了一帧,但后续会因为这一帧的偏差带来之后更多更大的偏差,从这一帧的不同开始,这已经是两场截然不同的战斗了.
因此我们必须使用定点数来消除精度误差带来的不可预知的结果,让同样的战斗逻辑在任何硬件,任何操作系统下运行都能得到同样的结果.同时也再次印证文章最开始提到的帧同步核心原理:
相同的输入 + 相同的时机 = 相同的显示
框架自带了一套完整的定点数库Fix64.cs,其中对浮点数与定点数相互转换,操作符重载都做好了封装,我们可以像使用普通浮点数那样来使用定点数

Fix64 a = (Fix64)1;
Fix64 b = (Fix64)2;
Fix64 c = a + b;

关于定点数的更多相关细节,请参看文后内容:哪些unity数据类型不能直接使用

关于Dotween的正确使用

提及定点数,我们不得不关注一下项目中常用的Dotween这个插件,这个插件功能强大,使用非常方便,让我们在做动画时游刃有余,但是如果放到帧同步框架中就不能随便使用了.
上面提到的浮点数精度问题有可能带来巨大的影响,而Dotween的整个逻辑都是基于时间帧(Time.deltaTime)插值的,而不是基于帧定长插值,因此不能在涉及到逻辑相关的地方使用,只能用在动画动作渲染相关的地方,比如下面代码就是不能使用的

DoLocalMove() function()
	//移动到某个位置后触发会影响后续判断的逻辑
	m_fixMoveTime = Fix64.Zero;
end

如果只是渲染表现,而与逻辑运算无关的地方,则可以继续使用Dotween.
我们整个帧框架的逻辑运算中没有物理时间的概念,一旦逻辑中涉及到真实物理时间,那肯定会对最终计算的结果造成不可预计的影响,因此类似Dotween等动画插件在使用时需要我们多加注意,一个疏忽就会带来整个逻辑运算结果的不一致.

随机数

游戏中几乎很难避免使用随机数,恰好随机数也是帧同步框架中一个需要高度关注的注意点,如果每次战斗回放产生的随机数是不一致的,那如何能保证战斗结果是一致的呢,因此我们需要对随机数进行控制,由于不同平台,不同操作系统对随机数的处理方式不同,因此我们避免使用平台自带的随机数接口,而是使用自定义的可控随机数算法SRandom.cs来替代,保证随机数的产生在跨平台方面不会出现问题.同时我们需要记录下每场战斗的随机数种子,只要确定了种子,那产生的随机数序列就一定是一致的.
部分代码片段:

// range:[min~(max-1)]
public uint Range(uint min, uint max)
{
    if (min > max)
        throw new ArgumentOutOfRangeException("minValue", string.Format("'{0}' cannot be greater than {1}.", min, max));

    uint num = max - min;
    return Next(num) + min;
}

public int Next(int max)
{
    return (int)(Next() % max);
}

参考:
unity帧同步游戏极简框架及实例(附客户端服务器源码)

​​​​​​​

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值