1. 多人 FPS 网络同步到底在同步什么
1.1 先搞清楚 FPS 网络同步的三大痛点
做多人 FPS 最难的不是开枪手感,不是地图设计,而是网络同步。单人模式里随便写个 AI 行为树,玩家在本地开枪、本地命中、本地结算,怎么调都爽。一旦切到多人,所有逻辑都要重新审视:玩家 A 看到的敌人在 B 看到的位置吗?玩家 B 屏幕里那个人到底是多少血量?两个人同时开枪,谁的命中生效?
FPS 网络同步要解决的核心问题,归结起来就三个。
第一是权威性。玩家打出的每一发子弹,命中结果由谁判定?如果由开枪的那个客户端自己判定,外挂改一下内存就能无限爆头,游戏规则直接崩塌;如果由服务器判定,又会引出延迟带来的挫败感——你明明看到准星压在敌人身上,服务器却回传“没打中”。权威性决定了公平性的天花板,也是网络同步一切讨论的起点。
第二是一致性。多个客户端看到的游戏世界必须大体相同。同一个敌人的位置、血量、当前武器、换弹状态,在不同玩家的屏幕上不能差距过大,否则对枪时双方看到的场景完全不一样,胜负就成了“谁的屏幕更诚实”的抽奖。一致性听起来是基本要求,真正做起来却非常烧钱,因为每个属性同步都有带宽成本,同步得越频繁、越精确,网络压力就越大。
第三是流畅性。FPS 玩家对延迟极其敏感,超过 80ms 的输入延迟就能明显感觉“飘”。鼠标点了、子弹没出,这种体验在竞技游戏里约等于劝退。网络同步不能为了绝对准确而牺牲响应速度,必须让本地操作立刻有反馈,哪怕是“假反馈”,也得先让玩家爽到,再让服务器慢慢纠正。
这三个问题互相牵扯,没有任何一个能单独解决。权威性要求服务器承担更多判定逻辑,这会增加等待时间;一致性要求同步更多状态数据,这会抬高带宽;流畅性要求客户端做预判渲染,这又可能和权威结果冲突。做多人 FPS,本质上就是在这些矛盾里反复横跳,找到一个玩家最能接受的平衡点。
1.2 状态同步与事件同步:两种模型的取舍
UE5 默认给的是状态同步思路:服务器作为权威,定期把 Actor 的属性变化广播给客户端。角色的位置、旋转、血量、动画状态位,都是状态的副本。每个客户端本地维护一份副本,服务器更新后推过来,客户端再渲染。这种模型的好处是容易理解,只要服务器属性一变,复制系统自动找到需要更新的连接,把数据送出去。缺点是带宽消耗大,很多属性其实没变化,但为了保险也被同步了一遍。
另一条路线是事件同步(有时也叫消息同步),像格斗游戏里的回滚式网络同步就偏向这个方向。客户端或服务器通过发送离散的“事件”来推进逻辑,而不是持续同步每个属性。格斗游戏里一个“出拳”事件就代表一个完整的动作逻辑,双方只需要把事件广播出去,各自本地演算。FPS 也能用事件同步处理部分内容,比如“扣动扳机”本身是一个事件,“换弹完成”也是一个事件,但子弹飞行轨迹、命中位置这些依赖连续位置状态,纯事件同步覆盖不了完整的 FPS 逻辑。
在 UE5 里做 FPS,我不会一开始就纠结“我到底选哪种模型”,而是先看游戏规模。如果只是 4 人小队合作 PvE,状态同步加适量事件同步就够用;如果要做 20 人以上的 PvP,就要提前规划属性复制的粒度,把高频变化状态和低频变化状态拆开处理。角色的位置是高频状态,几乎每帧都在变;血量是低频状态,只有受伤时才变化。针对不同属性设置不同的同步频率和网络优先级,这是后期优化带宽的关键手段,也是从“能跑”走向“能上线”的分水岭。
1.3 从玩家体验倒推设计目标
我接手多人 FPS 项目时,习惯先把玩家体验拆成几个“感觉点”,再反推技术方案。因为网络同步的目标不是“数据同步”,而是“玩家觉得公平、跟手、不卡”。
开镜手感要求玩家按下右键屏幕立刻开镜,不能等服务器确认再切视角,所以开镜动画和 FOV 变化必须走本地预测。射击手感要求按下左键,枪口火光、枪声、后坐力立刻反馈,所以射击表现要本地先行,命中结果再走服务器裁决。击杀反馈可以延迟几十毫秒,飘血伤害数字晚一点跳出来问题不大,但不能完全丢失。敌人移动必须平滑,画面上的敌人不能一卡一顿地瞬移,所以要用插值来填补网络包之间的空档。伤害判定必须服务器说了算,但要结合开枪者的网络延迟做补偿,不然延迟 80ms 以上的玩家永远打不中移动目标。
把这些体验目标列出来,再回头去读 UE5 的复制文档,思路会清晰得多。网络同步不是为了同步本身,是为了让玩家觉得“这游戏真跟手、真公平”,所有技术选型都应该围绕这个目标展开。
2. 复制系统核心原理:别只盯着同步频率
2.1 Actor 复制、属性复制、RPC 的分工
UE5 的网络复制体系分三层:Actor 复制、属性复制和 RPC。
Actor 复制是基础管道。一个 Actor 设置了bReplicates = true,它才会被纳入复制系统。服务器负责创建和销毁 Actor,客户端只是接收实例化的指令。没有 Actor 复制,后面的一切都不存在。
属性复制是 State 的传递。Actor 上标记了Replicated的属性,会在变化时被服务器推送到相关客户端。比如血量CurrentHealth,服务器把它改成 80,所有相关客户端收到新值后更新本地状态。这里要注意区分Replicated和ReplicatedUsing:前者是“服务器改了我就收”,后者是“收的同时在客户端自动触发一个回调”。FPS 里血条 UI 刷新、受击飘血这类表现逻辑,强烈建议用ReplicatedUsing配合OnRep_函数,否则客户端只拿到数值,还要自己轮询监听,额外增加复杂度。
RPC 是 Event 的传递。Server 函数用于客户端向服务器发送请求,比如“我按下了开火键”;Multicast 函数用于服务器向所有客户端广播事件,比如“手雷爆炸了”;Client 函数用于服务器向指定客户端单独下发信息,比如“你被击杀了,切换到观察者视角”。RPC 适合用来传递瞬时事件,而不是持续状态。我见过不少新人把位置更新写成Server_SendMove这种每帧调用的 RPC,结果带宽直接爆炸。位置这类高频状态应该走属性复制加插值,RPC 只负责扣扳机、换弹、投掷这些低频离散事件。
这里还有一个容易踩的坑:RPC 的执行条件是“握着有效连接”。在 Listen Server(监听服务器)模式下,Host 玩家调用 Server RPC,本质上是本地直接执行,不走网络。在 Dedicated Server 模式下,所有玩家都是纯客户端,Server RPC 必须真的经过网络层。上线前一定要在 Dedicated Server 环境测试一遍,很多人只在 PIE 模式里玩,结果发布到 Linux 服务器后各种 RPC 失灵,排查半天发现根本没有客户端调用到服务器。
2.2 Replicated、RepNotify 和连接相关性
属性复制标记看着简单,实际用起来有讲究。Replicated表示“我要同步这个属性”,但同步时机、同步优先级、是否只在部分连接上同步,都另有一套规则。
先说ReplicatedUsing。给属性加上它,并写一个OnRep_函数名的回调,客户端收到新值时会自动触发回调。这比客户端自己轮询属性要优雅得多,而且天然适合驱动 UI。比如:
UPROPERTY(ReplicatedUsing = OnRep_Health) float Health; UFUNCTION() void OnRep_Health();服务器修改 Health 后,客户端自动执行OnRep_Health,在函数里更新血条、播放受击特效、判断是否死亡。这种做法把“数据变化”和“表现反馈”解耦,结构清晰也方便维护。
然后是连接相关性。UE5 的复制系统不会把每个 Actor 都同步给所有客户端,而是根据相关性判断“这个客户端需不需要知道这个 Actor”。默认基于距离剔除,离玩家太远的 Actor 不复制;也可以用IsNetRelevantFor自定义规则。FPS 里常见需求是“队友位置必须同步,但敌人的弹药数量不需要同步”,这时就要对属性做分类,或者自定义相关性规则。盲目关闭距离剔除会导致带宽失控,20 个玩家互相看到所有子弹和所有道具,网络包瞬间塞满。
我实测过一个简单场景:8 人团队死斗,地图里有 200 个可拾取弹药盒。默认距离剔除下,每人周围只有 5 到 8 个弹药盒需要同步,带宽毫无压力。一旦把剔除关了,所有 200 个弹药盒全部同步给每个人,属性更新量直接翻了 20 多倍。这个案例说明,复制的第一原则不是“同步什么”,而是“不同步什么”。