[原创]UE—GamePlay启动流程

趁着国庆假期把 GamePlay 框架又梳理了一番, 遂尝试将梳理的脉络用文字表达出来, 借此加深理解, 倘若能为他人解惑那更是善莫大焉; 当前引擎版本: UnrealEngine4.25.3

游戏引擎的GamePlay框架伪代码基本如下:

int main()
{
    init();
    while(!exit_requested)
    {
        update(); //input,gamelogic,render...
    }
    exit();
}
//CodeSnippet.1

UE的GamePlay框架也是遵循这个伪代码展开; 我们打开UE的引擎源码, 从 GuardedMain() 函数开始 (为了便于理解流程,本文所有代码段都对源码进行一定的删改 )

//Launch.cpp 
int32 GuardedMain(const TCHAR* CmdLine)
{
    struct EngineLoopCleanupGuard 
    { 
       ~EngineLoopCleanupGuard() 
       {
          GEngineLoop.Exit(); 
       }
    } CleanupGuard;
 
    //run early Initialization,load engine modules
    GEngineLoop.PreInit(CmdLine);
 
    //Create and initizlize a UEngine, run late Initialization,start the game
    GEngineLoop.Init();
 
    //kick off rendering,tick the engine,update RHI
    while(!IsEngineExitRequested())
    {
	 GEngineLoop.Tick();
    }
} 
//CodeSnippet.2

CodeSnippet.2 和 CodeSnippet.1 的区别就是退出函数。 GuardedMain() 定义了一个局部变量 CleanupGuard,在函数结束以后系统会调用 ~EngineLoopCleanupGuard(), 从而退出游戏

GEngineLoop.PreInit() 函数的主要内容就是按照顺序加载游戏相关的引擎、项目和第三方插件等模块, 代码如下:

//LaunchEngineLoop.cpp
int32 FEngineLoop::PreInit(const TCHAR* CmdLine)
{
    LoadCoreModules(); //CoreUObject
    LoadPreInitModules(); //Engine,Renderer,AnimGraphRuntime,RenderAPI...
    LoadCustomModules(); //EarliestPossible,PostConfigInit,PostSplashScreen...
    LoadStartupCoreModules(); //Core,Networking,SlateCore,Slate,Overlay...
    ...
    return 0;
}
//CodeSnippet.3

加载完当前模块会进行模块注册等相关内容(CDO也是在此阶段生成), 预初始化执行完就开始初始化,CodeSnippet.2 的 GEngineLoop.Init() 就会被执行,代码如下:

//LaunchEngineLoop.cpp
int32 FEngineLoop::Init()
{
    FString GameEngineClassName;
    GConfig->GetString(TEXT("/Script/Engine.Engine"), TEXT("GameEngine"), GameEngineClassName, GEngineIni);
    UClass* EngineClass = StaticLoadClass( UGameEngine::StaticClass(), nullptr, *GameEngineClassName);
   
    //create UGameEngine
    GEngine = NewObject<UEngine>(GetTransientPackage(), EngineClass);
    check( GEngine );
   
    //create GameInstance; Client: create ViewpointClient and LocalPlayer
    GEngine->Init(this);
    UEngine::OnPostEngineInit.Broadcast();
    FCoreDelegates::OnPostEngineInit.Broadcast();
   
    //initialize any late-loaded modules(加载哪些设置在postEngineinit阶段才加载的模块)
    IProjectManager::Get().LoadModulesForProject(ELoadingPhase::PostEngineInit);
    IPluginManager::Get().LoadModulesForEnabledPlugins(ELoadingPhase::PostEngineInit));

    //start the game,typically this load the default map
    GEngine->Start();
    GIsRunning = true;
    FCoreDelegates::OnFEngineLoopInitComplete.Broadcast();
    return 0;
}
//CodeSnippet.4

本文分析的是非编辑器环境,因此 创建的 UEngine 就是 UGameEngine, 创建完就执行初始化 GEngine->Init(this); 代码如下:

//GameEngine.cpp
void UGameEngine::Init(IEngineLoop* InEngineLoop)
{
    //create GameInstance
    FSoftClassPath GameInstanceClassName = GetDefault<UGameMapsSettings>()->GameInstanceClass;
    UClass* GameInstanceClass = LoadObject<UClass>(NULL, *GameInstanceClassName.ToString()));
    GameInstance = NewObject<UGameInstance>(this, GameInstanceClass);
 
    //create FWorldContext and dummy World,Init Subsystem
    GameInstance->InitializeStandalone();
	
    //Create ans Initialize the viewport client
    if(GIsClient)
    {
        ViewportClient = NewObject<UGameViewportClient>(this, GameViewportClientClass);
        ViewportClient->Init(*GameInstance->GetWorldContext(), GameInstance);
        GameViewport = ViewportClient;
        GameInstance->GetWorldContext()->GameViewport = ViewportClient;
        CreateGameViewport(ViewportClient);

        //Create a ULocalPlayer and associate it with viewpoint client
        FString Error;
        ViewportClient->SetupInitialLocalPlayer(Error);
        UGameViewportClient::OnViewportCreated().Broadcast();
    }
}
//CodeSnippet.5

CodeSnippet.5 主要就是创建 UGameInstance 并初始化。 此时开始出现 Client 和 Server ( 本文特指 Dedicated Server,暂不考虑 Listen Server) 的区别

Server

CodeSnippet.5 执行完毕,Server 端拥有的对象: UGameEngine , UGameInstance , UWorldContext以及创建的一个临时世界 ; 接下来就是执行 CodeSnippet.4 内的 GEngine->Start() , 它最终会调用 UEngine::LoadMap() 代码如下:

//UnrealEngine.cpp
bool UEngine::LoadMap( FWorldContext& WorldContext, FURL URL, class UPendingNetGame* Pending, FString& Error )
{
    //Let any interested parties konw that the current world is about to be unloaded
    FCoreUObjectDelegates::PreLoadMap.Broadcast(URL.Map);
   
    //Clear Current World
    // Destory player-controlled Pawns and PlayerControllers
    //Route the EndPlay event to all actors
    //Clean up the world, destory all actors
   
    //Notify the GameInstance of the map change in case it wants to load assets
    WorldContext.OwningGameInstance->PreloadContentForURL(URL);
   
    //Load our persistent level's map package and get top-level UWorld
    //UWorld,ULevel,Actor 等在编辑器已经生成好,打包的时候被序列化到了.umap文件内, 加载完反序列化就能拿到所有内容
    //也包括WordSettings对象(以及给它设置的各种对象配置)
    UPackage*  WorldPackage = LoadPackage(nullptr, *URL.Map, LOAD_None));;
    UWorld*    NewWorld = UWorld::FindWorldInPackage(WorldPackage);
   
    //Give the world a reference to the GameInstance
    NewWorld->SetGameInstance(WorldContext.OwningGameInstance);
    GWorld = NewWorld;
   
    //just-load UWorld and get it ready for gameplay
    WorldContext.SetCurrentWorld(NewWorld);
    WorldContext.World()->WorldType = WorldContext.WorldType;
    WorldContext.World()->AddToRoot();
    WorldContext.World()->InitWorld(); //物理,寻路导航,AI以及声音系统进行设置
    WorldContext.World()->SetGameMode(URL); //让GameInstance在世界生成GameMode,Server-Only
   
    //Load any per-map packages and make sure "always aloaded" sub-levels are fully loaded
    LoadPackagesFully(WorldContext.World(), FULLYLOAD_Map, WorldContext.World()->PersistentLevel->GetOutermost()->GetName());
    WorldContext.World()->FlushLevelStreaming(EFlushLevelStreamingType::Visibility);

    //Register components, then initialize all actors and their components
    {
       FRegisterComponentContext Context(WorldContext.World());
       WorldContext.World()->InitializeActorsForPlay(URL, true, &Context);
       Context.Process();
    }

    //Spawn play actors(i.e. PlayerControllers) for all active local players
    for(auto It = WorldContext.OwningGameInstance->GetLocalPlayerIterator(); It; ++It)
    {
        FString Error2;
        (*It)->SpawnPlayActor(URL.ToString(1),Error2,WorldContext.World());
    }
   
    //如果是DS或者监听服,那么创建Socket监听客户端请求
    if (Pending == NULL && (!GIsClient || URL.HasOption(TEXT("Listen"))))
    {
       if (!WorldContext.World()->Listen(URL))
       {
          UE_LOG(LogNet, Error, TEXT("LoadMap: failed to Listen(%s)"), *URL.ToString());
       }
    }

    //Route the BeginPlay event to indicate that all actors are present and initialized
    WorldContext.World()->BeginPlay();

    //let listeners konw that the map change is done
    PostLoadMapCaller.Broadcast(WorldContext.World());

    double StopTime = FPlatformTime::Seconds();
    UE_LOG(LogLoad, Log, TEXT("Took %f seconds to LoadMap(%s)"), StopTime - StartTime, *URL.Map);
    FLoadTimeTracker::Get().DumpRawLoadTimes();
    WorldContext.OwningGameInstance->LoadComplete(StopTime - StartTime, *URL.Map);

    // Successfully started local level.
    return true;
} 
//CodeSnippet.6

根据URL加载对应的 URL.ump , 然后将 URL.ump 内的所有对象反序列化出来。因此我们就有了新的UWorld,ULevel 以及场景内包含的 Actors

接下来执行的代码是 SetGameMode(URL) 它会创建一个很重要的对象: GameModeBase。它代表着游戏的规则,比如:游戏时长,玩家数量,输赢判定等

再接下来执行的代码是 InitializeActorsForPlay(URL, true, &Context) 它的作用是初始化当前关卡内所有Actors; 包括注册 Actors 的组件,执行 Actors 的三个初始化方法 PreInitializeComponents,InitializeComponents,PostInitializeComponents 等

GameModeBase 继承自 Actor , 因此它的 PreInitializeComponents 方法也会被调用, 代码如下:

//GameModeBase.cpp
void AGameModeBase::PreInitializeComponents()
{
    Super::PreInitializeComponents();
   
    //Spawn a GameState actor
    UWorld* World = GetWorld();
    FActorSpawnParameters SpawnInfo;
    SpawnInfo.Instigator = GetInstigator();
    SpawnInfo.ObjectFlags |= RF_Transient; // We never want to save game states or network managers into a map                            
    GameState = World->SpawnActor<AGameStateBase>(GameStateClass, SpawnInfo);
   
    //Associate the GameState with the world
    World->SetGameState(GameState);
    GameState->AuthorityGameMode = this;

    //spawn a GameNetWorkManager, Server Only
    AWorldSettings* WorldSettings = World->GetWorldSettings();
    World->NetworkManager = World->SpawnActor<AGameNetworkManager>(WorldSettings->GameNetworkManagerClass, SpawnInfo);
   
    //Initialize the GameState based upon the initial configuration of the GameMode
    InitGameState();
} 
//CodeSnippet.7

CodeSnippet.7 主要是生成 GameState , NetworkManager ; GameState 用来存储当前游戏模式的基础数据

Actor 在调用 InitializeComponents 方法时,如果Actor勾选了bAutoActivate ,则会执行激活Actor 。所以如果你想在Actor的 BeginPlay 之前做一些操作的话,最好的时机就是 PostInitializeComponents 方法内

