Mapz's Blog

可以递归的函数指针

在 Gauntlet 测试中我们经常需要往 UAT 或是游戏中传入一些命令行参数,来自定义一些内容,那么如何传参呢

向 UAT 传参

已知 Gauntlet 命令行启动选择 Gauntlet 脚本的使用的是

-test=xxxx.xxxx.xxxx

其中 xxxx.xxxx.xxxx 为 Gauntlet 的测试配置脚本

如果要向里面传参,则在后面加上括号,括号里参数用逗号隔开即可

例如

-test=xxxx.xxxx.xxxx(Arg1=Value1,Arg2)

这样参数就会传递到 UAT 中

那么如何使用呢

在 Gauntlet 脚本工程中,新建一个 Config 类继承 EpicGame.EpicGameTestConfig

例如

1
2
3
4
5
6
7
8
public class TestConfig : EpicGame.EpicGameTestConfig
{
[AutoParam]
public string Arg1;

[AutoParam]
public bool Arg2;
}

然后更新自己的 Gauntlet 配置脚本,并让其使用自定义的 TestConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyTest : UnrealTestNode<TestConfig>
{
public MyTest(UnrealTestContext inContext) : base(inContext)
{
}

public override TestConfig GetConfiguration()
{
TestConfig config = base.GetConfiguration();
// 中间略,里面可以使用 config.Arg1 等来获取命令行参数
return config;
}
}

这样就可以在 UAT 中使用自定义参数了

向游戏中传参

在 TestConfig 类中重写函数

1
public override void ApplyToConfig(UnrealAppConfig AppConfig, UnrealSessionRole ConfigRole, IEnumerable<UnrealSessionRole> OtherRoles);

然后利用 AppConfig 参数,往游戏中添加命令行参数

例如,如果游戏中需要用到 Arg1,Arg2 那么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public override void ApplyToConfig(UnrealAppConfig AppConfig, UnrealSessionRole ConfigRole, IEnumerable<UnrealSessionRole> OtherRoles)
{
base.ApplyToConfig(AppConfig, ConfigRole, OtherRoles);

if (!string.IsNullOrEmpty(Arg1))
{
AppConfig.CommandLine += string.Format(" -Arg1=\"{0}\"", Arg1);
}

if (Arg2)
{
AppConfig.CommandLine += string.Format(" -Arg2");
}

}
}

这样就可以往游戏中添加命令行参数啦~

编写 GauntletController 的时候,如何调试呢

最简单的方式是游戏添加启动参数

-gauntlet=xxxController1,xxxController2

然后启动的时候,在加载

1
FGauntletModuleImpl

的时候,就会自动寻找到 GauntletController 的 UClass,并执行其 OnInit

学习 UAT 的时候,自己运行 RunUAT ,并且单步调试跟着走一遍,是一种比较快捷的方法

还可以发现很多以前不知道的参数和设置

那么如何调试单步 UAT 呢

首先在 UAT 脚本加上参数 -WaitForDebugger

再运行的时候,命令行就会显示

1
Waiting for debugger to be attached...

这时,打开 IDE 和工程,并且 Debug -> Attach 到名称为 AutomationTool 的进程,就可以了

原因

UE4 更新到 UE5 后, UAT 的工具链依赖从 .NetFramework 更新到了 .NetCore

所以在 UE4 工程里面使用的 Gauntlet 脚本的 csproject 项目文件也需要更新到 .NetCore 的项目文件

否则在编译的时候会报错

迁移工具

使用 try-convert 工具,可以尝试将 .NetFramework 项目文件转换到 .NetCore 风格的 .csproject

1
dotnet tool install -g try-convert

然后在包含 .csproject 的目录下执行

1
try-convert

执行之后可能会让你选择 .NetCore 的版本

按下数字键回车即可

转换完成后,老文件会备份

除错

自动转换的项目文件 TargetFramework 字段和 UE5 不匹配

手动修改为

1
2
<TargetFramework>netcoreapp3.1 </TargetFramework>

即可

原因

在模拟端,UE4 的 Character 类型移动同步是依靠属性同步来实现的

Character 类型的对象通常会被加入到 RG 的网格同步节点中的动态节点

而 Replication Graph 默认的网格同步节点

UReplicationGraphNode_GridSpatialization2D

的单个网格实现是

UReplicationGraphNode_GridCell

其中管理动态对象的节点获取函数为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UReplicationGraphNode* UReplicationGraphNode_GridCell::GetDynamicNode()
{
if (DynamicNode == nullptr)
{
if (CreateDynamicNodeOverride)
{
DynamicNode = CreateDynamicNodeOverride(this);
}
else
{
DynamicNode = CreateChildNode<UReplicationGraphNode_ActorListFrequencyBuckets>();
}
}

return DynamicNode;
}

故而,Character 同步一般用到的是 UReplicationGraphNode_ActorListFrequencyBuckets 节点

这个节点中,会有分帧同步的策略,所以不太适合 Movment 这种需要每帧同步的内容

处理方式

幸运的是,Grid Cell 节点提供了 CreateDynamicNodeOverride 成员,可以让我们自定义动态成员节点

所以我们可以创建自己的节点来管理动态成员

比如

