拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从团队分工视角解析游戏引擎架构设计

1. 从引擎这个词说起为什么团队分工决定了架构长什么样很多人第一次接触游戏引擎架构脑子里冒出来的第一个画面是一堆类继承图、渲染管线、内存分配器。但如果你真的在一个五人以下的独立团队里干过你会发现最先压垮你的根本不是技术难点而是谁该写哪一块、谁写的代码该被谁调用这件事。游戏引擎架构的第一课其实不是技术课是组织课。我见过太多小团队在立项第一天就喊着我们要自研引擎然后主程一个人闷头写了三个月渲染器回头一看 gameplay 程序员在等他暴露接口工具链没人做资源管线全靠手动拖文件。最后项目黄了不是因为渲染器写得不好而是因为架构从一开始就没有对应团队的真实分工。所以这篇内容我想聊的不是某个具体引擎的源码分析而是从团队分工这个视角去理解游戏引擎底层架构为什么长成今天这个样子。你适合在什么阶段读它如果你是一个正在纠结自研还是用现成引擎的技术负责人或者是一个想理解引擎各模块为什么这样切分的程序再或者是一个刚入行、想知道引擎里那些大模块到底谁负责的开发者这篇都值得往下看。关键词里出现了 C、Godot、分布式架构、微服务架构这些词我会在合适的地方做类比和对照但核心始终围绕一件事引擎架构是团队分工的技术投影。你团队怎么分人引擎就该怎么分层你团队有多少人决定了你的架构能有多重。先说一个反直觉的结论引擎架构的复杂度上限不是由技术决定的而是由团队沟通成本决定的。一个三人团队能维护的架构和一个三十人团队能维护的架构完全是两个物种。理解这一点后面所有的模块划分、接口设计、工具链取舍都会变得顺理成章。2. 引擎的四大件与它们背后的人2.1 渲染、逻辑、资源、工具四个模块对应四种角色任何一款游戏引擎不管它是 Unity、Unreal、Godot 还是自研的剥开外壳核心都逃不出四块渲染系统、游戏逻辑框架、资源管理系统、编辑器与工具链。这四块不是凭空切出来的它们恰好对应了游戏团队里四类截然不同的工作角色。渲染系统对应的是图形程序。这类人关心的是 GPU 管线、着色器、批处理、光照模型。他们的代码对性能极度敏感一个 draw call 的优化可能就要抠半天。游戏逻辑框架对应的是Gameplay 程序他们关心的是角色状态机、技能系统、物理交互、AI 行为树。资源管理系统对应的是TA技术美术和资源程序他们关心的是资源怎么打包、怎么热更新、怎么在运行时按需加载。编辑器与工具链对应的是工具程序他们关心的是策划怎么改数值不用找程序、美术怎么预览效果不用反复导出。你看这四块的分法本质上就是把不同技能栈的人隔离开让他们各自在自己的领域里深耕同时通过接口协作。这就是引擎架构最底层的设计动机。2.2 为什么不是按功能分而是按角色分有人会问为什么不按功能分比如角色系统战斗系统UI 系统这样切因为按功能切会导致同一个功能横跨多个技能领域。比如角色系统里角色模型渲染是图形程序的事角色移动逻辑是 Gameplay 的事角色资源加载是资源程序的事。如果按功能切模块这三个人就得在同一个模块里改代码冲突率爆炸。按角色分本质上是按变更频率和技能边界分。图形程序改渲染管线的时候不应该动到 Gameplay 的代码Gameplay 加一个新技能的时候不应该关心这个技能的粒子特效是怎么渲染的。这种隔离就是架构存在的意义。我打个比方这就像一家餐厅。炒菜的、切菜的、采购的、设计菜单的各干各的。你不会让炒菜的师傅去采购食材也不会让采购的去设计菜单。引擎架构里的模块划分就是厨房里的岗位划分。2.3 小团队的现实一个人可能身兼四职但现实是三人小团队里一个人可能既是图形程序又是 Gameplay 程序。这时候架构该怎么设计答案是架构分层依然要保留但实现可以合并。也就是说你依然要把渲染和逻辑的接口分清楚但可以让同一个人去实现两边。这样做的原因是一旦团队扩张你可以立刻把其中一块交给新人而不需要重构整个代码库。我自己的经验是哪怕只有两个人也要在代码目录结构上把render/、gameplay/、resource/、editor/分开。这不是形式主义这是在为未来的自己留后路。我踩过的坑就是早期把所有代码堆在一个src/目录里等到第三个人加入的时候光是理清依赖关系就花了两周。3. 底层架构的三层地基平台层、核心层、框架层3.1 平台层把操作系统的差异关进笼子里引擎最底下那一层叫平台层它干的事情就一件把 Windows、Linux、macOS、Android、iOS 这些操作系统的差异封装起来对上提供统一的接口。文件读写、线程创建、时间获取、窗口管理、输入事件这些在不同平台上 API 完全不一样平台层就是那个翻译官。为什么这一层必须独立因为如果不独立你的渲染代码里就会到处是#ifdef _WIN32这样的条件编译代码会变得无法维护。平台层的存在让上层代码可以假装世界上只有一种操作系统。这一层的接口设计有个原则接口要尽可能窄。也就是说平台层只暴露上层真正需要的东西不要把操作系统的所有能力都透传上去。比如文件系统上层可能只需要读文件写文件判断文件是否存在这三个接口那你就不要暴露目录遍历、文件锁这些底层能力。接口越窄移植到新平台的成本就越低。3.2 核心层容器、数学、内存、日志这些无聊但致命的东西核心层是引擎里最不起眼但最要命的一层。它包含自定义容器数组、哈希表、字符串、数学库向量、矩阵、四元数、内存分配器、日志系统、断言与错误处理。这些东西听起来很基础但它们的质量直接决定了整个引擎的稳定性和性能。为什么不用 STL这是新手最常问的问题。答案不是STL 不好而是STL 不可控。游戏引擎对内存分配有极高的要求你需要知道每一次分配发生在哪里、分配了多少、什么时候释放。STL 的分配器虽然可以自定义但在跨平台、跨编译器的场景下行为不完全一致。所以成熟引擎通常自己写一套容器保证行为可预测。内存分配器这块我要多说一句。游戏引擎通常会做分层分配小对象用池分配器大对象用堆分配器临时对象用栈分配器。这样做的目的是减少内存碎片和分配开销。我实测下来一个设计良好的池分配器在高频创建销毁对象的场景下性能能比默认的new/delete快三到五倍。3.3 框架层把游戏这个概念抽象出来框架层是引擎里最贴近游戏的一层它提供游戏对象模型、组件系统、场景管理、事件系统、脚本绑定这些能力。Unity 的 GameObject/Component 模型、Unreal 的 Actor/Component 模型都是这一层的产物。这一层的设计直接决定了 Gameplay 程序怎么写代码。如果框架层设计得好Gameplay 程序只需要关心我这个角色有什么组件、组件之间怎么通信而不需要关心底层渲染和资源加载。如果设计得不好Gameplay 程序就会被迫去写大量样板代码开发效率直线下降。框架层的核心设计问题是用继承还是用组合。早期引擎多用继承比如Character extends Actor现代引擎几乎全部转向组合Actor挂载各种Component。原因很简单继承层次一深代码就变得僵化而组合可以灵活拼装。这个转变背后其实是游戏玩法越来越复杂、越来越需要灵活组合的现实需求推动的。4. 模块之间怎么说话接口设计与依赖方向4.1 依赖只能从上往下不能从下往上引擎架构有一条铁律依赖方向只能从上层指向下层不能反向。也就是说渲染层可以调用核心层的容器但核心层绝对不能调用渲染层的任何东西。这条规则听起来简单但实际项目中违反它的代价极其惨重。为什么因为一旦下层依赖了上层你就无法单独测试下层也无法单独替换上层。比如你的内存分配器如果依赖了渲染层的某个日志函数那你就没法在非渲染环境下使用这个分配器。这种耦合会像藤蔓一样蔓延最后整个代码库变成一团乱麻。我排查过的一个真实案例一个项目的资源加载模块属于资源层直接调用了 UI 模块的一个提示函数用来在加载失败时弹窗。结果后来要做服务器端的资源校验工具发现根本没法复用这个加载模块因为它依赖了 UI。最后只能把加载模块重写一遍。这个坑的根源就是依赖方向反了。4.2 事件系统模块之间解耦的利器那模块之间如果需要通信怎么办比如 Gameplay 里角色死亡了UI 需要更新血条音效需要播放死亡音效。如果 Gameplay 直接调用 UI 和音效模块就又违反了依赖方向。这时候就需要事件系统。事件系统的思路是Gameplay 只负责发出一个角色死亡事件至于谁关心这个事件、关心之后做什么Gameplay 一概不管。UI 模块和音效模块各自订阅这个事件收到之后做自己的事情。这样 Gameplay 就不需要知道 UI 和音效的存在依赖方向保持干净。事件系统的实现方式有很多种简单点的用字符串做事件名加回调列表复杂点的用类型安全的信号槽机制。Godot 的 signal 系统就是后者用起来很舒服。我个人的建议是如果团队规模小用简单的字符串事件就够了如果团队大、模块多一定要上类型安全的机制否则事件名拼错这种低级错误会浪费你大量调试时间。4.3 接口的粒度粗一点还是细一点接口粒度是另一个容易踩坑的地方。接口太细调用方需要调很多次才能完成一件事而且每次调用都是一次跨模块开销接口太粗调用方就被迫接受很多它不需要的功能灵活性下降。我的经验法则是接口粒度应该匹配调用方的使用场景。比如渲染接口如果 Gameplay 只需要画一个模型那你就提供DrawModel(model, transform)这样一个粗接口而不是让 Gameplay 自己去设置顶点缓冲、索引缓冲、着色器参数。后者是渲染层内部的事情不应该暴露给上层。这条法则说起来简单做起来难。因为程序员天生有暴露更多能力的冲动总觉得多暴露点接口以后可能用得上。但实际上每多暴露一个接口就多一份维护成本和耦合风险。能不给的接口就不给这是架构设计的基本自律。5. 从零搭一个最小引擎骨架目录结构与依赖规则5.1 目录结构应该反映架构分层假设你现在要从零搭一个最小引擎骨架第一步不是写代码而是定目录结构。目录结构是架构的物理体现它应该和你的逻辑分层一一对应。下面是我常用的一个结构engine/ platform/ # 平台层文件、线程、时间、窗口 core/ # 核心层容器、数学、内存、日志 resource/ # 资源层加载、打包、缓存 render/ # 渲染层设备、管线、材质 framework/ # 框架层对象、组件、场景、事件 editor/ # 工具层编辑器、调试工具 game/ gameplay/ # 游戏逻辑 ui/ # 界面 audio/ # 音频这个结构的关键在于每一层的目录只能依赖它下面层的目录不能依赖上面层的目录。你可以在构建系统里加一条规则来自动检查这个约束比如用 CMake 的target_link_libraries来强制依赖方向。一旦有人违反编译直接报错。这比靠代码审查靠谱得多。5.2 用 CMake 强制依赖方向具体怎么做把每一层编译成一个独立的静态库然后在 CMake 里声明依赖关系。比如add_library(engine_platform STATIC platform/...) add_library(engine_core STATIC core/...) target_link_libraries(engine_core PUBLIC engine_platform) add_library(engine_render STATIC render/...) target_link_libraries(engine_render PUBLIC engine_core)这样如果engine_core里有人写了#include render/renderer.h编译就会失败因为engine_core没有链接engine_render。这个机制我用了好几年帮我挡掉了无数次无意的反向依赖。5.3 一个最小可运行骨架的启动流程骨架搭好之后启动流程大概是这样的main函数在平台层负责初始化窗口和文件系统然后调用核心层的初始化设置内存分配器和日志接着初始化资源层挂载资源目录再初始化渲染层创建渲染设备最后初始化框架层创建场景和游戏对象。整个流程是严格从上往下的每一层只依赖它下面已经初始化好的层。这个启动顺序不是随便定的它反映了依赖关系下层必须先于上层初始化因为上层要用到下层的服务。如果你把顺序搞反了比如先初始化渲染层再初始化平台层渲染层创建窗口的时候就会失败。这种错误在新手项目里非常常见根源就是没想清楚依赖方向。6. 团队规模如何反向塑造架构决策6.1 三人团队能省则省别碰分布式三人团队做引擎最重要的原则是能省则省。不要搞分布式架构不要搞微服务那一套不要搞复杂的构建系统。你的沟通成本低一个人喊一嗓子全团队都听到了不需要用架构来解决沟通问题。这个阶段我建议直接用单体架构所有代码在一个仓库里构建系统用最简单的 CMake 或甚至一个 Makefile。资源管线能手动就手动编辑器能不做就不做。把精力全部放在核心玩法和渲染效果上这才是小团队能活下来的关键。我见过一些三人团队非要搞分布式资源服务器微服务化后端结果光是把服务跑起来就花了一个月游戏本身反而没做多少。这是典型的架构过度设计根源是没有认清团队规模和技术选型的关系。6.2 十人团队开始需要工具链和自动化到了十人规模情况就变了。策划改数值需要程序帮忙、美术预览效果需要程序导出、每次打包都要手动操作这些都会成为瓶颈。这时候你就需要工具链和自动化一个简单的编辑器、一套资源导出脚本、一个自动打包流程。这个阶段的架构重点从运行时转向工具链。你需要把资源格式定义清楚让工具能自动处理你需要把打包流程脚本化让 CI 能自动跑。这些工作看起来不酷但它们决定了十人团队能不能高效协作。6.3 三十人以上模块化、接口化、文档化三十人以上的团队沟通成本开始指数级上升。这时候架构的核心任务变成降低沟通成本模块边界要清晰接口要稳定文档要齐全。因为这时候已经不可能靠喊一嗓子来同步信息了必须靠架构和文档来传递。这个阶段你会开始需要版本化的接口、模块间的契约测试、自动化的依赖检查。这些机制的目的都是同一个让不同模块的开发者可以独立工作而不需要频繁沟通。这就是架构在大型团队里的核心价值。7. 几个我踩过的坑和对应的经验7.1 过早抽象接口还没稳定就急着定框架我早期最大的坑是过早抽象。项目刚开始玩法还没确定我就急着把框架层的对象模型、组件系统全设计好了。结果玩法一改发现原来的组件设计根本不适配只能推倒重来。这个坑的教训是架构要跟着需求走不要跑到需求前面。正确的做法是先用最直接的方式实现功能等某个模式重复出现三次以上再把它抽象成框架。这就是所谓的三次法则。第一次遇到直接写第二次遇到复制一份改改第三次遇到才值得抽象。7.2 接口泄漏下层实现细节渗透到上层第二个坑是接口泄漏。比如渲染层为了性能把顶点缓冲的句柄直接暴露给了 Gameplay结果 Gameplay 代码里到处是BindVertexBuffer这样的调用。后来渲染层要改成多线程渲染发现 Gameplay 里全是直接操作渲染资源的代码根本没法改。这个坑的根源是接口设计时只考虑了方便没考虑隔离。正确的做法是渲染层应该提供DrawMesh(meshHandle, transform)这样的高层接口把底层资源管理完全封装起来。Gameplay 只持有meshHandle这个不透明句柄不关心它背后是什么。7.3 循环依赖两个模块互相调用第三个坑是循环依赖。资源模块需要通知 UI 模块加载进度UI 模块需要调用资源模块加载资源两边互相依赖编译都过不了。这个问题的解法是引入一个中间层或者用事件系统解耦。我当时的解法是把加载进度通知改成事件资源模块只发事件UI 模块订阅事件依赖方向就理顺了。循环依赖是架构设计里最危险的信号之一。一旦发现两个模块互相依赖就说明你的模块划分有问题必须重新审视边界。我的经验是任何循环依赖都可以通过引入中间层或事件机制来打破关键是你愿不愿意花时间去重构。8. 关于引擎架构我最后想分享的几点体会做引擎架构这些年我最大的体会是架构不是设计出来的是演化出来的。你不可能一开始就设计出一个完美的架构你只能根据团队规模、项目阶段、实际需求不断调整和演化。那些看起来优雅的架构背后都是无数次重构的结果。第二个体会是架构的复杂度必须匹配团队的沟通能力。三人团队用三人团队的架构三十人团队用三十人团队的架构不要盲目追求先进。我见过太多团队被先进架构拖垮根源就是架构复杂度和团队能力不匹配。第三个体会是接口设计比实现设计更重要。实现可以随时改但接口一旦被大量代码依赖改动成本就极高。所以在设计接口的时候要多花时间思考这个接口会不会泄漏实现细节这个接口的粒度合不合适这个接口未来会不会需要扩展这些问题想清楚了后面的路会好走很多。最后一个实用建议如果你现在正在搭引擎骨架先别急着写渲染器和物理系统先把目录结构、依赖规则、构建系统这三样东西定下来。这三样东西是架构的地基地基打好了上面盖什么都稳地基没打好上面盖得再漂亮也会塌。我自己就是这么过来的早期跳过这一步后面花了成倍的时间去补得不偿失。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门