MENU

Replication Graph 从入门到入土

October 23, 2022 • 程序阅读设置

前言

Replication Graph 作为官方插件在 UE 4.21 版本发布。在学习使用过程中,也尝试上网查找过许多相关资料,没有发现特别详细的介绍。其实对于 Unreal 开发来说,很多事情的确不需要人家专门来长篇大论给一篇文章看——毕竟代码都给你了,有什么问题自己看看不就得了么?所以,上网找不到资料,只能说是吃了知识的亏,不够熟悉或者看不懂代码,那确实是自己的问题。

本文尝试简单介绍 UE 的 Actor 复制相关的内容,结合一些实际使用上的问题,希望能对完全没接触过的读者提供一些小帮助。笔者目前接触 Unreal 还只有三四个月时间,了解不多,才疏学浅,若是有谬误之处还请包涵与指正。

笔者项目目前使用 UE 5.0.3 版本。

背景

众所周知,在 UE 里是以 Actor 为单位进行复制的。在 NetDriver 的每个 TickFlush 里,会进行 ServerReplicateActors 操作,将需要同步的数据发送给客户端。下面我们来简单看一下这个过程。

默认同步方案

创建 Actor

当 Actor 在 World 中 Spawn 的时候(UWorld::SpawnActor),我们能够看到,在返回之前,有进行 AddNetworkActor 的操作。而 World 干的事情很简单:

void UWorld::AddNetworkActor(AActor* Actor)
{
    // ...
    ForEachNetDriver(GEngine, this, [Actor](UNetDriver* const Driver)
    {
        if (Driver != nullptr)
        {
            Driver->AddNetworkActor(Actor);
        }
    });
}

在 NetDriver 层套娃而已。而 NetDriver 除了将其添加到自己的 NetworkObjectList 之外,还会继续套娃,看是否存在 ReplicationDriver,如果有则往上面也套一层调用。

每帧复制

NetDriver 在 TickFlush 时会调用 ServerReplicateActors,它做的事情比较多:

  • Build Consider List
  • 对于每个客户端连接
    • Prioritize Actors
    • Process Prioritized Actors

Build Consider List 指,对于已经放入了 Network Object List 的 Actor,先判断一下这一帧它是否到了复制间隔等,如果本次需要复制,则放到一个临时列表中。对于每个客户端连接,会去查看该列表的内容,选择需要复制到对应客户端连接的 Actor 并且排序。然后在 Process Prioritized Actors 步骤中真正通过网络发送出去。

比较值得一提的是,Prioritize Actors 步骤,通过 Actor 基类的 IsNetRelevantFor 方法来判断对应的客户端连接与该 Actor 是否“相关”,是否需要复制。这个函数的默认行为是:

  • AlwaysRelevant 对所有人相关
  • NetUseOwnerRelevancy 使用父级对象的相关性
  • OnlyRelevantToOwner 仅对拥有者相关
  • UseDistanceBaseRelevancy? 使用基于距离的相关性?
    • Distance to viewer less than NetCullDistance(Squared)

按顺序来判断的。上面的这些内容都是 Actor 基类的属性,相信大家都经常在编辑器中看到,也并不陌生。同时,Prioritize 排序,默认也是基于距离远近的,越近当然优先级越高。

需要指出,IsNetRelevantFor 函数是可以 override 的,因此可以改变它的行为。比如常见的组队同步需求,某些 Actor 只在各个玩家队伍内部共享。此时则可以改写 IsNetRelevantFor 的行为,通过判断 Player Controller 的队伍和 Actor 所属队伍是否相同,来决定返回真还是假。

存在的问题

默认的同步方式(NetDriver 版)有明显的历史痕迹,大部分逻辑都非常地“硬”和耦合,比较祖传的感觉。不过比起圈复杂度很高的代码,一个很显然的问题是:时间复杂度比较高。设 $ M $ 个连接,$ N $ 个需要同步的 Actor,不做任何优化情况下,每个 Tick Flush 的时间复杂度在这里是 $ M \times N $ 级别了,还别说其中某些操作的常数还不小。

官方在 Actor 基类给出的几个字段,提供了不太管用的解决方案。你可以设置:

  • NetCullDistanceSquared 距离裁剪,减少数据量
  • NetUpdateFrequency 降低更新频率,减少遍历量
  • NetPriority 指定优先级,缓解带宽压力,为重要数据让路

其中降低 update frequency 是对遍历量降低有一定效果的。然而对于基于距离的同步,或是自己重写了相关性判断的 Actor,默认方案实在是没有什么好办法,还是需要浪费不少 CPU 的。

