1. 引擎基础架构到底在解决什么问题
很多人第一次翻开引擎源码,看到的是满屏的MemoryManager、Object、RTTI、Handle,然后就开始一行行读实现,读了三天还在Allocator里打转。我自己早年也这么干过,结果就是知道每个类怎么写的,但完全说不清它们为什么长这样。后来带团队做自研引擎,被内存泄漏和对象生命周期问题反复折磨之后才想明白一件事:引擎基础架构不是一堆工具类的集合,它是一套对"资源"和"生命周期"的约束系统。
你写一个游戏,本质上是在做两件事:申请资源、释放资源。渲染要显存,逻辑要堆内存,音频要缓冲区,物理要临时向量。如果每个系统各自new、各自delete,项目跑到中期必然出现三类问题:内存碎片导致大块分配失败、对象释放顺序错误导致悬空指针、跨模块引用无法追踪导致泄漏。基础架构存在的意义,就是在这些系统之上建立一层统一的规则,让资源的申请、持有、释放都有迹可循。
这一篇我打算把引擎基础架构拆成几个真正影响后续所有模块的部分来讲:内存管理、对象模型、数据结构选型、以及它们之间的耦合关系。不堆概念,重点讲每个设计决策背后的取舍,以及我在实际项目中踩过的坑。适合正在学引擎、准备自研引擎、或者想从"会用引擎"进阶到"理解引擎"的开发者。读完你应该能回答一个核心问题:为什么商业引擎的基础层看起来那么"重",而这份"重"到底换来了什么。
2. 内存管理:引擎基础架构里最先该定下来的事
2.1 为什么不能直接用 malloc 和 new
新手最容易问的一句话是:C++ 都有new了,为什么引擎还要自己写一套内存管理?我拿一个真实场景回答你。假设你的游戏一帧内要创建 2000 个粒子对象,每个对象 64 字节,用new逐个分配。跑起来你会发现帧率不稳,用性能分析工具一看,大量时间花在malloc内部的锁竞争和空闲链表遍历上。更麻烦的是,这些粒子对象大小不一、生命周期长短不一,跑几分钟之后堆内存就被切得七零八落,这时候你想申请一块连续的 4MB 显存映射缓冲区,系统告诉你没有连续空间了——明明总空闲内存还有几百 MB。
引擎自研内存管理的第一个目标就是把"通用分配"变成"专用分配"。做法是按用途划分内存池:粒子用一个固定大小块的内存池,每块 64 字节,分配和释放就是从一个空闲链表里摘一个节点、还一个节点,O(1) 且无碎片;场景对象用另一个池;临时计算用栈式分配器,一帧结束整体回退。这样每个池内部块大小一致,永远不会产生外部碎片。
第二个目标是可追踪。自研分配器可以在每块内存头部加一段元信息,记录分配的文件、行号、大小、所属系统。线上崩溃时导出这份数据,你能立刻知道是哪块内存被谁申请、有没有被释放。这个能力在项目后期排查泄漏时价值极高,new是给不了你的。
2.2 分配器的分层设计
一个能落地的引擎内存架构通常是分层的,我按从下到上的顺序说。
最底层是系统分配器,直接封装操作系统的虚拟内存接口,负责向系统要一大块地址空间。这一层只做一件事:拿到大块内存,不关心怎么切分。它存在的意义是减少和操作系统打交道的次数,因为每次系统调用都是昂贵的。
中间层是池分配器和栈分配器。池分配器管理固定大小的块,适合生命周期零散、大小一致的对象;栈分配器管理一块线性增长的内存,只支持"标记-回退"两种操作,适合一帧内用完就丢的临时数据。这两者覆盖了引擎里 80% 的分配需求。
最上层是对象分配器,它知道对象的类型信息,能在分配内存的同时调用构造函数、在释放时调用析构函数,并且把分配记录登记到追踪系统里。
// 一个极简的池分配器骨架,说明核心思路 class PoolAllocator { struct FreeNode { FreeNode* next; }; FreeNode* freeList = nullptr; void* memoryBlock = nullptr; size_t blockSize; size_t blockCount; public: PoolAllocator(size_t blockSize, size_t count) : blockSize(blockSize), blockCount(count) { // 一次性申请整块内存,避免多次系统调用 memoryBlock = ::operator new(blockSize * count); // 把所有块串成空闲链表 char* p = static_cast<char*>(memoryBlock); for (size_t i = 0; i < count; ++i) { FreeNode* node = reinterpret_cast<FreeNode*>(p + i * blockSize); node->next = freeList; freeList = node; } } void* Allocate() { if (!freeList) return nullptr; // 池耗尽,需要上层处理 FreeNode* node = freeList; freeList = freeList->next; return node; } void Deallocate(void* ptr) { FreeNode* node = static_cast<FreeNode*>(ptr); node->next = freeList; freeList = node; } };这段代码的关键点在于:分配和释放都只操作链表指针,没有任何查找和合并操作。这就是池分配器快的原因。代价是块大小固定、池容量固定,所以它只适合特定场景,不能当通用分配器用。
2.3 内存对齐这个坑,几乎每个人都踩过
我见过太多项目在 x86 上跑得好好的,一上 ARM 设备就崩溃,最后定位到内存对齐。这里必须说清楚。
CPU 访问内存不是按字节读的,而是按字长读的。一个 4 字节的int,如果它的起始地址不是 4 的倍数,在某些架构上会直接触发硬件异常,在另一些架构上会静默地读两次再拼接,性能掉一半。引擎里对象大小各异,如果你自己管理内存却不做对齐处理,迟早出事。
对齐规则很简单:任何类型的起始地址必须是它自身大小的整数倍。int对齐到 4,double对齐到 8,含double的结构体对齐到 8。分配器在切分内存块时,必须把每块的起始地址向上取整到对齐边界。
// 向上对齐的通用写法,mask 必须是 2 的幂减一 inline size_t AlignUp(size_t value, size_t alignment) { return (value + alignment - 1) & ~(alignment - 1); }注意:对齐参数必须是 2 的幂,否则位运算会出错。如果你不确定某个平台的对齐要求,用
alignof(T)查询,不要硬编码。
还有一个隐蔽的坑:池分配器的块大小必须是对齐值的整数倍。比如你要存 12 字节的对象,对齐到 8,那么每块实际占用应该是 16 字节而不是 12,否则第二块的起始地址就不是 8 的倍数了。这个细节在写池分配器时如果漏掉,跑一段时间就会随机崩溃,而且极难定位。
2.4 内存追踪与泄漏定位的实操方法
光有分配器还不够,你得知道谁在分配。我的做法是在调试构建里给每块内存加一个头部,记录分配序号、大小、文件、行号、所属标签。
struct AllocHeader { uint32_t magic; // 校验值,检测越界写 uint32_t size; uint32_t allocId; const char* file; uint32_t line; const char* tag; // 如 "Render", "Physics" };分配时在返回给用户的指针前面留出头部空间,释放时通过指针偏移找回头部。magic字段用来检测缓冲区越界——如果释放时 magic 不对,说明有人写坏了头部,立刻报错。
线上排查泄漏时,我通常按标签统计当前存活的内存块数量和总字节数,每隔一段时间打印一次。如果某个标签的数量持续增长不回落,基本可以锁定泄漏来源。这个方法比事后用工具扫描堆快照要直接得多,因为它能告诉你"哪个系统在漏",而不只是"漏了多少"。
3. 对象模型:引擎里所有东西的公共底座
3.1 为什么引擎需要自己的对象系统
C++ 本身有类、有继承、有虚函数,为什么引擎还要再搞一套对象系统?因为游戏对象有几个 C++ 原生机制满足不了的需求。
第一是运行时类型识别。你需要根据一个字符串名字创建对象(比如从关卡文件里读到 "Enemy" 就创建敌人),需要在运行时判断"这个对象是不是可渲染的",需要遍历场景里所有"可序列化"的对象。C++ 的dynamic_cast能做一部分,但它依赖 RTTI,性能开销大,而且无法从字符串反查类型。
第二是统一的生命周期管理。游戏对象不能随便delete,因为可能还有别的系统持有它的引用。你需要引用计数、需要延迟销毁、需要"标记为待删除但本帧仍然有效"这种语义。
第三是序列化与反射。编辑器要能显示对象的属性、要能保存和加载场景,这要求对象系统能枚举自己的成员变量。C++ 原生做不到,必须靠宏或代码生成来补充元信息。
所以引擎对象系统的本质是:在 C++ 类型系统之上,叠加一层运行时可查询、可创建、可序列化的元数据层。
3.2 类型注册与反射的落地方式
实现反射有两条主流路线:宏手写和代码生成。宏手写灵活但啰嗦,代码生成干净但需要额外的构建步骤。我两种都用过,中小项目建议先用宏,等类型数量上来了再考虑代码生成。
宏方案的核心是给每个类注册一个静态的TypeInfo,里面存类名、父类指针、成员列表、创建函数。
#define REGISTER_TYPE(ClassName, ParentClass) \ static TypeInfo s_typeInfo_##ClassName( \ #ClassName, \ &ParentClass::GetStaticTypeInfo(), \ []() -> Object* { return new ClassName(); } \ ); \ const TypeInfo& ClassName::GetStaticTypeInfo() { \ return s_typeInfo_##ClassName; \ } \ virtual const TypeInfo& GetTypeInfo() const override { \ return s_typeInfo_##ClassName; \ }有了这套机制,你就能实现TypeInfo::CreateInstance()按名字造对象,能实现IsA()判断继承关系,能遍历类型树做编辑器面板。成员变量的注册稍微复杂一点,需要为每个属性存一个"读写函数指针",这样序列化系统就能不依赖具体类型地读写任意属性。
提示:反射的成员注册不要一开始就追求完美。先把类名和创建函数注册好,成员属性等编辑器需求明确了再加,否则会写一堆用不上的样板代码。
3.3 对象句柄与生命周期管理
直接传对象指针在引擎里是危险的,因为对象随时可能被销毁。我推荐用句柄代替裸指针。句柄是一个包含索引和版本号的结构,索引指向对象池里的槽位,版本号用来检测"这个槽位是否已经被复用"。
struct ObjectHandle { uint32_t index; uint32_t version; bool IsValid() const { // 通过全局对象池校验 index 和 version 是否匹配 return ObjectPool::Get().IsValid(*this); } };对象销毁时,槽位的版本号加一,所有旧句柄自动失效。这样即使某个系统还持有已销毁对象的句柄,调用IsValid()也会返回 false,不会访问到野内存。这个设计比引用计数更轻量,也比裸指针安全得多。
对象池本身用前面讲的池分配器管理,槽位固定大小,销毁的对象槽位回收到空闲链表。版本号用uint32_t,理论上跑 40 亿次复用才会回绕,实际项目里完全够用。
3.4 组件化与继承的取舍
对象模型设计里争论最多的是:用继承树还是用组件。继承树直观,Actor -> Pawn -> Character一眼看懂;组件灵活,一个对象挂什么能力就有什么能力。
我的经验是:核心层级用继承,能力扩展用组件。比如"是否可渲染""是否有碰撞"这种横切能力,用组件;而"玩家""敌人""NPC"这种有明确分类的,用继承。纯组件方案在小型项目里很优雅,但对象数量上千之后,组件查找和通信的开销会变得明显,而且调试时很难一眼看出一个对象到底有什么。
实际落地时,对象基类只保留最基础的东西:类型信息、句柄、生命周期状态、组件容器。具体能力全部下沉到组件。这样基类稳定,不会因为加功能而频繁改动,编译依赖也小。
4. 数据结构选型:引擎里没有"随便用用"的容器
4.1 为什么标准库容器在引擎里要慎用
std::vector、std::map、std::unordered_map在业务代码里很好用,但在引擎核心路径上要谨慎。原因有三个。
第一是内存分配不可控。std::vector扩容时会重新分配并拷贝,这个分配走的是全局new,绕过了你的内存池,追踪系统看不到它。项目后期统计内存时,这部分"隐形分配"经常是漏网之鱼。
第二是性能不可预测。std::unordered_map的哈希函数和桶数量由实现决定,不同平台表现不同,而且它每个节点单独分配,缓存局部性差。引擎里遍历频繁的容器,缓存命中率直接决定帧率。
第三是接口不匹配。引擎需要"从池里分配节点的链表""不抛异常的数组""支持内存对齐的容器",标准库要么不提供,要么需要包装。
所以成熟引擎通常自研一套容器,接口模仿标准库但底层接自己的分配器。这不是重复造轮子,是为了把内存和性能的控制权拿回来。
4.2 数组、链表、哈希表的实际选择依据
选容器不要看"哪个高级",要看访问模式。我总结了一张表,是我实际项目里最常用的判断依据。
| 访问模式 | 推荐容器 | 原因 |
|---|---|---|
| 频繁随机访问、尾部增删 | 动态数组 | 连续内存,缓存友好,O(1) 索引 |
| 频繁中间插入删除、遍历为主 | 侵入式链表 | 节点内嵌指针,无额外分配,O(1) 增删 |
| 按 key 快速查找、遍历少 | 开放寻址哈希表 | 连续存储,无节点分配,缓存友好 |
| 需要有序遍历、范围查询 | 平衡树或跳表 | 有序性保证,O(log n) 操作 |
| 一帧内临时收集 | 栈式数组 | 帧结束整体回退,零释放开销 |
这里重点说侵入式链表,因为它是引擎里最被低估的结构。普通链表每个节点单独分配,节点和数据的生命周期分离;侵入式链表把next、prev指针直接放进你的对象里,对象本身就在链表上,增删只是改指针,没有任何分配。场景里的实体列表、渲染队列、待更新对象列表,用侵入式链表都非常合适。
struct IntrusiveNode { IntrusiveNode* next = nullptr; IntrusiveNode* prev = nullptr; }; // 你的对象继承这个节点,就自动能挂到链表上 class GameObject : public IntrusiveNode { // ... };代价是同一个对象同一时间只能在一个侵入式链表里(除非加多组指针),但这个限制在大多数场景下不是问题。
4.3 缓存友好性:被忽视的性能杀手
我做过一个实测:遍历 10000 个对象,用std::vector<Object*>(指针数组)和std::vector<Object>(值数组),后者比前者快将近 3 倍。原因就是缓存。指针数组里每个指针指向堆上分散的对象,每次访问都要跳一次内存,缓存命中率极低;值数组里对象连续排列,一次缓存行加载能带出好几个对象。
这个结论对引擎设计影响很大:热数据要连续存放。渲染时遍历的变换矩阵、物理时遍历的刚体状态、动画时遍历的骨骼数据,都应该用连续数组存,而不是对象指针数组。这就是所谓"面向数据"的思路,也是现代引擎架构演进的方向。
具体做法是把"组件数据"和"组件逻辑"分离。数据存在紧凑的数组里,逻辑系统按数组顺序批量处理。这样既保证了缓存友好,又避免了虚函数调用的开销。
注意:面向数据不是银弹。对象数量少、逻辑复杂的场景,传统面向对象更清晰。我的建议是热点路径面向数据,冷路径保持面向对象,不要为了架构纯粹性牺牲可读性。
4.4 自定义容器的接口设计要点
自研容器要模仿标准库的接口,降低学习成本,但有几个地方必须改。
第一,分配器作为模板参数或构造参数,让容器能接不同的内存池。第二,不抛异常,分配失败返回空或断言,因为引擎里异常处理代价高且不可控。第三,提供Reserve和Resize的明确语义,避免隐式扩容。第四,迭代器要简单,最好就是指针,方便调试和优化。
template<typename T> class DynamicArray { T* data = nullptr; size_t size = 0; size_t capacity = 0; IAllocator* allocator = nullptr; public: void Reserve(size_t newCapacity) { if (newCapacity <= capacity) return; T* newData = static_cast<T*>(allocator->Allocate(newCapacity * sizeof(T))); // 移动而非拷贝,减少开销 for (size_t i = 0; i < size; ++i) { new (&newData[i]) T(std::move(data[i])); data[i].~T(); } allocator->Deallocate(data); data = newData; capacity = newCapacity; } // ... };这段代码里placement new和显式析构调用是关键,它把"内存管理"和"对象构造"分开,这正是引擎容器和标准库容器在实现上的核心差异。
5. 基础架构各模块之间怎么咬合
5.1 内存、对象、容器三者的依赖关系
单独看内存管理、对象模型、容器,每个都不难,难的是让它们协同工作。我画不出图(这里也不画),但可以用文字说清楚依赖方向。
最底层是内存分配器,它不依赖任何东西。往上一层是容器,容器依赖分配器,但不依赖对象模型。再往上是对象模型,对象模型依赖容器(用容器存组件、存类型信息)和分配器(对象从池里分配)。最上层是各个功能系统,它们依赖对象模型。
这个依赖方向很重要:下层绝不能依赖上层。如果哪天你发现分配器里出现了GameObject的字样,说明架构已经腐化了。保持单向依赖,才能让底层模块独立测试、独立替换。
5.2 初始化顺序与关闭顺序的坑
引擎启动时,模块的初始化顺序有严格要求。分配器必须最先初始化,因为后面所有模块都要用它。对象系统要在功能系统之前初始化,因为功能系统要注册类型。关闭时顺序完全相反,最后初始化的最先关闭。
我踩过的坑是:某个系统在关闭时还在访问已经销毁的对象池,导致崩溃。原因是关闭顺序写错了。解决办法是给每个模块一个明确的Initialize和Shutdown,并且在Shutdown里断言"我依赖的模块还没关闭"。这个断言在开发期能帮你抓出大量顺序错误。
class Engine { public: void Initialize() { memorySystem.Initialize(); // 1. 最先 objectSystem.Initialize(); // 2. 其次 renderSystem.Initialize(); // 3. 功能系统 physicsSystem.Initialize(); } void Shutdown() { physicsSystem.Shutdown(); // 逆序关闭 renderSystem.Shutdown(); objectSystem.Shutdown(); memorySystem.Shutdown(); // 最后 } };5.3 跨模块通信的边界设计
基础架构还要解决一个问题:模块之间怎么通信。渲染系统需要知道场景里有哪些对象,物理系统需要知道哪些对象有碰撞体,音频系统需要知道什么时候播放音效。如果让它们互相直接引用,就变成了网状依赖,改一处牵动全身。
我的做法是事件 + 查询接口。模块之间不直接调用,而是通过事件总线发消息,或者通过只读的查询接口拉数据。比如渲染系统每帧向对象系统查询"所有带渲染组件的对象",而不是对象系统主动推给渲染系统。这样渲染系统只依赖对象系统的查询接口,不依赖具体对象类型。
事件总线本身要轻量,不要搞成复杂的消息队列。一个简单的订阅-发布就够了,事件类型用枚举或类型 ID,回调用函数指针或std::function。关键是事件的生命周期要短,一帧内处理完就丢弃,不要堆积。
5.4 调试构建与发布构建的差异管理
基础架构里很多功能只在调试构建里开启:内存追踪、对象句柄校验、容器边界检查、类型断言。发布构建里这些都要关掉,否则性能损失可能达到 30% 以上。
管理这个差异的常见做法是用宏。但宏用多了代码会变得很难读。我的经验是把调试逻辑封装成函数,函数体在发布构建里为空,这样调用点不用改。
inline void ValidateHandle(const ObjectHandle& handle) { #ifdef ENGINE_DEBUG ASSERT(ObjectPool::Get().IsValid(handle), "Invalid object handle"); #endif }发布构建里ValidateHandle是空函数,编译器会直接优化掉,零开销。调试构建里它做完整校验。这样代码只有一份,行为按构建类型切换。
提示:不要用
#ifdef把整个函数体包起来,那样调试和发布走的是两份代码,容易不一致。用空函数内联的方式,保证调用路径一致。
6. 我在基础架构上踩过的几个真实坑
6.1 池分配器的块大小算错导致随机崩溃
前面提过对齐,这里讲一个更隐蔽的。我写过一个对象池,块大小按sizeof(T)算,没考虑对齐。在 x86 上跑了两周没问题,换到移动设备上,某些对象一访问就崩。查了三天才发现,sizeof(T)是 12,但T里有double成员,需要 8 字节对齐,池里第二块的起始地址是 12,不是 8 的倍数。
修复方法很简单:块大小按AlignUp(sizeof(T), alignof(T))算。但这个坑的教训是:任何涉及内存布局的计算,都要显式考虑对齐,不要假设sizeof就是实际占用。
6.2 对象句柄版本号回绕引发的幽灵 bug
句柄的版本号用uint16_t时,复用 65536 次就会回绕。我们的测试场景里有个对象频繁创建销毁,跑了几小时之后,一个早就该失效的句柄突然"复活"了,指向了一个完全不相干的新对象。这种 bug 极难复现,因为要精确跑到回绕那一次。
后来把版本号改成uint32_t,并且加了一条规则:版本号回绕时,整个槽位标记为永久失效,不再复用。虽然浪费一点槽位,但彻底杜绝了这类问题。这个经验告诉我,句柄的版本号宽度要按最坏情况估算,宁可浪费也不要回绕。
6.3 容器扩容时的自引用失效
有个经典 bug:代码里持有DynamicArray内部元素的指针,然后往数组里Push了一个新元素,触发扩容,原来的指针全部失效,后续访问就是野指针。这个问题在标准库std::vector里同样存在,但很多人不知道。
我的应对是:凡是可能扩容的容器,绝不长期持有元素指针,改用索引或句柄。如果确实需要指针稳定,就用不会移动元素的容器,比如侵入式链表或节点式容器。这个规则写进团队规范之后,这类 bug 基本消失了。
6.4 关闭顺序错误导致的退出崩溃
游戏退出时崩溃,是很多项目的通病。原因通常是某个系统在关闭时还在访问已经销毁的资源。我遇到过一次:音频系统在关闭时尝试停止所有正在播放的音效,但对象系统已经先关闭了,音效对象全没了,访问就是野指针。
解决办法除了前面说的逆序关闭,还有一个技巧:关闭时先"静默"再"销毁"。先让所有系统停止接受新任务、停止回调,确认没有跨模块引用之后,再逐个销毁。这样即使顺序有小问题,也不会立刻崩溃。
7. 基础架构演进:什么时候该重构
基础架构不是一次设计好就永远不动的。项目规模变化、平台变化、需求变化,都会逼着你调整。我的判断标准是:当某个问题反复出现三次以上,且每次都要绕路解决时,就该动架构了。
比如内存碎片问题,如果只是偶尔出现,加个池就能缓解;如果每个系统都在抱怨分配失败,说明整体分配策略需要重新设计。再比如对象查找慢,如果只是某个系统慢,优化那个系统就行;如果到处都是查找瓶颈,说明对象索引结构需要升级。
重构基础架构风险很高,因为所有模块都依赖它。我的做法是渐进式替换:新代码用新架构,旧代码保持不动,通过适配层桥接。等旧代码自然淘汰得差不多了,再删掉适配层。这样风险可控,也不会阻塞业务开发。
最后分享一个我坚持了很多年的习惯:给基础架构写测试。分配器的对齐、容器的扩容、句柄的失效、反射的创建,这些都要有单元测试。基础架构的 bug 影响面太大,靠人工测试根本覆盖不过来。测试不用多复杂,能覆盖边界条件就行。这个习惯帮我拦下了至少五次会在线上爆发的严重问题。