最近几年,越来越多团队开始做自研引擎,或者基于开源项目二次开发自己的框架。不管你是引擎岗位的从业者,还是打算从应用层下沉到引擎层的开发者,有个问题迟早要面对:游戏引擎基础架构到底是什么,它由哪些东西组成,模块之间怎么协作、怎么防止自己写着写着就乱了。这篇内容是“游戏引擎架构深度解析”系列的第一篇,我先从整体框架入手,把引擎基础架构的核心模块、设计思路和常见坑位讲清楚,后续再逐个展开渲染、资源、ECS、多线程等子主题。
我见过不少项目,功能模块都写得很卖力,渲染效果也很漂亮,但一到跨平台、热更新、多人联调、性能分析的时候就崩得一塌糊涂,根源往往不在某一个具体系统,而在整体架构没有立住。引擎架构看着不像某个炫酷特性那样容易出成果,它却决定了你能在多大复杂度上、多长的开发周期里、多大规模的团队协作下把项目扛起来。
- 引擎基础架构的整体分层设计
1.1 引擎为什么需要分层,而不是把所有东西堆在一起
接触引擎架构的人,最先听到的就是“层”。层不是凭空造出来的概念,它本质上是在回答一个问题:如何让代码在“天天变”和“尽量不变”之间找到平衡。
先说结论,成熟引擎通常至少分这么几层:平台适配层、核心基础库、运行时框架、功能系统、游戏逻辑层。不是每套引擎都必须一模一样画出来,但这个分层逻辑是普适的。
- 平台适配层:封装操作系统、编译器、硬件API差异,比如窗口创建、输入事件、图形API(D3D12/Vulkan/Metal等)、内存分配、文件系统、多线程原语。它的目标很简单,上层永远不写
#ifdef _WIN32这种散弹式宏。 - 核心基础库:数学库、容器、字符串、内存管理、日志、断言、哈希、随机数等。这一层不依赖任何引擎功能系统,是纯工具性质的。
- 运行时框架:主循环、帧同步、场景图、对象生命周期、事件系统、模块注册与依赖管理。这一层决定了引擎“跑起来”的骨架。
- 功能系统:渲染、物理、动画、音频、粒子、寻路、网络等。它们之间互相隔离,只通过接口和运行时框架交换信息。
- 游戏逻辑层:最终承载玩法、Gameplay对象、脚本绑定等。对引擎基础架构来说,这一层是需要被稳定支撑的上层建筑。
这样分层的核心原因,用大白话说就是“隔离变化”。我见过一个项目,刚立项时为了赶Demo,渲染、逻辑、输入全部写在一块,前半年进度飞快,等Demo一过,想加一个新平台、换一套渲染API,几乎等于重写。而分层之后,平台换了,平台层重写;渲染API换了,渲染后端换掉,上层接口不变。你的游戏逻辑就能从“和操作系统绑定”变成“只和引擎接口绑定”。
1.2 模块间的依赖方向,比模块本身更重要
架构设计里真正难的从来不是划分模块怎么命名,而是控制依赖方向。理想状态下,依赖关系是单向的:游戏逻辑 -> 功能系统 -> 运行时框架 -> 核心基础库 -> 平台层。
你可以在项目里很清楚地用一条规则来约束团队:任何底层模块不得反向包含上层模块,同层模块之间不允许互相依赖,只能通过更下层的通用设施沟通。这种规矩看起来简单,实行起来很考验人的克制力。
之前一个同事为了图方便,在物理模块里直接调用了渲染模块的调试绘制接口,一开始就图能少写几行代码,后来渲染模块改造把那个接口删了,物理模块编译直接崩掉。谁也不会天天盯着这种依赖,但架构腐烂往往就从这种“偶然耦合”开始。实操里我会在CI里加一个依赖扫描脚本,用简单的正则或者解析头文件包含关系,每周跑一次,发现有反向依赖或者同层互引就马上拉出来排队解决。这个动作看着土,却是保证架构真正不腐化的关键一步。
- 核心基础库:被低估的架构地基
2.1 内存管理直接影响你的性能天花板
很多人聊引擎架构先聊渲染或者场景管理,但我个人看法,内存管理才是游戏引擎性能的第一道防线。控制台和手机上可用内存本来就不宽裕,外加GPU和CPU对内存访问的步调完全不一致,随便一个频繁的堆分配就会造成卡顿。
引擎基础架构里,内存模块至少要做这几件事:
- 实现一个全局分配器包装,替换
new/delete/malloc,所有模块尽可能走这层接口。 - 提供帧分配器,每帧开始分配、帧尾整体释放,适合临时数据。
- 提供对象池,针对特定类型反复出现的对象做复用,比如粒子、战斗飘字、临时节点。
- 提供栈式分配器,适合加载阶段的临时数据,后进先出,释放极快。
- 支持内存标签和统计,按系统统计分配量,定位内存泄漏和碎片。
不少引擎基础库会用双缓冲的内存池来支撑主线程和渲染线程的通信,避免每一帧都锁全局锁。这个思路本质上就是环形队列、无锁队列的变种。
注意:一定要把“是否有内存泄漏”和“是否有内存碎片”分开来看,很多运行时崩溃其实是内存碎片导致分配失败,而不是总量不足。调试时不要只看峰值量,要配合碎片率指标一起观测。
2.2 数学库、容器和IO层,基础中的基础
数学库在引擎基础架构里承担的角色很朴素:向量、矩阵、四元数、颜色、变换、相交检测、随机数。听上去简单,但性能差异可以非常大。
前几年我接手过一个项目,数学库还是最原始的类封装,每个Vector3都是按值传参,所有运算符返回新对象,结果就是任何大量向量计算的系统(物理、骨骼动画、粒子)都跑不快。后来换成基于SIMD的实现,内存布局改成16字节对齐的XMMVector,尽量使用float4而不是三个分离的float,性能直接提升了一个量级。有个很容易忽略的细节:编译器对SIMD的优化依赖参数的传递方式,最好统一约定按指针或者按const引用传递,而不是按值传递。自己写数学库的时候,能用inline函数就不要用成员运算符重载返回对象,否则很容易生成大量中间对象拷贝。
容器这块,问题集中在对“标准库容器能不能直接用”的判断上。我的观点是:编辑器、工具代码用标准库完全没问题,但游戏运行时的高频路径需要定制容器。因为你往往需要精确控制分配器、尾插效率、缓存命中率、迭代器失效行为。常见做法是提供一套“面向引擎场景的容器”,比如小尺寸固定数组、帧内对象池、内存友好的哈希表,每个容器都带上自定义分配器参数。
还有一点就是IO抽象层。不同平台下读取文件的API完全不同,有的平台天然就是异步IO,有的平台同步读取会卡线程,有的平台文件路径大小写规则都不一样。引擎基础架构里就应该提供一个统一的文件访问接口,把同步/异步、路径规则、压缩包内读取、热更新挂载这些杂事全部封装到位。实操中,这个层还要支持虚拟文件路径和真实磁盘路径的映射,不然做补丁、热更、DLC都寸步难行。
- 运行时框架:主循环、帧频与任务调度
3.1 帧循环到底在循环什么
游戏引擎“跑起来”的核心就是主循环,也叫帧循环。每帧必须完成几件事:处理输入、更新逻辑、更新物理、更新动画、渲染准备、提交渲染命令、收尾。这个循环是引擎的“心跳”,最基础的版本长这样:
while (running) { float deltaTime = timer.Tick(); inputSystem->Update(deltaTime); physicsSystem->Update(deltaTime); animationSystem->Update(deltaTime); gameplaySystem->Update(deltaTime); renderer->BeginFrame(); renderer->RenderScene(gameWorld); renderer->EndFrame(); }这个写法看起来天经地义,里面全是坑。第一个坑是deltaTime的不稳定。主循环每一帧要处理的消息不同,导致帧耗时上下跳动,如果把真实耗时直接传给物理,物理模拟就会忽快忽慢。通常引擎会拆成固定步长逻辑更新和渲染帧率两套概念,逻辑系统使用固定的物理步长,例如1/60秒,然后靠累加器决定每帧跑几次逻辑更新,而渲染依然使用每帧实际插值来输出画面。
第二个坑是主循环里的系统顺序。早年间很多引擎把“渲染”放在循环最后,然后想象成每帧从输入到渲染的线性过程。但引擎系统之间并不是简单的先后关系,更常见的是依赖数据流:物理产生变换给渲染、动画混合结果也喂给渲染,AI读取感知结果再更新行为。哪一步先、哪一步后,直接影响延迟和表现。比如在帧内先跑物理再跑动画,还是先动画再物理,会让角色的脚踩到地面时的表现有明显差异。
3.2 多线程下的任务调度,比加锁更重要
今天的引擎不可能只在单线程里跑。核数那么多,不用白不用。但多引擎的难点从来不是“开线程”,而是“数据竞争”。直接上互斥锁,这是最简单也最容易导致性能崩盘的办法。锁一多,等待变多,核再多也没用。
引擎基础架构里最常用的多线程模式是任务图/任务调度,也叫Job System。基本思想是把每帧要做的工作拆成一个个小任务,比如“更新场景中所有角色的骨骼动画”“更新粒子系统”“生成阴影贴图”“做遮挡剔除”,任务之间存在依赖关系,后一个任务等前一个任务完成后才能执行。调度器用线程池在多个核心上并行调度这些任务。
我实话说,手写一个Job System不难,难在两点:
- 依赖图的构建和死锁规避。任务之间的依赖如果成环,就必须在设计期规避。常见做法是支持“任务A等待任务B结束后开始”,但不支持“A等B、B等A”的互相等待,检测到环直接断言失败。
- 并行粒度与缓存局部性的平衡。任务拆得太细,调度器和缓存同步的开销比收益还大;拆得太粗,又无法吃满多核。这个平衡没有银弹,取决于目标平台和场景复杂度,得靠分析器数据说话。
3.3 并发读写:渲染线程和逻辑线程的协同
引擎基础架构里有一个常见但需要谨慎使用的设计:渲染线程和逻辑线程并行,中间用命令缓冲区传递渲染数据。也就是说,逻辑线程构造下一次要渲染的场景描述,而渲染线程同时消费上一次的渲染命令。这样可以提高帧率,也可以防止渲染API的阻塞拖垮主线程。
这个方案带来的直接问题就是“你看到的东西老是慢一帧”,哪怕逻辑线程已经把角色位置更新好了,渲染出来的还是上一帧的位置。有些时候为了流畅度,这种延迟是可以接受的,因为感观上不容易察觉;但对交互性很强的操作(比如射击瞄准),延迟就不能太大,否则手感发飘。所以一些引擎会给关键输入单独走一套低延迟通道,不经过帧尾的渲染命令提交路径。
我自己踩过一个比较深的坑:并行渲染时,不要尝试在渲染线程里直接读取逻辑线程的实时数据当作“最新数据”,特别是有物理修改的动态顶点、粒子位置这类高频数据。一定要通过快照或者双缓冲的方式传递,否则你修复一个数据竞争,另外几个数据竞争又从画面闪烁里冒出来。简单来说,数据只有一份,线程却有两个,设计上最好约定清楚每个数据结构的归属线程。
- 资源管理:加载、引用与依赖的关系
4.1 资源不只是“文件”,更是运行时的对象
引擎里所谓“资源”,指的是模型、贴图、材质、动画、音频、字体、特效配置这些可以被复用的内容。资源管理的目标是:让各功能系统能够在运行时按需加载、使用、释放资源,同时保证不会重复加载、悬空引用或者用错版本。
如果只把资源当成“硬盘上的文件”,那访问起来就是“文件读进来、转换成引擎对象”这么简单。但实际引擎的复杂性来自三方面。第一,资源可能带有大量依赖:一个模型可能依赖一批骨骼权重、材质参数、贴图。第二,资源的加载时间可能非常长,不能同步卡住主线程。第三,多个场景、多人系统、热更新可能同时争用同一份资源,卸载时机很难把握。
因此资源管理在引擎基础架构中,往往以“资源管理器”的形式存在,它的核心API是这样的:
ResourceHandle meshHandle = resourceManager->LoadAsync("character/mesh_zombie.fbx"); ... meshHandle->Get<Mesh>(); // 资源加载完成之后才可安全访问返回的Handle是一个引用计数的智能指针,资源内部维护计数,没人引用时会自动卸载。要注意的是,这个引用计数必须跨线程安全,否则一个资源在加载线程卸载、在渲染线程还在被使用,轻则画面闪一下重则直接崩溃。
4.2 异步加载的关键路径
资源加载最容易被“优化”坏的地方是异步加载流程。当引擎请求异步加载资源时,本质上发生了一系列事情:
- 资源管理器校验请求,如果已有同名资源正在加载或已经加载,直接返回已有对象。
- 打开文件流,读取磁盘数据。这一步通常放在独立IO线程,不能阻塞主线程。
- 解析文件格式,构建引擎对象。纯CPU的解码/反序列化工作往往很耗时,可以放在提前加载阶段或者任务图里。
- 如果资源依赖GPU资源(贴图、Vertex Buffer),则要把数据上传到显存,这个过程必须放在渲染线程的合适时机。
- 完成回调触发,通知等待者资源可用。
实操中有一个很常见的教训:异步加载不等于“加载一定会很快完成”,一定会有资源没有准备好的时刻。所以场景切换时,所有需要同步使用的资源都应当提前排队预加载,而不是到现场再去请求。遮罩一个黑屏Loading画面看起来简单,但它背后其实是资源管理系统在按依赖图顺序完成所有前端加载。
要判断资源系统设计得好不好,有个很经典的自测场景:两个场景切换,整体耗时是多少?如果触发跨场景加载时明显卡几秒,大概率不是网络问题,而是架构上没有做异步加载、资源没有做依赖预分析、不用的旧资源也没有及时释放。
4.3 依赖分析和引用计数,别等到运行时才去“猜”
资源管理器必须维护一套“资源依赖图”。简单说,每个资源声明它依赖哪些子资源,这样才能在加载时自动把下游也加载出来,卸载时也知道到底哪些对象要一起处理。
之前有一个项目,美术改了一个贴图,但代码里只加载模型资源,模型依赖的材质和贴图没有自动加载机制,结果模型永远是白模。后来补齐了依赖分析,这套问题才算根治。资源打包工具也是基于这份依赖图来构建安装包/热更新分包的,依赖关系先于运行时确定下来,相当于“把能提前做的事都提前做完”,运行时只负责按图索骥。
提示:资源依赖图不要靠资源文件内部写死,最好在构建阶段生成一份“资源索引+依赖数据库”,运行时按数据库知道自己下一步加载谁。这样能大幅减少运行时解析的开销。
- 场景与实体组件:架构里最核心的组织方式
5.1 从“对象树”到“组件系统”,架构取舍的演化
引擎场景的组织方式,十几年来发生了很大的变化。传统的地图编辑器风格的引擎,场景是一棵节点树,每个节点就是一个对象,可以挂脚本、挂渲染器、挂音频组件。这棵树支持层级变换传递(父节点动,子节点跟着动),很适合直接把编辑器的Hierarchy面板映射过来。
但这种组织方式在工程上有明显局限。行为逻辑被硬编码在对象类里,新功能要么继承派生、要么往对象里堆大量可选项,导致类体系又宽又深,对象体积臃肿。所以后来大量引擎引进了组件系统:一个游戏对象是一个容器,里面装不同组件,组件负责各自的行为,渲染、物理、音频都是组件。这样代码复用和扩展性都好了很多。
再往后,为了性能继续演进到了现代ECS(Entity-Component-System)。ECS架构把对象、数据、行为彻底拆开:实体只是一个ID,组件是纯数据容器,系统是处理逻辑的函数。数据按组件类型组织在连续内存里,遍历时缓存命中率极高,也方便并行处理。
对引擎基础架构来说,这种从对象树到ECS的演进不是谁替代谁的关系,而是不同取舍。小项目用对象树可以很直观;当实体数量上万、组件类型多样、多线程并行需求上来以后,ECS方案在性能和组织清晰度上优势明显。很多引擎现在都是两者共存,上层用GameObject类写玩法,底层用ECS做批量数据处理。
5.2 Transform层级与缓存问题
不管用哪种组织方式,场景里总有大量对象需要维护父级子级变换。如果场景有上万个对象,每个对象都维护一个单独的变换矩阵,很容易产生两个问题:一是矩阵乘法重复计算,二是缓存不友好。
实操中解决方案通常是:把所有对象的局部变换和世界变换都保存在结构紧凑的数组里,只对真正发生变化的节点做增量更新,而不是每帧全量重算。这需要一个脏标记系统,节点变化后标记自己“脏”,只更新自己和后代。我之前见过一个项目,没有做脏标记,每帧对所有节点重算世界矩阵,几千个角色的时候CPU时间就肉眼可见地飙高,加个脏标记之后,很多静态物体直接跳过了全帧矩阵更新,速度明显上去。
场景管理也是类似逻辑。四叉树、八叉树、BVH这些空间数据结构,不只是渲染用,物理碰撞、视野裁剪、AI感知也会用。引擎基础架构里的场景模块最好提供一套通用的空间查询接口,而不是让每个系统自己写一遍。
- 工程视角:从零到一搭建你自己的基础架构
6.1 最小闭环先跑起来,不要贪多
如果你现在想自己写一套引擎或者改造开源引擎,我的建议是先从“最小可运行的帧循环”开始,不要一上来就设计几十个模块。
一个最小的基础闭环包括了:主循环、输入抽象、窗口创建、简单渲染管线(清屏+绘制一个三角形)、简易时间系统。这几样东西跑通后,你已经有了“每帧做事的节奏”。接下来再叠加资源管理、场景管理和组件系统。每加入一个新模块,都保持遵循前文说的单向依赖规则。
为什么强调最小闭环?我见过不少开发者一开始就规划好几十个模块的接口,写了三个星期还没有一个能跑的画面,热情就被消耗光了。反过来,先把骨架立起来,后续每一个模块填充进去都看得到效果,迭代速度反而更快。
6.2 模块扩展的优先级:哪些值得先投入
假如你是在学习阶段自研引擎,或者是从零搭建中小型项目的基础架构,我会建议按这个优先级投入:
- 平台抽象层。只要你想支持不同平台,这个越早做越好,不然后续每移植一次,改得痛不欲生。
- 日志与断言系统。没有这两样,排查问题全靠运气,你每天都会在崩溃日志里浪费大量时间。
- 资源管理。没有它,场景加载和热更新的复杂度会淹死你。
- 内存分配器。至少做一个帧分配器,可以立刻让很多临时分配开销下降。
- 主循环与任务调度。先单线程跑通,再逐步并行化。
6.3 调试与可观测性:架构真正让你活下来的地方
这里提醒一个很多人忽视的点:引擎基础架构最大的价值,其实是让“程序可观测”。换句话说,你的引擎应该能回答一系列问题:当前在加载什么资源、为什么卡了一帧、内存涨在哪、哪个系统占用了多少时间。这些能力不是额外福利,它本身就是架构目标。
比如提供按帧的性能剖析标记(Profile Marker),让任何系统代码都能把自己包一层耗时统计;提供运行时控制台,让你能改参数而不用重启;提供断点式的资源状态查看工具,让你知道某个资源加载到哪一步了。没有这些,再漂亮的分层也救不了你,因为你连问题出在哪一层都定位不到。
6.4 常见问题排查速查表
根据我这些年的实践,把引擎基础架构开发中高频出现的坑整理成一张速查表,方便你排查时参考:
| 现象 | 概率最大的原因 | 排查方向 |
|---|---|---|
| 偶尔掉帧,时不时的卡顿 | 高频路径出现堆分配 | 用内存统计看是否有持续分配/释放 |
| 场景切换时黑屏很久 | 资源没有提前预加载,或者依赖链断掉 | 打点看加载时长分布,检查资源依赖图 |
| 多线程后画面撕裂/闪对象 | 逻辑线程与渲染线程共用可变数据 | 检查是否有直接跨线程读写了共享数据 |
| 发布会卡,但单帧耗时不高 | 可能是资源流式加载后台IO阻塞 | 检查IO线程突发队列和磁盘速度 |
| 内存峰值异常高 | 资源引用计数泄漏或重复加载 | 开启资源管理器调试,打印引用计数 |
| 开始好好的,越玩越卡 | 每帧都触发对象创建/销毁 | 查找临时对象,确认是否走了对象池 |
| 打开场景就崩溃 | 资源加载未完成就访问 | 排查是否访问尚未加载完成的资源句柄 |
这张表不可能覆盖所有情况,但大部分引擎层的问题,顺着这些方向都能找到踪迹。
我个人在实际操作中的体会是,引擎基础架构不是一个“设计完就完事”的东西,它更像是养成系的迭代过程。每次你加上新系统,架构都在承受新的压力,但只要分层清晰、依赖方向正确、可观测性足够,它就能持续扛住复杂度增长。相反,基础架构没立住的项目,团队越大,内耗越高。
这也是我强烈建议任何人去尝试一下“写个最小引擎”的原因。哪怕最后只是在一个窗口里渲染了一个三角形,这个过程中你真正理解到的主循环、资源生命周期、线程模型这些东西,比看十篇架构文章都管用。
下一篇我打算展开写渲染系统在引擎架构下的设计路径,包括渲染流水线的组织、资源上传和GPU数据流的管理,这些内容足够再写一篇长的。先到这,我要去处理项目里一个粒子对象池的泄漏问题了。