# 预测与回滚 ⭐

兼顾低延迟和一致性的终极方案;竞技格斗游戏的核心技术

前置知识:第03章 帧同步Lockstep + 第04章 状态同步


阅读指南(初学者必看)

为什么你需要学习预测与回滚?

帧同步操作延迟大(等所有人输入),状态同步画面延迟大(等服务器确认)。预测与回滚结合了两者优点:先预测推进,错了再回滚——这是目前竞技格斗游戏的标配技术(GGPO)。

学完本章,你能回答:

  • GGPO 的核心原理是什么?预测+回滚怎么配合?
  • Rollback Netcode 的完整实现是怎样的?
  • 输入延迟为什么反而能减少回滚?设几帧合适?
  • 状态保存与恢复怎么实现?内存开销多大?
  • Source引擎的延迟补偿是怎么做的?

本文结构

第一部分:GGPO(Good Game Peace Out)——预测回滚的核心原理
第二部分:Rollback Netcode 完整实现
第三部分:输入延迟——人为增加延迟来减少回滚
第四部分:状态保存与恢复——回滚的工程实现
第五部分:延迟补偿(Lag Compensation)——让高延迟玩家也公平

一、GGPO(Good Game Peace Out)

生活类比:预测回滚就像写文章。你写了一段(预测),编辑说"这里不对"(服务器校正),你就从那个地方重写(回滚+重新模拟)。

核心思想:
1. 客户端预测:不等其他玩家输入,假设他们不变,先推进
2. 输入到达:如果预测正确,继续;如果错误,回滚到预测前状态,用正确输入重新模拟
3. 状态保存:每帧保存完整状态快照,用于回滚

流程:
Frame N:
  保存状态快照 snapshot[N]
  预测其他玩家输入 = 上一帧输入
  模拟一帧
  
Frame N+1:
  收到玩家A的真实输入(和预测不同!)
  回滚到 snapshot[N]
  用真实输入重新模拟 Frame N
  继续模拟 Frame N+1

二、Rollback Netcode 完整实现

class RollbackNetcode {
  constructor() {
    this.currentFrame = 0;
    this.inputBuffer = new Map();  // frame -> { playerId: input }
    this.gameStates = new Map();    // frame -> gameState(用于回滚)
    this.maxRollbackFrames = 8;    // 最多回滚8帧
  }
  
  // 保存当前帧的状态
  saveState() {
    const state = JSON.parse(JSON.stringify(this.gameState));
    this.gameStates.set(this.currentFrame, state);
    
    // 只保留最近的N帧状态
    const toDelete = this.currentFrame - this.maxRollbackFrames;
    this.gameStates.delete(toDelete);
  }
  
  // 执行一帧
  executeFrame() {
    // 保存当前状态
    this.saveState();
    
    // 获取所有玩家的输入
    const localInput = this.getLocalInput();
    const remoteInput = this.inputBuffer.get(this.currentFrame);
    
    // 如果远程输入还没到,预测
    const allInputs = {
      local: localInput,
      remote: remoteInput || this.predictRemoteInput()
    };
    
    // 执行游戏逻辑
    this.applyInputs(allInputs);
    this.updateGameState();
    this.render();
    
    this.currentFrame++;
  }
  
  // 收到远程输入
  onRemoteInput(frame, input) {
    this.inputBuffer.set(frame, input);
    
    // 检查是否和预测不同
    if (frame < this.currentFrame) {
      // 这个帧已经执行过了
      // 需要回滚!
      this.rollbackToFrame(frame);
    }
  }
  
  // 回滚到指定帧
  rollbackToFrame(targetFrame) {
    // 1. 恢复目标帧的状态
    this.gameState = JSON.parse(JSON.stringify(this.gameStates.get(targetFrame)));
    this.currentFrame = targetFrame;
    
    // 2. 从目标帧重新执行到当前帧
    while (this.currentFrame < this.savedCurrentFrame) {
      const localInput = this.savedLocalInputs.get(this.currentFrame);
      const remoteInput = this.inputBuffer.get(this.currentFrame);
      
      if (remoteInput) {
        // 用真实的远程输入
        this.applyInputs({ local: localInput, remote: remoteInput });
      }
      
      this.updateGameState();
      this.currentFrame++;
    }
    
    // 3. 恢复渲染
    this.render();
  }
  
