Yuerer's Blog

钰儿的Blog

UE5 的垃圾回收(GC)采用 标记-清除(Mark & Sweep) 策略,通过遍历对象引用关系确定可达对象并清理其余内存。本文聚焦 UE5.6增量垃圾回收,尤其是 增量标记(Incremental Marking) 的最新变化,以及 工程上实现该算法的优化。阅读前如已熟悉通用 GC 原理(例如 Lua 的三色标记),会更易理解 UE 的实现细节与优化策略。

从 STW 到 增量扫描

在 UE5.4 之前,GC 的可达性分析通常以 一次性完成(Stop-the-World) 的方式进行:扫描阶段暂停游戏逻辑,实现简单,但缺点是 停顿时间可能较长,带来 Gameplay 卡顿。
从 UE5.4 起,引擎引入 增量扫描:将可达性分析拆分到多帧执行,平滑每帧的 GC 开销。这引出了一个核心问题:

扫描间隙产生的新对象/新引用如何处理?

对象分配

对象通过 NewObject 分配时,会进入 UObjectBase 构造并注册到全局对象表 GUObjectArray

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
UObjectBase::UObjectBase(UClass* InClass,
EObjectFlags InFlags,
EInternalObjectFlags InInternalFlags,
UObject *InOuter,
FName InName,
int32 InInternalIndex,
int32 InSerialNumber,
FRemoteObjectId InRemoteId)
: ObjectFlags(InFlags)
, InternalIndex(INDEX_NONE)
, ClassPrivate(InClass)
, OuterPrivate(InOuter)
{
AddObject(InName, InInternalFlags, InInternalIndex, InSerialNumber, InRemoteId);
}

AddObject 会把对象注册到 GUObjectArray 并设置内部标志位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void UObjectBase::AddObject(FName InName, EInternalObjectFlags InSetInternalFlags, int32 InInternalIndex, int32 InSerialNumber, FRemoteObjectId InRemoteId)
{
NamePrivate = InName;
EInternalObjectFlags InternalFlagsToSet = InSetInternalFlags;
if (!IsInGameThread())
{
InternalFlagsToSet |= EInternalObjectFlags::Async;
}
if (ObjectFlags & RF_MarkAsRootSet)
{
InternalFlagsToSet |= EInternalObjectFlags::RootSet;
ObjectFlags &= ~RF_MarkAsRootSet;
}
if (ObjectFlags & RF_MarkAsNative)
{
InternalFlagsToSet |= EInternalObjectFlags::Native;
ObjectFlags &= ~RF_MarkAsNative;
}
GUObjectArray.AllocateUObjectIndex(this, InternalFlagsToSet, InInternalIndex, InSerialNumber, InRemoteId);
HashObject(this);
}

AllocateUObjectIndex 中,可见关键点:非 “DisregardForGC” 窗口下,新对象会被标上 “Reachable” 位,即默认可达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void FUObjectArray::AllocateUObjectIndex(UObjectBase* Object, EInternalObjectFlags InitialFlags, int32 AlreadyAllocatedIndex, int32 SerialNumber, FRemoteObjectId RemoteId)
{
LockInternalArray();
FUObjectItem* ObjectItem = IndexToObject(Index);
ObjectItem->Flags = (int32)EInternalObjectFlags::PendingConstruction;
if (!(IsOpenForDisregardForGC() & GUObjectArray.DisregardForGCEnabled()))
{
ObjectItem->Flags |= (int32)UE::GC::Private::FGCFlags::GetReachableFlagValue_ForGC();
}
ObjectItem->SetObject(Object);
ObjectItem->RefCount = 0;
ObjectItem->ClusterRootIndex = 0;
ObjectItem->SerialNumber = SerialNumber;
Object->InternalIndex = Index;

if (InitialFlags != EInternalObjectFlags::None)
{
ObjectItem->ThisThreadAtomicallySetFlag(InitialFlags);
}
UnlockInternalArray();
}
阅读全文 »

本文剖析 UnLua 是如何将 Lua 接入到 UE5中。尽可能少贴代码,将部分 Lua C API 的操作转为 Lua 伪代码,同时每个小节只关注主线内容,方便阅读和理解。

