【UEC++】UE引擎程序设计浅析
前言:
本书分为以下三个部分
- 虚幻引擎C++编程:这部分将简单介绍UE的C++编程方式和有关使用虚幻引擎进行编程的相关知识,并给出了一部分官方文档尚未介绍但可以被使用的库、API 与技巧
- 虚幻引擎浅析:这部分将会引导读者去研究虚幻引擎源码,并给出在深入使用虚幻引擎进行游戏开发过程中,可能需要具备的引擎架构模块如何工作的知识,介绍虚幻引擎是如何工作的
- 拓展虚幻引擎:这部分是通过介绍虚幻引擎的插件编写,将第二部分知识运用起来,定制虚幻引擎以符合自己游戏实际情况的能力
第一部分:虚幻引擎C++编程
假定已经了解 C++ 语法:包括变量、函数、类、指针
一. 五个常见基类
请思考以下问题:
- 如何最快速上手虚幻引擎的 C++ 编程?
- 什么时候该继承自 UObject 类? 什么时候应该声明一个纯 C++ 类?
- 什么时候该继承自 Actor 类?
- 什么时候该继承自 Character 类?什么时候又该继承自 Pawn 类?
- 要快速上手虚幻引擎的C++编程,可以通过抓住最核心的 5 个类,提纲挈领地学习
- UObject
- Actor
- Pawn
- Character
- Controller
1.1 UObject 类
- 从语义上看,UObject 类表示的是一个“对象”,而一个类继承自 UObject 类,是需要 UObject 类提供的功能
- 那么 UObject 类为我们提供了哪些功能呢?从官网文档可知:
- Garbage collection 垃圾收集
- Reference updating 引用自动更新
- Reflection 反射
- Serialization 序列化
- Automatic updating of default property changes 自动检测默认变量的更改
- Automatic property initialization 自动变量初始化
- Automatic editor integration 和虚幻引擎编辑器的自动交互
- Type information available at runtime 运行时类型识别
- Network replication 网络复制
- Garbage collection 垃圾收集
- C++ 的内存管理是由程序员完成的,往往一个对象可能引用多个其他对象,如果释放,就可能产生野指针或报错;而不释放,则这片内存区域永远无法被回收
- 对此,虚幻引擎提供了如下两个解决方案:
- 继承自 UObject 类,同时指向用 UObject 类实例对象的指针成员变量,使用 UPROPERTY 宏标记进行标记。虚幻引擎的 UObject 架构会自动的被 UProperty 标记的变量考虑到垃圾回收系统中,自动的进行对象生命周期管理
- 采用智能指针。但是只有非 UObject 类型才能够使用智能指针进行自动内存释放(详见后文)
- Reflection 反射
- 指一种语言的机制,并非图形学意义上的“反射”(详见第二部分:虚幻引擎浅析)
- Serialization 序列化
- 当你希望把一个类的对象保存到磁盘,同时在下次运行时完好无损的加载,那么同样可以继承自 UObject 类(当然也可以通过给自己的纯 C++ 类手动实现序列化所需要的函数,让这个类支持序列化功能)
- Automatic editor integration 和虚幻引擎编辑器的自动交互
- 如果希望类的变量能够被引擎编辑器的 Editor 面板简单编辑,需要继承自 UObject 类
- Type information available at runtime 运行时类型识别
- 如果希望使用 C++ 标准的 RTTI 机制:dynamic_cast,可以继承自 UObject 类,然后使用 Cast<> 函数来完成
- (虚幻已经打开了 /GR- 编译器参数,意味着无法使用 C++ 标准的 RTTI 机制)
- Network replication 网络复制
- 如果希望在网络游戏开发时能够自动的处理变量同步,从服务器端复制对应的变量到客户端,可以继承自 UObject 类,其被宏标记的变量能够自动完成网络复制功能
- UObject 类会在引擎加载阶段,创建一个 Default Object 默认对象,这意味着:
- 构造函数并不是在游戏运行时调用,同时即使你只有一个 UObject对象存在于场景中,构造函数依然会被调用两次
- 构造函数被调用时,UWord不一定存在,因此 GetWord() 返回值可能为空
1.2 Actor 类
- Actor 是游戏中一切实体 Actor 的基类
- 同理,我们分析 Actor 提供了什么功能让我们选择继承自 Actor:
- 虚幻中一个场景实体对应一个类
- Actor 能够被挂载组件,Component 组件的含义被大大削弱(区别于 Unity 3D 中组件的大部分功能将会交给对应的继承自 Actor 类的子类来实现)
- 假如你希望能 Actor 被渲染?给一个静态组网格组件
- 希望 Actor 有骨骼动画?给一个骨骼网格物体组件
- 需要坐标与旋转量?给一个 Scene Component 组件
- 希望 Actor 能够移动?通常来说可以直接在 Actor 类中书写代码来实现,当然也可以附加一个 Movement 组件专门处理移动
1.3 肉体与灵魂:Pawn、Character、Controller
- Pawn
- Pawn 类提供了被“操作”的特性,能够被一个 Controller 操纵,这个 Controller 可以是玩家,也可以是 AI 人工智能,Pawn 类就像一个无法脱离棋手被操纵的兵卒,一个无法自主行动的肉体
- Character
- Character 代表一个角色,它继承自 Pawn 类
- 那么,什么时候该继承自 Character 类?什么时候又该继承自 Pawn 类呢?
- 这个问题的答案必须从 Character 类的定义中寻找,即它提供了什么样的功能?
- Character 类提供了一个特殊的组件 Character Movement。这个组件提供了一个基础的基于胶囊体的角色移动功能,包括移动和跳跃,必要时还能拓展出更多(如蹲伏和爬行)
- 如果 Pawn 类十分简单或不需要这样的移动逻辑,那么可以不需要继承自 Character类,当然,现在很多游戏中的角色都能够适用于 Character 类的逻辑
- (在 Unreal Engine 3 中没有 Character 类,只有 Pawn 类)
- Controller
- Controller 是漂浮在 Pawn / Character 之上的灵魂,它操纵着 Pawn 和 Character 的行为,Controller 可以是 AI,(AI Controller 类中可以使用行为树/ EQS 环境查询系统),同样也可以是玩家(Player Controller 类中可以绑定输入,然后转换为对 Pawn 的指令)
- 既然 Controller 是灵魂,那么肉体就不唯一,因此灵魂可以通过 Possess / UnPossess 来控制肉体或离开肉体
- 不同的角色也许会共享同样的 Controller 从而获得类似的行为,其实 Controller 抽象掉了“角色行为”,也即扮演了有神论眼中的“灵魂”
- “肉体”(Pawn / Character)拥有的只是简单的前进,转向,跳跃,开火等函数,而“灵魂”(Controller)则能调用这些函数
二. 需求到实现
思考:
- 制作游戏时,该设计哪些类?
2.1 分析需求
- 客户想要什么什么,就是需求
- 从游戏开发的角度而言,需求就是身为游戏设计师分析出的需要实现的东西
- 通过完整的、成文本的设计书,描述这个游戏的大体设计,最终目的是用设计书阐述游戏设计,让陌生人都能明白你想要做一个什么样的游戏
- 接下来,需要将设计转化为需求点,按照分类来排列所有需求,在 UML(统一建模语言)中,用例图来表达每一个需求
- 此外,还需要版本控制系统,进行项目管理
2.2 转化需求为设计
- 假定我们拿到需求:“玩家手中会持有一把武器,按下鼠标左键时,武器会发射子弹”
- 其中,有几个重要名词:玩家、武器、子弹,都可以作为类:
- 有些类引擎已经提供给我们了,如玩家 APlayerController 类
- 那么,武器该继承自什么?
- 武器类是一种角色吗?不是,所以不该是 Pawn 的子类
- 武器类有坐标吗?是的,这该是一个 Actor 的子类
- 同理,通过分析确定子弹类也应该继承自 Actor 类,并带有 Projectile Movement 组件
- 进而分析出,类与类之间的持有、通信关系:
- 玩家类对象“持有”武器类对象
- 武器类对象“产生”子弹对象
- 玩家的输入会“调用”武器类对象的函数,以发射子弹
- 那么,类与函数的初步设计基本完成,下文将系统阐述如何把这些设计转化为实实在在的C++代码
三. 创建 C++ 类
思考:
- 该如何创建 C++ 类?
3.1 使用 Unreal Editor 创建 C++ 类
- 在内容浏览器的 C++ 类文件夹中,单击鼠标右键,弹出菜单中选择“新建 C++ 类”
- 选择父类(如果找不到目标父类,可以勾选“显示所有类”)
- 填写类型名与路径
- 创建成功后,引擎会自动打开编辑器,并产生两个模板文件(.h和.cpp),然后会自动编译并加载到引擎中
3.2 手工创建 C++ 类
- 在工程目标的 Source 文件夹下,找到同名文件夹,根据不同人创建的工程结构不同,一般会有如下两种文件结构:
- public 文件夹
- 存放创建的 .h 文件(声明类)
- private 文件夹
- 存放创建的 .cpp 文件(实现类)
- .build.cs
(这种文件结构是标准的虚幻引擎模块文件结构)
- .h 文件
- .cpp 文件
- .build.cs
- 编译项目
3.3 虚幻引擎类命名规则
- 按照虚幻引擎的命名规则,添加命名前缀,常用的前缀包括:
- F 纯C++类
- U 继承自 UObject,但不继承自 Actor
- A 继承自 Actor
- S Slate 控件相关类
- H HitResult 相关类
- 虚幻引擎头文件工具 Unreal Hard Tool 会在编译前检查类命名,如果类命名错误,则会警告并终止编译
四. 对象
思考:
- 如何实例化对象?
- 如何调用这些对象身上的函数?
4.1 类对象的产生
- 在标准 C++ 中,一个类产生一个对象,被称为“实例化”
- 如果类为纯 C++ 类型(F开头),实例化对象的方法是通过 new 关键字
- 如果类继承自 UObject 但不继承自 Actor,需要通过 NewObject 函数来生成对象
- 调用方式:
- 会返回一个指向你的类的指针,此时这个对象被分配在临时包中,下一次加载会被清除。NewObject 函数定义如下:
- 如果类继承自 AActor,需要通过 SpawnActor 函数来生成对象
- 调用方式:
- 通过 UWord 对象(GetWord() 获取)的 SpawnActor 函数来生成对象,SpawnActor 函数定义如下(SpawnActor 函数有多个,这里只列出一个):
4.2 类对象的获取
- 获取类对象的唯一方法是:通过某种方式传递到这个对象的指针或引用
- 但有一种特殊情况:需要借助 Actor 迭代器 TActorIterator 来获取一个场景中,某种 Actor 的所有实例
- 其中,TActorIterator 的泛型参数不一定是 Actor,可以是需要查找的其他类型
- 可以通过:
- 来获取指向实例对象的指针
- 或者可以直接通过:
- 来调用需要的成员函数
4.3 类对象的销毁
- 纯 C++ 类
- 在函数体中创建,则类对象会在函数调用结束后,随着函数栈空间的释放而释放,无需手动干涉
- 使用new分配内存,则需要手动删除,否则,这块内存将永远无法被释放,进而产生内存泄(建议在没有充分把握之前,不要使用手动 new / delete 方案)
- 使用智能指针 TSharedPtr / TSharedRef 来管理,智能指针会使用引用计数来完成自动的内存释放,可以使用 MakeShareable 函数来转化普通指针为智能指针:
TSharedPtrMakeShareable(new YourClass());
- UObject 类
- UObject 情况略有不同,无法使用智能指针来管理 UObject 的对象
- 因为 UObject 采用自动垃圾回收机制,当一个类的成员变量包含指向 UObject 的对象,同时又带有 UPROPERTY 宏定义,那么这个成员变量将会触发引用计数机制
- 垃圾回收器会定期从根节点 Root 开始检查,当一个 UObject 没有被别的任何 UObject 引用,就会被垃圾回收,但也可以通过 AddToRoot 函数让 UObject 一直不被回收
- Actor 类
- Actor 类对象可以通过调用 Destroy 函数来请求销毁,这样销毁意味着当前 Actor 从所属的世界中摧毁,但是对象对应内存的回收依然是由系统决定
五. 从 C++ 到蓝图
思考:
- 如何让蓝图能够调用我的 C++ 类中的变量和函数?
5.1 UPROPERTY 宏
- 只需要借助 UPROPERTY 宏,即可将一个 UObject 类的子类成员变量注册到蓝图中
- 还可以传递更多的参数来控制 UPROPERTY 宏的行为,如:
UPROPERTY(BlueprintReadWrite, VisibleAnywhere, Category = "Object")
5.2 UFUNCTION 宏
- 通过 UFUNCTION 宏来注册函数到蓝图中
UFUNCTION(BlueprintCallable, Category = "Test")
- 其中,BlueprintCallable 是一个很重要的参数,表示这个函数可以被蓝图调用
- 可选的还有:
- BlueprintImplementEvent,表示这个成员函数由其蓝图的子类实现(因此不该在 C++ 中给出函数实现,易导致链接错误)
- BlueprintNativeEvent,表示这个成员函数提供一个 C++ 默认实现,同时也可以被蓝图重载,需要提供一个“函数名_Implementation”为名字的函数实现,放置于 .cpp 中
六. 游戏性框架概述
6.1 行为树:概念与原理
- 为什么选择行为树?
- 同样的 AI 模式,用状态机会涉及大量的跳转,但是用行为树相对来说更加简化
- 同时,行为树的“退行”特点,即“逐个尝试,不行就换”的思路更加接近人类的思维方式
- 行为树原理
- 行为树是一种通用的 AI 框架(模式),并不依附于特定的引擎存在,且引擎的行为树也与标准的行为树模式存在一定的差异
- 行为树包含三种类型的节点:
- ① 流程控制:包含 Selector 选择器和 Sequence 顺序执行器
- 除去根节点 Root,Selector 选择器就是一个流程控制节点,它会从左到右逐个执行下面的子树,如果有一个子树返回 true,则会返回 true,反之,当所有子树均返回false,才会返回 false
- 类似于日常生活中“几个方案都试一试”的概念
- 而 Sequence 顺序执行器节点,就会按顺序执行自己的子树,只有当前子数返回 true,才会执行下一子树,直到全部执行完毕,向上一级返回 true,当任何一个子数返回了 false,都会停止执行并返回 false
- 相当于“把已有任务分成几步骤,逐个执行,任一失败都意味着任务失败”
- 总结
Selector 选择器 |
Sequence 顺序执行器 |
终止性 |
顺序性 |
一 true 则 true, |
一 false 则 false, 全 true 才 true |
- ② 装饰器:对子树的返回结果进行处理的节点
- 如 Force Success 节点就是强制让子树返回 true
- ③ 执行节点:执行节点必然是叶子节点,执行具体的任务(且在任务执行一段时间后,根据任务执行成功与否,返回 true 或 false)
- 使用行为树对行为进行分析的关键在于:一定要从宏观到微观,先切分大步骤,再逐步细化,以“上班”为例:
- 上班主要分为三个步骤:准备阶段,交通阶段,上楼阶段
- 而这三个阶段是按顺序执行的,不可分割
- 继续以细分“交通阶段”为例:
- 可以选择的交通工具,按优先级排列:
- 地铁(因为通行时间固定,成为上班族首选的交通工具)
- 开车(特殊情况下,考虑开车上班)
- 走路(迫不得已的情况下,选择走路)
- 因此,“交通阶段”是 Selector,是从多个加权方案(有优先级)中逐个尝试的
- 同理,“上楼阶段”也可能出现电梯或楼梯的选择:
6.2 虚幻引擎网络架构
同步:
- 最早的联机同步按照“点对点网络”的思路进行设计,这意味着,A 输入的每一个指令与信息,都会发给其他人,从而让所有人的画面一致
- 毕竟这是一个网状结构,点对点同步也带来了许多弊端:
- 网络传输压力很大
- 点对点同步,不存在“权威性”,且很难判定作弊
- 由此,经典的服务器 - 客户端架构模型产生了
- 一台具有较高性能的主机作为中心服务器,所有的游戏性相关指令都会被发往中心服务器进行处理
- 随后,中心服务器会把世界的状态同步到各个客户端,于是之前的网状结构变成了星型结构,且出现了权威服务器的概念
- 这意味着作弊更加困难,无法通过直接传递坐标给服务端(只能传递游戏性相关指令),即使是本地客户端,坐标也来自于服务端同步过来的信息
- 也就是说,我们能把游戏框架切分为两个部分:
- 一部分是“指令”,是对游戏世界造成影响的代码请求
- 另部分是“状态”,是游戏世界的各种数值状态
- 客户端只能向服务端发送指令,服务端根据指令处理后改变游戏世界的状,并且状态同步给每一个客户端。这被作为现在绝大多数网络同步模型的基本思路
- 但同时又带来另一个问题就是:延迟
- 为了解决延迟问题,虚幻3 提出了广义的客户端 - 服务端模型的概念
广义的客户端 - 服务端模型:
客户端是对服务端的拙劣模仿
- 这意味着,客户端自己也同样运行着一个世界,并不断“预测”服务端的行为,从而不断更新当前世界,以最大程度接近服务端的世界
- 也就是说,承认“延迟”客观存在,服务端的世界是绝对正确的,而客户端则是不断试图猜测服务端当前时间的状态,并通过修正数据去模仿服务端(如调整当前对象的速度方向,指向新的位置,如果服务端的位置和客户端相差太大,就强行闪现修正)
七. 引擎系统相关类
7.1 正则表达式
- 正则表达式又称正规表示法,常规表示法
- 是对字符串操作的一种逻辑公式,用事先定义好的一些特定字符及这些特定字符的组合,组成一个“规则字符串”,用来表达对字符串的一种过滤逻辑
- 正则表达式在线测试工具
7.2 FPaths 类的使用
- 在 Core 模块中,引擎提供了一个用于路径处理的类:FPaths
- 具体路径类: FPaths::GameDir() 获取游戏根目录
- 工具类:FPaths::FileExists() 判断一个文件是否存在
- 路径转换类:FPaths::ConvertRelativePathToFull() 将相对路径转换为绝对路径
7.3 XML 和 JSON
XML:
- XML 指可扩展标记语言(Xtensible Markup Language),被设计用来传输和存储数据,不用于表现和展示数据(HTML 则用来表现数据)
- 在 XmlParser 模块中,提供了两个类解析 XML 数据:
- FastXML
- FXmlFile(以此为例)
<?xml version="1.0" encoding="UTF-8"?> <node name="Ami" age="100"> <to>George</to> <from>John</from> <heading>Reminder</heading> <body>Don't forget the meeting</body> <list> <line>Hello</line> <line>World</line> <line>PPPPP</line> </list> </note>
//需要引入相应头文件 FString xmlFilePath = FPaths::GamePluginsDir()/ TEXT("SimpleWindow/Resources/Test.xml"); FXmlFile* xml = new FXmlFile(); xml->LoadFile(xmlFilePath); FXmlNode* RootNode = xml->GetRootNode(); FString from_content = RootNode->FindChildNode("from")->GetContent(); UE_LOG(LogSimpleApp, Warning, TEXT("from=%s"), *from_content); FString note_name = RootNode->GetAttribute("name"); UE_LOG(LogSimpleApp, Warning, TEXT("note @name=%s"), *note_name); TArray<FXmlNode*> list_node = RootNode->FindChildNode("list")->GetChildrenNodes(); for(FXmlNode *node : list_node) { UE_LOG(LogSimpleApp, Warning, TEXT("list: %s"), *(node->GetContent())); }
JSON:
- JSON 是存储和交换文本信息的语法(JavaScript Object Notation,JavaScript 对象表示法),类似 XML,但比 XML 更小、更快,更易解析,易于读写
- C、Python、C++、Java、PHP、Go 等编程语言都支持 JSON
//JSON解析需要用到 JSON模块及 include"Json.h" FString JsonStr = "[{\"author\":\"Tim\"},{\"age\":\"100\"}]"; TArray<TSharedPtr<FJsonValue>> JsonParsed; TSharedRef< TJsonReader<TCHAR> > JsonReader = TJsonReaderFactory<TCHAR>::Create(JsonStr); bool BFlag = FJsonSerializer::Deserialize(JsonReader, JsonParsed); { UE_LOG(LogSimpleApp, Warning, TEXT("解析JSON成功")); FString FStringAuthor = JsonParsed[0]->AsObject()->GetStringField("author"); UE_LOG(LogSimpleApp, Warning, TEXT("author = %s"), *FStringAuthor); }
7.4 文件读写与访问
- 虚幻提供了与平台无关的文件读写与访问接口,即 FPlatformFileManager
- 该类定义于头文件 FPlatformFileManager 中所属模块为,一般引擎会默认包含本模块,如果没有需要手动在当前模块的
.build.cs
中包含 - 通过
FPlatformFileManager::Get()->GetPlatformFile();
调用,能够获得一个 IPlatformFile 类型的引用 - 之所以这么麻烦,是为了提供文件的跨平台性,如果纯粹提供静态函数或者全局函数,会增加代码的复杂程度,而 FPlatformFileManager 作为全局管理单例,能更有效的管理文件读写操作
- 常用函数如下:(\Engine\Source\Runtime\Core\Public\GenericPlatform\GenericPlatformFile.h)
- 拷贝函数:提供文件目录和文件的拷贝操作
- CopyFile():递归拷贝某个目录
- CopyDirectoryTree():拷贝当前文件
- 创建函数:提供创建文件和目录的操作,目录创建成功或目录已经存在,都会返回 true
- CreateDirectory():创建目录
- CreateDirectoryTree():创建一个目录树,即给定一个路径字符串,如果对应路径的父目录不存在,也会被创建出来
- 删除函数:删除指定目录和文件,成功删除返回 true,否则false
- DeleteFile():删除指定文件
- DeleteDirectory():删除指定目录
- DeleteDirectoryRecursively():递归删除指定目录
- 移动函数:只有一个函数
- MoveFile():移动文件
- 属性函数:提供对文件、目录的属性访问操作
- DirectoryExists():检查目录是否存在
- FileExists():检查文件是否存在
- GetStatData():获得文件状态信息,返回 FFileStatData 类型对象
- GetAccessTimeStamp():获得当前文件上一次访问的时间
- SetTimeStamp():设置文件的修改时间
- FileSize():获得文件大小,如果文件不存在则返回-1
- IsReadOnly():文件是否只读
- 遍历函数:需要传入一个 FDirectoryVisitor 或 FDirectoryStatVisitor 对象作为参数,可以创造一个类继承自该类,然后重写 Visit 函数,每当遍历到一个文件或目录时,遍历函数会调用 Visitor 对象的 Visit 函数以通知执行自定义逻辑
- IterateDirectory():遍历某个目录
- IterateDirectoryRecursively():递归遍历某个目录
- IterateDirectoryStat():遍历文件目录状态,Visit 函数参数为状态对象,而非路径字符串
- IterateDirectoryStatRecursively():同上,递归遍历
- 读写函数:最底层的读写函数,往往返回提供了直接读写二进制功能的 IFileHandle 类型的句柄,如果非必要,可以考虑更高层的API
- OpenRead():打开一个文件用于读取,返回 IFileHandle 类型的句柄用于读取
- OpenWrite():打开一个文件用于写入,返回 IFileHandle 类型的句柄
- 同时,虚幻还提供一套更简单的 FFileHelper 类,用于读写文件内容,提供以下静态函数:(\Engine\Source\Runtime\Core\Public\Misc\CoreMisc.h)
- LoadFileToArray():直接将路径指定的文件读取到一个 TArray<uint8> 类型的二进制数组中
- LoadFileToString():直接将路径指定的文本文件读取到一个 FString 类型字符串中(字符串有长度限制,不要读取超大文本文件)
- SaveArrayToFile():保存一个二进制数组到文件中
- SaveStringToFile():保存一个字符串到指定文件中
- CreateBitmap():在硬盘中创建一个 BMP 文件
- LoadANSITTextFileToStrings():读取一个 ANSI 编码的文本文件到一个字符串数组中,每行对应一个 FString 类型的对象
7.5 GConfig 类的使用
- 虚幻提供了专门读写配置文件的 GConfig 类,t通过 API 可以快速读写配置,且是属于 Core 模块的类,不需要添加模块引用
- 写配置:
GConfig->SetString(TEXT("MySection"), TEXT("Name"), TEXT("李白"), FPaths::GameDir()/"MyConfig.ini");
- 调用 GConfig 的 SetString 函数,保存一个字符串类型的变量到配置文件里
- 第一个参数是指定 Section,即一个区块(可以理解成一个分类)
- 第二个参数是指定此配置的 Key(相当于此配置的具体名字)
- 第三个参数是具体的值
- 第四个参数是指定配置文件的路径(文件不存在会自动创建此文件)
- SetInt()、SetBool()、SetFloat() 等函数写配置同理
- 读配置:
GConfig->GetString(TEXT("MySection"), TEXT("Name"), FString Result, FPaths::GameDir()/"MyConfig.ini");
- 与写配置不同,读配置采用 Get 系列函数,且第三个参数从具体的值变成一个 FString 类型变量的引用
- 值得注意的是,通常在运行过程中,进行写入配置操作,值并不会马上写入到文件,如果需要马上生效,则可以调用
GConfig->Flush()
函数
7.6 UE_LOG
- Log 通常有以下几个作用:
- 记录程序运行过程,函数调用过程
- 记录程序运行过程中,参与运算的数据信息
- 反馈错误信息用于 debug
- 查看 Log
- Game(打包)模式:启动参数后加 -Log
- 编辑器模式:打开 Log 窗口(Window - Developer Tools - Output Log)
- 使用 Log
UE_LOG(LogMy, Warning, TEXT("Hello World")); UE_LOG(LogMy, Warning, TEXT("Hello %s"), *FString("String")); UE_LOG(LogMy, Warning, TEXT("Hello Int %d"), 100);
- 第一个参数为 log 的分类,需要预先定义
- 第二个参数包括 3 种类型:Log(打印文字颜色为灰色)、Warning(黄色)、Error(红色)
- 第三个参数是具体输出内容,可自行构造,几种常用符号如下:
- %s:字符串
- %d:整型数据
- %f:浮点型数据
- 自定义 Category
- 虚幻提供多种自定义的宏,可参考 LogMactos.h 文件
- 在使用自定义 Log 分类的时候,可以将
DEFINE_LOG_CATEGORY_STATIC(LogMyCategory, Warning, All);
宏放在需要输出 Log 的源文件顶部,为了方便使用,可以将它放在 PCH 文件中,或模块的头文件里(原则上是将 Log 分类定义放在被多数源文件 include 的文件里)
7.7 字符串处理
- 虚幻中的“文字”类型其实是一组类型,为了加快访问速度并提供本地化支持,才把字符串进行细分:
- FName、FText、FString 这三种类型可以相互转换
- 当然还有 TCHAR 类型,只不过 TCHAR 不是虚幻定义的字符串类
FName |
FText |
FString |
|
提供修改操作 |
否 |
否 |
是 |
大小写不敏感; 不重复存储; |
支持运行时本地化 |
消耗较高 |
- 一般都使用 FString 来传递字符串
- 但为了强制要求本地化,Slate 控件的文字参数往往使用 FText
7.8 编译器相关技巧
- “废弃”函数的标识
- 在准备废弃或更改一个函数时,虚幻不会立即废弃(否则影响范围太大),而是在编译期给出一个警告,类似于:function Please update your code to the new API before upgrading to the next release,otherwise your project will no longer compile
- 但如果使用编译器宏 message,则会在任何时候都输出消息(只要这段代码被编译,就会输出),而虚幻的废弃函数则是在“被调用”时才会输出
- 编译器指令实现跨平台
- 为了兼容不同平台,一种传统的办法是通过多态来解决,即:存在一个基类,定义了抽象的接口,独立于操作系统存在,每个操作系统对应版本继承自这个基类,然后做出自己的实现,运行时根据当前操作系统来进行切换
- 但对于一些情形(如 main 函数),采用多态跨平台,就显得很困难
- 于是,虚幻采用了编译期跨平台的方案,即:准备多个平台的实现,通过宏定义来切换不同的平台(如虚幻有的通用类 FPlatformMisc 就包含了大量平台相关的工具函数,也有一系列的平台相关实现,如 Linux 下的 FLinuxPlatformMisc,随后通过 typedef 来完成,即:
typedef FLinuxPlatformMisc FPlatformMisc;
于是就完成了编译期跨平台的过程 - 同时,.build.cs 文件会根据编译平台的类型是 Win64 还是 Linux 做出判断,以包含不同的文件夹,避免冲突
- 虚幻还对不同平台提供了不同的宏定义,如对 GCC 使用
__attribute__
关键字,对 Visual Studio 使用__declspec
关键字,然后调用deprecated
关键字来输出,诸如此类不同的编译关键字,可以实现许多不同的效果
7.9 Images
- 虚幻提供了 ImagerWrapper 作为所有图片类型的抽象层,模块设计的思路如下:
- 图片文件自身的数据是压缩后的数据,称为 CompressedData
- 图片文件对应的真正的 RGBA 数据,是没有压缩且与格式无关的数据(不考虑压缩损失),称为 RawData
- 同样图片保存为不同的格式,RawData不变(不考虑压缩),CompressedData会随图片格式不同而不同
- 因此,所有图片的格式都可以被抽象为一个 CompressedData 和 RawData 的组合
- 读取 JPG 图片
- 从文件中读取为 TArray 二进制数据
- 用 SetCompressData 填充为压缩数据
- 使用 GetRawData 即可获得 RPG 数据
- 转化 PNG 图片到 JPG
- 从文件中读取为 TArray 的二进制数据
- 用 SetCompressData 填充为压缩数据
- 使用 GetRawData 即可获得 RPG 数据
- 将 RPG 数据填充到 JPG 类型的 ImageWrapper 中
- 使用 GetCompressData 即可获得压缩后的 JPG 数据
- 使用 FFileHelper 写入到文件中
- 读取硬盘中的贴图
- 从硬盘中读取压缩过的图片文件到二进制数组,获得图片压缩后数据
- 将压缩后的数据借助 ImageWrapper 的 GetRaw 转换为原始 RGB 数据
- 填充原始的 RGB 数据到 UTexture 的数据中
- 值得注意的是,PNG 图片的转换和导入,会出现 PGB 通道交换的情况
第一部分小结
- 如何最快速上手虚幻引擎的 C++ 编程?
- 通过抓住最核心的 5 个类(UObject、Actor、Pawn、Character、Controller),提纲挈领地学习
- 什么时候该继承自 UObject 类? 什么时候应该声明一个纯 C++ 类?
- 当需要使用 UObject 类为我们提供的如下功能时,应继承自 UObject 类
- Garbage collection 垃圾收集
- Reference updating 引用自动更新
- Reflection 反射
- Serialization 序列化
- Automatic updating of default property changes 自动检测默认变量的更改
- Automatic property initialization 自动变量初始化
- Automatic editor integration 和虚幻引擎编辑器的自动交互
- Type information available at runtime 运行时类型识别
- Network replication 网络复制
- 什么时候该继承自 Actor 类?
- 需要挂载组件的时候继承自 Actor 类
- 什么时候该继承自 Character 类?什么时候又该继承自 Pawn 类?
- Character 类中的 Character Movement 组件,提供了一个基础的基于胶囊体的角色移动功能,包括移动和跳跃,还能拓展出蹲伏和爬行等
- 如果十分简单或不需要这样的移动逻辑,可以继承自 Pawn 类
- 制作游戏时,该设计哪些类?
- 通过设计书阐述设计思路,并借助 UML 绘制需求分析图
- 根据需求特点设计继承类,并进一步分析出类与类之间的持有、通信关系
- 该如何创建 C++ 类?
- 使用 Unreal Editor 创建 C++ 类(鼠标右键“新建 C++ 类”)
- 手工创建 C++ 类(public - .h / private - .cpp)
- 如何实例化对象?
- 纯 C++ 类型(F开头),通过 new 关键字实例化对象
- 继承自 UObject 类,通过 NewObject 函数实例化对象
- 继承自 Actor 类,通过 SpawnActor 函数实例化对象
- 如何调用这些对象身上的函数?
- 继承自 UObject 类,调用 NewObject 函数:
- 继承自 AActor 类,调用 SpawnActor 函数:
//通过 UWord 对象(GetWord() 获取)的 SpawnActor 函数
- 借助 Actor 迭代器 TActorIterator 来获取一个场景中,某种 Actor 的所有实例:
//获取指向实例对象的指针
//调用需要的成员函数
- 使用 MakeShareable 函数转化普通指针为智能指针:
TSharedPtrMakeShareable(new YourClass());
- 如何让蓝图能够调用自定义 C++ 类中的变量和函数?
- 通过 UPROPERTY 宏,注册变量到蓝图中
UPROPERTY(BlueprintReadWrite, VisibleAnywhere, Category = "Object")
- 通过 UFUNCTION 宏,注册函数到蓝图中
UFUNCTION(BlueprintCallable, Category = "Test")
- 引擎系统中常用的工具实用类包括?
- 正则表达式
#include "Internationalization/Regex.h" void AUECGameMode::BeginPlay() { //调用父类的 BeginPlay() Super::BeginPlay(); //目标字符串 FString TextStr("ABCDEFGHIJKLMN"); //通过 FRegexPattern创建一个待查找或匹配的模式(用 TEXT包裹正则表达式具体内容) FRegexPattern TestPattern(TEXT("C.+H")); //FRegexMatcher驱动正则表达式运行 FRegexMatcher TestMatcher(TestPattern,TextStr); //FindNext():返回 bool值,表示是否找到匹配表达式的内容 if(TestMatcher.FindNext()) { UE_LOG( LogTemp, Warning, TEXT("找到匹配内容范围在: %d -%d"), TestMatcher.GetMatchBeginning(), TestMatcher.GetMatchEnding() ); } }
- 路径管理器 FPaths
#include "Misc/Paths.h" void AUECGameMode::BeginPlay() { //调用父类的 BeginPlay() Super::BeginPlay(); //1.具体路径类 //EXE所在目录 GEngine->AddOnScreenDebugMessage(-1, 10.0, FColor::Red,FPlatformProcess::BaseDir()); //根目录 GEngine->AddOnScreenDebugMessage(-1, 10.0, FColor::Red, FPaths::RootDir()); //2.路径转换类 //将相对路径转换为绝对路径 GEngine->AddOnScreenDebugMessage(-1, 10.0, FColor::Red, FPaths::ConvertRelativePathToFull(TEXT("E:/My_Programs/UE_Programs/UEC/Content/Test_MetaHuman)"))); //3.工具类 //判断文件是否存在 UE_LOG(LogTemp, Warning, TEXT("判断文件是否存在: %d"), FPaths::FileExists(TEXT("E:/My_Programs/UE_Programs/UEC/Content/Test_MetaHuman"))); }
- 配置文件解析
- 文件读写
- LOG 输出
- 字符串
- 编译器
- Images(转换图片类型、从硬盘导入图片作为贴图)
第二部分:虚幻引擎浅析
虚幻引擎本质上也是一个 C++ 程序,也有 main 函数
八. 模块机制
思考:
- 虚幻引擎为什么要引入模块机制?
- 如何自定义模块?
- 模块的配置、编译、启动、运行方式?
- UHT 如何配合 UBT 实现反射机制?
- UHT 的生成结果是什么?代码如何完成诸如“类有哪些成员变量、成员函数”之类的信息注册?
8.1 模块简介
- 虚幻 3 借助 MakeFile 来模拟模块
- 虚幻 4 借助 UnrealBuildTool 引入了模块机制,引擎源码目录按照 Runtime、Development、Editor、Plugin 四大部分进行规划,每个部分内部包含的一个个小文件夹对应一个个模块
- 一个模块文件夹中包含以下内容:
- Public 文件夹
- Private 文件夹
- .build.cs 文件夹
- 模块的划分是一门艺术,不同模块间的相互调用并不方便,只有通过 XXXX_API 宏暴露的类和成员函数,才能够被其他模块访问
- 因此,模块系统需要工程师精心设计自己的类,避免出现复杂的类的类依赖
8.2 创建自己的模块
- 通过切分模块,能够有效的切分自己代码的结构和框架
- 同时需要区分编辑器模块和运行模块
- 创造一个新的模块:
- 创建模块文件夹结构
- 创建模块构建文件.build.cs
- 创建模块头文件与实现文件
- 创建模块预编译头文件 PrivatePCH.h
- 创建模块的 C++ 声明和定义
- 模块创建
- 在 C++ 工程的 Source 文件夹下,创建一个新的模块文件,文件结构如下:
PluginName 模块文件夹
- Public 文件夹
- PluginName.h
- Private 文件夹
- PluginName.cpp
- PluginNamePrivatePCH.h
- PluginName.Build.cs
//需要 .Build.cs文件告诉 UBT如何配置自己的编译和构建环境 using UnrealBuildTool; //类名要和模块名一致 public class PluginName:MoudleRules { public PluginName(TargetInfo Target) { PublicDependencyModuleNames.AddRange(new string[]{ "Core", "CoreUObject", "Engine", "InputCore"}); PrivateDependencyModuleNames.AddRange(new string[]{}); } }
#pragma once #include "Engine.h" #include "ModuleManager.h" class FPluginNameModule:public IModuleInterface { public: //模块允许在 C++层提供一个类 //实现 StartupModule与 ShutdownModule函数,从而自定义模块加载和卸载过程中的行为 virtual void StartupModule() override; virtual void ShutdownModule() override; }
//需要包含刚刚创建的头文件,否则无法通过编译 #include "PluginName.h" #include "PluginNamePrivatePCH.h" void FPluginNameModule::StartupModule() { //模块启动时需要执行的内容 } void FPluginNameModule::ShutdownModule() { //模块卸载时需要执行的内容 } IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, PluginName, "PluginName") IMPLEMENT_MODULE(FPluginNameModule, PluginName)
//预编译头文件(可以为空,放在 Private文件中) //能够加速代码的编译,当前模块公用的头文件都可以放置于这个预编译头文件中 //当前模块所有的 .cpp文件都需要包含此预编译头文件
- 引入模块
- 对于游戏模块而言,引入当前模块的方式是:在游戏工程目录下的 Source 文件夹中找到 工程名.Target.cs 文件,打开后修改以下函数:
Public override void SetupBinaries( TargetInfo Target, ref List<UEBuildBinaryConfiguration> OutBuildBinaryConfigurations, ref List<string> OutExtraModuleNames ) { OutExtraModuleName.AddRange(new string[]{"PluginName"}); //添加引入模块名 }
- 对于插件模块而言,方法是修改当前插件的 .uplugin 文件,在 Modules 数组中添加模块对象:
{ "FileVersion": 3, "EngineAssociation": "4.27", "Category": "", "Description": "", "Modules": [ { "Name": "UEC", //插件模块名称 "Type": "Runtime", //模块加载类型 "LoadingPhase": "Default", //模块加载时机 "AdditionalDependencies": [ "Engine"。 "Json" //定义引入模块(以 Json模块引入为例) ] } ], "Plugins": [ { "Name": "HoudiniEngine", "Enabled": true } ] }
8.3 虚幻引擎初始化模块加载顺序
- 引擎模块加载分为两大部分:
- 以硬编码形式硬性规定(加载顺序有自己的依赖原则)
- 松散加载
- 根据引擎发布的版本不同(Editor/Development/Shipping),模块加载也不同,有些模块不会被加载
- 总体来说,模块的加载遵循以下顺序:
- 首先,加载的是 Platform File Module 引擎读取文件
- 接下来,加载的是核心模块 FEngineLoop::PreInit -> LoadCoreModules
- 加载 CoreUObject
- 接着在初始化引擎之前加载 FEngineLoop::LoadPreInitModules
- 加载 Engine
- 加载 Renderer
- 加载 AnimGraphRuntime
- 根据平台不同,会加载平台相关模块 FPlatFormMisc::LoadPreInitModules,(以 Window 平台为例)
- D3D11RHI(开启 bForceD3D12 后会加载 D3D12)
- OpenGLDrv
- SlateRHIRenderer
- Landscape
- ShaderCore
- TextureCompresser
- Start Up Modules:FEngineLoop::LoadStartupCoreModules
- Core(引擎核心 Core 模块的加载时机并不在最初)
- Networking
- 然后,加载平台相关模块(以 Window 平台为例)
- XAudio2
- HeadMountedDisplay
- SourceCodeAccess
- Messaging
- SessionServices
- EditorStyle
- Slate
- UMG
- MessageLog
- CollisionAnalyzer
- FunctionalTesting
- BehaviorTreeEditor
- GameplayTasksEditor
- GameplayAbilitiesEditor
- EnvironmentQueryEditor
- OnlineBlueprintSupport
- IntroTutorials
- Blutility
- 根据启用的插件,加载对应模块
- TaskGraph
- ProfilerService
- 至此,虚幻引擎初始化完成所需的模块加载
- 之后的 Module 加载根据情况来完成,即 Unreal Editor 自己来控制其需要加载什么样的程序
- 发布后的游戏因为没有携带 Unreal Editor,因此不需要加载这些模块
- 对模块加载顺序的研究主要集中在引擎加载初期,后期的模块大多针对具体功能
- Editor 模块加载在 UEditorEngine 的 Init 中,被一个数组控制:
- Documentation
- WorkspaceMenuStructure
- MainFrame
- GammaUI
- OutputLog
- SourceControl
- TextureCompressor
- MeshUtilities
- MovieSceneTools
- ModuleUI
- Toolbox
- ClassViewer
- ContentBrowser
- AsserTools
- GraphEditor
- KismetCompiler
- Kismet
- Persona
- LevelEditor
- MainFrame
- PropertyEditor
- EditorStyle
- PackagesDialog
- AssetRegistry
- DetailCustomizations
- ComponentVisualizers
- Layers
- AutomationWindow
- AutomationController
- DeviceManager
- ProfilerClient
- SessionFrontend
- ProjectLauncher
- SettingEditor
- EditorSettingsViewer
- ProjectSettingsViewer
- Blutility
- OnlineBlueprintSupport
- XmlParser
- UserFeedback
- GameplayTagsEditor
- UndoHistory
- DeviceProfileEditor
- SourceCodeAccess
- BehaviorTreeEditor
- HardwareTargetin
- LocalizationDashboard
- ReferenceViewer
- TreeMap
- SizeMap
- MergeActors
8.4 UBT 和 UHT简介
- UBT(Unreal Build Tool)的工作分为三个阶段:
- 收集阶段 :UBT 收集环境变量、虚幻引擎源代码目录、虚幻引擎目录、工程目录等一系列信息
- 参数解析阶段:UBT 解析传入的命令行参数,确定需要生成的目标类型
- 实际生成阶段:UBT 根据环境和参数,开始生成 .makefile 文件,确定 C++ 的各种目录位置(包括各种路径、预编制头文件位置等),最终开始构建整个项目(此时编译工作交给标准 C++ 编译器)
- 同时 UBT 也负责监视是否需要热加载,并调用 UHT 收集各个模快的信息
- UBT 被设计为一个跨平台的构建工具,因此,针对不同的平台有相对应的类来进行处理,UBT 生成的 makefile 会被对应编译器平台的 ProjectFileGenerater 用于生成解决方案,而不是一开始就针对某个平台的解决方案来确定如何生成
- WinMain 函数如何被链接到 .exe 文件?
- 通常在 Windows 平台最终的 .exe 文件中,必须包含 Main 函数(C/C++控制台程序)或 WinMain 函数(Windows),否则找不到应用程序的入口点
- 而虚幻引擎的模块,最终大多被编译为 dll 动态链接库文件
- 但 Launcher 模块没有被编译为dll,是因为被 UE4Editor.Target.cs 文件所控制编译(如果使用编译版本引擎,则看不到完整的编译控制),在 .Target.cs 文件中如下所示:
public override void SetupBinaries( TargetInfo Target, ref List OutBuildBinaryConfigurations, ref List OutExtraModuleName ) { OutExtraModuleName.Add("UE4Game"); }
- 就完成了对二进制文件的设置工作
- 在引擎源代码的 UEBuildEditor.cs 的 SetupBinaries 中,可以看到 BuildRules 类在设置好自己的二进制数据输出后,Launcher 模块会被设置为 UEBuildBinaryTypes.Executable 添加进 OutBuildBinaryConfigurations 数组,作为 .exe 文件的包含模块
- 此时 .exe 文件就会包含 Launcher 模块中平台对应的 Main 函数,对于Windows 平台而言,WinMain 函数会被链接进 .exe 文件
- 双击引擎的 Edito.exe 后,WinMain 函数被调用,引擎开始在你的系统上运行起来
- UHT(Unreal Header Tool):一个引擎独立应用程序
- UHT 拥有自己的 .target.cs 和 .build.cs 文件
- 在其 .target.cs 文件中,它把自己设置为 .exe 的输出模块
- 在 .build.cs 文件中,它指出了自己的依赖模块:
- Core
- CoreUObject
- Json
- Project
- 然后又包含了 Launch 模块中的 public/private 文件夹,以便调用 GEngine->PreInit 函数
- 最终,UHT 会被编译成一个 .exe 文件,通过命令行参数调用
- UHT 大致工作流程:
- UHT 的 Main 函数在 UnrealHeadToolMain.cpp 文件中
- 该文件提供了 Main 函数,且也通过 IMPLEMENT_APPLICATION 宏声明了这是个独立应用程序,具体执行内容如下:
- 调用 GEngineLoop->PreInit 函数,初始化 Log、文件系统等基础系统,从而允许借助 UE_LOG 宏输出 log 信息
- 调用 UnrealHeadTool_Main 函数,执行真正的工作内容
- 调用 FEngineLoop::AppExit 退出
- 虚幻引擎反射机制
- 经过 UHT 的三遍编译,最终生成 .generated.cpp 和 .generated.h 两种文件
- 随后,引擎把 C++ 类与 UClass 中对应的“UPROPERTY”和“UFUNCTION”绑定起来(UClass 可以理解为包含 UPROPERTY 和 UFUNCTION 的数据结构)
- 加载信息
- 在 C++ 机制中,静态全局变量的初始化先于 Main 函数执行
- 在生成的 .generated.cpp 文件中,会看到
IMPLEMENT_CLASS
宏,展开后实质上完成了两个内容:
- 声明了一个具有独一无二名字的
UClassCompiledInDefer <当前类名>
静态全局变量实例,这个类的构造函数调用了 UClassCompiledInDefer 函数,即:
- 静态全局变量会在 Main 函数之前初始化
- 初始化就会调用该变量的构造函数
- 构造函数会调用 UClassCompiledInDefer 函数
- UClassCompiledInDefer 会添加 ClassInfo 变量到延迟注册的数组中
- 实现了当前类的 GetPrivateStaticClass 函数
- 总之,在 Main 函数执行前,先执行 UClassCompiledInDefer 函数
九. 重要核心系统简介
思考:
- 虚幻引擎如何运行?
9.1 内存分配
- Windows 操作系统下的内存分配方案
- 在 Windows 操作系统下,虚幻引擎通过宏来控制,并在几个内存分配器中选择的,对应代码如下:
FMalloc* FWindowsPlatformMemory::BaseAllocator() { #if ENABLE_WIN_ALLOC_TRACKING_CrtSetAllocHook(WindowsAllocHook); #endif //ENABLE_WIN_ALLOC_TRACKING #if FORCE_ANSI_ALLOCATOR return new FMallocAnsi(); #elif(WITH_EDITORONLY_DATA || IS_PROGRAM) && (TBB_ALLOCATOR_ALLOWED) return new FMallocTBB(); #else return new FMallocBinned((uint32)(GetConstants().PageSize&Max_uint32),(uint64)MAX_uint32+1); #endif }
- Windows 平台提供了标准的 Malloc(ANSI)、Inter TBB 内存分配器以及 Binned 内存分配器三个方案
- Inter TBB 内存分配器
- 采用 TBB 内存分配的原因:
- 为了避免引擎工作在多个线程,而出现内存分配 bug,所以强制同一时间只有一个线程分配内内存,但这就导致了内存分配的速度大幅度降低
- CPU 中存在高速缓存,而同一个缓存,一次只能被一个线程访问,如果出现特殊情况(两个变量靠在一起
int a;int b;
线程一访问 a,线程二访问 b,理论上此时可以并行,但由于在加载 a 的时候,缓存把 b 的内存空间也加载进去,导致线程二在访问时还需要重新加载缓存),这就带来相当大的 CPU 周期浪费,被称为“假共享”
- 在《游戏引擎架构》一书中,对内存分配方案做出了许多描述,重点提到两个方面:
- 通过内存池降低 malloc 消耗
- 通过对齐降低缓存命中失败消耗
- Inter TBB 提供了
- scalable_allocator:不在同一个内存池中分配内存,解决由于多线程竞争带来的无谓消耗
- cache_aligned_allocator:通过缓存对齐,避免“假共享”
- 这一方案的代价是内存消耗量增加
- 虚幻中主要使用的还是 scalable_allocator,摘录部分代码如下:
void* FMallocTBB::Malloc(SIZE_T Size, uint32 Alignment) { void* Result = TryMalloc(Size, Alignment); if (Result == nullptr && Size) { OutOfMemory(Size, Alignment); } return Result; }
- 如果对 Inter TBB 内存分配感兴趣,可以参考《Inter Threading Building Blocks 编程指南》
9.2 引擎初始化过程
- 引擎初始化简介
- 引擎初始化分为两个过程:PreInit 预初始化和 Init 初始化
- 其具体实现由 FEngineLoop 类来提供
- 在不同平台上,入口函数不同(如 Windows 平台下是 WinMain,Linux 平台下是Main),但最后都会调用同样的 FEngineLoop 中的函数,实现跨平台
- 预初始化
- PreInit 是预初始化过程
- 和初始化 Init 过程最显著的区别在于:PreInit 带有参数 CmdLine,即能够获得传入的命令行字符串
- PreInit 预初始化过程主要根据传入的命令行字符串,来完成一系列的设置状态工作,可以分为如下设置内容:
- 设置路径:当前程序路径、当前工作目录路径、游戏的工程路径
- 设置标准输出:设置 Glog 系统输出设备(是输出到命令行还是何处)
- 初始化一部分系统,包括:
- 初始化 GameThread 游戏主线程(把当前线程设置为主线程)
- 初始化随机数系统(随机数是需要初始化的,否则同样的种子会产生出虽然随机,但是一模一样的随机序列)
- 初始化 TaskGraph 任务系统,并按照当前平台的核心数量来设置 TaskGraph 的工作线程数量,同时,也会启动一个专用的线程池,生成一堆线程,以备不时之需(引擎的线程数量是远多于核心数量的,如初始化线程数量是:Task 线程 3 个,线程池线程 8 个)
- 预初始化过程也会判断引擎的启动模式(是以游戏模式启动?还是以服务器模式启动?)
- 调用 LoadCoreModules
- 随后,所有的 PreInitModules 都会被启动起来,包括:
- 引擎模块
- 渲染模块
- 动画蓝图
- Slate 渲染模块
- Slate 核心模块
- 贴图压缩模块
- 地形模块
- 加载这些模块完毕后,AppInit 函数会被调用,进入引擎正式的初始化阶段
- 初始化
- 如果被加载到内存的模块中有 PostEngineInit 函数的,都会被调用从而初始化
- 由于 Init 的过程被分摊到了每个模块中,因此初始化的过程显得格外简洁
- 这一过程借助 IProjectManager 完成
- 主循环
- 虚幻的主循环代码,可以被表述为:
while(!GIsRequestingExit) { EngineTick(); }
- 引擎是按照标准引擎架构来写的,游戏主线程存在于一个专门的引擎循环,即 EngineTick(引擎的渲染线程是独立更新的)
- 引擎的 Tick 按照以下顺序来更新引擎中各个状态:
- 更新控制台变量(也可以使用控制台直接设置变量)
- 请求渲染线程更新当前帧率文字
- 更新 App::DeltaTime 当前应用程序的时间
- 更新内存分配器的状态
- 请求是渲染线程刷新当前的一些底层绘制资源
- 等待 Slate 程序的输入状态捕获完成
- 更新 GEngine,调用 GEngine->Tick
- 假如现在有个视频正在播放,需要等待视频播放完。之所以在 GEngine 之后等待,是因为 GEngine 会调用用户的代码,此时用户有可能会请求播放一个视频
- 更新 SlateApplication
- 更新 RHI
- 收集下一帧需要清理的 UObject 对象
- GEngine->Tick 最重要的任务是更新当前 world
- 很多任务可能无法在一次 Tick 中完成(否则会卡死游戏主线程),因此会分在多次 Tick 函数中以一次载入一点点的方式完成
9.3 并行与并发
十. 对象模型
思考:
- 虚幻引擎 UObject、组件、Actor 对象的生命周期?
10.1 UObject 对象
- UObject 对象的产生与销毁:
- UObject 对象借助 NewObject 函数产生(无法通过 new 产生)
- UObject 对象由虚幻引擎垃圾回收系统管理,无需手动销毁
- UObject 对象的初始化
- 对象的初始化被分为两个阶段:
- 内存分配阶段
- 获取当前 UObject 对象对应的 UClass 类信息,根据类成员变量的总大小,加上内存对齐
- 然后在内存中分配一块合适的区域存放,此外也调用了 UObjectBase 原地构造(PlacementNew 不分配内存,直接假设当前内存指向的区域为一个已经分配好与当前对象等大的内存区域,然后调用构造函数)
- 对象构造阶段
- 用 UClass 的构造函数指针 ClassConstructor 完成内存构造,通常指针在反射生成的 .generated.h 中完成注册赋值(不直接给构造函数传递参数,而是获得 ClassConstructor 供复用)
- 如果开发者自己实现了一个以 FObjectInitialzer 为参数的构造函数,则直接将该函数指针指向静态函数_Default Constructor,以获取当前 FObjectInitialzer 对象持有的、指向刚构造出来的 UObject 的指针,然后对其调用 PlacementNew,传入 FObjectInitialzer 作为参数调用构造函数,完成构造
- 如果开发者只是实现了一个无参构造函数,则直接调用无参构造函数,完成构造
- UObject 序列化
第二部分小结
- 虚幻引擎为什么要引入模块机制?
- 在游戏最终发布时,有些模块是游戏需要的,有些是不需要的。因此 UE4 在UnrealBuildTool 中引入了模块机制
- 如何自定义模块?
- 通过
- 模块的配置、编译、启动、运行方式?
- UHT 如何配合 UBT 实现反射机制?
- UHT 的生成结果是什么?代码如何完成诸如“类有哪些成员变量、成员函数”之类的信息注册?
- 虚幻引擎如何运行?
- 虚幻引擎 UObject、组件、Actor 对象的生命周期?
未完待续....
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!