在我们的自定义节点下创建分帧以及 Actor List 两个子节点

然后根据不同的策略把 Actor 加入不同的子节点中即可

在这里,我们只需要把 Character 类型的策略设置为不分帧,或者把需要更精确同步运动的单个 Character 设置为不分帧

Replication Graph 4.27 变化内容

UReplicationGraph::PostServerReplicateStats

在本帧执行同步后会执行此函数

1
virtual void PostServerReplicateStats(const FFrameReplicationStats& Stats) {};

其中参数

FFrameReplicationStats 定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/** Collects basic stats on the replicated actors */
struct FFrameReplicationStats
{
// Total number of actors replicated.
int32 NumReplicatedActors = 0;

// Number of actors who did not send any data when ReplicateActor was called on them.
int32 NumReplicatedCleanActors = 0;

// Number of actors replicated using the fast path.
int32 NumReplicatedFastPathActors = 0;

// Total connections replicated to during the tick including children (splitscreen) and replay connections.
int32 NumConnections = 0;

void Reset()
{
*this = FFrameReplicationStats();
}
};

主要记录了当次同步的一些信息,可利用这些信息重写函数后来做一些操作

FActorRepListRefView 重构

不再是一个包含 FActorRepList 的类型,而是由一个 TArray

1
TArray<FActorRepListType> RepList;

来直接储存 Actor 指针信息,其中 FActorRepListType 为 AActor*

增加了迭代器函数,可以直接使用 for 循环来遍同步列表了

FActorRepListRawView 不再使用,换成了 FActorRepListConstView 来存一个临时的列表引用

FActorRepListStatCollector

当前用于收集展示 RepList 的状态信息

执行控制台命令

1
Net.RepGraph.Lists.Stats

后,逻辑和 4.26 不同,因为结点的同步列表数结构发生变化了,Debug 信息的收集也不太一样了

会执行

1
void UReplicationGraph::CollectRepListStats(FActorRepListStatCollector& StatCollector) const

其中会对所有的 Node 执行

1
void UReplicationGraphNode::DoCollectActorRepListStats(FActorRepListStatCollector& StatsCollector) const

现在 Node 节点提供了一个可重写的函数

1
2
3
4
/**
* Implement this to visit any FActorRepListRefView and FStreamingLevelActorListCollection your node implemented.
*/
virtual void OnCollectActorRepListStats(struct FActorRepListStatCollector& StatsCollector) const {}

可以自定义操作

FActorRepListStatCollector

自己的结点如果需要获得准确的 Debug 信息,应当重写函数,并调用

1
void FActorRepListStatCollector::VisitRepList(const UReplicationGraphNode* NodeToVisit, const FActorRepListRefView& RepList)

1
void FActorRepListStatCollector::VisitStreamingLevelCollection(const UReplicationGraphNode* NodeToVisit, const FStreamingLevelActorListCollection& StreamingLevelList)

等来加入节点中的同步列表信息,信息记录的内容有

1
2
3
4
5
6
7
8
struct FRepListStats
{
uint32 NumLists = 0;
uint32 NumActors = 0;
uint32 MaxListSize = 0;
uint32 NumSlack = 0;
uint64 NumBytes = 0;
};

会按照节点类型,Streaming Level 类型 来分类存储展示

新增结点 TearDown 函数

1
2
/** Mark the node and all its children PendingKill */
virtual void TearDown();

因为上面的重构,需要手动调用自行管理的同步列表的 TearDown ,来清空 List

同时也可以自定义需要的逻辑在里面

例如

1
2
3
4
5
6
7
void UReplicationGraphNode_ActorList::TearDown()
{
Super::TearDown();

ReplicationActorList.TearDown();
StreamingLevelCollection.TearDown();
}

前言

UE4 中的网格分块同步是基于 UReplicationGraphNode_GridSpatialization2D 同步节点的,本文尝试分析 UReplicationGraphNode_GridSpatialization2D 节点的同步流程

UReplicationGraphNode_GridSpatialization2D 下文简称 “分块节点”


相关配置

CellSize:网格的大小
SpatialBias:网格的边界,超过边界会重建网格
ConnectionMaxZ:大于这个高度的玩家不会参与到收集同步 Actor 的过程中


添加和移除同步对象

分块节点本身不保存和移除任何同步对象,对象的记录是通过

1
2
3
4
5
6
7
void AddActor_Static(const FNewReplicatedActorInfo& ActorInfo, FGlobalActorReplicationInfo& ActorRepInfo) { AddActorInternal_Static(ActorInfo, ActorRepInfo, false); }
void AddActor_Dynamic(const FNewReplicatedActorInfo& ActorInfo, FGlobalActorReplicationInfo& ActorRepInfo) { AddActorInternal_Dynamic(ActorInfo); }
void AddActor_Dormancy(const FNewReplicatedActorInfo& ActorInfo, FGlobalActorReplicationInfo& ActorRepInfo);

void RemoveActor_Static(const FNewReplicatedActorInfo& ActorInfo);
void RemoveActor_Dynamic(const FNewReplicatedActorInfo& ActorInfo) { RemoveActorInternal_Dynamic(ActorInfo); }
void RemoveActor_Dormancy(const FNewReplicatedActorInfo& ActorInfo);

