1. 从“谁在写引擎”说起:团队分工决定了架构长什么样
很多人第一次接触游戏引擎架构,习惯性地打开源码从main函数往下读,结果读了两千行还在初始化内存分配器,最后放弃。我早年也这么干过,后来才想明白一件事:引擎架构不是凭空设计出来的,它是团队分工的投影。你去看任何一款自研引擎的模块划分,几乎都能反推出这家公司的组织架构——谁负责渲染、谁负责物理、谁负责工具链,模块边界往往就是部门墙的位置。
这个规律不是我瞎总结的。商业引擎和自研引擎在这件事上表现完全不同。商业引擎(比如面向大众授权的通用引擎)必须把模块切得极其干净,因为它的用户是外部团队,接口一旦定死就很难改,所以架构上倾向于“大内核 + 插件化”,渲染、物理、音频、脚本各自独立,通过稳定的抽象层通信。而自研引擎往往服务于单一项目,模块之间可以“脏”一点,直接互相调用,省掉抽象开销,代价是复用性差。你在做技术选型时,第一件事不是看哪个架构更“先进”,而是先问自己:这个引擎是给一个团队用,还是给很多团队用?
1.1 三种典型分工模式对应的架构形态
我把常见的团队分工归纳成三类,每一类都对应一种架构倾向。
第一种是垂直切片型。小团队(5 到 15 人)常见,每个人从头到尾负责一个功能,比如“角色系统”一个人包干,从动画到碰撞到状态机全管。这种模式下引擎架构通常是薄引擎 + 厚游戏层,引擎只提供最基础的平台抽象(窗口、输入、文件、渲染设备),游戏逻辑直接写在引擎之上,模块之间耦合严重但开发速度快。我见过不少独立团队用这种模式,三个月做出可玩 Demo,代价是第二个项目几乎没法复用第一个项目的代码。
第二种是水平分层型。中大型团队(30 人以上)常见,按技术领域切分:渲染组、物理组、音频组、工具组、引擎基础组。这种模式下引擎架构必须是厚引擎 + 薄游戏层,引擎提供完整的运行时和编辑器,游戏层只写业务逻辑。模块之间通过明确的接口通信,渲染组不关心物理组怎么算碰撞,物理组不关心渲染组怎么画。这种架构的典型特征是有一个核心服务层(Core Services),负责内存、任务调度、文件系统、日志、反射,所有上层模块都依赖它。
第三种是平台矩阵型。跨平台项目常见,团队按平台切分:PC 组、主机组、移动组,每个组都要维护自己平台的适配层。这种模式下引擎架构会多出一层平台抽象层(PAL),把文件 IO、线程、图形 API、输入设备全部抽象成统一接口,上层代码不直接调用任何平台 API。这一层的设计质量直接决定移植成本,我见过最惨的案例是一个引擎把#ifdef _WIN32散落在两百多个文件里,移植到新平台时改了三个月。
| 分工模式 | 团队规模 | 架构倾向 | 核心特征 | 典型风险 |
|---|---|---|---|---|
| 垂直切片型 | 5-15 人 | 薄引擎厚游戏 | 模块耦合、开发快 | 复用性差 |
| 水平分层型 | 30 人以上 | 厚引擎薄游戏 | 接口清晰、可维护 | 抽象开销大 |
| 平台矩阵型 | 跨平台项目 | 多一层 PAL | 移植成本低 | PAL 设计复杂 |
1.2 为什么“先定分工再定架构”比反过来更靠谱
我踩过的一个坑是:先画了一张漂亮的架构图,分层清晰、模块解耦,然后拿去给团队看,结果没人认领。渲染组说“这个渲染抽象层太薄了,我们还要自己管资源生命周期”,物理组说“这个物理接口太厚了,我们只想暴露几个函数”。最后架构图被改得面目全非,还不如一开始就按现有分工来设计。
正确的顺序是:先明确谁负责什么,再决定模块边界画在哪里。具体操作上,我会做三件事。第一,列出所有功能域(渲染、物理、音频、动画、脚本、网络、工具),标注每个域的负责人。第二,找出负责人之间的依赖关系,比如动画依赖渲染、物理依赖数学库。第三,把依赖关系画成有向图,强依赖的模块放在同一层,弱依赖的模块之间加接口。这样设计出来的架构,每个模块都有明确的“主人”,接口变更时知道找谁对齐,不会出现“三不管地带”。
提示:如果你现在正在设计一个新引擎,先别急着写代码。拿一张白纸,把团队里每个人的名字写上去,然后画线连接“谁需要谁的东西”。这张图就是你的架构草图,比任何 UML 工具都管用。
2. 底层架构的第一块砖:内存与资源管理为什么必须最先做
几乎所有引擎架构文章都会把渲染放在第一章,但我个人的经验是:内存管理没做好,后面全是坑。原因很简单,渲染、物理、音频、脚本,每一个子系统都要分配内存,如果每个子系统各自new和delete,最后你会得到一堆内存碎片、泄漏和难以追踪的崩溃。我见过一个项目在 PC 上跑得好好的,移植到主机上直接 OOM,排查了两周才发现是某个音频模块每次播放音效都分配一次临时缓冲区,主机内存本来就紧张,几千个音效同时播放直接把内存吃光了。
2.1 引擎内存管理的三层结构
一个可用的引擎内存管理通常分三层。最底层是系统分配器,直接调用平台 API(malloc、VirtualAlloc、mmap),这一层只负责向操作系统要内存,不做任何策略。中间层是通用分配器,提供Alloc、Free、Realloc接口,内部可以用不同的策略实现,比如线性分配器(适合帧内临时内存)、池分配器(适合固定大小对象)、栈分配器(适合作用域内存)。最上层是子系统分配器,每个子系统(渲染、物理、音频)有自己的分配器实例,互相隔离,一个子系统的内存泄漏不会污染另一个。
这种三层结构的好处是可追踪。你可以在通用分配器里加统计,记录每个子系统分配了多少、峰值多少、有没有泄漏。我习惯在引擎启动时给每个子系统分配一个带名字的分配器,比如RendererAllocator、PhysicsAllocator,然后在日志里定期打印各子系统的内存占用。这个习惯帮我抓过好几次泄漏,有一次发现物理模块的接触点缓存只增不减,跑一小时就吃了 2GB 内存。
2.2 资源管理的引用计数与生命周期
内存管理解决的是“怎么分配”,资源管理解决的是“什么时候释放”。引擎里的资源(纹理、网格、材质、音频片段)通常被多个对象引用,比如同一个材质可能被一百个模型用,你不能让第一个模型销毁时就把材质释放了。引用计数是最常用的方案,但引用计数有个经典问题:循环引用。A 引用 B,B 引用 A,计数永远不归零。
我的做法是区分强引用和弱引用。强引用增加计数,弱引用不增加。资源句柄默认是弱引用,只有真正需要持有资源时才升级为强引用。另外,引擎通常有一个资源加载器,负责异步加载和缓存。加载器内部维护一个资源表,键是资源路径,值是资源对象和引用计数。当计数归零时,资源不会立即释放,而是进入一个延迟释放队列,在下一帧或内存压力大时才真正释放。这个延迟机制很重要,因为同一帧内可能有多个对象释放同一个资源,立即释放会导致重复释放或悬空指针。
// 一个简化的资源句柄示例 template<typename T> class ResourceHandle { T* ptr; ResourceManager* manager; public: T* operator->() { return ptr; } // 升级为强引用 void AddRef() { manager->AddRef(ptr); } // 降级为弱引用 void Release() { manager->Release(ptr); } };注意:引用计数不是万能的。对于大型资源(比如整个关卡的地形数据),引用计数归零后释放可能造成帧率卡顿。这时候需要分帧释放,把释放操作拆到多帧里做,每帧释放一部分。
3. 渲染、物理、脚本的模块边界:接口怎么切才不打架
模块边界的设计是引擎架构里最容易吵架的地方。渲染组觉得物理组传过来的数据格式不对,物理组觉得渲染组管得太宽,脚本组觉得两边都不给它留口子。我参与过的一次架构评审,光是“物理碰撞结果怎么传给渲染”这一个问题就吵了一下午。最后定下来的方案是:物理组只输出碰撞事件和变换矩阵,渲染组自己决定怎么画。这个边界很清晰,物理不关心渲染,渲染不关心物理,两边通过一个中间数据结构通信。
3.1 渲染模块的边界:只认数据,不认逻辑
渲染模块的职责应该被严格限制在“把数据画到屏幕上”。它不应该知道这个数据是角色还是子弹,不应该知道这个角色在干什么,不应该知道游戏规则。渲染模块的输入应该是渲染队列(Render Queue)或场景图(Scene Graph),里面是一堆带有变换、网格、材质、光照参数的渲染项。渲染模块的输出是帧缓冲。
这种边界的好处是可替换。你可以在 PC 上用 DirectX 渲染,在主机上用自研 API 渲染,在移动端用 OpenGL ES 渲染,上层游戏逻辑完全不用改。我见过一个项目把渲染后端从 OpenGL 换成 Vulkan,因为边界切得干净,只改了一个渲染设备实现类,游戏层一行没动。
3.2 物理模块的边界:只算碰撞,不管表现
物理模块的职责是“算”。算碰撞检测、算刚体动力学、算约束求解。它不应该知道碰撞后的表现是什么,不应该播放音效,不应该触发粒子效果。物理模块的输出应该是碰撞事件和变换更新。碰撞事件里包含碰撞双方的身份标识、接触点、法线、冲量,游戏层拿到这些事件后自己决定怎么处理。
这里有一个常见的坑:物理和渲染的帧率不一致。渲染通常跑 60 帧或 120 帧,物理通常跑固定步长(比如 1/60 秒)。如果物理步长和渲染帧率不匹配,会出现抖动或穿透。解决方案是固定步长 + 插值:物理按固定步长更新,渲染时在两次物理状态之间插值。这个插值逻辑应该放在游戏层还是引擎层?我的经验是放在引擎层,提供一个PhysicsInterpolator,游戏层只需要调用GetInterpolatedTransform就行。
3.3 脚本模块的边界:只调接口,不碰内存
脚本模块(Lua、Python、C# 等)的边界最微妙。脚本语言通常有垃圾回收,而引擎的 C++ 层是手动管理内存,两者混用容易出问题。我的原则是:脚本层只能通过引擎暴露的接口访问引擎对象,不能直接持有 C++ 指针。引擎给脚本层提供的是句柄(Handle)或包装对象(Wrapper),脚本层拿到句柄后通过引擎接口操作,引擎层负责句柄到指针的映射和生命周期管理。
// 脚本层看到的接口 class ScriptAPI { public: // 通过 ID 获取对象位置 Vec3 GetPosition(ObjectID id); // 通过 ID 设置对象位置 void SetPosition(ObjectID id, Vec3 pos); // 播放音效 void PlaySound(SoundID id); };这种设计下,脚本层即使写出了while(true) {}死循环,也不会直接搞崩引擎,因为引擎可以在脚本执行超时后强制中断。我见过一个项目让脚本直接操作 C++ 指针,结果脚本里一个野指针把整个引擎干崩了,排查了一周才发现是脚本层的问题。
| 模块 | 输入 | 输出 | 禁止事项 |
|---|---|---|---|
| 渲染 | 渲染队列、场景图 | 帧缓冲 | 不处理游戏逻辑 |
| 物理 | 刚体描述、碰撞形状 | 碰撞事件、变换 | 不播放音效、不触发粒子 |
| 脚本 | 引擎接口调用 | 引擎状态变更 | 不直接持有 C++ 指针 |
4. 从零搭建一个最小引擎骨架:先跑通再优化
理论说了这么多,最后落到实操。如果你现在要动手写一个引擎,我建议不要一上来就追求完整架构。先搭一个最小骨架,能跑通“窗口 + 输入 + 渲染一个三角形 + 主循环”,然后再往上加模块。这个顺序很重要,因为主循环是整个引擎的心跳,心跳不稳,后面加什么都是白搭。
4.1 主循环的设计:固定步长还是可变步长
主循环有两种常见设计:固定步长和可变步长。固定步长是每次更新用固定的时间间隔(比如 1/60 秒),可变步长是根据实际帧间隔更新。固定步长的好处是物理和逻辑稳定,坏处是如果渲染帧率高于逻辑帧率,需要插值;如果渲染帧率低于逻辑帧率,需要追帧。可变步长的好处是简单,坏处是物理不稳定,帧率波动时可能出现穿透或抖动。
我的建议是混合模式:逻辑和物理用固定步长,渲染用可变步长,中间加插值。具体实现上,主循环维护一个累加器,每次循环把实际帧时间加到累加器上,当累加器超过固定步长时,执行一次逻辑更新,累加器减去固定步长。渲染时用累加器剩余时间做插值。
double accumulator = 0.0; const double fixedStep = 1.0 / 60.0; while (running) { double frameTime = GetFrameTime(); accumulator += frameTime; while (accumulator >= fixedStep) { UpdateLogic(fixedStep); accumulator -= fixedStep; } double alpha = accumulator / fixedStep; Render(alpha); // 用 alpha 插值 }这个模式我用了很多年,稳定性很好。唯一需要注意的是螺旋死亡:如果某一帧特别长(比如加载资源),累加器会积累很多,导致连续执行很多次逻辑更新,帧时间更长,恶性循环。解决方案是给累加器设上限,比如最多积累 0.25 秒,超过就丢弃。
4.2 平台抽象层的最小实现
平台抽象层不需要一开始就做得很完整,但窗口创建、输入处理、文件读取、时间获取这四个功能必须最先抽象。我见过太多项目直接在游戏代码里调用CreateWindowEx或glfwCreateWindow,后来想换平台时哭都来不及。
最小 PAL 的接口大概长这样:
class Platform { public: virtual bool Init() = 0; virtual void Shutdown() = 0; virtual Window* CreateWindow(const WindowDesc& desc) = 0; virtual double GetTime() = 0; virtual bool PollEvent(Event& outEvent) = 0; virtual FileData ReadFile(const char* path) = 0; };Windows 实现用 Win32 API,Linux 实现用 X11 或 Wayland,主机实现用平台 SDK。上层代码只依赖Platform接口,不依赖任何具体实现。这个抽象层的成本很低,但收益极高。我参与过的一个项目,因为一开始就做了 PAL,后来从 Windows 移植到 Linux 只用了三天,其中两天还是在装环境。
4.3 日志与断言:引擎的“黑匣子”
日志和断言是引擎开发中最容易被忽视、但出事时最救命的东西。我的习惯是引擎启动第一件事就是初始化日志系统,比初始化渲染还早。日志系统要支持分级(Trace、Debug、Info、Warn、Error、Fatal)、分类(按子系统打标签)、输出到控制台和文件。断言要区分 Debug 和 Release,Debug 下断言失败直接断点,Release 下记录日志并继续或崩溃。
#define ENGINE_ASSERT(cond, msg) \ do { \ if (!(cond)) { \ LogFatal("Assertion failed: %s, file %s, line %d", msg, __FILE__, __LINE__); \ DEBUG_BREAK(); \ } \ } while(0)我踩过的一个坑是:日志系统本身有 bug,导致日志输出死循环,引擎启动就卡死。后来我给日志系统加了一个递归保护,如果日志函数在日志函数内部被调用,直接丢弃并输出到 stderr。这个保护很简单,但能避免很多诡异问题。
提示:日志文件不要只写一个,按天或按启动次数分文件,否则跑几天后日志文件几个 GB,打开都费劲。我通常保留最近 10 次启动的日志,旧的自动删除。
5. 架构演进中的常见误区与我的应对经验
引擎架构不是一次设计好的,它是长出来的。我参与过的引擎,没有一个是一开始就设计成现在这样的,都是随着项目需求不断调整。但调整过程中有几个误区,我几乎每次都能见到,这里列出来供你参考。
5.1 过度设计:抽象层太多,调用链太长
新手架构师容易犯的错是为了解耦而解耦。一个简单的GetPosition调用,经过接口层、代理层、缓存层、适配层,最后才到实际数据,调用链十几层,性能差不说,调试时跟进去都晕。我的原则是:抽象层只在需要替换实现时才加。如果某个模块只有一个实现,而且短期内不会换,就不要加接口,直接调用。等真的需要换了,再重构也来得及。
我见过一个引擎把文件读取抽象了五层,结果读一个配置文件要经过FileSystem -> FileSystemProxy -> FileSystemCache -> FileSystemAdapter -> PlatformFile,每层都做一次字符串拷贝,读 1MB 文件花了 200ms。后来砍掉三层,直接PlatformFile,降到 5ms。
5.2 循环依赖:模块之间互相引用
循环依赖是架构腐化的开始。A 模块引用 B 模块的头文件,B 模块又引用 A 模块的头文件,编译时互相等待,最后只能把两个模块合并。我的做法是用前向声明和接口隔离。如果 A 需要调用 B 的某个函数,但 B 也需要调用 A,就把这个函数抽到一个独立的接口里,A 和 B 都依赖这个接口,而不是互相依赖。
// 不要这样 // A.h #include "B.h" class A { B* b; }; // B.h #include "A.h" class B { A* a; }; // 应该这样 // IObserver.h class IObserver { virtual void OnEvent() = 0; }; // A.h #include "IObserver.h" class A : public IObserver { ... }; // B.h #include "IObserver.h" class B { IObserver* observer; };5.3 工具链滞后:引擎跑起来了,编辑器还没影
很多团队把全部精力放在运行时引擎上,编辑器随便糊一个,结果策划和美术用不了,只能程序员手动改数据。我的经验是:编辑器要和运行时同步开发,甚至先于运行时。因为编辑器的需求会反过来推动运行时接口的设计。比如编辑器需要实时预览材质,运行时就必须提供材质热重载接口;编辑器需要撤销重做,运行时就必须提供命令模式支持。
我参与过的一个项目,运行时引擎做了半年,编辑器做了两周,结果策划宁愿用 Excel 配表也不愿意用编辑器。后来花了三个月重做编辑器,运行时也跟着改了不少接口。如果一开始就同步做,这三个月能省下来。
| 误区 | 表现 | 后果 | 应对 |
|---|---|---|---|
| 过度设计 | 抽象层太多 | 性能差、调试难 | 只在需要替换时加接口 |
| 循环依赖 | 模块互相 include | 编译慢、难拆分 | 前向声明 + 接口隔离 |
| 工具链滞后 | 编辑器简陋 | 策划美术用不了 | 编辑器与运行时同步开发 |
6. 关于引擎架构,我最后想分享的几条个人体会
写了这么多,最后说几条我自己的体会,不一定对,但都是踩坑踩出来的。
第一条:架构是给团队用的,不是给简历用的。我见过太多人为了“架构好看”而引入各种模式,结果团队里没人看得懂,维护成本比收益还高。好的架构是团队里每个人都能理解、都能改、都能扩展的架构,而不是最“先进”的架构。
第二条:性能问题要早发现,但不要早优化。引擎启动时加一个性能统计面板,记录每帧各模块的耗时,这个成本很低。但不要因为某个模块耗时 2ms 就去优化它,先看整体预算,如果总帧时间在预算内,2ms 就 2ms。等真的超预算了,再针对性优化。
第三条:文档要写,但不要写太多。引擎架构文档只需要写清楚三件事:模块划分、接口定义、依赖关系。不要写实现细节,实现细节看代码。文档太多没人看,太少又找不到,我的经验是每个模块一页纸,整个引擎架构文档不超过 20 页。
第四条:留好扩展点,但不要留太多。扩展点太多,代码里全是if (extension) { ... },可读性差。我的做法是:只在确定会扩展的地方留接口,比如渲染后端、脚本语言、平台适配,其他地方直接写死,等需要扩展时再重构。
第五条:测试要覆盖核心路径,但不要追求 100% 覆盖率。引擎的核心路径是:启动、加载资源、主循环、渲染、物理更新、脚本调用、关闭。这些路径必须有测试,其他边缘路径可以靠人工测试。追求 100% 覆盖率在引擎开发里不现实,投入产出比太低。
这些体会不一定适用于所有团队,但如果你正在从零开始搭引擎,或者正在重构现有引擎,希望它们能帮你少走点弯路。架构这件事,说到底就是在约束条件下做取舍,没有标准答案,只有适合你团队当前阶段的答案。