接下来执行的代码是 Listen(URL), 启动 Server 的网络监听; 再就是调用所有Actors 的 BeginPlay

至此 Server 分析完毕,它目前具备了一个游乐场所需要的一切设备,梦幻的场景( UWorld,ULevel ), 有趣的游戏规则(UGameMode), 游乐场看门老头( Listen(URL) ) ,满心欢喜的在等待着 Client 加入这场游戏盛宴

Client

CodeSnippet.4 内的 GEngine->Init(this) 方法( CodeSnippet.5 ), 在Client端它会生成 GameViewportClient(可以想象成屏幕本身:它本质上就是作为渲染、声音以及输入系统的高级接口,也是用户与引擎之间的交互接口) 和 LocalPlayer(可以想象成坐在屏幕前的用户)

而CodeSnippet.4 内的 GEngine->Start()方法在 Client 它会调用到 UEngine::Browse() , 代码如下:

//UnrealEngine.cpp
EBrowseReturnVal::Type UEngine::Browse( FWorldContext& WorldContext, FURL URL, FString& Error )
{
    if( URL.IsLocalInternal() )
    {
       //Server: Create GameMode,GameSession,GameState,GameNetWorkManager
       return LoadMap( WorldContext, URL, NULL, Error ) ? EBrowseReturnVal::Success : EBrowseReturnVal::Failure;
    }
   
    if( URL.IsInternal() && GIsClient )
    {
       //Client, Create UPendingNetGame,UNetDriver,send "MIT_Hello" to Server
       WorldContext.PendingNetGame = NewObject<UPendingNetGame>();
       WorldContext.PendingNetGame->Initialize(URL);
       WorldContext.PendingNetGame->InitNetDriver();
       return EBrowseReturnVal::Pending;
    }
 }
//CodeSnippet.8

CodeSnippet.8 的主要功能是创建 UpendingNetGame 并初始化执行登录流程,流程大概如下:

UE4 登录流程
登录ds服务器流程

Create Pawn In Server

那么Server 和 Client , 分别是什么时候开始生成玩家的呢?

Server 是在收到 NMT_Join 消息, UWorld::SpawnPlayActor 生成 PlayerController, 代码如下:

APlayerController* UWorld::SpawnPlayActor(UPlayer* NewPlayer, ENetRole RemoteRole, const FURL& InURL, const FUniqueNetIdRepl& UniqueId, FString& Error, uint8 InNetPlayerIndex)
{
    AGameModeBase* const GameMode = GetAuthGameMode()

    //Give the GameMode a chance to accept the login
    APlayerController* const NewPlayerController = GameMode->Login(NewPlayer, RemoteRole, *InURL.Portal, Options, UniqueId, Error);

    //Possess the newly-spawned player
    NewPlayerController->NetPlayerIndex = InNetPlayerIndex;
    NewPlayerController->SetRole(ROLE_Authority);
    NewPlayerController->SetReplicates(RemoteRole != ROLE_None);
    NewPlayerController->SetPlayer(NewPlayer); //NewPlayer type is UNetConnection

    //Spawn Pawn
    GameMode->PostLogin(NewPlayerController);
    return NewPlayerController;
}
//CodeSnippet.9

在 NewPlayerController 的构造方法执行之后 PostActorConstruction方法会被调用,它内部会依次调用 PreInitializeComponents,InitializeComponents,PostInitializeComponents。 与 PlayerController 对应的 PlayerState 则会在 PostInitializeComponents 方法内生成并执行初始化

//PlayerController.cpp
void APlayerController::PostInitializeComponents()
{
    if ( !IsPendingKill() && (GetNetMode() != NM_Client) )
    {
       // create a new player replication info
       FActorSpawnParameters SpawnInfo;
       SpawnInfo.Owner = this;
       SpawnInfo.Instigator = GetInstigator();
       SpawnInfo.ObjectFlags |= RF_Transient; 

       const AGameModeBase* GameMode = GetWorld()->GetAuthGameMode();
       PlayerState = World->SpawnActor<APlayerState>(GameMode->PlayerStateClass, SpawnInfo);
    } 
} 
//CodeSnippet.10

而PlayerState 创建以后,也会执行 PostInitializeComponents 方法,将自己放入 GameStateBase 中; 这样 GameStateBase 就能获取到所有加入到游戏的玩家信息(通过 PlayerArray), 通过玩家信息又能获取到对应的 PlayerController, 通过 PlayerController, 就能获取到 UPlayer (type is UNetConnection)

生成PlayerControllery以后,接下来调用GameMode->PostLogin, 这里就会生成最终我们控制的Pawn

Create Pawn In Client

Client 在收到 NMT_Welcome 消息后开始加载地图,CodeSnippet.6内 SpawnPlayActor() 被调用,之后的流程和 Server 一致

小结

GamePlay的初始化流程分为两个大的阶段:

  • LoadMap 之前: 生成 GameEngine, GameInstance, WorldContext, GameViewportClient(Client Only) , LocalPlayer(Client Only)
  • LoadMap 之后: URL.umap资源序列化以后的对象(UWorld, ULevel, AActor, UActorComponent…), GameModeBase(Server Only), GameSession (Server Only) , GameStateBase, GameNetworkManager (Server Only) , PlayerController, PlayerState, Pawn

LoadMap 之前生成的对象只有一个, GameInstance 的数量在 PIE 模式下有点特殊, 会根据 Number of Players 生成多个(Play Offline/Play As Listen Server: n , Play As Client:n+1)

至此,GamePlay启动流程基本结束。如果对文中一些类的具体用途以及为什么如此设计,则可以翻阅大钊老师的专栏《InsideUE5》,里面详细讲解了GamePlay框架中最重要的几个类的设计原理以及具体用途

