GAS 系统深度学习指南UE 5.4 / 5.5

11 网络预测(Prediction)

目标:吃透 GAS 的客户端预测机制——为什么需要预测、预测边界、PredictionKey 如何工作、ScopedPredictionWindow 与 WaitNetSync 的用法、预测失败的回滚原理、预测生成 Actor 的分工、预测的局限(GE 移除/冷却)与 Instant GE 预测的"闪烁"问题。这是 GAS 最核心也最容易被"背错"的部分。


11.1 为什么需要预测

在标准客户端-服务端模型里,客户端任何操作都要等服务端确认后才看到结果(网络 RTT 往往 50~150ms),导致输入延迟感。GAS 让拥有者客户端先本地执行(播放动画、扣蓝、出特效),再异步等服务端"验证",从而获得即时手感——这就是预测(Prediction)。

通俗理解:就像在餐厅点菜,服务员(客户端)不等厨房(服务器)确认就先说"好的,马上来"。如果厨房能做,一切顺利;如果厨房说"这道菜卖完了",服务员再道歉并更正。

GAS 只对"拥有者客户端 + 本地预测"做预测。Simulated Proxy 永远不预测(其他玩家看到你放技能,是通过复制/表现通道,不是真的在那边跑技能)。

11.2 预测的边界(能预测什么)

可预测

内容 说明
能力激活(Ability Activation) 客户端先激活,服务端后验证
触发事件(Triggered Events) 事件驱动的激活
GE 应用 只有 Modifier 可预测,ExecCalc 不可预测
GameplayTag 修改 预测 GE 附加的标签
GameplayCue 事件 含预测性 GE 中的和独立触发的
动画蒙太奇(Montages) 播放/停止
移动 由 CharacterMovementComponent 内置支持

不可预测

内容 说明
GE 移除(GameplayEffect Removal) 必须等服务端确认
周期效果(Periodic Effects / DOT Tick) 服务器权威
ExecCalc 可执行任意复杂逻辑,无法保证两端一致

关键要点:只有 Modifier 可以预测,ExecCalc 不可预测。这是"伤害不预测"的根本原因之一。

Epic 的设计理念

"... we are also not all in on a 'predict everything seamlessly and automatically' solution. We still feel player prediction is best kept to a minimum (meaning: predict the minimum amount of stuff you can get away with)." —— Dave Ratti, Epic Games

Paragon 和 Fortnite 都不预测伤害。它们很可能使用 ExecCalc 进行伤害计算,而 ExecCalc 本身就不可预测。

11.3 预测要解决的六个核心问题

问题 说明 GAS 的解决方案
"我能做吗?" 基本的预测协议 客户端先执行,服务器后验证
"撤销" 预测错误时如何回滚 移除所有用该 PredictionKey 创建的预测 GE
"重做" 避免重复执行服务器复制的效果 匹配 PredictionKey,客户端移除预测版本
"完整性" 确保预测了所有副作用 要求开发者手动确保所有副作用都被预测
"依赖关系" 管理预测链 Scoped Prediction Window 管理依赖
"覆盖" 预测性覆盖服务器拥有的状态 预测 GE 覆盖服务器状态,服务器确认后移除

11.4 PredictionKey:预测的"对账单"

FPredictionKey 是客户端预测与服务端确认之间的唯一凭证。它是一对整数(Current + Base),由客户端生成并随 RPC 发给服务端。

完整生命周期

客户端                                              服务器
  │ 1. 激活能力,生成 Activation Prediction Key        │
  │ 2. 将 Key 添加到所有预测的 GE                       │
  │ 3. CallServerTryActivateAbility(Key) ──────────→   │
  │                                                   │ 4. 接收 Key
  │                                                   │ 5. 将 Key 添加到服务器的 GE
  │                                                   │ 6. 运行能力逻辑
  │ ←────────────────────────────────────────────   │ 7. 复制 Key 回客户端
  │ 8. 接收到复制的 Key(标记为过期)                    │
  │ 9. 移除所有用该 Key 创建的预测 GE                    │
  │ 10. 服务器的 GE 复制过来(预测正确)                 │

预测正确的场景

