UE插件与工具开发:基础概念

UE plug-in and tool development: basic concepts

在使用UnrealEngine进行项目开发的过程中,经常会开发和接入各种类型的插件来对引擎进行扩展,从而实现不同的需求。对于开发者而言,对运行机制的理解优于工具的使用。所以对于插件的接入和开发,有必要了解其原理。

之前我也开发了一些UE的工具和插件,我希望写一个相关的系列文章,把UE插件与工具开发相关具有共性的技术内容做一些总结,并分享一些我开发插件时的一些思考和功能脚手架。尽可能用最少的代码、最小的侵入式和最佳的实现策略,来实现需要的功能。

什么是UE中的插件?

UE中的插件是通过uplugin进行组织的一些模块(Module)集合。可以在引擎中进行方便地启用,能够基于引擎中的已有功能进行开发,也可以包含其他插件中的模块进行扩展。

一般情况下,从功能上进行区分,最常用的模块类型可以分为以下几类:

  1. Runtime,引擎的运行时,在Editor和打包后都会运行;
  2. Developer,在非Shipping阶段都会参与编译和运行,用于一些开发阶段的功能,在Shipping时会自动剔除,避免测试功能进入发行包;
  3. Editor,在Target为Editor的目标程序加载的类型,如启动引擎的编辑器、Commandlet等,都会启动Editor类型的模块。通常情况下,Editor类型的模块用于扩展编辑器功能,如创建独立的视口、给某些资源添加功能按钮、以及配置面板等等。

单个插件可以包含多个不同类型的模块,如同时包含Runtime/Developer/Editor等类型,只需要在uplugin中描述即可。

在引擎的组织结构中,引擎内的插件位于Engine/Plugins目录下:

项目的工程则位于PROJECT_DIR/Plugins下:

引擎和项目中的插件有一定的依赖层级关系,需要控制好依赖层级,避免出现依赖混乱。

  1. 引擎中的Module应该只依赖引擎内置的Module,不应该包含其他插件中的Module。
  2. 引擎插件中的Module可以依赖引擎内置的Module和其他引擎内置插件中的Module,不应包含工程的Module
  3. 工程插件的Module可以包含引擎内置Module/引擎插件Module/其他工程插件的Module,不应包含工程里定义的Module
  4. 工程中的Module则可以包含以上所有Module的集合。

依赖关系如下图所示:

插件必须依托于某个工程启动,工程不单指游戏工程,而是指包含target.cs定义的代码单元。一个工程既可以包含插件,也可以包含自己的模块。工程的Target配置会影响插件中的Module加载。

以UE创建的默认工程为例:

)

在Source目录下,创建出了两个target*.cs文件,用于区分打包的Runtime和Editor,当然如果DS工程也会有对应的target.cs,这里对每个target.cs的定义都统称为工程编译目标。

它们的区别和文件命名无关,和文件内容相关。对比一下两者区别:

通过TargetType指定当前Target的类型。

TargetType是在UBT中定义的枚举类型,可选值为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace UnrealBuildTool
{
[Serializable]
public enum TargetType
{
/// Cooked monolithic game executable (GameName.exe). Also used for a game-agnostic engine executable (UE4Game.exe or RocketGame.exe)
Game,

/// Uncooked modular editor executable and DLLs (UE4Editor.exe, UE4Editor*.dll, GameName*.dll)
Editor,

/// Cooked monolithic game client executable (GameNameClient.exe, but no server code)
Client,

/// Cooked monolithic game server executable (GameNameServer.exe, but no client code)
Server,

/// Program (standalone program, e.g. ShaderCompileWorker.exe, can be modular or monolithic depending on the program)
Program,
}
}

表示了UE项目的五种工程的Target类型。

