前言
在网络游戏开发中,无法避免的问题就是网络延迟,而为了降低网络延迟带来的感受,常使用一种通用方案就是预测回滚,这篇文章主要记录虚幻引擎中GAS框架是如何实现能力(GA)的预测回滚
一、网络延迟
1. 网络延迟是什么
网络传输需要时间,消息存在滞后性,这个滞后性被称为网络延迟
2. 产生网络延迟过程
- 客户端--->>>服务端的传输时间
- 服务器运行处理时间
- 服务器--->>>客户端的传输时间
3. 网络延迟产生原因
网络延迟产生情况很复杂
- 可能是客户端与服务器距离太远
- 可能是环境信号差
- 可能服务器处理压力太大等
都可能会出现网络延迟,针对网络延迟,无法避免,只能尽最大程度保证游戏流畅性
4. 常见解决方案
- 使用CDN,主要减少物理传输距离
- 搭建专用网络,主要减少网络传输堵塞
- 更换性能更优越的服务器主机,主要提升处理速度
- 使用UDP代替TCP传输(为什么UDP比TCP更快?)
- 使用预测回滚技术,本文主要讲述的内容
- 适当提高客户端和服务器帧率,主要提升传输频率
二、预测回滚
1. 预测回滚是什么
在网络游戏开发中,客户端预先模拟服务器的操作被称为预测;客户端预测失败时取消所执行过的操作被称为回滚
2. 为什么要做预测回滚
通俗的说是为了降低网络延迟带来的不流畅体验
3. 预测回滚的思考
既然是预测,面临的情况就是预测成功和失败
如果客户端预测成功了,Do X; 如何避免重做?
如果客户端预测失败了,Do Y; 如何回滚,UnDo?Rejected
无论成功与否,Do Z CatchUpOrRejected
那些操作能预测?
那些操作不能预测?
那些操作建议不进行预测等?
例如
- 预测成功了,客户端调整因为预测做的一些临时操作,或一些由服务器同步的操作,而避免重复执行
- 预测失败了
既然要回滚,当然要做的就是将做过的操作记录下来,然后书写回滚的逻辑,怎么才能恢复到之前的状态。
这里又需要考虑的一个问题,这个回滚,如果多步骤(非原子操作),回滚的时序和回滚的步骤都息息相关,需要在设计时支持这个操作能够被回滚。且是否支持在乱序中,漏序中也能恢复到预期 - 无论成功与否,客户端因为做了一些临时的便于回滚的操作。
例如GE预测,失败时需要移除此GE,成功时也需要移除客户端GE效果,再从服务器同步下来
三、GAS中能力预测回滚的实现
GA中有四种网络执行策略Net Execution Policy
- Local Predicted:在拥有的客户端上先执行,服务器也会执行,以服务器为权威,同步修改客户端预测不正确的地方
- Local Only:只在拥有者客户端上执行
- Server Only:只在服务器上执行,例如被动技能
- Server Initiated:服务器先执行,客户端后执行
这里重点讨论Local Predicted类别
技能的激活流程
客户端
- 首先在客户端激活技能,调用TryActivateAbility--->>>InternalTryActivateAbility
在InternalTryActivateAbility中会进行CanActivateAbility的判定 - 当客户端判定能够执行后,会设置激活信息ActivationInfo。并调用FScopePredictionWindow生成唯一性的PredictionKey与当前技能进行关联
- 再一并通过CallServerTryActivateAbility RPC发送到服务器,并绑定当前PredictionKey的CaughtUp委托,以进行PredictionKey从服务器复制下来后处理逻辑
Tip:PredictionKey可以理解为这次激活技能操作集合的一个ID,所有在本次技能所执行的操作,都会关联这个ID,后续做回滚时,才知道需要回滚那些的操作
例如在激活一次技能,播放了Montage,应用了GE操作等,这些操作都关联一个ID,此时技能预测失败了,需要找到预测ID关联的操作集合进行回滚,以上就需要将Montage立刻停止,并且删除这次技能所应用的GE
服务器
- 当服务器收到客户端RPC后,调用ServerTryActivateAbility_Implementation方法
- 服务器进行技能合法性和CanActiveAbility等条件判定
- 如果失败,直接通过ClientActivateAbilityFailed RPC通知客户端预测失败后广播PredictionKey中的RejectedDelegates,进行回滚操作,并调用K2_EndAbility结束技能
- 如果成功,则通过ClientActivateAbilitySucceed RPC通知客户端预测成功
在此前进行了FScopedPredictionWindow构造,在InternalTryActivateAbility函数调用完毕后,FScopedPredictionWindow析构中调用PredictionKey的复制,在客户端上广播了PredictionKey的CautchUpDelegates,取消客户端上的一些临时操作,例如Instant GE的在客户端的预测需要取消。
技能的结束流程
- 同样结束技能也有一套流程,客户端调用EndAbility时,执行客户端上的技能结束逻辑后,通过CallServerEndAbility RPC告诉服务器,客户端准备结束技能了
- 服务器收到RPC后,如果是预测的技能,进行FScopedPredictionWindow构造,在服务器调用 ServerEndAbility_Implementation完成后,通过FScopedPredictionWindow的析构复制PredictionKey从而调用客户端上的CaughtUp委托
- 服务器找到与当前PredictionKey的技能实例,执行服务器结束逻辑,执行完成后,再通过ClientEndAbility RPC告诉客户端,此时客户端IsActive为false,直接结束流程
GE的预测
GE的执行流程
一般情况下都不对GE进行预测,所以GE都是在权威端执行,客户端不执行
但GAS也支持部分类型的GE预测,不过无论最终预测成功或失败都需要在客户端上移除执行的GE。
在客户端进行GE的预测限制也比较多,例如周期性GE(Period>0)、叠加GE、GE的移除操作等都不能进行预测
以下是应用GE客户端的预测流程
- 在客户端进行应用GE预测时,首先在GAS全局方法ShouldPredictTargetGameplayEffects()中获取是否需要进行客户端预测,否则不进行预测
- 然后判定本次GE的使用是否有合法的预测PredictionKey,此PredictionKey是在激活技能时生成的,如果Key无效也不进行预测
- 当有合法的预测Key,但GE是周期性的也不支持预测
- 再通过GE一系列的检测,如果Instant类的GE,因为是瞬时GE,执行之后为了方便回滚,在客户端上需要将其转为Inifinite GE
// Clients should treat predicted instant effects as if they have infinite duration. The effects will be cleaned up later.
bool bTreatAsInfiniteDuration = GetOwnerRole() != ROLE_Authority && PredictionKey.IsLocalClientKey() && Spec.Def->DurationPolicy == EGameplayEffectDurationType::Instant;
- 在进行FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec时,如果是叠加的GE,也不进行预测
- 最后对InPredictionKey.NewRejectOrCaughtUpDelegate的委托进行Bind
UAbilitySystemComponent::RemoveActiveGameplayEffect_NoReturn,在预测失败或者PredictionKey的属性复制通知时,移除预测的客户端GE
// Clients predicting should call MarkArrayDirty to force the internal replication map to be rebuilt.
MarkArrayDirty();
// Once replicated state has caught up to this prediction key, we must remove this gameplay effect.
InPredictionKey.NewRejectOrCaughtUpDelegate(FPredictionKeyEvent::CreateUObject(Owner, &UAbilitySystemComponent::RemoveActiveGameplayEffect_NoReturn, AppliedActiveGE->Handle, -1));
例如,GAS中,应用无限期的GE,成功,需要在属性同步达到时(怎么知道是这个GE的属性同步呢?),移除GE;失败,直接移除
存在副作用?因为属性同步和PredictionKey不一定同步到来
AttributeSet的预测
GameplayPrediction.h
GameplayPrediction中重要的结构体
1. FPredictionKeyEvent:单播委托
DECLARE_DELEGATE(FPredictionKeyEvent);
用于Bind委托方法,调用相关回滚和CaughtUp方法
2.FPredictionKey:核心预测结构体
USTRUCT()
struct GAMEPLAYABILITIES_API FPredictionKey
{
GENERATED_USTRUCT_BODY()
UPROPERTY()
int16 Current;
UPROPERTY()
int16 Base;
static FPredictionKey CreateNewPredictionKey(class UAbilitySystemComponent*);
static FPredictionKey CreateNewServerInitiatedKey(class UAbilitySystemComponent*);
void GenerateDependentPredictionKey();
/** Creates new delegate called only when this key is rejected. */
FPredictionKeyEvent& NewRejectedDelegate();
/** Creates new delegate called only when replicated state catches up to this key. */
FPredictionKeyEvent& NewCaughtUpDelegate();
/** Add a new delegate that is called if the key is rejected or caught up to. */
void NewRejectOrCaughtUpDelegate(FPredictionKeyEvent Event);
private:
void GenerateNewPredictionKey();
};
- 从预测回滚的实现上来说每进行一次预测行为就需要一个唯一的Key作为标识,而预测行为中的操作集合都需要Bind这一个唯一的Key,从而在预测成功或失败时,可以通过Key找到所关联的操作,进行回滚相关操作。FPredictionKey就是GAS中作为唯一性预测Key的结构,其关联着所有的预测行为。
- 在结构体中,真正作为唯一Key的是Current字段,其通过GenerateNewPredictionKey方法保证每次生成的唯一性,可以看出其内部实现很简单,就使用了一个static关键字,通过函数内静态变量的全局特性,进行自加在赋值给Current保证唯一性。
void FPredictionKey::GenerateNewPredictionKey()
{
static KeyType GKey = 1;
Current = GKey++;
if (GKey < 0)
{
GKey = 1;
}
}
3. FPredictionKeyDelegates
struct FPredictionKeyDelegates
{
public:
struct FDelegates
{
public:
/** This delegate is called if the prediction key is associated with an action that is explicitly rejected by the server. */
TArray<FPredictionKeyEvent> RejectedDelegates;
/** This delegate is called when replicated state has caught up with the prediction key. Doesnt imply rejection or acceptance. */
TArray<FPredictionKeyEvent> CaughtUpDelegates;
};
TMap<FPredictionKey::KeyType, FDelegates> DelegateMap;
static FPredictionKeyDelegates& Get();
static FPredictionKeyEvent& NewRejectedDelegate(FPredictionKey::KeyType Key);
static FPredictionKeyEvent& NewCaughtUpDelegate(FPredictionKey::KeyType Key);
static void NewRejectOrCaughtUpDelegate(FPredictionKey::KeyType Key, FPredictionKeyEvent NewEvent);
static void BroadcastRejectedDelegate(FPredictionKey::KeyType Key);
static void BroadcastCaughtUpDelegate(FPredictionKey::KeyType Key);
static void Reject(FPredictionKey::KeyType Key);
static void CatchUpTo(FPredictionKey::KeyType Key);
static void AddDependency(FPredictionKey::KeyType ThisKey, FPredictionKey::KeyType DependsOn);
};
- 管理着FPredictionKey与RejectedDelegates、CaughtUpDelegates数组列表的映射
- 可使用其Get方法获得其单例,通过Key生成Rejected、CaughtUpFPredictionKeyEvent事件或通过Key广播Rejected、CaughtUp事件
4.FScopedPredictionWindow
struct GAMEPLAYABILITIES_API FScopedPredictionWindow
{
/** To be called on server when a new prediction key is received from the client (In an RPC).
* InSetReplicatedPredictionKey should be set to false in cases where we want a scoped prediction key but have already repped the prediction key.
* (For example, cached target data will restore the prediction key that the TD was sent with, but this key was already repped down as confirmed when received)
**/
FScopedPredictionWindow(UAbilitySystemComponent* AbilitySystemComponent, FPredictionKey InPredictionKey, bool InSetReplicatedPredictionKey = true);
/** To be called in the callsite where the predictive code will take place. This generates a new PredictionKey and acts as a synchonization point between client and server for that key. */
FScopedPredictionWindow(UAbilitySystemComponent* AbilitySystemComponent, bool CanGenerateNewKey=true);
~FScopedPredictionWindow();
FPredictionKey ScopedPredictionKey;
private:
TWeakObjectPtr<UAbilitySystemComponent> Owner;
bool ClearScopedPredictionKey;
bool SetReplicatedPredictionKey;
FPredictionKey RestoreKey;
};
- 对FPredictionKey做了封装,在客户端上主要作为生成Key的作用,调用第二个构造函数,生成的Key通过RPC发送到服务器上
- 在服务器上不生成新的Key,使用第一个构造函数,直接传入客户端发上来的预测Key,并且在方法调用结束,进行析构时调用FPredictionKey的属性复制。
5. FReplicatedPredictionKeyMap:继承FFastArraySerializer
USTRUCT()
struct FReplicatedPredictionKeyMap : public FFastArraySerializer
{
GENERATED_USTRUCT_BODY()
FReplicatedPredictionKeyMap();
UPROPERTY()
TArray<FReplicatedPredictionKeyItem> PredictionKeys;
void ReplicatePredictionKey(FPredictionKey Key);
bool NetDeltaSerialize(FNetDeltaSerializeInfo & DeltaParms);
FString GetDebugString() const;
static const int32 KeyRingBufferSize;
};
template<>
struct TStructOpsTypeTraits< FReplicatedPredictionKeyMap > : public TStructOpsTypeTraitsBase2< FReplicatedPredictionKeyMap >
{
enum
{
WithNetDeltaSerializer = true,
};
};
字段TArray
6. FReplicatedPredictionKeyItem:继承FFastArraySerializerItem
USTRUCT()
struct FReplicatedPredictionKeyItem : public FFastArraySerializerItem
{
GENERATED_USTRUCT_BODY()
FReplicatedPredictionKeyItem()
{}
UPROPERTY()
FPredictionKey PredictionKey;
void PostReplicatedAdd(const struct FReplicatedPredictionKeyMap &InArray) { OnRep(); }
void PostReplicatedChange(const struct FReplicatedPredictionKeyMap &InArray) { OnRep(); }
FString GetDebugString() { return PredictionKey.ToString(); }
private:
void OnRep();
};
字段PredictionKey,FastArray的标准用法,在PredictionKey新增或变更时,调用OnRep,从而调用相关Key的CaughtUp相关委托
FPredictionKeyDelegates::CatchUpTo(PredictionKey.Current);
总结
GAS预测的实现正在尝试解决的问题
- "我可以这样做吗",那些能够预测
- "回滚"如何回滚预测失败时已经产生的表现
- "重做"如何避免重做带来的负面表现,例如我们在客户端做了预测但是这个操作也从服务器上复制下来了
- "完整性"如何确保我们真正预测了所有情形
- "依赖"如何管理依赖性预测和预测时间链
- "覆盖"如何预测性的覆盖由服务器复制/拥有的状态