本文的内容主要来自youtube上一位大佬的视频讲解(有好心人搬到了B站), 最好的学习方法就是照着视频的内容,一步步看源码然后记录下来,记载成文加深记忆和理解

youtube:The Unreal Engine Game Framework: From int main() to BeginPlay
bilibili:UE4游戏框架:从int main()到BeginPlay

[原创]UE— Garbage Collection

这是一篇长文,涵盖了UE4垃圾回收的大部分知识点。如若有错误之处,望留言纠正(亦可邮件探讨:lingzelove2008@163.com),感激不尽

垃圾回收起源

何为垃圾回收 (Garbage Collection) ?它是一种自动管理资产 (Assets) 生命周期的运行时机制

UE 采用的编程语言是 C++,这也是很多 Unity 程序员转行到 UE 的第一个重量级拦路虎。有好心的 UE 前辈会告诉你 : “UE 的 C++ 是魔改版,用起来跟 C# 差不多”。这句话有一定的道理,让前辈们有这种错觉最重要的一点就是,UE 引擎实现了垃圾回收(C++标准未实现 )

C++的内存管理,new 必须对应 delete ; 但是游戏项目非常庞大,指针满天飞,你压根不知道应该在哪儿 delete 。隔壁 Unity 只需要 new 系统会自动 delete,这让 UE 引擎专家甚是羡慕,就强撸了一个垃圾回收系统来自动 delete,让 UE 的开发难度骤降

垃圾回收的功能

如果让你来实现这个垃圾回收系统,你觉得垃圾回收系统应该具备哪些功能呢?

  • 实现自定义的 NewObject 方法( 无需调用对应的DeleteObject )
  • 自动回收没有被引用的垃圾对象
  • 不影响其他系统的正常功能

垃圾回收伪实现

垃圾回收的算法有很多, 标记-清除算法 、 复制算法 、标记-整理算法 、分代收集算法等。我们就用最”简单”的标记清除算法来实现。标记-清除算法,看名字就知道有两个阶段,标记和清除:

  • 标记:遍历所有对象,根据某种规则,标记其是否需要清除
  • 清除:遍历所有对象,清除标记了的对象,回收内存

因此可知,要实现标记清除垃圾回收,在标记阶段我们需要做到以下两点:

  • 能拿到所有对象
  • 确定对象清除的规则

在自定义的 NewObject 方法内,把生成的对象指针放入全局数组 GUObjectArray ,这样我就能拿到所有对象了

想象一个画面:空场景内站着一个英雄。这个情况下,垃圾回收系统是不是应该围绕着英雄来判断?英雄用到的对象就保留,没用到的对象就清除。此时这个英雄就是 “根对象”。因此,标记清除的规则就是,根对象用到的对象保留,其他对象清除。那根对象怎么确定?就得我们”手动”标记(AddToRoot)

以下就是垃圾回收的伪实现:

  • 启动垃圾回收,加锁( 保持所有对象的引用关系不变 )
  • 设置所有对象为”不可达”标记(根对象、特殊对象 除外)
  • 遍历根对象列表,根对象引用到的对象去除”不可达”标记
  • 收集所有仍然标记为”不可达”的对象,全部删除

垃圾回收 UE4 实现

UE4的垃圾回收过程,正是照着上文的伪实现一步步执行的,不过细节更丰富,效率也更高

GC 启动

手动调用:UWorld::ForceGarbageCollection( bool bFullPurge),它会在World.tick 的下一帧强行进行垃圾回收

自动调用:系统会根据默认的设置(可重新配置)一定的间隔时间或者条件下,自动调用垃圾回收

GC 锁

void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
    AcquireGCLock(); //获取GC锁
    CollectGarbageInternal(KeepFlags, bPerformFullPurge); //垃圾回收
    ReleaseGCLock(); //释放GC锁
}

GC锁的主要用处就是为了暂停其他线程以免UObject对象的引用关系在GC过程中发生变化。主要步骤:

  • 发送信号,表示我想获取GC锁,GCWantsToRunCounter 自增(原子操作)
  • GC 线程 Sleep,查看 AsyncCounter 是否等于 0 判断其他线程是否有阻塞GC的操作还在执行,不等于 0 就继续等待
  • AsyncCounter = 0,通过另一个变量 GCCounter 递增(原子操作),来标识正在执行GC,其他所有线程将被阻塞
  • 执行内存屏障
  • 将 GCWantsToRunCounter 设为 0,开始真正的 GC 操作
  • GC 操作完毕, GCCounter 自减释放 GC 锁

内存屏障的主要意思就是,在这个屏障之前的所有读和写的操作,一定会在这个屏障后面的读和写的操作之前执行。为了防范多线程读写操作时序问题导致的逻辑 bug,详细内容自行 Google

获取所有对象

上文伪实现,NewObject的时候把生成的对象的指针放入一个全局数组,实际上UE4确实也是这么做的。UObject对象继承自UObjectBase,它的构造方法如下(代码有删减改动):

//UObjectBase.cpp

//NewUObject方法调用后,UObject对象初始化
UObjectBase::UObjectBase(UClass* InClass, EObjectFlags InFlags, EInternalObjectFlags InInternalFlags, UObject *InOuter, FName InName)
{
   AddObject(InName, InInternalFlags); 
}

void UObjectBase::AddObject(FName InName, EInternalObjectFlags InSetInternalFlags)
{ 
   //将对象加入GUObjectArray,并且为Object分配InternalIndex(对象的索引位置)
   GUObjectArray.AllocateUObjectIndex(Object);
}

GUObjectArray 虽然命名是 Array 结尾,实际上它是个容器体。虽然它里面一堆花里胡哨的东西,实际上只要理解它是为了多线程的时候分块扫描其存储的内容而设计就行。UObject对象不接存入容器的,而是被组装成了 FUObjectItem 结构:

