虚幻引擎编译系统总结

虚幻引擎编译系统总结

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!

一、前言

一直以来很多人都在说,虚幻引擎的学习难度要比Unity大很多,其中一个原因是C++本身很难,另外一点就是由于虚幻写了自己的编译系统,并且为了实现反射对C++代码进行封装,因此就算对C++基础掌握的不错,也一样很难理解其中代码的意思。本文将介绍虚幻引擎对编译和反射做了哪些工作,帮助刚接触虚幻引擎的开发者理解并快速上手开发,做一篇避坑指南,本文涉及到项目开发过程中,如何避免头文件、宏等奇怪的报错,解决常见编译问题,理解虚幻引擎模块化管理代码方式,理解引擎编译和启动过程,创建插件,引用第三方库,并参考引擎代码设计,获取快速开发技巧等等。全文大概2万字,希望能耐心看完。

二、介绍

当我们创建一个普通的C++空项目时,一般的步骤是配置平台、版本后,添加代码,创建main函数入口,右键项目点击Build(生成)或者重新生成,或者直接点击运行,然后就可以测试自己的代码和功能了。

一般情况下,是不需要点重新生成(Rebuild)的,尤其是在使用UE4时,这项一定要慎用,因为要等很久很久才能编译完。但除非你遇到了奇怪的编译错误,已经确保我之后说的那些配置正确之后还有问题,再点重新生成试一下能否成功,这里附一张图说明:

UE4运行项目也是如此,但不同之处在于点击“Build”的过程。首先先分析下UE4项目文件结构,不然很难理解。

下面展示的是UE4项目带C++的样子(不带C++的项目这里不再赘述):文件夹数量可能有多有少,和编译目标、插件有关,都无关紧要。

当右键项目点击Generate Visual Studio Project选项时(如果没有该选项,检查下Epic Games Launcher是不是需要更新),就会生成Visual Studio项目。Visual Studio项目生成之后会出现.vs、Binaries文件夹,上面说关于Rebuild慎用,这里说一个的替换方式:删除Binaries、Intermediate和.vs文件夹,并重新点Generate Visual Studio Project,打开VS生成项目。这样就避免Rebuild可能造成重新编译引擎的问题了。

另外,Config文件夹下是引擎和项目的配置文件,一定要小心,不要删除。Content文件夹是游戏内容资源的目录;Plugins是项目依赖插件目录;Saved保存项目的一些缓存数据,包括性能调试、命令行生成的文件、保存的游戏数据和打包Cook数据等,可以删除;Source是项目C++代码目录。

打开VS查看项目结构,分别是:

Engine文件夹下包含引擎源码;Game下包含项目代码,包括插件;Programs文件夹下有两个重要的项目:UnrealBuildTool(编译工具)和UnrealHeaderTool(头文件解析工具)即UBT和UHT。

虚幻引擎的代码量非常恐怖,因此需要更专业的方式管理,虚幻引擎采用模块化的方式管理代码,每个模块之前相互引用依赖,通过引用的方式递归加载对应的模块,而管理这些模块的工具就是UBT,UHT用于头文件生成解析。

使用UBT管理模块的另一个原因是为了方便跨平台,这样我们不需要为每个平台都做对应的配置,不方便且容易出问题,对开发者不友好。有了UBT,只需要在对应的CS文件里配置一次后就可以应用到多个平台了。跨平台的工作由UBT来替你完成。

接下来详细介绍。

使用工具
以下关于代码的分析等使用的工具环境是:

  • Windows10
  • UE 4.26
  • Visual Studio 2019

三、UBT(UnrealBuildTool)

到目前为止大概了解了UBT是虚幻引擎管理各个模块的工具,但它不会编译代码,只是负责收集模块之间的信息然后根据平台和编译目标并告诉编译器进行编译。UBT源码可以在解决方案的Programs\UnrealBuildTool\下找到。

UBT采样NMake Build System,我们可以在项目属性的NMake处看到相关设置,这里显示的就是当我们右键点击Build和Rebuild,Clear事件会执行的内容,如下图:

我们顺着它指示的路径找,然后就会找到Build.bat文件,打开后查看内容:

@echo off
setlocal enabledelayedexpansion

