Calmer的文章

  • 首页
  • 文章归档
  • 关于页面

  • 搜索
体验游戏 笔记 推荐 工具链 工具使用 小游戏 插件 UI 软件 教程

延迟和预测回滚(GAS)

发表于 2024-04-09 | 分类于 游戏开发 | 0 | 阅读次数 3479

前言

在网络游戏开发中,无法避免的问题就是网络延迟,而为了降低网络延迟带来的感受,常使用一种通用方案就是预测回滚,这篇文章主要记录虚幻引擎中GAS框架是如何实现能力(GA)的预测回滚


一、网络延迟

1. 网络延迟是什么

网络传输需要时间,消息存在滞后性,这个滞后性被称为网络延迟

2. 产生网络延迟过程

  • 客户端--->>>服务端的传输时间
  • 服务器运行处理时间
  • 服务器--->>>客户端的传输时间

3. 网络延迟产生原因

网络延迟产生情况很复杂

  • 可能是客户端与服务器距离太远
  • 可能是环境信号差
  • 可能服务器处理压力太大等
    都可能会出现网络延迟,针对网络延迟,无法避免,只能尽最大程度保证游戏流畅性

4. 常见解决方案

  1. 使用CDN,主要减少物理传输距离
  2. 搭建专用网络,主要减少网络传输堵塞
  3. 更换性能更优越的服务器主机,主要提升处理速度
  4. 使用UDP代替TCP传输(为什么UDP比TCP更快?)
  5. 使用预测回滚技术,本文主要讲述的内容
  6. 适当提高客户端和服务器帧率,主要提升传输频率

二、预测回滚

1. 预测回滚是什么

在网络游戏开发中,客户端预先模拟服务器的操作被称为预测;客户端预测失败时取消所执行过的操作被称为回滚

2. 为什么要做预测回滚

通俗的说是为了降低网络延迟带来的不流畅体验

3. 预测回滚的思考

既然是预测,面临的情况就是预测成功和失败
如果客户端预测成功了,Do X; 如何避免重做?
如果客户端预测失败了,Do Y; 如何回滚,UnDo?Rejected
无论成功与否,Do Z CatchUpOrRejected

那些操作能预测?
那些操作不能预测?
那些操作建议不进行预测等?

例如

  1. 预测成功了,客户端调整因为预测做的一些临时操作,或一些由服务器同步的操作,而避免重复执行
  2. 预测失败了
    既然要回滚,当然要做的就是将做过的操作记录下来,然后书写回滚的逻辑,怎么才能恢复到之前的状态。
    这里又需要考虑的一个问题,这个回滚,如果多步骤(非原子操作),回滚的时序和回滚的步骤都息息相关,需要在设计时支持这个操作能够被回滚。且是否支持在乱序中,漏序中也能恢复到预期
  3. 无论成功与否,客户端因为做了一些临时的便于回滚的操作。
    例如GE预测,失败时需要移除此GE,成功时也需要移除客户端GE效果,再从服务器同步下来

三、GAS中能力预测回滚的实现

GA中有四种网络执行策略Net Execution Policy

GA的四种网络执行策略.jpg

  • Local Predicted:在拥有的客户端上先执行,服务器也会执行,以服务器为权威,同步修改客户端预测不正确的地方
  • Local Only:只在拥有者客户端上执行
  • Server Only:只在服务器上执行,例如被动技能
  • Server Initiated:服务器先执行,客户端后执行

这里重点讨论Local Predicted类别

技能的激活流程

技能激活流程.drawio.png

客户端

  1. 首先在客户端激活技能,调用TryActivateAbility--->>>InternalTryActivateAbility
    在InternalTryActivateAbility中会进行CanActivateAbility的判定
  2. 当客户端判定能够执行后,会设置激活信息ActivationInfo。并调用FScopePredictionWindow生成唯一性的PredictionKey与当前技能进行关联
    FScopedPredictionWindow构造Key.png
  3. 再一并通过CallServerTryActivateAbility RPC发送到服务器,并绑定当前PredictionKey的CaughtUp委托,以进行PredictionKey从服务器复制下来后处理逻辑
    客户端CallServer激活技能.png

Tip:PredictionKey可以理解为这次激活技能操作集合的一个ID,所有在本次技能所执行的操作,都会关联这个ID,后续做回滚时,才知道需要回滚那些的操作
例如在激活一次技能,播放了Montage,应用了GE操作等,这些操作都关联一个ID,此时技能预测失败了,需要找到预测ID关联的操作集合进行回滚,以上就需要将Montage立刻停止,并且删除这次技能所应用的GE

  1. 最后客户端直接在本地执行ActivateAbility
    客户端ActivateAbility.png

