大象无形虚幻引擎程序设计浅析.pdf
http://www.100md.com
2020年1月13日
![]() |
| 第1页 |
![]() |
| 第5页 |
![]() |
| 第14页 |
![]() |
| 第22页 |
![]() |
| 第47页 |
![]() |
| 第317页 |
参见附件(8729KB,429页)。
大象无形虚幻引擎程序设计浅析是作家罗丁力和张三写的关于计算机知识的书籍,主要讲述了c语言进行编程的方法,最常见的基类等等。

大象无形虚幻引擎程序设计浅析内容
《大象无形:虚幻引擎程序设计浅析》以两位作者本人在使用虚幻引擎过程中的实际经历为参考,包括三大部分:使用C++语言进行游戏性编程、了解虚幻引擎本身底层结构与渲染结构、编写插件扩展虚幻引擎。提供了不同于官方文档内容的虚幻引擎相关细节和有效实践。有助于读者一窥虚幻引擎本身设计的精妙之处,并能学习到定制虚幻引擎所需的基础知识,实现对其的按需定制。
大象无形虚幻引擎程序设计浅析作者信息
罗丁力:电子科技大学在读学生、腾讯课堂三巫教育课程作者。自UDK开始使用虚幻引擎,有多年的虚幻引擎使用经验。
张三:多年游戏开发经验,资深UE4开发者,三巫社区创始人。
大象无形虚幻引擎程序设计浅析章节目录
第一部分虚幻引擎C++编程
第1章开发之前——五个最常见基类
1.1简述
1.2本立道生:虚幻引擎的UObject和Actor
1.2.1UObject类
1.2.2Actor类
1.3灵魂与肉体:pawn、Character和Controller
13.1Pawn
13.2 Character
13.3Controller
第2章需求到实现
2.1分析需求
2.2转化需求为设让
第3章创建自己的C++类
3.1使用Unreal Editor创建C++类
3.2手工创建C++类
3.3虚幻引擎类命名规则
第4章对象
4.1类对象的产生
4.2类对象的获取
4.3类对象的销毁
第5章从C++到蓝图
5.1UPROPERTY宏
5.2UFUNCTION宏
第6章游戏性框架概述
6.1行为树:概念与原理
6.1.1为什么选择行为树
6.1.2行为树原理
6.2虚幻引擎网络架构
6.2.1同步
6.2.2广义的客户端-服务端模型
第7章引擎系统相关类
Z.1在虚幻引擎4中使用正则表达式
Z.2FPaths类的使用
Z3XML与ISON
Z4文件读写与访问
Z.5GConfi类的使用
Z.5.1写配置
Z.5.2读配置
Z.6UE LOG
Z.6.1简介
Z6.2查看Log
Z.6.3使用Log
Z64自定义Category
2.7字符串处理
2.8编译器相关技巧
Z8.1“废弃”函数的标记
Z8.2编译器指令实现跨平台
Z9Images
第二部分虚幻引擎浅析
第8章模块机制
8.1模块简介
8.2创建自己的模块
8.2.1快速完成模块创建
8.2.2创建模块文件夹结构
8.2.3创建模块构建文件
8.2.4创建模块头文件与定义文件
8.2.5创建模块预编译头文件
8.2.6引入模块
8.3虚幻引整初始化模块加载顺庄
8.4道常无名:UBT和UHT简介
8.4.1UBT
8.4.2UHT
第9章重要核心系统简介
9.1内存分配
9.1.1 Windows操作系统下的内存分配方案
9.1.2IntelTBB内存分配器
9.2引擎初始化过程
9.3并行与并发
9.3.1从实验开始
9.3.2线程
9.3.3TaskGraph系统
9.3.4Std::Threa
9.3.5线程回步
9.3.6多进程
第10章对象模型
10.1UObject对象
10.1.1来源
10.1.2重生:序列化
10.1.3释放与消亡
10.1.4垃圾回收
10.2Actor对象
10.2.1来源
10.2.2加载
10.2.3释放与消亡
第11章虚幻引擎的渲染系统
11.1渲染线程
11.1.1渲染线程的启动
11.1.2渲染线程的运行
11.2渲染架构
11.2.1延迟渲染
11.2.2延迟渲染在PostProcess中的运用
11.3渲染过程
11.3.1延迟渲染到最终结果
11.3.2渲染着色器数据提供
11.4场量代理SceneProxy
11.4.1逻辑的世界与渲染的世界
11.42渲染代理的创建
11.4.3渲染代理的更新
11.4.4实战:创建新的渲染代理
11.4.5进阶:创建静态渲染代理
11.4.6静态网格物体渲染代理排庄
11.5Shader
11.5.1测试工程
11.5.2定义Shader
11.5.3定义Shader对应的C++类
11.5.4我们做了什么
11.6材质
11.6.1概述
11.6.2材质相关C++类关系
11.6.3编译
11.6.4ShaderMap产生
第12章Slate界面系统
12.1Slate的两次排布
12.2Slate的更新
12.3Slate的渲染
第13章蓝图
13.1蓝图架构简述
13.2前端:蓝图存储与编辑
13.2.1Schema
13.2.2编辑器
13.3后端:蓝图的编译
13.4蓝图虚拟机
13.4.1便笺纸与白领的故事
13.4.2虚幻引擎的实现
13.4.3C++函数注册到蓝图
13.5蓝图系统小结
第三部分扩展虚幻引擎
第14章引擎独立应用程庄
14.1简介
14.2如何开始
14.3BlankProgram
14.4走得更远
14.4.1预先准备
14.4.2增加模块引用
14.4.3添加头文件引用
14.4.4修改Main函数为WinMain
14.4.5添加LOCTEXT NAMESPACE定义
14.4.6添加SlateStandaloneApplication
14.4.7链接CoreUObject
14.4.8添加一个Window
14.4.9最终代码
14.5剥离引擎独立应用程庄
第15章插件开发
15.1简介
15.2开始之前
15.3创建插件
15.3.1引整插件与项且插件
15.3.2插件结构
15.3.3模块入口
15.4基于Slate的界面
15.4.1Slate简介
15.4.2Slate基础概念
15.4.3最基础的界面
15.4.4SNew与SAssignNew
15.45Slate控件的三种类型
15.4.6创建自定义控件
15.4.7布局控件
15.4.8控件参数与属性
15.4.9Delegate
15.410自定义皮肤
15.4.11图标字体
15.4.12组件继承
15.4.13动态控制Slot
15.4.14自定义容器布局
15.5UMG扩展
15.6蓝图扩展
15.6.1蓝图函数库扩展
15.6.2异步节点
15.7第三方库引用
15.2.1lib静态链接库的使用
15.7.2dl动态链接库的使用
第16章自定义资源和编辑器
16.1简易版自定义资源类型
16.2自定义资源类型
16.2.1切分两个模块
16.2.2创建资源类
16.2.3在Editor模块中创建工厂类
16.2.4引入Editor模块
16.3自定义资源编辑器
16.3.1资源操作类
16.3.2资源编辑器类
16.3.3增加3D预览窗口
大象无形虚幻引擎程序设计浅析截图