  // 预测远程输入
  predictRemoteInput() {
    // 最简单的预测:假设和上一帧一样
    const prevInput = this.inputBuffer.get(this.currentFrame - 1);
    return prevInput || { action: 'idle' };
  }
}

三、输入延迟

输入延迟 = 人为增加的操作延迟

为什么要有输入延迟?
- 给网络传输留出时间
- 输入延迟 2 帧 = 33ms → 在这 33ms 内,其他玩家的输入可以到达
- 减少需要回滚的次数

设置:
- 本地网络好:1~2 帧延迟
- 网络一般:3~5 帧延迟
- 网络差:5~7 帧延迟

格斗游戏通常固定 2~3 帧输入延迟

四、状态保存与恢复

class RollbackManager {
  constructor(maxFrames = 60) {
    this.snapshots = new Map(); // frame → 状态快照
    this.maxFrames = maxFrames;
  }
  
  save(frame, gameState) {
    this.snapshots.set(frame, deepClone(gameState));
    // 清理过老的快照
    const oldest = frame - this.maxFrames;
    for (const key of this.snapshots.keys()) {
      if (key < oldest) this.snapshots.delete(key);
    }
  }
  
  restore(frame) {
    return deepClone(this.snapshots.get(frame));
  }
  
  rollback(fromFrame, toFrame, correctInputs) {
    // 1. 恢复到 toFrame 的状态
    let state = this.restore(toFrame);
    
    // 2. 用正确输入重新模拟
    for (let f = toFrame; f <= fromFrame; f++) {
      simulateOneFrame(state, correctInputs[f]);
      this.save(f, state); // 保存重新模拟后的状态
    }
    
    return state;
  }
}

内存开销

  • 格斗游戏每帧状态可能只有几 KB,保存 60 帧约 300KB
  • MMO 的状态可能几 MB,保存 60 帧就是几百 MB
  • 所以预测回滚主要用于状态较小的竞技游戏

五、延迟补偿(Lag Compensation)

什么是延迟补偿?

延迟补偿 = 让高延迟玩家和低延迟玩家体验一致

问题:
玩家A(延迟10ms)射击玩家B(延迟100ms)
A看到B在位置X,发射子弹
但B在100ms前已经移动到位置Y
子弹打不中B → A觉得不公平

解决方案:服务器回退时间
服务器收到A的射击请求时,
回退到10ms前的状态(A看到的画面),
在那个状态下判定是否命中。

Source引擎的延迟补偿实现

class LagCompensation {
  constructor() {
    this.history = [];  // 过去的游戏状态
    this.maxHistory = 1000;  // 保存1秒的历史(1000ms)
  }
  
  // 每帧保存状态
  saveHistory(timestamp, state) {
    this.history.push({ timestamp, state });
    
    // 删除太旧的历史
    while (this.history.length > 0 && 
           timestamp - this.history[0].timestamp > this.maxHistory) {
      this.history.shift();
    }
  }
  
  // 处理射击,使用延迟补偿
  processShot(shooterId, aimPosition, shotTimestamp) {
    // shotTimestamp = 射击者的本地时间
    // 找到射击者看到的世界状态
    const historicalState = this.findStateAt(shotTimestamp);
    
    if (!historicalState) {
      // 历史太旧,用当前状态
      return this.processShotCurrentState(shooterId, aimPosition);
    }
    
    // 在历史状态下判定命中
    const hitPlayer = this.checkHit(historicalState, aimPosition);
    
    if (hitPlayer) {
      // 命中!应用到当前状态
      this.applyDamage(hitPlayer, shooterId);
      return { hit: true, playerId: hitPlayer.id };
    }
    
    return { hit: false };
  }
  
  // 找到指定时间的状态
  findStateAt(timestamp) {
    for (let i = this.history.length - 1; i >= 0; i--) {
      if (this.history[i].timestamp <= timestamp) {
        return this.history[i].state;
      }
    }
    return null;
  }
}

