尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity多人FPS网络架构实战:基于Mirror插件MultiFPS源码解析

Unity多人FPS网络架构实战:基于Mirror插件MultiFPS源码解析 我当年第一次在 Unity 里做多人 FPS 的时候踩过的最狠一个坑就是本地开枪明明打中了服务器那端的角色完全没反应本地跑得飞快其他玩家眼里我的角色在瞬移。后来换成了一套基于 Mirror 的插件 MultiFPS才真正把“网络架构”这四个字搞清楚。这篇不聊广告式的功能介绍直接从源码和实现原理层面拆开看MultiFPS 到底怎么组织它的网络架构、位置同步、射击判定和房间生命周期以及基于它做二次开发时最容易翻车的地方。这篇文章适合两类人一类是刚接触 Unity 多人开发的想明白“服务器授权”“状态同步”到底落地成代码是什么样子另一类是已经在用 Mirror 做项目但同步一直不丝滑、抖得厉害的开发者。我会把 MultiFPS 里的关键设计决策、代码层面的实现套路以及我自己实测后的改动经验全部摊开讲。1. 先搞清楚 MultiFPS 动了哪些“蛋糕”从 FPS 网络痛点说起1.1 为什么是 Mirror 而不是其他网络库市面上 Unity 多人方案其实不少Photon、UNET、Netcode for GameObjects、Mirror 这几种我都摸过。MultiFPS 偏偏选了 Mirror这个选型本身是有讲究的。Mirror 是 UNET 被 Unity 官方放弃之后社区接力做出来的分支API 风格和早期 UNET 几乎一致迁移成本极低。FPS 这种高频同步项目最需要的是三件事可靠的连接状态管理、低层传输层的可替换性、以及明确的服务器权威模型。Mirror 的前身 UNET 当年用起来骂声一片主要是坑在安全性上——但 Mirror 把这一层重新做了现在它的NetworkBehaviour、NetworkConnection、ServerRpc也就是原来的Command这套机制表达力很直接。更重要的一点是 Mirror 是纯 C# 开源实现没有闭源云服务依赖。MultiFPS 作为一套想让人“改完之后部到自己服务器上跑”的插件必须保证开发者能完全掌控网络节点。Photon 如果你不用它的云服务就得自己搭 Master Server复杂度陡增。Mirror 只需要一个 Host 主机就能开游戏对中小型 FPS 项目来说这是性价比最高的起点。注意这里说的“服务器”不一定是独立机房里的高性能机器。Mirror 支持 Host服务器和客户端同一个进程和 Dedicated Server独立服务器进程两种模式。MultiFPS 默认跑 Host 模式但代码结构上已经为 Dedicated Server 做好了准备。1.2 FPS 同步的核心难点本地手感与跨网络真实性的对抗为什么卡牌游戏、回合制游戏做多人容易FPS 做多人难因为 FPS 对“因果一致性”要求极高。你按下鼠标左键到屏幕上出现特效的延迟用户能感知到的阈值大约是 100 毫秒而网络 RTT 在很多地区轻松超过这个数。如果强行等服务器确认开枪结果再播放动画手感就稀烂如果让客户端直接播放又可能出现“我明明打中了服务器却不认账”的情况。MultiFPS 的整体设计思路底子上就是围绕这个矛盾展开的把输入和裁决分离把表现和逻辑分离。具体来说玩家的移动输入由客户端采集但最终位移由服务器驱动。这样可以防变速齿轮也防修改器直接改本地坐标。开枪的动作表现可以在客户端立即播放但真正对敌人造成伤害的判定必须由服务器执行射线检测。玩家的位置坐标同步走服务器广播客户端拿到之后做插值缓冲而不是简单赋值。这三点听起来简单实际落地上每一句都牵扯到很多细节。下面我按 MultiFPS 的架构层次逐层拆解。2. 整体架构拆分从传输层到房间管理的每一层职责2.1 传输与连接管理层Transport 的选型与回调模型Mirror 的网络栈分三层上层是NetworkManager与NetworkBehaviour的游戏逻辑中间是Transport抽象层负责具体的字节收发底层是实际走 UDP 还是 TCP 的实现。MultiFPS 在这个分层上利用得非常彻底。默认情况下 MultiFPS 使用的是 Mirror 自带的KcpTransport。KCP 是一套基于 UDP 的可靠传输协议它和 TCP 最大的区别是TCP 为了保证有序可靠一旦某个包丢了后面的包都得排队等重传KCP 则允许丢包后只重传缺失段其他数据继续往前送。FPS 里玩家的坐标每帧都在变迟到 0.1 秒的旧坐标毫无意义用 TCP 的“堵车排队”机制会放大延迟用 KCP 才合理。在代码层面MultiFPS 的项目里一般会有单独的NetworkManager子类覆盖OnServerConnect、OnServerDisconnect、OnClientConnect之类的回调。它的作用是维护一个玩家会话列表并在这个阶段完成玩家数据的初始化而不是直接处理字节流。这里有一个新手很容易忽略的点OnServerConnect触发时场景里其实还没有对应的玩家对象先要等连接通过验证再在OnServerAddPlayer里生成并生成玩家预制体。2.2 大厅、房间与玩家对象的生命周期MultiFPS 里玩家对象的生命周期管理得比较干净核心就三步服务器接受连接创建GamePlayer对象GamePlayer拥有NetworkIdentity注册进 Mirror 的生成系统玩家死亡后对象不直接销毁而是走Rpc禁用控制脚本服务器端延迟后重生再开启。大厅和房间的设计MultiFPS 默认是用场景来隔离的大厅场景和战斗场景是两个独立的 Unity Scene。开房间、加房间其实是通过NetworkManager的StartHost、StartClient加上自定义的匹配逻辑完成的。也就是说它没有用 Photon 那套独立的“房间服务器”而是靠 Mirror 的 Scene 切换 玩家对象自动迁移。这里有个关键代码套路服务器切换场景前要先把玩家对象标记成“跨场景保留”。Mirror 里这个动作由SceneManager.MoveGameObjectToScene或者直接对NetworkRoomPlayer这类组件做特殊处理。如果你在二次开发时发现“玩家一切场景就掉线”十有八九是这里没有处理。2.3 场景切换与重生机制的实现FPS 里重生Respawn不是简单地在原地复活它牵扯到网络标识的保留和视角控制权的转移。MultiFPS 使用的是 Mirror 的NetworkStartPosition机制服务器在场景里预先放置若干个复活点玩家死亡计时结束之后从一个随机复活点重新定位玩家的Transform并重置生命值。这里做得好的一点是MultiFPS 把“玩家的物理存在”和“玩家的视角控制”拆成了两层。在服务器端玩家对象始终存在死亡只是挂起它的CharacterController并禁用输入处理在客户端死亡时切换到一个“观战模式”摄像机底层网络连接不断。这样比“销毁对象再重新生成”的写法稳定得多因为NetworkConnection和玩家账号数据的关联不需要重新绑定。如果你从零搭最容易被搞晕的就是“同一个人死了之后谁来负责把他拉起来”。MultiFPS 给的答案是服务器的GameMode类统一管理生命周期客户端没有任何一帧可以自己决定“我现在要重生”——就算是伪装的客户端发来重生请求服务器也会先校验死亡时间和伤害来源。3. 玩家控制与射击判定服务器授权为什么是 FPS 的命根子3.1 客户端输入采集从键盘鼠标到网络消息MultiFPS 的客户端输入模块核心原则是“只发意图不发结果”。键盘 WASD 的分量、鼠标旋转的增量这些算出来之后打包成一个很小的输入消息发到服务器。典型的数据结构长这样public struct PlayerInput { public Vector2 moveInput; // 移动方向归一化之后的范围 public Vector2 lookDelta; // 鼠标增量 public bool jumpPressed; // 跳跃始于客户端终于服务器判断 public bool firePressed; // 开火意图 public uint inputSequence; // 输入序号用于服务器端重排序 }这个inputSequence很重要它让服务器端的输入处理逻辑可以把乱序的网络包重新排列避免“先收到的跳跃把后收到的移动顶掉”这种时序错乱。MultiFPS 在客户端侧不会直接把Rigidbody.velocity或transform.position发到网络上。所有这类信息都是服务器算完再同步回来的。这样做的核心原因只有一个信任边界。客户端被作弊者控制那是分分钟的事如果服务器盲信客户端发来的坐标和伤害外挂只要改内存就能无敌。3.2 服务器端角色控制CharacterController 的移动与碰撞既然判定权在服务器那移动到底怎么做MultiFPS 的主流做法是在服务器端为每个玩家保留一个CharacterController组件然后用Move方法每帧驱动。服务器固定逻辑帧率通常 30Hz 或 60Hz执行void HandleMovement(PlayerInput input, float deltaTime) { Vector3 motion transform.forward * input.moveInput.y transform.right * input.moveInput.x; motion Vector3.ClampMagnitude(motion, 1f) * walkSpeed; motion.y - gravity * deltaTime; controller.Move(motion * deltaTime); }关键点在于transform.forward方向也不是客户端直接告诉服务器的而是客户端传的lookDelta在服务器端经过角速度累积后计算出来的。这样一个外挂就算想把自己的坐标改到天上也改不动服务器的物理体位置。客户端本地看到的手感则来自于位置同步回来后的本地插值优化。也就是说客户端本地可能会有轻微的“延迟手感”但如果帧率稳定这种感觉会非常轻微。MultiFPS 实际参数里服务器同步频率一般可以设在 20 到 30 次/秒。如果低于 15Hz插值后的移动就会明显“橡皮筋”一样地拉扯。3.3 射击判定Raycast 应该发生在哪一端射击判定是 FPS 网络架构里最见功力的地方。MultiFPS 的做法很明确只在服务器端做射线检测。客户端扣扳机之后把攻击者 ID、携带的瞄准点或者直接上传射击方向和起点发给服务器服务器从玩家的枪口位置发射一条射线打到的第一个或按射线距离排序的几个目标才被判定为有效命中。这里最容易被误解的是“命中反馈”。客户端本地做到“开枪动画立即播放、枪口火光立即出现”但伤害数字、击杀提示这些“因果性信息”必须等服务器的ClientRpc回来再显示。如果不等就弹伤害数字你可能会看到“打中了三个人但服务器只认两个”的奇怪场面。MultiFPS 里对命中的判定有一个细节命中检测的起点不是摄像机的transform.position而是服务器端玩家对象上的武器挂点WeaponMuzzle。这可以防一种视角差作弊——你明明躲在墙后但摄像机却从墙边探出去开枪。服务器只看角色模型上的枪口位置墙后的枪是打不了人的。提示服务器射线检测时物理层也要做过滤。玩家的子弹只能打中带Damageable标签的对象不然服务器射线可能会打到空气、触发器或其他玩家自己的碰撞体上导致明明瞄准了却掉血在别人身上。4. 状态同步细节插值、旋转同步与本地预测的手感补全4.1 位置同步频率与插值缓冲FPS 网络同步第一道手感的坎就是“别人眼中的你”。MultiFPS 在同步其他玩家位置时用的不是直接把transform.position复制过来而是维护一个短小的位置缓冲区每帧从缓冲里取一个合适的历史位置来渲染。打个比方网线就好比一条堵车的路。你看到的其他玩家位置其实是 100 毫秒之前的位置。如果服务器当前发来了一个最新位置你直接把它当成“现在”的位置反而会产生跳跃感。正确做法是把网络包里的时间戳记录下来按时间排序然后在渲染时往前推一段插值时间让所有玩家的位置都落后真实时间一点点但彼此之间的相对位置是平滑连续的。MultiFPS 在 NetworkTransform 的基础上做了这么几件事位置和旋转强制走服务器广播收到新同步包后不立即替换而是与上一帧位置做Vector3.Lerp根据网络包间隔动态调整插值权重网络越差插值权重越小超过 500 毫秒没有新包的玩家直接标记为“疑似断线”进入冻结状态。如果你发现多人联机时其他角色走得“一卡一卡”的先检查服务器发送频率再去检查插值窗口是不是被改成了 0。很多人图省事把插值禁用了结果自然难看。4.2 旋转同步为什么头部旋转不能用线性插值位置可以插值但旋转这个问题比位置要麻烦得多。MultiFPS 里玩家的准心和枪口方向需要按帧更新否则你瞄准时枪口就会“黏”在别处。在实现上旋转同步一般分两个层次身体的yaw水平转角跟随移动方向这个是平滑的可以做插值枪械与摄像机的pitch俯仰角瞄准方向这个要和准星严格同步不能线性插值。MultiFPS 的做法是玩家的Transform.rotation同步用四元数并施加很小的平滑但武器挂点和镜头视角的旋转则直接设置不做插值。因为一旦插值你瞄准的动作就会变成“慢放”准星已经移动了枪口还在原地这对 FPS 来说是不可接受的。这里还有个优化经验旋转数据可以量化压缩比如把欧拉角的角度值从 float 压成 short节省一半带宽。代价是角度精度变成约 0.01 度这对普通 FPS 完全足够。4.3 动画与武器状态把表现层和逻辑层分开刚开始做网络同步时很多人会犯一个错把 Animator 的状态参数直接做成[SyncVar]结果一个isReloading变量同步得很慢换弹动作在别的玩家眼里就像“瞬间完成”。MultiFPS 的处理方法是把“状态逻辑”和“表现逻辑”分离服务器只同步一个枚举或布尔值isReloading、isAiming、currentWeaponIndex这些值变化时通过ClientRpc通知客户端客户端拿到状态后自己决定播哪一段动画用多层动画融合或状态机播放。这样做的原因很实际动画是表现层受帧率、机型、分辨率影响不能作为同步数据直接传。服务器最多告诉你“这个人现在在换弹”具体是左手先动还是右手先动完全由客户端本地表现层决定。另外武器系统里的弹药数据也必须是服务器持有的。客户端可以展示自己弹匣里的子弹数但如果减子弹的逻辑只在客户端执行外挂就可以无限子弹。MultiFPS 的准则是服务器是唯二能改变弹药计数的地方一个客户端操控自己的行动另一个比对伤害的结果。5. 二次开发实录基于 MultiFPS 改造成实战项目的踩坑与建议5.1 实际测试最常碰到的三个问题我在把 MultiFPS 接入自己项目的时候最先撞上的是三个问题这里挨个讲一下排查链路。问题一玩家在局域网内流畅一上公网就“瞬移”。排查链先看服务器发送频率是不是被固定成 60Hz 了。公网带宽和路由器缓冲会让 60 个包/秒变成“堵车”50% 的包排队客户端拿到手全是老数据。解决办法是控制发送频率到 20~30Hz增加插值缓冲时间再把移动速度上限稍微调低一点。服务器的逻辑帧率没必要和渲染帧率一样固定 30 就够。问题二开枪时服务器端检测到的射线方向是歪的。排查链这不是 MultiFPS 的 bug是你把射线起点设到了“摄像机”而不是“枪口”。摄像机在客户端本地如果你把客户端的摄像机世界坐标发到服务器作弊者完全可以造假。正确做法是服务器从玩家的枪口挂点位置发射射线而不是从客户端上传来的“瞄准点”。我还发现一个问题如果枪械预制体的muzzle挂点没设置在角色骨骼上服务器射线会把玩家的手部模型挡掉导致近距离打不出伤害。问题三死亡重生后武器 UI 的弹药显示不同步。排查链这说明弹药的权威数据没统一。检查服务器端WeaponManager是不是各自持有一份弹药数据。正确做法是服务器持有一份弹药数据客户端通过ClientRpc同步展示服务器扣弹药后如果剩余弹量归零要广播给所有客户端让枪声动画和实际弹量一致。否则观战视角看到的就是一个人开枪打到一半突然换弹但玩家屏幕上还有 10 发。5.2 扩展新武器和角色技能的网络设计建议MultiFPS 默认会带几套武器但真正做产品肯定要加自己的。加武器时重点不光在做模型和动画而是在网络侧新增三类逻辑射击器逻辑每把武器需要有独立的WeaponShooter负责把攻击行为转化为服务器端的射线/投射物逻辑。尽量把射线检测和伤害计算放到一个统一的CombatResolver里每把武器只是配置参数射速、伤害、弹速而不是复制代码。弹药管理每把武器的弹药数据都在服务器端用一个字典管理键是玩家 NetId、武器 ID值是当前弹药量。客户端只拿只读快照。表现层钩子换弹、开镜、切枪都要有对应的客户端通知通道。建议从底层就定义好一个WeaponEvents枚举所有武器表现改变统一走一条OnWeaponEvent的ClientRpc避免每个武器单独写一套同步代码。至于角色技能我的建议是尽量做成“配置驱动”。技能系统不要直接依赖具体的玩家控制器而是抽象成“技能请求 → 服务器裁决 → 结果广播”三段式。比如一个冲刺技能客户端发一个冲刺请求服务器检查冷却时间、是否在地面、是否在战斗中然后才允许位移并广播给所有人。MultiFPS 的网络骨架完全可以承载这套逻辑前提是你不要绕过它的[Server]标注直接改服务器状态。5.3 带宽与性能调优的个人实测参数我做过 8 人 FPS 的 20 分钟对局压测默认参数下每客户端上行大约在 12~20KB/s下行在 40~80KB/s。这个数字看起来不大但如果你用云服务器且玩家分布很广并发一多还是有压力。我实测下来有效的性能优化排列是降低坐标同步频率从 60Hz 降到 25Hz带宽砍掉一大半画面通过插值基本无感旋转数据压缩四元数转成 3 个 short省掉一半旋转带宽开销延迟向感兴趣区域AOI同步只把位置和状态发给视角范围内的玩家而不是全服广播。Mirror 没有内置完整的 AOI但可以通过重写NetworkBehaviour的可见性判断实现。MultiFPS 在小型对战里可以不用但如果做 30 人以上就必须上。武器音效和特效不要在广播数据里带事件 ID用固定状态机推断。一个fire事件通过ClientRpc发和每帧发特效位置开销差别是几个数量级。5.4 移动端与 WebGL 适配的注意点MultiFPS 如果是用来做 PC 端的 demo很轻松但如果你和我一样要把它往移动端或者 WebGL 上搬下面几点必须提前处理。移动端最大的问题是触摸摇杆的输入精度和发射频率。PC 鼠标可以做到每帧频繁移动触摸屏的滑动增量如果直接映射到lookDelta会非常飘。我的做法是在输入层加一个触摸灵敏度系数和死区同时在客户端对输入做简单的低通滤波避免手指微抖变成准星乱跳。WebGL 版比较麻烦的是传输层。Mirror 默认的 KcpTransport 在 WebGL 上跑不通因为浏览器只支持 WebSocket不支持裸 UDP。需要切换成WebSocketTransport。这个问题我在打包时卡了一整天才搞清楚最后症状是客户端连接一直转圈服务器端连新连接回调都不触发。换了 transport 之后项目场景才正常跑起来。注意WebGL 上 Mirror 的服务器必须另外部署一个支持 WebSocket 的进程浏览器只能当客户端。如果你用 Host 模式在 WebGL 里既当主机又当客户端是不行的。这就是为什么上线 WebGL 版本前就要把“专用服务器部署”提上日程不要等打包完再后悔。5.5 对新手最有帮助的几处源码阅读顺序如果你准备拿 MultiFPS 的源码做学习材料我建议不要按目录从头读到尾那样很容易迷。按我后来的阅读顺序走效率会高很多先读NetworkManager的派生类搞清楚连接、生成、场景切换的触发点接着读玩家预制体上挂的脚本分清哪些是[Server]、哪些是[Client]建立“服务器动作 vs 客户端表现”的对照表再读一个武器的完整开火链路从客户端输入到服务器射线到伤害回调到客户端特效这个链路吃透了整个网络架构就通了一半最后读NetworkTransform的插值实现理解坐标从发出到渲染中间经历的缓冲和插值过程。按这个顺序走一周之内就能改出属于自己的一套武器从头直接啃源码的话很容易在SyncVar和Rpc的汪洋里绕不出来。我自己在深入改到最近最大的体会就是 MultiFPS 的价值不在于“又多了一个插件”而是它把 FPS 网络开发里最枯燥的那一层——服务器权威、输入分发、状态同步、事件回调——拆得清清楚楚。你完全可以把它的角色控制器、武器系统和网络模型都拆掉重新写自己的只留下网络骨架这个骨架本身已经帮你避开了绝大多数新手要踩的坑。做多人射击核心从来不是谁的代码写得花哨而是谁能在一个不可信、有延迟的网络上把“公平”和“手感”这两桩看起来矛盾的事情同时立住。MultiFPS 的做法不是唯一的答案但绝对是一套值得先照着跑通、再往上改的可靠起点。
返回列表