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)
{
ansform.localPosition = Vector3.Lerp(m_fixv3LastPosition.ToVector3(), m_fixv3LogicPosition.ToVector3(), interpolation);
}
else
{
ansform.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;
关于定点数的更多相关细节,请参看⽂后内容:
关于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);
}
服务器同步校验
服务器校验和同步运算在现在的游戏中应⽤的越来越⼴泛,既然要让服务器运⾏相关的核⼼代码,那么这部分客户端与服务器共⽤的逻辑就有⼀些需要注意的地⽅.
逻辑和渲染如何进⾏分离
服务器是没有渲染的,它只能执⾏纯逻辑,因此我们的逻辑代码中如何做到逻辑和渲染完全分离就很重要
虽然我们在进⾏模式设计和代码架构的过程中会尽量做到让逻辑和渲染解耦,独⽴运⾏ (具体实现请参见sample源码), 但出于维护同⼀份逻辑代码的考量,我们并没有办法完全把部分逻辑代码进⾏隔离,因此怎么识别当前运⾏环境是客户端还是服务器就很必要了
unity给我们提供了⾃定义宏定义开关的⽅法,我们可以通过这个开关来判断当前运⾏平台是否为客户端,同时关闭服务器代码中不需要执⾏的渲染部分
我们可以在unity中Build Settings–Player Settings–Other Settings中到Scripting Define Symbols选项,在其中填⼊
_CLIENTLOGIC_
宏定义开关,这样在unity中我们便可以此作为是否为客户端逻辑的判断,在客户端中打开与渲染相关的代码,同时也让服务器逻辑不会受到与渲染相关逻辑的⼲扰,⽐如:
#if _CLIENTLOGIC_
ansform.localPosition = position.ToVector3();
#endif
逻辑代码版本控制策略
版本控制:
同步校验的关键在于客户端服务器执⾏的是完全同⼀份逻辑源码,我们应该极⼒避免源码来回拷贝的情况出现,因此如何进⾏版本控制也是需要策略的,在我们公司项⽬中,需要服务器和客户端同时运
⾏的代码是以git⼦模块的形式进⾏管理的,双端各⾃有⾃⼰的业务逻辑,但⼦模块是相同的,这样维护起来就很⽅便,推荐⼤家尝试.
不同服务器架构如何适配:
客户端是c#语⾔写的,如果服务器也是采⽤的c#语⾔,那正好可以⽆缝结合,共享逻辑,但⽬前采⽤c#作为游戏服务器主要语⾔的项⽬其实很少,⼤多是java,c++,golang等,⽐如我们公司⽤的是skynet,如果是这种不同语⾔架构的环境,那我们就需要单独搭建⼀个c#服务器了,⽬前我们的做法是在fedora下结合mono搭建的战⽃校验服务器,⽹关收到战⽃校验请求后会转发到校验服务器进⾏战⽃校验,把校验结果返回给客户端,具体的⽅式请参阅后⽂:
哪些unity数据类型不能直接使⽤
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论