帧同步的基本原理
帧同步是指客户端每帧收集玩家的操作并将操作上报给服务器,服务器每帧把所有客户端上报的操作同步给所有的客户端,各个客户端在收到服务器下发的操作指令后再推进游戏,因此帧同步游戏的游戏逻辑全部都在客户端,服务器基本上只做操作指令的转发。
帧同步的优缺点:
- 优点:网络性能更好,和状态同步相比,帧同步的数据包体积要小的多,及时每帧都同步,总体的带宽消耗也很小。
- 缺点:反作弊较为薄弱,因为所有的逻辑运算都在客户端执行,因而和所有逻辑都运行在服务器的状态同步相比帧同步作弊要容易的多。
帧同步的同步间隔:
- 上限:间隔的上限为网络延迟的上限,例如网络延迟为30ms,那么同步的极限间隔为30ms,间隔比这个更小只会带来带宽和性能消耗。
- 下限:确保玩家能流程体验游戏,如果间隔过大则玩家的操作会感觉明显的卡顿,因此一般帧同步选用在20-30帧之间,大多会选择15帧(1000/15 = 66ms)的帧率来作为帧同步的帧率。
帧同步的通信方式:
对于网络通信一般有TCP和UDP两种方式,对比两种同步方式可以发现:
- TCP:本身为可靠传输,但是传输效率比较低,尤其是网络延迟比较高时,TCP本身的流量控制机制会导致发包频率,从而导致传输效率进一步降低,这对于网络游戏一般是难以接受的。
- UDP:为可不靠传输,但是传输效率比TCP要高,但是网络延迟比较高时会出现比较严重的丢包现象并且收到的包可能是乱序的,不过可以通过在上层增加一些特殊的机制来让帧同步的UDP变得可靠:
- 通过客户端和服务端的帧号控制执行流程,确保不会出现玩家在第9帧还没执行时第10帧就开始执行
- 通过服务器的补发机制来确保一帧内的操作数据一定送到客户端
- 通过服务器补发和客户端追帧的方式解决网络延迟问题,对于一些比较老的帧同步游戏,例如红色警戒2,在有可一个客户端网络比较卡时,服务器会停止转发直到收集到所有客户端的操作,现在的帧同步服务器会在某一个客户端延迟比较高时继续进行转发,当收到网络延迟较高的客户端上报的操作后会补发客户端缺失的帧并且由客户端自行进行追帧
帧同步的基本流程
基本流程总结起来为两部分:
- 服务器收集所有客户端的操作并转发给所有客户端
- 客户端收到服务器转发的操作并执行
以下为更详细的步骤,使用的语言为lua:
服务器上的操作
当服务器拉起一场对局后,服务器会为当局比赛中的每个玩家都分配一个FrameId代表下一帧要进入的Id,初始化为1
在服务器上定义FrameState,保存每个玩家的每帧进行的操作
1 | -- 当前帧玩家进行的操作 |
- 服务器上有一个NextFrameOpt,服务器将客户端采集来的操作加入到NextFremeOpt
1 | export type FNextFrameOpts = { |
服务器上启动一个定时器每隔50ms触发一次(选用20帧作为逻辑帧)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- 代表游戏开始的逻辑
function Game:StartGame()
-- 获取这场比赛的所有玩家
local Players = Game:GetPlayers()
for UserId in pairs(Players) do
-- 在这里进行一些全局初始化的操作
end
-- 初始化帧数据
self.FrameHistory = {
FrameId = 1,
PlayerFrameOpts = {} -- FFrameState数组
}
-- 下一帧的数据
self.NextFrameOpts = {
FrameId = 1,
Opts = {},
}
self.CurrentFrameId = 1 -- 当前帧号
local LogicFrameRate = 20 -- 逻辑帧率
local InitializeDelay = 5 -- 初始延迟
local bLoop = true, -- 是否循环
local function TimerCallback()
self:OnLogicFrame() -- 触发每帧的逻辑
end
-- 启动一个定时器
self.FrameSyncTimerHandler = TimerManager.SetTimer(1000 / LogicFrameRate, InitializeDelay, bLoop, TimerCallback)
end
function Game:OnLogicFrame()
-- 每帧的逻辑
end触发OnLogicFrame时把采集的操作保存到FrameState中
1 | function Game:OnLogicFrame() |
- 遍历每个玩家,给每个玩家发送帧操作数据
1 | function Game:OnLogicFrame() |
- 服务器进入下一帧的逻辑并清空上一帧的操作
1 | function Game:OnLogicFrame() |
- 发送服务器认为的这个玩家还没有同步的帧,所以需要给在服务器上增加一个每个玩家已经同步了多少帧(SyncFrameId),从SyncFrameId + 1开始一直到最新的帧,假设当前服务器已经同步到了第10帧,但是客户端只同步到了第五帧,那么就需要把6、7、8、9、10帧的数据一并同步给客户端
1 | -- 所以给Game增加一个每个玩家同步到的帧号 |
以上是服务端的基本逻辑,接下来是客户端需要完成的操作
客户端上的操作
客户端上的逻辑概括为绑定收到服务器发来的帧数据UDP数据包回调,对UDP包进行解析。
- 客户端对收到的逻辑帧数据进行解析,同时每个客户端自己内部维护一个SyncFrameId记录客户端真正同步到的帧号
- 判断同步的帧号是否合法:如果收到的帧Id小于SyncFrameId,则丢弃该包,通过这种方式解决UDP发包中先发后到或者后发先到的问题
- 如果收到帧操作时如果上一帧的操作不为空,则需要在处理下一帧之前同步上一帧的结果,以此来保证新的帧开始时所有的客户端都是从同一状态开始模拟的,例如正在播放的动画,人物的位置信息等,所以需要在收到最新帧时强制同步上一帧的结果,这也是帧同步的核心
- 客户端进行跳帧处理,当服务器发来的帧是到100帧,但是客户端自己记录的结果只有80帧,则需要从一次性从81帧一直处理到第99帧,在跳帧时直接应用最终结果,到第100帧时走正常处理流程
- 客户端采集自己操作的下一帧并上报给客户端,同时把收到的最新帧+1一并上报
操作回到服务端
- 服务器同步玩家的帧号,更新服务器认为客户端已经接收到的操作帧
- 如果收到的玩家操作帧不等于服务器要发送的下一帧,则认为这个操作是过时的,放弃该操作
- 保存玩家的操作并等待下一个OnLogicFrame触发不断循环下去
- 本文作者: KongXinQing
- 本文链接: https://13114987559.github.io/2026/05/31/essay/帧同步的基本原理/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!