目 录
内容简介
前言
第一部分 虚幻引擎C++编程
第1章 开发之前——五个最常见基类
1.1 简述
1.2 本立道生:虚幻引擎的UObject和Actor
1.2.1 UObject类
1.2.2 Actor类
1.3 灵魂与肉体:Pawn、Character和Controller
1.3.1 Pawn
1.3.2 Character
1.3.3 Controller
第2章 需求到实现
2.1 分析需求
2.2 转化需求为设计
第3章 创建自己的C++类
3.1 使用Unreal Editor创建C++类
3.2 手工创建C++类
3.3 虚幻引擎类命名规则
第4章 对象
4.1 类对象的产生
4.2 类对象的获取
4.3 类对象的销毁
第5章 从C++到蓝图5.1 UPROPERTY宏
5.2 UFUNCTION宏
第6章 游戏性框架概述
6.1 行为树:概念与原理
6.1.1 为什么选择行为树
6.1.2 行为树原理
6.2 虚幻引擎网络架构
6.2.1 同步
6.2.2 广义的客户端-服务端模型
第7章 引擎系统相关类
7.1 在虚幻引擎4中使用正则表达式
7.2 FPaths类的使用
7.3 XML与JSON
7.4 文件读写与访问
7.5 GConfi类的使用
7.5.1 写配置
7.5.2 读配置
7.6 UE_LOG
7.6.1 简介
7.6.2 查看Log
7.6.3 使用Log
7.6.4 自定义Category
7.7 字符串处理
7.8 编译器相关技巧
7.8.1 “废弃”函数的标记
7.8.2 编译器指令实现跨平台
7.9 Images第二部分 虚幻引擎浅析
第8章 模块机制
8.1 模块简介
8.2 创建自己的模块
8.2.1 快速完成模块创建
8.2.2 创建模块文件夹结构
8.2.3 创建模块构建文件
8.2.4 创建模块头文件与定义文件
8.2.5 创建模块预编译头文件
8.2.6 引入模块
8.3 虚幻引擎初始化模块加载顺序
8.4 道常无名:UBT和UHT简介
8.4.1 UBT
8.4.2 UHT
第9章 重要核心系统简介
9.1 内存分配
9.1.1 Windows操作系统下的内存分配方案
9.1.2 IntelTBB内存分配器
9.2 引擎初始化过程
9.3 并行与并发
9.3.1 从实验开始
9.3.2 线程
9.3.3 TaskGraph系统
9.3.4 Std::Threa
9.3.5 线程同步
9.3.6 多进程
第10章 对象模型10.1 UObject对象
10.1.1 来源
10.1.2 重生:序列化
10.1.3 释放与消亡
10.1.4 垃圾回收
10.2 Actor对象
10.2.1 来源
10.2.2 加载
10.2.3 释放与消亡
第11章 虚幻引擎的渲染系统
11.1 渲染线程
11.1.1 渲染线程的启动
11.1.2 渲染线程的运行
11.2 渲染架构
11.2.1 延迟渲染
11.2.2 延迟渲染在PostProcess中的运用
11.3 渲染过程
11.3.1 延迟渲染到最终结果
11.3.2 渲染着色器数据提供
11.4 场景代理SceneProxy
11.4.1 逻辑的世界与渲染的世界
11.4.2 渲染代理的创建
11.4.3 渲染代理的更新
11.4.4 实战:创建新的渲染代理
11.4.5 进阶:创建静态渲染代理
11.4.6 静态网格物体渲染代理排序
11.5 Shader11.5.1 测试工程
11.5.2 定义Shader
11.5.3 定义Shader对应的C++类
11.5.4 我们做了什么
11.6 材质
11.6.1 概述
11.6.2 材质相关C++类关系
11.6.3 编译
11.6.4 ShaderMap产生
第12章 Slate界面系统
12.1 Slate的两次排布
12.2 Slate的更新
12.3 Slate的渲染
第13章 蓝图
13.1 蓝图架构简述
13.2 前端:蓝图存储与编辑
13.2.1 Schema
13.2.2 编辑器
13.3 后端:蓝图的编译
13.4 蓝图虚拟机
13.4.1 便笺纸与白领的故事
13.4.2 虚幻引擎的实现
13.4.3 C++函数注册到蓝图
13.5 蓝图系统小结
第三部分 扩展虚幻引擎
第14章 引擎独立应用程序
14.1 简介14.2 如何开始
14.3 BlankProgram
14.4 走得更远
14.4.1 预先准备
14.4.2 增加模块引用
14.4.3 添加头文件引用
14.4.4 修改Main函数为WinMain
14.4.5 添加LOCTEXT_NAMESPACE定义
14.4.6 添加SlateStandaloneApplication
14.4.7 链接CoreUObject
14.4.8 添加一个Window
14.4.9 最终代码
14.5 剥离引擎独立应用程序
第15章 插件开发
15.1 简介
15.2 开始之前
15.3 创建插件
15.3.1 引擎插件与项目插件
15.3.2 插件结构
15.3.3 模块入口
15.4 基于Slate的界面
15.4.1 Slate简介
15.4.2 Slate基础概念
15.4.3 最基础的界面
15.4.4 SNew与SAssignNew
15.4.5 Slate控件的三种类型
15.4.6 创建自定义控件15.4.7 布局控件
15.4.8 控件参数与属性
15.4.9 Delegate
15.4.10 自定义皮肤
15.4.11 图标字体
15.4.12 组件继承
15.4.13 动态控制Slot
15.4.14 自定义容器布局
15.5 UMG扩展
15.6 蓝图扩展
15.6.1 蓝图函数库扩展
15.6.2 异步节点
15.7 第三方库引用
15.7.1 lib静态链接库的使用
15.7.2 dll动态链接库的使用
第16章 自定义资源和编辑器
16.1 简易版自定义资源类型
16.2 自定义资源类型
16.2.1 切分两个模块
16.2.2 创建资源类
16.2.3 在Editor模块中创建工厂类
16.2.4 引入Editor模块
16.3 自定义资源编辑器
16.3.1 资源操作类
16.3.2 资源编辑器类
16.3.3 增加3D预览窗口未经许可,不得以任何方式复制或抄袭本书之部分或全部内容。
版权所有,侵权必究。
图书在版编目(CIP)数据
大象无形:虚幻引擎程序设计浅析罗丁力,张三著.—北京:电子工
业出版社,2017.5
ISBN 978-7-121-31349-3
Ⅰ.①大… Ⅱ.①罗…②张… Ⅲ.①游戏程序-程序设计 Ⅳ.
①TP317.6
中国版本图书馆CIP数据核字(2017)第076264号
策划编辑:符隆美
责任编辑:徐津平
印 刷:三河市良远印务有限公司
装 订:三河市良远印务有限公司
出版发行:电子工业出版社
北京市海淀区万寿路173信箱 邮编:100036
开 本:787×980 116 印张:19.75 字数:380千字
版 次:2017年5月第1版印 次:2017年5月第1次印刷
定 价:65.00元
凡所购买电子工业出版社图书有缺损问题,请向购买书店调换。若书店
售缺,请与本社发行部联系,联系及邮购电话:(010)88254888,88258888。
质量投诉请发邮件至zlts@phei.com.cn,盗版侵权举报请发邮件至
dbqq@phei.com.cn。
本书咨询联系方式:(010)51260888-819 faq@phei.com.cn。内容简介
本书以两位作者本人在使用虚幻引擎过程中的实际经历为参考,内
容包括三大部分:虚幻引擎C++编程、虚幻引擎浅析和扩展虚幻引擎。
本书提供了不同于官方文档内容的虚幻引擎相关细节和有效实践。有助
于读者一窥虚幻引擎本身设计的精妙之处,并能学习到定制虚幻引擎所
需的基础知识,实现对其按需定制。
本书适合初步了解虚幻引擎编程,并希望学习虚幻引擎架构或者希
望定制和扩展虚幻引擎的人士。前言
建德若偷,质真若渝。大方无隅,大器晚成。
大音希声,大象无形。夫唯道善贷且成。
——老子,《道德经》
虚幻引擎作为业界一流的次时代引擎,开发了无数成功的作品。在
短暂的计算机图形学发展历史上,虚幻引擎历经四代,成为游戏引擎界
举足轻重的成员之一。
但是虚幻引擎庞大而复杂的设计,阻碍了许多人学习的步伐。尽管
有蓝图系统作为图形化编程,降低了虚幻引擎的上手难度,但是当开发
者们走入虚幻引擎的C++范畴,依然会感觉到无从下手。
因此,我决定和我的同事一起来撰写本书。希望能够借助我们微薄
之力,帮你理解庞大的虚幻引擎是如何工作的。笔者对本书内容的期望
是,这是一本笔者在学习虚幻引擎时希望能够获得的书 。
同时也请明白,虚幻引擎的代码量为五百万行。本书篇幅不足以分
析整个虚幻引擎的所有模块,也无法精确地向读者展示每段代码的意
义。相反地,本书立足于:展示引擎基本结构 ,即尽可能告诉读者“它
是这么跑起来的”,对于希望精确研究每一段代码过程的读者,本书会
告知你如何寻找到对应的代码。
本书主标题为“大象无形”,《道德经》中有言:大器晚成,大音希声,大象无形。本书取“伟大的设计对于使用者来说似乎感觉不到存
在”和“优秀的系统设计让开发者不需要过多了解原理即能使用”这样的
含义。对于虚幻引擎而言,本书中介绍的很多知识,对于普通开发者来
说似乎是“没有感觉到存在”的东西,例如引擎的渲染系统,普通开发者
几乎只需要简单地完成导入和摆放就能使用,并不需要实际了解渲染系
统的工作原理。能够达到这样的效果,恰恰说明了虚幻引擎设计的优
秀:能够让开发者不需要了解系统的机制,就能够快速使用其来完成自
己的需求,此即“无形”。然而这样优秀的设计是如何完成的?如何扩展
这样的设计来让开发者完成自己独特的需求?这是本书希望探讨的内
容。
本书由两位作者共同编撰而成,其中罗丁力先生完成了第一部分
(除《引擎系统相关类》章节)和第二部分,以及第三部分中《引擎独
立应用程序》《自定义资源和编辑器》章节,张三先生完成了第二部分
中《引擎系统相关类》章节与第三部分中《插件开发》章节。
笔者才疏学浅,撰写本书仅仅为个人一家之言。欢迎每一位读者对
本书提出建议和指正,也欢迎更多的人去撰写虚幻引擎相关的书籍,共
同为虚幻引擎的推广、运用做出努力。你可以发送邮件到
three@sanwu.org。
感谢Unreal Engine,陪伴我度过了最美好的青春。
阅读之前
你好,欢迎你阅读本书。在这里我希望能向你讲述一些关于阅读本书的约定。首先,这不是一本“虚幻引擎入门宝典”或是“虚幻引擎从入
门到精通”。本书的作者们希望把视角集中到那些市面上的教程没有涉
及的领域,所以我们不会教你:
1. 如何下载引擎
2. 如何安装引擎和Visual Studio
3. 如何更新引擎
4. 如何申请虚幻引擎账户
我们假定你已经掌握这些知识。并且我们也不会教你:
1. C++语法
2. C语言语法
我们认为你在使用虚幻引擎的C++语言进行编程之前,已经掌握了
C++的基础语法,包括函数、变量、类、指针与模板。当然,我们会向
你解释虚幻引擎中的独有的C++成分,包括C++11标准的一些内容。
如果你已经做好了准备,欢迎开始你的阅读之旅。本书分为以下三
个部分:
虚幻引擎C++编程 这个部分简单介绍虚幻引擎的C++编程方式,你可以通过这个部分回顾、整理你从官方文档学习到的有关使用虚
幻引擎 进行编程的知识,并给出了一部分官方文档尚未介绍但可
以被使用的库、API与技巧。
虚幻引擎浅析 这个部分将会引导读者去研究虚幻引擎源码,并
给出笔者认为在深入使用虚幻引擎进行游戏开发的过程中,可能会
需要具备的引擎架构、模块如何工作的知识。换句话说,这个部分介绍虚幻引擎是如何工作的 。
扩展虚幻引擎 这个部分则是通过介绍虚幻引擎的插件编写,将
第二部分的知识运用起来,让读者不至于觉得这是“屠龙之技”,虽
有思辨的乐趣,却没有用武之地。进而赋予读者定制虚幻引擎 以
符合自己游戏实际情况的能力。笔者认为这是专业游戏开发者所需
要具备的技能。
在每一小节开头,笔者会提供一个常常被问及的问题,然后根据这
个问题来阐述接下来的内容,就像这样:
问题
我该如何学习虚幻引擎?
读者可以在阅读完每一个小节后,回顾小节开头的问题,以检验自
己是否已经理解了本节的内容。
笔者在这里衷心地祝愿你找到你希望学习的知识,祝你一切顺利!
鸣谢
本书在撰写过程中受到了大量同行、朋友及亲人的帮助,有许多同
行无私地贡献了自己的想法、意见及自己宝贵的经验,在此对他们表示
真挚的感谢:非常感谢Net Fly和秦春林先生对本书的支持,他们不仅帮助笔者联
系了本书的出版社,也非常认真地审阅本书的稿件,并给出了中肯有效
的意见,没有他们的帮助,本书不可能出版。
非常感谢傅建钊先生对本书的帮助,提出了大量有效的意见,并组
织了相当多的业内人士共同讨论本书的主题,他的知乎专栏《Inside
UE4》对虚幻引擎的剖析同样非常精彩,建议读者可以参考。
同时,也有不少同行针对书中许多主题给出了自己独到的见解,并
被整理到书中。LSFW先生给笔者多次反复讲解渲染框架设计,贡献出
了自己对渲染系统的研究成果;黄河水先生、Dest1ny先生撰写了大量
博客来分析虚幻引擎的底层架构,给笔者启发颇多;王德立先生帮助本
书绘制了插图。还有许许多多同行,在此恕无法一一举名。
感谢三巫社区和Epic Games对本书的出版过程的支持与帮助。
最后,作者之一罗丁力希望感谢Black Rock Shooter,感谢她在撰写
本书的过程中,对其鼓励与陪伴。
读者服务
轻松注册成为博文视点社区用户(www.broadview.com.cn),您即
可享受以下服务。
提交勘误: 您对书中内容的修改意见可在【提交勘误】处提交,若被采纳,将获赠博文视点社区积分(在您购买电子书时,积分可
用来抵扣相应金额)。与作者交流: 在页面下方【读者评论】处留下您的疑问或观点,与作者和其他读者一同学习交流。
页面入口:http:www.broadview.com.cn31349第一部分
虚幻引擎C++编程
本文假定你已经了解C++的语法,包括变量、函数、类与指针。
我们不会在这里教你如何书写函数声明,如何调用一个函数等。本
文并不需要你了解虚幻引擎的C++编程,因为我们会讲述到。但是,不
同于官方教程的简单明了,我们将会向你介绍更多关于“为什么这样设
计”的内容。如果你希望在几分钟之内上手虚幻引擎的C++编程,可能
你更应该直接阅读虚幻引擎的C++Quick Start Guide。
如果你发现,在阅读了官方的文档之后,你依然感到无所适从,不
知道如何下手的话,我想接下来的章节能够让你对虚幻引擎的开发,有
更加全面的认识。
本部分希望用一组相互之间比较独立的短文,来向你陈述虚幻引擎
C++编程的一些技巧,并帮助你准备后文需要的一些知识。第1章
开发之前——五个最常见基类
1.1 简述
问题
如何最快上手虚幻引擎的C++编程?
许多人都询问过这样的问题。学习虚幻引擎的编程技术有许多的道
路,但是笔者认为,抓住最核心的五个类,提纲挈领地学习,能够更好
地理解。这五个类就是:
UObject Actor Pawn Controller Character
1.2 本立道生:虚幻引擎的UObject
和Actor
1.2.1 UObject类
问题什么时候该继承自UObject类?什么时候应该声明一个纯C++类?
任何一个C++程序员都知道,不同于Java,C++的类可以没有父
类。那么,什么样的类对象应该继承自UObject类?
从语义上看,UObject类表示这是一个“对象”,但是这并不能说服
一个C++程序员。毕竟,任何一个纯C++类都能实例化对象。事实上,我们应该这样思考。一个类继承自UObject类,应该是它需要UObject类
提供的功能。什么样的功能让你选择继承自UObject类?
从虚幻引擎官方文档我们可以得知,UObject类提供了以下功能:
1. Garbage collection垃圾收集
2. Reference updating引用自动更新
3. Reflectio反射
4. Serialization序列化
5. Automatic updating of default property changes自动检测默认变量的更
改
6. Automatic property initialization自动变量初始化
7. Automatic editor integration和虚幻引擎编辑器的自动交互
8. Type information available at runtime运行时类型识别
9. Network replication网络复制
我将会详细讲述这些功能中重点功能的含义。
垃圾收集C++的内存管理是由程序员完成的。因此对象管理一直是一个很棘
手的问题。往往一个对象可能会引用多个其他对象,同一个对象也可能
会被多个对象引用。那么,当你不需要用到当前对象A的时候,该不该
释放该对象所在的内存区域?
任何人都会犹豫:
释放 一旦有别的对象引用当前对象,释放后就会产生野指针。
当另一个对象来访问时,会看到空空如也甚至是其他对象的内存区
域。
不释放 有可能我已经是最后一个引用这个对象的人了,一旦我
丢弃这个指针,这个对象就不会再有人知道,这片内存区域永远无
法被回收。
对此虚幻引擎提供了如下两个解决方案:
1. 继承自UObject类,同时指向UObject类实例对象的指针成员变量,使用UPROPERTY宏进行标记。虚幻引擎的UObject架构会自动地
被UProperty标记的变量 考虑到垃圾回收系统中,自动地进行对象
的生命周期管理。
2. 采用智能指针。请注意,只有非UObject类型 ,才能够使用智能指
针进行自动内存释放。关于智能指针的讨论,请看本书后面的章
节。
反射反射并不是图形学意义上的“反射”。而是指一种语言的机制。这样
的机制在C、Java中已经存在,但是C++并没有。我以一种通俗易懂的
解释来描述反射,如果你需要反射的详细解释,请阅读搜索引擎中对反
射的解释。
如果你是一名C++程序员,那么请你思考一个问题:
我该如何在运行时获取一个类?有哪些成员变量、成员函数?我该
如何获取这些成员变量的名字?
很难对吗?C++本身没有提供这样一套机制。尽管你可以用各种方
式来手动实现。
虚幻引擎实现了这样一套机制。如果你好奇反射是怎样实现的,可
以阅读本书第二部分的内容。
序列化
当你希望把一个类的对象保存到磁盘,同时在下次运行时完好无损
地加载,那么你同样需要继承自UObject类。
但是需要澄清的是,你可以通过给自己的纯C++类手动实现序列化
所需要的函数,来让这个类支持序列化功能。这并不是UObject类独有
的。
与虚幻引擎编辑器的交互还记得虚幻引擎编辑器的Editor面板吗?你希望你的类的变量能够
被Editor简单地编辑吗?那么你需要继承自这个类。
运行时类型识别
请注意,虚幻引擎打开了GR-编译器参数。意味着你无法使用
C++标准的RTTI机制:dynamic_cast。如果你希望使用,请继承自
UObject类,然后使用Cast<>函数来完成。
这是因为虚幻引擎实现了一套自己的、更高效的运行时类型识别的
方案。
网络复制
当你在进行网络游戏开发(cs架构)时,你一定希望能够自动地处
理变量的同步。
而继承自UObject类,其被宏标记的变量能够自动地完成网络复制
的功能。从服务器端复制对应的变量到客户端。
综上所述,当你需要这些功能的时候,你的这个类应该继承自
UObject类。
请注意:UObject类会在引擎加载阶段,创建一个Default Object默
认对象。这意味着:1. 构造函数并不是在游戏运行的时候调用,同时即便你只有一个
UObject对象存在于场景中,构造函数依然会被调用两次。
2. 构造函数被调用的时候,UWorld不一定存在。GetWorld返回值有
可能为空!
1.2.2 Actor类
问题
什么时候该继承自Actor类?
Actor类是游戏中一切实体Actor的基类。这样的解释严格来说是错
误的,笔者不能使用Actor来解释Actor。那么我们应该更加明晰一些。
同样地,我们采用之前的分析方式。Actor类提供了什么功能,让我们
选择继承自它?
有朋友会回答,Actor类在场景中拥有一个位置坐标和旋转量。
请注意,这是也是错误 的。
Actor类拥有这样的能力:它能够被挂载组件 。
组件并不是Actor。如果你观察,会发现所有组件的类的开头是U而
不是A。虚幻引擎中,Component的含义与Unity引擎中的Component具
有极大的区别。如果你从Unity引擎转移而来,我有必要向你澄清这一
点:虚幻引擎中,一个场景实体对应一个类。 在Unity中,一个对象可
以挂载多个脚本组件。每个脚本组件是一个单独的类。因而从某种意义
上说,有许多Unity程序员认为这相当于一个场景实体可以看作是多个
类。
虚幻引擎中,Component的含义被大大削弱。它只是组件,不能越
俎代庖。Untiy引擎中组件的大多数功能将会交给对应的、继承自Actor
类的子类来实现。
而坐标与旋转量,只是一个Scene Component组件。如果这个Actor
不需要一个固定位置(例如你的某个Manager),你甚至可以不给Actor
挂载Scene Component组件。
你希望让Actor被渲染?给一个静态网格组件。
你希望Actor有骨骼动画?给一个骨架网格物体组件。
你希望你的Actor能够移动?通常来说你可以直接在你的Actor类中
书写代码来实现。当然,你也可以附加一个Movement组件以专门
处理移动。
所以,需要挂载组件的时候,你才应该继承自Actor类。也就是
说,我刚刚描述的,你的Manager,也许只需要一个纯C++类就够了
(当然,你需要序列化之类的功能,那就是另一回事了)。
1.3 灵魂与肉体:Pawn、Character
和Controller
1.3.1 Pawn在国际象棋里面,Pawn代表的是如图1-1所示:
图1-1 Pawn,国际象棋棋子
没错,就是兵或卒。那么这个类为何这样命名?因为这个命名我认
为是极为形象的。其十分生动地体现了Pawn类的特性。
如果你研究了Pawn类的源码,你会发现Pawn类提供了被“操作”的
特性。它能够被一个Controller操纵。这个Controller可以是玩家,当然
也可以是AI(人工智能)。这就像是一个棋手,操作着这个棋子。这就是Pawn类,一个被操纵的兵或卒,一个一旦脱离棋手就无法
自主行动的、悲哀的肉体。
1.3.2 Character
Character类代表一个角色,它继承自Pawn类。那么,什么时候该继
承自Character类,什么时候该继承自Pawn类呢?这个问题的答案,我们
必须从Character类的定义中寻找——它提供了什么样的功能?
Character类提供了一个特殊的组件,Character Movement。这个组
件提供了一个基础的、基于胶囊体的角色移动功能。包括移动和跳跃,以及如果你需要,还能扩展出更多,例如蹲伏和爬行。
如果你的Pawn类十分简单,或者不需要这样的移动逻辑(比如外
星人飞船),那么你可以不继承自这个类。请不要有负罪感:
1. 不是虚幻引擎中的每一个类,你都得继承一遍。
2. 在Unreal Engine 3中,没有Character类,只有Pawn类。
当然,现在很多游戏中的角色(无论是人类,还是某些两足行走的
怪物),都能够适用于Character类的逻辑。
1.3.3 Controller
本节的标题是:灵魂与肉体。作为一名无神论者,我只是采用这样
的比喻。但是似乎Epic的开发人员选择了棋手与棋子的比喻。相比之
下,我的比喻还是太过肤浅了些。Controller是漂浮在PawnCharacter之上的灵魂。它操纵着Pawn和
Character的行为。Controller可以是AI,AIController类,你可以在这个
类中使用虚幻引擎优秀的行为树EQS环境查询系统。同样也可以是玩
家,Player Controller类。你可以在这个类中绑定输入,然后转化为对
Pawn的指令。
我希望阐述的是,为何虚幻引擎采用这样的设计。Epic给出的理由
非常简单:“不同的怪物也许会共享同样的Controller,从而获得类似的
行为”。其实,Controller抽象掉了“怪物行为”,也就是扮演了有神论者
眼中“灵魂”的角色。
既然是灵魂,那么肉体就不唯一,因此灵魂可以通过
PossessUnPossess来控制一个肉体,或者从一个肉体上离开。
肉体拥有的只是简单的前进、转向、跳跃、开火等函数。而
Controller则是能调用这些函数。从某种意义上来说,MVC中的
Controller与虚幻引擎这套系统有着某种类似。虚幻引擎的Controller对
应着MVC的Controller,Pawn就是Model,而Pawn挂载的动态网格组件
(骨架网格或者静态网格),对应着MVC的View。虽然这种比喻不是
非常恰当,但是能方便理解。第2章
需求到实现
问题
我有一个庞大的游戏创意,但这是我第一次制作游戏,我该设计
哪些类?
2.1 分析需求
在前面的内容中,笔者介绍了虚幻引擎中重要的几个类的含义。并
告诉了你如何继承。但是,如何从具体的需求中,分析出类的设计和架
构,并最终导出一个完整的解决方案呢?这其实是更多人希望知道的。
那么,我们该从什么地方开始呢?答案是,从需求开始。
考虑到阅读本书的有些朋友没有经过系统的软件工程训练(这不是
你的错,有可能你是一个热爱游戏,热爱虚幻引擎的开发者),因此,我有必要简单介绍需求这个概念。
什么是需求?客户想要什么,就是需求。
那么,从游戏开发的角度而言,需求就是你身为游戏设计师,分析
出的需要实现的东西。可能最开始只是模糊不清的字句,比如“我希望
我的主角能够在楼宇之间不断跳跃”,或是“我希望我的主角能够手持一个手电筒不断探索”。不管怎样,你的游戏应该有一个完整的、成文本
的设计书。这个设计书描述了你这个游戏的大体设计。你可以从网络上
找到各种各样的游戏设计书模板,但是你最终的目的是要向一个陌生
人,用你的设计书阐述你的游戏设计,让他明白你想做一个什么样的游
戏。接下来,你需要将你的设计转化为需求点。你应该按照分类来排列
这些需求。如果你希望你的开发过程更加清晰可控,你应该采用专业的
软件工程建模工具来绘制你的需求分析图。在UML(统一建模语言)
中,你可以用“用例图”来表达你的每一个需求。
或者,通过有意义的短句:
“玩家可以通过按下空格键来跳跃”。
“玩家通过鼠标滚轮来切换武器”。
从而让你的设计变成一个一个的开发单位。
我知道,如果你是一个游戏行业的从业人士,你会认为我的这段描
述是多么的粗略,甚至显得外行。但是,你应该意识到,我是在向一些
非从业人士,那些依靠热血的独立开发者们,讲解软件工程的一些知
识。
如果你是一名热血的开发者,正在使用QQ群,仅仅凭借聊天来继
续你的项目,我希望你能够认真地看待我所提出的建议。另外,你还需
要版本控制系统,以及起码的一个项目管理系统。如果你希望知道按照
怎样的步骤来开发你的游戏,我建议你可以先从敏捷开发模型中选择一
个,在你的项目中运用。软件工程是一门建立在无数开发者血泪之上的
科学,请谨慎地对待这门科学给出的建议。2.2 转化需求为设计
如果你接受过软件工程训练,那么你会不假思索地描述,在需求分
析之后,应该是概要设计、详细设计和编码,之后会有测试等等。当
然,你也会回忆起各种敏捷开发模型,例如XP、测试驱动开发、Scrum,等等。但我不会在这里讲述软件工程,而是把讨论聚集在如何
实现一个具体需求设计上。这就好像,我不是在讨论绘画时的握笔、排
线,而是在讨论在哪个位置下笔,在哪个位置排线。假设我们拿到的是
这样的需求:
“玩家手中会持有一把武器,按下鼠标左键时,武器会射出子弹”。
从这句话中,我们能够找到这样的几个重要名词:玩家、武器和子
弹。
我们意识到,这几个名词都可以作为类。也许有些类虚幻引擎已经
提供给我们了,如玩家APlayerController类。那么,我们意识到,我们
需要给武器和子弹各创建一个类。现在问题是,武器类该继承自什么?
让我们回顾前面的章节。
首先,武器类有坐标吗?有的。这该是一个Actor的子类。
武器类是一种兵吗?不是,武器类不该是Pawn的子类。
恭喜你,你已经确定了武器类在整个游戏的类树中的位置。同样,你也能够确定子弹类在类树中的位置。它应该继承自Actor类,同时带
有一个Projectile Movement组件。进一步你能够分析出,类与类之间的
持有、通信关系:1. 玩家类对象持有 武器类对象。
2. 武器类对象产生 子弹对象。
3. 玩家的输入会调用 武器类对象的函数,以发射子弹。
你已经能够想象出这几个类与函数的设计了。在下个章节中,笔者
将会阐述如何把这些设计转化为实实在在的C++代码。第3章
创建自己的C++类
问题
我想好我有哪些类了,现在我该怎么创建它们的代码呢?
3.1 使用Unreal Editor创建C++类
使用Unreal Editor创建C++类
你可以按照以下的步骤,使用Unreal Editor的C++类向导来创建你
的C++类。在内容浏览器的C++类文件夹中单击鼠标右键,在弹出菜单
中选择新建C++类,如图3-1所示。图3-1 新建C++类第一步
在弹出的菜单中选择你的父类,如图3-2所示。
图3-2 新建C++类第二步
如果你找不到你的父类,请勾选“显示所有类”,如图3-3所示。图3-3 新建C++类第三步
选中合适的父类后点击继续,填写你的类型名与路径,如图3-4所
示。
图3-4 新建C++类第四步
点击创建类后,虚幻引擎会自动打开Visual Studio。并且产生出两
个模板文件(.h与.cpp),然后会自动编译,并且加载到引擎中。接下
来你要做的与你在C++教程中学习到的一致。调用各种函数,完成你想
要的功能吧。
3.2 手工创建C++类
如果你出于某种原因,希望自己手动创建C++类。你需要完成以下
的步骤:在工程目录的Source文件夹下,找到和你游戏名称一致的文件夹。
根据不同人创建的工程结构不同,你可能会发现下面两种文件结构:
1. public文件夹,private文件夹,.build.cs文件。
2. 一堆.cpp和.h文件,.build.cs文件。
第一种文件结构是标准的虚幻引擎模块文件结构。
1. 创建你的.h和.cpp文件,如果你是第一种文件结构,.h文件放在
public文件夹内,.cpp文件放置在private文件夹内。
2. 在.h中声明你的类:如果你的类继承自UObject,你的类名上方需要
加入UCLASS宏。同时,你需要在类体的第一行添加
GENERATED_UCLASS_BODY宏,或者GENERATED_BODY
宏。前者需要手动实现一个带有const FObject Initializer参数的构
造函数。后者需要手动实现一个无参数构造函数。注意笔者说的
是“实现”而非声明。
3. 在你的.cpp文件中,包含当前模块的PCH文件。一般是模块名
+private PCH.h。如果是游戏模块,有可能包含的是游戏工程名.h。
4. 编译。
3.3 虚幻引擎类命名规则
终于我们要讨论到了虚幻引擎类的命名规则了。如果你仔细阅读,你会发现,笔者在前文中几乎没有用“Object”而是用的UObject作为类的
名字。那么之前的U代表什么?这是按照虚幻引擎的命名规则,添加的
命名前缀。常用的前缀如下:F 纯C++类
U 继承自UObject,但不继承自Actor
A 继承自Actor
S Slate控件相关类
H HitResult相关类
虚幻引擎头文件工具Unreal Header Tool会在编译前检查你的类命
名。如果类的命名出现错误,那么它会提出警告并终止编译。在后文的
描述中,笔者会按照以下规则:
1. 如果笔者是在阐述理论和逻辑,例如“你需要一个Weapon武器
类”,这个时候笔者不会加上前缀。这是为了行文以及阅读的流
畅。
2. 如果笔者是在描述具体的代码,例如“请阅读
UStaticMeshComponent类的代码”,或者“调用AActor实例的
GetActorLocation函数”,此时为了与实际代码匹配,笔者会加上类
的前缀。第4章
对象
问题
我也声明好了需要的类,那么:
我该如何实例化对象?
我该如何在世界中产生我声明的Actor类?
我该如何调用这些对象身上的函数?
4.1 类对象的产生
在标准C++中,一个类产生一个对象,被称为“实例化”。实例化对
象的方法是通过new关键字。
而在虚幻引擎中,这一个问题变得略微复杂。对于某些类型,我们
不得不通过调用某些函数来产生出对象。具体而言:
1. 如果你的类是一个纯C++类型(F开头),你可以通过new来产生对
象。
2. 如果你的类继承自UObject但不继承自Actor,你需要通过
NewObject函数来产生出对象。3. 如果你的类继承自AActor,你需要通过SpawnActor函数来产生出对
象。
New Object函数定义如下:
template
T NewObject(
UObject Outer = (UObject)GetTransientPackage,UClass Class = T::StaticClass,FName Name = NAME_None,EObjectFlags Flags = RF_NoFlags,UObject Template = nullptr,bool bCopyTransientsFromClassDefaults = false,FObjectInstancingGraph InInstanceGraph = nullptr)
事实上你可以简单地这样调用它:
NewObject
这会返回一个指向你的类的指针,此时这个对象被分配在临时包
中。下一次加载会被清除。如果你的类继承自Actor,你需要通过
UWorld对象(可以通过GetWorld获得)的SpawnActor函数来产生出对
象。函数定义如下(有多个,这里只列出一个):
template< class T >T SpawnActor(
FVector const Location,FRotator const Rotation,const FActorSpawnParameters SpawnParameters = FActorSpawnParameters
)
你可以这样简单地调用它:
GetWorld( )->SpawnActor( )
极为特殊的,如果你需要产生出一个Slate类——如果你有这样的需
求,要么你已经在进行很深的开发,要么就是你的教程的版本过老,依
然在使用Slate来开发游戏的界面控件,你需要使用SNew函数。我无法
给出SNew函数的原型。关于Slate的详细讨论,请阅读后文中Slate的章
节。
4.2 类对象的获取
获取一个类对象的唯一方法,就是通过某种方式传递到这个对象的
指针或引用。
但是有一个特殊的情况,也是大家经常询问到的:如何获取一个场
景中,某种Actor的所有实例?答案是,借助Actor迭代器:
TActorIterator。示例代码如下:for(TActorIterator Iterator(GetWorld);Iterator;++Iterator)
{...do something
}
其中TActorIterator的泛型参数不一定是Actor,可以是你需要查找的
其他类型。你可以通过
Iterater
来获取指向实际对象的指针。或者,你可以直接通过
Iterater->YourFunction(
来调用你需要的成员函数。
4.3 类对象的销毁
如今,一个类走到了其生命的尽头。我们希望销毁它,从而获得其
所占用的内存空间。我们应该采用什么样的方式?我并不认为这是一个
简单的命题。事实上我会分几类来加以阐述:
纯C++类如果你的纯C++类是在函数体中创建,而且不是通过new来分配内
存,例如:
void YourFunction( )
{
FYourClass YourObject=FYourClass;...Do something.
}
此时这个类的对象会在函数调用结束后,随着函数栈空间的释放,一起释放掉。不需要你手动干涉。
如果你的纯C++类是使用new来分配内存,而且你直接传递类的指
针。那么你需要意识到:除非你手动删除,否则这一块内存将永远不
会被释放 。如果你忘记了,这将产生内存泄漏。
如果你的纯C++类使用new来分配内存,同时你使用智能指针
TSharedPtrTSharedRef来进行管理,那么你的类对象将不需要也不应该
被你手动释放。智能指针会使用引用计数来完成自动的内存释放。你可
以使用MakeShareable函数来转化普通指针为智能指针:
TSharedPtr YourClassPtr=MakeShareable(new YourClass);
笔者强烈建议,在你没有充分的把握之前,不要使用手动
newdelete方案。你可以使用智能指针。UObject类
UObject类的情况略有不同。事实上你无法使用智能指针来管理
UObject对象 。
前文已经提到,UObject采用自动垃圾回收机制。当一个类的成员
变量包含指向UObject的对象,同时又带有UPROPERTY宏定义,那么
这个成员变量将会触发引用计数机制。
垃圾回收器会定期从根节点Root开始检查,当一个UObject没有被
别的任何UObject引用,就会被垃圾回收。你可以通过AddToRoot函数来
让一个UObject一直不被回收。
Actor类
Actor类对象可以通过调用Destory函数来请求销毁,这样的销毁意
味着将当前Actor从所属的世界中“摧毁”。但是对象对应内存的回收依然
是由系统决定。第5章
从C++到蓝图
问题
虚幻引擎的蓝图真是太好用了,我该如何让蓝图能够调用我的
C++类中的函数呢?
5.1 UPROPERTY宏
当你需要将一个UObject类的子类的成员变量注册到蓝图中时,你
只需要借助UPROPERTY宏即可完成。
UPROPERTY(...)
你可以传递更多参数来控制UPROPERTY宏的行为,通常而言,如
果你要注册一个变量到蓝图中,你可以这样书写:
UPROPERTY(BlueprintReadWrite,VisibleAnywhere,Category=Object)
关于能够在UPROPERTY中使用的参数,请阅读官方文档的这一章
节。5.2 UFUNCTION宏
你也可以通过UFUNCTION宏来注册函数到蓝图中。下面是一个注
册的案例:
UFUNCTION(BlueprintCallable,Category=Test)
其中BlueprintCallable是一个很重要的参数,表示这个函数可以被蓝
图调用。可选的还有:BlueprintImplementEventBlueprintNativeEvent。
前者表示,这个成员函数由其蓝图的子类实现,你不应该尝试在C++中
给出函数的实现,这会导致链接错误。后者表示,这个成员函数提供一
个“C++的默认实现”,同时也可以被蓝图重载。你需要提供一个“函数名
_Implement”为名字的函数实现,放置于.cpp中。第6章
游戏性框架概述
6.1 行为树:概念与原理
6.1.1 为什么选择行为树
在虚幻引擎3的时代,AI框架选择的是状态机来实现。甚至为了支
持状态机编程,虚幻引擎的脚本语言Unreal Script专门增加了几个状态
相关的关键字,足以看出虚幻引擎官方对状态机系统的重视。但是这个
系统在虚幻引擎4中被行为树系统代替,状态机只在动画蓝图中保留
(当然这并不妨碍你在任何地方书写状态机,毕竟用C++实现一个状态
机模式并不复杂)。
那么,是什么促使Epic做出这样的决定呢?简而言之,同样的AI模
式,用状态机会涉及大量的跳转,但是用行为树就相对来说更加简化。
同时由于行为树的“退行”特点,也就是“逐个尝试,不行就换”的思路,更加接近人类的思维方式,因此当你熟悉了行为树的框架之后,能够更
加快速地撰写AI相关的代码。
6.1.2 行为树原理
在对虚幻引擎的行为树进行介绍之前,我认为应该先介绍“行为
树”。“行为树”是一种通用的AI框架或者说模式,其并不依附于特定的引擎存在,并且虚幻引擎的行为树也与标准的行为树模式存在一定的差
异。
现在关于行为树的讨论并不多,因此我将会简单介绍行为树的一些
内容。请看如图6-1所示的行为树案例1:Selector。
图6-1 行为树案例1:Selector
我们会发现,这是一个由节点、连接线构成的行为树。行为树包含
三种类型的节点:
流程控制:包含Selector选择器和Sequence顺序执行器(关于平行执
行parallel节点,暂时不做分析)。
装饰器:对子树的返回结果进行处理的节点。
执行节点:执行节点必然是叶子节点,执行具体的任务,并在任务
执行一段时间后,根据任务执行成功与否,返回true或者false。
让我们看一个具体的例子,如图6-2所示:
图6-2 行为树案例除去根节点Root,Selector就是一个流程控制节点。Selector节点会
从左到右逐个执行下面的子树,如果有一个子树返回true,它就会返回
true,只有所有的子树均返回false,它才会返回false。这就类似于日常
生活中“几个方案都试一试”的概念。
反映到如图6-2所示的行为树案例,对应左侧的图片。这个行为树
实际上讲述了一段艰难的故事:在荒年,如果吃不起饭的时候,就只能
选择吃糠了。
假如流程节点被换为了Sequence,那么Sequence节点就会按顺序执
行自己的子树,只有当前子树返回true,才会去执行下一个子树,直到
全部执行完毕,才会向上一级返回true。任何一个子树返回了false,它
就会停止执行,返回false。类似于日常生活中“依次执行”的概念。把一
个已有的任务分为几个步骤,然后逐个去执行,任何一个步骤无法完
成,都意味着了任务失败。也就是说,这一次的行为树表达了这样的概
念:荒年好不容易收了点大米,先做成饭,然后慢慢吃,每天吃点饭之
后,就开始吃糠,直到吃饱。
仔细归纳一下,我们会发现这两个行为树其实包含了许多信息:
Selector版本
终止性 只要能通过吃饭把自己吃饱,绝不吃糠,表达了一种“今
朝有酒今朝醉”式的享乐主义理念。换句话说,只要“吃饭”节点返
回成功,今天就算过去了,直接向Root返回成功。
优先级 即使是Selector选择器,依然具有优先级。一旦饿了优先
找饭吃,而不是找糠。先吃好的,吃饱了再说。只有当“吃饭”节点
返回失败的情况下,才开始尝试优先级更低的节点。只要有一个成功便是成功,全部失败才失败 只要找到一点能吃
的东西,吃饱了都算数。只有饭也没有,糠也没了,什么吃的都找
不到了,才向Root汇报失败——没办法,真的没东西吃了。
Sequence版本
顺序性 这可能是一家精打细算的主人,即使有饭吃,也得一边
吃点饭,一边吃糠。这是为了细水长流,以后每天都有点饭吃。也
就是说,当“吃饭”返回成功的时候,需要继续执行接下来的节点。
任何一个步骤失败都失败,全部步骤做完才成功 当一点饭都没
有的时候,主人家陷入了绝望的境地,也不去尝试吃糠了,直接向
Root节点汇报“没办法了,真的失败了”。
标准的装饰器节点,是对子树返回的结果进行处理,再向上一级进
行返回的。例如Force Success节点,就是强制让子树返回true,不管子
树真正返回的是什么。如上文例子,假如在Root上加了一个Force
Success装饰器节点,那就像是古代有些鱼肉百姓的官员,一方面对下面
报上来的饥荒情况心知肚明,另一方面又不停向上级返回“成功”。
日常生活中的很多行为,都可以被这样的方式总结出来。而行为树
对行为进行分析的关键在于,一定要从宏观到微观。先切分大的步骤,再逐步细化。以上班为例:
上班主要分为三个步骤:准备阶段,交通阶段,上楼阶段。
这三个步骤是按顺序执行的,不能分割。任何一个步骤出问题,都
会导致上班不成功,比如你准备阶段不成功(“我再睡五分钟”,没想到
醒过来已经是中午了),或者交通阶段不成功(被车撞飞导致无法继续前往公司),结果都是上班不成功,你今天的工资肯定没了。
那么我们要继续细分,以细分交通阶段为例。对于通常的城市上班
族来说,基本上能选择的交通工具,按照优先级排列如下:
1. 地铁:因为地铁基本上通行时间固定,而且地铁很少挤不上去,所
以会成为上班族首选的交通工具。
2. 开车:可能由于特殊原因,今天地铁整修,或者是被水淹了之类,总之地铁选择不了的情况下,就会考虑开车上班,虽然堵了一点,但是至少能到公司。
3. 走路:结果没想到大家都想开车上班,于是马路被堵得彻底走不动
了。这时候就只能选择走路去公司了。
因此,交通阶段是一个Selector,是从多个加权方案(有优先级)
中逐个尝试的。由此大家会发现,Selector与普通的随机选择还是有区
别的,我们能够通过顺序来定义“从一般到特殊”的AI行为。这是行为树
很强大的一个地方。
同样的,上楼阶段也有可能出现选择电梯或者楼梯的选择,这里不
再赘述。最终应该是如图6-3所示的行为树案例3:上班。图6-3 行为树案例3:上班
通过这个行为树,我们会发现,在某个状况下,这个AI会按照这样
的方式执行:
1. 首先进入准备阶段:
a. 刷牙。
b. 洗脸。
2. 准备阶段完成,开始准备去上班:
a. 看看地铁能不能坐:
i. 走到地铁站,发现地铁因为修理被关闭。
ii. 返回false(回到家中)。
b. 选择开车方案:
i. 开车出门。
ii. 返回true,顺利到达上班地点。
c. 交通阶段完成,开始上楼:
i. 尝试电梯,发现电梯坏掉了,返回false。ii. 尝试走楼梯,终于到达了办公室。
这可能是一个很倒霉的AI,不过它做出了一套让我们都能够认为很
自然的行为。而不是因为地铁封闭,就傻傻地站在地铁口等待。这就是
行为树系统的威力。
6.2 虚幻引擎网络架构
6.2.1 同步
从第一个联机游戏开始,同步就成为了一个重点的研究对象。随着
需要同步的人数不断变多,联机同步架构的设计也在不断地变动。
最早的联机同步按照点对点网络的思路进行设计。也就意味着,假
如开了一个房间,有4个玩家加入,那么玩家1输入每一个指令与消息,都会发往其他3个人,从而让4个人的画面得以一致。大致类似于,如果
你按了一下W键,那么这个前进的指令就会发送到所有你连接的人的电
脑上。然后你所控制的那个人物都会向前移动一点点。
在某些早期的联机游戏中,为了保证所有人的同步,甚至采用过更
极端一些的方法,如强制所有人更新频率一致。
点对点同步带来的弊端相当得多,毕竟这是一个网状结构。例如:
1. 由于点对点同步带来的传输消耗,因此网络传输压力会很大。
2. 由于点对点同步不存在“权威”性,因此当其中一个人作弊时,会影
响所有的客户端。且很难判定“作弊”。因此,经典的服务器-客户端架构模型产生了。
一台(在当年)具有较高性能的主机被选出来,作为中心服务器。
所有的“游戏性相关指令”都会被发往中心服务器进行处理,随后中心服
务器会把世界的状态 同步到各个客户端。
于是之前的网状结构变为了星型结构 ,且出现了权威服务器 的概
念。也就意味着,作弊变得更加困难。无法通过直接传递坐标(只能传
递游戏性相关指令)给服务端,就算是本地客户端,坐标也来自于服务
端同步过来的信息。
也就是说,我们能把游戏框架切分为两个部分:一部分是“指令”,是对游戏世界造成影响的代码请求,比如“人物前移3米”“人物挥刀碰到
了怪物”;另一部分是“状态”,是游戏世界的各种数值状态,比如“当前
人物生命值”“当前怪物生命值”。客户端只能向服务端发送“指令”,服
务端根据指令处理后,改变游戏世界的状态,并将状态同步 给每一个
客户端。
这是相当优秀的一个设计,对同步考虑了非常多。因此,被作为了
现在绝大多数网络同步模型的基本思路。不过这是不够的,因为有另一
个非常基本的问题,那就是延迟。而为了解决延迟问题,虚幻引擎3提
出了广义的客户端-服务端模型 的概念。
6.2.2 广义的客户端-服务端模型
对于这个模型,其实虚幻引擎官方在Unreal Development Kit的文档
UDN中,有一句非常精彩的表述:客户端是对服务端的拙劣模仿
这句话的意思是说,客户端自己也同样运行着一个世界,并不断预
测 服务端的行为。从而不断更新当前世界,以最大程度地接近 服务端
的世界。譬如说,在虚幻引擎3的时代,服务端会不断同步当前对象的
位置和速度到客户端,由于广泛存在着网络延时,因此当这个状态信息
到达客户端时,实际上这个状态已经过时了。那么客户端怎么办呢?
让我们把思路扭转一下,也就是说,客户端不再试图去“同步”服务
端,而是去“模仿”服务端。这就是说,我们承认“延迟”客观存在,只要
我们的客户端模仿得别太差劲,那么玩家是可以接受这样的效果的。客
户端可以根据同步数据发送时的当前对象的位置与速度,加上数据发送
的时间,猜测出当前对象在服务端的可能位置。并且通过修正当前世界
(比如调整当前对象的速度方向,指向新的位置),去模仿服务端位
置。如果服务端的位置和客户端差距太大,就强行闪现修正。
而拙劣 二字,就是在强调,服务端的世界是绝对正确的,而客户
端则是不断试图猜测服务端当前时间的状态。
打个比方,如图6-4所示:假设一个飞行员在追踪一个UFO(不明
飞行物),飞行员看不到那个UFO的位置,只能从基地给他的报告中获
得UFO的位置与速度向量。如何才能尽可能靠近UFO呢?因为飞行员如
果直接向基地给出的报告位置飞行,那么由于飞到那个位置需要一定时
间,等飞行员飞到,UFO已经不在那儿了。飞行员可以选择根据自己飞
行的时间、UFO当前位置、UFO的速度,猜测出一个位置,那个位置是
当UFO保持当前速度方向、大小不变的情况下,自己的飞机最终一定
会在那里和UFO汇合 ,然后向那个位置飞行。然后不断根据基地的信
息修正,于是就能尽可能保证靠拢UFO的行动位置。图6-4 网络同步的比喻:飞行员与UFO,作者王德立,已取得授权
再次反思我们刚才讨论的例子,就会发现,客户端的体验相对来说
是非常流畅的。只要网络延迟不太大,那么客户端的对象就不会发生瞬
移。这也就解释了有些采用同样模型的游戏中存在的“Ping神”现象。就
是说当某个人延迟在阈值上下波动时,一会儿客户端会去用速度调整的
方式修正位置(在地上滑来滑去),一会儿客户端又会直接把这个人的
位置强行同步(滑了一会儿一下子又传送回原地)。第7章
引擎系统相关类
问题
官方文档介绍的内容不多,我不知道我要的功能引擎有没有提
供,怎么办?
本章希望向读者介绍一些笔者在开发过程中积累的虚幻引擎自带的
功能。有些功能隐藏于代码中,没有出现在官方文档,因此尽可能介绍
给读者,避免读者重复“造轮子”。
7.1 在虚幻引擎4中使用正则表达式
正则表达式,又称正规表示法、常规表示法。
正则表达式是对字符串操作的一种逻辑公式,就是用事先定义好的
一些特定字符,以及这些特定字符的组合,组成一个“规则字符串”,这
个“规则字符串”用来表达对字符串的一种过滤逻辑。
在虚幻引擎4使用正则表达式,首先,我们要添加头文件。
includeRegex.h需要注意的是,此头文件是放在Core模块里的。一般我们不需要额
外在Build.cs里添加了。
然后,我们需要写表达式的内容。
如:FRegexPattern TestPattern(TEXT(\^d{5,12}));
其中TEXT里的内容就是正则表达式的具体内容。
接下来,我们要用到另一个类:FRegexMatcher。
FRegexMatcher主要作用是驱动正则表达式的运行,因为
FRegexPattern只是一个表达式,其不具备任何作用。除了一个构造函
数,其没有其他函数了。
FRegexMatcher提供多个函数,如返回查找字符的起始位置、结束
位置、设置查找区间等。
但是我们最常用的还是FindNext这个函数。它会返回一个bool
值,表示是否查找到匹配表示式的内容。
代码示例:
FString TextStr(ABCDEFGHIJKLMN);
FRegexPattern TestPattern(TEXT(C.+H));
FRegexMatcher TestMatcher(TestPattern, TextStr);
if (TestMatcher.FindNext{
UE_LOG(MyLog, Warning, TEXT(找到匹配内容 %d -%d),TestMatcher.GetMatchBeginning, TestMatcher.GetMatchEnding);输出 找到匹配内容 2-8
}
7.2 FPaths类的使用
在Core模块中,虚幻引擎4提供了一个用于路径相关处理的类——
FPaths。在FPaths中,主要添加3类“工具”性质的API。
1. 具体路径类 ,如:FPaths::GameDir可以获取到游戏根目录。
2. 工具类 ,如:FPaths::FileExists用于判断一个文件是否存在。
3. 路径转换类 ,如:FPaths::ConvertRelativePathToFull用于将相对
路径转换为绝对路径。
由于FPaths类中的函数众多,这里不一一列举。
7.3 XML与JSON
XML
在Xml Parser模块中,提供了两个类解析XML数据,分别是
FastXML与FXmlFile。相对而言,用FXmlFile更方便一些。所以本例使
用FXmlFile。
首先,创建一个XML文件。示例内容如下:
George
John
Reminder
Don't forget the meeting!
内容简介
前言
第一部分 虚幻引擎C++编程
第1章 开发之前——五个最常见基类
1.1 简述
1.2 本立道生:虚幻引擎的UObject和Actor
1.2.1 UObject类
1.2.2 Actor类
1.3 灵魂与肉体:Pawn、Character和Controller
1.3.1 Pawn
1.3.2 Character
1.3.3 Controller
第2章 需求到实现
2.1 分析需求
2.2 转化需求为设计
第3章 创建自己的C++类
3.1 使用Unreal Editor创建C++类
3.2 手工创建C++类
3.3 虚幻引擎类命名规则
第4章 对象
4.1 类对象的产生
4.2 类对象的获取
4.3 类对象的销毁
第5章 从C++到蓝图5.1 UPROPERTY宏
5.2 UFUNCTION宏
第6章 游戏性框架概述
6.1 行为树:概念与原理
6.1.1 为什么选择行为树
6.1.2 行为树原理
6.2 虚幻引擎网络架构
6.2.1 同步
6.2.2 广义的客户端-服务端模型
第7章 引擎系统相关类
7.1 在虚幻引擎4中使用正则表达式
7.2 FPaths类的使用
7.3 XML与JSON
7.4 文件读写与访问
7.5 GConfi类的使用
7.5.1 写配置
7.5.2 读配置
7.6 UE_LOG
7.6.1 简介
7.6.2 查看Log
7.6.3 使用Log
7.6.4 自定义Category
7.7 字符串处理
7.8 编译器相关技巧
7.8.1 “废弃”函数的标记
7.8.2 编译器指令实现跨平台
7.9 Images第二部分 虚幻引擎浅析
第8章 模块机制
8.1 模块简介
8.2 创建自己的模块
8.2.1 快速完成模块创建
8.2.2 创建模块文件夹结构
8.2.3 创建模块构建文件
8.2.4 创建模块头文件与定义文件
8.2.5 创建模块预编译头文件
8.2.6 引入模块
8.3 虚幻引擎初始化模块加载顺序
8.4 道常无名:UBT和UHT简介
8.4.1 UBT
8.4.2 UHT
第9章 重要核心系统简介
9.1 内存分配
9.1.1 Windows操作系统下的内存分配方案
9.1.2 IntelTBB内存分配器
9.2 引擎初始化过程
9.3 并行与并发
9.3.1 从实验开始
9.3.2 线程
9.3.3 TaskGraph系统
9.3.4 Std::Threa
9.3.5 线程同步
9.3.6 多进程
第10章 对象模型10.1 UObject对象
10.1.1 来源
10.1.2 重生:序列化
10.1.3 释放与消亡
10.1.4 垃圾回收
10.2 Actor对象
10.2.1 来源
10.2.2 加载
10.2.3 释放与消亡
第11章 虚幻引擎的渲染系统
11.1 渲染线程
11.1.1 渲染线程的启动
11.1.2 渲染线程的运行
11.2 渲染架构
11.2.1 延迟渲染
11.2.2 延迟渲染在PostProcess中的运用
11.3 渲染过程
11.3.1 延迟渲染到最终结果
11.3.2 渲染着色器数据提供
11.4 场景代理SceneProxy
11.4.1 逻辑的世界与渲染的世界
11.4.2 渲染代理的创建
11.4.3 渲染代理的更新
11.4.4 实战:创建新的渲染代理
11.4.5 进阶:创建静态渲染代理
11.4.6 静态网格物体渲染代理排序
11.5 Shader11.5.1 测试工程
11.5.2 定义Shader
11.5.3 定义Shader对应的C++类
11.5.4 我们做了什么
11.6 材质
11.6.1 概述
11.6.2 材质相关C++类关系
11.6.3 编译
11.6.4 ShaderMap产生
第12章 Slate界面系统
12.1 Slate的两次排布
12.2 Slate的更新
12.3 Slate的渲染
第13章 蓝图
13.1 蓝图架构简述
13.2 前端:蓝图存储与编辑
13.2.1 Schema
13.2.2 编辑器
13.3 后端:蓝图的编译
13.4 蓝图虚拟机
13.4.1 便笺纸与白领的故事
13.4.2 虚幻引擎的实现
13.4.3 C++函数注册到蓝图
13.5 蓝图系统小结
第三部分 扩展虚幻引擎
第14章 引擎独立应用程序
14.1 简介14.2 如何开始
14.3 BlankProgram
14.4 走得更远
14.4.1 预先准备
14.4.2 增加模块引用
14.4.3 添加头文件引用
14.4.4 修改Main函数为WinMain
14.4.5 添加LOCTEXT_NAMESPACE定义
14.4.6 添加SlateStandaloneApplication
14.4.7 链接CoreUObject
14.4.8 添加一个Window
14.4.9 最终代码
14.5 剥离引擎独立应用程序
第15章 插件开发
15.1 简介
15.2 开始之前
15.3 创建插件
15.3.1 引擎插件与项目插件
15.3.2 插件结构
15.3.3 模块入口
15.4 基于Slate的界面
15.4.1 Slate简介
15.4.2 Slate基础概念
15.4.3 最基础的界面
15.4.4 SNew与SAssignNew
15.4.5 Slate控件的三种类型
15.4.6 创建自定义控件15.4.7 布局控件
15.4.8 控件参数与属性
15.4.9 Delegate
15.4.10 自定义皮肤
15.4.11 图标字体
15.4.12 组件继承
15.4.13 动态控制Slot
15.4.14 自定义容器布局
15.5 UMG扩展
15.6 蓝图扩展
15.6.1 蓝图函数库扩展
15.6.2 异步节点
15.7 第三方库引用
15.7.1 lib静态链接库的使用
15.7.2 dll动态链接库的使用
第16章 自定义资源和编辑器
16.1 简易版自定义资源类型
16.2 自定义资源类型
16.2.1 切分两个模块
16.2.2 创建资源类
16.2.3 在Editor模块中创建工厂类
16.2.4 引入Editor模块
16.3 自定义资源编辑器
16.3.1 资源操作类
16.3.2 资源编辑器类
16.3.3 增加3D预览窗口未经许可,不得以任何方式复制或抄袭本书之部分或全部内容。
版权所有,侵权必究。
图书在版编目(CIP)数据
大象无形:虚幻引擎程序设计浅析罗丁力,张三著.—北京:电子工
业出版社,2017.5
ISBN 978-7-121-31349-3
Ⅰ.①大… Ⅱ.①罗…②张… Ⅲ.①游戏程序-程序设计 Ⅳ.
①TP317.6
中国版本图书馆CIP数据核字(2017)第076264号
策划编辑:符隆美
责任编辑:徐津平
印 刷:三河市良远印务有限公司
装 订:三河市良远印务有限公司
出版发行:电子工业出版社
北京市海淀区万寿路173信箱 邮编:100036
开 本:787×980 116 印张:19.75 字数:380千字
版 次:2017年5月第1版印 次:2017年5月第1次印刷
定 价:65.00元
凡所购买电子工业出版社图书有缺损问题,请向购买书店调换。若书店
售缺,请与本社发行部联系,联系及邮购电话:(010)88254888,88258888。
质量投诉请发邮件至zlts@phei.com.cn,盗版侵权举报请发邮件至
dbqq@phei.com.cn。
本书咨询联系方式:(010)51260888-819 faq@phei.com.cn。内容简介
本书以两位作者本人在使用虚幻引擎过程中的实际经历为参考,内
容包括三大部分:虚幻引擎C++编程、虚幻引擎浅析和扩展虚幻引擎。
本书提供了不同于官方文档内容的虚幻引擎相关细节和有效实践。有助
于读者一窥虚幻引擎本身设计的精妙之处,并能学习到定制虚幻引擎所
需的基础知识,实现对其按需定制。
本书适合初步了解虚幻引擎编程,并希望学习虚幻引擎架构或者希
望定制和扩展虚幻引擎的人士。前言
建德若偷,质真若渝。大方无隅,大器晚成。
大音希声,大象无形。夫唯道善贷且成。
——老子,《道德经》
虚幻引擎作为业界一流的次时代引擎,开发了无数成功的作品。在
短暂的计算机图形学发展历史上,虚幻引擎历经四代,成为游戏引擎界
举足轻重的成员之一。
但是虚幻引擎庞大而复杂的设计,阻碍了许多人学习的步伐。尽管
有蓝图系统作为图形化编程,降低了虚幻引擎的上手难度,但是当开发
者们走入虚幻引擎的C++范畴,依然会感觉到无从下手。
因此,我决定和我的同事一起来撰写本书。希望能够借助我们微薄
之力,帮你理解庞大的虚幻引擎是如何工作的。笔者对本书内容的期望
是,这是一本笔者在学习虚幻引擎时希望能够获得的书 。
同时也请明白,虚幻引擎的代码量为五百万行。本书篇幅不足以分
析整个虚幻引擎的所有模块,也无法精确地向读者展示每段代码的意
义。相反地,本书立足于:展示引擎基本结构 ,即尽可能告诉读者“它
是这么跑起来的”,对于希望精确研究每一段代码过程的读者,本书会
告知你如何寻找到对应的代码。
本书主标题为“大象无形”,《道德经》中有言:大器晚成,大音希声,大象无形。本书取“伟大的设计对于使用者来说似乎感觉不到存
在”和“优秀的系统设计让开发者不需要过多了解原理即能使用”这样的
含义。对于虚幻引擎而言,本书中介绍的很多知识,对于普通开发者来
说似乎是“没有感觉到存在”的东西,例如引擎的渲染系统,普通开发者
几乎只需要简单地完成导入和摆放就能使用,并不需要实际了解渲染系
统的工作原理。能够达到这样的效果,恰恰说明了虚幻引擎设计的优
秀:能够让开发者不需要了解系统的机制,就能够快速使用其来完成自
己的需求,此即“无形”。然而这样优秀的设计是如何完成的?如何扩展
这样的设计来让开发者完成自己独特的需求?这是本书希望探讨的内
容。
本书由两位作者共同编撰而成,其中罗丁力先生完成了第一部分
(除《引擎系统相关类》章节)和第二部分,以及第三部分中《引擎独
立应用程序》《自定义资源和编辑器》章节,张三先生完成了第二部分
中《引擎系统相关类》章节与第三部分中《插件开发》章节。
笔者才疏学浅,撰写本书仅仅为个人一家之言。欢迎每一位读者对
本书提出建议和指正,也欢迎更多的人去撰写虚幻引擎相关的书籍,共
同为虚幻引擎的推广、运用做出努力。你可以发送邮件到
three@sanwu.org。
感谢Unreal Engine,陪伴我度过了最美好的青春。
阅读之前
你好,欢迎你阅读本书。在这里我希望能向你讲述一些关于阅读本书的约定。首先,这不是一本“虚幻引擎入门宝典”或是“虚幻引擎从入
门到精通”。本书的作者们希望把视角集中到那些市面上的教程没有涉
及的领域,所以我们不会教你:
1. 如何下载引擎
2. 如何安装引擎和Visual Studio
3. 如何更新引擎
4. 如何申请虚幻引擎账户
我们假定你已经掌握这些知识。并且我们也不会教你:
1. C++语法
2. C语言语法
我们认为你在使用虚幻引擎的C++语言进行编程之前,已经掌握了
C++的基础语法,包括函数、变量、类、指针与模板。当然,我们会向
你解释虚幻引擎中的独有的C++成分,包括C++11标准的一些内容。
如果你已经做好了准备,欢迎开始你的阅读之旅。本书分为以下三
个部分:
虚幻引擎C++编程 这个部分简单介绍虚幻引擎的C++编程方式,你可以通过这个部分回顾、整理你从官方文档学习到的有关使用虚
幻引擎 进行编程的知识,并给出了一部分官方文档尚未介绍但可
以被使用的库、API与技巧。
虚幻引擎浅析 这个部分将会引导读者去研究虚幻引擎源码,并
给出笔者认为在深入使用虚幻引擎进行游戏开发的过程中,可能会
需要具备的引擎架构、模块如何工作的知识。换句话说,这个部分介绍虚幻引擎是如何工作的 。
扩展虚幻引擎 这个部分则是通过介绍虚幻引擎的插件编写,将
第二部分的知识运用起来,让读者不至于觉得这是“屠龙之技”,虽
有思辨的乐趣,却没有用武之地。进而赋予读者定制虚幻引擎 以
符合自己游戏实际情况的能力。笔者认为这是专业游戏开发者所需
要具备的技能。
在每一小节开头,笔者会提供一个常常被问及的问题,然后根据这
个问题来阐述接下来的内容,就像这样:
问题
我该如何学习虚幻引擎?
读者可以在阅读完每一个小节后,回顾小节开头的问题,以检验自
己是否已经理解了本节的内容。
笔者在这里衷心地祝愿你找到你希望学习的知识,祝你一切顺利!
鸣谢
本书在撰写过程中受到了大量同行、朋友及亲人的帮助,有许多同
行无私地贡献了自己的想法、意见及自己宝贵的经验,在此对他们表示
真挚的感谢:非常感谢Net Fly和秦春林先生对本书的支持,他们不仅帮助笔者联
系了本书的出版社,也非常认真地审阅本书的稿件,并给出了中肯有效
的意见,没有他们的帮助,本书不可能出版。
非常感谢傅建钊先生对本书的帮助,提出了大量有效的意见,并组
织了相当多的业内人士共同讨论本书的主题,他的知乎专栏《Inside
UE4》对虚幻引擎的剖析同样非常精彩,建议读者可以参考。
同时,也有不少同行针对书中许多主题给出了自己独到的见解,并
被整理到书中。LSFW先生给笔者多次反复讲解渲染框架设计,贡献出
了自己对渲染系统的研究成果;黄河水先生、Dest1ny先生撰写了大量
博客来分析虚幻引擎的底层架构,给笔者启发颇多;王德立先生帮助本
书绘制了插图。还有许许多多同行,在此恕无法一一举名。
感谢三巫社区和Epic Games对本书的出版过程的支持与帮助。
最后,作者之一罗丁力希望感谢Black Rock Shooter,感谢她在撰写
本书的过程中,对其鼓励与陪伴。
读者服务
轻松注册成为博文视点社区用户(www.broadview.com.cn),您即
可享受以下服务。
提交勘误: 您对书中内容的修改意见可在【提交勘误】处提交,若被采纳,将获赠博文视点社区积分(在您购买电子书时,积分可
用来抵扣相应金额)。与作者交流: 在页面下方【读者评论】处留下您的疑问或观点,与作者和其他读者一同学习交流。
页面入口:http:www.broadview.com.cn31349第一部分
虚幻引擎C++编程
本文假定你已经了解C++的语法,包括变量、函数、类与指针。
我们不会在这里教你如何书写函数声明,如何调用一个函数等。本
文并不需要你了解虚幻引擎的C++编程,因为我们会讲述到。但是,不
同于官方教程的简单明了,我们将会向你介绍更多关于“为什么这样设
计”的内容。如果你希望在几分钟之内上手虚幻引擎的C++编程,可能
你更应该直接阅读虚幻引擎的C++Quick Start Guide。
如果你发现,在阅读了官方的文档之后,你依然感到无所适从,不
知道如何下手的话,我想接下来的章节能够让你对虚幻引擎的开发,有
更加全面的认识。
本部分希望用一组相互之间比较独立的短文,来向你陈述虚幻引擎
C++编程的一些技巧,并帮助你准备后文需要的一些知识。第1章
开发之前——五个最常见基类
1.1 简述
问题
如何最快上手虚幻引擎的C++编程?
许多人都询问过这样的问题。学习虚幻引擎的编程技术有许多的道
路,但是笔者认为,抓住最核心的五个类,提纲挈领地学习,能够更好
地理解。这五个类就是:
UObject Actor Pawn Controller Character
1.2 本立道生:虚幻引擎的UObject
和Actor
1.2.1 UObject类
问题什么时候该继承自UObject类?什么时候应该声明一个纯C++类?
任何一个C++程序员都知道,不同于Java,C++的类可以没有父
类。那么,什么样的类对象应该继承自UObject类?
从语义上看,UObject类表示这是一个“对象”,但是这并不能说服
一个C++程序员。毕竟,任何一个纯C++类都能实例化对象。事实上,我们应该这样思考。一个类继承自UObject类,应该是它需要UObject类
提供的功能。什么样的功能让你选择继承自UObject类?
从虚幻引擎官方文档我们可以得知,UObject类提供了以下功能:
1. Garbage collection垃圾收集
2. Reference updating引用自动更新
3. Reflectio反射
4. Serialization序列化
5. Automatic updating of default property changes自动检测默认变量的更
改
6. Automatic property initialization自动变量初始化
7. Automatic editor integration和虚幻引擎编辑器的自动交互
8. Type information available at runtime运行时类型识别
9. Network replication网络复制
我将会详细讲述这些功能中重点功能的含义。
垃圾收集C++的内存管理是由程序员完成的。因此对象管理一直是一个很棘
手的问题。往往一个对象可能会引用多个其他对象,同一个对象也可能
会被多个对象引用。那么,当你不需要用到当前对象A的时候,该不该
释放该对象所在的内存区域?
任何人都会犹豫:
释放 一旦有别的对象引用当前对象,释放后就会产生野指针。
当另一个对象来访问时,会看到空空如也甚至是其他对象的内存区
域。
不释放 有可能我已经是最后一个引用这个对象的人了,一旦我
丢弃这个指针,这个对象就不会再有人知道,这片内存区域永远无
法被回收。
对此虚幻引擎提供了如下两个解决方案:
1. 继承自UObject类,同时指向UObject类实例对象的指针成员变量,使用UPROPERTY宏进行标记。虚幻引擎的UObject架构会自动地
被UProperty标记的变量 考虑到垃圾回收系统中,自动地进行对象
的生命周期管理。
2. 采用智能指针。请注意,只有非UObject类型 ,才能够使用智能指
针进行自动内存释放。关于智能指针的讨论,请看本书后面的章
节。
反射反射并不是图形学意义上的“反射”。而是指一种语言的机制。这样
的机制在C、Java中已经存在,但是C++并没有。我以一种通俗易懂的
解释来描述反射,如果你需要反射的详细解释,请阅读搜索引擎中对反
射的解释。
如果你是一名C++程序员,那么请你思考一个问题:
我该如何在运行时获取一个类?有哪些成员变量、成员函数?我该
如何获取这些成员变量的名字?
很难对吗?C++本身没有提供这样一套机制。尽管你可以用各种方
式来手动实现。
虚幻引擎实现了这样一套机制。如果你好奇反射是怎样实现的,可
以阅读本书第二部分的内容。
序列化
当你希望把一个类的对象保存到磁盘,同时在下次运行时完好无损
地加载,那么你同样需要继承自UObject类。
但是需要澄清的是,你可以通过给自己的纯C++类手动实现序列化
所需要的函数,来让这个类支持序列化功能。这并不是UObject类独有
的。
与虚幻引擎编辑器的交互还记得虚幻引擎编辑器的Editor面板吗?你希望你的类的变量能够
被Editor简单地编辑吗?那么你需要继承自这个类。
运行时类型识别
请注意,虚幻引擎打开了GR-编译器参数。意味着你无法使用
C++标准的RTTI机制:dynamic_cast。如果你希望使用,请继承自
UObject类,然后使用Cast<>函数来完成。
这是因为虚幻引擎实现了一套自己的、更高效的运行时类型识别的
方案。
网络复制
当你在进行网络游戏开发(cs架构)时,你一定希望能够自动地处
理变量的同步。
而继承自UObject类,其被宏标记的变量能够自动地完成网络复制
的功能。从服务器端复制对应的变量到客户端。
综上所述,当你需要这些功能的时候,你的这个类应该继承自
UObject类。
请注意:UObject类会在引擎加载阶段,创建一个Default Object默
认对象。这意味着:1. 构造函数并不是在游戏运行的时候调用,同时即便你只有一个
UObject对象存在于场景中,构造函数依然会被调用两次。
2. 构造函数被调用的时候,UWorld不一定存在。GetWorld返回值有
可能为空!
1.2.2 Actor类
问题
什么时候该继承自Actor类?
Actor类是游戏中一切实体Actor的基类。这样的解释严格来说是错
误的,笔者不能使用Actor来解释Actor。那么我们应该更加明晰一些。
同样地,我们采用之前的分析方式。Actor类提供了什么功能,让我们
选择继承自它?
有朋友会回答,Actor类在场景中拥有一个位置坐标和旋转量。
请注意,这是也是错误 的。
Actor类拥有这样的能力:它能够被挂载组件 。
组件并不是Actor。如果你观察,会发现所有组件的类的开头是U而
不是A。虚幻引擎中,Component的含义与Unity引擎中的Component具
有极大的区别。如果你从Unity引擎转移而来,我有必要向你澄清这一
点:虚幻引擎中,一个场景实体对应一个类。 在Unity中,一个对象可
以挂载多个脚本组件。每个脚本组件是一个单独的类。因而从某种意义
上说,有许多Unity程序员认为这相当于一个场景实体可以看作是多个
类。
虚幻引擎中,Component的含义被大大削弱。它只是组件,不能越
俎代庖。Untiy引擎中组件的大多数功能将会交给对应的、继承自Actor
类的子类来实现。
而坐标与旋转量,只是一个Scene Component组件。如果这个Actor
不需要一个固定位置(例如你的某个Manager),你甚至可以不给Actor
挂载Scene Component组件。
你希望让Actor被渲染?给一个静态网格组件。
你希望Actor有骨骼动画?给一个骨架网格物体组件。
你希望你的Actor能够移动?通常来说你可以直接在你的Actor类中
书写代码来实现。当然,你也可以附加一个Movement组件以专门
处理移动。
所以,需要挂载组件的时候,你才应该继承自Actor类。也就是
说,我刚刚描述的,你的Manager,也许只需要一个纯C++类就够了
(当然,你需要序列化之类的功能,那就是另一回事了)。
1.3 灵魂与肉体:Pawn、Character
和Controller
1.3.1 Pawn在国际象棋里面,Pawn代表的是如图1-1所示:
图1-1 Pawn,国际象棋棋子
没错,就是兵或卒。那么这个类为何这样命名?因为这个命名我认
为是极为形象的。其十分生动地体现了Pawn类的特性。
如果你研究了Pawn类的源码,你会发现Pawn类提供了被“操作”的
特性。它能够被一个Controller操纵。这个Controller可以是玩家,当然
也可以是AI(人工智能)。这就像是一个棋手,操作着这个棋子。这就是Pawn类,一个被操纵的兵或卒,一个一旦脱离棋手就无法
自主行动的、悲哀的肉体。
1.3.2 Character
Character类代表一个角色,它继承自Pawn类。那么,什么时候该继
承自Character类,什么时候该继承自Pawn类呢?这个问题的答案,我们
必须从Character类的定义中寻找——它提供了什么样的功能?
Character类提供了一个特殊的组件,Character Movement。这个组
件提供了一个基础的、基于胶囊体的角色移动功能。包括移动和跳跃,以及如果你需要,还能扩展出更多,例如蹲伏和爬行。
如果你的Pawn类十分简单,或者不需要这样的移动逻辑(比如外
星人飞船),那么你可以不继承自这个类。请不要有负罪感:
1. 不是虚幻引擎中的每一个类,你都得继承一遍。
2. 在Unreal Engine 3中,没有Character类,只有Pawn类。
当然,现在很多游戏中的角色(无论是人类,还是某些两足行走的
怪物),都能够适用于Character类的逻辑。
1.3.3 Controller
本节的标题是:灵魂与肉体。作为一名无神论者,我只是采用这样
的比喻。但是似乎Epic的开发人员选择了棋手与棋子的比喻。相比之
下,我的比喻还是太过肤浅了些。Controller是漂浮在PawnCharacter之上的灵魂。它操纵着Pawn和
Character的行为。Controller可以是AI,AIController类,你可以在这个
类中使用虚幻引擎优秀的行为树EQS环境查询系统。同样也可以是玩
家,Player Controller类。你可以在这个类中绑定输入,然后转化为对
Pawn的指令。
我希望阐述的是,为何虚幻引擎采用这样的设计。Epic给出的理由
非常简单:“不同的怪物也许会共享同样的Controller,从而获得类似的
行为”。其实,Controller抽象掉了“怪物行为”,也就是扮演了有神论者
眼中“灵魂”的角色。
既然是灵魂,那么肉体就不唯一,因此灵魂可以通过
PossessUnPossess来控制一个肉体,或者从一个肉体上离开。
肉体拥有的只是简单的前进、转向、跳跃、开火等函数。而
Controller则是能调用这些函数。从某种意义上来说,MVC中的
Controller与虚幻引擎这套系统有着某种类似。虚幻引擎的Controller对
应着MVC的Controller,Pawn就是Model,而Pawn挂载的动态网格组件
(骨架网格或者静态网格),对应着MVC的View。虽然这种比喻不是
非常恰当,但是能方便理解。第2章
需求到实现
问题
我有一个庞大的游戏创意,但这是我第一次制作游戏,我该设计
哪些类?
2.1 分析需求
在前面的内容中,笔者介绍了虚幻引擎中重要的几个类的含义。并
告诉了你如何继承。但是,如何从具体的需求中,分析出类的设计和架
构,并最终导出一个完整的解决方案呢?这其实是更多人希望知道的。
那么,我们该从什么地方开始呢?答案是,从需求开始。
考虑到阅读本书的有些朋友没有经过系统的软件工程训练(这不是
你的错,有可能你是一个热爱游戏,热爱虚幻引擎的开发者),因此,我有必要简单介绍需求这个概念。
什么是需求?客户想要什么,就是需求。
那么,从游戏开发的角度而言,需求就是你身为游戏设计师,分析
出的需要实现的东西。可能最开始只是模糊不清的字句,比如“我希望
我的主角能够在楼宇之间不断跳跃”,或是“我希望我的主角能够手持一个手电筒不断探索”。不管怎样,你的游戏应该有一个完整的、成文本
的设计书。这个设计书描述了你这个游戏的大体设计。你可以从网络上
找到各种各样的游戏设计书模板,但是你最终的目的是要向一个陌生
人,用你的设计书阐述你的游戏设计,让他明白你想做一个什么样的游
戏。接下来,你需要将你的设计转化为需求点。你应该按照分类来排列
这些需求。如果你希望你的开发过程更加清晰可控,你应该采用专业的
软件工程建模工具来绘制你的需求分析图。在UML(统一建模语言)
中,你可以用“用例图”来表达你的每一个需求。
或者,通过有意义的短句:
“玩家可以通过按下空格键来跳跃”。
“玩家通过鼠标滚轮来切换武器”。
从而让你的设计变成一个一个的开发单位。
我知道,如果你是一个游戏行业的从业人士,你会认为我的这段描
述是多么的粗略,甚至显得外行。但是,你应该意识到,我是在向一些
非从业人士,那些依靠热血的独立开发者们,讲解软件工程的一些知
识。
如果你是一名热血的开发者,正在使用QQ群,仅仅凭借聊天来继
续你的项目,我希望你能够认真地看待我所提出的建议。另外,你还需
要版本控制系统,以及起码的一个项目管理系统。如果你希望知道按照
怎样的步骤来开发你的游戏,我建议你可以先从敏捷开发模型中选择一
个,在你的项目中运用。软件工程是一门建立在无数开发者血泪之上的
科学,请谨慎地对待这门科学给出的建议。2.2 转化需求为设计
如果你接受过软件工程训练,那么你会不假思索地描述,在需求分
析之后,应该是概要设计、详细设计和编码,之后会有测试等等。当
然,你也会回忆起各种敏捷开发模型,例如XP、测试驱动开发、Scrum,等等。但我不会在这里讲述软件工程,而是把讨论聚集在如何
实现一个具体需求设计上。这就好像,我不是在讨论绘画时的握笔、排
线,而是在讨论在哪个位置下笔,在哪个位置排线。假设我们拿到的是
这样的需求:
“玩家手中会持有一把武器,按下鼠标左键时,武器会射出子弹”。
从这句话中,我们能够找到这样的几个重要名词:玩家、武器和子
弹。
我们意识到,这几个名词都可以作为类。也许有些类虚幻引擎已经
提供给我们了,如玩家APlayerController类。那么,我们意识到,我们
需要给武器和子弹各创建一个类。现在问题是,武器类该继承自什么?
让我们回顾前面的章节。
首先,武器类有坐标吗?有的。这该是一个Actor的子类。
武器类是一种兵吗?不是,武器类不该是Pawn的子类。
恭喜你,你已经确定了武器类在整个游戏的类树中的位置。同样,你也能够确定子弹类在类树中的位置。它应该继承自Actor类,同时带
有一个Projectile Movement组件。进一步你能够分析出,类与类之间的
持有、通信关系:1. 玩家类对象持有 武器类对象。
2. 武器类对象产生 子弹对象。
3. 玩家的输入会调用 武器类对象的函数,以发射子弹。
你已经能够想象出这几个类与函数的设计了。在下个章节中,笔者
将会阐述如何把这些设计转化为实实在在的C++代码。第3章
创建自己的C++类
问题
我想好我有哪些类了,现在我该怎么创建它们的代码呢?
3.1 使用Unreal Editor创建C++类
使用Unreal Editor创建C++类
你可以按照以下的步骤,使用Unreal Editor的C++类向导来创建你
的C++类。在内容浏览器的C++类文件夹中单击鼠标右键,在弹出菜单
中选择新建C++类,如图3-1所示。图3-1 新建C++类第一步
在弹出的菜单中选择你的父类,如图3-2所示。
图3-2 新建C++类第二步
如果你找不到你的父类,请勾选“显示所有类”,如图3-3所示。图3-3 新建C++类第三步
选中合适的父类后点击继续,填写你的类型名与路径,如图3-4所
示。
图3-4 新建C++类第四步
点击创建类后,虚幻引擎会自动打开Visual Studio。并且产生出两
个模板文件(.h与.cpp),然后会自动编译,并且加载到引擎中。接下
来你要做的与你在C++教程中学习到的一致。调用各种函数,完成你想
要的功能吧。
3.2 手工创建C++类
如果你出于某种原因,希望自己手动创建C++类。你需要完成以下
的步骤:在工程目录的Source文件夹下,找到和你游戏名称一致的文件夹。
根据不同人创建的工程结构不同,你可能会发现下面两种文件结构:
1. public文件夹,private文件夹,.build.cs文件。
2. 一堆.cpp和.h文件,.build.cs文件。
第一种文件结构是标准的虚幻引擎模块文件结构。
1. 创建你的.h和.cpp文件,如果你是第一种文件结构,.h文件放在
public文件夹内,.cpp文件放置在private文件夹内。
2. 在.h中声明你的类:如果你的类继承自UObject,你的类名上方需要
加入UCLASS宏。同时,你需要在类体的第一行添加
GENERATED_UCLASS_BODY宏,或者GENERATED_BODY
宏。前者需要手动实现一个带有const FObject Initializer参数的构
造函数。后者需要手动实现一个无参数构造函数。注意笔者说的
是“实现”而非声明。
3. 在你的.cpp文件中,包含当前模块的PCH文件。一般是模块名
+private PCH.h。如果是游戏模块,有可能包含的是游戏工程名.h。
4. 编译。
3.3 虚幻引擎类命名规则
终于我们要讨论到了虚幻引擎类的命名规则了。如果你仔细阅读,你会发现,笔者在前文中几乎没有用“Object”而是用的UObject作为类的
名字。那么之前的U代表什么?这是按照虚幻引擎的命名规则,添加的
命名前缀。常用的前缀如下:F 纯C++类
U 继承自UObject,但不继承自Actor
A 继承自Actor
S Slate控件相关类
H HitResult相关类
虚幻引擎头文件工具Unreal Header Tool会在编译前检查你的类命
名。如果类的命名出现错误,那么它会提出警告并终止编译。在后文的
描述中,笔者会按照以下规则:
1. 如果笔者是在阐述理论和逻辑,例如“你需要一个Weapon武器
类”,这个时候笔者不会加上前缀。这是为了行文以及阅读的流
畅。
2. 如果笔者是在描述具体的代码,例如“请阅读
UStaticMeshComponent类的代码”,或者“调用AActor实例的
GetActorLocation函数”,此时为了与实际代码匹配,笔者会加上类
的前缀。第4章
对象
问题
我也声明好了需要的类,那么:
我该如何实例化对象?
我该如何在世界中产生我声明的Actor类?
我该如何调用这些对象身上的函数?
4.1 类对象的产生
在标准C++中,一个类产生一个对象,被称为“实例化”。实例化对
象的方法是通过new关键字。
而在虚幻引擎中,这一个问题变得略微复杂。对于某些类型,我们
不得不通过调用某些函数来产生出对象。具体而言:
1. 如果你的类是一个纯C++类型(F开头),你可以通过new来产生对
象。
2. 如果你的类继承自UObject但不继承自Actor,你需要通过
NewObject函数来产生出对象。3. 如果你的类继承自AActor,你需要通过SpawnActor函数来产生出对
象。
New Object函数定义如下:
template
T NewObject(
UObject Outer = (UObject)GetTransientPackage,UClass Class = T::StaticClass,FName Name = NAME_None,EObjectFlags Flags = RF_NoFlags,UObject Template = nullptr,bool bCopyTransientsFromClassDefaults = false,FObjectInstancingGraph InInstanceGraph = nullptr)
事实上你可以简单地这样调用它:
NewObject
这会返回一个指向你的类的指针,此时这个对象被分配在临时包
中。下一次加载会被清除。如果你的类继承自Actor,你需要通过
UWorld对象(可以通过GetWorld获得)的SpawnActor函数来产生出对
象。函数定义如下(有多个,这里只列出一个):
template< class T >T SpawnActor(
FVector const Location,FRotator const Rotation,const FActorSpawnParameters SpawnParameters = FActorSpawnParameters
)
你可以这样简单地调用它:
GetWorld( )->SpawnActor
极为特殊的,如果你需要产生出一个Slate类——如果你有这样的需
求,要么你已经在进行很深的开发,要么就是你的教程的版本过老,依
然在使用Slate来开发游戏的界面控件,你需要使用SNew函数。我无法
给出SNew函数的原型。关于Slate的详细讨论,请阅读后文中Slate的章
节。
4.2 类对象的获取
获取一个类对象的唯一方法,就是通过某种方式传递到这个对象的
指针或引用。
但是有一个特殊的情况,也是大家经常询问到的:如何获取一个场
景中,某种Actor的所有实例?答案是,借助Actor迭代器:
TActorIterator。示例代码如下:for(TActorIterator
{...do something
}
其中TActorIterator的泛型参数不一定是Actor,可以是你需要查找的
其他类型。你可以通过
Iterater
来获取指向实际对象的指针。或者,你可以直接通过
Iterater->YourFunction(
来调用你需要的成员函数。
4.3 类对象的销毁
如今,一个类走到了其生命的尽头。我们希望销毁它,从而获得其
所占用的内存空间。我们应该采用什么样的方式?我并不认为这是一个
简单的命题。事实上我会分几类来加以阐述:
纯C++类如果你的纯C++类是在函数体中创建,而且不是通过new来分配内
存,例如:
void YourFunction( )
{
FYourClass YourObject=FYourClass;...Do something.
}
此时这个类的对象会在函数调用结束后,随着函数栈空间的释放,一起释放掉。不需要你手动干涉。
如果你的纯C++类是使用new来分配内存,而且你直接传递类的指
针。那么你需要意识到:除非你手动删除,否则这一块内存将永远不
会被释放 。如果你忘记了,这将产生内存泄漏。
如果你的纯C++类使用new来分配内存,同时你使用智能指针
TSharedPtrTSharedRef来进行管理,那么你的类对象将不需要也不应该
被你手动释放。智能指针会使用引用计数来完成自动的内存释放。你可
以使用MakeShareable函数来转化普通指针为智能指针:
TSharedPtr
笔者强烈建议,在你没有充分的把握之前,不要使用手动
newdelete方案。你可以使用智能指针。UObject类
UObject类的情况略有不同。事实上你无法使用智能指针来管理
UObject对象 。
前文已经提到,UObject采用自动垃圾回收机制。当一个类的成员
变量包含指向UObject的对象,同时又带有UPROPERTY宏定义,那么
这个成员变量将会触发引用计数机制。
垃圾回收器会定期从根节点Root开始检查,当一个UObject没有被
别的任何UObject引用,就会被垃圾回收。你可以通过AddToRoot函数来
让一个UObject一直不被回收。
Actor类
Actor类对象可以通过调用Destory函数来请求销毁,这样的销毁意
味着将当前Actor从所属的世界中“摧毁”。但是对象对应内存的回收依然
是由系统决定。第5章
从C++到蓝图
问题
虚幻引擎的蓝图真是太好用了,我该如何让蓝图能够调用我的
C++类中的函数呢?
5.1 UPROPERTY宏
当你需要将一个UObject类的子类的成员变量注册到蓝图中时,你
只需要借助UPROPERTY宏即可完成。
UPROPERTY(...)
你可以传递更多参数来控制UPROPERTY宏的行为,通常而言,如
果你要注册一个变量到蓝图中,你可以这样书写:
UPROPERTY(BlueprintReadWrite,VisibleAnywhere,Category=Object)
关于能够在UPROPERTY中使用的参数,请阅读官方文档的这一章
节。5.2 UFUNCTION宏
你也可以通过UFUNCTION宏来注册函数到蓝图中。下面是一个注
册的案例:
UFUNCTION(BlueprintCallable,Category=Test)
其中BlueprintCallable是一个很重要的参数,表示这个函数可以被蓝
图调用。可选的还有:BlueprintImplementEventBlueprintNativeEvent。
前者表示,这个成员函数由其蓝图的子类实现,你不应该尝试在C++中
给出函数的实现,这会导致链接错误。后者表示,这个成员函数提供一
个“C++的默认实现”,同时也可以被蓝图重载。你需要提供一个“函数名
_Implement”为名字的函数实现,放置于.cpp中。第6章
游戏性框架概述
6.1 行为树:概念与原理
6.1.1 为什么选择行为树
在虚幻引擎3的时代,AI框架选择的是状态机来实现。甚至为了支
持状态机编程,虚幻引擎的脚本语言Unreal Script专门增加了几个状态
相关的关键字,足以看出虚幻引擎官方对状态机系统的重视。但是这个
系统在虚幻引擎4中被行为树系统代替,状态机只在动画蓝图中保留
(当然这并不妨碍你在任何地方书写状态机,毕竟用C++实现一个状态
机模式并不复杂)。
那么,是什么促使Epic做出这样的决定呢?简而言之,同样的AI模
式,用状态机会涉及大量的跳转,但是用行为树就相对来说更加简化。
同时由于行为树的“退行”特点,也就是“逐个尝试,不行就换”的思路,更加接近人类的思维方式,因此当你熟悉了行为树的框架之后,能够更
加快速地撰写AI相关的代码。
6.1.2 行为树原理
在对虚幻引擎的行为树进行介绍之前,我认为应该先介绍“行为
树”。“行为树”是一种通用的AI框架或者说模式,其并不依附于特定的引擎存在,并且虚幻引擎的行为树也与标准的行为树模式存在一定的差
异。
现在关于行为树的讨论并不多,因此我将会简单介绍行为树的一些
内容。请看如图6-1所示的行为树案例1:Selector。
图6-1 行为树案例1:Selector
我们会发现,这是一个由节点、连接线构成的行为树。行为树包含
三种类型的节点:
流程控制:包含Selector选择器和Sequence顺序执行器(关于平行执
行parallel节点,暂时不做分析)。
装饰器:对子树的返回结果进行处理的节点。
执行节点:执行节点必然是叶子节点,执行具体的任务,并在任务
执行一段时间后,根据任务执行成功与否,返回true或者false。
让我们看一个具体的例子,如图6-2所示:
图6-2 行为树案例除去根节点Root,Selector就是一个流程控制节点。Selector节点会
从左到右逐个执行下面的子树,如果有一个子树返回true,它就会返回
true,只有所有的子树均返回false,它才会返回false。这就类似于日常
生活中“几个方案都试一试”的概念。
反映到如图6-2所示的行为树案例,对应左侧的图片。这个行为树
实际上讲述了一段艰难的故事:在荒年,如果吃不起饭的时候,就只能
选择吃糠了。
假如流程节点被换为了Sequence,那么Sequence节点就会按顺序执
行自己的子树,只有当前子树返回true,才会去执行下一个子树,直到
全部执行完毕,才会向上一级返回true。任何一个子树返回了false,它
就会停止执行,返回false。类似于日常生活中“依次执行”的概念。把一
个已有的任务分为几个步骤,然后逐个去执行,任何一个步骤无法完
成,都意味着了任务失败。也就是说,这一次的行为树表达了这样的概
念:荒年好不容易收了点大米,先做成饭,然后慢慢吃,每天吃点饭之
后,就开始吃糠,直到吃饱。
仔细归纳一下,我们会发现这两个行为树其实包含了许多信息:
Selector版本
终止性 只要能通过吃饭把自己吃饱,绝不吃糠,表达了一种“今
朝有酒今朝醉”式的享乐主义理念。换句话说,只要“吃饭”节点返
回成功,今天就算过去了,直接向Root返回成功。
优先级 即使是Selector选择器,依然具有优先级。一旦饿了优先
找饭吃,而不是找糠。先吃好的,吃饱了再说。只有当“吃饭”节点
返回失败的情况下,才开始尝试优先级更低的节点。只要有一个成功便是成功,全部失败才失败 只要找到一点能吃
的东西,吃饱了都算数。只有饭也没有,糠也没了,什么吃的都找
不到了,才向Root汇报失败——没办法,真的没东西吃了。
Sequence版本
顺序性 这可能是一家精打细算的主人,即使有饭吃,也得一边
吃点饭,一边吃糠。这是为了细水长流,以后每天都有点饭吃。也
就是说,当“吃饭”返回成功的时候,需要继续执行接下来的节点。
任何一个步骤失败都失败,全部步骤做完才成功 当一点饭都没
有的时候,主人家陷入了绝望的境地,也不去尝试吃糠了,直接向
Root节点汇报“没办法了,真的失败了”。
标准的装饰器节点,是对子树返回的结果进行处理,再向上一级进
行返回的。例如Force Success节点,就是强制让子树返回true,不管子
树真正返回的是什么。如上文例子,假如在Root上加了一个Force
Success装饰器节点,那就像是古代有些鱼肉百姓的官员,一方面对下面
报上来的饥荒情况心知肚明,另一方面又不停向上级返回“成功”。
日常生活中的很多行为,都可以被这样的方式总结出来。而行为树
对行为进行分析的关键在于,一定要从宏观到微观。先切分大的步骤,再逐步细化。以上班为例:
上班主要分为三个步骤:准备阶段,交通阶段,上楼阶段。
这三个步骤是按顺序执行的,不能分割。任何一个步骤出问题,都
会导致上班不成功,比如你准备阶段不成功(“我再睡五分钟”,没想到
醒过来已经是中午了),或者交通阶段不成功(被车撞飞导致无法继续前往公司),结果都是上班不成功,你今天的工资肯定没了。
那么我们要继续细分,以细分交通阶段为例。对于通常的城市上班
族来说,基本上能选择的交通工具,按照优先级排列如下:
1. 地铁:因为地铁基本上通行时间固定,而且地铁很少挤不上去,所
以会成为上班族首选的交通工具。
2. 开车:可能由于特殊原因,今天地铁整修,或者是被水淹了之类,总之地铁选择不了的情况下,就会考虑开车上班,虽然堵了一点,但是至少能到公司。
3. 走路:结果没想到大家都想开车上班,于是马路被堵得彻底走不动
了。这时候就只能选择走路去公司了。
因此,交通阶段是一个Selector,是从多个加权方案(有优先级)
中逐个尝试的。由此大家会发现,Selector与普通的随机选择还是有区
别的,我们能够通过顺序来定义“从一般到特殊”的AI行为。这是行为树
很强大的一个地方。
同样的,上楼阶段也有可能出现选择电梯或者楼梯的选择,这里不
再赘述。最终应该是如图6-3所示的行为树案例3:上班。图6-3 行为树案例3:上班
通过这个行为树,我们会发现,在某个状况下,这个AI会按照这样
的方式执行:
1. 首先进入准备阶段:
a. 刷牙。
b. 洗脸。
2. 准备阶段完成,开始准备去上班:
a. 看看地铁能不能坐:
i. 走到地铁站,发现地铁因为修理被关闭。
ii. 返回false(回到家中)。
b. 选择开车方案:
i. 开车出门。
ii. 返回true,顺利到达上班地点。
c. 交通阶段完成,开始上楼:
i. 尝试电梯,发现电梯坏掉了,返回false。ii. 尝试走楼梯,终于到达了办公室。
这可能是一个很倒霉的AI,不过它做出了一套让我们都能够认为很
自然的行为。而不是因为地铁封闭,就傻傻地站在地铁口等待。这就是
行为树系统的威力。
6.2 虚幻引擎网络架构
6.2.1 同步
从第一个联机游戏开始,同步就成为了一个重点的研究对象。随着
需要同步的人数不断变多,联机同步架构的设计也在不断地变动。
最早的联机同步按照点对点网络的思路进行设计。也就意味着,假
如开了一个房间,有4个玩家加入,那么玩家1输入每一个指令与消息,都会发往其他3个人,从而让4个人的画面得以一致。大致类似于,如果
你按了一下W键,那么这个前进的指令就会发送到所有你连接的人的电
脑上。然后你所控制的那个人物都会向前移动一点点。
在某些早期的联机游戏中,为了保证所有人的同步,甚至采用过更
极端一些的方法,如强制所有人更新频率一致。
点对点同步带来的弊端相当得多,毕竟这是一个网状结构。例如:
1. 由于点对点同步带来的传输消耗,因此网络传输压力会很大。
2. 由于点对点同步不存在“权威”性,因此当其中一个人作弊时,会影
响所有的客户端。且很难判定“作弊”。因此,经典的服务器-客户端架构模型产生了。
一台(在当年)具有较高性能的主机被选出来,作为中心服务器。
所有的“游戏性相关指令”都会被发往中心服务器进行处理,随后中心服
务器会把世界的状态 同步到各个客户端。
于是之前的网状结构变为了星型结构 ,且出现了权威服务器 的概
念。也就意味着,作弊变得更加困难。无法通过直接传递坐标(只能传
递游戏性相关指令)给服务端,就算是本地客户端,坐标也来自于服务
端同步过来的信息。
也就是说,我们能把游戏框架切分为两个部分:一部分是“指令”,是对游戏世界造成影响的代码请求,比如“人物前移3米”“人物挥刀碰到
了怪物”;另一部分是“状态”,是游戏世界的各种数值状态,比如“当前
人物生命值”“当前怪物生命值”。客户端只能向服务端发送“指令”,服
务端根据指令处理后,改变游戏世界的状态,并将状态同步 给每一个
客户端。
这是相当优秀的一个设计,对同步考虑了非常多。因此,被作为了
现在绝大多数网络同步模型的基本思路。不过这是不够的,因为有另一
个非常基本的问题,那就是延迟。而为了解决延迟问题,虚幻引擎3提
出了广义的客户端-服务端模型 的概念。
6.2.2 广义的客户端-服务端模型
对于这个模型,其实虚幻引擎官方在Unreal Development Kit的文档
UDN中,有一句非常精彩的表述:客户端是对服务端的拙劣模仿
这句话的意思是说,客户端自己也同样运行着一个世界,并不断预
测 服务端的行为。从而不断更新当前世界,以最大程度地接近 服务端
的世界。譬如说,在虚幻引擎3的时代,服务端会不断同步当前对象的
位置和速度到客户端,由于广泛存在着网络延时,因此当这个状态信息
到达客户端时,实际上这个状态已经过时了。那么客户端怎么办呢?
让我们把思路扭转一下,也就是说,客户端不再试图去“同步”服务
端,而是去“模仿”服务端。这就是说,我们承认“延迟”客观存在,只要
我们的客户端模仿得别太差劲,那么玩家是可以接受这样的效果的。客
户端可以根据同步数据发送时的当前对象的位置与速度,加上数据发送
的时间,猜测出当前对象在服务端的可能位置。并且通过修正当前世界
(比如调整当前对象的速度方向,指向新的位置),去模仿服务端位
置。如果服务端的位置和客户端差距太大,就强行闪现修正。
而拙劣 二字,就是在强调,服务端的世界是绝对正确的,而客户
端则是不断试图猜测服务端当前时间的状态。
打个比方,如图6-4所示:假设一个飞行员在追踪一个UFO(不明
飞行物),飞行员看不到那个UFO的位置,只能从基地给他的报告中获
得UFO的位置与速度向量。如何才能尽可能靠近UFO呢?因为飞行员如
果直接向基地给出的报告位置飞行,那么由于飞到那个位置需要一定时
间,等飞行员飞到,UFO已经不在那儿了。飞行员可以选择根据自己飞
行的时间、UFO当前位置、UFO的速度,猜测出一个位置,那个位置是
当UFO保持当前速度方向、大小不变的情况下,自己的飞机最终一定
会在那里和UFO汇合 ,然后向那个位置飞行。然后不断根据基地的信
息修正,于是就能尽可能保证靠拢UFO的行动位置。图6-4 网络同步的比喻:飞行员与UFO,作者王德立,已取得授权
再次反思我们刚才讨论的例子,就会发现,客户端的体验相对来说
是非常流畅的。只要网络延迟不太大,那么客户端的对象就不会发生瞬
移。这也就解释了有些采用同样模型的游戏中存在的“Ping神”现象。就
是说当某个人延迟在阈值上下波动时,一会儿客户端会去用速度调整的
方式修正位置(在地上滑来滑去),一会儿客户端又会直接把这个人的
位置强行同步(滑了一会儿一下子又传送回原地)。第7章
引擎系统相关类
问题
官方文档介绍的内容不多,我不知道我要的功能引擎有没有提
供,怎么办?
本章希望向读者介绍一些笔者在开发过程中积累的虚幻引擎自带的
功能。有些功能隐藏于代码中,没有出现在官方文档,因此尽可能介绍
给读者,避免读者重复“造轮子”。
7.1 在虚幻引擎4中使用正则表达式
正则表达式,又称正规表示法、常规表示法。
正则表达式是对字符串操作的一种逻辑公式,就是用事先定义好的
一些特定字符,以及这些特定字符的组合,组成一个“规则字符串”,这
个“规则字符串”用来表达对字符串的一种过滤逻辑。
在虚幻引擎4使用正则表达式,首先,我们要添加头文件。
includeRegex.h需要注意的是,此头文件是放在Core模块里的。一般我们不需要额
外在Build.cs里添加了。
然后,我们需要写表达式的内容。
如:FRegexPattern TestPattern(TEXT(\^d{5,12}));
其中TEXT里的内容就是正则表达式的具体内容。
接下来,我们要用到另一个类:FRegexMatcher。
FRegexMatcher主要作用是驱动正则表达式的运行,因为
FRegexPattern只是一个表达式,其不具备任何作用。除了一个构造函
数,其没有其他函数了。
FRegexMatcher提供多个函数,如返回查找字符的起始位置、结束
位置、设置查找区间等。
但是我们最常用的还是FindNext这个函数。它会返回一个bool
值,表示是否查找到匹配表示式的内容。
代码示例:
FString TextStr(ABCDEFGHIJKLMN);
FRegexPattern TestPattern(TEXT(C.+H));
FRegexMatcher TestMatcher(TestPattern, TextStr);
if (TestMatcher.FindNext{
UE_LOG(MyLog, Warning, TEXT(找到匹配内容 %d -%d),TestMatcher.GetMatchBeginning, TestMatcher.GetMatchEnding);输出 找到匹配内容 2-8
}
7.2 FPaths类的使用
在Core模块中,虚幻引擎4提供了一个用于路径相关处理的类——
FPaths。在FPaths中,主要添加3类“工具”性质的API。
1. 具体路径类 ,如:FPaths::GameDir可以获取到游戏根目录。
2. 工具类 ,如:FPaths::FileExists用于判断一个文件是否存在。
3. 路径转换类 ,如:FPaths::ConvertRelativePathToFull用于将相对
路径转换为绝对路径。
由于FPaths类中的函数众多,这里不一一列举。
7.3 XML与JSON
XML
在Xml Parser模块中,提供了两个类解析XML数据,分别是
FastXML与FXmlFile。相对而言,用FXmlFile更方便一些。所以本例使
用FXmlFile。
首先,创建一个XML文件。示例内容如下:
Don't forget the meeting!
解析XML代码如下(需要引入相应头文件):
FString xmlFilePath =
FPaths::GamePluginsDir TEXT(SimpleWindowResourcesTest.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
for (FXmlNode node : list_node)
{
UE_LOG(LogSimpleApp, Warning, TEXT(list :%s ), (node->
GetContent));
}
JSON
JSON解析需要用到JSON模块以及IncludeJson.h。使用示例:
FString JsonStr = [{\author\:\Tim\},{\age\:\100\}];
TArray
TSharedRef< TJsonReader
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。考虑到许多读者有读写自己文件的需求,故花一
定篇幅重点阐述文件读写。
该类定义于头文件PlatformFilemanager.h中,所属模块为Core,一般
虚幻引擎会默认包含本模块,如果没有,请手动在当前模块的.build.cs
中包含。
通过以下调用:
FPlatformFileManager::Get->GetPlatformFile;
能够获得一个IPlatformFile类型的引用。这个接口即提供了通用的
文件访问接口,读者可以自行查询。
虚幻引擎之所以这么麻烦,是为了提供文件的跨平台性。如果纯粹
提供静态函数或者全局函数,会增加代码的复杂程度。而且
FPlatformFileManager作为全局管理单例,能够更有效地管理文件读写操
作。
以下文件获得函数调用的具体参数信息:
\Engine\Source\Runtime\Core\Public\GenericPlatform\GenericPlatformFile.h
虚幻引擎提供的比较常用的函数如下:
拷贝函数 提供文件目录和文件的拷贝操作:
CopyDirectoryTree 递归拷贝某个目录;CopyFile 拷贝当前文件。
创建函数 提供创建文件和目录的操作,目录创建成功或者目录
已经存在都会返回真:
CreateDirectory 创建目录;
CreateDirectoryTree 创建一个目录树,即给定一个路径字
符串,如果对应路径的父目录不存在,也会被创建出来。
删除函数 删除指定目录或文件,成功删除返回真,否则失败:
DeleteDirectory 删除指定目录;
DeleteDirectoryRecursively 递归删除指定目录;
DeleteFile 删除指定文件。
移动函数 只有一个函数MoveFile,在移动文件时用。
属性函数 提供对文件、目录的属性访问操作:
DirectoryExists 检查目录是否存在;
FileExists 检查文件是否存在;
GetStateData 获得文件状态信息,返回FFileStatData类型对
象,这个对象其实包含了足够的状态信息,接下来的一系列函
数也只是提供了快捷访问的接口。具体包含信息如表7-1所
示:表7-1 GetStateData包含信息
GetAccessTimeStamp 获得当前文件上一次访问的时间;
SetTimeStamp 设置文件的修改时间;
FileSize 获得文件大小,如果文件不存在返回-1;
IsReadOnly 文件是否只读。
遍历函数 该类函数都需要传入一个FDirectoryVisitor或
FDirectoryStatVisitor对象作为参数。你可以创造一个类继承自该
类,然后重写Visit函数。每当遍历到一个文件或者目录时,遍历函
数会调用Visitor对象的Visit函数以通知执行自定义的逻辑:
IterateDirectory 遍历某个目录;
IterateDirectoryRecursively 递归遍历某个目录;
IterateDirectoryStat 遍历文件目录状态,Visit函数参数为状
态对象而非路径字符串;IterateDirectoryStatRecursively 同上,递归遍历。
读写函数 最底层的读写函数往往返回IFileHandle类型的句柄,这
个句柄提供了直接读写二进制的功能。如果非绝对必要,可以考虑
更高层的API,下文有讲述:
OpenRead 打开一个文件用于读取,返回IFileHandle类型的
句柄用于读取;
OpenWrite 打开一个文件用于写入,返回IFileHandle类型的
句柄。
同时,针对一些极其普遍的需求,虚幻引擎提供了一套更简单的方
式用于读写文件内容,即FFileHelper类,位于CoreMisc头文件中,提供
以下静态函数:
LoadFileToArray 直接将路径指定的文件读取到一个
TArray
LoadFileToString 直接将路径指定的文本文件读取到一个FString
类型的字符串中。请注意,字符串有长度限制,不要试图读取超大
文本文件;
SaveArrayToFile 保存一个二进制数组到文件中;
SaveStringToFile 保存一个字符串到指定文件中;
CreateBitmap 在硬盘中创建一个BMP文件;
LoadANSITextFileToStrings 读取一个ANSI编码的文本文件到一个字符串数组中,每行对应一个FString类型的对象。
7.5 GConfi类的使用
在我们平时的开发过程中,会经常有读写配置文件需求,需要虚幻
引擎4提供底层的文件读写功能,而且C++原生也提供操作系统级别的
文件读写。但读文件、写文件及解析内容等操作,还是不够方便。这时
候我们就可以用虚幻引擎4提供的专门读写配置文件的类——GConfi。
GConfi类提供了非常方便的API让我们可以快速读写配置。而且
GConfi是属于Core模块的类,一般也不需要进行添加模块引用。接下来
我们分别看一下具体使用方法。
7.5.1 写配置
GConfig->SetString(
TEXT(MySection),TEXT(Name),TEXT(李白),FPaths::GameDir MyConfig.ini);
以上即为一个简单的写配置示例。只需要调用GConfi的SetString函
数就可以了。这个函数是保存一个字符串类型的变量到配置文件里。此
函数共4个参数,第一个参数是指定Section,即一个区块(可以理解为
一个分类);第二个参数是指定此配置的Key;相当于此配置的具体名字;第三个参数为具体的值;最后一个参数是指定配置文件的路径,如
果文件不存在,会自动创建此文件。
除了SetString外,GConfi针对各种类型的数据都有相应的函数,如
SetInt、SetBool、SetFloat等。具体的使用方法与SetString基本一样。
7.5.2 读配置
FString Result;
GConfig->GetString(
TEXT(MySection),TEXT(Name),Result,FPaths::GameDir MyConfig.ini);
与写配置不同,读配置则采用Get系列的函数。而且第三个参数从
具体的值变为一个变量的引用。
使用GConfi来读写文件操作,变得非常简单。但需要注意的一点是
写文件的时候,通常在运行过程中,进行写入配置操作值并不会马上写
入到文件。如果你需要马上生效,则可以调用GConfig->Flush函数。
7.6 UE_LOG
7.6.1 简介Log作为开发中经常用到的功能,可以在任何需要的情况下记录程
序运行的情况。Log通常有以下几个作用。
1. 记录程序运行过程,函数调用过程。
2. 记录程序运行过程中,参与运算的数据信息。
3. 将程序运行中的错误信息反馈给开发团队。
7.6.2 查看Log
Game模式
在Game(打包)模式下,记录Log需要在启动参数后加-Log。
编辑器模式
在编辑器下,需要打开Log窗口(Window->DeveloperTools-
>OutputLog)。
7.6.3 使用Log
UE_LOG(LogMy, Warning, TEXT(Hell World));
UE_LOG(LogMy, Warning, TEXT(Show a String %s),FString(Hello));
UE_LOG(LogMy, Warning, TEXT(Show a Int %d),100);
UE_LOG宏输出Log,第一个参数为Log的分类(需要预先定义)。
第二个参数为类型,有Log、Warning、Error 三种类型。这三种类型区别是颜色不同,Log为灰色,Warning为黄色,Error为红色。具体的
输出内容为TEXT,可以根据需要自行构造。几种常用的符号如下:
1. %s 字符串(FString)
2. %d 整型数据(int32)
3. %f 浮点形(float)
7.6.4 自定义Category
虚幻引擎4提供了多种自定义Category的宏。读者可自行参考
LogMactos.h文件。这里介绍一种相对简单的自定义宏的方法。
DEFINE_LOG_CATEGORY_STATIC(LogMyCategory,Warning,All);
在使用DEFINE_LOG_CATEGORY_STATIC自定义Log分类的时
候,我们可以将此宏放在你需要输出Log的源文件顶部。为了更方便地
使用,可以将它放到PCH文件里,或者模块的头文件里(原则上是将
Log分类定义放在被多数源文件include的文件里)。
7.7 字符串处理
在虚幻引擎中,“文字”类型其实是一组类型:FName,FText和
FString。这三种类型可以互相转换。当然还有TCHAR类型,只不过
TCHAR不是虚幻引擎定义的字符串类。虚幻引擎把字符串进行细分,为的是加快访问速度,以及提供本地化支持。FName
简单而言,FName是无法被修改的字符串,大小写不敏感。从语义
上讲,名字也应该是唯一的。不管同样的字符串出现了多少次,在字符
串表里只被存储一次。而借助这个哈希表,从字符串到FName的转换,以及根据Key查询FName会变得非常快速。
FText
FText表示一个“被显示的字符串”。所有你希望“显示”的字符串都应
该是FText。因为FText提供了内置的本地化支持,也通过一张查找表来
支持运行时本地化。FText不提供任何的更改操作,对于被显示的字符
串来说,“修改”是一个非常不安全的操作。
FString
FString是唯一提供修改操作的字符串类。同时也意味着FString的消
耗要高于FName和FText。
事实上,一般我们都使用FString来传递。尽管如此,Slate控件的文
字参数往往是FText。这是为了强制要求本地化。
7.8 编译器相关技巧
7.8.1 “废弃”函数的标记
在虚幻引擎中,有“废弃函数”的概念。准备废弃或者更改一个函数
的时候,虚幻引擎不会立即废弃(否则影响范围太大),而是在编译期给出了一个警告。警告有点像这样: function Please update your code to
the new API before upgrading to the next release,otherwiseyourprojectwillnolongercompile。需要注意的是,如果你使用编
译器宏message,会在任何时候都会输出消息。只要你的这段代码被编
译,就会输出。而虚幻引擎的废弃函数则是在“被调用”的时候才会输
出。
虚幻引擎对不同平台提供了不同的宏定义,对GCC使用
__attribute__关键字,对Visual Studio使用__declspec关键字。然后调用
deprecated关键字来输出。还有许许多多的编译器关键字可以用来实现
诸多效果。
7.8.2 编译器指令实现跨平台
虚幻引擎被设计为一个跨平台的引擎。但是虚幻引擎所有平台公用
一套源代码。那么如何才能完成对不同平台的兼容呢?
至少在Windows平台下的程序入口点,即WinMain函数,在Linux下
是没有的。
那么这该如何是好?一种传统的办法是通过多态来解决。即存在一
个基类,定义了抽象的接口,独立于操作系统存在。然后每个操作系统
对应版本继承自这个基类,然后做出自己的实现。运行时根据当前操作
系统来进行切换。
虚幻引擎部分采用了这种方案,但是对于一些情形,例如Main函
数,采用多态跨平台就显得很困难了。于是虚幻引擎采用了编译期跨平
台的方案,即:准备多个平台的实现,通过宏定义来切换不同的平台。例如虚幻引
擎有一个通用的类:FPlatformMisc,包含了大量平台相关的工具函数。
也有一系列的平台相关实现,如Linux下是FLinuxPlatformMisc,随后通
过一个typedef来完成,即:typedef FLinuxPlatformMisc
FPlatformMisc;。于是就完成了编译期跨平台的过程。
那么有读者就要提出问题了,假如Windows定义一下,Linux定义
一下,那不就全乱了吗?
说得对,所以实际上,.build.cs文件就会对此做出判断,根据编译
平台的类型是Win64还是Linux,会包含不同的文件夹,而不是一股脑儿
全部包含,就不会出现冲突。
7.9 Images
问题
我希望能够转换图片的类型!我希望能直接从硬盘导入图片作为
贴图!应该怎么办呢?
虚幻引擎提供了ImagerWrapper作为所有图片类型的抽象层。其思
想设计还是非常精妙的。我们可以这样来看待所有的图片格式类型:
1. 图片文件自身的数据是压缩后的数据,称为CompressedData。
2. 图片文件对应的真正的RGBA数据,是没有压缩的,且与格式无关
的(不考虑压缩带来的损失)的数据,称为RawData。3. 同样图片保存为不同的格式,RawData不变(不考虑压缩),CompressedData会随着图片格式不同而不同。
4. 因此,所有图片的格式都可以被抽象为一个CompressedData和
RawData的组合,这就是ImageWrapper模块设计的思路。
那么这样的设计思路有什么用处呢?让我们看几个案例:
读取JPG图片
1. 从文件中读取为TArray的二进制数据;
2. 用SetCompressData填充为压缩数据;
3. 使用GetRawData即可获取RGB数据。
转换PNG图片到JPG
1. 从文件中读取为TArray的二进制数据;
2. 用SetCompressData填充为压缩数据;
3. 使用GetRawData即可获取RGB数据;
4. 将RGB数据填充到JPG类型的ImageWrapper中;
5. 使用GetCompressData,即可获得压缩后的JPG数据;
6. 使用FFileHelper写入到文件中。
我在这里给出一个转换的代码案例:
bool FSystemToolsPublic::CovertPNG2JPG(const FString SourceName, const FString TargetName) {
check(SourceName.EndsWith(TEXT(.png))TargetName.EndsWith(
TEXT(.jpg)));
IImageWrapperModule ImageWrapperModule = FModuleManager::
LoadModuleChecked < IImageWrapperModule >(FName(ImageWrapper)); IImageWrapperPtr SourceImageWrapper= ImageWrapperModule.
CreateImageWrapper(EImageFormat::PNG);
IImageWrapperPtr TargetImageWrapper= ImageWrapperModule.
CreateImageWrapper(EImageFormat::JPEG);
TArray
TArray
int32 Width, Height;
const TArray
if (!FPlatformFileManager::Get.GetPlatformFile.FileExists
(SourceName))
{
文件不存在
return false;
}
if (!FFileHelper::LoadFileToArray(SourceImageData , SourceName))
{
文件读取失败
return false;
}
if (SourceImageWrapper.IsValid SourceImageWrapper ->
SetCompressed(SourceImageData.GetData, SourceImageData.
Num))
{
if (SourceImageWrapper ->GetRaw(ERGBFormat::RGBA, 8,UncompressedRGBA))
{
Height=SourceImageWrapper ->GetHeight; Width = SourceImageWrapper ->GetWidth;
if (TargetImageWrapper ->SetRaw(
UncompressedRGBA ->GetData,UncompressedRGBA ->Num, Width, Height,ERGBFormat::RGBA, 8))
{
TargetImageData = TargetImageWrapper
->GetCompressed;
if (!FFileManagerGeneric::Get.
DirectoryExists(TargetName))
{
FFileManagerGeneric::Get.
MakeDirectory(FPaths::GetPath(
TargetName),true);
}
return FFileHelper::SaveArrayToFile(
TargetImageData , TargetName);
}
}
}
return false;
}
基于同样的思路,我们可以用这样的方式来读取硬盘中的贴图:
1. 从硬盘中读取压缩过的图片文件到二进制数组,获得图片压缩后数
据;2. 将压缩后的数据借助ImageWrapper的GetRaw转换为原始RGB数
据;
3. 填充原始的RGB数据到UTexture的数据中。
读者可以参考这一段来自虚幻引擎官方wiki的Rama先生的代码,笔
者加上了注释:
UTexture2D FSystemToolsPublic::LoadTexture2DFromBytesAndExtension(
const FString ImagePath ,uint8 InCompressedData ,int32 InCompressedSize ,int32 OutWidth,int32 OutHeight)
{
UTexture2D Texture = nullptr;
IImageWrapperPtr ImageWrapper = GetImageWrapperByExtention(ImagePath);
if (ImageWrapper.IsValid ImageWrapper ->SetCompressed(
InCompressedData , InCompressedSize))读取压缩后的图片数据
{
const TArray
if (ImageWrapper ->GetRaw(ERGBFormat::RGBA, 8,UncompressedRGBA))获取原始图片数据
{
Texture = UTexture2D::CreateTransient(
ImageWrapper ->GetWidth, ImageWrapper ->
GetHeight, PF_R8G8B8A8); if (Texture != nullptr)
{
通过内存复制,填充原始RGB数据到贴图的数据中
OutWidth = ImageWrapper ->GetWidth;
OutHeight = ImageWrapper ->GetHeight;
void TextureData = Texture->
PlatformData ->Mips[0].BulkData.
Lock(LOCK_READ_WRITE);
FMemory::Memcpy(TextureData,UncompressedRGBA ->GetData,UncompressedRGBA ->Num);
Texture->PlatformData ->Mips[0].
BulkData.Unlock;
Texture->UpdateResource;
}
}
}
return Texture;
}
UTexture2D FSystemToolsPublic::LoadTexture2DFromFilePath(
FString ImagePath,int32 OutWidth,int32 OutHeight)
{
文件是否存在 if (!FPlatformFileManager::Get.GetPlatformFile.FileExists
(ImagePath))
{
return nullptr;
}
读取文件资源
TArray
if (!FFileHelper::LoadFileToArray(CompressedData , ImagePath))
{
return nullptr;
}
return LoadTexture2DFromBytesAndExtension(ImagePath,CompressedData.GetData, CompressedData.Num, OutWidth,OutHeight);
}第二部分
虚幻引擎浅析
考虑到不少人对虚幻引擎存在一种奇妙的恐惧,尤其是认为虚幻引
擎是一大堆无法理解的、如魔法与巫术混合的代码的集合体,被充满玄
学的编译器编译而产生的一个巨大无比的机械。因此,笔者希望在本部
分最开头向大家回答一个简单的问题:虚幻引擎有Main函数吗?
答案是,有。虚幻引擎本质而言也是一个C++程序。对于Windows
平台而言,你可以在Launcher模块下的LaunchWindows.cpp中找到你熟
悉的WinMain函数。第8章
模块机制
8.1 模块简介
问题
虚幻引擎为什么需要引入模块机制?
任何一名C++程序员,一定被C++的项目配置所困扰过。你需要添
加头文件目录、lib目录等。还需要对Debug模式、Release模式下不同的
包含做出控制。这是相当复杂的一个问题。更不用说对于虚幻引擎来
说,需要的编译模式远远不止这么几种。在编辑器模式下,有些类会被
引入,在最终发布的游戏中,这些类又完全不再需要,如何处理这样的
问题呢?
在虚幻引擎3的时代,使用MakeFile来模拟模块,而到了虚幻引擎4
的时代,为了彻底解决这样的问题,虚幻引擎借助UnrealBuildTool引入
了模块机制 [1]。
仔细观察虚幻引擎的源码目录,会发现其按照四大部分:
Runtime、Development、Editor、Plugin来进行规划,而每个部分内部,则包含了一个一个的小文件夹。每个文件夹即对应一个模块。一个模块文件夹中应该包含这些内容。
Public文件夹
Private文件夹
.build.cs文件
模块的划分是一门艺术。你需要知道的是,不同模块之间的互相调
用并不是很方便。只有通过XXXX_API宏暴露的类和成员函数才能够被
其他模块访问。因此,模块系统也让你从一开始就对自己的类进行精心
的设计,以免出现复杂的类与类依赖。
8.2 创建自己的模块
问题
我能不能创建自己的模块呢?
通过切分模块,能够有效地切分自己代码的结构和框架,另外,区
分编辑器模块和运行时模块也是必须要做的事情。
创建一个新的模块分为如下几步:
1. 创建模块文件夹结构;
2. 创建模块构建文件.build.cs;
3. 创建模块头文件与实现文件;
4. 创建模块预编译头文件PrivatePCH.h;5. 创建模块的C++声明和定义。
8.2.1 快速完成模块创建
如果你只需要快速创建一个模块,不考虑模块的加载和卸载行为,也不考虑模块接口的公开和隐藏,则可以这样快速创建一个模块。
在你C++工程的Source文件夹下,创建一个新的模块文件夹,然后
创建如下的文件结构:
各文件对应内容如下:
using UnrealBuildTool;
类的名称与模块名称一致
public class pluginDev : ModuleRules
{
public pluginDev(TargetInfo Target)
{
PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, InputCore });
PrivateDependencyModuleNames.AddRange(new string[] {
});
}
} 模块名.h(pluginDev.h)
pragma once
include Engine.h
模块名.cpp(pluginDev.cpp)
include pluginDev.h包含你刚刚创建的头文件
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl , pluginDev, pluginDev );替换pluginDev为你当前的模块名字
在这种情况下,你的“模块名.h”文件会被作为你的预编译头文件。
当前模块其他类的.cpp文件,需要包含这个预编译头文件,否则将无法
通过编译。
8.2.2 创建模块文件夹结构
对于一个标准的模块而言,前文的创建方式是不够的。
一个模块应当包含以下文件结构:在你C++工程的Source文件夹下,创建一个新的模块文件夹,命名
随意,此处以pluginDev为例。并在文件夹内添加Public和Private两个文
件夹,形成如上所示的文件夹结构。
8.2.3 创建模块构建文件
一个模块需要用一个.Build.cs文件来告知UBT如何配置自己的编译
和构建环境。因此你需要在上文的模块文件夹下,创建
pluginDev.Build.cs。并添加以下内容到文件中:
using UnrealBuildTool;
类的名称与模块名称一致
public class pluginDev : ModuleRules
{
public pluginDev(TargetInfo Target)
{
PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, InputCore });
PrivateDependencyModuleNames.AddRange(new string[] {
}); }
}
8.2.4 创建模块头文件与定义文件
模块允许在C++层提供一个类,并实现StartupModule与
ShutdownModule函数,从而自定义模块加载与卸载过程中的行为。创建
对应的模块名.h和模块名.cpp,并填充内容如下。
完整版本的创建方式如下:
模块名.h(pluginDev.h)
pragma once
include ModuleManager.h
class FPluginDevModule : public IModuleInterface
{
public:
IModuleInterface implementation
virtual void StartupModule override;
virtual void ShutdownModule override;
};
模块名.cpp(pluginDev.cpp)
include PluginDevPrivatePCH.h包含接下来要定义的模块预编译头文件名
void FPluginDevModule::StartupModule
{
...这里书写模块启动时需要执行的内容
}
void FPluginDevModule::ShutdownModule
{
...这里书写模块卸载时需要执行的内容
}
IMPLEMENT_MODULE(FPluginDevModule, pluginDev)
8.2.5 创建模块预编译头文件
预编译头文件能够加速代码的编译,因此当前模块公用的头文件可
以放置于这个头文件中。
该头文件的标准命名方式为:“模块名PrivatePCH.h”,放置于
Private文件夹中。内容可以为空。
当前模块所有的.cpp文件,都需要包含预编译头文件。
8.2.6 引入模块
对于游戏模块而言,引入当前模块的方式是在游戏工程目录下的
Source文件夹中,找到工程名.Target.cs文件,打开后修改以下函数: 工程名.Target.cs
public override void SetupBinaries(
TargetInfo Target,ref List
OutBuildBinaryConfigurations ,ref List
{
OutExtraModuleNames.AddRange( new string[] { pluginDev } );在这里添加引入的模块名称
}
对于插件模块而言,方法是修改当前插件的.uplugin文件,该文件
大概内容如下:
插件名.uplugin
{
FileVersion: 3,Version: 1,VersionName: 1.0,FriendlyName: 插件名,Description: 插件描述,Category: Other,CreatedBy: ,CreatedByURL: , DocsURL: ,MarketplaceURL: ,SupportURL: ,Modules: [在这里引入模块
{
Name: 插件模块名称,Type: Editor,模块加载类型
LoadingPhase : PostEngineInit模块加载时机
}
],EnabledByDefault: true,CanContainContent: true,IsBetaVersion: false,Installed: false
}
在Modules数组中添加Json对象,定义引入的模块。格式可以参考
上文给出的案例。
8.3 虚幻引擎初始化模块加载顺序
本节希望给有需要的读者一个参考性质的模块加载顺序。实际上虚
幻引擎模块加载分为两大部分,一部分是以硬编码形式硬性规定,另一
部分则是松散加载。前半部分的加载顺序有自己的依赖原则。
注意,根据你引擎发布的版本不同(EditorDevelopmentShipping),模块加载也不相同。有些模块不会被
加载。但是总体上说,模块的加载遵循这样的顺序:
1. 首先加载的是Platform File Module,因为虚幻引擎要读取文件。
2. 接下来加载的是核心模块:
(FEngineLoop::PreInit→LoadCoreModules)。
3. 加载CoreUObject。
4. 然后在初始化引擎之前加载模块:
FEngineLoop::LoadPreInitModules。
5. 加载Engine。
6. 加载Renderer。
7. 加载AnimGraphRuntime。
根据你的平台不同,会加载平台相关的模块
(FPlatformMisc::LoadPreInitModules),在Windows平台下加载的是:
1. D3D11RHI(开启bForceD3D12后会加载D3D12)
2. OpenGLDrv
3. SlateRHIRenderer
4. Landscape
5. ShaderCore
6. TextureCompresser
7. Start Up Modules:FEngineLoop::LoadStartupCoreModules
8. Core(很有趣,虚幻引擎的核心Core模块加载的时机并不是在最
初)
9. Networking
然后是平台相关模块,Windows平台下是:1. XAudio2
2. HeadMountedDisplay
3. SourceCodeAccess
4. Messaging
5. SessionServices
6. EditorStyle
7. Slate
8. UMG
9. MessageLog
10. CollisionAnalyzer
11. FunctionalTesting
12. BehaviorTreeEditor
13. GameplayTasksEditor
14. GameplayAbilitiesEditor
15. EnvironmentQueryEditor
16. OnlineBlueprintSupport
17. IntroTutorials
18. Blutility
接下来,会根据启用的插件,加载对应的模块。然后是:
1. TaskGraph
2. ProfilerServic
至此,虚幻引擎初始化完成所需的模块加载。
之后的Module加载,是根据情况来完成的。也就是说Unreal Editor
自己来控制其需要加载什么样的程序。发布后的游戏因为没有携带Unreal Editor,因此自然不需要加载这些模块。而笔者认为,对模块加
载顺序的研究,主要的部分还是集中在引擎加载初期。后期的模块大多
针对具体功能。
当然,有很多时候,我们需要处理模块依赖,这个时候其他模块的
加载顺序也是有一定的研究价值的。
如果你好奇Editor模块的加载顺序,Editor模块加载是在
UEditorEngine的Init中,被一个数组控制:
1. Documentation
2. WorkspaceMenuStructure
3. MainFrame
4. GammaUI
5. OutputLog
6. SourceControl
7. TextureCompressor
8. MeshUtilities
9. MovieSceneTools
10. ModuleUI
11. Toolbox
12. ClassViewer
13. ContentBrowser
14. AssetTools
15. GraphEditor
16. KismetCompiler
17. Kismet
18. Persona19. LevelEditor
20. MainFrame
21. PropertyEditor
22. EditorStyle
23. PackagesDialog
24. AssetRegistry
25. DetailCustomizations
26. ComponentVisualizers
27. Layers
28. AutomationWindow
29. AutomationController
30. DeviceManager
31. ProfilerClien
32. SessionFrontend
33. ProjectLauncher
34. SettingsEditor
35. EditorSettingsViewer
36. ProjectSettingsViewer
37. Blutility
38. OnlineBlueprintSupport
39. XmlParser
40. UserFeedback
41. GameplayTagsEditor
42. UndoHistory
43. DeviceProfileEdito
44. SourceCodeAccess
45. BehaviorTreeEditor46. HardwareTargetin
47. LocalizationDashboard
48. ReferenceViewer
49. TreeMap
50. SizeMap
51. MergeActors
以上内容是为了方便读者在引入模块的时候确定“哪些模块一定加
载”。读者也可以根据这里的模块名称来大致推测自己需要的功能可能
位于哪个模块。例如根据XmlParser模块名字,就能推测这里放置了
XML解析需要的API。
8.4 道常无名:UBT和UHT简介
问题
模块机制很厉害,但是模块是用什么样的方式配置、编译、启动
和运行呢?大牛们常常提到的“UBT”和“UHT”又是什么呢?
8.4.1 UBT
UBT概览
如果你下载了一份虚幻引擎的源代码,你能够在UBT(Unreal BuildTool)项目的Unreal Build Tool.cs文件中找到Main函数。
大致而言,UBT的工作分为三个阶段:
收集阶段 收集信息。UBT收集环境变量、虚幻引擎源代码目
录、虚幻引擎目录、工程目录等一系列的信息。
参数解析阶段 UBT解析传入的命令行参数,确定自己需要生成
的目标类型。
实际生成阶段 UBT根据环境和参数,开始生成makefil,确定
C++的各种目录的位置。最终开始构建整个项目。此时编译工作交
给标准C++编译器。
同时UBT也负责监视是否需要热加载。并且调用UHT收集各个模块
的信息。
请注意,UBT被设计为一个跨平台的构建工具,因此针对不同的平
台有相对应的类来进行处理。UBT生成的makefil会被对应编译器平台的
ProjectFileGenerater用于生成解决方案,而不是一开始就针对某个平台
的解决方案来确定如何生成。
再谈WinMain
在最开头我们谈到了WinMain函数在哪,解除了大家许多心理的戒
备。但是其实接下来的问题更加严峻:WinMain函数最终怎么会被链接
到.exe文件?事实上,笔者也专门为此探究了一阵。如果你对此没什么兴趣,直
接跳过本部分就行。
首先如果大家熟悉C++,就会知道,在Windows平台最终的.exe文
件中,必须包含Main函数(CC++控制台程序)或者WinMain函数
(Windows)。否则是找不到应用程序的入口点的。而虚幻引擎的模
块,最终大多被编译为DLL动态链接库文件。
如果你看过之前的模块加载(通过
FModuleManager::LoadModule),你会发现这里有一大堆眼熟的模块
DLL(Slate、SlateCore等)。
那么问题来了,为什么我们的Launcher模块没有被编译为DLL?是
什么控制的呢?
答案是,在.target.cs文件控制。
我们现在的虚幻引擎4,被UE4Editor.Target.cs文件所控制编译,如
果你是用的编译版引擎,你是看不到完整的编译控制的,所以你会困惑
一些。其实,在.Target.cs文件中,如下所示:
public override void SetupBinaries(
TargetInfo Target,ref List OutBuildBinaryConfigurations,ref List OutExtraModuleNames)
{
OutExtraModuleNames.Add(“UE4Game”);}
就完成了对二进制文件的设置工作。
在引擎源代码的UEBuildEditor.cs的SetupBinaries中,你会看到,你
的BuildRules类在设置好自己的二进制数据输出后,OutBuildBinaryConfiguration数组会被添加一个特殊的成员:
Launcher模块会被设置为UEBuildBinaryTypes.Executable添加进来。
作为.exe文件的包含模块。
此时.exe文件就会包含Launcher模块中平台对应的Main函数。对于
Windows平台而言,WinMain函数会被链接进.exe文件。
当你双击虚幻引擎的Editor.exe后,WinMain函数被调用。虚幻引擎
开始在你的系统上运行起来。
8.4.2 UHT
问题
前文有提到,UHT配合实现了反射机制,那UHT是如何完成的
呢?
UHT(Unreal Header Tool)一个引擎独立应用程序笔者不知道该如何形容这样的程序,只是勉强给出了一个我个人的
命名。
引擎独立应用程序是指,这种应用程序依赖引擎。比如依赖UBT以
配置编译环境,从而能跨平台编译,依赖引擎的某些模块。
但是这样的应用程序又不是一个引擎,也不是一个游戏。它最终输
出为一个.exe文件。但是又不需要引擎完全启动,甚至不需要Renderer
模块,以至于有可能只是一个命令行工具——比如UHT。
通过虚幻引擎源代码,你会发现UHT拥有自己的.target.cs和.build.cs
文件。
在其.target.cs文件中,它把自己设置为exe的输出模块。
在.build.cs文件中,它指出了自己的依赖模块:
Core
CoreUObjects
Json
Projects
然后又包含了Launch模块的publicprivate文件夹,以便让自己能够
调用GEngine->PreInit函数。
最终,UHT会被编译成一个.exe文件,通过命令行参数调用。
很奇妙不是吗?一个能够用来编译引擎的程序,居然依赖引擎本
身。在本书的第三部分,将会教读者自己制作一个这样的程序。UHT大致工作流程
UHT的Main函数在UnrealHeadToolMain.cpp文件中。这个文件提供
了Main函数,并且也通过IMPLEMENT_APPLICATION宏声明了这是个
独立应用程序。具体执行的内容如下:
1. 调用GEngineLoop->PreInit函数,初始化Log、文件系统等基础系
统。从而允许借助UE_LOG宏输出log信息。
2. 调用UnrealHeaderTool_Main函数,执行真正的工作内容。
3. 调用FEngineLoop::AppExit退出。
那么,UnrealHeaderTool_Main函数到底干了什么?
UHT到底干了什么
首先,UBT会通过命令行参数告诉UHT,游戏模块对应的定义文件
在哪。这个文件是一个.manifest文件。这是个由UBT生成的文件。如果
你用记事本之类的应用打开,你会发现这是个Json字符串。
笔者把自己的一个工程中的这个Json字符串复制到了在线Json格式
化工具,然后截了个图。这样你能更加直观地感受这个Json字符串装了
些什么。图8-1 .manifest文件内容
你会发现这个字符串包含了所有Module的编译相关的信息。包括各
种路径,以及预编译头文件位置等。
然后UHT开始了自己的三遍编译:Public Classes Headers、Public
Headers、Private Headers。第一遍其实是为了兼容虚幻引擎3时代的代
码。虚幻引擎3时代的Classes文件夹会向Unreal Script暴露这些类。接下
来的两遍则是解析头文件定义,并生成C++代码 以完成需要的功能。生
成的代码会和现有代码一起联合编译以产生结果。
虚幻引擎反射机制与超生游击队的故事
问题
UHT生成的是代码么?代码如何完成“类有哪些成员变量、成员函
数”这样的信息的注册呢?
经过UHT的三遍编译,最终生成的是.generated.cpp和.generated.h这两种文件。而我们理解UHT工作内容,也得根据这两个文件来理解。
让我们先来思考一个问题。众所周知,虚幻引擎的一个UClass,可
以理解为包含UProperty和UFunction的一个数据结构:
UClass
-UProperty
-...
-UProperty
-UFunction
-...
-UFunction
随后虚幻引擎需要做的其实是,把C++类(也就是真正的“成员变
量”和“成员函数”)与UClass中的对应的“Property数据类型”和“Function
数据类型”绑定起来。
打个比方,UClass其实只是一张表,或者说类似户口本的东西。上
面记录的是指向真实的“家庭”的指针,可能还包含一些额外的信息。假
设我们拿到的是张家人的户口本(当然这里是假设的户口本,随便写了
点信息),上面写着:
张大:
男性
1967年生
张二:
女性
1968年生张三:
男性
1969年生
此时我们知道,这三个条目记录的是,张大、张二和张三的“额外
信息”,或者说元数据metadata,但是到时候你找人,比如说你要拿着这
个户口本找张大、张二和张三。你还得根据这个户口本条目找到对应的
那个人,也就得通过一个指针。
现在问题是,虚幻引擎该怎么处理这个户口本机制呢?一种方式
是,一开始编译时就把户口本都填好,放在一个文件里面。要找某家人
的时候,就读取出来,开始查。但是大家都知道,每一次编译,函数的
地址可能会发生变化,而每一次运行,函数映射到内存中的位置也可能
不同。直接存储地址值是不行的。就比方说张家人每次人口普查之前都
搬家(因为超生了,超生游击队),所以如果直接存储上次普查得到的
地址,那么时过境迁,根据这个地址去找张家,实际找到的可能是李
家,甚至这个地址都拆迁了。
所以虚幻引擎想了个办法。UHT不是存储的“每家人的户口本”,而
是把“进行户口调查的过程”存储了下来。比方说,它存储了每个家庭有
哪些人需要登记信息。然后在运行之初,借助某个特殊的技巧,在Main
函数调用之前,逐个敲门让每家人进行登记。虽然张家人要搬家,没关
系,跑得了和尚跑不了庙,我把这个城里的每个家庭找一遍,你总归还
是要被我找到的。
当然,有些热心的读者要说了,万一张家又生了个娃(张四),岂
不是记录不到了吗?虚幻引擎的UHT就是扮演着那个计生办的角色。你的C++类,如果改动了头文件,肯定得编译吧?意味着你生娃总得给娃
办一个户口吧,否则那个娃以后是黑户,书都读不了。所以当你给娃办
一个户口(用UPROPERTY标记或者用UFUNTION标记),编译的时候
就被UHT看到了,它就把如何获取具体的信息(如何根据户口本找到张
四)的步骤写在了.generated.cpp里面。
加载信息
仅仅记录获取信息的过程还不足够,还需要在加载的时候调用记录
好的过程来进行实际的记录。前文说道这是通过在Main函数调用前执行
记录过程的技巧,实质上来说,方法是通过一个简单的C++机制:静态
全局变量的初始化先于Main函数执行 [2]。
在生成好的.generated.cpp文件中,会看到一个宏:
IMPLEMENT_CLASS
这个宏展开后实质上完成了两个内容:
1. 声明了一个具有独一无二名字的UClassCompiledInDefer<当前类名>
静态全局变量实例。
2. 实现了当前类的GetPrivateStaticClass函数。
我们重点观察前一个步骤。这个类的构造函数调用了
UClassCompiledInDefer函数。即:1. 这个静态全局变量会在Main函数之前初始化。
2. 初始化就会调用该变量的构造函数。
3. 构造函数会调用UClassCompiledInDefer函数。
4. UClassCompiledInDefer会添加ClassInfo变量到延迟注册的数组中。
绕了这么大一圈,其实是希望能够在Main函数执行前,先执行
UClassCompiledInDefer函数。这个函数带有“Defer”,是因为实际注册操
作是后来执行的,但是在Main函数之前必须得“先摇个号” 。这样虚幻
引擎才知道有哪个类是需要延迟加载。借助这个技巧,虚幻引擎能够在
启动前就给全部的类发个号,等时机成熟再一个一个加载信息。
[1] 据说在C++17中也会引入语言层面上的模块机制。当然这是后
话,期待C++17标准的正式推出。
[2] 严格来说,无副作用的静态全局变量的初始化可以延迟到使用
时,但是这不影响我们的分析。第9章
重要核心系统简介
本章介绍了虚幻引擎中的一部分重要核心系统,这些系统与后文的
讨论有关,故详细介绍。实际上虚幻引擎的Core模块包含大量的内容,例如TArray定义等,在此不做过多赘述。
9.1 内存分配
9.1.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.
PageSizeMAX_uint32), (uint64)MAX_uint32+1);
endif
}
也就是说,Windows平台提供了标准的Malloc(ANSI)、Intel TBB
内存分配器,以及Binned内存分配器三个方案。
9.1.2 IntelTBB内存分配器
如果读者对IntelTBB内存分配器感兴趣,可以参考这本书:《Intel
Threading Building Blocks编程指南》。
采用TBB内存分配,主要是出于以下的原因:
1. 虚幻引擎工作在多个线程,而标准内存分配为了避免出现内存分配
BUG,是强制同一时间只有一个线程分配内存。导致内存分配的速
度大幅度降低。
2. 缓存命中问题。CPU中存在高速缓存,而同一个缓存,一次只能被
一个线程访问。如果出现比较特殊的情况,如两个变量靠在一起:
int a;
int b;
然后线程1访问a,线程2访问b。理论上此时可以并行。但是由于在
加载a的时候,缓存把b的内存空间也加载进去,导致线程2在访问的时候,还需要重新加载缓存。这带来相当大的CPU周期浪费,被
称为“假共享”。
《游戏引擎架构》一书中,对内存分配方案做出了相当多的描述,其重点提到的也是两个方面:
1. 通过内存池降低malloc消耗。
2. 通过对齐降低缓存命中失败消耗。而虚幻引擎的目的相同,方法不
同。
Intel TBB提供了scalable_allocator:不在同一个内存池中分配内存,解决由于多线程竞争带来的无谓消耗;cache_aligned_allocator:通过缓存
对齐,避免假共享。
这一方案的代价是内存消耗量增加。对应其他平台的内存分配方案
在这里不再做过多介绍。有兴趣的读者可以自行分析。
在虚幻引擎中,主要使用的还是scalable_allocator。为方便读者参
考,将FMallocTBB::Malloc的代码摘录如下:
void FMallocTBB::Malloc( SIZE_T Size, uint32 Alignment )
{
IncrementTotalMallocCalls;
MEM_TIME(MemTime -= FPlatformTime::Seconds);
void NewPtr = NULL;
if( Alignment != DEFAULT_ALIGNMENT )
{
Alignment = FMath::Max(Size >= 16 ? (uint32)16 : (uint32)8, Alignment);
NewPtr = scalable_aligned_malloc( Size, Alignment );
}
else
{
NewPtr = scalable_malloc( Size );
}
if( !NewPtr Size )
{
OutOfMemory(Size, Alignment);
}
if UE_BUILD_DEBUG || UE_BUILD_DEVELOPMENT
else if (Size)
{
FMemory::Memset(NewPtr, DEBUG_FILL_NEW , Size);
}
endif
MEM_TIME(MemTime += FPlatformTime::Seconds);
return NewPtr;
}
9.2 引擎初始化过程
问题虚幻引擎是怎么运行起来的?
引擎初始化简介
虚幻引擎初始化分为两个过程,即预初始化PreInit和Init。其具体实
现由FEngineLoop这个类来提供。而在不同平台上,入口函数不同(如
Windows平台下是WinMain,Linux平台下是Main),不同的入口函数最
后会调用同样的FEngineLoop中的函数,实现跨平台。
预初始化
PreInit是预初始化过程,和初始化过程最显著的区别在于PreInit带
有参数CmdLine,也就是说,能够获得传入的命令行字符串。
这个过程主要是根据传入的命令行字符串来完成一系列的设置状态
的工作,大致来说,能够分为这样几个设置的内容:
1. 设置路径:当前程序路径,当前工作目录路径,游戏的工程路径。
2. 设置标准输出:设置GLog系统输出的设备,是输出到命令行还是
何处。
并且也初始化了一部分系统,包括:
初始化游戏主线程GameThread,其实只是把当前线程设置为主线
程。初始化随机数系统(用过C语言随机数库的都知道,随机数是需要
初始化的,否则同样的种子会产生出虽然随机但是一模一样的随机
序列)。
初始化TaskGraph任务系统,并按照当前平台的核心数量来设置
TaskGraph的工作线程数量。同时也会启动一个专门的线程池,生
成一堆线程,用于在需要的时候使用。也就是说虚幻引擎的线程数
量是远多于核心数量的。在我的电脑上,初始化的线程数量是:
Task线程3个,线程池线程8个。
预初始化过程也会判断引擎的启动模式,是以游戏模式启动,还是
以服务器模式启动。
在完成这些之后,会调用LoadCoreModules。目前所谓的
CoreModules指的就是CoreUObject。具体为何CoreUObject需要这样额外
的启动,会在分析UObject的时候进行分析。
随后,所有的PreInitModules会被启动起来。这些强大的模块是:
引擎模块、渲染模块、动画蓝图、Slate渲染模块、Slate核心模块、贴图
压缩模块和地形模块。
当这些模块加载完毕后,AppInit函数会被调用,进入引擎正式的初
始化阶段 [1]。
初始化
此时虚幻引擎进入初始化流程。所有被加载到内存的模块,如果有
PostEngineInit函数的,都会被调用从而初始化。这一过程是借助IProjectManager完成的。
由于Init的过程被分摊到了每个模块中,因此初始化的过程显得格
外简洁。
主循环
虚幻引擎的主循环代码,如果为了符合虚幻引擎的源码引用规范
(30行以内),那么可以被表述为以下这样:
while( !GIsRequestingExit )
{
EngineTick;
}
所以说虚幻引擎也是按照标准的引擎架构来书写的,至少游戏主线
程是存在一个专门的引擎循环的,也就是EngineTick。注意,虚幻引擎
的渲染线程是独立更新的,不在我们主循环分析的内容中,可以看后文
虚幻引擎渲染架构的分析。
引擎的Tick按照以下的顺序来更新引擎中的各个状态:
更新控制台变量。这些控制台变量可以使用控制台直接设置。
请求渲染线程更新当前帧率文字。
更新当前应用程序的时间,也就是App::DeltaTime。
更新内存分配器的状态。请求渲染线程刷新当前的一些底层绘制资源。
等待Slate程序的输入状态捕获完成。
更新GEngine,调用GEngine->Tick。
假如现在有个视频正在播放,需要等待视频播放完。之所以在
GEngine之后等待,是因为GEngine会调用用户的代码,此时用户有
可能会请求播放一个视频。
更新SlateApplication。
更新RHI。
收集下一帧需要清理的UObject对象。
有一些Tick的内容没有写在这里,具体希望了解有哪些内容,可以
阅读LaunchEngineLoop中的内容。
那么现在大家最关心的内容其实集中在了,在GEngine->Tick中到
底做了什么。其实这个内容也并不是非常复杂。只不过引擎考虑了相当
多的情况,所以有许许多多的If判断,从而阻碍大家去理清。
从最正常的角度来说,GEngine->Tick最重要的任务是更新当前
World。无论是编辑器中正在编辑的那个World,还是说游戏模式下只有
一个的那个World。此时所有World中持有的Actor都会被得到更新。
请注意,笔者并没有说UObject会得到更新,事实上有许多UObject
根本没有Tick功能。
那么其他的Tick内容则是从时间分片的角度来看待很多任务。这个
的意思是说,很多任务可能无法在一次Tick中完成,否则会卡死游戏主
线程,带来极其不好的游戏体验,因此会分在多次Tick函数中完成。当
前Tick需要完成什么任务,则是在Tick中根据诸多的状态来决定的。例如正在加载地图,则每次都Tick一下,加载一点点;正在动态载入地图
(StreamLevel),或是正在进行HotReload,这些都会被分在不同的
Tick中,以一次载入一点点的方式完成。
9.3 并行与并发
虚幻引擎的系统一开始就被设计为并行化,故对虚幻引擎并行、并
发系统的研究非常重要,有助于理解接下来对渲染等过程的描述。同
时,并行系统也是开发者手中的利器。
9.3.1 从实验开始
任何知识的学习都来源于实践,因此本节的最开头笔者将会讲述如
何快速配置一个实验环境,来极快地撰写测试代码。
你需要的是利用虚幻引擎的Automation System,也就是自动化测试
系统。具体的方式其实很简单。你需要在你的模块文件夹下建立一个
Private文件夹,然后放入一个Test文件夹。在这个文件夹中,放置一个
以Private文件夹中已有cpp的名字+Test.cpp结尾的文件。比如以下这
样:
然后打开你的“模块名Test.cpp”,加入以下内容:include 当前模块对应的PrivatePCH.h
include ...包含其他需要的头文件
DEFINE_LOG_CATEGORY_STATIC(TestLog, Log, All);
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMultiThreadTest, MyTest.PublicTest
.MultiThreadTest, EAutomationTestFlags::EditorContext |
EAutomationTestFlags::EngineFilter)
bool FMultiThreadTest::RunTest(const FString Parameters)
{
UE_LOG(TestLog, Log, TEXT(Hello));
return true;
}
这些代码看上去特别混乱,不管怎么说,先复制进去,然后笔者再
解释好了。
随后正常编译并打开编辑器,请打开Frontend,中文版翻译为“会话
窗口”,如图9-1所示。图9-1 打开会话窗口
然后进入以下页面,如图9-2所示。
图9-2 会话窗口界面
勾选前面的白框然后点击Start Tests。你会在Message Output窗口看
到这样一行输出,如图9-3所示。图9-3 能够正常看到输出的Log
恭喜,以后你就可以用这样的方式快速测试代码了。
9.3.2 线程
从实例开始
在虚幻引擎中,最接近于我们传统认为的“线程”这个概念的,就是
FRunnable对象。FRunnable也有自己的子类,可以实现更多的功能。不
过如果只是为了演示多线程的话,继承自FRunnable就足够了。那么首
先请先感受一下用法,实例代码如下:
class FRunnableTestThread :public FRunnable {
public:
FRunnableTestThread(int16 _Index) :Index(_Index) {}
virtual bool Init override
{
UE_LOG(TestLog, Log, TEXT(Thread %d Init),Index);
return true;
}
virtual uint32 Run override
{
UE_LOG(TestLog, Log, TEXT(Thread %d Run:1), Index);
Sleep(10.0f); UE_LOG(TestLog, Log, TEXT(Thread %d Run:2), Index);
Sleep(10.0f);
UE_LOG(TestLog, Log, TEXT(Thread %d Run:3), Index);
Sleep(10.0f);
return 0;
}
virtual void Exit override
{
UE_LOG(TestLog, Log, TEXT(Thread %d Exit), Index);
}
private:
int16 Index;
};
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FFRunnableTest , MyTest.PublicTest.
RunnableTest, EAutomationTestFlags::EditorContext |
EAutomationTestFlags::EngineFilter)
bool FFRunnableTest::RunTest(const FString Parameters)
{
FRunnableThread::Create(new FRunnableTestThread(0), TEXT(TestThread0));
FRunnableThread::Create(new FRunnableTestThread(1), TEXT(TestThread1));
FRunnableThread::Create(new FRunnableTestThread(2), TEXT(TestThread2));
return true;
}
如果你成功运行的话,结果应该是这样,如图9-4所示。图9-4 多个线程按照异步的方式打印内容
可以看到,所有的线程异步启动,并且非同步执行。这说明我们确
实创建了三个线程,并运行它们。
如果你实际创建线程,并且在Run函数中间下断点,你能够在
Visual Studio的线程调试窗口看到你所创建的线程名称,如图9-5所示。
图9-5 刚刚创建的线程
分析
那么下面就可以来看,我们这段代码究竟完成了什么。首先我们声明了一个继承自FRunnable的类,并实现了三个函数:
Init、Run和Exit。Init函数内放置初始化代码,Run函数放置运行的代
码,Exit函数用于在线程退出的时候做清理工作。这些就是创建一个线
程所需要完成的。事实上,你需要做的其实非常少(当然无法达到
C++11那种极为简单的地步)。
那么在生成一个线程对象之后,接下来需要的是“启动这个线程”。
方法就是借助FRunnableThread的“Create”方法。第一个参数传入一个
FRunnable对象,第二个参数则是线程的名字。
9.3.3 TaskGraph系统
虚幻引擎的另一个多线程系统则更加的现代,是基于Task思想,对
线程进行复用从而实现的系统。频繁创建和销毁线程的代价是很大的,但是很多时候我们创建的线程都是临时的,不会伴随我们的软件的整个
生命周期。而为了达到并行化的目的,我们可以把需要执行的“指
令”和“数据”封成一个包,然后交给Task Graph,当Task Graph对应的线
程有空闲,就会取出其中的Task执行。
感谢虚幻引擎论坛的Rama先生,在虚幻引擎早期就对Task Graph系
统给出了相当清晰的描述。
从实践开始
同样地,让我们先来看一段示例性的代码,并看看运行结果:class FShowcaseTask {
public:
FShowcaseTask(int16 _Index) :Index(_Index) {}
static const TCHAR GetTaskName
{
return TEXT(FShowcaseTask);
}
FORCEINLINE static TStatId GetStatId
{
RETURN_QUICK_DECLARE_CYCLE_STAT(FShowcaseTask ,STATGROUP_TaskGraphTasks);
}
static ENamedThreads::Type GetDesiredThread
{
return ENamedThreads::AnyThread;
}
static ESubsequentsMode::Type GetSubsequentsMode
{
return ESubsequentsMode::TrackSubsequents;
}
void DoTask(ENamedThreads::Type CurrentThread , const FGraphEventRef
MyCompletionGraphEvent)
{
UE_LOG(TestLog, Log, TEXT(Thread %d Run:1), Index);
Sleep(10.0f);
UE_LOG(TestLog, Log, TEXT(Thread %d Run:2), Index);
Sleep(10.0f); UE_LOG(TestLog, Log, TEXT(Thread %d Run:3), Index);
Sleep(10.0f);
}
private:
int16 Index;
};
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMyTaskGraphTest , MyTest.PublicTest
.TaskGraphTest, EAutomationTestFlags::EditorContext |
EAutomationTestFlags::EngineFilter)
bool FMyTaskGraphTest::RunTest(const FString Parameters)
{
TGraphTask
TGraphTask
TGraphTask
return true;
}
如果一切没有问题,你会得到这样的结果,如图9-6所示。
图9-6 Caption分析:首先需要明白的是,Task Graph由于采用的是模板匹配,因
此并不需要每个Task继承自一个指定的类,而是只要具有指定的几个函
数,就能够让模板编译通过。这些函数就是:
GetTaskName:静态函数,返回当前Task的名字。
GetStatId:静态函数,返回当前Task的ID记录类型,你可以借助
RETURN_QUICK_DECLARE_CYCLE_STAT宏快速定义一个并返
回。
GetDesiredThread:可以指定这个Task是在哪个线程执行,关于可选
项有哪些,可以查看ENamedThreads::Type。
GetSubsequentsMode:这是Task Graph用来进行依赖检查的前置标
记,可选包括Track Subsequents和Fire And Forget两个选项。前者表
示这个Task有可能是某个其他Task的前置条件,所以Task Graph系
统会反复检查这个Task有没有执行完成。后者表示一旦开始就不用
去管了,直到执行完毕。也就不能作为其他Task的前置条件。
DoTask:最重要的函数,里面是这个Task的执行代码。
而启动一个Task的方式是这样的:
TGraphTask::CreateTask(NULL, ENamedThreads::GameThread).
ConstructAndDispatchWhenReady(0);
其实需要注意的是,这是一个连续函数调用:
CreateTask的结果被调用了ContstructAndDispatchWhenReady。而给
Task传递的参数是在这个时候给出的。这里的0就是构造函数里面的
ID。这实质上是一种“延迟构造”的设计,因为当前的Task有可能依赖于其他的Task,因此构造函数的调用会延迟到很久之后。
如果你下断点查看的话,能够发现,执行代码没有运行在一个单独
的线程之中,而是运行在已有的线程TaskGraphThread2中,如图9-7所
示。
图9-7 代码在TaskGraphThread2中执行,而不是在新的线程中执行
9.3.4 Std::Threa
作为C++11的特性之一,笔者其实是相当喜欢Std::Thread的。而且
写出来的代码也是最为简单、凝练且带有一种C++特有的气息。从实践开始
这是目前最简单的多线程代码,为了运行,请在开头包含:
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FSTDThreadTest, MyTest.PublicTest.
STDThreadTest, EAutomationTestFlags::EditorContext |
EAutomationTestFlags::EngineFilter)
bool FSTDThreadTest::RunTest(const FString Parameters)
{
std::function
[=](int16 Index) {
UE_LOG(TestLog, Log, TEXT(Thread %d Run:1), Index);
Sleep(10.0f);
UE_LOG(TestLog, Log, TEXT(Thread %d Run:2), Index);
Sleep(10.0f);
UE_LOG(TestLog, Log, TEXT(Thread %d Run:3), Index);
Sleep(10.0f);
};
new std::thread(TaskFunction, 0);
new std::thread(TaskFunction, 1);
new std::thread(TaskFunction, 2);
return true;
}
最终的输出结果如图9-8所示。可以看到输出的结果也是没有问题的。
图9-8 使用标准库提供的多线程函数
分析
我们可以把这段代码分为两段,前半段是定义“指令”,实质上是通
过Lambda表达式,定义了一个“可执行对象”。这就是functional这个头
文件的用处。其统一了普通的函数,通过重载“”操作符实现的“函数对
象”,以及由Lambda表达式定义的匿名函数。
后半段则是定义Thread,其直接New出Thread对象,然后传递前面
规定的指令,以及后边的参数。标准库的线程对象一旦被实例化,则立
刻就会开始执行。
9.3.5 线程同步
我们已经能够创建线程来并发执行任务,那么接下来必须要对我们
创建的线程进行一定程度上的控制,否则我们的线程就会像脱缰的野马一样狂飙:我们不知道它们什么时候已经完成了任务。因此引入线程的
同步非常有必要。虚幻引擎吸纳了大量的线程同步思想,操作系统与
C++11标准都有所吸纳。总体而言,虚幻引擎的线程同步系统是分层级
的。
最底层的包括:FCriticalSection临界区,这是实现线程同步的基
石。其有一对Lock、Unlock操作原语,当一个线程将一个临界区Lock的
时候,其他线程试图Lock就会阻塞,直到这个线程Unlock或者超时为
止。而虚幻引擎的Mutex实现也是依赖于临界区的。这也是最符合传统
多线程编程思想的多线程同步方式。但是代价就是有可能某一个线程需
要轮询临界区,而且很多功能非常不方便实现(例如一个从子线程获取
返回值,都必须要写一个忙等死循环,或者写成一个在Tick中不断查询
的代码)。
而虚幻引擎也提供了更先进的线程同步方式,包括:
1. 投递Task到另一个线程的TaskGraph中,这样另一个线程就会去执
行这个Tas ......
您现在查看是摘要介绍页, 详见PDF附件(8729KB,429页)。





