LOADING

加载过慢请开启缓存 浏览器默认开启

Unity 网络同步技术浅谈

一、三种方案核心差异:为何而生?

方案 传输内容 一致性保证 延迟要求 典型适用场景
帧同步 玩家输入 严格锁步一致 取决于最大网络延迟 RTS、格斗、回合/即时策略
状态同步 世界状态快照 最终一致 / 镜像 可接受中高抖动 MMO、大型开放世界、FPS
混合方案 输入 + 快照校正 高一致 + 容错 回滚与缓冲共存 竞技格斗、赛车、MOBA

三者在“一致性 vs. 延迟 vs. 带宽”三角中各取所需,下面我们先深入帧同步,探讨它的优势、难题与破解之道。

二、帧同步 (Lockstep):

1. 方案原理:只传玩家输入

帧同步的核心理念是最小化网络开销:客户端只需将按键、方向等操作输入封装成极小的数据包广播至所有对等节点(或服务器),并在每一帧上同步执行。相比于每帧传输上百 KB 的世界快照,这种方式能节省至少 90% 以上的带宽。

2. 痛点

2.1 浮点运算不一致

  • 问题:不同平台/编译器对浮点数的处理略有差异,长时间累积会导致全局状态漂移。
  • 解决:统一定点运算,将关键数值量化为整数处理。

2.2 随机数偏差

  • 问题:系统伪随机生成器(如 Random.value)在不同平台上产生的序列不一致。

  • 解决方案:采用相同的 线性同余生成器(LCG):

    保证所有客户端使用同一初始种子和参数。

2.3 网络延迟与抖动

  • 问题:不稳定的延迟 (Latency) 和抖动 (Jitter) 会阻塞帧同步,导致游戏“卡”或逻辑错位。

  • 解决方案:通过 Lookahead 缓冲策略,让客户端提前 D 帧执行输入:

    其中,L_{max} 为最大网络延迟,T_{proc} 为本地处理时间。

2.4 丢包恢复与可靠性

  • 问题:UDP 协议本身不保证可靠交付,输入丢失会导致某些帧无效。
  • 解决方案
    • ACK+重传:为每个输入包打上帧编号,未收到 ACK 时触发重传;
    • **前向纠错(FEC)**:每 N 帧附加 R 帧冗余,R ≥ p×N,p 为丢包率,通过解码重构丢失数据。

3. 帧同步示例伪代码

下面示例展示了一个典型的帧同步主循环:包含输入捕获、可靠发送、延迟缓冲与逻辑推进。

const int TICK_RATE = 30;
float deltaTime = 1f / TICK_RATE;
int lookahead = Mathf.CeilToInt((maxLatency + procTime) / deltaTime);
int localTick = 0;
Dictionary<int, Input[]> inputBuffer = new Dictionary<int, Input[]>();

void FixedUpdate() {
    // 1. 捕获本地输入并可靠发送
    Input myInput = ReadLocalInput();
    SendReliable(myInput, frame: localTick);
    inputBuffer.GetOrCreate(localTick)[myPlayerId] = myInput;

    // 2. 接收并缓存远端输入
    foreach (var pkt in ReceivePackets()) {
        inputBuffer.GetOrCreate(pkt.frame)[pkt.playerId] = pkt.input;
    }

    // 3. 延迟执行:localTick - lookahead
    int execTick = localTick - lookahead;
    if (inputBuffer.TryGetValue(execTick, out var inputs) && inputs.All(i => i != null)) {
        SimulateFrame(inputs);
        inputBuffer.Remove(execTick);
    }
    localTick++;
}

在这个循环中,SendReliableReceivePackets 分别负责封装 ACK/NACK 或 FEC;SimulateFrame 则依赖完全确定性逻辑,保证所有客户端在相同 execTick 的状态一模一样。

二、状态同步 (Snapshot):

在大多数 MMO、MMOARPG、射击类等非严格锁步的网络游戏中,状态同步(Snapshot/State Sync)是主流架构:服务器定期广播实体状态,客户端本地插值渲染。它相比帧同步更灵活,但也带来了带宽、抖动、丢包等挑战。


