UE4中的C++编程简介

面向初次使用虚幻引擎的C++程序员的入门指南

Choose your operating system:

Windows

macOS

Linux

image alt text

虚幻C++太棒了!

本指南主要介绍如何在虚幻引擎4(UE4)中编写C++代码。不必担心,虚幻引擎中的C++编程十分有趣,入门也很简单!我们通常会认为虚幻C++是"辅助C++",因为我们有很多功能可以让所有人都能更简单的使用C++。

在开始之前,您必须已经对C++或另一种编程语言有所认知。在编写本页内容时,我们假设您已经有了一些C++经验,但如果您了解C#、Java或JavaScrip,也会发现有许多相似之处。

如果您完全没有编程经验,我们也考虑到了这一种情况!请查看我们的 蓝图视觉脚本指南 ,您就有了基本认知并可以继续学习了。您可以使用蓝图脚本创建整个游戏!

在UE4中,您可以编写标准C++代码,但阅读本指南并学习有关虚幻编程模型的基本知识后,您会取得更大的成功。接下来我们将详细介绍。

C++与蓝图

UE4提供了两种创建新Gameplay元素的方法:C++和蓝图视觉脚本。程序员利用C++即可添加基础Gameplay系统,然后设计师可基于这些系统进行构建或利用这些系统为某个特定关卡或游戏本身创建自定义Gameplay。在这些情况下,C++程序员在文本编辑器(如Notepad++)或IDE(通常是Microsoft Visual Studio或Apple Xcode)中工作,设计师则在UE4的蓝图编辑器中工作。

Gameplay API和框架类在这两个系统中都可以使用,可以单独使用,但组合使用,互补长短才能发挥出它们真正的作用。那么到底有何意义呢?这意味着,当程序员使用C++来创建Gameplay构建块,设计师利用这些块创建有趣的Gameplay时,引擎就能发挥最大作用。

言至于此,我们来看看C++程序员为设计师创建构建块的典型工作流程。在此情况下,我们将创建一个类,稍后设计师或程序员可以通过蓝图扩展此类。在该类中,我们将创建一些设计师可以设置的属性,并且我们将根据这些属性派生新值。整个过程使用我们提供的工具和C++宏就可以完成,非常简单。

类向导

首先,使用编辑器中的类向导来生成稍后将通过蓝图扩展的基本C++类。下图显示了向导的第一步,即创建新Actor。

image alt text

该流程中的第二步是告诉向导,您已经生成的类的名称。这是使用默认名称的第二步。

image alt text

选择创建类后,向导会生成文件,并打开您的开发环境,便于您开始编辑。下面是为您生成的类定义。有关类向导的更多信息,请单击 该链接

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

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

public:
    // 设置该Actor属性的默认值
    AMyActor();

    // 每一帧都调用
    virtual void Tick( float DeltaSeconds ) override;

protected:
    // 游戏开始或产生时调用
    virtual void BeginPlay() override;
};

类向导会生成你的类,并提供 BeginPlay Tick 两个重载方法。 BeginPlay 事件告诉您Actor以可运行状态进入了游戏。这是启动类Gameplay逻辑的好位置。 Tick 每帧调用一次,使用自上次调用传递以来经过的时间。您可以在这里执行任何重复逻辑。但是,如果您不需要该功能,最好将其移除,这样对性能有益。如果将其移除,确保移除构造函数中指示应开始发生tick事件的相应行。下面的构造函数就包含所提及的行。

AMyActor::AMyActor()

{

    // 将该Actor设置为每帧调用一次Tick()。如果您没有这个需要,可以将其关闭来改善性能。

    PrimaryActorTick.bCanEverTick = true;

}

让属性出现在编辑器中

创建类后,现在让我们创建一些设计师可以在虚幻编辑器中设置的属性。将属性公开给编辑器非常简单,只需要使用说明符 UPROPERTY 即可实现。您只需在属性声明的上一行加入 UPROPERTY(EditAnywhere) 即可,如以下类中所示。

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

    UPROPERTY(EditAnywhere)
    int32 TotalDamage;

    ...
};

只要完成上述操作,即可在编辑器中编辑该值。还有更多方法可以控制编辑该值的方式和位置。方法是将更多信息传递到 UPROPERTY 说明符。例如,如果您想要TotalDamage属性出现在包含相关属性的某个部分中,可以使用分类功能。具体请参见下面的属性声明。

UPROPERTY(EditAnywhere, Category="Damage")
int32 TotalDamage;

当用户想要编辑该属性时,它现在会出现在"伤害(Damage)"标题下面,与您已经标记为此类别名称的任何其他属性在一起。这是将常用设置放在一起以供设计师编辑的好方法。

现在,让我们将同一个属性公开给蓝图。

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
int32 TotalDamage;

如您所见,有一个说明符可以让属性在蓝图图表中可供读写。有一个单独的说明符 BlueprintReadOnly ,如果您希望属性在蓝图中被视为 常量 ,可以使用这个选项。还有一些选项可用来控制将属性公开给编辑器的方式。要查看更多选项,请单击 该链接

再继续以下部分前,我们来向该样本类添加几个属性。已经有一个属性可以控制该Actor将释放出的总伤害量,但让我们更进一步,让这个伤害随着时间而逐渐释放出来。下面的代码添加了一个可以由设计师设置的属性,以及一个对设计师可见但不能更改的属性。

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

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
    int32 TotalDamage;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
    float DamageTimeInSeconds;

    UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Transient, Category="Damage")
    float DamagePerSecond;

    ...
};

DamageTimeInSeconds 是设计师可以修改的属性。 DamagePerSecond 属性的值使用设计师的设置计算得出(请参见下一节)。 VisibleAnywhere 说明符将该属性标记为可见,但不可编辑。 Transient 说明符意味着,它不会保存或从磁盘加载;它就是一个派生的非持久值,所以没有必要存储它。下图显示作为类默认值一部分的属性。

image alt text

在我的构造函数中设置默认值

在构造函数中为属性设置默认值的方式与典型的C++类一样。下面是两个在构造函数中设置默认值的示例,它们在功能上是等效的。

AMyActor::AMyActor()
{
    TotalDamage = 200;
    DamageTimeInSeconds = 1.0f;
}

AMyActor::AMyActor() :
    TotalDamage(200),
    DamageTimeInSeconds(1.0f)
{
}

这是在构造函数中添加默认值后的相同属性视图。

image alt text

为了按实例支持设计师设置属性,还会从给定对象的实例数据加载值。该数据在构造函数之后应用。您可以根据设计师设置值创建默认值,方法是钩入 PostInitProperties() 调用链中。下面示例展示了 TotalDamage DamageTimeInSeconds 为设计师指定值的流程。尽管它们是设计师指定的,但您仍可以为它们提供合理的默认值,就像上述示例一样。

如果您不提供属性的默认值,引擎会自动将该属性设置为0或空指针(如果是指针类型)。

void AMyActor::PostInitProperties()
{
    Super::PostInitProperties();
    DamagePerSecond = TotalDamage / DamageTimeInSeconds;
}

这里同样是添加了上述 PostInitProperties() 代码后的属性视图。

image alt text

热重载

虚幻有一个非常帮的功能,如果您已习惯于在其他项目中进行C++编程,可能会对这个功能感到惊奇。您不必关闭编辑器就可以编译C++更改!有两种方法可以达到这个目的:

  1. 在编辑器继续运行的情况下启用该功能,并像正常操作那样通过Visual Studio或Xcode构建。编辑器会检测出新编译的DLL,并立即加载修改!

    image alt text

    如果您已经连接了调试器,需要先断开连接,这样Visual Studio才会允许您构建。

  2. 或者直接点击编辑器主工具栏中的 编译(Compile) 按钮。

    image alt text

在本教程的后续章节中,您将用到这个功能。

通过蓝图扩展C++类

目前,我们已经用C++类向导创建了简单的Gameplay类,并添加了一些可供设计师设置的属性。现在来看一看设计师如何在我们已经完成的简要基础工作上开始创建独特的类。

首先要根据 AMyActor 类创建新的蓝图类。请注意下图中,所选基类的名称显示为 MyActor ,而不是 AMyActor 。这是故意为之,目的是向设计师隐藏工具所用的命名约定,让名称对设计师而言更加友好。

image alt text

选取了 选择(Select) 后,会为您创建一个新的默认命名的蓝图类。在本例中,我将名称设置为 CustomActor1 ,如下面 内容浏览器 截图中所示。

image alt text

这是我们将以设计师角色自定义的第一个类。首先,我们将更改伤害属性的默认值。在本例中,设计师将 TotalDamage 更改为300,将释放该伤害所需的时间更改为2秒。这是这些属性现在的样子。

image alt text

我们计算的值与预期不符。它应该是150,但默认值仍然是200。其原因是我们仅计算了从加载过程初始化属性后的每秒伤害值。编辑器中的运行时更改没有考虑在内。这个问题有一种简单的解决方案,因为引擎会在编辑器中发生变化时通知目标对象。下面的代码显示了为了计算派生值在编辑器中发生变化时的值而添加的钩。

void AMyActor::PostInitProperties()
{
    Super::PostInitProperties();

    CalculateValues();
}

void AMyActor::CalculateValues()
{
    DamagePerSecond = TotalDamage / DamageTimeInSeconds;
}

#if WITH_EDITOR
void AMyActor::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
    CalculateValues();

    Super::PostEditChangeProperty(PropertyChangedEvent);
}
#endif

需要注意的一点是, PostEditChangeProperty 方法位于特定于编辑器的 #ifdef 内部。这样才能在构建游戏时只编译真正需要的代码,删除任何多余的、导致可执行文件大小增大的代码。现在,我们已经编译了代码, DamagePerSecond 值与我们预期的值匹配,如下图所示。

image alt text

在C++和蓝图边界中调用函数

目前,我们已经展示了如何将属性公开给蓝图,但还有最后一个入门主题需要介绍,然后才能更深入地探索引擎。在创建Gameplay系统期间,设计师将需要能够调用C++程序员创建的函数。而程序员也要能够从C++代码调用蓝图中实现的函数。首先我们来让CalculateValues()能够从蓝图调用。将函数公开给蓝图就像公开属性一样简单。只需在函数声明前放置一个宏即可!以下代码片段显示了所需内容。

UFUNCTION(BlueprintCallable, Category="Damage")
void CalculateValues();

UFUNCTION() 宏负责处理将C++函数公开给反射系统。 BlueprintCallable 选项将其公开给蓝图虚拟机。每一个公开给蓝图的函数都需要一个与之关联的类别,这样右键点击快捷菜单的功能才能正确生效。下图显示了类别对快捷菜单的影响:

image alt text

如您所见,该函数可以从 伤害(Damage) 类别中选择。下面的蓝图代码显示了TotalDamage值的变化,后面是用来重新计算依赖数据的调用。

image alt text

这里使用了我们之前添加的用来计算相关属性的同一个函数。引擎的大部分都通过 UFUNCTION() 宏公开给蓝图,因此用户可以直接构建游戏,而不必编写C++代码。但是,最佳方法是使用C++构建基本Gameplay系统和性能关键代码,用蓝图自定义行为或从C++构建块创建组合式行为。

现在,设计师已经可以调用C++代码了,接下来探索一种更强大的C++/蓝图边界交叉调用方法。该方法让C++代码能够调用蓝图中定义的函数。我们通常使用这种方法,将设计师在认为合适时可以响应的事件通知给设计师。这通常包括产生效果或其他视觉影响,如隐藏或取消隐藏Actor。下面的代码片段显示了蓝图实现的函数。

UFUNCTION(BlueprintImplementableEvent, Category="Damage")
void CalledFromCpp();

该函数的调用方法与任何其他C++函数一样。在后台,虚幻引擎生成基本C++函数实现,用以理解如何在蓝图VM中调用。这通常称为形实替换(Thunk)。如果所提及蓝图不为这种方法提供函数体,则函数行为就像没有实体行为的C++函数一样:不执行任何操作。如果想要提供C++默认实现,同时仍允许蓝图覆盖此方法,该怎么办呢?或许可以使用UFUNCTION()宏的一个选项。以下代码片段显示了为达到此目的需要在标头中进行的更改。

UFUNCTION(BlueprintNativeEvent, Category="Damage")
void CalledFromCpp();

该版本仍会生成用于在蓝图VM中调用的形实替换方法。那么如何提供默认实现呢?工具还会生成一个新的函数声明,类似于 <函数名>_Implementation() 。您必须提供该版本的函数,否则项目无法建立关联。下面是对上述声明的实现代码。

void AMyActor::CalledFromCpp_Implementation()
{
    // 这里可以添加些有趣的代码
}

现在,该版本函数会在所提及蓝图不覆盖此方法时被调用。注意,在先前版本的构建工具中,会自动生成_Implementation()声明。在4.8或更高版本中,您需要显式将该声明添加到标头中。

现在我们已经介绍了与设计师合作构建Gameplay功能的常见Gameplay程序员工作流程和方法,接下来该由您自己选择前进方向了。您可以继续阅读本文,进一步了解如何在引擎中使用C++,也可以直接参见我们在启动程序中提供的样本来获得更多实践经验。

深入探索

看来您决定继续阅读本文。很好。下面的讨论主题将围绕着Gameplay类层级展开。在本节中,我们首先介绍基本构建块,然后介绍它们彼此之间的关系。这里我们将说明虚幻引擎如何使用继承与复合来构建自定义Gameplay功能。

Gameplay类:对象、Actor和组件

从大部分Gameplay类可以派生出4种主要类型的类。它们分别是 UObject AActor UActorComponent UStruct 。下面几节将说明其中每一种构建块。当然,您可以创建不从任何类派生的类型,但它们不会参与到引擎中构建的功能。在 UObject 层级外部创建的典型类用法是:集成第三方库、包裹操作系统特定功能等。

虚幻对象(UObject)

虚幻引擎中的基本构建块叫做UObject。该类结合 UClass ,可以提供多个最重要的引擎服务:

  • 反射属性和方法

  • 序列化属性

  • 垃圾回收

  • 按名称查找 UObject

  • 属性的可配置值

  • 属性和方法的联网支持

UObject 派生的每个类都会创建有一个 UClass UClass 包含有关该类实例的所有元数据。 UObject UClass 一起位于Gameplay对象在其生命周期所有作用的最根部位置。如果要解释 UClass UObject 的差异在哪里,最合适的方法是 UClass 描述的是 UObject 实例的样子、可序列化和联网的属性等。大多数Gameplay开发不会直接从 UObject 派生,而是从AActor和UActorComponent派生。您无需知道 UClass UObject 工作方式细节,这并不影响您编写Gameplay代码,知道这些系统的存在即可。

AActor

AActor 是将会成为Gameplay体验的一部分的 UObject 。AActor由设计师放在关卡中,或者通过Gameplay系统在运行时创建。可以放入关卡的所有对象都是从该类扩展而来的。示例包括 AStaticMeshActor ACameraActor APointLight Actor。由于 AActor 派生自 UObject ,因此可以使用上一节所列的所有标准功能。Actor可以显式销毁,方法是使用Gameplay代码(C++或蓝图),或者在所属关卡从内存中卸载时通过标准的垃圾回收机制销毁。Actor负责游戏对象的高级行为。 AActor 还是可以在联网时复制的基本类型。在网络复制期间,Actor还可以分发其拥有的、需要网络支持或同步的任何 UActorComponent 的信息。

