导语
近期排查一个问题时,将Unreal的启动的初始化流程和基础的Gameplay类又review了下。 将相关内容整理,随笔记录下来。
Unreal程序入口点和主循环
Unreal使用C 作为基础开发语言;作为一个引擎,它的代码结构庞杂,功能繁多。但如果从整个计算机体系看,它仅是操作系统的一个进程;作为C 编写的可执行程序,它启动后,代码执行的入口必然是main函数。
在Windows环境下,使用Visual Studio调试,我们在FEngineLoop的PreInitPreStartupScreen函数增加断点。可以得到如下堆栈:
代码语言:javascript复制 FEngineLoop::PreInitPreStartupScreen(const wchar_t * CmdLine)
FEngineLoop::PreInit(const wchar_t * CmdLine)
EnginePreInit(const wchar_t *)
GuardedMain(const wchar_t * CmdLine)
GuardedMainWrapper(const wchar_t * CmdLine)
LaunchWindowsStartup(...)
WinMain(HINSTANCE__ * hInInstance, HINSTANCE__ * hPrevInstance, char * pCmdLine, int nCmdShow)
在调用栈的最底层,正是所有Windows程序的入口点。
不同平台的程序入口点分别位于如下代码目录。
代码语言:javascript复制Engine/Source/Runtime/Launch/Private/${platform}
如果跳过为了可移植性编写的不同操作系统的入口点函数,引擎程序本身的入口点可以认为是GuardedMain。(实现位于Runtime/Launch/Private/Launch.cpp文件)。
GuardedMain函数实现的主要流程如下:
代码语言:javascript复制GuardedMain( const TCHAR* CmdLine )
{
<...>
EnginePreInit
<...>
EngineInit
<...>
while( !IsEngineExitRequested() )
{
EngineTick();
}
<...>
}
这个函数和大多数的服务器程序类似:先执行初始化,然后进入所谓的程序循环,在收到引擎退出的请求后,则终止执行。
在介绍上述流程中的初始化步骤前,我们回顾下:
Gameplay的基础类
Unreal构建游戏玩法的基础类罗列如下。对于有志于引擎,或者Unreal游戏开发的同学,花一周左右的时间,对下面的类及其使用基本耳熟能详。
代码语言:javascript复制UEngine UGameInstance(Outer) FWorldContext UWorld(OwningGameInstance) ULevel(OwningWorld)
AGameMode AGameSession AGameState
AActor(Outer) UActorComponent
APawn AController
UNetConnection UPlayer
简单的讲, UEngine,UGameInstance,UWorld,ULevel是抽象等级比较高的Gameplay管理类。
UEngine是引擎功能的抽象,负责完成整个逻辑循环的实现。ULevel是游戏对象(AActor)的集合。一般来说,UWorld由一个Persistent Level和若干Streaming Levels构成。对于拥有不同的场景的游戏,比如手游的大厅界面和单局游戏,对应着不同的UWorld。 UGameInstance的抽象层级,介于UEngine和UWorld之间。对于GameEngine来说,基本和UEngine具有相同的生命周期,原生实现仅有一个UGameInsance实例;只有在EdiotrEngine中,原生实现会有多个多个不同的UGameInsance实例。
World创建之后,会创建对应的AGameMode实例负责游戏玩法的状态转移,它会创建AGameSession和AGameState辅助自己完成相应工作。在玩家登录时,它会Spawn出玩家控制的角色ACharacter(或者APawn),以及玩家控制器(AController)。
Unreal引擎初始化过程
上面的陈述比较笼统。读者可以结合下面整理的初始化流程,熟悉下各Gameplay对象初始化的时机。以及彼此之间的关系。
代码语言:javascript复制GuardedMain(Launch.cpp)
EnginePreInit
FEngineLoop::PreInit
FEngineLoop::PreInitPreStartupScreen
FEngineLoop::AppInit()
FCoreDelegates::OnInit.Broadcast()
InitUObject()
StaticUObjectInit();
GObjTransientPkg = NewObject<UPackage>(nullptr, TEXT("/Engine/Transient"), RF_Transient);
FEngineLoop::PreInitPostStartupScreen
EngineInit
FEngineLoop::Init
GEngine = NewObject<UEngine>(GetTransientPackage(), EngineClass);
UGameEngine::Init
GameInstance = NewObject<UGameInstance>(this, GameInstanceClass);
UGameEngine::Start
UGameInstance::StartGameInstance()
UEngine::Browse
UEngine::LoadMap
NewWorld = UWorld::FindWorldInPackage(WorldPackage);
WorldContext.World()->SetGameMode(URL);
UGameInstance::CreateGameModeForURL
World->SpawnActor<AGameModeBase>(GameClass, SpawnInfo);
UWorld::InitializeActorsForPlay
AGameModeBase::InitGame
World->SpawnActor<AGameSession>
UWorld::BeginPlay
AGameMode::StartPlay()
FCoreUObjectDelegates::PostLoadMapWithWorld.Broadcast
EngineTick循环
FEngineLoop::Tick()
UGameEngine::Tick
UWorld::Tick
UWorld::RunTickGroup
FTickTaskManager::RunTickGroup
<...>
FActorTickFunction::ExecuteTick
AActor::TickActor
这里赘述下几个比较关键的节点
1. PreInit的时候, 会创建TransientPkg。
2. FEngineLoop::Init会首先创建Engine类的实例;在Engine初始化过程中会创建GameInstance类的实例。
3. FEngineLoop::Init其后会调用Engine::Start。这里会执行LoadMap的操作,期间会创建UWorld,并Spawn GameMode。
4. 在LoadMap的最后,会使用PostLoadMapWithWorld事件通知关心该事件的对象。
5. 在完成了初始化,进入Tick循环。
初始化流程的定制
对于业界现有的单局化的游戏开发来说,LoadMap承载的只是单局内的静态资源,对于每局都有一定随机性的游戏设计,需要在LoadMap完成后,继续一些动态的初始化流程。另外为了优化性能,游戏一般会引入预创建的过程。动态初始化和预创建,可以通过对以上流程的理解,安排在合适的时机:比如PostLoadMap,或者放到Tick内。
这些定制化的流程,可能带来游戏流程的变化。比如等待这些定制化流程完成的阶段。比如等待所有玩家登录的阶段。
这里仅做抛砖引玉,读者可以结合自己实际工作,自行思考下定制化的初始化流程实现。
玩家登录后的初始化过程
以上是整个游戏的初始化流程。下面我们看下玩家的初始化流程。
对于联机游戏,玩家登录的初始化流程是在如下堆栈之上完成的。
代码语言:javascript复制 UWorld::NotifyControlMessage(UNetConnection * Connection, unsigned char MessageType, FInBunch & Bunch)
UControlChannel::ReceivedBunch(FInBunch & Bunch)
UChannel::ReceivedSequencedBunch(FInBunch & Bunch)
UChannel::ReceivedNextBunch(FInBunch & Bunch, bool & bOutSkipAck)
UChannel::ReceivedRawBunch(FInBunch & Bunch, bool & bOutSkipAck)
UNetConnection::ReceivedPacket(FBitReader & Reader, bool bIsReinjectedPacket)
UNetConnection::ReceivedRawPacket(void * InData, int Count)
[外部代码]
Invoke(void(UNetDriver::*)(float))
UE::Core::Private::Tuple::TTupleBase<TIntegerSequence<unsigned int>>::ApplyAfter(void(UNetDriver::*)(float) &)
TBaseUObjectMethodDelegateInstance<0,UNetDriver,void __cdecl(float),FDefaultDelegateUserPolicy>::ExecuteIfSafe(float <Params_0>)
TMulticastDelegate<void __cdecl(float),FDefaultDelegateUserPolicy>::Broadcast(float <Params_0>)
UWorld::Tick(ELevelTick TickType, float DeltaSeconds) 行 1373 C
UGameEngine::Tick(float DeltaSeconds, bool bIdleMode) 行 1831 C
引擎会在tick中调用NetDriver的命令处理。连接的登录请求包,最终由UWorld::NotifyControlMessage来处理。
其后的玩家创建流程如下:
代码语言:javascript复制UWorld::NotifyControlMessage
UWorld::SpawnPlayActor
AGameModeBase::Login
SpawnPlayerController
InitNewPlayer
UpdatePlayerStartSpot
<...>
ChoosePlayerStart_Implementation
AGameModeBase::PostLogin
HandleStartingNewPlayer
RestartPlayer
<...>
AGameModeBase::SpawnDefaultPawnAtTransform_Implementation
StartMatch
玩家初始化的关键点有两个:
1. 执行GamoMode的Login流程,主要是创建PlayerController,并选择出生点。
2. 执行GamoMode的PostLogin流程,这里会创建玩家控制的Character或者Pawn, 同时根据GameMode状态决定是否可以进入下一状态(StartMatch)。
同样的,玩家的登录流程也可以定制。比如增加玩家鉴权,比如拉取玩家装备。这里也仅作抛砖引玉,不细致展开。
结语
本文对Unreal的主程序入口和Gameplay基础类做了一定剖析,并详细整理了引擎初始化,和玩家登录后的初始化流程。 希望对于做Unreal Gameplay开发的同学,减少相关代码的学习时间,并对定制化的游戏流程设计产生帮助。
随笔系列说明
23年新挖一个《Unreal随笔系列》的坑。所谓随笔,就是研究过程中的一些想法随时记录;细节可能来不及考证,甚至一些想法可能也不太成熟,有失偏颇;希望读者也可以帮忙指正和讨论。这个系列主要求量,希望每个月给自己布置一些研究小课题,争取今年发满12篇。