2.1 架构与核心流程

  1. 服务端快照生成

    • Tick Rate(快照频率)f_s:通常 10–30 Hz。
    • 全量快照S(t_i):每个 Tick 汇总所有关注实体的状态(位置、方向、速度、动画帧等)。
  2. 网络传输

    • 数据包包头带有 序列号seq时间戳t_i
    • 可能使用 UDP(无连接、丢包可控)或 RUDP(可靠 UDP)。
  3. 客户端接收与缓存

    • 将连续快照按序号存入 插值队列
    • 读取当前渲染时间 t_render = t_now - bufferDelay,通常 bufferDelay ≈ 2 / f_s,留出插值空间。
  4. 插值 / 外推

    • 找到包围 t_render 的两帧快照 S(t₀)S(t₁),做线性或球面插值:

      $$\mathbf{p}(t) =\ \mathbf{p}_0 +\ \frac{t - t_0}{t_1 - t_0}\bigl(\mathbf{p}_1 - \mathbf{p}_0\bigr)$$

    • t_render > t_last,则外推(Dead Reckoning):

      $$\mathbf{p}(t) =\ \mathbf{p}_{\rm last} +\ (t - t_{\rm last})\mathbf{v}_{\rm last}$$


2.2 痛点

痛点 现象 原因
带宽激增 带宽占用过高 → 延迟↑、丢包↑ 全量快照体积大,实体数/状态字段多
网络抖动(Jitter) 插值区间不均匀 → 画面抖动、错位 抖动导致 t_i 间隔不一致
丢包与乱序 客户端插值断档、外推误差 ↑ UDP 丢包、乱序,快照序列号跳跃
插值畸变 瞬移、“橡皮筋”现象 外推误差累积、插值边界不平滑
实体优先级 重要玩家角色更新迟缓,远程玩家占用资源 无兴趣管理(Interest Management)

2.3 优化方案

2.3.1 差分压缩 (Delta Compression)

  • 思路:只发送相对于上一次快照的差异 ΔS = S(t_i) – S(t_{i-1}),大幅降低包体积。
  • 实现:对每个字段维护上帧值,按位对比打包,例:
struct Snapshot {
    int seq;
    float timestamp;
    Vector3[] positions;
}

byte[] PackDelta(Snapshot cur, Snapshot prev) {
    var writer = new BitWriter();
    writer.WriteInt(cur.seq);
    writer.WriteFloat(cur.timestamp);
    for (int i = 0; i < cur.positions.Length; i++) {
        Vector3 d = cur.positions[i] - prev.positions[i];
        if (d.sqrMagnitude > epsilon) {
            writer.WriteBool(true);
            writer.WriteCompressedVector3(d);
        } else {
            writer.WriteBool(false);
        }
    }
    return writer.ToArray();
}

2.3.2 Interest Management

  • 思路:客户端只订阅“视野范围内”或“逻辑相关”的实体状态,减少无效同步。

  • KD-Tree / 四叉树分区:在服务器维护空间分区,生成客户端关注列表:

List<Entity> QueryInterest(Vector3 playerPos, float radius) {
    return spatialIndex.QuerySphere(playerPos, radius);
}

2.3.3 插值与缓冲策略

  • 双缓冲延迟bufferDelay = k / f_sk 一般 = 2–3,平衡延迟与平滑度。

  • 时间抖动模型:设理想间隔 T = 1/f_s,真实间隔 T_i = t_i – t_{i-1},抖动 J_i = T_i – T

    • 滑动窗口抑制:调整 t_render

      $$t_{\rm render} = t_{\rm lastReceived} - (T + \alpha\ J_{\rm avg})$$

    • J_avg 可用指数移动平均:

      $$J_{\rm avg}^{(n)} = \beta\ J_n + (1-\beta)\ J_{\rm avg}^{(n-1)}$$

2.3.4 Dead Reckoning 与纠偏

  • 基本外推
Vector3 Extrapolate(EntityState last, float t) {
    float dt = t - last.timestamp;
    return last.position + last.velocity * dt;
}
  • 误差校正:当下一快照到达,执行平滑纠偏:

    $$\mathbf{p}_{\rm smooth}(t) = \mathbf{p}_{\rm ext}(t)(1-\gamma) + \mathbf{p}_{\rm snap}(t)\gamma$$

    γ ∈ [0,1] 控制纠偏速度。

2.3.5 丢包重传

  • RUDP + FEC:结合确认 ACK 与前向纠错,减少重传延迟。

  • 滑动窗口重发

sendWindow = new Queue<Packet>();
OnSend(packet):
    sendWindow.Enqueue(packet);
    UDP.Send(packet);