//UObjectArray.h

//对象存储的结构体,GC操作的就是这个对象
struct FUObjectItem
{
   class UObjectBase* Object; //对象
   int32 Flags;               //EInternalObjectFlags标识
   int32 ClusterRootIndex;    //当前所属簇索引
   int32 SerialNumber;        //对象唯一序列码(WeakObjectPtr实现用到它)
}

这个结构体对于理解GC很重要。成员 Object 是 NewObject 生成的对象的指针,EInternalObjectFlags 是啥呢?就是用来做标记的枚举类型。结构如下:

//ObjectMacros.h

enum class EInternalObjectFlags : int32
{
   None = 0,
   ReachableInCluster = 1 << 23, ///< 簇中可达
   ClusterRoot = 1 << 24, //cluster root 不会被GC回收,簇根节点
   Native = 1 << 25, // UClass
   Async = 1 << 26, //异步对象
   AsyncLoading = 1 << 27, //异步加载中
   Unreachable = 1 << 28, // 不可达对象,会被GC删除
   PendingKill = 1 << 29, // 等待析构,会被GC删除
   RootSet = 1 << 30, // 根节点
   
   GarbageCollectionKeepFlags = Native | Async | AsyncLoading, //拥有这三种标记之一的对象在GC检测时会被跳过
};

标记不可达

以下代码就是获取所有对象,并根据标签 标记部分对象为不可达;可达对象都放入 ObjectsToSerialize 数组内 ( 代码有删减,部分变量名用auto替换,是为了减少长度)

//GarbageCollection.cpp

void PerformReachabilityAnalysis(EObjectFlags KeepFlags, bool bForceSingleThreaded, bool bWithClusters)
   {
      //从系统提供的数组池中获取数组(为了支持多线程)
      auto ArrayStruct = FGCArrayPool::Get().GetArrayStructFromPool();
      auto ObjectsToSerialize = ArrayStruct->ObjectsToSerialize;

      // 继承了FGCObject的非Uobject对象,放入ObjectsToSerialize
      ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer);

      //将对象标记为不可达,并且将根节点以及不可删除对象放入ObjectsToSerialize
      MarkObjectsAsUnreachable(ObjectsToSerialize, KeepFlags);
      
      //分析ObjectsToSerialize数组内的对象,它能达到的对象,去掉不可达标签
      PerformReachabilityAnalysisOnObjectsInternal(ArrayStruct)
   }

代码都有注释,很好理解;ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer); 这一行,很关键,这也是为什么你看很多文章,都说非UObject对象,继承 FGCObject 后,也可以将它引用的对象加入垃圾回收

class FMyStruct: public FGCObject
{
    UObject* NoGCObj;

    void AddReferencedObjects(FReferenceCollector& Collector) override
    {
	Collector.AddReferencedObject(NoGCObj);
    }
}

这部分的代码分析,可见我的上一篇文章:[原创]UE —UObject类智能指针

引用关系分析

MarkObjectsAsUnreachable() 方法消耗不大,因为是多线程操作,且这些 FUObjectItem 结构体对象是内存块连续的数据,遍历不会有缓存 miss。PerformReachabilityAnalysisOnObjectsInternal(ArrayStruct) 才是最复杂,最耗时的操作

UE 是怎么获取一个对象引用了哪些其他对象呢?

UE 中每个 UObject 对象都有一个与之对应的 UClass , 这个UClass保存了对应UObject 的所有反射系统相关信息,描述了各个 Property 之间的内存布局和关系;通过UObject对象的实例化地址就可以将相应的属性遍历出来,进行读写(注意:UClass描述的属性是我们手动加了 UPROPERTY() 标签的)

Obj 对象所引用的其他 UObject 对象,伪代码如下:

//遍历Obj对象的所有属性,如果属性的类型是UObjectle类型,就取消不可达标签
for(TFieldIterator<FProperty> PropertyIter(Obj->GetClass()); PropertyIter; ++PropertyIter)
{
    const FProperty* PropertyIns = *PropertyIter;
    //该属性是否是UObject,是否是Tarray<UObject>,是否是TMap<key,UObject>等
    //然后依次取消 "不可达"标记
    //然后递归遍历该属性对象所引用的对象......
}

这么实现,确实可以 ;但是 UE 并没有这么做,为什么呢?因为属性内其实大部分都是非 UObject类型 ,全部遍历效率太低。因此在生成 UObject对应 UClass 的时候,就构造了一个新的概念,将所引用的其他对象用一个很巧妙的整数,存入 ReferenceTokenStream 变量的 Tokens 数组内

ReferenceTokenStream

断点一个UObject对象,查看其ReferenceTokenStream,内容如下:

这个里面包含当前对象 Obj 引用到的所有标记了 UPROPERTY() 的属性(包括它的父类),那这个数字到底是啥意思呢 ?其实它是三个数字的合并。前8位, 引用对象的嵌套深度 ;中间5位,引用对象的类型( EGCRefenceType );最后则是当前引用的对象的变量在 Obj 对象内的内存偏移值(根据这个偏移值,可获取这个引用的对象)

struct FGCReferenceInfo
{
    union
    {
        struct
        {
            uint32 ReturnCount  : 8;
            uint32 Type         : 5;
            uint32 Offset       : 19;
        };
        //ReturnCount + Type  + Offset
        //00000000    + 00000 + 0000000000000000000
        uint32 Value; 
    };
};

根据 Tokens 内的值,我们可以获取它引用的属性的类型。比如: GCRT_ArrayObject 、 GCRT_ArrayStruct、 GCRT_Object等,根据它们的类型做不同的操作。PerformReachabilityAnalysisOnObjectsInternal 代码内最后调用的分析代码是 ProcessObjectArray (以下代码有删减):

