Module是构成Unreal的基本元素,每一个Module封装和实现了一组功能,并且可以供其他的Module使用,整个Unreal Engine就是靠各个Module组合驱动的,连我们创建的游戏项目本身,都是一个单独的Module。
那么UE又是怎么创建和构建这这些Module的呢?这是写这篇文章的主要目的,研究一下Unreal的构建系统以及它们(Target和Module)支持的各种属性。
建议在看这篇文章之前先看一下我之前的这篇文章:Build flow of the Unreal Engine4 project,主要内容是大致过一遍UE的构建流程,本篇文章只是UE构建系统中的一环。
对于UE项目比较熟悉的都知道,当使用UE创建一个C++游戏项目时,会在项目路径下创建Source
文件夹,默认包含了下列文件:
1 | Example\GWorld\Source>tree /a /f |
其中,*.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
管。
插个题外话,在GWorld.h
和GWorld.cpp
中定义的是Module真正的执行逻辑,使用IMPLEMENT_MODULE
定义。UE中所有的Module都是继承自IModuleInterface
,具有以下接口:
1 | class IModuleInterface |
通过IModuleInterface
来驱动Module的启动与关闭,不过一般Game Module
不使用这个控制游戏流程。
这部分的详细内容可以看我之前的文章:UE4 Modules:Load and Startup
Target
每一个基于Unreal的项目,都有一个Tergat.cs
,具有一个继承自TargetRules
的类定义;并且默认需要关联着一个同名(非必要,但建议)的Module
的定义,否则编译时会有Module未定义错误,它的含意时将指定的Module
编译到Target
中:
1 | UnrealBuildTool : error : Could not find definition for module 'GWorld' (referenced via GWorld.Target.cs) |
与Target
关联的Module
的名字可以通过ExtraModuleNames
来指定:
1 | public class GWorldTarget : TargetRules |
上面指定的是GWorld
,UBT解析的时候就会去找GWorld
这个Module的定义,也就是GWorld.build.cs
这个文件中的GWorld
类定义,如果没有就会产生上面的Module未定义错误。
注意,与
Target
关联的Module
不仅仅只是一个指定的名字这么简单,所有代码中使用的XXXX_API
都是与Module
的名字相关的。
如果我进行以下改动:ExtraModuleNames.AddRange( new string[] { "GWorldAAA" } );
,那么需要对项目中所有的源文件进行的改动有:
- 将原有的
GWorld.build.cs
文件改名为GWorldAAA.build.cs
,并将文件内容的所有GWorld
替换为GWorldAAA
; - 将项目内所有头文件的
GWORLD_API
改名为GWORLDAAA_API
,因为XXX_API
的导出符号是依赖于ModuleName
的;
实在是个不小的工作量,所以还是建议将ExtraModuleNames
中指定的名字与Game Module
同名。
通过上面的内容,我们可以知道了Target.cs
是如何与Build.cs
关联的。那么,其实Game
/Server
/Client
/Editor
的Target
可以共用同一个Module
,将他们的ExtraModuleNames
都设置成同一个就可以了(如果你想要针对每个Target类型单独写也可以)。
TargetRules
的代码在UnrealBuildTools/Configuration/ModuleRules(ReadOnlyTargetRules
也定义其中),可以看一下所支持参数的默认值;UE对Target
支持属性的描述文档:Targets。
但是UE的官方文档里面也只是代码里的注释,有些描述看了之后摸不着头脑,后面我会分析一下TargetRule
一些属性的含义,先埋个坑。
Type(TargetType)
TargetRules
中的属性Type
,其类型为TargetType
,定义为TargetRules.cs
中,是指定项目要编译出来的是什么程序。
- Game - A standalone game which requires cooked data to run.
- Client - Same as Game, but does not include any server code. Useful for networked games.
- Server - Same as Game, but does not include any client code. Useful for dedicated servers in networked games.
- Editor - A target which extends the Unreal Editor.
- Program - A standalone utility program built on top of the Unreal Engine.
LinkType(TargetLinkType)
TargetRules
中的LinkType
,其类型为TargetLinkType
,定义在TargetRules.cs
中,是指定项目的链接类型。
TargetLinkType
具有三个枚举值:
1 | /// <summary> |
TargetLinkType.Default
是LinkType
的默认值,在此种状态下,如果当前Target
的Type
为Editor
则使用Modular
类型,链接所有的模块的方式为动态链接库。TargetLinkType.Modular
:以动态链接库的方式链接ModuleTargetLinkType.Monolithic
:将所有的模块链接到单个文件(静态链接)
可以通过修改LinkType
来修改。
1 | /// <summary> |
Name(string)
Target的名字,只读属性,传进来的项目名字。
Platform(UnrealTargetPlatform)
Platform
的类型为UnrealTargetPlatform
,它是一个枚举,定义在UnrealBuildTool\Configuration\UEBuildTarget.cs
。
它记录着当前Target的平台信息,如Win32/Win64等等,目前UE_4.22
的版本支持的平台为:
1 | public enum UnrealTargetPlatform |
我们可以在build.cs
或者target.cs
中通过判断Platform来做不同的事情。
如:
1 | if(Target.Platform != UnrealTargetPlatform.Win32 && Target.Platform != UnrealTargetPlatform.Win64) |
IsInPlatformGroup
这是一个函数bool IsInPlatformGroup(UnrealPlatformGroup Group)
,定义在TargetRules.cs
中,它用来判断当前的Platform
是否输入某一组。
需要传入的参数为UnrealTargetformGroup
枚举类型,它定义在UEBuildTarget.cs
中:
1 | /// <summary> |
Configuration(UnrealTargetConfiguration)
当前编译的配置,类型为UnrealTargetConfiguration
的枚举,定义在UEBuildTarget.cs
中,由VS中的Configuration
构造而来,如:
- Development
- Shipping
- DebugGame
- Debug
- Test
- Unknow
也就是通过这个设置,UBT才在编译环境中添加了下列宏:
1 | public override void SetUpConfigurationEnvironment(ReadOnlyTargetRules Target, CppCompileEnvironment GlobalCompileEnvironment, LinkEnvironment GlobalLinkEnvironment) |
Architecture(string)
所运行的平台的架构信息:x86
/arm
等等。
CppStandard(CppStandardVersion)
用于指定编译项目时所用的C++标准版本(在新版本引擎(4.23)中才有)。CppStandardVersion
:
Latast
Cpp17
- Cpp14
这个选项本质上就是将/std:c++xxx
添加到VS的编译选项中。
1 | void AppendCLArguments_CPP(CppCompileEnvironment CompileEnvironment, List<string> Arguments) |
bUseDebugCRT(bool)
用来控制输出的Runtime Librart
类型是MT
还是MD
;
还用来控制添加_DEBUG
和NODEBUG
宏:
1 | public override void SetUpConfigurationEnvironment(ReadOnlyTargetRules Target, CppCompileEnvironment GlobalCompileEnvironment, LinkEnvironment GlobalLinkEnvironment) |
ProjectDefinitions(List<string>)
为当前项目添加的宏定义,在整个项目中可用。
GlobalDefinitions(List<string>)
添加在整个Target中都可以用的宏定义。
bShouldCompileAsDLL(bool)
将Target编译为DLL,为true
时要求LinkType
为Monolithic
。
1 | /// <summary> |
AdditionalCompilerArguments(String)
传递给编译器的参数。
AdditionalLinkerArguments(String)
传递给连接器的参数。
bUsesSlate(bool)
控制打包时时候把Slate
相关的图片资源打包到pak中。
bUseInlining(bool)
是否开启内联优化,内联的本质是把展开函数,降低函数调用的开销。
但可能会存在一些问题,如引擎中的Engine
模块中的FStreamingLevelsToConsider函数,它并没有导出符号ENGINE_API
。
而在Engine/World.h
中的IsStreamingLevelBeingConsidered
函数,以头文件中定义的形式调用了它:
1 | /** Returns true if StreamingLevel is part of the levels being considered for update */ |
在头文件中定义的函数,是由编译器实现的隐式内联的,如果对World->IsStreamingLevelBeingConsidered
的调用,在其他的模块中被内联,会导致链接错误:
1 | Undefined symbols for architecture x86_64: |
因为内联展开之后,就相当于在调用处直接执行,而恰好FStreamingLevelsToConsider
符号未导出,所以会产生链接错误。
要解决这样的问题,要么修改引擎,导出符号,要么在target.cs中关闭内联:
1 | bOverrideBuildEnvironment = true; |
在UBT的代码里有检测它的值,然后添加实际的编译参数:
1 | // |
Module
与Target
类似,每一个Unreal的Module
,都有一个专属的ModuleName.Build.cs
里面定义着专属的ModuleName
类,它由ModuleRules
继承而来,我们对Module
构建时进行的操作就是通过它来控制。
注意:不管是Game Module还是Plugin Module,只要是项目依赖的Module,编译时它们都会接收到当前使用的
Target
信息。
ModuleRules
的代码在UnrealBuildTools/Configuration/ModuleRules,同样可以看一下支持的属性默认值;UE对Modules
描述的官方文档:Modules,这里也同样只有代码的注释内容,没有实际例子,我就先来分析一些在工程中常见的Build.cs中属性的含义。
*.Build.cs
中可以通过它构造接收的ReadOnlyTargetRules Target
参数来获取*.Target.cs
中的属性信息。
1 | using UnrealBuildTool; |
通过Target
对象,可以在*.build.cs
中控制对不同的平台(Platform),架构(Architecture),以及其他的选项来对Module进行不同的操作(比如定义不同的宏/包含不同的ThridParty/链接不同的Lib等等)。
ModuleDirectory
string ModuleDirectory
:项目的源码路径PROJECT_NAME/Source/PROJECT_NAME
的绝对路径。
EngineDirectory
string EngineDirectory
:引擎目录Engine/
在当前环境的绝对路径。
PublicAdditionalLibraries
添加静态链接库文件(注意与PublicLibraryPaths
的区别),一般是用于第三方库的链接。
1 | PublicAdditionalLibraries.AddRange( |
详细的内容可以看:Linking Static Libraries Using The Build System
同样可以用在DLL的导入库,与
PublicDelayLoadDLLs
和RuntimeDependencies
配合使用。
PublicAdditionalShadowFiles
当执行远程编译的时候,指定当前模块需要复制到远程服务器上的文件,确保能够链接成功。
如远程打包IOS平台时,需要把当前模块依赖的静态链接库添加到里面(如Game模块依赖某个插件中的External
模块)。
RuntimeDependencies
list<RuntimeDependency> RuntimeDependencies
:Module在运行时依赖的文件(.so
/.dll
等),打包时将会拷贝到存储目录。
在打包Windows时会直接把文件拷贝到打包的对应目录下,但是在Android上会把文件放到Apk包的main.obb.webp中。
PublicDelayLoadDLLs
List<string> PublicDelayLoadDLLs
:延迟加载的DLL列表,通常用于第三方库。
1 | // build.cs |
含义是不在程序启动时立即加载DLL的列表,等到首次需要使用他们的符号后再进行加载。这样可以在模块的StartupModule
中自行指定位置并加载他们,从而实现可以不把dll放到exe的目录。
1 | FString AbsPath = FileManager::Get().ConvertToAbsolutePathForExternalAppForRead(*MyLibPath); |
PS:配合
PublicAdditionalLibraries
可以用在使用DLL导入库的第三方库。
- 使用PublicAdditionalLibraries添加lib
- DLL的名字添加至PublicDelayLoadDLLs
- 使用RuntimeDependencies打包时拷贝dll
- 如果拷贝到的目录不是exe路径,需要StartupModule里执行
AddDllDirectory
和PushDllDirectory
把dll的路径添加到里面PublicDelayLoadDLLs只添加xxxx.dll 就可以了,不需要路径。
我使用GoogleInstantPreview
测试使用DLL+导入库并将DLL放在非exe目录的例子:ue4-plugin-GoogleInstanceIns.7z
PublicDefinitions
List<string> PublicDefinitions
:为当前Module添加公开宏定义,等同于传统VS项目在项目设置中添加一个预处理宏。
它被UBT分析之后会在产生一个Definitions.PROJECT_NAME.h
的头文件,里面定了各种宏。
1 | Intermediate\Build\Win64\UE4Editor\Development\ReflectionExample\Definitions.ReflectionExample.h |
PublicSystemIncludePaths
List<string> PublicSystemIncludePaths
:文档介绍是用于添加系统的Include路径,与PublicIncludePaths
的区别是会跳过头文件解析检查(但是经我测试,使用这种方式包含的代码依然会检测下列错误(UE_4.20)):
1 | error : Expected mpack-platform.h to be first header included. |
注意:如果不指定路径,则默认的IncludePath路径是
Engine/Source
。
比如:
1 | PublicSystemIncludePaths.AddRange( |
它表示的路径是:
1 | D:\UnrealEngine\Epic\UE_4.21\Engine\Source\TEST_LIB |
所有可以在*.build.cs
中指定的*IncludePaths
,默认的路径都是Engine/Source
.
PrivateRuntimeLibraryPaths
List<string> PrivateRuntimeLibraryPaths
:运行时库的搜索路径。例如.so
或者.dll
。
PublicRuntimeLibraryPaths
List<string> PublicRuntimeLibraryPaths
:运行时库的搜索路径。例如.so
或者.dll
。
因为动态链接库的查找路径默认只有:
- 系统的PATH路径;
- 可执行程序的当前目录;
如果我们的动态链接库在其他的位置,运行时就会错误,可以通过PublicRuntimeLibraryPaths
或者PrivateRuntimeLibraryPaths
来添加。
PublicLibraryPaths
添加链接库文件的路径,如在源码中使用的:
1 |
可以通过PublicLibraryPaths
来添加依赖的Lib。
DynamicallyLoadedModuleNames
List<string> DynamicallyLoadedModuleNames
:添加需要运行时动态加载的Module,使用FModuleManager::LoadModuleChecked<MODULE_TYPE>(TEXT("MODULE_NAME"))
等函数启动。
1 | // e.g |
PublicDependencyModuleNames
List<string> PublicDependencyModuleNames
:添加对执行Module的源文件依赖,自动添加所依赖Module的Public
和Private
源文件包含。
PrivateDependencyModuleNames
List<string> PrivateDependencyModuleNames
:与PublicDependencyModuleNames
不同的是,意味着所依赖的Module中的源文件只可以在Private
中使用。
假如现在有一个模块A,还有一个模块B,他们中都是UE的Module/Public
和Module/Private
的文件结构。
- 如果B中依赖A,如果使用的是
PrivateDependencyModuleNames
的方式添加的依赖,则A模块的源文件只可以在B的Private
目录下的源文件中使用,在Public
目录下的源文件使用时会报No such file or directory
的错误。 - 如果使用的是
PublicDependencyModuleNames
方式添加的依赖,则A的源文件在B的Public
与Private
中都可用。
除了上述的区别之外,还影响依赖于B模块的模块 ,当一个模块C依赖模块B的时候,只能访问到B模块的PublicDependencyModule中的模块暴露出来的类。
例如,C依赖B,B依赖A;那么,假如C想访问A中的类则有两种方式:
- 在C的依赖中添加上A模块
- 确保B在
PublicDependencyModuleNames
依赖中添加的A模块,这样C就可以间接的访问到A。
经过测试发现,其实对于游戏模块(PROJECT_NAME/Source/PROJECT_NAME.target.cs
)使用而言,所依赖的模块是使用PublicDependencyModuleNames
还是PrivateDependencyModuleNames
包含,没什么区别。
使用Private
方式依赖的Module中的头文件依然可以在游戏模块的Public
中用,这一点与插件等其他模块有所不同(但是这只有在所依赖的模块不是bUsePrecompiled
的基础上的,如果所依赖的模块是bUsePrecompiled
的,则与其他的模块一样,PrivateDependencyModuleNames
依赖的模块不可以在Pulibc
目录下的源文件使用),这个行为比较奇怪:有时候出错有时又不出错。
注意:在游戏项目中使用依赖其他Module时尽量确定性需求地使用
PrivateDependencyModuleNames
或者PublicDependencyModuleNames
,在组合其他的选项时可能会有一些奇怪的行为。
相关的讨论:
- What is the difference between PublicDependencyModuleNames and PrivateDependencyModuleNames
- Explanation of Source Code folder structure?
bPrecompile与bUsePrecompiled
1 | /// <summary> |
这个两个属性需要组合来使用。
考虑下列需求:
如果我们写好的一个模块A希望拿给别人来用,但是又不想把所有代码开放出来,该怎么办?
在传统的C++领域,我应该会说:把代码编译成DLL,然后把头文件和DLL发放给用户就可以啦。
对!其实bPrecompile
和bUsePrecompiled
就是做的类似的事情。
当我们对模块A进行编译之前,在它的*.build.cs
中添加:
1 | public class A : ModuleRules |
然后编译模块A。编译完成之后,将模块A的Source/Private
删除(删除之前请确保你已经备份),然后删除模块目录下的Intermediate
,但是要保留Binaries
目录。
最后,打开模块A的A.build.cs
,将bPrecompile=true;
删掉,然后再添加:
1 | public class A : ModuleRules |
此时我们想要实现的目标都已经完成了:不发布实现代码(Private
),发布预先编译好的二进制,但是这样无法进行静态链接,如果只是暴露给蓝图使用可以,在其他的Module中使用它的符号会有符号未定义错误。
OptimizeCode(CodeOptimization)
这个属性是用来控制当前模块是否要开启优化代码,在我们用VS调试时,有时候会看到“变量已被优化,因而不可用”,这就是因为被优化了。
可以使用它来关闭优化:
1 | // build.cs |
CodeOptimization
支持几种值,默认是Default
,开启优化:
- Never
- Default
- InNonDebugBuilds
- InShippingBuildsOnly
相关的代码:
1 | // UnrealBuildTool/Configutation/UEBuildModuleCPP.cs |
这个函数在UEBuildModuleCPP.cs
的CreateModuleCompileEnvironment
中调用,将结果赋值给了CppCompileEnvironment.bOptimizeCode
,进而又在VCToolChain.cs
中被使用:
1 | UnrealBuildTool\Platform\Windows\VCToolChain.cs |
可以看到,在Debug
的环境下,是默认关闭优化的。在非Debug
时根据CompileEnvironment.bOptimizeCode
的值来决定是否开启优化。
调试效果:
当使用默认时(OptimizeCode = CodeOptimization.Default;
):
当关闭代码优化时(OptimizeCode = CodeOptimization.Never;
):
建议使用OptimizeCode = CodeOptimization.InShippingBuildsOnly;
。
注意:这个选项和普通的C++项目在VS中的
Properties
-Configuration
-C/C++
-Optimization
-Optimization
的设置时一样的。
bEnableUndefinedIdentifierWarnings (bool)
是否启用在预处理代码#if
中使用未定义标识符的警告。
1 |
如果这个宏未定义,在启用bEnableUndefinedIdentifierWarnings
的情况下会产生C4688
错误。
相关的代码时定义在UBT的代码中的:
1 | // Source\Programs\UnrealBuildTool\Platform\Windows\VCToolChain.cs |
bUseRTTI (bool)
UE4默认关闭了RTTI,所以在工程的代码中写了类似typeid
的代码,会产生下列错误:
1 | In file included from C:\UnrealProject_\Source\GWorld\Private\Modules\Flibs\FLibIniConfigHelper.cpp:10: |
解决办法只有两个:去掉rtti
相关的代码,或者在当前Module
的build.cs
中把bUseRTTI
设置为true
。