0%

UnrealC++

Unreal C++

Unreal C++

 

命名规范

AActor派生的类以A开头、UObject派生的类以U开头、枚举以E开头、接口以I开头、模板类以T开头、SWidget派生的类以S开头、其余类以F开头。

 

容器

 

标准C++

  • char/short/int/long/float/double

UEC++

  • int8/int16/int32/int64/float/double

 

字符串

  • FString——类似C++中的String,含有多种字符串操作方法,能被修改,可多次存储。
  • FName——表示字符串标识符的类,通常用于在代码中唯一标识对象、变量、函数等。无法修改、大小写不敏感、查询对比快。只是一个全局名字表的索引。
  • FText——处理本地化文本的类,可以理解为“要被显示”的字符串,UE中有内置的本地化支持。

 

容器

  • TArray——std::vector

    • Add——插入数组尾部
    • Clear——清除
    • Length——返回个数
  • TMap——std::map

    • Add
    • Clear
    • Contains——根据键查找是否存在键值对
    • Find——根据键返回值
    • Keys——找出所有键
    • Length——返回键值对数量
    • Remove——根据键去除一个键值对
    • Values——找出所有值
  • TSet——std::set

    • Add
    • Clear
    • Length
    • Remove

UE4常用,且支持反射系统

 

反射

首先理解一下反射这个词意味着什么。

反射是指程序在运行时动态获取对象属性与方法的一种机制

这一句用来描述广义上的反射是没有任何问题的,从技术定义上讲,反射就是用来解决运行时调控程序运行的一种方式,不过在不同的应用场景中,反射具有不同的解释。

对于游戏开发来说,反射更适合这样的定义。

反射是运行时动态获取属性、调用方法、序列化与通信的基础,也可为开发者提供运行时获取类型信息、使用脚本语言编写逻辑并较为方便地与宿主引擎通信的能力

 

对于游戏开发er来说,理解反射,获取反射相关知识之前先默认都熟悉某引擎,至少使用过相关引擎做过一些demo。

那么肯定会对这个场景很熟悉:

  • 在场景中拉入一个角色,感觉角色的血量需要调整,于是在编辑器里甚至在游戏运行过程中动态调整血量的最大值。

这个血量包括任何可能需要调整的数据,包括obj的transform等等一系列属性。

这是一个最为常见的场景,也是游戏引擎需要实现的最为基础的功能了。

你也可以在代码中直接设置数据,但是这样做很明显十分麻烦,因为数据肯定是一个需要实时调整的值,所以引擎需要实现不动代码的情况下去对一些数值进行设置,这样再简单不过的一个动作本身其实涉及了很复杂的反射系统。

曾经对反射系统长期感到困惑,因为没有意识到引擎运行本身就是程序在运行,错把“游戏开始”和“引擎开始”搞混了。

也正印证了反射的定义——满足“动态”获取对象属性方法等一系列需求。

 

其他应用场景

游戏引擎在反射上的获益其实非常多,只不过大部分都在程序员不知不觉中完成了。

  • 蓝图属性映射C++属性,修改蓝图属性引用到运行时。
  • Socket数据包的序列化与反序列化。
  • 调用Lua方法,Lua代替蓝图逻辑,Lua调用C++方法。
  • 运行时动态加载库。
  • 根据字符串找到类。
  • .......

 

实现思路

1、在程序打包之前,将程序中相关类信息、类中属性信息、属性类型、成员函数信息等等以元数据的方式存储下来。

元数据:描述数据的数据

2、初始化类之前利用元数据生成了类型对象,这个类型对象包含实现实例对象反射的一系列方法,也包含了一些其他方法。

3、实例化实例对象,一个类型的所有实例对象都含有一个指针指向唯一的类型对象。

几乎所有的具有反射功能的语言实现反射都是用的这一套思路,一般的文章介绍到这里也就结束了,这里直捣黄龙把反射实现具体功能的一套流程都整理出来。

以上面说的动态调整属性为例:

4、选中相应对象后,绘制器可以直接获取到这个对象所代表的类的标识,并通过这个标识在元数据中查到这个类的相关信息。

5、通过类信息获取到这个类的对象,也就是当前操作对象,绘制器直接将这个类(组件)的所需要打印的属性打印出来。

6、获取到对象的属性后,即可以实时通过反射实时更新这个对象的属性了。

对于UE来说,可以通过UObject的GetClass来获取某类的实例,这个是通过直接存储这个类实例的指针来做到的。不同语言都会有一个类似的直接获取到实例的方式,不过会有一定限制条件,例如至少要先获取到类信息才能获取到具体对象。

对于反射来说,反射本身也包装了一套获取实例的方式,例如java中的Class AClass = A.class;本身就运用反射获取到了A的类信息,然后底层可能就调用了类似GetClass的方法获取到了类实例对象。

在UE中,针对继承自 UObject 的类,可以通过 GetClass() 来获取 UClass 实例,但是如果想直接获取某个类型的 UClass,则可以通过 StaticClass<UObject> 或者 UObject::StaticClass() 来获取。

 

原因

写这篇文章的时候是2024年,Java、C#都早早实现了官方的反射功能,早些年为了让可执行文件足够“小”,编程时不会想着使用额外的内存空间将类的相关信息提前存储好,毕竟C++的结构体满足可以无需序列化直接传输的功能。

不过C++结构直接传输是不易读的,随技术发展,网络带宽与硬件内存限制不再是问题,这个时候的主要矛盾是数据需要序列化成xml或者json等文件来进行传输。序列化本身是一件比较麻烦的事,所以Java率先实现了反射功能,让类在序列化时可以直接通过类的元数据动态获取类的属性与值,而不需要一个个手动序列化。

C++到底有没有保存类的信息,作为菜鸟的我不想去追究,但是可以知道的是虚幻引擎为了这个功能直接掀桌手动实现了一整套的C++反射功能,这也是虚幻当中最为重要最为复杂的一个系统了。

 

UE反射系统流程

  • 标记
  • 分析
  • 生成
  • 编译
  • 使用

 

标记

也就是标签,对Class前添加UClass标签,对Function添加UFunction标签,让编译器把被标记的内容纳入反射系统中。

对于文件来说,需要添加include文件。

#include "FileName.generated.h"

UENUM()、UCLASS()、USTRUCT()、UFUNCTION()、UPROPERTY()

标签本身是一个空宏,仅提供标记功能,实际并不影响原代码编译过程。

 

分析

https://zhuanlan.zhihu.com/p/89220125 我们要知道,我们写的UE4代码不是标准的C++代码,是基于UE4源代码层层改装了很多层的(如反射),所以,UHT将UE4代码转化成标准的C++代码,而UBT负责调用UHT来实现这个转化工作的,转化完以后,UBT调用标准C++代码的编译器来将UHT转化后的标准C++代码完全编译成二进制文件,整体上看,UHT是UBT的编译流程的一部分。

UBT(UnrealBuildTool),管理编译配置调用编辑器来编译UE代码。如果任意一个头文件从上一次编译起发生了变化,就会调用UHT

UHT(UnrealHeaderTool),解析UE代码。

标记本质上是一个宏,在分析阶段被替换成反射相关函数(todo)。

 

生成

生成含反射数据的C++代码(头文件.gen.cpp)

生成各种帮助函数以及thunk函数(头文件.generated.h)

todo,,,

 

编译

生成的代码与源代码一起被编译,这个时候可以将类信息收集在二进制文件中。

 

内存池分配

Binned/Binned2,和C++内存管理类似。

参考:C++内存管理 | Coding中。。。 (jiuriri.com)

 

垃圾回收

常用的垃圾回收算法:

  • 标记清除
  • 引用计数
  • 保守式GC

 

标记清除

分为标记、清除、合并三个阶段。

在垃圾回收中,被引用的对象被视为“有用的”对象,没有被任何对象引用的叫做“不可达”对象。

