ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

游戏引擎基础架构设计:对象模型、内存管理与更新循环的核心取舍

游戏引擎基础架构设计:对象模型、内存管理与更新循环的核心取舍 1. 引擎基础架构到底在解决什么问题很多人第一次翻开引擎源码看到的是满屏的Object、Actor、Component、World然后就开始逐个类去啃啃了两周发现脑子里还是一团浆糊。问题出在顺序上——你还没搞清楚引擎基础架构到底要解决什么问题就一头扎进了实现细节。游戏引擎基础架构要解决的核心问题其实就一句话让一堆会变化的数据在有限的内存和CPU周期里被高效地创建、更新、查询和销毁。听起来像废话但你把这句话拆开看每一个词都对应着架构里的一个关键决策。“会变化的数据”意味着引擎里的对象不是静态的位置会变、状态会变、生命周期会变。这就引出了第一个架构选择对象怎么表示是继承体系还是组合是裸指针还是句柄“有限的内存”意味着你不能随便 new不能指望操作系统帮你兜底必须自己管内存池、自己做缓存友好布局。“高效地查询”意味着你需要空间划分结构、需要类型索引、需要避免每帧遍历整棵场景树。“销毁”则涉及延迟删除、引用计数、垃圾回收策略的取舍。我见过不少自研引擎的项目前期功能跑得飞快到了中期开始卡顿、崩溃、内存泄漏回头一查全是基础架构阶段埋的雷。比如用shared_ptr管理所有游戏对象结果循环引用导致对象永远不释放比如场景节点用树形结构但每帧递归遍历节点一多CPU直接爆掉比如对象创建用工厂模式但没做对象池每帧生成子弹时 new/delete 把堆打成了筛子。所以这一篇不打算讲某个具体引擎的源码而是把引擎基础架构里最核心的几条设计主线拆开讲清楚每个决策背后的权衡。你如果是刚入行想理解引擎怎么搭起来的或者正在自研引擎需要做架构选型这篇内容应该能帮你少走几个月弯路。注意本文讨论的是引擎的“基础架构层”不涉及渲染管线、物理模拟、动画系统等上层模块的具体实现。基础架构是这些模块的地基地基没打好上面盖什么都会歪。2. 对象模型继承、组合与数据导向的三角博弈2.1 从“万物皆对象”到“万物皆数据”早期引擎比如Unreal Engine 3时代的对象模型是典型的深继承体系UObject下面派生AActorAActor下面派生APawnAPawn下面派生ACharacter一层层往下。这种设计的好处是直观ACharacter天然拥有APawn和AActor的所有能力代码复用靠继承就够了。但继承体系有个致命问题菱形继承和功能膨胀。假设你想让一个静态物体也能被玩家拾取但拾取逻辑写在APawn里你就得把逻辑往上提提到AActor里结果所有Actor都背上了拾取相关的虚函数和成员变量。改一个功能整个继承树都跟着抖。现代引擎Unity、Godot、以及UE4之后的版本普遍转向了组合优于继承。核心思路是GameObject本身几乎是个空壳所有能力都通过挂载Component来获得。TransformComponent管位置MeshComponent管渲染CollisionComponent管碰撞。你想让物体能被拾取加一个PickupComponent就行不需要动其他任何东西。组合模式解决了继承膨胀但引入了新问题组件之间的通信成本。组件A要读组件B的数据得先拿到B的引用如果B还没创建或者已经被销毁就得处理空指针。而且组件多了之后每帧遍历所有组件调用Update()虚函数跳转和缓存不命中会让CPU很痛苦。于是有了第三条路数据导向设计DOD。不把对象当成“带方法的实体”而是当成“一组数据的集合”。位置数据放一个数组速度数据放一个数组渲染数据放一个数组。更新时不是遍历对象调方法而是遍历数组做批量运算。这样CPU缓存命中率高也方便做SIMD并行。三种范式的对比如下维度深继承组合数据导向代码复用靠继承容易膨胀靠组件灵活靠系统极致灵活缓存友好度差对象分散中组件分散优数据连续上手难度低中高适合场景小型项目、原型通用游戏开发大规模实体模拟典型代表UE3Unity、GodotUE5 Mass、自研ECS实际项目中纯DOD很少见因为写起来太反直觉。更常见的是混合方案对象层用组合热点数据用DOD。比如UE5的Mass框架就是ECS风格但普通Actor还是组合模式。你自研引擎时如果团队规模不大、项目不是万人同屏那种量级组合模式足够用别为了“先进”硬上ECS调试成本会让你怀疑人生。2.2 对象标识指针、句柄与ID的三层选择对象创建出来之后你怎么引用它最直接的是裸指针Object* obj解引用就能用零开销。但裸指针有两个坑一是对象销毁后指针变野指针访问就崩二是对象在内存里移动比如内存整理后指针失效。于是有了句柄Handle。句柄本质上是一个索引比如uint32_t指向一个对象槽位表。槽位表里存实际的对象指针。你拿到的句柄是稳定的对象移动了只需要更新槽位表里的指针句柄本身不变。销毁对象时把槽位标记为空句柄访问时检查槽位有效性就能避免野指针。再往上还有全局唯一ID通常是个64位整数用于网络同步、存档序列化等场景。ID不直接用于内存访问而是作为查找键通过哈希表或映射表找到句柄或指针。这三层选择不是互斥的而是配合使用。我自己的经验是内部高频访问用裸指针比如渲染线程遍历可见对象列表直接用指针数组别绕弯。跨系统引用用句柄比如AI系统引用目标对象用句柄目标销毁后句柄自动失效AI逻辑里判断一下就行。持久化与网络用ID存档时存ID读档时通过ID重建引用。实操心得句柄的槽位表不要用std::vector直接存指针因为vector扩容会导致槽位地址变化。用std::deque或者分块数组保证槽位地址稳定。另外槽位回收时句柄里可以带一个“代数”字段每次回收代数加一这样旧句柄即使指向了被复用的槽位代数不匹配也能被识别为无效。2.3 生命周期管理谁创建、谁销毁、何时销毁对象生命周期是引擎里最容易出bug的地方。常见的问题包括对象A引用了对象BB先被销毁了A访问B时崩溃对象在帧中间被销毁但当前帧还有系统在遍历它多线程环境下一个线程在销毁对象另一个线程在访问。解决思路分几个层面第一层明确所有权。每个对象必须有且只有一个“所有者”。所有者负责在合适的时机销毁对象。其他系统持有的是“引用”或“观察者”不负责销毁。C里可以用unique_ptr表达所有权用裸指针或weak_ptr表达引用。第二层延迟销毁。不要在遍历过程中直接销毁对象而是把待销毁对象加入一个“待销毁队列”等当前帧所有系统更新完毕后再统一处理。这样避免了遍历时容器被修改的问题。第三层引用计数与弱引用。如果对象可能被多个系统共享用引用计数管理。但引用计数有循环引用问题所以还需要弱引用机制。弱引用不增加计数访问时先检查对象是否还活着。第四层分帧销毁。如果一帧内要销毁大量对象比如切换关卡一次性销毁会造成卡顿。可以把销毁操作分摊到多帧每帧销毁一批控制单帧耗时。// 一个简化的延迟销毁队列实现 class ObjectManager { std::vectorObject* pendingDestroy; public: void RequestDestroy(Object* obj) { if (obj !obj-IsPendingDestroy()) { obj-MarkPendingDestroy(); pendingDestroy.push_back(obj); } } void ProcessPendingDestroy() { for (Object* obj : pendingDestroy) { obj-OnDestroy(); DestroyObjectInternal(obj); } pendingDestroy.clear(); } };这个模式看起来简单但实际项目中经常被忽略。我见过一个项目在子弹命中时直接delete子弹对象结果子弹的碰撞回调还在栈上没返回访问了已释放的内存崩溃日志指向一个完全无关的函数排查了一整天。3. 内存管理引擎性能的隐形战场3.1 为什么引擎不能依赖默认的 new/deletenew和delete是通用内存分配器设计目标是“在大多数场景下表现尚可”而不是“在游戏场景下表现最优”。游戏引擎的内存访问模式有几个特点分配频繁且大小集中每帧可能创建几百个子弹、粒子、临时对象大小往往是16、32、64字节这种小块。生命周期差异大有的对象活几帧就销毁有的活整个关卡。对延迟敏感一帧只有16毫秒60帧目标内存分配不能有不可预测的停顿。默认分配器的问题在于每次new都要走一遍通用分配逻辑加锁、查找空闲块、可能触发系统调用。小对象频繁分配时碎片化严重缓存命中率低。更致命的是delete的时机不可控可能在一帧中间触发大量释放造成帧率抖动。所以引擎通常自己管内存核心手段是内存池和自定义分配器。3.2 内存池把分配变成“从架子上拿东西”内存池的基本思路是预先向系统申请一大块内存然后自己切成固定大小的小块。分配时从空闲链表里取一块释放时还回去。整个过程没有系统调用没有锁竞争单线程池速度极快。// 一个极简的固定大小内存池 class FixedPool { struct Block { Block* next; }; Block* freeList nullptr; void* memory nullptr; size_t blockSize; size_t blockCount; public: FixedPool(size_t size, size_t count) : blockSize(size), blockCount(count) { memory malloc(size * count); // 初始化空闲链表 for (size_t i 0; i count; i) { Block* block reinterpret_castBlock*( static_castchar*(memory) i * size); block-next freeList; freeList block; } } void* Allocate() { if (!freeList) return nullptr; // 池耗尽 Block* block freeList; freeList freeList-next; return block; } void Free(void* ptr) { Block* block static_castBlock*(ptr); block-next freeList; freeList block; } };这个池的分配和释放都是O(1)而且因为块大小固定不会有外部碎片。缺点是块大小固定不同大小的对象需要不同的池。实际引擎里会有一组池分别对应16、32、64、128、256字节等常见大小分配时向上取整到最近的池。踩坑提醒内存池的块大小不要设得太细否则池的数量爆炸管理开销反而上升。我一般按2的幂次设置从16到1024超过1024的走通用分配器。另外池耗尽时的处理策略要提前想好是返回nullptr让调用方处理还是自动扩容自动扩容会破坏“预分配”的确定性建议在开发期就监控池的使用率快满时报警。3.3 缓存友好布局数据放在一起比放在哪里更重要现代CPU的缓存层级是L1约32KB、L2约256KB、L3几MB到几十MB。一次L1缓存命中约4个周期L2约12个周期L3约40个周期主存约200个周期。也就是说如果数据不在缓存里CPU要等200个周期才能拿到这期间什么都干不了。引擎里最典型的缓存问题就是遍历对象数组。假设你有1000个敌人每个敌人有位置、血量、状态。如果对象是分散在堆上的遍历时每次访问都要从主存拉数据缓存命中率极低。但如果把位置数据放在一个连续数组里遍历时CPU可以预取一次拉一整条缓存行64字节效率差好几倍。这就是结构数组SoA和数组结构AoS的区别// AoS每个对象包含所有数据内存布局是 [pos,hp,state][pos,hp,state]... struct Enemy { Vec3 pos; float hp; int state; }; std::vectorEnemy enemies; // SoA每种数据单独一个数组内存布局是 [pos,pos,pos...][hp,hp,hp...] struct EnemySystem { std::vectorVec3 positions; std::vectorfloat healths; std::vectorint states; };AoS写起来直观enemies[i].pos一目了然。SoA写起来别扭但遍历更新时缓存友好。实际项目中热数据用SoA冷数据用AoS。比如位置、速度、血量这些每帧都要访问的拆成独立数组名字、描述、图标路径这些偶尔访问的留在对象里。3.4 内存对齐与伪共享多线程下的隐形杀手内存对齐是指数据地址要是某个值的倍数通常是4、8、16。CPU访问未对齐的数据可能要两次总线周期性能下降。更严重的是伪共享两个线程分别修改同一缓存行里的不同变量虽然逻辑上不冲突但硬件层面会导致缓存行在两个核心之间反复失效性能急剧下降。// 伪共享示例两个线程分别修改a和b但a和b在同一缓存行 struct SharedData { int a; // 线程1修改 int b; // 线程2修改 }; // 解决方案用alignas把变量对齐到缓存行边界 struct alignas(64) PaddedData { int a; char padding[60]; int b; };引擎里多线程任务系统、渲染线程与逻辑线程的数据交换都要注意伪共享。我一般会在性能分析时用缓存未命中率来定位问题如果某个多线程模块的缓存未命中率异常高八成是伪共享。4. 数据结构选型没有最好只有最合适4.1 场景图树形结构的代价与替代方案场景图Scene Graph是引擎里最经典的数据结构用树来表示物体之间的父子关系。父节点移动子节点跟着动父节点销毁子节点一起销毁。听起来很美好但树形结构有几个性能陷阱陷阱一递归遍历。每帧更新世界变换时如果递归遍历整棵树函数调用开销和缓存不命中会累积。1000个节点的树递归深度可能几十层每层都有虚函数调用。陷阱二指针追逐。树节点用指针连接遍历时CPU要不断跳转内存地址预取器完全失效。陷阱三动态修改。运行时增删节点树的结构变化缓存局部性被破坏。替代方案是扁平化数组索引。把所有节点存在一个连续数组里父子关系用索引表示。遍历时按数组顺序处理缓存友好。更新世界变换时先按层级排序然后从根到叶顺序计算避免递归。struct SceneNode { Mat4 localTransform; Mat4 worldTransform; uint32_t parentIndex; // 无效索引用UINT32_MAX表示 uint32_t firstChild; uint32_t nextSibling; }; std::vectorSceneNode nodes;这种“数组索引”的布局在UE5的Mass、Unity的DOTS里都是核心思想。代价是代码写起来不如指针直观但性能收益在节点数量大时非常明显。4.2 空间划分四叉树、八叉树与BVH的适用边界空间划分结构用于加速“查询附近物体”这类操作比如碰撞检测、视锥剔除、AI感知。常见的有四叉树2D、八叉树3D、BVH层次包围盒、网格Grid。结构适用场景优点缺点均匀网格物体分布均匀查询O(1)实现简单分布不均时内存浪费四叉树/八叉树静态场景、分布不均自适应划分动态更新成本高BVH动态物体、光线追踪动态更新较快构建成本高空间哈希稀疏分布、无限世界内存按需分配哈希冲突处理选型的核心是看物体动态程度和查询模式。如果场景是静态的比如地形、建筑八叉树构建一次用很久很划算。如果物体每帧都在动比如子弹、角色八叉树每帧重建的成本可能比暴力遍历还高这时候用均匀网格或者空间哈希更合适。我自己的经验是先用暴力遍历性能不够再上空间划分。很多项目一上来就搞八叉树结果发现物体数量根本没到需要空间划分的量级白白增加了复杂度和bug面。一般物体数量超过500-1000且查询频繁时才值得引入空间划分。4.3 对象查询类型索引与组件过滤引擎里经常需要“找到所有带某组件的对象”或者“找到所有某类型的对象”。如果每次查询都遍历全部对象对象多了之后开销不可忽略。优化手段是类型索引为每种组件类型维护一个对象列表。查询时直接取列表不用遍历全部。但组件可以动态增删所以列表要支持快速增删。用“交换删除法”可以在O(1)时间内从数组里移除元素templatetypename T class ComponentList { std::vectorT* components; std::unordered_mapT*, size_t indexMap; public: void Add(T* comp) { indexMap[comp] components.size(); components.push_back(comp); } void Remove(T* comp) { size_t idx indexMap[comp]; T* last components.back(); components[idx] last; indexMap[last] idx; components.pop_back(); indexMap.erase(comp); } };对于“同时拥有多个组件”的查询可以用位掩码。每个对象有一个组件掩码查询时用位运算过滤。比如查询“有位置且有血量”的对象掩码是POSITION | HEALTH遍历时if ((obj.mask queryMask) queryMask)即可。5. 更新循环帧的节奏与系统的秩序5.1 固定时间步与可变时间步的取舍游戏循环的核心问题是物理更新和逻辑更新用固定时间步还是可变时间步固定时间步每帧更新固定的时间比如1/60秒。好处是物理模拟稳定确定性好方便回放和网络同步。坏处是如果渲染帧率高于逻辑帧率需要插值如果低于需要追帧可能导致卡顿。可变时间步每帧更新实际经过的时间。好处是简单渲染和逻辑同步。坏处是物理模拟不稳定帧率波动时行为不一致极端情况下可能穿透。实际引擎通常用固定时间步插值逻辑以固定频率更新比如60Hz渲染每帧根据当前时间在前后两个逻辑状态之间插值。这样物理稳定渲染平滑。double accumulator 0.0; const double fixedDelta 1.0 / 60.0; void Frame(double realDelta) { accumulator realDelta; while (accumulator fixedDelta) { FixedUpdate(fixedDelta); accumulator - fixedDelta; } double alpha accumulator / fixedDelta; Render(alpha); // 用alpha插值 }注意固定时间步的循环要有上限防止“死亡螺旋”——如果一帧耗时太长accumulator累积太多下一帧要跑很多次FixedUpdate导致更慢。一般限制单帧最多跑5-10次FixedUpdate超出就丢弃时间。5.2 系统更新顺序依赖关系决定执行次序引擎里有很多系统输入、AI、物理、动画、渲染。它们的更新顺序不能随便定必须满足依赖关系。比如输入系统先跑收集玩家输入。AI和逻辑系统根据输入更新状态。物理系统根据状态更新位置和碰撞。动画系统根据物理结果更新骨骼。渲染系统最后跑收集所有可见对象。如果顺序错了比如物理在AI之前跑AI基于旧位置做决策就会出现“AI反应慢半拍”的现象。如果动画在物理之前跑动画可能基于旧位置出现抖动。更复杂的是有些系统之间有双向依赖。比如物理影响动画动画也影响物理比如布娃娃系统。这时候需要拆成多个阶段或者用迭代求解。我一般会在引擎里显式定义系统阶段Phase每个系统注册到某个阶段阶段内可以并行阶段间串行。这样依赖关系清晰也方便做性能分析。5.3 多线程更新任务图与数据隔离现代引擎基本都会利用多核。多线程更新的核心挑战是数据竞争。两个线程同时写同一个对象结果不可预测。解决方案有几种方案一按对象划分。把对象分成N组每组一个线程组内串行组间并行。前提是对象之间没有跨组依赖或者依赖可以通过消息传递解决。方案二按系统划分。不同系统跑在不同线程系统之间通过双缓冲交换数据。比如物理线程写位置缓冲渲染线程读上一帧的位置缓冲。方案三任务图。把更新拆成细粒度任务任务之间有依赖关系调度器根据依赖关系并行执行。UE的TaskGraph、Unity的JobSystem都是这个思路。// 简化的任务图示例 TaskGraph graph; auto inputTask graph.AddTask([](){ ProcessInput(); }); auto aiTask graph.AddTask([](){ UpdateAI(); }, {inputTask}); auto physicsTask graph.AddTask([](){ UpdatePhysics(); }, {aiTask}); auto renderTask graph.AddTask([](){ Render(); }, {physicsTask}); graph.Execute();多线程的坑很多伪共享、锁竞争、任务粒度过细导致调度开销大于收益。我的建议是先做单线程性能不够再逐步并行化而且并行化要从最耗时的系统开始不要一上来就全面多线程。6. 从架构到落地几个容易踩的坑6.1 过度设计架构不是越“先进”越好我见过不少自研引擎一上来就上ECS、上JobSystem、上多线程渲染结果团队里没人能驾驭bug修不完进度拖了半年。架构选型要匹配团队能力和项目需求。如果项目是中小规模、团队经验有限用经典的组合模式单线程更新把功能做出来、做稳定比追求“先进架构”重要得多。6.2 忽视工具链没有调试工具的架构是空中楼阁引擎基础架构必须配套工具对象查看器、内存分析器、性能分析器、帧调试器。没有这些工具出了问题只能靠printf和猜。我一般会在架构设计阶段就预留调试接口比如对象管理器提供遍历所有对象的接口内存池提供使用率统计更新循环提供每阶段耗时。6.3 过早优化先跑通再跑快“这个循环每帧跑1000次我要用SIMD优化”——先等等你确定这1000次循环是瓶颈吗用性能分析器测一下可能它只占总耗时的1%优化它收益微乎其微。先让功能跑通用分析器找到真正的热点再针对性优化。6.4 忽略平台差异PC上跑得好不代表手机上没问题PC的CPU缓存大、内存带宽高、多核性能强。手机CPU缓存小、内存带宽有限、大小核架构差异大。在PC上设计的内存布局和多线程方案到手机上可能完全不是那回事。如果项目要跨平台架构设计阶段就要考虑移动端的限制比如减少内存占用、避免过度多线程、注意大小核调度。7. 写在最后引擎基础架构这个话题展开讲可以写一本书。这篇内容聚焦在对象模型、内存管理、数据结构、更新循环这几条主线上每条线都只讲了核心思路和关键取舍。实际落地时每个决策都要结合项目需求、团队能力、目标平台来权衡。我自己在做引擎架构时最深的体会是架构的价值不在于用了多少设计模式而在于让团队里的每个人都能理解、能维护、能扩展。一个“不够先进”但团队都懂的架构比一个“技术领先”但没人敢改的架构对项目成功的贡献大得多。如果你正在自研引擎建议先把对象生命周期和内存管理这两块做扎实它们是所有上层功能的地基。地基稳了上面盖什么都不会塌。
返回列表