Unity多人联机开发:基于Photon Fusion 2的确定性状态同步入门实践

发布时间:2026/7/13 18:57:43

Unity多人联机开发:基于Photon Fusion 2的确定性状态同步入门实践 1. 项目概述为什么选择 Unity 2022 LTS 与 Photon Fusion 2如果你正在 Unity 里琢磨多人联机大概率已经听过 Mirror、Netcode for GameObjects或者老牌的 Photon PUN。但当你需要一个能处理复杂状态同步、支持大规模玩家、并且对延迟和作弊有更好控制的方案时Photon Fusion 2 就进入了视野。它不是简单的 RPC 调用而是一个基于 Tick帧的确定性状态同步网络引擎这意味着游戏逻辑的每一“帧”都在服务器和客户端上以相同的方式计算和验证为竞技游戏或需要高一致性的体验打下了基础。我选择 Unity 2022 LTS长期支持版作为开发环境是因为 LTS 版本经过了更长时间的社区和官方测试稳定性远超那些功能花哨但 Bug 频出的 Tech Stream 版本。对于网络游戏这种对稳定性和性能要求极高的项目一个可靠的引擎基础至关重要能避免你在开发后期被引擎的莫名崩溃或性能回退搞得焦头烂额。而这个 Demo 的目标很明确不是构建一个完整的游戏而是搭建一个最精简、可运行的“脚手架”。通过这个脚手架你能亲手摸到 Fusion 2 的核心概念——Network Runner、Network Object、Networked Properties 和 Input——并理解它们是如何协同工作让一个立方体在多个客户端间同步移动的。代码虽小五脏俱全理解了它你就拿到了打开 Fusion 高级功能大门的钥匙。2. 环境准备与项目初始化2.1 Unity 2022 LTS 安装与设置首先确保你从 Unity Hub 安装的是Unity 2022.3.x LTS版本。我个人推荐 2022.3.20f1 或更高的小版本它们在稳定性和对较新包的支持上做得更好。创建新项目时模板选择3D Core即可我们不需要 URP 或 HDRP 的额外复杂度来干扰初期的网络学习。创建项目后第一件事是进入Edit - Project Settings - Player在Other Settings区域将Api Compatibility Level设置为.NET Standard 2.1。Fusion 的某些异步操作和网络库依赖于此版本使用旧的.NET Framework或新的.NET可能会在后续引入难以排查的编译错误。注意如果你之前用过 Unity 2021 或更早版本习惯使用UnityWebRequest或某些旧式 API在 .NET Standard 2.1 下基本是兼容的无需担心。这个设置主要是为了匹配 Fusion SDK 的依赖环境。2.2 Photon Fusion 2 SDK 导入与配置Photon Fusion 2 不再通过传统的 Unity Asset Store 直接导入一个.unitypackage文件。现在官方推荐使用Unity Package Manager (UPM)进行安装这能更好地管理依赖和更新。打开Window - Package Manager。点击左上角的号按钮选择“Add package from git URL...”。在弹出的输入框中粘贴 Fusion 2 的 Git URL。这里需要特别注意版本。对于 Unity 2022 LTS我强烈建议使用一个经过充分测试的稳定版本而不是最新的开发版。你可以使用类似以下的 URL 格式具体版本号请以 Photon 官方文档或 Dashboard 提供的为准https://github.com/photonengine/photon-unity-networking.git?path/Packages/Fusion#v2.0.0示例请替换v2.0.0为实际稳定版本号。点击Add。Unity 会开始从 Git 仓库克隆并解析包这可能需要几分钟时间取决于你的网络。安装完成后你会在 Package Manager 的列表里看到Photon Fusion。接下来是关键一步配置 App Id。访问 Photon Engine 官网 注册并登录。在 Dashboard 中创建一个新的“Fusion”类型应用。创建成功后复制系统为你生成的App Id。回到 Unity在菜单栏找到Fusion - Fusion Hub并打开。在 Fusion Hub 窗口的“Project”标签页下你会看到Photon App Settings资产。点击它或在 Project 窗口搜索此资产。在 Inspector 面板将复制的 App Id 粘贴到App Id Fusion字段中。App Id Realtime和App Id Voice暂时留空我们这个 Demo 用不到 Chat 和 Voice 功能。实操心得我建议在项目的Resources文件夹下创建一个名为Photon的子文件夹并将PhotonAppSettings资产移动进去。这样结构更清晰也符合 Unity 对 Resources 加载的惯例虽然 Fusion 不一定从这里加载。另外千万不要将包含真实 App Id 的PhotonAppSettings资产提交到公开的 Git 仓库务必将其添加到你的.gitignore文件中。团队协作时每个成员应在本地配置自己的测试 App Id或使用一个共享的、用于开发环境的 Id。3. Fusion 核心概念与项目结构设计在动手写代码前花十分钟理解这几个核心概念能让你后面的操作事半功倍而不是对着报错瞎猜。Network Runner: 这是 Fusion 的“大脑”或“总指挥”。它负责管理网络会话、处理 Tick 循环、序列化/反序列化数据、以及协调所有 Network Object。任何一个 Fusion 网络场景中都必须有一个且只有一个活跃的 Network Runner。你可以把它理解为一个高级别的NetworkManager。Network Object: 任何需要在网络上同步的 GameObject都必须挂载NetworkObject组件。它为 GameObject 提供了一个网络身份Network Id。仅有NetworkObject还不够它需要与具体的网络行为脚本如NetworkBehaviour配合。Network Behaviour: 这是你编写网络逻辑的地方。你的脚本需要继承自NetworkBehaviour而不是MonoBehaviour。在这个类里你可以定义Networked Properties网络同步变量和RPCs远程过程调用。Networked Properties: 使用[Networked]属性标记的变量。Fusion 会自动同步这些变量的状态到所有客户端。这是状态同步的核心。你可以同步基础类型int, float, Vector3、结构体甚至一些网络类型如NetworkString_。Input: Fusion 的输入系统是面向 Tick 的。客户端在每个 Tick 收集输入并将其发送到服务器或在主机模式下传递给 Host。服务器在固定的 Tick 上接收并处理这些输入进行计算然后将结果状态同步回所有客户端。这为客户端预测Client-Side Prediction和回滚Lag Compensation提供了基础。基于这些概念我们的 Demo 项目结构可以这样设计Assets/ ├── Scripts/ │ ├── Managers/ │ │ └── BasicGameManager.cs // 启动Network Runner处理游戏流程 │ ├── Player/ │ │ └── BasicPlayerController.cs // 玩家控制与网络同步逻辑 │ └── Utilities/ │ └── CameraFollow.cs // 本地相机跟随纯本地逻辑 ├── Prefabs/ │ └── Player.prefab // 玩家预制体挂载NetworkObject和BasicPlayerController ├── Scenes/ │ └── SampleScene.unity // 主场景 └── Resources/ └── Photon/ └── PhotonAppSettings.asset // Photon 配置这个结构清晰地将管理逻辑、实体逻辑和工具类分开便于后续扩展。BasicGameManager负责初始化网络环境BasicPlayerController处理玩家具体的移动和同步而CameraFollow是一个纯粹的客户端表现层脚本不与网络直接交互。4. 手把手实现从零搭建联机 Demo4.1 创建 Network Runner 与启动游戏会话首先我们创建BasicGameManager.cs。这个脚本不继承NetworkBehaviour因为它是一个普通的 MonoBehavior职责是启动网络会话。using UnityEngine; using Fusion; using System.Threading.Tasks; using UnityEngine.SceneManagement; public class BasicGameManager : MonoBehaviour { [SerializeField] private NetworkRunner _runnerPrefab; // 在Inspector中拖入一个预制体 private NetworkRunner _runner; async void Start() { // 确保场景中只有一个NetworkRunner实例 if (_runner null) { _runner Instantiate(_runnerPrefab); _runner.name Network Runner; DontDestroyOnLoad(_runner); // 跨场景不销毁 } // 配置启动参数 var startGameArgs new StartGameArgs() { GameMode GameMode.Shared, // 使用共享模式。Host模式是GameMode.Host SessionName FusionDemoRoom, // 房间名 Scene SceneManager.GetActiveScene().buildIndex, // 启动后加载的场景索引 SceneManager gameObject.AddComponentNetworkSceneManagerDefault() // 场景管理器 }; // 异步启动Network Runner var startResult await _runner.StartGame(startGameArgs); // 检查启动是否成功 if (startResult.Ok) { Debug.Log($Runner started. Player ID: {_runner.LocalPlayer}); } else { Debug.LogError($Failed to start Runner: {startResult.ShutdownReason}); } } }你需要先在场景中创建一个空的 GameObject命名为GameManager挂载此脚本。然后创建一个新的 GameObject命名为NetworkRunner Prefab为其添加NetworkRunner和NetworkSceneManagerDefault组件并将其拖成预制体。最后将这个预制体拖到BasicGameManager的_runnerPrefab字段上。注意事项GameMode.Shared是 Fusion 的一种独特模式它运行一个“云托管”的模拟器Simulator所有客户端直接连接到这个模拟器由它进行权威计算。这对于学习和测试非常方便因为你不需要自己架设 Dedicated Server。在真实项目中你可能会根据情况选择GameMode.Host一个客户端兼主机或GameMode.Server纯专用服务器。4.2 制作玩家预制体与网络控制器接下来是核心部分创建玩家及其网络控制器BasicPlayerController.cs。在场景中创建一个 Cube命名为PlayerPrefab。选中它在 Inspector 中点击Add Component搜索并添加NetworkObject。这是将该 GameObject 标记为网络实体的关键。创建一个新的 C# 脚本BasicPlayerController.cs将其挂载到 Cube 上。打开BasicPlayerController.cs开始编写using Fusion; using UnityEngine; public class BasicPlayerController : NetworkBehaviour { // [Networked] 属性标记的变量会被自动同步 [Networked] private NetworkButtons _previousButtons { get; set; } [Networked] public Vector3 NetworkedPosition { get; set; } [Networked] public Quaternion NetworkedRotation { get; set; } // 移动速度这是一个本地变量不需要同步 [SerializeField] private float _moveSpeed 5f; [SerializeField] private float _rotateSpeed 180f; // 用于存储本地玩家输入的结构 private struct NetworkInputData : INetworkInput { public Vector2 MoveDirection; public NetworkButtons Buttons; } public override void Spawned() { // 当这个Network Object在网络上被生成时调用 if (Object.HasInputAuthority) { // 只有本地控制的玩家才执行比如设置相机跟随 Debug.Log(Spawned local player); // 这里可以获取并设置相机跟随等 } else { // 这是其他玩家的对象 Debug.Log(Spawned remote player); } } public override void FixedUpdateNetwork() { // 这个函数在每个网络Tick固定更新被调用 // 这里是进行权威模拟的地方 // 1. 检查是否有输入权限并获取输入 if (GetInputNetworkInputData(out var input)) { // 2. 处理移动输入 Vector3 moveDirection new Vector3(input.MoveDirection.x, 0, input.MoveDirection.y); if (moveDirection.sqrMagnitude 0.01f) { moveDirection.Normalize(); // 根据输入计算新的位置和旋转 Quaternion targetRotation Quaternion.LookRotation(moveDirection); NetworkedRotation Quaternion.RotateTowards(NetworkedRotation, targetRotation, _rotateSpeed * Runner.DeltaTime); NetworkedPosition transform.forward * _moveSpeed * Runner.DeltaTime; } // 3. 处理按钮输入例如跳跃 var currentButtons input.Buttons; var pressed currentButtons.GetPressed(_previousButtons); if (pressed.IsSet(MyButtons.Jump)) // MyButtons 是一个自定义枚举见下文 { // 处理跳跃逻辑例如给一个向上的速度 // NetworkedVelocity Vector3.up * _jumpForce; } _previousButtons currentButtons; } // 4. 无论是否有输入对于非权威客户端或插值对象都应用最新的网络状态到Transform transform.position NetworkedPosition; transform.rotation NetworkedRotation; } // 在普通的Update中收集本地输入 public override void Render() { // Render在每次图形渲染帧调用用于表现层的平滑插值 // 因为FixedUpdateNetwork只在Tick更新而Render调用更频繁 // 我们可以在这里做视觉平滑处理但对于这个简单DemoFixedUpdateNetwork已经直接更新了Transform。 // 更高级的做法是在Render中根据Networked状态进行插值让移动更平滑。 // 本例为简化已在FixedUpdateNetwork中直接赋值。 } }你还需要定义一个按钮枚举例如在同一个文件或其他地方public enum MyButtons { Jump 0, Fire 1, // ... 可以定义更多 }这个脚本做了几件关键事[Networked]属性同步了位置和旋转。FixedUpdateNetwork是网络 Tick 更新的核心。GetInput尝试获取这个网络对象在本 Tick 的输入。只有具有输入权限的客户端或服务器才能成功获取并处理输入进而修改[Networked]变量。在FixedUpdateNetwork的最后我们将NetworkedPosition和NetworkedRotation应用回transform。这样所有客户端包括本地和远程的玩家对象都会根据权威计算出的最新状态更新其位置。4.3 实现玩家输入与状态同步上面的控制器已经包含了输入处理。但我们需要一个地方来收集本地输入并将其提供给 Fusion。通常我们会创建一个NetworkInput的派生结构如上面的NetworkInputData并在一个实现了INetworkInputSource的类中提供它。不过Fusion 为简化流程允许我们直接在NetworkBehaviour中通过GetInput读取输入前提是 Runner 以某种方式接收到了输入。更常见的做法是创建一个独立的脚本来收集输入。这里我们创建一个简单的LocalInputPoller.cs挂载到NetworkRunner预制体上using Fusion; using UnityEngine; public class LocalInputPoller : MonoBehaviour, INetworkInputSource { private NetworkInputData _localInputData; public void PollInput(NetworkRunner runner, NetworkInput inputContainer) { // 每Tick收集一次输入 _localInputData.MoveDirection new Vector2(Input.GetAxisRaw(Horizontal), Input.GetAxisRaw(Vertical)); // 收集按钮状态 NetworkButtons buttons new NetworkButtons(); if (Input.GetKey(KeyCode.Space)) buttons.Set(MyButtons.Jump, true); // 可以设置更多按钮... _localInputData.Buttons buttons; // 将输入数据塞入容器 inputContainer.Set(_localInputData); } }然后确保你的NetworkRunner预制体上挂载了这个LocalInputPoller脚本。Fusion 会自动发现并调用它。现在回到BasicPlayerController的FixedUpdateNetwork中GetInputNetworkInputData就能拿到由LocalInputPoller收集并传递过来的输入数据了。4.4 玩家生成与基础场景搭建我们需要告诉 Fusion当玩家加入游戏时生成哪个预制体作为玩家角色。这通过NetworkObject的Player Prefab属性来配置。将你制作好的PlayerPrefab带有NetworkObject和BasicPlayerController的 Cube拖入 Project 窗口创建一个预制体。选中你的NetworkRunner预制体。在 Inspector 中找到Network Runner组件其中有一个Player Prefab字段。将刚刚创建的玩家预制体拖拽赋值到这个字段。这样每当一个新玩家通过Runner.StartGame加入会话时Fusion 就会在默认位置或你指定的位置为这个玩家实例化一个PlayerPrefab的副本并且该玩家的客户端会获得对这个预制体实例的输入权限。对于场景我们只需要一个简单的地面。创建一个 Plane 或一个大 Cube调整位置和缩放作为地面。确保所有玩家的生成位置目前是默认的Vector3.zero在这个地面之上。你也可以创建一个空的 GameObject挂载NetworkStartPosition组件并将其拖入NetworkRunner组件的Start Positions列表来定义多个出生点。5. 运行测试与核心机制验证5.1 单机与多人测试方法一切就绪点击 Unity 编辑器播放按钮。如果一切配置正确你会看到GameManager启动实例化NetworkRunner。NetworkRunner以Shared模式启动连接到 Photon Cloud或本地模拟器。一个玩家立方体PlayerPrefab在场景中被生成。你可以用 WASD 或方向键控制这个立方体移动。单机测试到此成功。但这只是本地模拟。要测试真正的多人联机你需要构建项目。进入File - Build Settings将当前场景添加到构建列表。点击Build And Run选择一个输出目录构建一个可执行文件例如.exe。构建完成后先不要运行它。回到 Unity 编辑器再次点击播放按钮。现在你有了两个“客户端”一个编辑器窗口一个独立的构建程序。观察两个窗口。你应该能在编辑器窗口中看到构建程序中的玩家立方体反之亦然。尝试在其中一个窗口中移动观察另一个窗口中的对应立方体是否同步移动。实操心得在开发初期频繁构建测试非常耗时。Fusion 提供了Fusion Debug Runner窗口Fusion - Debug Runner。你可以在这里快速以不同模式Host, Client, Shared启动多个实例并在编辑器内进行多人模拟极大提升调试效率。务必学会使用这个工具。5.2 网络状态监控与调试技巧当出现不同步、延迟或无法连接时如何排查Fusion Stats GUI在NetworkRunner预制体上你可以添加Fusion Stats组件。运行游戏后屏幕上会显示一个性能面板包括 Ping、丢包率、输入缓冲、实体数量等关键信息。这是诊断网络健康状况的第一手工具。Network Object ID在BasicPlayerController的Spawned方法中打印Object.Id。这能帮你确认生成的对象是否是你认为的那个。输入权限检查在FixedUpdateNetwork中通过Object.HasInputAuthority和Object.HasStateAuthority来区分本地玩家对象和远程玩家对象。确保你的逻辑写在正确的分支里。Photon Dashboard登录 Photon Engine 官网的 Dashboard进入你的应用可以实时查看在线会话、玩家数量、流量等数据。如果连接失败这里通常会有错误日志。一个常见的调试技巧是给本地玩家和远程玩家设置不同的视觉外观便于区分。可以在Spawned方法中实现public override void Spawned() { if (Object.HasInputAuthority) { GetComponentMeshRenderer().material.color Color.green; // 本地玩家是绿色 } else { GetComponentMeshRenderer().material.color Color.red; // 远程玩家是红色 } }6. 常见问题排查与性能优化要点即使按照步骤操作你也可能会遇到一些坑。这里记录了几个我踩过并总结出来的典型问题。6.1 连接失败与初始化错误问题现象可能原因解决方案StartGame返回ShutdownReason: FailedToConnectToMasterServer1. App Id 配置错误或为空。2. 网络防火墙或代理阻止连接。3. Photon 服务区域Region设置不匹配。1. 双重检查PhotonAppSettings.asset中的App Id Fusion。2. 尝试关闭防火墙或更换网络环境。3. 在StartGameArgs中指定Region如Region asia。NetworkObject生成失败玩家看不到彼此1.Player Prefab未赋值或预制体上没有NetworkObject。2. 预制体未放在Resources文件夹或未正确配置路径。3. 生成位置冲突或被遮挡。1. 确认NetworkRunner上的Player Prefab字段已赋值且预制体根物体有NetworkObject。2. Fusion 默认通过资源路径加载确保预制体在Resources文件夹内或使用NetworkProjectConfig注册。3. 检查NetworkStartPosition或生成逻辑。控制无反应GetInput返回 false1. 输入收集脚本如LocalInputPoller未挂载或未实现INetworkInputSource。2. 该NetworkObject没有输入权限。3.GameMode不是Host或Shared客户端无输入权限。1. 确保有一个活动的组件实现了INetworkInputSource并挂载在NetworkRunner或其子物体上。2. 确认你控制的物体Object.HasInputAuthority为 true。3. 在Client模式下客户端默认没有输入权限需要服务器转发或使用Shared模式测试。6.2 网络延迟与同步抖动处理即使网络通畅你也可能看到其他玩家的移动有些“卡顿”或“跳跃”。这通常是网络延迟和插值设置导致的。理解插值InterpolationFusion 默认会对远程对象的状态进行插值。这意味着你看到的位置是过去某个时间点的状态而不是最新状态目的是为了平滑移动。如果网络延迟高插值延迟也会变大导致“滞后感”。调整插值参数在NetworkObject组件上你可以找到Interpolation相关的设置。尝试调整Interpolation Delay增加会让显示更平滑但更滞后减少则更及时但可能抖动。对于快节奏游戏可能需要精细调整。使用NetworkTransform组件对于简单的位移、旋转同步Fusion 提供了NetworkTransform组件。你可以移除手动同步NetworkedPosition的代码改为使用这个组件它能自动处理插值和压缩往往比自己手动同步更高效、平滑。只需挂载NetworkTransform并勾选需要同步的选项Position, Rotation。客户端预测Client-Side Prediction对于需要极高响应性的操作如角色移动可以让本地客户端立即响应输入预测然后等待服务器校正。这涉及更复杂的[Networked] OnChanged回调和状态回滚是进阶话题。但 Fusion 内置了对它的支持当你使用GetInput并在FixedUpdateNetwork中直接修改[Networked]变量时已经在使用一种基础的、服务器权威下的预测模型了。6.3 代码结构与资源管理建议随着项目扩大以下几点能让你后期少很多麻烦分离网络逻辑与表现逻辑BasicPlayerController里直接操作Transform是简化做法。更好的架构是网络脚本NetworkBehaviour只负责计算和更新[Networked]状态如NetworkedPosition。另一个纯客户端的脚本如PlayerView在Update()或Render()中读取这个状态并平滑地应用到实际的Transform、动画控制器或特效上。这实现了逻辑与渲染的分离。使用NetworkObject池频繁实例化和销毁NetworkObject会产生垃圾和网络开销。Fusion 支持对象池。你可以实现INetworkObjectPool接口并在NetworkRunner中指定从而复用对象。优化[Networked]变量只同步必要的数据。Vector3可以用NetworkVector3精度可调来减少带宽。布尔值可以合并成位掩码。对于变化不频繁的数据可以考虑使用RPC而不是每帧同步。兴趣管理Interest Management当场景中有成百上千个网络对象时没必要把所有对象的状态同步给所有玩家。Fusion 提供了强大的兴趣管理系统可以只同步玩家“感兴趣”如视野内的对象大幅降低带宽和CPU开销。这在制作大型多人在线游戏时是必备技能。这个 Demo 只是一个起点它让你跑通了从零到一的 Fusion 网络流程。真正掌握 Fusion还需要深入理解其状态同步机制、输入系统、快照插值以及针对具体游戏类型如 FPS、RTS、MMO的优化策略。多阅读官方文档研究示例项目并在实际项目中不断实践和踩坑才是快速成长的不二法门。

相关新闻