尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

游戏引擎基础架构设计:内存管理与数据结构优化实战

游戏引擎基础架构设计:内存管理与数据结构优化实战 1. 引擎基础架构到底在解决什么问题很多人第一次接触游戏引擎注意力都放在渲染效果、物理模拟、动画系统这些“看得见”的模块上觉得把画面做好看了引擎就成功了。但真正在工业级项目里摸爬滚打过几年的人都知道决定一个引擎能不能扛住大型项目、能不能跨平台、能不能让几十号人协同开发的恰恰是那些“看不见”的底层基础架构。引擎基础架构这个词听起来很虚但它要解决的问题非常具体内存怎么分配才不碎片化数据结构怎么设计才能让十万个对象高效遍历模块之间怎么通信才不会牵一发动全身资源怎么加载才能不卡帧。我参与过几个从零搭建的小型引擎项目也深度改造过开源引擎的底层模块踩过的坑基本都集中在基础架构层面。渲染出问题往往一眼能定位但内存泄漏、对象生命周期混乱、模块耦合过深这些问题排查起来动辄几天甚至几周。所以这个系列的第一篇我不打算聊任何花哨的效果实现就老老实实把引擎基础架构拆开讲清楚它包含哪些核心子系统每个子系统背后的设计取舍是什么以及在实际编码中怎么落地。这篇文章适合三类人看。第一类是有一定编程基础、想理解引擎内部运转机制的开发者你可能用过Unity或Unreal但没深究过它们底层怎么组织代码。第二类是正在准备面试、需要系统梳理引擎架构知识点的同学基础架构是高频考点。第三类是自己动手写过小引擎、但总觉得代码越写越乱、想找到规范化设计思路的独立开发者。我会尽量用生活化的类比把复杂概念讲透同时给出可以直接参考的代码结构和参数选择依据。2. 引擎基础架构的整体设计与模块划分2.1 为什么引擎需要分层架构一个游戏引擎本质上是一个极其复杂的软件系统它要同时处理渲染、物理、音频、输入、脚本、资源管理、网络等十几个子系统。如果把这些子系统全部平铺在一起互相调用代码会在几个月内变成一团乱麻。分层架构的核心思路是把功能相近的模块聚在一起层与层之间定义清晰的接口上层依赖下层下层不感知上层。我习惯把引擎基础架构分成四层来理解。最底层是平台抽象层负责屏蔽操作系统差异比如文件读写、线程创建、时间获取Windows和Linux的API完全不同这一层把它们统一成一套接口。往上是核心系统层包含内存管理、数学库、容器与数据结构、日志与断言这些是引擎的“地基”几乎所有其他模块都要用到。再往上是资源与对象层管理游戏对象的生命周期、资源的加载与引用计数、场景图的组织。最上面是功能模块层渲染器、物理引擎、音频系统、脚本虚拟机都在这一层。这样分层的直接好处是当你要把引擎从Windows移植到另一个平台时只需要重写平台抽象层上面的代码一行不用改。当你要替换渲染API时只动功能模块层核心系统层完全不受影响。这就是分层带来的隔离性。2.2 模块间通信的三种典型方案分层之后层与层之间、模块与模块之间怎么通信是基础架构设计里最容易出问题的地方。我见过三种典型方案各有适用场景。第一种是直接函数调用。A模块直接include B模块的头文件调用B的函数。这种方式最简单、性能最好但耦合度最高。一旦B的接口变了所有调用方都要改。适合关系稳定、不会频繁变动的模块比如核心系统层内部各模块之间的调用。第二种是接口抽象加依赖注入。A模块持有一个B模块的抽象接口指针具体实现通过构造函数或初始化函数注入。这样A只依赖接口不依赖实现替换实现时A不用改。渲染器就常用这种方式引擎核心持有一个IRenderer接口具体是OpenGL实现还是其他实现在初始化时决定。第三种是事件系统或消息总线。模块A发出一个事件模块B订阅这个事件两者互不感知对方的存在。这种方式解耦最彻底但调试困难因为事件流不像函数调用那样有清晰的调用栈。适合跨层级的通知类通信比如“资源加载完成”这种事件。我的经验是核心系统层内部用直接调用功能模块层之间用接口抽象跨层级的通知用事件系统。不要一刀切全用事件否则性能和维护成本都会失控。2.3 引擎初始化和关闭的顺序设计基础架构里有一个特别容易被忽视但极其关键的细节初始化和关闭的顺序。引擎启动时必须先初始化内存管理器再初始化日志系统再初始化平台抽象层最后才是各功能模块。关闭时顺序完全反过来。为什么因为日志系统需要内存功能模块需要日志和平台接口。如果顺序搞反轻则日志丢失重则空指针崩溃。我一般会在引擎主类里维护一个模块列表每个模块实现统一的Init()和Shutdown()接口引擎按列表顺序初始化、逆序关闭。这样新增模块时只需要往列表里加一项不用手动调整调用顺序。这个模式在工业级引擎里非常常见值得一开始就设计好。3. 内存管理引擎性能的隐形战场3.1 为什么不能直接用new和delete初学者写引擎对象创建直接用new销毁用delete在小规模测试里完全没问题。但游戏引擎的运行特征和普通应用完全不同它需要在每帧通常16毫秒内创建和销毁大量临时对象比如粒子、碰撞信息、渲染命令。如果每个对象都走系统级的内存分配会产生两个致命问题。第一是性能问题。系统级的内存分配涉及内核态切换、空闲链表查找、内存碎片整理一次分配可能耗时几百纳秒甚至微秒。一帧内如果有几万次分配光分配开销就吃掉了整个帧预算。第二是碎片问题。频繁分配释放不同大小的内存块会导致堆内存变得支离破碎最终即使总空闲内存足够也找不到一块连续的大内存分配失败。所以引擎必须自己管理内存核心思路是一次性向系统申请一大块内存然后自己在这块内存上做分配和回收。这就是内存池的概念。3.2 内存分配器的分层设计我推荐把内存分配器分成三层来设计。最底层是系统分配器直接封装malloc和free只负责向操作系统要内存不处理任何策略。中间层是池分配器针对固定大小的对象做高效分配比如所有粒子对象大小相同就可以用一个粒子池来管理。最上层是通用分配器处理变长内存请求通常用空闲链表或伙伴系统实现。池分配器的原理很简单预先分配一大块内存按固定大小切成若干槽位用一个空闲链表串起来。分配时从链表头取一个槽位释放时把槽位放回链表头。整个过程就是几次指针操作耗时是常数级而且完全不会产生碎片。我实测过池分配器比系统分配快一个数量级以上。通用分配器的实现要复杂一些常见方案有空闲链表法和伙伴系统。空闲链表法在每个空闲块头部存一个指针指向下一个空闲块分配时遍历链表找足够大的块。伙伴系统把内存按2的幂次大小分块分配和释放时做块的合并与分裂。伙伴系统的优点是碎片可控缺点是内部碎片较多。选择哪种取决于你的引擎对内存利用率和分配速度的权衡。3.3 内存对齐与缓存友好性内存管理还有一个容易被忽略但影响巨大的点内存对齐。现代CPU读取内存是按缓存行通常64字节为单位加载的如果一个对象跨越了两个缓存行就需要两次内存访问。更严重的是如果多个线程访问同一缓存行里的不同数据会产生伪共享导致缓存频繁失效性能急剧下降。所以引擎里的对象分配必须做对齐处理。一般规则是对象大小按16字节对齐因为大多数SIMD指令要求16字节对齐。分配器返回的地址也要保证对齐。我通常会在分配函数里加一个断言检查返回地址是否满足对齐要求这样能在开发阶段就发现对齐问题。缓存友好性还体现在数据布局上。面向对象编程习惯把每个对象的属性放在一起但引擎里更高效的做法是结构体数组而不是数组结构体。比如一万个粒子不要定义成Particle particles[10000]而是定义成float positionsX[10000]、float positionsY[10000]这样按属性分开存储。这样遍历位置时缓存里全是有效数据不会把速度、颜色等暂时用不到的属性也加载进来。这个优化在粒子系统和渲染批次处理里效果极其明显。3.4 内存追踪与泄漏排查开发阶段一定要有内存追踪机制。我的做法是在分配器里记录每次分配的大小、文件、行号用一个哈希表存起来。引擎关闭时检查哈希表是否为空不为空就打印出所有未释放的分配及其来源。这个机制在Debug模式下开启Release模式下关闭对性能没有影响。排查内存泄漏时光知道哪里分配了还不够还要知道谁持有的。我通常会在对象基类里加一个引用计数每次AddRef和Release都记录日志。当发现某个对象泄漏时回溯日志就能找到是哪次AddRef没有对应的Release。这套机制帮我定位过好几个隐蔽的循环引用问题。注意内存追踪本身也会分配内存要小心递归调用。我的做法是追踪用的哈希表使用独立的内存池不经过被追踪的分配器。4. 数据结构引擎运转的骨架4.1 引擎中高频使用的数据结构盘点游戏引擎里用到的数据结构种类其实不多但每一种都要求极致优化。我统计过自己项目里的使用频率排在前面的依次是动态数组、哈希表、空闲链表、对象池、场景树、四叉树或八叉树。动态数组是最基础也最常用的。引擎里几乎所有的列表都用它比如渲染命令列表、碰撞对列表、活动对象列表。它的关键是扩容策略。我一般用1.5倍扩容而不是2倍因为1.5倍在多次扩容后更容易复用之前释放的内存块减少碎片。扩容时用memcpy而不是逐个元素拷贝因为引擎里的对象通常是POD类型或者可平凡拷贝的。哈希表主要用于资源查找和对象索引。引擎里资源路径到资源句柄的映射、对象ID到对象指针的映射都靠哈希表。哈希函数的选择很关键我用过FNV-1a和MurmurHash后者分布更均匀但计算稍慢。对于短字符串键FNV-1a足够好。冲突处理我用开放寻址法而不是链地址法因为开放寻址的缓存局部性更好。空闲链表在内存池和对象池里大量使用。它的实现极其简单但要注意ABA问题。如果多个线程同时从空闲链表取节点一个线程刚读到头节点还没来得及更新头指针另一个线程可能已经把该节点取走又放回来了导致第一个线程操作了错误的节点。解决办法是加锁或者用带版本号的指针。4.2 场景图的组织与遍历优化场景图是引擎里最核心的数据结构之一它决定了游戏对象之间的父子关系、变换的继承、渲染的排序。最直观的实现是每个节点存一个子节点列表和一个父节点指针形成一棵多叉树。但这样遍历时指针跳转频繁缓存不友好。我后来改用扁平化数组加索引的方式。所有节点存在一个连续数组里每个节点存父节点索引和子节点索引范围。遍历时按数组顺序访问缓存命中率大幅提升。代价是插入和删除节点时需要移动数组元素但场景图的结构变化通常不频繁这个代价可以接受。遍历顺序也有讲究。渲染时通常需要按材质排序以减少状态切换但变换更新需要按父子顺序。我的做法是维护两个遍历顺序一个按层级顺序用于变换更新一个按材质排序用于渲染提交。两个顺序在场景图结构变化时重建平时直接复用。4.3 空间划分结构的选型碰撞检测和视锥剔除都需要空间划分结构来加速查询。常见的有四叉树、八叉树、BVH和网格。选哪种取决于场景特征。四叉树适合平面分布均匀的场景比如2D游戏或地形平坦的3D场景。八叉树适合三维空间分布均匀的场景。BVH适合对象大小差异大、分布不均匀的场景因为它按对象包围盒来划分自适应性强。均匀网格适合对象大小相近、分布密集的场景查询是常数时间但内存占用大。我在实际项目里最常用的是BVH因为它的自适应性和查询效率平衡得最好。构建BVH时用SAH代价模型来选择分割平面虽然构建慢一些但查询快很多。对于动态对象我用增量更新的方式每帧只更新移动过的对象所在的节点而不是重建整棵树。4.4 数据结构的线程安全考量现代引擎都是多线程的主线程负责逻辑工作线程负责渲染提交、物理计算、资源加载。数据结构如果不做线程安全处理会出现各种诡异的数据竞争问题。我的原则是能不共享就不共享。每个线程有自己的临时数据区线程间通过消息队列传递数据。必须共享的数据结构读多写少的用读写锁读写都频繁的用无锁队列或者分片锁。分片锁是把一个哈希表分成多个桶每个桶一把锁不同线程操作不同桶时互不阻塞。无锁队列的实现要小心内存序问题。C里用std::atomic和合适的内存序标记比如生产者用memory_order_release消费者用memory_order_acquire。我见过有人用默认的memory_order_seq_cst性能差很多其实大部分场景用acquire-release就够了。5. 实操搭建一个最小可用的引擎基础框架5.1 项目目录结构与构建系统说了这么多理论接下来动手搭一个最小可用的基础框架。目录结构我习惯这样组织Engine/ Source/ Core/ // 内存管理、数学库、容器 Platform/ // 平台抽象层 Resource/ // 资源管理 Scene/ // 场景与对象 Renderer/ // 渲染器 ThirdParty/ // 第三方库 Build/ // 构建脚本构建系统我用CMake因为它跨平台支持好而且能方便地管理多个子模块。每个子模块一个CMakeLists.txt顶层CMakeLists负责组织依赖关系。关键是要把平台相关的源文件用条件编译区分开比如Platform/Windows/和Platform/Linux/CMake根据当前平台选择编译哪部分。5.2 核心内存分配器的代码实现先实现一个最简单的池分配器。假设我们要管理固定大小的粒子对象每个对象64字节。class PoolAllocator { public: PoolAllocator(size_t objectSize, size_t objectCount) : mObjectSize(objectSize), mObjectCount(objectCount) { mBlockSize objectSize sizeof(void*) ? sizeof(void*) : objectSize; mMemory malloc(mBlockSize * objectCount); // 构建空闲链表 mFreeList mMemory; char* p static_castchar*(mMemory); for (size_t i 0; i objectCount - 1; i) { *reinterpret_castvoid**(p) p mBlockSize; p mBlockSize; } *reinterpret_castvoid**(p) nullptr; } void* Allocate() { if (!mFreeList) return nullptr; void* result mFreeList; mFreeList *reinterpret_castvoid**(mFreeList); return result; } void Deallocate(void* ptr) { *reinterpret_castvoid**(ptr) mFreeList; mFreeList ptr; } private: void* mMemory nullptr; void* mFreeList nullptr; size_t mObjectSize; size_t mObjectCount; size_t mBlockSize; };这段代码的关键点mBlockSize至少要是sizeof(void*)因为空闲链表需要在空闲块里存下一个块的地址。分配和释放都是O(1)没有任何系统调用。实测下来分配一百万次耗时不到一毫秒。5.3 动态数组的实现与扩容策略动态数组是使用频率最高的容器我给它加上1.5倍扩容和移动语义支持。templatetypename T class DynamicArray { public: void PushBack(const T value) { if (mSize mCapacity) { Grow(); } mData[mSize] value; } void Grow() { size_t newCapacity mCapacity 0 ? 8 : mCapacity * 3 / 2; T* newData static_castT*(malloc(newCapacity * sizeof(T))); if (mData) { memcpy(newData, mData, mSize * sizeof(T)); free(mData); } mData newData; mCapacity newCapacity; } private: T* mData nullptr; size_t mSize 0; size_t mCapacity 0; };扩容用memcpy而不是逐个拷贝前提是T是可平凡拷贝的。如果T有自定义拷贝构造函数就得用循环。引擎里大部分容器存的是POD类型或指针所以memcpy是安全的。1.5倍扩容的数学依据是多次扩容后之前释放的内存块大小之和会超过当前需要的容量分配器有机会复用这些块。5.4 引擎主循环与模块生命周期管理最后把所有模块串起来实现引擎主循环。class Engine { public: bool Init() { mMemoryManager new MemoryManager(); mLogSystem new LogSystem(); mPlatform new PlatformLayer(); mRenderer new Renderer(); // 按顺序初始化 mModules {mMemoryManager, mLogSystem, mPlatform, mRenderer}; for (auto* m : mModules) { if (!m-Init()) return false; } return true; } void Run() { while (!mShouldQuit) { mPlatform-PumpEvents(); for (auto* m : mModules) m-Update(); mRenderer-Present(); } } void Shutdown() { // 逆序关闭 for (auto it mModules.rbegin(); it ! mModules.rend(); it) { (*it)-Shutdown(); } } private: std::vectorIModule* mModules; bool mShouldQuit false; };这个框架虽然简单但已经具备了工业级引擎的骨架分层、模块化、统一生命周期管理。后续往里面加渲染管线、物理系统、脚本系统都只需要实现IModule接口并注册到模块列表里。6. 常见问题与排查技巧实录6.1 内存问题速查表现象可能原因排查方法运行一段时间后崩溃内存泄漏导致耗尽开启内存追踪检查未释放分配分配失败但总内存充足内存碎片用池分配器替代通用分配器多线程下随机崩溃数据竞争用线程检查工具检查共享数据访问性能随运行时间下降碎片化加剧监控分配器碎片率调整分配策略对象数据错乱缓冲区溢出加边界检查用地址消毒剂6.2 我踩过的三个典型坑第一个坑是在内存追踪器里用了被追踪的分配器。追踪器本身要分配哈希表节点如果这些节点也走被追踪的分配路径就会递归调用自己栈溢出。解决办法是追踪器用独立的系统分配器不经过追踪层。第二个坑是动态数组扩容时没有处理自引用。如果数组里存的是指向自己元素的指针扩容后memcpy到新地址这些指针就悬空了。解决办法是扩容后遍历修正自引用指针或者干脆禁止存自引用指针。第三个坑是空闲链表的ABA问题。单线程下没问题多线程下偶尔崩溃。后来改成每个线程一个独立的内存池线程间不共享空闲链表问题消失。如果必须共享就用带版本号的指针或者加锁。6.3 性能调优的实用建议调优内存和数据结构时不要凭感觉要用数据说话。我通常用三个工具性能分析器看热点函数内存分析器看分配模式和碎片率缓存分析器看缓存命中率。这三个数据结合起来基本能定位到瓶颈。一个反直觉的经验减少分配次数比减少分配总量更重要。分配一万次每次1KB比分配一百次每次100KB慢得多因为每次分配都有固定开销。所以能批量分配的绝不逐个分配能用池的绝不用通用分配器。另一个经验数据布局的优化收益往往大于算法优化。把数组结构体改成结构体数组可能带来几倍的性能提升而换一个更快的排序算法可能只提升百分之几十。在引擎这种数据密集型的场景里缓存友好性是第一优先级。7. 基础架构的扩展方向基础架构搭好之后往上扩展就有了稳固的地基。接下来可以做的事情很多资源管理系统可以加入异步加载和引用计数场景系统可以加入序列化和预制体渲染器可以加入多线程命令提交。但无论怎么扩展底层的分层原则、内存管理策略、数据结构选型都不会变。我在实际项目里最大的体会是基础架构的投入在前期的回报不明显甚至会觉得“写这么多代码就为了管理内存值得吗”。但项目规模一旦上去基础架构的质量直接决定了开发效率和运行性能。那些前期偷懒省下的时间后期会以几倍的代价还回去。所以如果你正在从零搭建引擎或者准备重构现有引擎的底层我建议先把内存管理和核心数据结构做扎实后面的路会好走很多。后续这个系列我会继续拆解资源管理、渲染管线、场景系统等模块每个模块都会延续这种“原理加实操加避坑”的写法。如果你在基础架构层面遇到过什么棘手的问题或者有更好的设计方案欢迎一起交流。
返回列表