一、两个根本问题
网络游戏面对的两个根本约束:
- 延迟(Latency):光速有限,深圳到洛杉矶单向约 100ms。你按下”移动”,服务端 100ms 后才知道。
- 丢包(Packet Loss):UDP 不保证送达。WiFi / 移动网络下丢包 1-5% 是常态。
面对这两个约束,游戏网络架构的核心选择是:谁来当权威,以及怎么让客户端”感觉”不延迟。
二、两种主流模型
1 状态同步(State Sync)—— UE 的方式
服务端是唯一的权威。客户端只是”终端显示”。
客户端 服务端 │ │ ├─ 发送移动意图 (RPC) ──────────────────→ │ │ ├─ 执行移动 │ ├─ 计算新位置 │ ├─ 做碰撞检测 │ ├─ 更新权威状态 │ ←────────── 同步权威状态 ────────────┤ │ │ ├─ 渲染权威位置 │服务端说什么就是什么。客户端只能请求,不能决定。
状态同步的特点:
- 逻辑集中——所有 Gameplay 代码跑在服务端,防作弊天然
- 网络流量小——只同步”变了的状态”,不需要同步输入
- 观战/重连容易——服务端有完整世界状态,新客户端加入时直接下发全量
2 帧同步(Lockstep)—— 格斗 / RTS / 竞拍类常见
服务端只做消息转发。每个客户端独立运行完整的游戏逻辑。
所有客户端: 收集所有玩家的输入 → 在同一逻辑帧执行 → 各自计算出相同的世界状态前提:游戏逻辑必须完全确定性(Deterministic)。同样的输入 + 同样的初始状态 = 完全相同的输出。浮点数精度、随机数种子、遍历顺序——所有这些必须一模一样。
帧同步的特点:
- 网络流量极小——只同步输入指令(“玩家 1 按了攻击”),几个字节
- 逻辑一致性天然——因为所有人都跑同一套逻辑
- 但极其脆弱——一个浮点误差、一个不同的编译优化,就能导致状态分叉
- 观战/重连困难——新加入的客户端需要从初始状态重新执行所有历史输入(快放)
3 你的竞拍游戏的网络模型
暗拍博弈天然适合帧同步模型——每人每轮只做一个操作(出价、使用技能),操作量极小,时序要求不高。但你们的实际实现可能混合了两种方案:
- 出价与结算走服务端权威(状态同步)→ 防止作弊,服务端裁决
- 藏品展示、开箱动画走客户端预测 → 演出效果不受延迟影响
大多数现代游戏其实是混合方案——核心博弈走权威,演出走预测。
三、客户端预测与服务器和解
1 问题:“按了 w,0.2 秒后才开始动”
纯状态同步下,客户端必须等服务端确认才能移动——按下 W 键 → 发送 RPC → 服务端处理 → 回传位置 → 客户端更新。来回 200ms,体验极差。
2 解决:客户端预测(Client-Side Prediction)
客户端不等服务端确认,立即本地模拟移动:
// 客户端:收到输入后立即执行void UCharacterMovement::OnMoveInput(FVector Direction) { // 本地预测 PredictedPosition = CurrentPosition + Direction * Speed * DeltaTime; SetActorLocation(PredictedPosition); // 立刻渲染新位置
// 同时发送 RPC 给服务端 Server_Move(GetCurrentTimestamp(), Direction);}服务端收到后做同样的移动计算,并把权威结果同步回来。
3 和解(Reconciliation):预测错了怎么办
服务端的状态可能和客户端预测的不一致——可能是因为延迟导致服务端看到的输入序列稍有不同,或者有其他人推了你一下。
客户端预测位置: (100, 0)服务端权威位置: (98, 0) ← 差了 2 个单位
服务端回传时携带:- 权威位置 (98, 0)- 处理过的最后一个客户端输入的 Timestamp客户端收到后:
void UCharacterMovement::OnServerCorrection(FVector ServerPos, int64 AckedTimestamp) { // 1. 将角色拉到服务端权威位置 SetActorLocation(ServerPos);
// 2. 重放(Replay)所有服务端还没确认的输入 for (auto& Move : PendingMoves) { if (Move.Timestamp > AckedTimestamp) { SimulateMove(Move); // 本地重新模拟这部分移动 } }}关键:客户端不删自己的预测,而是在服务端确认的位置上重放未确认的输入。这样如果预测只是有微小偏差,下一帧就平滑修正了,玩家几乎无感。
4 UE 中这都是现成的
UCharacterMovementComponent 内置了完整的预测+和解逻辑:
PerformMovement→ 做了预测移动ClientAdjustment→ 收到服务端校正ServerMoveRPC 携带了客户端 Timestamp 和加速度数据SavedMoves队列保存了未确认的输入用于重放
你不需要自己写这套逻辑——但理解原理有助于调试网络 bug。当一个 QA 说”角色在移动中会瞬移”,你知道这很可能是和解校正引起的——客户端预测和服务端权威差距太大,校正拉扯感明显。
四、UE 的属性复制系统
1 不是每个 Actor 都一样
UE 的复制层级:
NetDriver (UNetDriver) └── ReplicationGraph (UReplicationGraph) └── Node (每个 Node 管理一批 Actor 的复制策略) └── Actor (AActor) └── Replicated Properties (标记了 Replicated 的 UPROPERTY)只有被标记为需要同步的、并且与某个客户端相关的 Actor 才会被纳入复制。
2 相关性(Relevancy)
“相关性”决定一个 Actor 是否需要同步给某个客户端:
// 默认:距离相关性bool AActor::IsNetRelevantFor(const AActor* Viewer, ...) const { return (GetActorLocation() - Viewer->Location).Size() < NetCullDistanceSquared;}在你竞拍游戏里,“其他玩家的出价”对所有客户端都是相关的(不管距离),需要覆盖 IsNetRelevantFor 返回 true。
3 属性比较与脏标记
复制系统不是每帧把 Actor 的全部属性发一遍。它追踪每个属性的上次同步值并比较:
Tick: for each Actor in Replication List: for each Replicated Property: CurrentValue = Actor->Health if (CurrentValue != LastReplicatedValue): MarkPropertyDirty() LastReplicatedValue = CurrentValue
Flush: for each Dirty Property: 写入 FNetBitWriter(位流)只发送变化了的属性。一个站立不动、满血的 NPC 每帧几乎不产生网络流量。
4 RPC:远程过程调用
UE 支持三种方向的 RPC:
UFUNCTION(Server, Reliable) // 客户端 → 服务端(移动、开火、使用物品)void Server_UseItem(int32 ItemID);
UFUNCTION(Client, Reliable) // 服务端 → 特定客户端(弹出提示、个人状态更新)void Client_ShowNotification(const FString& Text);
UFUNCTION(NetMulticast, Reliable) // 服务端 → 所有客户端(爆炸特效、广播消息)void Multicast_PlayExplosion(FVector Location);Reliable vs Unreliable:
- Reliable:保证送达,内部有确认重传机制(类似 TCP 但做了优化)。适合”必须到达”的消息(出价、伤害、状态变更)
- Unreliable:发一次,丢了不管。适合”丢了也没关系”的数据(移动同步、粒子特效)
你们的出价 RPC 一定是 Reliable——少一个出价,整局就乱了。
五、断线重连
1 需要恢复什么
掉线的玩家重连后,需要快速恢复对世界的认知:
| 需要恢复的 | 处理方式 |
|---|---|
| 世界状态(其他玩家位置、当前轮次) | 服务端下发全量快照 |
| 自己还未确认的输入 | 重新发送或丢弃 |
| 回合状态 | 服务端下发当前状态机的状态 |
2 UE 的方式
UE 通过 AGameMode::PreLogin / APlayerController::PostLogin 和 FObjectReplicator 的全量同步机制来处理重连:
- 新连接进来 →
UNetDriver::ServerReplicateActors检测到该客户端没有任何已复制的 Actor - 对所有 Relevant Actor 做一次全量属性复制(不检查脏标记——把所有属性都发一遍)
- 之后恢复为增量同步模式
3 竞拍游戏的特殊考虑
竞拍类游戏有一个特殊场景:玩家在出价阶段断线了,而其他玩家已经出了价。重连策略可以是:
- 断线自动弃权(是最简单的方案)——该玩家本轮视为未出价或保底出价,被系统接管
- 等待恢复——给一个宽限期(如 5-10 秒),轮次暂停或该玩家进入托管
- 客户端本地缓存 + 快速快照——重连时立即下发当前轮次所有已确认的出价
你们的实际方案取决于产品设计决策——但技术上的底层机制(全量快照同步 + 增量恢复)是一样的。
六、网络调优的几个实用概念
1 带宽管理
复制系统的发送端维护一个带宽预算。每帧可发送的总字节数有限(UE 默认约 256Kbps 可配)。超出预算时,低优先级的 Actor 的复制被推迟到下一帧。
这意味着场景里如果有 1000 个需要复制的 Actor,不是所有人同时收到更新——远处的、不重要的 Actor 可能每秒才同步一次。
2 Ping 补偿(Lag Compensation)
射击游戏里常见:当客户端向服务端报告”我打中了敌人”,服务端需要判断这个命中是否有效。
由于客户端看到的是过去的画面(延迟 100ms),敌人可能已经移动了。服务端的处理是回溯到客户端发出射击时的那个世界状态来做命中检测:
// 服务端bool CheckHit(FVector ClientAim, int64 ClientTimestamp) { // 找到 ClientTimestamp 时刻的敌人位置(历史快照) FVector EnemyPosAtThatTime = GetHistoricalPosition(Enemy, ClientTimestamp); return IsHit(ClientAim, EnemyPosAtThatTime);}UE 的 UNetDriver::ServerReplicateActors 和 FCharacterMovementComponent 都管理了短时间窗口内的历史状态,用于这种回溯检测。
3 状态机的确定性
你自己在竞拍游戏的竞拍状态机(之前聊过的 WAITING → INTEL → BIDDING → REVEAL → ... )如果跑在服务端权威模型下,只需要保证服务端逻辑正确。如果用帧同步方式做,就需要仿照浮点数用定点数、随机种子必须由服务端统一下发——不然不同客户端可能因为同一次随机开箱开出不同结果。
七、总结
| 概念 | 一句话 |
|---|---|
| 状态同步 | 服务端权威,客户端展示——UE 的默认模型 |
| 帧同步 | 所有人独立运行相同逻辑——格斗/RTS 常用 |
| 客户端预测 | 不等服务端,本地先动——消除延迟感 |
| 服务器和解 | 服务端说了算——预测错了就拉回来+重放 |
| Relevancy | 不是所有 Actor 都同步给所有客户端,按需过滤 |
| RPC | 三种方向(Server/Client/Multicast),Reliable vs Unreliable |
| 重连 | 全量快照后恢复增量同步 |
网络同步是多人游戏的基石。它决定了”玩家感知到的世界是否公平”——移动会不会瞬移、伤害有没有吞掉、出价到没到服务器。理解几种模型的设计取舍,比记住具体 API 重要得多。