来处理的,其中 AddActor_Dynamic 是会移动的 Actor,Static 则是静态不会移动的, AddActor_Dormancy 则是休眠的时候看做静态的,非休眠的时候变为可移动的 Actor

其中 Internal 的几个函数都是 virtual ,可继承后在子类改变其行为

这里只讨论默认的行为

Add Dynamic

如果 Actor 的属性为 bAlwaysRelevant ,会什么也不干,并触发 Warning

否则加入

1
TMap<FActorRepListType, FCachedDynamicActorInfo> DynamicSpatializedActors;

Add Static

如果 Actor 还未初始化,会加入一个

1
TArray<FPendingStaticActors> PendingStaticSpatializedActors;

否则会把它的 GlobalActorReplicationInfo (下文称Actor全局同步Info)
的位置属性赋值为当前 Actor 位置

如果分块节点有范围限制,则在此 Actor 可撑大范围的前提下(通过 Class 过滤)
重新按此 Actor 位置来设定范围

设定规则为当前 Actor 2D 位置减去单个网格大小的二分之一(即是扩展到 Actor 外侧多二分之一网格大小)

然后把对象加入到

1
TMap<FActorRepListType, FCachedStaticActorInfo> StaticSpatializedActors;

并根据其位置,获取到对应的 UReplicationGraphNode_GridCell 结点(代表单个网格的节点,下文称 __网格节点__)

并调用其 UReplicationGraphNode_GridCell::AddStaticActor 函数加入到网格节点中

网格节点在分块节点下保存在

1
TArray< TArray<UReplicationGraphNode_GridCell*> > Grid;

中,为一个二维数组,按 X 和 Y 划分

UReplicationGraphNode_GridCell::AddStaticActor

网格节点继承自 UReplicationGraphNode_ActorList

自带一个同步对象列表

当 Actor全局同步Info 的 bWantsToBeDormant 为 true 的时候

会把这个 Actor 加入到网格节点的 UReplicationGraphNode_DormancyNode 子节点中

否则会直接加入网格节点

Remove Static

Add Static 的反操作,没啥可说的

Remove Dynamic

先从 DynamicSpatializedActors 中找到 Actor 的 FCachedDynamicActorInfo

然后根据其中储存的网格信息找到相关网格,然后调用网格的

1
UReplicationGraphNode_GridCell::RemoveDynamicActor(const FNewReplicatedActorInfo& ActorInfo)

移除 Actor,
然后再移除 DynamicSpatializedActors 中相应的 Actor

UReplicationGraphNode_GridCell::RemoveDynamicActor

找到网格节点的 DynamicNode ,并调用其 NotifyRemoveNetworkActor

DynamicNode 的默认实现为 UReplicationGraphNode_ActorListFrequencyBuckets

但是可以自定义其类型

最终获取同步对象的时候,Dynamic 类型的 Actor 都是从 DynamicNode 上获取的
,而从上面 Static Add 的部分我们可以看出来 Static 类型的 Actor 是直接从网格节点上获取的

关于 UReplicationGraphNode_ActorListFrequencyBuckets

这个节点用于限制每次同步的时候从节点上获取的 Actor 数量,来处理避免扎堆同步的情况,细节上先不表

AddDormancy

如果是 ActorRepInfo.bWantsToBeDormant 为 true
直接调用 AddActorInternal_Static,只是休眠参数传入 true
否则直接调用 AddActorInternal_Dynamic

简单明了

休眠状态的变化,则是通过 Add 的时候添加休眠事件的回调来处理的

RemoveDormancy

和上面那个相反的操作,没啥可说的

Actor 信息存放位置总结

Static 对象引用是放在网格节点上的
Dynamic 对象引用是放在网格节点的 Dynamic 节点上的
Dormancy 对象在休眠的时候,放在网格节点的 Dormancy 子节点,非休眠的时候,放在 Dynamic 子节点上


同步前:PrepareForReplication

我们来查看函数

1
UReplicationGraphNode_GridSpatialization2D::PrepareForReplication()

首先,遍历 DynamicSpatializedActors,做以下操作:

  1. 获取其 Actor 位置,并查看是否需要扩大同步的范围(和 AddStatic 中的操作一样)

  2. 无需重新创建网格的时候,执行以下操作

    a. 获取当前 Actor 所在网格信息

    b. 检测当前所在网格和上次所在网格的关系,如发生改变了,移除不在的网格节点中的 Actor 信息,新增新网格节点中的 Actor 信息

    c. 如果有更新网格信息,则重设 Actor 同步信息中的网格信息

然后,遍历 PendingStaticSpatializedActors,检查是否初始化完毕了,并加入 Static 列表中

如果需要重建网格,执行以下操作

  1. 移除当前的所有网格节点
  2. 遍历 DynamicSpatializedActors 和 StaticSpatializedActors,根据现有 Actor 重建网格节点并加入 Actor 信息

同步前处理内容总结

同步之前,主要做的是重新设定 Actor 的网格的工作,如有需要,会重建网格节点列表


搜集需要同步的 Actor 列表:GatherActorListsForConnection