OnAck(seq):
    while sendWindow.Peek().seq ≤ seq:
        sendWindow.Dequeue();
Periodic:
    foreach packet in sendWindow:
        if (Time.now - packet.sentTime > timeout) resend(packet);

2.4 完整伪代码示例

class StateSyncClient {
    float bufferDelay = 0.1f;   // 100 ms
    Queue<Snapshot> buffer = new Queue<Snapshot>();

    void OnReceive(byte[] data) {
        Snapshot snap = Unpack(data);
        buffer.Enqueue(snap);
        // 丢弃过旧帧
        while (buffer.Peek().timestamp < Time.time - 1.0f)
            buffer.Dequeue();
    }

    void Update() {
        float t_render = Time.time - bufferDelay;
        // 找到包围 t_render 的两帧
        Snapshot prev = null, next = null;
        foreach (var s in buffer) {
            if (s.timestamp <= t_render) prev = s;
            if (s.timestamp >  t_render) { next = s; break; }
        }
        if (prev != null && next != null) {
            float α = (t_render - prev.timestamp) / (next.timestamp - prev.timestamp);
            foreach (int i in Entities) {
                Vector3 p0 = prev.positions[i];
                Vector3 p1 = next.positions[i];
                entities[i].position = Vector3.Lerp(p0, p1, α);
            }
        } else if (prev != null) {
            // 外推
            foreach (int i in Entities) {
                entities[i].position = Extrapolate(prev.states[i], t_render);
            }
        }
    }
}

2.5 小结

状态同步以广播“状态快照”为核心,适用于大规模、对一致性要求不严格的场景。

  • 优势:抗抖动、支持异步多客户端、易于跨平台;
  • 劣势:带宽高、插值/外推误差、丢包纠正复杂。

通过差分压缩、Interest Management、抖动抑制与 FEC 等策略,可以在保证流畅度的同时,大幅降低带宽与延迟对体验的冲击。

三、混合方案 (Rollback + Snapshot):结合实时响应与周期校正

当你的游戏既需要对关键玩家操作保证严格一致性(如格斗连招、即时对战),又要对大量非关键实体(如环境 NPC、弹幕特效)保持高吞吐量时,单纯的帧同步或状态同步都显得力不从心。混合方案通过“双通道”架构,将最关键的输入走帧同步(Lockstep),大规模实体走状态同步(Snapshot),兼顾一致性与性能。


3.1 架构与核心流程

  1. 双通道定义

    • 输入通道(Lockstep Channel)
      • 只广播玩家输入(按键、技能指令),每帧数据量极小。
      • 服务器按固定 Tick(f_lock)收集并广播给所有客户端。
      • 客户端收到后,与本地缓存的历史输入一起,做一次确定性仿真
    • 状态通道(Snapshot Channel)
      • 周期性(f_snap)广播所有非关键实体的状态快照(位置、朝向、速度、动画等)。
      • 客户端对快照做插值/外推,平滑渲染大批量对象。
  2. 时间轴与同步

    • 定义两条时钟:

      $$t_{\rm lock} = \dfrac{n}{f_{\rm lock}},\quad t_{\rm snap} = \dfrac{m}{f_{\rm snap}}$$

    • 客户端维护两个缓冲队列:

      • inputBuffer[n] 存放第 n 帧的所有玩家输入;
      • snapshotBuffer[m] 存放第 m 次状态快照。
  3. 流程示意

// =======================
// === 服务器主循环 ===
// =======================
public class GameServer : MonoBehaviour
{
    public float lockStepRate = 20f;    // 帧同步频率 (Hz)
    public float snapshotRate = 10f;    // 状态快照频率 (Hz)

    private int lockSeq = 0;
    private int snapSeq = 0;
    private float lockTimer = 0f;
    private float snapTimer = 0f;

    void Update()
    {
        float dt = Time.deltaTime;
        lockTimer += dt;
        snapTimer += dt;

        // ———— 帧同步通道 ————
        if (lockTimer >= 1f / lockStepRate)
        {
            lockTimer -= 1f / lockStepRate;

            // 收集所有玩家的输入
            PlayerInput[] inputs = CollectAllPlayerInputs();
            // 广播给所有客户端
            BroadcastLockstepPacket(lockSeq, inputs);
            lockSeq++;
        }

        // ———— 状态快照通道 ————
        if (snapTimer >= 1f / snapshotRate)
        {
            snapTimer -= 1f / snapshotRate;

            // 收集所有非关键实体状态
            SnapshotData snapshot = CollectNonCriticalStates();
            // 广播给所有客户端
            BroadcastSnapshotPacket(snapSeq, snapshot);
            snapSeq++;
        }
    }

