游戏服务端网络游戏的帧同步实现
MeteorCat本篇章主要实现多人在线的帧同步流程, 首先是定义客户端和服务端会用到的游戏交互事件 Protobuf:
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
| syntax = "proto3";
message InitFrame{ int32 frame = 1; float x = 2; float y = 3; }
message InputFrame{ string sessionId = 1; int32 frame = 2; int32 direction = 3; bool jump = 4; }
message Frames{ int32 frame = 1; repeated InputFrame frames = 2; int64 timestamp = 5 ; }
|
以上还有事件没有说明: 进入场景(JoinScene), 离开场景(LeaveScene) 等, 可以思考游戏参与多人游戏整体流程需要多少事件
帧同步说明:
-
连接服务端的时候, 获取下最新的服务器序列帧挂载到目前客户端最新帧, 其实就是对齐序列帧
-
客户端开始启动定时器按照指定帧率运行(后面这个定时器假定为 FrameTimer, 帧更新方法为 FrameTimer.Update )
-
在帧更新方法 FrameTimer.Update 之中, 每一帧都要提交给服务端, 并且 frame = frame + 1
-
服务端帧接收, 需要注意的是服务端也是启动和客户端一致帧率定时器, 需要 Map<Integer,List> frames 的对象来维护消息列表
客户端的帧提交伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| var frame = 0 or ? # 本地同步帧号(与服务端对齐)
# 核心帧循环逻辑, 这里就是客户端在拉取最新帧之后初始化具体每次运行周期(15/30/60fps)的定时器 func Update(): frame += 1 # 收集输入(对应你的移动/跳跃逻辑) var direction = Input.get_axis("move_left", "move_right") var jump = Input.is_action_just_pressed("jump") # 封装成 Protobuf 并且网络推送指令等 var msg = InputFrame{ sessionId: "玩家唯一标识", frame, direction, jump} WebSocket.send(100,msg) # 注意: 服务端和客户端必须采用相同的帧率运行, 具体精度为 15/30/60fps(1000÷15ms,1000÷30ms,1000÷60ms) # 一般来说15帧(0.03秒)适合大部分轻中度类型游戏, 30帧适合塔防MMORPG类型游戏, 60帧则专业的FPS和格斗竞技类游戏 # 也就是后续不再使用 Godot._process 或者 Unity.Update 更新方法处理玩家输入, 而是利用启动的自定义帧率定时器处理逻辑
|
服务端的帧广播伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| private final AtomicInteger frame = new AtomicInteger(0); // 全局帧号, 从0开始且必须原子量保证帧率不会乱序 private final Map<Integer,List<InputFrame>> frames = new ConcurrentHashMap(); // 输入缓存, 按帧号存储玩家输入, 也是必须要保证线程安全性
// 核心帧循环逻辑, 这里就是定时器的具体每次运行周期(15/30/60fps), 服务端和客户端必须保持一致 void Update(){ long frameTime = System.currentTimeMillis(); // 记录毫秒帧时间戳 int currentFrame = frame.get(); // 获取当前帧号(递增前的帧号,作为本帧标识) // 收集本帧所有玩家的输入 List<InputFrame> inputs = frames.remove(currentFrame); if (inputs == null) { inputs = new ArrayList<>(); // 若没有输入,用空列表 } // 生成Protobuf帧数据并广播给所有客户端 - Frames 消息结构 BroadcastToAllClients(new Frames{ frame:currentFrame,frames: inputs,timestamp: frameTime}); // 序列帧递增 frame.incrementAndGet(); }
|
以上就是服务端的每帧广播和递进处理, 另外还有提交上来的帧也是需要判断下, 假设目前客户端提交序列帧行为:
注意: 以上的共享对象必须采用原子值线程锁或者 Actor 等来保证线程安全, 否则会出现序列帧异常
还有游戏的 追帧 概念, 用来处理客户端和服务端状态异常需要重新同步序列帧, 接下来就是需要说明的重要概念
追帧处理
对于 追帧 应用主要场景如下:
-
玩家中途加入游戏场景, 从服务端的 InitFrame 获取最新序列帧为0, 但是设备卡顿导致服务器的实际序列帧误差为1000+
-
网络卡顿或者切换恢复, 移动端最常见的网络可能自主从优由 4G|5G 切换到 WIFI, 期间交换网络恢复的过程会存在极大误差帧
-
断线重连的帧数据异常, 这种就是各方面原因导致的, 可能是设备|网络|环境等导致掉线, 而客户端采用掉线重连导致目前序列帧和本地有误差
若不进行追帧, 这些客户端会始终落后于服务端和其他玩家, 导致画面不同步(如其他玩家已移动到新位置, 滞后客户端仍显示在旧位置)
另外追帧过程不需要做渲染(跳过动画播放|粒子特效等UI更新), 只处理状态更新直到追帧完成开始正常序列帧同步
这里也就代表服务端需要把每一个序列帧都要做好保存, 保证后续客户端能够正常取出对应的数据帧
序列帧最好后续异步入库保存, 后续作弊举报或者bug复现的时候可以通过序列帧重放还原现场.
在帧同步游戏中, 需要触发追帧的核心判定条件是客户端当前状态帧号显著落后于服务端当前帧, 具体场景可分为以下几类:
-
客户端中途加入游戏(最常见场景)
- 判定条件: 客户端连接时, 服务端当前帧 > 客户端初始帧号(通常是0)
- 示例: 服务端已运行到帧1000, 新客户端刚连接(初始帧0), 两者帧差1000帧必须追帧
-
网络异常后恢复连接
- 判定条件: 客户端连接时, 服务端当前帧 > 客户端初始帧号(通常是0)
- 示例: 客户端因网络卡顿断连5秒(30fps下对应150帧), 重连后帧差150帧需要追帧
-
客户端帧数据丢失且无法重传
- 判定条件: 客户端检测到连续丢失的帧数量 > 阈值(如3帧), 且重传请求未得到响应
- 示例: 客户端丢失帧501-503, 多次请求重传失败, 此时服务端已推进到帧510, 帧差7帧触发追帧
-
客户端状态与服务端校验不一致
- 判定条件: 服务端定期广播关键状态快照(如每100帧), 客户端对比后发现状态偏差超过阈值(如位置偏差>1米)
- 示例: 客户端因本地预测错误, 角色位置与服务端快照偏差2米, 判定为需要追帧校正
一般为了避免频繁触发追帧都会设定合理的辅助机制帮助确认是否要调用追帧:
-
帧偏移阈值: 在处于轻微偏差帧(1~2帧)不触发追帧, 而如果偏差比较大(6~9帧)的时候就要开始追帧处理
-
时间偏差: 在指定时间内(100ms)没有获取到新的序列帧更新时间, 需要主动发起追帧确认是否服务异常
还有一些不需要追帧的的情况, 这种情况就尽可能避免触发追帧:
-
帧差在阈值内: 如仅落后1-2帧, 可通过正常接收服务端后续帧自然同步(无需专门追帧)
-
客户端超前服务端: 帧同步中客户端不会主动超前(因帧号由服务端广播驱动), 若出现超前直接以服务端帧号为准重置即可, 无需追帧
-
单帧数据丢失但可重传: 丢失1-2帧时, 优先请求服务端重传该帧数据, 而非触发完整追帧流程(重传成本更低)
另外追帧其实还有很多隐患需要明确留意一下:
-
画面瞬移: 因为追帧是批量把帧按操作顺序直接执行到最新, 所以表现行为看起来就像一直在瞬移变动
-
操作延迟: 客户端启动追帧的时候是直接阻止玩家发起的操作去优先处理历史帧, 所以会看到触发游戏行为的时候没反应等到追帧到最新帧
-
状态不一致: 这也是很常见的问题, 可能受到硬件浮点数|队列排序|编程语言等差异, 会出现最后追帧和多个玩家展示的画面不一致
-
回滚风暴: 状态不一致 引发的后续问题, 因为内部不一致就需要从不一致的帧重新拉取服务器的序列帧, 多次不一致会出现频繁回滚追帧
-
数据庞大: 这也是很常见的问题, 比如从0~500的追帧无法确定操作量, 如果序列帧数据太大会导致短时间内网络带宽占用激增从而进一步让网络拥堵
-
安全问题: 有些帧同步游戏内部核心游戏逻辑在客户端, 服务端则只验证结果; 在追帧的过程之中可能会被第三方内部手动插入伪造帧从而达到作弊效果
追帧需要额外追加同步的协议:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| syntax = "proto3";
message InputFrame{ string session_id = 1; int32 frame = 2; int32 direction = 3; bool jump = 4; }
message CatchFrameRequest{ string session_id = 1; int32 start = 2; int32 target = 3; bool snapshot = 4; }
message CatchFrameResponse{ string session_id = 1; int32 start = 2; int32 offset = 3; int32 frame = 4; repeated InputFrame frames = 5; CatchFrameSnapshot snapshot = 6; bool last = 7; }
message CatchFrameSnapshot{ int32 frame = 1; repeated RoleFrameState roles = 2; }
message Vector2{ float x = 1; float y = 2; }
message RoleFrameState{ string session_id = 1; Vector2 position = 2; }
message CatchupCompleted{ string session_id = 1; int32 frame = 2; bool success = 3; }
message CatchFrameCommand{ enum CommandType { CONTINUE = 0; ABORT = 1; SLOW_DOWN = 2; RESET_FRAME = 3; } CommandType type = 1; string reason = 2; }
|
这部分消息结构流程如下:
-
玩家进入场景, 发送 InitFrame 结构服务端响应相同 InitFrame.frame 获取到当前最新序列帧
-
InitFrame.frame 对比本地的最新序列帧(默认刚进来的序列帧为0), 这时候就要触发追帧请求
-
客户端发送 CatchFrameRequest 消息, 要求服务端返回指定的 CatchFrameResponse 帧消息
-
CatchFrameResponse 列表包含有当前帧玩家和场景快照等相关信息, 不过一般都是采用批量提取 5~10帧 返回
-
客户端不断递归追帧, 直到 CatchFrameResponse.frame 返回的服务帧为 InitFrame.frame 返回序列帧一致
-
当序列帧一致的时候, 客户端提交 CatchupCompleted 表示追帧完成, 本地可以创建和服务端相同的定时器获取消息
-
如果其中服务端检测到消息异常, 那么服务端会返回 CatchFrameCommand 用来确认重新追帧还是直接把玩家踢下线
需要注意: 如果客户端递归追帧 CatchFrameRequest 频繁没有校正正确情况下, 可以判断异常玩家让其下线重新追帧
这里理解起来整体比较抽象, 不过只需要处理过几次就能大概直到内部总体流程.
追帧缺陷
从上面就能看出利用 追帧 相当于将玩家属性(坐标等)初始化到指定值, 然后通过不断迭代序列帧指令从而实现回滚到最新序列帧的帧号.
不过这里面也带来缺陷: 频繁追帧导致如果帧号偏差过大(0→99999), 导致网络转发和CPU运算处于高负载.
上面采用的多人共享状态场景的 Actor, 当每次有玩家进入场景都要做频繁的网络发包同步;
而如果客户端出现数据帧校正不一致就会触发频繁的序列帧回滚, 如果回滚正常还好说, 要是回滚校正失败会触发频繁再次回滚.
虽然有重试机制限制, 但是如果是多人参与的游戏场景就很复杂了(SLG之类游戏), 频繁校正会使得单一的 Actor 一直处于高负载.
共享 Actor 成本很高, 既要做网络广播转发还要做关键逻辑判断, 序列帧列表有时需要保存入库方便举报还原现场等情况
所以后面这种默认从0开始追帧设计除非是需要强实时性且参与人数较少情况(最好是能动态控制场景的运行时间),
最明显的就是 Moba|RTS|ACT 之类匹配开房间对战且人数组合较少的情况, 直接从0追帧保证游戏玩法公平性和连贯性.
而不适合的就是 MMORPG|FPS|SLG|BigMap 之类游戏场景特别复杂, 参与的玩家数量庞大且序列帧冗长;
持久化地图可能数天甚至数个月的序列帧不可能完成, 客户端追帧在低端设备上容易直接搞崩系统;
而 FPS 则是有的游戏逻辑是跑在本地客户端, 可以被人为手动去伪造 ‘追帧请求’ 产生数据风暴让服务端系统卡顿从而实现作弊效果.
所以后面的轻量化帧同步方法都是采用 初始化快照状态(snapshoot) + 追帧处理(frame),
也就是进入场景的 InitFrame 消息表不仅带有最新的帧号, 还带有 场景快照结构(role+secne+npc)
标识 所有玩家|场景机关|NPC状态 直接同步到最新状态, 然后直接从当前帧开始 Update 开始跑逻辑更新.
这种方法可以作为简单的动态游戏 Actor 设计, 也就是玩家点击进入游戏扣除体力游玩关卡这种简单游戏类型,
服务端会动态创建 Actor 作为游戏场景直到最后通关成功|失败来做回收 Actor 并落地入库序列帧数据.
实际上最后简单概括就是对于需要持久化的 Actor 并不推荐采用帧同步处理, 而是寻求状态同步的处理方式避免掉这些同步帧流程.
代码说明
注意多人共享的游戏场景, 一般采用单个 Actor 处理共享的场景状态:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268
| package io.fortress.quarkus.protobuf;
import io.fortress.quarkus.protobuf.message.Game; import org.apache.pekko.actor.*; import org.apache.pekko.event.LoggingAdapter;
import java.time.Duration; import java.util.*;
public class ProtobufFrameScene extends AbstractActor {
public static class ProtobufFrameSceneWrapper {
final ActorRef actorRef;
public ProtobufFrameSceneWrapper(ActorRef actorRef) { this.actorRef = actorRef; } }
public record Vec2(int x, int y) { }
public enum Fps { Fps15(1000 / 15), FPS30(1000 / 30), FPS60(1000 / 60), ;
final int value;
Fps(int value) { this.value = value; } }
public interface FixedUpdate { }
final LoggingAdapter log = context().system().log();
final Scheduler scheduler = context().system().scheduler();
final Map<Integer, List<Game.InputFrame>> frames = new LinkedHashMap<>();
final Map<String, ActorRef> actors = new LinkedHashMap<>();
final Fps fps;
final Cancellable timer;
int frame = 0;
final Map<ActorRef, Vec2> positions = new HashMap<>();
public ProtobufFrameScene(Fps fps) { this.fps = fps; this.timer = scheduler.scheduleAtFixedRate( Duration.ofMillis(fps.value), Duration.ofMillis(fps.value), getSelf(), new FixedUpdate() { }, context().dispatcher(), ActorRef.noSender() ); }
@Override public void postStop() { if (Objects.nonNull(this.timer)) this.timer.cancel(); }
@Override public Receive createReceive() { return receiveBuilder() .match(Game.JoinScene.class, (e) -> !actors.containsKey(e.getSessionId()), (event) -> { actors.put(event.getSessionId(), getSender()); positions.put(getSender(), new Vec2(1, 1));
actors.forEach((key, value) -> value.tell(event, getSelf())); }) .match(Game.LeaveScene.class, (e) -> { actors.remove(e.getSessionId());
actors.forEach((key, value) -> value.tell(e, getSelf())); }) .match(Game.InitFrame.class, (e) -> { ActorRef sender = getSender(); Vec2 vec2 = positions.get(sender);
var message = Game .InitFrame .newBuilder() .setFrame(frame) .setX(Objects.isNull(vec2) ? 0 : vec2.x) .setY(Objects.isNull(vec2) ? 0 : vec2.y) .build(); getSender().tell(message, getSelf()); }) .match(Game.InputFrame.class, this::onInputFrame) .match(FixedUpdate.class, this::onFixedUpdate) .build(); }
private void onFixedUpdate(FixedUpdate ignore) { long frameTime = System.currentTimeMillis();
List<Game.InputFrame> inputs = frames.remove(frame); if (Objects.isNull(inputs)) { inputs = Collections.emptyList(); }
for (Game.InputFrame input : inputs) { ActorRef actor = actors.get(input.getSessionId()); if (Objects.nonNull(actor)) {
Vec2 pos = positions.get(actor); Vec2 newPos = new Vec2(pos.x + input.getDirection(), pos.y); positions.put(actor, newPos); } }
if (!actors.isEmpty()) { var builder = Game .Frames .newBuilder() .setFrame(frame) .setTimestamp(frameTime); for (Game.InputFrame input : inputs) { builder.addFrames(input); } var message = builder.build(); for (ActorRef actor : actors.values()) { actor.tell(message, getSelf()); } }
frame = frame + 1;
if (frame == Integer.MAX_VALUE) frame = 0; }
private void onInputFrame(Game.InputFrame f) { int value = f.getFrame(); if (value == frame || value == (frame + 1)) { var opt = onLogicUpdate(f); if (opt.isEmpty()) { log.warning("Validated logical update for frame " + f.getFrame()); } else { frames.computeIfAbsent(value, k -> new ArrayList<>()).add(f); } } else { log.warning("Invalid frame value: {}, session: {}", value, f.getSessionId()); } }
private Optional<Game.InputFrame> onLogicUpdate(Game.InputFrame f) {
var direction = f.getDirection(); if (direction < -1 || direction > 1) { f.toBuilder().setDirection(0).build(); }
return Optional.of(f); }
}
|
上面还有实现具体的追帧功能, 仅仅作为简单的帧同步服务 Actor 服务; 理论上服务器在 8核心16线程 + 16G 的环境下作为轻量的百人同时在线服务,
主要的问题怎么尽可能提高 Actor 的网络转发效率, 目前上面还有很多待优化的地方, 而且客户端代码后续看情况补充上(
可能后续没时间).
这部分建议的使用场景是动态创建房间这种情况, 比如常见流程: 选择关卡 → 进入游戏 → 服务端创建 Actor 启动 → 等待游玩结果完成退出 Actor
应付一般的轻度日常游戏关卡就行了, 不过一般会加个 ‘一键扫荡’ 功能; 其实除了玩家不喜欢重复刷刷刷之外, 服务器也不喜欢这种频繁的帧同步操作…
格斗游戏序列帧
如果你游玩过相关格斗游戏(比如街头霸王)就会有相关帧概念, 以 60fps(17ms) 来说明, 具体玩家打出的动作拆解为:
-
发生帧, 玩家触发输出提交到服务器并产生攻击窗口判定(启动)
-
持续帧, 攻击判定转移到攻击结束的流程(持续)
-
恢复帧, 攻击结束到恢复默认状态(硬值)
其中还有状态概念, 而玩家一个动作的 整体帧率 = 启动 + 持续 + 硬直;
假设我们目前服务器 frame=0, 且释放重攻击整体为 32帧(启动10帧 + 持续5帧 + 恢复17帧),
当我们点击重攻击的触发角色攻击, 会发生以下序列帧递增:
-
frame=0,state=startup: A玩家触发重攻击提交服务端, 广播A玩家出攻击启动UI效果界面
-
frame=1,state=startup: 目前还是处于启动帧, 跳过玩家其他输入等待启动完成
-
frame=2,state=startup: 同上
-
frame=3,state=startup: 同上
-
…不断递增启动帧的流程, 不接受任何操作序列帧提交
-
frame=9,state=active: 完成启动帧流程切换到持续帧, A玩家已经展开攻击窗口需要判断窗口当前是否命中, 并且需要播放动画
-
frame=10,state=active: 目前还是处于持续帧, 持续帧越长代表目前攻击判定越久
-
frame=11,state=active: 同上
-
…不断递增持续帧的流程, 不接受任何操作序列帧提交
-
frame=14,state=recovery: 完成持续帧流程切换到硬直状态, 其实就是展开攻击窗口开始关闭并且等待恢复成默认状态
-
frame=15,state=recovery: 玩家硬直的窗口, 不接收任何操作
-
frame=16,state=recovery: 同上
-
…不断递增硬直帧的流程, 不接受任何操作序列帧提交
-
frame=31,state=default: 硬直结束, 开始等待玩家指令来触发下次发生的帧事件
这就是基于 60fps 简单帧同步的发生流程, 而且每帧间隔约 17毫秒|17ms, 也就是上面整个动作流程触发时间为 554ms|0.54s;
而上面仅仅是作为简单的动作发生序列帧, 没有包含 绿冲(强制中断当前追加攻击) 和 打空(攻击没命中) 等判断逻辑,
对于 60fps 双人对局的 CPU负载 和 网络转发 是个不小的考验, 更别说还有更多排位赛可能有时候要连续开上百把游戏.