首先,遍历每个玩家角色

  1. 获得当前玩家所在的网格信息

  2. 获得玩家上次的位置

  3. 合并相同玩家的网格信息,在合并后的网格中,收集需要同步的 Actor

    a. 和上面的 位置 一样,找到相应的网格节点的子节点,收集相关内容

  4. 把玩家所在的网格标记为 Active 网格

如果需要销毁 Dynamic 的休眠 Actor,则遍历 Active 网格,如果 Active 网格发生改变了,则

  1. 找到网格的 DormancyNode 并获取休眠 Actor 列表
  2. 找到 Active 网格的上次的玩家网格信息的 DormancyNode 并获取休眠 Actor 列表

如果 Active 网格发生改变了,并且上次的玩家网格中有休眠 Actor,则遍历休眠 Actor 列表

  1. 通知客户端 Destroy 这个 Actor
  2. 修改这个 Actor 的网格节点上的休眠节点的这个 Actor 的同步状态

收集同步列表总结

先将玩家所在网格标记为 Active 网格,合并重复网格后,收集网格中需要同步的 Actor,然后检查并通知切换了网格的客户端销毁上个网格中的休眠的动态 Actor

UE4引擎采用的是状态同步的网络同步方式,虚幻竞技场为了优化客户端体验,减少不一致的情况,使用了延迟补偿机制

在 PlayerController 中,定义了一些补偿相关的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class UNREALTOURNAMENT_API AUTPlayerController : public AUTBasePlayerController
{
//..... 略
//-----------------------------------------------
// Perceived latency reduction
/** Used to correct prediction error. */
UPROPERTY(EditAnywhere, Replicated, GlobalConfig, Category=Network)
float PredictionFudgeFactor;

/** Negotiated max amount of ping to predict ahead for. */
UPROPERTY(BlueprintReadOnly, Category=Network, Replicated)
float MaxPredictionPing;

/** user configurable desired prediction ping (will be negotiated with server. */
UPROPERTY(BlueprintReadOnly, GlobalConfig, Category=Network)
float DesiredPredictionPing;

UPROPERTY(GlobalConfig, EditAnywhere, Category = Debug)
bool bIsDebuggingProjectiles;

/** Propose a desired ping to server */
UFUNCTION(reliable, server, WithValidation)
virtual void ServerNegotiatePredictionPing(float NewPredictionPing);

/** Console command to change prediction ping */
UFUNCTION(Exec)
virtual void Predict(float NewDesiredPredictionPing);

/** Return amount of time to tick or simulate to make up for network lag */
virtual float GetPredictionTime();

/** How long fake projectile should sleep before starting to simulate (because client ping is greater than MaxPredictionPing). */
virtual float GetProjectileSleepTime();

/** List of fake projectiles currently out there for this client */
UPROPERTY()
TArray<class AUTProjectile*> FakeProjectiles;

//..... 略
}

在客户端的 PlayerController 会通过 ServerNegotiatePredictionPing RPC 在在服务器上设置 MaxPredictionPing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void AUTPlayerController::BeginPlay()
{
bSpectatorMouseChangesView = false;
Super::BeginPlay();
if (Role < ROLE_Authority)
{
ServerNegotiatePredictionPing(DesiredPredictionPing);
}
}

void AUTPlayerController::ServerNegotiatePredictionPing_Implementation(float NewPredictionPing)
{
MaxPredictionPing = FMath::Clamp(NewPredictionPing, 0.f, UUTGameEngine::StaticClass()->GetDefaultObject<UUTGameEngine>()->ServerMaxPredictionPing);
}

通过 GetPredictionTime 函数,可以取得需要补偿的时间

1
2
3
4
5
6
float AUTPlayerController::GetPredictionTime()
{
// exact ping is in msec, divide by 1000 to get time in seconds
//if (Role == ROLE_Authority) { UE_LOG(UT, Warning, TEXT("Server ExactPing %f"), PlayerState->ExactPing); }
return (PlayerState && (GetNetMode() != NM_Standalone)) ? (0.0005f*FMath::Clamp(PlayerState->ExactPing - PredictionFudgeFactor, 0.f, MaxPredictionPing)) : 0.f;
}

再查看 Character 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class UNREALTOURNAMENT_API AUTCharacter : public ACharacter, public IUTTeamInterface
{
//...略
/** Stored past positions of this player. Used for bot aim error model, and for server side hit resolution. */
UPROPERTY()
TArray<FSavedPosition> SavedPositions;

/** Maximum interval to hold saved positions for. */
UPROPERTY()
float MaxSavedPositionAge;

/** Mark the last saved position as where a shot was spawned so can synch firing to client position. */
virtual void NotifyPendingServerFire();

/** Called by CharacterMovement after movement */
virtual void PositionUpdated(bool bShotSpawned);

/** Returns this character's position PredictionTime seconds ago. */
UFUNCTION(BlueprintCallable, Category = Pawn)
virtual FVector GetRewindLocation(float PredictionTime, AUTPlayerController* DebugViewer=nullptr);

/** Max time server will look back to found client synchronized shot position. */
UPROPERTY(EditAnyWhere, Category = "Weapon")
float MaxShotSynchDelay;

UPROPERTY(BlueprintReadOnly, Category = Pawn)
class AUTGameVolume* LastGameVolume;

/** Returns most recent position with bShotSpawned. */
virtual FVector GetDelayedShotPosition();
virtual FRotator GetDelayedShotRotation();

/** Return true if there's a recent delayed shot */
virtual bool DelayedShotFound();

/** returns a simplified set of SavedPositions containing only the latest position for a given frame (i.e. each element has a unique Time)
* @param bStopAtTeleport - removes any positions prior to and including the most recent teleportation
*/
void GetSimplifiedSavedPositions(TArray<FSavedPosition>& OutPositions, bool bStopAtTeleport) const;
//...略
}

