
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 引擎内存管理的三层结构一个可用的引擎内存管理通常分三层。最底层是系统分配器直接调用平台 APImalloc、VirtualAlloc、mmap这一层只负责向操作系统要内存不做任何策略。中间层是通用分配器提供Alloc、Free、Realloc接口内部可以用不同的策略实现比如线性分配器适合帧内临时内存、池分配器适合固定大小对象、栈分配器适合作用域内存。最上层是子系统分配器每个子系统渲染、物理、音频有自己的分配器实例互相隔离一个子系统的内存泄漏不会污染另一个。这种三层结构的好处是可追踪。你可以在通用分配器里加统计记录每个子系统分配了多少、峰值多少、有没有泄漏。我习惯在引擎启动时给每个子系统分配一个带名字的分配器比如RendererAllocator、PhysicsAllocator然后在日志里定期打印各子系统的内存占用。这个习惯帮我抓过好几次泄漏有一次发现物理模块的接触点缓存只增不减跑一小时就吃了 2GB 内存。2.2 资源管理的引用计数与生命周期内存管理解决的是“怎么分配”资源管理解决的是“什么时候释放”。引擎里的资源纹理、网格、材质、音频片段通常被多个对象引用比如同一个材质可能被一百个模型用你不能让第一个模型销毁时就把材质释放了。引用计数是最常用的方案但引用计数有个经典问题循环引用。A 引用 BB 引用 A计数永远不归零。我的做法是区分强引用和弱引用。强引用增加计数弱引用不增加。资源句柄默认是弱引用只有真正需要持有资源时才升级为强引用。另外引擎通常有一个资源加载器负责异步加载和缓存。加载器内部维护一个资源表键是资源路径值是资源对象和引用计数。当计数归零时资源不会立即释放而是进入一个延迟释放队列在下一帧或内存压力大时才真正释放。这个延迟机制很重要因为同一帧内可能有多个对象释放同一个资源立即释放会导致重复释放或悬空指针。// 一个简化的资源句柄示例 templatetypename 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 APILinux 实现用 X11 或 Wayland主机实现用平台 SDK。上层代码只依赖Platform接口不依赖任何具体实现。这个抽象层的成本很低但收益极高。我参与过的一个项目因为一开始就做了 PAL后来从 Windows 移植到 Linux 只用了三天其中两天还是在装环境。4.3 日志与断言引擎的“黑匣子”日志和断言是引擎开发中最容易被忽视、但出事时最救命的东西。我的习惯是引擎启动第一件事就是初始化日志系统比初始化渲染还早。日志系统要支持分级Trace、Debug、Info、Warn、Error、Fatal、分类按子系统打标签、输出到控制台和文件。断言要区分 Debug 和 ReleaseDebug 下断言失败直接断点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% 覆盖率在引擎开发里不现实投入产出比太低。这些体会不一定适用于所有团队但如果你正在从零开始搭引擎或者正在重构现有引擎希望它们能帮你少走点弯路。架构这件事说到底就是在约束条件下做取舍没有标准答案只有适合你团队当前阶段的答案。