在使用UnrealEngine进行项目开发的过程中,经常会开发和接入各种类型的插件来对引擎进行扩展,从而实现不同的需求。对于开发者而言,对运行机制的理解优于工具的使用。所以对于插件的接入和开发,有必要了解其原理。
之前我也开发了一些UE的工具和插件,我希望写一个相关的系列文章,把UE插件与工具开发相关具有共性的技术内容做一些总结,并分享一些我开发插件时的一些思考和功能脚手架。尽可能用最少的代码、最小的侵入式和最佳的实现策略,来实现需要的功能。
什么是UE中的插件?
UE中的插件是通过uplugin
进行组织的一些模块(Module)集合。可以在引擎中进行方便地启用,能够基于引擎中的已有功能进行开发,也可以包含其他插件中的模块进行扩展。
一般情况下,从功能上进行区分,最常用的模块类型可以分为以下几类:
- Runtime,引擎的运行时,在Editor和打包后都会运行;
- Developer,在非Shipping阶段都会参与编译和运行,用于一些开发阶段的功能,在Shipping时会自动剔除,避免测试功能进入发行包;
- Editor,在Target为Editor的目标程序加载的类型,如启动引擎的编辑器、Commandlet等,都会启动Editor类型的模块。通常情况下,Editor类型的模块用于扩展编辑器功能,如创建独立的视口、给某些资源添加功能按钮、以及配置面板等等。
单个插件可以包含多个不同类型的模块,如同时包含Runtime/Developer/Editor等类型,只需要在uplugin
中描述即可。
在引擎的组织结构中,引擎内的插件位于Engine/Plugins
目录下:
项目的工程则位于PROJECT_DIR/Plugins
下:
引擎和项目中的插件有一定的依赖层级关系,需要控制好依赖层级,避免出现依赖混乱。
- 引擎中的Module应该只依赖引擎内置的Module,不应该包含其他插件中的Module。
- 引擎插件中的Module可以依赖引擎内置的Module和其他引擎内置插件中的Module,不应包含工程的Module
- 工程插件的Module可以包含
引擎内置Module
/引擎插件Module
/其他工程插件的Module
,不应包含工程里定义的Module - 工程中的Module则可以包含以上所有Module的集合。
依赖关系如下图所示:
插件必须依托于某个工程启动,工程不单指游戏工程,而是指包含target.cs定义的代码单元。一个工程既可以包含插件,也可以包含自己的模块。工程的Target配置会影响插件中的Module加载。
以UE创建的默认工程为例:
)
在Source目录下,创建出了两个target*.cs
文件,用于区分打包的Runtime和Editor,当然如果DS工程也会有对应的target.cs,这里对每个target.cs的定义都统称为工程编译目标。
它们的区别和文件命名无关,和文件内容相关。对比一下两者区别:
通过TargetType
指定当前Target的类型。
TargetType
是在UBT中定义的枚举类型,可选值为:
1 | namespace UnrealBuildTool |
表示了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 | public LearnPlugin(ReadOnlyTargetRules Target) : base(Target) |
如何描述一个插件?
uplugin
UE中通过uplugin
文件描述插件,它是一种基于json语法表示一个插件的组织结构和关键信息的配置。
- 插件的命名、作者等基础信息
- 插件模块的类型
- 模块的启动时机
- 对其他插件的依赖
- 平台黑白名单
以HotChunker的uplugin为例:
1 | { |
通过该信息,能清晰地知道插件中的模块数量、类型和启动时机。
- 在编译时,UBT也会读取插件的Type,决定模块是否参与编译。
- 在引擎启动时,会根据模块定义的信息进行启动。如LoadingPhase配置,在不同的启动阶段加载插件。
Modules
uplugin中的Modules
元素是一个数组,用于描述当前插件中的模块,每个Module的描述有以下基础信息:
- Module名,也就是Build.cs里定义的模块名
- Type,模块的类型,Runtime/Developer/Editor/Program等,在哪些Target时参与编译和加载。
- Loadingphase,模块的启动时机,引擎中的模块都是有启动顺序的,通过调整该配置,可以控制插件模块的加载时机,在有些依赖执行顺序的流程中非常有有,如引擎内某些模块会检测启动参数,如果不想要通过手动指定而在插件代码中实现,就可以创建出一个先于它启动的模块,把启动参数追加到FCommandLine中,实现无需手动操作的流程自动化。
当然,对于一些特殊的Module,如上面HotChunker模块的Type
是Program
的,它表示只有在Target为Program的时,才会参与编译与启动,并且可以通过ProgramAllowList
来指定在哪些Program中才有效。
同样的,引擎中LoadingPhase的所有支持值(4.27+):
1 | namespace ELoadingPhase |
以以下模块的描述为例:
1 | { |
该模块表达的意思是:HotChunker是一个Program类型的模块,并且启动时机是在配置文件加载之后(PostConfigInit),只允许在UnrealPak
这个Program中使用,并且只在Win64的平台中参与编译。
通过对uplugin文件的内容分析,可以根据开发需要描述插件中的所有Module,指导UBT找到对应的模块进行编译以及在引擎启动时对它们进行加载。
注意:模块在平台中的白黑名单描述(
WhitelistPlatforms
),如果一个模块A被标记了平台白名单,全平台模块B引用了A,A会被带进来参与全平台的编译,这跟模块A自身的定义没有关系。是引用者出现的问题。
Plugin的目录结构
通常一个插件会包含以下几个目录和文件:
- Content:可选,插件的uasset资源,类似于游戏工程的Content目录。需要
uplugin
中的CanContainContent=true
- Resources:插件依赖的其他资源,如插件内的图标Icon等等
- Config:可选,插件的配置文件,类似于工程的Config目录,用于存储一些ini的配置项
- Source:插件中的代码目录
- *.uplugin:当前插件的uplugin描述
需要重点关注的是Source
目录,它是实现代码插件的关键。通常Source下会创建对应Module的目录,用于存储不同Module的代码,让不同的Module的文件组织相互隔离。
如uplugin中定义了两个Module:
1 | "Modules": [ |
就在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
的类,用于注册到引擎中,当启动该模块时,作为模块的执行入口,并且在引擎关闭时,执行模块卸载,可以清理资源。
定义如下:
1 |
|
以及定义:
1 |
|
最为关键的就是最后的IMPLEMENT_MODULE
宏,它是把模块暴露给外部的。
UE编译的可执行程序,有两种链接模式,分别是Modular
、Monolithic
模式。
可以在target.cs
中进行控制,不过默认情况下,Editor是Modular
的,其他的Target类型(如Game、Program)都是Monolithic
的:
1 | public TargetLinkType LinkType |
Modular模式
Modular模式,就是所谓的分片模式,每个模块都被编译为独立的可执行文件,优点是可以增量编译变动的模块,而不用编译整个工程。
以Editor下的非Monolithic模式为例(模块编译为DLL),它的宏定义为:
1 |
把宏展开之后为:
1 | // for not-monolithic |
查看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
模式下,为了编译为独立的可执行程序,会创建标记了DLLEXPORT
的InitializeModule
函数,但Monolithic模式不需要,它使用的是静态符号:
1 | // If we're linking monolithically we assume all modules are linked in with the main binary. |
宏展开之后为:
1 | static FStaticallyLinkedModuleRegistrant< FNewCreateModule > ModuleRegistrantNewCreateModule( TEXT("NewCreateModule") ); |
实际上是定义了一个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 | /** |
用于引擎后续根据uplugin
中的模块的启动时机信息进行调用。
这样,UE就在接口层面统一了Modular和Monolithic模式。
结语
本篇文章,介绍了UE中插件的基本信息,以及如何描述一个插件(uplugin)、并对Plugin的目录结构、Module定义做了介绍。
之前我在博客中写的涉及C++编译模型和UE构建系统的相关文章:
- UE Build System: Target and Module
- UE Modules:Find the DLL and load it
- UE Modules:Load and Startup
- Build flow of the Unreal Engine4 project
- Macro defined by UBT in UE4
- 为什么需要 extern “C”?
它们是在UE中进行工具开发的基础概念,了解并最大限度利用UE自身的实现机制,尽可能用最少的代码实现需要的功能。