可以看到角色上使用 TArray 存储了一组 FSavedPosition 信息

包括了位置,旋转,速度,是否发出了射击,时间戳等

定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
USTRUCT(BlueprintType)
struct FSavedPosition
{
GENERATED_USTRUCT_BODY()

FSavedPosition() : Position(FVector(0.f)), Rotation(FRotator(0.f)), Velocity(FVector(0.f)), bTeleported(false), bShotSpawned(false), Time(0.f), TimeStamp(0.f) {};

FSavedPosition(FVector InPos, FRotator InRot, FVector InVel, bool InTeleported, bool InShotSpawned, float InTime, float InTimeStamp) : Position(InPos), Rotation(InRot), Velocity(InVel), bTeleported(InTeleported), bShotSpawned(InShotSpawned), Time(InTime), TimeStamp(InTimeStamp) {};

/** Position of player at time Time. */
UPROPERTY()
FVector Position;

/** Rotation of player at time Time. */
UPROPERTY()
FRotator Rotation;

/** Keep velocity also for bots to use in realistic reaction time based aiming error model. */
UPROPERTY()
FVector Velocity;

/** true if teleport occurred getting to current position (so don't interpolate) */
UPROPERTY()
bool bTeleported;

/** true if shot was spawned at this position */
UPROPERTY()
bool bShotSpawned;

/** Current server world time when this position was updated. */
float Time;

/** Client timestamp associated with this position. */
float TimeStamp;
};

更新位置信息 TArray 的地方是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void AUTCharacter::PositionUpdated(bool bShotSpawned)
{
const float WorldTime = GetWorld()->GetTimeSeconds();
if (GetCharacterMovement())
{
new(SavedPositions)FSavedPosition(GetActorLocation(), GetViewRotation(), GetCharacterMovement()->Velocity, GetCharacterMovement()->bJustTeleported, bShotSpawned, WorldTime, (UTCharacterMovement ? UTCharacterMovement->GetCurrentSynchTime() : 0.f));
}

// maintain one position beyond MaxSavedPositionAge for interpolation
if (SavedPositions.Num() > 1 && SavedPositions[1].Time < WorldTime - MaxSavedPositionAge)
{
SavedPositions.RemoveAt(0);
}
}

可以看到加入新位置的时候会移除超过保存时间限制的位置

调用栈

1
2
+ AUTCharacter::PositionUpdated(bool bShotSpawned)
+ UUTCharacterMovement::PerformMovement()

可见每帧会保存一个当前帧的数据,其中 bShotSpawned 参数传入当前帧是否会发射射击,是否射击的状态在武器类上维护

下面我们继续查看抛射物如何做预测

1
2
调用 AUTProjectile* AUTWeapon::FireProjectile()
调用 AUTProjectile* AUTWeapon::SpawnNetPredictedProjectile(TSubclassOf<AUTProjectile> ProjectileClass, FVector SpawnLocation, FRotator SpawnRotation)

在客户端上,如果当前延迟超过了最大预测延迟,会在等待一段超出最大预测延迟的时间后,射出模拟子弹,避免延迟过高的情况下,补偿产生的副作用

1
GetWorldTimerManager().SetTimer(SpawnDelayedFakeProjHandle, this, &AUTWeapon::SpawnDelayedFakeProjectile, SleepTime, false);

如果当前延迟没有超过最大延迟补偿

在服务器上,则会直接对抛射物和它的 MovememtComponent 执行一次 tick

tick 传入的时间就是补偿的时间

从而达到补偿的目的

客户端上则会执行 InitFakeProjectile 初始化一个模拟抛射物

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
if (Role == ROLE_Authority)
{
NewProjectile->HitsStatsName = HitsStatsName;
if ((CatchupTickDelta > 0.f) && NewProjectile->ProjectileMovement)
{
// server ticks projectile to match with when client actually fired
// TODO: account for CustomTimeDilation?
if (NewProjectile->PrimaryActorTick.IsTickFunctionEnabled())
{
NewProjectile->TickActor(CatchupTickDelta * NewProjectile->CustomTimeDilation, LEVELTICK_All, NewProjectile->PrimaryActorTick);
}
NewProjectile->ProjectileMovement->TickComponent(CatchupTickDelta * NewProjectile->CustomTimeDilation, LEVELTICK_All, NULL);
NewProjectile->SetForwardTicked(true);
if (NewProjectile->GetLifeSpan() > 0.f)
{
NewProjectile->SetLifeSpan(0.1f + FMath::Max(0.01f, NewProjectile->GetLifeSpan() - CatchupTickDelta));
}
}
else
{
NewProjectile->SetForwardTicked(false);
}
}
else
{
NewProjectile->InitFakeProjectile(OwningPlayer);
NewProjectile->SetLifeSpan(FMath::Min(NewProjectile->GetLifeSpan(), 2.f * FMath::Max(0.f, CatchupTickDelta)));
}
}

