ARTICLE DETAIL

资讯详情

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

游戏引擎基础架构设计:从依赖约束到Job系统的工程实践

游戏引擎基础架构设计:从依赖约束到Job系统的工程实践 聊游戏引擎架构的人很多但真正能把基础架构讲清楚的文章很少。我这两年在团队里负责引擎底层模块的重构从平台抽象到主循环从资源句柄到Job调度每一步都踩过坑也填过坑所以想用这个系列把经验沉淀下来。开篇先聚焦引擎基础架构——它是整个引擎的骨架包括分层设计、核心系统、模块依赖、主循环、资源生命周期和并发底座这些东西都不炫目却决定了引擎三五年后是越改越顺还是越改越乱。这篇更适合两类人看一类是正准备自研或正在评估自研引擎的客户端程序员另一类是维护老引擎、想摸清架构现状的架构师。1. 引擎基础架构到底在解决什么问题1.1 架构的本质是约束而不是目录结构先说一个我亲眼见过的场景。某个项目组把引擎分了资源层、功能层、应用层三层目录也按这个划分看起来整整齐齐。结果代码依赖完全乱掉资源层直接调用应用层的UI回调功能层又绕回去new资源对象。后来项目想拆出独立的服务器端发现谁也拆不动因为每个模块都像蜘蛛网一样连在一起。问题不在于分了几层而在于架构没有形成约束。目录可以随便划但依赖没有强制手段保证架构就只是一张PPT。引擎基础架构首先要解决的就是依赖约束。它用模块边界、编译顺序、接口定义来保证上层可以依赖下层下层不能回头依赖上层。一个最简单的验证方法把你模块的依赖图画出来如果存在环就说明这个环节有隐患。环依赖会让增量编译越来越慢会让单元测试没法写还会让某一天你想把渲染从引擎里抽出去替换成新方案时发现渲染代码里已经写死了游戏逻辑。1.2 基础架构的第一受益人是玩法程序员还有一层容易被忽略引擎基础架构表面上是给引擎程序员用的其实是给游戏玩法程序员用的。想想玩法程序员每天接触的是什么是Update的调用时机、是资源加载接口、是实体组件的生命周期。这些表面API背后的实现全部由基础架构决定。如果主循环里物理和逻辑的更新次序没有定义清楚玩法程序员就会遇到我改了坐标渲染却晚了一帧的诡异表现如果资源句柄设计得不好玩法层就会到处传裸指针最后变成野指针和悬垂引用满天飞。我判断一个引擎基础架构好不好不只看内部设计多精妙还看它对上层暴露出来的心智模型是否一致。一致的心智模型比任何炫技都重要。打个比方基础架构就像大楼的承重墙和管线井住户看不见它们但每个房间能不能正常住人、以后能不能改格局全看它们当初是怎么布置的。1.3 一切围绕可控性、可测性、可替换性展开把基础架构拆到底层你会发现无非在追求三件事可控性内存、执行顺序、资源生命周期都掌握在引擎手里而不是交给操作系统或运行时随机决定。可测性模块之间边界清楚可以单独拎出来做单元测试和性能测试。可替换性某个内部实现比如物理引擎、渲染后端未来可以换掉不影响上层逻辑。这三点就是后面所有设计判断的标尺。遇到任何方案争论用它们过一遍答案基本就出来了。2. 分层设计平台抽象层是地基中的地基2.1 一张图看清引擎的六层划分很多新人在网上搜引擎架构会看到一大堆分层图有的分三层有的分七层。我自己习惯的划分是六层从下往上层主要职责典型模块平台层抽象操作系统与硬件差异窗口、输入、文件、线程、时间核心层提供基础能力内存、日志、容器、数学、哈希资源层管理外部数据生命周期资源系统、流式加载、导入管线功能层具体游戏系统渲染、物理、动画、音频、相机场景层组织游戏对象与组件场景图、实体组件系统ECS应用层对接游戏逻辑游戏状态机、关卡流程、玩法模块先说明这个分层不是唯一正确答案但我认为它的理解成本最低。核心思想是每一层只依赖正下方的那一层尽量不要跨层调用。跨层调用一旦放开很快就会退化成我开头说的那种混乱状态。2.2 平台抽象层要抽象到什么程度平台抽象层大概是争议最大的部分。抽象太薄比如只封装窗口和输入那平台差异还是会漏到上层你在PC上写好的文件路径到主机上全跑不通抽象太厚比如把所有能想到的平台API都包装一遍又会陷入无休止的维护平台层代码量比游戏逻辑还多。我的经验是先抽象引擎真正会用到的能力不要贪多。一家游戏公司通常只针对PC、一两款主机、移动端做目标平台平台层的设计应该围绕这几个平台展开而不是抽象一个理论上的万能平台。常见的平台抽象有窗口与应用生命周期创建窗口、进入后台/恢复、退出。输入把键盘、鼠标、手柄、触屏统一成输入事件流。文件系统统一路径风格、提供异步读取接口。线程与同步原语封装线程、互斥量、条件变量、原子操作。时间提供高精度时钟屏蔽不同平台时间函数的差异。一个容易踩坑的细节是文件系统抽象。PC上大家习惯直接读写路径但主机平台往往需要异步IO配合大块读取如果抽象层只是简单封装了同步ReadFile后面做流式加载会非常痛苦。我建议文件接口从一开始就设计成异步回调风格哪怕是PC上也用异步实现这样切平台时不用改上层调用。2.3 核心层为什么不能无脑用标准库核心层通常包含内存分配器、日志、基础容器、数学库。很多从应用开发转过来的程序员会问为什么引擎要自己实现String和Map标准库不香吗这个问题要分场合回答。在编辑器工具这类非性能敏感模块里用标准库完全合理。但在游戏主线程和渲染线程的热路径上标准库有几个问题动态分配没有池化、容器对缓存局部性不友好、错误处理方式不统一、不同编译单元间的运行时行为难以控制。引擎核心层自研容器和分配器不是否定标准库而是为了在关键时刻掌握控制权。你在做的系统每秒要分配几万次临时对象分配器策略会直接决定内存碎片、GC压力和帧率稳定性。这一节先记住结论第三节展开细说。3. 核心系统设计内存、日志、容器、数学库3.1 内存分配器的分级策略内存管理是引擎基础架构里最影响稳定性的一环。我不会一上来就设计十几个分配器而是先满足三个基本诉求避免在游戏循环内随意new/delete造成碎片对大块资源和中、小临时对象采用不同策略提供内存跟踪上线前能查泄漏。实际项目中我常用的分配器包括栈式分配器适合一帧内创建、帧末统一释放的临时数据。实现简单按栈顶分配按栈顶回退基本没有碎片。池分配器预分配固定大小的块适合组件、句柄这类频繁创建销毁的对象。释放时把块放回空闲链表即可。自由列表分配器适合大小不一但生命周期相似的对象按大小分级减少碎片。系统分配器兜底方案调用平台的malloc或aligned_alloc只在特殊情况使用。这里给一个非常实在的建议无论用哪种分配器一定要做内存对齐。游戏引擎里向量运算要求16字节对齐而这个对齐信息往往会透过容器和句柄传递忽略它会在特定硬件上遇到莫名其妙的崩溃而且极难排查。我见过一个项目内存没对齐只在ARMV7设备上偶现崩溃查了整整一周才发现是分配器的问题。3.2 日志系统是调试的第一条命脉可能有人觉得日志系统太简单不值得设计。但我见过太多项目出线上问题时因为日志信息不全、多线程日志乱序、Release包打不出日志白加好几天班。一个好的引擎日志系统至少要具备分级过滤Verbose、Debug、Info、Warn、Error、Fatal按级别控制输出量。多通道输出控制台、文件、网络回传、调试器窗口可以并行。多线程安全用原子操作或轻量锁保证多条日志不交错。崩溃时自动flush奔溃回调里强制把日志缓冲写盘否则最后几条关键日志会丢。我特别想强调网络回传。在主机和移动真机上你没法像PC一样开个控制台看输出。如果日志系统支持把日志通过UDP回传到PC端工具调试效率会提升一个档次。这个功能如果等出问题了再补往往来不及——因为出问题的现场你恰恰没有日志。3.3 数学库与容器的性能取向数学库是老生常谈。引擎里向量、矩阵、四元数这些类型关键不是算法多高级而是保证SIMD友好的内存布局、明确返回值语义避免隐式拷贝、提供调试可视化方便在编辑器里查看向量和包围盒。容器方面我建议在热路径上使用带小对象优化的本地容器而不是无脑用标准库容器。举个例子一个实体的组件列表通常只有几个到十几个元素如果用默认的std::vector每次扩容都可能触发堆分配如果给局部数组预留栈上空间绝大多数场景可以零分配完成。这类优化写起来不难但对帧率稳定性帮助明显这也是基础架构典型的无聊但重要的工作。我通常在容器里内置一个固定容量的小缓冲区超过容量才退回到堆分配实测在实体系统这种频繁遍历的场景下性能提升能超过30%。3.4 调试可视化要预留接口核心层经常被忽略的子项调试可视化。你想想数学库提供了包围盒、射线、矩阵但如果玩法程序员想直观看到某个包围盒是不是算错了没有一个统一的可视化调试接口他就只能自己写临时代码写完再删。引擎基础架构里应该预留一套最朴素的调试绘制接口画线、画框、画圆、画文字。渲染模块实现它其他任何模块都可以在调试状态下调用。这套东西实现起来成本很低但对引擎可测性的提升非常明显。我甚至会在Release包里保留关闭状态的可视化接口开关线上遇到特定问题时远程打开能省掉大量猜测时间。4. 模块间依赖管理从混乱到有序4.1 依赖方向先于代码实现我在实际项目中见过的最严重的架构问题不是代码写得烂而是依赖方向反了。比如渲染模块为了做物体剔除内部遍历了整个场景的对象列表这就是渲染层依赖场景层的例子。单独看没问题但一旦你想把渲染做成可替换后端或者想跑一个不开渲染的服务器模式这个依赖就会变成巨大阻碍。处理思路是依赖倒置渲染层定义一个剔除接口由场景层实现并注入。这样渲染层只依赖抽象接口不依赖具体场景实现。用C的虚接口或者C风格函数指针都行关键是方向要清楚。类似的例子还有物理与渲染之间的变换同步、动画与渲染之间的骨骼数据传递。依赖方向这个事越早纠正成本越低。代码量小的时候改起来半小时等几百万行规模再发现有环改一次可能要以周为单位计算。所以我在架构评审时第一件事永远是看依赖图而不是看某个算法实现得多漂亮。4.2 模块边界静态库与动态库的取舍模块边界要不要真的用动态库DLL/SO切出来这里有个常见认知误区拆DLL不等于架构好。Windows上拆DLL有时候只是为了加速增量编译不代表依赖关系就是合理的反过来说不拆DLL全部静态链接也不代表架构混乱。我的建议是第一优先级是保证编译期模块边界——每个模块对外只暴露一个明确的头文件集合内部实现对外不可见。先做到这一层再考虑要不要物理上拆动态库。很多中小型引擎一直保持全静态链接照样维护得很好因为他们严格的头文件边界保证了依赖可控。动态库真正能带来的是运行时热替换和插件机制这在编辑器工具链上有价值但在游戏运行本体里往往弊大于利加载顺序、符号冲突、平台差异都会带来额外复杂度。所以我在架构层面更推荐静态为主、动态为辅。4.3 用增量编译时间诊断依赖污染分享一个排查依赖问题的实用办法看增量编译时间。如果你的一个核心头文件被改动导致几十个模块全部重编译说明这个头文件被严重滥用了。我在一个项目里就遇到过引擎的基础类型头文件里混入了资源加载相关的声明结果每次改资源加载接口整个引擎层面都跟着重编一次增量编译要半小时。后来做的清理很简单把资源相关声明挪到资源模块基础头文件只保留纯数据类型增量编译时间降到了几分钟。这个案例背后的原则是头文件本身就是一个依赖关系图头文件的传染面要尽量小。所以每次新建头文件时我会问自己一个问题如果这个文件内容变了最多影响多少个编译单元超过预期就要考虑拆分。5. 主循环与帧节奏引擎心跳的取舍5.1 固定步长还是可变步长混合方案主循环Game Loop是引擎基础架构里最容易被轻视的部分。看起来无非是while循环里加update加render但更新时机、步长策略、逻辑与渲染的分离直接影响所有上层系统的行为。两种主流策略可变步长Variable Timestep每帧按实际耗时计算deltaTime逻辑简单但物理模拟在帧率波动时表现不一致帧率越低游戏越慢越不真实。固定步长Fixed Timestep逻辑按固定时间片推进比如1/60秒物理稳定但需要处理一帧内多次逻辑更新或时间累积的问题。现代引擎基本都采用混合方案逻辑update使用固定步长累积渲染做插值。这样既保证物理稳定又让画面平滑。关键的实现细节在累积器accumulator上。如果某帧耗时特别长比如加载卡顿累积器不应该无限追帧否则会出现死亡螺旋——逻辑忙得不可开交渲染全部丢帧。通常要设定最大追帧次数超过就直接丢弃多余时间。// 固定步长累积器的核心逻辑伪代码 const float fixed_dt 1.0f / 60.0f; float accumulator 0.0f; while (running) { float frame_time get_delta_time(); accumulator frame_time; // 防止死亡螺旋最多执行4次固定更新 int steps 0; while (accumulator fixed_dt steps 4) { fixed_update(fixed_dt); accumulator - fixed_dt; steps; } // 剩余时间用于渲染插值 float alpha accumulator / fixed_dt; render(alpha); }这个模式几乎所有引擎都在用但不同引擎对最大追帧次数的处理差别很大。我建议把它做成可配置参数因为低端移动设备和高端PC的表现差异极大写死值会害死适配同学。5.2 一帧内各系统的更新次序主循环内部系统更新的次序也是一门学问。我的常见次序是接收输入并生成输入事件。预更新pre-update处理暂停、时间缩放、网络事件。逻辑更新游戏玩法逻辑实体组件更新。物理模拟在固定步长内部完成。动画与IK求解。相机与场景剔除。渲染提交生成渲染命令并提交给渲染线程。后处理与显示。这个次序不是金科玉律但有一个原则要守住所有系统应该明确知道自己处在读阶段还是写阶段避免在同一帧内交叉读写同一份数据。如果动画写了骨骼矩阵渲染又在同一阶段去读在多线程下就是一场竞态灾难。还有个容易被忽略的点帧尾的延迟销毁。一帧内某个对象被标记销毁但其他系统可能还持有它的引用直接销毁会崩。我会把所有销毁操作推迟到帧尾统一执行这也是主循环次序的一部分。5.3 帧剖帧工具要在基础架构里埋点基础架构阶段就要埋帧率分析的桩否则后面优化无从谈起。我建议在主循环里内置一个轻量级的帧剖帧工具至少能记录每个大阶段的耗时百分比。工具不必很复杂甚至可以只是一个环形缓冲把每阶段开销写到一帧的调试数据里。渲染线程和逻辑线程各有各的Profile块主线程只负责汇总。等到做性能优化的时候你会感激当初埋的这些点——没有这些数据优化就是盲人摸象。6. 数据驱动与资源生命周期6.1 场景组织从对象树到ECS场景层是基础架构和上层玩法之间的桥梁。怎么组织游戏对象直接影响玩法程序员的心智。早期引擎喜欢用深层次的对象树场景图一个节点带一堆组件后来大家发现性能瓶颈出现在缓存不友好和对象间耦合上ECS实体组件系统逐渐成为主流。ECS的核心思想是数据与行为分离实体只是一个ID组件是纯数据系统是逻辑。这种划分让同类型组件可以连续存储遍历起来对缓存极其友好也让每个系统只处理自己关心的数据成为可能。我不建议一上来就实现一个复杂的ECS框架。如果项目规模不大一个简单的对象ID加组件表就能满足需求。关键是先把生命周期和访问方式定义清楚ECS只是实现这种定义的一种手段不是为了潮流去追逐的工具。6.2 资源句柄不要给上层裸指针资源管理是基础架构里最容易翻车的部分。纹理、网格、音频、动画这些资源如果上层直接持有指针一旦资源卸载或者流式释放悬垂指针就会变成定时炸弹。更稳妥的做法是使用资源句柄Handle上层持有一个整数ID或轻量结构通过资源系统转成实际数据指针。资源系统内部维护引用计数资源不再使用时统一回收。句柄方案还带来一个额外好处资源可以安全地在多个线程间共享不需要上层关心线程安全。句柄的缺点是间接访问带来的一点点性能开销以及调试时需要多一层转换。但相对换来的安全性这点代价完全值得。我在实践中还会在句柄里带一个世代号generation用来检测句柄指向的资源已经被替换的情况这在热重载场景里特别有用。出现悬垂句柄时至少能明确地报错而不是让内存错误乱窜。6.3 热重载对基础架构的隐含要求热重载修改资源或代码后不重启程序就生效听起来像编辑器功能但它对基础架构有明确要求资源生命周期必须统一管理系统启动和销毁要有清晰接口数据与逻辑要分离。如果资源句柄和引用计数设计得干净重载一个纹理就变成加载新资源、替换句柄、保留旧资源直到引用结束的流程。我在团队里推动热重载时最大的阻力往往不是实现而是既有代码里到处都是裸资源指针根本不知道谁还在用旧资源。这就是为什么基础架构阶段就要用句柄等代码规模大了再改成本会高到劝退。资源流的加载也要留好口子。基础架构至少要把同步加载和异步加载两种模式都支持并且定义清楚回调在哪条线程执行否则玩法层很容易写出线程安全问题。7. Job系统与多线程现代引擎的并发底座7.1 为什么不能只靠线程池加锁很多引擎早期用几个工作线程加一堆锁来处理并行。锁的问题是它不表达依赖关系。线程A要等线程B的结果你只能挂起A但挂起浪费了核心依赖链一旦长起来锁竞争和死锁会吃掉所有多核红利。Job系统任务系统的思路是以数据依赖为核心组织并行。一个Job是一个可执行任务Job之间用依赖关系连接调度器在依赖满足后自动派发到空闲工作线程。上层写代码时强调的是这个任务依赖什么而不是我该创建几个线程、怎么加锁。这两者的区别有点像手动管理网络请求和用异步框架管理请求的区别。线程池加锁是手忙脚乱地指挥Job系统是搭建一条流水线让任务自动流动。7.2 Job系统的核心概念与调度模型三个核心概念Job最小并行单元包含一个执行函数和它的参数包。依赖图声明Job之间的先后关系调度器按图推进。工作线程从全局任务队列里取可执行的Job。一个经典例子渲染准备阶段剔除Visibility Culling可以拆成多个Job并行处理不同区域所有剔除Job完成后才启动生成渲染批次的Job。这里的启动收集Job就是依赖图在发挥作用调度器在剔除Job全部完成后自动把收集Job派发出去。线程模型的演进也可以对照着理解模式优点缺点单线程简单、无竞争吃不满多核线程组加锁有一定并行度死锁风险高、依赖表达弱Job系统依赖明确、扩展性好实现复杂、调试难度大我建议新引擎直接从Job系统起步哪怕一开始只支持最简单的依赖关系也别用过度的锁去凑合。因为历史包袱一旦背上后面很难卸。7.3 数据竞争的治理先隔离再共享Job系统本身不解决数据竞争它只是让竞争更可控。我的实践经验是尽量设计只读输入、独立输出风格的Job减少共享写。如果必须共享用无锁容器或分区写入避免一把大锁。遵循帧内阶段化同一数据的写者只允许出现在同一个阶段读者只允许出现在后续阶段。这套规则在实现上就变成引擎的依赖框架。你在构建Job依赖图时其实也在声明数据的读写阶段。这也是为什么很多现代引擎的Job系统总和一个数据访问框架绑定使用单有Job没有数据访问规则并行度还是上不去。分享一个真实案例我们早期Job系统只保证任务顺序数据竞争全靠自觉结果跑分测试时一开多线程就偶发画面撕裂和崩溃。后来把每个系统可以读写的数据集合摆到明面上Job在图里注册自己的读写数据调度器遇到冲突就自动串行化崩溃立刻消失性能还比之前加一堆锁高了一截。8. 自研或重构引擎时我给你的几条实在建议8.1 先扫依赖图再动代码如果你正在接手一个老引擎别急着改代码。先把模块依赖图画出来方法很简单用脚本扫描每个模块的include关系画出完整依赖图找出环。这个图就是你的架构体检报告。依赖关系的清理往往比新增功能更紧急因为它决定了后续所有工作的安全性。我通常会让构建系统在每次编译时输出模块间include的统计信息隔一段时间看一眼发现异常的边及时处理。这个成本很低但能防止依赖腐化悄悄发生。8.2 基础设施先行日志、内存跟踪、帧剖帧、崩溃转储我犯过的错误是先把功能做完再补调试工具。结果调试工具上线时代码已经复杂到没法安全接入。后来我改成了基础设施先行在引擎第一天就接好日志、内存跟踪、帧剖帧和崩溃转储。事实证明这省下的时间是巨大的。上线验证问题时十分钟内就能定位而不是靠猜。尤其崩溃转储去符号化流程必须提前跑通否则线上崩溃文件拉下来也不会解析。这些都是枯燥的基础工作但它们是整个引擎最值得的投资。8.3 引擎层与游戏层的边界判定基础架构还要回答一个问题什么代码算引擎什么代码算游戏。我的标准是能否被多个项目复用。能复用的东西放引擎层否则放游戏层。这个边界如果模糊引擎就会膨胀成全世界最复杂的游戏游戏代码也会变得无法移植。团队越小越要警惕因为边界一旦模糊重构的代价会随时间指数增长。我见过几个小团队的项目最后把玩法逻辑写进了引擎的渲染系统里美其名曰性能优化实际是把引擎焊死在了单一项目上。8.4 留出可测试的演进活口最后一点也是我常常提醒自己的架构不是一次设计完的。引擎会随项目需求不断演进基础架构唯一能保证的是演进时不塌方。要做到这一点就要让每层都有可替换的空间、有测试保护、有清晰的接口。我见过太多团队追求一步到位的完美架构结果在过度设计里耗尽了耐心。比起完美的分层理论我更需要一个改坏了能立刻发现的机制。所以单元测试、内存泄漏检测、性能回归测试这些自动化保护都是基础架构的一部分。我自己在实操里最深的一个体会是基础架构的好坏平时看不出来只有在项目最紧张、压测最狠、线上问题最诡异的时候才见分晓。它就像一个团队的应急体质好的架构让你在乱局里还能快速定位问题、安全修改代码差的架构会让你在每次加需求时都如履薄冰。所以如果你正打算自研引擎或者正被老引擎的依赖纠缠得头疼别急着堆功能先把骨架理清楚。骨架正了后面长肉才不歪。下一篇我会接着聊资源管理系统的具体实现那里面的坑比基础架构还要多我们到时候细说。
返回列表