REM The %~dp0 specifier resolves to the path to the directory where this .bat is located in.
REM We use this so that regardless of where the .bat file was executed from, we can change to
REM directory relative to where we know the .bat is stored.
pushd "%~dp0\..\..\Source"

REM %1 is the game name
REM %2 is the platform name
REM %3 is the configuration name

IF EXIST ..\..\Engine\Binaries\DotNET\UnrealBuildTool.exe (
        ..\..\Engine\Binaries\DotNET\UnrealBuildTool.exe %*
        popd

        REM Ignore exit codes of 2 ("ECompilationResult.UpToDate") from UBT; it's not a failure.
        if "!ERRORLEVEL!"=="2" (
            EXIT /B 0
        )

        EXIT /B !ERRORLEVEL!
) ELSE (
    ECHO UnrealBuildTool.exe not found in ..\..\Engine\Binaries\DotNET\UnrealBuildTool.exe 
    popd
    EXIT /B 999
)

发现本质就是运行UnrealBuildTool.exe,换句话说就是给UnrealBuildTool传递配置参数并运行UnrealBuildTool.exe。

参数来自上面的选项中,包括项目名字、编译目标、32位还是64位等信息。然后我们从UnrealBuildTool项目代码中找到入口函数,如下:

可以看到会接受对应平台等信息并进行解析,之后的内容就是读取分析Target.cs和Build.cs文件,然后调用编译工具编译项目,至于细节也没必要深究,对UBT了解到这一步感觉就足够了。

模块配置
提了好几次模块,但还没有具体介绍,因为模块内容量很大,我会放在后面详细介绍,不过这里想大概介绍下每个模块下的Build.cs和项目中的Target.cs文件。

看下模块的基础结构:

每个模块必须存在的三个文件:

  • 模块名字Build.cs文件,用于为UBT提供模块以来信息
  • 模块核心类,继承自IModuleInterface,并实现两个核心方法:StartupModule和ShutdownModule,会在引擎启动加载模块的时候执行,用于初始化模块信息,一般不需要更改

Target.cs只在项目中存在,它类似于项目设置,配置目标和扩展依赖模块,后面再解释。

Generate Visual Studio Project

Generate Visual Studio Project选项作用非常大,当我们打开VS项目后,我们会发现本地磁盘文件中的Source和VS显示的内容是没有保持一致的,因为虚幻引擎编译会忽略SLN文件,SLN存在的意义只是方便打开编写代码,不会查看Source下的文件结构,如果在VS项目中删除某个类,它会依然存在磁盘本地文件夹下请确保同时删除本地文件,或者当你更改或移动某个类文件路径,本地文件实际没有发生变化,而如果你这时不通过VS移动,而是直接移动本地文件夹下的文件,在VS中就会提示找不到该文件引用,学习虚幻引擎的新人一定会受此问题困扰,我当初也是,后来知道重新Generate Project Files就好了~第一个避坑指南说完了。

一般只要你修改 [ModuleName].Build.cs 文件,或移动文件,就需要为IDE生成解决方案文件,有时候也不用,如果出问题了就点一下,下面这三种方式一样效果:

  1. 运行GenerateProjectFiles.bat。
  2. 右键点击项目的.uproject文件,然后点击Generate Project Files
  3. 在虚幻编辑器中,点击文件(File)>刷新(Refresh)Visual Studio项目(Project)

另外说一下Generate Visual Studio Project的本质也是执行UnrealBuildTool.exe的带参命令,所以当你在Visual Studio中点Build的同时点Generate Visual Studio Project,就会提示你正在运行中,当然这两个命令的参数不同。

总结UBT工作流程
接下来说一下虚幻项目点击Build过程的工作:

  1. UBT读取每个模块的Build.cs文件获取各模块之前的依赖关系(如果代码没有更改,则会跳过该模块)
  2. UBT调用UHT(头文件解析)根据反射属性标签(UCLASS,UFUNCTION,UPROPERTY等)生成generated.h和gen.cpp文件,用于暴露给蓝图该类的信息
  3. UBT通过依赖关系调用MSBuild编译C++代码