对于其他同步下来的抛射物,客户端也会在同步到位置信息或者 BeginPlay 的时候,直接给予一个延迟补偿

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void AUTProjectile::PostNetReceiveLocationAndRotation()
{
//...略
// forward predict to get to position on server now
if (!bFakeClientProjectile)
{
AUTPlayerController* MyPlayer = Cast<AUTPlayerController>(InstigatorController ? InstigatorController : GEngine->GetFirstLocalPlayerController(GetWorld()));
if (MyPlayer)
{
float CatchupTickDelta = MyPlayer->GetPredictionTime();
if ((CatchupTickDelta > 0.f) && ProjectileMovement)
{
ProjectileMovement->TickComponent(CatchupTickDelta, LEVELTICK_All, NULL);
}
}
}
//...略
}

void AUTProjectile::BeginPlay()
{
//...略
if (Role == ROLE_Authority)
{
//...略
}
else
{
AUTPlayerController* MyPlayer = Cast<AUTPlayerController>(InstigatorController ? InstigatorController : GEngine->GetFirstLocalPlayerController(GetWorld()));
if (MyPlayer)
{
// Move projectile to match where it is on server now (to make up for replication time)
float CatchupTickDelta = MyPlayer->GetPredictionTime();
if (CatchupTickDelta > 0.f)
{
CatchupTick(CatchupTickDelta);
}
//...略
}
}
//...略
}

那么 Rewind 机制是在哪儿使用的咧?
我们查找寻回角色历史位置的函数

1
2
3
/** Returns this character's position PredictionTime seconds ago. */
UFUNCTION(BlueprintCallable, Category = Pawn)
virtual FVector GetRewindLocation(float PredictionTime, AUTPlayerController* DebugViewer=nullptr);

在武器的发射函数中

1
void AUTWeapon::FireShot()

有抛射物模式和快速检测命中模式两种类型的发射
如果是抛射物,执行的上面提到的通过 TickActor 和 TickMovement 来补偿的方式
如果是快速检测命中
则会执行射线检测

射线检测会遍历所有的 Character
然后对每个 Character 在其当前位置(客户端),或者补偿位置(服务器),也就是通过 GetRewindLocation 传入补偿时间获得的历史位置
来检查位置和射线的最近距离
再把最近距离和胶囊体大小比较来判断是否击中

如果是客户端,会先快速在客户端本地做一次无补偿的射线检测
如果有射中目标,则会通过

1
2
UFUNCTION(Server, Unreliable, WithValidation)
void ServerHitScanHit(AUTCharacter* HitScanChar, uint8 HitScanEventIndex);

上报服务器客户端命中的目标和命中事件的 Index

同时服务器上也会做一次带补偿的射线检测

如果客户端有上报数据,那么服务器上的对于客户端上报的命中数据会有一些额外的优惠(更容易被判定为击中)

CallRemoteFunction

函数直接定义在 UObject 上,但是无意义

1
2
3
4
5
6
7
8
9
10
11
/**
* Call the actor's function remotely
*
* @param Function function to call
* @param Parameters arguments to the function call
* @param Stack stack frame for the function call
*/
virtual bool CallRemoteFunction( UFunction* Function, void* Parms, struct FOutParmRec* OutParms, FFrame* Stack )
{
return false;
}

在需要的类型上,可以自己重载函数

典型的就是 Actor 的 CallRemoteFunction

Actor RPC 流程

  1. 执行 AActor::CallRemoteFunction
  2. 调用 UNetDriver::ProcessRemoteFunction
  3. 尝试执行 UReplicationDriver::ProcessRemoteFunction

    UReplicationDriver::ProcessRemoteFunction 默认实现总是会返回失败

  4. 尝试执行失败后,执行默认 RPC 方案
    a. 是 Server Multicast 则遍历所有 Connection
    b. 针对 Connection,判断 Actor 是否 IsNetRelevantFor,或者是否为 Reliable 的 RPC,排除不符合条件的 Connection
    c. 调用 RepLayout->BuildSharedSerializationForRPC 生成共享的 RPC 数据缓存
    d. 调用 UNetDriver::InternalProcessRemoteFunctionPrivate
    e. 遍历完成后调用 RepLayout->ClearSharedSerializationForRPC 清除 RPC 数据共享缓存
    f. 不是 Multicast,则调用 UNetDriver::InternalProcessRemoteFunction,内部依然调用的是 UNetDriver::InternalProcessRemoteFunctionPrivate