在IDE中选择不同的BuildConfiguration时,就会使用不同的Target:

  • Development Editor会使用Blank427Editor.Target.cs中定义的配置(TargetType.Editor
  • Development会使用Blank427.Target.cs中定义的配置(TargetType.Game

在编译时,工程的BuildConfiguration也会传递给插件,用于决定插件中的哪些Module参与编译。

在插件的build.cs中可以对当前Target的信息进行检测,用于处理编译不同Target时行为不同的情况:

1
2
3
4
5
6
7
public LearnPlugin(ReadOnlyTargetRules Target) : base(Target)
{
if (Target.Type == TargetType.Editor)
{
// ...
}
}

如何描述一个插件?

uplugin

UE中通过uplugin文件描述插件,它是一种基于json语法表示一个插件的组织结构和关键信息的配置。

  1. 插件的命名、作者等基础信息
  2. 插件模块的类型
  3. 模块的启动时机
  4. 对其他插件的依赖
  5. 平台黑白名单

以HotChunker的uplugin为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "HotChunker",
"Description": "",
"Category": "Other",
"CreatedBy": "lipengzha",
"CreatedByURL": "https://imzlp.com/",
"DocsURL": "",
"MarketplaceURL": "",
"SupportURL": "",
"EnabledByDefault" : true,
"CanContainContent" : false,
"IsBetaVersion": false,
"IsExperimentalVersion": false,
"Installed": false,
"SupportedPrograms": [ "UnrealPak" ],
"Plugins": [
{
"Name": "HotPatcher",
"Enabled": true
}
],
"Modules": [
{
"Name": "HotChunker",
"Type": "Program",
"LoadingPhase": "PostConfigInit",
"ProgramAllowList": [ "UnrealPak" ]
},
{
"Name": "HotChunkerCore",
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "HotChunkerEditor",
"Type": "Editor",
"LoadingPhase": "Default"
}
]
}

通过该信息,能清晰地知道插件中的模块数量、类型和启动时机。

  • 在编译时,UBT也会读取插件的Type,决定模块是否参与编译。
  • 在引擎启动时,会根据模块定义的信息进行启动。如LoadingPhase配置,在不同的启动阶段加载插件。

Modules

uplugin中的Modules元素是一个数组,用于描述当前插件中的模块,每个Module的描述有以下基础信息:

  1. Module名,也就是Build.cs里定义的模块名
  2. Type,模块的类型,Runtime/Developer/Editor/Program等,在哪些Target时参与编译和加载。
  3. Loadingphase,模块的启动时机,引擎中的模块都是有启动顺序的,通过调整该配置,可以控制插件模块的加载时机,在有些依赖执行顺序的流程中非常有有,如引擎内某些模块会检测启动参数,如果不想要通过手动指定而在插件代码中实现,就可以创建出一个先于它启动的模块,把启动参数追加到FCommandLine中,实现无需手动操作的流程自动化。

当然,对于一些特殊的Module,如上面HotChunker模块的TypeProgram的,它表示只有在Target为Program的时,才会参与编译与启动,并且可以通过ProgramAllowList来指定在哪些Program中才有效。

同样的,引擎中LoadingPhase的所有支持值(4.27+):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace ELoadingPhase
{
enum Type
{
/** As soon as possible - in other words, uplugin files are loadable from a pak file (as well as right after PlatformFile is set up in case pak files aren't used) Used for plugins needed to read files (compression formats, etc) */
EarliestPossible,
/** Loaded before the engine is fully initialized, immediately after the config system has been initialized. Necessary only for very low-level hooks */
PostConfigInit,
/** The first screen to be rendered after system splash screen */
PostSplashScreen,
/** Loaded before coreUObject for setting up manual loading screens, used for our chunk patching system */
PreEarlyLoadingScreen,
/** Loaded before the engine is fully initialized for modules that need to hook into the loading screen before it triggers */
PreLoadingScreen,
/** Right before the default phase */
PreDefault,
/** Loaded at the default loading point during startup (during engine init, after game modules are loaded.) */
Default,
/** Right after the default phase */
PostDefault,
/** After the engine has been initialized */
PostEngineInit,
/** Do not automatically load this module */
None,
// NOTE: If you add a new value, make sure to update the ToString() method below!
Max
};
}

以以下模块的描述为例:

1
2
3
4
5
6
7
8
9
{
"Name": "HotChunker",
"Type": "Program",
"LoadingPhase": "PostConfigInit",
"ProgramAllowList": [ "UnrealPak" ],
"WhitelistPlatforms": [
"Win64"
]
}

该模块表达的意思是:HotChunker是一个Program类型的模块,并且启动时机是在配置文件加载之后(PostConfigInit),只允许在UnrealPak这个Program中使用,并且只在Win64的平台中参与编译。

通过对uplugin文件的内容分析,可以根据开发需要描述插件中的所有Module,指导UBT找到对应的模块进行编译以及在引擎启动时对它们进行加载。

注意:模块在平台中的白黑名单描述(WhitelistPlatforms),如果一个模块A被标记了平台白名单,全平台模块B引用了A,A会被带进来参与全平台的编译,这跟模块A自身的定义没有关系。是引用者出现的问题。

Plugin的目录结构

通常一个插件会包含以下几个目录和文件:

  1. Content:可选,插件的uasset资源,类似于游戏工程的Content目录。需要uplugin中的CanContainContent=true
  2. Resources:插件依赖的其他资源,如插件内的图标Icon等等
  3. Config:可选,插件的配置文件,类似于工程的Config目录,用于存储一些ini的配置项
  4. Source:插件中的代码目录
  5. *.uplugin:当前插件的uplugin描述

需要重点关注的是Source目录,它是实现代码插件的关键。通常Source下会创建对应Module的目录,用于存储不同Module的代码,让不同的Module的文件组织相互隔离。

如uplugin中定义了两个Module:

1
2
3
4
5
6
7
8
9
10
11
12
"Modules": [
{
"Name": "ResScanner",
"Type": "Developer",
"LoadingPhase": "Default"
},
{
"Name": "ResScannerEditor",
"Type": "Editor",
"LoadingPhase": "Default"
}
]

就在Source目录下创建两个对应名字的目录:

以及分别创建它们的build.cs和实际的C++代码文件。

编译环境

UE的编译环境是有UBT决定的,UBT会分析参与编译工程的target.cs作为目标的基础编译环境。
target.cs中控制的是整个工程,它会作用到工程中所有会参与编译的Module。

*.Target.cs 与 *.Build.cs 是 Unreal 构建系统的实际控制者,UBT 通过扫描这两个文件来确定整个编译环境,它们也是本篇文章研究的重点。
它们的职责各不相同:

  • *.Target.cs 控制的是生成的可执行程序的外部编译环境,就是所谓的 Target。比如,生成的是什么 Type(Game/Client/Server/Editor/Program),开不开启 RTTI (bForceEnableRTTI),CRT 使用什么方式链接 (bUseStaticCRT) 等等。
  • *.Build.cs 控制的是 Module 编译过程,由它来控制所属 Module 的对其他 Module 的依赖、文件包含、链接、宏定义等等相关的操作,*.Build.cs 告诉 UE 的构建系统,它是一个 Module,并且编译的时候要做哪些事情。

以一言以蔽之:与外部编译环境相关的都归 *.target.cs 管,与 Module 自身相关的都归 *.build.cs 管。

我在文章UE Build System: Target and Module中有更详细的描述。

Module的C++定义

C++的插件,通常都包含一个继承自IModuleInterface的类,用于注册到引擎中,当启动该模块时,作为模块的执行入口,并且在引擎关闭时,执行模块卸载,可以清理资源。

定义如下:

NewCreateModule.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma once  

#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"

class FNewCreateModule : public IModuleInterface
{
public:

/** IModuleInterface implementation */
virtual void StartupModule() override;
virtual void ShutdownModule() override;

};

以及定义:

NewCreateModule.cpp
1
2
3
4
5
6
7
8
9
10
11

#include "NewCreateModule.h"

#define LOCTEXT_NAMESPACE "FNewCreateModule"

void FNewCreateModule::StartupModule() {}

void FNewCreateModule::ShutdownModule() {}

#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FNewCreateModule, NewCreate)

最为关键的就是最后的IMPLEMENT_MODULE宏,它是把模块暴露给外部的。

UE编译的可执行程序,有两种链接模式,分别是ModularMonolithic模式。

可以在target.cs中进行控制,不过默认情况下,Editor是Modular的,其他的Target类型(如Game、Program)都是Monolithic的:

1
2
3
4
5
6
7
8
9
10
11
public TargetLinkType LinkType
{
get
{
return (LinkTypePrivate != TargetLinkType.Default) ? LinkTypePrivate : ((Type == global::UnrealBuildTool.TargetType.Editor) ? TargetLinkType.Modular : TargetLinkType.Monolithic);
}
set
{
LinkTypePrivate = value;
}
}

Modular模式

Modular模式,就是所谓的分片模式,每个模块都被编译为独立的可执行文件,优点是可以增量编译变动的模块,而不用编译整个工程。

以Editor下的非Monolithic模式为例(模块编译为DLL),它的宏定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define IMPLEMENT_MODULE( ModuleImplClass, ModuleName ) \
\
/**/ \
/* InitializeModule function, called by module manager after this module's DLL has been loaded */ \
/**/ \
/* @return Returns an instance of this module */ \
/**/ \
extern "C" DLLEXPORT IModuleInterface* InitializeModule() \
{ \
return new ModuleImplClass(); \
} \
/* Forced reference to this function is added by the linker to check that each module uses IMPLEMENT_MODULE */ \
extern "C" void IMPLEMENT_MODULE_##ModuleName() { } \
PER_MODULE_BOILERPLATE \
PER_MODULE_BOILERPLATE_ANYLINK(ModuleImplClass, ModuleName)

把宏展开之后为:

1
2
3
4
5
6
// for not-monolithic
extern "C" DLLEXPORT IModuleInterface* InitializeModule()
{
return new FNewCreateModule();
}
extern "C" void IMPLEMENT_MODULE_NewCreate() { }

查看DLL文件的导出表就能看到InitializeModule函数,以及其他标记了导出的符号(都是经过name-mangling后的):

Monolithic模式

Monolithic模式,就是工程中所有的C++代码,都被静态链接编译在同一个可执行程序之内。(添加的动态链接库不算,额外的静态链接库的也会编译到可执行程序之内。

UE中,默认打包时就是Monolithic的模式,引擎和工程的代码都会编译到一个文件,如:

  • WindowsNoEditor:WindowsNoEditor/GameName/Binaries/Win64/GameName.exe
  • Android:lib/arm64-v8a/libUE4.so
  • IOS:Payload/GameName.app/GameName

这种方式,优点就是没有额外的PE文件查找和加载开销,所有的操作都是在当前的进程空间内执行。

模块的定义也对应有些区别,在Not-Monolithic模式下,为了编译为独立的可执行程序,会创建标记了DLLEXPORTInitializeModule函数,但Monolithic模式不需要,它使用的是静态符号:

1
2
3
4
5
6
7
// If we're linking monolithically we assume all modules are linked in with the main binary.  
#define IMPLEMENT_MODULE( ModuleImplClass, ModuleName ) \
/** Global registrant object for this module when linked statically */ \
static FStaticallyLinkedModuleRegistrant< ModuleImplClass > ModuleRegistrant##ModuleName( TEXT(#ModuleName) ); \
/* Forced reference to this function is added by the linker to check that each module uses IMPLEMENT_MODULE */ \
extern "C" void IMPLEMENT_MODULE_##ModuleName() { } \
PER_MODULE_BOILERPLATE_ANYLINK(ModuleImplClass, ModuleName)

宏展开之后为:

1
2
static FStaticallyLinkedModuleRegistrant< FNewCreateModule > ModuleRegistrantNewCreateModule( TEXT("NewCreateModule") );
extern "C" void IMPLEMENT_MODULE_NewCreate() { }

实际上是定义了一个static的对象,它其实利用到了C++里的一个特性:具有static storage dutation的对象的初始化会在main函数的第一个语句之前执行!

[ISO/IEC 14882:2014(E)] It is implementation-defined whether the dynamic initialization of a non-local variable with static storageduration is done before the first statement of main.

所以,当引擎启动时,所有的模块,都会构造出一个这样的static对象,并进行初始化。

FStaticallyLinkedModuleRegistrant的构造函数中,创建出注册的模块类单例,并将其注册到ModuleManager中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Utility class for registering modules that are statically linked.
*/
template< class ModuleClass >
class FStaticallyLinkedModuleRegistrant
{
public:
FStaticallyLinkedModuleRegistrant( FLazyName InModuleName )
{
// Create a delegate to our InitializeModule method
FModuleManager::FInitializeStaticallyLinkedModule InitializerDelegate = FModuleManager::FInitializeStaticallyLinkedModule::CreateRaw(
this, &FStaticallyLinkedModuleRegistrant<ModuleClass>::InitializeModule );

// Register this module
FModuleManager::Get().RegisterStaticallyLinkedModule(
InModuleName, // Module name
InitializerDelegate ); // Initializer delegate
}

IModuleInterface* InitializeModule( )
{
return new ModuleClass();
}
};

用于引擎后续根据uplugin中的模块的启动时机信息进行调用。

这样,UE就在接口层面统一了Modular和Monolithic模式。

结语

本篇文章,介绍了UE中插件的基本信息,以及如何描述一个插件(uplugin)、并对Plugin的目录结构、Module定义做了介绍。

之前我在博客中写的涉及C++编译模型和UE构建系统的相关文章:

它们是在UE中进行工具开发的基础概念,了解并最大限度利用UE自身的实现机制,尽可能用最少的代码实现需要的功能。

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:UE插件与工具开发:基础概念
文章作者:查利鹏
发布时间:2023年01月29日 12时12分
本文字数:本文一共有5.2k字
原始链接:https://imzlp.com/posts/75405/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!