☰
游戏引擎基础系统设计:内存管理、对象系统与事件通信实战
2026/10/14 4:34:49 网站建设 项目流程

1. 引擎基础系统到底在搭什么架子

很多人第一次翻引擎源码,看到"基础系统"这四个字就头大,觉得无非是些工具类堆砌。但真上手写过一版能跑起来的小引擎之后,你会发现基础系统才是决定这个引擎能不能长大的地基。它不像渲染器那样能立刻出画面,也不像物理系统那样能马上看到碰撞效果,可一旦基础系统设计歪了,后面每加一个模块都要还债。

我理解的引擎基础系统,核心就三件事:内存怎么管、对象怎么活、模块之间怎么说话。这三件事听起来简单,但每一个都能衍生出一堆设计取舍。比如内存管理,你是直接用系统 malloc 还是自己搭一套分配器?对象生命周期,你是手动 new/delete 还是搞引用计数或者 GC?模块通信,你是直接函数调用还是搞事件总线?这些选择没有绝对的对错,但必须和你的目标场景匹配。

这篇文章我打算把基础系统和内存管理拆开揉碎讲一遍。不是那种"某某引擎源码分析"的论文式写法,而是站在一个实际动手写过引擎的人的角度,把当时踩过的坑、做过的取舍、以及后来回头看觉得应该怎么改,都摊开来说。适合已经有一点 C++ 基础、想自己动手写个小引擎练手的开发者,也适合在现有引擎里做工具链或者底层优化的同学参考。

先给个整体认知:一个引擎的基础系统通常包含这几个子系统——内存分配器、对象系统、句柄系统、事件/消息系统、配置系统、日志系统。其中内存分配器是重中之重,因为其他所有系统都要在它上面跑。对象系统和句柄系统决定了你游戏里成千上万个实体怎么被创建、引用和销毁。事件系统则是模块解耦的关键,没有它,你的渲染、物理、音频模块会互相纠缠成一团乱麻。

我见过不少新手写引擎,上来就写渲染循环,结果写到一半发现内存碎片严重、对象销毁时机混乱、模块之间循环依赖。回头再补基础系统,成本比一开始就设计好要高好几倍。所以我的建议是:哪怕你只是写个玩具引擎,也先把基础系统的骨架搭出来,后面往里填肉会舒服很多。

2. 内存管理:引擎性能的隐形战场

2.1 为什么不能直接用 malloc 和 new

刚入门的时候我也觉得,C++ 有 new 和 delete,为什么还要自己搞内存管理?直接用不就行了?直到我在一个场景里同时创建了几万个粒子对象,帧率直接从 60 掉到 15,我才意识到问题的严重性。

系统自带的 malloc/new 是通用分配器,它要兼顾各种大小的分配请求,所以内部逻辑很复杂,每次分配都有不小的开销。更麻烦的是内存碎片——你不停地分配和释放不同大小的内存块,堆里就会出现很多小空洞,虽然总空闲内存够,但就是找不到一块连续的大空间。这在游戏这种频繁创建销毁对象的场景里是致命的。

还有一个问题是缓存友好性。现代 CPU 的缓存命中率对性能影响巨大,而 malloc 返回的内存地址是随机的,你连续创建的对象在内存里可能隔得很远,遍历的时候缓存命中率极低。自己管理内存就可以把同类对象紧凑排列,大幅提升缓存利用率。

所以引擎自己搞内存管理,核心目标就三个:降低分配开销、减少碎片、提升缓存局部性。下面我按从简单到复杂的顺序,把几种常见的分配器方案讲一遍。

2.2 线性分配器:最简单也最快的方案

线性分配器(Linear Allocator)是所有分配器里最简单的一种。它的思路极其朴素:预先申请一大块内存,然后维护一个偏移指针,每次分配就把指针往后挪,释放的时候要么全部重置,要么什么都不做。

class LinearAllocator { public: LinearAllocator(size_t size) { m_start = static_cast<char*>(malloc(size)); m_offset = 0; m_capacity = size; } void* Allocate(size_t size, size_t alignment = 8) { size_t alignedOffset = (m_offset + alignment - 1) & ~(alignment - 1); if (alignedOffset + size > m_capacity) return nullptr; void* ptr = m_start + alignedOffset; m_offset = alignedOffset + size; return ptr; } void Reset() { m_offset = 0; } private: char* m_start; size_t m_offset; size_t m_capacity; };

