1. 项目概述:为什么Fusion 2的共享模式是多人游戏开发的“深水区”?
如果你正在用Unity开发一款强调动作同步、低延迟体验的多人游戏,比如一款俯视角射击或者格斗游戏,那么Photon Fusion 2的Shared Mode(共享模式)大概率是你的首选。它带来的确定性物理和状态权威性,是构建公平、流畅对战体验的基石。然而,当你兴冲冲地搭建好网络框架,准备处理玩家输入和相机时,往往会发现这里遍布“暗礁”。输入延迟、相机抖动、不同客户端视角不一致……这些问题足以让一个功能正常的单人原型,在联机测试时变得一团糟。
这个项目标题直指两个核心痛点:输入处理与相机跟随。在Fusion的共享模式下,它们不再是简单的Input.GetAxis和Camera.main.transform.LookAt。你需要理解“输入权威(Input Authority)”、“状态权威(State Authority)”和“渲染插值(Render Interpolation)”这些概念,并将它们巧妙地编织在一起。我经历过多次深夜调试,才让角色的移动在三个客户端上看起来同步且响应迅速,也让跟随相机平滑得如同单机游戏。接下来,我将拆解整个实战过程,分享那些官方文档不会告诉你的“避坑”细节和实现心法。
2. 核心架构解析:理解Fusion共享模式下的数据流与权限
在动手写代码之前,我们必须彻底理解Fusion共享模式下的运行机制。这决定了我们后续每一个设计决策。
2.1 状态权威、输入权威与渲染代理
在Fusion的共享模式中,每个网络对象(NetworkObject)都有一个明确的“状态权威(State Authority)”。通常,这个权威属于创建该对象的客户端(比如,玩家角色的权威属于玩家自己)。状态权威端负责计算和驱动该对象的“网络状态(NetworkState)”,例如位置、旋转、血量等。这些状态会通过Fusion的确定性Tick系统同步给所有其他客户端。
你的客户端可能控制着你的玩家角色(拥有状态权威),但同时也会接收到其他玩家角色的状态(作为“代理”)。对于你控制的角色,你需要处理本地输入并应用它;对于其他玩家角色,你只是接收并渲染其状态。
输入权威(Input Authority)则特指有权为某个玩家对象提供输入数据的客户端。在典型的玩家角色场景中,输入权威和状态权威是同一个客户端。Fusion会将本地采集的输入,通过网络发送给状态权威端进行计算。
最关键的一点来了:渲染(Render)发生在一个比网络Tick(如每秒60次)高得多的频率下(如每秒渲染144帧)。为了在非权威客户端上实现平滑的视觉表现,Fusion引入了“渲染代理(Render Proxy)”的概念。你可以把它理解为你本地场景中,一个专门用于视觉表现的“影子”对象。网络状态在固定的Tick间更新,而渲染代理则根据收到的状态数据,在两次Tick之间进行插值,从而实现平滑移动。
注意:很多相机抖动问题,根源就在于错误地将相机绑定在了直接受网络状态驱动的对象上,而不是绑定在经过插值平滑处理的渲染代理上。
2.2 输入采集与网络传输的时序陷阱
Fusion通过INetworkRunnerCallbacks接口中的OnInput回调来采集输入。这里有一个至关重要的细节:OnInput是在固定更新(FixedUpdateNetwork)中被调用的。这意味着输入采集的频率与你的网络Tick率一致。
如果你像单机游戏那样,在Update中调用Input.GetKey,然后在FixedUpdate或FixedUpdateNetwork中应用,你会遇到“输入丢失”或“响应迟缓”的问题。因为Update的调用频率不稳定且远高于网络Tick,你在一帧内按下的按键,可能因为时机不对,没能被最近的一个OnInput回调收集到,从而延迟了一个完整的Tick(约16.6ms @ 60Hz)才被处理。对于快节奏游戏,这是不可接受的。
正确的做法是:建立一个本地的“输入缓冲区”或直接使用Fusion提供的NetworkInput结构。在Update中,我们将原始的Unity输入系统(如Input System包或旧的Input类)的状态进行采样,并存储起来。然后,在OnInput回调被触发时,我们将这个缓冲的输入状态填入NetworkInput中。这样确保了每个网络Tick都能获取到最新、最准确的输入快照。
// 示例:一个简单的输入缓冲与采集类 public struct MyNetworkInput : INetworkInput { public Vector2 MoveDirection; public bool IsJumpPressed; public bool IsFirePressed; } public class LocalInputPoller : MonoBehaviour { private MyNetworkInput _cachedInput; void Update() { // 在每帧渲染更新中采样输入 _cachedInput.MoveDirection = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); _cachedInput.IsJumpPressed = Input.GetButton("Jump"); _cachedInput.IsFirePressed = Input.GetMouseButton(0); // 注意:这里只是缓存,真正的发送发生在OnInput中 } public void OnInput(NetworkRunner runner, NetworkInput inputContainer) { // 将缓存的输入状态设置到NetworkInput中 inputContainer.Set(_cachedInput); // 可选:在一帧内多次FixedUpdateNetwork时,可以重置某些瞬时输入(如Jump) // _cachedInput.IsJumpPressed = false; // 小心处理,取决于你的输入消费逻辑 } }3. 实战:共享模式下的玩家输入处理
理解了架构,我们开始实现玩家角色的输入处理。我们的目标是:在状态权威端,使用收到的网络输入驱动角色逻辑;在非权威端(代理),也能根据预测或插值进行平滑的视觉反馈。
3.1 创建网络化的玩家角色与输入处理
首先,创建一个玩家角色的NetworkBehaviour脚本。我们需要定义它的网络状态,并处理输入。
public class NetworkPlayer : NetworkBehaviour { // 1. 定义网络状态 [Networked] public Vector3 NetworkPosition { get; set; } [Networked] public Quaternion NetworkRotation { get; set; } [Networked] public MyNetworkInput NetworkInput { get; set; } [Networked] private TickTimer _jumpCooldown { get; set; } // 2. 引用本地组件 private CharacterController _controller; private LocalInputPoller _inputPoller; public float MoveSpeed = 5f; public float JumpForce = 7f; private Vector3 _verticalVelocity = Vector3.zero; private const float GRAVITY = -9.81f; public override void Spawned() { // Spawned在所有客户端上都会调用 _controller = GetComponent<CharacterController>(); // 只有拥有输入权威的客户端,才需要本地输入采集器 if (Object.HasInputAuthority) { _inputPoller = Runner.GetComponent<LocalInputPoller>(); if (_inputPoller == null) { _inputPoller = Runner.gameObject.AddComponent<LocalInputPoller>(); } // 将本NetworkPlayer注册到输入采集器(如果采集器需要知道目标对象) } // 初始化逻辑... if (Object.HasStateAuthority) { // 状态权威端的初始化,比如设置出生点 } } public override void FixedUpdateNetwork() { // FixedUpdateNetwork 在每个网络Tick都会被调用,无论是否有状态权威 // 这是执行核心游戏逻辑的地方 // 3. 检查是否有输入数据 if (GetInput<MyNetworkInput>(out var input)) { // 只有当获取到输入时,才进行处理 // 对于状态权威端,这个input来自网络(其他客户端或自己) // 对于代理端,如果开启了输入预测,这里也可能有本地预测的输入 NetworkInput = input; // 可选:存储最后一次输入 // 4. 只有状态权威才执行真正的移动逻辑 if (Object.HasStateAuthority) { ProcessMovementInput(input); } } // 5. 所有客户端都可以执行一些视觉或预测相关的更新 // 例如,代理端可以根据NetworkPosition进行插值(但通常放在Render中) } private void ProcessMovementInput(MyNetworkInput input) { // 状态权威端的移动逻辑 Vector3 moveDirection = new Vector3(input.MoveDirection.x, 0, input.MoveDirection.y); moveDirection = transform.TransformDirection(moveDirection); // 考虑旋转 // 应用重力 if (_controller.isGrounded) { _verticalVelocity.y = -0.5f; // 一个小值保持贴地 if (input.IsJumpPressed && _jumpCooldown.ExpiredOrNotRunning(Runner)) { _verticalVelocity.y = Mathf.Sqrt(JumpForce * -2f * GRAVITY); _jumpCooldown = TickTimer.CreateFromSeconds(Runner, 0.5f); // 跳躍冷卻 } } else { _verticalVelocity.y += GRAVITY * Runner.DeltaTime; } Vector3 finalMovement = (moveDirection * MoveSpeed + _verticalVelocity) * Runner.DeltaTime; _controller.Move(finalMovement); // 将最终位置同步到网络状态 NetworkPosition = _controller.transform.position; // 注意:CharacterController的移动可能会被碰撞体阻挡,NetworkPosition反映的是实际位置 } }关键点解析:
GetInput(out var input):这个方法至关重要。对于状态权威端,它尝试从网络获取为本对象发送的输入。如果开启了预测(NetworkProjectConfig中设置),对于本地玩家对象,它还会融合本地预测的输入。对于纯代理端且无预测的对象,它通常返回false。Object.HasStateAuthority:这是执行游戏逻辑(如移动计算、伤害判定)的守卫。只有状态权威端能修改核心的网络状态(如NetworkPosition)。代理端绝对不能直接修改这些状态。- 输入预测:为了让本地玩家操作有即时反馈,即使在没有收到服务器确认前,也可以根据本地输入先模拟移动。这需要在Fusion项目配置中启用预测,并且代码能处理预测与权威状态的调和(Reconciliation)。这是一个高级话题,初期可以关闭预测以简化问题。
3.2 处理输入延迟与缓冲技巧
即使按照上述方法,在高速移动或需要精确帧同步的动作(如格斗游戏的出拳)中,你仍可能感觉到细微的延迟。一个进阶技巧是输入缓冲(Input Buffering)。
例如,在跳跃判定中,如果玩家在落地前几帧按下了跳跃键,单机游戏通常会有一个小的缓冲窗口让角色在落地瞬间自动起跳。在网络游戏中,由于Tick的离散性,这个时机更难把握。
我们可以在MyNetworkInput结构中增加一个byte BufferedAction字段,或者利用NetworkInput本身来存储一个时间序列。更实用的方法是,在状态权威端的逻辑中,不仅检查当前Tick的输入,也回顾之前一到两个Tick的输入(如果存储了的话),来实现网络化的输入缓冲。Fusion的GetInput允许你通过参数获取之前Tick的输入,这为实现缓冲提供了可能。
// 在FixedUpdateNetwork中 if (GetInput<MyNetworkInput>(out var currentInput)) { // 尝试获取上一Tick的输入用于缓冲判定 MyNetworkInput previousInput = default; if (Runner.TryGetInputForTick(Runner.Tick - 1, out previousInput)) { // 例如:如果当前帧刚落地,但上一帧输入了跳跃,则允许跳跃 if (_controller.isGrounded && previousInput.IsJumpPressed) { // 执行跳跃逻辑 } } // ... 处理当前输入 }4. 实战:平滑且正确的相机跟随实现
相机跟随是另一个重灾区。在多人游戏中,相机不仅要跟随本地玩家,还要处理玩家角色是“权威对象”还是“代理对象”这一根本区别。
4.1 绑定正确的目标:渲染代理(Render Proxy)
这是最重要的一条原则:相机永远不应该直接绑定到带有NetworkTransform或直接更新NetworkPosition的游戏对象上。因为这个对象的位置只在每个网络Tick更新,直接绑定会导致相机在Tick之间“卡顿”,而在Tick更新时“跳跃”。
Fusion为每个NetworkObject自动管理了一个渲染代理(Object.RenderProxy)。对于状态权威端,渲染代理和真正的网络对象可能是同一个GameObject(取决于配置)。但对于代理端,它们是不同的。渲染代理的位置和旋转是经过插值平滑处理的。
因此,相机跟随脚本应该跟随Object.RenderProxy的变换(Transform)。
public class SmoothNetworkCamera : MonoBehaviour { [SerializeField] private Vector3 _offset = new Vector3(0, 10, -10); [SerializeField] private float _smoothTime = 0.1f; private NetworkObject _targetNetworkObject; private Transform _targetRenderProxy; private Vector3 _currentVelocity = Vector3.zero; public void SetTarget(NetworkObject target) { _targetNetworkObject = target; if (_targetNetworkObject != null) { // 关键:获取渲染代理的Transform _targetRenderProxy = _targetNetworkObject.Runner.GetPhysicsScene().GetRenderProxy(_targetNetworkObject.Id).Transform; // 注意:上述API可能随版本变化,更通用的方法是直接访问Object.RenderProxy // _targetRenderProxy = _targetNetworkObject.GetComponent<NetworkTransform>().InterpolationTarget?.transform; // 或者,如果你的NetworkPlayer脚本自己管理了一个用于渲染的视觉子对象,则跟随它。 } } void LateUpdate() { if (_targetRenderProxy == null) return; Vector3 desiredPosition = _targetRenderProxy.position + _offset; // 使用SmoothDamp实现平滑跟随,抵消网络Tick更新带来的跳跃感 transform.position = Vector3.SmoothDamp(transform.position, desiredPosition, ref _currentVelocity, _smoothTime); transform.LookAt(_targetRenderProxy.position); } }4.2 处理多相机与分屏场景
对于本地分屏游戏,每个本地玩家都需要一个独立的相机。你需要为每个拥有输入权威的玩家实例动态创建并配置一个相机。
- 相机管理:创建一个
CameraManager单例或一个由NetworkRunner管理的脚本,在Spawned事件中,当检测到一个本地玩家被生成时(Object.HasInputAuthority),为其创建专属的相机(或启用/配置一个已存在的相机视口)。 - 视口矩形:根据玩家索引计算
camera.rect。例如,两个玩家分屏,玩家1的视口可能是new Rect(0, 0, 0.5f, 1),玩家2是new Rect(0.5f, 0, 0.5f, 1)。 - 音频监听器:确保场景中只有一个激活的
AudioListener,否则会出现音频问题。通常的做法是,只为其中一个相机(如主玩家相机)启用AudioListener,或者使用Unity的音频混合器(Audio Mixer)和音频监听器组(Listener Proximity)来处理。
public class PlayerCameraController : NetworkBehaviour { public Camera PlayerCameraPrefab; // 一个预设,包含Camera和SmoothNetworkCamera组件 public override void Spawned() { // 只有这个玩家对象被本地客户端控制时,才设置相机 if (!Object.HasInputAuthority) return; // 查找或创建相机管理器 var cameraManager = Runner.GetComponentInChildren<PlayerCameraManager>(); if (cameraManager != null) { cameraManager.AssignCameraToPlayer(this); } } } public class PlayerCameraManager : MonoBehaviour { private List<Camera> _playerCameras = new List<Camera>(); public void AssignCameraToPlayer(NetworkPlayer player) { int playerIndex = GetLocalPlayerIndex(player); // 需要自己实现一个获取本地玩家索引的方法 Camera playerCam = Instantiate(player.PlayerCameraPrefab, transform); SmoothNetworkCamera followCam = playerCam.GetComponent<SmoothNetworkCamera>(); followCam.SetTarget(player.Object); // 配置视口 playerCam.rect = CalculateViewportRect(playerIndex, Runner.ActivePlayers.Count()); // 如果是第一个玩家,保留AudioListener,否则禁用 if (playerIndex > 0) { AudioListener audioListener = playerCam.GetComponent<AudioListener>(); if (audioListener != null) audioListener.enabled = false; } _playerCameras.Add(playerCam); } private Rect CalculateViewportRect(int index, int total) { // 简单的水平分屏计算 if (total <= 0) return new Rect(0,0,1,1); float width = 1.0f / total; return new Rect(index * width, 0, width, 1); } }5. 常见问题排查与性能优化
即使按照最佳实践实现,在复杂项目中仍会遇到各种诡异问题。下面是一些常见陷阱及其解决方案。
5.1 相机剧烈抖动或“抽搐”
- 症状:相机不是平滑移动,而是频繁地微小跳动或突然大幅偏移。
- 排查:
- 确认跟随目标:首先在
LateUpdate中打印_targetRenderProxy.position和玩家NetworkObject.transform.position。在代理客户端上,它们应该不同,且_targetRenderProxy.position的变化应该是平滑的。如果两者相同,说明你错误地跟随了网络对象本身。 - 检查插值:确保
NetworkTransform组件(如果你用了的话)的Interpolation模式不是None。对于需要平滑移动的对象,应设置为Interpolate(代理端插值)或LocalInterpolate(所有客户端插值)。 - 平滑时间冲突:
SmoothDamp的_smoothTime值可能太小,无法过滤网络更新带来的跳跃。尝试增大这个值(如从0.05f增加到0.15f)。同时,确保LateUpdate的执行顺序在Fusion的所有网络更新之后。 - Time.deltaTime vs Runner.DeltaTime:在
LateUpdate中做平滑移动时,使用Time.deltaTime。在FixedUpdateNetwork中执行物理或游戏逻辑时,使用Runner.DeltaTime。混用会导致速度不一致。
- 确认跟随目标:首先在
5.2 输入响应迟钝或感觉“粘滞”
- 症状:按下按键后,角色反应慢半拍,移动不跟手。
- 排查:
- 输入采集时机:确保没有在
FixedUpdateNetwork中直接调用Input.GetKey。必须使用在Update中采样,在OnInput中发送的模式。 - 网络Tick率:检查
NetworkProjectConfig中的Simulation部分的TickRate。默认可能是30,对于动作游戏建议提高到60甚至更高。更高的Tick率意味着更低的固有延迟,但会消耗更多带宽和CPU。 - 预测是否开启:对于本地玩家角色,务必在项目配置中启用输入预测(
NetworkProjectConfig -> Physics -> Predict)。这能让你在等待服务器确认的同时,立即看到本地输入的效果。 - 网络条件:使用Fusion的
SimulationStats窗口或代码查看当前的RTT(往返时间)和丢包率。高延迟和丢包会自然导致输入反馈延迟。需要考虑在游戏设计中加入客户端预测和服务器调和。
- 输入采集时机:确保没有在
5.3 不同客户端看到的玩家位置不一致
- 症状:玩家A看到自己击中了玩家B,但玩家B屏幕上显示自己躲开了。
- 排查:
- 确定性验证:这是共享模式的核心。确保所有影响游戏逻辑的运算在所有客户端上绝对一致。
- 浮点数确定性:避免直接使用Unity的
Mathf或Vector3的某些非确定性函数(如涉及Time.time或Random.value)。对于物理,使用Fusion的NetworkRigidbody并确保所有客户端的物理步骤一致。 - 输入处理顺序:确保所有客户端以完全相同的方式处理和解释输入数据。例如,对摇杆输入的死区处理、归一化处理必须在所有客户端上一致。
- 浮点数确定性:避免直接使用Unity的
- 状态权威:确认伤害判定等关键逻辑只在状态权威端执行。例如,子弹的命中检测,如果子弹由射击者客户端生成,那么检测逻辑必须运行在子弹对象或命中目标的状态权威端(通常是服务器或主机),并将结果同步。
- 渲染与逻辑分离:视觉表现(如击中特效、受击动画)应该由状态变化触发,而不是由检测逻辑直接播放。当状态权威端判定击中并同步了“血量减少”这个状态后,所有客户端再根据这个状态播放受击效果。
- 确定性验证:这是共享模式的核心。确保所有影响游戏逻辑的运算在所有客户端上绝对一致。
5.4 性能开销过大
- 症状:玩家数量增多后,游戏帧率显著下降。
- 优化:
- 网络状态精简:
[Networked]属性标记的变量越多,同步开销越大。只同步必要的数据。例如,角色的颜色、昵称可以在生成时一次性同步,之后不需要每帧同步。 - 插值对象管理:对于大量非玩家对象(如NPC、子弹),如果它们移动平滑度要求不高,可以关闭插值(
Interpolation模式设为None)。 - 兴趣管理(AOI):如果游戏世界很大,使用Fusion的兴趣管理功能,只同步玩家视野范围内的对象状态。
- 相机裁剪:确保相机只渲染必要的层。对于分屏,每个相机的视锥体可能重叠,造成物体被重复渲染。合理设置分层距离裁剪(Layer Cull Distances)。
- 网络状态精简:
6. 进阶技巧:输入预测与状态调和的简化实现
对于追求极致响应速度的游戏,输入预测是必须的。这里给出一个非常简化的概念性实现,帮助你理解这个过程。
核心思想:本地客户端在发送输入后,不等待服务器确认,立即根据这个输入模拟一次移动(预测)。当收到服务器的权威状态时,对比预测的位置和权威位置。如果差异超过某个阈值,就进行“调和”——将角色位置“拉回”到权威位置,并可能重新模拟从那个时间点之后的输入。
Fusion内置的预测系统(NetworkTransform配合预测)已经处理了大量底层工作。但如果你是自己控制移动(如使用CharacterController),你需要更手动地处理。
- 存储历史状态:在本地,存储过去若干Tick的输入和 resulting 的位置。
- 预测移动:在
FixedUpdateNetwork中,即使没有收到权威输入(GetInput返回false),只要你是输入权威,也根据本地缓存的输入进行移动,并将结果存储为“预测状态”。 - 接收权威状态:当
GetInput返回true,且你收到了来自状态权威(可能是服务器)的输入和 resulting 状态时,对比你之前预测的状态。 - 调和:如果发现不一致,将你的角色位置和状态“硬设置”到权威状态。然后,从那个不一致的Tick开始,用你存储的本地输入历史,重新模拟(Re-simulate)之后的移动,直到当前Tick。这个过程可能很复杂,并且如果输入历史很长,开销较大。
实操心得:对于大多数中小型项目,我建议在项目初期不要自己实现完整的预测与调和系统。充分利用Fusion
NetworkTransform的预测功能,并将游戏逻辑设计得对微小延迟不那么敏感(例如,使用服务器权威的命中检测,而非客户端预测)。当基础体验稳定后,如果仍有明显的操作延迟感,再考虑深入优化预测逻辑。过早引入复杂的预测系统会极大增加调试难度。
让相机平滑地跟随一个由网络同步的角色,其精髓在于严格区分“逻辑更新”与“渲染更新”的界限。逻辑更新(FixedUpdateNetwork)发生在离散的网络Tick上,它负责计算权威的游戏状态。渲染更新(Update/LateUpdate)发生在每一帧,它负责用最平滑的方式将这些状态呈现出来。Object.RenderProxy就是连接这两个世界的桥梁。牢牢抓住这一点,你就避开了多人游戏相机处理中最深的一个坑。