ARTICLE DETAIL

资讯详情

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

游戏引擎基础架构拆解:分层模型、主循环与数据驱动设计

游戏引擎基础架构拆解:分层模型、主循环与数据驱动设计 最近在带团队重构一套内部引擎几个同事来回反复斟酌过的一件事就是引擎的架构边界到底应该画在哪里。正好有朋友问起引擎基础架构怎么入门干脆把沉淀下来的思路整理成一个系列先从最底层的引擎基础架构讲起包括引擎的分层模型、主循环设计、内存与资源管理、实体组织方式再到渲染、物理、音频这几条业务线的架构划分。文章直接按我个人拆解的路径走所有内容都是基于实际工程项目来的不铺概念只讲取舍逻辑和踩过的坑。适合正在入门引擎开发、或者打算自研底层的人参考完整的直接看源码也行把这篇文章当作路线图读。1. 从一颗裸机到完整引擎架构到底拆出了哪些块游戏引擎本质上是给游戏提供一个运行环境。但运行环境四个字太抽象落地到代码上就是一层一层往上叠加的模块。我习惯把它拆成五层平台抽象层、核心工具层、资源与数据层、功能子系统层、游戏逻辑层。每层都有自己的关注点层与层之间只通过明确的接口通信不可以越界调用。1.1 平台抽象层让引擎不绑死在某一个系统上平台抽象层在所有引擎里都是最底层的。它负责处理操作系统相关的特性窗口创建、输入设备鼠标、键盘、手柄、触摸屏、文件系统访问、线程原语、图形 API 的换装、音频设备的后端。这里有个容易误解的地方很多初学者以为平台抽象层就是包一层 API比如把 OpenGL 和 DirectX 再包一遍就完事。其实它更重要的职责是把业务参与者的思维方式统一掉——你在引擎上层不会直接看到这是一个 Win32 窗口还是这是一个 X11 窗口你只会看到一个叫 Window 的对象它有 SetSize、SetTitle、OnClosed 这样的通用接口。以渲染抽象为例真实的引擎往往不是简单封装 Vulkan/D3D12而是定义一套渲染后端接口创建逻辑设备与交换链提交渲染命令缓冲管理 GPU 内存的环形缓冲同步多帧渲染任何厂商 API 都只是这套接口的实现之一。这样设计的好处一是换后端不动业务代码二是有办法做软件回退软件光栅器用来做调试三是不同平台的特性差异被隔离在实现文件里不会污染上层逻辑。1.2 核心工具层数学、容器、内存与诊断再往上一层是核心工具层。这一层在工程上动作最大但又是最容易被低估的一层。它提供的东西包括数学库向量、矩阵、四元数、变换、包围体容器动态数组、哈希表、稀疏集、内存优化的小容器字符串与哈希处理矮字符串、短字符串优化、常用于资源索引的 FNV/Murmur 哈希内存分配器栈式分配器、池分配器、帧分配器、双缓冲分配器诊断工具断言、日志系统、崩溃转储挂钩、性能计数器为什么先强调这一层因为引擎的所有子系统都会共用这里的容器和内存策略这直接决定了整个引擎的性能基线。很多引擎的卡顿和内存碎片问题追根溯源都出在这一层。这里特别聊一下内存分配器的取舍。游戏引擎和数据密集型应用有一个共同的敌人就是频繁的堆分配导致的碎片化与性能抖动。工程上常见的做法是引入一个帧分配器Frame Allocator它的规则非常简单在帧开始时拿到一块大内存这一帧里所有临时对象都从这块区里分配帧结束后整块释放不做单个对象上的释放操作这本质上是一个 O(1) 的 Arena用栈顶指针移动代替系统 malloc/free。比如一个场景里要收集当前帧可见的所有光源你不会每次new一个光线对象而是直接从帧分配器里借一片连续内存填数据。帧结束统一回卷指针下一帧重新使用。这样的分配器内存访问热度极高也完全避免了内存碎片问题。区别有多大一个角色数量多的游戏场景如果用系统默认分配器跑每帧可能的 malloc 调用次数高达数万次换帧分配器之后这一块开销基本归零。1.3 功能子系统层与游戏逻辑层的边界功能子系统层才是大家认知中引擎功能最直观的部分渲染器、物理系统、动画系统、音频系统、粒子系统、场景管理系统、脚本系统等。它们之间也并不是互相独立的比如渲染要用 transform 数据物理要同步 transform 数据动画要产生 transform 数据——这就涉及数据流的组织方式后面单独展开。游戏逻辑层则是开发者真正写玩法的地方。它跟引擎功能层的边界如果画不清楚后面一定出问题。我见过很多失败项目玩法代码直接塞在场景类里场景类膨胀到上万行。正确思路是游戏玩法只通过数据组件和事件与引擎互动引擎提供能力玩法决定组合方式。你可以在引擎里建一个灯光组件但不应该在引擎里写这个灯是给主角次要技能用的这种逻辑。2. 主循环整个引擎的心跳节奏怎么定如果说分层架构是引擎的骨架那主循环就是引擎的心跳。几乎所有实时引擎都有一个主循环简化的伪代码长这样while (running) { processInput(); update(realDeltaTime); render(); }但实际项目里主循环的设计远比这段代码复杂。核心问题永远是两件事渲染的频率和逻辑更新的频率怎么对齐以及物理模拟的步长怎么稳定。2.1 固定时间步长 vs 可变时间步长一个经典争论可变时间步长就是每帧用真实流动的时间去更新游戏逻辑。好处是逻辑能直接反映真实时间坏处是物理稳定性差、联机同步困难、浮点误差不可控。固定时间步长则强制逻辑按固定步长比如 1/60 秒推进渲染频率可以跟逻辑频率不一样但逻辑永远按同一个心跳去更新这对物理、对推挤他人、对浮点一致性都非常友好。工程上我推荐的做法是固定逻辑步长 可变渲染插值。也就是说逻辑每 16.666 毫秒推进一步渲染在每次垂直同步前取最近的两次逻辑状态做插值。这样逻辑稳定渲染又能保持流畅而且万一逻辑跟不上渲染频率还能用累积器把积压的逻辑帧一次性补完。2.2 累积器的实现细节累积器的标准写法如下double accumulator 0.0; double fixedStep 1.0 / 60.0; while (running) { double frameTime getDeltaTime(); accumulator frameTime; while (accumulator fixedStep) { update(fixedStep); accumulator - fixedStep; } // accumulator 里剩余的是下一帧用不到的零头可以做插值权重 float alpha accumulator / fixedStep; render(alpha); }有几个容易踩的坑要提醒一下。累积器溢出问题如果某次frameTime异常大比如调试器中断了几秒while 循环会卡住狂跑几万次逻辑更新。要加一个最大累积阈值超过上限就丢弃多余时间防止死亡螺旋。alpha 插值不要对逻辑状态做二次修改插值计算应该在渲染准备阶段生成一个副本不要写回逻辑状态否则下一帧的物理数据会被污染。2.3 引擎里帧的真正含义还有一个新手容易误解的地方引擎里说的 Frame 不是一个固定概念。渲染帧是 GPU 显示的一帧逻辑帧是固定步长的一步物理帧可以和逻辑帧一致也可以更低频率跑子步substep。音频回调有自己的时钟频率完全不受帧率影响。一个设计良好的引擎并不试图让帧在全球统一成同一个数字而是把不同节奏的系统通过时间戳、同步点和插值来协调。这听起来绕但想清楚之后很多帧率相关 bug 就能从根上消除。3. 数据驱动设计架构里最容易被低估的一环很多人一谈引擎架构就是渲染管线、物理引擎、ECS很少有人会把数据驱动当作架构核心。但从我这么多年的经验看能不能做出一个可扩展的引擎万水千山都是从数据驱动开始的。什么是数据驱动简单说就是把系统的行为参数从代码里抽出来放进资源文件运行期通过资源加载来配置系统行为。这样策划改数值、美术调颜色都不用重新编译代码一个热加载就完事。更深层的是让实体对象本身可以由数据描述——比如一个角色的属性列表、状态机的转移条件、物品的合成公式这些都是配置而非硬编码逻辑。3.1 资源文件格式的选择JSON、二进制还是自定义 DSL工程上资源格式是一大争论点。小而美的项目可以用 JSON 起步格式可读、工具链丰富但项目大了以后JSON 的解析开销和体积会成为一个实际痛点。所以商业引擎普遍会做资源导入与序列化管线编辑阶段用人类可读的格式构建阶段统一转成二进制格式运行期只加载二进制。二进制格式可不只是把 JSON 字符串压缩一遍它要针对引擎的读取路径做优化用哈希表预生成的偏移表代替逐名字查找字符串池化相同字符串只存一份对齐到缓存行减少内存随机访问加载时内存布局和磁盘布局一致避免大内存互拷这里有个很出名的技巧磁盘直读映射。一些引擎在加载资源时并不解析数据而是直接把文件的字节块映射到内存地址空间然后在需要的地方用偏移量访问。这背后其实是把运行时解析变成构建时预解析的思路。3.2 资源生命周期管理引用计数还是 Handle 体系资源生命周期是一个比想象中更复杂的问题。直接裸用共享指针管理资源在资源依赖复杂时会出各种循环引用、悬空引用。工业级引擎现在普遍用资源 Handle 资源管理器的统一方案。Handle 本质上是一个轻量级索引它不直接存对象指针而是指向资源表中某个条目。表里记录资源状态、加载进度、引用计数、最后访问时间。做法有这些好处资源可以热释放、异步加载上层拿到 Handle 仍然安全引用计数挂在资源管理器里更容易做引用分析崩溃时能清清楚楚看到资源表状态不用在代码堆里捞指针设计的代价是要封装齐全Handle 出错时调试体验不如直接指针所以好的引擎会提供资源管理器调试面板把表中所有资源的状态可视化出来。3.3 热重载与数据流编辑器里改数据游戏里立刻生效数据驱动带来的一个直接红利是热重载。编辑器里改了数值引擎不重启也能看到效果这在玩法原型阶段特别提升效率。实现上有两种思路资源重载标志 轮询每帧检查资源的生成时间戳发现变化就重新导入文件事件监听通过操作系统的文件监控接口比如 inotify收到事件后立刻重载我建议从轮询开始做起因为它跨平台、依赖少、实现简单。真正能用起来之后你很快会发现改配置 → 看效果的循环有多丝滑团队的调试心态会有本质性的变好。4. 基于组件的实体组织从表驱动到 ECS游戏场景里所有可以被玩家看到、被逻辑操作、被物理碰撞的东西统称为实体/对象。实体如何组织直接决定了引擎的扩展方式。我最早接触引擎时遇到的是经典继承体系一个 GameObject 基类下面派生 Character、Item、Vehicle……结果越到后期越痛苦因为需求总是横切的一个飘在水面上的箱子既是物理体、又是可拾取物、还能被技能点燃。4.1 组件模式的基本规则组件模式把这个局面彻底换了个思路实体本身不承载任何行为它只是一个 ID行为全部放在独立的组件上。引擎提供一系列组件类型Transform、MeshRenderer、Rigidbody、AudioSource、Light、ScriptComponent……想给实体加可被点燃能力就挂一个火源组件 受燃组件不需要为了新功能去改实体类本身。这样做的核心好处是运行期的组合能力。策划要一辆会发光的车你不需要发明一个LightCar类而是创建 Car 实体挂 mesh 组件再挂 light 组件。架构开的程度完全不一样。我在自研引擎里甚至把组件解构做得更彻底一些组件之间不互相引用只通过消息事件通信。这样某个组件崩溃、重载、删除影响的只是实体本身不会波及全场场景。4.2 到了 ECS 为什么非得拆成 Entity/Component/System 三段组件模式发展到后期大家发现一个优化瓶颈组件按实体分组存放时相邻组件在内存里并不相近每个系统要遍历所有实体上的对应组件卡在缓存命中率上。ECS 的做法是连续性存储把所有同类型的组件放在一个紧密数组里迭代时随便顺序访问CPU 缓存友好度极高。这其实是我在项目里做过一次从组件模式迁到 ECS的动因——一个粒子量大的场景原来每帧都在随机制空内存到处跳换 ECS 之后帧耗掉了三分之一。ECS 还有个附带收益是 System 的并行度。引擎可以把互不依赖的 System 同时调度到多个工作线程上只要它们各自读写自己的组件数组就行。要注意的是System 之间如果确实存在读写同一套组件的数据依赖就必须加同步点。一个常见的工程实现是维护一个 System 优先列表按拓扑排序确定执行顺序。4.3 应该自己写 ECS 还是用现成方案如果你的目标是自己做一个完整引擎我建议自己写一遍 ECS。这不是说现成方案不好而是 ECS 的底层逻辑其实很单纯自己实现一遍能彻底吃透它涉及的所有数据结构问题——稀疏集、Archetype 表、索引定位、内存对齐。而这些知识在你评估任何第三方 ECS 库时都极其有用别人说我们的 ECS 快你能直接问一句组件存储是 SoA 还是 AoSComponentId 怎么映射的这就够了。实践中几个小细节值得留意ECS 的实体 ID 通常是 整数 版本号的组合版本号用来防止悬空引用删除实体时要先把组件搬出数组再把最后一个元素挪到空位避免空洞System 的迭代顺序影响 cache 局部性和结果可预期性建议统一在初始化时收集顺序5. 引擎三大业务线渲染、物理、音频的组织方式渲染、物理、音频是引擎里体量最大的三条业务线它们各有各的数据模型和执行频率架构上需要预留足够的独立空间。5.1 渲染架构从场景图到命令队列很多引擎的第一版渲染器都是遍历场景图 逐个画 Mesh这个原型能动但一到真实游戏场景就会撞墙。问题在于它把逻辑场景和渲染场景耦合死了。逻辑场景里物体是按层次组织的一个模型挂在骨骼点下面但 GPU 需要的是绘制命令的连续列表。工程上更通用的做法是建立渲染场景与渲染队列逻辑层把 transform、材质、网格等信息同步到渲染场景渲染场景每帧做视锥剔除、阴影剔除、遮挡剔除剔除结果组织成一长串 MeshDrawCommand 列表交给渲染线程渲染线程做排序、合批、提交 GPU这套设计的最大好处是渲染管线可以做到按特殊需求注入。比如你想实现一种特殊的后处理效果只需要在渲染队列的对应位置插入一个处理阶段而完全不用在乎逻辑场景怎么组织。渲染前端的另一大块是光照架构。传统的前向渲染和延迟渲染之争本质是在哪个空间做光照、光照数据的带宽要求。现在的引擎一般做成可切换的渲染路径或使用统一的台架式架构deferred 框架 forward 的透明通道。这背后遵循的原则是一样的光照计算尽量解耦场景组织光源数据的收集和分类放在 CPU 侧完成GPU 侧只拿到已经排好序的光源列表。5.2 物理架构宽相与窄相分离物理系统值得一提。物理引擎最常见的架构是分两个阶段宽相Broadphase和窄相Narrowphase。宽相的目标是用极低成本过滤出可能碰撞的对常用数据结构是 Sweep and PruneSAP 或 Dynamic Bounding Volume HierarchyBVH。窄相才去做精确的几何求交和接触点生成。很多引擎架构上的大坑是把这两个阶段混在一起写。其实宽相和窄相的生命周期完全不同宽相每帧或每两帧跑一次窄相则依赖子步调用两到三次。如果把它们捆绑物理引擎的步进频率就绑死了看起来稳定但后天也很难调优。物理还有一处关键的架构决策物理模拟状态是直接驱动 Transform还是通过插值去做。我的建议是物理驱动一个物理代理变换渲染逻辑在每帧渲染前再读取代理变换做插值万万不可在物理子步里直接改渲染 transform不然高帧率下抖到你怀疑人生。5.3 音频架构被忽视的响度实时管线音频系统经常被放在最后实现但它的架构其实不简单。音频不只是一个播放文件的功能它涉及解码mp3/ogg/flac、重采样多采样率混合、音量与音序声音事件管理、3D 空间化距离衰减、多普勒、混音与母线bus 系统、低延迟输出asio/wasapi等一整套实时管线。关键点在于音频回调是硬实时约束它不允许你在线程池里随便休眠等待锁。音频线程完全独立于逻辑循环它读数据、处理数据、输出数据任何不可预测操作都不能出现在音频回调里。所以引擎里的音频系统往往通过对事件队列进行无锁设计逻辑线程往里推指令音频线程在回调前消费指令两边用原子位标记。我自己踩过一次很深的坑音频系统用了普通互斥锁来保护声音源状态结果音频线程在锁里等逻辑线程 … 那段时间真是一卡一卡找了半天才发现是音频线程跟逻辑线程发生了锁竞争。换成无锁队列之后从此清净。6. 实际工程里最容易翻车的三处架构设计前面说的是标准架构下面聊实战里翻车频率最高的地方。这些不是概念性总结而是我自己和同行的项目里真实发生过的。6.1 全局单例从爽到崩的捷径新手写引擎很喜欢把所有系统设成单例RenderSystem::Instance()、PhysicsSystem::Instance()。前期写起来确实爽调用方拿全局指针到处用什么问题都能快速拼出来。但项目到了中期就开始遭罪模块之间隐式依赖初始化顺序成了玄学测试单元跑不起来因为全局状态难隔离热重载、多 world、编辑器预览多个世界时单例彻底变成单世界限制你想拆掉单例几乎等于重构一遍所有接口调用层。所以我的建议是引擎核心对象最好像一个生命周期明确的上下文传递比如创建一个 EngineContext 结构在启动时组装显式传给各个系统。一开始写起来多敲几个字后面换取的是清晰的依赖图和可测试性。这个取舍我在三个项目里反复确认过值得。6.2 层间循环依赖看起来没死锁改起来想骂人分层架构最大的纪律就是上层依赖下层下层不依赖上层。但现实里引擎模块经常不知不觉出现循环依赖渲染系统想拿场景里的行为数据场景系统又想调用渲染器做截图导出。如果你遵守下层不能依赖上层的纪律就需要把这些跨层调用转成事件或回调。举一个我实际经历的例子一个 UI 系统想读取角色视角的朝向数据来旋转小地图。按直觉写法是 UI 模块直接调用角色模块函数结果两个模块的依赖图绕了一圈。后来把信息改成事件发布——角色模块每帧发布视角变更事件UI 模块订阅并消费。UI 模块完全不感知角色模块存在纠缠就解开了。事件机制在引擎里不是装饰品它就是架构纪律的兜底工具。6.3 预计算与离散化伸手伸得太早第三个容易翻车的地方是在架构底层就做太多优化。有些引擎在起步时为了显得会优化过早引入状态缓存、预分配池、双缓冲池项目一复杂才发现很多优化假设根本不成立——同事调侃说我们在为一场还没签到的比赛提前耗尽体力。正确的节奏是先把架构跑直再用 profiling 找热点只针对真实热点做优化。引擎的很多模块之间数据流复杂过早早优化的后果是数据失效、缓存失效、维护成本激增。我在项目里立了一个规矩任何性能优化必须带 profiler 数据才开始不能出现我觉得会慢这种直觉驱动。7. 关于架构第一版的个人建议如果你正打算从零动手做一个引擎或者想给现有项目搭更稳的地基我最后分享几点经验和取舍第一版不要追求拥抱所有平台先保证在一个平台上完整跑通闭环再谈跨平台。平台抽象接口设计时可以按跨平台来规划但别急着为所有平台写驱动。把调试工具纳入第一版最早的架构里就应该有日志、断言、性能计数器。没有这些引擎跑起来出问题根本没法定位那时候再补架构非常痛苦。先做一个能玩的 demo再谈优化第一件事永远是接入渲染让屏幕上出现一个可以控制的立方体。架构好不好在这个闭环里立刻能体现出来。把引擎源码读起来如果真要深入理解架构我认为比看任何博客更有效的是直接读几个开源引擎的源码对照它们的主循环、资源管理器、渲染队列来理解我们这里谈到的概念。引擎基础架构是个很大的工程后面系列里我会继续拆资源管线、渲染架构、物理集成这些重头戏。按我自己的经验越满不了急越需要把底层这块一步一步理清楚——最怕的就是玩命堆功能累到后面全部推倒重来。
返回列表