    // 示例方法签名
    PlayerInput[] CollectAllPlayerInputs() { /* … */ }
    void BroadcastLockstepPacket(int seq, PlayerInput[] inputs) { /* … */ }
    SnapshotData CollectNonCriticalStates() { /* … */ }
    void BroadcastSnapshotPacket(int seq, SnapshotData data) { /* … */ }
}


// =======================
// === 客户端主循环 ===
// =======================
public class HybridClient : MonoBehaviour
{
    public float bufferDelay = 0.1f;  // 渲染延迟,单位秒

    private Dictionary<int, PlayerInput[]>   inputBuffer    = new Dictionary<int, PlayerInput[]>();
    private Dictionary<int, SnapshotData>    snapshotBuffer = new Dictionary<int, SnapshotData>();

    private int nextLockSeq = 0;
    private int recvLockSeq = -1;
    private int recvSnapSeq = -1;

    void OnEnable()
    {
        Network.OnLockstepPacket   += HandleLockstepPacket;
        Network.OnSnapshotPacket   += HandleSnapshotPacket;
    }
    void OnDisable()
    {
        Network.OnLockstepPacket   -= HandleLockstepPacket;
        Network.OnSnapshotPacket   -= HandleSnapshotPacket;
    }

    void HandleLockstepPacket(LockstepPacket pkt)
    {
        recvLockSeq = pkt.Sequence;
        inputBuffer[pkt.Sequence] = pkt.Inputs;
    }

    void HandleSnapshotPacket(SnapshotPacket pkt)
    {
        recvSnapSeq = pkt.Sequence;
        snapshotBuffer[pkt.Sequence] = pkt.Data;
    }

    void Update()
    {
        float now = Time.time;

        // ———— 1. 执行未处理的 FrameLock Tick ————
        while (nextLockSeq <= recvLockSeq)
        {
            ApplyDeterministicTick(inputBuffer[nextLockSeq]);
            nextLockSeq++;
        }

        // ———— 2. 渲染非关键实体(状态插值/外推) ————
        float renderTime = now - bufferDelay;
        RenderSnapshotsAtTime(renderTime);

        // ———— 3. 合并关键与非关键实体的渲染结果 ————
        FinalizeFrame();
    }

    void ApplyDeterministicTick(PlayerInput[] inputs)
    {
        // 基于 inputs 完整推进所有关键实体的逻辑
        /* … */
    }

    void RenderSnapshotsAtTime(float t)
    {
        // 在 snapshotBuffer 中找出包围 t 的两帧快照并插值/外推
        /* … */
    }

    void FinalizeFrame()
    {
        // 将帧锁步(关键对象)与快照(非关键对象)渲染结果合成
        /* … */
    }
}

// ———— 支撑数据结构示例 ————
public struct PlayerInput
{
    public int   PlayerId;
    public byte  ButtonMask;    // 按键位域
    public float AxisX, AxisY;  // 摇杆输入
}

public class SnapshotData
{
    public int   Sequence;
    public float Timestamp;
    public List<EntityState> States;
}

public struct EntityState
{
    public int      Id;
    public Vector3  Position;
    public Quaternion Rotation;
    public Vector3  Velocity;
}

// 网络事件总线示例
public static class Network
{
    public static event Action<LockstepPacket> OnLockstepPacket;
    public static event Action<SnapshotPacket> OnSnapshotPacket;
}

public class LockstepPacket
{
    public int PlayerCount;
    public int Sequence;
    public PlayerInput[] Inputs;
}

public class SnapshotPacket
{
    public int Sequence;
    public SnapshotData Data;
}

3.2 痛点

问题 现象 原因
通道不同步 锁步对象卡顿、快照对象滑动 t_lockt_snap 缓冲不一致,网络抖动导致延迟差异
状态漂移 Lockstep 结果与 Snapshot 渲染位置错开 确定性仿真微小误差累积、插值平滑不足
复杂度上升 代码维护与测试成本成倍增长 需同时实现并验证两套同步逻辑及它们的交互
带宽压力 双通道流量叠加,特别是高 f_snap 时段 Snapshot 体量大,Lockstep Packet 虽小但频率高