客户端:创建预测 GE(Key=5),生命值 100 → 80
服务器:创建服务器 GE(Key=5),生命值 100 → 80
客户端收到复制的 Key=5:
  - 移除预测 GE(临时回到 100)
  - 服务器 GE 复制过来(再次变 80)
  - 结果:最终生命值 = 80 ✓

预测错误的场景

客户端:创建预测 GE(Key=5),生命值 100 → 80
服务器:拒绝(目标有免疫),不创建 GE
客户端收到复制的 Key=5:
  - 移除预测 GE(回到 100)
  - 没有服务器 GE 复制过来
  - 结果:最终生命值 = 100 ✓(预测被回滚)

核心规则:客户端预测的任何东西,都必须把 PredictionKey 一起发给服务端,服务端用同一个 Key 执行后回传。 没有 Key 的操作,服务端不会"认领"为预测,客户端也永远等不到确认。

11.5 依赖预测键(Dependent Prediction Keys)

当预测嵌套发生时(技能 A 预测地激活技能 B,B 又施加 GE),需要依赖键保持链路:

客户端:激活 A(Key=1)→ 在 A 中激活 B(依赖 Key=1 的新 Key=2)
        → 在 B 中 ApplyGE(依赖 Key=2 的 Key=3)
服务端:以同样顺序执行,把 1、2、3 全部确认

依赖键保证:只要父操作未被确认,子操作也不可能先被确认;父操作被拒绝时,整条链回滚。这就是"为什么链式技能必须显式传递依赖键"的原因。

准确说法:引擎没有"MaxPredictionDepth=4"之类的配置项(旧资料有误)。预测深度受设计约束(每层嵌套都要维护依赖键),合理组织预测边界远比"调大上限"重要。经验法则是:每个技能内只预测"这一个技能的完整生命周期",不要把跨技能链无限嵌套。

11.6 ScopedPredictionWindow(核心 API)

FScopedPredictionWindow 是一个 RAII 作用域对象:构造时让 ASC 进入"预测窗口",析构时退出,窗口内的操作被标记为"本预测键的一部分"。

// 标准用法:施加一个"可预测的 GE"
void UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf_Predictive(
    const FGameplayEffectSpec& Spec, FPredictionKey InPredictionKey)
{
    FPredictionKey PredictionKey;
    if (IsOwnerActorAuthoritative())          // 拥有者本地:生成键
    {
        PredictionKey = CreatePredictionKeyForNewServerAction();
    }
    else                                      // 服务端:用客户端带来的键
    {
        PredictionKey = InPredictionKey;
    }

    FScopedPredictionWindow ScopedPredictionWindow(this, PredictionKey, true);
    ApplyGameplayEffectSpecToSelf(Spec);      // 窗口内的操作被记录
}

要点:

  • 拥有者客户端:窗口内新生成键,操作立即本地执行(预测);
  • 服务端:用客户端传来的键开窗口,权威执行并回传确认;
  • 不加 ScopedPredictionWindow 就直接 Apply 的 GE,不会走预测链路(服务端会正常执行,但客户端没有"本地先行")。

预测技能的标准生命周期

以 LocalPredicted 技能为例(TryActivateAbility 内部就是此流程):

客户端调用 TryActivateAbility
  → ASC 客户端:
      CreatePredictionKey(Current=++, Base=前键)
      FScopedPredictionWindow(Key)
      ServerTryActivateAbility(Handle, InputPressed, Key)   // RPC
      ClientTryActivateAbility → 本地立即执行 ActivateAbility
  → 服务端收到 ServerTryActivateAbility:
      FScopedPredictionWindow(Key)
      校验 CanActivate → 权威 ActivateAbility
      执行 CommitAbility(扣蓝/启 CD)
      复制结果回客户端
  → 客户端收到复制结果:
      预测键被确认 → 本地表现采用服务端最终状态
      若被拒绝 → 回滚本地效果

客户端预测性地"扣蓝"为何不会双扣

CommitAbility 在客户端预测性执行(本地立刻看到 MP 下降),服务端用同一个预测键权威执行。由于该 GE 带预测键,客户端本地那份"效果"在服务端结果到达时被标记为"已被服务端认领",不会再次应用——这就是为什么"预测扣蓝不会双扣"。