UNetDriver::InternalProcessRemoteFunctionPrivate 流程

  1. 获得连接,判断类型和函数的 NetCache 是否存在

  2. 获得或者创建 UActorChannel

  3. 调用 UNetDriver::ProcessRemoteFunctionForChannelPrivate
    a. 如果 ActorChanel 还没有 Open,则开启 Open 步骤,初始化同步一次 Actor
    b. 开始搭建 RPC Bunch
    c. 写入 RPC 参数的值

    1
    2
    TSharedPtr<FRepLayout> RepLayout = GetFunctionRepLayout(Function);
    RepLayout->SendPropertiesForRPC(Function, Ch, TempWriter, Parms);

    d. 装载类型ID,函数ID,和参数值到 Bunch
    e. 根据是否使用队列,判断是加入 RPC 队列还是直接调用
    f. 如果是 RPC 队列,则加入队列

    1
    Ch->QueueRemoteFunctionBunch(TargetObj, Function, Bunch);

    g. 如果直接发送,则发送 Bunch

    1
    Ch->SendBunch(&Bunch, true);

GameplayDebugger 简述

UE4 中有很多种把调试信息显示在屏幕上的办法

比如 AHUD::OnShowDebugInfo 注册一个函数来打印

比如 GEngine->AddOnScreenDebugMessage

每种方式都有自己的适用场景

GEngine->AddOnScreenDebugMessage 适用于一次性的打印

AHUD::OnShowDebugInfo 适合于收集实时状态并显示在屏幕上

而我们今天要说的 GameplayDebugger 则可以看做一个加强版的 OnShowDebugInfo

它的定位也是实时收集状态并显示在屏幕上,但是他的原理更加复杂,提供的功能更多,适用场景也不完全相同

相对于 OnShowDebugInfo,GameplayDebugger 解决的问题主要是收集显示服务器上的状态并打印在当前客户端上

所以我们可以看到官方提供的几个调试类型,包括 NavMesh,EQS,AI,BehaviorTree,Ability 这些

都是在服务器上运行主要或者全部逻辑,不靠同步就无法在客户端上查看平时非展示的信息

依靠 Dedicated Server 的游戏,在调试版本开启 GameplayDebugger 宏,也可以轻松的在客户端屏幕上展示需要查看的服务器信息

而不是只能事后查看日志

如果是单机游戏,使用 AHUD::OnShowDebugInfo 也就够了,当然 GameplayDebugger 的代码结构性更强一点,一般只需要继承实现一些接口就可以了

另外呢,GameplayDebugger 设计上也是最小化 Debugger 所用的网络带宽,提倡自定义序列化 Debugger 数据,并且还支持自定义调试者按键操作的功能

这些支持在 OnShowDebugInfo 都是没有的

GameplayDebugger 的基本使用

在游戏中,按下 ‘ 键,或者在控制台中输入 EnableGDT ,就会呼出 GameplayDebugger 控制台

图片中上面的部分为 GameplayDebugger 状态栏

0 到 n 是当前支持的 Debug Category,使用数字小键盘按下相应的数字开启或关闭,开启的是绿色,关闭的显示为白色

右上角显示的是当前 Debug 的 Actor 和是否记录 VLog

各个开启的 Category 的 Debug Info 会显示状态栏的下面部分,而 Debug 信息的绘制是完全自定义的

GameplayDebugger 的基本原理

Debugger 会针对每个 Category 配置好的周期,针对在编辑器中选择的 Actor 来进行按一定规则的数据收集

并把收集到的数据通过针对每个玩家独立的一个 Replicator 同步到测试者的客户端上

然后通过自定义的规则,将数据显示在屏幕上

GameplayDebugger 的基本扩展

实现自定义的 Category

创建同步用结构体

自定义一个同步用的 Struct ,用于描述需要同步的 Debug 信息

这个 Struct 通常为了不过度占用游戏本身网络资源引起 Debugger 的副作用,会自定义序列化函数 void Serialize(FArchive& Ar),尽量减少网络占用

创建自定义 Category

继承 FGameplayDebuggerCategory 类型,编写自定义的 Category 类,实现其中的几个接口

  1. 收集数据
    1
    virtual void CollectData(APlayerController* OwnerPC, AActor* DebugActor)

    在服务器上面收集需要 Debug 的信息,收集完成后会根据同步数据包是否 Dirty 的情况进行同步,OnwerPC 是操作者的 PlayerContoller,DebugActor 是当前选中需要收集信息的 DebugActor
    这个里面通常有设置同步用 Struct 内容的操作
    收集的内容其实也不限于 DebugActor ,能够通过 DebuggerActor 收集到的数据,都可以作为信息被收集
    更是可以突破 DebugActor 的限制,从其他的地方提前收集参数并缓存起来,再在 CollectData 的时候设置给同步用的 Struct

  2. 绘制Debug信息
    1
    virtual void DrawData(APlayerController* OwnerPC, FGameplayDebuggerCanvasContext& CanvasContext);

    本地绘制需要显示在屏幕上的 Debug 信息

  3. 高级绘制
    1
    virtual FDebugRenderSceneProxy* CreateDebugSceneProxy(const UPrimitiveComponent* InComponent, FDebugDrawDelegateHelper*& OutDelegateHelper);

    如果需要一些高级的Debug信息绘制方式,可以覆盖这个函数
    详情可以了解 FDebugRenderSceneProxy 以及其父类 FPrimitiveSceneProxy 的工作方式
    可以实现一些高级效果,比如在 Actor 周围绘制线条,或者图形,文本等

  4. 客户端接收同步回调
    1
    virtual void OnDataPackReplicated(int32 DataPackId);

    Debug 信息同步完成的回调函数