对象绑定

本小节只关注当 UE5 创建一个对象时,是如何将其和 Lua 脚本给绑定起来的。

1. 创建虚拟机

我们需要创建一个 Lua 虚拟机来执行游戏逻辑,但有时又希望每个 GameInstance 各自拥有自己的虚拟机,这样会更方便调试和管理,这就意味着需要确定每个 Object 会被分配到哪个虚拟机(以后为和代码保持一致,会简称为 Env),抽象出 ULuaEnvLocator 用于定位 Object 所属 Env,并创建 EnvEnv 的一些简单操作会封装到 FLuaEnv 中。
大部分情况下,只需认为整个客户端只会开启一个 Lua 虚拟机就可以了,基于这个前提,甚至可以去掉这个类。

1
2
3
4
5
6
7
class UNLUA_API ULuaEnvLocator : public UObject
{
GENERATED_BODY()
public:
virtual UnLua::FLuaEnv* Locate(const UObject* Object);
TSharedPtr<UnLua::FLuaEnv, ESPMode::ThreadSafe> Env;
};

定位当前 Object 属于哪个 Env

1
2
EnvLocator = NewObject<ULuaEnvLocator>(GetTransientPackage(), EnvLocatorClass);
const auto Env = EnvLocator->Locate(Class);

从以上代码能看出 FLuaEnv 就是虚拟机本身的封装类。

2. 绑定 UE 反射对象到 Lua

通过继承以下两个类,来进行监听当前创建、销毁哪些 UObject ,从而实现绑定,内部实现为了解耦,会在多处监听。

1
2
3
4
5
6
class FUnLuaModule : public IUnLuaModule,  
public FUObjectArray::FUObjectCreateListener,
public FUObjectArray::FUObjectDeleteListener

GUObjectArray.AddUObjectCreateListener(this);
GUObjectArray.AddUObjectDeleteListener(this);

知道了哪些对象创建出来后,还需要知道该对象跟哪份 Lua 文件进行绑定,Unlua 有好几种方案,第一种是最好理解的,要求实现 IUnLuaInterface 接口:

1
2
3
4
5
6
7
class UNLUA_API IUnLuaInterface
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintNativeEvent)
FString GetModuleName() const;
};
阅读全文 »

本文以 UE5.4 为基准,讲解智能指针的实现机制。阅读本文前需要对 C++11 的智能指针有基本了解。

简介

  • 虚幻智能指针库 为C++11智能指针的自定义实现,旨在减轻内存分配和追踪的负担。该实现包括行业标准 共享指针弱指针 和 唯一指针。其还可添加 共享引用,此类引用的行为与不可为空的共享指针相同。

根据官方文档,与 C++ 标准库相比,虚幻引擎新增了共享引用的概念。智能指针的实现位于 Core 模块下。

线程安全

UE 的智能指针分为两种模式,表示是否为线程安全。这里所说的线程安全仅指其内部的引用计数器是否线程安全。

1
2
3
4
5
enum class ESPMode : uint8
{
NotThreadSafe = 0,
ThreadSafe = 1
};

默认为 ThreadSafe 和 C++标准库保持一致。

1
2
3
4
template< class ObjectType, ESPMode Mode = ESPMode::ThreadSafe > class TSharedRef;
template< class ObjectType, ESPMode Mode = ESPMode::ThreadSafe > class TSharedPtr;
template< class ObjectType, ESPMode Mode = ESPMode::ThreadSafe > class TWeakPtr;
template< class ObjectType, ESPMode Mode = ESPMode::ThreadSafe > class TSharedFromThis;

引用计数

引用控制器(以下简称控制块)中记录了共享引用次数和弱引用次数。

若为线程安全,内部计数器采用原子操作。

1
2
3
4
5
6
7
8
template <ESPMode Mode>
class TReferenceControllerBase
{
using RefCountType = std::conditional_t<Mode == ESPMode::ThreadSafe, std::atomic<int32>, int32>;
public:
RefCountType SharedReferenceCount{1};
RefCountType WeakReferenceCount{1};
};
阅读全文 »

本文以 UE5.4 为基准,剖析蓝图编译的全流程。在阅读本文之前,需要比较熟悉蓝图,比较熟悉反射,同时最好先阅读 蓝图编译器概述

