ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

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 基础服务的初始化顺序有讲究

读引擎源码时,最怕的就是一头扎进某个模块的细节里,完全不知道整个程序是怎么跑起来的。所以一定要先梳理启动流程。

引擎启动大体上分两段。第一段是基础服务初始化,第二段是游戏世界的创建与进入主循环。

基础服务初始化的顺序通常是有讲究的:

  1. 先初始化平台层:窗口创建、图形设备上下文、输入设备注册。
  2. 再初始化日志系统和断言系统,这样后面出任何问题都能记下来。
  3. 接着初始化内存系统,否则日志系统想动态分配内存都没地方可用。
  4. 然后初始化资源系统的底层,比如文件系统、压缩解压。
  5. 再启动线程池和任务调度器,让后面的模块可以使用多线程。
  6. 之后才是各个功能模块的注册,比如渲染后端、物理引擎、音频设备。

我遇到过一个反例:某个项目的日志系统要用网络上报崩溃信息,所以在日志系统初始化之前就把网络模块初始化了。结果网络模块又依赖日志系统来输出错误信息,形成了互相依赖的循环。最后每次启动时哪个先初始化完全看运气,经常在崩溃边缘试探。

所以读任何引擎源码,先画一条"初始化顺序链"出来,这对理解后面的所有逻辑都有帮助。

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 自研引擎最值得先搭的部分

如果你打算从零开始做一个小引擎练手,我的建议是不要一上来就模仿大引擎的全套架构。大引擎的分层、模块、线程模型,很多是为几百人的团队协作设计的,个人项目照搬会变成纯粹的负担。

我觉得更实际的自研顺序是:

  1. 先做一个基础平台抽象层,能把窗口打开、能处理基本输入。
  2. 做一个简单的线性内存分配器和日志系统,保证后续调试有抓手。
  3. 写一个资源加载presentation,哪怕只是同步加载,也先让模型和贴图能显示出来。
  4. 搭一个事件总线和简单的场景管理器,把"谁跟谁通信"这个地基打稳。
  5. 最后才是往上盖渲染、物理这些大楼。

我自己的体会是,前四步看似不起眼,但它们决定了后面所有模块的舒适度。前四步草草了事的人,后面基本上都会回头返工。

6.3 读源码时我建议的阅读顺序

最后分享一个我梳理引擎源码时的阅读顺序,算是给准备动手读源码的同学一条参考主线:

  • 第一步,先找启动文件,看初始化顺序,画出基础服务初始化链。
  • 第二步,找到主循环,理解每帧按什么顺序更新各模块。
  • 第三步,跟着一个最简单的对象(比如一个静态网格体)从加载到渲染的完整路径,把资源层、场景管理、渲染提交串起来。
  • 第四步,找一个异步场景,比如物理计算,看它怎么和主线程同步。
  • 第五步,再回头看具体模块的细节,这时候你会发现自己已经能看懂它为什么这么写了。

我在读第一遍某引擎源码时,就是因为没按这个顺序,先去啃了某个模块的细节,导致后面反复回头看基础逻辑,浪费了很多时间。这个教训比任何代码技巧都值得记下来。

架构这东西,学的时候总觉得枯燥,但等你带着具体问题重新审视它时,会发现自己恨不得给它写表扬信。引擎基础架构的价值不在于它看起来有多规整,而在于它能让你在后续几年的功能迭代里少走多少弯路。希望这篇拆解能给你一条属于自己的主线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进