原文来自
Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/Public/GameplayPrediction.h
做一个翻译和注解
概述
在 GameplayAbility 层面(实现某个 Ability 时),预测是透明的。一个 Ability 只需描述"执行 X→Y→Z",系统会自动对其中可以预测的部分进行预测。
我们希望避免在 Ability 内部出现"如果是权威端:执行 X;否则:执行 X 的预测版本"这样的逻辑。
目前并非所有情况都已解决,但我们已经拥有一个非常扎实的客户端预测框架。
我们所说的"客户端预测",真正的含义是客户端对游戏仿真状态进行预测。有些东西仍然可以"完全在客户端"运行,而无需进入预测系统。
例如,脚步声完全是客户端本地的,从不与该系统交互。但客户端预测施法时蓝量从 100 降到 90,才是"客户端预测"。
预测内容
预测哪些内容:
- 初始 GameplayAbility 激活(以及有条件的链式激活)
- 触发事件(Triggered Events)
- GameplayEffect 应用:
- 属性修改(例外:Executions 目前不支持预测,仅属性修改器支持)
- GameplayTag 修改
- Gameplay Cue 事件(包括在预测 GameplayEffect 内部触发的,以及独立触发的)
- 蒙太奇(Montages)
- 移动(内置于 UE 的 UCharacterMovement)
不预测哪些
- GameplayEffect 的移除
- GameplayEffect 的周期性效果(持续伤害跳点等)
用过的比如PlayMontaageAndWait 客户端预测性执行。服务器拒绝后会终止这个蒙太奇。
客户端预测性修改属性,服务器拒绝后,不数值改回去。但是属性是否需要预测给基于业务。
例如血条预测性扣掉,又加回来很丑。但是exec policy 是local perdict又是整个activity包裹的。
所以其实 ability sys global 中有一个 关闭GE预测的选项。
基本原理
- "我能做这件事吗?" — 预测的基本协议。
- "撤销(Undo)" — 预测失败时,如何回滚副作用。
- "重做(Redo)" — 如何避免重复执行本地已预测、且服务器也复制下来的副作用。
- "完整性(Completeness)" — 如何确保我们真正预测了所有副作用。
- "依赖(Dependencies)" — 如何管理依赖性预测和链式预测事件。
- "覆盖(Override)" — 如何以预测方式覆盖原本由服务器复制/持有的状态。
这几点我们了解完细节后再解答这几个问题。
PredictionKey
该系统的核心概念是预测键(FPredictionKey)。预测键本身只是一个在客户端某个中心位置生成的唯一 ID。客户端将预测键发送给服务器,并将预测动作和副作用与该键关联。服务器可以对预测键做出接受/拒绝的响应,同时也会将服务器端产生的副作用与该预测键关联。
(重要) FPredictionKey 始终从客户端复制到服务器,但在服务器向客户端复制时,它只复制给最初发送该预测键的那个客户端。
这在 FPredictionKey::NetSerialize 中实现。所有其他客户端在通过复制属性接收到来自客户端的预测键时,将收到一个无效(0)的预测键。
===
解析:
客户端生成一个Key=1,发给服务器。服务器同一作用域代码内拥有同一个key=1。
(第二段的意思)在广播的时候,主客户端会收到这个Key,这样就知道是我发的,我先行了。这次服务器的广播的执行对我无效。其他客户端得到了一个key=0,就会执行这个。这样预测客户端就不会重复执行预测的行为,前面的“重做”
Ability Activation / Ability 激活
Ability 激活是一种一等(first-class)预测动作——它会生成一个初始预测键。每当客户端预测性地激活某个 Ability 时,它会显式地向服务器发起请求,服务器也会显式地做出响应。一旦 Ability 被预测性激活(但请求尚未发送),客户端就进入一个有效的"预测窗口",在此窗口内发生的预测副作用无需被显式询问。(例如,我们不会显式询问"我能扣蓝吗?我能让这个 Ability 进入冷却吗?"这些动作被视为与激活 Ability 在逻辑上是原子性的。)你可以将这个预测窗口理解为 ActivateAbility 的初始调用栈。一旦 ActivateAbility 返回,你的预测窗口(以及预测键)就不再有效。这一点非常重要,因为蓝图中的定时器或延迟节点等许多因素都可能使预测窗口失效;我们不跨帧进行预测。
AbilitySystemComponent 提供了一组函数,用于在客户端和服务器之间传递 Ability 激活信息:
TryActivateAbility → ServerTryActivateAbility → ClientActivateAbility(Failed/Succeed)。
- 客户端调用
TryActivateAbility,生成新的FPredictionKey并调用ServerTryActivateAbility。 - 客户端不等待服务器响应,继续执行,以生成的 PredictionKey 调用
ActivateAbility,该键与 Ability 的ActivationInfo关联。 - 在
ActivateAbility调用完成之前发生的所有副作用都与生成的FPredictionKey关联。 - 服务器在
ServerTryActivateAbility中决定 Ability 是否真正发生,并调用ClientActivateAbility(Failed/Succeed),同时将UAbilitySystemComponent::ReplicatedPredictionKey设置为客户端请求中携带的预测键。 - 若客户端收到
ClientAbilityFailed,立即终止 Ability 并回滚与该预测键关联的副作用。- 5a. "回滚"逻辑通过
FPredictionKeyDelegates和FPredictionKey::NewRejectedDelegate/NewCaughtUpDelegate/NewRejectOrCaughtUpDelegate注册。 - 5b.
ClientAbilityFailed实际上是唯一会"拒绝"预测键的情况,因此当前所有预测都依赖于 Ability 是否成功激活。
- 5a. "回滚"逻辑通过
- 若
ServerTryActivateAbility成功,客户端必须等待属性复制追上来(Succeed RPC 会立即发送,属性复制会自行完成)。一旦ReplicatedPredictionKey追上前述步骤中使用的键,客户端就可以撤销其预测的副作用。
参见FReplicatedPredictionKeyItem::OnRep了解 CatchUpTo 逻辑,参见UAbilitySystemComponent::ReplicatedPredictionKeyMap了解键的实际复制方式,参见~FScopedPredictionWindow了解服务器如何确认键。
===
解析:
TryActivateAbility 是激活技能的接口。他会根据激活策略RPC到对应的进程。
(LocalPredicted, LocalOnly, ServerInitiated, ServerOnly 本地预测,本地执行,服务器初始化执行如果有本地客户端也执行,只服务器执行)
如果是LocalPredicted,客户端执行技能的时候,会生成一个预测Key,本地接着跑,同时也RPC服务器带着这个key执行技能。执行过程中的所有副作用都与这个key关联。比如蒙太奇,GE,Attr修改等。
如果犹豫某种原因导致了技能失败。ClientActivateAbilityFailed 会在里面的客户端回调进行回滚。各种回滚逻辑会往上注册。
如果成功执行,客户端就会撤销预测,确认行为。
哪些东西有预测的逻辑。
接下来是
GE,属性修改,Cue事件,Triggered Data,是如何预测和回滚的,有什么细节。
几个副作用的预测
GameplayEffect 预测
Lyra直接关掉了。只在服务器跑GE。
- 只有存在有效预测键时,GameplayEffect 才会在客户端被应用。
- 若 GameplayEffect 被预测,属性、GameplayCue 和 GameplayTag 均被一并预测。
- 创建
FActiveGameplayEffect时,会存储预测键(FActiveGameplayEffect::PredictionKey)。即时效果详见下文"属性预测"。 - 在服务器端,同样的预测键也被设置在将要向下复制的服务器
FActiveGameplayEffect上。 - 客户端收到带有有效预测键的复制
FActiveGameplayEffect时,会检查是否已有具有相同键的ActiveGameplayEffect;若匹配,则不执行"应用时"类型的逻辑(例如 GameplayCue),以此解决"Redo"问题。但在ActiveGameplayEffects容器中会临时存在两个"相同"的 GameplayEffect。 - 与此同时,
FReplicatedPredictionKeyItem::OnRep会追上来,预测性效果将被移除。在此情况下移除时,我们再次检查 PredictionKey,并决定是否跳过"移除时"逻辑 / GameplayCue。
===
解析:
创建GE的时候,客户端和服务器同时跑。并且 FActiveGameplayEffect 会存储预测key。
服务器的 FActiveGameplayEffect 复制的时候(这是一个fast array)客户端收到GE,会检查key是否相同
相同就不执行,来解决redo的问题,(但是容器会临时存储两个相同key的GE)
FReplicatedPredictionKeyItem::OnRep 在服务器确认GE的时候会追上来,预测性逻辑移除,正式的GE效果顶位。
同时这里也要处理效果消失又出现的问题。会再次检查key
属性预测
由于属性是作为标准 uproperty 复制的,预测对其的修改可能很棘手("Override"问题)。即时修改更难,因为其本质上是无状态的。
基本思路是将属性预测视为增量预测而非绝对值预测。我们不预测自己有 90 点蓝,而是预测相对于服务器值有 -10 点蓝,直到服务器确认预测键。本质上,在预测性执行期间,将即时修改视为无限持续时间的属性修改。这解决了"Undo"和"Redo"问题。
对于"Override"问题,在属性的 OnRep 中通过将复制的(服务器)值视为属性的"基础值"而非"最终值"来处理,并在复制发生后重新聚合"最终值"。
- 将预测性即时 GameplayEffect 视为无限持续时间的 GameplayEffect。参见
UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf。 - 必须始终接收属性的 RepNotify 回调(不仅仅是在与上次本地值不同时)。通过
REPNOTIFY_Always实现。【非常重要】 - 在属性 RepNotify 中,调用
AbilitySystemComponent::ActiveGameplayEffects以根据新"基础值"更新"最终值",GAMEPLAYATTRIBUTE_REPNOTIFY宏可完成此操作。 - 其余逻辑与上文 GameplayEffect 预测相同:预测键被追上时,预测性 GameplayEffect 被移除,属性回归服务器给定值。
===
分析:
属性修改本质是无状态的,预测和回滚有点难。
所以我们不预测最终值,而是预测我们有 -10 的变化量。直到服务器确认前,这个修改是无限时间的。
这解决了Undo 和 Redo。(因为服务器的属性直接复制下来覆盖和取消预测就好了)
上面第二点,预测的属性需要标记 REPNOTIFY_Always (Lyra的属性都是这个,另一个是OnChange)
然后在OnRep中 GAMEPLAYATTRIBUTE_REPNOTIFY
Gameplay Cue 事件
在 GameplayEffect 之外,Gameplay Cue 也可以独立激活。相关函数(如 UAbilitySystemComponent::ExecuteGameplayCue)会考虑网络角色和预测键。
- 在
UAbilitySystemComponent::ExecuteGameplayCue中,若为权威端则执行多播事件(携带复制键);若为非权威端但具有有效预测键,则预测该 GameplayCue。 - 在接收端(如
NetMulticast_InvokeGameplayCueExecuted),若存在复制键,则不执行事件(假设已经预测过了)。
注意:FPredictionKey 只复制给发起方 Owner。
===
这个和GE有点像,似乎都一样。客户端一个key,发给服务器。服务器广播的时候,除了Owner都清空key。
Owner检测到相同的key就不播放key,
Triggered Data Prediction / 触发数据预测
触发数据目前用于激活 Ability,其代码路径与 ActivateAbility 基本相同。区别在于 Ability 不是由输入触发,而是由游戏代码驱动的事件触发。客户端可以预测性地执行这些事件,从而预测性地激活 Ability。
但有一些细节:服务器也会独立运行触发事件的代码,而不是等待客户端。服务器会维护一个由预测性 Ability 激活的触发 Ability 列表。当收到某个触发 Ability 的 TryActivate 时,服务器会查看自己是否已经运行过该 Ability,并以此作为响应。
问题在于,我们目前无法正确回滚这些操作。触发事件和复制方面仍有工作待完成。
===
所以呢?
如果 Ability A 在执行过程中通过事件触发了 Ability B,当 A 被服务器拒绝时,B 无法自动回滚。B 会继续在客户端和服务器上执行
和下面依赖链问题好像有点像,
高级主题:依赖链
我们可能遇到这样的情况:"Ability X 激活后立即触发事件激活 Ability Y,Y 又触发 Ability Z",依赖链为 X→Y→Z。
每个 Ability 都可能被服务器拒绝。若 Y 被拒绝,Z 也从未发生,但服务器从未尝试运行 Z,因此服务器不会显式地决定"Z 不能运行"。
为解决此问题,引入了基础预测键(Base PredictionKey)的概念,它是 FPredictionKey 的一个成员。调用 TryActivateAbility 时,传入当前 PredictionKey(如果有),该键将作为新生成预测键的基础键。以此方式构建键链,当 Y 被拒绝时可以连带使 Z 无效。
X 的预测键被视为 Y 和 Z 的基础键。Y 到 Z 的依赖完全保存在客户端,通过 FPredictionKeyDelegates::AddDependancy 实现。若 Y 被拒绝/确认,注册的委托将同步拒绝/确认 Z。
该依赖系统使我们能够在单个预测窗口/作用域内拥有多个逻辑上不原子的预测动作。
但存在一个问题:因为依赖关系保存在客户端,服务器实际上不知道它之前是否拒绝了某个依赖动作。可以通过在 Gameplay Ability 中使用激活标签(activation tags)来规避此问题。例如,在预测连招 GA_Combo1 → GA_Combo2 时,可以让 GA_Combo2 只在拥有由 GA_Combo1 给予的 GameplayTag 时才激活,从而使 GA_Combo1 的拒绝也导致服务器拒绝 GA_Combo2 的激活。
===
解析:
客户端 技能X ->技能Y->技能Z (在前一个中激活后一个)。
客户端跑 X, Y, Z。服务器拒绝在Y,当然也不会跑下一个技能Z,那么客户端偷跑的Z由谁来拒绝呢?
预测Key有一个Current 和 Base。可以把Base理解为上一个作用域的Key。
【客户端】通过 FPredictionKeyDelegates::AddDependancy 注册委托,Y被拒绝的时候,拒绝Z
但是有一个问题,说的是
前面说客户端的Z偷跑了,服务器收到Z激活的时候。由于不知道Y之前被拒绝了,如果条件满足会激活Z。
怎么办,自己想办法,自己加个TAG维护一下状态。
额外的预测窗口
如前所述,预测键只能在单个逻辑作用域内使用。一旦 ActivateAbility 返回,该键基本就废弃了。若 Ability 在等待外部事件或定时器,当准备继续执行时,可能已经收到了服务器的确认/拒绝。
这本身不是大问题,但某些 Ability 需要响应玩家输入。例如,"长按蓄力"类 Ability 希望在按键松开时立即预测某些内容。可以通过 FScopedPredictionWindow 在 Ability 内部创建新的预测窗口。
FScopedPredictionWindow 提供了一种向服务器发送新预测键、并让服务器在同一逻辑作用域内使用该键的方式。
以 UAbilityTask_WaitInputRelease::OnReleaseCallback 为例:
- 客户端进入
OnReleaseCallback,创建新的FScopedPredictionWindow,生成新预测键(FScopedPredictionWindow::ScopedPredictionKey)。 - 客户端调用
AbilitySystemComponent->ServerInputRelease,传入ScopedPrediction.ScopedPredictionKey。 - 服务器执行
ServerInputRelease_Implementation,取出传入的 PredictionKey,通过FScopedPredictionWindow将其设为UAbilitySystemComponent::ScopedPredictionKey。 - 服务器在同一作用域内运行
UAbilityTask_WaitInputRelease::OnReleaseCallback。 - 当服务器执行到
::OnReleaseCallback中的FScopedPredictionWindow时,从UAbilitySystemComponent::ScopedPredictionKey获取预测键,该键将用于此逻辑作用域内所有副作用。 - 服务器结束该作用域预测窗口后,所用预测键完成使命并被设为
ReplicatedPredictionKey。 - 此作用域内创建的所有副作用现在在客户端和服务器间共享同一个键。
此机制的关键在于:::OnReleaseCallback 调用 ::ServerInputRelease,后者在服务器上调用 ::OnReleaseCallback,不给其他代码使用该预测键留有机会。
虽然本例中没有"Try/Failed/Succeed"调用,但所有副作用在流程上是分组/原子性的,从而为在服务器和客户端上运行的任意函数调用解决了"Undo"和"Redo"问题。
===
解析:
不支持的功能 / 已知问题 / 待办事项
触发事件目前不会显式复制。例如,若触发事件只在服务器运行,客户端永远无法得知。这也阻止了跨玩家/AI 等事件的实现。该功能最终应被添加,遵循与 GameplayEffect 和 GameplayCue 相同的模式(用预测键预测触发事件,若 RPC 事件携带预测键则忽略)。
整个系统的一个重大限制:链式激活(包括触发事件)的回滚目前无法开箱即用。例如,若 GA_Mispredict 被预测激活且立即激活了 GA_Predict1,随后 GA_Mispredict 被服务器拒绝,GA_Predict1 仍会在客户端和服务器上继续执行,因为没有委托来拒绝依赖的 Ability,服务器也不知道存在依赖关系。可以通过标签系统设计规避,确保 GA_Mispredict 成功后 GA_Predict1 才能激活。
===
【预测"元"属性(伤害/治疗)vs "真实"属性(生命值)】
我们无法预测性地应用元属性(Meta Attributes)。元属性只在即时效果的后端(UAttributeSet 的 Pre/Post Modify Attribute)工作,在应用基于持续时间的 GameplayEffect 时不会调用这些事件。
为支持此功能,可能需要对基于持续时间的元属性添加有限支持,并将即时 GameplayEffect 的转换逻辑从前端(UAbilitySystemComponent::ApplyGameplayEffectSpecToSelf)移至后端(UAttributeSet::PostModifyAttribute)。
===
【预测持续性乘法 GameplayEffect】
预测基于百分比的 GameplayEffect 时也存在限制。由于服务器复制下来的是属性的"最终值",而非完整的聚合器修改链,客户端可能无法准确预测新 GameplayEffect 的效果。
例如:
- 客户端有一个永久 +10% 移速 Buff,基础移速 500 → 最终移速 550。
- 客户端有一个 Ability 额外给予 10% 移速 Buff,预期是将两个百分比相加,最终移速 500 × 1.20 = 600。
- 但在客户端,实际是对 550 应用 10% → 605,产生偏差。
此问题需要通过向下复制属性聚合器链来修复。目前已复制了部分数据,但尚未复制完整的修改器列表。
解析:
这里永久10% BUFF是额外的一个BUFF东西。
聚合器计算 500 * 1.1 = 550 复制给客户端 550
有一个技能有10%的BUFF。
- 正确的,服务器是 500 * 1.2 = 600
- 客户端只有复制下来的 550 和 1.1 怎么算到600。总之就是底层不支持
===
【弱预测】
仍然可能存在一些不适合该系统的场景。例如,某个 Ability 使所有与玩家碰撞/接触的玩家都受到减速并将材质变蓝的 GameplayEffect。由于无法每次都发送 Server RPC,客户端和服务器之间无法关联 GameplayEffect 副作用。
一种思路是引入"弱预测"模式——不使用新鲜的预测键,而是让服务器假设客户端会预测整个 Ability 的所有副作用。这至少能解决"Redo"问题,但不能解决"完整性(Completeness)"问题。如果客户端预测尽可能精简——例如只预测初始粒子效果而不预测状态和属性变化——问题就不那么严重了。
可以设想一种弱预测模式,作为当没有可用的可准确关联副作用的新鲜预测键时某些 Ability 的回退方案。在弱预测模式下,也许只有某些动作可以被预测——例如 GameplayCue 的执行事件,而非 OnAdded/OnRemove 事件。
==
解析:高频的事情做不了每帧发预测,还未解决。后面这个是TODO,
总结
为了避免延迟,我们通常需要客户端先行。
UE的预测系统要解决的问题是,避免在代码中写如果权威执行1,否则执行2的情况,并把复杂性封装在底层。
UE在激活技能的时候TryActivateAbility,会根据执行策略,RPC到对应的进程执行。如果是本地预测。
客户端会在本地创建预测key。带着key,RPC服务器激活技能,同时自己继续跑。【代码1】
如果服务器执行失败,触发了commit技能 的时候某些东西不够。会回滚响应的影响。
这个影响主要包括GE,属性,PlayMontageAndWait,Cue。
比如我们写代码的时候完全不知道预测这回事,两端都谢了播放蒙太奇,和提交技能。
服务器如果成功了,需要广播,原客户端不能重复执行(Redo)
服务器如果失败了,会触发回滚,RPC客户端触发各种回调。来消除影响。(Undo)
对于写代码的来说,可能要知道的问题:
- 自定义预测窗口的情况
- 如果要预测属性的修改
REPNOTIFY_Always
好像 AbilityTask_ 直接用
==
这是客户端会预测性的扣除相关东西?
commit ability 本质的Cost和cooldown,走的是GE的预测。
客户端不一定会执行扣除。比如Lyra客户端就不扣子弹,服务器扣再同步过来,这我们自定义的权威判断。
如果引擎就符合GE的预测,如果配置了GE不预测,这里面就会被跳过。
代码1
激活,客户端 大概在 InternalTryActivateAbility 的这里
客户端先走,第二个 else if。rpc到服务器会走到同一个函数的第一个if。然后激活技能
bool UAbilitySystemComponent::InternalTryActivateAbility(...)
{
if (!AbilitySource->CanActivateAbility(...))
{
return false;
}
if (Ability->GetNetExecutionPolicy() == EGameplayAbilityNetExecutionPolicy::LocalOnly || (NetMode == ROLE_Authority))
{
if (Ability->GetInstancingPolicy() == EGameplayAbilityInstancingPolicy::InstancedPerExecution)
{
// ...
}
else
{
AbilitySource->CallActivateAbility(...);
}
}
else if (Ability->GetNetExecutionPolicy() == EGameplayAbilityNetExecutionPolicy::LocalPredicted)
{
FScopedPredictionWindow ScopedPredictionWindow(this, true);
// 服务器激活
CallServerTryActivateAbility(...)
if (Ability->GetInstancingPolicy() == EGameplayAbilityInstancingPolicy::InstancedPerExecution)
{
}
else
{
// 客户端激活
AbilitySource->CallActivateAbility(...)
}
}
}
服务器在 ServerTryActivateAbility_Implementation->InternalServerTryActivateAbility->InternalTryActivateAbility
然后走到一起