void ProcessObjectArray(FGCArrayStruct& InObjectsToSerializeStruct, const FGraphEventRef& MyCompletionGraphEvent)
{
    while (CurrentIndex < ObjectsToSerialize.Num())
    {
        CurrentObject = ObjectsToSerialize[CurrentIndex++];
        // 获取当前对象的ReferenceTokenStream
        auto* TokenStream = &CurrentObject->GetClass()->ReferenceTokenStream;
        uint32 ReferenceTokenStreamIndex = 0;
        //当前对象的起始地址
        uint8* StackEntryData = (uint8*)CurrentObject;
        while (true)
        {
			//注意:源码这里不是直接++,但是为了方便理解,这里直接用
	    ReferenceTokenStreamIndex++;
	    FGCReferenceInfo ReferenceInfo = TokenStream->AccessReferenceInfo(ReferenceTokenStreamIndex);
            switch(ReferenceInfo.Type)
            {
                case GCRT_Object:
                case GCRT_Class:
                {
                    // 引用对象的地址: 起始地址 + Offset
                    UObject**   ObjectPtr = (UObject**)(StackEntryData + ReferenceInfo.Offset);
                    UObject*&   Object = *ObjectPtr;
             ReferenceProcessor.HandleTokenStreamObjectReference(Object...);
                }
                break;
                case GCRT_ArrayObject:
                {
                    TArray<UObject*>& ObjectArray = *((TArray<UObject*>*)(StackEntryData + ReferenceInfo.Offset));
                    for (int32 ObjectIndex = 0, ObjectNum = ObjectArray.Num(); ObjectIndex < ObjectNum; ++ObjectIndex)
                    {
          ReferenceProcessor.HandleTokenStreamObjectReference(Object...);
                    }
                }
                break;
            }
        }
    }
}

HandleTokenStreamObjectReference 会调用 HandleObjectReference,它会去除它的”不可达”标记,并将它加入NewObjectsToSerialize,开辟新的 task 线程去处理,而不是在当前线程递归

清理

GC 的清理过程,在函数 IncrementalPurgeGarbage 内,它的两个参数可以让我们选择是增量清理还是全量清理,清理的过程分为两步:收集 和 清理

收集不可达对象:GatherUnreachableObjects,多线程操作, 遍历GUObjectArray 将所有标记为”不可达”的 FUObjectItem 加入 GUnreachableObjects 数组

清理看起来实现应该很简单, 直接遍历 GUnreachableObjects 数组,然后delete就行,实际很复杂,具体步骤如下:

  • 获取 GC 锁(因为 BeginDestroy 到 FinishDestroy 之间可能会有异步操作)
  • 执行 UnhashUnreachableObjects 方法,遍历数组GUnreachableObjects,调用对象的 BeginDestroy 方法,可重载这个方法进行自定义操作
  • 执行 IncrementalDestroyGarbage 方法,遍历数组 GUnreachableObjects ,调用对象的 FinishDestroy 方法,注意:此时可能 BeginDestroy 内的异步方法还没执行完毕,因此 FinishDestroy 方法会执行失败,先把对象存储到 GGCObjectsPendingDestruction 中,之后再处理
  • 遍历数组 GGCObjectsPendingDestruction, 尝试调用对象的 FinishDestroy 方法
  • TickDestroyGameThreadObjects 执行真正的删除对象,遍历 GUnreachableObjects 内的所有对象;执行之后的操作,代码如下:
// TickDestroyGameThreadObjects 对垃圾对象最后的处理 
FUObjectItem* ObjectItem = GUnreachableObjects[ObjCurrentPurgeObjectIndexOnGameThread];       
if (ObjectItem)
{
   //将GUObject内的对象设置为nullptr
   GUnreachableObjects[ObjCurrentPurgeObjectIndexOnGameThread] = nullptr;
   UObject* Object = (UObject*)ObjectItem->Object;
   //调用析构函数
   Object->~UObject();
   //释放内存
   GUObjectAllocator.FreeUObject(Object);
}

至此,本文告一段落。当然,关于垃圾回收还有很多未解之谜。比如:神秘的UClass 是怎么生成的 ?它又是如何收集 UObject 的属性(标记了 UPROPERTY ),方法(标记了 UFUNCTION) ?ReferenceTokenStream是如何生成的 ? Cluster (簇) 是什么 ,它对GC有何影响?这些巨坑,等以后再慢慢填

[原创]UE—GAS系列:1. 基础概念

UE的技能系统 ( Gameplay Ability System 简称:GAS ) ,官方是以插件的形式嵌入在UE引擎中;你完全可以在自己的项目中用使用自己团队的”祖传”技能系统,但我还是推荐大型项目优先使用 GAS 。它的优点是:度高灵活、可复用性强;缺点是:过于庞大、不易上手

如果你是第一次接触 GAS,可能被一些专有名词给绕晕 :GameplayAbility(GA)、 AbilitySystemComponent(ASC) 、GameplayEffect (GE)、AttributeSet(AS)、 GameplayCue (GC); 那么,我就通俗易懂的解释一下这些概念

GameplayAbility(GA)

武当有一门神技《梯云纵》,施展以后就能一跃十丈,潇洒无边。这本秘籍就是GA ,我们来看看这本秘籍的内容:梯云纵(第一重)

神技心法:

  • 气沉丹田
  • 聚气成力

神技说明:

  • 每天只能施展一次
  • 每次施展消耗一点真气

神技威力:

  • 一跃而起,离地一丈
  • 形成雷鸣,方圆一里

AbilitySystemComponent(ASC)

想要习得上乘武功,就必须有习武的神经中枢(俗称:天赋),没有天赋只有秘籍那就是个王语嫣;而这个神经中枢就是ASC

