更新日期:2026年8月6日。
项目源码:获取源码。
索引
- 帧同步的确定性
- 一、数学库
- 1.FixFloat
- 2.FixFloat基础运算
- 3.FixFloat重载运算符
- 二、随机数
- 三、容器
- 四、逻辑与渲染分离
- LogicFrameBehaviour
- 五、时间管理
- 六、物理
- 七、乐观策略
帧同步的确定性
本篇我们直接切入正题,率先引入需要实现帧同步技术的前置工作。
已知的是,帧同步最大的难点在于确定性。所有客户端的逻辑必须完全一致,哪怕是一个浮点数误差,最终都会导致“蝴蝶效应”,让不同玩家屏幕上的战局出现差异(即“不同步”)。
那么总结一下,实现帧同步确定性的技术清单大致如下:
- 数学库:实现确定性的定点数(或基于整数缩放),避免浮点误差。
- 随机数:必须使用确定性伪随机数生成器,且所有客户端使用相同的随机种子。
- 容器:避免使用无序容器(如 Dictionary,HashSet),改用有序容器,且保证顺序的确定性。
- 逻辑与渲染分离:所有游戏逻辑(移动、攻击判定等)都应该在逻辑帧中执行,渲染帧只负责根据逻辑计算出的结果去渲染画面,实现“逻辑驱动渲染”。
- 时间管理:所有客户端使用相同的逻辑帧计数器,而不是真实时间。
- 物理:要么避免物理,要么使用确定性物理引擎(或自研简单碰撞)。
- 乐观策略:服务器不等待所有玩家的输入,而是以固定频率(比如每秒30帧),将当前收集到的所有输入指令打包广播出去。
一、数学库
1.FixFloat
众所周知,浮点数float的计算会带来误差,所以必须要实现确定性的定点数来替换float,避免浮点误差,且在逻辑帧中的任何计算都不能涉及到float数据。
如下是我们实现的定点数FixFloat:
/// <summary>/// 确定的浮点型/// </summary>publicstructFixFloat{privateint_integer;privateint_fractional1;privateint_fractional2;privateint_fractional3;privateint_fractional4;/// <summary>/// 确定的浮点型/// </summary>/// <param name="integer">整数部分</param>/// <param name="fractional1">小数部分(小数点后第1位)</param>/// <param name="fractional2">小数部分(小数点后第2位)</param>/// <param name="fractional3">小数部分(小数点后第3位)</param>/// <param name="fractional4">小数部分(小数点后第4位)</param>publicFixFloat(intinteger,intfractional1=0,intfractional2=0,intfractional3=0,intfractional4=0){_integer=integer;_fractional1=fractional1;_fractional2=fractional2;_fractional3=fractional3;_fractional4=fractional4;}}FixFloat只精确到小数点后4位(在我们的游戏中已完全足够),其设计理念是将每一位小数使用整型int单独存储(摒弃了基于整数缩放的策略,因为其容易导致数据溢出)。
比如一个小数15.1675,其使用FixFloat定义如下:
newFixFloat(15,1,6,7,5);2.FixFloat基础运算
我们先实现FixFloat的两个基础运算方法(加法+和减法-,乘法*和除法/暂不考虑,我们游戏中也几乎用不到),基于这两个基础运算方法,我们才能进一步重载FixFloat的运算符,这是自定义数据类型的必要工作。
FixFloat基础加法运算(从低位开始,逐位相加,满10进1):
/// <summary>/// FixFloat基础加法运算(a>=0 且 b>=0)/// </summary>privatestaticFixFloatAddition(FixFloata,FixFloatb){boolenterUp=false;intfractional4=a._fractional4+b._fractional4;enterUp=fractional4>=10;if(enterUp)fractional4-=10;intfractional3=a._fractional3+b._fractional3+(enterUp?1:0);enterUp=fractional3>=10;if(enterUp)fractional3-=10;intfractional2=a._fractional2+b._fractional2+(enterUp?1:0);enterUp=fractional2>=10;if(enterUp)fractional2-=10;intfractional1=a._fractional1+b._fractional1+(enterUp?1:0);enterUp=fractional1>=10;if(enterUp)fractional1-=10;intinteger=a._integer+b._integer+(enterUp?1:0);returnnewFixFloat(integer,fractional1,fractional2,fractional3,fractional4);}FixFloat基础减法运算(从低位开始,逐位相减,不足时向前借1):
/// <summary>/// FixFloat基础减法运算(a>=0 且 b>=0 且 a>=b)/// </summary>privatestaticFixFloatSubtraction(FixFloata,FixFloatb){if(a._fractional4<b._fractional4){a._fractional3-=1;a._fractional4+=10;}intfractional4=a._fractional4-b._fractional4;if(a._fractional3<b._fractional3){a._fractional2-=1;a._fractional3+=10;}intfractional3=a._fractional3-b._fractional3;if(a._fractional2<b._fractional2){a._fractional1-=1;a._fractional2+=10;}intfractional2=a._fractional2-b._fractional2;if(a._fractional1<b._fractional1){a._integer-=1;a._fractional1+=10;}intfractional1=a._fractional1-b._fractional1;intinteger=a._integer-b._integer;returnnewFixFloat(integer,fractional1,fractional2,fractional3,fractional4);}
FixFloat基础加法运算的两个参数必须>=0。FixFloat基础减法运算的两个参数必须>=0,且被减数始终>=减数。
提炼出这两个基础运算方法意欲何为?我们举个例子a + b:
- 情况1:
a >= 0,b >= 0,则a + b直接调用基础加法运算,得到最终结果。 - 情况2:
a >= 0,b < 0,且a >= abs(b),则a + b = a - abs(b),直接调用基础减法运算,得到最终结果。 - 情况3:
a >= 0,b < 0,且a < abs(b),则a + b = -(abs(b) - a),先调用基础减法运算,再将结果符号逆转,得到最终结果。 - 情况4:
a < 0,b < 0,则a + b = -(abs(a) + abs(b)),先调用基础加法运算,再将结果符号逆转,得到最终结果。
也即是说,这两个基础运算方法可以涵盖FixFloat的所有加、减运算。
3.FixFloat重载运算符
基于此,我们便可以重载FixFloat的运算符了。
+运算符(FixFloat + FixFloat):
publicstaticFixFloatoperator+(FixFloata,FixFloatb){//a、b均 >= 0,直接调用【基础加法运算】if(a>=0&&b>=0){returnAddition(a,b);}//a、b均 < 0//先对两者的绝对值调用【基础加法运算】//再将结果逆转(正变负,负变正)//Flip用于逆转一个FixFloat的符号elseif(a<0&&b<0){FixFloatabsA=Abs(a);FixFloatabsB=Abs(b);returnFlip(Addition(absA,absB));}//a < 0、b >= 0elseif(a<0&&b>=0){FixFloatabsA=Abs(a);//将绝对值更大的一方作为被减数,调用【基础减法运算】if(absA<=b){returnSubtraction(b,absA);}//如果更大的一方为负数,则最终结果需要逆转符号else{returnFlip(Subtraction(absA,b));}}//a >= 0、b < 0elseif(a>=0&&b<0){FixFloatabsB=Abs(b);//将绝对值更大的一方作为被减数,调用【基础减法运算】if(a>=absB){returnSubtraction(a,absB);}//如果更大的一方为负数,则最终结果需要逆转符号else{returnFlip(Subtraction(absB,a));}}else{thrownewArgumentException($"FixFloat 的 + 运算:出现了意料之外的情况,参数1:{a},参数2:{b}");}}-运算符(FixFloat - FixFloat):
publicstaticFixFloatoperator-(FixFloata,FixFloatb){//a、b均 >= 0,直接调用【基础减法运算】//如果被减数较小,则最终结果需要逆转符号if(a>=0&&b>=0){if(a>=b){returnSubtraction(a,b);}else{returnFlip(Subtraction(b,a));}}//a、b均 < 0//先对两者的绝对值调用【基础减法运算】//如果被减数较小,则最终结果需要逆转符号elseif(a<0&&b<0){FixFloatabsA=Abs(a);FixFloatabsB=Abs(b);if(absA>=absB){returnFlip(Subtraction(absA,absB));}else{returnSubtraction(absB,absA);}}//a < 0、b >= 0//负数 - 正数 = 两个数的绝对值相加 * -1elseif(a<0&&b>=0){FixFloatabsA=Abs(a);returnFlip(Addition(absA,b));}//a >= 0、b < 0//正数 - 负数 = 两个数的绝对值相加elseif(a>=0&&b<0){FixFloatabsB=Abs(b);returnAddition(a,absB);}else{thrownewArgumentException($"FixFloat 的 - 运算:出现了意料之外的情况,参数1:{a},参数2:{b}");}}然后是与整型int的运算符(与float的运算符不用考虑,因为float不能参与逻辑运算)。
+运算符(FixFloat + int):
publicstaticFixFloatoperator+(FixFloata,intb){//将b构造为一个FixFloat参与运算returna+newFixFloat(b);}-运算符(FixFloat - int):
publicstaticFixFloatoperator-(FixFloata,intb){//将b构造为一个FixFloat参与运算returna-newFixFloat(b);}>运算符(FixFloat > FixFloat):
publicstaticbooloperator>(FixFloata,FixFloatb){//取两个FixFloat中的对比位GetContrastBit(a,b,outintaValue,outintbValue);//对比位的大小直接决定了整个FixFloat的大小returnaValue>bValue;}/// <summary>/// 取两个FixFloat中的对比位(从高位至低位,第一个数值不相等的位)/// </summary>privatestaticvoidGetContrastBit(FixFloata,FixFloatb,outintaBit,outintbBit){intaValue=a._integer;intbValue=b._integer;if(aValue==bValue){aValue=a._fractional1;bValue=b._fractional1;if(aValue==bValue){aValue=a._fractional2;bValue=b._fractional2;if(aValue==bValue){aValue=a._fractional3;bValue=b._fractional3;if(aValue==bValue){aValue=a._fractional4;bValue=b._fractional4;}}}}aBit=aValue;bBit=bValue;}比如有如下两个数:
FixFloata=newFixFloat(1,2,4,5,6);//1.2456FixFloatb=newFixFloat(1,2,3,5,6);//1.2356取对比位也即是小数点后第二位(从左至右,第一个数值不相等的位),a的对比位为4,b的对比位为3,所以a > b。
其他比较运算符同理。
当然也有转换为float的情况(一般是渲染需要):
publicfloatToFloat(){return_integer+_fractional1/10f+_fractional2/100f+_fractional3/1000f+_fractional4/10000f;}//转换int直接抛弃小数部分publicintToInt(){return_integer;}二、随机数
必须使用确定性伪随机数生成器,且所有客户端使用相同的随机种子。
这里直接使用System.Random随机数即可,每一关的所有随机事件(随机生成关卡,AI随机行为)都应该指定一个随机种子,且该种子必须被记录,战斗回放时也使用该种子进行随机才能保证绝对一致的游戏内容。
/// <summary>/// 关卡种子(关卡生成时所有随机行为使用此种子)/// </summary>publicintLevelSeed{get;set;}=1;/// <summary>/// 敌人AI种子(敌人AI所有随机行为使用此种子)/// </summary>publicintAISeed{get;set;}=1;三、容器
避免使用无序容器(如Dictionary,HashSet),改用有序容器(如List),且保证顺序的确定性。
四、逻辑与渲染分离
所有游戏逻辑(移动、攻击判定等)都应该在逻辑帧中执行,渲染帧只负责根据逻辑计算出的结果去渲染画面,实现逻辑驱动渲染。
LogicFrameBehaviour
LogicFrameBehaviour支持逻辑帧更新的行为组件,其将逻辑与渲染分割开来,游戏场景中的坦克、子弹等需要进行帧同步的对象,都继承至此组件:
/// <summary>/// 支持逻辑帧更新的行为组件/// </summary>publicclassLogicFrameBehaviour:HTBehaviour{/// <summary>/// 逻辑帧更新/// </summary>/// <param name="frameIndex">当前逻辑帧编号</param>publicvirtualvoidOnLogicFrame(intframeIndex){}/// <summary>/// 渲染帧更新/// </summary>/// <param name="factor">逻辑帧之间的间隔系数</param>publicvirtualvoidOnRenderFrame(floatfactor){}}逻辑帧更新方法由统一的入口调用,至于这个入口方法OnLogicFrame,单人模式时由本地的逻辑帧时钟调用,多人模式时由服务端的逻辑帧时钟调用,回放模式时由回放系统的逻辑帧时钟调用:
/// <summary>/// 当前逻辑帧编号/// </summary>privateint_logicFrameIndex=0;/// <summary>/// 所有的支持逻辑帧更新的行为组件/// </summary>privateList<LogicFrameBehaviour>_logicFrameBehaviours=newList<LogicFrameBehaviour>();/// <summary>/// 逻辑帧更新/// </summary>/// <param name="index">逻辑帧编号</param>publicvoidOnLogicFrame(intindex){//记录当前逻辑帧编号_logicFrameIndex=index;//调用所有组件的逻辑帧更新for(inti=0;i<_logicFrameBehaviours.Count;i++){if(_logicFrameBehaviours[i].enabled&&_logicFrameBehaviours[i].gameObject.activeSelf){_logicFrameBehaviours[i].OnLogicFrame(_logicFrameIndex);}}}渲染帧更新的方法就直接由OnUpdate调用即可,在Unity的每一个渲染帧调用一次我们的渲染帧:
/// <summary>/// 逻辑帧之间的时间间隔/// </summary>privatefloat_logicInterval;/// <summary>/// 逻辑帧计时器/// </summary>privatefloat_logicTimer=0;publicoverridevoidOnUpdate(){base.OnUpdate();if(_logicTimer<_logicInterval){_logicTimer+=Time.deltaTime;}floatfactor=_logicTimer/_logicInterval;for(inti=0;i<_logicFrameBehaviours.Count;i++){if(_logicFrameBehaviours[i].enabled&&_logicFrameBehaviours[i].gameObject.activeSelf){_logicFrameBehaviours[i].OnRenderFrame(factor);}}}渲染帧更新的factor参数为渲染画面从上一逻辑帧更新到当前逻辑帧的进度(0-1),可以将其理解为渲染插值,因为每秒30帧逻辑,画面会有些卡顿,所以需要插值技术来让画面变得平滑。
factor = 0,代表完全处于上一逻辑帧;factor = 0.4,代表处于上一逻辑帧到当前逻辑帧之间的时间长度的0.4位置;factor = 1,代表完全处于当前逻辑帧。
五、时间管理
所有客户端使用相同的逻辑帧计数器,而不是真实时间。
比如2秒后让坦克爆炸,不能使用定时器2秒后执行,而应该使用逻辑帧计数器定时60帧(1秒30帧)后执行:
/// <summary>/// 所有的帧计时器/// </summary>privateList<FrameTimer>_frameTimers=newList<FrameTimer>();/// <summary>/// 逻辑帧更新/// </summary>/// <param name="index">逻辑帧编号</param>publicvoidOnLogicFrame(intindex){//逻辑帧更新......//逻辑帧计数器更新for(inti=0;i<_frameTimers.Count;i++){//每一个逻辑帧流逝,计数器减1_frameTimers[i].Frame-=1;//计数完毕后,执行回调if(_frameTimers[i].Frame<=0){_frameTimers[i].Callback?.Invoke();_frameTimers.RemoveAt(i);i-=1;}}}/// <summary>/// 添加一个帧计时器/// </summary>/// <param name="frame">计时的帧数</param>/// <param name="callback">计时结束回调</param>publicvoidAddFrameTimer(intframe,HTFActioncallback){_frameTimers.Add(newFrameTimer(){Frame=frame,Callback=callback});}六、物理
我们将完全摒弃Unity中的物理效果,使用自研的简单碰撞,用来判断子弹碰撞坦克,碰撞砖块等。
七、乐观策略
传统的帧同步有一个致命缺陷:所有人都要等网络最慢的那个玩家提交输入指令,导致整体卡顿,为了解决这个问题,必须引入乐观策略。
服务器不等待所有玩家的输入,而是以固定频率(比如每秒30帧),将当前收集到的所有输入指令打包广播出去。
如果某个玩家因网络延迟指令未及时送达,服务器不会等他,会直接广播他没有任何操作,这避免了少数人拖垮全队,网络差的玩家只会感到自己操作有延迟,而不会影响到其他人的体验。
虽然我们的坦克大战是双人联机游戏,但映射到三人、四人联机也是同理的,当然,本篇只是做了一个大致的总结,有些内容后续会循序展开。