UBT配置代码的不方便之处
由于UBT是由C#代码编写的,在C++项目中没有智能提示,里面的代码无法转至定义,虽然需要编写的代码不多,但依然很不方便,这对新手来说更加不友好,有办法解决提示问题吗?有。放到后面说,这里不是卖关子,只是要介绍的插件不仅仅可以用来提示UBT开发。

四、UHT(UnrealHeaderTool)

文本解析工具UnrealHeaderTool存在的意义就是为虚幻反射系统服务,蓝图的实现原理。(反射系统简单来说就是运行时获取类的信息,包括类的属性、方法、属性类型、名字、访问权限、方法名字和返回值等等,获取类的信息可以做到很多事情,包括通过名字调用类的方法等。)

根据反射属性标签(UCLASS,UFUNCTION,UPROPERTY等)生成generated.h和gen.cpp文件,用于暴露给蓝图该类的信息,凡是继承自UObject的类都会生成generated.h和gen.cpp文件。

UBT的主要工作就是利用开发者手动标记的属性、方法、类等宏来识别类的信息,然后将类信息封装到gen.cpp的代码中,当然还包括对构造析构函数等内容的修改,使用虚幻本身的垃圾回收系统进行管理

看下它是怎么获取类的信息的:

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h"


UCLASS()
class TEST_API AMyActor : public AActor
{
    GENERATED_BODY()

public:    
    // Sets default values for this actor's properties
    AMyActor();
    ~AMyActor();
    int a;
protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public:    
    // Called every frame
    virtual void Tick(float DeltaTime) override;
    UFUNCTION(BlueprintCallable)
        static void StaticFun();

    UFUNCTION(BlueprintCallable)
        void SetMesh(UStaticMesh* m);
    UFUNCTION(BlueprintCallable)
        UStaticMesh * GetMesh();
    UPROPERTY(EditAnywhere,BlueprintReadWrite)
        TSoftObjectPtr<AActor> ptr;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
        UStaticMesh *mesh;
};

可以看到最基础的Actor类的属性、方法、类等内容被一些特殊宏标注,包括:UCLASS()、UPROPERTY()、UFUNCTION()等,当然不止这些,还有很多其他不常用的。同时还可以看到有点属性和方法没有被宏标记,他们主要区别如下:

  1. 上面提到过这些宏的作用是为了服务反射系统,将类的信息暴露给反射系统,也是蓝图能访问的基础条件,如果不加宏就无法被反射系统识别,更不用说在蓝图中调用了。当然如果需要蓝图对属性能进行更改等操作,还需要在宏括号内加标签进一步修饰。
  2. 当类属性有宏修饰时,虚幻的垃圾回收系统可以对该属性进行管理,没有被UPROPERTY宏修饰或在AddReferencedObjects函数被没添加引用的UObject*成员变量无法被虚幻引擎识别,垃圾回收系统不会管理,因此使用指针时要注意。

具体有:

每一项都有可选参数,主要是告诉蓝图关于这个类/函数/结构体等内容信息,不同参数在蓝图中有不同表现。

下面有宏体内类型修饰符都有哪些的介绍,分别对应UCLASS()、UINTERFACE()、UFUNCTION()、UPROPERTY()和USTRUCT()。里面具体参数项非常多,但实际上大部分都用不到,所以这里我就挑常用的介绍下:

对于UCLASS
当被标记为:

运行后的效果就是不可创建蓝图子类:

改为:Blueprintable后,就可以创建了,也是继承Actor的默认的选项:

如果不写默认是NotBlueprintable:

但Actor默认是Blueprintable,原因在Actor的定义处:

Actor这个类标注了Blueprintable,因此子类也就继承了这一选项,所以当一个继承自Actor的子类即便不写也是Blueprintable的。换句话就是,有些属性可以继承,具体有哪些可以在ObjectMacros.h中查看

其他常用的:
BlueprintType:可以在蓝图中创建这个类变量
Const:该类所有函数和属性应该是Const的,且Const标签可以继承
Abstract:该类是抽象类

注意:这里应该保持蓝图和C++代码本身的一致性,即,如果你在UCLASS中加了Abstract,应该也要将这个类写为抽象类,不然会出各种问题,包括下面的UFUNCTION也一样,如果是BlueprintPure修饰,就应加Const修饰函数,下面会有例子。

