ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

游戏引擎基础架构深度解析:模块划分、主循环与内存管理

2026/10/8 4:25:38 拓冰建站 浏览量
游戏引擎基础架构深度解析:模块划分、主循环与内存管理 1. 引擎基础架构到底在聊什么做游戏开发十几年我对游戏引擎架构的认识分三个阶段第一阶段只会用现成引擎调接口第二阶段开始改引擎源码做定制第三阶段干脆从零搭了一套轻量级引擎骨架。走到第三阶段才发现当初那些想不明白的怪问题几乎都出在基础架构上为什么会突然卡顿为什么内存会莫名涨为什么换个平台就一堆诡异bug答案往往不在某个具体功能的代码里而在引擎整体的设计里。游戏引擎架构说白了就是一套模块怎么划分、依赖怎么管理、数据怎么流动的顶层设计。很多人一听到架构就觉得虚觉得把渲染、物理、音频这些模块堆起来就是引擎。真正搭过一遍就明白模块堆起来只是第一步模块之间怎么通信、依赖方向怎么约束、一帧的更新顺序怎么编排、内存和资源怎么管理这些看不见的连接才是架构的核心。你在任何一个商业引擎里看到的子系统看起来千差万别骨架却是高度相似的。这是游戏引擎架构深度解析系列的第一篇重点放在最底层的地基模块划分与依赖规则、主循环与帧系统、内存管理、资源加载管线。适合三类人看想从调API升级到懂原理的中级程序员需要做技术选型、带队封装引擎的技术负责人以及纯粹想自己写个小引擎玩玩的爱好者。如果是对引擎完全没概念的新手我会尽量用生活化的类比把每个概念讲透你也可以先看第2章和第6章把整体长什么样和会踩什么坑记住再回头补中间的细节。我尽量不写教科书式的概念罗列每个模块的为什么这么设计都会讲清楚顺带穿插我在实际项目里验证过的做法和踩过的坑。废话不多说先看整体架构怎么搭。2. 模块划分与依赖关系先画好这张组织架构图2.1 经典四层模型每一层只信任下一层我自己搭引擎骨架时第一件事不是写代码而是拿一张白纸把模块画出来。这个动作听起来很虚但能省掉后面大量的返工——模块边界画错了写出来的代码十有八九要推倒重来。引擎界比较通用的画法是四层模型从下到上依次是平台抽象层、核心层、引擎层、游戏层每一层只依赖它正下方的层不越级调用也不反过来回头依赖。平台抽象层窗口创建、输入事件、文件IO、线程、时间等系统能力目标是把Windows、Linux、移动端、主机的差异全部挡住。这一层宁可做得薄也不要塞业务逻辑否则上层代码会不知不觉绑死某个平台。核心层数学库、容器、内存分配器、日志、线程池、任务系统。它不关心游戏是什么只提供基础设施。引擎层渲染器、物理、音频、动画、资源管理。每块都是相对独立的子系统彼此通过接口或事件协作。游戏层场景管理、ECS、玩法逻辑、脚本系统。商业引擎里这就是Gamemode、Actor、Component之类的东西在自研引擎里这层往往就是你的玩法项目代码。这个分层模型为什么要反复强调因为依赖方向是架构里最值钱的一条纪律。我在项目里经常跟同事说一句话上层可以调用下层下层绝不能反过来认识上层。渲染器不需要知道你的角色叫张三还是李四它只拿到一个场景描述结构物理模块也不需要知道哪些Actor在玩家手里它只负责更新一堆刚体数据。一旦这条规矩被打破比如渲染器为了方便直接读了玩法数据最初几天你可能觉得效率很高三个月后就会发现自己改任何一个地方都战战兢兢因为依赖图已经变成一团蜘蛛网了。打个比方引擎架构就像一家公司的组织架构。平台层是行政后勤核心层是技术中台引擎层是业务线游戏层是前台项目组。前台项目组可以找中台申请资源但中台绝不能反过来天天问前台要数据否则业务一调整整个中台就得跟着重构。很多游戏项目做到后期沦为屎山从来不是某个功能的实现写得烂而是模块依赖先烂了。2.2 模块间通信的三种约定直调、事件、服务定位分层定好之后还要解决一个实际问题平级模块之间怎么通信我用过三种方式各有适用场景。第一种是直接接口调用。比如物理模块要通知场景变换可以直接调场景管理器的接口。好处是链路清晰、性能好坏处是产生编译期依赖。我会把这种直调限制在职责上确有主从关系的模块之间比如渲染器驱动资源管理器做创建销毁这是单向且明确的。第二种是事件系统。某个模块广播角色死亡其他系统各自订阅不需要互相认识。这在解耦上是神器但坏处是出了问题不好追——你在回调堆栈里能看到事件类型但看不到到底是哪个模块、哪段逻辑发的。我自己的经验是对信息广播类消息用事件对请求执行类调用尽量用直调接口。把所有调用全换成事件最后的调试体验会很酸爽。第三种是中央服务注册表也就是Service Locator。引擎启动时把各核心服务注册到一张表里谁要用就按名字拿。Unity的引擎内驱、我们自研引擎的ModuleManager都走这条路。好处是模块之间完全没有硬依赖坏处是容易退化成上帝对象人人都在里面翻东西。我现在的做法比较混合模块化内部用直调跨大层之间通过事件全局单例服务走注册表。没有哪种方案是银弹关键是每一对依赖关系都要想清楚它属于哪种类型再决定通信方式。2.3 循环依赖架构腐烂的起点循环依赖是我在评审代码时看到就想拍桌子的东西。举个例子资源管理器要读硬盘文件文件系统模块要打日志日志模块要申请内存内存分配器依赖平台层这些还都是单向的。但如果有同事图省事让渲染器初始化的时候直接回调资源管理器去加载贴图资源管理器为了创建GPU资源又反过来调用渲染器接口好了两个模块互相引用。轻则初始化顺序怎么排都别扭重则构造时崩溃、析构时崩溃、热重载时崩溃。为什么循环依赖这么可怕因为编译器和链接器管不了逻辑层面的互相引用代码能编过但运行时对象的构造顺序、反初始化顺序全都变成玄学。更麻烦的是这种问题往往不是一两天暴露的而是某个模块悄悄改了一行初始化逻辑第二天全组人的编辑器都启动不起来了。解决循环依赖的标准做法是引入抽象层。A要调B但B又需要A的回调那就在共同的下层目录放一组纯虚接口A接口由B实现B接口由A实现中间的对接逻辑全部依赖接口而不是依赖具体类。我在自研引擎里专门留了一个Interfaces目录里面只放抽象接口和数据结构任何模块都能往下依赖它但它不能反过来依赖任何上层。这条规矩立住之后我最大的感受是项目越大越能从改一个模块要连带改五个模块的泥潭里解放出来。3. 游戏主循环与帧系统引擎的心脏3.1 一个看似简单实则棘手的 while 循环模块划分理清楚之后接下来就是引擎的心脏——主循环。所有游戏引擎的心跳都是同一个东西游戏主循环。简化到极致就是三行代码while (!exitRequested) { pollInput(); update(dt); render(); }三行代码谁都会写但真正把它做好坑比想象的多。最核心的问题就是那个 dt。如果一个游戏里角色跑步速度是每秒5米而update里用的是每帧移动5乘dt那么dt不稳定时角色在60帧和30帧下移动的总距离是一致的但物理模拟里的弹簧、碰撞响应、车辆漂移这类对时间步长极其敏感的逻辑就会因为每一步的步长不一样而出现抖动甚至发散。帧率低的时候物理表现异常看起来像是物理卡了其实是时间积分方法本身在高步长下精度不够导致的。这也是为什么老玩家会说60帧的某游戏和144帧的某游戏手感完全不同一部分原因就藏在 dt 的统计特性里。3.2 固定时间步长为什么物理模拟离不开它把主循环改成固定时间步长是游戏引擎里非常经典的一个设计。思路是不管渲染帧率是30、60还是120逻辑更新都按固定的60分之一秒一步步走多出来的时间存到一个累加器里留给后续帧去消化。代码大概长这样const double fixedDt 1.0 / 60.0; double accumulator 0.0; while (!exitRequested) { double frameDt queryFrameTime(); // 获取本帧真实时长秒 accumulator frameDt; int stepCount 0; while (accumulator fixedDt stepCount 4) { update(fixedDt); accumulator - fixedDt; stepCount; } render(); // 渲染时做插值让画面更平滑 }这里有一个新手很容易犯的错累加器里如果某帧突然卡了200mswhile循环可能会连续补十几次逻辑更新这一帧的CPU开销会爆炸于是下一帧更卡形成恶性循环这就是所谓的死亡螺旋。所以正规实现里一定会给单帧的最大模拟次数封顶并配合渲染插值来消化剩余的时间。渲染插值又是一个细节。因为逻辑是每60分之一秒跳一步的而渲染可能是144Hz两帧之间的逻辑状态其实是静止的画面上就会看到一顿一顿的感觉。解决办法是让渲染器拿到上一帧和当前帧两个状态做线性插值这在帧同步、回放、格斗游戏里尤其重要。我做引擎改动时最深的体会是帧系统不是在纠结怎么跑而是在追求怎么跑得可预测——固定步长让逻辑可预测插值让画面可预测这两件事分开做调试和回放功能才会有立足之地。3.3 帧数据与多阶段更新顺序模块越多越需要对一帧里谁先谁后做严格的约定。我习惯在一帧开始的时候先收集一份帧数据FrameData里面至少包含帧序号、真实时间间隔、缩放后的时间间隔用于子弹时间、暂停、输入快照、随机种子。别小看这个结构它最大的价值是让整帧的所有更新都基于同一份输入和时间而不是每个模块各自去调系统时钟。我自己吃过大亏以前某个版本的引擎里UI模块、逻辑模块、物理模块各自取系统时间结果在低帧率机器上一套操作下来三个模块拿到的时间戳不一致导致UI明明点了按钮逻辑层却显示没收到输入。后来统一用FrameData分发这类问题直接绝迹。更新顺序上业界的通用约定大致是这样的先收输入再走预更新处理事件接着固定步长逻辑更新然后是普通玩法更新再之后是后期更新处理完物理插值、动画插值最后才交给渲染器做剔除和绘制。为什么要后更新动画因为玩法逻辑可能改了角色的朝向、速度、攻击状态动画模块要在这之后才能算出最终姿态为什么渲染放在最后因为渲染器要拿到的是一帧最终的结果而不是中间态。现代商业引擎在这套顺序之上还加了多线程调度把更新拆成若干Job每个Job标注依赖线程池去抢着跑渲染线程只做提交。比如动画更新依赖位移更新布料模拟又依赖动画更新连成一个有向无环图。自研引擎不一定要一开始就上多线程但建议把更新阶段用枚举明确列出来后续要并行化时每个阶段天然就是一个Job边界。4. 内存管理最容易被忽视的隐形地基4.1 为什么引擎里不能全用 new/delete很多从应用开发转过来的同事写游戏引擎代码时的第一反应是需要对象就new一个不用了delete掉。这在普通业务系统里没有问题但在游戏引擎里这是性能灾难的开始。我见过不少项目早期功能开发飞快一到同屏单位数量上来帧率立刻跌破30翻代码才发现遍地都是裸malloc和new。原因有三层。第一通用分配器的一次new平均要消耗16字节左右的元数据头还会在释放时产生堆碎片第二游戏循环里每帧都会创建大量临时对象全走堆分配的话每帧都会触发系统的缺页、锁竞争帧率曲线就会变成心电图第三通用堆分配器为了保证任意大小、任意顺序的分配释放都能工作逻辑非常重而这个通用性在游戏场景里大部分时候用不上。游戏引擎的内存管理哲学和普通程序正好相反先规划好哪些数据多长生命周期、多大体积、怎么释放再决定用什么分配策略。我经常打一个比方new方式像你每次需要一颗螺丝都去五金店买一趟自定义分配器则像在家里备好一抽屉分类收纳的螺丝找的时候快用完归位也快。4.2 三种常用分配器栈、池子、自由链我搭轻量引擎时最先实现的是栈式分配器Frame Allocator每帧开始记录一个标记位置之后所有临时数据都往里塞帧结束直接把标记一拨整块内存整体回收不逐个析构。这个方案特别适合一帧之内生成、一帧之后丢弃的临时数据比如碰撞检测的候选对、渲染命令列表。它的代价是释放必须是后进先出的顺序不能做到任意对象单独释放但对帧临时数据来说根本不是问题。第二种是池式分配器Pool Allocator预先一次性申请一批大小相同的内存块用空闲链表串起来。分配时取链表头一个块释放时把块归还链表头操作都是O(1)而且完全没有碎片。引擎里的粒子、子弹、特效这类生命周期短、尺寸固定的对象特别适合用池管理。我见过一个极端案例某个项目把普通子弹改成池分配后同屏500发子弹的GC停顿从每帧2ms降到了接近0。第三种是自由链表Free List分配器管理变长小块一般用于需要频繁分配删除的不同大小节点。它比通用堆轻量但要做好内存块合并否则还是会产生碎片。实际项目里我通常把它留给小尺寸、变长、低频的数据高频路径几乎不用它。分配器类型分配速度释放方式典型用途栈式分配器最快整体回卷帧临时数据、渲染命令池式分配器O(1)单个归还子弹、粒子、特效实体自由链表较快单个归还低频变长节点、消息体这张表是我在项目里做选型时贴在墙上的至今没变过。4.3 句柄机制别在引擎里裸奔指针引擎里的对象生命周期极其复杂一个角色可能在物理系统、动画系统、AI系统、UI系统里都被引用。如果大家都存裸指针一旦角色销毁其他系统拿着一个已经释放的指针去访问那画面就精彩了——原地飞升、无限金币、存档损坏什么怪事都有。成熟引擎的解法是句柄Handle把对象的引用从指针换成数组下标世代号。对象真实数据存放在一块连续大数组里句柄里记录它在数组里的索引以及一个世代计数器。当对象销毁时索引位置被复用世代号加一。其他系统拿着旧句柄来访问时发现世代号对不上就知道这个对象已经不在了可以安全地报错或跳过。这个设计最大的好处是指针解引用是O(1)且没多余检查句柄解引用虽然多一次世代比对但换来了彻底的安全性。我见过很多自研引擎坚持用裸指针传引用省那一次数组寻址的钱结果对象生命周期管理全部靠人肉约定最后项目多人协作时仅悬垂指针一类bug就消耗了团队大量的联调时间。如果你正在设计引擎我强烈建议核心对象类型实体、资源、场景节点全部走句柄性能损失在误差范围内收益却是几十倍的调试效率。4.4 缓存友好实体数据怎么摆放光管理好内存的分配与释放还不够取数据的顺序同样重要。现代CPU一分钟能执行几十亿条指令但从主存取一次数据的延迟大约要几百个时钟周期。如果一个循环频繁发生缓存缺失再好的算法也跑不出理论性能。ECS实体组件系统能成为近年来的热门一个重要原因就是它的数据摆放方式是缓存友好的。同样是更新所有角色的位置传统做法里角色对象散落在堆的各处循环遍历时每访问一个对象都可能触发缓存缺页ECS把位移组件存在一个紧密的数组里遍历时CPU能一次把一连串数据预取到缓存里速度差距可以达到数倍。我做过一个简单的实测场景里1万个移动实体每家都带Transform、Velocity、Renderable三个组件传统散对象遍历约耗时6ms换成ECS式紧凑数组后压到了1.2ms左右。这不是说ECS是银弹而是说明数据怎么摆放对性能的杠杆比普通人想象的大得多。如果你暂时不上整套ECS至少可以把高频循环里用到的数据从对应的对象里抽出来摆到平行的连续数组里同样能拿到大部分收益。5. 资源管理与加载管线地基层的最后一环5.1 资源的一生从磁盘到运行时引擎里的资源是一个抽象概念贴图、模型、音频、动画、关卡配置都是资源。一个贴图资源的一生大致是开发期被打包工具处理成引擎格式运行期由一个资源系统按需加载到内存再转成GPU或音频硬件需要的运行时对象使用结束后被引用计数销毁。这里的重点不是加载这个动作而是整条生命周期的所有权归属——谁负责创建、谁负责销毁、销毁时还有谁在引用。这里面有一个关键设计逻辑资源和运行时对象要分离。逻辑资源是磁盘上的那份数据描述运行时对象是GPU显存里的纹理、声卡缓冲区里的音频。前者可以被多个场景共享后者因为硬件上下文不同通常不能跨平台通用。我在公司内部经常让新人先想清楚这个对象是CPU侧的描述还是GPU侧的实例很多接口设计混乱的根源就是这两类东西被混在一起。资源管理模块的核心数据结构是一张全局资源表文件名、路径、类型、加载状态、引用计数、版本号。所有部门要资源都通过资源管理器申请而不是各自直接读文件。这样做的好处是只有一处地方集中管理生命周期文件重复加载可以被缓存命中加载错误能被统一上报。5.2 同步、异步与流式加载分别用在哪儿加载方式的选择直接决定启动速度和场景切换体验。同步加载最简单请求一个资源路径解析、文件读取、格式解析、GPU上传全部在当前线程完成。优点是逻辑清晰适合启动时把核心资源一次性加载完。缺点是阻塞时间长如果场景切换时在游戏线程里同步加载一个几十MB的关卡十几秒的白屏/黑屏体验谁也受不了。异步加载是主流方案资源管理器发一个加载任务到线程池文件IO与解析在后台做完成后通过回调或帧末轮询通知游戏线程。这里要注意的是完成回调的执行时机——如果回调直接在加载线程里执行UI更新那多半要出问题。我的做法是把加载完成的事件放到主线程下一帧的队列里统一派发保证只有一条线程碰游戏对象。流式加载则更进一步把一个大关卡切成若干区块Chunk只加载玩家附近的区块走远就卸载走近再加载。开放世界游戏基本都是这个思路。流式加载最麻烦的是边界管理玩家在两个区块交界处来回走动时会触发频繁的加载卸载必须做缓冲区和优先级队列否则会看到贴图突然从低模变成高模的弹现。我踩过的坑是加载线程抢IO带宽太猛导致主线程读包体时卡顿后来加了IO带宽配额把加载线程的带宽限制在物理吞吐的60%问题就缓解了。5.3 引用计数、热重载与循环引用资源生命周期管理我基本只用引用计数申请一次加一释放一次减一归零就卸载。这套机制简单可靠但有两个坑必须说。坑一循环引用。A资源依赖BB资源又反过来依赖A两者互相持引用后计数永远归不了零内存就泄漏了。解决思路跟代码对象一样让其中一方的引用是弱引用或者引入根对象做GC标色。商业引擎里常见做法是所有资源都挂在场景根目录下场景销毁时按遍历结果强制计数归零用图遍历绕开循环。坑二热重载。开发期改完一张贴图预览窗口里最好立刻能看到新效果这就涉及资源系统要监听文件变化、重新导入、替换运行时对象。热重载难在替换时机正在渲染的那一帧不能半路换纹理正在被动画引用的模型不能突然变成新骨骼结构。稳妥的做法是标记资源为待重载下一帧的加载管理阶段统一替换并通知所有持有方触发各自的刷新逻辑。我在项目里的资源管线大概长这样文件变化检测-触发导入器重跑-生成新版本资源-主线程帧首检查版本号-替换数据并广播事件。这条链路跑顺之后美术和策划的迭代效率提升非常明显值得花力气维护。6. 实操复盘搭建引擎骨架时踩过的坑6.1 主循环莫名卡顿的定位思路第一个坑来自我自己搭引擎骨架三个月后。现象是游戏跑着跑着每过十几秒就卡一下卡顿还固定发生在同一帧序号附近。一开始怀疑GC、怀疑着色器编译排查了一圈都没结果。最后才查到问题出在某个临时容器上这个容器在每帧结束时被清空但清空只释放了元素没有释放底层容量导致它在运行中不断扩容。而扩容恰好发生在帧中间触发了大片内存拷贝卡顿随之而来。这个案例给我最深的教训是引擎代码里所有每帧都可能变化大小的容器都要在一开始预留足量容量并且养成习惯用帧分配器避免重要的临时数据走堆扩容。定位这类问题的时候我也总结了一套顺序先看是否有资源加载或编译触发再看内存分配和容器扩容然后看线程锁和IO最后才怀疑业务逻辑。按这个顺序大部分间歇性卡顿都能在半小时内定位。6.2 内存碎片引发的诡异崩溃第二个坑更诡异引擎在PC上连跑三天不出问题换到低端安卓机器上一小时就崩而且在内存占用显示还不到峰值的60%就崩了。用工具dump内存后发现物理内存碎片率已经到了吓人的程度大量小块空闲内存散落在堆里一个大对象申请失败直接触发崩溃。回到代码里找根因发现是某个邻域模块频繁地new和delete一堆小节点每次分配的内存块大小不一加上系统堆分配器本身会产生元数据头碎片就这么慢慢攒起来了。解决方案很直接把小节点改成池式分配器管理同时对长期稳定的大对象做成句柄式复用。这个改动之后同样压测三天也没再崩过。从那以后我给自己立了条死规矩凡是每帧创建销毁超一千次的对象禁止直接走通用new/delete必须走分配器。6.3 资源加载卡死一场死锁教学第三个坑是资源加载过程中的死锁。现象是游戏切场景时偶发卡死复现概率不高但一出现就只能杀进程。排查到最后发现死锁发生在加载线程持有文件锁等待渲染线程释放资源而渲染线程又在等加载线程返回。两条线程互相等待就是教科书式的死锁。修起来其实不难规定好加载线程只能在后台操作文件IO和CPU解析不能直接触发GPU资源创建GPU上传一定要回到渲染线程。但为什么当初会设计成那个样子因为我在早期懒想让加载线程直接做完GPU纹理上传省掉一次主线程间的数据搬运。省事的设计往往会把跨线程依赖埋进深处代价就是前期爽、后期痛。如果你也在搭引擎我建议从第一天就把线程归属写清楚哪个数据活在哪个线程谁负责它的创建和销毁不要做模棱两可的跨线程对象。6.4 给新手的四点启动建议如果你也想从零搭一个引擎骨架我的建议是别一上来就想做大而全。先把四件事做好能跑一个稳定的固定步长主循环能用帧分配器管理临时数据能加载并显示一个带纹理的正方体能通过句柄安全地创建和销毁场景对象。这四件事覆盖了模块划分、帧系统、内存、资源四大地基跑通之后再往里面加物理、加音频、加场景编辑器都只是填充细节。另外建议一开始就用版本控制管理好一份架构原则文档把依赖方向、线程归属、分配器使用规范这些约定写下来。别嫌务虚等到项目上千文件、协作超过三个人这份文档比任何花哨的模块代码都重要。我在实际项目里最深的体会是引擎架构的问题绝大多数不是某个算法写不出来而是一开始没想清楚依赖和归属。地基打歪了后面所有层都在还债。今天就先讲到这里下一篇我会继续拆场景管理与ECS的数据流到时候见。