11.7 Scoped Prediction Window:在 Task 回调中开新窗口

为什么需要新窗口

Prediction Key 在能力激活时生成,只在同一帧内有效。如果一个 AbilityTask 的回调在后续帧执行(等待输入、等待延迟),原来的 Prediction Key 就过期了——回调中应用的 GE 无法被预测。

哪些 Task 自带新窗口

一些输入相关 AbilityTask 内置 Synch Point,会自动生成新的 Scoped Prediction Key:

  • WaitInputPress
  • WaitInputRelease
  • WaitConfirmCancel
  • 其他输入相关任务

手动开新窗口:WaitNetSync

对没有内置 Synch Point 的 Task(如 WaitDelay),用 WaitNetSync(OnlyServerWait 模式)手动创建:

  • 客户端:生成新的 Scoped Prediction Key → RPC 发给服务器 → 继续执行;
  • 服务器:等待接收客户端的 Key → 收到后继续执行。
// 等待延迟后的回调需要新的预测窗口
void UMyGameplayAbility::OnDelayFinished()
{
    UAbilityTask_WaitNetSync* SyncTask =
        UAbilityTask_WaitNetSync::WaitNetSync(this, EAbilityTaskNetSyncType::OnlyServerWait);
    SyncTask->OnSync.AddDynamic(this, &UMyGameplayAbility::OnSyncReady);
    SyncTask->ReadyForActivation();
}

void UMyGameplayAbility::OnSyncReady()
{
    // 现在有了新的 Scoped Prediction Key,可以预测性地应用 GE
    FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(CostGE, GetAbilityLevel());
    ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, SpecHandle);
}

WaitNetSync 的安全问题

注意:WaitNetSync(OnlyServerWait) 会让服务器阻塞等待客户端的 RPC。恶意玩家可以故意延迟发送,造成"延迟开关"式作弊。

Epic 在 Paragon 的应对:需要即时确认的 Targeting Task 加超时机制(超时后服务器自行生成 TargetData 或取消能力);需要玩家确认的 Targeting 模式不设超时(玩家本来就需要时间瞄准)。

最佳实践:安全优先时避免 WaitNetSync,改用其他方式。Fortnite 的武器系统完全不使用 WaitNetSync——它把武器能力的激活、TargetData 发送、能力结束全部合并到一个 RPC 中(Ability Batching)。

场景:冲刺能力持续消耗体力的预测

void UGA_Sprint::ActivateAbility(...)
{
    GetCharacterMovement()->SetMovementMode(MOVE_Custom, CMOVE_Sprint);
    StartSprintCostLoop();   // 每帧消耗体力需要持续预测
}

void UGA_Sprint::StartSprintCostLoop()
{
    UAbilityTask_WaitDelay* WaitTask =
        UAbilityTask_WaitDelay::WaitDelay(this, SprintCostInterval);
    WaitTask->OnFinish.AddDynamic(this, &UGA_Sprint::OnCostIntervalReady);
    WaitTask->ReadyForActivation();
}

void UGA_Sprint::OnCostIntervalReady()
{
    // 每次消耗前创建新的预测窗口
    UAbilityTask_WaitNetSync* SyncTask =
        UAbilityTask_WaitNetSync::WaitNetSync(this, EAbilityTaskNetSyncType::OnlyServerWait);
    SyncTask->OnSync.AddDynamic(this, &UGA_Sprint::ApplySprintCost);
    SyncTask->ReadyForActivation();
}

void UGA_Sprint::ApplySprintCost()
{
    FGameplayEffectSpecHandle SpecHandle =
        MakeOutgoingGameplayEffectSpec(SprintCostGE, GetAbilityLevel());
    ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, SpecHandle);
    StartSprintCostLoop();   // 继续下一个周期
}

11.8 预测生成 Actor(投射物分工)

GAS 不提供开箱即用的预测 Actor 生成功能。SpawnActor AbilityTask 只在服务器上生成 Actor。

纯表现 Actor

如果 Actor 只是表现性的,限制服务器的复制范围即可:

// 拥有者用本地生成的版本,其他客户端用服务器版本
bool AMyCosmeticActor::IsNetRelevantFor(
    const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
    return !IsOwnedBy(ViewTarget);
}

游戏逻辑 Actor(投射物)——UnrealTournament 方案

客户端:生成"虚拟投射物"(Dummy Projectile,立即看到飞出)
服务器:生成真实的复制投射物(权威命中判定)
当服务器的投射物复制到客户端时 → 同步位置 → 虚拟投射物与服务器投射物"汇合"

标准分工(GASShooter 等项目的做法):

客户端(拥有者):
  ├─ 生成"预测投射物"(视觉本体,能立即看到飞出去)
  └─ 发送 ServerSpawnProjectile(带预测键)→ 服务端
服务端:
  ├─ 用同一预测键生成"权威投射物"(负责命中判定/伤害)
  └─ 命中后按权威数据广播伤害
客户端:
  ├─ 服务端确认 → 销毁/同步预测本体为权威体
  └─ 若被拒 → 销毁预测本体(没放出来)

核心原则:预测只管"视觉与手感",伤害权威永远在服务端。

11.9 Instant GE 的预测特性

对自己的 Cost GE:无缝预测

Instant GE(如 Cost GE)对自己属性修改可无缝预测:

// 法力消耗 Cost GE(Instant)
// 预测时:客户端立即扣蓝
// 服务器确认后:服务器的 GE 复制过来(同一预测键,不会双扣)

对其他角色的 Instant GE:短暂"闪烁"

当你预测性地对其他角色应用 Instant GE 时,会出现短暂属性值"闪烁":

客户端预测:敌人生命值 100 → 80(预测的 Instant GE 被当作 Infinite GE 处理,为了能回滚)
服务器确认:敌人生命值 100 → 80(服务器 Instant GE 复制到客户端)

问题:客户端同时有两个 GE 存在(预测的 Infinite + 服务器的 Instant)
结果:生命值可能出现短暂的 60(100-20-20)然后回到 80

原因:预测的 Instant GE 被当作 Infinite GE 处理(这样预测错误才能回滚)。服务器 Instant GE 到达时,两个效果短暂叠加。

最佳实践:不要预测对其他角色的 Instant GE 伤害。这是 Paragon 和 Fortnite 不预测伤害的主要原因之一。

11.10 预测的局限性与应对

GE 移除不可预测

限制:GAS 不能预测 GE 的移除。移除减速效果必须等服务端确认,玩家会感受到延迟。

Dave Ratti 表示曾尝试添加"GE 移除预测",但未解决所有边界情况。

变通方案:预测性地添加"反向效果"来抵消:

// 预测一个 40% 减速
ApplyGameplayEffectSpecToOwner(SlowGE_40);
// 想要移除减速时,预测性地添加一个 40% 加速
ApplyGameplayEffectSpecToOwner(SpeedBuff_40);
// 然后同时移除两个效果

注意:不适用于所有场景,特别是冷却。无法用"反向冷却"预测冷却结束。

冷却预测的固有缺陷

玩家 A(50ms 延迟)和玩家 B(200ms 延迟)都有 1 秒冷却的技能:
  A:激活 → 50ms 后服务器确认 → 1s 后冷却结束 → 50ms 后客户端收到 ≈ 1.1s 间隔
  B:激活 → 200ms 后服务器确认 → 1s 后冷却结束 → 200ms 后客户端收到 ≈ 1.4s 间隔
→ 高延迟玩家射速天然劣势!

Fortnite 的解决方案:不使用 GE 做武器冷却,用自定义计时器管理射击速率,完全绕过 GAS 冷却系统。

Dave Ratti: "Fortnite does its own bookkeeping for weapon firing rates: it does not use GEs for cooldowns on weapons. I would recommend this if this is a critical problem for your game."

Network Prediction Plugin(未来)

Epic 正在开发新插件,旨在替代 CharacterMovementComponent 并与 GAS 深度集成:移除 Prediction Key 显式交换、改用 NetworkPrediction frames、支持 GE 移除预测、更好的物理预测。目前仍在早期开发中,未稳定到生产可用。