对于UFUNCTION
常用的是UFUNCTION(BlueprintCallable),没有BlueprintCallable,蓝图是不能调用的BlueprintPure,这个宏的意思是纯函数,但不可以理解为和C++中的纯虚函数有关系,事实上,这两个没有任何关系,当初我就是陷入这个循环无法自拔。

首先,BlueprintPure标记的函数必须有返回值,因为没有输入输出节点,看下图。其次BlueprintPure的作用有些类似于用Const修饰的函数:即不可以更改类的成员变量值,但是它的限制并不像Const那样,你改了值就报错,但是,请不要在标记为BlueprintPure的函数中更改其类的变量值,一定不要,看一下下面的例子:

UFUNCTION(BlueprintCallable)
void constBlueprintPure() const;

UFUNCTION(BlueprintCallable)
int constBlueprintPureWithValue() const;

UFUNCTION(BlueprintPure)
int blueprintPure();
int AMyActor1::blueprintPure()
{
return 0;
}
void AMyActor1::constBlueprintPure()const
{

}

int AMyActor1::constBlueprintPureWithValue()const
{
return 0;
}

对应在蓝图中的样子:和普通函数有些区别,是绿色的,没有执行输入和输出节点,至于返回值为void的就有了,不然它就没办法在蓝图里调用。

这个就是函数中Pure的真面目了:被Const标记的函数,因此以后再用就不要在标记Pure的函数中更改类成员变量的值了。

对于UPROPERTY
每一项节选内容介绍:

  • Const:const常量
  • VisibleAnywhere:属性面板可见但不可编辑
  • BlueprintReadOnly:蓝图中只读
  • BlueprintReadWrite:可读可写
  • Category:分组名字
  • EditDefaultsOnly:只可在属性面板编辑
    ......

每一项的属性都很多,比如说属性(UPRPOPERTY)能被EditAnywhere和VisibleAnywhere等修饰,但是这两个只能同时存在一个,原因是这两个属性属于相同的枚举类型,我们可以转到定义处查看,确保不出现类似错误,另外就是多用就熟悉了。

详细的参考下面的链接:

对于meta
一般用于给蓝图节点改名字,设置默认值,限制范围等,例如:

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "UI", meta = (ClampMin = "1", ClampMax = "100"))
int32 RowCount;

GENERATED_BODY()
你可能还会看到GENERATED_USTRUCT_BODY或者GENERATED_UCLASS_BODY,还有GENERATED_UINTERFACE_BODY和GENERATED_IINTERFACE_BODY。

首先,它们的作用都是封装了引擎给你写好的代码。包括重写构造函数等,他们的区别是什么呢,看一下定义就好了:

会发现以上提到的四个宏最终都指向了GENERATED_BODY_LEGACY和GENERATED_BODY。GENERATED_UCLASS_BODY、GENERATED_UINTERFACE_BODY和GENERATED_IINTERFACE_BODY这三个的本质是GENERATED_BODY_LEGACY,而GENERATED_USTRUCT_BODY的本质是GENERATED_BODY,现在的UCLass和UStruct一般都会用GENERATED_BODY,如果你想用GENERATED_UCLASS_BODY和GENERATED_USTRUCT_BODY也是一样的作用。

这里顺便说下,GENERATED_BODY_LEGACY()比GENERATED_BODY多了ATestActor(const FObjectInitializer& ObjectInitializer);,一个带参数的构造函数

有的构造函数里带FObjectInitializer& ObjectInitializer,这都是虚幻早期版本,现在基本都不用了,当然为了兼容,构造函数这样写也没问题,看一下UObject的定义就知道答案了:

至于这些宏具体干了什么,大钊老师已经在他的反射文章里写的非常清楚了,感兴趣可以看下《《InsideUE4》UObject(八)类型系统注册-CoreUObject模块加载》

还有,一定要注意,GENERATED_BODY必须放在类的开头位置,除了加friend class如:

其他
类前面模块名_API宏的作用:用于暴露这个类给其他模块访问,如果没有,其他模块是无法获取到这个类的信息的,即便在Build.cs中引入了该模块。

