Unity 2023与Photon Fusion 2多人联机开发:从零构建状态同步Demo

Unity 2023与Photon Fusion 2多人联机开发:从零构建状态同步Demo 1. 项目概述为什么选择 Unity 2023 Photon Fusion 2如果你正在看这篇文章大概率和我当初一样被“多人联机”这个目标吸引却又在 Unity 琳琅满目的网络方案前犯了选择困难症。UNet 已老Mirror 虽好但需要自己处理很多底层逻辑而像 Fish-Net 这样的后起之秀生态还在成长。折腾了一圈我的目光最终落在了Photon Fusion 2上。它不是一个简单的 RPC 调用库而是一个完整的状态同步网络引擎官方称之为“确定性网络引擎”。简单来说它帮你处理了最头疼的网络延迟补偿、客户端预测和服务器权威验证让你能更专注于游戏逻辑本身。而选择Unity 2023 LTS作为开发环境则是一个求稳的决定。LTS 版本意味着长期支持Bug 更少社区解决方案更成熟。对于网络游戏这种稳定性要求极高的项目一个稳定的引擎基础至关重要。这个组合相当于给你一辆底盘扎实的赛车Unity 2023再配上一个经验丰富的领航员Photon Fusion 2目标是让你在搭建第一个多人联机 Demo 的赛道上少走弯路直达终点。这个“保姆级避坑指南”的目的就是把我从零开始踩过的坑、绕过的弯、最终成功跑通一个简单多人 Demo 的完整过程记录下来。我会假设你熟悉 Unity 的基本操作和 C# 编程但对网络同步概念可能一知半解。我们将一起搭建一个最简单的场景两个玩家方块在同一个场景里移动并能看到彼此的实时位置。别小看这个 Demo它涵盖了 Fusion 最核心的NetworkObject、NetworkTransform和基础输入处理。准备好了吗我们开始。2. 环境准备与 Photon Fusion 2 导入万事开头难而配置环境往往是第一个“坑”。这一步走顺了后面会轻松很多。2.1 Unity 2023 LTS 项目创建与基础设置首先去 Unity Hub 创建一个新项目。我强烈建议选择3D (URP)模板。为什么是 URP 而不是内置渲染管线因为 URP 是 Unity 现在主推的、更轻量且功能强大的渲染管线未来兼容性和性能优化都更好。项目名称可以随意比如FusionMultiplayerDemo。创建完成后有几项关键设置需要立刻调整这能避免后续一些诡异的兼容性问题进入Edit - Project Settings - Player。在Resolution and Presentation下取消勾选Run In Background。对于联机测试我们经常需要切换窗口勾选这个可能导致焦点切换时游戏逻辑暂停。在Other Settings部分确保Api Compatibility Level设置为.NET Framework而不是 .NET Standard 2.1。Fusion 的一些底层库对 .NET Framework 的支持更稳定。还是在Other Settings将Scripting Backend设置为Mono。IL2CPP 虽然性能好但在开发阶段Mono 的编译速度更快调试也更方便等项目成熟后再考虑切换。2.2 Photon Fusion 2 的获取与导入Photon Fusion 2 不是 Unity Asset Store 里的免费资产你需要去Photon Engine 官网注册账号并获取。具体流程是登录 Photon 仪表板创建一个新的 “Fusion” 类型的应用程序。创建成功后你会获得一个至关重要的App Id请妥善保存。接下来是导入 Fusion SDK。官方推荐通过Unity Package Manager (UPM)进行安装这是最干净的方式。在 Unity 编辑器中打开Window - Package Manager。点击左上角的号选择Add package from git URL...。输入 Fusion 的 Git URL你可以在 Photon 文档中找到最新的稳定版 URL通常形如https://github.com/photonengine/photon-unity-sdk.git#fusion-2.0。点击Add。 等待 Unity 下载并导入。这个过程可能会花费几分钟取决于你的网速。导入成功后你会在 Package Manager 中看到Photon Fusion包。注意有时通过 Git URL 导入可能会失败或遇到依赖问题。备选方案是直接从 Photon 官网下载.unitypackage文件然后通过Assets - Import Package - Custom Package进行导入。虽然会多几个步骤但通常更稳妥。2.3 初始场景与 Fusion Bootstrap 设置导入成功后你的项目里会出现Photon Fusion菜单。我们首先需要创建一个 Fusion 的运行器Runner和引导Bootstrap场景。在菜单栏点击Fusion - Create - Fusion Bootstrap。这会在你的场景中创建一个名为FusionBootstrap的 GameObject并自动生成一个NetworkDebugStart脚本。选中FusionBootstrap对象在 Inspector 面板找到Network Project Config字段。我们需要创建一个新的配置文件。点击字段右侧的圆圈图标在弹出的选择窗口中点击底部的Create按钮新建一个NetworkProjectConfig资源可以命名为FusionNetworkConfig。选中新建的FusionNetworkConfig在其 Inspector 面板中最关键的一步来了将之前在 Photon 官网获得的App Id填入Fusion - Photon App Id Fusion字段中。没有这个 ID你的客户端将无法连接到 Photon 的云服务器或你自己的私有服务器。现在保存当前场景命名为Bootstrap。这个场景将作为我们游戏的启动入口。后续我们所有的网络逻辑和玩家预制体都会通过这个引导场景加载。3. 核心概念解析与第一个网络对象在写代码之前我们必须理解 Fusion 的几个核心概念。这能让你明白每一步在做什么而不是机械地复制粘贴。3.1 NetworkRunner, NetworkObject 与 NetworkBehaviour这是 Fusion 的三驾马车必须搞清楚它们的关系。NetworkRunner这是 Fusion 网络系统的“大脑”或“发动机”。它负责管理网络连接、发送/接收数据、协调所有网络对象的状态。我们之前创建的FusionBootstrap就包含了一个NetworkRunner组件。通常一个游戏实例中只有一个活动的NetworkRunner。NetworkObject任何需要在网络上同步的 GameObject都必须挂载NetworkObject组件。它赋予了这个 GameObject 一个网络身份Network Id让NetworkRunner能够追踪和管理它。你可以把它想象成这个物体在网络世界的“身份证”。NetworkBehaviour这是你编写网络逻辑脚本时必须继承的基类类似于 Unity 的MonoBehaviour。只有继承自NetworkBehaviour的脚本才能访问网络状态、RPC 方法等 Fusion 特有功能。你的玩家控制脚本、怪物 AI 脚本等都需要继承它。它们的关系是NetworkRunner管理多个NetworkObject而每个NetworkObject下面可以挂载多个NetworkBehaviour脚本这些脚本包含了具体的、需要同步的游戏逻辑。3.2 创建玩家预制体与基础移动理解了概念我们来创建第一个会动的网络玩家。在场景中创建一个 Cube重命名为PlayerPrefab。选中这个 Cube点击 Inspector 面板底部的Add Component搜索并添加NetworkObject组件。现在它具备了成为网络实体的资格。我们还需要一个脚本来控制移动。创建一个新的 C# 脚本命名为BasicPlayerController。关键点来了这个脚本必须继承NetworkBehaviour而不是MonoBehaviour。using Fusion; using UnityEngine; public class BasicPlayerController : NetworkBehaviour { // 这是一个网络属性。当它的值改变时Fusion会自动同步给所有客户端。 [Networked] private NetworkButtons _previousButtons { get; set; } // 移动速度这是一个本地变量不需要同步。 public float moveSpeed 5.0f; public override void FixedUpdateNetwork() { // FixedUpdateNetwork 是 Fusion 的网络更新循环在这里处理输入和状态逻辑最合适。 // 首先检查我们是否拥有这个对象的输入授权Input Authority。 // 只有本地玩家控制的角色才能获取到输入。 if (GetInputNetworkInputData(out var input)) { // 处理移动输入 Vector3 moveDirection new Vector3(input.Direction.x, 0, input.Direction.y); moveDirection.Normalize(); // 使用 Fusion 的物理移动而不是直接 Transform能更好地与网络预测和补偿配合。 // Runner.DeltaTime 是 Fusion 管理的固定时间步长。 if (moveDirection ! Vector3.zero) { transform.position moveDirection * moveSpeed * Runner.DeltaTime; } } } }将BasicPlayerController脚本挂载到PlayerPrefab上。最后将PlayerPrefab从 Hierarchy 窗口拖到 Project 窗口的某个文件夹如Resources或Prefabs将其制作成一个预制体。制作完成后可以删除场景中的那个 Cube 实例。3.3 输入系统与 NetworkInputData你可能注意到了上面的代码尝试获取一个NetworkInputData。这是一个自定义的结构体用于封装每一帧的玩家输入。Fusion 会负责将这个结构体从客户端有输入授权的客户端发送到服务器。创建另一个 C# 脚本命名为NetworkInputData。注意这不是一个NetworkBehaviour而是一个简单的struct并且需要实现INetworkInput接口。using Fusion; using UnityEngine; // 这个结构体定义了我们要在网络间传递的输入数据 public struct NetworkInputData : INetworkInput { // 使用 Unity 的 Vector2 来表示移动方向水平、垂直 public Vector2 Direction; // 你可以在这里添加其他输入比如跳跃、攻击按钮状态 // 例如public NetworkButtons Buttons; }现在我们需要一个脚本来收集本地输入并设置给 Fusion。创建一个名为LocalInputPoller的脚本挂载到FusionBootstrap或任何在场景中持续存在的 GameObject 上。using Fusion; using UnityEngine; public class LocalInputPoller : MonoBehaviour { private NetworkRunner _runner; void Start() { _runner FindObjectOfTypeNetworkRunner(); if (_runner null) { Debug.LogError(NetworkRunner not found in scene!); } } // 在 Update 中轮询输入因为输入设备键盘、鼠标的采样是每帧进行的。 void Update() { if (_runner ! null _runner.IsRunning) { // 创建一个新的输入数据结构 var input new NetworkInputData(); // 从 Unity 的 Input 系统获取原始输入 input.Direction.x Input.GetAxisRaw(Horizontal); input.Direction.y Input.GetAxisRaw(Vertical); // 将输入设置给 NetworkRunner它会传递给对应的玩家对象 _runner.AddInputForPlayer(_runner.LocalPlayer, input); } } }至此我们已经搭建了最基础的数据流LocalInputPoller收集本地键盘输入 - 封装成NetworkInputData- 交给NetworkRunner-NetworkRunner在FixedUpdateNetwork中将输入传递给有输入授权的BasicPlayerController- 控制器根据输入移动玩家。4. 网络游戏逻辑与玩家生成有了能动的玩家预制体接下来我们需要让 Fusion 在游戏开始时为每个连接的客户端生成一个玩家实例。4.1 NetworkRunner 回调与游戏启动我们需要修改NetworkDebugStart脚本或者创建自己的游戏管理器来处理游戏启动逻辑。NetworkDebugStart是 Fusion Bootstrap 自带的简易启动器我们直接用它来演示。选中场景中的FusionBootstrap对象在 Inspector 中找到Network Debug Start脚本。这个脚本有一个Game Mode下拉菜单。对于我们的 Demo选择Shared模式。这是最常用的模式之一意味着所有客户端共同在一个“共享”的服务器逻辑上运行实际上可以指定一个客户端作为 Host兼具服务器和客户端功能。我们需要监听NetworkRunner的回调。创建一个新的脚本GameManager也挂载到FusionBootstrap上。using Fusion; using UnityEngine; public class GameManager : MonoBehaviour { [SerializeField] private NetworkRunner _runner; [SerializeField] private NetworkObject _playerPrefab; // 拖入我们之前创建的 PlayerPrefab private void OnEnable() { if (_runner null) _runner GetComponentNetworkRunner(); // 订阅 NetworkRunner 的重要事件 _runner.AddCallbacks(this); } private void OnDisable() { if (_runner ! null) _runner.RemoveCallbacks(this); } // 当本地玩家成功加入游戏会话时Fusion 会调用此方法 public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) { Debug.Log($Player {player.PlayerId} joined.); // 检查这个加入的玩家是不是本地客户端 if (player runner.LocalPlayer) { Debug.Log(Spawning local player.); // 在随机位置生成玩家预制体并将输入授权赋予这个玩家 Vector3 spawnPosition new Vector3(Random.Range(-3, 3), 0.5f, Random.Range(-3, 3)); runner.Spawn(_playerPrefab, spawnPosition, Quaternion.identity, player); } } public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) { Debug.Log($Player {player.PlayerId} left.); } }将GameManager脚本挂载到FusionBootstrap上并在 Inspector 中将_playerPrefab字段赋值为我们之前创建的PlayerPrefab带有NetworkObject和BasicPlayerController的预制体。4.2 使用 NetworkTransform 进行位置同步如果你现在运行游戏可能会发现一个问题你只能移动自己的方块但看不到其他玩家如果你打开了多个游戏实例。这是因为我们目前的移动只发生在本地transform.position的修改并没有自动同步到网络。我们需要同步 Transform。当然我们可以手动使用[Networked]属性来同步Vector3位置但 Fusion 提供了一个更强大、开箱即用的组件NetworkTransform。选中 Project 窗口中的PlayerPrefab预制体。在 Inspector 中点击Add Component搜索并添加NetworkTransform组件。NetworkTransform组件有几个重要属性Transform Synchronization: 选择你要同步的 Transform 属性位置、旋转、缩放。我们勾选Position即可。Interpolation Data Sources: 插值数据源。选择Snapshots可以获得最平滑的视觉表现Fusion 会自动在收到的网络状态快照之间进行插值让其他玩家的移动看起来更流畅即使有网络延迟。现在修改我们的BasicPlayerController脚本。我们不再直接修改transform.position而是修改一个由NetworkTransform控制的、网络同步的位置。但更常见的做法是我们使用CharacterController或Rigidbody进行移动让NetworkTransform去同步结果。为了简单我们换一种方式直接让NetworkTransform来同步位置而我们的控制器只负责计算移动向量。实际上NetworkTransform组件会自动同步它所挂载的 GameObject 的 Transform。我们只需要确保移动逻辑是在网络回调中执行的即可。我们之前的FixedUpdateNetwork已经满足条件。NetworkTransform会在网络更新后自动将权威的位置来自服务器或有状态同步权的客户端应用到物体的 Transform 上。所以保持BasicPlayerController的移动逻辑不变NetworkTransform会自动处理同步。这就是 Fusion 的便利之处你只需要关心“输入”和“逻辑”状态同步由引擎底层帮你搞定。4.3 构建与多实例测试理论完成实践开始。这是验证我们成果的关键一步。在 Unity 编辑器中打开File - Build Settings。将Bootstrap场景拖入Scenes In Build列表。选择目标平台如 Windows, Mac, Linux点击Build And Run。将构建出的可执行文件保存到一个地方比如命名为FusionDemo.exe。不要关闭 Unity 编辑器。我们将在编辑器中运行一个实例再用构建好的程序运行一个或多个实例来模拟多个客户端。在 Unity 编辑器中点击 Play 按钮。NetworkDebugStart脚本会自动启动一个Shared模式的会话。双击运行你刚刚构建的FusionDemo.exe。在启动的游戏窗口中点击Start或Join取决于NetworkDebugStart的 UI 设置加入同一个房间。如果一切顺利你应该能在 Unity 编辑器运行的实例中看到从可执行文件实例中生成的玩家方块一个 Cube并且双方可以互相看到对方的移动。恭喜你你的第一个 Fusion 多人联机 Demo 跑通了5. 深度避坑与性能优化指南能跑通只是第一步要做一个健壮的 Demo还有无数个坑等着你。下面是我在开发过程中总结的一些关键问题和解决方案。5.1 常见编译错误与版本兼容性问题错误The type or namespace name Fusion could not be found原因Fusion SDK 没有正确导入或程序集引用丢失。解决检查Packages/manifest.json文件确保有 Fusion 的 Git 引用或本地包引用。尝试关闭 Unity删除项目根目录下的Library和obj文件夹然后重新打开 Unity 让它重新导入和编译。如果使用.unitypackage导入请确保所有文件都勾选导入。错误关于INetworkStruct或序列化的错误原因你自定义的NetworkInputData或其他[Networked]结构体不符合 Fusion 的序列化要求。解决确保结构体中的字段都是 Fusion 支持的基本类型int,float,bool,Vector3,Quaternion等或其他INetworkStruct。不要使用string、数组除非是固定大小的[Networked, Capacity(N)]数组、List、Dictionary等复杂托管类型作为[Networked]字段。如果需要需要使用 Fusion 提供的NetworkString_或NetworkLinkedList等包装类型。结构体必须实现INetworkInput输入结构或INetworkStruct普通网络结构接口。Unity 2023 与 Fusion 2 的 Input System 冲突现象新的 Unity Input System 包可能与 Fusion 的输入处理产生干扰。解决在Project Settings - Player - Other Settings - Configuration中将Active Input Handling设置为Both。或者如果你只用旧 Input Manager就设为Input Manager (Old)。并在代码中统一使用UnityEngine.Input来获取输入就像我们LocalInputPoller做的那样。5.2 网络延迟与客户端预测的直观理解Fusion 的核心优势在于其内置的客户端预测和状态回滚State Reconciliation。这是什么意思没有预测的情况传统RPC你按下“前进”键客户端发送一个“前进”指令给服务器服务器收到后计算新位置再广播给所有客户端。你从按下键到看到自己移动会感受到至少一个来回的网络延迟Ping操作会显得“粘滞”。Fusion 的预测你按下“前进”键客户端立即在本地移动你的角色预测同时将输入发送给服务器。服务器在稍晚的时间点以权威逻辑运行相同的输入计算出“正确”的位置。如果客户端预测的位置与服务器计算的位置有差异Fusion 会自动将客户端的角色状态“回滚”到服务器确认的状态并重新模拟从那个点之后的所有输入。这个过程通常发生在几毫秒内玩家几乎感知不到结果是操作即时响应且最终状态由服务器权威决定公平公正。对于我们这个简单的移动 DemoNetworkTransform已经帮我们处理了这些。但当你需要做复杂的物理交互比如碰撞、射击判定时就必须深入理解[Networked]属性、FixedUpdateNetwork周期和GetInput的运作机制确保你的游戏逻辑是确定性的即在所有客户端和服务器上相同的输入序列产生完全相同的结果。5.3 资源管理与网络对象生命周期生成Spawn使用Runner.Spawn()。务必在OnPlayerJoined这类网络回调中或由其他网络事件触发不要在普通的Start()或Update()里直接调用。销毁Despawn使用Runner.Despawn()。这能确保网络对象在所有客户端上被正确清理。绝对不要用GameObject.Destroy()来销毁网络对象。预制体引用Runner.Spawn()需要传入一个NetworkObject类型的预制体引用。最佳实践是将这些预制体放在一个NetworkProjectConfig指定的资源文件夹如Resources中或者通过Addressables系统进行加载和管理。5.4 调试与监控技巧Fusion Stats GUI在 Play 模式下按Backquote键~通常在 ESC 下方可以呼出 Fusion 的内置统计面板。这里可以看到网络流量、RPC 调用次数、实体数量、模拟延迟等关键信息是性能调优的利器。Network Object ID在NetworkObject组件上你可以看到Network Id。在调试时这个 ID 可以帮助你区分不同的网络实体。区分本地与远程对象在NetworkBehaviour脚本中使用HasInputAuthority或HasStateAuthority来判断当前实例是否由本地玩家控制。这对于处理摄像机跟随、输入响应、特效播放等“只有本地玩家才需要”的逻辑至关重要。例如你只想让本地玩家的角色有摄像机跟随脚本。public override void Spawned() { // Spawned 是 NetworkBehaviour 的生命周期函数在对象生成后调用 if (HasInputAuthority) { // 只有本地玩家对象才执行比如挂载相机 Camera.main.transform.SetParent(transform); Camera.main.transform.localPosition new Vector3(0, 10, -10); } else { // 远程玩家对象可以禁用一些不必要的组件以节省性能 GetComponentInChildrenAudioListener().enabled false; } }6. 从 Demo 到原型下一步扩展思路当你成功运行起这个基础 Demo 后可以尝试添加更多功能来深入理解 Fusion。同步颜色给PlayerPrefab添加一个MeshRenderer。在BasicPlayerController中增加一个[Networked] Color NetworkedColor { get; set; }属性。在Spawned方法中根据HasStateAuthority谁生成了这个对象来随机设置一个颜色并将这个颜色赋值给NetworkedColor。然后重写Render方法这是一个在渲染帧调用的方法在这里根据NetworkedColor来更新MeshRenderer.material.color。你会看到所有玩家的颜色都能同步。简单的 RPC 调用实现一个“跳跃”动作。在NetworkInputData里增加一个NetworkButtons字段来捕获空格键。在FixedUpdateNetwork中检测按钮按下事件然后调用一个RPC方法RPC_Jump。RPC 方法需要用[Rpc]属性标记Fusion 会负责它的网络调用。在RPC_Jump里给角色一个向上的速度。基础房间管理利用NetworkRunner的Session相关 API创建一个简单的 UI允许玩家输入房间名、创建房间或加入现有房间而不是依赖NetworkDebugStart的默认行为。使用NetworkRigidbody将玩家的移动从直接修改Transform改为通过Rigidbody驱动。添加NetworkRigidbody组件来代替NetworkTransform它能够同步物理状态处理碰撞和力的同步更适合有物理交互的游戏。记住学习 Fusion 或任何网络引擎最关键的是理解其数据流和权威逻辑。多查看官方示例项目Fusion SDK 自带多个示例多阅读官方文档从简单的功能开始逐步构建你的多人游戏世界。这个 Demo 是你旅程的起点希望这份指南能帮你避开那些我曾經跌入的坑更顺畅地体验多人游戏开发的乐趣。