Unity多人游戏开发:Photon Fusion 2状态同步与Tick同步全流程解析 1. 项目概述为什么选择Photon Fusion 2做状态同步如果你正在用Unity做多人游戏并且对网络同步的延迟、预测和回滚这些概念感到头疼那Photon Fusion 2绝对值得你花时间研究。它不是那种简单的RPC远程过程调用框架而是一个完整的、基于确定性状态同步的网络引擎。简单来说它的核心思想是让所有客户端都运行相同的游戏逻辑只通过网络传输最精简的输入指令而不是每一帧都去同步每个物体的位置、旋转。这样即使网络有延迟游戏也能通过预测和回滚保持流畅和一致。我最初接触Fusion是因为一个快节奏的俯视角射击项目。用传统的权威服务器模式玩家移动总感觉“粘滞”射击判定也经常出问题。Fusion的“状态同步输入同步”模式从根本上解决了这个问题。它把Tick游戏逻辑更新的最小时间单位作为同步的基础所有客户端的逻辑都在相同的Tick上推进通过比对输入流来保证世界状态的一致。这意味着你的游戏手感可以做得和单机游戏一样跟手同时又能支持多人在线。这个项目标题“从房间管理到Tick同步全流程”其实就勾勒出了一个完整的Fusion项目骨架。房间管理是多人游戏的入口决定了玩家如何聚集在一起而Tick同步是游戏运行的心脏决定了游戏世界的公平与流畅。中间还涉及到网络对象、输入处理、状态复制、预测与插值等一系列核心概念。接下来我会以一个实际的竞技场对战游戏为例拆解这整个流程分享从零搭建到核心功能实现的全套经验和踩过的坑。2. 核心概念与项目初始化在动手写代码之前必须吃透Fusion的几个核心概念不然很容易在后期陷入混乱。2.1 Fusion的核心架构Runner, NetworkObject, NetworkBehaviourFusion的世界围绕几个核心类展开NetworkRunner: 这是Fusion的“大脑”和总控制器。一个游戏实例中只有一个NetworkRunner它负责管理网络连接、Tick计时、消息分发和所有网络对象的生命周期。你可以把它理解为一个加强版的、专为网络游戏设计的MonoBehaviour。NetworkObject: 所有需要在网络上同步的物体都必须挂载这个组件。它相当于给GameObject贴了一个网络身份证包含了唯一的网络IDNetworkId和权威性信息是本地预测的还是服务器权威的。NetworkBehaviour: 这是你编写网络逻辑脚本时要继承的基类类似于MonoBehaviour。它提供了丰富的网络属性和方法比如[Networked]属性、GetInputT()、FixedUpdateNetwork()等。初始化一个Fusion项目第一步就是创建并启动NetworkRunner。通常我会在一个启动场景中用一个专门的GameManager脚本来处理。using Fusion; using UnityEngine; using UnityEngine.SceneManagement; public class GameLauncher : MonoBehaviour { [SerializeField] private NetworkRunner _runnerPrefab; private NetworkRunner _runner; public async void StartHost() { // 创建NetworkRunner实例 _runner Instantiate(_runnerPrefab); _runner.name NetworkRunner; // 启动配置 var startGameArgs new StartGameArgs() { GameMode GameMode.Host, // 作为主机启动同时运行服务器和客户端逻辑 SessionName MyArenaSession, // 房间名 Scene SceneManager.GetActiveScene().buildIndex, // 启动后加载的场景 SceneManager gameObject.AddComponentNetworkSceneManagerDefault() // 场景管理器 }; // 异步启动并等待结果 var result await _runner.StartGame(startGameArgs); if (result.Ok) { Debug.Log(Host started successfully.); } else { Debug.LogError($Failed to start Host: {result.ShutdownReason}); Destroy(_runner.gameObject); } } }注意GameMode的选择至关重要。GameMode.Host表示当前玩家既是服务器拥有最高权威也是客户端。对于小规模测试和原型这是最方便的模式。但在正式部署时你可能会使用GameMode.Server纯服务器和GameMode.Client纯客户端分离的架构。2.2 房间管理与玩家加入流程房间Session是玩家聚集的逻辑空间。Fusion的房间管理相对直接主要通过StartGameArgs来配置。创建房间的关键参数SessionName: 房间的唯一标识符。玩家通过这个名字来寻找并加入特定房间。可以设计一个大厅UI让主机输入房间名。PlayerCount: 设置房间的最大玩家数量。达到上限后其他玩家将无法加入。CustomLobbyName: 如果你使用Fusion的Lobby功能可以指定一个自定义大厅方便玩家浏览公开房间。玩家加入流程对于客户端玩家启动参数GameMode需设置为GameMode.Client并指定相同的SessionName。public async void StartClient(string sessionName) { _runner Instantiate(_runnerPrefab); var startGameArgs new StartGameArgs() { GameMode GameMode.Client, SessionName sessionName, // 加入指定的房间 Scene SceneManager.GetActiveScene().buildIndex, SceneManager gameObject.AddComponentNetworkSceneManagerDefault() }; var result await _runner.StartGame(startGameArgs); // ... 处理结果 }在实际项目中我通常会做一个简单的房间列表UI。这需要利用Fusion的SessionList回调。你需要创建一个类实现INetworkRunnerCallbacks接口并在其中处理OnSessionListUpdated事件将获取到的房间信息更新到UI列表上。一个常见的坑网络启动是异步操作。UI按钮点击后一定要禁用按钮并显示加载状态直到StartGame的Task完成否则用户可能重复点击导致创建多个Runner引发不可预知的问题。3. 网络化玩家角色输入、状态与视觉表现房间建好了玩家也进来了接下来就是让玩家在游戏中拥有一个可以控制的网络化角色。这是Fusion实战中最核心的一环。3.1 创建网络化玩家预制体与输入处理首先创建一个玩家角色的预制体PlayerPrefab。这个预制体必须包含NetworkObject组件。然后我们创建一个脚本比如PlayerCharacter继承自NetworkBehaviour。处理玩家输入Fusion的输入系统是状态同步的基石。输入不是在每帧实时发送而是在每个固定的Tick收集然后通过网络同步给所有其他实例对于Host/Server或发送给服务器对于Client。定义输入结构创建一个可序列化的结构体来描述一帧内的所有输入。using Fusion; using UnityEngine; public struct PlayerInput : INetworkInput { public Vector2 MoveDirection; public NetworkBool IsJumpPressed; public NetworkBool IsFirePressed; public Vector3 AimDirection; // 对于3D射击游戏可能需要一个瞄准方向 }INetworkInput接口是必须的。NetworkBool是Fusion提供的布尔类型用于网络序列化。在PlayerCharacter中获取并应用输入重写FixedUpdateNetwork方法这是网络逻辑更新的地方。public class PlayerCharacter : NetworkBehaviour { [SerializeField] private float _moveSpeed 5f; [SerializeField] private float _jumpForce 7f; private CharacterController _controller; public override void Spawned() { // Spawned在对象被网络生成时调用类似于Start _controller GetComponentCharacterController(); if (Object.HasInputAuthority) // 判断这个网络对象是否由本地玩家控制 { // 可以在这里获取本地相机并设置为跟随此角色 Camera.main.GetComponentCameraFollow().SetTarget(transform); } } public override void FixedUpdateNetwork() { // 只有被控制的角色才处理输入 if (GetInputPlayerInput(out var input)) { // 移动 Vector3 move new Vector3(input.MoveDirection.x, 0, input.MoveDirection.y) * _moveSpeed * Runner.DeltaTime; _controller.Move(move); // 跳跃简化版忽略地面检测 if (input.IsJumpPressed _controller.isGrounded) { _controller.Move(Vector3.up * _jumpForce * Runner.DeltaTime); } // 这里可以处理射击等 } } }GetInputT方法会尝试获取当前Tick的输入数据。对于拥有输入权限的客户端它返回本地采集的输入对于其他客户端或服务器它返回从网络接收到的、经过缓存的该玩家的输入。如何采集本地输入你需要另一个脚本来收集本地输入并将其设置给NetworkRunner。通常我会创建一个LocalInputPoller脚本挂载在拥有NetworkRunner的场景中。public class LocalInputPoller : MonoBehaviour { private NetworkRunner _runner; void Start() { _runner FindObjectOfTypeNetworkRunner(); } void Update() { if (_runner ! null _runner.GetPlayerInputPrototypePlayerInput() is PlayerInput inputPrototype) { var input new PlayerInput(); // 采集键盘/手柄输入 input.MoveDirection new Vector2(Input.GetAxisRaw(Horizontal), Input.GetAxisRaw(Vertical)).normalized; input.IsJumpPressed Input.GetButton(Jump); input.IsFirePressed Input.GetMouseButton(0); // 采集鼠标位置计算瞄准方向3D示例需要主摄像机 if (Camera.main ! null) { Ray mouseRay Camera.main.ScreenPointToRay(Input.mousePosition); Plane groundPlane new Plane(Vector3.up, Vector3.zero); if (groundPlane.Raycast(mouseRay, out float distance)) { Vector3 worldPos mouseRay.GetPoint(distance); input.AimDirection (worldPos - transform.position).normalized; } } // 将输入提交给Runner _runner.SetInputForPlayer(_runner.LocalPlayer, input); } } }3.2 网络状态同步与[Networked]属性仅仅同步输入是不够的。有些状态是所有玩家都需要看到的并且可能由服务器权威验证比如玩家的生命值、分数、装备等。这时就需要使用[Networked]属性。[Networked]标记的变量其值的变化会被Fusion自动捕获并在网络上同步。它只能用在继承自NetworkBehaviour的类中。public class PlayerCharacter : NetworkBehaviour { [Networked] public int Health { get; set; } 100; [Networked] public NetworkString_32 PlayerName { get; set; } // 网络字符串最大32字节 [Networked] public TickTimer RespawnTimer { get; set; } // Fusion内置的基于Tick的计时器 [Networked(OnChanged nameof(OnWeaponChanged))] public WeaponType CurrentWeapon { get; set; } // OnChanged回调当CurrentWeapon值变化时在所有客户端上触发 private static void OnWeaponChanged(ChangedPlayerCharacter changed) { // 在这里更新武器模型、音效等视觉效果 changed.Behaviour.UpdateWeaponVisual(changed.Behaviour.CurrentWeapon); } }关于[Networked]属性的重要细节权限默认情况下只有StateAuthority状态权威方通常是服务器或Host可以修改[Networked]属性的值。客户端尝试修改会被忽略。这是保证状态一致性的关键。OnChanged回调这是一个极其有用的功能。它允许你在某个网络状态发生变化时立即执行一些逻辑比如播放音效、切换模型、更新UI。注意这个回调会在值变化后的下一个渲染帧在所有客户端上触发是处理视觉反馈的绝佳位置。支持的类型并非所有C#类型都支持。支持的基础类型有int,float,bool,Vector3,Quaternion等。Fusion也提供了NetworkString,NetworkBool,TickTimer等专用类型。对于自定义结构体需要实现INetworkStruct接口。3.3 预测、插值与视觉平滑处理这是Fusion的魔法所在也是保证游戏手感流畅的关键。预测 (Prediction)对于本地玩家控制的角色客户端不会傻傻地等待服务器确认后再移动。相反它会立即根据本地输入应用移动逻辑。这就是预测。如果之后从服务器收到的状态与本地预测的状态不一致Fusion会执行“回滚Rollback和重演Re-simulation”将游戏状态退回到产生分歧的Tick然后用正确的输入重新模拟到当前Tick。这个过程非常快玩家通常感知不到但能消除输入延迟。在我们的PlayerCharacter脚本中因为我们在FixedUpdateNetwork里直接使用了GetInput并移动角色实际上已经启用了预测。Fusion自动为我们管理了输入的缓冲和重演。插值 (Interpolation)对于非本地玩家控制的角色其他玩家我们接收到的网络状态是离散的是按Tick比如每秒30次更新的。如果直接把这些状态应用到渲染帧每秒60次或更高运动就会显得卡顿。插值就是为了解决这个问题。Fusion会自动对其他玩家的NetworkTransform组件如果你用了的话或[Networked]的Vector3/Quaternion进行插值在两个已知的网络状态之间计算出平滑的中间位置和旋转然后应用到Update循环中从而实现平滑的视觉运动。手动处理插值有时你可能需要更精细的控制。比如你想自己处理角色的旋转而不是用NetworkTransform。你可以在[Networked]属性上使用[Networked(OnChanged nameof(OnAimDirChanged))]来同步瞄准方向然后在Update中使用Vector3.RotateTowards进行平滑插值。[Networked] private Vector3 _networkAimDirection { get; set; } private Vector3 _smoothedAimDirection; private static void OnAimDirChanged(ChangedPlayerCharacter changed) { // 当网络方向更新时我们只是记录下新值不立即设置视觉旋转 } void Update() { // 在Update中平滑插值到最新的网络方向 if (Object.HasStateAuthority false) // 对于其他玩家的角色 { _smoothedAimDirection Vector3.RotateTowards(_smoothedAimDirection, _networkAimDirection, Time.deltaTime * 720f, 1f); transform.forward _smoothedAimDirection; } }一个重要的经验对于本地玩家视觉表现如模型、动画应严格跟随预测逻辑。对于其他玩家视觉表现应跟随经过插值后的网络状态。永远不要用插值后的位置去做碰撞检测或游戏逻辑判断那会导致不一致。逻辑判断永远基于最新的、未经插值的网络状态。4. 高级同步子弹、伤害与游戏状态有了可移动的玩家接下来就要让玩家之间能互动——射击、造成伤害、更新游戏状态如分数、比赛时间。4.1 生成与同步网络对象以子弹为例子弹是一个典型的需要动态生成的网络对象。你不能只在本地生成必须让服务器或Host权威地生成它以确保所有客户端看到的是同一颗子弹。public class PlayerCharacter : NetworkBehaviour { [SerializeField] private NetworkPrefabRef _bulletPrefab; [SerializeField] private Transform _firePoint; public override void FixedUpdateNetwork() { if (GetInputPlayerInput(out var input)) { // ... 移动逻辑 if (input.IsFirePressed) { // 只在有状态权威的一方服务器/Host执行生成逻辑 if (Object.HasStateAuthority) { FireBullet(input.AimDirection); } // 注意即使没有StateAuthority本地玩家也需要播放开火动画、音效等视觉效果。 // 这可以通过OnChanged回调或RPC来实现。 } } } private void FireBullet(Vector3 direction) { // Runner 是 NetworkBehaviour 的属性 var bulletObj Runner.Spawn(_bulletPrefab, _firePoint.position, Quaternion.LookRotation(direction), Object.InputAuthority); // 获取子弹脚本并初始化速度等参数 var bullet bulletObj.GetComponentBullet(); if (bullet ! null) { bullet.Initialize(direction.normalized * 50f); } } }关键点Runner.Spawn是生成网络对象的方法。它必须在有StateAuthority的上下文中调用通常是服务器或Host。Object.InputAuthority参数指定了这个子弹是由哪个玩家生成的这对于后续的伤害归属判定很有用。NetworkPrefabRef是一个对网络预制体的引用需要在Unity编辑器中将子弹预制体拖拽赋值。这个预制体同样必须包含NetworkObject组件。子弹本身的脚本也需要处理移动和碰撞。public class Bullet : NetworkBehaviour { [Networked] private Vector3 Velocity { get; set; } [Networked] private TickTimer LifeTimer { get; set; } public void Initialize(Vector3 velocity) { Velocity velocity; LifeTimer TickTimer.CreateFromSeconds(Runner, 2.0f); // 2秒后自毁 } public override void FixedUpdateNetwork() { // 移动 transform.position Velocity * Runner.DeltaTime; // 生命周期检查 if (LifeTimer.Expired(Runner)) { Runner.Despawn(Object); // 网络反生成 } // 服务器端进行碰撞检测 if (Object.HasStateAuthority) { CheckCollision(); } } private void CheckCollision() { // 使用简单的射线检测或物理查询 if (Runner.LagCompensation.Raycast(transform.position, Velocity.normalized, Velocity.magnitude * Runner.DeltaTime, Object.InputAuthority, out var hit)) { // 如果击中玩家 if (hit.Hitbox ! null) { var player hit.Hitbox.Root.GetComponentPlayerCharacter(); if (player ! null) { // 应用伤害 player.ApplyDamage(10, Object.InputAuthority); Runner.Despawn(Object); // 击中后销毁子弹 } } } } }4.2 伤害判定与延迟补偿在网络游戏中由于延迟客户端看到的其他玩家位置是过去的位置。如果服务器直接用客户端报告的开火位置和方向进行检测可能会打不中在客户端看来明明击中的目标。这就是“我明明打中了”的经典问题。Fusion通过延迟补偿Lag Compensation来解决这个问题。上面代码中的Runner.LagCompensation.Raycast就是延迟补偿射线检测。它的原理是当服务器处理一个“开火”事件时它会将游戏世界回滚到该玩家发出开火指令时的那个Tick的状态然后在这个“过去”的世界状态中进行碰撞检测。检测完成后世界再恢复到现在。这样检测就基于玩家开枪时所看到的画面公平性大大提升。使用延迟补偿的注意事项性能回滚和重演是有开销的。只对关键的、需要高公平性的检测如子弹命中、近战攻击使用延迟补偿。对于非关键或持续性的效果如范围持续伤害可以使用简单的服务器权威检测。命中盒Hitbox为了进行精确的延迟补偿碰撞检测你需要为玩家角色设置Hitbox。Hitbox是HitboxRoot和Hitbox组件的层级结构它定义了角色身上用于碰撞检测的体积。Fusion的延迟补偿系统专门与Hitbox协作。输入权威InputAuthority在延迟补偿检测中传入Object.InputAuthority参数非常重要这告诉系统在回滚时要排除掉开枪者自己的命中盒避免打中自己并且使用正确的输入缓冲进行回滚。4.3 全局游戏状态管理除了玩家和子弹游戏还需要管理全局状态比如当前游戏模式、剩余时间、团队分数等。这通常通过一个单例的、网络化的GameState管理器来实现。public class GameStateManager : NetworkBehaviour { public static GameStateManager Instance { get; private set; } [Networked] public GamePhase CurrentPhase { get; set; } GamePhase.Lobby; [Networked] public TickTimer RoundTimer { get; set; } [Networked, Capacity(4)] public NetworkArrayint TeamScores default; // 假设最多4个队 public override void Spawned() { if (Object.HasStateAuthority) { Instance this; // 服务器初始化游戏状态例如开始倒计时 StartNewRound(); } } private void StartNewRound() { CurrentPhase GamePhase.InProgress; RoundTimer TickTimer.CreateFromSeconds(Runner, 300f); // 5分钟一局 } public override void FixedUpdateNetwork() { if (Object.HasStateAuthority) { // 服务器检查回合是否结束 if (CurrentPhase GamePhase.InProgress RoundTimer.Expired(Runner)) { EndRound(); } } } private void EndRound() { CurrentPhase GamePhase.Ended; // ... 计算胜利者准备下一轮等 } // RPC让客户端请求或服务器广播状态变化 [Rpc(RpcSources.All, RpcTargets.StateAuthority)] public void RPC_PlayerReady() { // 处理玩家准备就绪的逻辑 } }这个GameStateManager应该作为一个网络对象在游戏开始时由Host/Server生成。它持有游戏的全局[Networked]状态。所有客户端都可以读取这些状态来更新自己的UI比如计时器、分数板。修改这些状态的权力比如增加分数、切换阶段必须通过RPC远程过程调用发送到StateAuthority服务器来执行或者由服务器直接修改。5. 调试、优化与部署实战开发完成并不意味着结束网络游戏的调试和优化往往更费精力。5.1 网络调试与可视化工具Fusion提供了一些内置工具来帮助调试Network Debug Runner在NetworkProjectConfig中启用后可以在游戏运行时看到一个调试GUI显示当前的Tick、延迟、数据包统计、网络对象数量等信息。这是监控网络健康状况的第一道工具。Hitbox Gizmos在Scene视图中可以绘制Hitbox的Gizmo方便你调整碰撞体积确保延迟补偿检测的准确性。自定义调试信息我习惯在玩家角色头顶绘制一个调试文本显示其PlayerRef、Ping、IsProxy是否是代理对象等信息一眼就能看出哪个对象是本地控制的哪个是远程同步的。void OnGUI() { if (Object ! null) { Vector3 screenPos Camera.main.WorldToScreenPoint(transform.position Vector3.up * 2f); GUI.Label(new Rect(screenPos.x - 50, Screen.height - screenPos.y, 100, 20), $Ping: {Runner.GetPlayerRtt(Object.InputAuthority):F0}ms\nAuth: {Object.HasStateAuthority}); } }5.2 性能优化关键点网络游戏对性能敏感尤其是Tick Rate每秒Tick数高的游戏。Tick Rate选择在NetworkProjectConfig中设置。30 Tick33ms对于大多数非格斗类游戏已经足够60 Tick16ms能提供更细腻的手感但带宽和CPU消耗翻倍。需要根据游戏类型和定位权衡。实测建议原型阶段用30 Tick后期根据测试反馈调整。网络状态精简[Networked]属性不是免费的。每个Tick每个网络对象上每个[Networked]属性的变化都会被检查并可能被同步。务必只同步必要的数据。避免同步整个变换组件Transform如果只需要位置就同步一个[Networked] Vector3 Position。对于变化不频繁的状态比如玩家等级可以考虑使用[Networked(OnChanged )]回调而不是每帧都去检查。使用Networked属性的Accuracy和Precision设置来降低浮点数的同步精度从而减少带宽例如[Networked(Accuracy 0.01f)] public float Health { get; set; }。网络预制体池Network Object Pooling频繁生成和销毁网络对象如子弹、特效会产生垃圾回收GC压力。Fusion允许你实现INetworkObjectPool接口来自定义网络对象的池化。这是提升性能的利器对于射击游戏尤其重要。你可以创建一个简单的池在AcquireInstance时从池中取出或实例化新对象在ReleaseInstance时将其禁用并放回池中而不是直接Destroy。插值与渲染分离对于大量同步的物体比如一群小兵如果每个都用NetworkTransform可能开销较大。可以考虑用一个管理器脚本批量接收它们的位置[Networked]数组然后在Update中统一进行插值和设置位置这比每个物体自己插值更高效。5.3 部署到Photon Cloud与测试当你准备进行线上测试时就需要将服务器部署到Photon Cloud或你自己的专用服务器上。配置AppId在Photon引擎官网创建应用获取AppId。将其填入Unity项目的Fusion-Photon App Ids设置窗口中。选择区域在NetworkProjectConfig的Photon Realtime设置中选择离你目标玩家最近的数据中心区域如Asia。构建与运行服务器构建Headless Server在Unity的Build Settings中选择“Dedicated Server”平台需要安装相关模块。构建出的程序没有图形界面纯后台运行。你可以在命令行启动它并指定启动参数如-room MyRoom -maxplayers 4。客户端构建正常构建PC、移动端或WebGL客户端。测试策略本地多实例测试在编辑器中使用Fusion的“Multi-Peer Mode”可以同时运行多个客户端和一个服务器进程非常适合快速迭代和调试。云测试将服务器程序上传到云服务器如AWS、Azure、阿里云ECS让远方的朋友用客户端连接测试真实延迟。这里要特别注意云服务器的防火墙需要开放UDP端口默认是5055否则客户端无法连接。压力测试尝试用机器人模拟客户端连接服务器观察在高负载下的服务器CPU、内存和网络带宽使用情况以及同步是否稳定。部署中最容易踩的坑端口和防火墙。确保你的服务器托管环境无论是本地网络路由器还是云服务器正确转发了Fusion使用的UDP端口。如果客户端始终无法连接第一步就是检查端口连通性。可以使用netstat命令查看服务器进程是否在监听指定端口。从房间创建到Tick同步从玩家移动到子弹伤害整个流程环环相扣。Fusion 2通过其高度集成的预测回滚和状态同步模型确实大大降低了制作高质量多人游戏的门槛但同时也要求开发者对其核心概念有清晰的理解。我的经验是多利用它的调试工具从小功能开始验证逐步构建复杂系统。当你能清晰地想象出每一帧数据如何在各客户端间流动、预测与回滚如何修正分歧时你就真正掌握了用Fusion开发多人游戏的钥匙。