五、创建模块

模块是引擎管理代码的基本单位,插件和游戏中最少要包含一个模块,当创建项目时会自动生成游戏主模块。

当在引擎中创建一个插件时也会有一个默认模块,像这样:目前手动创建模块还没什么高效的方式,只能创建对应文件夹和模板文件。

这是一个模块最基本的结构,创建一个新模块只能先创建文件夹,然后加上对应模块名称的文件,Build.cs和ModuleName.h和cpp。

创建完这个模板之后记得右键点击项目的.uproject 文件,然后点击Generate Project Files。上面有说。

编写Build.cs文件
标准Build.cs中的内容:

using UnrealBuildTool;
public class Menu3 : ModuleRules
{
    public Menu3(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
        PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore","GameModule"});
        //PrivateDependencyModuleNames.AddRange(new string[] {  });
        // Uncomment if you are using Slate UI
    PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
    }
}

核心需要更改的便是依赖模块数组PublicDependencyModuleNames或者PrivateDependencyModuleNames。

这里引用大钊老师总结的内容:每个模块都有Public和Private文件夹,如果类写在最外面(不在Public文件夹内)就会被视为Private,如果包含模块还是无法引用某个类,要检查头类文件是否在Public文件夹内:

如果能用Private包含,尽量用Private,以减少模块依赖,减少编译时间。

其他序列:

  • PublicIncludePathModuleNames和PublicDependencyModuleNames

二者都能使本模块可以包含其它模块Public目录下的头文件,从而调用函数。但是PublicIncludePathModuleNames只能调用定义全部在头文件里的函数,这里要区别什么是声明(不带实现)、什么是定义(带实现)。但是如果定义在头文件中,很容易会出现重定义的错误,而且会拖慢编译速度,除非用Static或者Inline修饰。

例:

// Class.h
voidA(){B();// 假设B的定义在.cpp文件中,那么这时A的函数实现由B组成,但B不在头文件中.
}
//则此时使用IncludePathModuleNames时,无法调用函数A。(报错:无法解析的外部符号B)

因此,总结就是不推荐使用PublicIncludePathModuleNames。

  • PublicIncludePaths和PublicDependencyModuleNames

PublicIncludePaths仅仅是包含相应目录,不是引用里面的头文件,如果你的#include里的路径很长,就可以利用这种方式在include的时候不用写对应头文件路径:

   PrivateIncludePaths.AddRange(
            new string[] {
                 "ShooterGame/Private",
                 "ShooterGame/Private/UI",
                 "ShooterGame/Private/UI/Menu",
                 "ShooterGame/Private/UI/Style",
                 "ShooterGame/Private/UI/Widgets",
            }
        );

相当于普通C++项目中附加包含路径:

常用的就是这些,具体参数可访问:
https://docs.unrealengine.com/5.0/zh-CN/module-properties-in-unreal-engine/

DLL和LIB库导入下面单独介绍。模块属性DLL和LIB库导入下面单独介绍。

编写ModuleName.h和cpp文件

//h
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
class FModule1Module : public IModuleInterface
{
public:
/** IModuleInterface implementation */
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};
//cpp
#include "Module1.h"
#define LOCTEXT_NAMESPACE "FModule1Module"
void FModule1Module::StartupModule()
{
// This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module
}
void FModule1Module::ShutdownModule()
{
// This function may be called during shutdown to clean up your module.  For modules that support dynamic reloading,
// we call this function before unloading the module.
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FModule1Module, Module1)

这里基本就是固定写法StartupModule和ShutdownModule两个方法分别是该模块被加载和卸载的时候执行,这里一般不需要修改什么,也可以选择在模块启动时写加载DLL之类的逻辑。至于模块什么时候被加载,是在ulpugin或者uproject中配置的。

这里注意IMPLEMENT_MODULE(FModule1Module, Module1)宏,前面是类名,后面是模块名。

其他注意事项

  • 当需要在本模块中引用另一个模块的类时,配置正确Build.cs之后,还要正确include头文件才能使用

为了正确做到上面两步,我一般选择的方式是去官网API查询,像这样:FDesktopPlatformModule

