news 2026/9/8 19:16:42

【Unity】TankBattle联机坦克大战(一)帧同步的基石“确定性”

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【Unity】TankBattle联机坦克大战(一)帧同步的基石“确定性”

更新日期: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 >= 0b >= 0,则a + b直接调用基础加法运算,得到最终结果。
  • 情况2:a >= 0b < 0,且a >= abs(b),则a + b = a - abs(b),直接调用基础减法运算,得到最终结果。
  • 情况3:a >= 0b < 0,且a < abs(b),则a + b = -(abs(b) - a),先调用基础减法运算,再将结果符号逆转,得到最终结果。
  • 情况4:a < 0b < 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;

三、容器

避免使用无序容器(如DictionaryHashSet),改用有序容器(如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帧),将当前收集到的所有输入指令打包广播出去。
如果某个玩家因网络延迟指令未及时送达,服务器不会等他,会直接广播他没有任何操作,这避免了少数人拖垮全队,网络差的玩家只会感到自己操作有延迟,而不会影响到其他人的体验。

虽然我们的坦克大战是双人联机游戏,但映射到三人、四人联机也是同理的,当然,本篇只是做了一个大致的总结,有些内容后续会循序展开。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 19:14:12

Ponytail:单日1364星,一个让Agent少写代码的技能包

1. 单日 1,364 星&#xff0c;Ponytail 为什么值得关注1.1 Agent 写代码的尴尬现状先说一个我最近越来越强烈的感受。过去大半年我一直在折腾各类 Agent 编程工具&#xff0c;从最开始的简单问答&#xff0c;到后来让 Agent 直接改仓库代码、跑测试、修 bug&#xff0c;一步步走…

作者头像 李华
网站建设 2026/9/8 19:13:26

激光雷达为何越用越不准?老化机制与健康评估全解析

1. 精度下降到底指什么&#xff1a;先分清“测不准”和“测不稳”先说结论&#xff1a;激光雷达用久了&#xff0c;精度确实会下降&#xff0c;但大部分情况下不是你想象中那种“测距从2cm漂到20cm”的断崖式衰退&#xff0c;而是以更隐蔽的方式出现——点云变稀、边角出现噪点…

作者头像 李华
网站建设 2026/9/8 19:12:31

电源完整性设计入门:PDN阻抗与去耦电容实战指南

先说个上周刚处理完的事。同事拿来一块FPGA板&#xff0c;说系统上电后偶尔复位&#xff0c;查了半天时钟和复位电路都正常&#xff0c;示波器挂在核心电源轨上一看就露馅了——1.0V的核心电压在负载切换瞬间掉到了0.82V&#xff0c;持续了几十微秒&#xff0c;刚好低于监控芯片…

作者头像 李华
网站建设 2026/9/8 19:12:27

关于雷达技术的书籍

下载链接&#xff1a;https://ftp.idu.ac.id/wp-content/uploads/ebook/tdg/MILITARY%20PLATFORM%20DESIGN/INTRODUCATION%20TO%20RADAR%20SYSTEM%20BY%20MERRIL,%20I%20SKLOINK%20(4).pdf https://soaneemrana.org/onewebmedia/INTRODUCATION%20TO%20RADAR%20SYSTEM%20BY%20ME…

作者头像 李华
网站建设 2026/9/8 19:11:29

AI研发流程再造:从代码管理到全链路可审计的工程化实践

1. 传统研发流程在AI项目上集体失灵&#xff0c;问题到底出在哪1.1 代码仓库管得住代码&#xff0c;却管不住“实验现场”去年我们团队上线了一个客服场景的大模型应用&#xff0c;头一个月一切正常。到了第三个月&#xff0c;业务方突然拿着几段对话截图找我&#xff0c;语气很…

作者头像 李华
网站建设 2026/9/8 19:10:39

从贝塞尔曲线到并查集:解析计算机中的“马尾巴”结构与优化

1. 从“高马尾”到“马尾巴”&#xff1a;一个词背后的系统命名法先说个有意思的事。我最早注意到“ponytail”这个词&#xff0c;不是在做发型&#xff0c;而是在读代码文档的时候。一个跟头发八竿子打不着的项目里&#xff0c;突然冒出来一堆ponytail、topknot、bun、braid这…

作者头像 李华