ARTICLE DETAIL

资讯详情

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

游戏引擎基础架构全解析:游戏循环、ECS与渲染流程

游戏引擎基础架构全解析:游戏循环、ECS与渲染流程 把游戏引擎架构这四个字拆开看你会发现最容易被低估的其实是基础架构这四个字。很多入门的朋友学引擎一上来就盯着渲染管线、物理系统、动画系统这些大模块翻来覆去研究PBR、IK、八叉树这些当然重要但真正让一个引擎能稳定跑起来、让几十个人的团队能协作开发、让一个项目从原型迭代到上线不崩盘的恰恰是底层那一层不怎么起眼的基础架构。我参与过几个自研引擎项目也在商业引擎上做过不少底层工作踩过无数坑之后最大的体会就是架构不是画出来的是被调用关系逼出来的。这篇先聊引擎的地基也就是基础架构部分——游戏循环怎么设计、对象模型怎么选、内存怎么管、渲染帧怎么组织、数据流怎么串起来。适合正在写游戏逻辑但想往引擎底层钻的人也适合准备自研引擎或者深度定制商业引擎的团队。1. 引擎整体的三层分布先把骨架立起来1.1 架构不是画出来的是被调用关系逼出来的很多人在设计引擎架构的时候喜欢先画一张大图把渲染、物理、动画、音频、网络、玩法系统铺成一圈中间放一个所谓的核心层然后宣称这就是架构。这种图看着漂亮实际写代码的时候完全用不上因为架构的真正约束来自调用链来自谁调用了谁、谁可以引用谁、数据在谁手里这三个问题。我倾向于把引擎从高层到底层切成三段最上面是Gameplay层玩法层中间是Framework层框架层最底下是Platform层平台抽象层。玩法层包括脚本系统、组件逻辑、关卡机制它只允许调用框架层的能力比如我想播放一个动画我想播放一个音效我想生成一个实体它不直接碰设备相关的东西。框架层是核心负责实体管理、组件调度、消息系统、渲染场景提交、物理世界管理、音频混合、动画状态机更新它也不直接碰具体平台的API。平台抽象层就是窗口、输入、GPU设备、文件系统、线程原语、时间系统这层把不同平台Windows、Android、主机的差异全部封装掉。这个三层结构最关键的点是依赖方向必须单向玩法层依赖框架层框架层依赖平台层任何反向依赖都要通过事件、回调、命令或数据驱动来解耦。实际开发中这条规则会被不断破坏比如玩法层直接调一个具体的渲染API框架层直接操作窗口句柄这种便捷会欠下技术债等到要换平台或者要并行开发的时候就爆发了。1.2 模块间的数据流和所有权关系有了三层骨架后还要明确模块之间怎么传递数据。我强烈建议在引擎基础层做两件事一个是全局的Timer服务和帧时钟另一个是模块间的消息总线。为什么这两件事要放在基础层因为几乎每个模块都需要知道当前是多少帧上一帧花了多少时间也几乎每个模块都需要通知别人某件事件发生了比如玩家输入了跳跃指令场景加载完成了资源流送触发了。如果你不把这些做进基础架构里每个模块就会自己维护一套时间系统和一套回调列表结果就是时间不同步、回调顺序失控、调试困难。我在早期项目里就吃过这个亏动画模块用自己的帧计数物理模块用真实耗时两个模块的时间基准不一样导致角色在空中跳跃的那一帧出现明显的抖动查了一整天才发现是时间源不一致。所有权关系同样重要。在基础架构里要明确问自己几个问题一份Mesh数据归谁所有渲染线程能改它吗物理模块能改Transform数据吗这个Transform是逻辑层持有还是渲染层持有很多引擎最终选择让核心的组件数据Transform、MeshInstance、LightInstance由框架层统一持有其他模块只能通过接口读取或提交修改请求不会出现多个模块同时写同一个内存导致的数据竞争。2. 游戏循环与帧节奏引擎的心跳怎么设计才不乱2.1 固定步长还是可变步长前面不选好后面全是坑游戏循环是引擎基础架构里最容易被轻视的部分。很多人觉得不过就是whilerunning{ update(); render(); }但真正做深了才知道里面的门道逻辑更新用固定步长还是可变步长渲染帧率要不要跟逻辑帧率解耦两者之间怎么同步这些都直接决定了物理模拟的稳定性、网络同步的可行性、动画插值的平滑度。固定步长的核心设计是逻辑以固定的时间间隔更新比如每秒60次每次更新耗时不能超过16.67毫秒如果跑不完就要累积下一帧补上或者丢帧。这种做法对物理系统极不友好因为物理引擎尤其是基于约束求解的对固定步长有强依赖步长抖动会导致模拟不稳定、穿透、抖动。可变步长的好处是逻辑跟真实时间走得近但坏处是物理、网络、相机插值全都受影响。我自己的实践经验是物理模拟和网络状态同步必须用固定步长其他玩法逻辑可以走可变步长或者跟帧最终通过插值对外表现平滑。业界主流的方案是半固定式固定一个逻辑步长比如1/60秒渲染循环按需运行可以是60帧也可以是120帧每次渲染前先根据累积时间把逻辑补足比如跑0、1或2个逻辑步然后再做渲染预测与插值。这套方案的实现核心是accumulator和插值因子代码不长但对架构的分层和数据的读写边界要求不低。2.2 逻辑帧与渲染帧解耦的实际写法在基础架构层我一般会提供一个FrameClock服务它负责计算deltaTime、输出当前的逻辑帧号、渲染帧号、插值因子alpha还会暴露一个回调注册接口OnLogicUpdate固定步长更新和OnRenderUpdate每帧渲染前更新。这样各个模块不需要自己拿系统时钟再算deltaTime直接订阅FrameClock即可。一个比较典型的循环实现是这样的void Engine::RunLoop() { const double fixedDelta 1.0 / 60.0; // 逻辑步长 double accumulator 0.0; while (m_running) { double frameTime m_clock.GetDeltaSeconds(); // 本帧的实际耗时 frameTime std::min(frameTime, 0.25); // 防止空档期闪断 accumulator frameTime; while (accumulator fixedDelta) { StepLogic(fixedDelta); // 每步固定步长更新物理 / 动画 / AI / 网络 accumulator - fixedDelta; } float alpha static_castfloat(accumulator / fixedDelta); // 插值因子 StepRender(alpha); // 渲染帧更新使用 alpha 做位置的插值 } }这段代码我用了很多年看着简单实际有几个必须注意的坑。第一个是frameTime必须做上限钳制如果窗口被系统拖拽或者后台卡顿了几百毫秒accumulator会巨增如果放手让它把几十个逻辑步一次性全跑完物理可能直接炸掉AI和动画也会瞬间跳进一个新世界。先钳到一个合理值比如250ms再决定是丢弃还是最多追多少帧不然就等着线上玩家反馈卡了一下人物瞬移了。第二个点逻辑更新步数与渲染更新步数不同意味着渲染层拿到的Transform是逻辑帧之间的中间态需要用alpha插值。如果不做插值60Hz逻辑配上120Hz显示器角色运动会出现肉眼可见的抖动这个在PC高刷屏上特别明显。注意固定步长不是越大越好也不是越小越好。1/60是物理和网络都比较平衡的默认值但如果你做的是赛车、格斗这种对精度和同步要求极高的玩法可能需要1/120甚至更高。不过步长越小每帧逻辑成本越高你要在架构初期就把这个步长做成可配置项而不是硬编码在循环里。3. 核心数据结构ECS与组件模式3.1 对象模型选型深继承还是组合引擎基础架构里最影响手感的决策之一就是游戏对象模型。早期引擎流行深继承GameObject基类往下派生出Actor、Character、Vehicle再往下派生出PlayerCharacter、EnemyCharacter……听起来挺符合直觉但项目一上去就遭殃当你要做一个可以被驾驶的载具同时也是一个怪物AI同时在特定关卡里又承担NPC对话的角色时继承树会变成一团乱麻特性交叉导致你又想继承这个又想继承那个最后只能到处复制粘贴。所以现在的自研引擎基本都倒向了组合/ECS路线。组合式Component-based是指一个Entity只是唯一标识符真正的数据和能力都挂在Component上TransformComponent、RenderComponent、AudioSourceComponent、HealthComponent。框架层统一管理Component的添加、删除、查询。ECS则是更进一步把数据Component按结构连续存储行为System单独组织这样既方便复用行为又能利用缓存局部性让遍历快很多。选择方案时要考虑你的团队状态和业务形态。如果团队熟悉OOP游戏类型又偏CRPG这种对象类型相对固定的用组合为主、轻量继承为辅就够了。如果做的游戏里大量实体需要动态组装、需要高性能查询比如弹幕游戏的数千子弹、模拟经营的上万单位那ECS才是正解。我不建议新手上来就全盘ECS因为ECS对架构的约束比组合模式严格得多编译和维护门槛都高不少。3.2 连续内存与对象池帧率稳定性的隐形功臣引擎性能差经常不是算法慢而是内存碎片化和Cache Miss。你在一个数组里遍历一万个Transform组件CPU可以以接近内存带宽的速度扫过去但如果这一万个Transform散落在内存各处每一次读取都可能触发缓存缺失性能可以掉一个数量级。所以基础架构里要做一个跟业务无关但对所有业务都起作用的事情组件存储尽量连续化Sparse Set就是ECS里常用的一种结构。Sparse Set大体上是一个稀疏索引加一个稠密数组Entity ID取模映射到索引索引指向稠密数组里的槽位组件本身存在稠密数组里删除时用尾部元素交换补洞。这样让组件数组始终是一个紧凑数组遍历时缓存友好。我实测过一个场景用Sparse Set存三万个Transform组件旋转更新一帧只要0.1毫秒左右而用unordered_map存同样数量的组件光遍历就要0.8毫秒以上差距非常可观。对象池也属于基础架构必须提供的能力。游戏里高频创建销毁的对象一定不要裸用new/delete否则堆分配和释放会让帧时间产生很大的毛刺spike。以子弹为例一般做法是预分配一个BulletPool内部维护空闲链表获取和归还都是O(1)同时池子还可以记录当前存活数峰值存活数这些统计方便性能分析。我在基础架构里通常还会加一层内存分类记账每个Frame开始自动打印本帧分配了多少字节、哪个模块分的、是否触发了垃圾回收如果有GC的宿主语言或者是否发生了堆外分配C里就是malloc调用次数。这些数据看着不起眼但当你排查为什么每玩三分钟卡一下这种问题时它们就是你破案的唯一线索。这里特别提醒不要小看分配量很多引擎的卡顿不是算法瓶颈而是GC或堆管理器在后台扫内存。4. 渲染系统从场景提交到GPU的一整套流程4.1 场景组织与渲染帧拆解先搜集再绘制不少新手对渲染的理解是一个DrawCall画一个模型按顺序刷屏幕就行实际上真正引擎级渲染流程要复杂得多。渲染系统通常要经历场景组织SceneGraph/Sector/Portal/Octree→ 视锥剔除 → 遮挡剔除 → 光照收集 → 排序 → 批处理 → 生成DrawCall数据 → 提交到GPU。基础架构里不一定要实现全部但必须把场景组织和渲染数据流的接口定清楚。我见过不少中小型引擎为了简单直接在渲染循环里遍历所有Actor然后分别Draw结果就是100个建筑、1000个小物件、500个特效全部分别提交DrawCall爆表移动端直接卡成PPT。正确做法是所有可见物体先收集到一套RenderCommand队列中这队列会经过排序和合并尽量把相同材质、相同贴图、相同Shader的状态切换合并成一次提交。渲染帧的数据流一般长这样逻辑帧结束时把需要渲染的实例Mesh、Transform、Material引用、Shadow标志位提交到渲染系统渲染系统做视锥剔除和排序生成RenderItem列表按照渲染通道Shadow Pass → Opaque Pass → Transparent Pass逐通道处理每个Pass内部按材质/深度排序后生成GPU命令SetPipelineState、SetRootConstant、DrawIndexed命令写入CommandBuffer由渲染后端提交给图形API。4.2 CPU与GPU的异步和同步“双缓冲”引擎基础架构里有一个特别容易出问题的点CPU和GPU的同步。很多图形API为了让CPU不必等GPU会允许你连续提交若干帧的命令GPU在后台异步执行。但如果你在CPU上改了一个ConstantBufferGPU下一帧才读到旧值这就出现了数据竞态。所以引擎必须做到渲染数据和帧号绑定CPU写入的是当前帧的资源GPU读取的是之前提交的帧资源中间用RingBuffer或者FrameInFlight多帧缓冲来错开。实际操作中我最常用的是三缓冲结构Triple Buffering每帧分配三个Uniform/TransformBuffer槽CPU写槽0GPU读槽2这样CPU最多领先GPU两帧不管是60Hz还是120Hz都能覆盖。这套架构虽然不复杂但需要在基础层就设计好否则后期想加多线程渲染、想加VR立体渲染、想加动态分辨率都要把渲染模块推翻重来。还有一个关键点渲染线程和逻辑线程怎么协作。很多引擎在基础架构里做了逻辑/渲染双线程主线程跑Gameplay和物理渲染线程跑剔除和命令提交两者通过一个线程安全的FrameDataQueue交换数据。逻辑线程把这一帧的Camera状态、Transform快照、灯光状态打包放进队列渲染线程消费后做自己的渲染工作两个线程之间尽量减少同步锁用帧号做校验。我建议团队做这个设计时一定要从一开始就约定清楚哪些数据是逻辑线程独占的、哪些是渲染线程只读的、哪些需要加锁或原子操作。否则后面就会出现离屏黑色角色抖动这种时隐时现的鬼畜bug极难排查。如果做不起双线程那至少要把渲染提交做成一个独立的函数在逻辑更新完之后只调用一次不要在逻辑里穿插几十个渲染API调用否则后面想加多线程渲染根本无处下手。5. 资产与场景管理运行时怎么喂引擎5.1 资产的加载、引用与卸载生命周期引擎基础架构里资产系统往往被低估但实际上一款3A游戏的资产数量动辄几万份怎么管理它们的生命周期是整个底层架构里最容易出漏洞的地方。资产模型、贴图、音频、动画、材质、UI预制体需要经历从磁盘读取 → 反序列化 → 上传GPU贴图、Mesh的显存版本→ 被多个对象引用 → 最终引用释放 → 卸载。这里面的核心问题是引用计数和异步加载。引用计数很常见但我在实践中发现一个陷阱资产的引用关系不是简单的树状常常有循环引用比如一个材质引用了贴图贴图又挂了一个指向材质的标签如果引用计数处理不好资产永远无法释放内存只增不减。所以引擎基础架构里一定要有元数据层面的引用表而不是完全依赖运行时引用计数。另一种方案是做资产依赖图每个资产显式声明依赖了哪些其他资产加载时按拓扑顺序加载卸载时按反拓扑顺序卸载。异步加载也很关键。很多游戏卡顿都发生在刚进入新场景模型贴图全都在现场加载的时候。基础架构应该提供一个AsyncAssetRequest系统界面先显示过场或者占位资源背后用后台线程流式加载资产加载完通过回调通知场景刷新。如果你做的引擎不支持异步加载那进大场景的加载时间会让玩家崩溃的即使现在的SSD已经很快了几千个小文件本身就够卡的了。这里推荐做资源包打包把零散文件打成几个大包减少IO次数加载时间能下降一个数量级。5.2 场景图与实体组织的正确打开方式场景管理听起来像是游戏逻辑的范畴但它的数据结构通常也属于引擎基础架构的一部分。一个场景可能有海量的静态物体和动态物体怎么把它们组织起来供渲染、物理、AI查询我建议场景本身不要做成一张巨型的实体数组而要在基础架构里引入空间分区结构静态物体用静态网格Static Octree/Grid动态物体用BVH或者动态加速结构。场景图Scene Graph在游戏里真正承担的角色是Transform层级关系和逻辑分组比如角色手部骨骼挂枪、车子的轮胎挂车身这种父子关系靠场景图来维护。但它不是用来做渲染剔除的渲染剔除靠的是空间分区。很多人把这两件事混在一起结果场景遍历慢、更新复杂。我给出一个经过多次验证的方法Entity系统负责游戏对象的身份和组件访问Transform组件的父子关系形成一棵动态层级树场景的空间加速结构单独维护每次Transform变化都打一个脏标记空间加速结构只在脏标记触发时才局部更新。这里再分享一个常见的坑不要在场景加载的时候把所有资源都放进内存。好的做法是分区域和分优先级流式加载进入某个区域之前就预热Preload离开区域时主动卸载。而判断区域用哪些资产可以在编辑器里导出场景资产依赖清单这个文件运行时直接参考这个清单做流送调度比运行时全靠引用计数判断要稳得多。6. 调试架构与工具链引擎开发者的仪表盘6.1 日志体系与监督机制怎么搭才不失控引擎基础架构有一个必须从第一天就做好的部分调试基础设施。注意不是等出了问题再打日志而是设计成架构的一部分。我见过太多引擎的调试方式是printf和断点这在写Demo的时候够用一旦系统并行起来了逻辑线程渲染线程流送线程断点会把整个引擎卡死printf输出也会干扰时序。所以基础架构里要有三层调试工具日志系统、统计仪表盘、内存检查器。日志系统要支持级别分级Trace/Debug/Info/Warn/Error/Fatal每个级别按分类过滤渲染、物理、网络、资产、玩法支持写文件、控制台、内存循环缓冲三种输出。特别推荐内存循环缓冲把最近N行日志保存在内存里固定大小崩溃的时候可以立即dump出来这样线上复现问题就不用靠玩家手动录屏了。统计仪表盘则是把每帧耗时、DrawCall数、三角形数、内存占用量、GC次数这些数据做成实时图表既要能看平均帧耗时也要能看99%帧耗时P99因为平均帧不卡不代表不卡。我再强调一个概念引擎的调试架构不仅要能看现在的状态还要能抓罪魁祸首。比如你要知道这一帧到底是谁把时间吃掉了所以每个模块要提供简单标量中间统计模块执行耗时、任务数量、等待次数记录在每帧的Profile里方便线上分析。这些统计本身不能太贵通常就是几个原子加法用RingBuffer存一下即可。6.2 开发期架构设计的常见坑与我的排查心得写到这里我想把这么多年见过的高频坑集中列一下也算给后面要自己搭引擎基础架构的兄弟们提个醒。第一个坑是过度设计。我一个朋友刚启动新引擎项目就非要引入DODData-Oriented Design、JobSystem、零拷贝流送结果做了三个月连一个能跑的Demo都没有。基础架构的优先级应该是先能稳定跑起来、能持续迭代再考虑极端性能优化。性能优化局部做不要为了先进把整个架构都调到一个极高复杂度。第二个坑是模块间硬编码依赖。比如物理模块直接修改渲染模块的GPU资源对象输入模块直接拉起一个Windows窗口API。这种耦合短期内看起来方便但一旦要换平台或者要上多线程所有模块都要重写。我强烈建议模块之间只通过接口和事件通信哪怕多写几层适配层换来的是长期的可维护性。第三个坑是不做版本化的存档和网络协议。基础架构阶段就要想清楚你未来会改组件结构旧存档怎么兼容网络协议版本怎么控制如果没有一个最初的版本化框架到后期改一个字段名都可能导致全网玩家掉线。这部分在我经历的几个项目中都是第一批吃吐血的。第四个坑是没有考虑平台差异。手机的CPU核心数和PC差很多移动端GPU对带宽和填充率极其敏感主机又有截然不同的内存架构。引擎基础架构的平台抽象层不只是换个编译器就能跑而是要按平台分别实现适配层不同平台的IO性能、显存带宽、线程调度策略都不同。如果从一开始就把平台层写成一堆宏开关后期你会被各种分支搞得焦头烂额。排查技巧方面我再分享一个个人经验大多数时好时坏的问题最后查出来都是数据同步问题而不是算法问题。当你发现一个渲染结果不稳定、物理抖动、音频断续时第一反应永远是去看帧号和时间戳确认数据是不是同一帧的。我用这个方法解决过很多奇难杂症强烈建议大家把所有跨模块的数据都打上帧号和时间戳这个习惯比任何调试器都管用。这套引擎基础架构我从零搭过三轮每一轮重新做的时候都能发现自己上一轮留下的蠢设计。游戏引擎架构这个问题技术上没有银弹但基础层如果骨架正了后面加模块就是体力活骨架歪了后面每一步都在还债。我个人最深的体会是架构设计一定要在写第一行代码之前就想清楚三个东西——依赖方向怎么走、数据归谁管、帧的节奏怎么统一。想清楚了再动手不迟。
返回列表