因此想要施展技能,Character 必须挂上ASC 组件,神经中枢是通过 GiveAbility 融汇贯通了梯云纵的神技心法

ASC->GiveAbility(const FGameplayAbilitySpec& Spec);
FGameplayAbilitySpec 主要参数如下:
UGameplayAbility* GA;  //梯云纵
int32 Level;           //第一重
UObject* SourceObject; //谁习得梯云纵(自己)

GameplayEffect (GE)

融汇贯通了梯云纵心法,那是不是就可以出门行侠仗义,闯荡江湖了呢?还是得从长计议,神技虽然威力无边,但也有很大的自身限制,因为神技第一重每天只能使用一次。用GAS它是怎么实现的呢?那就要用到 GE 了

GE 通俗一点理解就是 Buff,DeBuff ;施展完一次梯云纵以后,GA会告知 ASC,请在小本本上写一行标记: CoolDown.Skill.TiYunZong ,然后24小时后删掉这行标记。伪代码如下:

//GE相当于一份配置文件,包含ASC要记的标记,持续时间等要素
UGameplayEffect* CooldownGE = GetCooldownGameplayEffect();
if (CooldownGE)
{
    const auto SpecHandle = MakeOutGESpec(CooldownGE->GetClass(), 1);
    ApplyGameplayEffectSpecToOwner(ActivationInfo, SpecHandle);
}

施展梯云纵之前,ASC 首先会去小本本检查是否有 CoolDown.Skill.TiYunZong 这个标签。如果有那就表示距离上次使用未超过一天时间不得再次施展,这就是GAS 进行技能冷却的原理

AttributeSet(AS)

施展梯云纵之前,中枢神经ASC还会去检查你体内是否有足够的真气,而真气就是 AS,也就是GAS中常说的属性点,比如: 真气(Mana),血量(Health) 等。那么扣一点属性点,是怎么实现的呢?同样是使用 GE 告知 ASC,你扣除身上的Mana属性值 1 点,瞬间完成。伪代码如下:

UGameplayEffect* CostGE = CostEnergyGE;
const auto SpecHandle = MakeOutGESpec(CostGE->GetClass(), 1);
if (SpecHandle.Data)
{
    //代码有删改,意思就是设置GE内对应tag扣的值
    SpecHandle.Data->SetSetByCallerMagnitude("Data.Energy.Mana", 1);
    ApplyGameplayEffectSpecToOwner(ActivationInfo, SpecHandle);
}

这样,就完成了属性点扣除。同样,在梯云纵施展之前,出了要检查是否有 CoolDown.Skill.TiYunZong 这个标签,ASC 还会去检查属性点 Mana 是否足够你施展完以后扣除,不够就不能施展;强行施展后会抽走全身精气走火入魔

GameplayCue (GC)

施展梯云纵的时候,由于它威力巨大,会在方圆一里产生巨大的雷鸣,震撼八方 宵小之辈。那么GAS 是如何实现的呢?也是通过 GE 告知 ASC,丢出去一个一次性暗器 ( 也就是GC ),它会播放一个3D环绕雷声(声音范围:方圆一里)以及雷电特效,播放完以后自动销毁 。GE 配置了丢哪个暗器(Gameplay Cue Tag),以及何时销毁(Has Duration)

总结

注意: ASC 组件不一定非得挂在 Character 身上,你也可以挂在 PlayerState 身上,甚至游戏内任何想要使用技能的 Actor 身上。GE 是 GAS 的灵魂,也是重中之重,很多效果都是通过 GE 去驱动。GAS 还有一个核心 — FGameplayTag,但这是UE4引擎本身的标签系统;如果没有 FGameplayTag 配合,GAS 就显得平平无奇;FGameplayTag + GAS 组成强大的技能系统,可以实现任何上天入地的炫酷技能

至此,我们可以基本上明白GAS内的各个名词的功能。但实际上,这才是梯云纵的第一层,想要达到大圆满境界,我们仍需努力。以下是我觉得比较好的一些 GAS 视频链接,适合新手登堂入室

GAS新手链接:

[原创]UE —UObject类智能指针

上文谈到C++ 标准智能指针,是为了让程序员从指针的 new 和 delete 中抽身,更专注逻辑本身的开发。UE4 针对非 UObject 对象也实现了一套类似的智能指针,至于具体实现原理,可查看知乎文章:UE4的智能指针 TSharedPtr

强如 Java 和 C# 等更“智能化”的高端语言已经实现了垃圾回收机制,恐怖如斯;UE虽身披 C++ 却心向 C# ,自己也捣鼓了一个低端版本的垃圾回收,针对UObject类型对象的智能指针,主要有:TWeakObjectPtr,TStrongObjectPtr

WeakObjectPtr

我们知道继承了 UObject,并且被标记了 UPROERTY 的对象 Obj ,会被 GC 追踪(会被GC追踪的对象还有其它情况,本文暂不讨论);因此我们在使用 Obj 之前一般都会有如下代码安全开道:

if (IsValid(Obj))
{
    //Use Obj do something
}

GC 的删除过程是分帧处理,但是会提前标记 PendingKill,所以 IsValid(Obj) 依然返回 false,确保我们的代码不会出问题。但是,如果我们的代码是在 GC 清理完全以后再调用呢?上面这段代码就可能会导致 Crash ,为什么呢?

//Object.h
FORCEINLINE bool IsValid(const UObject *Test)
{
    return Test && !Test->IsPendingKill();
}

//UObjectBaseUtility.h
FORCEINLINE bool IsPendingKill() const
{
    return GUObjectArray.IndexToObject(InternalIndex)->IsPendingKill();
}