Actor还有它们自己的行为(通过继承实现特殊化),但它们也充当Actor Component层级容器(通过复合实现特殊化)。这个过程通过Actor的 RootComponent 成员实现,它包含一个 USceneComponent ,而后者继而包含许多其他成员。在可以将Actor放入关卡之前,它必须包含至少一个Scene Component,Actor可以从后者绘制其平移、旋转和缩放。

Actor包含在AActor生命周期中调用的一系列事件。以下列表是一组简化的事件,描绘了整个生命周期:

  • BeginPlay :Actor首次在Gameplay中存在时调用。

  • Tick :每帧调用一次,随着时间的进行持续完成工作。

  • EndPlay :对象离开Gameplay空间时调用。

请参见 Actors 以了解有关 AActor 类的更详细讨论。

运行时生命周期

我们在上文讨论了AActor生命周期的一小部分。对于关卡中放置的Actor,了解生命周期是很容易想象的到的:Actor加载并存在,最终关卡被卸载后,Actor被销毁。产生Actor比在游戏中创建普通对象稍微复杂一点,因为Actor需要注册到多个运行时系统才能满足其所有需要。需要设置Actor的初始位置和旋转。物理可能需要知道这些信息。负责告诉Actor执行tick事件的管理器也需要知道。诸如此类。因此,我们有一种方法专门用来产生Actor,叫做 SpawnActor UWorld 的成员之一)。成功产生Actor后,引擎会调用它的 BeginPlay 方法,下一帧调用 Tick

Actor生命周期结束时,您可以调用 Destroy 来将它销毁。在该过程中,将调用 EndPlay ,让您能在Actor进入回收站之前执行自定义逻辑。另一个控制Actor生命周期时长的方法是使用 Lifespan 成员。您可以在对象的构造函数中设置Actor的时间跨度,也可以在运行时使用其他代码进行设置。当这段时间到期后,会自动对该Actor调用 Destroy

要进一步了解产生Actor的信息,请参阅 生成 Actors 页面。

UActorComponent

Actor Component ( UActorComponent 类)有自己的行为,通常负责在许多类型Actor之间共享的功能,例如,提供视觉网格体、粒子效果、摄像机视角和物理互动。Actor通常提供与其游戏总体角色有关的高级目标,而Actor Component通常执行用于支持这些更高级目标的单独任务。组件也可以与其他组件相连接,或者可以成为Actor的根组件。一个组件只能连接到一个父组件或Actor,但可以连接多个子Actor。您可以想象一个组件树。子组件的位置、旋转和缩放相对于其父组件或Actor。

Actor和组件有很多用法,一种方法是将Actor-组件关系视为Actor可能会回答问题"这是什么?",而组件可能会回答"这个东西是用什么做成的?"

  • RootComponent - 这是 AActor 的一个成员,用于保存AActor组件树中的顶级组件。

  • Ticking - 在所属Actor的Tick()过程中执行 Tick 函数。(在编写自己的 Tick 函数时,必须确保调用 Super::Tick 。)

分解第一人称角色

为了描绘 AActor 及其 UActorComponent 之间的关系,接下来将深入根据第一人称模板生成新项目时创建的蓝图。下图是 FirstPersonCharacter Actor的组件树。 RootComponent CapsuleComponent 。与 CapsuleComponent 相连的是 ArrowComponent Mesh 组件和 FirstPersonCameraComponent 。最末端组件是"Mesh1P",它的父代是 FirstPersonCameraComponent ,意味着第一人称网格体相对于第一人称摄像机。

image_14.png

从视觉角度来看,这个组件树有点类似于下图,您会在3D空间中看到除 Mesh 组件之外的所有其他组件。

image_15.png

这个组件树与一个Actor类相连。如示例所示,您可以使用继承和复合构建复杂Gameplay对象。如果想要自定义现有 AActor UActorComponent ,可以使用继承。如果希望许多不同的 AActor 类型共享功能,可以使用复合。

UStruct

要使用 UStruct ,您不必从任何特定类扩展,只需用USTRUCT()标记该结构体,构建工具就会为您完成基本工作。与 UObject 不同的是, UStruct 实例不会被垃圾回收。如果您要创建它们的动态实例,必须自行管理其生命周期。 UStruct 应该是纯数据类型,包含 UObject 反射支持,可以在虚幻编辑器、蓝图操控、序列化、联网等中编辑。

现在,我们已经介绍了Gameplay类构造中使用的基本层级,接下来又到了您选择的时候。您可以在 此处 继续阅读Gameplay类内容,访问启动程序中具有更多信息的样本,也可以继续探索更多用于构建游戏的C++功能。

继续深入探索

显然您还想继续学习。让我们继续深入探索引擎的工作方式。

虚幻反射系统

博文:虚幻属性系统(反射)

