UE 的 module 是一堆 C++ 类和代码的集合,类似于 DLL,而 UE 本身也是由一堆 module 构成的。将代码拆分为 module 的目的是:

  • 封装和功能分离
  • 方便重用代码
  • 降低编译链接时间,减小编译结果体积
  • 允许控制加载 module 的时机

目录结构

假设需要创建一个名为 FooBar 的 module,那么首先需要在工程的 Source 目录下创建一个与 module 同名的目录(此处为 FooBar),然后在该目录下创建一个 FooBar.Build.cs 文件,大致会有如下的目录结构:

ProjectFolder
└── Source
    └── FooBar  <----------------- module 对应目录
        ├── FooBar.Build.cs  <---- 构建描述文件
        ├── Private  <------------ 私有文件目录
        └── Public  <------------- 公开文件目录

YourModuleName.Build.cs 文件的功能:

  • 声明 module 的构建方式
  • 定义 module 的依赖
  • &mldr;&mldr;

UE 基于 C# 实现了名为 UBT(Unreal Build Tool)的工具完成构建流程。UBT 根据 .Target.cs 文件和 .Build.cs 文件对一个工程进行构建。它不依赖于特定平台或特定 IDE 的构建描述(如 Visual Studio 的 .sln 文件),因为 UE 要支持不同平台的编译。

在修改或移动 .Build.cs 文件后,最好重新生成一下 IDE 的 Solution 文件,以便 IDE 能同步到最新构建信息,有几种方法可以生成 IDE 的 Solution 文件:

  • 执行 GenerateProject.bat 脚本
  • 右键点击 .uproject 文件,然后选择「Generate xxx Project Files」
  • 在 UE 编辑器的菜单中选择 File - Refresh xxx Project

最简单的 .Build.cs 文件:

