帧同步 (Lockstep / Frame Sync) 核心思想:所有客户端执行相同的输入,保证逻辑完全一致,只同步”操作指令”不同步状态。
客户端收集本帧输入
将输入发送给服务器
服务器等待所有客户端输入(或超时补默认输入)
服务器广播所有客户端的输入
所有客户端用相同输入推进同一逻辑帧
定期做 checksum 校验防止逻辑分叉
帧同步客户端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 [System.Serializable ] public struct FrameInput{ public int playerId; public int frame; public Vector2 moveDir; public bool firePressed; } public class LockstepManager : MonoBehaviour { public static int CurrentFrame { get ; private set ; } private const float FrameInterval = 0.066f ; private float _timer; private Dictionary<int , List<FrameInput>> _frameInputBuffer = new (); private FrameInput _localInput; void Update () { CollectLocalInput(); _timer += Time.deltaTime; if (_timer >= FrameInterval) { _timer -= FrameInterval; TrySendInput(); } } void CollectLocalInput () { _localInput = new FrameInput { playerId = NetworkManager.LocalPlayerId, frame = CurrentFrame, moveDir = new Vector2(Input.GetAxisRaw("Horizontal" ), Input.GetAxisRaw("Vertical" )), firePressed = Input.GetButton("Fire1" ) }; } void TrySendInput () { NetworkManager.Send(_localInput); } public void OnReceiveFrameInputs (List<FrameInput> inputs ) { _frameInputBuffer[CurrentFrame] = inputs; ExecuteFrame(CurrentFrame, inputs); CurrentFrame++; } void ExecuteFrame (int frame, List<FrameInput> inputs ) { foreach (var input in inputs) { var player = PlayerManager.GetPlayer(input.playerId); player.ApplyInput(input); } int checksum = ComputeChecksum(); NetworkManager.SendChecksum(frame, checksum); } int ComputeChecksum () { int hash = 0 ; foreach (var player in PlayerManager.AllPlayers) { hash ^= player.LogicPosition.GetHashCode(); } return hash; } } public class PlayerLogic : MonoBehaviour { public Vector2Int LogicPosition; private const int Speed = 5 ; public void ApplyInput (FrameInput input ) { LogicPosition += Vector2Int.RoundToInt(input.moveDir * Speed); transform.position = new Vector3(LogicPosition.x * 0.01f , 0 , LogicPosition.y * 0.01f ); } }
状态同步 核心思想:服务器是权威,定期将游戏状态广播给所有客户端,客户端做插值表现。
客户端发送输入/操作到服务器
服务器执行逻辑,更新权威状态
服务器定期广播状态快照
客户端收到状态后做插值/预测修正
客户端本地预测(可选,减少延迟感)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 [System.Serializable ] public struct PlayerState{ public int playerId; public Vector3 position; public Vector3 velocity; public float timestamp; } public class ServerPlayerController : MonoBehaviour { private Rigidbody _rb; private float _broadcastInterval = 0.05f ; private float _timer; void FixedUpdate () { _timer += Time.fixedDeltaTime; if (_timer >= _broadcastInterval) { _timer = 0 ; BroadcastState(); } } public void OnReceiveInput (Vector2 moveDir, int playerId ) { Vector3 force = new Vector3(moveDir.x, 0 , moveDir.y) * 10f ; _rb.AddForce(force); } void BroadcastState () { var state = new PlayerState { playerId = GetPlayerId(), position = transform.position, velocity = _rb.velocity, timestamp = Time.time }; NetworkManager.BroadcastToAll(state); } } public class ClientPlayerInterpolation : MonoBehaviour { private PlayerState _from; private PlayerState _to; private float _lerpT; private const float InterpDelay = 0.1f ; private Queue<PlayerState> _stateBuffer = new (); public void OnReceiveState (PlayerState state ) { _stateBuffer.Enqueue(state); } void Update () { float renderTime = Time.time - InterpDelay; while (_stateBuffer.Count > 1 && _stateBuffer.Peek().timestamp <= renderTime) { _from = _stateBuffer.Dequeue(); } if (_stateBuffer.Count > 0 ) { _to = _stateBuffer.Peek(); float t = Mathf.InverseLerp(_from.timestamp, _to.timestamp, renderTime); transform.position = Vector3.Lerp(_from.position, _to.position, t); } } } public class ClientPrediction : MonoBehaviour { private Vector3 _predictedPos; private Rigidbody _rb; void Update () { Vector2 input = new Vector2(Input.GetAxisRaw("Horizontal" ), Input.GetAxisRaw("Vertical" )); _rb.AddForce(new Vector3(input.x, 0 , input.y) * 10f ); NetworkManager.SendInput(input); } public void OnServerCorrection (PlayerState authoritative ) { float posError = Vector3.Distance(transform.position, authoritative.position); if (posError > 0.5f ) { transform.position = authoritative.position; _rb.velocity = authoritative.velocity; } else { transform.position = Vector3.Lerp(transform.position, authoritative.position, 0.3f ); } } }
对比
维度
帧同步
状态同步
核心职责
客户端跑逻辑,服务器转发输入
服务器跑逻辑,客户端渲染状态
网络带宽
低(只传输入指令)
高(传完整状态数据)
服务器压力
低
高
延迟敏感度
高,需等待最慢玩家
低,可做客户端预测掩盖延迟
反外挂能力
弱,逻辑在客户端
强,服务器权威不可篡改
断线重连
慢,需回放所有历史帧
快,直接同步当前状态快照
实现复杂度
高(必须保证确定性)
中(需处理插值和预测修正)
适用场景
RTS、MOBA、格斗游戏
FPS、MMO、竞技射击
帧同步(Lockstep) 所有客户端共享相同的初始状态,服务器只负责收集并广播每帧所有玩家的输入指令。每个客户端用完全相同的输入自行推进逻辑,只要逻辑是确定性的,所有客户端的状态就会始终一致。本质是”同步输入,各自计算”。
浮点数在不同平台/编译器结果可能不同,必须用定点数
不能用任何随机函数,需用确定性伪随机(相同种子)
物理引擎(如 Unity PhysX)本身不确定,需自研或用确定性物理库
状态同步(State Sync) 服务器是唯一的权威,负责运行所有游戏逻辑并定期将结果(位置、血量等状态)广播给客户端。客户端只负责展示,不参与权威计算。本质是”服务器计算,同步结果”。
网络延迟导致操作反馈滞后,需要客户端预测
预测结果和服务器权威状态不一致时需要平滑修正(防止位置跳变)
状态数据量大时需要做差量同步(delta compression)降低带宽
为什么帧同步必须用定点数 帧同步的前提是:所有客户端用相同输入,得到完全相同的结果。浮点数破坏这个前提:
浮点数在不同平台/编译器的问题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 float a = 1.0f / 3.0f ;float pos = 0f ;for (int i = 0 ; i < 1000 ; i++) pos += 0.1f ;
定点数:整数运算,在所有平台结果完全一致 1 2 3 4 5 6 7 Fixed pos = Fixed.From(0 ); Fixed step = Fixed.From(0.1f ); for (int i = 0 ; i < 1000 ; i++) pos += step;
状态同步中客户端收到的是服务器算好的结果,不存在”两台机器算同一件事结果不同”的问题。
定点数 -> 用整数放大 65536 倍模拟小数 -> 牺牲数值范围,换取跨平台一致性
帧同步需要定点数:多个客户端必须算出完全相同的结果,必须保证结果一致 -> 必须用定点数 状态同步不需要:只有服务器一处计算,结果天然唯一 -> 浮点数完全没问题,只有一份唯一结果广播给所有客户端
在Unity中实现状态同步 NetworkVariable — 自动状态同步 使用NetCode库中的 NetworkVariable ,在数值变化时自动同步给所有客户端。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 using Unity.Netcode;using UnityEngine;public class PlayerStats : NetworkBehaviour { public NetworkVariable<int > Health = new (100 ); public NetworkVariable<Vector3> Position = new (Vector3.zero); public override void OnNetworkSpawn () { Health.OnValueChanged += OnHealthChanged; } void OnHealthChanged (int oldVal, int newVal ) { UIManager.UpdateHealthBar(newVal); if (newVal <= 0 ) PlayDeathEffect(); } public void TakeDamage (int damage ) { if (!IsServer) return ; Health.Value = Mathf.Max(0 , Health.Value - damage); } }
插值 (Interpolation) — 让移动平滑 客户端缓存服务器状态,在两个快照之间做插值,消除网络抖动。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public class InterpolatedTransform : NetworkBehaviour { private struct TransformSnapshot { public float time; public Vector3 position; public Quaternion rotation; } private Queue<TransformSnapshot> _buffer = new (); private const float BufferDelay = 0.1f ; [ClientRpc ] public void UpdateTransformClientRpc (Vector3 pos, Quaternion rot ) { if (IsOwner) return ; _buffer.Enqueue(new TransformSnapshot { time = Time.time, position = pos, rotation = rot }); } void Update () { if (IsOwner || _buffer.Count < 2 ) return ; float renderTime = Time.time - BufferDelay; TransformSnapshot from = _buffer.Peek(); TransformSnapshot to = default ; foreach (var snap in _buffer) { if (snap.time <= renderTime) from = snap; else { to = snap; break ; } } if (to.time == 0 ) return ; float t = Mathf.InverseLerp(from .time, to.time, renderTime); transform.position = Vector3.Lerp(from .position, to.position, t); transform.rotation = Quaternion.Slerp(from .rotation, to.rotation, t); } }
客户端预测 (Client Prediction) — 消除操作延迟感 本地玩家立即响应输入,同时发送给服务器,收到服务器校正后平滑修正。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public class ClientPrediction : NetworkBehaviour { private Rigidbody _rb; private Vector3 _serverPosition; private bool _hasCorrection; void Update () { if (!IsOwner) return ; Vector3 input = new Vector3( Input.GetAxisRaw("Horizontal" ), 0 , Input.GetAxisRaw("Vertical" ) ); _rb.MovePosition(transform.position + input * 5f * Time.deltaTime); MoveServerRpc(input); if (_hasCorrection) { float error = Vector3.Distance(transform.position, _serverPosition); transform.position = error > 1f ? _serverPosition : Vector3.Lerp(transform.position, _serverPosition, 0.2f ); _hasCorrection = false ; } } [ServerRpc ] void MoveServerRpc (Vector3 input ) { _rb.MovePosition(transform.position + input * 5f * Time.deltaTime); CorrectPositionClientRpc(transform.position); } [ClientRpc ] void CorrectPositionClientRpc (Vector3 authoritative ) { if (!IsOwner) return ; _serverPosition = authoritative; _hasCorrection = true ; } }
RPC — 触发瞬时事件 RPC 不用于持续状态同步,专门处理”一次性事件”如特效、音效、技能触发。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class CombatEffects : NetworkBehaviour { [SerializeField ] private ParticleSystem _hitEffect; [SerializeField ] private AudioClip _hitSound; public void OnHit (Vector3 hitPoint ) { if (!IsServer) return ; PlayHitEffectClientRpc(hitPoint); } [ClientRpc ] void PlayHitEffectClientRpc (Vector3 hitPoint ) { _hitEffect.transform.position = hitPoint; _hitEffect.Play(); AudioSource.PlayClipAtPoint(_hitSound, hitPoint); } [ServerRpc(RequireOwnership = true) ] public void CastSkillServerRpc (int skillId, Vector3 targetPos ) { SkillManager.Execute(skillId, targetPos, OwnerClientId); } }
RPC特性 [ClientRpc] 是 C# 的 Attribute(特性) 语法,是 Unity Netcode for GameObjects (NGO) 框架提供的标记。
标记特性 1 2 [AttributeName ] public void MyMethod () { }
本质是一种元数据标签,告诉框架”这个方法有特殊行为”。
NGO 中的 RPC 特性 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class MyNetworkObject : NetworkBehaviour { [ClientRpc ] public void PlayEffectClientRpc (Vector3 pos ) { Instantiate(effectPrefab, pos, Quaternion.identity); } [ServerRpc ] public void RequestFireServerRpc (Vector3 direction ) { SpawnBullet(direction); } } void Update (){ if (IsServer) PlayEffectClientRpc(transform.position); if (IsOwner) RequestFireServerRpc(aimDirection); }
NGO 2.0 之后推荐统一用 [Rpc]:
NGO 2.0 新写法(Rpc Attribute) 1 2 3 4 5 6 7 8 9 10 11 12 13 [Rpc(SendTo.ClientsAndHost) ] public void PlayEffectRpc (Vector3 pos ) { }[Rpc(SendTo.Server) ] public void RequestFireRpc (Vector3 dir ) { }[ClientRpc ] public void PlayEffectClientRpc (Vector3 pos ) { }[ServerRpc ] public void RequestFireServerRpc (Vector3 dir ) { }
[ClientRpc] → 方法名必须以 ClientRpc 结尾,否则报错 [ServerRpc] → 方法名必须以 ServerRpc 结尾,否则报错
差量同步 (Delta Compression) 核心思想:只发送”变化的部分”,不发送没变的数据。
最简单的实现:字段级脏标记 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 [System.Flags ] public enum PlayerDirtyFlags : uint { None = 0 , Position = 1 << 0 , Rotation = 1 << 1 , Health = 1 << 2 , Mana = 1 << 3 , Animation = 1 << 4 , } public class PlayerState { public Vector3 Position; public float Rotation; public int Health; public int Mana; public int AnimationId; private PlayerState _lastSent = new (); private PlayerDirtyFlags _dirty; public void SetPosition (Vector3 pos ) { if (Position == pos) return ; Position = pos; _dirty |= PlayerDirtyFlags.Position; } public void SetHealth (int hp ) { if (Health == hp) return ; Health = hp; _dirty |= PlayerDirtyFlags.Health; } public byte [] SerializeDelta () { using var writer = new BinaryWriter(new MemoryStream()); writer.Write((uint )_dirty); if (_dirty.HasFlag(PlayerDirtyFlags.Position)) { writer.Write(Position.x); writer.Write(Position.y); writer.Write(Position.z); } if (_dirty.HasFlag(PlayerDirtyFlags.Health)) writer.Write(Health); if (_dirty.HasFlag(PlayerDirtyFlags.Mana)) writer.Write(Mana); _dirty = PlayerDirtyFlags.None; return ((MemoryStream)writer.BaseStream).ToArray(); } public void DeserializeDelta (byte [] data ) { using var reader = new BinaryReader(new MemoryStream(data)); var flags = (PlayerDirtyFlags)reader.ReadUInt32(); if (flags.HasFlag(PlayerDirtyFlags.Position)) { Position = new Vector3( reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle() ); } if (flags.HasFlag(PlayerDirtyFlags.Health)) Health = reader.ReadInt32(); if (flags.HasFlag(PlayerDirtyFlags.Mana)) Mana = reader.ReadInt32(); } }
快照对比法(适合复杂状态) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 public class SnapshotDelta { public struct WorldSnapshot { public Dictionary<int , PlayerSnapshot> Players; public int Tick; } public struct PlayerSnapshot { public Vector3 Position; public int Health; public int Score; } private WorldSnapshot _baseline; public byte [] GenerateDelta (WorldSnapshot from , WorldSnapshot to ) { using var writer = new BinaryWriter(new MemoryStream()); writer.Write(to.Tick); foreach (var (id, toPlayer) in to.Players) { if (!from .Players.TryGetValue(id, out var fromPlayer)) { writer.Write((byte )DeltaOp.Add); writer.Write(id); WriteFullPlayer(writer, toPlayer); continue ; } PlayerDirtyFlags dirty = PlayerDirtyFlags.None; if (fromPlayer.Position != toPlayer.Position) dirty |= PlayerDirtyFlags.Position; if (fromPlayer.Health != toPlayer.Health) dirty |= PlayerDirtyFlags.Health; if (fromPlayer.Score != toPlayer.Score) dirty |= PlayerDirtyFlags.Score; if (dirty != PlayerDirtyFlags.None) { writer.Write((byte )DeltaOp.Update); writer.Write(id); writer.Write((uint )dirty); if (dirty.HasFlag(PlayerDirtyFlags.Position)) WriteVector3(writer, toPlayer.Position); if (dirty.HasFlag(PlayerDirtyFlags.Health)) writer.Write(toPlayer.Health); } } foreach (var id in from .Players.Keys) { if (!to.Players.ContainsKey(id)) { writer.Write((byte )DeltaOp.Remove); writer.Write(id); } } return ((MemoryStream)writer.BaseStream).ToArray(); } enum DeltaOp : byte { Add, Update, Remove } }
位置压缩(进一步降低带宽) 位置数据是最频繁变化的,可以额外压缩:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public static class PositionCompressor { private const float Precision = 0.01f ; private const float MaxDelta = 32767 * Precision; public static short [] CompressDelta (Vector3 from , Vector3 to ) { Vector3 delta = to - from ; return new short [] { (short )(delta.x / Precision), (short )(delta.y / Precision), (short )(delta.z / Precision) }; } public static Vector3 DecompressDelta (Vector3 baseline, short [] delta ) { return baseline + new Vector3( delta[0 ] * Precision, delta[1 ] * Precision, delta[2 ] * Precision ); } }
带宽对比 1 2 3 4 5 6 7 8 100个玩家,每人10个字段,20次/秒广播: 全量同步: 100 × 10字段 × 4字节 × 20次 = 800 KB/s 差量同步(假设每帧平均20%字段变化): 4字节标记位 + 变化字段 100 × (4 + 2字段×4字节) × 20次 ≈ 176 KB/s → 节省约78%
在实际游戏中,应该根据不同的数据类型需要使用不同的差量压缩机制:
移动中的玩家:位置每帧变,用压缩差值
静止玩家:位置不变,完全不发
血量/状态:只在变化时发一次,不需要每帧发
大地图场景:加上 AOI(Area of Interest),只同步视野范围内的玩家,带宽再降一个数量级
核心流程总结 1 2 3 4 5 6 7 客户端输入 ↓ ServerRpc 服务器执行权威逻辑 ↓ NetworkVariable 自动同步 / ClientRpc 触发事件 所有客户端收到状态 ↓ 插值平滑表现 + 本地预测修正
NetworkVariable 管持续状态,RPC 管瞬时事件,预测+插值管体验,三者配合就是状态同步在 Unity 里的完整实现。