官网会告诉你引入的模块名字和include的方式,但是,这里我使用官网的方式是无法找到头文件的,而写上路径Developer后面的全部才能正确include这个头文件,像这样,红色的是错误写法,官网的方式,下面的是正确写法:

路径:

这个例子告诉我们:不要过于相信官网的提示,有可能是没有更新或者遗漏。

一般情况下,include内的内容就是模块名字后classes/public/private内的路径名。Classes文件夹是历史遗留产物,代表Public,现在一般都是Public/Private。看上图路径。

  • 另外,尽量不要复制代码,尤其是整体类,太容易出问题了,而且复制代码容易导致很多模块重新编译,巨慢。

引用外部LIB,DLL库

  • 普通Visual Studio项目引用LIB方式

引入LIB库需要头文件和LIB库。

头文件添加到:

LIB加到:

LIB路径(可选:也可以在上面的glfw.lib前面加,下面就不用写了)

  • UE引用方式

引入LIB库,在模块的Build.cs中加入:

PublicIncludePaths.Add(Path.Combine(ThirdPartyPath));//头文件路径
PublicAdditionalLibraries.Add(Path.Combine(ThirdPartyPath, "CaptureScreen.lib"));//LIB库路径

引入DLL库,在Build.cs中加入:

PublicIncludePaths.Add(Path.Combine(ThirdPartyPath));//头文件路径
PublicAdditionalLibraries.Add(Path.Combine(ThirdPartyPath, "CaptureScreen.lib"));//LIB库路径
PublicDelayLoadDLLs.Add("CaptureScreen.dll");//DLL名字

然后在某个地方动态加载DLL,在使用前,可以写到StartupModule函数中:

FString BaseDir = IPluginManager::Get().FindPlugin("ThirdPartyLibrary")->GetBaseDir();
// Add on the relative location of the third party dll and load it
FString LibraryPath;
 #if PLATFORM_WINDOWS
  LibraryPath = FPaths::Combine(*BaseDir, TEXT("Source/ThirdParty/ThirdPartyLibraryLibrary/x64/Release/TestDLL.dll"));
 #elif PLATFORM_MAC
        LibraryPath = FPaths::Combine(*BaseDir, TEXT("Source/ThirdParty/ThirdPartyLibraryLibrary/Mac/Release/libExampleLibrary.dylib"));
 #endif // PLATFORM_WINDOWS
  vois * ExampleLibraryHandle = !LibraryPath.IsEmpty() ? FPlatformProcess::GetDllHandle(*LibraryPath) : nullptr;
        if(ExampleLibraryHandle)
          //load successfully

六、创建插件

创建插件一般通过引擎创建引擎很多。

其实插件就是模块的集合,最少有一个模块,基本结构如下:Module1是自己加进去的:

插件对模块的管理写在uplugin文件中,事例:

{
        "FileVersion": 3,
        "Version": 1,
        "VersionName": "1.0",
        "FriendlyName": "MyPlugin",
        "Description": "",
        "Category": "Other",
        "CreatedBy": "",
        "CreatedByURL": "",
        "DocsURL": "",
        "MarketplaceURL": "",
        "SupportURL": "",
        "CanContainContent": true,
        "IsBetaVersion": false,
        "IsExperimentalVersion": false,
        "Installed": false,
  "Modules": [
    {
      "Name": "MyPlugin",
      "Type": "Runtime",
      "LoadingPhase": "Default"
    },
    {
      "Name": "Module1",
      "Type": "Runtime",
      "LoadingPhase": "Default"
    }
  ]
}

上面大部分信息都是描述插件信息的,关键是Modules部分,里面定义了模块的名字,加载时机等关键信息。

除此之外,还可以加下面这些信息:(但一般不需要)

最后一项AdditionalDependencies尽量在模块的Build.cs中配置,不要在这里写(PublicDependency那些)。

Type,描述的是打包到某个平台时是否要加载这个模块,比如打包游戏就不需要Editor相关的模块代码。

具体有:

namespace EHostType
{
    enum Type
    {
        Runtime,
        RuntimeNoCommandlet,
        RuntimeAndProgram,
        CookedOnly,
        UncookedOnly,
        Developer,
        DeveloperTool,
        Editor,
        EditorNoCommandlet,
        EditorAndProgram,
        Program,
        ServerOnly,
        ClientOnly,
        ClientOnlyNoCommandlet,
        Max,
    }
}

常用的就是:
Runtime(任何编译目标都会加载该模块)
Editor(只在编辑器下加载该模块)
LoadingPhase,描述什么时候加载该模块,这个比较重要,依赖其他模块的模块应该在依赖模块之后加载,不然就会报错找不到对应模块。
https://docs.unrealengine.com/4.27/en-US/API/Runtime/Projects/EHostType__Type/

具体有:

namespace ELoadingPhase
{
    enum Type
    {
        EarliestPossible,
        PostConfigInit,
        PostSplashScreen,
        PreEarlyLoadingScreen,
        PreLoadingScreen,
        PreDefault,
        Default,
        PostDefault,
        PostEngineInit,
        None,
        Max,
    }
}

这个是按时间排序的,默认是Default,顺便Default一般是引擎启动到百分之75左右加载这些模块,就是这个时候:

EarliestPossible是还没看到这个黑的加载界面之前就加载,几乎用不到,想知道其他的什么时候加载打断点测一下就好。

最后是管理插件,是在uproject中实现的,下面介绍。

七、创建多个游戏模块

在游戏项目中创建模块的方式和在插件中无异,模块的基本结构都是一样的,但唯一需要注意的是对应的主模块类里的宏会发生变化:

游戏模块(非主模块使用的宏)是IMPLEMENT_GAME_MODULE(FDefaultGameModuleImpl, GameModule);,而不是插件中的IMPLEMENT_MODULE(FMyPluginModule, MyPlugin)。其他更改就需要注意名字匹配正确。

而主游戏模块用的宏(默认的游戏模块)是IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, Test, "Test" );。

uproject
里面的内容先看下:

{
"FileVersion": 3,
"EngineAssociation": "4.26",
"Category": "",
"Description": "",
  "Modules": [
    {
      "Name": "Test",
      "Type": "Runtime",
      "LoadingPhase": "Default"
    },
    {
      "Name": "GameModule",
      "Type": "Runtime",
      "LoadingPhase": "Default"
    }
  ]
}

上面版本信息不是介绍重点,主要是里面的模块,这里我创建了一个GameModule的游戏模块,里面的属性配置和上面讲的一致,关键是如果这里面不写GameModule模块,游戏引擎中是不会识别到这个模块的(引擎C++文件夹处不会有GameModule模块代码,也因此无法引用该模块内容,即便主模块的Build.cs中引用了GameModule模块),因此,这里一定要加上你写的模块。这里思考一个问题:如果GameModule没有被主模块引用,打包的时候会发生什么。

Target.cs

using UnrealBuildTool;
using System.Collections.Generic;
public class MyProjectTarget :TargetRules
{
    public MyProjectTarget(TargetInfo Target) : base(Target)
    {
        Type = TargetType.Game;
DefaultBuildSettings = BuildSettingsVersion.V2;
        // 此处为其他属性
    }
}

在讲虚幻模块的文章中,很少有讲到Target.cs文件的作用的,项目一般会默认创建两个,一个是Target.cs,另一个是Editor.Target.cs。

刚刚一直说模块的引用方式依赖Build.cs,但是没有任何模块依赖游戏主模块,游戏主模块是怎么加载进内存的呢,这就依赖了Target.cs中的ExtraModuleNames.AddRange( new string[] { } );。默认情况下,里面加的是游戏主模块名字,这也就是为什么游戏主模块能加进引擎的原因了。

回到上面提的那个问题上,如果自己写的模块没有被主模块引用,打包的时候会发生什么。

我测试了几种情况:
uproject加模块GameModule信息,主模块Build.cs加GameModule引用,ExtraModuleNames中不加入GameModule模块,测试打包存在GameModule:

uproject加模块GameModule信息,主模块Build.cs不加GameModule引用,ExtraModuleNames中不加入GameModule模块,测试打包不存在GameModule:

uproject加模块GameModule信息,主模块Build.cs不加GameModule引用,ExtraModuleNames中加入GameModule模块,测试打包存在GameModule。