简介

UE5 的蓝图编译总共分为16个阶段,可简单分类为收集、过滤、验证、兼容、构建骨架、构建语句、生成字节码,重新链接这几个阶段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
STAGE I: GATHER
STAGE II: FILTER
STAGE III: SORT
STAGE IV: SET TEMPORARY BLUEPRINT FLAGS
STAGE V: VALIDATE
STAGE VI: PURGE (LOAD ONLY)
STAGE VII: DISCARD SKELETON CDO
STAGE VIII: RECOMPILE SKELETON
STAGE IX: RECONSTRUCT NODES, REPLACE DEPRECATED NODES (LOAD ONLY)
STAGE X: CREATE REINSTANCER (DISCARD 'OLD' CLASS)
STAGE XI: CREATE UPDATED CLASS HIERARCHY
STAGE XII: COMPILE CLASS LAYOUT
STAGE XIII: COMPILE CLASS FUNCTIONS
STAGE XIV: REINSTANCE
STAGE XV: POST CDO COMPILED
STAGE XVI: CLEAR TEMPORARY FLAGS

下面将从蓝图编辑器按下编译按钮时进行剖析,在每个阶段遇到新的概念时会进行讲解。

当按下蓝图的编译按钮时,将会执行到 FBlueprintEditor::Compile()

1
2
3
4
5
6
void FBlueprintEditor::Compile()
{
UBlueprint* BlueprintObj = GetBlueprintObj();
FKismetEditorUtilities::CompileBlueprint(BlueprintObj, CompileOptions, &LogResults);
...
}

UBlueprint 就是用户正在编辑的蓝图资源。

最终会进入 FBlueprintCompilationManagerImpl::FlushCompilationQueueImpl 函数,此时正式进入蓝图编译的16个阶段。

UE5_blueprint_compile

STAGE I: GATHER

阶段1:收集所有需要重新编译的依赖蓝图。

若当前编译的蓝图是宏蓝图,则需要完全编译(生成骨架、字节码)所有依赖该宏蓝图的蓝图,因为宏的引脚类型可能发生变化。

而依赖该普通蓝图的蓝图仅需重新生成字节码,而不重新生成骨架,虽然引脚类型也可能发生改变,但此时还没有足够的信息证实是否发生了改变,先假定没有发生改变,避免不必要的开销。

阅读全文 »

本文以 UE5.4 为基准,剖析反射代码的生成内容和注册流程。

简介

UE5 的 C++ 需要各种宏来辅助开发,在编译时使用 UHT 工具扫描这些宏来生成 XXX.generated.hXXX.gen.cpp 两个文件,接下来将分析UHT生成的文件,来学习反射具体做了什么。代码将会删减掉热更相关的内容,只关注核心逻辑。

反射代码

Enum

枚举的声明在C++中有三种方式,C风格、namespace、enum class。

1
2
3
4
5
6
enum class ECppForm
{
Regular,
Namespaced,
EnumClass
};

为了减小本文篇幅,此处只看 enum class。在一个新文件中定义完该 enum class 后,进行构建。

1
2
3
4
5
6
UENUM(BlueprintType)
enum class EMyEnumClass:uint8
{
Enum1 UMETA(DisplayName = "DisplayInBlueprint"),
Enum2,
};

最终会生成 generated.hgen.cpp 两个文件。

generated.h 的内容如下,仅仅只是一些模板全特化,便于开发。

1
2
3
4
5
6
7
#define FOREACH_ENUM_EMYENUMCLASS(op) \
op(EMyEnumClass::Enum1) \
op(EMyEnumClass::Enum2)

enum class EMyEnumClass : uint8;
template<> struct TIsUEnumClass<EMyEnumClass> { enum { Value = true }; };
template<> REFLECTION_API UEnum* StaticEnum<EMyEnumClass>();
阅读全文 »

本文主要剖析 UE5 网络中是如何进行属性同步和RPC的。

同步 Actor

要进行属性同步,首先就要先同步 Actor,但更要知道哪些 Actor 需要网络同步。

哪些 Actor 需要网络同步

