GAS(Gameplay Ability System)劝退人的从来不是概念多,而是"客户端预测的技能在服务器拒绝后怎么收回去"。这块的答案全在 FPredictionKey 上。基于 UE5.3 源码(Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities)拆一遍。
账本模型:GAS 预测的本质
先把问题的结构看清。多人游戏里客户端不能等服务器确认再播放技能表现——一个 RTT 的延迟就足够让动作游戏手感崩掉。所以客户端必须乐观地先执行,同时保留"服务器说不算数时完整撤回"的能力。这本质上是乐观并发控制(optimistic concurrency control):客户端不等锁就提交一个"本地事务",服务器确认等于 commit,拒绝等于 abort。
FPredictionKey 就是这个事务的 ID。GAS 预测的整套机制就是围绕"事务 ID 怎么发、怎么记、怎么销"展开的,理解了这个类比,源码里 key 的每次传递都知道它在干嘛。和数据库 OCC 的区别在回滚粒度:数据库 abort 能恢复前值,GAS 里 instant GameplayEffect 已经把属性改掉了,前值没有存档——这个差异直接决定了后面要讲的"预测期只用可撤销操作"这条纪律。
PredictionKey 是什么
客户端执行技能时,UAbilitySystemComponent::TryActivateAbility 里本地立刻激活,同时生成一个 FPredictionKey——一个由 FPredictionKey::GenerateNewPredictionKey 发出的递增整数,连接级唯一(存活在 UNetConnection 的 prediction key pool 里)。这个 key 会:
- 跟着客户端发的 ServerTryActivateAbility RPC 一起上传;
- 在客户端本地标记所有预测产生的副作用:激活期间应用的 GameplayEffect、加的 GameplayTag、触发的 GameplayCue,都在
FScopedPredictionWindow作用域内被打上这个 key。
FScopedPredictionWindow(AbilitySystemComponent.h)是个 RAII 结构:构造时把 key 压进 ASC 的 ScopedPredictionKey,析构弹出。技能激活函数体内创建的所有东西自动归到这个 key 名下——这是 GAS 预测代码写起来"无感"的原因。记账这个动作被埋进了作用域,业务代码不需要显式传 key。
服务器确认与回滚
服务器收到激活 RPC 后两种结果:
接受:服务器自己也激活技能,然后 ClientActivateAbilitySucceed 带着服务器侧的 key 回客户端。客户端在 FPredictionKey::NewRejectOrCaughtUpDelegate 里收到确认后,把这个 key 标记为 caught up——意思是"这 key 名下的副作用已被服务器状态覆盖,不再是预测"。关键在 UAbilitySystemComponent::OnPredictionKeyCaughtUp:客户端开始接受这些 key 关联属性的复制更新(预测期间属性复制是被屏蔽的,防止服务器旧数据覆盖本地预测值——服务器的数据是按它的时间线算的,比本地预测旧一个 RTT,直接覆盖会让玩家看到数值倒退)。
拒绝:ClientActivateAbilityFailed 回来,客户端调 FailedActivationRejected → 对这个 key 做回滚:遍历 key 名下的预测 GameplayEffect(ActiveGameplayEffects 里 PredictionKey 匹配的 FActiveGameplayEffectHandle),全部移除;预测加的 tag 撤掉;触发的 cue 停掉。代码在 AbilitySystemComponent_Prediction.cpp 的 RemoveActiveEffectsWithPredictionKey 一带。
这里有个实现上很微妙的点:回滚不是反向重演,而是直接删除。预测期加的 GE 被 Remove 时,如果它是 instant 类型(已经一次性改了属性),属性值不会自动还原——instant 效果不可逆,这正是账本模型里"事务不可中止"的那类操作。所以官方实践是"预测期间尽量用 duration GE 而非 instant 扣血",duration GE 的属性贡献是 aggregator 动态算出来的,Remove 之后贡献消失,账目自动平掉。实测不遵守这条,服务器拒绝技能后客户端血量会留下幻影差值,要等下一次属性复制覆盖。
属性复制:Aggregator + Push Model
FGameplayAttribute 的同步不是走普通 DOREPLIFETIME 逐属性,而是:
- 属性当前值存在
FAttributeSet里,变更统一过FAggregator(GameplayEffectAggregator)计算; - ASC 持有属性时,变更通过
FGameplayAttributeRepData(量化过的 float)复制;EGameplayEffectReplicationMode三档(Full/Mixed/Minimal)决定 GE 本身复制多少信息——玩家自己的 ASC 用 Full,AI 用 Minimal 只同步属性值; - 5.x 里 ASC 属性走 Push Model(
AbilitySystemComponent里大量MARK_PROPERTY_DIRTY_FROM_NAME调用),属性没变不比较不发送,技能频繁触发属性的场景下省掉了每 tick 全属性扫描。
Aggregator 的求值值得展开。一个属性的 CurrentValue 不是某个字段,而是把所有生效 GE 的 modifier 按操作类型分层求值出来的表达式(简化形态):
$$V = \frac{\big(\text{BaseValue} + \sum_i A_i\big)\cdot\big(1 + \sum_j M_j\big)}{1 + \sum_k D_k}$$
其中 $A_i$ 是 Add 类修正,$M_j$ 是 Multiply 类(加性堆叠百分比),$D_k$ 是 Divide 类,Override 类一旦存在直接替换整个表达式。这个求值顺序是刻意定的:先加后乘意味着"固定值 buff"和"百分比 buff"的叠加结果与施加顺序无关——顺序无关性对分布式系统是刚需,客户端和服务器应用 GE 的顺序不可能完全一致,如果求值依赖顺序,两端必然发散。BaseValue 单独存的意义也在这里:它是去掉所有临时效果的底数,GE 过期时 aggregator 重算即可,不需要记录"这个 GE 当初加了多少"再减去——减法是浮点误差和顺序 bug 的温床,重算是幂等的。
预测期间的属性变化是本地 aggregator 算的;服务器版本到达后(key caught up),FActiveGameplayEffectsContainer::OnAttributeAggregatorDirty 路径用服务器的 authoritative 值覆盖。两边 GE 列表一致时值自然收敛——前提是客户端和服务器对 GE 的应用决策一致,这就是 GAS 要求关键 GE 应用逻辑必须 deterministic 的原因:同一个 CanApply 判断,两边结果不同就会产生永久偏差,因为系统里没有"全量重放校正"的环节,服务器只发结果值不重审决策过程。
GameplayCue 的预测位置
Cue 不参与回滚(特效音效没法"撤回",顶多 Stop),所以 GAS 把 cue 设计成纯表现层:FGameplayCuePendingExecute 队列在预测 key 名下触发,服务器版本到达时去重(GameplayCueTag + 参数匹配)。网络差的机器上特效"闪两次",通常是客户端预测触发一次、服务器复制又触发一次而 dedup 没匹配上——参数必须完全一致才能去重。
预测窗口的嵌套与边界
FScopedPredictionWindow 是可以嵌套的:技能激活里触发 GE 应用、GE 的 Execution 里又触发 cue,内层作用域会继承外层栈顶的 key(实现上是个栈而不是单值)。但要注意边界——RPC 回调里没有预测窗口。在 AbilityTask 的回调(比如等待服务器事件回来后再执行逻辑)里创建 GE,拿到的不是当时的预测 key,而是一个新 key 或干脆没有预测语义,这些副作用就不在当初那次激活的回滚范围内。规范做法:预测性副作用尽量在激活的同步路径内做完,异步回来的逻辑当 authoritative 处理。
另一个边界是 key 的生命期。FPredictionKey 在低带宽下可能几帧才被 caught up,期间依赖该 key 结果的二次预测(比如"冲刺命中后立刻接二段技能")会生成依赖 key(key 之间能建父子依赖,FPredictionKey 内部有 Dependents 链)。父 key 被拒绝时子 key 一并作废——AbilitySystemComponent 里 RejectAbilityActivation 会沿依赖链级联清理。写连招类技能时要意识到这个链的存在,否则会出现"第一段被服务器拒了,第二段效果却留在本地"的幽灵状态。
排障套路
- 预测技能表现异常先开
showdebug abilitysystem:左侧面板能看到本地 ActiveGE 的 PredictionKey 和 caught up 状态; AbilitySystem.Debug.NextTarget配合LogGameplayAbilities看 reject 路径;- 怀疑属性不同步时对比两端 aggregator 的 modifier 列表,不一致就查 GE 的
CanApply里有没有读客户端独有状态(比如本地 UI 状态)——这是预测分歧最常见的根因。
GAS 预测的本质可以一句话说清:用 key 把"我猜的"和"服务器确认的"两本账分开记,确认后并账,拒绝后销账。理解了账本模型,再看 FPredictionKey 在源码里四处传递就不晕了。