Replication Driver

让我们把目光放回到 NetDriver 上来。它其实提供了一个口子可以让我们注入自己想要的逻辑,那就是 Replication Driver。比如 ServerReplicateActors 的代码注释是这么说的:

ServerReplicateActors: this is main function to replicate actors to client connections. It can be "outsourced" to a Replication Driver.

即 Replication Driver 可以实现一些逻辑,NetDriver 发现有 Replication Driver 的话,自己就不干了,直接外包给 Replication Driver。上文中提到的这些关键事件,都可以由 Replication Driver 来接管处理。这么一来就方便多了,如果你愿意,完全可以用自定义的逻辑来接管,不用落入引擎的窠臼当中。

默认情况下是不存在 Replication Driver 的,即 Net Driver 自己大包大揽。如果需要使用 Replication Driver,需要实现一堆虚函数:

  • SetRepDriverWorld
  • InitForNetDriver
  • InitializeActorsInWorld
  • ResetGameWorldState
  • AddClientConnection
  • RemoveClientConnection
  • AddNetworkActor
  • RemoveNetworkActor
  • ForceNetUpdate
  • FlushNetDormancy
  • NotifyActorTearOff
  • NotifyActorFullyDormantForConnection
  • NotifyActorDormancyChange
  • NotifyDestructionInfoCreated
  • SetRoleSwapOnReplicate
  • ServerReplicateActors

接着通过修改 DefaultEngine.ini 或是绑定委托的形式,将自己的 Replication Driver 类创建出来。

[/Script/OnlineSubsystemUtils.IpNetDriver]
ReplicationDriverClassName="/Script/XGame.XGameReplicationGraph"
UReplicationDriver::CreateReplicationDriverDelegate().BindLambda(/* returns object */);

基本概念

上面提到的 Replication Driver 可以接管一部分 Net Driver 的工作。而本文主题,Replication Graph 其就是派生自 Replication Driver。不过作为官方插件,已经搭建出一些框架,不需要从一清二白做起,给我们提供了一定程度上的便利。通常来说 UReplicationGraph 这个 Replication Driver 并不能拿来直接用,我们需要根据项目的实际需求,继承它,并且加入自己的逻辑。

Rep Graph 一种可能的形态

上图是我随意绘制的,用来向完全没有接触过的人展示 Rep Graph 的形态,有个感性的认识。我们会发现,其实这是一颗树,根就是 Rep Graph 本身,叶子节点都是一个个的 UReplicationGraphNode。当然,树的确也是图,Graph 这个名字,没毛病。每一个节点,需要实现下面几件事情:

  • NotifyAddNetworkActor
  • NotifyRemoveNetworkActor
  • GatherActorListsForConnection

即,当有 Actor 创建或销毁时,通知到节点;上层询问节点,对于某客户端连接,是否有需要同步的 Actor。当必须实现的接口只剩下 3 个时,网络复制这件事情,脉络似乎变得相当之清晰。伴随而来的,还有一个观念的转变:默认是遍历全部 Actor,判断它是否需要复制到某个客户端;现在是告诉节点,客户端是什么,请节点把需要复制的 Actor 列表提供出来。从遍历求解,变成了直接出答案,再也不需要全量遍历 Actor 了!

默认节点简介

Replication Graph 插件自带了 10 种节点:

  • UReplicationGraphNode_ActorList
  • UReplicationGraphNode_ActorListFrequencyBuckets
  • UReplicationGraphNode_DynamicSpatialFrequency
  • UReplicationGraphNode_ConnectionDormancyNode
  • UReplicationGraphNode_DormancyNode
  • UReplicationGraphNode_GridCell
  • UReplicationGraphNode_GridSpatialization2D
  • UReplicationGraphNode_AlwaysRelevant
  • UReplicationGraphNode_AlwaysRelevant_ForConnection
  • UReplicationGraphNode_TearOff_ForConnection

它们并不全都是“一级节点”,而是可以有一定的组合关系。比如本章要介绍的场景化节点,其(默认)关系就是:

  • UReplicationGraphNode_GridSpatialization2D
    • UReplicationGraphNode_GridCell
    • UReplicationGraphNode_ActorListFrequencyBuckets
    • UReplicationGraphNode_DormancyNode

这 10 种节点,并不是全部都必须使用的。本文也不打算全部介绍。仅介绍常见的几种。

Actor List 节点

普通版

即一个节点上可以存放一些 Actor 指针。会区分不属于 streaming 的,以及属于 streaming level 的(单独分 Level 存放)。