六、GGPO 同步点校验

// 关键概念:同步点(Sync Point)
// 每隔N帧,所有客户端交换状态哈希,验证一致性

class GGPOClient {
  constructor() {
    this.syncInterval = 60;  // 每60帧同步一次
    this.frameCount = 0;
    this.stateChecksum = 0;
  }
  
  executeFrame() {
    this.frameCount++;
    super.executeFrame();
    
    // 定期同步
    if (this.frameCount % this.syncInterval === 0) {
      this.checksum = this.calculateChecksum();
      this.sendChecksum(this.checksum);
    }
  }
  
  onRemoteChecksum(checksum) {
    if (checksum !== this.checksum) {
      console.error('状态不同步!需要重新同步');
      this.resync();
    }
  }
  
  calculateChecksum() {
    // 用游戏状态计算一个哈希值
    let hash = 0;
    for (const player of Object.values(this.gameState.players)) {
      hash = ((hash << 5) - hash + player.x) | 0;
      hash = ((hash << 5) - hash + player.y) | 0;
      hash = ((hash << 5) - hash + player.hp) | 0;
    }
    return hash;
  }
}

自问自答

Q:预测回滚和帧同步是什么关系? A:预测回滚是帧同步的增强。帧同步必须等所有输入到齐才能推进帧,预测回滚允许先预测推进,错了再回滚。本质是在帧同步基础上加了"预测"和"回滚"两层。

Q:回滚会不会导致画面闪烁? A:会,这是预测回滚的主要视觉问题。解决方案:1)在回滚重模拟后,对角色位置做插值平滑而不是直接跳转;2)适当增加输入延迟减少回滚频率;3)视觉上用动画过渡掩盖位置跳变。

Q:状态快照的内存开销大吗? A:取决于游戏状态大小和保存帧数。一个格斗游戏的状态可能只有几 KB,保存 60 帧约 300KB。但一个 MMO 的状态可能几 MB,保存 60 帧就是几百 MB。所以预测回滚主要用于状态较小的竞技游戏。

Q:为什么格斗游戏固定 2~3 帧输入延迟? A:23 帧 ≈ 3350ms,人类感知不到这个延迟。但这个延迟足够让大多数本地网络对局中其他玩家的输入到达,从而大幅减少回滚次数。是延迟体验和回滚频率的最佳平衡点。

Q:回滚网络代码能用在H5游戏中吗? A:可以。但H5游戏的性能限制(JavaScript执行速度)意味着回滚帧数不宜过多(建议<5帧)。简单的预测+回滚就足够提升体验。


实践任务

  • 任务1:实现 GGPO 风格的预测回滚 demo(双人,预测输入+回滚重模拟)
  • 任务2:对比有无预测回滚的操作体验——在 100ms 延迟下,操作手感差异有多大
  • 任务3:测试不同网络延迟下的回滚频率,绘制"延迟 vs 回滚次数"图表
  • 任务4:实现输入延迟可调功能,找到最佳输入延迟值
  • 任务5:优化回滚后的视觉表现——添加位置插值平滑,消除画面闪烁
  • 任务6:实现延迟补偿系统,模拟高延迟玩家射击低延迟玩家的场景

与其他章节的关联

本章内容 关联章节 关联点
预测机制 第04章 状态同步 状态同步也有客户端预测,但不回滚——只校正
回滚重模拟 第03章 帧同步 回滚本质是帧同步+预测,确定性是前提
状态快照 第04章 状态同步 状态同步的快照用于增量更新,预测回滚的快照用于回滚
输入延迟 第06章 自定义UDP协议与KCP 输入延迟需要可靠的输入传输,自定义UDP可以优化
延迟补偿 第08章 游戏安全与反作弊 延迟补偿可能被滥用,需要服务端校验
反作弊 第08章 游戏安全与反作弊 预测回滚的反作弊难度介于帧同步和状态同步之间

上一章:04-状态同步 下一章:06-自定义UDP协议与KCP