服务器

  1. 当服务器收到客户端RPC后,调用ServerTryActivateAbility_Implementation方法
  2. 服务器进行技能合法性和CanActiveAbility等条件判定
  3. 如果失败,直接通过ClientActivateAbilityFailed RPC通知客户端预测失败后广播PredictionKey中的RejectedDelegates,进行回滚操作,并调用K2_EndAbility结束技能
    服务器告诉客户端预测失败.png
  4. 如果成功,则通过ClientActivateAbilitySucceed RPC通知客户端预测成功
    在此前进行了FScopedPredictionWindow构造,在InternalTryActivateAbility函数调用完毕后,FScopedPredictionWindow析构中调用PredictionKey的复制,在客户端上广播了PredictionKey的CautchUpDelegates,取消客户端上的一些临时操作,例如Instant GE的在客户端的预测需要取消。
    服务器告诉客户端预测成功.png

技能的结束流程

技能结束流程.drawio.png

  1. 同样结束技能也有一套流程,客户端调用EndAbility时,执行客户端上的技能结束逻辑后,通过CallServerEndAbility RPC告诉服务器,客户端准备结束技能了
  2. 服务器收到RPC后,如果是预测的技能,进行FScopedPredictionWindow构造,在服务器调用 ServerEndAbility_Implementation完成后,通过FScopedPredictionWindow的析构复制PredictionKey从而调用客户端上的CaughtUp委托
    服务器结束技能.png
  3. 服务器找到与当前PredictionKey的技能实例,执行服务器结束逻辑,执行完成后,再通过ClientEndAbility RPC告诉客户端,此时客户端IsActive为false,直接结束流程

GE的预测

GE的执行流程
GE的预测流程.drawio.png
一般情况下都不对GE进行预测,所以GE都是在权威端执行,客户端不执行
但GAS也支持部分类型的GE预测,不过无论最终预测成功或失败都需要在客户端上移除执行的GE。
在客户端进行GE的预测限制也比较多,例如周期性GE(Period>0)、叠加GE、GE的移除操作等都不能进行预测
以下是应用GE客户端的预测流程

  1. 在客户端进行应用GE预测时,首先在GAS全局方法ShouldPredictTargetGameplayEffects()中获取是否需要进行客户端预测,否则不进行预测
  2. 然后判定本次GE的使用是否有合法的预测PredictionKey,此PredictionKey是在激活技能时生成的,如果Key无效也不进行预测
  3. 当有合法的预测Key,但GE是周期性的也不支持预测
  4. 再通过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;
  1. 在进行FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec时,如果是叠加的GE,也不进行预测
  2. 最后对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();
};
  1. 从预测回滚的实现上来说每进行一次预测行为就需要一个唯一的Key作为标识,而预测行为中的操作集合都需要Bind这一个唯一的Key,从而在预测成功或失败时,可以通过Key找到所关联的操作,进行回滚相关操作。FPredictionKey就是GAS中作为唯一性预测Key的结构,其关联着所有的预测行为。
  2. 在结构体中,真正作为唯一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);

};
  1. 管理着FPredictionKey与RejectedDelegates、CaughtUpDelegates数组列表的映射
  2. 可使用其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;
};
  1. 对FPredictionKey做了封装,在客户端上主要作为生成Key的作用,调用第二个构造函数,生成的Key通过RPC发送到服务器上
  2. 在服务器上不生成新的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 PredictionKeys,FastArray的标准用法,最终用于FPredictionKey数组的网络复制

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预测的实现正在尝试解决的问题

  1. "我可以这样做吗",那些能够预测
  2. "回滚"如何回滚预测失败时已经产生的表现
  3. "重做"如何避免重做带来的负面表现,例如我们在客户端做了预测但是这个操作也从服务器上复制下来了
  4. "完整性"如何确保我们真正预测了所有情形
  5. "依赖"如何管理依赖性预测和预测时间链
  6. "覆盖"如何预测性的覆盖由服务器复制/拥有的状态
  • 本文作者: Calmer
  • 本文链接: https://mytechplayer.com/archives/yan-chi-he-yu-ce-hui-gun-gas
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
# 笔记
UE4/5中编辑器和命令的扩展(新)
UE5根运动原理
  • 文章目录
  • 站点概览
Calmer

Calmer

88 日志
7 分类
10 标签
RSS
Creative Commons
0%
© 2020 — 2025 Calmer
由 Halo 强力驱动
蜀ICP备20010026号-1川公网安备51019002006543
Copyright © 2020-2025 Calmer的文章