
1. 为什么单机思维做不了多人同步刚入行做游戏那会儿我接的第一个联机需求是两个人能互相看见对方在动。当时觉得这有什么难的本地角色怎么动把坐标发过去不就行了。结果真跑起来才发现两台机器上的画面差得离谱——A看到B在原地抽搐B看到A瞬移打一枪过去对方三秒后才掉血。那段时间我翻了不少资料也踩了一堆坑才慢慢把网络同步这件事的门道摸清楚。这篇内容聊的是多人游戏网络同步的基础原理。不涉及具体引擎的API怎么调也不讲某个现成框架怎么用而是把底层那套逻辑掰开揉碎讲清楚数据从一台机器到另一台机器中间到底发生了什么为什么会产生延迟和不同步以及业界常用的几种同步策略各自适合什么场景。适合刚接触联机开发、被延迟和状态不一致折磨过的开发者也适合想从单机转向多人的朋友。看完之后你至少能明白自己项目里那些鬼畜现象是怎么来的以及该往哪个方向去修。多人同步的本质其实是一个分布式系统问题。只不过这个分布式系统对实时性要求极高而且允许一定程度的不精确。理解这一点很关键——你不需要让所有机器在任何时刻都完全一致你只需要让所有玩家觉得看起来是一致的。这个认知转变是做好同步的第一步。2. 状态同步与帧同步两条根本不同的路在动手写任何同步代码之前必须先做一个顶层选择你的游戏走状态同步还是帧同步。这两条路的分歧点在于——到底同步的是结果还是过程。2.1 状态同步服务器说了算状态同步的思路很直白服务器是唯一权威所有游戏逻辑在服务器上跑客户端只负责把服务器发来的状态渲染出来同时把玩家的操作上报给服务器。客户端不参与任何逻辑计算它就是一个显示器加手柄。这种模式的好处是防作弊能力强。因为客户端根本不知道逻辑怎么算的它只是把服务器算好的位置、血量、状态画出来。玩家想改数据改了也没用服务器下一帧就把正确值覆盖回来了。另外状态同步对掉线和延迟的容忍度相对高一些因为客户端不需要自己预测逻辑它只要等服务器数据就行。但代价也很明显。服务器要承担全部逻辑计算玩家越多服务器压力越大。而且每个客户端都要接收大量状态数据带宽消耗不小。更麻烦的是玩家操作到看到反馈之间要经历上报服务器→服务器计算→下发客户端这一整圈延迟感会比较明显。2.2 帧同步只同步操作各自算结果帧同步走的是另一条路。服务器不跑游戏逻辑它只负责收集所有玩家的操作指令然后把这些指令按帧打包广播给所有客户端。每个客户端拿到相同的指令序列在本地跑相同的逻辑理论上就能得到相同的结果。这里的关键在于确定性。只要所有客户端的逻辑代码完全一致、输入完全一致、随机数种子一致那么跑出来的结果就必然一致。帧同步的带宽消耗极低因为传输的只是操作指令不是每帧的状态数据。服务器压力也小它基本就是个消息转发器。但帧同步的坑在于确定性这三个字说起来容易做起来难。浮点数在不同平台上的精度可能不一样物理引擎的碰撞检测顺序可能不一样甚至某些数学库的实现都有细微差异。这些差异在单机时看不出来一旦放到多台机器上跑几帧之后就会累积成肉眼可见的偏差。而且帧同步的防作弊是个大问题——客户端有完整逻辑想改数据太容易了。2.3 怎么选看游戏类型和需求这两种模式没有绝对的好坏只有适不适合。我一般用下面这个表来判断维度状态同步帧同步逻辑运行位置服务器各客户端传输内容状态数据操作指令带宽消耗较高较低服务器压力高低防作弊能力强弱确定性要求低极高适合场景MMO、卡牌、回合制RTS、MOBA、格斗简单说如果你的游戏逻辑复杂、状态量大、对防作弊要求高选状态同步。如果你的游戏操作频率高、状态变化快、需要精确的帧级同步选帧同步。当然实际项目中也有混合使用的比如用状态同步做大厅和匹配用帧同步做战斗这都很常见。3. 延迟从哪来为什么它杀不死确定了同步模式之后下一个绕不开的问题就是延迟。很多新手会问能不能把延迟降到零答案是不能。光速是有限的数据在物理介质里传输需要时间这个物理规律你改不了。你能做的是让玩家感觉不到延迟。3.1 延迟的四个来源延迟不是一个单一的东西它由好几部分组成。我习惯把它拆成四块来看网络传输延迟数据从A机器网卡出发经过路由器、交换机、光缆到达B机器网卡的时间。这是物理层面的取决于距离和网络质量。同城可能几毫秒跨国可能上百毫秒。处理延迟数据到达之后操作系统协议栈处理、应用程序解析、逻辑计算所花的时间。这部分通常很小但如果代码写得烂也能积少成多。排队延迟数据包在网络设备里排队等待转发的时间。网络拥堵时这部分会急剧增加而且波动很大。渲染延迟从逻辑算出结果到画面真正显示在屏幕上中间还有渲染管线的时间。这部分在单机时也存在但在联机时会被放大。这四块加起来就是玩家从按下按键到看到反馈的总延迟。其中网络传输和排队延迟是你无法直接控制的只能通过架构设计来掩盖。3.2 客户端预测先斩后奏客户端预测的核心思想是不等服务器确认客户端先按自己的理解把操作执行了画面先动起来。等服务器结果回来之后再对比一下如果和预测一致那就什么都不用做如果不一致再把客户端状态纠正到服务器的结果。举个例子。玩家按下前进键客户端不等服务器同意直接让角色往前走了一小段。同时把这个操作发给服务器。服务器算完之后回来说你确实往前走了位置是X。客户端一看和自己预测的一样那就继续。如果服务器说不行你前面有堵墙你只能走到Y。客户端就把角色拉回到Y的位置。这样做的好处是操作手感立刻变好玩家感觉不到延迟。坏处是如果预测错了会出现回拉现象——角色往前走了一步又被拽回来体验很差。所以预测的准确性很关键预测逻辑要和服务器逻辑尽量一致。3.3 延迟补偿让过去的事看起来像现在延迟补偿是另一个思路。既然延迟不可避免那就让服务器回到过去来判断。具体做法是服务器收到玩家的射击请求时不是用当前的世界状态来判断命中而是把世界状态回滚到玩家发出请求的那个时刻在那个时刻的状态下判断命中然后再把结果应用到当前。这样做的好处是玩家瞄准的是他屏幕上看到的目标而那个目标在服务器上可能已经移动了。如果不做补偿玩家明明瞄准了却打不中会觉得很冤。做了补偿之后服务器用玩家当时看到的状态来判断命中率就正常了。但延迟补偿也有副作用。被击中的人可能会觉得我明明已经躲到墙后面了怎么还被打中因为服务器用的是过去的状态那时候他还没躲进去。所以延迟补偿本质上是在让射击者舒服和让被射击者舒服之间做权衡通常射击游戏会偏向射击者因为射击手感更重要。3.4 插值与外推让移动看起来顺滑即使没有预测和补偿单纯让远程玩家的移动看起来顺滑也需要技巧。因为网络数据是离散的你不可能每帧都收到对方的位置更新。如果收到一个位置就硬生生地把角色瞬移过去画面会一顿一顿的。插值的做法是收到两个位置点之后不立刻跳到最新点而是在这两个点之间平滑过渡。比如你收到了t1时刻的位置A和t2时刻的位置B那就在t1到t2之间慢慢从A移动到B。这样画面就顺了代价是你会看到对方过去的位置有一点点延迟。外推则相反它是根据当前的速度和方向预测对方下一时刻会在哪。如果预测对了画面很顺如果对方突然转向就会出现瞬移或者穿模。所以外推通常只在短时间、小范围内使用而且要有纠错机制。4. 状态同步的实操拆解从数据打包到增量更新聊完原理来说点具体的。假设你现在要做一个状态同步的游戏从零开始该怎么下手。我按实际开发顺序把关键环节拆开讲。4.1 定义同步数据什么该发什么不该发第一步是确定哪些数据需要同步。原则很简单所有影响游戏逻辑的状态都需要同步纯表现层的东西不需要同步。比如一个角色的位置、朝向、血量、状态站立/奔跑/攻击这些会影响逻辑必须同步。但角色的粒子特效、UI动画、音效播放这些不影响逻辑各客户端自己播就行不需要同步。这里有个常见的坑新手容易把太多东西塞进同步数据里导致带宽爆炸。我见过一个项目每帧同步所有角色的完整状态包括每个骨骼的位置结果一局游戏下来流量惊人。正确的做法是只同步必要的抽象状态具体的表现由客户端根据状态自己推导。4.2 数据序列化把结构体变成字节流确定了要同步什么之后下一步是把这些数据变成可以在网络上传输的字节流。这个过程叫序列化。序列化的核心考量是紧凑。网络带宽是宝贵资源能省则省。比如一个位置坐标用float是4字节但如果你的地图范围只有1000个单位精度要求到0.1那用int16就够了2字节搞定。一个朝向角度如果只需要区分8个方向3个bit就够了。我一般会自己写一套简单的序列化工具而不是直接用JSON或XML。JSON可读性好但体积太大而且解析慢。二进制序列化虽然调试麻烦但性能和带宽都占优。折中方案是用Protocol Buffers这类工具既有一定的可读性又有不错的压缩率。4.3 增量更新只发变化的部分如果每帧都发完整状态带宽很快就撑不住了。所以状态同步通常用增量更新只发这一帧发生变化的数据。实现增量更新的关键是维护一个脏标记系统。每个可同步的属性都有一个标记当属性变化时标记置位。每帧收集所有被标记的属性打包发送然后清除标记。但增量更新有个问题如果某个数据包丢了客户端就永久缺失了那部分变化。所以通常还需要一个全量同步机制定期或者在特定条件下发送完整状态用来纠正累积的误差。4.4 优先级与频率控制不是所有数据都同等重要不同数据的同步频率可以不一样。角色的位置变化很快需要高频同步但角色的装备、等级这些变化很少低频同步就够了。我通常会把同步数据分成几个优先级高优先级位置、朝向、关键状态每帧或每几帧同步一次。中优先级血量、能量、buff状态变化时同步但可以稍微延迟。低优先级装备、外观、统计信息低频同步或按需同步。这样可以在有限的带宽下把资源用在最影响体验的地方。5. 帧同步的确定性陷阱为什么你的逻辑跑偏了帧同步看起来很美——带宽低、服务器压力小、同步精确。但真正做过帧同步的人都知道确定性是个磨人的小妖精。下面说说我踩过的几个典型坑。5.1 浮点数跨平台的隐形杀手浮点数在不同CPU、不同编译器、不同优化级别下计算结果可能有细微差异。这些差异在单机时完全看不出来但在帧同步里几帧之后就会累积成明显的偏差。解决办法是尽量用定点数代替浮点数。定点数把小数用整数来表示比如用1000表示1.0运算时全部用整数结果就完全确定了。代价是精度和范围受限而且需要自己实现一套定点数运算库。如果非要用浮点数那就要确保所有客户端的编译选项、数学库版本完全一致。这在PC上可能还行但跨平台比如PC和手机联机就非常困难。5.2 随机数种子必须一致游戏里经常要用随机数比如暴击判定、掉落概率。在帧同步里所有客户端的随机数序列必须完全一致否则同样的操作会得到不同的结果。做法是使用确定性随机数生成器并且所有客户端用相同的种子初始化。每次需要随机数时从同一个序列里取。千万不要用系统时间做种子那样每台机器都不一样。5.3 容器遍历顺序看不见的不确定性这个坑很隐蔽。比如你用哈希表存储游戏对象遍历哈希表的时候不同实现的遍历顺序可能不一样。如果遍历顺序影响了逻辑比如碰撞检测的顺序那结果就会不同。解决办法是使用有序容器或者在遍历前先排序。另外物理引擎的碰撞检测顺序也要注意有些物理引擎内部用了无序容器需要特别处理。5.4 逻辑与表现分离帧同步的架构要求帧同步要求逻辑和表现严格分离。逻辑层只负责计算不涉及任何渲染、音效、UI。表现层根据逻辑层的结果来渲染。这样做的好处是逻辑层可以独立运行、独立测试也方便做回放和观战。我见过一些项目逻辑和表现混在一起渲染代码里直接改逻辑状态结果帧同步怎么都调不通。后来花大力气重构把逻辑抽出来才解决问题。所以如果你打算做帧同步一开始就要把架构设计好不然后面改起来很痛苦。6. 实战中的取舍没有银弹只有权衡聊了这么多原理和实操最后说点实在的。网络同步这件事没有完美的方案只有适合当前项目的权衡。6.1 同步频率不是越高越好很多人觉得同步频率越高越好其实不然。同步频率高带宽消耗大服务器压力大而且客户端处理不过来反而会卡。我一般会根据游戏类型来定快节奏动作游戏10-20次/秒普通RPG5-10次/秒策略类1-5次/秒关键是找到看起来流畅和资源消耗可接受之间的平衡点。这个点需要通过实际测试来调不能拍脑袋决定。6.2 容错设计网络一定会出问题做联机游戏必须假设网络随时会出问题。丢包、延迟抖动、断线重连这些都要提前设计好处理机制。丢包的处理通常是重传或者用后续数据覆盖。延迟抖动的处理是加缓冲用插值来平滑。断线重连则需要保存玩家状态重连后恢复。这些机制会增加复杂度但不做的话玩家体验会很差。6.3 测试本地测不出网络问题最后强调一点网络同步的问题在本地开发环境里基本测不出来。你必须模拟真实的网络环境加延迟、加丢包、加抖动才能发现潜在问题。我常用的做法是写一个网络模拟层可以动态调整延迟和丢包率。测试时把延迟调到100ms、200ms甚至500ms看看游戏还能不能玩。很多在本地跑得好好的逻辑一加延迟就原形毕露。另外多找几个人一起测比一个人开多个客户端更有价值。因为真人操作的不确定性能暴露出很多自动化测试发现不了的问题。网络同步是个深坑但也是个很有意思的领域。每次解决一个同步问题看到画面变得顺滑那种成就感还是很足的。希望这些内容能帮你少走点弯路早日做出手感丝滑的联机游戏。