Photon Fusion 2共享模式:输入处理与相机跟随的实战避坑指南

📅 2026/7/21 5:20:05
Photon Fusion 2共享模式:输入处理与相机跟随的实战避坑指南
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 GetComponentCharacterController(); // 只有拥有输入权威的客户端才需要本地输入采集器 if (Object.HasInputAuthority) { _inputPoller Runner.GetComponentLocalInputPoller(); if (_inputPoller null) { _inputPoller Runner.gameObject.AddComponentLocalInputPoller(); } // 将本NetworkPlayer注册到输入采集器如果采集器需要知道目标对象 } // 初始化逻辑... if (Object.HasStateAuthority) { // 状态权威端的初始化比如设置出生点 } } public override void FixedUpdateNetwork() { // FixedUpdateNetwork 在每个网络Tick都会被调用无论是否有状态权威 // 这是执行核心游戏逻辑的地方 // 3. 检查是否有输入数据 if (GetInputMyNetworkInput(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 (GetInputMyNetworkInput(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.GetComponentNetworkTransform().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.GetComponentInChildrenPlayerCameraManager(); if (cameraManager ! null) { cameraManager.AssignCameraToPlayer(this); } } } public class PlayerCameraManager : MonoBehaviour { private ListCamera _playerCameras new ListCamera(); public void AssignCameraToPlayer(NetworkPlayer player) { int playerIndex GetLocalPlayerIndex(player); // 需要自己实现一个获取本地玩家索引的方法 Camera playerCam Instantiate(player.PlayerCameraPrefab, transform); SmoothNetworkCamera followCam playerCam.GetComponentSmoothNetworkCamera(); followCam.SetTarget(player.Object); // 配置视口 playerCam.rect CalculateViewportRect(playerIndex, Runner.ActivePlayers.Count()); // 如果是第一个玩家保留AudioListener否则禁用 if (playerIndex 0) { AudioListener audioListener playerCam.GetComponentAudioListener(); 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并确保所有客户端的物理步骤一致。输入处理顺序确保所有客户端以完全相同的方式处理和解释输入数据。例如对摇杆输入的死区处理、归一化处理必须在所有客户端上一致。状态权威确认伤害判定等关键逻辑只在状态权威端执行。例如子弹的命中检测如果子弹由射击者客户端生成那么检测逻辑必须运行在子弹对象或命中目标的状态权威端通常是服务器或主机并将结果同步。渲染与逻辑分离视觉表现如击中特效、受击动画应该由状态变化触发而不是由检测逻辑直接播放。当状态权威端判定击中并同步了“血量减少”这个状态后所有客户端再根据这个状态播放受击效果。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。这个过程可能很复杂并且如果输入历史很长开销较大。实操心得对于大多数中小型项目我建议在项目初期不要自己实现完整的预测与调和系统。充分利用FusionNetworkTransform的预测功能并将游戏逻辑设计得对微小延迟不那么敏感例如使用服务器权威的命中检测而非客户端预测。当基础体验稳定后如果仍有明显的操作延迟感再考虑深入优化预测逻辑。过早引入复杂的预测系统会极大增加调试难度。让相机平滑地跟随一个由网络同步的角色其精髓在于严格区分“逻辑更新”与“渲染更新”的界限。逻辑更新FixedUpdateNetwork发生在离散的网络Tick上它负责计算权威的游戏状态。渲染更新Update/LateUpdate发生在每一帧它负责用最平滑的方式将这些状态呈现出来。Object.RenderProxy就是连接这两个世界的桥梁。牢牢抓住这一点你就避开了多人游戏相机处理中最深的一个坑。