Frequency Buckets

含有最简单的优化方式:即分桶分次同步。构造时要给定桶的数量,对于非 streaming level 的 Actor,会放在不同桶中。每次复制只取一个桶,比较均匀地降低复制频率。下文将提到的 2D 网格场景化节点,它的 Dynamic 节点默认就是使用的 Frequency Bucket 方案。

2D 网格场景化节点

还记得前文提到的基于距离裁剪吗?对于优化复制压力、流量、客户端性能来说,裁剪是必不可少的。而默认方案中,全量遍历并裁剪,存在遍历量过大的问题。

位置计算和复制

GridSpatialization2D示意

2D 网格节点在 XY 平面上将世界划分成多个正方形格子。以上图为例,某个 Actor,其裁剪半径为 R,则上图的虚线圆是它的影响范围。作这个圆的外接正方形,外接正方形所覆盖到的范围的格子,都会被“添加该 Actor”。从图中来看,圆形范围只占 5 个格子,但是其覆盖范围实际是 9 个格子。

GridSpatialization2D的复制

请看上图。当进行复制时,是以客户端连接的 ViewTarget 为位置参考的(分屏游戏模式下则会有多个 ViewTarget)。假设图中的红点为客户端 A 的 ViewTarget。图中黑点为客户端 B 控制的 Pawn,虚线圆表示该 Pawn class 的裁剪范围。

图 1,显然红点和黑点不在同一个格子中;但上文所述,黑点范围是包含了红点所在格子的,因此通过 ViewTarget 坐标得到红点格子,遍历其持有的 Actor 列表,是含有黑点 Pawn 的。此时会再次判断两点之间距离,发现距离并不符合要求,因此还是会被裁剪掉,即此时 A 不能同步到 B。

图 2,A 往右上方移动,进入虚线正方形范围后,B Pawn 依然不会被复制下来。虚线正方形表示的是 B Pawn 将出现在哪些格子里。图 3,A 进入了虚线圆形,此时会复制了,虚线圆形才是 B 真正的有效范围。

动态和静态

这里只会简单带过一下。网格节点理解 3 种类型:

  • Dynamic,动态,该 Actor 会任意移动
  • Static,静态,该 Actor 创建后,坐标位置不会再改变
  • Dormant,冬眠态,该 Actor 暂时不会动,但可以转变为动态

不过多展开 Dormancy 的概念,这也是 Actor 的固有属性。我们的 GridCell 节点,实际存放 Actor 是在下面的 Dynamic 和 Dormancy 两个节点,上层根据自己的路由规则来选取。这样做的理由也很显然:对于确实不会动的,如果每次还要重新计算它的范围和所属格子,属于是浪费资源。

附加效果

有了网格节点,我们还可以附加地实现范围查找功能。这个功能很实用,写起来也很麻烦。但是现在有了网格节点后,稍微写一点点代码就完事了!

项目集成

前面说过,UReplicationGraph 这玩意是没法直接用的。它只能挨个通知下面的节点 Add/Remove 了 Actor,和挨个询问对于某个客户端连接,需要同步哪些 Actor。它本身是没有节点的,每个项目必须自己继承一个 Replication Graph,至少得往里面加节点,才能用。

节点规划

个人感觉至少得有这几样:

  • Always Relevant,所有人都能拿到的
  • Always Relevant For Connection,对应连接能拿到的
  • Grid Spatialization 2D,网格优化的

当然上面只是表意,你看我写的也不是类名,也就是说不一定非得用官方给的这几个类哈。比如我觉得官方的 AlwaysRelevant_ForConnection 并不好,就没有使用,而是简单弄了一套符合项目需求的替代版本。

除此之外,队伍内同步感觉绝大多数游戏都少不了吧。加个 Team 节点,写起来可比 IsNetRelevantFor 简单高效多了!

原生逻辑兼容

当然,特殊情况也是存在的。比如,使用了一个外部库,它就是通过覆写 IsNetRelevantFor 来实现复制规则控制,而我们没法/不希望修改它时,则需要 Replication Graph 提供原生的 IsNetRelevantFor 判断能力。那,这还不好办吗,加个节点然后把这种东西路由进去呗!不过此时节点内又退化成了全量遍历,所以这种机制不可滥用啊。

一种可能的使用例

一种可能的使用例

初始化和路由

