尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

游戏引擎架构解析:从团队分工到底层模块设计

游戏引擎架构解析:从团队分工到底层模块设计 游戏引擎架构 001从团队分工到底层架构我做过几年游戏引擎开发也带过引擎组今天想把引擎架构这个话题好好聊一聊。很多刚入行的同学甚至工作了几年的人对游戏引擎架构的理解往往停留在引擎就是渲染引擎这个层面或者觉得引擎架构就是一套代码目录怎么组织。但实际上真正的引擎架构首先是一个团队协作问题然后才是技术问题。你团队怎么分工边界划在哪里直接决定了底层架构长什么样反过来底层架构的设计又深刻影响着团队每一天的协作效率。这两件事是同一枚硬币的两面脱离团队谈底层架构或者脱离架构谈团队配置都是纸上谈兵。这篇内容适合谁如果你准备组建引擎团队或者你是游戏客户端工程师想往引擎方向转又或者你只是好奇游戏引擎内部到底是怎么运转的这篇内容都很值得读完。我会从团队分工讲到底层架构的核心设计逻辑穿插一些实际踩过的坑尽量大白话但该硬核的地方不含糊。1. 引擎团队怎么分工1.1 引擎团队的角色全景很多人以为引擎团队就是一堆资深程序员在一起写代码其实引擎团队的岗位划分远比想象中细致。一个中型游戏工作室的引擎组通常覆盖这样几块核心阵地核心工具链组负责编译管线、连续集成、脚本系统、Inspector之类的编辑器扩展。这个组的工作量常常被忽视但它的产出直接决定策划和美术每天的工作效率。渲染组处理渲染API封装、资源上传、Shader编译、渲染管线的实现与调优。这是引擎团队中规模最庞大、人员最紧俏的板块。物理与动画组负责刚体物理、碰撞检测、IK、骨骼绑定、动画状态机等逻辑的实现与扩展。性能优化组这组人往往隶属于其他模块但有的成熟团队会专门拉出来负责Profiler工具、内存分析、Draw Call统计等通用性能设施。平台移植组在主机、PC、移动端、Web等多平台制作环境中负责底层适配层和各平台特性的兼容。架构组通常由技术总监或首席引擎程序员牵头负责整体方向、模块接口、代码规范与数据流设计。这还只是引擎程序的范畴。实际项目中引擎组旁边通常还有技术美术TA团队他们不是引擎程序员但他们对渲染管线的需求、对美术资源格式的反馈本质上也在深度参与引擎架构的演进。1.2 分工背后的核心逻辑边界即接口团队分工的本质不是在分人是在划分技术边界。边界划分得好模块之间只需要通过稳定的接口通信边界划得烂代码层面就会耦合成一团任何改动都要跨小组协调。这里有一个我特别认同的原则谁的性能瓶颈谁说了算。举个典型例子物理模块。物理引擎无论自研还是集成PhysX/Havok有自己独立的时间步长、碰撞检测流水线、约束求解器。如果让渲染组来定义物理系统的数据布局那物理组就会非常痛苦因为物理迭代的数据排列和渲染需要的数据排列完全是两码事。所以成熟的团队都会把物理模块的数据所有权交给物理组对外只暴露一个干净的接口比如提供从物理世界查询Transform的能力而不是让外面随便拿物理内部结构体的指针。反过来如果某个模块在项目里已经成了性能瓶颈比如渲染线程的提交Submission环节卡顿严重那么架构上就应该把渲染相关资源的生命周期管理彻底交还给渲染组而不是让其他模块各自为政地驱动渲染状态。我见过很多团队在这上面吃过亏分工是分了但边界没有体现在代码依赖上最后沦为口头边界。口头边界只是让成员之间的人情关系变得紧张对代码质量毫无帮助。1.3 引擎组与游戏组之间的服务关系谈到团队分工绕不开的还有引擎组和游戏内容团队策划、玩法程序、美术之间的关系。很多团队把引擎组定义成服务团队这个描述我认为只说对了一半。引擎组确实要服务内容团队但好的引擎架构不是被动地游戏需要什么我就加什么而是主动地识别需求模式抽象出通用能力。举个例子策划不断往场景里加动态光源。游戏组一开始可以用最粗暴的方式直接用渲染API往帧里塞一个光源。但在引擎架构层面正确的做法是设计一个灯光管理器统一管理灯光创建、数据上传、剔除、Shadow Map分配。游戏组只需向管理器注册一个我需要在某个位置有个颜色为X的灯至于这个灯在架构内部走什么渲染路径就完全不用操心。这种服务关系的具体体现就是引擎提供的功能接口而非功能实现。判断边界是否清晰有个很简单的标准换一个美术资源格式或者换一套渲染后端比如从OpenGL换到Vulkan游戏组写的上层玩法逻辑应该完全不改动。如果改动波及到上层说明底层抽象泄漏了接口设计有问题。2. 底层架构到底在架什么2.1 五大层的职责边界真正动手设计引擎架构时我通常会从依赖方向入手。任何一款成熟的引擎代码依赖的方向应该始终清晰从底层到高层逐级向上一般不回头。按我的理解引擎可以分为五个层次平台抽象层处理操作系统差异、文件系统、窗口创建、输入事件、定时器、线程原语。这是引擎能跨平台的地基。不要小看这一层很多团队在这里追求一次编写到处编译结果被各平台微妙的差异折磨得体无完肤。我建议这一层保持薄只封装不得不封装的系统调用不要在这个层面做过度抽象的业务逻辑。核心基础层提供内存分配器、容器动态数组、哈希表、字符串池、数学库向量、矩阵、四元数、反射系统、序列化框架、日志系统。这些组件像积木一样供上面所有模块使用。这一层的质量要求极高因为一旦出现Bug调试成本会翻倍。我自己写核心容器时会额外跑几轮压力测试比如百万级元素随机增删确保内存布局的稳定。资源层处理各种资源模型、贴图、音频、动画Clip、预制体的导入、缓存、异步加载、热更新。资源层往往是被新人忽略但最容易出问题的层。做架构设计时一定要把它当成独立的一层而不是归属于某个功能模块。功能模块层渲染、物理、动画、音频、网络、寻路、Gameplay脚本绑定等。这些是玩家能感知到的功能集合也是引擎显性价值的体现。应用层引擎与具体游戏项目对接的一层。包含游戏对象模型、关卡管理、Update调度、调试命令系统。用现代引擎的行话说就是游戏框架。这个分层的核心理念是依赖方向单向向下。应用层可以调用功能模块层的接口功能模块层可以调用核心基础层的工具但反过来不行——功能模块层绝不能依赖应用层的具体游戏逻辑否则引擎就会退化成专用游戏代码。2.2 循环依赖是架构腐烂的第一信号架构设计里循环依赖是最常见也最隐蔽的问题。我举一个真实团队里反复出现的案例某个项目的场景管理模块需要知道所有Actor的位置用于剔除而Actor系统为了做场景查询又需要调用场景管理模块的接口。表面上两个模块是互相需要于是直接互相include头文件编译也能过。刚开始一切正常但随着功能增加两边的接口同步修改的频率越来越高任何改动都可能牵扯一片最后陷入谁也不敢动的囚徒困境。解除这种耦合的办法其实并不神秘常见的思路有三种引入中间层把共享的数据结构比如空间哈希网格的数据定义抽到更底层让两个模块都依赖第三层而不互相依赖。相当于母亲劝架你们别吵了都听我的。依赖倒置在高层定义接口比如场景系统定义ICullingQuery接口让低层模块实现这个接口高层持有接口而非具体实现。C里的纯虚类C#里的接口C#之外则用回调注册表或事件系统实现。事件解耦两个模块之间不直接调用而是通过事件总线广播。但这种做法要谨慎事件满天飞会让代码隐式交互太多调试时很难定位这个事件到底是谁触发的。判断循环依赖是否存在可以用一个笨办法把引擎源代码目录当作一张依赖图从一个模块出发沿着include关系沿着依赖方向走如果走一圈能回到起点那就有问题了。工具层面可以用cppDepend之类的静态分析工具做周期检测比人肉眼扫代码靠谱得多。2.3 数据驱动架构从万物皆对象到ECS聊到底层架构的演进ECSEntity-Component-System是一个绕不开的话题。很多团队一提ECS就要踩坑但理解它的底层动机很重要传统OOP把数据和行为封装在一起很容易把数据嵌入到各个对象的私有状态里最终导致迭代器跳转、缓存不友好CPU的缓存命中率惨不忍睹。ECS则把数据抽出来连续摆放在线性内存中系统遍历时能顺序读取这是现代CPU极其友好的形态。我不是鼓吹所有项目无脑转ECS但引擎底层架构确实越来越倾向于组件就是纯数据系统就是纯逻辑的思路。这个思路的核心收益不只是性能更在于可控性——系统是显式的依赖是显式的程序的执行路径比一坨互相调用的对象要清晰得多。如果你从零开始设计引擎的运行时模型我建议认真考虑你的游戏对象是于严谨的类继承体系还是基于数据的组件组合后者在项目成长到一定规模之后重构成本要低很多。3. 核心子系统的设计要点3.1 游戏循环的演进从单线程到多线程框架游戏循环是一个根深蒂固的话题很多教程讲游戏循环就是while(true) { input(); update(); render(); }但真实引擎里的循环远不止这么简单。现代引擎的循环至少得考虑三件事逻辑线程与渲染线程分离主线程处理游戏逻辑渲染线程专门负责提交渲染命令。两线程之间用Frame同步比如上一帧的逻辑快照被渲染线程读取。这样即使渲染跟不上逻辑帧率也可以保持稳定不伤核心手感。固定步长与可变步长的取舍物理更新通常需要固定步长比如1/60秒一次保证确定性而更新逻辑可以采用可变步长deltaTime以适应运行性能波动。这里的关键是通过时间累积器把固定步长和渲染帧率解耦而不是简单地在每个渲染帧里执行一次固定步长。任务调度器Job System当项目逐渐走向多核单纯的逻辑线程渲染线程也不再够用。引擎架构会在最底层引入一个任务调度器允许各系统把工作拆成小任务Job依赖关系通过图DAG来描述比如动画任务必须在物理任务之前完成。这就是高性能引擎的常态。3.2 渲染系统的推数据设计与剔除管线渲染系统是引擎架构里最显眼的部分。但真正决定渲染效率的并不是后处理特效多炫而是如何把最少的必要数据推给GPU。底层架构上渲染系统的标准玩法是逻辑帧结束时游戏系统把可见性的候选对象提交推给渲染系统通常以一个渲染命令缓冲区的形式。渲染系统内部做层级剔除视锥剔除、遮挡剔除、距离裁剪再依据材质和渲染顺序重新组织批次。最终生成Draw Call序列交给渲染API。这里特别想强调推数据这个词。很多新手架构容易做成拉数据的模式——渲染系统需要遍历GameObject来获取Transform、Mesh、材质等数据。这种模式的可怕之处在于渲染系统的执行时机必须与游戏逻辑完全同步一旦涉及多线程或者延迟渲染管线就会深陷数据竞争的泥潭。推数据模式的好处在于渲染系统拿到的是游戏逻辑帧的一个快照这个快照必然是干净、无竞争、有明确生命周期的。它天然适合并行化——逻辑线程与渲染线程之间的数据交互彻底解耦。多说一句Transform数据组织是渲染架构中的隐藏坑。把几千个对象的Transform直接用深拷贝塞给渲染系统内存带宽很受伤。成熟的架构会用Transform数组的方式直接在内存中连续存储渲染线程只拿指针偏移和数量避免不必要的复制。3.3 内存分配与布局容易被忽视的第二性能游戏引擎的底层架构最容易被低估的就是内存管理。物理内存布局往往是很多帧率瓶颈的真正元凶。两个关键设计准则使用内存池和对象池避免频繁的new/delete。引擎运行时对象的创建销毁非常频繁子弹、粒子、临时向量每次内存分配都伴随锁和系统调用对实时渲染是灾难。对象池的意义在于内存复用分配和释放都退化为一个出栈入栈操作几乎没有开销。保证Cache友好CPU访问内存的速度远比主频慢现代CPU的L1缓存大概也就几十KB到几百KB。如果对象在内存中是分散的遍历10000个对象会反复触发Cache Miss速度会慢得让人崩溃。按类型组织的连续数组Structure of ArraysSoA是引擎架构中应对这类问题的常规策略。我印象很深的一次经历某功能上线后帧率突然掉了20%排查了渲染、Shader、GPU相关所有可能最后发现罪魁祸首是一段遍历所有GameObject的代码里面用了大量指针引用且对象在内存中随机分布。改造成连续数组后帧率马上恢复了。这就是内存布局的力量远超大多数程序员的直觉。3.4 资源加载的异步管道资源管理的架构设计核心目标只有一个字不卡主线程。从底层架构的角度资源加载的标准形态是一条异步管道IO线程发起文件读取现在大多直接用操作系统的异步IO。解析线程做格式解包解压、反序列化这个过程不触碰主线程数据。主线程只接收加载完成的通知然后以程序化填充的方式把资源送入GPU或内存。这条管道的核心难度在于生命周期管理资源还没加载完时上层在某个Update里引用它怎么办资源加载过程中被卸载怎么办这里需要一套精密的引用计数或资源句柄机制。我建议从引擎架构设计的第一天就把资源系统做成异步的不要先把同步加载跑通再改异步。改造成本会成倍增长因为同步版本里会有数不清的隐式假设资源A必然在B之前加载完成、某个贴图一旦加载就能立即读取等。这些假设在异步模型下全都不成立。4. 架构设计中的权衡与取舍4.1 通用性 vs 定制性引擎不是瑞士军刀几乎每个引擎团队都经历过这个纠结引擎要不要提供一套非常通用的方案好让所有项目都能用我的答案很干脆不要。通用意味着抽象层加厚每个功能的实现都被包上多层接口最终人们会发现实际游戏里90%的场景只需要某一种特定实现剩下10%的特殊情况却要把整个抽象层都拖下水。引擎架构更像一套专门的标准流程而不是一个什么都能装的工具箱。对大多数项目你需要的是一套清楚定义好的、能满足当前需求并在可预见的未来可扩展的架构而不是覆盖所有可能性的架构。4.2 抽象泄漏与性能剥离跨平台是引擎架构里最难平衡的领域。有的团队为了封装平台差异做出一套超级平台独立层结果每个平台的独有功能比如主机的SSD高速IO、移动端GPU厂商扩展都因为抽象层而得不到利用。这个问题的经典解法叫平台能力分级基础功能平台通用高端特性通过扩展接口暴露。例如渲染架构可以定义一套最小特性集比如GLES3.0级别的通用功能然后对于能支持更高阶特性的平台提供显式扩展接口让游戏层按需取用。经验法则是抽象层绝不能阻塞高性能通道。如果某个操作在某个平台上能用一行API解决千万不要为了统一的封装硬生生包三层函数调用来回拷贝数据。4.3 架构演进比架构设计更重要我在成熟团队里学到的一个观念架构不是画完一张蓝图然后照抄而是边写边演进出来的。你所设计的任何架构在遇到真实需求的瞬间都会变得不再完全正确。但演进不等于随便改。演进需要有纪律接口一旦稳定就尽量少动很多接口哪怕设计得不完美一旦被多个模块依赖修改成本就不是改一行代码那么简单。你至少要进行全源码树的感知性分析、回归测试甚至还要考虑外部工具的兼容。允许局部重写拒绝全局推翻有坏味道的模块可以单独拿出来重写但不要因为一个模块不好就喊着整个引擎都要推倒重来。这句话我见过太多次每次都是项目灾难的前兆。每次架构调整都要有明确的性能或协作收益如果调整的动机只是新来的架构师看着不爽请他坐回座位喝口茶先。5. 常见问题与踩坑实录5.1 模块耦合排查从看似没有循环到实际完全纠缠我遇到过最隐蔽的一次耦合问题是两个模块在编译层面完全没有循环依赖但在运行时数据流上形成了循环渲染系统每帧结束会读取场景管理器的状态而场景管理器又每帧等待渲染系统返回某个上一帧的结果。这种运行时的环状依赖编译器检测不出来性能分析器也只会显示两者交替花费时间很多。排查这类问题我只能推荐一个土办法把所有模块之间的关键函数进出打点然后用数据分析工具画出调用时序图。如果两个模块的进入点和退出点交叉出现闭合成环那就要警惕了。实在不行考虑把两者的共享状态提取到更低层级——这也是最有效的根治手段。5.2 团队协作中接口管理的两个教训接口管理在引擎团队内部也一样重要。有两个教训我一直想分享给团队新人别让临时接口活到下一个里程碑。开发过程中为了调通某个功能临时加的接口很正常但若不上心它就变成新的依赖被悄悄利用最终所有人都在用它架构边界被击穿。规则很简单临时接口必须打上标记比如接口名带Deprecated前缀并在代码评审中专门审视它的去留。接口变更必须同步修改所有调用方。现实中经常出现这种场景一个引擎模块改了接口签名但Gameplay层还有人调用旧接口结果编译期报错层层适配糊了一层补丁接口的纯度和可维护性大打折扣。引擎团队的代码评审流程里必须强制检查这个接口变更是否影响所有业务层调用方而不是只检查接口本身的实现。5.3 重写重构的时机判断最后聊一个所有团队都会面临的问题什么时候该重写架构我见过太多团队走到架构崩了就想推倒重来的悬崖边。我的判断标准很简单只有当新架构能解决的当前痛点能覆盖旧的已知功能并且团队有足够人力完成切换时才值得重写。大多数情况下渐进式重构是更理性的选择。我见过最成功的重构案例是团队花了两三个迭代周期把原来几百个文件互相纠结的地带逐步拆成三个清晰解耦的模块。每个迭代结束后都有完整的可运行版本风险很低。如果你真的走到了必须重写的关口我的建议是先把新架构的核心骨架搭出来哪怕它暂时只能渲染一个三角形立方体也务必先跑通主干路径。之后按模块分批迁移过程中旧系统的快进版本一直保持可用状态随时能够回滚。这比这半年我们全力重写引擎先不考虑游戏功能要稳妥得多。我个人在实际经历中的体会是引擎架构工作最迷人也最折磨人的地方就是它永远需要你在多个维度之间做选择团队效率与运行效率、抽象通用与性能直达、稳定可靠与灵活演进。没有绝对正确的架构只有适合当前团队、当前项目、当前时间点约束下的架构。希望这篇内容能给正在做或准备做引擎架构的你一些参考。
返回列表