游戏引擎架构#2 容器、内存、RTTI与反射

已发布在微信公众号上,点击跳转

背景:

作为游戏开发从业者,从业务到语言到框架到引擎,积累了一些知识和经验,特别是在看了好几遍《游戏引擎架构》后对引擎的架构感触颇深。

近段时间对引擎剖析的想法也较多,正好借着书本对游戏引擎架构做一个完整分析。此书用简明、清楚的方式覆盖了游戏引擎架构的庞大领域,巧妙地平衡了广度与深度,并且提供了足够的细节,使得初学者也能很容易地理解其中的各种概念。

目标是掌握游戏引擎架构知识,方法是跟随《游戏引擎架构》这本书、结合引擎源码、自己的经验,分析游戏引擎的历史、架构、模块。最后通过实践自主引擎的开发来完成对引擎知识的掌握。

游戏引擎知识面深而广,所以对这系列的文章书编写范围做个保护,即不对细节进行过多的阐述,重点剖析的是架构、流程以及模块的运作原理。

虽然参考了《游戏引擎架构》这本书,但由于它的部分知识太陈旧,所以我不得不将这些知识重新深挖后总结自己的观点。

概述:

本章开始对引擎中的重要的模块和库进行详细的分析,我挑选了十五个库和模块来分析:

  1. 时间库
  2. 自定义容器库
  3. 字符串散列库
  4. 内存管理框架
  5. RTTI与反射模块
  6. 图形计算库
  7. 资产管理模块
  8. 低阶渲染器
  9. 剔除与合批模块
  10. 动画模块
  11. 物理模块
  12. UI核心框架
  13. 性能剖析器的核心部分
  14. 脚本系统
  15. 视觉效果模块

本篇内容为列表中的前五个。

正文:

由易到难,我们从最简单的说起。

时间库

时间库最简单使用的也最多,在引擎中的每个模块都会使用到。其时间信息包括:真实时间、游戏时间、全局时间、相对时间,时间缩放因子。 其常见问题为各平台的时间获取方式不同,因此基本都会针对每个平台分别实现一个时间获取函数。

测量时间在时间库中占重要位置,游戏中循环调用间隔、帧率、以及移动速度都会使用时间测量的单位来进行。

通常引擎都会用帧率调控的方法来稳定帧率,例如我们在引擎上设置了30帧/s,那么当本帧耗时小于33ms时,则在主循环结束时让线程在剩下的时间里休眠。 反之,如果主循环耗时大于33ms,则等待到下一帧再执行。通过这样调控的方式来稳定帧率。

垂直同步是另一种帧率调控方法,由于前置缓冲区和后置缓冲区在交换时会有部分消隐问题导致画面撕裂,因此垂直同步会等待消隐时间,错过了则等待下一次消隐区间,这会让画面更加稳定,但并不保证以某个特定帧率运行而且时常会降低帧率,因此很少有游戏使用这种技术。

当使用测量时间时,通常都会以帧的形式更新时间跨度,例如计算每帧之间的时间、计算动画当前帧、移动速度下当前帧的移动距离等。除此之外,最常用的如最大帧率、固定帧率、全局时间缩放等,这些因子在很多引擎模块中也常会用到,例如动画时间、音频时间,粒子生命时间等。

自定义容器库

我们在使用的各式各样集合型数据结构也被称为容器,它们的任务都是一样的,存储及管理多个数据元素。 然而细节上各种容器运行方式会有一些差异,它们各自也有各自的优缺点。它们包括但不仅限于:

  1. 数组(Array)
  2. 动态数组(Dynamic Array)
  3. 链表(Linked List)
  4. 堆栈(Stack)
  5. 队列(Queue)
  6. 双端队列(Double-ended Queue)
  7. 优先队列(Priority Queue)
  8. 树(Tree)
  9. 二叉查找树(Binary Search Tree,BST)
  10. 二叉堆(Binary Heap)
  11. 字典(Dictionary)
  12. 集合(Set)
  13. 图(Graph)
  14. 有向无环图(Directed Acyclic Graph,DAG)

