实话说,搞游戏开发的朋友,不管你是写玩法逻辑还是做引擎工具,迟早都会碰到一个绕不开的话题:游戏引擎架构。尤其是当你从“用引擎”切换到“看引擎”“改引擎”甚至“写引擎”的时候,最直观的感受就是——这玩意儿怎么这么多层?为什么一个简单的“显示一个三角形”背后,要牵扯出窗口管理、渲染设备、资源系统、内存分配、日志系统一大堆东西?
这就是引擎基础架构的威力。它在你不注意的地方,决定了整个项目后期是“越写越顺畅”还是“越写越想摔键盘”。这篇文章我打算按我自己的理解,把引擎基础架构拆开揉碎,从分层设计到核心模块,从运行时机到数据驱动,把“地基”讲清楚。写的是“(一)”,所以重点放在最底层的主干结构上,后面再慢慢往里填肉。
1. 游戏引擎基础架构要解决的三个根本问题
很多人第一次打开引擎源码会被扑面而来的模块搞得头皮发麻:平台层、核心层、资源层、渲染、音频、物理、动画、脚本……这些模块不是随便堆在一起的。它们的存在,本质上是回答三个问题。
第一个问题是运行时秩序。游戏是每帧都在动的程序,一秒钟要跑60次甚至144次更新。那么问题来了:谁先更新谁后更新?输入什么时候采集?物理什么时候步进?渲染什么时候提交?如果这些顺序全靠游戏逻辑自己约,项目一大人就会疯。引擎基础架构要做的第一件事,就是把这套“帧内秩序”定死:一个框架只负责一件事,定的死死的,不让上层去瞎猜。
第二个问题是模块边界。渲染要用数学库,物理也要用数学库,动画还得用;资源系统要读文件,音频也要读文件。那这套公共能力放谁那里?放渲染模块里,物理模块就得反向依赖渲染,那整个依赖图瞬间就烂了。所以引擎基础架构会强制划出一条边界:谁can依赖谁,谁绝对不能依赖谁,靠分层来执行这个规定。我见过很多项目死在“就破例这一次”上,破完一次,后面全是环。
第三个问题是数据与逻辑的流向。游戏世界里到处是实体、组件、资源、配置。架构得回答:数据活在哪儿,谁可以改它,改完怎么通知别人。这听起来像程序设计课的内容,但在引擎里是真刀真枪的。现代引擎几乎全走上了“数据驱动”的路子——用数据和配置来描述游戏内容,用框架代码去解释执行。架构决定了你改一个数值是“改一行配置”还是“改一坨C++代码然后再编译五分钟”。
2. 分层:一台引擎从下到上是怎么长出来的
2.1 平台抽象层:让引擎忘记“我在哪个操作系统上”
引擎想跨平台,第一道关就是平台抽象层。Windows、macOS、Linux、iOS、Android、主机平台,这些系统的窗口管理、输入事件、线程、文件路径、动态库加载、图形接口全都不一样。
平台抽象层的做法是给上层一个统一的虚拟接口,把差异封装在底层。比如窗口系统,引擎内部只有一个Window结构体,不管你是Win32的HWND还是iOS的UIView,创建完都变成同一个内部概念。输入事件也是,上层只认“手柄A键按下”“鼠标左键点击”,至于底下是DirectInput还是XInput还是GameController.framework,那都是平台层的事。
这里有个很多新手容易忽略的细节:平台抽象层连“文件路径”这种小事都要管。Windows路径分隔符是反斜杠,Linux和macOS是正斜杠,游戏资源目录在各个平台的真实位置还不一样(iOS的沙盒目录、Android的assets目录)。好的引擎会在自己的文件系统模块里,把这堆差异全部抹平,你在游戏代码里写data/models/player.fbx,引擎知道在不同的系统上该去哪找。
2.2 核心基础层:所有模块的公共工具箱
平台层之上,是核心基础层,也叫Core层。这层不负责具体游戏功能,它提供的是所有模块都要用的公共能力:内存分配、字符串处理、容器与算法(List、HashMap、String)、数学库(向量、矩阵、四元数)、日志系统、断言机制,还有后面单独要讲的事件系统。
Core层是引擎里“被依赖次数最多”的层。几乎每一个功能模块都会include它的头文件。但也正因如此,Core层必须恪守一个原则:不依赖任何上层的功能模块。换句话说,Core层永远不能知道“渲染器”“物理系统”是什么东西。一旦Core层去引用上层模块,依赖环就出现了,编译时间暴涨,模块化名存实亡。我见过最惨的情况是,有人为了方便,在Core的容器工具里加了个 “发送给渲染线程”的接口,从此全引擎所有模块都间接依赖Render,整个架构图直接画成蜘蛛网。
2.3 资源与资产管理层:让引擎知道“数据长什么样”
再往上一层是资源/资产管理层。游戏里的Mesh、Texture、AudioClip、AnimationClip、Prefab、Shader,这些都是“资产”。资产层要解决的核心问题是:这些文件怎么导入、怎么处理成引擎运行时友好的格式、怎么装载到内存、怎么跟踪引用计数、怎么在场景切换时安全卸载。
这层通常由一个资源管理器统一管辖。资源管理器维护一个大表,每个资源有唯一ID、文件路径、内存地址、引用数、加载状态(未加载/加载中/已就绪)。你需要一个模型时,不是直接开文件读,而是向资源管理器报一个ID,它返回一个资源句柄。好处是显而易见的:同一个模型被十个体块引用,内存里只有一份;切换场景时只需要按引用计数做增量卸载,不会粗暴地清空一切。
现代引擎的资源层还有一个重要职责:资产导入管线。美术丢进来一个FBX,引擎要先跑一个导入器,把它转成引擎自定义的二进制格式,再生成带缩略图的资产文件。这个管线如果做得顺,项目的协同开发效率能拉满;做得烂,项目后期光等美术资源重导就能耗掉一半工作时间。
2.4 运行时/场景管理层:游戏内容在这个层活起来
资源层再往上,就是运行时层,也叫场景管理或世界管理。这一层负责维护“当前游戏中到底有哪些东西”,也就是场景里的所有游戏对象、组件、脚本实例。它管理场景图、对象的创建与销毁、组件的添加与移除,以及每帧对这些对象做迭代更新。
放到引擎架构里,这个层往往是游戏引擎和纯游戏代码的“握手区”。引擎提供运行环境,你的玩法逻辑就是往这个运行环境里挂组件、注册回调。运行时层还会管理关卡/场景的生命周期:场景加载、场景切换、卸载旧场景。做得好的引擎,场景切换是渐进式的,先加载新的,再在某个安全时刻做交换,避免画面卡死。
这一层在架构上最考验抽象功力,因为它既要保持灵活性(允许各种刁钻的游戏类型),又要提供足够的秩序(性能和安全)。一直是引擎中上层架构迭代最频繁的区域,也是后面聊ECS(Entity Component System)时的主要战场。
3. 核心层设计:内存、日志、容器这些“基建”为什么不能将就
3.1 内存分配:为什么游戏引擎要自己管内存
在游戏引擎里,通用动态分配——就是操作系统默认的那个malloc/new——性能是不够看的。原因一是频繁小对象分配会产生大量碎片和系统调用开销;原因二是你需要知道内存的真实布局,才能给缓存友好性做优化。
引擎的常规做法是提供多重分配器。你需要在某个固定线程上做大量小分配,就搞一个线程局部的小块线性分配器(Linear Allocator),它只管往前顶指针,帧结束时一键重置。你需要给某类固定大小对象做批量管理,就搞一个池分配器(Pool Allocator),提前申请一大块内存,按固定大小切块,空闲块串成链表,分配释放都是O(1)。你需要在不同子模块间隔离内存,就搞分区分配器,避免物理系统的内存膨胀把渲染缓冲挤垮。
我自己的经验是:引入内存分配器的门槛并不高,但它带来的架构收益会逐步显现。特别是做主机平台和移动平台时,系统提供的堆往往很保守,而引擎自己管理的分配器可以全盘掌握可用内存,把碎片控制在极低水平。反过来,如果引擎不提供内存管理的概念,每个模块都默认new一把,后面做内存分析和内存预算就完全无从谈起。
注意:分配器的设计必须和所有权语义一起设计。也就是说,谁分配谁释放、释放发生在哪个阶段(帧末还是立即)、是否允许跨线程传递指针,这些在架构文档里就要写死。否则你很快会看到“分配在逻辑线程、释放在渲染线程”的悬空指针惨案。
3.2 日志系统:多线程下最容易被忽视的架构
日志系统看着简单,但在引擎架构里是典型的“看起来容易做起来难”。写玩法代码时打个console.log谁都会;但在引擎里,日志的调用方遍布逻辑线程、渲染线程、流式加载线程、音频线程,如果每个线程直接往stdout写,那输出就完全乱套,而且同步IO会卡线程。
成熟的引擎日志系统,架构上是“异步队列”模型。调用方只负责把日志消息塞进一个无锁环形缓冲(Ring Buffer),立刻返回。后台有一个专用的日志线程,负责从缓冲取出消息,写入文件、输出到调试器、或推给编辑器控制台。这样多线程下打日志的开销极其可控,不会因为某个IO卡住拖垮主游戏循环。
另外,日志系统还有一个在引擎里很微妙的职责:日志级别(Trace/Debug/Info/Warn/Error/Fatal)应当可以被运行时动态调整。大型项目往往在正式包里只开Error级别,但线上出问题后,可以通过后台开关把某几个模块的日志调到Debug,再复现一次。这样引擎架构就具备了“现场取证”的能力。如果你在设计引擎时没给日志系统留这套开关,后面上线排障会非常痛苦。
3.3 数据容器与数学库:STL能用但别直接用
这里可能争议很大。很多人说:我直接用STL不就行了?现代C++的STL品质并不差。但游戏引擎的容器有两个特殊要求:
第一是分配器可控。STL容器的默认分配器走的是全局new/delete,你要让它用上引擎自定义分配器,标准做法是给容器传自定义Allocator模板参数。结果就是要么全项目统一别名,要么到处写一长串模板参数。大多数引擎的应对方案是自定义一套自己的容器(如TArray、THashMap之类的轻量实现),内部默认走引擎分配器。这样写的人省心,控制的人放心。
第二是内存连续性。对性能敏感的系统的容器,必须保证元素在内存里连续排列。遍历一个巨大的vector<Component>和遍历一个链表式Component集合,帧尾开销差别可能是几十倍。引擎容器在设计时就倾向于“动态数组”而非“节点式容器”,遍历性能更好。你的CPU一级缓存就这么大,数据贴得越近遍历越快。
数学库就更是军事重地了。引擎里所有位置、旋转、缩放,最终都要变成矩阵和向量。数学库的架构重点,一是要提供FloatingPoint一致性——开启/关闭FastMath后不能出诡异偏差;二是要提供 SIMD 友好的内存布局。对齐到16字节甚至32字节的Vector3/Matrix4,才能放进SIMD寄存器。很多引擎还会直接让渲染层和物理层共享同一个数学库,避免两套矩阵互转的精度损失和性能浪费。
4. OS抽象层:引擎为什么非要自己包一层系统调用
4.1 窗口与输入:两个最常见的“平台差异陷阱”
游戏引擎几乎很少直接调用操作系统API来创建窗口,而是自己包一层。原因特别直白:引擎要同时跑Windows、主机甚至网页端,总不能给每个平台写一套完全不同的窗口逻辑吧。
窗口系统抽象后,引擎对外提供一个Window接口,支持设置标题、尺寸、全屏状态、垂直同步等一堆操作。平台层内部,Win32窗口、Cocoa窗口、SDL后端、甚至浏览器Canvas,都被包装成同一套接口。这样引擎上层(UI、渲染、工具链)就只需要跟Window打交道。
输入也一样。键盘鼠标是多数人的直觉认知,但对引擎来说,输入是“设备无关的抽象事件流”:键盘、鼠标、手柄、触摸屏统一成InputEvent。手柄在Windows上是XInput,在移动平台上是GCController,在桌面编辑器里可能被模拟成一套虚拟手柄。架构上,输入系统还要解决“映射”问题:游戏需要“跳跃”这个语义,但手柄上绑A键还是B键,应由配置决定,而不是写死在代码里。输入系统在基础架构里,本质上是一个“设备事件 -> 逻辑动作”的翻译层。
4.2 文件IO与线程:同步还是异步,这是个架构决策
同样一个“读文件”操作,在普通应用程序里随叫随读没问题,在游戏引擎里可就不同了。游戏主循环一帧只有16.6毫秒预算(60帧)或5.5毫秒预算(120帧),同步磁盘IO一卡就是几十毫秒,帧率直接爆掉。
所以引擎的文件IO层基本上强制走异步模型。上层请求“加载某张贴图”,实际接管的是一个流式加载系统:它维护一个IO线程池,从请求队列里取任务,后台用系统调用读文件,拷入内存后做解压或格式转换,最后发布“加载完成”事件。游戏逻辑层收到事件后才把资源挂到场景对象上。
这个设计会向上传染:资源系统、场景系统、流式加载管线全部要围绕异步回调来组织。这也是为什么引擎基础架构一开始就必须定IO模型的理由——等到了项目百人团队再改,几乎等于重写资源层。我再补一句:异步IO千万别自己用裸线程硬搞,一个标准的IO任务队列加线程池,比什么都管用。
5. 运行时组织:主循环、模块生命周期与Tick顺序
5.1 主循环的两种流派
引擎的“心脏”是主循环。几乎所有游戏引擎都有一个类似“while(running) { Update(); Render(); }”的大循环,但节奏控制有两种流派。
第一种是可变步长(Variable Step)。每一帧的时间戳间隔由真实时钟决定,上层更新会乘以DeltaTime,所以帧率波动不会导致游戏速度飘。绝大多数现代引擎的默认玩法逻辑更新都走可变步长,画面流畅度高,实现简单。
第二种是固定步长(Fixed Step)。物理系统几乎都走这种:每1/60秒或1/120秒固定步进一次,不管渲染帧是多少。为什么物理要用固定步长?因为物理方程的数值稳定性依赖一致的时间片。你把时间切成1/120秒,每一步的积分误差可控且一致。把渲染和逻辑放在同一个步长下,帧率一波动物理就可能疯掉。
这里有个实操经验:好的引擎架构会把“逻辑Update”和“渲染提交”分开。逻辑可以以固定频率跑,渲染则可以按显示器的刷新率尽量跑。主循环里,先处理输入事件,然后按固定步长推进逻辑,再执行渲染帧。为了减少逻辑卡顿,有的引擎还会把逻辑 Update 跟渲染帧率解耦,在渲染间隙补多次逻辑Tick。这就是所谓的“优先保证逻辑时间一致性”。
5.2 模块生命周期的启动与关闭
引擎里有一堆模块:物理、渲染、音频、网络、ScriptSystem……它们不是一启动就乱七八糟地同时运行,而是有严格的启动顺序。
为什么要有“顺序”?因为模块之间有依赖。打开日志系统才能记录启动信息;打开资源管理器才能加载初始资产;然后才能创建渲染窗口;渲染窗口就绪后才能初始化渲染设备;再之后物理与音频这类副系统才敢初始化,因为它们可能要分配GPU缓冲或音频缓冲。启动顺序不是钦点谁大谁小,而是资源依赖的必然结果。
对应的,关闭顺序恰好反过来:先停游戏逻辑,再卸场景,再关渲染设备,最后收日志。如果关闭顺序不对,最容易出现的问题就是:某个模块还在用另一个模块的资源,结果后者已经释放了,直接崩溃。这个问题在实际项目里极常见,特别是热重载和关卡切换阶段。所以引擎架构里通常会维护一个明确的生命周期列表,按照依赖排序来执行启动和关闭,而不是让每个模块自己在某个动态库里注册全局析构函数。
5.3 Tick顺序与依赖关系:谁先谁后,差之毫厘
同一次循环里,多个系统都要更新。那顺序如何决定?经常有人觉得“反正都跑,跑完就行”,但实际性能与正确性差得非常远。
以物理和动画的合作为例:动画系统先更新骨架,把骨骼矩阵写进缓存;物理再把玩家控制器/力场应用到角色身上;但如果在物理推进后再重新采样动画,就会有一帧角色姿态跟碰撞体位置对不上,也就是常见的“穿模时角色还保持上个姿势”。这就是Tick顺序的经验教训。
一个合理的更新流程大致是:输入采样、逻辑脚本(玩家控制)、动画系统(姿态计算)、物理系统(碰撞和约束求解)、摄像机跟随、粒子与音频播放、场景查询与UI、渲染提交。每个引擎都不太一样,但它一定是“被依赖的优先更新”。
另外,Tick顺序要支持“阶段切片”。到底哪些系统在PrePhysics阶段跑,哪些在PostPhysics阶段跑,架构上最好提供一个生命周期钩子。这样写玩法模块的人可以在音序里选择自己的挂载点,不用强行搞多线程同步。少一点黑科技,多一点明确顺序,项目的稳定性会高一个档次。
6. ECS与组件化:现代引擎基础架构的关键转向
6.1 从继承树到组合:一个GameObject的演进
老一代引擎(以及不少新手教程)喜欢用“继承树”:Entity -> Character -> Player,层级深、复用差。角色要能变成“植物人”怎么办?你还得调继承树。后来大家发现,组合远胜过继承。
现代引擎的标配是“GameObject + Component”。一个GameObject只是一个坐标和一堆组件的容器。它本身不定义“是什么”,而由挂载的组件来定义:挂渲染组件就能被画出来,挂碰撞组件就能参与物理,挂脚组件就能响应玩家输入。这样做的好处是极致的灵活性:同样是“武器”,在玩家手里挂一把刀的组件,在地上只是个静态拾取物。你不用为每种变化发明新的类。
在架构层,“组件容器”管理的就是一个对象及其组件的增删改查。你向场景发一条AddComponent的命令,引擎在运行时动态更新描述这个对象的组件数组。组件的排序通常遵循“同类型聚簇”,因为它顺便解决了缓存局部性问题——同一帧要更新所有Transform、所有MeshRenderer的时候,跳过泛型虚函数,直接在一个连续数组上跑循环。
6.2 数据连续性与缓存命中
ECS(实体组件系统)的核心思想,比“组合优于继承”更激进一点:它把组件数据从对象中抽出来,按“列”存成数组。场景里有一万个实体,每个实体都有Position组件,那么ECS把一万个Position放进一个连续的大数组里。更新系统时,一次线性遍历这一万个Position,CPU缓存命中率极高,性能非常稳定。
对比传统的面向对象方案:一万个对象散落在内存不同角落,每个对象又拖着数据,遍历时缓存行里能命中的比例很低。ECS把“对数据的访问模式”设计成连续扫描,在CPU架构上几乎是为游戏定制的性能方案。
现代引擎在基础架构里普遍把ECS作为核心运行时组织方式,还因为它在多线程上有天然优势——不同的System可以并行处理不同的组件数组,数据间没有共享变量,锁和同步可以极大减少。
6.3 什么场景不要上ECS
不过,别急着把一切都搬到ECS。以我个人经验,ECS也有它的“反能力”:
复杂对象间的多态行为在ECS里很难优雅表达。如果游戏里有大量类型不同、行为弱相关的对象,用传统组件的组合式脚本可能更直观。ECS的系统逻辑和组件数据分离,但某些天生内聚的对象(例如“一个对话框UI节点”),强行拆成Position / Text / Animation三个组件数组,反而会增加复杂度和跨系统同步成本。
我的倾向是:在引擎基础架构里把 ECS 作为一个可选而强力的运行时模型,而不要让全引擎所有东西强制ECS。像渲染场景、物理场景这种高频大数量场景可以尽量ECS化;玩法逻辑里的状态机、UI、AI行为树,用传统的组合对象或脚本系统反而更稳。一个架构成熟的引擎,应该能同时容纳两套模型,并提供互操作。
7. 数据驱动与资源管线:架构的另一半是数据
7.1 资源加载的异步化
游戏内容不是写死在代码里的,而是以资源形式存在。于是引擎基础架构的另一半重头戏,就是“资源加载”这件事。
资源加载的第一个原则:主线程绝不去同步读文件。前面聊IO时说过异步化,这里再来一遍是因为资源层的问题是连锁的。你请求加载一个模型,它可能依赖材质,材质又依赖贴图和Shader。资源管理器要能够找出依赖关系,形成一个加载图,再按拓扑顺序去异步载入。目标资源就绪后,再通知对它有依赖的对象。
这里往往藏着一个性能深坑:你以为只加载了一个大场景,实际上它背后拖挂了成千上万个小资源。如果架构没有做“依赖追踪”,每个资源独立加载,就会造成大量重复IO和重复解压,加载时间直接翻倍。所以资源架构里要有“引用图”的概念:哪里引用哪里,哪里释放条件满足。通过资源引用计数和依赖图,才能做出安全的增量加载、预加载、以及后台按优先级加载。
7.2 序列化与热重载
资源管线最后要落地成二进制格式。引擎架构里,序列化系统既要保证稳定(玩家存档/关卡文件不能因为引擎版本更新就读不出来),又要保证高性能(大场景秒级加载)。
做好序列化的一个关键策略是版本化与预留。每个资源文件头要有版本号和元数据区。引擎为每种资源结构标好“版本”,在读取时做兼容迁移。没有版本控制的资源管线,在团队协作中就是灾难:美术改了一版模型,程序的老代码读不了新资源,两边互相甩锅。别问我怎么知道的。
热重载也是这套架构的试金石。好的引擎,在编辑器中修改一份资源,能让运行中的游戏直接收到更新,而不用重启。实现方式通常是:资源管理器监听文件变化,重新导入资源后保留原ID,只替换底下的数据块,再有引用它的对象做同步刷新。做到这一步,玩法策划和美术的迭代速度会快得飞起。数据驱动架构的回报就在这些日常体验里累积起来。
8. 引擎架构的几个反模式:我踩过和见过别人踩的坑
8.1 全局单例泛滥
新手引擎最喜欢把所有系统做成全局单例:RenderSystem::Instance()、AudioSystem::Instance()、ResourceManager::Instance(),满屏Get()。
短期确实方便,长期必然埋雷:单例的初始化顺序不可控,关闭时生命周期交接混乱,测试时几乎无法替换依赖。更麻烦的是,它破坏了“依赖边界”——任何地方都能随手拿到全局状态,模块之间的边界就名存实亡了。
我在自己写过的一版引擎里就吃过这个亏。后来重构时,把单例改成“通过上下文对象传递依赖”(也就是注入到需要他们的模块中)。比如渲染系统需要资源管理器,不是在内部调用单例,而是构造时传入资源管理器指针。初见时觉得多写了不少代码,但项目规模上去之后,模块可测性、可替换性全部都回来了。
8.2 模块循环依赖
架构烂掉最典型的表现就是“循环依赖”:动画模块include了物理模块,物理模块又include了骨架数据模块,骨架数据由动画模块维护。这种图的编译时间呈指数级上升,改一个头文件,整个引擎所有模块全部重编。
修法只有一个,就是“拆”。找到环里的关键依赖,把它抽到一个更底层、谁也不依赖的公共模块。比如物理和动画要共享角色骨架数据,那就建一个“角色底盘数据模块”,里面只有纯数据和简单工具,谁都不依赖它?不,引擎里没有“谁都不依赖”的模块,准确说是它只依赖Core,而上层物理和动画都只依赖它。通过降低公共依赖的“体量”,把循环依赖断掉。
8.3 一锅端的更新循环
还有一个很隐蔽的反模式:在主循环的Update()里,把所有系统全遍历一遍,结果每个系统的代码都越长越肥,最后变成一个几千行的“超级系统”。
正确解法就是我前面提到的“Tick顺序”和“阶段钩子”。把大Update拆成多个生命周期钩子(如OnInput、OnPrePhysics、OnPhysics、OnPostPhysics、OnRender),每个模块只在自己的阶段里做自己的事。主循环本身保持轻量,只负责敲鼓点(Tick),具体每拍谁跳,由各模块挂到相应的阶段。这样以后加新系统、调顺序、做性能分析,都是在局部模块里完成,而不是在主循环里打补丁。
我在实际项目的体会是,玩架构不是炫技。你的引擎可以简单,但模块边界要清晰;可以不做全套ECS,但不能没有秩序;可以没有几十个系统的高大上调度器,但“启动/运行/关闭”的生命周期一定得稳定。做好这些,后面几十年(或者说直到你换语言重写)都省心。
最后再分享一个小技巧:每次面向引擎框架做设计时,先问自己一句“这个模块被谁依赖、它依赖哪些模块”,把依赖图画出来,超过三层环的,先停下来重构,再加上“有依赖边界的代码和没有依赖边界的代码,它们一年的维护成本差十倍”。这话可能略显极端,但在我自己的引擎开发生涯里,每次架构的回报都是长期显现的。希望这篇对你有用,下一篇具体聊渲染器还是物理系统,看评论区呼声了。