这个分配器的分配操作就是几次算术运算,快到飞起。但它有个硬伤:不支持单独释放。你只能一次性 Reset 掉所有分配。所以它适合那种"一整帧用完就全清"的场景,比如每帧的临时数据、渲染命令缓冲、粒子系统的临时数组。

我实测下来,线性分配器在帧内临时数据上的表现非常稳,分配开销几乎可以忽略。但千万别拿它做长期存活对象的管理,不然内存会一直涨到爆。

注意:线性分配器一定要配合"帧结束统一 Reset"的机制使用,否则就是内存泄漏制造机。我建议在引擎主循环的末尾加一个明确的 Reset 调用点,让所有临时分配器在这里统一清理。

2.3 栈分配器:线性分配器的可回退版本

栈分配器(Stack Allocator)在线性分配器的基础上加了一个"标记回退"的能力。你可以记录当前偏移位置作为一个 Mark,然后分配一堆东西,最后回退到这个 Mark,把中间分配的全部释放掉。

class StackAllocator { public: using Marker = size_t; Marker GetMarker() const { return m_offset; } void FreeToMarker(Marker marker) { m_offset = marker; } // Allocate 同线性分配器 };

这个方案比线性分配器灵活一点,适合那种"进入某个作用域分配一批数据,离开作用域全部释放"的场景。比如加载一个关卡时分配的资源,关卡卸载时统一释放。它的分配速度依然极快,而且支持嵌套的作用域管理。

实际用的时候,我习惯把它和 RAII 结合起来,写一个 ScopedStackAlloc 的辅助类,构造时记录 Marker,析构时自动 FreeToMarker。这样就不用担心忘记回退导致内存泄漏。

2.4 池分配器:固定大小对象的最优解

游戏里大量对象是固定大小的,比如子弹、粒子、敌人实例。这种场景用池分配器(Pool Allocator)最合适。它的思路是:把一大块内存切成等大的小块,用一个空闲链表串起来,分配时从链表头取一块,释放时把块塞回链表头。

class PoolAllocator { public: PoolAllocator(size_t blockSize, size_t blockCount) { m_blockSize = std::max(blockSize, sizeof(void*)); m_blockCount = blockCount; m_memory = malloc(m_blockSize * blockCount); // 初始化空闲链表 m_freeList = nullptr; for (size_t i = 0; i < blockCount; ++i) { void* block = static_cast<char*>(m_memory) + i * m_blockSize; *reinterpret_cast<void**>(block) = m_freeList; m_freeList = block; } } void* Allocate() { if (!m_freeList) return nullptr; void* block = m_freeList; m_freeList = *reinterpret_cast<void**>(block); return block; } void Free(void* ptr) { *reinterpret_cast<void**>(ptr) = m_freeList; m_freeList = ptr; } private: void* m_memory; void* m_freeList; size_t m_blockSize; size_t m_blockCount; };

池分配器的分配和释放都是 O(1),而且完全没有碎片问题,因为所有块大小一样。它的内存局部性也很好,因为所有块都在一块连续内存里。缺点是只能管理固定大小的对象,不同大小的对象需要不同的池。

我一般会给引擎里每种高频创建的对象类型配一个池,比如 BulletPool、ParticlePool、EntityPool。池的大小根据场景预估,宁可稍微大一点也不要频繁扩容。

2.5 分配器选型对照表

分配器类型分配速度释放灵活性碎片风险适用场景
线性分配器极快只能全部重置无帧内临时数据
栈分配器极快支持标记回退无作用域内批量数据
池分配器快支持单独释放无固定大小高频对象
通用堆分配慢完全灵活高低频、大小不定

选型的时候先问自己三个问题:这个内存要活多久?大小固定吗?分配频率高吗?答案清楚了,方案基本就定了。

3. 对象系统与句柄:让对象安全地生老病死

3.1 裸指针的三大罪状

对象系统要解决的核心问题是:游戏里成千上万的对象,怎么被安全地引用和销毁。最直觉的做法是用裸指针,但裸指针有三个致命问题。

第一是悬空指针。你 delete 了一个对象,但别的地方还存着它的指针,再访问就是未定义行为,轻则崩溃重则数据错乱。第二是生命周期管理混乱。谁负责创建、谁负责销毁、什么时候销毁,没有统一规则的话,代码会变成一团乱麻。第三是内存局部性差。每个对象单独 new 出来,在堆里散落各处,遍历时缓存命中率低。

解决这些问题的常见方案有两种:引用计数和句柄系统。引用计数大家比较熟,shared_ptr 就是典型代表,但它有循环引用和原子操作开销的问题。句柄系统则是游戏引擎里更常用的方案,下面重点讲。

3.2 句柄系统的设计思路

句柄(Handle)本质上是一个索引加版本号的组合。对象实际存储在一个连续的数组里,句柄只存数组下标和版本号。访问对象时,先用下标找到槽位,再校验版本号是否匹配,匹配才返回对象指针。

struct Handle { uint32_t index : 24; uint32_t version : 8; }; template<typename T> class ObjectPool { public: Handle Create() { uint32_t index; if (!m_freeIndices.empty()) { index = m_freeIndices.back(); m_freeIndices.pop_back(); } else { index = m_slots.size(); m_slots.emplace_back(); } Slot& slot = m_slots[index]; slot.version++; slot.object = new T(); slot.alive = true; return Handle{index, slot.version}; } T* Get(Handle handle) { if (handle.index >= m_slots.size()) return nullptr; Slot& slot = m_slots[handle.index]; if (!slot.alive || slot.version != handle.version) return nullptr; return slot.object; } void Destroy(Handle handle) { if (handle.index >= m_slots.size()) return; Slot& slot = m_slots[handle.index]; if (!slot.alive || slot.version != handle.version) return; delete slot.object; slot.object = nullptr; slot.alive = false; m_freeIndices.push_back(handle.index); } private: struct Slot { T* object = nullptr; uint32_t version = 0; bool alive = false; }; std::vector<Slot> m_slots; std::vector<uint32_t> m_freeIndices; };

这个设计的精妙之处在于版本号。当你销毁一个对象后,槽位的版本号会递增。如果别处还存着旧句柄,用它访问时版本号对不上,就会返回 nullptr,而不是访问到已经被复用的槽位里的新对象。这就从根本上杜绝了悬空指针问题。

3.3 句柄系统的实操要点

实际用的时候有几个细节要注意。版本号位宽要选好,8 位版本号意味着同一个槽位被复用 256 次后版本号会回绕,理论上存在误判可能。如果你的场景对象创建销毁极其频繁,建议用 16 位甚至 32 位版本号。我一般用 24 位索引加 8 位版本号,能支持 1600 万个对象和 256 次复用,对大多数项目够用了。

空闲索引的管理用 vector 当栈就行,销毁时 push,创建时 pop。这样能保证索引复用是 LIFO 的,最近销毁的槽位最先被复用,缓存局部性更好。

对象存储方式有两种选择:存指针还是存对象本身。存指针的话,对象还是在堆上散落,缓存局部性没改善。存对象本身的话,槽位数组会很大,而且对象类型必须可移动或者用 placement new。我一般用后者配合 placement new,把对象直接构造在槽位内存里,这样遍历时缓存命中率最高。

提示:句柄系统的一个常见坑是"句柄在跨帧使用时失效"。比如你这一帧存了个句柄,下一帧对象被销毁了,再用就返回 nullptr。这其实是正确行为,但如果你没做空指针检查就会崩。我的习惯是每次用句柄取对象后都判空,养成肌肉记忆。

3.4 对象系统的生命周期管理

有了句柄系统,对象的生命周期管理就清晰多了。我一般把对象分成三类:持久对象(整个游戏生命周期存活,比如全局管理器)、场景对象(随场景加载卸载,比如关卡里的实体)、临时对象(帧内或短时间存活,比如特效)。

持久对象用单例或者全局服务定位器管理,场景对象用场景管理器统一创建销毁,临时对象用对象池加帧末清理。这样每类对象都有明确的归属,不会出现"这个对象到底谁负责销毁"的扯皮。

销毁时机也很关键。我踩过的坑是:在遍历对象列表的过程中销毁对象,导致迭代器失效或者漏遍历。解决方案是延迟销毁——把要销毁的对象句柄先记到一个待销毁列表里,遍历结束后统一处理。这个模式在引擎里非常常用,值得养成习惯。

4. 事件系统与模块通信:解耦的关键

4.1 为什么模块之间不能直接调用

刚开始写引擎的时候,我图省事让渲染模块直接调用物理模块的接口拿位置数据,物理模块又直接调用音频模块播放音效。写着写着就发现,改一个模块的接口,其他模块全要跟着改,编译依赖也乱成一团。这就是典型的紧耦合问题。

事件系统的作用就是把这些直接调用改成"发布-订阅"模式。物理模块不直接调用音频模块,而是发布一个"碰撞发生"事件,音频模块订阅这个事件,收到后自己决定播什么音效。这样两个模块互不认识,只认识事件类型,耦合度大幅降低。

4.2 事件系统的核心设计

一个基础的事件系统包含三部分:事件类型、事件数据、订阅者列表。事件类型用枚举或者字符串标识,事件数据用结构体承载,订阅者列表存回调函数。

using EventType = uint32_t; struct Event { EventType type; void* data; }; class EventBus { public: using Callback = std::function<void(const Event&)>; void Subscribe(EventType type, Callback callback) { m_subscribers[type].push_back(std::move(callback)); } void Publish(const Event& event) { auto it = m_subscribers.find(event.type); if (it == m_subscribers.end()) return; for (auto& cb : it->second) { cb(event); } } private: std::unordered_map<EventType, std::vector<Callback>> m_subscribers; };

这个最简版本已经能解决大部分解耦需求。但实际用的时候有几个问题要处理。

性能问题:std::function 有堆分配和间接调用的开销,高频事件(比如每帧的碰撞检测)用它会拖慢性能。优化方案是用函数指针加 void* 上下文,或者用模板化的静态分发。我一般对高频事件用专门的快速通道,低频事件才走通用 EventBus。

订阅者生命周期:如果订阅者对象被销毁了,但回调还挂在 EventBus 上,事件触发时就会访问已销毁对象。解决方案是订阅时返回一个订阅句柄,销毁时取消订阅。或者用弱引用包装订阅者,触发时检查是否还有效。

事件顺序:多个订阅者订阅同一事件时,触发顺序是不确定的。如果业务逻辑依赖顺序,需要显式指定优先级。我一般给订阅接口加个 priority 参数,内部按优先级排序。

4.3 事件系统的实操心得

我实际用下来,事件系统最大的价值不是性能,而是让代码结构变清晰。以前模块之间互相调用,看代码要跳来跳去,现在只要看事件定义就知道模块之间怎么交互的。

但事件系统也不能滥用。我的经验是:跨模块通信用事件,模块内部通信用直接调用。如果一个模块内部也到处发事件,那调试起来会非常痛苦,因为调用链被事件系统隐藏了,断点都不好打。

还有一个坑是事件风暴。有次我写了个"对象位置变化"事件,结果每帧几万个对象位置变化,事件系统直接被冲垮。后来改成批量事件,一帧发一次包含所有变化对象的数组,性能立刻回来了。所以高频、大批量的数据传递,不要用逐个事件,要用批量事件。

注意:事件系统调试的时候,建议加一个事件日志开关,把事件的发布和订阅都打出来。排查"为什么这个事件没触发"或者"为什么触发了多次"的时候,日志比断点好用得多。

5. 常见问题与排查技巧实录

5.1 内存问题排查速查表

现象可能原因排查方法解决方案
帧率周期性下降内存碎片导致分配变慢打印每帧分配耗时改用池分配器或线性分配器
内存持续增长不释放分配器未 Reset 或对象未销毁用内存快照对比检查 Reset 调用点和对象生命周期
随机崩溃在对象访问处悬空指针或句柄版本不匹配加句柄校验和空指针检查统一用句柄系统,禁用裸指针
缓存命中率低对象在堆中散落用性能分析工具看缓存 miss用连续数组存储对象
事件触发顺序错乱订阅者顺序不确定打印订阅和触发日志给订阅加优先级

5.2 我踩过的几个典型坑

坑一:线性分配器忘记 Reset。有次我写了个临时字符串分配器,用线性分配器实现,结果忘了在帧末 Reset,内存一路涨到几个 G 才被发现。后来我强制要求所有线性分配器必须绑定一个明确的生命周期,要么帧末 Reset,要么作用域结束 Reset,不允许裸用。

坑二:句柄版本号回绕。早期我用 4 位版本号,结果在一个高频创建销毁粒子的场景里,版本号很快回绕,导致旧句柄误判为有效,访问到了新对象。后来改成 8 位,再后来对高频对象直接用池分配器加代际索引,彻底避免这个问题。

坑三:事件回调里销毁订阅者。有次在事件回调里销毁了订阅者对象,结果 EventBus 继续遍历订阅者列表时访问了已销毁对象,直接崩溃。解决方案是事件发布时先拷贝一份订阅者列表,或者用延迟销毁机制。

坑四:对象池扩容时的句柄失效。早期对象池用 vector 存槽位,扩容时 vector 重新分配内存,所有槽位地址变了,但句柄里存的是索引不是地址,所以句柄本身没失效。但如果代码里缓存了对象指针,扩容后就悬空了。所以我的规则是:永远不缓存对象指针,只缓存句柄。

5.3 性能优化的几个实用技巧

批量分配:如果需要创建大量同类型对象,不要一个个 Create,而是提供 BatchCreate 接口,一次性分配好所有槽位。这样能减少锁竞争和函数调用开销。

内存对齐:对象存储时注意对齐要求,特别是 SIMD 类型需要 16 字节或 32 字节对齐。分配器要支持指定对齐参数,否则可能出现性能下降甚至崩溃。

冷热数据分离:把频繁访问的数据(比如位置、速度)和很少访问的数据(比如名字、描述)分开存储。遍历时只加载热数据,缓存利用率更高。这个技巧在实体组件系统(ECS)里用得很多。

预分配加扩容策略:对象池和事件订阅列表都建议预分配一个合理初始容量,避免频繁扩容。扩容时按 1.5 倍或 2 倍增长,摊还下来每次插入的开销是 O(1)。

6. 基础系统搭建的实操路线建议

如果你现在准备动手写自己的引擎基础系统,我建议按这个顺序来,不要跳步。

第一步:先写一个最简单的线性分配器。不用考虑太多,能分配能 Reset 就行。用它来管理帧内临时数据,感受一下自己管理内存和用 malloc 的区别。

第二步:实现对象池加句柄系统。先支持固定大小对象,把 Create、Get、Destroy 三个接口跑通。写几个测试用例,验证悬空句柄返回 nullptr、版本号正确递增、槽位正确复用。

第三步:搭一个最简事件总线。用 std::function 实现就行,先跑通发布订阅流程。然后写两个假模块,一个发事件一个收事件,验证解耦效果。

第四步:把这三个系统串起来。让对象池用线性分配器做底层内存,让事件总线用句柄引用对象。这时候你会发现系统之间的配合开始产生化学反应。

第五步:加日志和调试工具。给分配器加统计信息(分配次数、峰值内存、碎片率),给句柄系统加存活对象计数,给事件总线加触发日志。这些工具在后期排查问题时价值巨大。

我个人的体会是,基础系统这东西,一开始多花一周时间设计好,后面能省下一个月填坑的时间。而且基础系统的设计能力,是区分"会写引擎"和"只会用引擎"的关键分水岭。你把这几个系统吃透了,再看任何引擎源码都会觉得亲切很多。

最后分享一个小技巧:写基础系统的时候,养成写单元测试的习惯。内存分配器的边界条件、句柄的版本回绕、事件的订阅取消,这些用测试覆盖住,后面改代码才敢改。我早期不写测试,改一行分配器代码要手动跑半天场景验证,效率极低。后来补上测试,改完跑一遍测试几秒钟就知道有没有问题,幸福感提升巨大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询