为了不让 Debugger 影响游戏主体的网络过多,应当适当的设置 FGameplayDebuggerCategory 的参数

float CollectDataInterval;

或者说设置非收集,然后用

void ForceImmediateCollect()

在合适的时机来强制收集(通常这样会在游戏本身的部分嵌入一些代码,会加大耦合,GameplayDebugger 本意上通过重写收集函数是尽量去耦合的,可以考虑用事件等方式把游戏本身和 Debugger 的代码解耦)

配置同步用 Struct 地址

1
2
template<typename T>
int32 SetDataPackReplication(T* DataPackAddr, EGameplayDebuggerDataPack Flags = EGameplayDebuggerDataPack::ResetOnTick)

这个函数会配置一个同步数据的 DataPack (FGameplayDebuggerDataPack 类型),传入的 T* 就是你自己的需要同步的 Struct Data 的地址

函数内部会绑一下序列化函数和数据包清空等操作

一个 DataPack 是复用的,内部包含数据的版本信息,传输状态等,也就是说,例如,我们通常通过一个 Struct 的地址来存放数据

DataPack 会在 Collect Data 后判断里面的内容是否变化等,来触发同步和客户端的接收

我们一般在 自定义的 Category 类中加一个同步用的 Struct 的成员

然后在构造函数中调用 SetDataPackReplication 传入这个成员的地址,来配置一个同步包

其他

其他的一些 FGameplayDebuggerCategory 的成员可在有需要的时候查看代码

启用 Category

1
2
3
4
5
#if WITH_GAMEPLAY_DEBUGGER
IGameplayDebugger& GameplayDebuggerModule = IGameplayDebugger::Get();
GameplayDebuggerModule.RegisterCategory(CUSTOM_DEBUG_CATAGORY_NAME, IGameplayDebugger::FOnGetCategory::CreateStatic(&FCustomDebuggerCategory::MakeInstance), EGameplayDebuggerCategoryState::Disabled);
GameplayDebuggerModule.NotifyCategoriesChanged();
#endif

通过上述代码来注册一个 Category 到 GameplayDebugger 系统中

其中 FCustomDebuggerCategory::MakeInstance 函数指针返回一个 TSharedRef 也就是你自己的 Category 的一个智能引用

通常这段代码写在游戏模块的

StartupModule()

中,并在

ShutdownModule()

的时候通过

1
2
3
4
5
#if WITH_GAMEPLAY_DEBUGGER
IGameplayDebugger& GameplayDebuggerModule = IGameplayDebugger::Get();
GameplayDebuggerModule.UnregisterCategory(CUSTOM_DEBUG_CATAGORY_NAME);
GameplayDebuggerModule.NotifyCategoriesChanged();
#endif

来卸载

关于 Replication Graph

Replication Graph 启动的时候,需要手动设置 AGameplayDebuggerCategoryReplicator 类型的同步行为

建议把官方 Shooter Game 中的代码复制过来

但是要注意官方 Shooter Game 中的代码有个bug,引起客户端无法同步 AGameplayDebuggerCategoryReplicator

修改方式在注释中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#if WITH_GAMEPLAY_DEBUGGER
void UShooterReplicationGraph::OnGameplayDebuggerOwnerChange(AGameplayDebuggerCategoryReplicator* Debugger, APlayerController* OldOwner)
{
auto GetAlwaysRelevantForConnectionNode = [&](APlayerController* Controller) -> UShooterReplicationGraphNode_AlwaysRelevant_ForConnection*
{
// 注意:此处应该改 OldOwner 为 Controller
if (OldOwner)
{
// 注意:此处应该改 OldOwner 为 Controller
if (UNetConnection* NetConnection = OldOwner->GetNetConnection())
{
if (UNetReplicationGraphConnection* GraphConnection = FindOrAddConnectionManager(NetConnection))
{
for (UReplicationGraphNode* ConnectionNode : GraphConnection->GetConnectionGraphNodes())
{
if (UShooterReplicationGraphNode_AlwaysRelevant_ForConnection* AlwaysRelevantConnectionNode = Cast<UShooterReplicationGraphNode_AlwaysRelevant_ForConnection>(ConnectionNode))
{
return AlwaysRelevantConnectionNode;
}
}

}
}
}

return nullptr;
};

if (UShooterReplicationGraphNode_AlwaysRelevant_ForConnection* AlwaysRelevantConnectionNode = GetAlwaysRelevantForConnectionNode(OldOwner))
{
AlwaysRelevantConnectionNode->GameplayDebugger = nullptr;
}

if (UShooterReplicationGraphNode_AlwaysRelevant_ForConnection* AlwaysRelevantConnectionNode = GetAlwaysRelevantForConnectionNode(Debugger->GetReplicationOwner()))
{
AlwaysRelevantConnectionNode->GameplayDebugger = Debugger;
}
}
#endif

结个尾

编写自定义的 Category 需要注意的是,Debugger 本身不应该过重的占用游戏本身的资源,包括计算力和网络,也应该做到和游戏本身的功能解耦合

GameplayDebugger 除了 Category 之外,还有自定义测试者输入的功能,后续有空的时候再写