去年底,我带团队把自研引擎从 2D 原型引擎重构成能支撑 3D 场景的版本。重构结束后,我让一个刚入职的应届同学跟着模块图走读代码,一周后他的反馈让我印象很深:“模块图我看懂了,渲染是渲染,物理是物理,可我不知道玩家按下攻击键以后,伤害是怎么算出来的,动作又是怎么播出来的。”这个反馈像一盆冷水:几乎所有架构图都能让人产生“我懂了”的错觉,但真正决定一个游戏引擎好用还是难用的,从来不是那张静态的模块图,而是运行时的数据流、控制流和生命周期规则。这套东西,业界一般统称为“引擎基础架构”。
这篇文章是这个系列的第一篇,我打算先把底座讲清楚:什么才是真正的基础架构、一帧之内到底发生了什么、模块之间怎么划界、依赖规则怎么定、多线程和资源管理这些基础设施又是怎么演进出来的。内容会尽量贴近实际项目,把我在这类架构里踩过的坑和取舍理由一起说出来。适合刚入行的游戏客户端工程师、准备从应用层转向引擎层的开发者,以及想给商业引擎做底层扩展的同学。至于已经带过多个大型引擎项目的同学,也可以看看我在某些决策上的思路是否和你一致。
1. 基础架构不是模块图,而是运行时数据流的设计
很多人一提到引擎架构,第一反应就是那张经典的模块分层图:最下面是平台抽象层,中间是渲染、物理、音频、动画,最上面是游戏逻辑。这张图没有错,但它只描述了“静态结构”,没有回答一个更关键的问题:运行时数据是怎么流动的?如果架构只是模块图,那照图写代码的人很快会发现,自己依然不知道某个功能该挂在哪一层,某个对象该由谁创建、谁释放、谁能访问。
1.1 静态结构:模块地图只是起点
我习惯把引擎的静态模块分成几个典型层次,每个层次只做一类事:
- 平台抽象层(Platform):窗口、输入、文件系统、线程、时间、图形设备上下文。这一层存在的意义是让上层代码不关心你跑的是 Windows、主机还是移动端。
- 核心层(Core):容器、字符串、数学库、内存分配器、日志。这一层是通用的,不依赖任何功能模块。
- 功能层(Feature):渲染、物理、音频、动画、粒子、UI、网络。这些模块彼此独立,但都依赖 Core。
- 游戏框架层(Framework):场景管理、实体/组件系统、序列化、脚本桥、事件系统。这一层把功能层的能力组织成“游戏玩法”可以用的形态。
- 工具层(Tooling):资源编译器、DCC 插件、调试可视化、性能分析器。
这个分层是“地图”,地图的意义在于告诉我们有哪些地标,以及地标之间的基本方位。但是你要真正在代码里走动,需要的是道路规则:哪些路是单向的,哪里可以掉头,哪里是禁区。
1.2 动态结构:一帧内的数据和控制流
静态结构之外,引擎基础架构还有一个动态维度。以一次普通的“玩家攻击”为例,完整链路大概是这样的:
- 平台抽象层从输入设备读取手柄/键盘事件,放进输入缓冲。
- 游戏框架层的玩家控制器读取输入,把“攻击键按下”翻译成意图,驱动角色状态机进入攻击态。
- 状态机触发动画系统播放攻击动作,同时创建一个伤害判定体并交给物理系统。
- 物理系统在固定步长内做碰撞检测,命中目标后产生命中事件。
- 命中事件被游戏逻辑消费,扣血、播放命中特效、音效系统收到通知。
- 渲染系统在下一帧根据动画和特效的最新变换,重建可视场景并提交 GPU 命令。
这条链路里每一步都在跨模块传递数据,而架构基础就是约定“数据从哪来、到哪去、在哪个线程上跑、由谁负责释放”。这些约定如果写进文档但代码里没体现,很快就会被破坏;如果靠个人自觉,那基本等于没有架构。
1.3 常见误区:把架构做成 PPT
我见过不少项目,架构评审时模块图画得很漂亮,各部门也一致通过,但代码里却是另一回事:渲染模块直接调文件系统、物理模块里出现业务回调节点、组件系统依赖 UI 系统。架构沦为了评审 PPT,原因就是缺少可执行的约束机制。真正可落地的架构,应该是代码里能检查的东西:头文件依赖是否违反分层、跨模块是否只通过声明好的接口通信、生命周期归属是否明确记录。在后面的章节里,我会专门讲怎么把这些约束固化到日常开发里。
2. 模块划分与依赖规则:先写清楚“谁能调用谁”
模块划分看起来简单,真正难的是定依赖规则。依赖规则决定了代码的可编译性、可测试性和可扩展性。它本质上不是审美问题,而是工程约束问题。
2.1 分层依赖树怎么定
在一个规范的分层架构里,依赖方向只有一个:上层依赖下层,下层不知道上层存在。平台抽象层不依赖任何功能模块,功能层之间原则上不能互相依赖。如果动画系统想用物理系统的 Query 接口,通常的做法是把物理 Query 能力下沉到核心层公共接口,或者通过游戏框架层做转发,而不是让动画模块直接 include 物理模块。
实际落地时,我会用一个脚本定期扫描模块之间的头文件 include 关系,任何一条反向依赖都会触发警告。这个脚本写起来不复杂,但它的威慑力比几十页架构文档大得多。因为代码审查只能拦住“人眼可见”的违规,而自动检查可以拦住所有历史包袱。
2.2 环形依赖:为什么会出现、怎么拆
环形依赖是引擎里最典型的架构事故。举一个我真实处理过的例子:动画系统需要读取物理系统维护的骨骼碰撞体来矫正动作,物理系统又需要读取动画系统算出来的骨骼矩阵来做布料模拟。两个模块都想直接调用对方,结果代码里出现了一个巨大的、互相 include 的怪圈,每次编译都要十几秒,改一个骨骼接口要同时动三个模块。
拆掉的思路是这样的:先把“骨骼矩阵 + 碰撞体包围盒”这些数据本身下沉到一个更底层的场景数据结构里,动画和物理都只读写这个数据,不直接引用对端模块。然后通过注册回调的方式解耦:动画系统注册“骨骼更新完成”事件,物理系统注册“碰撞体变化”事件,双方都只跟事件总线打交道。环形依赖拆掉以后,编译速度肉眼可见地提升了,修改接口时的波及范围也小了很多。
2.3 模块间通信用什么
模块间通信有直接调用和事件/消息两种范式。我的经验是:能直接调用就直接调用,事件总线是最后的选择。直接调用简单直观,能利用编译器做类型检查,调用链也容易跟踪和调试。事件总线虽然解耦,但它把调用链藏了起来,出了问题很难定位是谁发的、谁收的、执行顺序是什么。除非是跨线程通知、热重载、需要支持外部插件这类场景,否则我会尽量避免在引擎内部大量铺事件总线。很多项目把事件系统当成解耦万能药,结果调试成了噩梦,这属于典型的“拿着锤子看什么都是钉子”。
3. 帧循环设计:引擎里最不能被忽视的“节拍器”
如果说模块划分是引擎的空间骨架,帧循环就是引擎的时间骨架。所有游戏逻辑、物理模拟、渲染提交都被约束在这个循环里。帧循环设计的好坏,直接决定游戏在不同帧率下的表现稳定性。
3.1 三种主流时间步长策略
帧循环有三种主流策略,直接列出来对比:
| 策略 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 可变步长 | 每帧 deltaTime 作为模拟时间 | 代码简单,画面与模拟严格同步 | 物理不稳定、逻辑依赖帧率、网络同步困难 |
| 固定步长 | 每 tick 固定 1/60 秒,帧间隔内执行多个 tick | 物理确定、逻辑可预测 | 低帧率下需要追赶,可能堆积 |
| 半固定步长 | 固定步长模拟 + 渲染插值 | 兼顾稳定性与平滑 | 实现最复杂,插值逻辑易出错 |
商业引擎里,物理和网络几乎都是固定步长,渲染则追求尽量平滑。所以大多数引擎实际是第三种:混合方案。
3.2 固定步长累积器:一段不能省的伪代码
半固定步长的核心是累积器(accumulator)方案。下面这段伪代码是简化版,但结构完整,可以直接作为参考:
const double fixedStep = 1.0 / 60.0; double accumulator = 0.0; double lastTime = GetTime(); bool running = true; while (running) { double now = GetTime(); double frameTime = now - lastTime; lastTime = now; // 防“螺旋死亡”:如果某帧卡了很久,不能让模拟循环疯狂追赶 if (frameTime > 0.25) frameTime = 0.25; accumulator += frameTime; // 每 fixedStep 执行一次模拟 while (accumulator >= fixedStep) { StepSimulation(fixedStep); accumulator -= fixedStep; } // 渲染时用剩余时间做插值,让渲染帧平滑 float alpha = static_cast<float>(accumulator / fixedStep); RenderFrame(alpha); }那段if (frameTime > 0.25) frameTime = 0.25;是无数项目忽略的细节。当你打断点调试或者系统卡顿的时候,帧间隔可能达到几秒钟。如果不限制 frameTime,模拟循环会在一瞬间执行几百上千次物理步进,直接把游戏世界搞飞。限制上限后,哪怕卡顿导致表现慢了一拍,恢复后也不会出现世界瞬间错乱。
3.3 为什么物理必须固定步长,渲染为什么要插值
物理系统要求固定步长有两个原因:一是数值稳定性,物理积分在步长变化大时容易抖动甚至爆炸;二是确定性需求,同样的输入序列应该产生同样的模拟结果,这一点对回放和网络同步至关重要。
渲染插值则解决的是“画面平滑”问题。假设你的显示器和主循环是异步的,显卡可能以 144Hz 刷新,而模拟是 60Hz。如果不做插值,画面就会出现明显的卡顿和抖动。alpha 插值的本质是:把最近两次固定步长的模拟状态按时间比例混合,得到渲染时刻的近似状态。注意插值不是所有东西都能做,比如刚体碰撞后的瞬时响应、玩家输入响应这些需要强实感的,插值会引入额外延迟,所以要按模块控制插值范围。
3.4 一帧内各系统的执行顺序与延迟一帧策略
一帧内的执行顺序决定了数据读写的一致性。一个典型的帧序如下:
- 输入读取与缓冲:设备事件统一收集,不直接跑逻辑。
- 模拟阶段:玩家控制器、AI、游戏规则、动画状态机按固定步长更新。
- 物理步进:若干次 FixedStep,碰撞结果写入对应组件。
- 动画求解:根据骨骼层级求出最终矩阵,写进渲染数据缓冲。
- 数据汇总:把模拟结果整理成渲染需要的紧凑结构(可见集、变换、灯参数)。
- 渲染裁剪与命令录制:主线程或工作线程生成 GPU 命令。
- 提交与交换:提交命令缓冲,GPU 接管,帧尾同步信号。
其中渲染阶段往往会使用上一帧完成的模拟结果,也就是“延迟一帧”。这换来了渲染线程和模拟线程的并行机会,也避免了 GPU 和 CPU 在同一份数据上互相等待。代价是输入到画面的总延迟多了一帧,很多时候这个延迟是值得的。
4. 从单线程主循环到 Job System:多线程是基础架构的成人礼
早年引擎普遍是单线程主循环:逻辑、物理、渲染同一帧内串行执行。后来 CPU 频率不涨了,核心数却越来越多,引擎只能往多线程演进。这个演进不是简单地把几个函数丢到不同线程,而是整套基础架构的数据模型都要跟着变。
4.1 渲染线程出现的原因和代价
第一步是把渲染抽离成独立线程。主线程只负责填写作弊般的“命令缓冲”,渲染线程负责把缓冲提交给 GPU。这样主线程不再阻塞在驱动调用和 GPU 同步上。代价是两边的数据结构不能再共享同一个可变状态,你需要引入双缓冲或者帧资源池:主线程写的是这一帧的资源,渲染线程读的是上一帧的资源,两者通过 fence 同步。这个阶段最容易出的 bug 是“数据竞争但没崩”——画面偶尔闪一下,或者隔几分钟才崩溃一次,这种问题极难复现,只能靠 ThreadSanitizer 和大量代码审查去排查。
4.2 Job System:把每帧工作拆成一张依赖图
有了渲染线程之后,动画、物理、裁剪这些重活也都可以并行。但直接开裸线程管理会很混乱,所以现代引擎普遍引入 Job System:把每帧的工作拆成很多小任务,任务之间有依赖关系,调度器把它们分发到线程池里执行。听起来高大上,核心其实就是一个任务图和线程池:
struct Job { std::string name; std::function<void(JobContext&)> func; std::vector<JobID> dependencies; // 前置任务 int priority; }; // 调度器维护一个“就绪队列”,依赖全部完成后任务才可执行 class JobSystem { public: void Schedule(Job job); void WaitAll(); };实现层面,你可以用线程池加互斥锁做,也可以用 work-stealing 的无锁队列做更高效的版本。我的建议是:不要一上来就上无锁结构。无锁队列在高并发下确实快,但正确性极难保证,调试工具也少。先在锁版本下把任务依赖图跑通,再用性能分析确认瓶颈,最后再优化。小马拉大车式地追求“看起来很先进”,通常会让项目死在调试的泥潭里。
4.3 ECS:数据驱动的架构范式
多线程并行之后,传统的面向对象组件模式开始暴露问题:组件散落在不同对象里,每个系统都要遍历并离散访问内存,缓存命中率极低,加锁又麻烦。ECS(Entity-Component-System)就是为这个问题来的:实体只是一个 ID,组件是紧凑的纯数据数组,系统是处理这些数组的逻辑。
ECS 的核心价值在于数据布局连续化。举个例子:物理系统要更新一万个刚体,传统对象式写法要遍历一万个指针,每个指针跳到不同的内存地址取值;ECS 写法直接遍历一个连续的 float 数组,CPU 缓存友好度天差地别。同时,不同系统处理不同组件,天然适合并行。Unity 的 DOTS、Bevy、以及不少主机大作都走了这条路。
但我要泼一盆冷水:ECS 不是银弹,小规模项目和大部分原型阶段用传统组件就够了。引入 ECS 意味着序列化、调试工具、编辑器 UI 甚至招聘成本都要跟着变。如果项目只有几百个实体,ECS 带来的性能提升根本感觉不到,复杂度倒是实打实的。我的经验是:当你发现系统的遍历已经成为性能热点、或者并行化无从下手的时候,再考虑迁移 ECS 也不迟。
5. 资源加载管线:基础架构里最容易“异步反噬”的地方
资源管理是很多引擎最容易出问题的环节。它不只是“读文件”,还牵涉引用关系、生命周期、跨线程加载、热重载和最终打包。基础架构里这块做得好不好,直接影响迭代效率和玩家看到的加载时间。
5.1 资源不只是文件
引擎里的资源是一个更广义的概念:一份网格数据、一张纹理、一个音频 clip、一个预制体、一段动画曲线,都是资源。每份资源通常对应磁盘上的一个文件,但运行时还要附加元信息:GUID、类型、版本、依赖列表、引用计数。资源系统的基本职责是:维护从资源 ID 到实际数据的映射,管理数据的加载/卸载,并在加载完成后回调给请求方。
5.2 引用计数与加载状态机
资源加载最核心的机制是引用计数加状态机。每个资源都有几个状态:
| 状态 | 含义 |
|---|---|
| NotLoaded | 尚未加载,只有元信息 |
| Loading | 正在异步加载中 |
| Loaded | 数据就绪,可以被使用 |
| Failed | 加载失败 |
| Unloaded | 已释放数据,回到未加载逻辑 |
异步加载时,写一个状态机可以减少大量重复代码。下面是简化示例:
enum class ResourceState { NotLoaded, Loading, Loaded, Failed, Unloaded }; struct Resource { ResourceId id; ResourceState state; std::shared_ptr<void> data; std::vector<std::shared_ptr<Resource>> dependencies; }; void LoadResource(ResourceHandle handle, LoadCallback cb) { auto& res = GetResource(handle); if (res.state == ResourceState::Loaded) { cb(handle); // 已加载,直接回调 return; } if (res.state == ResourceState::Loading) { queueCallback(handle, cb); // 正在加载,先排队 return; } res.state = ResourceState::Loading; AsyncReadFile(res.path, [handle, cb](ByteBuffer buffer) { // 依赖先加载,数据再解析 LoadDependencies(res.dependencies, [handle, cb, buffer]() { ParseResource(buffer); res.state = ResourceState::Loaded; cb(handle); // 处理排队中的回调 }); }); }这个状态机避免了一个经典 bug:同一帧内多个系统请求同一个未加载资源,结果资源被触发了两次加载,二次回调把数据覆盖掉。把 Loading 状态和回调队列做进去,这个问题就从根上消失了。
5.3 异步加载、热重载与打包管线的那些坑
异步加载最常见的坑是把“异步”写成了“异步开倒车”:你开了几个异步文件请求,却在回调里访问主线程的游戏对象,或者反过来在主线程里等待异步结果,把线程又卡住。调通一个异步系统最快的方法是定死一条铁律:资源数据在加载线程解析,解析完成后只把不可变数据交出去,任何跨线程可变共享都是架构红线。
热重载在开发期很有用,改个材质参数、调个特效数值,不需要重启游戏就能看到结果。但热重载不是简单的重新读文件,它要处理引用更新、状态保持、以及运行时对象和资源实例的绑定关系。我的实操经验是,热重载只针对明确的资源类型,不搞全资源通用热重载,否则每修改一次都要担心那些做过引用缓存的模块没收到通知。
打包管线方面,最需要关注的也是资源依赖问题。开发期可以实时读原文件,发布期必须把资源打成包(Bundle/PAK),并按 GUID 映射而不是按路径映射。路径依赖的坑在于:只要美术改了目录结构,几万个引用就全断了。GUID 映射虽然初看麻烦,但目录随便挪,依赖关系稳定,这是值得早期就投入的基础设施。
6. 内存布局与分配策略:基础架构的隐形地基
内存是引擎最容易忽略却又影响最深远的模块。逻辑写对了,内存分配不对,照样会卡顿、闪烁、莫名其妙崩溃。这里说的不只是内存泄漏,还有碎片化、缓存不友好、分配锁竞争等等。
6.1 为什么引擎要自己管内存
malloc作为通用分配器,在实时引擎里会带来几个问题:一是锁竞争严重,多线程频繁分配和释放会让性能急剧下降;二是碎片化,长期运行后小对象交错,可用大块内存反而减少;三是不确定性,同样的操作在不同时间分配到的地址可能跳动,影响缓存命中。所以引擎一般会自己做内存分区,用不同策略的分配器管理不同生命周期:
- 栈分配器:按启动、关卡加载等阶段顺序分配/释放,适合关卡级数据。
- 帧分配器:每帧开始分配,帧末整体重置,适合临时计算缓冲,性能极好。
- 池分配器:同构对象一次性分配大量内存,对象释放时回收到池里,适合粒子、子弹、UI 元素这类高频创建销毁对象。
- 堆分配器:真正需要任意分配的场景,尽量少用。
帧分配器的实现其实很短,我贴一个最简版本:
class FrameAllocator { char* memory; size_t offset; size_t capacity; public: FrameAllocator(size_t size) : memory(new char[size]), offset(0), capacity(size) {} void* Allocate(size_t sz) { assert(offset + sz <= capacity); void* ptr = memory + offset; offset += sz; return ptr; } void Reset() { offset = 0; } // 每帧结束直接重置 ~FrameAllocator() { delete[] memory; } };注意:这里没有考虑对齐,实际项目里要在 Allocate 里加上对齐修正,比如offset = (offset + alignment - 1) & ~(alignment - 1),否则给 SIMD 或 GPU 数据分配会踩坑。
6.2 缓存友好性:数据布局比你想的更重要
很多性能问题不在算法复杂度,而在数据怎么放。假设有一万个动画组件,对象式写法的存储是分散的,每个组件对象散在堆的不同角落;遍历一次需要跳一万次内存。改成连续数组存储以后,纯遍历速度可能快一个数量级。这就解释了为什么现代引擎那么迷恋紧凑数组、结构体数组(SoA)、以及 ECS 那一套——它们解决的不是“存不下”,而是“读得太慢”的问题。
6.3 生命周期责任:谁分配、谁释放
内存管理最怕的不是分配不够,而是不明确谁负责释放。我的习惯是:任何跨模块传递的数据,必须写明所有权转移规则。是调用方释放?还是被调用方释放?还是引用计数?如果只想让调用方用完不关心,那就应该传引用或者只读视图,而不是裸指针。引擎里的智能指针要定制,不要直接用标准库的shared_ptr,因为标准库的引用计数是原子操作,跨线程安全的代价是计数器更新的性能损失,如果在一个帧内高频传递对象,这部分开销可以测出来。定制一个在线程内非原子的IntrusivePtr,在跨线程边界再转成原子版本,是更精细的做法。
7. 基础架构的演化节奏:技术选型从来不只是风格问题
讲了这么多具体的架构组件,最后想聊一个经常被忽视的问题:基础架构和项目形态的匹配。架构没有绝对好坏,只有合不合适。很多团队失败不是因为选错了技术,而是因为用错了场景。
7.1 引擎形态决定架构取舍
不同类型的项目,对基础架构的要求差异很大:
| 引擎形态 | 典型需求 | 架构倾向 |
|---|---|---|
| 单机手游 | 简单关卡、低并发 | 单线程主循环 + 轻量资源系统即可 |
| 高并发联机 | 确定性要求高 | 固定步长、网络同步、回放支持 |
| 主机 3A 大作 | 复杂世界、大量实体 | Job System、ECS、流式加载 |
| 工业/CAD 可视化 | 超大模型、插件生态 | 插件系统、可扩展渲染架构 |
最典型的是“联机游戏用了 3A 的 ECS,却连固定步长都没做对”,结果回放和反作弊全部崩坏。基础架构的选型是跟着业务形态走的,不是跟着热度走的。
7.2 什么时候重构基础架构
架构问题会积累,但重构的时机需要判断。我自己的观察信号是这样的:
- 跨模块调用开始失控,新功能不知道该挂在哪里。
- 并行化扩展举步维艰,用不了多核性能。
- 每次改数据结构都要动十几个文件。
- 加载和管理资源的方式出现大量重复代码。
如果出现三个以上,就该考虑做一次渐进式重构,而不是全量重写。重写引擎的诱惑很大,但绝大多数团队死在半路上。渐进式重构的顺序我建议是:先隔离依赖,修正模块拓扑;再改数据布局,把高频系统逐步切成紧凑数组;最后再上并行,用 Job System 把已经稳定的系统调度起来。每一步都要有可回归验证的基准,而不是凭感觉“感觉快了”。
7.3 架构守护:让规则活在日常开发里
架构文档写得再好,也会随着人员流动和工期压力漂移。真正能让架构活下去的,是把规则固化到工具链里。我在项目里推行过几件事,效果都还不错:
- 提交前自动化检查头文件依赖,违反分层直接阻止提交。
- Code Review 模板里专门加一栏“这个改动是否符合依赖方向”。
- 核心数据结构的所有权转移,必须由负责人评审。
- 新模块接入时,必须提供依赖图和生命周期说明,否则不通过架构评审。
这些约束乍一看很麻烦,但时间久了大家会形成条件反射,违反架构的成本变高,架构也就自然被遵守了。
写了这么多,最后想聊聊我个人的体会。做了这么多年引擎架构,我最深的感受是:架构不是某个天才设计出来的,而是在一次次“不合理的依赖”“莫名其妙的崩溃”“改一个参数要编译五分钟”的教训里长出来的。如果你正在搭自己的引擎,不要急于追求最前沿的方案,先把依赖规则、帧循环、资源生命周期、内存所有权这几件事做到真正清晰,后面的系统都是水到渠成。如果你在维护一个老引擎,也不要害怕重构,哪怕每次只挪动一块砖,只要能回到正确的依赖方向上,也是实实在在的进步。下一篇我会接着讲渲染后端的基础抽象和 GPU 资源管理,那又是一个大坑。