ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

游戏引擎架构从团队分工到分层设计:主循环、内存管理与组件模型

游戏引擎架构从团队分工到分层设计:主循环、内存管理与组件模型 不少打算入行引擎开发的朋友过来问我第一句话往往是引擎代码该从哪里开始读。我的回答通常让他们意外别急着翻代码先去找一张引擎开发团队的组织架构图。这不是职场厚黑学而是游戏引擎工程里一条很少被写进教材的铁律——系统架构是组织架构的影子。你团队怎么分工,你的引擎底层就长什么样反过来引擎底层架构的每一次调整最终都会回落成团队协作方式的调整。这篇文章是游戏引擎架构系列的第一篇标题里的001已经说明了它的定位在深入渲染、物理、ECS这些具体话题之前先把引擎是什么、为什么长成这样这个地基问题讲清楚。它适合两类人一类是从游戏开发转向引擎开发的程序员另一类是中小团队里正考虑自研引擎的技术负责人。我会从团队分工切入一路拆到分层骨架、主循环、内存管理和组件模型最后聊聊架构如何跟团队规模匹配。这些问题串起来就是一套完整的引擎底层架构认知框架。1. 团队分工如何决定引擎架构的模块边界1.1 一家人数上百的引擎团队到底在拆成多少人干活先说一个很实际的现象。随便打开一家商业引擎公司的招聘页你会发现它们不是一个引擎程序员岗位而是拆成了渲染工程师、物理工程师、动画工程师、音频工程师、工具链工程师、平台移植工程师、技术美术等一堆岗位。这不是公司闲得没事干而是引擎本身就长成了这样的模块边界。我把常见岗位和它们维护的代码区域整理成了一张表你可以快速对照岗位方向日常维护的代码区域对应的引擎层渲染工程师RHI、渲染管线、材质系统、着色器编译渲染模块物理工程师碰撞检测、刚体模拟、约束求解物理模块动画工程师骨骼蒙皮、动画状态机、IK动画模块音频工程师混音、音效播放、音频流音频模块工具链工程师编辑器、资源导入、序列化、构建工具层平台工程师平台抽象、驱动适配、发布打包平台层技术美术材质模板、Shader库、资源规范内容与渲染的桥梁你会发现一个规律岗位越多引擎的模块边界就越清晰。渲染工程师不需要直接懂物理约束求解他只需要通过明确的接口拿到物理模块算出来的刚体变换。反过来物理工程师也不关心渲染管线是怎么画出一帧的。这种我不关心你内部怎么实现我只关心你给我的接口的协作方式最终会原封不动地复制到代码架构里。1.2 Conway定律组织沟通结构和系统设计结构是一体的20世纪60年代Melvin Conway提出了一个后来被软件工程反复验证的定律设计系统的组织最终产生的设计等于该组织内部的沟通结构。翻译成人话就是——产品形态会复刻团队沟通的形态。放到引擎上理解特别精准。假设你的引擎团队只有两个小组运行时组和工具组。运行时组负责游戏跑起来的所有逻辑工具组负责编辑器。那么你的引擎代码里一定会有一条非常鲜明的运行时和工具边界比如Unreal的Runtime模块和Editor模块就是分开编译的。再假设你把音频工程师和渲染工程师放在同一个大组里那过不了多久音频代码和渲染代码就会开始互相引用私有函数模块边界变得模糊。我见过不少自研引擎的团队架构图画得挺漂亮但模块之间耦合得一塌糊涂。查根因的时候十有八九会回溯到组织问题——两组人座位挨得太近、绩效绑在一起、KPI要求共建某功能于是代码也不分彼此了。反过来只要团队边界是清晰的、负责模块是明确的代码里的依赖方向通常也会是清晰的。这是引擎架构最容易被忽略的第一原则。1.3 从岗位边界反推架构边界一个实用的逆向拆解方法既然团队分工决定架构边界那我们就可以反过来用团队结构去审视架构是否合理。接手一套陌生的引擎代码库时我的习惯是先做两个图一个是组织架构图一个是模块依赖图。然后拿它们对照凡是依赖图里出现诡异跨层调用的地方基本都能在组织架构图里找到对应的跨组协作。举个例子。你看到Gameplay框架代码直接调用了渲染模块的私有资源创建函数去查团队结构发现写Gameplay框架的程序员和写渲染模块的程序员在同一个小组里这就说得通了。如果两个模块属于不同小组却出现了这种调用那大概率是从前某次重构时的临时绕过没清理掉。这种绕过的存在比代码本身烂更危险因为它暗示着团队协作的规则被打破了。所以在做引擎架构设计的时候我建议第一步不是去选ECS还是组件式不是去纠结用不用JobSystem而是先回答一个问题你的团队结构是什么样的、哪些人长期维护哪些模块、模块之间的协作接口谁来定。这个答案定下来架构草图能直接画出来。2. 底层架构的分层骨架依赖方向与模块边界2.1 平台抽象层把操作系统和GPU的差异吸收掉引擎的最底层是所有平台相关的代码。Windows、macOS、Linux、PlayStation、Xbox、Switch、iOS、Android这些平台在窗口管理、文件系统、输入设备、图形API上的差异各不相同。如果这些差异直接暴露给上层那渲染模块、物理模块、Gameplay框架就得写满条件编译代码根本没法定型。所以引擎架构里几乎都有一个平台抽象层最典型的就是渲染硬件接口层RHIRender Hardware Interface。它把Vulkan、D3D12、Metal这些图形API的差异封装起来对外只暴露创建纹理提交命令执行渲染Pass这类高度抽象的方法。底层到底走的是哪个API上层完全不感知。换一个新平台工作量主要集中在平台层和RHI适配而不需要把整个渲染引擎重写一遍。平台抽象层的设计原则很简单尽量薄尽量稳定。它不该包含任何业务逻辑也不该把平台特性一律抹平。比如有的平台支持异步计算、有的支持网格着色器完全抹平会损失硬件能力正确的做法是把这些特性做成能力查询接口上层按需使用。2.2 核心库层引擎里的公共设施到底提供了什么平台层之上是引擎的核心库层。这一层包含的东西是很多学习引擎架构的人第一眼看到会觉得很通用的代码数学库、容器、字符串、内存分配器、并发原语、文件读写封装。乍一看这些玩意儿跟游戏好像没什么关系放在任何C项目里都成立。但引擎的核心库层跟通用基础库有一个非常重要的区别它的一切设计都在为性能和可控性服务。举个例子标准库的std::vector在扩容时会使用通用内存分配器而游戏引擎通常在启动阶段就预分配好大块内存再用自定义分配器管理。核心库里的容器几乎都是自定义实现目的不是我还原一个vector而是要在帧循环的开销极度敏感的路径上提供可预测的分配行为。再比如数学库生产级引擎的数学库都是SIMD友好的向量、矩阵、四元数的内存布局会严格按照16字节对齐因为在各种架构上不对齐的读取会带来巨大的性能惩罚。这一层是所有上层模块的公共依赖所以它的稳定性要求比平台层还要高。你在里面加一个函数可能所有模块都能用到你在里面改一个结构体布局可能整个引擎都要重新编译。核心库层的设计核心是克制——能不加就不加能设计成内联模板就不过度抽象。2.3 功能模块层渲染、物理、动画、音频这些子系统怎么协同再往上一层才轮到我们平时最关注的各大功能模块渲染、物理、动画、音频、粒子、地形、镜头、寻路。这些模块的特点是每个模块都很大、很专业正常情况下互相之间没有强烈的调用需求。渲染模块需要物理模块给的刚体位置但它并不需要知道刚体内部用的是轮流积分还是半隐式欧拉音频模块需要知道一个GameObject在哪个位置发声但它不需要知道这个GameObject的模型是怎么渲染的。所以功能模块层的架构重点在于两点一是模块内部可以足够复杂但模块之间必须只用简单接口通信二是要制定明确的依赖方向规则尽量减少两两依赖。一个常见的做法是引入场景层/世界层作为中介——所有模块都读写场景数据模块本身不直接互相调用。比如物理模块碰撞结果写到场景里的变换组件渲染模块从场景里读出所有需要绘制的物体然后由渲染系统决定怎么画。依赖方向对了模块才能独立演化。渲染团队想重写光照模型物理团队完全不需要停下手里的工作因为他们只依赖稳定的场景接口不依赖渲染内部。2.4 工具层与游戏层内容生产者和玩家代码的栖息地引擎的上层是工具层和游戏层。工具层通常指编辑器、资源导入管线、序列化系统、调试可视化工具。这一层服务的对象不是玩家而是游戏内容开发者。一个引擎好不好用很大程度上取决于工具层做得怎么样。很多人研究引擎架构只看运行时却忽略了一个事实商业引擎的工程量里编辑器相关的代码往往比运行时还多。工具层之下是最终让游戏跑起来的那一层——Gameplay框架。它定义了游戏对象怎么创建、组件怎么挂载、游戏逻辑脚本怎么被驱动、场景怎么加载和切换。这一层离玩家最近的代码也是大多数游戏开发团队每天都在写的层。工具层和游戏层的存在让引擎有了明确的生产者/消费者分离。内容制作团队不需要读引擎底层代码只需要通过编辑器的界面和脚本接口工作引擎研发团队不需要关心某个具体游戏怎么做只需要把接口和工具打磨稳定。两层之间通过资源系统和序列化协议连接这也是为什么资源格式和序列化协议要从第一天就考虑清楚——一旦游戏团队开始大规模生产内容资源格式就几乎不可再改。2.5 分层架构的红利可测试、可并行、可移植分层不只是为了让代码好看它带来的三个实际红利值得说透。第一个是可测试。当下层模块被独立出来时你不必运行整个游戏就能测试物理、渲染或内存分配器。像引擎这种重平台交互的项目能跑单元测试层任务是巨大的研发效率优势。第二个是可并行开发。模块边界清晰意味着渲染团队、物理团队、动画团队可以在各自的层上独立发版、独立测试不用等彼此。第三个是可移植。新平台到来时你只需要动平台层上层全部保持不变新功能添加时你只需要在对应模块层动工其他模块不感知。我见过不少团队口头上说我们做了分层但实际连编译期依赖都没分开。真正的分层必须以依赖关系约束为准——上层可以依赖下层下层绝对不允许依赖上层。如果一个下层模块因为你某个功能需要回了上层那就是架构腐化的开始。3. 支撑一切的地基主循环、内存管理和组件模型3.1 主循环与帧循环一切功能运行的心跳游戏引擎和普通应用的第一个本质区别就是它运行在一个持续循环里。普通应用是事件驱动用户在界面上点一下系统响应一次游戏引擎是帧驱动以固定的频率不断重复处理输入、更新逻辑、模拟物理、渲染画面。这个不断循环的机制就是引擎的帧循环或者说主循环。伪代码大体是这样while (running) { input-poll(); // 采集输入事件 gameLogic-update(dt); // 更新游戏逻辑 physics-simulate(dt); // 推进物理模拟 scene-lateUpdate(); // 后置更新 renderer-frame(); // 渲染一帧 }看上去简单但它的每一步都有讲究。最经典的讨论就是固定时间步长和可变时间步长。固定步长适合物理模拟——物理引擎的稳定性和可复现性都依赖于固定步长可变步长适合渲染和逻辑——如果机器跑不动可变步长可以让游戏整体变慢而不是跳帧跳得乱七八糟。现代引擎通常把逻辑步进和渲染步进解耦逻辑用固定的tick频率比如每秒60次渲染则用尽可能高的刷新率两者之间靠插值对齐。这样做的原因是物理模拟在可变步长下很容易出现隧道效应和抖动而逻辑和物理共享同一个时间基准能避免很多bug。主循环之所以是底层架构的地基是因为引擎里几乎所有的功能都要挂在这个循环上网络同步要tick、角色动画要tick、AI行为树要tick、编辑器预览要tick。谁能进入主循环、谁不能本身就是一种架构权力。你设计一个新模块想让它每帧跑就得给它一条注册进主循环的途径。主循环写得不好整个引擎都会有节奏问题。3.2 内存管理容易被忽略但崩一次就够受的游戏引擎的性能问题最后有相当高的比例会落到内存管理上。内存碎片、缓存未命中、频繁分配释放每一个都能让精心设计的渲染算法跑出掉帧的效果。所以引擎的底层架构里内存管理从来不是用new和delete就行的事。常规做法是给引擎设计一套多层次的内存分配体系。比如帧内存FrameAllocator在每一帧开始时临时分配、帧结束时整体释放非常适合存放那些只在单帧内有效的临时数据比如渲染命令参数对象池专门预分配一堆同类型对象比如粒子、子弹避免运行时的频繁创建销毁还有长生命周期数据比如纹理、网格资源走独立的全局堆。贴一张常见分配策略的对照表方便理解内存策略分配特点适合场景帧内存分配器按帧生命周期整块分配、整块释放临时命令、帧内计算结果对象池启动时预分配运行时复用粒子系统、子弹、碰撞盒全局堆分配常规分配但做对齐和定长块优化长期资源、大块数据栈式分配器后进先出支持分区释放UI布局、逻辑中间数据底层架构层面最核心的思维是要关注内存访问模式。CPU访问内存时是从内存到缓存逐条读取的一次会拉回一个缓存行通常是64字节。如果你的数据在内存里东一块西一块CPU每取一个数都要去重新拉一次缓存行性能会成倍下降。这就是为什么ECS能够带来那么大的性能提升——它的底层原因其实是数据布局的缓存友好性而不只是所谓模式先进。内存管理这把地基决定了上层所有模块的运行时表现值得每个引擎工程师认真对待。3.3 组件模型与ECS从深继承到组合优于继承引擎的第三个地基是它如何组织游戏对象。早期引擎走的是深继承路线Actor派生PawnPawn派生Character再接各种功能子类。这套路在项目小的时候挺好用但次数多了就会撞到继承的天花板——一个需要飞行、潜水、驾驶的角色该从哪个基类往下继承多重继承又带来复杂性。于是游戏业界转向了组合优于继承的组件模型一个游戏对象是一个空壳实体它上面挂一堆组件比如Transform组件、MeshRenderer组件、Rigidbody组件、AudioSource组件。对象的行为由组件组合出来而不由继承树确定。这套模型在Unity里是GameObject Component在Unreal里是Actor ActorComponent。再往后发展就是ECSEntity-Component-System。它进一步把组件数据从对象中抽出来存在紧凑的结构化数组中然后由System批量处理。ECS的底子其实是两个维度的重构一是把对象持有组件改成实体是ID组件是数据二是把对象里调用行为方法改成System遍历所有匹配数据并处理。// ECS风格的数据处理示意 for (auto entityId : rigidbodyQuery) { auto vel velocityData[entityId]; vel gravity * dt; positionData[entityId] vel * dt; }这种做法的好处显而易见数据连续存放缓存命中率高System之间天然解耦可以并行实体只是ID组合任意能力不需要创建新类。ECS如今是自研引擎的热门选择但选它之前要想清楚一个问题——你的开发团队是否习惯这种偏底层的思维方式如果团队更擅长面向对象强行上纯ECS反而会降低开发效率。组件化是地基用不用ECS则是地基上可行的两种建造方式。3.4 为什么我把这三个称为地基主循环提供时间基准内存管理提供空间基准组件模型提供组织方式。任何一个上层功能想跑起来都得遵守这三个基准。渲染管线可以是前向也可以是延迟物理引擎可以用 bullet 也可以用自研动画系统可以是骨骼蒙皮也可以是程序化生成——但主循环如何推进、内存如何分配、数据如何组织这些东西一旦定下来改造成本就极高。换个角度理解你招聘一个资深渲染工程师他可以迅速上手项目里任何一种渲染管线但如果你的主循环设计有缺陷伤害的会是整个引擎的所有逻辑模块。这就是地基的含义——它不需要炫技它需要稳。4. 团队规模变化时架构如何跟着演进4.1 小团队/个人项目快速原型优先别急着抽象如果你是个人开发者或者三五个人的小团队想写一个引擎来支持自己的游戏我的建议是架构能多平就多平抽象能多晚就多晚。这个阶段最需要的是快速试错能力。你用一个简单的GameObject Component甚至可以直接在Update里写逻辑先把游戏跑起来再说。很多伟大的游戏原型都是在这种不太架构的代码里诞生的。这一阶段真正要防的坑是过早分层。我看到过不少独立开发者跑来说我已经把渲染、物理、音频模块都分好了结果撑了一年发现游戏逻辑还没做出来。小团队没有那么多人力去维护抽象层的优雅你的核心资产应该是流畅、自由的代码修改能力。等游戏玩法验证通了、用户规模上来了再回头谈架构改造完全来得及。4.2 中型团队模块化开始成为必需品当团队来到十几人、几十人的规模问题会开始变化。这时候代码量已经大到一个人无法完全掌握多个程序员会同时改不同的模块。如果没有模块边界每次合并都可能是一场灾难。中型团队最需要做的不是把架构设计得更先进而是把已有的模块边界严格落实下来每个模块要有唯一负责人、有明确对外接口、有依赖关系检查工具。另一个中型团队容易忽略的是工具链。团队规模扩大后策划、美术、音频这么多内容成员如果还要靠程序写代码来调Shader参数、摆关卡、配对话产出效率会低到你怀疑人生。这时候哪怕引擎已经很完善也一定要投入人力把资源管线、编辑器工具链打磨起来。很多自研引擎项目折戟不是死在渲染上而是死在工具链不行上。4.3 大型团队数据驱动与可插拔服务100人以上的引擎团队架构重点又会不同。这个阶段的特征是并行开发程度极高内容团队规模也很大引擎内部模块之间的协同复杂度急剧上升。这个时候数据驱动架构几乎是必然选择。典型的做法包括用一套可靠的元数据系统比如Unreal的UObject反射让数据驱动编辑器和蓝图把渲染管线做成可插拔的比如Unity的SRP让不同项目可以定制渲染流程把AI行为树、动画状态机、对话系统这些偏应用能力拆分到引擎之外的框架层让不同游戏项目能在同一引擎上各取所需。大团队场景里另一个容易被忽略的底层架构决策是资源管理和热更新。如何让几十个内容开发者并行产出资源、如何让资源在开发态和发布态之间可切换、如何实现热更这些都会反过来逼着你重新设计资源系统和加载管线。可以说大团队的底层架构在很大程度上不再是一个引擎架构问题而是一个多人协作的软件工程问题。4.4 演进的核心架构匹配协作方式而不是匹配技术高度说了这么多我想强调一个反直觉的结论引擎架构的每一步演化都应该以提高协作效率为目标而不是以技术看起来更牛为目标。直白一点说架构是为团队协作服务的不是为技术崇拜服务的。Unreal 和 Unity 的架构都极其复杂但那是因为它们需要支撑几千人的生态。如果你是一个30人的团队非要复刻这套复杂性结果只能是反噬自己。小团队重写起来很快重写比引入复杂框架更划算中型团队需要模块化和工具链大型团队需要数据驱动和可插拔。架构演进的路线不是从简单到高级而是从匹配当前协作方式到匹配下一阶段协作方式。这是一个工程判断问题不是一个纯技术问题。5. 实操体会评审架构、上手路径与常见误区5.1 看一个引擎的架构先从它的团队结构看起这几年的习惯让我越来越坚信想高效理解一个引擎第一件事不是打开代码目录而是去打听这个引擎背后的团队构成。谁说他要维护渲染、谁维护物理、谁做工具链你脑海里的模块边界立刻就能建立起来。用这个角度去看开源社区也很好使——一个由美术出身的开发者维护的引擎通常工具链更友好一个由引擎老兵构成的团队通常底层库设计更严谨。如果是在公司内部做架构评审我建议评审里加一道必答题每个模块的owner是谁这个模块的对外接口发生过几次破坏性变更。答案能反映模块边界的真实健康度。如果一个模块的接口三天两头变化那说明这个模块还没真正独立——它在被其他模块的进度推着走。5.2 新人上手引擎代码按这个顺序走效率最高经常有人问我拿到一套引擎源码该按什么顺序读。我通常给这样的路径先看平台层和核心库把系统相关的概念建立起来再看主循环搞清楚引擎每帧到底在干嘛然后选一个具体模块深入进去我推荐首选渲染模块或者Gameplay框架因为它们一个偏底层、一个偏应用正好能让新人形成从下到上的全貌。不要一上来就啃物理引擎或者全局光照那些都是专业性极强的子领域深入进去容易陷在细节里出不来。一定先画一个分层依赖图贴在自己工位上每次看到不认识的文件先查它属于哪一层再决定看不看。5.3 常见误区多文件夹不等于分层多接口不代表好架构最后聊几个我反复撞见过的坑。第一个误区是把分层理解成建一堆文件夹。真正的分层是依赖关系的隔离是编译期和运行期的边界。代码放在不同目录却在编译时互相随便 include那不叫分层叫自欺欺人。第二个误区是过度抽象。我为不存在的需求预先设计接口等需求真来了发现接口完全用不上反而被接口束缚——抽象应该跟随需求走而不是跑在需求前面。第三个误区是忽视工具链。很多人把引擎理解成运行时的一堆模块忘了编辑器、资源管线、构建系统都是引擎的一部分。一个工具链差劲的引擎哪怕运行时性能再强在项目里也会被开发者吐槽到不行。这些坑我再怎么强调都不为过因为它们不是单纯的技术问题而是架构服务于人的问题。引擎架构这个话题真要展开讲至少可以写个几十篇。我自己刚入行时也是从引擎就是渲染器的误解开始的后来踩了不少坑才慢慢意识到引擎首先是协作系统然后才是技术系统。如果你正在做自研引擎的架构选型我最大的建议是把团队结构画出来照着它设计模块把主循环、内存、组件模型这三个地基钉稳再去折腾上面那些花活。我的这个游戏引擎架构系列后面还会聊渲染管线的分工、资源系统的设计、热更新怎么做、ECS的完整实现这些具体话题。这一篇先把骨架立住。
返回列表