UE5C++编程
留意UE5为编程提供的功能:
- 在C++中创建新的Gameplay类,在用Visual Studio编译后,所有的改变都反映在虚幻编辑器中。在UE引擎中创建类与创建标准C++类、函数和变量相似,用标准C++语法定义的。
- 使用虚幻反射系统,用元数据属性说明符的宏封装类,提供编辑器功能。每个类都定义了一个新的Object或Actor的模板。
- UE引擎中的容器提供关于类和数据结构的集合信息
- 使用Gameplay Architecture在UE引擎中构建你的项目。Gameplay框架提供了一个由Object和Actor构成的结构。这些类包含模板变量和函数,可以在创建和设计互动体验时使用。
- 创建委托:以泛型、类型安全的方式调用c++对象上的成员函数。可以动态地将委托绑定到任意对象的成员函数,以便将来调用对象上的函数,即使调用者不知道对象的类型。
UE引擎反射系统
反射系统,将类与提供引擎和编辑器功能的各种宏封装在一起。
UE中对象的基类是UObject。每个类都新定义了一个用于Actor或Object的模板。
在定义类时,要使用UCLASS宏标记从UObject派生的类(一般都是),以便UObject处理系统可以注意到这些类。
TSubclassOf是一个提供UClass类型安全的模板类。它对于指定派生自特定类型的类非常有用。例如将此变量暴露给蓝图,让设计人员指定伤害类型/武器类别等等
类可以包含结构体。使用USTRUCT宏单独定义结构体。
虚幻智能指针库是C++11智能指针的自定义实现,旨在减轻内存分配和跟踪的负担。该实现包括行业标准的共享指针、弱指针、唯一指针和共享引用
接口提供在多个或不同的类实现函数和额外的游戏行为。玩家角色与世界中的各种Actor互动。每个互动都能引起对一个事件的不同反应。
UFUNCTION以及UPROPERTY宏使UE感知到新的类、函数和变量。引擎会对这些宏进行垃圾回收。指定宏后,可以在虚幻编辑器中编辑和显示它们。
接口
背景:在游戏功能被大量复杂而不同的类共享的情况下。例如,实现一个系统,输入一个触发器体积可以激活陷阱、警告敌人或向玩家奖励点数。这可以通过对陷阱、敌人和点数奖励器都执行ReactToTrigger函数实现。而陷阱、敌人、奖励点数器可能并没有除了UObject以外的共同父类。所以,我们通过接口确保实现一组通用的功能。
接口声明
接口类使用UINTERFACE宏,直接从UInterface继承而不是UObject继承。
1 | UINTERFACE([specifier, specifier, ...], [meta(key=value, key=value, ...)]) |
GENERATED_BODY():UE的一个宏?用于标记C++类的特定位置。必须放在类、结构或枚举的开头?必须与其他宏配合使用。如果忘记使用GENERATED_BODY,相关类、结构或枚举无法被UE的反射系统识别,也无法被蓝图使用。
注意UINTERFACE类不是实际的接口,它是一个空白类,它的存在只是为了向虚幻反射系统确保可见性。将由其他类继承的实际接口必须使用相同的类名,但是开头从U改为I。
前缀为U的类不需要任何构造函数或任何其他函数,而前缀为I的类将包含所有接口函数
如果想要让蓝图实现此接口,则需要Blueprintable说明符,即UINTERFACE(Blueprintable)
接口说明符:
- Blueprintable:公开该接口,以便蓝图可以实现该接口。如果接口包含BlueprintImplementableEvent和BlueprintNativeEvent函数以外的任何函数,则接口不能暴露给蓝图。使用NotBlueprintable或者meta=(CannotImplementInterfaceInBlueprint),指明在蓝图中不能安全实现。
- BlueprintType,将该类公开为可用于蓝图中的变量的类型
- DependsOn=(ClassName1, ClassName2, …):所有列出的类都将在该类之前编译。ClassName必须指定同一个包中的一个类。
- MinimalAPI:只导出类的类型信息供其他模块使用。类型可以被转化,但类的函数不能被调用,除内联函数外。对于不需要在其他模块中访问其所有函数的类,可以避免导出所有信息,从而缩短编译时间。
在C++中实现接口
include相应接口头文件
继承自I开头的接口类
声明接口中的函数
有多种方法可以在接口中声明函数,每种函数都可以在不同的上下文中实现或调用。所有这些函数都必须在I开头的接口类中声明,并且它们必须是public修饰的,以便外部类可见。
仅C++接口函数
即在接口类的头文件中声明一个C++的虚函数,不需要用UFUNCTION标识。这些函数也必须是虚函数,以便在实现它们的类中被Override。
在派生类中实现接口时,可以创建和实现该类特有的Override。
示例代码:
1 | UCLASS(Blueprintable, Category="MyGame") |
override关键字,用于显式声明派生类中的某个成员函数重写了基类的虚函数,让编译器在编译阶段检查该函数是否真正覆盖了基类中的虚函数。
但以上方式声明的C++接口函数对蓝图是不可见的。
蓝图可调用接口函数
要创建一个蓝图可调用的接口函数,必须完成以下操作:
- 函数声明前,使用UFUNCTION修饰,并在宏内使用BlueprintCallable说明符
- UFUNTION宏内还必须使用BlueprintImplementableEvent或BlueprintNativeEvent说明符
- 蓝图可调用接口函数不能为虚函数
如果蓝图可调用接口函数没有返回值,UE会将该函数视为蓝图中的事件。
BlueprintImplementableEvent:带有该说明符的函数不能在C++中被Override,但可以在实现或继承该接口的蓝图中被Override。
BlueprintNativeEvent: C++和蓝图都可以Override,但是蓝图实现会覆盖C++默认实现(C++实现要用_Implementation后缀修饰)
从C++中调用蓝图事件,即安全地在Blueprintable接口上调用蓝图可调用接口函数,必须使用特殊的静态Execute_函数包装器。
1 | bool bReacted = IReactToTriggerInterface::Execute_ReactToTrigger(OriginalObject); |
接口函数类型
分为Base、Implementation、Execute
- Base function:位于基本接口类中,为可以在子类中实现的函数定义,用于接口和实现仅在C++中定义的情况。
- Implementation wrapper: 位于实现接口的C++类中,为在C++中实现接口功能,用于只调用C++实现,不调用任何蓝图Override的情况。
- Execute wrapper: 由UE的反射系统自动创建,为促进C++和蓝图中定义的实现之间的通信,用于调用包括C++和蓝图Override的函数实现的情况
确定类是否实现了接口
为了与实现接口的C++和蓝图类兼容,使用以下任意函数来确定一个类是否实现了接口:
1 | bool bIsImplemented; |
OriginalObject是一个对象。对于示例1,通过GetClass方法获取对象的类信息,再通过ImplementInterface方法检查是否实现某个接口,
示例2直接通过OriginalObject调用Implements<InterfaceName>()方法
示例3定义实际接口的指针ReactingObject,调用Cast<>()方法,对OriginalObject进行转化,如果返回值不为Null,这只说明OriginalObject在C++中实现了该接口
转换为其他虚幻类型
UE的转换系统支持从一个接口转换到另一个接口,或者从一个接口转换到一个虚幻类型。
示例如下:
1 | /* ReactingObject is non-null if the interface is implemented */ |
安全地存储对象和接口指针
要存储对实现特定接口的对象的引用,可以使用TScriptInterface。如果有一个实现接口的对象,可以初始化TScriptInterface如下:
1 | // 定义一个指向UMyObject的指针和一个TScriptInterface |
对象
概述
UE引擎中所有对象的基类都是UObject。而UCLASS宏的作用是标记UObject的子类,以便UObject处理系统可以识别它们。
UCLASS宏为UObject提供了一个UCLASS引用,用于描述它在UE引擎中的类型。即当使用UCLASS宏声明一个类时,UE会为该类生成一个UClass类型的对象。该对象包含关于该类的所有元数据,例如类的属性、方法等。每个UClass还维护一个称作类默认对象的对象,简称CDO。CDO本质是一个默认的模板对象,由类的构造函数生成,此后不再修改。
给定对象实例的UCLASS和CDO均可被检索,但一般视为只读。可以使用GetClass方法访问对象实例的UClass。
UObject创建
UClass中包含一组定义类的属性和函数。这些都是在标准C++代码基础上,使用UE引擎特定的元数据进行标记,例如UPROPERTY、UFUNCTION。
UObject绝不能使用new和delete创建和删除对象,相当于手动管理内存,而是要通过虚幻引擎进行内存管理和垃圾回收
UObject不支持构造函数参数。所有C++的UObject都是在引擎启动时初始化的,引擎会调用它们的默认构造函数。如果没有默认构造函数,UObject将无法编译。对于Actor和Actor Components,初始化功能应放在BeginPlay方法中。UObject只应在运行时使用NewObject或者CreateDefaultSubobject来构造。
NewObject<class>:创建一个新实例,并为所有可用创建选项提供可选参数。提供广泛的灵活性,包括自动生成名称的简单用例。
CreateDefaultSubobject<class>:创建一个组件或子对象,该组件或子对象提供了一种方法,用于创建一个子类并返回父类的实例。
例如:
1 | Mesh1P = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("CharacterMesh1P")); |
UObjects提供的功能
UObjects提供的功能如下:
- 垃圾回收
- 引用更新
- 反射
- 序列化
- 默认属性变化自动更新
- 自动属性初始化
- 自动编辑器整合
- 运行时类型信息可用
- 网络复制
大部分这些也适用于UStruct,但UStruct被当作数值类型处理并且不会垃圾回收。
UHT(虚幻头文件工具)
为利用UObject派生类型所提供的功能,需要在头文件上为这些类型执行一个预处理步骤,以核对需要的信息。该预处理步骤由UnrealHeaderTool,即虚幻头文件工具,简称UHT执行。UObject派生的类型需要遵守特定的结构。
对于.cpp文件的实现,与普通C++类似,但是其在头文件(.h)中的定义必须遵守特定的基础结构,这样才能在UE引擎中正常工作。建议就是使用编辑器来新建C++类,这是设置格式正确头文件的最简单的方法。
假设UObject派生类名为UMyObject,创建它的项目名为MyProject,那么该基本头文件如下所示:
1 | #pragma once |
UE特有的部分为#include “MyObject.generated.h”,即.generated.h,这一般是文件中最后一条#include指令。
这是由UHT自动生成的一个重要文件,用于支持引擎的反射系统、蓝图系统、序列化、网络功能等。
还有UCLASS宏和GENERATED_BODY()宏,后者不获取参数,但会对类进行设置,以支持引擎要求的基础结构。
更新对象
Ticking为UE引擎中对象的更新方式。所有Actor均可在每帧被tick,便于执行必要的更新计算或操作。Actor和Actor组件会在注册时自动调用它们的Tick函数,然而UObjects不具有嵌入的更新能力,即它默认没有Tick函数。要使得UObject在每帧更新,需要继承自FTickableGameObject类。通过继承该类并实现Tick函数。
大部分游戏内对象都是Actor,可以按用户设置的最短间隔进行tick,而不是每帧tick
销毁对象
对象不被引用后,垃圾回收系统会自动进行对象销毁。这代表没有任何UPROPERTY指针、引擎容器、TStrongObjectPtr或类实例会拥有任何对它的强引用。
无论对象是否被垃圾回收,弱指针对其都没有影响。
垃圾回收器运行时,寻找到的未引用对象将被内存中移除。此外,函数MarkPendingKill可在对象上直接调用。此函数将把指向对象的所有指针设为NULL,并从全局搜索中删除该对象。该对象将在下一次垃圾回收时被完全删除。
但是MarkPendingKill已被MarkAsGarbage所替代,这个函数仅用于追踪那些旧对象,即不再被使用的对象。如果gc.PendingKillEnabled=true,那么所有标记为PendingKill的对象会被垃圾回收器自动清空并销毁。
IsValid用于检查对象是否为null或垃圾,但也可以用适当的约定来替代,例如规定每次调用Actors的OnDestroy事件时清空指向Actors的指针。
就Actor而言,即使该Actor已经被调用了Destroy,并且已从关卡中移除,它也不会被垃圾回收,直到它的所有引用都被释放。
如果不手动清除Nullptr,则应使用IsValid()调用替换现有的nullptr检查。
UObject实例创建
NewObject(),最简单的UObject工厂方法。接收一个可选的外部对象和类,并创建一个带有自动生成名称的新实例。
源码部分展示:
1 | template< class T > |
Outer,是一个常见的概念,表示该对象的父对象或者外部容器对象。如果未提供,则默认使用GetTransientPackage(),代表创建一个临时对象
Class,指定对象的类
返回值为指向指定类的生成的实例的指针
文档中还提到了NewNamedObject和ConstructObject,但好像不常用,留待之后更新。
对象标记:
EObjectFlags枚举类用于快速简洁地描述对象,描述对象类型、垃圾回收如何处理、对象在其生命周期中所处的阶段等。还有特殊的全掩码或无掩码和预定义的标记组。
查阅即可。
虚幻Object处理
通过宏标记,将类、属性和函数转变为UClass,UProperty,UFunction,得以让UE引擎得以访问
自动属性初始化
在调用构造函数之前,UObject在初始化时自动归零,即针对所有成员变量,这一过程发生在调用构造函数之前。然后通过调用构造函数,给成员变量赋予自定义的初始值。
这确保构造对象时,不会出现未初始化的成员。
自动更新引用
当AActor或UActorComponent被销毁或者从运行中删除时,所有可以被反射系统识别(反射系统可见的)的对它的引用都将自动清空(被置为nullptr)。这防止了悬空指针,并且使得对空指针的检查更为可靠。所谓可以被反射系统识别的那些引用包括UPROPERTY标记的引用以及UE容器类中的指针(如TArray)等
1 | // C++原始指针: |
但这并不代表我们需要将所有UObject*变量都必须是UProperty。可以考虑使用TWeakObjectPtr,声明弱指针,不会妨碍垃圾回收,但可以查询有效性,再接受访问,并且它所指向的Object要被销毁时,它将被设置为空。
另一种自动清空的情况是在编辑器中对资产使用了强制删除,则所有指向该资产的UProperty引用都将被自动置为nullptr。因此,所有与资产相关的代码要考虑这种情况。
序列化
当UObject被序列化时,其所有UProperty值都将被自动写入或读取,除非这些变量被显式标记为transient(瞬态,不参与序列化)或者它们的值没有改变,仍然是构造函数设置的默认值。
即当在场景中放置一个AEnemy对象,将其Health设置为500,然后保存场景并重新加载。UE会自动保存对象的当前状态到关卡文件中,在重新加载时,还原该对象的状态,即Health为500.我们并不需要写额外的代码来手动保存或加载Health属性。
当属性被增加或移除时,即增加或移除UProperty成员时,UE会自动处理。即新的属性将使用新的类默认对象CDO中的默认值。而被移除的属性,旧的序列化数据会自动忽略这些属性,确保不会发生问题。
如果想要自定义序列化逻辑,则需要重写UObject::Serialize函数,可以用来检测数据错误、检查版本号或执行自动转换或更新。
更新属性值
当类默认对象CDO被更改时,引擎将尝试在加载该类的所有实例时对这些实例都进行相应更改。对于给定Object实例,如果变量值与旧CDO中的值相匹配,则更新为新的CDO中的值。如果变量包含任何其他值,则系统假设该值为故意设置的,不会对其进行更改。
即假设场景中有一群敌人,默认血量为100,将其中一个改为300。此时如果将默认血量修改为150,则那些血量为100的敌人会变为150,而300的还是300。
运行时类型信息和类型转换
由于UObject是UE反射系统的一部分,它们始终知道它们是哪些UClass,并可以在运行时做出有关类型的决定和类型转换。
并且原生代码中,每个UObject类,规定Super类型定义设置为其父类。
例如Super::Speak(),代表调用父类的Speak方法。
另外,使用模板化Cast函数或者ISA查询,可以安全地将Object从基类转换为更衍生性的类。
例如:
1 | class ALegendaryWeapon : public AWeapon |
垃圾回收
GC机制通过清理那些不再被引用或显式标记为销毁的UObject,定期进行内存管理。引擎构建一个引用图,来确定哪些UObject仍然在使用,哪些已经成为孤立对象。该图存在根节点,一组被指定为根集的UObject。任何UObject都可以被添加到根集中。垃圾回收时,引擎会从根集开始,通过搜索已知UObject引用的树来追踪所有被引用的UObject。任何没有被引用的UObject,即在树的搜索中找不到的对象,都会被认为不再需要,并会被移除。
对于Actor通过调用Destroy函数显式标记为销毁。组件Components可以通过DestroyComponent函数显式销毁,但通常会在它们所属的Actor被销毁时自动销毁。
项目设置中对垃圾回收可以做如下设置调节垃圾回收器性能:
- 创建垃圾回收UObject簇(Create Garbage Collector UObject Clusters):默认打开,相关Object将被分组到一起归入一个簇中,这样只需要检查簇自身即可,而不必检查每个Object。
- 启用Actor簇(Actor Clustering Enabled):打开该选项,并将bCanBeInCluster变量设置为true,则Actor可以成簇。默认情况下,该选项关闭,除了静态网格体Actor和反射捕获组件。
- 待清除终止对象之间的间隔(Time Between Purging Pending Kill Objects):通过缩短回收间隔,可以减少将在下一次可访问性分析阶段发现的无法访问的对象的可能数量,并避免同时清除大量Actor时可能会发生的卡顿。
属性
属性声明使用标准的C++变量语法声明,前面用UPROPERTY宏来定义属性元数据和变量说明符。
即
1 | UPROPERTY([specifier, specifier, ...], [meta(key=value, key=value, ...)]) |
核心数据类型
整数:uint8~64,int8~64
整数属性现在可以以位掩码形式公开给编辑器。在meta分段中添加bitmask
示例:
1 | UPROPERTY(EditAnywhere, Meta = (Bitmask)) |
添加此元标记后,可以使整数在编辑器中作为下拉列表的形式进行编辑。
蓝图可调用函数的整型参数也可以表现为位掩码,方法是在参数的UPARAM指定器上添加Bitmask元标签。
浮点类型:使用C++标准浮点类型,即float和double
字符串类型:
FString:典型动态字符数组字符串类型
FName:全局字符串表中不可变且不区分大小写的字符串的引用。
FText:指定用于处理本地化的更可靠的字符串表示。
大多数情况下,UE依靠TCHAR类型来表示字符。TEXT宏可用于表示TCHAR文字。