子系统的启动和终止
C++的静态初始化次序不可用,因为C++在调用程序进入点(main())之前,全局及静态对象已经被构建,而我们对这些构造函数的调用次序不可预知。
游戏引擎的子系统,常见的设计模式是为每个子系统定义单例类。常用的单例模式的实现方法,难以控制它的析构次序,而且一个获取管理器的实例的方法,可能会有很高的开销。因此,游戏开发中,直接采用简单粗暴的方法。
具体来说,就是将构造和析构函数留空,内部不做任何事情,直接在main函数中按需要的次序调用自定义的启动和终止函数。
class RenderManager{ public: RenderManager(){} ~RenderManager(){} void startUp(){ //自定义的启动函数 } void shutDown(){ //自定义的终止函数 } }; class PhysicsManager{/*同上面类似*/ }; class AnimationManager{/*同上面类似*/ }; class MemoryManager{/*同上面类似*/ }; RenderManager gRenderManager; PhysicsManager gPhysicsManager; AnimationManager gAnimationManager; MemoryManager gMemoryManager; int _tmain(int argc, _TCHAR* argv[]) { //启动各个子系统 gMemoryManager.startUp(); ... gRenderManager.startUp(); gAnimationManager.startUp(); gPhysicsManager.startUp(); //运行游戏 //终止各个子系统 gPhysicsManager.shutDown(); gAnimationManager.shutDown(); gRenderManager.shutDown(); ... gMemoryManager.shutDown(); return 0; }
内存管理
动态内存分配效率较低:
- 堆分配器是通用的,它需要处理任何大小的内存分配,这就需要管理开销;
- 许多操作系统中,调用malloc/free会引起上下文切换,需要从用户模式切换到内核模式,去处理请求。
因此,游戏开发中,维持最低限度的堆分配,并且永不在紧凑循环中使用堆分配。
常用的定制分配器
堆栈分配器
先预分配一块连续的内存,堆栈分配器管理这块内存;后面内存分配通过分配器的堆栈指针来实现,分配和释放内存即是指针的移动。
注意:
堆栈分配器释放内存时次序必须是分配时相反的顺序,而不是任意的。实现方法,可以不允许释放个别的内存块,而是由分配器提供一个函数,每次将堆栈指针回滚至标记的位置(它会释放这之间的多个内存块)。这个标记是位于分配的内存块之间的边界。
双端堆栈分配器
与堆栈分配器类似,但是,双端堆栈分配器在内存块的两端各有一个堆栈分配器,两个堆栈指针从两边向中间靠拢。
池分配器
游戏引擎可能会用到大量大小相同的小块内存,此时,可以用池分配器。
池分配器也会与分配一大块内存,大小时分配的元素的整数倍。池中的每个元素存放在一个自由链表中,池分配器收到分配请求时,将链表中的一个元素取出,分配出去;释放元素时,把元素重新挂到链表中就可以了。
自由元素的链表可实现为单链表,单链表的next指针可以直接存在每个元素内;如果元素的尺寸小于指针,可以用索引代替指针。
对齐功能的分配器
所有内存分配器都必须传回对齐的内存块,实现中,只需在分配内存时,分配多一点内存,然后将内存地址上调至对齐地址,最后传回调整的地址。多分配的内存字节等于对齐的字节。如何调整内存地址?即如何计算对其需要的最小字节数,这个很简单,这里省略。
这样释放时如何释放正确大小的内存呢?实现方式可以再调整好的内存地址的前一个字节处记录实际分配的内存大小,释放时按照实际的大小来释放。
单帧和双缓冲内存分配器
单帧分配器和堆栈分配器类似,只是单帧分配器在每帧开始时,会将堆栈指针重置为内存块的底端地址。这样就不需要手动释放内存,因为每帧开始就会自动释放前一帧的所有分配的内存。但是这也意味着,该分配器分配的内存只在当前帧上有效。
双缓冲分配器与单帧分配器的区别在于,它有两个大小相同的单帧分配器,它会交替使用两个单帧分配器,这样第i帧分配的内存在第i+1帧中仍然可用。
内存碎片
在支持虚拟内存的操作系统中,内存碎片不是大问题,但是很多游戏引擎不会使用虚拟内存,因为它会导致很多额外的开销。前面的介绍可以看出堆栈和池分配器不会产生内存碎片。
内存碎片的整理需要移动已分配的内存块,它会导致指针的失效;可以使用重定位来解决这个问题。具体来说有两种方法。
- 使用智能指针管理指针,并将智能指针加入一个全局的链表中,当整理内存时,在全局链表中查找地址对应的智能指针,修改智能指针中管理的地址。
- 使用索引管理指针,指针指向的是索引;这样整理内存时,遍历索引管理的地址值,并更新它的值。由于指针指向的是索引,而索引在整理内存时不会改变。
但是有些内存块不能被重定位,那么可以将这些内存块分配到不可重定位的内存区中,或者容许少量不可重定位的内存块的存在。
内存整理是很耗时的过程,但是不需要一次完成,所以可以把它分摊到多个帧中。
CPU会有多级高速缓存,缓存中存取数据的速度快与内存中的存取,为了提高效率就需要提高高速缓存的命中率。对于多级缓存CPU最外层的缓存命中失败的成本比内层的缓存命中失败的成本高。缓存分为指令缓存和数据缓存。
提高数据缓存命中率的方法
保证数据大小较小,且将他们尽可能放到连续的内存块中,顺序访问这些数据。(和堆栈分配器很契合)
提高指令缓存命中率的方法
单个函数的机器码几乎总是置于连续的内存;编译器和链接器按函数在源代码中出现次序排列内存布局,因此一个源文件中的函数总在连续内存块中。
- 高效能代码的体积越小越好,体积指的是机器指令数量。
- 在性能关键的代码中,避免调用函数;
- 如果要调用某个函数,就把该函数的实现放到最接近调用它的地方;
- 审慎地使用内联函数,因为过多使用内联函数会使代码体积增大。
容器
常用容器:数组(array)、动态数组(dynamic array)、堆栈(stack)、队列(queue)、双端队列(deque)、优先队列(priority queue)、树(tree)、二叉查找树(binary search tree)、二叉堆(binary heap)、字典(dictionary)、集合(set)、图(graph)、有向非循环图(directed acyclic graph)
迭代器的优点
- 隐藏容器内部细节
- 简化迭代过程
尽量使用前置递增,因为后置递增有个拷贝的过程,如果是迭代器,这个拷贝可能很耗时。
以下的情况下可能建立自定义容器
- 完全掌控:希望能掌控容器的内存需求和分配,算法;
- 优化的机会:能够借助硬件或某些应用做优化;
- 可定制性:要求特殊的功能;
- 消除外部依赖:第三方库出现问题。
比较自定义数据结构和第三方库,才能决定是否去自定义。为此要先了解第三方库。常用的包括:STL、STL的变种(STLport)、Boost。
STL
STL的优点:
-
- 功能丰富
- 许多平台上都很健壮
- 几乎所有C++编译器都带有STL
STL的缺点
-
- 相比为某些问题定制的数据结构,STL较慢,且占用更多内存
- STL有较多的动态内存分配
- STL的实现在各个编译器上有微小差异
STL的应用时机
-
- 使用STL前要认识它的效率和内存特性
- 若代码中重量级STL类会造成瓶颈,就要避免使用它们
- 占用少量内存的情况下才使用STL
- 若引擎支持多平台可以使用STLport(http://www.stlport.org)
Boost
Boost的优点
-
- boost由STL中没有的一些功能
- Boost提供了代替方案,能解决STL设计和实现上的一些问题
- Boost能有效处理一些复杂问题
- Boost文档很易读
Boost的缺点
-
- Boost大部分核心类是模板,因此,需要包含一些头文件,且有些Boost库会生成较大的.lib文件,不适合小型游戏项目
- Boost不提供任何保证,不保证支持后向兼容
- Boost库按Boost软件许可证发布
模板元编程(template metaprogramming,TMP)是利用编译器做一些通常在运行期才会做的工作。Loki是一个强大的C++TMP库。(http://loki-lib.sourceforge.net)
缺点
- 代码难以使用和理解
- 有些功能依赖某些编译器的“副作用”,移植时需要小心
游戏编程中经常使用固定大小的数组,因为它无需内存分配,且对缓存友好;但是编译期间难以决定数组的大小,所以倾向于使用链表和动态数组。但是最后当数组的大小能够确定时,把它改为固定大小的数组。
链表的建议
- 外露式表:节点保存指向实际元素的指针。优点是一个元素能同时置于多个链表,缺点是必须动态分配节点。使用池分配器是最佳选择。
- 侵入式表:元素的数据结构被嵌入节点。优点是无须动态分配,缺点是没有外露式表那么有弹性。
- 循环链表增加一个节点表示首尾指针能简化出入删除操作。
- 若不惜一切代价都要避免动态内存分配,则选用侵入式表;若能负担得起池分配的开销,或链表中的实例来自第三方库,则选用外露式表
字典和散列表:注意散列(把任意类型的键转换为整数)函数的选择是关键。若键为32位整数,把其位模式诠释为32位整数;若键为字符串,则把字符串中所有字符的ASCII或UTF码合并为单个32位整数,常见的字符串散列函数有LOOKUP3、CRC32、MD5等。
字符串
字符串类虽然方便,但有隐性成本:传递字符串对象时,函数声明或使用不当引起多个拷贝构造函数的开销;复制字符串涉及动态内存分配。游戏编程中一般避免字符串类,若一定要使用字符串类,应该查明其运行性能特性在可接受的范围,并让所有使用它的程序员知悉其开销。在储存和管理文件系统路径时,使用特化的字符串类(如Path类)来处理多平台的字符串差异,在游戏引擎中是很有价值的。
唯一标识符
唯一标识符(64位或128位的GUID字符串)用于识别游戏对象或资产,由于数量非常多,大量的比较在游戏中可能极有影响。最好找到一种方法,既保留字符串的表达能力和弹性,又要有整数操作的速度。方法是可以把字符串散列并存于表中(该过程称为字符串扣留),并通过散列码(也称为字符串标识符,string id或SID)取回原来的字符串,但要选取恰当的散列函数保证不碰撞。
因为字符串扣留(散列,分配字符串内存,复制至查找表)非常缓慢,所以通常在运行时就进行,而且仅进行一次,把结果储存备用。
#define U32 unsigned int #define StringId U32 static HashTable<StringId, const char *> gStringIdTable; StringId internString(const char *str){ StringId sid = hashCrc(str); if (gStringIdTable.find(sid) == gStringIdTable.end()){ //字符串未加入表中时,将其拷贝的副本加入表中 /* strdup函数原型: strdup()主要是拷贝字符串s的一个副本,由函数返回值返回,这个副本有自己的内存空间,和s不相干。 strdup函数复制一个字符串,使用完后要记得删除在函数中动态申请的内存,strdup函数的参数不能为NULL,一旦为NULL,就会报段错误. 因为该函数包括了strlen函数,而该函数参数不能是NULL. **/ gStringIdTable[sid] = strdup(str); } return sid; } static StringId sid_foo = internString("foo");//确保只调用一次,而不要放到判断条件时调用 static StringId sid_bar = internString("bar"); void fun(StringId id){ if (id == sid_foo){} else if (id == sid_bar){} }
本地化
对每个向用户显示的字符串,都要事先翻译为需要支持的语言(程序内部使用的,永不显示于用户的字符串无须本地化)。除了通过使用合适的字体,为所有支持语言准备字符字形,游戏还需要处理不同的文本方向(针对一些阅读顺序很特殊的语言)。
推荐先阅读这篇文章:《每个软件开发者都绝对必知的Unicode及字元集必备知识(没有借口!)》。游戏引擎中最常采用的是UTF-8和UTF-16。
Windows下的Unicode
在Windows下,wchar_t用来表示单个“宽”UTF-16字符(WCS),char则用作ANSI字符及多字节UTF-16字符串(MBCS)。Windows容许程序员编写字符集无关的代码,即提供TCHAR数据类型,它会根据实际所用的字符集自动typedef为特定的类型。
注意Windows中各种API和标准函数库,无前缀表示普通ANSI字符,前缀为“w”“wcs”表示宽字符,缀为“mbs”表示多字节UTF-16,如strcmp()、wcscmp()和_mbscmp()。不同的引擎采用哪种编码并不重要,重要的是在项目中尽早决定,并始终贯彻使用。
其他本地化要考虑的事
- 本地化不仅包括字符,还包括录制语音、带文字的纹理,还要注意一些符号在不同文化中意义的差别,注意不同市场的评级界限。
- 本地化系统需要建立字符串数据库,通过SID以及全局的“当前语言”设定来查找对应的语言字符串。其函数声明可能为:const wchar_t* getLocalizedString(const char* sid)
- 数据库的实现细节不是很重要,可以用CSV,也可以用专门的DBMS。
- 程序员切记不要硬编码原始字符串,而是采用上述查找函数取得所需字符串。注意字符串可能需要处理像"Player {0} Score: {1}"这样的格式化串。
引擎配置
读写选项
可配置选项可简单实现为全局变量或单例中的成员变量,这些选项必须可供用户配置,储存到硬盘、记忆卡或其他媒体,游戏能随时读取。下面是一些读写选项的方法:
- 文本配置文件:如INI、XML、JSON等;通过键值对,并将键值对以逻辑段分组。
- 经压缩的二进制文件:主要用于老式游戏主机上储存空间极其有限的记忆卡
- Windows注册表:以树形式存储,内部节点为注册表项(类似文件夹),叶节点以键值对储存选项。任何应用程序都可预留一个注册表项存储任意内容
- 命令行选项:通过扫描命令行取得选项设置
- 环境变量
- 线上用户设定档:存储在中央服务器,必须通过联网存取,一般用于存储用户成就、已购买或解锁的游戏内容、游戏选项及其他信息
个别用户选项
个别用户选项保留了每个玩家自己配置其喜欢的选项,与全局选项区分开来。需要小心控制每个玩家只能“看见”自己的选项,而不会遇见其他玩家在同一设备的选项。
在Windows上,应用程序通常在C:Documents and Settings的隐藏文件夹Application Data文件夹中建立自己的文件夹,存放个别用户数据。或者通过读写注册表HKEY_CURRENT_USER下的注册表项,来存取管理当前用户的配置选项。
真实引擎中的配置管理
- 雷神之锤的主控台变量(Console Variables,CVAR):一个储存浮点数或字符串的全局变量,可在主控台下查看及修改。多个CVAR存储在全局键表中,每个CVAR时动态配置的struct_cvar_t实例,链表的方式连接起来。部分值可储存到硬盘上的config.cfg文件。
- OGRE引擎:使用INI,像plugins.cfg记录要启用的插件及路径,resources.cfg包含游戏资产的路径。通过Ogre::ConfigFile类可轻易读写全新的配置文件
- 顽皮狗的神秘海域引擎:使用以下多种配置机制
游戏内置菜单选项:每个可配置选项都实现为全局变量,为选项创建菜单项目时,会提供全局变量的地址,之后菜单项目就能直接控制该全局变量的值
命令行参数:可指定要载入的关卡名称,以及其他常用参数
Scheme(一种Lisp方言)数据定义:通过脚本定义数据结构,并用自建的数据编译器转换为二进制文件,同时自动生成C/C++的头文件以解释二进制文件的数据。可以在运行期间重编译和重加载二进制文件,以便随时修改数据结构并立即看到效果。这种系统给予程序员巨大的弹性,可以定义复杂的数据结构,如细致的动画树、物理参数、游戏机制等。下面的代码示例,用于为动画定义属性,并导出2个动画
;; Scheme代码,定义一个新的数据类型,名为simple-animation (deftype simple-animation () ( (name string) (speed float: default 1.0) (fade-in-seconds float: default 0.25) (fade-out-seconds float: default 0.25) )) ;; 定义此数据结构2个实例 (define-export anim-walk (new simple-animation :name "walk" :speed 1.0 ) ) (define-export anim-jump (new simple-animation :name "jump" :fade-in-seconds 0.1 :fade-out-seconds 0.1 ) )
Scheme代码会产生以下C/C++头文件:
// simple-animation.h // 警告:本文件是Scheme自动生成的,不要手工修改 struct SimpleAnimation { const char* m_name; float m_speed; float m_fadeInSeconds; float m_fadeOutSeconds; };
在游戏编程中,可调用LookupSymbol()函数读取数据,该函数以返回类型为模板参数:
#include "simple-animation.h" void someFunction() { SimpleAnimation* pWalkAnim = LookupSymbol<SimpleAnimation*>("anim-walk"); SimpleAnimation* pJumpAnim = LookupSymbol<SimpleAnimation*>("anim-jump"); // 在此使用这些动画...... }