游戏引擎支持系统(下)

游戏编程中需要使用各种各样的集合型数据结构,称为容器或集合。字符串看似是个简单基本的数据类型,但在游戏引擎中会涉及许多设计问题和限制。此外,游戏引擎总是伴随大量可调校的选项,有的通过游戏中的选项菜单给玩家调校,有的则只为游戏开发团队设置,在正式发行时被隐掉或去除。

本文将接着上一篇,从游戏引擎的角度描述容器、字符串和引擎配置等游戏引擎支持系统。

1. 容器

常见的容器类型包括但不限于:数组、动态数组(可变长)、链表 、堆栈、队列、双端队列、优先队列(二叉堆)、树、二叉查找树(红黑树、伸展树、AVL树等)、字典、集合、图、有向非循环图。常见操作有:插入、移除、顺序访问/迭代、随机访问、查找、排序等等。

访问容器元素通常都会使用迭代器,它“知道”如何高效地访问容器中的某个元素,移至下一个元素,并用某种方式表示是否遍历完所有元素。使用迭代器的好处是:避免破坏容器类的封装,简化迭代过程。

1.1 是否建立自定义的容器类

许多游戏引擎会提供常见容器的自定义实现,而非使用第三方库,原因如下:

  • 完全掌控:控制数据结构的内存需求、具体算法、分配内存时机等
  • 优化的机会:如借助游戏机独有的硬件功能来优化
  • 可定制性:提供第三方库不常见的功能,如搜寻n个最相关的元素
  • 消除外部依赖:使用第三方库有无法自行调试维护的风险

第三方库功能强大,使用方便,但有时并不适合游戏引擎。如果决定要使用第三方库,要对它们的优缺点有全方位的了解。

  • STL
    • 优点:功能丰富;跨平台;几乎所有C++编译器都自带
    • 缺点:学习曲线陡峭;相比自定义数据结构速度较慢;占用更多内存;进行许多动态内存分配;各编译器的实现微小差异导致移植多平台麻烦
    • STL比较适合PC上的引擎,而不适用于游戏主机
    • 使用经验:用某个STL类前,充分认识其效能和内存特性;避免在可能的性能瓶颈处使用STL;占小量内存的情况才使用STL;若引擎需要支持多平台,推荐会用STLport
  • Boost
    • 优点:提供许多STL没有的有用功能;提供解决STL设计或实现上的问题的替代方案;有效处理智能指针这种复杂问题;文档写得很好(也是优秀的学习材料)
    • 缺点:生成颇大的.lib文件,不适合小型项目;不保证支持向后兼容;小心阅读许可证内容
  • Loki:其模板元编程功能极其强大,但代码可能望而生畏,难以使用,而且某些元件依赖编译器的“副作用”行为。Loki不适合胆小者,但是其设计理念非常值得学习