我们在操作容器时,常用的操作有:插入、移除、顺序访问(迭代)、随机访问、查找、排序。

这里介绍下第三方标准库的优缺点:

许多引擎都会提供常见的自定义容器实现,建立自定义容器类一般都处于如下原因:

  1. 完全掌控:控制数据结构的内存需求、使用的算法、内存分配规则。
  2. 优化性能:针对某个业务做出适合性的调整,或借助某些硬件功能可以优化数据结构和算法。
  3. 可定制性:根据业务需要增加第三方库没有的功能、例如容器性能调试、内存统计、内存快照等。
  4. 消除外部依赖:当第三方库出现问题时,需要依赖外部的团队,这可能无法提供及时的服务。自定义容器,能在库出现问题时做到可自行修复。
  5. 并发同步:常用第三方容器在线程间并发同步上的操作可能没有你想的那么完美,用自定义容器就能为自己定制更合适的同步机制。

Unreal容器一看名字就知道是什么,它们包括: TArray、TArrayView、TBasicArray、FBinaryHeap、TBitArray、TChunkedArray、TCircularBuffer、TCircularQueue、 TDiscardableKeyValueCache、TResourceArray、FHashTable、TIndirectArray、TLinkedList、TIntrusiveLinkedList、TDoubleLinkedList、TList、 TMapBase、TSortableMapBase、TQueue、FScriptArray、TSet、TSortedMap、TSparseArray、TStaticArray、TStaticBitArray、TTripleBuffer、TUnion等等

Unity容器方面基本上与Unreal差不多,Unreal有的Unity也基本有,各自也都有一些特殊用途的容器,只是这些容器代码分散较开,说明容器部分的架构Unity编排的相对混乱一些。

字符串散列库

字符串在程序中占据了很大的内存,通常有这三个问题,

  1. 拷贝多
  2. 拼接多
  3. 判断相同字符串

拷贝多,原因:

在函数的形参和返回值上,常常会使用实例的方式去做,这导致字符串拷贝变的频繁。每次字符串拷贝都需要经历,内存分配,内存拷贝,内存销毁这三步骤,可想而知字符串在拷贝上的消耗非常大。

拷贝多,解决方案:

通常是由于业务代码引起的,因此也只有调整业务代码才能缓解。包括修改函数形参类型和返回值类型。

拼接多,原因:

每个字符串在拼接完毕后通常都会生成一个临时的新的字符串,这导致拼接的那几个字符串内存被丢弃而浪费。

拼接多,解决方案:

1.编码规范问题,具体业务具体分析 2.用对象池方式重复利用内存

判断相同字符串,原因:

相同字符串的判断逻辑在代码中占比通常比较大,特别是在业务逻辑中。如果只是单纯的比较两个字符串的每个字符,效率会变得非常低下

判断相同字符串,解决方案:

使用HashID就能解决这个问题,即把字符串计算成Hash值,用数字比较来代替字符串比较。

注意,并不是一定要让字符串做Hash才能增加效率,如果相同字符的判断操作比较少,而Hash计算和加入容器的操作比较多,那么就会得不偿失。

Unreal引擎的字符串FString构建:

1.用TArray动态数组作为容器 2.通过TArray扩容构建和拼接字符串 3.TArray使用专门的内存块作为管理,能做到提前分配和释放回池

Unreal引擎字符串的FName:

1.FName和FString有些不同,FName有HashID,是含有一个uint32整数的结构体,而FString则没有 2.FName的字符串存储在FNameEntry实例中 3.每个FName都有自己的HashID,用于比较相同的字符串 4.FNameEntry是一个Char数组+HashID+Header的实例 5.通过FNamePool存储FNameEntry,FNamePool就是一个字典容器,存储着所有的FNameEntry实例 6.FName通过HashID从对象池中获得字符串实体FNameEntry 7.也可以直接通过HashID比较两个字符串是否相同,从而提高效率

Unity与Unreal不同,使用对象池方式重复利用字符串内存,并且没有字符串散列机制(因为引擎内部不需要)。

内存管理框架

先介绍下操作系统自身的内存管理方式:

1.操作系统以进程为单位来运行每个程序。 2.同时为每个进程分配了一个独立的虚拟空间。 3.每个虚拟空间里有内核空间和用户空间之分。 4.内核空间为共享库和内核程序使用的堆栈空间。 5.每个虚拟空间都会拆分成多个段来存储各类数据和程序指令 6.虚拟内存和物理内存之间使用页表进行映射,因此在虚拟空间中连续的内存在物理空间中不一定连续。 7.操作系统会将不使用的内存块交换(Swap)出去成为硬盘空间的一部分,当需要访问时再交换(Swap)回来。

下面我画了3个图,用图来解释会更容易理解些:

图1

图2

图3

以上三张图完整的体现了操作系统内存的运作方式。

1.图1描述了,每个进程都有各自的独立虚拟空间,分为用户空间和内核空间,并且32位和64的空间大小不同。 2.图2描述了,一个虚拟空间中有很多个段,其中包括栈段、堆段、代码段、数据段等。 3.图3描述了,虚拟内存和物理内存通过页表映射,物理内容与硬盘会有一个Swap机制。

写程序时我们比较关心堆内存,那么操作系统是如何管理堆内存的呢?

我们来了解下堆内存的分配和释放机制

图1:

图2:

图3:

为了快速学习,画了三张图方便大家理解堆内存分配机制: 1.图1描述了,堆内存会切割成不同的块 2.图2描述了,堆内存分配设计为大堆、小堆和缓冲堆 3.图3描述了,堆内存分配流程,先找索引再从缓冲区中找最后切割大块内存

一句话概括为,堆内存以分块方式切割设计,并分为大堆、小堆、和缓冲堆,通过索引和缓冲区来加速内存的分配和释放。

游戏引擎中通常不依赖操作系统内存分配机制,原因是对于引擎来说操作系统的内存分配效率太差,因此每个引擎都有自建的内存管理框架。

自建的内存管理框架通常由一些分配规则构成,这些分配规则通常会写成内存分配器(Allocator)被用于引擎的各个模块中。

虽然内存管理框架有很多种,但内存分配的规则都是相似的,每一种内存分配规则我们称为内存分配器(Allocator),下面我们简单列举一下内存分配器的种类及其规则。

这里列举了11种(还有更多)内存分配器并分别用一句话概括它们:

  1. 线性内存分配器,分配一块大内存,并不断向前分配。
  2. 环形内存分配器,支持循环利用的内存分配器。
  3. 双端内存分配器,两个模块共享一个线性内存分配,并从两端分别进行分配。
  4. 固定大小内存分配器,把一大块内存拆分成固定大小的N个内存块,每次分配一块。
  5. 泛化的固定大小内存分配器,拆分成M个大块内存,每个大块内存都有自己固定大小的N个内存块, 且不同块间的小内存块的大小不同。
  6. 散列式页内存分配器,按某个固定大小的页拆分内存块,用多叉树索引方式连接内存块,一次分配多页并调整树形索引。
  7. 栈型内存分配器,不断向前分配,并按先进后出的原则回收内存。
  8. 动态合并内存分配器,从一大块内存块开始分配,分配时不断切割,并在回收时合并相邻的内存块。
  9. 大小池内存分配器,有大内存池和小内存池之分,大小内存池的分配策略不同,小内存池通常使用泛型固定大小内存分配规则,大内存池由于分配频率低因此分配方式更自由些,可切割可固定可合并。
  10. 批量内存分配器,多次连续分配多个小内存,先临时分配并立即使用,最后提交锁定内存区间。
  11. 线程安全的内存分配器,在分配时加入了更多的原子操作或同步锁,让多个同线程可以共享一个内存块。

引擎内存管理框架

不同种类的引擎中的内存框架其实都是大同小异:

1.大都使用内存分配器来搭建内存框架 2.多种类型的内存分配器混合使用很常见 3.每个模块都有自己的内存分配器 4.经常多个模块共享一个内存分配器

Unreal引擎的内存分配框架

Unreal有个HAL(Hardware Abstraction Layer)存放了大部分内存管理内容。

用图来表示为:

Unreal的内存管理框架总结:

1.Unreal有统一的内存分配和释放接口FMemory 2.内存统计,使用的是获取堆栈、回溯堆栈信息的方式 3.虽然内存分配器有很多种,但主内存分配器只能选择一种 4.容器有自己专属的分配器,它封装了FMemory接口 5.UObject等业务逻辑有专属的分配器,它封装了FMemory接口,并在此基础上做了垃圾回收设计。 6.引擎各模块大部分使用了FMemory接口来分配和释放内存,少数封装了自己的分配器。 7.主内存分配器中FMallocBinned为主内存分配器(1、2、3代),其分配原则为大小内存池。

Unity与Unreal稍有不同,它将GC和引擎内存分开管理,并且引擎独立模块有独立内存分配器自己管理。理论上来说这种做法会更好一些,每个模块需要有合适的内存分配算法。

RTTI与反射模块

RTTI(Run-Time Type Information)运行时类型检查,它提供了运行时确定对象类型的方法。

C++内建的RTTI通常很难满足我们的业务需求,特别是在需要做反射的业务上尤其明显,因此引擎通常都需要自建RTTI并增加反射系统。

先说C++内建的RTTI。与内建RTTI相关的运算符为:typeid 和 dynamic_cast

快速回顾下typeid 和 dynamic_cast两个运算符的原理

我在前面的文章中详细介绍过C++内存模型,可以参考下

《深度探索C++对象模型》

每个有虚继承或虚函数的C++类都会在运行时有一个type_info数据,通过虚表中的指针指向type_info数据。

type_info数据结构包含了类名字和父类指针,因此我们可以通过typeid来获得多态类的type_info数据。

dynamic_cast就是借助type_info来做的功能,它通过多态的type_info来识别是否可以转换类型。

例如:

Point2D pt2d = dynamic_cast<Point2D>(pt);

可以拆解为:
Point2D pt2d = NULL;
type_info type_pt2d = typeid(Point2D);
type_info type_pt = typeid(Point);
if(type_pt2d == type_pt || type_pt.before(&type_pt2d))
{
        pt2d = (Point2D)pt;
}
return pt2d;

自建RTTI

由于内置C++的RTTI时常无法满足业务需求,所以通常人们都会自己去建立自己的RTTI。

人们自建RTTI通常是因为:

1.有反射需求,例如查找类、调用函数、获取变量、遍历属性等。 2.优化性能,包括减少RTTI内存,提高查找type_info效率等。

Unreal引擎内部RTTI是默认被禁用的,它通过枚举或整数的方式来定制需要识别的类型。 Unity也是一样,在引擎内核中,无法使用内置的RTTI。

禁用仅限于引擎实时运行库上,在工具套件和编辑器上仍然被使用。

反射模块

反射模块是建立在自建RTTI之上的,因为反射需要通过RTTI来获取足够多的类、变量、函数的信息。

通常当我们通过RTTI来建立反射框架时,通常需要对type信息结构做些规划。例如在type结构中增加动态数组或字典容器将变量和函数的名字和类型存储起来。

示例代码例如:

struct RTTR_LOCAL class_data
{
    class_data(get_derived_info_func func, std::vector<type> nested_types)
    :   m_derived_info_func(func),
        m_nested_types(nested_types),
        m_dtor(create_invalid_item<destructor>())
    {}
    get_derived_info_func       m_derived_info_func;
    std::vector<type>           m_base_types;
    std::vector<type>           m_derived_types;
    std::vector<rttr_cast_func> m_conversion_list;
    std::vector<property>       m_properties;
    std::vector<method>         m_methods;
    std::vector<constructor>    m_ctors;
    std::vector<type>           m_nested_types;
    destructor                  m_dtor;
};

如上代码中我们看到,type实例结构中通常有属性结构实例容器、类型结构实例容器、函数结构实例容器等,目的就是为了存储变量和函数的信息。在实时运行过程中,当我们需要用字符串查找某个函数,或者查找某个变量时,则会从这些容器中去查找。

自建RTTI是通过对象信息来构建RTTI信息集合代码的,因此每个需要RTTI类的实例都需要写相应的编码,通常都会用宏和自动化来代替繁琐的实例化RTTI编码。

