ARTICLE DETAIL

资讯详情

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

游戏引擎基础架构拆解:分层、核心模块与ECS

游戏引擎基础架构拆解:分层、核心模块与ECS 实话说搞游戏开发的朋友不管你是写玩法逻辑还是做引擎工具迟早都会碰到一个绕不开的话题游戏引擎架构。尤其是当你从“用引擎”切换到“看引擎”“改引擎”甚至“写引擎”的时候最直观的感受就是——这玩意儿怎么这么多层为什么一个简单的“显示一个三角形”背后要牵扯出窗口管理、渲染设备、资源系统、内存分配、日志系统一大堆东西这就是引擎基础架构的威力。它在你不注意的地方决定了整个项目后期是“越写越顺畅”还是“越写越想摔键盘”。这篇文章我打算按我自己的理解把引擎基础架构拆开揉碎从分层设计到核心模块从运行时机到数据驱动把“地基”讲清楚。写的是“一”所以重点放在最底层的主干结构上后面再慢慢往里填肉。1. 游戏引擎基础架构要解决的三个根本问题很多人第一次打开引擎源码会被扑面而来的模块搞得头皮发麻平台层、核心层、资源层、渲染、音频、物理、动画、脚本……这些模块不是随便堆在一起的。它们的存在本质上是回答三个问题。第一个问题是运行时秩序。游戏是每帧都在动的程序一秒钟要跑60次甚至144次更新。那么问题来了谁先更新谁后更新输入什么时候采集物理什么时候步进渲染什么时候提交如果这些顺序全靠游戏逻辑自己约项目一大人就会疯。引擎基础架构要做的第一件事就是把这套“帧内秩序”定死一个框架只负责一件事定的死死的不让上层去瞎猜。第二个问题是模块边界。渲染要用数学库物理也要用数学库动画还得用资源系统要读文件音频也要读文件。那这套公共能力放谁那里放渲染模块里物理模块就得反向依赖渲染那整个依赖图瞬间就烂了。所以引擎基础架构会强制划出一条边界谁can依赖谁谁绝对不能依赖谁靠分层来执行这个规定。我见过很多项目死在“就破例这一次”上破完一次后面全是环。第三个问题是数据与逻辑的流向。游戏世界里到处是实体、组件、资源、配置。架构得回答数据活在哪儿谁可以改它改完怎么通知别人。这听起来像程序设计课的内容但在引擎里是真刀真枪的。现代引擎几乎全走上了“数据驱动”的路子——用数据和配置来描述游戏内容用框架代码去解释执行。架构决定了你改一个数值是“改一行配置”还是“改一坨C代码然后再编译五分钟”。2. 分层一台引擎从下到上是怎么长出来的2.1 平台抽象层让引擎忘记“我在哪个操作系统上”引擎想跨平台第一道关就是平台抽象层。Windows、macOS、Linux、iOS、Android、主机平台这些系统的窗口管理、输入事件、线程、文件路径、动态库加载、图形接口全都不一样。平台抽象层的做法是给上层一个统一的虚拟接口把差异封装在底层。比如窗口系统引擎内部只有一个Window结构体不管你是Win32的HWND还是iOS的UIView创建完都变成同一个内部概念。输入事件也是上层只认“手柄A键按下”“鼠标左键点击”至于底下是DirectInput还是XInput还是GameController.framework那都是平台层的事。这里有个很多新手容易忽略的细节平台抽象层连“文件路径”这种小事都要管。Windows路径分隔符是反斜杠Linux和macOS是正斜杠游戏资源目录在各个平台的真实位置还不一样iOS的沙盒目录、Android的assets目录。好的引擎会在自己的文件系统模块里把这堆差异全部抹平你在游戏代码里写data/models/player.fbx引擎知道在不同的系统上该去哪找。2.2 核心基础层所有模块的公共工具箱平台层之上是核心基础层也叫Core层。这层不负责具体游戏功能它提供的是所有模块都要用的公共能力内存分配、字符串处理、容器与算法List、HashMap、String、数学库向量、矩阵、四元数、日志系统、断言机制还有后面单独要讲的事件系统。Core层是引擎里“被依赖次数最多”的层。几乎每一个功能模块都会include它的头文件。但也正因如此Core层必须恪守一个原则不依赖任何上层的功能模块。换句话说Core层永远不能知道“渲染器”“物理系统”是什么东西。一旦Core层去引用上层模块依赖环就出现了编译时间暴涨模块化名存实亡。我见过最惨的情况是有人为了方便在Core的容器工具里加了个 “发送给渲染线程”的接口从此全引擎所有模块都间接依赖Render整个架构图直接画成蜘蛛网。2.3 资源与资产管理层让引擎知道“数据长什么样”再往上一层是资源/资产管理层。游戏里的Mesh、Texture、AudioClip、AnimationClip、Prefab、Shader这些都是“资产”。资产层要解决的核心问题是这些文件怎么导入、怎么处理成引擎运行时友好的格式、怎么装载到内存、怎么跟踪引用计数、怎么在场景切换时安全卸载。这层通常由一个资源管理器统一管辖。资源管理器维护一个大表每个资源有唯一ID、文件路径、内存地址、引用数、加载状态未加载/加载中/已就绪。你需要一个模型时不是直接开文件读而是向资源管理器报一个ID它返回一个资源句柄。好处是显而易见的同一个模型被十个体块引用内存里只有一份切换场景时只需要按引用计数做增量卸载不会粗暴地清空一切。现代引擎的资源层还有一个重要职责资产导入管线。美术丢进来一个FBX引擎要先跑一个导入器把它转成引擎自定义的二进制格式再生成带缩略图的资产文件。这个管线如果做得顺项目的协同开发效率能拉满做得烂项目后期光等美术资源重导就能耗掉一半工作时间。2.4 运行时/场景管理层游戏内容在这个层活起来资源层再往上就是运行时层也叫场景管理或世界管理。这一层负责维护“当前游戏中到底有哪些东西”也就是场景里的所有游戏对象、组件、脚本实例。它管理场景图、对象的创建与销毁、组件的添加与移除以及每帧对这些对象做迭代更新。放到引擎架构里这个层往往是游戏引擎和纯游戏代码的“握手区”。引擎提供运行环境你的玩法逻辑就是往这个运行环境里挂组件、注册回调。运行时层还会管理关卡/场景的生命周期场景加载、场景切换、卸载旧场景。做得好的引擎场景切换是渐进式的先加载新的再在某个安全时刻做交换避免画面卡死。这一层在架构上最考验抽象功力因为它既要保持灵活性允许各种刁钻的游戏类型又要提供足够的秩序性能和安全。一直是引擎中上层架构迭代最频繁的区域也是后面聊ECSEntity Component System时的主要战场。3. 核心层设计内存、日志、容器这些“基建”为什么不能将就3.1 内存分配为什么游戏引擎要自己管内存在游戏引擎里通用动态分配——就是操作系统默认的那个malloc/new——性能是不够看的。原因一是频繁小对象分配会产生大量碎片和系统调用开销原因二是你需要知道内存的真实布局才能给缓存友好性做优化。引擎的常规做法是提供多重分配器。你需要在某个固定线程上做大量小分配就搞一个线程局部的小块线性分配器Linear Allocator它只管往前顶指针帧结束时一键重置。你需要给某类固定大小对象做批量管理就搞一个池分配器Pool Allocator提前申请一大块内存按固定大小切块空闲块串成链表分配释放都是O(1)。你需要在不同子模块间隔离内存就搞分区分配器避免物理系统的内存膨胀把渲染缓冲挤垮。我自己的经验是引入内存分配器的门槛并不高但它带来的架构收益会逐步显现。特别是做主机平台和移动平台时系统提供的堆往往很保守而引擎自己管理的分配器可以全盘掌握可用内存把碎片控制在极低水平。反过来如果引擎不提供内存管理的概念每个模块都默认new一把后面做内存分析和内存预算就完全无从谈起。注意分配器的设计必须和所有权语义一起设计。也就是说谁分配谁释放、释放发生在哪个阶段帧末还是立即、是否允许跨线程传递指针这些在架构文档里就要写死。否则你很快会看到“分配在逻辑线程、释放在渲染线程”的悬空指针惨案。3.2 日志系统多线程下最容易被忽视的架构日志系统看着简单但在引擎架构里是典型的“看起来容易做起来难”。写玩法代码时打个console.log谁都会但在引擎里日志的调用方遍布逻辑线程、渲染线程、流式加载线程、音频线程如果每个线程直接往stdout写那输出就完全乱套而且同步IO会卡线程。成熟的引擎日志系统架构上是“异步队列”模型。调用方只负责把日志消息塞进一个无锁环形缓冲Ring Buffer立刻返回。后台有一个专用的日志线程负责从缓冲取出消息写入文件、输出到调试器、或推给编辑器控制台。这样多线程下打日志的开销极其可控不会因为某个IO卡住拖垮主游戏循环。另外日志系统还有一个在引擎里很微妙的职责日志级别Trace/Debug/Info/Warn/Error/Fatal应当可以被运行时动态调整。大型项目往往在正式包里只开Error级别但线上出问题后可以通过后台开关把某几个模块的日志调到Debug再复现一次。这样引擎架构就具备了“现场取证”的能力。如果你在设计引擎时没给日志系统留这套开关后面上线排障会非常痛苦。3.3 数据容器与数学库STL能用但别直接用这里可能争议很大。很多人说我直接用STL不就行了现代C的STL品质并不差。但游戏引擎的容器有两个特殊要求第一是分配器可控。STL容器的默认分配器走的是全局new/delete你要让它用上引擎自定义分配器标准做法是给容器传自定义Allocator模板参数。结果就是要么全项目统一别名要么到处写一长串模板参数。大多数引擎的应对方案是自定义一套自己的容器如TArray、THashMap之类的轻量实现内部默认走引擎分配器。这样写的人省心控制的人放心。第二是内存连续性。对性能敏感的系统的容器必须保证元素在内存里连续排列。遍历一个巨大的vectorComponent和遍历一个链表式Component集合帧尾开销差别可能是几十倍。引擎容器在设计时就倾向于“动态数组”而非“节点式容器”遍历性能更好。你的CPU一级缓存就这么大数据贴得越近遍历越快。数学库就更是军事重地了。引擎里所有位置、旋转、缩放最终都要变成矩阵和向量。数学库的架构重点一是要提供FloatingPoint一致性——开启/关闭FastMath后不能出诡异偏差二是要提供 SIMD 友好的内存布局。对齐到16字节甚至32字节的Vector3/Matrix4才能放进SIMD寄存器。很多引擎还会直接让渲染层和物理层共享同一个数学库避免两套矩阵互转的精度损失和性能浪费。4. OS抽象层引擎为什么非要自己包一层系统调用4.1 窗口与输入两个最常见的“平台差异陷阱”游戏引擎几乎很少直接调用操作系统API来创建窗口而是自己包一层。原因特别直白引擎要同时跑Windows、主机甚至网页端总不能给每个平台写一套完全不同的窗口逻辑吧。窗口系统抽象后引擎对外提供一个Window接口支持设置标题、尺寸、全屏状态、垂直同步等一堆操作。平台层内部Win32窗口、Cocoa窗口、SDL后端、甚至浏览器Canvas都被包装成同一套接口。这样引擎上层UI、渲染、工具链就只需要跟Window打交道。输入也一样。键盘鼠标是多数人的直觉认知但对引擎来说输入是“设备无关的抽象事件流”键盘、鼠标、手柄、触摸屏统一成InputEvent。手柄在Windows上是XInput在移动平台上是GCController在桌面编辑器里可能被模拟成一套虚拟手柄。架构上输入系统还要解决“映射”问题游戏需要“跳跃”这个语义但手柄上绑A键还是B键应由配置决定而不是写死在代码里。输入系统在基础架构里本质上是一个“设备事件 - 逻辑动作”的翻译层。4.2 文件IO与线程同步还是异步这是个架构决策同样一个“读文件”操作在普通应用程序里随叫随读没问题在游戏引擎里可就不同了。游戏主循环一帧只有16.6毫秒预算60帧或5.5毫秒预算120帧同步磁盘IO一卡就是几十毫秒帧率直接爆掉。所以引擎的文件IO层基本上强制走异步模型。上层请求“加载某张贴图”实际接管的是一个流式加载系统它维护一个IO线程池从请求队列里取任务后台用系统调用读文件拷入内存后做解压或格式转换最后发布“加载完成”事件。游戏逻辑层收到事件后才把资源挂到场景对象上。这个设计会向上传染资源系统、场景系统、流式加载管线全部要围绕异步回调来组织。这也是为什么引擎基础架构一开始就必须定IO模型的理由——等到了项目百人团队再改几乎等于重写资源层。我再补一句异步IO千万别自己用裸线程硬搞一个标准的IO任务队列加线程池比什么都管用。5. 运行时组织主循环、模块生命周期与Tick顺序5.1 主循环的两种流派引擎的“心脏”是主循环。几乎所有游戏引擎都有一个类似“while(running) { Update(); Render(); }”的大循环但节奏控制有两种流派。第一种是可变步长Variable Step。每一帧的时间戳间隔由真实时钟决定上层更新会乘以DeltaTime所以帧率波动不会导致游戏速度飘。绝大多数现代引擎的默认玩法逻辑更新都走可变步长画面流畅度高实现简单。第二种是固定步长Fixed Step。物理系统几乎都走这种每1/60秒或1/120秒固定步进一次不管渲染帧是多少。为什么物理要用固定步长因为物理方程的数值稳定性依赖一致的时间片。你把时间切成1/120秒每一步的积分误差可控且一致。把渲染和逻辑放在同一个步长下帧率一波动物理就可能疯掉。这里有个实操经验好的引擎架构会把“逻辑Update”和“渲染提交”分开。逻辑可以以固定频率跑渲染则可以按显示器的刷新率尽量跑。主循环里先处理输入事件然后按固定步长推进逻辑再执行渲染帧。为了减少逻辑卡顿有的引擎还会把逻辑 Update 跟渲染帧率解耦在渲染间隙补多次逻辑Tick。这就是所谓的“优先保证逻辑时间一致性”。5.2 模块生命周期的启动与关闭引擎里有一堆模块物理、渲染、音频、网络、ScriptSystem……它们不是一启动就乱七八糟地同时运行而是有严格的启动顺序。为什么要有“顺序”因为模块之间有依赖。打开日志系统才能记录启动信息打开资源管理器才能加载初始资产然后才能创建渲染窗口渲染窗口就绪后才能初始化渲染设备再之后物理与音频这类副系统才敢初始化因为它们可能要分配GPU缓冲或音频缓冲。启动顺序不是钦点谁大谁小而是资源依赖的必然结果。对应的关闭顺序恰好反过来先停游戏逻辑再卸场景再关渲染设备最后收日志。如果关闭顺序不对最容易出现的问题就是某个模块还在用另一个模块的资源结果后者已经释放了直接崩溃。这个问题在实际项目里极常见特别是热重载和关卡切换阶段。所以引擎架构里通常会维护一个明确的生命周期列表按照依赖排序来执行启动和关闭而不是让每个模块自己在某个动态库里注册全局析构函数。5.3 Tick顺序与依赖关系谁先谁后差之毫厘同一次循环里多个系统都要更新。那顺序如何决定经常有人觉得“反正都跑跑完就行”但实际性能与正确性差得非常远。以物理和动画的合作为例动画系统先更新骨架把骨骼矩阵写进缓存物理再把玩家控制器/力场应用到角色身上但如果在物理推进后再重新采样动画就会有一帧角色姿态跟碰撞体位置对不上也就是常见的“穿模时角色还保持上个姿势”。这就是Tick顺序的经验教训。一个合理的更新流程大致是输入采样、逻辑脚本玩家控制、动画系统姿态计算、物理系统碰撞和约束求解、摄像机跟随、粒子与音频播放、场景查询与UI、渲染提交。每个引擎都不太一样但它一定是“被依赖的优先更新”。另外Tick顺序要支持“阶段切片”。到底哪些系统在PrePhysics阶段跑哪些在PostPhysics阶段跑架构上最好提供一个生命周期钩子。这样写玩法模块的人可以在音序里选择自己的挂载点不用强行搞多线程同步。少一点黑科技多一点明确顺序项目的稳定性会高一个档次。6. ECS与组件化现代引擎基础架构的关键转向6.1 从继承树到组合一个GameObject的演进老一代引擎以及不少新手教程喜欢用“继承树”Entity - Character - Player层级深、复用差。角色要能变成“植物人”怎么办你还得调继承树。后来大家发现组合远胜过继承。现代引擎的标配是“GameObject Component”。一个GameObject只是一个坐标和一堆组件的容器。它本身不定义“是什么”而由挂载的组件来定义挂渲染组件就能被画出来挂碰撞组件就能参与物理挂脚组件就能响应玩家输入。这样做的好处是极致的灵活性同样是“武器”在玩家手里挂一把刀的组件在地上只是个静态拾取物。你不用为每种变化发明新的类。在架构层“组件容器”管理的就是一个对象及其组件的增删改查。你向场景发一条AddComponent的命令引擎在运行时动态更新描述这个对象的组件数组。组件的排序通常遵循“同类型聚簇”因为它顺便解决了缓存局部性问题——同一帧要更新所有Transform、所有MeshRenderer的时候跳过泛型虚函数直接在一个连续数组上跑循环。6.2 数据连续性与缓存命中ECS实体组件系统的核心思想比“组合优于继承”更激进一点它把组件数据从对象中抽出来按“列”存成数组。场景里有一万个实体每个实体都有Position组件那么ECS把一万个Position放进一个连续的大数组里。更新系统时一次线性遍历这一万个PositionCPU缓存命中率极高性能非常稳定。对比传统的面向对象方案一万个对象散落在内存不同角落每个对象又拖着数据遍历时缓存行里能命中的比例很低。ECS把“对数据的访问模式”设计成连续扫描在CPU架构上几乎是为游戏定制的性能方案。现代引擎在基础架构里普遍把ECS作为核心运行时组织方式还因为它在多线程上有天然优势——不同的System可以并行处理不同的组件数组数据间没有共享变量锁和同步可以极大减少。6.3 什么场景不要上ECS不过别急着把一切都搬到ECS。以我个人经验ECS也有它的“反能力”复杂对象间的多态行为在ECS里很难优雅表达。如果游戏里有大量类型不同、行为弱相关的对象用传统组件的组合式脚本可能更直观。ECS的系统逻辑和组件数据分离但某些天生内聚的对象例如“一个对话框UI节点”强行拆成Position / Text / Animation三个组件数组反而会增加复杂度和跨系统同步成本。我的倾向是在引擎基础架构里把 ECS 作为一个可选而强力的运行时模型而不要让全引擎所有东西强制ECS。像渲染场景、物理场景这种高频大数量场景可以尽量ECS化玩法逻辑里的状态机、UI、AI行为树用传统的组合对象或脚本系统反而更稳。一个架构成熟的引擎应该能同时容纳两套模型并提供互操作。7. 数据驱动与资源管线架构的另一半是数据7.1 资源加载的异步化游戏内容不是写死在代码里的而是以资源形式存在。于是引擎基础架构的另一半重头戏就是“资源加载”这件事。资源加载的第一个原则主线程绝不去同步读文件。前面聊IO时说过异步化这里再来一遍是因为资源层的问题是连锁的。你请求加载一个模型它可能依赖材质材质又依赖贴图和Shader。资源管理器要能够找出依赖关系形成一个加载图再按拓扑顺序去异步载入。目标资源就绪后再通知对它有依赖的对象。这里往往藏着一个性能深坑你以为只加载了一个大场景实际上它背后拖挂了成千上万个小资源。如果架构没有做“依赖追踪”每个资源独立加载就会造成大量重复IO和重复解压加载时间直接翻倍。所以资源架构里要有“引用图”的概念哪里引用哪里哪里释放条件满足。通过资源引用计数和依赖图才能做出安全的增量加载、预加载、以及后台按优先级加载。7.2 序列化与热重载资源管线最后要落地成二进制格式。引擎架构里序列化系统既要保证稳定玩家存档/关卡文件不能因为引擎版本更新就读不出来又要保证高性能大场景秒级加载。做好序列化的一个关键策略是版本化与预留。每个资源文件头要有版本号和元数据区。引擎为每种资源结构标好“版本”在读取时做兼容迁移。没有版本控制的资源管线在团队协作中就是灾难美术改了一版模型程序的老代码读不了新资源两边互相甩锅。别问我怎么知道的。热重载也是这套架构的试金石。好的引擎在编辑器中修改一份资源能让运行中的游戏直接收到更新而不用重启。实现方式通常是资源管理器监听文件变化重新导入资源后保留原ID只替换底下的数据块再有引用它的对象做同步刷新。做到这一步玩法策划和美术的迭代速度会快得飞起。数据驱动架构的回报就在这些日常体验里累积起来。8. 引擎架构的几个反模式我踩过和见过别人踩的坑8.1 全局单例泛滥新手引擎最喜欢把所有系统做成全局单例RenderSystem::Instance()、AudioSystem::Instance()、ResourceManager::Instance()满屏Get()。短期确实方便长期必然埋雷单例的初始化顺序不可控关闭时生命周期交接混乱测试时几乎无法替换依赖。更麻烦的是它破坏了“依赖边界”——任何地方都能随手拿到全局状态模块之间的边界就名存实亡了。我在自己写过的一版引擎里就吃过这个亏。后来重构时把单例改成“通过上下文对象传递依赖”也就是注入到需要他们的模块中。比如渲染系统需要资源管理器不是在内部调用单例而是构造时传入资源管理器指针。初见时觉得多写了不少代码但项目规模上去之后模块可测性、可替换性全部都回来了。8.2 模块循环依赖架构烂掉最典型的表现就是“循环依赖”动画模块include了物理模块物理模块又include了骨架数据模块骨架数据由动画模块维护。这种图的编译时间呈指数级上升改一个头文件整个引擎所有模块全部重编。修法只有一个就是“拆”。找到环里的关键依赖把它抽到一个更底层、谁也不依赖的公共模块。比如物理和动画要共享角色骨架数据那就建一个“角色底盘数据模块”里面只有纯数据和简单工具谁都不依赖它不引擎里没有“谁都不依赖”的模块准确说是它只依赖Core而上层物理和动画都只依赖它。通过降低公共依赖的“体量”把循环依赖断掉。8.3 一锅端的更新循环还有一个很隐蔽的反模式在主循环的Update()里把所有系统全遍历一遍结果每个系统的代码都越长越肥最后变成一个几千行的“超级系统”。正确解法就是我前面提到的“Tick顺序”和“阶段钩子”。把大Update拆成多个生命周期钩子如OnInput、OnPrePhysics、OnPhysics、OnPostPhysics、OnRender每个模块只在自己的阶段里做自己的事。主循环本身保持轻量只负责敲鼓点Tick具体每拍谁跳由各模块挂到相应的阶段。这样以后加新系统、调顺序、做性能分析都是在局部模块里完成而不是在主循环里打补丁。我在实际项目的体会是玩架构不是炫技。你的引擎可以简单但模块边界要清晰可以不做全套ECS但不能没有秩序可以没有几十个系统的高大上调度器但“启动/运行/关闭”的生命周期一定得稳定。做好这些后面几十年或者说直到你换语言重写都省心。最后再分享一个小技巧每次面向引擎框架做设计时先问自己一句“这个模块被谁依赖、它依赖哪些模块”把依赖图画出来超过三层环的先停下来重构再加上“有依赖边界的代码和没有依赖边界的代码它们一年的维护成本差十倍”。这话可能略显极端但在我自己的引擎开发生涯里每次架构的回报都是长期显现的。希望这篇对你有用下一篇具体聊渲染器还是物理系统看评论区呼声了。
返回列表