1.2 一些常用数据结构的使用建议

  • 动态数组:游戏编程中大量使用固定大小数组以避免动态分配的开销,而且因连续而对缓存友好。可以在开发期选用动态数组,当确定适当的内存预算时,将其改为固定大小的数组(可以自行建立一个兼容std::vector接口的模板
  • 链表
    • 外露式表:节点保存指向实际元素的指针。优点是一个元素能同时置于多个链表,缺点是必须动态分配节点。使用池分配器是最佳选择。
    • 侵入式表:元素的数据结构被嵌入节点。优点是无须动态分配,缺点是没有外露式表那么有弹性。
    • 若不惜一切代价都要避免动态内存分配,则选用侵入式表;若能负担得起池分配的开销,或链表中的实例来自第三方库,则选用外露式表
  • 字典和散列表:注意散列(把任意类型的键转换为整数)函数的选择是关键。若键为32位整数,把其位模式诠释为32位整数;若键为字符串,则把字符串中所有字符的ASCII或UTF码合并为单个32位整数,常见的字符串散列函数有LOOKUP3、CRC32、MD5等等

2. 字符串

2.1 字符串类

字符串类虽然方便,但有隐性成本:传递字符串对象时,函数声明或使用不当引起多个拷贝构造函数的开销;复制字符串涉及动态内存分配。若一定要使用字符串类,应该查明其运行性能特性在可接受的范围,并让所有使用它的程序员知悉其开销。

在储存和管理文件系统路径时,使用特化的字符串类(如Path类)来处理多平台的字符串差异,在游戏引擎中是很有价值的。

2.2 唯一标识符

唯一标识符(64位或128位的GUID字符串)用于识别游戏对象或资产,由于数量非常多,大量的比较在游戏中可能极有影响。最好找到一种方法,既保留字符串的表达能力和弹性,又要有整数操作的速度。可以把字符串散列并存于表中(该过程称为字符串扣留),并通过散列码(也称为字符串标识符,string id或SID)取回原来的字符串,但要选取恰当的散列函数保证不碰撞。

因为字符串扣留(散列,分配字符串内存,复制至查找表)非常缓慢,所以通常在运行时就进行,而且仅进行一次,把结果储存备用

2.3 本地化

对每个向用户显示的字符串,都要事先翻译为需要支持的语言(程序内部使用的,永不显示于用户的字符串无须本地化)。除了通过使用合适的字体,为所有支持语言准备字符字形,游戏还需要处理不同的文本方向(针对一些阅读顺序很特殊的语言)。

推荐先阅读这篇文章:《每个软件开发者都绝对必知的Unicode及字元集必备知识(没有借口!)》。游戏引擎中最常采用的是UTF-8和UTF-16。

2.3.1 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()

对于游戏机上的Unicode,Xbox 360开发套件几乎完全采用WCS字符串。不同的引擎采用哪种编码并不重要,重要的是在项目中尽早决定,并始终贯彻使用。

2.3.2 其他本地化要考虑的事

  • 本地化不仅包括字符,还包括录制语音、带文字的纹理,还要注意一些符号在不同文化中意义的差别,注意不同市场的评级界限
  • 本地化系统需要建立字符串数据库,通过SID以及全局的“当前语言”设定来查找对应的语言字符串。其函数声明可能为:const wchar_t* getLocalizedString(const char* sid)
  • 数据库的实现细节不是很重要,可以用CSV,也可以用专门的DBMS
  • 程序员切记不要硬编码原始字符串,而是采用上述查找函数取得所需字符串。注意字符串可能需要处理像"Player {0} Score: {1}"这样的格式化串

3. 引擎配置

3.1 读写选项

可配置选项可简单实现为全局变量或单例中的成员变量,这些选项必须可供用户配置,储存到硬盘、记忆卡或其他媒体,游戏能随时读取。下面是一些读写选项的方法:

  • 文本配置文件:如INI、XML、JSON等等
  • 经压缩的二进制文件:主要用于老式游戏主机上储存空间极其有限的记忆卡
  • Windows注册表:以树形式存储,内部节点为注册表项(类似文件夹),叶节点以键值对储存选项。任何应用程序都可预留一个注册表项存储任意内容
  • 命令行选项:通过扫描命令行取得选项设置
  • 环境变量
  • 线上用户设定档:存储在中央服务器,必须通过联网存取,一般用于存储用户成就、已购买或解锁的游戏内容、游戏选项及其他信息

3.2 个别用户选项

个别用户选项保留了每个玩家自己配置其喜欢的选项,与全局选项区分开来。需要小心控制每个玩家只能“看见”自己的选项,而不会遇见其他玩家在同一设备的选项。

在Windows上,应用程序通常在C:\Documents and Settings的隐藏文件夹Application Data文件夹中建立自己的文件夹,存放个别用户数据。或者通过读写注册表HKEY_CURRENT_USER下的注册表项,来存取管理当前用户的配置选项。

3.3 真实引擎中的配置管理

  • 雷神之锤的主控台变量(Console Variables,CVAR):一个储存浮点数或字符串的全局变量,可在主控台下查看及修改,部分值可储存到硬盘上的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");
    // 在此使用这些动画......
}

参考文献:电子工业出版社《游戏引擎架构》第5.3-5.5节

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器