其基本思想是采用宏来代替常规的变量定义,这样我们就可以在宏函数中将定义的变量添加至自建的反射系统。

例如:

#include <rttr/registration>
using namespace rttr;

struct MyStruct { MyStruct() {}; void func(double) {}; int data; };

RTTR_REGISTRATION
{
    registration::class_<MyStruct>("MyStruct")
         .constructor<>()
         .property("data", &MyStruct::data)
         .method("func", &MyStruct::func);
}

Unreal的RTTI和反射用途较多,主要包括蓝图和垃圾回收。

与其他所有引擎一样,Unreal生成反射代码的步骤是:

1.利用特殊的宏来对变量做标记 2.对C++代码文件进行语法分析 3.用指定的宏提取出对应的数据 4.扫描工具生成RTTI代码 5.最后在初始化时运行生成的代码 6.启动时将收集到的数据保存

UE4定义了一系列的宏,来帮助开发者将自定义的字段和函数添加至反射系统:

UCLASS,告诉UE这个类是一个反射类。类必须派生自UObject
USTRUCT,可以不用派生自UObject。不支持GC,也不能包含函数
UPROPERTY,定义一个反射的变量
UFUNCTION,定义一个反射的函数
UENUM,告诉UE这是一个反射的枚举类。支持enum, enum class, enum namespace
UINTERFACE,定义一个反射接口类,只能包含函数
UMETA,反射的一些元数据定义,可以通过标签定义一些该变量的属性
UPARAM,定义函数的参数属性。主要就是显示名字和Ref属性
UDELEGATE,告诉UE这是一个可反射的delegate

例如:

// 一定要声明UCLASS
UCLASS()
class MYGAME_API UMyClass : public UObject
{
    GENERATED_BODY()
    public:
        // 定义一个可反射的函数
        UFUNCTION(BluprintCallable)
        void MyFunc();private:

        // 定义一个可反射的变量
        UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(AllowPrivateAccess = "true"))
        int MyIntValue;
}

通过声明反射将数据结构、变量和函数添加到反射中。

UE4的UHT(Unreal Header Tool)模块扫描之后生成的代码反射两个代码文件:

.generated.h文件:重载各种操作符函数,声明各种构造函数。 .gen.cpp文件:单例实现,构造UClass(提取信息并注册)

Unity与Unreal的稍有不同,一部分为C++调用Mono,另一部分为C#利用反射调用具体的函数。

1.C++通过Mono获取C#接口 2.反过来C#调用C++则不需要反射。可以通过数组将C++句柄存储起来,用索引获取句柄的方式调用。 3.C#使用反射调用C#代码则比较常见,在编辑器与业务逻辑交互之间会比较多。

参考资料:

《动态链接与装载》

https://mp.weixin.qq.com/s?__biz=MzU1ODY1ODY2NA==&mid=2247484788&idx=1&sn=8e4f7f36e4ddf6dd2f7c086463c089de&chksm=fc226073cb55e965e5ab259f04293c48f718a2fd72858b11397852d8ba8c7fab656b0f46e248&token=283688006&lang=zh_CN#rd

《深度探索C++对象模型》

http://luzexi.com/2020/11/20/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B014

《malloc和free的实现原理解析》

https://jacktang816.github.io/post/mallocandfree/

《UE4 反射系统详细剖析》

https://cloud.tencent.com/developer/article/1606872

《UE4内存分配器概述》

https://www.cnblogs.com/kekec/p/12012537.html

《RTTR C++ Reflection Library》

https://github.com/rttrorg/rttr

《UE4 MallocBinned2分配器》

https://zhuanlan.zhihu.com/p/79715624

《FMallocBinned2内存分配器》

https://www.cnblogs.com/kekec/p/14675228.html

《UE4垃圾回收》

https://zhuanlan.zhihu.com/p/67055774

《UE4蓝图》

https://zhuanlan.zhihu.com/p/67683606

已发布在微信公众号上,点击跳转

· 读书笔记, 前端技术, 图解游戏引擎

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    游戏引擎架构#2 容器、内存、RTTI与反射

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)