不可达对象也就是需要被清除的对象。

标记阶段会从根对象开始,寻找根对象的引用并对引用对象进行标记,这个算法会遍历所有的被标记的对象寻找该对象的引用并做上标记,不断增加标记的对象的数量。直到所有该遍历的对象都被遍历完时遍历停止,此时剩下没被标记的对象就称作不可达对象。

清除阶段会直接从整个堆开始遍历所有对象,如果遍历到没有标记的对象,就将它回收,并将这块空间连接到另一个“空闲链表”中。

合并阶段就是在清除阶段中,如果发现某两块回收的内存是连续的,则将他们进行合并成一个大内存。当有对象需要分配内存时,遍历空闲链表,寻找到合适大小的块进行分配。

  • 优点:实现简单,就地GC
  • 缺点:容易生成碎片块,分配速度慢
  • 改进:多个空闲链表记录不同大小的块,根据对象大小进入不同的分块中,可以加快查找时间;把堆区分成不同大小的区间,每一个区间内只存有相同大小的块,对象直接更具大小匹配不同区间,提高内存使用效率。

 

引用计数

额外分配内存块为每个对象存储有哪些对象引用了自己,当引用数为零时,即刻回收对象内存。

  • 优点:即刻回收,不需要等待GC判断;不需要遍历链表
  • 缺点:计数器频繁计算占用CPU效率;占用额外空间;实现复杂;循环引用无法回收
  • 改进:延迟引用,只记录引用数的变化存在某表中而不立即计算他,待到一定条件的时候去遍历变化表来释放0引用内存;减少计数器位数,哪怕数量溢出也不管他,把他剔除引用技术的GC体系,可以考虑等待利用标记清除来GC他。

 

GC复制算法

把有效的对象全复制到另一块内存当中,然后清除原内存的所有对象,这样不会产生碎片,遍历次数也少,具体就不展开了。

 

UE中的GC利用的是标记清除算法

 

智能指针

减轻内存分配和追踪的负担

  • TSharedPtr
  • TUniquePtr
  • TWeakPtr
  • TSharedRef(和TSharedPtr的唯一区别就是不能为空)

虚幻引擎的智能指针库不能与UObject系统同时使用。——UObject本身就配置了GC

和标准C++智能指针不同的是配备了枚举类ESPMode来控制智能指针是否线程安全。

TSharedPtr<int, ESPMode::ThreadSafe>

使用Reset()来重置指针,原来的内存不一定会立即析构,重置共享指针,会影响到所有指向它的弱指针。

UE自己实现一套指针的原因:

  • 保证智能指针类型方法和名称与UE代码体系标准一致,其他容器无缝协作
  • 可选的线程安全,保证性能,具有灵活性

weaptr补充:

进程控制块内有sharedcount也有weakcount,sharedcount为0时对象析构,但只有sharedcount与weakcount同时为0时,进程控制块才能释放。

weakptr没有访问对象的能力,只用使用lock来将其转化为sharedptr,如何对象已经销毁,则返回一个空sharedptr。

TSharedRef必须要指向一个非空对象,必须初始化。

所以它不需要有Reset和Isvalid。其他特征和TSharedPtr一模一样。

使sharedptr的使用更加方便化,比如由于sharedptr可能为空,所以得多做一步判空。

TUniquePtr的赋值拷贝构造函数被delete标记,只能通过MoveTemp转移内存所有权。

 

方法

  • MakeShard/MakeUnique:对于进程控制块,一次性将其与对象需要的空间申请出来,而不是正常情况下的分两次。
  • MakeShareable:将一个普通指针转换为智能指针
  • TSharedFromThis:允许类的对象能够在自身成员函数中安全地创建 TSharedPtr 指向自身,使用时类需要继承TSharedFromThis< T>,在类中一般定义方法来调用AsShared()来返回出去。

    • 当一个类对象希望将自身以 TSharedPtr 的形式传递给其他对象或系统。
    • 当需要确保对象在其他地方仍有引用时不会被意外销毁。