在介绍预测功能前,先问个问题,为啥要有这个功能?
这个功能是在网络游戏所需的,单机游戏不需要。网络游戏主要牵扯到一个网络交互的问题,客户端和服务器之间交互是有延迟的,如果将操作数据提交等待服务器返回再运行,玩家会明显的感觉到延迟,影响玩家游戏体验。
所以,游戏通常会使用预测技术来立即显示玩家的操作结果,而不必等待服务器的确认。比如在客户端执行技能时,它会将行为上传服务器处理,并且在本地执行预测版本,这一部分内容是可以回退。正常情况下,服务器端返回结果用于同步,没问题就直接用预测版本执行的内容。但是中间会牵扯很多内容,接下来,我们来学习了解一下UE5里面封装好的预测GameplayPrediction。
我们可以打开源码查看源码注释,可以对GAS内置的预测有一个了解。
在Rider编辑器内,双击shift键,搜索GameplayPrediction.h可以查看它的注释。
首先就介绍了预测的最高级别的目标:
就是实现自动预测,避免开发者书写代码时,总是做if else的判断,如果是服务器 怎么做 如果是客户端怎么做。
而预测则只预测重要的内容,有一些内容不需要预测,如行走的脚步声。
当前预测已经支持的自动预测:
- 激活GameplayAbility
- 触发Events
- GameplayEffect 应用
- 属性修改
- GameplayTag的修改
- Gameplay Cue事件
- 蒙太奇
- 角色的移动(基于UE的UCharacterMovement组件)
还有一些没有实现的,理论上可以支持但是没做
- GameplayEffect的移除
- GameplayEffect的周期性的效果(如中毒掉血)
预测尝试解决的问题
- 可行性:基本预测协议。
- 撤销(Undo):预测失败时如何撤销副作用。
- 重做(Redo):避免重放服务器已复制的预测副作用。
- 完整性(Completeness):确保所有副作用都被预测。
- 依赖性(Dependencies):管理依赖预测和预测事件链。
- 覆盖(Override):以预测方式覆盖由服务器复制/拥有的状态。
接下来将讲解一下每个的实现描述
PredictionKey(预测键)
本系统中的一个基本概念是PredictionKey(预测键,FPredictionKey)。预测键本身就是一个在客户端中央位置生成的唯一ID。客户端会将其预测键发送给服务器,并将预测动作和副作用与此键相关联。服务器可能会对预测键做出接受/拒绝响应,并将服务器端产生的副作用也与此预测键相关联。
(重要提示) FPredictionKey总是从客户端复制到服务器,但是当从服务器复制到客户端时,它们只会复制到最初向服务器发送预测键的客户端。这发生在FPredictionKey::NetSerialize过程中。当通过复制属性将一个客户端发送的预测键复制回来时,所有其他客户端将收到一个无效的(0)预测键。
GameplayAbility 激活
激活GameplayAbility是一类重要的预测内容。每当客户端预测地激活一个技能时,它会明确地向服务器询问,服务器也会明确做出反馈。一旦能力被预测性地激活,客户端就有一个有效的prediction window
预测窗口,在这个窗口中,需要预测的副作用可能会生成,这些副作用不需要明确询问服务器(比如:询问要不要减少法力值,询问技能要不要进入冷却。这些操作在逻辑上和技能激活是一个整体。)
ASC提供了一组函数,用于客户端和服务器之间通信技能激活:ryActivateAbility -> ServerTryActivateAbility -> ClientActivateAbility(Failed/Succeed)。
- 客户端调用TryActivateAbility,生成一个新的FPredictionKey,并调用ServerTryActivateAbility。
- 客户端在收到服务器的回复之前继续执行,并使用与Ability的ActivationInfo相关联的生成的PredictionKey调用ActivateAbility。
- 在调用ActivateAbility完成之前发生的任何副作用都与生成的FPredictionKey相关联。
- 服务器在ServerTryActivateAbility中决定能力是否真的发生,调用ClientActivateAbility(Failed/Succeed),并将UAbilitySystemComponent::ReplicatedPredictionKey设置为发送的生成键。
- 如果客户端收到ClientAbilityFailed,它会立即终止能力并回滚与预测键相关联的副作用。
- 回滚"是通过FPredictionKeyDelegates和 FPredictionKey::NewRejectedDelegate/NewCaughtUpDelegate/NewRejectOrCaughtUpDelegate完成的。
在TryActivateAbility时注册回调:
- 回滚"是通过FPredictionKeyDelegates和 FPredictionKey::NewRejectedDelegate/NewCaughtUpDelegate/NewRejectOrCaughtUpDelegate完成的。
cpp
// 如果此PredictionKey被拒绝,我们将调用OnClientActivateAbilityFailed
ThisPredictionKey.NewRejectedDelegate().BindUObject(this, &UAbilitySystemComponent::OnClientActivateAbilityFailed, Handle, ThisPredictionKey.Current);
在ClientActivateAbilityFailed_Implementation中调用回调
cpp
FPredictionKeyDelegates::BroadcastRejectedDelegate(PredictionKey);
- 如果客户端请求被接受,客户端必须等待
属性复制
同步(Succeed RPC将立即发送,属性复制将自行进行)。一旦ReplicatedPredictionKey与客户端之前步骤中使用的键同步,客户端就可以撤销之前所做的预测性副作用。请参阅UAbilitySystemComponent::OnRep_PredictionKey。
GameplayEffect 预测
GameplayEffect被视为预测的副作用(预测的内容)不会明确请求服务器。
- 只有在有效的预测键时,游戏效果才会在客户端上应用。如果没有预测键,客户端将跳过应用。
- 如果预测了游戏效果,那么属性(Attributes)、游戏玩法提示(GameplayCues)和游戏玩法标签(GameplayTags)也会被预测。
- 当FActiveGameplayEffect被创建时,它会存储预测键(FActiveGameplayEffect::PredictionKey),用于追踪预测操作。
- 在服务器上,相同的预测键也会被设置在服务器的FActiveGameplayEffect上,随后这个效果会复制到客户端。
- 当客户端接收到一个带有有效预测键的复制的FActiveGameplayEffect时,它会检查是否已经有一个相同键的活跃游戏玩法效果。
- 如果存在匹配,客户端不会应用"应用时"(on applied)的逻辑,例如游戏玩法提示。这解决了"重做"(Redo)问题。
- 但是,这会导致活跃GameplayEffect 容器中暂时存在两个"相同"的游戏玩法效果。
- 同时,UAbilitySystemComponent::ReplicatedPredictionKey将同步上来,预测的效果将会被移除掉。在移除时,客户端会再次检查预测键,决定是否执行移除的逻辑(On Remove)或GameplayEffect
属性的预测
由于属性是作为标准的UPROPERTIES进行复制的,预测对它们来说有可能很麻烦(因为覆写的问题)。对于即时修改更加麻烦。(如果修改了以后,再回滚,因为没有记录操作。)这对于撤回或重做都很麻烦。
所以,在属性预测处理时,将属性的修改预测都做为增量预测,就是不记录修改后的值,而是记录增加或减少了多少值。而对于客户端的即时修改(Instant),在预测修改时,都是作为Infinite类型去修改。
为了解决服务器复制值覆盖客户端预测值的问题,在属性的OnRep(On Replicate)函数中,将服务器的复制值视为属性的"基础值"(base value),而不是"最终值"(final value)。
在复制发生后,基于新的基础值重新合并为最终值。
实现细节:
- 客户端将Instant的GameplayEffect视为Infinite类型的GameplayEffect
- 属性必须始终接收RepNotify(Replication Notification)调用,而不仅仅是在本地值发生变化时。这通过使用REPNOTIFY_Always实现。
- 在属性的RepNotify函数中,调用AbilitySystemComponent::ActiveGameplayEffects来更新基于新基础值的最终值。GAMEPLAYATTRIBUTE_REPNOTIFY宏可以用于此目的。
- 当预测键被服务器同步时,预测的游戏效果将被移除,客户端将使用服务器提供的值。
案例:
在刚开始学习UE时,我们不理解在AttributeSet里面设置一个属性为什么这么复杂,其实它们是用于预测的。
这个代码是在指定在游戏的生命周期指定哪些属性被复制,这包括在不同的客户端之间的复制。
cpp
void UMyHealthSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME_CONDITION_NOTIFY(UMyHealthSet, Health, COND_None, REPNOTIFY_Always);
}
这个函数是自定义的复制通知函数,在获取服务器复制通知后触发,
cpp
void UMyHealthSet::OnRep_Health()
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UMyHealthSet, Health);
}
Gameplay Cue Events
Gameplay Cue翻译为游戏提示,它包含一些技能特效,声效,伤害数值显示等,用于提示操作声效的效果。
Gameplay Cue可以被GameplayEffect激活,也可以独立激活。(通过UAbilitySystemComponent::ExecuteGameplayCue 函数,它考虑了在网络中的角色(服务器/客户端)和预测键)
- 在发送端UAbilitySystemComponent::ExecuteGameplayCue中,如果当前端具有权威性,它将执行多播事件(带有replication key)让每个客户端都播放此提示。如果没有权威性但有预测键,则进行预测GameplayCue。
- 在接收端(如NetMulticast_InvokeGameplayCueExecuted等),如果接收到带有复制键的游戏玩法提示事件,则不执行该事件(假设它已经被预测)。
预测键(FPredictionKey)仅复制到原始的所有者(即,发送预测键的客户端)。这是 FReplicationKey 的一个固有属性。
预测键的作用
- 预测键用于标识和追踪预测操作,确保客户端和服务器就预测的游戏状态达成一致。
- 在游戏玩法提示的上下文中,预测键帮助客户端预测并模拟游戏玩法提示事件的结果,而不需要等待服务器的确认。
TargetData 预测
TargetData一般在激活技能中使用。从本质上讲,这与ActivateAbility走的是相同的代码路径。技能不是通过输入操作来激活的,而是由游戏代码驱动的其他事件来激活。客户端能够预测性地执行这些事件,从而预测性地激活技能。
然而,这里有一些细微差别,因为服务器也会运行触发事件的代码。服务器不会只是等待客户端的消息。服务器会保留一个列表,记录已经从预测性技能中激活的触发技能。当从触发技能接收到TryActivate时,服务器会检查它是否已经运行了这个技能,并用该信息做出响应。
关于它的触发和复制,我们将在后面讲。
高级主题:
依赖性(Dependencies)
- 在游戏中,可能会存在一个技能激活(Ability X)立即触发一个事件,该事件又激活了另一个技能(Ability Y),进而触发了第三个技能(Ability Z)。这样就形成了一个依赖链:X -> Y -> Z。
- 服务器可以拒绝任何技能的激活请求。如果Y被拒绝,那么Z也不会被激活,但服务器实际上并不会去尝试激活Z,因此服务器不会明确地做出"Z不能运行"的决定。
- 为了处理这种依赖性,引入了基础预测键的概念。在调用TryActivateAbility尝试激活技能时,我们会传递当前的预测键(如果适用)。这个预测键用作生成任何新预测键的基础。
- 通过这种方式,我们可以构建一个预测键的链,并在Y被拒绝时使Z失效。
- 在X->Y->Z的案例中,服务器在尝试运行链式激活之前,只会接收到X的预测键。服务器将使用从客户端接收到的原始预测键来尝试激活Y和Z。
而客户端每次调用TryActivateAbility时都会生成一个新的预测键,因为每次激活在逻辑上都不是原子性的。链式事件中产生的每个副作用都需要有一个唯一的预测键。 - 为了解决这个问题,X的预测键被视为Y和Z的基础键。Y到Z的依赖关系完全在客户端保持,通过FPredictionKeyDelegates::AddDependancy来实现。我们添加了委托(delegates),以便在Y被拒绝或确认时拒绝或赶上(catchup)Z。
- 这个依赖系统允许我们在单个预测窗口或作用域内进行多个预测操作,而这些操作在逻辑上并不是统一的。
额外的预测窗口(Additional Prediction Windows)
为什么需要额外的预测窗口?
一个预测键(Prediction Key)只能在单个逻辑作用域内使用。一旦ActivateAbility(激活技能)返回,该键的使命就完成了。如果技能在等待外部事件或计时器,那么在它返回时,服务器可能已经发送了确认或拒绝的响应。
有时技能需要对玩家输入做出反应,例如,一个需要长按并累积能量的技能,在按钮释放时可能需要立即预测某些效果。为了实现这一点,可以在技能内部使用FScopedPredictionWindow创建一个新的预测窗口。
FScopedPredictionWindow提供了一种机制,允许客户端向服务器发送一个新的预测键,并让服务器在相同的逻辑作用域内使用这个键。
以UAbilityTask_WaitInputRelease::OnReleaseCallback(这是一个监听按键或鼠标抬起后的事件)为例,事件流如下:
- 客户端进入OnReleaseCallback,并开始一个新的FScopedPredictionWindow,这会为这个作用域创建一个新的预测键。
- 客户端调用AbilitySystemComponent->ServerInputRelease,将ScopedPredictionKey作为参数传递。
- 服务器执行ServerInputRelease_Implementation,接收传递的预测键,并将其设置为UAbilitySystemComponent::ScopedPredictionKey,使用FScopedPredictionWindow。
- 在这个逻辑范围内产生的所有副作用都将使用这个预测键,确保客户端和服务器之间的同步。
虽然没有处理 "Try/Failed/Succeed"相关调用,如果出现对应的问题,会自动处理。
触发事件的复制:注释中提到的一个问题是,触发事件目前不显式地复制。如果触发事件仅在服务器上运行,客户端将永远不会知道它。这限制了跨玩家/AI等事件的处理。
未来的改进:应该添加对触发事件的支持,并遵循与 GameplayEffect 和 GameplayCues 相同的模式,即用预测键预测触发事件,如果 RPC 事件有预测键,则忽略它。
以下为UAbilityTask_WaitInputRelease的部分源码分析,我们如果需要可以按照这种方式书写。
以下是UAbilityTask_WaitInputRelease的激活事件,如果是本地控制的客户端,将会在键位抬起时触发回调,而非本地控制的客户端和服务器,将监听它的委托。AbilityReplicatedEventDelegate就是将本地控制的客户端将事件广播出去的函数。
cpp
void UAbilityTask_WaitInputRelease::Activate()
{
//技能激活,获取激活时的时间
StartTime = GetWorld()->GetTimeSeconds();
//获取ASC
UAbilitySystemComponent* ASC = AbilitySystemComponent.Get();
if (ASC && Ability)
{
if (bTestInitialState && IsLocallyControlled())
{
FGameplayAbilitySpec *Spec = Ability->GetCurrentAbilitySpec();
if (Spec && !Spec->InputPressed)
{
OnReleaseCallback();
return;
}
}
//设置对抬起事件的监听,如果触发对应事件,将触发UAbilityTask_WaitInputRelease::OnReleaseCallback回调
DelegateHandle = ASC->AbilityReplicatedEventDelegate(EAbilityGenericReplicatedEvent::InputReleased, GetAbilitySpecHandle(), GetActivationPredictionKey()).AddUObject(this, &UAbilityTask_WaitInputRelease::OnReleaseCallback);
//如果不是本地控制的客户端执行时,返回true
if (IsForRemoteClient())
{
//尝试调用远程事件代理,如果设置了相应的事件,并且调用成功,则不需要进一步操作。
if (!ASC->CallReplicatedEventDelegateIfSet(EAbilityGenericReplicatedEvent::InputReleased, GetAbilitySpecHandle(), GetActivationPredictionKey()))
{
//如果没有成功调用远程事件代理,那么设置当前任务为等待远程玩家的数据。
SetWaitingOnRemotePlayerData();
}
}
}
}
在回调函数中,首先获取到一共按下了多长时间(抬起时间-按下时间)
cpp
void UAbilityTask_WaitInputRelease::OnReleaseCallback()
{
//获取按下时间
float ElapsedTime = GetWorld()->GetTimeSeconds() - StartTime;
//获取ASC
UAbilitySystemComponent* ASC = AbilitySystemComponent.Get();
if (!Ability || !ASC)
{
return;
}
//取消对键位抬起事件的监听
ASC->AbilityReplicatedEventDelegate(EAbilityGenericReplicatedEvent::InputReleased, GetAbilitySpecHandle(), GetActivationPredictionKey()).Remove(DelegateHandle);
//创建新的预测窗口,并生成新的预测键,此后的内容将会在服务器进行评估是否能够触发。
FScopedPredictionWindow ScopedPrediction(ASC, IsPredictingClient());
//判断当前是否为预测客户端
if (IsPredictingClient())
{
// 如果是本地控制客户端,将事件复制到服务器,并将预测窗口的预测键上传。
ASC->ServerSetReplicatedEvent(EAbilityGenericReplicatedEvent::InputReleased, GetAbilitySpecHandle(), GetActivationPredictionKey(), ASC->ScopedPredictionKey);
}
else
{
//如果不是本地控制客户端,那么触发此回调时,已经获取到了从服务器复制过来的数据,那么将调用处理事件的函数。
ASC->ConsumeGenericReplicatedEvent(EAbilityGenericReplicatedEvent::InputReleased, GetAbilitySpecHandle(), GetActivationPredictionKey());
}
// 到此已经完成了,判断是否能够触发委托(判断条件,技能实例是否还处于激活状态)
if (ShouldBroadcastAbilityTaskDelegates())
{
//实际向外广播AbilityTask委托的地方
OnRelease.Broadcast(ElapsedTime);
}
//关闭AbilityTask
EndTask();
}
"元"属性(Meta Attributes)与"真实"属性(Real Attributes)之间的差异和相关挑战
元属性(Meta Attributes):
元属性是影响游戏玩法的属性,但它们并不直接表示游戏状态。例如,增加伤害或治疗效果的属性可以视为元属性,因为它们影响玩家造成或受到的伤害量。
真实属性(Real Attributes):
真实属性是直接表示游戏状态的属性,如玩家的生命值(Health)。这些属性通常作为游戏内状态的直接反映,并在游戏玩法中实时更新。
预测元属性的挑战
当前系统无法预测元属性的持续效果。元属性只能在Instant GameplayEffect中应用,这些效果在 GameplayEffect 的后端(UAttributeSet 的 Pre/Post Modify Attribute)处理。当应用基于持续时间的Instant GameplayEffect时,不会调用这些事件。
预测基于百分比(multiplicative)的GameplayEffects时遇到的挑战
服务器复制属性时,只会复制最终值到客户端,但不会复制整个聚合链(aggregator chain),即那些修改属性的GameplayEffects列表。
客户端缺少完整的聚合链,可能无法准确预测新的结果。
示例场景:
比如在一个客户端有一个永久增加10%移动速度的GameplayEffect,基础移动速度为500,当前角色移动速度则为550 。
如果角色获得了一个额外的10%增速,理论上它应该获得20%增速,最终速度为600 。但是一个客户端它只知道它有550的速度,最终速度设置为了605 。
为了解决这种问题,服务器需要发送更多的信息给每个客户端,包括属性值修改的完整的聚合链。这样客户端就可以正确的计算百分比增益。虽然现在支持了一些,但还没有完美的支持。
"弱预测"(Weak Prediction)
在网络游戏中,有时会遇到一些难以通过常规预测系统进行准确预测的情况。例如,当一个玩家与其他玩家碰撞或接触时,会给接触的玩家施加一个游戏效果,如减速,并且他们的材质变为蓝色。由于这种情况下无法每次发生时都发送服务器远程过程调用(Server RPCs),而且服务器在其模拟阶段可能无法处理这些消息,因此无法在客户端和服务器之间准确关联游戏效果的副作用。
为了解决这个问题,UE提出了一个"弱预测"(Weak Prediction)解决方案。在不使用新的预测键,而是让服务器假定客户端将预测来自整个能力的所有效果。这种方法至少可以解决"重做"(Redo)问题,即避免服务器重复发送已经由客户端预测并应用的效果,但无法解决"完整性"(Completeness)问题,即无法保证所有效果都被预测和应用。
在弱预测模式下,可能只有某些特定的动作可以被预测,例如执行游戏玩法提示(GameplayCue)事件,但不包括 OnAdded/OnRemove 事件。
为什么需要弱预测
- 复杂游戏机制:弱预测为那些不适合标准预测系统的游戏机制提供了一种解决方案。
- 性能考虑:通过减少需要服务器处理的 RPCs 数量,弱预测有助于提高游戏性能。
- 简化实现:在某些情况下,使用弱预测可以简化游戏逻辑的实现,尤其是在涉及大量客户端预测和服务器确认的场景中。
- 减少同步问题:通过减少需要在客户端和服务器之间同步的数据量,弱预测有助于减少同步问题。