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. 手把手实现:从零搭建联机 Demo
4.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.AddComponent<NetworkSceneManagerDefault>() // 场景管理器 }; // 异步启动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 (GetInput<NetworkInputData>(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调用更频繁 // 我们可以在这里做视觉平滑处理,但对于这个简单Demo,FixedUpdateNetwork已经直接更新了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中,GetInput<NetworkInputData>就能拿到由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) { GetComponent<MeshRenderer>().material.color = Color.green; // 本地玩家是绿色 } else { GetComponent<MeshRenderer>().material.color = Color.red; // 远程玩家是红色 } }6. 常见问题排查与性能优化要点
即使按照步骤操作,你也可能会遇到一些坑。这里记录了几个我踩过并总结出来的典型问题。
6.1 连接失败与初始化错误
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
StartGame返回ShutdownReason: FailedToConnectToMasterServer | 1. 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返回 false | 1. 输入收集脚本(如LocalInputPoller)未挂载或未实现INetworkInputSource。2. 该 NetworkObject没有输入权限。3. GameMode不是Host或Shared,客户端无输入权限。 | 1. 确保有一个活动的组件实现了INetworkInputSource并挂载在NetworkRunner或其子物体上。2. 确认你控制的物体 Object.HasInputAuthority为 true。3. 在 Client模式下,客户端默认没有输入权限,需要服务器转发或使用Shared模式测试。 |
6.2 网络延迟与同步抖动处理
即使网络通畅,你也可能看到其他玩家的移动有些“卡顿”或“跳跃”。这通常是网络延迟和插值设置导致的。
- 理解插值(Interpolation):Fusion 默认会对远程对象的状态进行插值。这意味着你看到的位置是过去某个时间点的状态,而不是最新状态,目的是为了平滑移动。如果网络延迟高,插值延迟也会变大,导致“滞后感”。
- 调整插值参数:在
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)的优化策略。多阅读官方文档,研究示例项目,并在实际项目中不断实践和踩坑,才是快速成长的不二法门。