Actor 需要设置 bReplicates 为 true,才会进行同步,

以 Spawn Pawn 为例,玩家登录之后会由 GameMode 创建 Pawn 实例。

1
2
3
4
5
6
7
8
AActor* UWorld::SpawnActor( UClass* Class, FVector const* Location, FRotator const* Rotation, const FActorSpawnParameters& SpawnParameters )
{
...
AActor* const Actor = NewObject<AActor>(LevelToSpawnIn, Class, NewActorName, ActorFlags, Template, false/*bCopyTransientsFromClassDefaults*/, nullptr/*InInstanceGraph*/, ExternalPackage);
// Add this newly spawned actor to the network actor list. Do this after PostSpawnInitialize so that actor has "finished" spawning.
AddNetworkActor( Actor );
return Actor;
}

bReplicates 为 true,则 RemoteRole 为 ROLE_SimulatedProxy,表示是远端为模拟代理。

1
2
3
4
5
void AActor::PostInitProperties()
{
Super::PostInitProperties();
RemoteRole = (bReplicates ? ROLE_SimulatedProxy : ROLE_None);
}

将需要同步的 Actor 加入到 NetDriver中的一个集合里, 至此就找到了要网络同步的 Actor,需要注意一点是 Replicate 是支持动态开关的。

阅读全文 »

本文剖析 UE5 客户端与DS建立连接后的登录流程,以及 Bunch 的发送接收,这样之后看属性同步时,能轻松一些。

登录流程

书接上回,握手之后会执行 UPendingNetGame::SendInitialJoin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void UPendingNetGame::BeginHandshake()
{
// Kick off the connection handshake
UNetConnection* ServerConn = NetDriver->ServerConnection;
if (ServerConn->Handler.IsValid())
{
ServerConn->Handler->BeginHandshaking(
FPacketHandlerHandshakeComplete::CreateUObject(this, &UPendingNetGame::SendInitialJoin));
}
else
{
SendInitialJoin();
}
}

在继续分析之前,先来看官方的注释,描述了登录的流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Most of the work for handling these control messages are done either in UWorld::NotifyControlMessage,
and UPendingNetGame::NotifyControlMessage. Briefly, the flow looks like this:

Client's UPendingNetGame::SendInitialJoin sends NMT_Hello.

Server's UWorld::NotifyControlMessage receives NMT_Hello, sends NMT_Challenge.

Client's UPendingNetGame::NotifyControlMessage receives NMT_Challenge, and sends back data in NMT_Login.

Server's UWorld::NotifyControlMessage receives NMT_Login, verifies challenge data, and then calls AGameModeBase::PreLogin.
If PreLogin doesn't report any errors, Server calls UWorld::WelcomePlayer, which call AGameModeBase::GameWelcomePlayer,
and send NMT_Welcome with map information.

Client's UPendingNetGame::NotifyControlMessage receives NMT_Welcome, reads the map info (so it can start loading later),
and sends an NMT_NetSpeed message with the configured Net Speed of the client.

Server's UWorld::NotifyControlMessage receives NMT_NetSpeed, and adjusts the connections Net Speed appropriately.
阅读全文 »

本文主要剖析 UE5 中客户端是如何与DS建立连接,构建基础 Channel,以及无状态握手流程。

连接建立

服务端监听流程

UGameInstance::EnableListenServer 调用 UWorld::Listen 去创建 UNetDriver 对象。

UNetDriver 默认有两个子类, IpNetDriver 和 DemoNetDriver 后面一个用于回放。

根据要创建的 NetDriver 名, 查找并构造该 Driver, 并存入 World->Context.ActiveNetDrivers,这里面很多地方会用到,比如需要设置某个 Actor 冻结时,就需要通知各个 NetDriver。

1
2
3
4
5
6
7
8
UNetDriver* CreateNetDriver_Local(UEngine* Engine, FWorldContext& Context, FName NetDriverDefinition, FName InNetDriverName)
{
Definition = Engine->NetDriverDefinitions.FindByPredicate(FindNetDriverDefPred);
UClass* NetDriverClass = StaticLoadClass(UNetDriver::StaticClass(), nullptr, *Definition->DriverClassName.ToString(), nullptr, LOAD_Quiet);
ReturnVal = NewObject<UNetDriver>(GetTransientPackage(), NetDriverClass);
// 数组 重载了 operator new
new(Context.ActiveNetDrivers) FNamedNetDriver(ReturnVal, Definition);
}
阅读全文 »