uproject不加模块GameModule信息,主模块Build.cs不加GameModule引用,ExtraModuleNames中加入GameModule模块,打开引擎直接报错了。

通过以上几种情况对比可知,如果自定义的模块没有被引用,打包的时候是不会加载进去的。uproject只决定模块是否对引擎可见,于打包配置无关。

然后根据不同的编译Target,引擎会执行不同的Target.cs,如果没用实现,会执行Target.cs中的基类内容

他们的不同在于Type = TargetType.Client;

          Type = TargetType.Game; 
        //Type = TargetType.Editor; 
        //Type = TargetType.Client;
        //Type = TargetType.Server; 
        //Type = TargetType.Program;
        //UnrealTargetConfiguration(Unknown, Debug, DebugGame, Development, Shipping, Test)
        //UnrealTargetPlatform(Win32, Win64, Linux, Mac, PS4..)
          DefaultBuildSettings = BuildSettingsVersion.V2;

具体可参考:
https://docs.unrealengine.com/4.27/zh-CN/ProductionPipelines/BuildTools/UnrealBuildTool/TargetFiles/

八、Build.cs,Target.cs,uplugin和uproject

从这些文件总结下UE4的编译:
模块由Build.cs确定相互依赖关系,如果模块在插件中,加载时机和编译目标在uplugin中定义,如果是游戏项目模块,在uproject中定义。

Target.cs是在打包到对应平台会起作用的文件,如果你的模块没有被依赖,就需要在这里加进去,当然如果模块被其他模块依赖了就不需要再加了。

uproject里除了游戏模块,还会控制插件是否激活,在引擎里更改插件是否激活,uproject就会动态生成对应内容,一般不需要手动添加插件信息。

九、开发工具介绍

Visual Studio
最新版2022对C#代码提示非常的智能,可惜虚幻要用C++开发。

VA_X
最常规C++代码提示工具,对虚幻项目提示能力不错,不过仅限于C++部分。

Resharper
由于UBT用C#编写代码,导致在配置模块的时候没有提示,虽然可以在引擎模块中复制相关代码,但是还是很不友好,这里就要出动强大的Resharper插件了,这个插件的强大之处不仅仅在于能提示C++中的各种宏以及普通C++语法提示,还能做到给Build.cs这种配置文件做出提示,有了它,再也不需要犯愁模块配置问题了,但他的强大不仅止于此:

  • 智能提示USF,HLSL文件,方便快速写Shader代码
  • 智能提示头文件是否被使用,如果没用会加灰色(IWWY,include what you use)提示能力简直恐怖
  • 提示所有虚幻引擎的各种宏,UBT的代码提示,快速跳转,补全逻辑等等
  • 快速在VS中创建Actor,UObject模板类,以前都是从引擎里创建,或者复制一个改很多内容,改错了就一堆奇怪的编译错误。有了这个功能,极大提升开发速度

下面展示:
宏错误或者缺失提示:

垃圾回收提示:

快速生成类模板

快速创建模板:

等等,功能太多了!

说到这就足够证明这个插件的强大了,不过它也有问题,就是严重拖慢Visual Studio启动速度,比较耗性能,所以根据大家的需求决定是否使用吧。下载就不说了,需要安装两个插件resharper C++和resharper C#两个。

Rider for Unreal Engine 2021.3
新工具,提示同Resharper,而且轻量级,现在引擎已经支持,但是我还是习惯用Visual Studio,附上网址,有需要自行申请。
Rider for Unreal Engine

十、总结

完成本文耗时还蛮久的,但感觉还是有必要写一下,对初学虚幻引擎的开发者们应该或多或少有所帮助,其实还有很多没有写,包括PCH,还有一些宏介绍,但是我更希望大家获取的是我学习这些东西的方法,关于学习方式主要还是参考官网、论坛,然后如果有能力翻墙就看看国外的教程,比国内的要好很多,当然B站和知乎上也有很多优秀的教程,最后如果哪里有错误或者不足还请大家指点。


这是侑虎科技第1454篇文章,感谢作者雪流星供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)

作者主页:https://www.zhihu.com/people/xueliuxing

再次感谢雪流星的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)