游戏对象引用与查询
Game Object References & Queries
游戏对象需要唯一 id 来区分、查找和充当通信目标;找到对象后还要能引用它,引用的寿命可以从一次函数调用到许多分钟。GEA 比较三种实现:裸指针最快最简单,但有孤儿对象、悬垂指针、非法指针三类问题,且不能配合内存重定位;智能指针可以检测对象已删除、做引用计数,但很难写对,GEA 个人建议远离它,非用不可就用 C++11 标准库或 Boost 的成熟实现;句柄是全局句柄表的整数索引,加上唯一 id 防止槽位复用后指向错误对象,简单而安全。对象查询则不追求通用数据库,而是为最常用的查询建专门的数据结构(GEA4 卷 II 17.5)。
裸指针
C/C++ 中最直接的对象引用是指针(或 C++ 引用),强大、简单直观,但有三类问题(卷 II 17.5.1):
- 孤儿对象 (orphaned object):理想情况下每个对象都有一个负责其生命周期的所有者,但指针不帮你强制这条规则,结果可能是对象仍占内存却已没人需要、也没人引用。
- 悬垂指针 (stale pointer):对象删除后应把所有指向它的指针置空,忘了就会指向已释放的内存。经由它读写会崩溃或行为错误;而且很难追查,因为对象删除后它可能还能"正常工作"一段时间,直到新对象分配到这块内存上、数据真正改变才崩溃。
- 非法指针:指针可以存任意地址。常见问题是解引用空指针,可以用断言宏在解引用前检查(见 错误处理与断言);更糟的是把一段数据误当成指针,解引用会读写一个基本随机的地址,非常难调。
很多引擎大量使用指针,因为它是最快、最高效、最好用的对象引用;但有经验的程序员总对指针保持警惕。有的团队出于安全实践、有的出于必要而改用更复杂的引用:例如引擎在运行时重定位内存块以消除碎片(卷 I 6.2.2.2)时就不能用裸指针,要么用对重定位健壮的引用,要么在移动每个块时手动修正所有指向它的指针。
智能指针
智能指针是一个小对象,多数情况下表现得像指针,又避免了原生指针的大部分问题。最简单的形式内含一个原生指针,重载 * 和 -> 分别返回被引用对象的引用和指针(卷 II 17.5.2)。
- 因为它是对象,可以带额外元数据或做额外工作,例如识别所指对象已被删除并开始返回空地址;
- 多个智能指针可以协作统计对某对象的引用数,即引用计数:降到零时对象不再需要,可以自动删除,程序员不必再操心所有权和孤儿对象。GEA 指出引用计数通常也是 Java、Python 等现代语言"垃圾回收"系统的核心。
原书问题:这一说法不准确。Java(HotSpot)的垃圾回收是追踪式的(从 GC 根出发标记可达对象,分代回收),并不使用引用计数(Oracle《HotSpot Virtual Machine Garbage Collection Tuning Guide》);CPython 确实以引用计数为主,但另有一个分代的循环垃圾回收器来处理引用计数无法回收的循环引用(Python 文档
gc模块与 Python Developer's Guide "Garbage collector design")。所以引用计数只是部分语言/实现的核心机制。
- 难写对:要处理的情况非常多,原始 C++ 标准库的
std::auto_ptr被公认在很多场合不够用;C++11 引入std::shared_ptr、std::weak_ptr、std::unique_ptr解决了大部分问题,它们以 Boost 的智能指针为蓝本。Boost 定义了六种:
| 类型 | 含义 |
|---|---|
boost::scoped_ptr | 指向单个对象,唯一所有者 |
boost::scoped_array | 指向对象数组,唯一所有者 |
boost::shared_ptr | 指向对象,生命周期由多个所有者共享 |
boost::shared_array | 指向对象数组,由多个所有者共享 |
boost::weak_ptr | 不拥有、不会自动销毁所指对象(假定其寿命由 shared_ptr 管理) |
boost::intrusive_ptr | 假定所指对象自己维护引用计数;与原生指针一样大(不需要计数设施),且可以直接从原生指针构造 |
- 正确实现的难点包括:类型安全、能否用于不完整类型、异常发生时的正确行为、可能很高的运行时开销。GEA 作者参与的一个项目尝试自己实现智能指针,直到项目最后都在修各种恶性 bug。他的个人建议是远离智能指针;非用不可,就用 C++11 标准库或 Boost 这类成熟实现,不要自己造。
原书问题(并列记录):GEA 写道"指针可以做算术运算,所以
+、-、++、--也被相应重载"。自制智能指针当然可以这样做,但 GEA 随后推荐的成熟实现都不提供指针算术:cppreference 列出的std::unique_ptr、std::shared_ptr成员运算符只有*、->、operator bool(数组版unique_ptr<T[]>另有[],shared_ptr<T[]>自 C++17 起有[]),没有+、-、++、--;Boost.SmartPtr 文档中shared_ptr、scoped_ptr、intrusive_ptr的类概要同样没有任何算术运算符。对引用单个对象的拥有型智能指针来说,算术运算会产生不属于该所有权的指针,正是标准库刻意不提供的原因。
与 UE5 的对照(观点并列):GEA 建议尽量避免智能指针;UE 则自带一整套智能指针(
TSharedPtr、TSharedRef、TWeakPtr、TUniquePtr),官方还建议 UCLASS/USTRUCT 里的 UObject 指针属性优先用TObjectPtr<T>,UObject 的生命周期由垃圾回收而不是引用计数管理,见 智能指针与 TObjectPtr 和 垃圾回收与对象生命周期。不过 UE 文档同样承认智能指针创建和拷贝比裸指针开销大、在渲染等底层代码里不太适用,这一点与 GEA 对运行时开销的担心一致。UE 的做法相当于 GEA 说的"使用成熟实现",而不是"每个项目自己造"。
句柄
句柄在很多方面像智能指针,但更简单、更不易出问题。它本质上是全局句柄表的整数索引,表里存着指向对象的指针。创建句柄就是在表中找到对象地址并记下索引;解引用就是取表中相应槽位再解引用其中的指针(卷 II 17.5.3,GEA 图 17.13)。
flowchart LR H["指向 Object5 的句柄<br/>m_handleIndex == 6"] --> T["全局句柄表<br/>0 Object1 / 1 Object2 / 2 NULL / 3 Object3<br/>4 Object4 / 5 NULL / 6 Object5"] T --> O["Object5"] O -."删除时把槽位置 NULL".-> T
图中(按 GEA 图 17.13 排版页核对)句柄只存槽位索引(下文的改进版还会存唯一 id),对象和句柄之间隔了一层句柄表,所以对象删除或移动时只需改表中这一项。
- 更安全:对象删除时把自己的槽位置空,所有现有句柄立即自动变成空引用;
- 支持内存重定位:对象移动后在表中更新其地址,所有句柄随之自动更新;
- 句柄可以是裸整数,但通常把索引包进一个简单的类,提供方便的创建和解引用接口。
过期句柄问题:对象 A 占槽 17,删除后槽 17 置空;之后新对象 B 恰好也占了槽 17,残留的 A 的句柄会突然指向 B 而不是空。简单的解法是在句柄里同时存唯一对象 id:残留句柄与 B 槽位相同但 id 不符,于是解引用返回空。GEA 的示例代码(已按排版页 p.586–587 核对,原书代码本身无误;这里压缩了注释排版):
class GameObject {
// ...
GameObjectId m_uniqueId; // 唯一 id
U32 m_handleIndex; // 存起来以便快速创建新句柄
friend class GameObjectHandle;
public:
GameObject() {
m_uniqueId = AssignUniqueObjectId(); // 可来自世界编辑器,也可运行时动态分配
m_handleIndex = FindFreeSlotInHandleTable(); // 找表中第一个空槽
}
};
// 句柄表的大小即同时存在的游戏对象上限
static const U32 MAX_GAME_OBJECTS = 20480;
static GameObject* g_apGameObject[MAX_GAME_OBJECTS];
class GameObjectHandle {
U32 m_handleIndex; // 句柄表索引
GameObjectId m_uniqueId; // 防止过期句柄
public:
explicit GameObjectHandle(GameObject& object)
: m_handleIndex(object.m_handleIndex), m_uniqueId(object.m_uniqueId) {}
GameObject* ToObject() const {
GameObject* pObject = g_apGameObject[m_handleIndex];
if (pObject != nullptr && pObject->m_uniqueId == m_uniqueId)
return pObject;
return nullptr;
}
};
GEA 说明:在 GameObject 里也存句柄索引,是为了不必搜索句柄表就能快速创建句柄。这个例子能用但不完整:可能还要实现拷贝语义、更多构造函数;表项里也可以存更多信息;固定大小的表也不是唯一设计,各引擎的句柄系统有所不同。附带好处:全局句柄表天然就是所有活动对象的列表,可以快速遍历世界中的所有对象,有时也让其他查询更容易实现。
游戏对象查询
每个引擎都至少提供几种运行时查找对象的方式。最简单的是按唯一 id 找,真实游戏还需要很多别的查询,例如:找出所有能看到玩家的敌人、遍历某类型的所有对象、找出生命高于 80% 的所有可破坏对象、给爆炸半径内的所有对象传递伤害、按由近到远遍历子弹或投射物路径上的所有对象。具体清单高度依赖游戏设计(卷 II 17.5.4)。
理想是一个通用的游戏对象数据库,能用任意条件做任意查询,而且都极快;现实中灵活性与极致速度通常不可兼得。团队一般先确定开发中最可能需要的查询类型,为它们实现专门的加速数据结构;新查询出现时,要么利用已有结构,要么在速度不够时发明新的:
出处
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.5 节