Unity PUN2多人联机射击:权威模型与同步优化实战

发布时间:2026/9/18 8:16:56
Unity PUN2多人联机射击:权威模型与同步优化实战 1. 为什么有人宁愿自己撸Socket最后却又回到PUN2多人联机射击游戏在Unity圈子里一直是个绕不开的坎。想做一个能跑起来的联机对射核心关键词无非是Unity、PUN2、插件、多人联机这几个。很多人第一反应是我用原生Socket或者Unity Netcode自己写多好凭什么用第三方插件我也是这么过来的。当年为了省那点CCU费用我硬是用TCP写过一个同步框架结果光是断线重连、房间广播、状态回滚这三件事就耗掉了两个月最后帧同步射击的手感依然像在泥里走路。后来换成PUN2Photon Unity Networking 2三天跑通了八人对射的雏形那种原来可以这么快的感觉很上头。这篇文章想聊的是一个真实的联机射击项目从零到可玩中间到底踩了哪些坑、做了哪些选型、写了哪些代码。它适合三类人刚接触Unity联机、想找个能直接抄的模板的萌新已经写过单机射击、想扩成网络对战的独立开发者以及被自研网络层折磨过、想评估PUN2值不值得换的老手。我不会只给你一堆API文档我会告诉你为什么这么写、参数为什么这么设、哪些地方看着对其实是个陷阱。PUN2的本质是什么一句话它把房间管理、玩家匹配、状态同步、RPC调用这四样东西打包成了一套基于Photon云或自建Photon Server的现成方案。你只需要挂PhotonView组件、实现IPunObservable接口、调用PhotonNetwork.Instantiate一堆底层的事它替你做完了。对于射击游戏这种位置高频变化、需要低延迟广播的场景它的价值不在于省代码而在于省掉了一整套自己维护的房间生命周期和同步调度的复杂度。提示PUN2和PUN1不是简单升级关系API改动很大网上大量PUN1的老教程直接照抄会报错认准PUN2的命名空间Photon.Pun和Photon.Realtime。先明确一件事PUN2不是银弹。它的同步模型是客户端权威为主、MasterClient兜底天生不是为竞技级反作弊射击设计的。如果你的目标是做CS级别的竞技游戏PUN2会让你在服务端权威和命中回溯上很难受这时候要考虑Photon Quantum或者自建FishNet/NGO搭配专用服务器。但如果你要做的是休闲对战、派对射击、联机合作打僵尸PUN2的性价比高到离谱。2. 联机射击的架构怎么定先想清楚谁说了算2.1 三种权威模型的真实取舍在写任何一行代码之前必须先把权威模型定下来。所谓权威就是玩家的位置和死亡由谁最终裁定。主流有三种模型位置谁算延迟表现反作弊能力适不适合PUN2客户端权威玩家自己自己最顺别人看你有延迟几乎没有非常适合主机权威MasterClient房主房主最占便宜中等适合服务器权威专用服务端所有人一致强PUN2做起来吃力客户端权威的意思是你操控的角色位置由你的本地计算然后广播给别人别人只是播放你的位置。好处是你自己操作零延迟坏处是别人看你总是滞后一个RTT往返时延而且改个内存就能瞬移飞天。射击游戏里大部分休闲作品用的都是这套因为手感第一作弊反正也拦不住。主机权威就是把房主当作轻量服务器所有伤害判定、死亡结算都由房主算其他玩家只负责发送输入。它在PUN2里对应的是PhotonNetwork.IsMasterClient。这套模型比纯客户端权威靠谱一些因为房主之外的玩家无法直接篡改结果但房主本人还是有绝对优势而且房主一退房就得迁移或者解散房间体验打折。服务器权威是竞技游戏的标准答案但对PUN2来说你得自己搭Photon Server插件写服务端逻辑工作量瞬间翻倍而且PUN2的同步模型本身并不鼓励你这么做。这就是为什么我说先想清楚你要做的是什么级别的游戏。2.2 射击游戏里谁说了算的落地判断我的经验是把游戏里所有的状态分成三类分别交给不同的权威方处理问题就清晰了角色位置和朝向客户端权威。每个玩家自己算通过PhotonView同步给其他人。理由是人眼对自己操作延迟极其敏感超过50ms就能感觉到粘滞。命中和伤害主机MasterClient权威。开火方做射线检测得到我打中了谁发给房主房主验证合理性距离、朝向、是否在冷却中后再广播伤害。游戏局面得分、回合、倒计时主机权威。用自定义属性Custom Properties挂在房间上任何玩家都能读只有房主能写。这套分权设计的好处是玩家自己操作的角色永远跟手命中和回合这种真正影响胜负的部分交给房主作弊空间被压缩到房主自己能作弊这一个最小范围。对一个联机射击小项目来说这是工程量和体验之间最好的平衡点。注意别在Update里直接改别人PhotonView的Transform。你只能改自己的别人的位置是被同步过来覆盖的你写了也会被下一帧的同步数据冲掉白忙活。3. 工程准备与PUN2接入从AppId到第一个房间3.1 插件导入与AppId配置PUN2在Asset Store里叫PUN 2 - FREE导入后先在Photon后台创建一个应用拿到AppId Realtime一串GUID。然后在Unity里打开Window - Photon Unity Networking - PUN Wizard把AppId填进去它会自动生成PhotonServerSettings资产。这一步很多人会卡在忘记把PUNServerSettings挂到场景或者用了PUN1的AppId前者是配置没落地后者是两套体系的AppId不通用。一个关键参数PhotonServerSettings里的Hosting区域国内项目建议选Asia或者CN视后台可用区而定不选的话默认可能连到欧美节点延迟直接翻几倍。这个坑我踩过当时玩家反馈开一枪要等半秒查了半天代码结果是服务器区域选错了。3.2 连接与进房的最小骨架PUN2的连接流程是固定三段式连接服务器、加入或创建房间、进入游戏场景。写法上继承MonoBehaviourPunCallbacks用回调驱动而不是协程轮询。using Photon.Pun; using Photon.Realtime; using UnityEngine; public class NetworkLauncher : MonoBehaviourPunCallbacks { void Start() { PhotonNetwork.AutomaticallySyncScene true; // 房主切场景时所有人跟随 PhotonNetwork.GameVersion 0.1.0; // 版本号不同的客户端互相看不到房间 PhotonNetwork.ConnectUsingSettings(); } public override void OnConnectedToMaster() { Debug.Log(已连接Master服务器); PhotonNetwork.JoinLobby(); } public override void OnJoinedLobby() { RoomOptions opt new RoomOptions { MaxPlayers 8, IsVisible true, PlayerTtl 30000, // 断线后玩家数据保留30秒用于重连 EmptyRoomTtl 10000 // 空房间保留10秒 }; PhotonNetwork.JoinOrCreateRoom(deathmatch_01, opt, TypedLobby.Default); } public override void OnJoinedRoom() { Debug.Log($进房成功当前人数 {PhotonNetwork.CurrentRoom.PlayerCount}); PhotonNetwork.LoadLevel(GameScene); } public override void OnDisconnected(DisconnectCause cause) { Debug.LogError($断线原因{cause}); } }这里有几个参数值得展开。AutomaticallySyncScene true是联机射击的必备开关否则房主切场景其他玩家不会跟着切会出现房主在打、别人还在大厅的诡异情况。PlayerTtl和EmptyRoomTtl是给断线重连和空房保留用的射击游戏节奏快、玩家容易掉线这两个值设小了重连会失败。GameVersion则是防版本混用的关键不同版本的客户端即使房间名一样也进不去同一个房间这在灰度测试时特别有用。进房后玩家自己用PhotonNetwork.Instantiate生成角色预制体并用PhotonNetwork.LocalPlayer区分谁是谁public void SpawnPlayer() { int spawnIndex PhotonNetwork.LocalPlayer.ActorNumber % 4; PhotonNetwork.Instantiate(PlayerRig, spawnPoints[spawnIndex].position, Quaternion.identity); // 用ActorNumber做初始位置分配简单省事避免所有人挤在同一出生点 }提示PhotonNetwork.Instantiate的资源名必须是放在Resources文件夹里的预制体否则运行时报prefab not found。这是新手最常犯的错误之一。4. 核心同步位置、旋转、动画到底怎么传4.1 PhotonView与所有权的基本规则PUN2同步的最小单元是PhotonView。每个需要在网络上被识别的物体都必须挂一个它有两层信息ViewID全局唯一和Owner谁拥有它。角色的PhotonView必须由生成它的玩家拥有也就是PhotonNetwork.Instantiate的那个客户端这样这个玩家对这个角色的位置同步拥有写入权。PhotonView有个Observed Components列表里面列出的组件才会被同步。这里有两个选择直接用现成的PhotonTransformView或者自己写一个实现IPunObservable的脚本。前者适合原型验证后者适合精细化控制。对于射击游戏我强烈建议自己写原因在下一节。4.2 IPunObservable手写同步与带宽控制PhotonTransformView的问题在于它会把位置、旋转、缩放一起打包发送而联机射击其实只需要位置和Y轴旋转缩放永远不变、X和Z旋转基本固定。这些冗余数据乘以20次每秒的发送频率再乘以房间人数带宽会迅速膨胀。自己实现IPunObservable可以只发必要字段using Photon.Pun; using UnityEngine; public class NetworkPlayerSync : MonoBehaviourPun, IPunObservable { [SerializeField] private Transform headBone; private Vector3 netPos; private float netYaw; private float netPitch; private float lerpSpeed 12f; void Update() { if (photonView.IsMine) return; // 非本地角色用插值把网络包之间的跳变抹平 transform.position Vector3.Lerp(transform.position, netPos, Time.deltaTime * lerpSpeed); Quaternion targetRot Quaternion.Euler(netPitch, netYaw, 0); transform.rotation Quaternion.Slerp(transform.rotation, targetRot, Time.deltaTime * lerpSpeed); } public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { if (stream.IsWriting) { // 我自己的角色把位置和朝向发出去 stream.SendNext(transform.position); stream.SendNext(transform.eulerAngles.y); stream.SendNext(headBone.localEulerAngles.x); // 头部俯仰角用于别人看到你看向哪里 } else { netPos (Vector3)stream.ReceiveNext(); netYaw (float)stream.ReceiveNext(); netPitch (float)stream.ReceiveNext(); } } }这里的核心思想是发送端只发数据、接收端做插值。如果接收端直接把收到的位置赋值给Transform物体就会以网络包到达的频率跳跃默认20次每秒也就是每50ms跳一次看起来像卡顿。用Vector3.Lerp在包与包之间做平滑视觉上就顺滑了。lerpSpeed这个系数是手感旋钮设太小会显得迟滞设太大又回到跳跃实测12左右在60帧下最舒服。注意插值只对别人做对自己千万别做。本地玩家如果也插值操作会变得粘滞这是很多新手第一次做联机时的通病。4.3 动画同步要不要也走网络包动画状态是个容易过度设计的地方。很多教程会教你用RPC同步跳、开枪这些一次性动作用状态同步处理跑、待机这些持续性动作。我的经验是持续性动画完全不用同步因为接收端已经拿到了位置和朝向直接用位移速度反推动画状态就行——位置在动就播跑没动就是待机比单独发一个状态位更省带宽也更不容易出错。一次性的动作才需要网络事件。比如开枪、换弹、跳跃、开镜这些都是瞬间发生、必须让所有人看到的用RPC广播[PunRPC] void RPC_PlayFireAnim(int viewId) { PhotonView v PhotonView.Find(viewId); v.GetComponentAnimator().SetTrigger(Fire); } public void Fire() { photonView.RPC(nameof(RPC_PlayFireAnim), RpcTarget.Others, photonView.ViewID); }用RpcTarget.Others而不是All是因为自己的动画本机已经在播放了没必要再被网络回包触发一次否则会出现开一枪动画抖两下的怪现象。5. 射击判定与伤害结算命中这件事最容易被做错5.1 射线检测由谁发起射击游戏的命中判定分两派客户端预测命中和服务端权威命中。前者是开火方本地做Physics.Raycast打中了谁就告诉房主结果后者是开火方把从哪打到哪发给房主由房主重新做一次射线检测。后者更安全但PUN2里会引入你要等一个RTT才能看到伤害的延迟感。折中方案是客户端预测 房主验证本地立刻播放命中特效爆炸、火花、击中音让玩家感觉打中了同时把射线起点终点发给房主做二次验证房主确认后才真正扣血。这样既保证了打击感又拦住了大部分作弊。public void TryFire(Vector3 origin, Vector3 dir) { if (Physics.Raycast(origin, dir, out RaycastHit hit, 100f, shootMask)) { PhotonView targetView hit.collider.GetComponentPhotonView(); if (targetView ! null !targetView.IsMine) { // 本地先放特效制造即时反馈 PlayLocalHitFx(hit.point, hit.normal); // 通知房主验证 photonView.RPC(nameof(RPC_RequestDamage), RpcTarget.MasterClient, targetView.ViewID, 25, origin, dir); } } } [PunRPC] void RPC_RequestDamage(int targetViewId, int dmg, Vector3 origin, Vector3 dir) { if (!PhotonNetwork.IsMasterClient) return; PhotonView tv PhotonView.Find(targetViewId); if (tv null) return; // 房主重新检测一次防止客户端谎报 if (Physics.Raycast(origin, dir, out RaycastHit hit, 100f, shootMask)) { if (hit.collider.GetComponentPhotonView() tv) { tv.RPC(nameof(RPC_ApplyDamage), RpcTarget.All, dmg); } } }5.2 伤害数值为什么用ПунRPC广播而不是属性有人会想把生命值做成PhotonPlayer.CustomProperties房主改一下所有人都能看到多好这在回合制的卡牌游戏里合适但在射击游戏里不合适。因为自定义属性的更新是最终一致的不保证时序容易出现我已经死了但还在开枪的错乱。伤害这种强时序事件用RPC的RpcTarget.All广播保证所有人按同一顺序收到才不会乱。再说一个实测细节伤害RPC一定要带上PhotonMessageInfo用info.Sender记录伤害来源否则做击杀播报时你不知道是谁打死的谁。PUN2会自动把发送者信息塞进info里用起来很省心。5.3 客户端预测的一个小技巧射速快的武器比如冲锋枪如果每颗子弹都走一次RPC一秒十发就是十个RPC带宽和延迟都很吃紧。我的做法是聚合把0.1秒内的多颗子弹合并成一个RPC发送房主那边按顺序处理。数值上每秒最多10个RPC降到最多10个其实没省多少但至少避免了同一帧内塞进多个网络包。真正省的地方在于玩家自己看到的是本地预测的特效RPC只负责伤害结算不负责表现。6. 延迟补偿与命中回溯高延迟下打中了却没掉血怎么破6.1 延迟补偿要解决的是什么你在自己屏幕上明明瞄准了对方的脑袋开火但对方已经跑到墙后了房主那边验证时射线打空了。这就是联机射击最经典的打中没伤害问题。根因是你看到的对方其实是过去的他位置经过了网络延迟加插值双重滞后。延迟补偿Lag Compensation的做法是房主在服务器端保存每个玩家过去约1秒的位置历史当你上报一次射击时附带我开火时的客户端时间戳房主把所有人回溯到那个时间点的位置再做射线检测。这样你看到什么就打中什么。6.2 用环形缓冲保存历史位置实现上每个角色维护一个历史位置队列public class LagCompensation : MonoBehaviourPun { struct Snapshot { public float time; public Vector3 pos; public Quaternion rot; } private readonly QueueSnapshot history new QueueSnapshot(); private const float maxHistory 1.5f; void FixedUpdate() { if (!PhotonNetwork.IsMasterClient) return; history.Enqueue(new Snapshot { time Time.time, pos transform.position, rot transform.rotation }); while (history.Count 0 Time.time - history.Peek().time maxHistory) history.Dequeue(); } public Vector3 GetPositionAt(float clientTime) { // 找到最接近的时间点简单起见取最近的一条 foreach (var s in history) if (s.time clientTime) return s.pos; return transform.position; } }房主在收到伤害请求时用请求里带的clientTime去找当时的位置把射线检测的碰撞盒临时搬到那个位置。要注意回滚、检测、恢复三步要在一帧内完成否则会影响其他玩家的射线。对休闲游戏来说也可以简化成只回滚被瞄准的那个目标实现成本更低效果也能提升一大截。提示PUN2没有内置延迟补偿这是它和Photon Quantum最大的差距。上面这套方案是我在几个项目里验证过的简化版够用但谈不上完美竞技向需求请直接上Quantum。7. 房间、匹配与玩家生命周期管理7.1 房间策略与匹配思路休闲射击最简单的匹配就是JoinOrCreateRoom谁来了就塞进已有的房满了再开新房。房间名用固定前缀加编号遍历一遍找没满的房间。PUN2的Lobby机制会自动帮你列出所有可见房间直接读PhotonNetwork.CountOfRooms和GetRoomList()即可。如果你要做实力匹配可以给玩家打一个MMR分把分数相近的人放进同一个房间——用TypedLobby做过滤或者干脆用Photon的Matchmaking。但说实话小项目里人数不足的时候严格匹配会导致永远匹配不到人反而不如先到先得。7.2 断线、离开与MasterClient迁移射击游戏玩家的进进出出非常频繁必须处理好三件事玩家离开OnPlayerLeftRoom里把他的角色销毁掉。注意用PhotonNetwork.Destroy同步销毁别用Destroy否则别人那边还留着一个鬼影。断线重连靠在RoomOptions里设的PlayerTtl。玩家掉线后他的角色先保留等他重连回来再由他接管。重连时检查ActorNumber是否一致不一致就得重新绑定。MasterClient迁移房主退房时PUN2会自动把房主身份交给编号最小的玩家OnMasterClientSwitched。所有只有房主能做的逻辑比如计分、刷怪都要在这个回调里重新接管不然会直接停摆。public override void OnMasterClientSwitched(Player newMaster) { if (newMaster.IsLocal) { Debug.Log(我成了新房主接管计分和刷怪); StartCoroutine(GameLoop()); } }这个回调是很多项目的隐形炸弹。测试时房主一直不退出所以没暴露上线后一堆房主中途关游戏整个房间就卡死不再刷新。所有跟房主强绑定的逻辑都必须在这个回调里处理接棒。8. 带宽优化与性能调优把流量按在地上摩擦8.1 SendRate与SerializationRate的取值实验PUN2里两个关键频率参数PhotonNetwork.SendRate每秒发多少网络包和PhotonNetwork.SerializationRate每秒序列化多少次。默认都是20或者30。这两个值直接决定带宽和同步精度。我做过一组实测八人对射每个玩家每秒同步一次位置约20字节SendRateSerializationRate单帧带宽平均延迟感手感评价1010最低明显滞涩卡但能玩2020低轻微推荐平衡点3030中顺滑适合小房间4040高极顺大房间容易崩结论是小房间4-8人用30/30手感明显提升超过10人要压回20/20。而且SendRate和SerializationRate最好保持一致不一致会导致打包了但没发出去的浪费。8.2 数据压缩的具体手段除了降频还能从数据本身下手量化位置把float的位置改成short除以精度系数再取整一个float省4字节变成short省2字节二十字节的包瞬间瘦身。只发变化量朝向不变时标记flag跳过跑动的角色才发朝向。按距离分级同步离你远的角色降频同步比如超过30米降到5Hz因为远处的小人在你屏幕上就是几个像素动没动你根本看不清。这几招叠加下来我做过一个项目从八人满房的峰值流量约1.6Mbps降到400Kbps左右几乎没牺牲手感。这是PUN2这类云同步方案里最值得花时间优化的部分。9. 常见问题与排查技巧实录9.1 掉线、不同步、动画抽搐的排查清单现象可能原因排查方向进不去同一个房间GameVersion不一致检查所有客户端的版本号字符串角色瞬移、抽搐接收端没做插值或SendRate过高加Lerp降SendRate到20-30只有房主动别人不动AutomaticallySyncScene未开或PhotonView未挂检查场景同步开关和组件挂载房主退房后游戏停摆没处理MasterClient迁移在OnMasterClientSwitched里接管逻辑打中没伤害射线层设置错或延迟补偿缺失检查LayerMask补上历史位置回溯重连后角色是鬼影没同步销毁旧角色用PhotonNetwork.Destroy而不是Destroy开一枪动画抖两下RPC用了RpcTarget.All改成RpcTarget.Others9.2 几个只有踩过才知道的坑第一个坑在Awake里访问PhotonNetwork.LocalPlayer。进房之前这个值是null直接访问会抛异常。所有依赖玩家身份的逻辑都必须放到OnJoinedRoom之后。第二个坑用GetComponentPhotonView()忘了加photonView判断。射线打中的可能是地面、掩体、装饰物这些没有PhotonView直接取会返回null然后报空引用。永远先判断再取或者用TryGetComponent。第三个坑在MasterClient里用协程做全局逻辑。房主迁移后旧协程还在跑新房主又开了一个会重复执行。传送门刷了两次、倒计时跳了两格这种事就是这么来的。要么用photonView.RPC驱动要么加锁标志位。第四个坑PhotonView的ViewID在房间重进后变化。如果你把ViewID存到本地做持久化引用退出再进同一个房间会失效。所有外部引用都应该通过PhotonView.Find重新获取别缓存ViewID。第五个坑测试时一直只用一台设备。很多人本地开两个窗口测网络是回环延迟几乎为零所有同步问题都被掩盖。上线后真实网络一测问题全冒出来。真机测试至少要有两台设备连同一个局域网或者用Photon内置的模拟延迟工具人为加200ms提前暴露问题。我个人在多个联机项目里摸爬滚打下来的体会是PUN2真正的难点从来不在API本身API就那么几个翻文档半天就能上手难的是网络模型怎么选、状态归属怎么分、延迟怎么藏。把第2章那套权威模型想透后面所有代码都是顺理成章的。做联机射击最忌讳的就是一上来就闷头写同步写了三天发现同步出来的画面全是鬼畜回头再改架构推倒重来。先画图定模型再动手写代码这是我唯一想反复强调的一点。后续如果你想让项目再往深走一步可以把伤害验证那块替换成服务器端Photon Server Plugin把房主权威升级成全服权威同时引入真正的时间戳回滚来替代上面那套简化版延迟补偿这样就能往竞技方向靠了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询