本文主要剖析 UE5 中的可靠UDP的设计思路。

简介

UE5的网络收发使用UDP进行通信,而UDP又是不可靠的协议,只管发出,不管对端是否收到,也不保序,因此需要有一套机制来使得UE5的数据包有保序、可靠这两大特点。

Packet

UE5在UDP之上,包装了一层 Packet,其内部传输的数据是一个个 Bunch,可靠不可靠指的是 Bunch的属性,Bunch 是什么这个暂时可以先不用关心,但 Packet 是需要搞明白的,因为 UE5 是使用 Packet 来完成保序的工作。

序列号

既然要实现保序、可靠,就需要知道对端收没收到包,那么自然是要通知对端我发的消息ID,以及我收到的消息ID,UE5也不例外,Packet 头部包含了 Seq 和 AckedSeq(取得最新收到的Packet的序列号),为了避免序列号回环无法直接比较序列号大小的情况,以及序列号占用比特位过大的问题,Seq 使用TSequenceNumber 实现,容量为14bit,可表示 [0, 16383],两个 Seq 的差值若小于最大值的一半,则认为比较是正确的,没有发生回环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <SIZE_T NumBits, typename SequenceType>
class TSequenceNumber
{
static_assert(TIsSigned<SequenceType>::Value == false, "The base type for sequence numbers must be unsigned");

public:
using SequenceT = SequenceType;
using DifferenceT = int32;

// Constants
enum { SeqNumberBits = NumBits };
enum { SeqNumberCount = SequenceT(1) << NumBits };
enum { SeqNumberHalf = SequenceT(1) << (NumBits - 1) };
enum { SeqNumberMax = SeqNumberCount - 1u };
enum { SeqNumberMask = SeqNumberMax };
};
阅读全文 »

近期在改 Lua 5.4 的垃圾回收,虽然之前也写过分代垃圾回收的原理,但这次改完之后对其更有感悟,就简单记录一下Lua 5.4 的分代垃圾回收的实现原理。

简介

分代垃圾回收认为对象分为年轻代和老年代,其中年轻代对象很快就会被释放(比如临时对象),而老年代对象存在的时间比较长,不容易被释放,因此也就不需要经常去扫描老年代,只需要经常去扫描年轻代,等到年轻代垃圾回收的时候实在收不回对象,再进行一次全量垃圾回收。

原理

Lua 的 age 总共占用 3 Bit,刚创建出来的对象为 G_NEW ,当它活过一轮垃圾回收后,提升为 G_SURVIVAL ,若再活过一轮垃圾回收,则彻底进入 G_OLD 老年代,不在年轻代中扫描它。

1
2
3
4
5
6
7
8
#define G_NEW		0	/* created in current cycle */
#define G_SURVIVAL 1 /* created in previous cycle */
#define G_OLD0 2 /* marked old by frw. barrier in this cycle */
#define G_OLD1 3 /* first full cycle as old */
#define G_OLD 4 /* really old object (not to be visited) */
#define G_TOUCHED1 5 /* old object touched this cycle */
#define G_TOUCHED2 6 /* old object touched in previous cycle */
#define AGEBITS 7 /* all age bits (111) */

这里面的 G_OLD0 是用于 Barrier forward,假设你创建了一个新对象,它本该是 G_NEW 但因为它被老年代对象引用,所以必须要强行将它改为老年代,否则会发生跨代引用,该新对象直接被清理掉。

同理 G_TOUCHED1 则是用于 Barrier back,假设你创建了一个新对象,然后放置在一个老年代的 table中,此时为了不频繁触发该 table 的 barrier,则将其修改为 G_TOUCHED1 ,同时将其放置在 grayagain 链表中,这是因为老年代table是不会在年轻代的垃圾回收中被扫描到,但此时老年代又确实引用了年轻代对象,所以要将它放在一条特殊链表中,使其能在年轻代中被扫描到。

阅读全文 »
0%