概念页
字符串与唯一标识符
Strings & Unique Identifiers
字符串在 C/C++ 中只是字符数组,变长意味着要么硬编码长度上限、要么动态分配;比较和复制都是 。引擎内部大量用字符串做资源名和对象 ID,处理不当会严重拖慢游戏。GEA 的建议:运行时代码尽量避免字符串类,若用则了解其成本并按引用传递;路径类是值得专门做的例外;对象与资产的唯一标识用哈希字符串 ID(string id),兼得字符串的可读性与整数的比较速度。
字符串的问题(卷 I 6.4.1)
- 存储与管理:C/C++ 中字符串不是原子类型,变长导致要么限定长度、要么动态分配缓冲。
- 本地化:面向玩家的字符串都要翻译,涉及字形、书写方向、译文长短差异,见 游戏本地化与 Unicode。
- 内部字符串:资源文件名、对象 ID(设计师给关卡对象起「PlayerCamera」「enemy-tank-01」「explosionTrigger」这样的名字)。比较整数或浮点是一条机器指令,比较字符串要
strcmp()做 扫描,复制要 拷贝甚至动态分配。作者在某项目中做性能分析,发现strcmp()和strcpy()是最贵的两个函数;去掉不必要的字符串操作并采用本节技术后,它们几乎从分析结果中消失,帧率显著提高(他也听其他工作室讲过类似故事)。
字符串类(卷 I 6.4.2)
std::string等字符串类方便,但有隐藏成本:C 风格数组传参通常只是把首字符地址放进寄存器;字符串对象若声明或使用不当会触发拷贝构造,可能动态分配,让看似无害的调用花费数千个周期。- 作者通常在运行时游戏代码中避免字符串类。若要用,先了解它:缓冲是否只读?是否用写时复制 (copy on write)?有无移动构造?是否拥有内存,还是能引用不归它所有的内存?经验法则:字符串对象总是按引用传递,绝不按值传递;尽早、经常做性能分析。
- 例外:路径类。
Path类可提供提取文件名、扩展名、目录的函数,并屏蔽操作系统差异(如把 Windows 反斜杠转换为 UNIX 正斜杠),跨平台实现很有价值(GEA 7.1.1.4 节有更多讨论)。
唯一标识符与字符串 ID(卷 I 6.4.3)
- 游戏对象(如吃豆人里的「pac_man」「blinky」「pinky」「inky」「clyde」)和构成它们的资产(网格、材质、纹理、音频、动画)都需要唯一标识。字符串很自然:资产常是磁盘文件,可用路径唯一标识;设计师也更愿意起可读名字,而不是记整数索引或 64/128 位 GUID。但比较速度至关重要,
strcmp()太慢。 - 哈希字符串 ID(卷 I 6.4.3.1):把字符串哈希成「半唯一」整数,比较就是整数比较;把原字符串存进哈希表,就能由哈希码找回原字符串,用于调试、屏幕显示和日志。游戏程序员常称之为 string id,Unreal 称为 name(
FName类)。 - 冲突:32 位哈希有四十多亿个值,好的哈希函数下合理输入几乎不会冲突。Naughty Dog 最初用 CRC-32 的变体,在《神秘海域》《最后生还者》多年开发中只遇到过几次冲突,修复也只需稍改其中一个字符串(加「2」或「b」,或换个同义词)。自《最后生还者 第二部》起改用 64 位哈希,按单个游戏的字符串数量和典型长度,基本消除了冲突可能。(哈希函数对比见 容器与哈希表。)
实现要点(卷 I 6.4.3.2)
- 何时哈希:多数引擎在运行时哈希。Naughty Dog 允许运行时哈希,同时用 C++ 用户自定义字面量把
"any_string"_sid在编译期变成整数,于是 string id 能用在任何需要整型常量的地方,包括switch的case标签(运行时函数调用的结果不是常量,不能作 case 标签)。 - 驻留 (interning):生成 string id 时通常还把字符串加入全局字符串表以便日后找回。工具链也可以预先把字符串哈希好,引擎读到的数据里已经是 ID。
- 驻留很慢(运行哈希、为字符串分配内存并复制进表),所以(非编译期生成时)每个字符串只驻留一次并保存结果:
static StringId sid_foo = internString("foo"); // 好:只驻留一次
void f(StringId id) { if (id == sid_foo) { /* ... */ } }
// 差:每次调用 f() 都重新驻留
void g(StringId id) { if (id == internString("foo")) { /* ... */ } }
internString()的一种实现:StringId sid = hashCrc32(str),若全局HashTable<StringId, const char*>中没有该 sid,则存入strdup(str)(复制一份,以防原字符串是动态分配的、之后会被释放)。原书问题:书中代码声明的表名是gStringIdTable,但if判断和插入处写成了gStringTable,照抄无法编译,应统一为同一个变量名。另外该实现只按哈希值查表,不比较已存字符串与str是否相同,所以发生冲突时会静默返回同一 ID;实际使用时宜在开发版中检测(表中已存在该 sid 但字符串不同则报错),以便按上文做法改名。- 包装成小类:Unreal 把 string id 和指向对应 C 字符数组的指针包进
FName;Naughty Dog 同样把 ID 包进StringId类,并写了自定义字面量运算符,使SID("any_string")生成该类实例,哈希值由"any_string"_sid产生。 - 字符串放调试内存:发售版游戏只用 ID,字符串仅供人看,所以字符串表可以放在零售机上不存在的内存区域。例:PS5 开发机除 16 GiB 零售内存外还有 16 GiB「调试内存」(debug / development / dev memory)。这样字符串不占最终游戏的内存——但绝不能写依赖这些字符串存在的产品代码(构建配置见 构建配置与编译优化)。
与 UE5 FName 的对照
- GEA 把
FName描述为「string id + 指向 C 字符数组的指针」的小类,归入哈希字符串 ID 一类。 - 容器与字符串(依据 UE 5.8 官方文档)则记录 FName 存的是全局字符串表中的索引和实例号,用哈希表完成字符串到 FName 的转换,
==只比较索引,且不区分大小写。 - 两种说法的共同点是「比较只需整数比较、原字符串在全局表中可找回」;具体存储内容不同,以官方文档描述的 UE 5.8 为准,GEA 的描述可视为对这一类设计的概括。GEA 未提大小写问题,而不区分大小写正是 UE 文档中 FString→FName 转换会丢信息的原因。
取舍小结
| 做法 | 优点 | 代价 / 注意 |
|---|---|---|
| C 字符数组 | 传参快 | 长度限制或手动分配 |
| 字符串类 | 方便 | 隐藏拷贝与分配;按引用传递 |
| 路径类 | 跨平台路径处理 | 值得专门实现 |
| 哈希 string id | 整数速度比较,可读 | 可能冲突(64 位基本消除);驻留慢,只做一次;编译期哈希更好 |
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 6 章 6.4.1–6.4.3 节
相关页面
游戏开发容器与哈希表Containers & Hash Tables容器的职责都是容纳和管理零个或多个元素,差别在于实现方式与取舍。GEA 概览了常见容器与操作、迭代器、大 O 复杂度与内存布局的综合考量,讨论引擎为何常自研容器、C++ 标准库 / Boost / Folly / Loki 的利弊,并重点讲了动态数组的增长策略与哈希表(开放哈希与封闭哈希、哈希函数、线性/二次探测、Robin Hood 哈希)。游戏开发 · UE5容器与字符串Containers & StringsUE 不用 STL,而用自带的值类型容器:TArray(连续数组)、TMap/TMultiMap(哈希映射)和 TSet(哈希集合),都假定元素可按字节直接搬移。字符串分三种:FName 存索引、比较便宜,FText 用于可本地化的界面文本,FString 可以修改但开销最大。选错类型的代价主要是性能和本地化。游戏开发游戏本地化与 UnicodeGame Localization & Unicode本地化(国际化,I18N)是把软件改编为其他语言版本,最好从第一天起规划并贯穿每个开发步骤。GEA 介绍了 Unicode 与 UTF-32/UTF-8/UTF-16/UCS-2 编码、char 与 wchart、Windows 的字符串 API 约定、主机上的选择,字符串之外的本地化问题,以及核心基础设施——字符串数据库 + 按 ID 查找当前语言字符串的运行时函数,并以 Naughty Dog 的本地化工具为例。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发构建配置与编译优化Build Configurations & Compiler OptimizationC++ 游戏代码从源文件到可执行文件要经过预处理、编译、链接。游戏项目通常不止 Debug/Release 两种构建配置 (build configuration),而是 Debug、Development、Ship,有时再加混合构建;配置越多测试越难。编译器优化分为局部优化和全局优化(链接时优化 LTO、剖析引导优化 PGO),它们让程序更快,但也让调试器“说谎”。所以要学会调试优化过的构建,并用统计式或插桩式性能分析器 (profiler) 找出最耗时的代码。游戏开发引擎配置系统Engine Configuration游戏引擎总会积累大量可配置选项:有的通过选项菜单暴露给玩家(画质、音量、手柄设置),有的只供开发团队使用、发售前隐藏或去除(如玩家最大行走速度在开发期可调、发售前改成硬编码)。GEA 讨论选项的存取方式(文本文件、压缩二进制、注册表、命令行、环境变量、在线档案)、全局与每用户选项,以及 Quake cvar、OGRE、Naughty Dog 三个真实例子。游戏开发引擎文件系统与异步文件读写Engine File System & Asynchronous File IO资源管理器大量依赖文件系统。引擎通常把操作系统的文件 API 包装成自己的 API:一是跨平台、屏蔽硬件差异;二是操作系统缺少引擎需要的功能(如流式加载),主机还要统一访问记忆棒、硬盘、蓝光盘、网络文件系统等介质。引擎文件系统 API 通常涵盖:文件名与路径操作、打开/关闭/读写文件、扫描目录、处理异步 I/O 请求(流式加载)。游戏开发资源管理器Resource Manager游戏由网格、材质、纹理、着色器、动画、音频、关卡布局、碰撞与物理参数等大量资源(也叫资产、媒体)构成。内存有限,所以引擎要保证每个资源在内存中只有一份(五个网格共用一张纹理时只载入一份)。每个引擎都有某种资源管理器:有的是统一集中的子系统,有的散布在多年间由不同人写的各个子系统中,但职责和要解决的问题是一样的。本页讲运行时部分:职责、文件组织与归档、文件格式、资源 GUID、资源注册表、生命周期(引用计数)和资源的内存管理;交叉引用与加载后初始化见 资源交叉引用与加载后初始化,离线工具链见 资产管线与工具链。游戏开发日志与调试绘制Logging & Debug Drawing几乎所有专业团队都会为引擎打造一套调试与开发工具。本页记录其中最基础的两类:日志与追踪(格式化调试输出、详细级别、通道、镜像到文件、崩溃报告)和调试绘制(在游戏世界或屏幕上画线、形状和文字以可视化数学计算)。(卷 I 第 10 章 引言、10.1–10.2 节)游戏开发骨骼、姿势与动画片段Skeletons, Poses & Animation Clips角色动画系统的数据核心是三样东西:骨骼(关节层级)、姿势(每个关节的 SRT 变换)和动画片段(姿势随局部时间变化的采样)。GEA 把各种动画方法看成对"逐点控制表面"这一理想的不同压缩方式;骨骼蒙皮用少量关节驱动大量顶点,是今天最主流的方案(GEA4 卷 II 13.1–13.4)。游戏开发音频引擎架构Audio Engine Architecture音频渲染软件和多数系统一样是分层的"栈":硬件 → 驱动 → 底层 API → 3D 音频引擎,功能集通常模仿录音棚的多通道调音台。每个 2D/3D 声音是一条语音 (voice),沿语音总线经过编解码、增益、预发送滤波、辅助发送分出干湿两路、混响、后发送滤波和声像,然后由主混音器合成多声道信号,再经主输出总线(预放、LFE 生成、EQ、压缩、主音量)送到扬声器。资产上分片段、音效提示 (cue)、声音库和流式声音;混音靠分组、闪避、总线预设与混音快照、实例限制和语音抢占(GEA4 卷 II 15.5)。游戏开发游戏音频功能与角色对话Game-Specific Audio Features & Character Dialog在 3D 音频渲染管线之上,游戏还要实现分屏、物理驱动音频、动态音乐、角色对话、声音合成和人群声音等专门功能。GEA 重点讲了分屏的多听者问题、顽皮狗《最后生还者》的角色对话系统(逻辑台词、随机不重复、优先级与打断、把对话作为一等实体、排他性、基于规则和事实字典的分支对话、按区域标签的情境台词、对话手势)以及音乐系统(播放列表、紧张度、插入乐、过渡)(GEA4 卷 II 15.6)。游戏开发世界块数据格式与生成器World Chunk Data Formats & Spawners世界块中的动态部分要为每个游戏对象记录两样东西:属性初始值和对象类型。GEA 比较了三种存储方式:二进制对象映像(生成最简单,但要处理指针、虚表和字节序,且对变化很脆弱)、序列化描述(XML/JSON/私有二进制格式,配合反射与类工厂),以及生成器 (spawner) + 类型模式 (type schema):生成器是只含类型 id 和键值对的轻量数据块,彻底切断世界编辑器与运行时代码的耦合;模式里的默认值配合"与默认值相同就省略键"可以让默认值的修改自动传播到旧实例(GEA4 卷 II 17.3)。游戏开发事件与消息传递Events & Message Passing游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。游戏开发游戏对象引用与查询Game Object References & Queries游戏对象需要唯一 id 来区分、查找和充当通信目标;找到对象后还要能引用它,引用的寿命可以从一次函数调用到许多分钟。GEA 比较三种实现:裸指针最快最简单,但有孤儿对象、悬垂指针、非法指针三类问题,且不能配合内存重定位;智能指针可以检测对象已删除、做引用计数,但很难写对,GEA 个人建议远离它,非用不可就用 C++11 标准库或 Boost 的成熟实现;句柄是全局句柄表的整数索引,加上唯一 id 防止槽位复用后指向错误对象,简单而安全。对象查询则不追求通用数据库,而是为最常用的查询建专门的数据结构(GEA4 卷 II 17.5)。游戏开发游戏脚本系统Game Scripting脚本语言是主要用来让用户控制和定制软件行为的编程语言;在游戏引擎中,它是高层、相对易用、能方便访问引擎大多数常用功能的语言,程序员和非程序员都能用它开发新游戏或给现有游戏做 mod。GEA 先区分数据定义语言和运行时脚本语言,梳理语言分类与游戏脚本的典型特征(解释执行、轻量、快速迭代、方便),介绍 QuakeC、UnrealScript、Lua、Python、Pawn,列出从"脚本回调"到"脚本驱动整个游戏"的六种架构,再以 Naughty Dog 的 DCScript 为例讲虚拟机与原生函数接口,最后是对象引用、事件收发、面向对象脚本、脚本状态机和协作式多线程脚本(GEA4 卷 II 17.游戏开发游戏内性能与内存剖析In-Game Profiling & Memory Tracking游戏是实时系统,要保持 30 或 60 FPS,同时受目标硬件内存限制。第三方剖析工具有局限、在主机上未必可用,所以许多引擎自带游戏内剖析器(手动插桩的代码块计时、层级关系、调用次数、导出 CSV)和内存统计与泄漏检测工具。GEA 讲解它们的实现要点和常见限制。(卷 I 10.8–10.9 节)游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。