ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

游戏引擎架构核心拆解:分层、Game Loop、数据驱动与多线程

2026/10/7 9:50:09 拓冰建站 浏览量
游戏引擎架构核心拆解:分层、Game Loop、数据驱动与多线程 聊游戏引擎架构很多人一上来就扑向源码打开Unreal或者Unity的仓库准备从FEngineLoop或者PlayerLoop一行行啃。但说句实在话如果你脑子里没有一张架构地图源码读得越多越容易被细节拉着走最后只记住了几个类名回头问你这个引擎是怎么组织起来的还是一脸懵。这篇文章我想用一线开发者的视角把“引擎基础架构”这几个字拆开揉碎讲清楚。不会面面俱到但会把最核心的骨架——分层划分、启动流程与Game Loop、数据驱动设计、内存模型和多线程框架——拿出来逐个讲透顺带把我这些年踩过的坑和觉得“要是当年有人告诉我这些就好了”的经验一并倒出来。适合刚入行1-3年的游戏客户端、想转行做引擎的同学也适合那些已经在用引擎、但想搞清楚背后到底发生了什么的人。1. 引擎分层架构先给整个系统划好边界1.1 经典四层模型与模块职责游戏引擎的架构说复杂可以复杂到每个模块都值得写一本书但说简单其实就是一套分层逻辑。几乎所有主流的商业引擎底层思路都能归纳成四层平台层、核心层、功能层、应用/工具层。平台层处理的是操作系统差异你在Windows上写的文件读写代码到PlayStation或者Android上大概率不能直接用。引擎的Platform Abstraction Layer就是干这个的把窗口创建、输入设备、文件系统、网络socket这些系统能力封装成统一接口。这一层一般没人让你去碰但它决定了引擎能不能“跑”起来跨平台能力差多少。核心层提供的是基础数学库、容器、内存分配器、字符串处理、任务调度Job System这些基础设施。你会发现引擎里的容器往往不是STL的原生实现而是自己写的一套。原因很简单STL的通用性追求和游戏引擎的性能追求有冲突游戏里的物理、渲染、动画每帧都在高频创建和销毁临时数据如果分配策略不够针对性能瓶颈瞬间就会出现。功能层就是大家最熟悉的一堆系统渲染系统、物理系统、动画系统、音频系统、UI系统、网络同步。这些系统相对独立但它们之间不是完全隔离的比如物理系统会把刚体变换数据反馈给渲染系统做插值动画系统会把骨骼矩阵送给渲染系统处理蒙皮所以这一层是模块协同最密集的地方。应用/工具层处于最顶端引擎编辑器、资源导入导出工具、关卡编辑器、蓝图或者可视化脚本都挂在这一层。这一层和用户的游戏项目直接打交道也是引擎团队和游戏开发团队协作时最容易产生摩擦的层面。分层的核心逻辑就是依赖方向从上往下上层依赖下层的接口下层不感知上层的存在。物理系统不关心你是用蓝图还是C写玩法渲染系统也不关心你的UI是哪一个模块在驱动。这种单向依赖保证了一个模块坏了不会把整个引擎拖下水。1.2 为什么分层能救你于水火我用一个实际案例说明分层的必要性。某项目在开发中后期策划希望换掉引擎内置的寻路方案换上一套自研的NavMesh。如果这套寻路逻辑被写死在玩法代码里到处都是对具体类的直接引用那这次替换就变成一次牵一发动全身的大手术。但如果引擎架构分层清晰AI系统只依赖NavigationSystem这个抽象接口下层怎么实现完全可以替换上层根本不需要知道你背后到底跑的是Recast还是HNA。分层还有一层更现实的好处它决定了团队协作的边界。引擎组、客户端组、工具链组、策划组每个组各自维护自己的模块接口定好大家并行推进。没有边界的时候一个模块的改动会顺着代码引用链烧到其他人的代码里这在商业项目的交付节奏里是绝对不能接受的。代价当然也有。分层意味着接口层要花心思设计很多引擎团队会为此开好几次会反复讨论接口的参数、返回值的生命周期、线程安全语义。接口定得不好下层模块想换个实现上层代码跟着大改这比不分层还痛苦。所以好的架构不是堆出来的是克制出来的——少提供接口比多提供接口更值钱。1.3 依赖方向与防腐层一个多数人漏掉的细节讲分层的时候很多教程会画一张漂亮的模块图看起来每个模块都各自待在自己的格子里。但真实代码里模块之间很容易产生“偷偷摸摸”的引用最典型的就是逻辑代码直接调渲染模块的私有函数。出现这种情况的原因通常是性能优化但结果是模块边界形同虚设后续所有模块级的修改都会变成世界级难题。我自己比较推荐在关键模块边界加一个防腐层也就是定义一套干净的接口接口让本模块和外部模块只能通过这些接口交互。比如渲染对外只要IRenderer里面包含DrawMesh、CreateViewport、SubmitFrame几个方法外部模块拿不到这个接口的具体实现。谁想直接用Renderer::GetDeviceContext()做什么直接编译期就拦住了。防腐层的代价是多一层封装可能牺牲一点点性能。但大多数时候这点性能相对于它带来的可维护性提升完全可以忽略。真正性能敏感的路径可以设计成“批处理接口”一次性传入大量数据让下层自己决定怎么快速处理而不是频繁进行细小调用。2. Game Loop与启动序列引擎的心脏跳法2.1 从启动序列看引擎如何“开门营业”引擎其实不是一个一直驻留内存的“服务”它是一套代码流程这套流程被操作系统启动之后经过一连串初始化最后进入一个无限循环。这个无限循环行业内叫Game Loop就是整个引擎的心脏。理解了它基本就理解了引擎为什么是这个节奏。以Unreal为例FEngineLoop::PreInit会先处理命令行参数确定平台、设置日志系统、初始化RHI渲染硬件接口然后是FEngineLoop::Init把各种系统模块启动起来资产管理器、模块系统、Slate/UMG的UI基础设施、网络会话再到注册所有插件。最后进FEngineLoop::Tick每秒跑几十上百次。Unity的流程没那么显式但Unity的PlayerLoop也是一个巨大的委托列表里面有脚本的生命周期函数Update、FixedUpdate、LateUpdate、渲染提交Rendering、UI更新、音频刷新等若干阶段。用户写MonoBehaviour本质上就是在Profiler的PlayerLoop里某个固定位置挂了自己的回调。这里我想强调一个容易忽略的点引擎的初始化顺序非常重要它往往是很多诡异BUG的来源。比如音频系统依赖资源管理器如果你在音频初始化阶段就去加载音频资源而资源管理器还没启动那必然崩溃。所以引擎里的模块初始化几乎都做成带依赖关系的Phase基础模块Phase0资源系统Phase1依赖资源的系统Phase2场景加载Phase3。很多资深引擎程序员会把这套Phase机制的系统日志打印出来当作排查启动问题的第一道工具。2.2 三种Game Loop变体与参数选择游戏引擎的Game Loop最基础就两种形态固定步长和可变步长以及它们的混合体。固定步长指的是逻辑更新频率是固定的比如每秒60次每帧Update消耗的时间大致可控物理计算稳定不会出现跳帧导致的物理穿透。代价是渲染帧率和逻辑频率解耦之后你需要处理渲染插值否则画面会看到抖动的移动。可变步长就简单粗暴了每帧调用Update的时间差就是上一帧的真实耗时逻辑和渲染都在同一个循环里实现简单。但在低帧率机器上逻辑会变慢物理和动画在帧率低的时候显得格外卡玩家操作响应也会变迟钝。现代引擎普遍采用的是半固定步长。逻辑保持在固定频率更新比如60Hz即FixedUpdate或者引擎主Tick的固定频率渲染和帧循环不做强约束在两次逻辑更新之间做Alpha插值让画面尽量平滑。像Unity的FixedUpdateUpdate、Unreal的FTickableGameObject配合渲染线程本质上都是这个思路。这里有个参数选择的实战经验。很多项目在选固定步长的时候无脑填60觉得够用。但如果你在一个30Hz锁定帧率的机器上测试60Hz的逻辑频率会让逻辑更新一次要求渲染跟上两次如果渲染跟不上插值再平滑也会有明显的漂移感。更稳的做法是让逻辑固定在帧率的最大公约数上比如目标帧率30或者60那么固定逻辑频率可以取最大值但要在低端设备上能承受得起它带来的CPU开销——物理和AI每帧都在计算开销是实打实的。另外一个常被忽略的参数是“最大帧耗时”。如果某帧的渲染耗时异常大比如刚好遇到场景加载、GC、操作系统抢占CPU引擎会怎么处理是不限时间地追赶逻辑进度直接跑好几轮FixedUpdate还是直接跳过并保留最近一帧的插值状态前者的优点是逻辑不落后但对低端机器极不友好会出现“一帧卡顿随后连续几帧飞快往前跑”的眩晕感后者的优点是反应平稳但跳跃的操作响应会变肉。商业引擎一般会做一个上限比如最多连续执行4次逻辑更新超过就丢弃时间缓冲把逻辑权交给下一帧。这个细节在项目调优时非常吃香。2.3 帧耗时统计与隐形坑Game Loop跑起来之后你不能只看平均帧率因为平均帧率掩盖了太多东西。比如几千帧里偶尔出现几帧巨卡平均值可能只掉了几毫秒但体感就是肉眼可见的卡顿。正确姿势是统计P95或者P99帧耗也就是95%或99%的帧耗掉落在什么水平再配合一份帧时间直方图看有没有长尾。我遇到过一个实际案例某游戏的场景切换过程在部分机型上会明显卡半秒。帧时间统计图表显示卡顿的根源不在渲染而在主线程的AssetStreaming预加载逻辑——它在加载战斗场景的大纹理时会同步等待一次磁盘IO。修复方式是把大纹理的加载改成异步流送或者提前一个场景预热。这类问题如果只看平均值永远定位不到。还有一个隐形成本叫Profiler开销本身。很多新手在开Profiler的时候发现卡顿更严重了误以为是代码优化不到位。其实是因为Profiler插桩产生了额外开销尤其是像精细到每条指令的采样分析器开启后帧率掉30%都是正常的。正确做法是先用开销较小的粗粒度统计框定热点区域再用高精度分析器追踪指定模块跑完立刻关闭。3. 数据驱动设计配置、资源与序列化3.1 数据驱动解决了什么引擎架构里有一个设计哲学贯穿始终数据和逻辑分离数据驱动逻辑。如果你写过一个稍微有点规模的玩法系统你会发现最糟糕的代码形态是满屏魔法数字——攻击力写死在代码里血量成长写死在代码里某个技能的参数也写死在代码里。策划要想调个数值得找程序改代码重新编译一次发布俩小时。数据驱动解决的就是这个问题。把“游戏中有什么内容”和“游戏怎么运行”拆开内容交给数据文件JSON、XML、二进制、或者专用Property格式运行交给代码框架。这也就是为什么现在的现代引擎里几乎所有单位属性、技能配置、物品定义、任务描述都不会直接写在逻辑代码里而是挂在DataTable、JsonAsset或者专用配置资源上。我在项目里最喜欢举的例子是同样一个技能系统如果技能的数据是配置驱动的新增一个技能就只是新增一条配置加上一个技能逻辑类注册进工厂如果是硬编码的那每次新增技能都要去修改技能管理器的一堆分支代码膨胀不说还极容易改出新老BUFF冲突的BUG。数据驱动救的不只是开发效率更是整个项目的可维护性。3.2 资源系统的生命周期与加载流程数据驱动离不开资源系统。引擎里的资源模型、贴图、音频、动画、Prefab、蓝图、DataAsset都需要由资源管理器统一加载、引用计数、卸载。一个标准的加载流程大致是这样业务代码拿到资源路径或者ID调用资源管理器请求加载。资源管理器先查内存里有没有已经加载的实例有就直接返回引用没有就发起异步IO从磁盘或者网络读取文件反序列化再把生成的资源实例放进缓存引用计数加一通知回调完成。这里很重要的是生命周期管理。游戏里的资源数量是海量的你不能全加载进内存否则几十G的素材会把内存撑爆。所以引擎有各种卸载策略远离的关卡卸载非必需资源、低优先级贴图降流送、LRU缓存淘汰等等。我自己在做战斗场景优化时最常用的手段就是梳理场景切切换前后的资源依赖图把切换瞬间必须加载的资源和可延后的资源分开用异步加载的方式错峰加载避免在切换那一刻把所有IO压力集中在一起。另外一个容易疏忽的点是资源的引用计数。引擎引擎的卸载机制很多时候是靠“最后引用释放”触发的。比如一个预制体加载了玩家角色对它持有一份引用UI系统还持有另一份只有两份都释放了预制体才会真正从内存中移除。如果你在代码里忘了手动释放UI系统持有的一帧引用这个资源就可能永远驻留在内存里一次小场景切换就是几十个这样的资源泄漏玩一个小时内存悄悄涨几百M这就是典型的内存泄漏排查难题。3.3 序列化方案与ID优化数据文件不可能永远是编辑器里看到的样子。它落到磁盘上必然是某种序列化格式。二进制、JSON、XML、MessagePack、Protobuf都可以。引擎会怎么选二进制格式省空间、快但可读性差且跨版本容易出兼容问题。JSON/YAML可读性强调试方便适合放在编辑器侧或者可热更的配置里但解析性能差、空间开销大。所以我们常见的做法是双轨并行编辑器产出的时候是人可读的中间格式到打包发布时再转成二进制快编格式运行时加载的效率高很多。这里想提一个很多新手不走心但确实影响性能的点资源ID的类型。很多引擎在接口里传string路径来做资源查找比如LoadAsset(Content/Characters/Hero/Hero_Mesh.uasset)。这种写法在几百个资源的时候没问题资源量上万或者每次查找都在做字符串比较压力就上来了。更合理的方案是预先为资源注册整数ID——字符串Hash成64位整数运行时查找全部走整数比较快一个数量级。序列化还有一个隐形坑跨引擎版本的兼容。引擎的类结构一旦变动老的存档或者资源文件在读取时可能对不上字段。所以引擎在写序列化接口的时候一定会做版本号控制、缺省字段兜底、字段改名映射这一类机制。很多引擎的本地存档出问题不是代码写错而是类结构改了之后没做老版本兼容处理数据处理直接按新结构去读老数据自然崩溃。用引擎API加载数据前先花点时间了解它的序列化版本策略能省掉很多线上事故。4. 内存管理与多线程性能的最后战场4.1 为什么游戏引擎拒绝频繁malloc聊引擎架构内存管理是一个躲不开的话题。很多从应用层开发转过来的同学会有疑问我写C直接new一个对象不就行了吗为什么要搞个内存池这里面的核心原因有两个性能碎片化和分配器锁竞争。先说碎片化。频繁malloc/new释放各种大小不等的对象内存堆会越来越碎大块连续内存越来越少。游戏帧率要求高的时候如果某一帧刚好要分配一个大块内存而系统找不到就会触发一次整理甚至系统级调用这一帧的耗时直接飙到几十毫秒玩家画面瞬间卡死。而使用内存池预先按固定大小分配好一块块连续区域创建对象时直接从空闲链表取一块释放回链表全程无系统调用速度是malloc的几倍甚至几十倍。再说锁竞争。游戏引擎现在基本是多线程的如果多个线程都在走系统的malloc/free它们会竞争同一个全局堆锁。线程一多大家都在等锁本来能并行的工作反而串行化了。引擎自己做分配器可以为每个线程分配独立的线程局部存储池或者无锁队列管理空闲块大幅降低竞争。我自己写ECS或者Job系统时最舒服的分配方式就是线性分配器也叫栈式分配器一大块内存分配的时候只是偏移指针向后移动不需要找空闲块释放的时候整块一次性回收。它只适用于“一帧内创建、一帧结束时全部销毁”的临时数据但这恰恰是游戏中最常见的场景——每帧的变换矩阵、临时顶点、物理接触点、命令缓冲。4.2 Job System是怎么搭出来的游戏引擎在PS5/Xbox Series这一代主机上已经是明确的多线程世界了。引擎基础架构里Job System就是把大块工作打碎成小任务分给Worker线程执行的调度系统。最简单的Job System设计是一个全局任务队列一组工作线程从这个队列里拿任务执行。任务可以也可以不声明依赖比如计算蒙皮需要先等动画采样完成物理模拟需要先等碰撞检测做完。调度器会维护一张依赖图满足条件的任务从Ready队列里冒出来由工作线程领取。主线程则只负责提交任务、等待关键任务完成。要避免设计成为锁地狱经验是尽量采用无锁队列。C11的std::atomic、内存序、SPSC队列单生产者单消费者或者MPMC队列多生产者多消费者是基本构件。如果项目里大家都能遵守“任务内不互相争锁”的约束队列性能会很稳定一旦有几个任务偷偷在代码深处加了全局锁那性能会直接崩盘而且极难定位。不同引擎有不同的调度模型。Unity的ECSDOTS引入了System的依赖和Job之间批量的并行度控制Frostbite引擎搞了一套帧并行流水线把渲染、物理、玩法分别放到不同的帧上错峰执行以帧落后为代价换取单帧耗时稳定。这些策略的本质都是把每个CPU核塞满不让任何一核闲着干等。4.3 线程安全与数据竞争实战经验多线程带来的最大问题不是性能而是数据竞争。你以为只是几个人同时在改一个变量实际上在CPU缓存层面会产生各种诡异的结果读到的可能是旧值甚至指令重排后逻辑结果不一致。游戏引擎里最常见的一个坑是渲染线程和逻辑线程共享场景数据。逻辑线程在更新角色位移渲染线程同时读取位移去插值如果没做线程同步偶尔会出现角色渲染位置“抖一下”看起来像模型瞬移其实是因为读到了还没写完的半新半旧数据。我推荐的做法是双缓冲逻辑线程写入帧A渲染线程读取帧B两个帧指针在固定点交换。这样逻辑线程和渲染线程永远不会同时触碰同一块数据。Unity的渲染和Update其实也有类似机制Transform数据在内部是延后提交的你在Update里改了Transform渲染线程拿到的其实是上一帧提交的快照。这套机制保证了稳定性代价是你不能在渲染回调里直接改Transform。数据竞争还有一个隐蔽卫士cache线bouncing。两个线程改了同一个缓存行上的不同变量伪共享即使各自逻辑上不冲突物理缓存线也会在两个核之间来回迁移导致性能秒掉一半。解决办法是把高频访问的独立变量按64字节对齐或者用一些编译器扩展标注隔离区。排查伪共享工具上可以用Intel VTune的Memory Access分析或者直接看perf的缓存miss指标。5. 实操心得把架构吃进自己脑子里的方法5.1 从零搭一个迷你引擎的路径听了这么多概念最容易出现的情况是道理我都懂但还是不知道从哪里下手。我的建议是别直接啃Unreal源码而是尝试自己搭一个微型引擎哪怕只有一个旋转立方体加一个输入处理也行。第一步先写一个最简Game Loop初始化窗口每帧处理输入、更新逻辑、渲染一帧三角形。跑通了你就有了一份最初的“平台层功能层应用层”的雏形。第二步把渲染部分抽出来让主循环只调接口你就有了一份“渲染系统作为模块”的认知。第三步引入固定时间步长和插值你开始理解为什么Update和渲染不是一回事。第四步加一个资源管理器让三角形模型和贴图的加载走统一路径你理解了数据驱动。这几步做完再去读Unreal的FEngineLoop或者Unity的PlayerLoop你会发现那些代码里很多让你懵的名字突然就都能对上号了。读源码和写代码是两种完全不同的学习效率前者是被动接受后者是主动理解。5.2 四个新手必踩的坑第一个坑是过早优化。很多初学者拿到一个需求第一反应就是“这块要性能优化我得用Job System”。但如果你都不知道瓶颈在哪做的优化大概率是瞎忙甚至因为过度抽象把代码搞得难以维护。正确顺序是先搭一个最简单的可运行的版本跑Profiler拿到数据再针对热点优化。第二个坑是无脑使用ECS。ECS是一个优秀的架构范式但它的学习曲线陡峭不适合所有玩法类型。如果你的项目是传统的主循环RPG做工整的OOP加上组件组合可能比硬上ECS舒服得多。ECS真正发光的地方是海量实体的并行更新和内存连续性不是银弹。第三个坑是忽略启动阶段的耗时。很多引擎项目在开发期感觉启动慢也无所谓等上了移动平台包体变大、资源变多、初始化步骤变复杂二三十秒的启动时间直接劝退玩家。设计引擎架构时要把启动耗时当成一个一等公民来考虑做启动流程裁剪、首帧场景渐进式加载而不是等发布前再优化。第四个坑是模块间“打洞”。代码评审时看到有人为了让某个系统工作得更快直接调用另一个模块的私有实现。当时解决了问题三个月后另一个模块重构了实现这个“聪明”的调用者就成了最大的负担。做架构一定要有“接口即契约”的自觉宁可多绕一层也不要在模块边界上开洞。想真正把引擎架构吃进脑子里可以从一次简单的源码阅读开始打开你常使用的引擎代码找到主循环入口顺着它的初始化顺序把每个System对象的创建和Tick都梳理一遍做成一张自己看得懂的图。这个过程本身就是一种极好的架构训练——你会慢慢发现所谓的“引擎基础架构”就是一套追求稳定、高效、可扩展的循环而已。