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 (GetInput<NetworkInputData>(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 = FindObjectOfType<NetworkRunner>(); 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 = GetComponent<NetworkRunner>(); // 订阅 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(普通网络结构)接口。
- 确保结构体中的字段都是 Fusion 支持的基本类型(
- 原因:你自定义的
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 会自动将客户端的角色状态“回滚”到服务器确认的状态,并重新模拟从那个点之后的所有输入。这个过程通常发生在几毫秒内,玩家几乎感知不到,结果是操作即时响应,且最终状态由服务器权威决定,公平公正。
对于我们这个简单的移动 Demo,NetworkTransform已经帮我们处理了这些。但当你需要做复杂的物理交互(比如碰撞、射击判定)时,就必须深入理解[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 { // 远程玩家对象,可以禁用一些不必要的组件以节省性能 GetComponentInChildren<AudioListener>().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 是你旅程的起点,希望这份指南能帮你避开那些我曾經跌入的坑,更顺畅地体验多人游戏开发的乐趣。