首页 / 资讯中心 / 文章详情

游戏引擎开发实战:团队分工与底层架构的关键认知

游戏引擎开发实战:团队分工与底层架构的关键认知 ★ FEATURED ARTICLE
先说明一下我对这篇文章的定位它不是一篇教科书式的百科讲解也不是某家引擎的官方文档翻译。我写的是自己做引擎开发这几年从团队协作到底层代码之间反复踩出来的认知。标题里带着001说明这是个系列第一篇先把团队分工和底层架构这两根主梁立起来后面的文章再往渲染、资源、工具链这些具体方向深入。很多人有个误区觉得引擎架构就是技术总监画几张框图团队分工是人事和项目经理该操心的事两者没太大关系。但真正做过引擎组的人都知道这两件事根本就是同一个问题的两面你定了什么样的架构团队就得按什么方式分工团队怎么协作又反过来逼着架构一点一点演变成现在的形状。所以这篇我打算把这条链路的起点讲清楚适合刚入行两到三年的客户端程序、想从业务逻辑转引擎方向的同学以及需要和引擎组打交道的策划和TA们阅读。读完你至少能搞明白一件事一个引擎里那么多模块到底谁该管什么底层代码又是怎么把几十个人放在同一个进程里不乱套的。1. 团队分工引擎开发从来不是一个人的孤军奋战1.1 一个完整引擎团队里到底有谁先说一个现实商业引擎团队和自研引擎团队的规模差距非常大。Epic、Unity那种几百人的引擎团队和你们公司里五个人维护一个内部引擎分工颗粒度完全不同。但剥开规模差异核心角色其实就那么几种。第一类是核心框架组也有人叫底层组或基础组。他们负责的东西听着最无聊但最重要内存分配器、容器库、字符串库、文件系统抽象、线程与同步原语、模块加载与卸载、日志系统。这个组的人都得有洁癖因为你写的每一个接口都会被全引擎最挑剔的渲染组和物理组天天调用。他们在设计容器接口的时候就得想清楚要不要支持自定义分配器迭代器失效的规则是什么在debug和release下行为是否一致这些看起来不起眼的问题在引擎里全都会变成大事故。第二类是渲染组。这个组通常人最多也最容易出明星工程师。他们管的事从CPU侧的视锥裁剪、遮挡剔除、渲染图RenderGraph到GPU侧的着色器、材质系统、后处理、光照阴影。渲染组几乎是所有引擎里沟通成本最高的组因为他们天上的事情也要管、地下的事情也要管。第三类是工具链组和资源管线组。这两位经常合并但我建议在稍大一点的团队里把职责分开。工具链组做的是编辑器、Inspector、Debugger、性能分析面板这类程序自己在用的玩意儿。资源管线组管的是美术的DCC软件Maya、Blender、Substance到引擎可读取格式的全套流程格式定义、导入插件、压缩策略、版本升级、增量打包。还有一类是平台适配组。做PC单机游戏可能感受不深但只要上了PlayStation、Switch、Xbox或者各种乱七八糟的安卓机型你就必须有一个小组专门负责硬件平台和系统SDK的差异。比如Sony的异步文件读取API、Nintendo的保存数据机制、安卓厂商的GPU驱动bug清单这些都是平台组的日常。这几类角色的存在本身就是在回答一个问题为什么引擎开发不能在用git的时候各写各的因为渲染要改一个资源格式管线组就得跟着改导出工具工具链给美术做了个新功能底层就得提供新的RPC通道。模块之间的依赖关系一旦建立团队协作的流程也就固定下来了。1.2 模块归属与接口协议谁说了算每个组都有自己的辖区但引擎的痛点从来不是辖区内部写什么而是辖区之间的接口。举个例子渲染组要做一个新的材质属性前端需要暴露给美术一个次表面散射强度的滑条后端需要把它打包进材质常量缓冲。这时候工具链组说Inspector上我统一用动态面板生成你只要在注册表里声明一个属性就行。渲染组说不行我的属性需要依赖另一个属性的值动态计算你的动态面板支持吗两边在MR里改来改去最后还得架构师出来拍板属性声明用Gamma统一接口导出端和运行时端解析同一份Schema。这种谁说了算的问题靠行政级别解决不了得靠架构上的原则。我所在的团队定过几条规则我觉得可以公开分享跨模块的数据结构定义在哪接口合同就归谁管。比如渲染资源描述结构放渲染层工具链可以读但无权修改。依赖方向只能单项流动。运行时层不允许依赖编辑器层编辑器层可以依赖运行时层但必须通过公共SDK接口。任何公共接口的变更要提前两个迭代周期公告并且提供迁移工具不给迁移工具就不许改。这几条听着平淡无奇但我见过太多团队翻车都是因为顺手改一下公共头文件引发的连锁编译错误。一个引擎项目里公共头文件是比核心代码更需要防守的部分。1.3 技术美术和引擎组的协作边界技术美术这个角色这两年越来越重要但和引擎组的边界反而越来越模糊。TA往往会写Shader、做材质库、调渲染效果、做PCG程序化生成工具这些工作在有些团队里会被算成引擎组的事在另一些团队里会被算成美术组的事。我的建议是凡是直接读引擎API、需要编译原生代码的工作都归引擎组代码评审凡是脚本层面、节点面板、材质图层的产出归TA负责但走引擎侧评审美术侧确认双通道。这样说可能有点保守但引擎架构最怕的就是出现一堆绕过公共接口的专属后门。我第一次见到那个灾难现场是在一个Demo项目里TA为了让某个草地效果更亮直接在Shader里写了个#ifdef THIS_IS_GRASS的特判三个星期之后没人敢动材质系统因为一改草地的渲染效果就崩。从那以后我坚决要求任何引擎代码Shader也是代码里出现的分支特判必须在评审时说明理由并且有一条明确的清理计划。你可以在这里写特判但你必须同时写一个任务卡告诉后面的人如果完善了这套功能记得删掉这里。2. 底层架构的精髓把变化隔离在正确的位置2.1 为什么引擎一定要做平台抽象层在给新手讲引擎架构的时候我最喜欢先讲平台抽象层。因为它最无聊但最能让新人理解隔离这两个字的分量。你在Windows上用CreateFile读文件在Linux上用open到了主机平台上可能得用SDK提供的异步IO接口。如果引擎代码里到处散落着#ifdef _WIN32或者#ifdef __APPLE__看起来也能跑但每一个新平台加入都意味着全代码库的搜索和修改。平台抽象层做的事情就是把这些操作系统差异压缩到一小撮文件里对上层只暴露一个稳定接口。设计平台抽象层有几个关键选择值得留意。第一个是文件系统不要直接返回std::ifstream而要返回引擎自己的ReadFileHandle对象因为游戏里的读取往往不是一次同步读而是需要异步排队、缓存命中、跨平台路径转换。第二个是线程不要直接用std::thread到处建线程而是通过引擎的线程池封装因为你在主机平台上可能需要把某几个核心专门留给音频或渲染。第三个是内存映射不是每个平台都有mmap你要做好在某个平台上只能走malloc兜底的准备。平台抽象层本身不该膨胀。如果这个层里的代码超过引擎代码总量的百分之二三你就得怀疑自己是不是把业务逻辑也塞进去了。这个层的职责是翻译而不是实现任何复杂的算法都不该出现在这里。2.2 核心运行时内存分配器、句柄与引用越过平台层往上是核心运行时。我在这一节里挑三个最值得新手搞懂的东西内存分配器、句柄系统和模块生命周期。先说内存分配器。游戏引擎很少直接用new和malloc原因不只是性能还有分配行为的不确定性。引擎代码里通常会分几类分配器堆分配器慢但通用、线性分配器一帧里快速创建然后整体释放、池分配器固定大小的对象反复创建销毁。渲染帧里海量的小对象比如每帧提交的绘制命令、变换矩阵、光源数据如果全部走通用堆分配CPU上浪费的时间会大得惊人。用线性分配器的时候有个特别爽的场景就是帧尾统一回收FrameAllocator::Reset()一调用这一帧分配的所有临时数据一次性死亡没有任何泄漏风险。代价是你不能在线性分配器里长期保留跨帧对象。我见过新人把贴图上传用的临时缓冲区放进了帧分配器结果下一帧刚开始就被覆盖变成了随机像素。查这个bug花了一整天最后定位到挂起分配器里的指针那种心情真是难以形容。然后是句柄系统。引擎里为什么不用裸指针到处引用因为对象可能被销毁啊。你在update里拿着一个指向GameObject的指针但另一条线程刚好把这个GameObject删了下一次你就会访问野指针。句柄的经典实现是一张全局句柄表句柄是一个包含索引和代号的32位整数查表得到实际指针。当对象销毁时表中对应的代号加一旧句柄再访问就直接失效。这比裸指针安全得多性能开销也就是一次数组访问。句柄系统有一个细节值得关注代号是一次性递增还是循环复用。循环复用的代号如果位数太小可能出现旧句柄恰好匹配新对象的情况产生幽灵引用。我见过一个项目在这上面翻过车当时调试出来的现象是某个怪物明明已经死亡了但它的攻击特效依然会偶发触发。最后查了很久才确认是句柄代号循环了一圈新怪物复用了同一个代号过期逻辑判断句柄相等就放行了。从此我建议句柄的代号至少要32位别在这上面省空间。2.3 模块生命周期与启动流程引擎启动时的先后顺序决定了很多上层功能能不能正常用。我的经验是可以把这个顺序固化成一张表平台层初始化 - 内存系统 - 日志系统 - 文件系统 - 线程池 - 资源管理器 - 渲染设备 - 场景管理器 - 脚本系统 - 游戏模块。为什么要这么严格因为比如资源管理器在加载时才需要文件系统如果文件系统还没起来你加载一个纹理就会在空指针上崩溃脚本系统如果加载太早没有场景管理器提供的场景容器脚本里的GameObject查找就找不到对象。这个顺序在代码层面通常体现为一个EngineInit()大函数里面按序号逐个调用模块初始化。我踩过的坑是在商业化项目里加模块热重启。当时为了减少玩家加载等待希望断线重连网络游戏时只热重启逻辑层不重启渲染层。结果发现逻辑层和渲染层之间藏了好多隐性共享状态某个光照探针管理器挂在渲染层上但它的初始化依赖逻辑层创建的关卡配置。解决办法后来是把所有跨层依赖都收归到上下文对象Context里由Context负责按依赖关系重新初始化。从那以后我强烈建议所有模块的对外状态要么放在自己内部要么放在Context里绝不允许访问另一个模块的私有单例。3. 渲染主链路与资源管线一帧画面靠什么跑起来3.1 从CPU提交到GPU成像一帧的旅程引擎架构里最能让人产生豁然开朗感觉的就是搞懂一帧是怎么从代码跑到屏幕上的。我们先简化一下CPU这边跑逻辑和渲染提交GPU那边跑光栅化和着色。CPU和GPU之间通过一个命令缓冲区CommandBuffer通信CPU往里填渲染指令GPU按顺序消费。在实际引擎里这个流程会被多线程化。一个典型的帧循环可能是这样的逻辑线程Tick游戏状态渲染线程根据逻辑产出的数据构建RenderGraph和绘制列表加载线程异步流送纹理和网格GPU在几十毫秒后慢慢把前面攒下来的工作做完。这里最大的麻烦是CPU不能等GPU否则帧率链就断裂了。所以引擎都会开一个帧延迟机制CPU上的渲染线程比逻辑线程落后2到3帧这样GPU始终有活干不会因为逻辑慢了就无辜空转。RenderGraph是这几年引擎架构绕不开的概念。它的思路是渲染线程不直接往GPU命令缓冲里写具体指令而是先构建一张我这一帧需要用哪些渲染Pass、这些Pass之间的依赖是什么的图。等这张图建完系统再做自动优化合并可以合并的Pass、发现无用加载就裁剪掉、把依赖关系组织成最优执行顺序。我当初从传统渲染流程迁到RenderGraph时一开始觉得这纯属过度设计但真正跑起来后发现它能解决一个特别实际的问题跨Pass的资源复用终于有了统一规划而不是靠程序员手动接线。写RenderGraph时一定要记得图构建阶段不要干任何GPU活儿否则你就是在拿CPU时间给GPU性能做交易。3.2 资源加载、依赖图与热重载引擎里第二个容易被低估的架构层是资源管理系统。很多项目死在资源加载卡顿这个病上老板打开游戏进度条转了半分钟玩家已经流失了。资源系统要处理的不是单个文件读取而是资源之间的依赖图一个关卡引用了几十种模型每种模型引用了若干个材质每个材质又引用了纹理和Shader。你不能平行地把所有文件一遍读取进来因为依赖的关系头尾不一。常见的负载控制方案是这样做的先扫依赖图确定加载队列的拓扑顺序然后按优先级把队列拆分成分批任务每批任务在时间片内完成避免单帧里卡死超过几十毫秒已加载的资源放进缓存池同一个资源被十次引用时只需要读一次盘。这里涉及一个架构上的取舍资源加载是异步优先还是同步兜底。我的建议是UI层和极端紧急关键路径上可以用同步加载但游戏世界物体的加载一律走异步队列这样进度条或者流送界面的表现才有优化的空间。热重载是另一块容易翻车的领域。做工具的时候你会希望改了代码或资源快捷键一按就看到效果不用重启整个编辑器。但热重载的难点在于如果资源对象的旧实例还在场景里新数据进来你怎么迁移是替换所有引用还是重建对象我在一个项目里做过多级资源热重载资源本身热重载、材质参数热重载、Shader热重载。三级越往上风险越大因为Shader的变更影响到GPU缓存里的PipelineState牵一发动全身。给所有接入资源系统的模块立过一条规矩资源管理器只保证资源数据是新的不保证场景对资源的引用关系是新的。每个模块自己负责监听资源版本变化并改造自己的内部状态。这条规矩让我后来省下了无数个刷新了材质但模型颜色没变的bug。3.3 数据驱动设计从硬编码到配置化早期客户端开发特别喜欢把数值写死在代码里——怪物血量、技能倍率、掉落概率全是const int。后来发现策划改个平衡要拿着需求单找程序员程序在忙着改bug两边互等。数据驱动设计解决的就是这个问题把游戏设计相关的所有数值、事件、表现配置化为外部数据引擎本身成为读取数据解释数据的执行器。资源管线在这里的作用就体现出来了。美术导出一个带有碰撞信息的FBX文件引擎导入器读取后生成一个碰撞网格资产策划在Excel里调整掉落概率表工具链脚本把Excel转成引擎数据格式程序员只需要定义数据结构告诉引擎这些数据从哪里加载、怎么反序列化、怎么接入逻辑。这套流程看似平淡但它把引擎架构从写下所有流程代码变成了描述数据如何解释和运行这其实是引擎架构现代化的一个关键转折点。数据驱动设计也有坑配置项太多会失控。一个技能在Excel里有50个参数策划真正需要的可能只有10个剩下的20个是技术内部折衷。我的经验是配置的Schema和引擎数据结构共享一份模板导出工具和运行时解析都从这份模板生成这样配置项和代码字段永远不会脱节。这个模板本身也要做版本管理否则老版本资源在新引擎上会解析错位那是所有资源系统的噩梦。4. 多线程与ECS架构演进的真实驱动力4.1 从GameObject到ECS为什么缓存友好这么重要十几年经验的引擎开发者都会经历过一个阶段引擎里到处都是GameObject和继承深度五六层的类每个对象自己更新自己的状态。这种写法在项目早期非常舒服但到了场景里有几万个对象的时候性能开始极速恶化。原因并不在于每帧循环里做的事情太多了而在于内存访问模式太差。解释一下CPU从内存读数据的时候是按缓存行通常64字节成块读取的。如果10000个敌人对象散落在物理内存各个角落你遍历它们时每读一个对象就要从内存取一个新缓存行CPU大部分时间都在等内存。而如果对象数据按紧凑数组存放遍历时每个缓存行能装下一批对象的字段计算单元就能吃饱。这个差距在密集物理、大规模人群、大量存活单位时可能是几十倍的效率差异。ECSEntity Component System之所以在近十年大流行本质上是为了把逻辑上属于同一个实体的数据拆开改成按数据类型分组存放。所有位置放在一个数组所有速度放在一个数组系统更新时访问的是两个线性数组缓存命中率极高。另一个好处是并行友好不同系统读不同数组天然可以丢到多个线程上跑而不用大量加锁。实话说这是我从面向对象引擎迁到数据导向引擎后感受最明显的变化。4.2 ECS不是银弹引入它的时机和代价但我要给ECS泼一点冷水。网上太多文章把ECS吹成了现代引擎的必选项好像不用ECS你就是老古董。实际上ECS对项目类型的适配是有选择的。如果你的游戏是动作冒险、大型开放世界、竞技射击实体数量和系统复杂度都不低ECS确实能带来显著收益。但如果你的项目是回合制卡牌、休闲小游戏、强叙事互动电影实体数量也许只有几百个你花大功夫改造ECS换来的性能收益几乎感知不到增加的调试成本却实打实。ECS还有一个让团队崩溃的隐性代价逻辑的可调试性。在传统GameObject写法里你可以在Inspector里看到每个对象的全部字段。到了ECS数据分散在几十个组件数组里游戏运行时要定位一个速度异常的敌人你得上调试工具联合查看好几个系统里的组件数组片段。我们项目组为此专门写了一个ECS调试面板按实体ID拉取它的所有组件数据才勉强恢复了一部分直观性。所以我的建议是折中路线把高频、海量、逻辑简单的那部分比如移动、碰撞查询、动画采样结果缓存切成ECS系统而把低频、复杂、有强状态机的那部分比如Boss的AI决策、NPC对话、叙事状态继续用传统的状态机或脚本对象实现。引擎核心架构只要预留好数据直入口两种范式是可以共存的。硬把所有逻辑塞进ECS只会让代码变得奇形怪状。4.3 线程模型设计谁在你的游戏里并排开车多线程不是开得越多越快。我先给个经验数字引擎里真正常驻的线程种类通常控制在10到15个之间。逻辑主线程、渲染提交线程、音频线程、资源加载线程池3~4个、网络线程、物理线程、Job系统的工作线程根据核心数调整。听起来人不少但它们各有各的任务重叠的地方其实很少。真正的麻烦来自Job系统。你希望把逻辑更新、动画采样、蒙皮计算、粒子更新这类可以并行的任务分发到所有核心上这时就会出现海量的并发任务。如果每个任务都要访问共享数据加锁的成本会比任务本身还高。架构设计上要规避这种局面Job之间通过数据依赖而不是锁来同步。典型模式是先写后读调度任务A产出数据任务B声明依赖任务AJob系统按依赖图调度B等A完成后自动触发B。因为不用锁这种模式跑得特别顺。我见过一个反面案例一个项目为了稳妥在每个Job里对共享的碰撞查询接口加了锁结果8核机器上跑出了2核的效果。后来把碰撞接口改成每个帧分配一份只读的加速结构查询Job通过只读访问它彻底去掉锁性能立刻上去了。这件事给我的教训是多线程架构里先想清楚数据的读写归属再决定线程怎么调度顺序反了就是白忙一场。5. 新人接入引擎时最常见的坑与排查心得5.1 配置环境与构建第一天就卡住的三个细节引擎开发新人的第一道坎往往不是看源码而是把引擎跑起来。我观察过很多次新手接入内部项目时卡住的情况最典型的有三个。第一个是第三方依赖和SDK版本不一致。引擎通常依赖一堆二方库和三方库渲染API的SDK、压缩库、JSON库、物理引擎、音频库。每个库都有版本号新人直接拉最新版通常会导致接口对不上。解决方案很简单第一次构建前严格对照项目根目录的依赖清单文件逐项核对版本不要用顺手更新一下看看能不能过的思路。第二个问题是IDE和编译器的参数不对。Windows上常见的坑是字符集设置不统一引擎代码里处理路径字符串时用了UTF-8但IDE默认用的是本地代码页路径里有中文时就会乱码。这不是逻辑错纯粹是环境错。如果是在Godot这类开源自研引擎里遇到乱码排查方向也一样先看源码文件和编译选项的统一编码再看控制台的代码页设置。第三个坑是增量构建的脏缓存。改了一个公共头文件后全量重编一遍大概是半小时新人心里着急CTRLS之后就直接跑结果用了五分钟前编译的旧二进制还没反应过来是构建没触发。5.2 调试器与Profiler别拿眼球当工具引擎开发最大的敌人是性能回归。我在团队里反复强调不要用感觉变卡了来判断性能问题你用眼球看一帧的卡顿怎么知道是CPU慢还是GPU慢得用数据说话。这也是架构给工程流程带来的价值CPU和GPU两端的耗时数据都可以按模块拆分出来对比基准版本和本次修改的帧耗时曲线。给新人的排查思路框架大致是这样先看是CPU卡还是GPU卡。GPU卡看DrawCall数量和渲染Pass的耗时分布CPU卡看逻辑更新、渲染提交、物理仿真、资源加载的并发耗时。确认瓶颈模块后再用调试器挂到线程栈上看函数占比而不是瞎猜。这个方法对线程模型复杂的引擎特别重要因为在主线程上看到的耗时可能大部分都是在等另一个Job写完数据。另一个经验是一定要保留一份性能基准场景。这个场景不需要很复杂但必须包含典型数量的物体、几种代表性材质和光照组合。每次重大架构改动后跑一遍基准场景对比帧时间曲线。不做这一步等两个月后性能跌了一半你根本想不起来是哪个改动造成的。这个教训我交过学费真心建议所有引擎组都做。5.3 当封装的资源系统遇到诡异Bug从哪里下手资源系统相关的bug是最折磨人的因为它的表现往往很玄学同样的场景有的机器卡有的机器不卡模型随机变成粉红色贴图偶尔撕裂。这类问题我一般按以下几个方向排查确认是不是资源版本不一致。加载进来的资源和美术最新导出的版本比较内容是否一致。确认是不是异步加载时序问题。同一份资源同时被两个模块加载第二个模块拿到的是第一个模块正在修改的中间状态吗确认是不是缓存失效问题。资源校验的哈希没有更新导致修改后的资源被误认为还是旧版。确认是不是平台IO差异问题。主机平台和PC的IO特性差异很大同一套代码在PC上看着没问题上主机就可能因为IO排队超时加载失败。最后那个情况是我们上主机平台时最痛苦的一轮联调。PC上磁盘IO快所有资源的异步加载都像本地一样秒回到了主机上整块的读取速度尚可但小碎片读取就变得极慢。后来通过平台抽象层增加了读取优先级接口把关键资源立到高优先级队列把背景音乐和普通贴图压到低优先级才算稳下来。所以架构这种事往往不是等出了问题再修而是你要在一开始就知道你的目标平台有哪些脾气。6. 架构师看的不是代码是系统在压力下的行为最后再分享一点我个人的体会。很多人觉得做引擎架构就是设计一套完美的类图只要把模块划分成正确的形状一切就顺理成章了。但我见到的真实情况是架构评估从来不看静态的类图而看系统在高压力下的行为。场景里有十万个物件时逻辑线程和渲染线程的负载比是稳定的还是忽高忽低资源加载队列里堵了多少个请求会不会把启动卡死物理系统在大量碰撞事件时是否会挤占动画更新的线程时间片这些问题只靠画图是回答不了的你得让代码跑起来用工具量再根据量出来的数据调整分工、调整依赖、调整线程配置。像ECS、RenderGraph、数据驱动资源管线这些大方向它们之所以成为主流不是因为业界跟风而是因为在压力测试下它们确实让系统的行为更容易被预测和并行化。而团队分工和架构层之间的对应关系也是一个动态平衡的过程。模块边界清晰了组与组之间的交流成本会下降依赖方向明确了代码评审的速度也能变快。万一你不幸加入了一个架构混乱的团队也别怕建议从本地建立一张模块依赖地图开始先把现有引擎里谁依赖谁画出来再逐步推动改动。所有的重构都是从一张准确的现状图开始起步的。下次有机会的话我会接着写渲染图的具体实现细节还有资源管线的版本管理怎么设计。那两块都是能单独撑起两三篇文章的硬骨头咱们慢慢啃。
阅读完成 · 觉得有帮助?
咨询建站