3.3 优化策略

3.3.1 时钟自适应与抖动补偿

  • 动态 Buffer Delay
    将客户端渲染时钟延迟设为:

    $$\Delta_{\rm buf} = \frac{1}{f_{\rm snap}} + \alpha \times \mathrm{Jitter}_{\rm avg}$$

    • 𝛼 控制平滑延迟权衡;

    • J_avg 使用指数移动平均估算:

      $$J_{n}^{\rm avg} = \beta\ J_n + (1-\beta)\ J_{n-1}^{\rm avg}$$

  • 通道对齐
    在最终渲染前,取最小时间戳确保两通道同步:

t_common = min(t_lock_applied, t_snap_rendered)
RenderAllEntities(at t_common)

3.3.2 差异检测与回滚校正

  • Checkpoint 机制
    定期在锁步仿真中创建状态快照(Checkpoint),并在服务器 Snapshot 中携带该 Checkpoint 序号与状态。

  • 回滚重放

    if (Distance(localState, snapState) > ε) {
        // 回滚到 checkpointSeq
        state = checkpointState[checkpointSeq];
        for (int seq = checkpointSeq + 1; seq <= currentLockSeq; seq++)
            ApplyDeterministicTick(inputBuffer[seq]);
    }
    
    • ε 控制触发敏感度;
    • Checkpoint 间隔依据最大可容忍误差设置。

3.3.3 渲染融合与权重平滑

  • 加权融合
    对半关键实体按照网络质量动态调整:

    $$\mathbf{x}_{\rm render} = (1 - \beta)\mathbf{x}_{\rm lock}+\beta\mathbf{x}_{\rm snap}\quad\beta \in [0,1]$$

    • 网络稳定时提高 β,让 Snapshot 主导;
    • 网络抖动时降低 β,让 Lockstep 保底一致。
  • 实体分层

    • 关键:全程 Lockstep;
    • 半关键:加权融合;
    • 非关键:纯 Snapshot。

3.3.4 带宽压缩

  • Lockstep 通道
    • 输入仅为若干位域(按位打包),采用简单压缩;
  • Snapshot 通道
    • 差分压缩 + Interest Management,只发送视野内实体的 Delta。

3.4 综合伪代码示例

class HybridClient {
    Dictionary<int, Input[]>  inputBuffer;
    Dictionary<int, Snapshot> snapshotBuffer;
    int nextLockSeq = 0, recvLockSeq = -1, recvSnapSeq = -1;

    void OnReceiveLockstep(Packet p) {
        recvLockSeq = p.seq;
        inputBuffer[p.seq] = p.inputs;
    }
    void OnReceiveSnapshot(Packet p) {
        recvSnapSeq = p.seq;
        snapshotBuffer[p.seq] = p.snapshot;
    }

    void Update() {
        float now = Time.time;

        // ① Lockstep 仿真
        while (nextLockSeq <= recvLockSeq) {
            ApplyDeterministicTick(inputBuffer[nextLockSeq]);
            nextLockSeq++;
        }

        // ② Snapshot 渲染
        float t_render = now - bufferDelay;
        var (prev, next) = GetBoundingSnapshots(t_render);
        RenderSnapshots(prev, next, t_render);

        // ③ 检测漂移并校正
        var localC = GetCriticalState();
        var snapC  = snapshotBuffer[recvSnapSeq].GetCriticalState();
        if ((localC.pos - snapC.pos).sqrMagnitude > ε * ε)
            RollbackToCheckpoint();

        // ④ 半关键实体加权渲染
        foreach (var e in halfCriticalEntities) {
            var p_lock = GetLockState(e.id).pos;
            var p_snap = next.GetState(e.id).pos;
            e.renderPos = Vector3.Lerp(p_lock, p_snap, β);
        }

        // ⑤ 最终合并与提交
        FinalizeFrame();
    }
}

3.5 小结

混合方案最适合需要同时满足操作一致性大规模表现的游戏(如 RTS、MOBA、MMOARPG)。

  • 优点
    • 关键操作确保帧同步的一致性;
    • 大量实体走状态同步,减轻带宽与 CPU 压力。
  • 难点
    • 双通道架构复杂,测试与维护成本高;
    • 需设计回滚、对齐与融合策略,保证无缝体验。