# Unreal ReplicationGraph 篇
# 概述
ReplicationGraph 作为 Unreal 引擎的核心功能,提供了多人游戏下的网络复制。为了适应不同游戏场景和特殊业务需求,ReplicationGraph 提供了一套可扩展可定制化的设计模板,通过抽象同步过程,把关联的 Actor 和 Connection 通过图的方式组织在一起,然后依据图的联通特性快速完成同步内容的收集、筛查、过滤,同时允许业务层基于视野网格、距离、敏感区块、重要性、时效性等方面定制化筛选策略。通过搭配组合各项策略可以更好的优化服务器对于同步的性能开销,降低开发者的开发门槛,最终实现高效且灵活的同步机制。下面就来介绍一下 Unreal5.2 版本下的 ReplicationGraph 同步框架的整体结构:
# 基本概念
在了解 ReplicationGraph 结构之前,先来认识一些 ReplicationGraph 中的基本概念:
- 每个客户端和服务器建立连接后,会创建对应的 Connection,一个 Connection 可能会有多个 ChildConnection(例如某些游戏支持在一台客户端上进行双人或多人对战的场景下)。因此一个 Connection 会关联至少一个 Actor。
- 不论是 ChildConnection 还是 Connection,都会通过 Viewer 用来代表观察者(通常是 PlayerController),同时还有一个 ViewTarget(通常是 Pawn)。
- PlayerController 用来接受玩家输入的服务器抽象。
- Pawn 表示客户端对象在服务器上的实体(角色)。
- 所有参与同步的对象都是 AActor 或者 AActor 派生类,且每个 AActor 同步到 Connection 时,都会建立对应的 ActorChannel。
- 权威性。这个名字比较抽象,可以简单理解为对当前对象(角色)的掌控权力:
- ROLE_SimulatedProxy:有话语权,但没有操作权。能对自己的角色指手画脚,听不听话还得看别人脸色。
- ROLE_AutonomousProxy:屁都不能放一个,别人说啥就是啥。
- ROLE_Authority:操作权。啥事都得听我的,我就是这个世界的卡密。
- 通常情况下服务器会拥有所有对象的「操作权」,客户端会拥有自己控制角色的「话语权」。
# 数据组成
最先介绍的是数据部分。由于数据贯穿整个同步流程中,经常被开发者所念叨的就是:“什么时候,哪些对象,同步哪些数据”。我想这也是整个复制框架一直在考虑的问题。ReplicationGraph 把这些问题,通过各项约束进行建模并形成一套完善的衡量体系,抽离出多个评估指标:
- 同步周期
- 基于类型的同步优先级
- 饥饿因子
- 距离因子
- 剔除距离
- 数据状态(Awake、Dormancy、TearOff...)
- 快捷通道(FastShared)
- 连接带宽
其中的数据又可以分为两个大类:
- 纯配置数据(不可变)
- 运行时数据(可变)
事不宜迟,接下来就从这些数据入手,看看 ReplicationGraph 如何组织这些数据的。弄清楚这些,整个 ReplicationGraph 的实现也就非常明了了:
# Actor 类全局数据「ClassMap」(纯配置数据)
ReplicationGraph 提供了基于 Actor 类型的定制策略,允许基于不同的 Actor 类型定制对应的同步策略。例如某些对象非常庞大,不论身处世界的哪个角落都能看见,那么「剔除距离」就可以设置为 0(无穷大)。又比如某些对象经常变化,且客户端对于这种变化感知需要非常敏感,那么就可以缩减这些对象的「同步周期」。
# Actor 类实例特化数据「ActorMap」(配置 && 运行时数据)
ActorMap 主要记录现有 Actor 实例的一些设置,部分来源于 ClassMap 的配置数据,还有一些是运行中产生的。比如有一些 Npc 创建出来后,附近有玩家时,会被激活(Dormancy -> Awake),那么在未激活情况下就不需要频繁同步。亦或者一些角色可以根据自己的装备特性让别的玩家看不见,那么就可以设置 TearOff 从而达到暂时隐身的效果。
# Connection 类实例中各个 Actor 实例数据「ActorInfoMap」(运行时数据)
由于 Actor 会根据需要同步给任意数量的 Connection。因此针对每个 Connection 单独记录一个同步设置也是必不可少的。比如一个 Actor 我可以看见但其他人就是看不到,那么就可以单独在我的 Connection 内设置可见性。而且不同 Connection 的同步周期也不是一致的,因此单独记录是十分有必要哒。
# 小总结
这里做个简单总结:
- 全局控制器:ReplicationGraph 通过 CDO 的默认设置,为全局的同步提供默认解决方案。
- 类的全局控制器:ReplicationGraph 通过对所有 Actor 类型提供定制的功能(ClassMap),比如某类物体同步快一些,又或者某类同步范围大一些。来控制该类对象的所有同步行为。
- 对象的全局控制器:ReplicationGraph 针对单个 Actor 实例通过控制其同步状态等(ActorMap),影响其在所有 Connection 的同步策略。
- 连接的控制器:ReplicationGraph 针对任意 Conntion 定制策略。影响某个或某些连接的同步规则(主要是限流)。
- 点对点的控制器:ReplicationGraph 针对任意 Conntion 中 Actor 的定制策略(ActorInfoMap)。影响点对点的同步模式。
# 三剑客
说完核心的数据部分,接下来就轮到负责承载和处理这些数据的「三剑客」,也是构筑起整个同步框架的核心类:
- UReplicationGraph
- UNetReplicationGraphConnection
- UReplicationGraphNode
# UReplicationGraph
话不多说,先上类图:
UReplicationGraph 继承自 UReplicationDriver。UReplicationDriver 中定义了贯穿整个同步系统的接口,对任何数据的操作都可以在 UReplicationDriver 中找到合适的接口,集百家所长,这也是 UReplicationGraph 功能丰富的原因之一。由于 UReplicationGraph 和 NetDriver 直接关联,而一个 NetDriver 只能绑定一个 World,UReplicationGraph 的复制体系本质上是建立在 World 内的。既然如此,之前提到的一些全局性质的数据存放于此也再合适不过 ——ClassMap && ActorMap。
除此以外,UReplicationGraph 还承担了 UNetReplicationGraphConnection 的管理工作,负责透传各项传操作给 UNetReplicationGraphConnection。
# 深入了解 ClassMap && ActorMap
在 UReplicationGraph::InitGlobalActorClassSettings
初始化的时候 ClassMap 也随之一起初始化,这两个类的数据都是通过若干个 FClassReplicationInfo 组成:
void UReplicationGraph::InitGlobalActorClassSettings() | |
{ | |
// AInfo and APlayerControllers have no world location, so distance scaling should always be 0 | |
FClassReplicationInfo NonSpatialClassInfo; | |
NonSpatialClassInfo.DistancePriorityScale = 0.f; | |
GlobalActorReplicationInfoMap.SetClassInfo( AInfo::StaticClass(), NonSpatialClassInfo ); | |
GlobalActorReplicationInfoMap.SetClassInfo( APlayerController::StaticClass(), NonSpatialClassInfo ); | |
// ... | |
} |
ActorMap 的初始化伴随则整个游戏周期,在 Actor 创建、销毁、状态切换等时机都会进行更新。同时也是用来评估 Actor 同步的重要数据源。此外 ActorMap 中还保存了有关 Actor 之间的依赖关系(部分 Actor 之前存在从属关系,父类同步的情况下,下属也需要连带同步)
从类图中可以看到各项指标的定义都在 FClassReplicationInfo 中。Actor 的动态数据则存在于 FGlobalActorReplicationInfo 中。
# UNetReplicationGraphConnection
UNetReplicationGraphConnection 在设计上对标了 UNetConnection。如果说 UReplicationGraph 是管全局的,那么 UNetReplicationGraphConnection 则主要负责管理单个连接,先来看看类图:
UNetReplicationGraphConnection 也继承了一个接口类 ——UReplicationConnectionDriver。目的也是类似的,主要是用来规范接口标准,保证操作能够透传遍整条链路。
# 深入了解 ActorInfoMap
作为 UNetConnection 在同步层的抽象,而 ActorInfoMap 又是记录 Connection 相关数据的,因此由 UNetReplicationGraphConnection 内部来维护 ActorInfoMap 再合适不过。
ActorInfoMap 内部主要由三个部分组成:
- GlobalMap:这里引用了的 UReplicationGraph 的 ActorMap && ClassMap,方便读取。
- ActorMap && ChannelMap:这两个结构也就是上面提到的 ActorInfoMap 。两个结构存放的内容非常相似,都是 Connection 中同步过的 Actor 的信息,一个是依据 Actor 为 key,一个是依据 Actor 的 Channel 为 key。两者区别主要是 Actor 的生命周期更长一些,如果 Actor 进入 Dormancy 状态时,Channel 会关闭,此时 Channel 的映射可能会移除,而 Actor 映射则是在 Actor 真正销毁的时候才会移除。使用两个 map 存储还有一点是方便不同的 key 进行查询。
# 优化策略初见端倪
可以看见 UNetReplicationGraphConnection 已经有一些 UReplicationGraphNode 的成员定义及相关数据了(绿色部分):
- 存放历史 Dormancy 状态 Actors 的 PrevDormantActorListPerNode。
- 存放 GridNode 中可以被 Viewers 看见的 Cell——NodesVisibleCells。
- 处理 TearOff 相关逻辑的 TearOffNode。
对于这些优化策略,暂且按下不表,将会在后面的 UReplicationGraphNode 处展开讲解。
# 销毁对象的处理
UNetReplicationGraphConnection 中花费了较多的逻辑来处理 ActorDestruction 的相关内容。
ActorDestruction 实际上是用来记录 Actor 销毁前的残留信息,其中包括 Actor 销毁时的坐标、所在流式关卡名称等信息。存储下来主要是为了能够确保销毁可以被正确同步,因为 Actor 销毁后对象就不复存在了,如果在该时刻客户端没有收到销毁信息,之后将没有途径获取销毁的具体信息。
销毁信息主要分为两类 DestructInfo && DormantDestructInfo:
- DormantDestructInfo:表示由于 Actor 进入休眠状态,直接视作销毁。这种方式销毁下 Connection 会同步给客户端一个休眠销毁信息,并且尝试关闭对应 Actor 的 Channel。
- DestructInfo:非休眠状态下的销毁信息。这部分信息会根据 Viewers 和被销毁对象的位置进行同步。触发同步时通过对比上次触发时所处位置和当前位置是否超过某个阈值,如果超过的情况下会基于 DistanceCulling 判断被销毁物体距离是否可见。
# UReplicationGraphNode
UReplicationGraphNode 主要充当要被同步 Actor 的容器,同时配合上层策略完成收集工作。
- UReplicationGraphNode_ActorListFrequencyBuckets、UReplicationGraphNode_ActorList 及其派生类作为容器负责存储 Actor,并提供一些筛选策略。
- 其他 UReplicationGraphNode 则仅仅只提供策略,不存储 Actor。
得益于 UReplicationGraphNode 本身的结构和接口的定义。可以演化出不同的同步策略,并且可以互相搭配实现复杂的同步机制。目前官方提供了 5 种基础策略:
- UReplicationGraphNode_GridSpatialization2D:网格划分的同步策略。
- UReplicationGraphNode_AlwaysRelevant && UReplicationGraphNode_AlwaysRelevant_ForConnection:始终可见的全局同步策略。
- UReplicationGraphNode_TearOff_ForConnection:针对 Connection 的定向擦除策略。
- UReplicationGraphNode_DormancyNode && UReplicationGraphNode_ConnectionDormancyNode:休眠唤醒策略。
- UReplicationGraphNode_DynamicSpatialFrequency:动态空间频率策略。
# UReplicationGraphNode_ActorList
Actor 的容器结构,里面主要是分为两部分:
- StreamingLevelCollection:通过流式关卡名称来分别记录 Actor
- ReplicationActorList:记录非流式关卡内的 Actor
# UReplicationGraphNode_GridSpatialization2D
UReplicationGraphNode_GridSpatialization2D 是一种比较常见的网格划分策略,需要搭配 UReplicationGraphNode_GridCell 一起使用。通过定义单个网格大小,将世界区域切分为多个 Cell,按照 Cell 为单位进行对象的同步。
具体流程:
- 每次触发同步操作的时候,需要执行 PrepareForReplication() 对所有 Actor 进行 Cell 信息的更新。
- 然后把对应的 Actor 放置在所属的 UReplicationGraphNode_GridCell 内,一个 Actor 可能存在于多个 Cell 内。
- 当某个 Connection 需要获取同步 Actor 列表的时候,会先计算出该 Connection 中的 Viewer 对于哪些 Cell 可见,然后查询这些 Cell 来收集同步对象。
- 最后基于各项指标评估是否可以被同步,然后同步给客户端。
# UReplicationGraphNode_AlwaysRelevant
UReplicationGraphNode_AlwaysRelevant 相对比较简单,其内部提供了设置永远可见的类型。
具体流程:
- 每次触发同步操作的时候,需要执行 PrepareForReplication() 对所有永远可见的类型的 Actor 挑选出来,加入到 ChildNode。
- 在 GatherActorListsForConnection 操作的时候无脑全量提取即可。
这个设计导致很多地方为了实现 AlwaysRelevant 都单独写了 Update 机制来替换 PrepareForReplication。
# UReplicationGraphNode_AlwaysRelevant_ForConnection
也是永久可见策略,但是基于 Connection。
内部会记录对于该 Connection 永久可见的 Actor,同时 Connection 中有关该 Actor 的距离剔除信息(ActorInfoMap)会被设置为 0,就可以不被距离策略给过滤掉。
# UReplicationGraphNode_TearOff_ForConnection
UReplicationGraphNode_TearOff_ForConnection 像是 UReplicationGraphNode_AlwaysRelevant_ForConnection 的对立。内部记录了某个 Actor 将要在该 Connection 内被擦除的帧数。
# UReplicationGraphNode_DormancyNode
用来管理 UReplicationGraphNode_ConnectionDormancyNode,自己不干什么活。主要是存储休眠了的 Actor(Actor 本身休眠而不是针对某个连接休眠)。
大部分接口都是通过 UReplicationGraphNode_GridSpatialization2D 或者更上层的 UReplicationGraph 进行调用,然后负责传达给下层的 UReplicationGraphNode_ConnectionDormancyNode。
# UReplicationGraphNode_ConnectionDormancyNode
用来管理 Connection 所关联的休眠 Actor,实现上高度依赖 UReplicationGraphNode_GridCell,大部分休眠节点来源于流式关卡的显隐状态切换导致。少部分通过上层业务主动调用。Actor 基于 Connection 的休眠流程如下:
- 基于某种原因触发 Actor 的状态切换(唤醒 -> 休眠)
- 体现在各个 Connection 中表现为:
- 休眠节点加入 Connection 的 PendingDormantDestructList。
- 重新设置为唤醒状态 ——NotifyActorDormancyFlush(强制唤醒 Actor)
- 同步 DormantDestructInfo 信息,让 Actor 走休眠销毁流程(这里对象必须处于唤醒状态,不然无法正常同步销毁信息)
- 收到(客户端)销毁信息,设置 Actor 为休眠状态。关闭 Connection 中对应 Actor 的 Channel。
- 收到(服务器)Channel 关闭,设置 Actor 为休眠状态。Actor 正式进入休眠。
整体实现还是不太好理解的,有兴趣可以去看看具体实现:
UNetReplicationGraphConnection::ReplicateDormantDestructionInfos
部分。UReplicationGraphNode_GridSpatialization2D::GatherActorListsForConnection
的 PrevDormantActorList 处理部分。UControlChannel::ReceiveDestructionInfo
的CloseReason == EChannelCloseReason::Dormancy
部分。UActorChannel::Close(EChannelCloseReason Reason
的Reason == EChannelCloseReason::Dormancy
部分。
# UReplicationGraphNode_DynamicSpatialFrequency
DynamicSpatialFrequency 思想主要来源于人类视觉系统对于不同方向和空间频率的敏感度存在「方向选择性」和「空间频率选择性」。基于这种特性就可以简单对视野范围内不同方位做不同频率的更新,从而在不影响体验的情况下优化性能。
其中的实现还是比较复杂的,需要根据法线方向划分周围区域到多个 Zone。然后根据 Actor 处于 Viewer 的某个 Zone,来决定其下次的更新时机。算是在更新周期上下了一番功夫,可能对于单次同步开销高昂的对象有不错的效果。
# 小总结
本节主要介绍了 5 种优化策略。有关 ReplicationGraph 如何组织数据以及如何用各种策略优化同步的方式,到此也有了一个基本认识。
ReplicationGraph 提供了基于 UReplicationGraphNode_GridSpatialization2D 的划分结构,在每个 UReplicationGraphNode_GridCell 内又提供了两种默认的优化策略 ——UReplicationGraphNode_DormancyNode && UReplicationGraphNode_ActorListFrequencyBuckets。
UReplicationGraphNode_DormancyNode 用来处理休眠对象,保证对象休眠后尽可能的减少同步开销。UReplicationGraphNode_ActorListFrequencyBuckets 对非休眠对象的同步检查进行分帧处理,保证性能的稳定。
除此以外,还提供了 UReplicationGraphNode_AlwaysRelevant_ForConnection && UReplicationGraphNode_TearOff_ForConnection 来处理 Connection 层面的可见和擦除。
这些基本策略都是默认包含的,只要采用了 UBasicReplicationGraph 就可以立马生效,此外还可以尝试一下 UReplicationGraphNode_DynamicSpatialFrequency 的优化,或许也会有意想不到的效果。
下面是 UBasicReplicationGraph 内的大致同步策略图:
# 如何知道哪些对象需要同步?
这个问题要说简单也简单,要说复杂也复杂。大部分情况下 GraphNode 中收集到的 Actor 都是需要同步的。这里需要依赖 Actor 的管理策略了,也就是上述 5 种策略如何搭配组合。这将决定每次执行 GatherActorListsForConnection
获取到需要同步的 ActorList 的结果。
但最终哪些会被同步还是取决于 ReplicateActorListsForConnections_Default
&& ReplicateActorListsForConnections_FastShared
。
- ReplicateActorListsForConnections_Default 会根据对象的各项权重计算出最终的因子。由于带宽资源有限,基于权重因子的排序的结果会觉定哪些对象更「重要」,从而更容易获得同步资格。
- ReplicateActorListsForConnections_FastShared 和 Default 是两个分开的赛道,这里面的对象走的是绿通,不需要考虑优先级,因此唯一的筛选标准只有「裁剪距离」。但是同样有自己的带宽限制,因此不可能全同步,公平期间采用的是分帧处理。
for (int32 ListIdx = 0; ListIdx < GatheredLists.Num(); ++ListIdx) | |
{ | |
const FActorRepListConstView& List = GatheredLists[(ListIdx + FrameNum) % GatheredLists.Num()]; | |
for (int32 i = 0; i < List.Num(); ++i) | |
{ | |
//... 这里用帧率对 GatheredLists 求余来做起始点的随机 | |
} | |
} |
下面简单梳理了一下同步的流程图:
# 其他特性
# Default && FastShared
这个上面也简单介绍了, GatherActorListsForConnection
里面有两种类型 Actor 的收集,一种是走正常同步,另一种则是快捷通道的同步。如果需要体验这种 FastShared,需要提前在 FGlobalActorReplicationInfo 中注册同步函数 FastSharedReplicationFunc
,该函数是针对 Actor 对象级别的处理。例如同步玩家的位置信息:
CharacterClassRepInfo.FastSharedReplicationFunc = [](AActor* Actor) | |
{ | |
bool bSuccess = false; | |
if (ALyraCharacter* Character = Cast<ALyraCharacter>(Actor)) | |
{ | |
bSuccess = Character->UpdateSharedReplication(); | |
} | |
return bSuccess; | |
}; | |
bool ALyraCharacter::UpdateSharedReplication() | |
{ | |
if (GetLocalRole() == ROLE_Authority) | |
{ | |
FSharedRepMovement SharedMovement; | |
if (SharedMovement.FillForCharacter(this)) | |
{ | |
// Only call FastSharedReplication if data has changed since the last frame. | |
// Skipping this call will cause replication to reuse the same bunch that we previously | |
// produced, but not send it to clients that already received. (But a new client who has not received | |
// it, will get it this frame) | |
if (!SharedMovement.Equals(LastSharedReplication, this)) | |
{ | |
LastSharedReplication = SharedMovement; | |
ReplicatedMovementMode = SharedMovement.RepMovementMode; | |
FastSharedReplication(SharedMovement); | |
} | |
return true; | |
} | |
} | |
// We cannot fastrep right now. Don't send anything. | |
return false; | |
} |
# ReplayConnection
回放连接,该连接特点在于 GatherActorListsForConnection
收集 Actor 的时候,可以共享所有 Connection Viewers 的视野进行 Actor 收集,有点类似全图视野的感觉。
# SwapRoles
SwapRoles 功能是可以永久切换 Actor 的权威性,可以让原本受服务器控制的 Npc 交给客户端管理,服务器充当被同步方。
这里为了保证客户端获取到权威性后,服务器能够正常把修改同步给其他客户端。一般会创建一个哨兵对象:
TOptional<FScopedActorRoleSwap> SwapGuard |
在客户端执行完操作并同步给服务器完成数据的复制以后,服务器会切换回权威状态,并处理同步操作给到其他客户端,由于这种调换是基于 Connection 的,因此其他客户端连接中不会发生权威性的互换操作,可以正常通过网络复制被同步。
# 定制化案例 Lyra
说了这么多,那具体如何上手呢?这里通过 Lyra 案例进行简单的介绍。LyraGame 中主要是定制了 ULyraReplicationGraph、ULyraReplicationGraphNode_AlwaysRelevant_ForConnection 并实现了另类的分帧策略 ULyraReplicationGraphNode_PlayerStateFrequencyLimiter。
# ULyraReplicationGraph
先来看看 ULyraReplicationGraph,这里比较关键的是 InitGlobalActorClassSettings 实现:
void ULyraReplicationGraph::InitGlobalActorClassSettings() | |
{ | |
// 注册了 Class Init 的函数,里面夹带了 RegisterClassRepNodeMapping 操作 | |
GlobalActorReplicationInfoMap.SetInitClassInfoFunc(/*....*/); | |
// 注册了对象初始化函数,主要是为了获取 Route 策略 | |
ClassRepNodePolicies.InitNewElement = /*....*/; | |
// 一些 Lyra 配置的路由策略类 | |
for (const FRepGraphActorClassSettings& ActorClassSettings : LyraRepGraphSettings->ClassSettings) | |
{ | |
if (ActorClassSettings.bAddClassRepInfoToMap) | |
{ | |
if (UClass* StaticActorClass = ActorClassSettings.GetStaticActorClass()) | |
{ | |
AddClassRepInfo(StaticActorClass, ActorClassSettings.ClassNodeMapping); | |
} | |
} | |
} | |
// 这里遍历了所有 UClass 类,把能够同步的记录下来,并设置对应的 Route 策略。 | |
// 该策略是 Lyra 定制的,用于指定哪些类型可以被放入到哪些 GraphNode 中 | |
TArray<UClass*> AllReplicatedClasses; | |
for (TObjectIterator<UClass> It; It; ++It) | |
{ | |
//.... | |
AllReplicatedClasses.Add(Class); | |
RegisterClassRepNodeMapping(Class); | |
} | |
// 设置了一下 ACharacter 各项权重因子 | |
auto SetClassInfo = [&](UClass* Class, const FClassReplicationInfo& Info) { GlobalActorReplicationInfoMap.SetClassInfo(Class, Info); ExplicitlySetClasses.Add(Class); }; | |
ExplicitlySetClasses.Reset(); | |
FClassReplicationInfo CharacterClassRepInfo; | |
CharacterClassRepInfo.DistancePriorityScale = 1.f; | |
CharacterClassRepInfo.StarvationPriorityScale = 1.f; | |
CharacterClassRepInfo.ActorChannelFrameTimeout = 4; | |
CharacterClassRepInfo.SetCullDistanceSquared(ALyraCharacter::StaticClass()->GetDefaultObject<ALyraCharacter>()->NetCullDistanceSquared); | |
SetClassInfo(ACharacter::StaticClass(), CharacterClassRepInfo); | |
// 设置了一下 ALyraCharacter 的 FastShared 同步函数处理,以及 FastShared 自身通道的相关配置 | |
CharacterClassRepInfo.FastSharedReplicationFunc = /*....*/; | |
CharacterClassRepInfo.FastSharedReplicationFuncName = FName(TEXT("FastSharedReplication")); | |
FastSharedPathConstants.MaxBitsPerFrame = (int32)((float)(Lyra::RepGraph::TargetKBytesSecFastSharedPath * 1024 * 8) / NetDriver->NetServerMaxTickRate); | |
FastSharedPathConstants.DistanceRequirementPct = Lyra::RepGraph::FastSharedPathCullDistPct; | |
SetClassInfo(ALyraCharacter::StaticClass(), CharacterClassRepInfo); | |
// 设置了一下分帧策略,因为 UReplicationGraphNode_GridCell 会用到 | |
UReplicationGraphNode_ActorListFrequencyBuckets::DefaultSettings.ListSize = 12; | |
UReplicationGraphNode_ActorListFrequencyBuckets::DefaultSettings.NumBuckets = Lyra::RepGraph::DynamicActorFrequencyBuckets; | |
UReplicationGraphNode_ActorListFrequencyBuckets::DefaultSettings.BucketThresholds.Reset(); | |
UReplicationGraphNode_ActorListFrequencyBuckets::DefaultSettings.EnableFastPath = (Lyra::RepGraph::EnableFastSharedPath > 0); | |
UReplicationGraphNode_ActorListFrequencyBuckets::DefaultSettings.FastPathFrameModulo = 1; | |
// 初始化 RPC 相关准入策略 | |
RPCSendPolicyMap.Reset(); | |
for (UClass* ReplicatedClass : AllReplicatedClasses) | |
{ | |
RegisterClassReplicationInfo(ReplicatedClass); | |
} | |
// 初始化 DestructInfo 最远距离 | |
DestructInfoMaxDistanceSquared = Lyra::RepGraph::DestructionInfoMaxDist * Lyra::RepGraph::DestructionInfoMaxDist; | |
// 初始化 RPC multicasts 广播策略 | |
RPC_Multicast_OpenChannelForClass.Reset(); | |
RPC_Multicast_OpenChannelForClass.Set(AActor::StaticClass(), true); // Open channels for multicast RPCs by default | |
RPC_Multicast_OpenChannelForClass.Set(AController::StaticClass(), false); // multicasts should never open channels on Controllers since opening a channel on a non-owner breaks the Controller's replication. | |
RPC_Multicast_OpenChannelForClass.Set(AServerStatReplicator::StaticClass(), false); | |
for (const FRepGraphActorClassSettings& ActorClassSettings : LyraRepGraphSettings->ClassSettings) | |
{ | |
if (ActorClassSettings.bAddToRPC_Multicast_OpenChannelForClassMap) | |
{ | |
if (UClass* StaticActorClass = ActorClassSettings.GetStaticActorClass()) | |
{ | |
RPC_Multicast_OpenChannelForClass.Set(StaticActorClass, ActorClassSettings.bRPC_Multicast_OpenChannelForClass); | |
} | |
} | |
} | |
} |
整体看下来主要做了以下几件事:
- 初始化所有 Actor 类型的路由策略。这会决定哪些类型的 Actor 会受到哪些优化策略影响。
- 给 Character 做了一定的优化。设置了些许权重,增加了 LyraCharacter 位置属性的 FastShared 同步。
- 设置了销毁信息内对于距离的限制,以及 RPC 调用时对于类型的准入和接收条件。这会决定哪些对象可以执行 RPC 调用,哪些对象可以收到 RPC 广播。
再来看看另一个比较重要的接口 InitGlobalGraphNodes:
void ULyraReplicationGraph::InitGlobalGraphNodes() | |
{ | |
// ----------------------------------------------- | |
// Spatial Actors | |
// ----------------------------------------------- | |
GridNode = CreateNewNode<UReplicationGraphNode_GridSpatialization2D>(); | |
GridNode->CellSize = Lyra::RepGraph::CellSize; | |
GridNode->SpatialBias = FVector2D(Lyra::RepGraph::SpatialBiasX, Lyra::RepGraph::SpatialBiasY); | |
if (Lyra::RepGraph::DisableSpatialRebuilds) | |
{ | |
GridNode->AddToClassRebuildDenyList(AActor::StaticClass()); // Disable All spatial rebuilding | |
} | |
AddGlobalGraphNode(GridNode); | |
// ----------------------------------------------- | |
// Always Relevant (to everyone) Actors | |
// ----------------------------------------------- | |
AlwaysRelevantNode = CreateNewNode<UReplicationGraphNode_ActorList>(); | |
AddGlobalGraphNode(AlwaysRelevantNode); | |
// ----------------------------------------------- | |
// Player State specialization. This will return a rolling subset of the player states to replicate | |
// ----------------------------------------------- | |
ULyraReplicationGraphNode_PlayerStateFrequencyLimiter* PlayerStateNode = CreateNewNode<ULyraReplicationGraphNode_PlayerStateFrequencyLimiter>(); | |
AddGlobalGraphNode(PlayerStateNode); | |
} |
这里定制了 LyraGame 的专属同步方案:
- 基于 UReplicationGraphNode_GridSpatialization2D 的全局优化,对地图做网格切分,优化同步。
- 基于 UReplicationGraphNode_ActorList 的全局可见方案。
- 基于 ULyraReplicationGraphNode_PlayerStateFrequencyLimiter 的全局定时角色状态同步。
剩下最后一个比较重要的接口 ——InitConnectionGraphNodes:
void ULyraReplicationGraph::InitConnectionGraphNodes(UNetReplicationGraphConnection* RepGraphConnection) | |
{ | |
Super::InitConnectionGraphNodes(RepGraphConnection); | |
ULyraReplicationGraphNode_AlwaysRelevant_ForConnection* AlwaysRelevantConnectionNode = CreateNewNode<ULyraReplicationGraphNode_AlwaysRelevant_ForConnection>(); | |
// This node needs to know when client levels go in and out of visibility | |
RepGraphConnection->OnClientVisibleLevelNameAdd.AddUObject(AlwaysRelevantConnectionNode, &ULyraReplicationGraphNode_AlwaysRelevant_ForConnection::OnClientLevelVisibilityAdd); | |
RepGraphConnection->OnClientVisibleLevelNameRemove.AddUObject(AlwaysRelevantConnectionNode, &ULyraReplicationGraphNode_AlwaysRelevant_ForConnection::OnClientLevelVisibilityRemove); | |
AddConnectionGraphNode(AlwaysRelevantConnectionNode, RepGraphConnection); | |
} |
这个也比较简单,上面两个主要是全局的策略,这里主要是定制了 Connection 级别的策略。为每个 Connection 设置了改良版的 ULyraReplicationGraphNode_AlwaysRelevant_ForConnection 节点。
还有一些常规化的定制逻辑,例如:
- 路由接口:RouteRemoveXXX、RouteAddXXX。
- 事件注册:OnXXXAdd、OnXXXRemove。
- 定义了若干全局数据。
# ULyraReplicationGraphNode_AlwaysRelevant_ForConnection
主要功能是在每次收集同步对象时,会把当前 Connection 关联的所有 Viewers 以及和该 Viewers 有依赖关系的对象加入同步列表。
同时基于关卡的可见性,从 ULyraReplicationGraph 获取可见流式关卡中的永远可见对象列表加入同步列表。
void ULyraReplicationGraphNode_AlwaysRelevant_ForConnection::GatherActorListsForConnection(const FConnectionGatherActorListParameters& Params) | |
{ | |
ULyraReplicationGraph* LyraGraph = CastChecked<ULyraReplicationGraph>(GetOuter()); | |
ReplicationActorList.Reset(); | |
// 这里主要是收集 Connection 中关联的 Viewers 和 ViewTargets. | |
// 因为一个 Connection 可能会有多个 ChildConnection,因此也可能出现多个 Viewers 和 ViewTargets. | |
for (const FNetViewer& CurViewer : Params.Viewers) | |
{ | |
ReplicationActorList.ConditionalAdd(CurViewer.InViewer); | |
ReplicationActorList.ConditionalAdd(CurViewer.ViewTarget); | |
if (ALyraPlayerController* PC = Cast<ALyraPlayerController>(CurViewer.InViewer)) | |
{ | |
// 50% throttling of PlayerStates. | |
const bool bReplicatePS = (Params.ConnectionManager.ConnectionOrderNum % 2) == (Params.ReplicationFrameNum % 2); | |
if (bReplicatePS) | |
{ | |
if (APlayerState* PS = PC->PlayerState) | |
{ | |
if (!bInitializedPlayerState) | |
{ | |
bInitializedPlayerState = true; | |
FConnectionReplicationActorInfo& ConnectionActorInfo = Params.ConnectionManager.ActorInfoMap.FindOrAdd(PS); | |
ConnectionActorInfo.ReplicationPeriodFrame = 1; | |
} | |
ReplicationActorList.ConditionalAdd(PS); | |
} | |
} | |
FCachedAlwaysRelevantActorInfo& LastData = PastRelevantActorMap.FindOrAdd(CurViewer.Connection); | |
if (ALyraCharacter* Pawn = Cast<ALyraCharacter>(PC->GetPawn())) | |
{ | |
UpdateCachedRelevantActor(Params, Pawn, LastData.LastViewer); | |
if (Pawn != CurViewer.ViewTarget) | |
{ | |
ReplicationActorList.ConditionalAdd(Pawn); | |
} | |
} | |
if (ALyraCharacter* ViewTargetPawn = Cast<ALyraCharacter>(CurViewer.ViewTarget)) | |
{ | |
UpdateCachedRelevantActor(Params, ViewTargetPawn, LastData.LastViewTarget); | |
} | |
} | |
} | |
// 这里就是打包流式关卡中永远可见的对象。 | |
// 主要是基于 Connection 对于流式关卡的可见性以及流式关卡本身是否处于 Dormany 状态来判断。 | |
CleanupCachedRelevantActors(PastRelevantActorMap); | |
Params.OutGatheredReplicationLists.AddReplicationActorList(ReplicationActorList); | |
FPerConnectionActorInfoMap& ConnectionActorInfoMap = Params.ConnectionManager.ActorInfoMap; | |
TMap<FName, FActorRepListRefView>& AlwaysRelevantStreamingLevelActors = LyraGraph->AlwaysRelevantStreamingLevelActors; | |
for (int32 Idx=AlwaysRelevantStreamingLevelsNeedingReplication.Num()-1; Idx >= 0; --Idx) | |
{ | |
const FName& StreamingLevel = AlwaysRelevantStreamingLevelsNeedingReplication[Idx]; | |
FActorRepListRefView* Ptr = AlwaysRelevantStreamingLevelActors.Find(StreamingLevel); | |
if (Ptr == nullptr) | |
{ | |
AlwaysRelevantStreamingLevelsNeedingReplication.RemoveAtSwap(Idx, 1, false); | |
continue; | |
} | |
FActorRepListRefView& RepList = *Ptr; | |
if (RepList.Num() > 0) | |
{ | |
bool bAllDormant = true; | |
for (FActorRepListType Actor : RepList) | |
{ | |
FConnectionReplicationActorInfo& ConnectionActorInfo = ConnectionActorInfoMap.FindOrAdd(Actor); | |
if (ConnectionActorInfo.bDormantOnConnection == false) | |
{ | |
bAllDormant = false; | |
break; | |
} | |
} | |
if (bAllDormant) | |
{ | |
AlwaysRelevantStreamingLevelsNeedingReplication.RemoveAtSwap(Idx, 1, false); | |
} | |
else | |
{ | |
Params.OutGatheredReplicationLists.AddReplicationActorList(RepList); | |
} | |
} | |
} | |
} |
# ULyraReplicationGraphNode_PlayerStateFrequencyLimiter
功能大致是为了做 PlayerState 的分帧同步,不过实现上比较糙,这里简单看一下就好:
// 每次同步之前重建一下同步列表,同步列表时以每两个 PlayerState 为单位组成的 | |
void ULyraReplicationGraphNode_PlayerStateFrequencyLimiter::PrepareForReplication() | |
{ | |
ReplicationActorLists.Reset(); | |
ForceNetUpdateReplicationActorList.Reset(); | |
ReplicationActorLists.AddDefaulted(); | |
FActorRepListRefView* CurrentList = &ReplicationActorLists[0]; | |
for (TActorIterator<APlayerState> It(GetWorld()); It; ++It) | |
{ | |
APlayerState* PS = *It; | |
if (IsActorValidForReplicationGather(PS) == false) | |
{ | |
continue; | |
} | |
if (CurrentList->Num() >= TargetActorsPerFrame) | |
{ | |
ReplicationActorLists.AddDefaulted(); | |
CurrentList = &ReplicationActorLists.Last(); | |
} | |
CurrentList->Add(PS); | |
} | |
} | |
// 收集同步对象的时候,从列表里面通过取余的方式抽取其中一个列表内的两个 PlayerState 进行同步 | |
void ULyraReplicationGraphNode_PlayerStateFrequencyLimiter::GatherActorListsForConnection(const FConnectionGatherActorListParameters& Params) | |
{ | |
const int32 ListIdx = Params.ReplicationFrameNum % ReplicationActorLists.Num(); | |
Params.OutGatheredReplicationLists.AddReplicationActorList(ReplicationActorLists[ListIdx]); | |
if (ForceNetUpdateReplicationActorList.Num() > 0) | |
{ | |
Params.OutGatheredReplicationLists.AddReplicationActorList(ForceNetUpdateReplicationActorList); | |
} | |
} |
# 性能 && 设计问题
整体看下来,感觉 ReplicationGraph 有些地方实现还是蛮糟糕的:
# 各种策略存在强依赖关系
ReplicationGraph 虽然看起来足够灵活,但实际上并非如此,每个策略都必须应用在某几个固定场景,虽然提供了灵活的搭配能力,但实际上可搭配点不多。
- UReplicationGraphNode_GridSpatialization2D 通常需要作为 GlobalNode 被使用,如果基于连接的划分,性能开销是不可估量的。因此很难针对连接来定制化同步网格的大小。
- UReplicationGraphNode_GridSpatialization2D 中的 UReplicationGraphNode_GridCell 完全依赖 UReplicationGraphNode_DormancyNode 的实现。这点上不能单纯移除 UReplicationGraphNode_DormancyNode 来解决,难以拆解意味着 DormancyNode 的应用场景非常局限。
- UReplicationGraphNode_GridCell 本身就可有很多个,每个里面都需要维护一个 DormancyNode 和 N 个 ConnectionDormancyNode,数量可能过于庞大。
- UReplicationGraphNode_TearOff_ForConnection 也是内嵌在实现里的,设计上强绑定在 GraphConnection 中,基于全局的擦除还需要自己实现,而且功能上和 DormancyNode 有点冲突。
- ReplicationGraph 内部定义了很多不相干的数据,如: PrevDormantActorListPerNode、NodesVisibleCells。这些实际上是用于 GridSpatialization2D 和 DormancyNode 的,等于是变相的把这两个节点和框架做了捆绑。
# Dormancy 实现很糟糕
直觉上应该是 DormancyNode 管理多个 ConnectionDormancyNode。每个 ConnectionDormancyNode 记录自身的休眠节点。对于 GridSpatialization2D 策略下,需要记录每个 Cell 的休眠节点,看起来才比较合理。现在的设计上是:每个 Cell 包含一个 DormancyNode,每个 DormancyNode 管理多个 ConnectionDormancyNode。
实际上阅读代码过程中其他 GraphNode 的注释中也多次,不得不因为 DormancyNode 而书写一些丑陋的代码,并强调 Dormancy 应该重写了。
这仅仅是个人观点,肯定也有很多考虑不周的地方(疯狂叠甲)
# 各种 Array 的查询很糟糕
能够明显的感觉到,整个 ReplicationGraph 框架内使用了大量的 Array 结构。最明显的就是 FConnectionGatherActorListParameters 以及 UReplicationGraphNode_ActorList
带来的性能开销是可以预见的:几乎所有的增删改查操作都需要做遍历,但是 Array 的遍历查询太慢了。
# 面对收集结果的去重问题很糟糕
目前看下来去重逻辑可能只在最终需要同步的时候会校验上次的同步帧。内部实际上 GraphNode 没有对 Actor 的独占性做任何保证,换句话说,任何 Actor 都可能同时存在于多个 GraphNode,这取决于代码写法的合理性。稍不留意就可能导致一个对象被收集多次从而带来额外的性能开销(例如在某个 ConnectionNode 中注册了多个 GraphNode,并且同时会调用 AddNetworkActor)。而且这些问题的暴露和查找可能会比较耗时。
# 总结
最后做个总结,就目前的 ReplicationGraph 框架来看,其提供的功能对于大部分项目来说,已经够用了。有基于网格的拆分、休眠、分帧等优化。虽然提供了非常多的优化方案,但框架底层确实存在很多不完善的点有待解决,想要用好还是比较考验项目自身设计的。但 ReplicationGraph 也提供了很多关于同步的思路,例如通过图的方式来规划同步策略,引入 Dormancy、TearOff、DynamicSpatialFrequency 等优化策略,最后附上一张 ReplicationGraph 的全局类图,本篇讲解到此也就结束了:
# 参考文档
- https://nashnie.github.io/none/2019/08/05/UE-replication-graph.html