Gameplay类利用特殊标记,因此在继续之前,先来介绍一下虚幻属性系统的基础知识。UE4使用其自己的反射实现来支持动态功能,如垃圾回收、序列化、网络复制和蓝图/C++通信。这些功能是可选的,意味着您必须将正确的标记添加到类型,否则虚幻将忽略它们,而不会为它们生成反射数据。下面是对基本标记的简要概述:

  • UCLASS() - 用于告诉虚幻为结构体生成反射数据。类必须派生自UObject。

  • USTRUCT() - 用于告诉虚幻为结构体生成反射数据。

  • `GENERATED_BODY()** - UE4将这个标记替换为将为该类型生成的所有必要的样板代码。

  • UPROPERTY() - 支持将UCLASS的成员变量或USTRUCT用作UPROPERTY。UPROPERTY有很多用法。它可以允许复制变量、序列化变量和从蓝图访问变量。它们可以供垃圾回收程序使用,用来跟踪对 UObject 的引用次数。

  • UFUNCTION() - 支持将UCLASS的类方法或USTRUCT用作UFUNCTION。UFUNCTION可以允许从蓝图调用类方法,用作RPC等多种用途。

以下是UCLASS声明示例:

#include "MyObject.generated.h"

UCLASS(Blueprintable)
class UMyObject : public UObject
{
    GENERATED_BODY()

public:
    MyUObject();

    UPROPERTY(BlueprintReadOnly, EditAnywhere)
    float ExampleProperty;

    UFUNCTION(BlueprintCallable)
    void ExampleFunction();
};

首先您会注意到包含了 MyClass.generated.h 。UE4将生成所有反射数据并放入该文件中。您必须将该文件作为声明类型的标头文件中的最后一个包含语句,将其包含进去。

该示例中的 UCLASS UPROPERTY UFUNCTION 标记包含一些其他说明符。这些虽不是必需的,但为了演示目的,已经添加了一些常见说明符。这样我们可以指定特定行为或属性。

  • Blueprintable - 该类可以由蓝图扩展。

  • BlueprintReadOnly - 该属性可以从蓝图读取,但不能写入蓝图。

  • EditAnywhere - 该属性可以在原型和实例上的属性窗口中编辑。

  • Category - 定义该属性将出现在编辑器"细节(Details)"视图下面的哪个部分。这对于整理结构而言十分有用。

  • BlueprintCallable - 该功能可以从蓝图调用。

说明符众多,不便在此一一列出,但可以参考下面的链接:

UCLASS说明符列表

UPROPERTY说明符列表

UFUNCTION说明符列表

USTRUCT说明符列表

对象/Actor迭代器

对象迭代器是非常有用的工具,可用来迭代特定 UObject 类型及其子类的所有实例。

// 查找所有当前UObject实例
for (TObjectIterator<UObject> It; It; ++It)
{
    UObject* CurrentObject = *It;
    UE_LOG(LogTemp, Log, TEXT("Found UObject named:%s"), *CurrentObject->GetName());
}

您可以通过为迭代器提供更具体的类型来限制搜索范围。假设您有一个类,名为UMyClass,它是从 UObject 派生而来的。您可以像下面这样找到该类的所有实例(以及从它派生而来的实例):

for (TObjectIterator<UMyClass> It; It; ++It)
{
    // ...
}

在PIE(编辑器中运行)中使用对象迭代器会导致意外结果。由于编辑器已经加载,对象迭代器将返回为游戏场景实例创建的所有 UObject 实例,此外还有编辑器使用的实例。

Actor迭代器与对象迭代器十分类似,但仅适用于从AActor派生的对象。Actor迭代器不存在上面所注明的问题,仅返回当前游戏场景实例使用的对象。

在创建Actor迭代器时,您需要为其指定一个指向 UWorld 的指针。类似 APlayerController 等许多 UObject 类都会提供一个 GetWorld 方法来帮助您。如果您不需确定,可以检查 UObject 上的 ImplementsGetWorld 方法来确认它是否实现GetWorld方法。

APlayerController* MyPC = GetMyPlayerControllerFromSomewhere();
UWorld* World = MyPC->GetWorld();

// 正如对象迭代器一样,您可以提供一个具体类来仅获得
// 属于该类或派生自该类的对象
for (TActorIterator<AEnemy> It(World); It; ++It)
{
    // ...
}

由于AActor派生自UObject,因此您也可以使用 TObjectIterator 来查找 AActor 的实例。只是在PIE中需要谨慎!

内存管理和垃圾回收

在本节中,我们将介绍基本内存管理和UE4中的垃圾回收系统。

Wiki:垃圾回收和动态内存分配

UObject和垃圾回收

UE4使用反射系统来实现垃圾回收系统。通过垃圾回收,您将不必手动删除 UObject 实例,只需维护对它们的有效引用即可。您的类需要派生自 UObject 才能对其进行垃圾回收。下面是我们将使用的简单示例类:

UCLASS()
class MyGCType : public UObject
{
    GENERATED_BODY()
};

在垃圾回收程序中,有一个概念叫做根集。该根集基本上是一个对象列表,这些对象是回收程序知道将不会被垃圾回收的对象。只要根集中的某个对象到一个对象存在引用路径,就不会对所涉及对象进行垃圾回收。如果某个对象不存在到根集的此类路径,则称为无法访问,将会在下次运行垃圾回收程序时将其回收(删除)。引擎按特定的时间间隔运行垃圾回收程序。

UPROPERTY 或UE4容器类(例如`TArray )中存储的任意 UObject` 指针都被视为垃圾回收的"引用"。首先让我们从简单示例入手。

void CreateDoomedObject()
{
    MyGCType* DoomedObject = NewObject<MyGCType>();
}

上述函数创建一个新 UObject ,但不会在任何 UPROPERTY 或UE4容器中存储指向它的指针,因此它不是根集的一部分。最终,垃圾回收程序会检测到该对象无法访问,从而将其销毁。

Actor和垃圾回收

除非在关卡关闭期间,Actor通常不会被垃圾回收。一旦产生后,必须手动对它们调用 Destroy 才能在不关闭关卡的情况下将其从关卡移除。它们会被立即从游戏中删除,并在下次垃圾回收时被完全删除。

有一种更为常见的情况,即您的Actor具有 UObject 属性。

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

public:
    UPROPERTY()
    MyGCType* SafeObject;

    MyGCType* DoomedObject;

    AMyActor(const FObjectInitializer& ObjectInitializer)
        : Super(ObjectInitializer)
    {
        SafeObject = NewObject<MyGCType>();
        DoomedObject = NewObject<MyGCType>();
    }
};

void SpawnMyActor(UWorld* World, FVector Location, FRotator Rotation)
{
    World->SpawnActor<AMyActor>(Location, Rotation);
}

当我们调用上述函数时,就会在场景中产生一个Actor。这个Actor的构造函数会创建两个对象。一个被分配UPROPERTY,另一个分配有裸指针。由于Actor会自动成为根集的一部分,因此 SafeObject 不会被垃圾回收,因为可以从根集对象访问它。但 DoomedObject 则不是这种情况。我们没有用UPROPERTY来标记它,因此回收程序不知道它被引用,因此最终将其销毁并留下一个摇摆指针。

UObject 被垃圾回收时,所有对它的UPROPERTY引用都会设置为空指针。这样您就可以安全地检查某个对象是否已被垃圾回收。

if (MyActor->SafeObject != nullptr)
{
    // 使用SafeObject
}

这一点很重要,因为正如之前所说,调用了 Destroy 的Actor会在垃圾回收程序下次运行时才会删除。您可以检查 IsPendingKill 方法,来确认 UObject 是否正在等待删除。如果该方法返回true,您应将对象视为已销毁,不要再使用它。

UStructs

如前所述, UStructs UObject 的轻量级版本。因此,不能将 UStructs 垃圾回收。如果必需使用 UStructs 的动态实例,可以使用智能指针,我们稍后将进行介绍。

非对象引用

通常,C++对象(非派生自 UObject )也能够添加对对象的引用并防止垃圾回收。为此,对象必须派生自 FGCObject 并覆盖其 AddReferencedObjects 方法。

class FMyNormalClass : public FGCObject
{
public:
    UObject* SafeObject;

    FMyNormalClass(UObject* Object)
        : SafeObject(Object)
    {
    }

    void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        Collector.AddReferencedObject(SafeObject);
    }
};

我们使用 FReferenceCollector 来手动添加对需要且不希望垃圾回收的 UObject 的硬引用。当该对象被删除且其析构函数运行时,该对象将自动清除其所添加的所有引用。

类命名前缀

虚幻引擎提供了一些在构建过程中生成代码的工具。这些工具会期待看到一些类命名,并在名称与预期不符时触发警告或错误。以下类前缀列表描述了工具期望的名称。

  • 派生自 Actor 的类带有 A 前缀,如 AController

  • 派生自 Object 的类带有 U 前缀,如 UComponent

  • Enums 的前缀是 E ,如 EFortificationType

  • Interface 的前缀通常是 I ,如 IAbilitySystemInterface

  • Template 的前缀是 T ,如 TArray

  • 派生自 SWidget 的类(Slate UI)带有前缀 S ,如 SButton

  • 其他类的前缀为 字母F ,如 FVector

数字类型

由于不同平台有不同的基本类型大小,如 短整型 整型 长整型 ,因此UE4提供以下类型供您备选:

  • int8 / uint8 :8位有符号/无符号整数

  • int16 / uint16 :16位有符号/无符号整数

  • int32 / uint32 :32位有符号/无符号整数

  • int64 / uint64 :64位有符号/无符号整数

浮点数也支持标准 float (32位)和 double (64位)类型。

虚幻引擎有一个模板`TNumericLimits `,用于查找值类型可以拥有的最小和最大范围。有关更多信息,请单击该 链接

字符串

UE4提供多个不同的类,便于您根据需要处理字符串。

完整主题:字符串处理

FString

FString 是一个可变字符串,类似于 std::string FString 拥有很多方法,方便您处理字符串。要创建新的 FString ,请使用 TEXT 宏:

FString MyStr = TEXT("Hello, Unreal 4!").

完整主题:FString API

FText

FText 类似于FString,但旨在用于本地化文本。要创建新的 FText ,请使用 NSLOCTEXT 宏。该宏将使用默认语言的名称空间、键和值。

FText MyText = NSLOCTEXT("Game UI", "Health Warning Message", "Low Health!")

您还可以使用 LOCTEXT 宏,这样只需要每个文件定义一个名称空间即可。确保在文件结束时取消定义。

// 在GameUI.cpp中
#define LOCTEXT_NAMESPACE "Game UI"

//...
FText MyText = LOCTEXT("Health Warning Message", "Low Health!")
//...

#undef LOCTEXT_NAMESPACE
// 文件结束

完整主题:FText API

FName

FName 存储通常反复出现的字符串作为辨识符,以在比较时节省内存和CPU时间。如果有多个对象引用一个字符串, FName 使用较小的存储空间索引来映射到给定字符串,而不是在引用它的每个对象中多次存储完整字符串。这样会将字符串内容存储一次,节省在多个对象中使用该字符串时占用的内存。 FName 比较更快是因为UE4能够检查其索引值来确认其是否匹配,而无须检查每一个字符是否相同。

UObject::FName完整主题:FName API

TCHAR

TCHAR 类型是独立于所用字符集存储字符的方法,字符集或许会因平台而异。实际上,UE4字符串使用 TCHAR 数组来存储 UTF-16 编码的数据。您可以使用重载的解除引用运算符(它返回 TCHAR )来访问原始数据。

完整主题:字符编码

某些函数需要使用它,例如 FString::Printf ,"%s" 字符串格式说明符期待的是 TCHAR ,而不是 FString

FString Str1 = TEXT("World");
int32 Val1 = 123;
FString Str2 = FString::Printf(TEXT("Hello, %s!You have %i points."), *Str1, Val1);

FChar 类型提供一组静态效用函数,用来处理各个 TCHAR 字符。

TCHAR Upper('A');
TCHAR Lower = FChar::ToLower(Upper); // 'a'

FChar 类型定义为`TChar `(因为它列示在该API中)。

完整主题:TChar API

容器

容器是一种类,它的主要功能是存储数据集合。最常见的这些类包括 TArray TMap TSet 。每个类都会自动调节大小,因此增长到您所需的大小。

Containers完整主题:容器API

TArray

在所有三个容器中,您在虚幻引擎4中将会使用的主要容器是TArray,它的功能与 std::vector 十分相似,但会提供更多功能。以下是一些常见操作:

TArray<AActor*> ActorArray = GetActorArrayFromSomewhere();

// 告知当前ActorArray中存储了多少个元素(AActor)。
int32 ArraySize = ActorArray.Num();

// TArray基于0(第一个元素将位于索引0处)
int32 Index = 0;
// 尝试检索给定索引处的元素
AActor* FirstActor = ActorArray[Index];

// 在数组末尾添加新元素
AActor* NewActor = GetNewActor();
ActorArray.Add(NewActor);

// 在数组末尾添加元素,但前提必须是该元素尚不存在于数组中
ActorArray.AddUnique(NewActor); // 不会改变数组,因为已经添加了NewActor。

// 从数组中移除"NewActor"的所有实例
ActorArray.Remove(NewActor);

// 移除指定索引处的元素
// 索引之上的元素将下移一位来填充空白空间
ActorArray.RemoveAt(Index);

// 更高效版本的"RemoveAt",但不能保持元素的顺序
ActorArray.RemoveAtSwap(Index);

// 移除数组中的所有元素
ActorArray.Empty();

TArray 添加了对其元素进行垃圾回收的好处。这样会假设 TArray 存储了 UObject 派生的指针。

UCLASS()
class UMyClass : UObject
{
    GENERATED_BODY();

    // ...

    UPROPERTY()
    AActor* GarbageCollectedActor;

    UPROPERTY()
    TArray<AActor*> GarbageCollectedArray;

    TArray<AActor*> AnotherGarbageCollectedArray;
};

我们将在后续章节进一步介绍垃圾回收。

完整主题:TArray

完整主题:TArray API

TMap

TMap 是键-值对的集合,类似于 std::map TMap 具有一些根据元素键查找、添加和移除元素的快速方法。您可以使用任意类型来表示键,因为它定义有 GetTypeHash 函数,我们稍后将进行介绍。

假设您创建了一个基于网格的棋盘游戏,并需要存储和查询每一个正方形上的内容。 TMap 会为您提供一种简单的可用方法。如果棋盘较小,并且尺寸不变,那么或许会有更有效的方法来达到此目的,但在此示例中,我们假设了一个尺寸较大、带有少量棋子的棋盘。

enum class EPieceType
{
    King,
    Queen,
    Rook,
    Bishop,
    Knight,
    Pawn
};

struct FPiece
{
    int32 PlayerId;
    EPieceType Type;
    FIntPoint Position;

    FPiece(int32 InPlayerId, EPieceType InType, FIntVector InPosition) :
        PlayerId(InPlayerId),
        Type(InType),
        Position(InPosition)
    {
    }
};

class FBoard
{
private:

    // 通过使用TMap,我们可以按位置引用每一块
    TMap<FIntPoint, FPiece> Data;

public:
    bool HasPieceAtPosition(FIntPoint Position)
    {
        return Data.Contains(Position);
    }
    FPiece GetPieceAtPosition(FIntPoint Position)
    {
        return Data[Position];
    }

    void AddNewPiece(int32 PlayerId, EPieceType Type, FIntPoint Position)
    {
        FPiece NewPiece(PlayerId, Type, Position);
        Data.Add(Position, NewPiece);
    }

    void MovePiece(FIntPoint OldPosition, FIntPoint NewPosition)
    {
        FPiece Piece = Data[OldPosition];
        Piece.Position = NewPosition;
        Data.Remove(OldPosition);
        Data.Add(NewPosition, Piece);
    }

    void RemovePieceAtPosition(FIntPoint Position)
    {
        Data.Remove(Position);
    }

    void ClearBoard()
    {
        Data.Empty();
    }
};

完整主题:TMap

完整主题:TMap API

TSet

TSet 存储唯一值集合,类似于 std::set 。虽然通过 TArray 可通过其 AddUnique Contains 方法支持类似集的行为, TSet 可以更快的实现这些运算且不会自动添加非独有元素。

TSet<AActor*> ActorSet = GetActorSetFromSomewhere();

int32 Size = ActorSet.Num();

// 向集添加元素,但前提是集尚未包含这个元素
AActor* NewActor = GetNewActor();
ActorSet.Add(NewActor);

// 检查元素是否已经包含在集中
if (ActorSet.Contains(NewActor))
{
    // ...
}

// 从集移除元素
ActorSet.Remove(NewActor);

// 从集移除所有元素
ActorSet.Empty();

// 创建包含TSet元素的TArray
TArray<AActor*> ActorArrayFromSet = ActorSet.Array();

完整主题:TSet API

容器迭代器

通过使用迭代器,您可以循环遍历容器的所有元素。以下是该迭代器语法的示例,使用的是 TSet

void RemoveDeadEnemies(TSet<AEnemy*>& EnemySet)
{
    // 从集开头处开始,迭代至集末尾
    for (auto EnemyIterator = EnemySet.CreateIterator(); EnemyIterator; ++EnemyIterator)
    {
        // *运算符获取当前元素
        AEnemy* Enemy = *EnemyIterator;
        if (Enemy.Health == 0)
        {
            //"RemoveCurrent"受TSet和TMap支持
            EnemyIterator.RemoveCurrent();
        }
    }
}

您可以用于迭代器的其他受支持的运算包括:

// 将迭代器向后移动一个元素
--EnemyIterator;

// 将迭代器向前/向后移动一定偏移量,这里的偏移量是个整数
EnemyIterator += Offset;
EnemyIterator -= Offset;

// 获取当前元素的索引
int32 Index = EnemyIterator.GetIndex();

// 将迭代器复位到第一个元素
EnemyIterator.Reset();

For-each循环

迭代器虽然好用,但如果您只想每个元素循环一次,未免有点麻烦。每个容器类还支持 for each 风格的语法来循环元素。 TArray TSet 返回各个元素,而 TMap 返回键-值对。

// TArray
TArray<AActor*> ActorArray = GetArrayFromSomewhere();
for (AActor* OneActor :ActorArray)
{
    // ...
}

// TSet——与TArray相同
TSet<AActor*> ActorSet = GetSetFromSomewhere();
for (AActor* UniqueActor :ActorSet)
{
    // ...
}

// TMap——迭代器返回键-值对
TMap<FName, AActor*> NameToActorMap = GetMapFromSomewhere();
for (auto& KVP :NameToActorMap)
{
    FName Name = KVP.Key;
    AActor* Actor = KVP.Value;

    // ...
}

auto 关键字不会自动指定指针或引用。在上述示例中,如果您使用 auto

将您自己的类型与TSet/TMap(散列函数)一起使用

TSet TMap 需要在内部使用散列函数。您经常会储存在 TSet 里,或用作指向 TMap 的键的UE4类型大部分已经定义了自己的散列函数。如果您创建自理的类,想要在 TSet 中使用它或者用作指向 TMap 的键,就需要提供一个散列函数,使用指向您的类型的常量指针或引用,并返回 uint32 。该返回值成为对象的 散列代码 ,应该是特定于该对象的唯一数字。这意味着您的类型的两个对象被视为相同的,应该始终返回相同的散列代码。

class FMyClass
{
    uint32 ExampleProperty1;
    uint32 ExampleProperty2;

    // 散列函数
    friend uint32 GetTypeHash(const FMyClass& MyClass)
    {
        // HashCombine是将两个散列值合并的效用函数。
        uint32 HashCode = HashCombine(MyClass.ExampleProperty1, MyClass.ExampleProperty2);
        return HashCode;
    }

    // 为了演示目的,两个相同的对象
    // 应该始终返回相同的散列代码。
    bool operator==(const FMyClass& LHS, const FMyClass& RHS)
    {
        return LHS.ExampleProperty1 == RHS.ExampleProperty1
            && LHS.ExampleProperty2 == RHS.ExampleProperty2;
    }
};

现在, TSet<FMyClass> TMap<FMyClass, ...> 在对键进行散列处理时将使用正确的散列函数。如果使用指针作为键(即, TSet<FMyClass*> ),则还要实现`uint32 GetTypeHash(const FMyClass* MyClass)`。

博文:值得了解的UE4库

欢迎帮助改进虚幻引擎文档!请告诉我们该如何更好地为您服务。
填写问卷调查
取消