using UnrealBuildTool;
public class FooBar : ModuleRules {
    public FooBar(ReadOnlyTargetRules Target) : base(Target) {
        PublicDependencyModuleNames.AddRange(new string[] {
            // UE 的 Engine 包含了一些常用的内容,例如 AActor
            "Engine" });
        PrivateDependencyModuleNames.AddRange(new string[] {
            // UE 的 Core module 包含了处理 module 的代码,所以至少要包含它
            "Core" });

这里 PrivateDependencyModuleNames 用于声明只在该 module 内部依赖的 module,而 PublicDependencyModuleNames 则用于声明在该 module 对外暴露的接口中依赖到的 module,这个依赖关系会被依赖该 module 的 module 继承,这个关系和 C++ 类继承中的 private public 关键字的区别类似。例如本 module 对外暴露了一个继承自 AActor 的类型,由于 AActor 被定义在 Engine module 中,因此这里需要将 Engine 添加到 PublicDependencyModuleNames 中。

实现 Module

注意到上面的目录中源码文件被分别放在两个目录下,一个 Public 一个 Private,其中,需要被其他 module include 的头文件放在 Public 目录下,其他文件(包括源码文件和本 module 私有的头文件)都放在 Private 目录下。

ProjectFolder
└── Source
    └── FooBar
        ├── FooBar.Build.cs
        ├── Private
        │   └── FooBarModule.cpp
        └── Public
            └── FooBarModule.h

在一个 Unreal 工程新建时会自动创建一个主游戏 module,这个 module 不会被别的 module 依赖,因此一般也不会区分 Public/Private 目录。

每个 module 至少要包含一个 module 对应的 .h 和 .cpp 文件,用于实现对应 module,这里的文件名可以是任意的,但一般会选用 YourModuleName.h/cpp 或 YourModuleNameModule.h/cpp 这样的形式,例如这里的 FooBarModule.h 和 FooBarModule.cpp。

其中 FooBarModule.h 中大致会有如下的内容:

#include "Modules/ModuleManager.h"
class FFooBarModule : public IModuleInterface {
    // 可以通过 override 生命周期回调触发自定义逻辑,如:
    // virtual void StartupModule() override;
    // virtual void ShutdownModule() override;
    // ...

这里声明了一个 FFooBarModule 类继承了 IModuleInterface 类。前面提到 Core module 包含了处理 module 的代码,所以任何 module 的依赖中至少要包含它,这里 include 的 ModuleManager.h 文件在就是 Core module 中的一部分。

而在 FooBarModule.cpp 中,则需要至少包含一个类似这样的实现:

#include "FooBarModule.h"
IMPLEMENT_MODULE(FFooBarModule, FooBar)

这里 IMPLEMENT_MODULE 宏的第一个参数是对应的类,第二个参数是 module 名。对于游戏 module 和主游戏 module,这里则改为使用 IMPLEMENT_GAME_MODULE IMPLEMENT_PRIMARY_GAME_MODULE

如果其他 module 想使用这个 module 类中的方法,可以使用 FModuleManager 获取对应 module 类对象的引用:

FModuleManager::Get().LoadModuleChecked<FFooBarModule>(TEXT("FooBar")).SomeMethod();

类似于 DLL,module 对外暴露的接口都需要进行额外声明,否则默认情况下并不会暴露出去。例如下面是 NicknamedActor.h 中的代码:

#pragma once
#include "GameFramework/Actor.h"
#include "CoreMinimal.h"
#include "NicknamedActor.generated.h"
UCLASS(Blueprintable)
class ANicknamedActor : public AActor {
    GENERATED_BODY()
  public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FString Nickname;
    UFUNCTION(BlueprintCallable)
    void SayNickname();

它的声明和实现在目录中的位置如下:

ProjectFolder
└── Source
    └── FooBar
        ├── FooBar.Build.cs
        ├── Private
        │   ├── NicknamedActor.cpp
        │   └── FooBarModule.cpp
        └── Public
            ├── NicknamedActor.h
            └── FooBarModule.h

此时,如果其他 module 添加了 FooBar 的依赖,那么它能在代码中访问到 ANicknamedActor 。但仍然无法链接到 ANicknamedActor 中的方法。为了将其中的方法暴露给其他 module,需要手动声明。

如果仅仅是想将类型信息暴露出去,那么可以在 UCLASS 声明中加 MinimalAPI

UCLASS(Blueprintable, MinimalAPI)
class ANicknamedActor : public AActor { /* ... */ };

这样一来,其他 module 可以:将别的类型转换到该类型;Spawn 该类型的对象;继承该类型;使用该类型中的内联函数。

在此基础上,如果想额外暴露一些方法出去,则需要在对应方法声明前添加 API 声明,形式为 YOURMODULENAME_API ,这个宏是 Unreal 自动为每个 module 生成的。例如这里就是 FOOBAR_API

UCLASS(Blueprintable, MinimalAPI)
class ANicknamedActor : public AActor {
    // ...
    UFUNCTION(BlueprintCallable)
    FOOBAR_API void SayNickname();

当然,一种更为常见的情况是我们直接将这整个类型的信息都暴露出去,此时可以将 API 声明放到类型名前面,例如:

UCLASS(Blueprintable)
class FOOBAR_API ANicknamedActor : public AActor {
    // ...

添加 Module 描述

每一个 module 都需要在工程的 meta 文件中进行声明,对于游戏工程而言是在 .uproject 文件中,对于插件而言是在 .uplugin 文件中。这两个 meta 文件都是 JSON 格式的,module 相关的描述在 Module 字段下:

{
    // Other meta data...
    "Module": [
            "Name": "FooBar",
            "Type": "Runtime",
            "LoadingPhase": "Default"
        // Other modules ...

显然,这里的 Name 字段对应 module 的名字。这里的 Type 字段对应 module 被加载的环境,最常用的就是 RuntimeEditor 分别对应运行时(包括编辑器)和仅编辑器,完整列表可以参考 EHostType::Type 的内容。而 LoadingPhase 则对应于 module 的加载阶段,最常见的是 Default ,如果是写一个包含 shader 的 module,则会用到 PostConfigInit 这个阶段,因为 Unreal 需要在引擎初始化前加载 shader,完整列表可以参考 ELoadingPhase::Type

简化 Module 操作

新建一个 module 的过程其实相当麻烦,一般为了避免出错,会复制一个现成的 module,然后将内容删除再将名字改掉,不仅麻烦还容易出错,这里实现了一个简单的小工具,可以简化这个过程。项目地址在 urem,这是个命令行工具,可以用 go install 命令安装。

先将 urem.exe 所在的位置加入 PATH ,然后就可以执行如下命令新建一个 module 了:

# 新建一个属于工程的 module
urem.exe new mod ModuleName YourPorjectRoot/Source
# 新建一个属于插件的 module
urem.exe new mod ModuleName YourPorjectRoot/Plugins/YourPlugin/Source

这个过程会在 Source 目录下新建一个以 ModuleName 为名的目录,其中包含 ModuleName.Build.cs 文件,以及对应的 ModuleNameModule.h/cpp 文件。另外,为了方便日志打印,还会新建 Log.h/cpp 文件,包含 log category 的声明。在新建文件后,会自动刷新 .sln 工程。

除了新建 module 之外,这个工具还集成了一堆便利的小功能,比如刷新 .sln 工程,刷新 clangd 工程,新增 gitignore 模板、clang-format 模板等。使用