1. 引擎基础架构到底在回答什么问题
先说一个可能颠覆新人认知的结论:游戏引擎的基础架构,本质上是一套关于依赖规则的约定,而不是某份代码或者某个类库。
我刚入行干引擎开发那会儿,很长一段时间的认知是“引擎就是一堆好用的类,封装了渲染、物理、音频、输入系统,游戏逻辑调它们就完事了”。直到接手一个内部自研引擎的架构梳理工作,才意识到这种想法会把人带沟里。面对成千上万个文件、上百个互相调用的模块,真正决定项目能不能推进下去的,不是某个渲染算法写得多漂亮,而是模块之间怎么划分边界、谁依赖谁、数据怎么流动。这些规则一旦定错,后面每一步都在还债。
业内常说引擎是个“程序员的操作系统”,我觉得更恰当的一个类比是:引擎像一套带管线预埋的高层建筑。玩家看得到的是水电和精装(画面、手感、内容),但真正决定这栋楼能不能盖到30层还不塌的,是承重结构、管道走向、强弱电分离这些“看不见的东西”。渲染、物理、动画这些系统相当于房间里的精装,而基础架构就是这个承重结构——它决定了后续所有功能的生长方式。
所以这篇解析会把重点放在三件事上:
- 模块怎么分:哪些东西天生应该聚在一起,哪些必须隔开。
- 依赖怎么控:引擎层、平台层、游戏逻辑层之间谁依赖谁,方向错了会出现什么后果。
- 运行时怎么转:从启动到主循环,再到一帧结束,数据在模块之间怎么流动。
适合谁看?如果你手里正在用现成商业引擎做项目,但总遇到“热更新难做”“内存到处泄漏”“测试跑起来很怪”之类的问题,这篇可以帮你理解这些问题其实都导因于架构决策。如果你是自研引擎的初学者,这篇能给你一套“先搭什么、后搭什么”的路线参考。
1.1 先别急着写代码:架构是一种“依赖规则”
任何形成了规模的引擎,代码库都大到了单个开发者无法逐行维护的程度。这时候架构的作用是降低局部修改带来的全局风险。
举个例子:如果渲染模块想读资源配置,发现资源系统没有公开接口,直接去引用了资源模块的某个内部数据结构,那这条依赖就“偷偷”建立了。短期看没什么问题,但等到你想把资源模块重构成带流式加载的版本时,所有直接引用内部结构的代码都会碎一地。更可怕的是这类依赖会指数级传播,最后整个项目形成一张谁也理不清的大网。
所以我做的第一件事,是给引擎定一套“依赖方向铁律”。我们内部把它简化成三条:
- 基础工具库(数学、容器、内存分配器)谁都不依赖,只被别人依赖。
- 平台层可以依赖基础库,但平台层不能反向依赖任何上层模块。
- 功能模块之间不互相直接调用,需要交互时通过事件总线、资源接口或者任务系统中转。
看着特别简单,但实际执行起来非常反人性。因为程序员在写单个功能时,总觉得自己只“临时”引一个头文件很合理。我见过最离谱的一次,是某项目里AI系统为了避免自己算寻路,直接调用了寻路模块内部的路径信封结构,原因是寻路系统公开接口“少了一个bool参数”。就为了省一个参数,两个本应完全独立的系统被焊死。所以架构不是多写几个设计文档就行,必须靠代码审查、依赖检测工具和定期的架构巡检一起上。
1.2 从一帧的旅程看引擎的骨架
如果说模块划分决定的是引擎“长什么样”,那帧循环就是引擎“怎么运转”。
一步最简单的帧旅程是这样展开的:操作系统把控制权交给引擎入口,引擎初始化各子系统后进入主循环;主循环每帧先处理输入设备产生的原始事件,把键盘鼠标手柄的状态统一成引擎自己的输入快照;随后开始更新游戏世界,包括玩家逻辑、AI、物理模拟;物理系统把碰撞结果写回场景状态;动画系统根据角色状态驱动骨骼;渲染系统拿到最终要绘制的场景表示后,做视锥剔除、排序、提交绘制指令;最后在帧结束处把命令缓冲提交给图形API。
这个流程最值得注意的一点是:每个阶段只依赖上一阶段产出的“数据”,而不是直接调用对方的功能。输入系统不关心角色逻辑怎么处理按键,角色逻辑不关心渲染用什么方式画自己,渲染不关心物理是哪种碰撞算法。各系统通过明确的数据交接点协作,而不是互相“伸手”进对方内部。基础架构的核心工作,就是把这一条条数据交接点设计清楚。
2. 模块化分层:核心层、平台层、游戏层的边界怎么划
接触过商业引擎的开发者应该都熟悉一个目录感受:引擎代码从底层到顶层分了好几个“大圈”,越往外越具体,越往里越通用。这个分层的直觉是对的,但真设计时容易犯两个极端错误——要么分得太细导致模块爆炸,要么分得太粗导致每次修改影响一片。
我自己的实践是三层四块:基础核心层、平台抽象层、功能模块层、游戏扩展层。下面拆开讲每一层的职责边界。
2.1 模块的“内聚”与“耦合”:哪些东西必须打包在一起
先回答一个问题:为什么数学库、容器、字符串、内存分配器这些“零零碎碎”的东西要放在基础核心层?
因为这些代码有一个共同特征:被所有其他模块依赖,本身不依赖任何引擎业务概念。向量、矩阵、Quaternion不关心你是在做角色扮演还是赛车游戏;一个侵入式链表容器也不需要知道什么网格渲染。把这类代码提到最底层,等于给整个引擎铺了一层“地基”,上层随便盖。
那什么情况下两个模块明明功能不同,也应该合在一起?看“变更频率”和“数据模型”是否一致。举个例子:场景管理和序列化系统,虽然名字上一个管运行时一个管存储,但如果你的场景格式需要把实体组件数据直接转成二进制块,那这两个系统在数据模型上是同构的,分开会造成大量的互相拷贝和同步成本。我在某自研项目里就把这两个模块合成了“场景数据层”,序列化直接读场景内部表示,省掉整整一层转换代码。
反过来,如果两个模块只是一方偶尔调用另一方,那就说明耦合已经发生了,要尽快通过接口隔离削弱依赖。核心方法论是:能不需要知道对方内部细节的,就设计一个稳定接口把细节藏住。
2.2 平台抽象层:让引擎不绑死在某个操作系统上
平台层是我见过自研引擎最容易糊弄过去的部分。很多团队一开始只在Windows上开发,于是直接把Win32 API、DirectX初始化写进了核心逻辑里,美其名曰“后面再抽”。但等到需要移植到其他平台时,会发现所有模块都散布着操作系统API调用,抽平台层的成本等于重构整个引擎。
平台抽象层的核心任务不是把每个平台API都包一层,而是定义引擎自己需要的最小能力集合。比如:
| 能力域 | 抽象内容 | 具体平台实现示例 |
|---|---|---|
| 窗口创建与生命周期 | 窗口句柄、宽高、焦点、关闭事件 | Windows: CreateWindow;移动端:surface 生命周期 |
| 文件系统访问 | 同步/异步读取、目录枚举、热加载监听 | 桌面平台:原生API;游戏机平台:特殊归档格式 |
| 动态库加载 | 加载插件、代码热更新 | LoadLibrary/dlopen 的抽象 |
| 时钟与高精度计时 | 帧时间、性能采样 | std::chrono/平台专属 tick 封装 |
| 内存系统接口 | 大块内存分配、对齐分配、页面信息 | VirtualAlloc、mmap、平台特定分配器 |
我在设计平台层时坚持两条铁律:第一,平台层不得出现任何游戏业务概念,它只负责“把能力递上来”;第二,平台层对外暴露的全部是稳定抽象接口,上层模块永远拿不到具体平台类型。只要这两条守住,换平台就只是替换一个“平台后端”,各模块代码基本不用动。
2.3 用实际模块拆解一个引擎的目录结构
抽象讲半天,不如直接看一个实际分层目录示例。以下是我在某自研引擎项目中用过的一套模块划分,取自一个中型规模的模拟项目,字段和命名做了简化:
engine/ foundation/ # 基础核心层:math、container、string、memory platform/ # 平台抽象层:window、input、file、time core/ # 引擎核心服务:task、event、job、config systems/ render/ # 渲染:renderer、scene graph、shader、material physics/ # 物理:collision、rigid body、scene query animation/ # 动画:skeleton、clip、blend audio/ # 音频:source、listener、stream resource/ # 资源:asset type、loader、cache、stream world/ # 场景与实体:entity、component、transform game/ # 游戏扩展层:项目特有逻辑、自定义组件、玩法系统这个目录最有价值的部分不是名字,而是依赖方向:core可以引用foundation和platform,但不能反向依赖;systems之间的互相引用必须走core里的事件或资源接口;game是唯一能自由引用所有底层模块和部分系统模块的地方,但game里的代码永远不能被systems引用。
如果在一个系统的内部代码里发现它#include了game下的头文件,不用看内容,基本可以断定设计已经出了偏差。我常用的检查方法很简单:每个月让人工扫一次依赖图,把“非法依赖”列成一个表格贴在迭代复盘里。一开始非法依赖会很多,随着修掉几轮,后续新增的依赖就会自然沿着正确的方向长。
3. 游戏循环与帧管线:引擎的“心跳”
主循环写起来容易——一个while加一次update和render就结束了。但要让这个循环在复杂引擎里稳定工作,问题会立刻转移到两个地方:时间模型怎么取、帧内任务怎么编排。
很多渲染卡顿、物理跳动、网络同步错乱的问题,根源都在主循环的时间设计上。这一节我会把循环设计背后“为什么这么做”讲透。
3.1 可变步长与固定步长:经典的循环设计
最基础的主循环有两大流派:可变步长和固定步长。
可变步长:每帧的真实耗时是多少,就按多少毫秒去更新游戏逻辑。实现简单,逻辑在低端机器上会自动变慢。问题也很致命——物理、动画、网络这种需要时间一致性的系统会乱套。假如一帧花了 30 毫秒,下一帧只花 5 毫秒,物理模拟的步长忽大忽小,碰撞穿透和抖动的概率呈指数上升。
固定步长:游戏逻辑按固定时间间隔更新,比如每 16.67 毫秒模拟一次,不管真实帧率是多少。渲染循环依然可变,但在两帧渲染之间会执行若干次固定时间粒度的逻辑更新。好处是物理、动画、网络步进都稳定,坏处是实现复杂度高一点,而且存在“螺旋死亡”风险:如果逻辑跟不上一帧消耗的时间,固定步长会越积越多,最后一帧做十几次逻辑更新,彻底卡死。
大多数商业引擎采用的混合方案是这样的:
- 渲染按真实显示频率走。
- 逻辑步进使用固定时长。
- 每次逻辑步进前,用“剩余时间累加器”决定要不要追一次逻辑更新。
- 一旦某个帧累积的欠账超过上限,干脆丢弃过期时间,避免“螺旋死亡”。
我在实际项目的实现,核心逻辑大致长这样(简化版):
float accumulator = 0.0f; const float fixedDelta = 1.0f / 60.0f; while (running) { float frameTime = min(timer.elapsed(), 0.25f); // 防止欠账过深 accumulator += frameTime; while (accumulator >= fixedDelta) { // 固定步长逻辑更新:输入快照、逻辑、物理、动画 gameLogic.Update(fixedDelta); accumulator -= fixedDelta; } // 插值 alpha 用于渲染中间帧,减少画面跳跃 float alpha = accumulator / fixedDelta; renderer.Render(alpha); }这段代码最有讲究的是min(frameTime, 0.25f)。如果某帧因为编译着色器或者系统卡顿花了 2 秒,你不截断这个帧时间,那么累加器会迫使引擎在恢复后立刻跑上百次逻辑更新,直接拖垮整个游戏。截断到 0.25 秒等于告诉引擎“太旧的时间我不要了,赶紧继续”,这是所有固定步长循环都必须有的保险。
3.2 主循环之外的并行化空间
主循环天生是串行的,但现在机器少则 8 核、多则几十核,如果引擎把整个逻辑更新和渲染串成一串,基本等于让大部分核心看戏。
并行化不是简单地把几个系统丢到线程里跑,而是要设计任务依赖图。比如渲染的“视锥剔除”不依赖逻辑更新结果,可以在上一帧逻辑结束后的间隙提前开始下一帧剔除;动画的骨骼变换和物理的刚体模拟在数据独立时也能并行。真正可行的方案是引入任务系统,把每个系统要做的事情拆成小任务,标注依赖,由任务调度器根据核心数量自动分配线程执行。
我在项目里用的模式是分三波:
- 帧头并行波:输入采样、网络包接收、场景流式加载检查。
- 世界更新波:经过依赖分析后的逻辑系统并行更新,物理和动画如果数据隔离就并行跑,否则串行。
- 渲染提交波:所有逻辑结果转换为渲染命令缓冲,这个过程可以并行,但主线程最终负责把命令缓冲交给图形 API。
并行化最大的坑是数据竞争。你无法靠锁解决引擎级的并发,因为锁会让核心变闲置。正确做法是数据所有权明确:一个系统只允许读它拥有的数据,别的系统读完整帧数据时,通过只读快照传递,而不是共享可变对象。
3.3 帧管线的流程编排
有了任务系统,主循环不再是“更新完渲染”,而是一个流程编排问题。我习惯把一帧分成几个阶段,每个阶段只处理一种任务类型:
| 阶段 | 任务 | 数据交接点 |
|---|---|---|
| 输入阶段 | 采样原始输入、生成快照 | 只读输入快照写入帧全局数据 |
| 模拟阶段 | 逻辑、物理、动画更新 | 世界状态更新完成 |
| 场景收集阶段 | 渲染数据收集、剔除、排序 | 渲染命令缓冲 Ready |
| 提交阶段 | 命令缓冲提交图形 API | 图形指令排入 GPU 队列 |
这个编排的好处是每个阶段的职责特别窄,只要数据交接点稳定,你甚至可以把其中某个阶段替换成网络同步版本(比如把“世界更新”换成“服务器回放”),整个帧管线不需大改。基础架构的价值恰恰在这里:它不是帮你写某一个功能,而是让你有能力在后期低成本地替换功能。
4. 资源生命周期与内存架构:容易烂尾的地基
基础架构里最容易被低估的就是资源管理和内存分配。很多项目前期跑得很欢,到中后期开始出现“资源重复加载”“释放后悬垂指针”“内存碎片化”之类的问题,这时候再补架构,代价往往是数月的返工。
我私下把资源系统叫“引擎的物业公司”,因为它的职责就是登记、分发、回收所有资产。如果一个引擎连资源的生命期都没管好,那所有上层模块都会活在“指针随时失效”的恐惧里。
4.1 资源必须收口:Handle、引用计数与垃圾回收
先说为什么资源不能直接暴露裸指针。
假如角色模型加载完,动画系统拿到一个Mesh*指针,缓存它以便以后使用。此时角色模型被卸载了,动画系统手里的指针就悬垂了。你当然可以让所有系统都遵循“先获取再释放”的纪律,但人不是可靠的执行者,尤其项目大了之后。
更稳妥的方案是把资源都交给资源管理器,外部永远只拿一个 Hand