中EN
概念页

游戏对象引用与查询

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)。

理想是一个通用的游戏对象数据库,能用任意条件做任意查询,而且都极快;现实中灵活性与极致速度通常不可兼得。团队一般先确定开发中最可能需要的查询类型,为它们实现专门的加速数据结构;新查询出现时,要么利用已有结构,要么在速度不够时发明新的:

查询加速结构
按唯一 id 查找以 id 为键的哈希表或二叉搜索树,存对象指针或句柄(见 容器与哈希表)
遍历满足某条件的所有对象按事先已知的条件预先把对象分到链表里,如某类型的所有对象、玩家某半径内的所有对象
投射物路径上或与目标点有视线的对象借用碰撞系统的快速射线投射,有的还支持球体或任意凸体的形状投射(卷 II 14.3.7,见 碰撞查询)
给定区域或半径内的对象空间哈希:简单如覆盖全世界的水平网格,复杂如四叉树、八叉树、kd 树等编码空间邻近性的结构(见 空间分区)
  1. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.5 节

相关页面

游戏开发 · UE5智能指针与 TObjectPtrSmart Pointers & TObjectPtrUE 有两套对象寿命管理:普通 C++ 对象用引用计数智能指针(TSharedPtr/TSharedRef/TWeakPtr/TUniquePtr),UObject 由垃圾回收管理,不能放进这些智能指针。UE5 新增的 TObjectPtr 用来替代 UPROPERTY 里的 UObject 裸指针:编辑器构建中提供动态解析和访问跟踪,非编辑器构建中与裸指针表现相同。游戏开发 · UE5垃圾回收与对象生命周期Garbage Collection & Object LifecycleUE 用标记-清除式的可达性分析管理 UObject 内存:从根集合 (Root Set) 出发沿反射可见的强引用遍历对象图,找不到的对象在下次垃圾回收 (Garbage Collection, GC) 时被删除。因此“谁持有 UPROPERTY 引用”决定了对象活多久;Actor 调用 Destroy() 只是请求销毁,真正释放仍取决于引用是否全部放开。游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。游戏开发字符串与唯一标识符Strings & Unique Identifiers字符串在 C/C++ 中只是字符数组,变长意味着要么硬编码长度上限、要么动态分配;比较和复制都是 O(n)。引擎内部大量用字符串做资源名和对象 ID,处理不当会严重拖慢游戏。GEA 的建议:运行时代码尽量避免字符串类,若用则了解其成本并按引用传递;路径类是值得专门做的例外;对象与资产的唯一标识用哈希字符串 ID(string id),兼得字符串的可读性与整数的比较速度。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发容器与哈希表Containers & Hash Tables容器的职责都是容纳和管理零个或多个元素,差别在于实现方式与取舍。GEA 概览了常见容器与操作、迭代器、大 O 复杂度与内存布局的综合考量,讨论引擎为何常自研容器、C++ 标准库 / Boost / Folly / Loki 的利弊,并重点讲了动态数组的增长策略与哈希表(开放哈希与封闭哈希、哈希函数、线性/二次探测、Robin Hood 哈希)。游戏开发碰撞查询Collision Queries碰撞系统除了检测真实物体的接触,还要回答假设性问题:子弹从枪口朝某方向飞,先打中什么?车能否从 A 开到 B 不碰到东西?某半径内有哪些敌人?这类操作称为碰撞查询。最常见的是投射 (cast),也叫 trace 或 probe:沿射线或线段移动一个假想物体,看它会碰到什么;被投射的东西并不真正进入碰撞世界,不会影响其他物体(GEA4 卷 II 14.3.7)。游戏开发空间分区Spatial Partition空间分区 (Spatial Partition) 把对象按位置存进空间数据结构,使“找出某位置附近的对象”这类查询不必遍历所有对象,把 O(n) 或 O(n^2) 的操作降下来。最简单的是固定网格;还可以选层次结构(四叉树、BSP、k-d 树、BVH),以及边界固定或随对象自适应的划分方式。代价是对象移动时要更新结构,并且要额外占用内存。游戏开发游戏脚本系统Game Scripting脚本语言是主要用来让用户控制和定制软件行为的编程语言;在游戏引擎中,它是高层、相对易用、能方便访问引擎大多数常用功能的语言,程序员和非程序员都能用它开发新游戏或给现有游戏做 mod。GEA 先区分数据定义语言和运行时脚本语言,梳理语言分类与游戏脚本的典型特征(解释执行、轻量、快速迭代、方便),介绍 QuakeC、UnrealScript、Lua、Python、Pawn,列出从"脚本回调"到"脚本驱动整个游戏"的六种架构,再以 Naughty Dog 的 DCScript 为例讲虚拟机与原生函数接口,最后是对象引用、事件收发、面向对象脚本、脚本状态机和协作式多线程脚本(GEA4 卷 II 17.游戏开发游戏世界的加载与流送Loading & Streaming Game Worlds世界加载系统负责两件事:把世界块和所需资源从磁盘读进内存的文件 I/O,以及这些资源的内存分配与释放;另外还要管理游戏对象的生成与销毁。GEA 按演进顺序介绍:一次只载一个关卡的栈式加载(LSR 资源压栈底),用小块气闸 (air lock) 区域实现基本无缝的过渡,再到把所有资产切成等大块、用块池分配器按关卡加载区域流送的细粒度方案(Naughty Dog);对象生成的内存问题有"不许运行时分配"、按尺寸分池、小内存分配器和内存重定位四种对策;存档则分检查点和随处存档(GEA4 卷 II 17.4)。游戏开发游戏世界与游戏对象Game World & Game Objects游戏由玩法 (gameplay) 而不是技术定义;游戏机制 (game mechanics) 是支配游戏中各实体交互的规则,以及目标、成败标准、玩家能力、非玩家实体和整体流程(GEA 引用的论文称实现玩法的软件系统为游戏的 G 因子)。大多数游戏世界由静态元素和动态元素组成,划分为若干世界块,并由高层游戏流程组织玩家目标;动态元素通常实现为游戏对象 (GO),引擎提供的建模设施称为游戏对象模型,且分为工具侧和运行时两套(GEA4 卷 II 16、16.1–16.2)。游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。游戏开发事件与消息传递Events & Message Passing游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。游戏开发错误处理与断言Error Handling & AssertionsGEA 把错误分为用户错误和程序员错误,两者处理方式相反:用户错误要尽量平稳地告知并继续,程序员错误应立即停机并给出详细的底层信息。“用户”又分玩家和制作游戏的开发者。实现手段有错误返回码、异常和断言。Naughty Dog、EA、Midway、Insomniac 等在运行时代码中通常关闭 C++ 异常,靠断言和静态断言 (staticassert) 捕获 bug,并用 RAII(Naughty Dog 称为 janitor)管理资源。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原