Obj 指向的 UObject 对象被初始化出来后,会被插入到数组 GUObjectArray,位置就是 InternalIndex 。GC后,GUObjectArray 数组内 InternalIndex 的位置被放入新的 UObject 对象,因此 IsValid ( Obj ) 返回的结果可能是 true;因此IsValid 判断 Obj 指针有效,而实际上 Obj 指针指向的内存地址里的内容解引用会失败,导致Crash

因此,我们希望有一种新型的智能指针Smart_Ptr用来包装 裸指针Obj,它必须满足以下两点要求:

  • Smart_Ptr 不能影响 Obj 的 GC
  • Obj 所指向的对象被 GC 删除以后,IsValid(Smart_Ptr) 会百分百返回 :false

UE很巧妙的撸了个 WeakObjectPtr ,实现原理很简单,它的核心就两个参数:

//WeakObjectPtr.h
int32	ObjectIndex;
int32	ObjectSerialNumber;

//WeakObjectPtr.cpp
ObjectIndex = GUObjectArray.ObjectToIndex((UObjectBase*)Object);
ObjectSerialNumber = GUObjectArray.AllocateSerialNumber(ObjectIndex);

ObjectIndex 就是当前WeakObjectPtr 指向的对象在数组GUObjectArray内的索引,而ObjectSerialNumber 则是该对象当初在创建的时候的唯一序列码 SerialNumber (赋值和自增都是原子操作,线程安全);因为它不直接引用 Obj 指针 ,因此不会影响 GC,然后 ObjectSerialNumber 会跟 GUObjectArray 内 ObjectIndex 位置对象的 SerialNumber 对比,不一样就表示根本不是同一个对象,使用如下:

//Assignment
TWeakObjectPtr<UMyObject> WeakObj = Obj;

//use
if(WeakObj .Get())
{
    UMyObject* Obj = Cast<UMyObject>(WeakObj);
    //Use Obj do something
}

因此,对于一些会隔帧执行的代码(定时器回调、 委托等),如果有用到 UObject 对象,强烈建议使用 WeakObjectPtr,能减少很多隐藏的Crash,让你的代码更乐百氏( Robust )

StrongObjectPtr

相对 WeakObjectPtr 来说, StrongObjectPtr 用的并不多。它提供的是一种绝对的把控,被他包装的 UObject 类型对象(不标记 UPROPERTY ),不会被 GC 自动清理,只有 StrongObjectPtr 析构以后,它包装的 UObject 才会被下一次 GC 清理

因此实现这种指针封装,需要满足以下两点:

  • 持有该对象,并且保证它不被 GC
  • 告诉GC,我用完了你可以清理它

我们查看 StrongObjectPtr 的实现,关键就一个变量:ReferenceCollector,类型是 FInternalReferenceCollector,继承自 FGCObject 。( ReferenceCollector 是 UniquePtr类型指针,因此它不能复制,只能转移所有权(OwnerShip))

为什么继承了 FGCObject 就可以被 GC 追踪 ?我们先看它的实现(注意:为了代码简短,去除了部分内容)

//GCObject.h
class COREUOBJECT_API FGCObject
{
   static UGCObjectReferencer* GGCObjectReferencer;
   static void StaticInit(void)
   {
      if (GGCObjectReferencer == NULL)
      {
         GGCObjectReferencer = NewObject<UGCObjectReferencer>();
         GGCObjectReferencer->AddToRoot();
      }
   }

   FGCObject(void)
   {
       StaticInit();
       GGCObjectReferencer->AddObject(this); 
   }

   virtual ~FGCObject(void)
   {
       GGCObjectReferencer->RemoveObject(this);
   }

   virtual void AddReferencedObjects( FReferenceCollector& Collector ) = 0;
};

静态指针 GGCObjectReferencer,用来管理所有继承了 GFGCObject 的指针对象;初始化执行如下两个方法

  • StaticInit() 内 AddToRoot;表示将 GGCObjectReferencer 设置为根节点,因此它永远不会被 GC 清理(除非调用 RemoveFromRoot)
  • AddObject(this) ,则是将自身放入 GGCObjectReferencer 内的ReferencedObjects 数组内

每一次GC的引用标记阶段,执行到 GGCObjectReferencer 指向的对象时,会执行它的 AddReferencedObjects 方法,而它会遍历调用 ReferencedObjects 数组内的所有 FGCObject 对象的 AddReferencedObjects 方法

//GCObjectReferencer.cpp
void UGCObjectReferencer::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
{	
    UGCObjectReferencer* This = CastChecked<UGCObjectReferencer>(InThis);
    for (FGCObject* Object : This->ReferencedObjects)
    {
	Object->AddReferencedObjects(Collector);
    }
}

我们来看看指针 ReferenceCollector 内的 AddReferencedObjects 方法:

//StrongObjectPtr.h
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
   Collector.AddReferencedObject(Object);
}

Collector.AddReferencedObject( Object) 就是将 StrongObjectPtr 所修饰的 UObject 指针对象放入 GC 体系(被 Collector 引用,因此不会被 GC )

以上,我们解释了第一条:持有该对象,并且保证它不被 GC ;那怎么告诉 GC,我现在用完了,可以清理呢?

上面的 ~FGCObject 析构函数已经给到答案了,就是将 ReferenceCollector 从 GGCObjectReferencer 移除。那么下次 GC 引用标记阶段执行到 GGCObjectReferencer 指向的对象时,它的 ReferencedObjects内就没有我们的 ReferenceCollector,那么它的 AddReferencedObjects 方法就不会被执行到; 那么 ReferenceCollector 内的UObject* Object 就没人引用,那么 GC 就会清理它

其实,大部分情况下,都用不到 StrongObjectPtr ,因为 UPROPERTY 标记足够好用。然而它可以在任何地方使用,包括F类内,这也是它的优势

参考引用: