游戏引擎基础架构拆解:源码视角下的分层与主循环

📅 发布时间:2026/10/13 11:09:44
游戏引擎基础架构拆解:源码视角下的分层与主循环
1. 为什么先聊基础架构源码阅读者的第一道门槛我最近在啃一款开源引擎的源码啃到第三天差点放弃。不是代码多难而是我一开始就犯了所有新手都会犯的错直接点开渲染模块想看它怎么画帧。结果被一堆依赖绕得晕头转向最后才发现问题根本不在渲染而在引擎的基础架构。所谓游戏引擎架构里的基础架构说白了就是一件事这些模块是怎么被组织起来、怎么互相通信、怎么在一条时间线上跑起来的。它不是某一个具体功能而是所有功能的地基。渲染、物理、动画、音频、资源加载、网络同步全都长在这块地基上。地基要是歪了上面盖多少层都是白搭。这篇文章我想从一个读源码的人的视角把我梳理引擎基础架构时踩过的坑、理清的主线、总结的方法论都摊开讲一遍。适合三类人看准备上手读引擎源码的开发者、正在设计自研引擎的同学、以及团队里负责引擎选型的技术负责人。看完你会对引擎的分层结构、核心模块职责、启动流程、主循环、数据驱动架构有一条清晰的主线而不是被各大模块的细节淹没。2. 分层设计的具体长法从平台层到工具层2.1 运行时层到底分几层大多数商业引擎的运行时架构看来看去都逃不开这样一个纵向分层层级职责范围典型内容依赖方向平台抽象层抹平操作系统和硬件差异窗口创建、输入采集、硬件信息查询、文件系统最底层谁都不依赖基础服务层提供通用基础设施内存分配、容器、日志、数学库、线程调度、任务系统只依赖平台层资源层统一管理外部数据资源导入、格式转换、加载、缓存、引用计数依赖基础服务层功能模块层提供具体游戏能力渲染、物理、动画、音频、粒子、网络依赖前三层游戏层承载具体玩法逻辑场景管理、实体组件、脚本系统、游戏状态机依赖所有底层注意这个表的依赖方向列。依赖关系只能自上而下不能反向。我在读源码时发现一个特别重要的判断技巧判断一个分层设计得好不好就看跨层调用是否被严格禁止。比如渲染模块不能直接去读游戏逻辑里的某个变量它只能通过资源层拿数据。一旦出现反向依赖比如渲染模块为了某个特殊效果去调了游戏层的接口短期内看着方便但后面重构时会痛不欲生。还有一点值得说分层不是越多越好。我曾经见过某个模拟项目把平台层又拆成系统适配层和硬件抽象层两层结果中间多出来的那层既不能完善平台差异还增加了每一层之间的函数跳转开销。实际工程里平台抽象层别太厚能包住窗口、输入、文件系统这些就够用了。2.2 工具链算不算架构的一部分很多刚接触引擎的人有个误区觉得工具链编辑器、资源导入器、调试面板不属于运行时架构。我一开始也是这么想的后来发现大错特错。引擎的工具链虽然运行在开发机上、不参与最终游戏的发布但它跟运行时引擎共享同一套资源格式、同一套序列化协议。如果工具链和运行时割裂最直接的后果就是你辛辛苦苦在编辑器里调的参数一运行游戏就变成另一套东西。真正复杂的引擎架构会在编辑器里直接复用运行时的模块代码。比如某款引擎的UI系统编辑器里的预览窗口和游戏里的实际渲染走的是同一条渲染路径。这么做的好处是所见即所得坏处是编辑器进程跟游戏进程之间要做严格的模块边界隔离否则编辑器改个参数把整个编辑器搞崩了还算小事更麻烦的是调试时两个进程都在跑同一套代码互相干扰很难定位问题。所以看架构的时候别只盯着运行时那一套把编辑器进程和运行时进程之间的数据流、通信协议、文件格式一起梳理清楚才是完整的引擎基础架构图。2.3 资源层和数据层容易混淆有一处特别容易混淆资源层和数据层。资源层管的是那些有明确外部来源的资产比如模型网格、贴图、音频文件、动画曲线。它的核心职责是导入-缓存-引用管理。数据层管的是玩法相关的配置比如人物属性表、关卡怪物分布、技能伤害公式。这些数据往往不是在美术工具里做的而是策划在表格或者可视化配置界面里填的。我在某个项目里见过这样的混乱为了图省事把配置数据也当成资源文件用同一套资源加载接口去读取。前期跑得挺欢后面做热更新时才发现资源和配置的生命周期完全不同。资源往往跟着关卡切换、场景加载走配置却需要在游戏运行过程中实时改变并立即生效。两套需求混在一次最后只能写一堆特判逻辑来区分这个资源能热更和那个资源不能。所以好的基础架构从一开始就要把asset和config分开对待。这不只是文件名后缀的问题而是生命周期管理、内存管理、热更新策略都不一样。3. 核心模块逐个拆哪些能砍、哪些动不得3.1 内存管理引擎里最早被设计出来的模块如果你去翻那些成熟的引擎源码会发现一个规律第一个正式模块几乎总是内存管理。这不难理解。游戏引擎的很多行为都受内存约束关卡加载不能卡顿、运行帧率不能波动、长时间挂机不能内存泄漏。而且游戏里有大量每帧创建、每帧销毁的临时对象比如每帧的碰撞检测结果、每帧的命令缓冲区、每帧的粒子计算数据。如果用系统自带的malloc和free分配和释放的开销会成为性能瓶颈而且内存碎片会越来越严重。常见的做法是引入内存池分配器和栈式分配器分配器类型适用场景优点代价线性分配器一帧内临时数据分配开销极低释放几乎零成本只能在帧末整体重置栈式分配器嵌套作用域的临时数据数据生命周期清晰后进先出不能随意释放中间数据池分配器同类型小对象反复创建无碎片、分配快每种类型要单独建池通用分配器生命周期不规则的长期对象灵活可能产生碎片读引擎源码时我特别建议你先看一下它有多少种分配器、每种分配器用在哪里。这个信息能快速告诉你引擎作者对性能的态度。我在梳理某引擎源码时发现它的渲染模块几乎每个子模块都自带一个线性分配器每一帧开始用帧结束统一丢弃。这样设计的好处有两个第一分配效率高第二渲染相关的临时数据绝不会泄漏到下一帧。但代价是如果你在渲染模块里突然想存一个跨帧的对象就得自己把它拷到堆内存里否则下一帧开始就被冲掉了。3.2 事件与消息系统模块之间靠什么说话分层能解决模块之间的谁依赖谁问题但解决不了模块之间怎么说话的问题。比如玩家按了一下跳跃键输入模块怎么告诉角色控制系统物理模块检测到碰撞怎么让游戏逻辑知道音效模块怎么在角色落地时自动播放脚步声业内最通用的答案是事件系统。它有两种典型实现方式一种叫同步事件广播。事件发出后所有订阅者的回调函数立即被执行。优点是简单、直观、排查问题方便缺点是如果某个订阅者的回调写得很重会直接拖慢发出事件的线程。另一种叫异步事件队列。事件先丢进队列由专门的线程或下一帧某个固定时机统一派发。优点是不会阻塞事件产生方缺点是事件不会立刻生效可能出现逻辑看起来指令已经发出去了但实际上还没处理的诡异情况。我在实际项目里踩过一个坑某个模拟项目里移动逻辑订阅了按键事件当时用的是异步队列结果按下按键到角色开始移动之间有十几毫秒的延迟手感明显黏滞。后来把输入事件改成同步广播移动立刻跟手了。所以架构设计上并没有哪种事件系统更好的统一答案而是要看事件类型输入、碰撞这类实时性强的建议同步广播或者至少保证在同一个帧内完成处理UI更新、成就解锁、数据显示这类时效性不敏感的异步队列更稳妥可以避免多个模块在同一帧内反复互相唤醒。3.3 渲染、物理、音频、动画的耦合关系再来看看这几个大模块之间谁跟谁说话。物理模块输出的刚体变换位置、旋转是渲染模块拿来做模型变换的依据。动画模块输出的骨骼矩阵要交给渲染模块做蒙皮。音频模块需要知道角色是否在移动才能切换脚步声音量。看到这你可能觉得这些模块之间应该直接互相调用。但实际上成熟的引擎架构里它们很少直接互相调用而是通过一个中间层来传递状态。这个中间层有的引擎叫场景系统有的叫世界模块有的叫运行时状态缓存。它的职责类似于一面墙物理写完状态把结果放到墙上的某个格子里渲染每帧开始的时候去格子里取数据。物理和渲染模块彼此不持有对方的指针只认这个中间层的数据结构。这种解耦带来的最大好处是你可以独立替换某个模块。比如把物理引擎从A换到B只要新引擎能往中间层的结构体里写同样的数据渲染模块根本不需要改动。我自己做自研引擎的时候因为用了这个思路把音频模块从底层API换到另一套底层API只花了一个周末。反过来说如果架构里直接让渲染模块调用物理模块的接口表面上少写几个结构体但两套模块的生命周期被焊死在一起。以后任何一个模块升级另一个都得跟着改。4. 启动流程与主循环读懂引擎的第一条主线4.1 基础服务的初始化顺序有讲究读引擎源码时最怕的就是一头扎进某个模块的细节里完全不知道整个程序是怎么跑起来的。所以一定要先梳理启动流程。引擎启动大体上分两段。第一段是基础服务初始化第二段是游戏世界的创建与进入主循环。基础服务初始化的顺序通常是有讲究的先初始化平台层窗口创建、图形设备上下文、输入设备注册。再初始化日志系统和断言系统这样后面出任何问题都能记下来。接着初始化内存系统否则日志系统想动态分配内存都没地方可用。然后初始化资源系统的底层比如文件系统、压缩解压。再启动线程池和任务调度器让后面的模块可以使用多线程。之后才是各个功能模块的注册比如渲染后端、物理引擎、音频设备。我遇到过一个反例某个项目的日志系统要用网络上报崩溃信息所以在日志系统初始化之前就把网络模块初始化了。结果网络模块又依赖日志系统来输出错误信息形成了互相依赖的循环。最后每次启动时哪个先初始化完全看运气经常在崩溃边缘试探。所以读任何引擎源码先画一条初始化顺序链出来这对理解后面的所有逻辑都有帮助。4.2 帧循环里的固定步长与可变步长主循环是基础架构里另一个重中之重。游戏世界本质上是一个每一帧更新一点状态、然后渲染出来的循环。读过多个引擎源码之后你会发现帧循环有两种截然不同的设计哲学设计方式更新时机优点缺点可变步长每帧按真实流逝时间更新实现简单帧率高时物理更细腻帧率波动会导致物理表现不稳定固定步长按固定时间间隔模拟物理表现稳定方便网络同步需要用插值把逻辑帧和渲染帧对齐两类引擎各有拥趸。固定步长架构的引擎里你会经常看到上一个累积时间、当前帧剩余时间这类变量它们的作用就是把游戏逻辑更新控制在固定的时间切片里而渲染部分仍然按真实帧率跑。这样的好处是网络同步容易做——因为每个客户端都在以同样的逻辑步长推进模拟。可变步长架构的引擎实现起来更简单但如果你玩过帧率忽高忽低的测试版游戏应该体会过明明设备卡了一下结果角色跑得比平时远一截的奇怪手感这就是可变步长在低帧率下的物理计算累积误差。4.3 引擎里那几条容易被绕晕的并行链现代引擎早就不是单线程跑完一帧的时代了。基础架构里必然有一套线程模型最典型的分工是游戏线程主逻辑线程跑玩法逻辑、驱动场景、发命令。渲染线程接收游戏线程提交的渲染命令构建命令缓冲区驱动GPU。工作线程池处理物理、动画蒙皮、资源解压、导航寻路等可以并行的任务。读源码时新手最常晕的是这帧到底谁先跑完。我建议脑内维持一个概念游戏线程和渲染线程之间存在一条生产者-消费者链。游戏线程是生产者把这一帧要画的物体、灯光、相机提交流入队列渲染线程是消费者从队列里取出数据准备并提交给GPU。如果两个线程同时动同一份数据就是灾难。所以架构里必须有一层帧数据双缓冲机制游戏线程写第N帧的数据渲染线程读第N-1帧的数据。这样两个线程永远不碰同一份数据代价不过是渲染结果比逻辑慢一帧这在绝大多数游戏里毫无感知。另一个容易晕的地方是物理。物理计算常常放在工作线程里因为它要处理大量碰撞检测任务非常耗时。但这就带来一个问题游戏线程这一帧在跑玩家输入而物理线程还在计算上一帧的碰撞结果两边怎么对齐成熟引擎的答案通常是物理结果插值——物理线程产出的刚体变换不是直接给游戏用而是保留最近几个历史状态游戏线程按渲染时间戳做插值获取平滑位置。5. 数据驱动架构为什么很多引擎把配置看得比代码还重5.1 数据驱动的三层含义数据驱动这四个字在引擎架构里被说烂了但至少包含三层不同的含义很多人混在一起聊。第一层叫资源驱动。美术和策划制作的外部资源模型、贴图、音效不写死在代码里而是通过资源系统加载。这一层几乎所有引擎都做到了它解决的是内容的可替换性。第二层叫配置驱动。玩法数值、关卡布怪、技能效果存成数据表或结构化文件。代码里只是写读取这张表的第X行解析出技能的伤害值、冷却时间、作用范围至于具体数值是多少策划改表格就行。这一层解决的是不用动代码就能调整游戏性。第三层叫规则驱动也叫行为驱动。更进一步不光是数值连行为逻辑本身也用数据来描述。比如事件触发条件、AI状态机的状态转移条件、技能连招的输入序列全都能配到数据里。引擎提供一个解释器去执行这些规则。我在实际项目中的体会是资源驱动没有悬念必须要做配置驱动值得做但别做太死规则驱动是最诱人也最容易翻车的一层。因为规则一旦复杂起来光靠数据描述就想涵盖所有情况最后的配置表会膨胀到没人能维护。5.2 序列化与热重载带来的架构压力数据驱动听起来美好但落到基础架构上它给引擎添加了几块隐形的骨头。第一块是序列化。游戏运行时的对象要能存成文件文件要能读回成对象。引擎必须统一规定每种数据类型在磁盘上的表示方式包括数字的字节序、字符串的编码、容器的长度前缀、指针怎么变成引用ID。这些看起来琐碎却是资源编辑器和运行时能共享同一套数据格式的基础。第二块是热重载。数据驱动的意义在开发期才能完全显现策划在编辑器里改了一个数值希望运行时立刻生效不用重新打包、不用重启游戏。要做到这一点引擎里必须有重新加载配置后需要通知哪些系统刷新缓存的机制。我见过某个项目把所有配置都丢到同一个可重载组件里改一个数值就整表重载运行时的滞后卡顿非常明显后来不得不做了配置依赖分组才解决。这两块内容虽然不显眼却直接决定了团队的迭代效率。一个引擎的数据驱动架构好不好你可以问一个很直接的问题从策划改数值到玩家看到效果中间要花多长时间这个时间越短说明基础架构越健康。5.3 不是所有模块都适合数据驱动聊数据驱动聊过了头容易走火入魔。我见过有人想把渲染管线都做成纯数据驱动结果那套配置比代码还复杂最后反而没有人愿意维护。我的感受是高频变化的、属于内容和玩法的部分适合数据驱动低频变化的、属于引擎机制的部分更适合代码驱动。举个最简单的例子物理引擎的穿透补偿系数、渲染的反走样策略这些属于引擎机制正常开发者根本不会频繁改硬把它做成配置只会增加一层无谓的间接访问。而角色的技能伤害、装备效果、关卡奖励掉落这些属于玩法内容哪怕数据文件再多也值得花精力做数据驱动。这个取舍本身就是基础架构设计中最说不清道不明、却又最重要的经验判断。6. 从架构视角看选型自研还是用现成6.1 判断一个引擎架构健康度的几个标志如果你要选一个引擎来用或者你想评估自己团队自研引擎做得怎么样我推荐从以下几个角度快速体检启动链是否清晰能不能在半小时内对一个新人讲清楚程序从入口到主循环发生了什么讲不清楚说明启动依赖不规范。跨模块调用是否收敛渲染到底能不能直接调物理的接口如果能架构的健康度就要打问号。线程边界是否明确哪些数据属于游戏线程、哪些属于渲染线程、哪些属于工作线程文档里说没说清楚说不清楚以后并行优化时会大面积踩雷。替换一个模块的成本比如把物理引擎从A换到B预期需要改多少文件如果答案是一个比较大的数字说明模块耦合过高。资源与数据的生命周期是否能独立管理这决定了热更新、关卡流式加载能做到什么程度。这几条不涉及具体实现细节但任何一条出问题未来都会以特别痛苦的方式暴露出来。6.2 自研引擎最值得先搭的部分如果你打算从零开始做一个小引擎练手我的建议是不要一上来就模仿大引擎的全套架构。大引擎的分层、模块、线程模型很多是为几百人的团队协作设计的个人项目照搬会变成纯粹的负担。我觉得更实际的自研顺序是先做一个基础平台抽象层能把窗口打开、能处理基本输入。做一个简单的线性内存分配器和日志系统保证后续调试有抓手。写一个资源加载presentation哪怕只是同步加载也先让模型和贴图能显示出来。搭一个事件总线和简单的场景管理器把谁跟谁通信这个地基打稳。最后才是往上盖渲染、物理这些大楼。我自己的体会是前四步看似不起眼但它们决定了后面所有模块的舒适度。前四步草草了事的人后面基本上都会回头返工。6.3 读源码时我建议的阅读顺序最后分享一个我梳理引擎源码时的阅读顺序算是给准备动手读源码的同学一条参考主线第一步先找启动文件看初始化顺序画出基础服务初始化链。第二步找到主循环理解每帧按什么顺序更新各模块。第三步跟着一个最简单的对象比如一个静态网格体从加载到渲染的完整路径把资源层、场景管理、渲染提交串起来。第四步找一个异步场景比如物理计算看它怎么和主线程同步。第五步再回头看具体模块的细节这时候你会发现自己已经能看懂它为什么这么写了。我在读第一遍某引擎源码时就是因为没按这个顺序先去啃了某个模块的细节导致后面反复回头看基础逻辑浪费了很多时间。这个教训比任何代码技巧都值得记下来。架构这东西学的时候总觉得枯燥但等你带着具体问题重新审视它时会发现自己恨不得给它写表扬信。引擎基础架构的价值不在于它看起来有多规整而在于它能让你在后续几年的功能迭代里少走多少弯路。希望这篇拆解能给你一条属于自己的主线。