11.11 回滚(Rollback)与预测失败

发生回滚的典型场景:

场景 结果
客户端预测激活,服务端判定 CanActivate 失败(如其实在冷却) 服务端回绝该 Key → 客户端回滚动画/状态,技能取消
客户端预测造成属性变化,服务端计算出不同值 以服务端值为准覆盖
依赖键链路中父级失败 整条链回滚

GAS 的回滚机制:

  • 属性回滚:ASC 记录预测期间的属性快照(PredictionMap),Key 被拒时恢复;
  • 效果回滚:预测性添加的 ActiveGE 在 Key 被拒时移除;
  • 动画回滚:Montage 的预测/回滚由 Task 的键管理,被拒时回到预测前状态。

调试预测问题要同时看客户端与服务端两份日志(LogAbilitySystem),比对"哪个键被拒绝、为什么"。别只盯一端。

11.12 常见陷阱清单

陷阱 说明与应对
忘记预测键已过期 Task 回调中直接 ApplyGE(回调在后续帧,原 Key 已过期)→ 效果"闪烁"。应对:回调前用 WaitNetSync 或自带 Synch Point 的 Task 开新窗口
试图预测 GE 移除 客户端移除了但服务器不知道 → 状态不一致。应对:用"反向效果"模拟移除
预测敌人伤害 客户端预测敌人死亡 → 布娃娃;服务器说没死 → 敌人"复活",体验极差。应对:伤害只在服务器算
冷却预测延迟劣势 高延迟玩家射速天然劣势。应对:参考 Fortnite 自定义计时器
WaitNetSync 安全风险 恶意延迟 RPC 卡住服务器能力。应对:超时机制 / 尽量少用 / 用 Ability Batching 替代
Instant GE 对他人预测闪烁 预测的 Instant 被当 Infinite 处理导致双叠。应对:不预测对他人伤害

11.13 使用场景分析

场景一:预测冲刺(移动速度变化)

按下 Shift → 预测性应用冲刺 GE(Modifier 改移速,可预测)
  → 客户端立即加速 → 服务器确认 → 移除预测 GE 保留服务器 GE → 无缝
  • Duration GE + Modifier(可预测)+ 不用 ExecCalc。

场景二:预测射击(动画预测,不预测伤害)

点击鼠标 → 预测播放射击动画和音效 → RPC 到服务器
  → 服务器用 ExecCalc 计算伤害 → 应用伤害 GE → 生成命中 GameplayCue
  • 动画/音效预测 ✓;伤害不预测 ✓;GameplayCue 通过 GE 自动触发。

场景三:预测消耗(法力/体力扣除)

按技能键 → 预测扣除法力(Cost GE, Instant)→ 动画开始 → 服务器确认
  → 法力不足则服务器拒绝 → 客户端回滚
  • Cost GE 用 Modifier(可预测)、对自己 Instant 无缝预测、持续消耗用 WaitNetSync。

场景四:为什么不该预测伤害(四理由)

1. 预测错误 → "血条跳动"(-20 血跳回原值,玩家困惑)
2. 预测死亡更危险 → 敌人布娃娃后"复活"继续攻击
3. ExecCalc 不可预测 → 复杂公式无法保证两端一致
4. Epic 经验 → Paragon/Fortnite 都不预测伤害,Dave Ratti: "I personally do not recommend it"

11.14 本章验证

  • [ ] 配置一个 LocalPredicted 技能,验证客户端"立即看到动画/扣蓝"而服务端权威结算;
  • [ ] 故意让服务端拒绝(把冷却调短让客户端误判),观察客户端回滚动画;
  • [ ] 实现预测性投射物(客户端视觉本体 + 服务端权威体),验证命中判定只认服务端;
  • [ ] 用 WaitNetSync 实现冲刺持续消耗体力的预测;
  • [ ] 能解释"预测扣蓝为什么不会双扣";
  • [ ] 能解释"预测 Instant GE 对他人为什么会闪烁";
  • [ ] 设计一个射击能力的预测策略:哪些预测、哪些不预测、为什么。

下一章:12 网络同步与复制(Replication)