相信每个人开始接触 Replication Graph,都有去找一些 demo 使用例。是的,我也看过 GitHub 或者一些其他地方的例子。我们会发现这些例子都有一个特点,会在启动时预处理所有的 Actor class,将它们的 Class Replication Info 处理好,预先存放在 Global Actor Replication Info Map(Replication Graph 基类成员)上。这也体现了其与原生方式的区别:按类,而非按对象。譬如你可以修改某个对象的 cull distance,这在原生方式上是可以的;而 Replication Graph 读取的是 Class Replication Info,(通常)是以 CDO 为准。

背景-默认方案-每帧复制一节中有提到 Actor 基类的一些属性。为了尽量使 Replication Driver 的替换对其他人透明,我们应当让绝大多数甚至全部正确使用了这些属性的 Actor 行为,和原生行为一致。这是一个比较复杂,比较容易出错的要求,抄抄补补是不可避免的。除了预处理 Class Replication Info,我们还需要一起生成 Actor class 的 Replication Graph 节点路由表。这样能使用常数复杂度的方式来完成 Add/Remove Network Actor 操作,而不是像 UReplicationGraph 默认遍历所有的节点挨个调用。

疑难案例

使用 Replication Graph 以来,自然遇到过不少大大小小的问题。此处选取几个比较有趣的,与大家分享一下。

Static 物品成片消失

场景中生成了若干允许拾取的掉落物品,鉴于它们生成后不再移动,以 Static 形式路由到 Grid Spatialization 2D 显然比较合适。此时我们操纵人物远离地上的物品,它们应该由远及近一个一个消失。然而却发现,所有物品是在 View Target 运动到某个临界位置时,成片成片地全部消失,这并不符合预期。

原来是负责生成的代码,编写方式有误。相关负责人将物品 Spawn 在默认位置之后,又挪动了位置。而物品以 Static 身份进入 Dormancy 节点,节点是认为它永远在默认位置上的。因此所有的物品在网格中,坐标位置都相同,导致成片失去复制。这告诉我们,标记为 Static,就要真的不动。非得移动,也必须在 Finish Spawning 之前给挪动完成。

无法强制同步队友

为了方便省事偷懒,同一个队伍中的队友 Character,我们在队伍节点给强制加到了待同步列表中。此时即使网格节点没有添加队友,也能保证队友是需要同步的。然而这个简单的方法,实测却事与愿违。我们发现,队友 Actor 的的确确被加入了列表,然而客户端还是在离开队友过远后,丢失了队友。

原来,Class Replication Info 中有个字段 DistancePriorityScale。在 Replication Graph 基类方法 Replicate Actor Lists For Connections 中,待复制 Actor 的类,Distance Priority Scale 大于 0 时,在这里还会做一次距离裁剪。而该字段默认值是 1,队友就这样被裁剪掉了。

世界分区下网格中 Actor 无法同步

世界分区 World Partition 是 UE 5 的新花样,这边就不展开介绍了。现象是,关卡场景中摆放了一个蓝图 Actor,跑起来之后,网格节点GetCellInfoForActor报错:其 Cull Distance 为 0。确实,如果半径为 0,在网格中就是一个点,没法计算它的覆盖范围了。

先陈述两个事实:

  1. 初始化时使用TObjectIterator<UClass>对所有的AActor类进行了遍历、预处理。
  2. 这个有问题的 Actor,bReplicates 和裁剪距离,是在蓝图中设置的;C++ 中并没有开启复制。

那就真相大白了!对于 C++,这个 Actor 是不需要复制的,没有处理。对于蓝图,它是摆放在场景中的,世界分区下,并没有启动时就加载到,因此没有遍历到这个蓝图,即使这个蓝图 CDO 的值没有问题。

解决方法也是两个:一,尽量还是在 C++ 中设置一下复制相关的属性;二,改造一下路由流程,当出现查不到类信息的类时,当场进行 Class Replication Info 的生成和保存。

总结

Replication Graph 其实就是一个官方给的有一些套路的 Replication Driver 实现。它的实现其实并没有多么优雅,但是确实能够解决一些问题(同时也带来一些问题)。其中 Grid Spatialization 2D 这个节点直接拿来用还是非常爽的。

细心的观众朋友可能发现了,全文还没有点过题。那么为什么叫从入门到入土呢?这是因为 Replication Graph 并没有那么地不可替代,它出现之前,相关的思想大家也都了解;加之实际使用上必须要做不少改造,其自身实现也远远谈不上优秀。这样说来,如果有必要的话,抛开 Replication Graph 转而使用一个真正自己的 Replication Driver,又未尝不可呢?

Archives Tip
QR Code for this page
Tipping QR Code