ARTICLE DETAIL

资讯详情

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

游戏引擎架构解析:主循环、渲染与资源管理的边界设计

游戏引擎架构解析:主循环、渲染与资源管理的边界设计 入行做引擎的那年我犯了几乎所有初学者都会犯的错误拿到需求第一件事就去写渲染器。半年之后画面倒是能看了但每加一个功能就要动掉一大片代码改一个加载逻辑能把动画和音频模块都牵连进来。第一个人月用来写渲染后面五个多月全花在修补撕裂的模块关系上。这篇是游戏引擎架构深度解析系列的第一篇我会把基础架构这部分掰开揉碎讲清楚引擎为什么要有分层、主循环应该怎么设计、渲染和资源系统如何接入架构而不至于互相绑架以及我从几个商业引擎源码和多年自研引擎迭代里踩出来的坑。适合刚起步想自写引擎的人也适合已经用现成引擎、想搞明白背后逻辑的人。1. 引擎基础架构到底在管理什么1.1 一个没有架构的引擎是什么样先看一份最原始的主循环代码。很多刚接触引擎编程的人第一版通常就是这么写的while (true) { handleInput(); updatePlayer(); updateEnemies(); updatePhysics(); renderEverything(); swapBuffers(); }这个版本能跑表面上也没毛病。但它有几个致命问题会在项目规模变大后集中爆发。第一没有时间概念。每一轮循环跑多快完全看机器性能。在高端台式机上跑300帧人物走路快得像开了倍速换到低端手机上只有15帧人物慢得像卡带。游戏逻辑必须跟真实时间挂钩而不是跟循环次数挂钩。第二模块之间互相裸调。handleInput()直接往updatePlayer()里塞数据玩家模块想改个数据结构输入模块就得跟着改。今天加了跳跃明天加了个飞行器updatePlayer()越堆越长最后变成几千行的巨型函数谁都不敢碰。第三顺序写死。AI想改成先于物理更新你要动主循环。动画想晚一步算你还要动主循环。想多个线程并行更新无从下手因为所有调用都是同步的、串行的、写死的。所以引擎基础架构做的第一件事就是把这种面条式代码拆成有边界、有规则的结构。1.2 架构管理的三件事我做了这些年引擎最终把基础架构的职责归纳成三件事。第一件管理时间与顺序。也就是谁在什么时候跑。主循环、Tick调度、帧率控制、逻辑帧和渲染帧的分离全归这一类。这一块处理不好游戏就会出现物理一会快一会慢网络同步对手跟我不在一个世界这类玄学问题。第二件管理依赖与边界。也就是谁能调用谁、谁不能调用谁。渲染模块能不能直接读取玩家的血量资源系统能不能反过来调用渲染接口创建纹理这些边界如果不画清楚项目中期就会陷入改一行代码引发连环编译错误的泥潭。第三件管理数据与所有权。一份3D模型资源到底归谁持有加载之后由谁释放如果角色和UI同时引用同一个材质谁负责保证材质不被提前卸载这些问题是资源管理系统的核心也是架构里最容易被低估的隐性骨架。这三件事互相纠缠顺序影响数据流数据流影响依赖关系依赖关系又反过来限制你能否改顺序。基础架构设计本质上就是在这三者之间找到一套稳定的、能支撑后续多年迭代的规则。2. 先画边界再写代码引擎的分层模型与单向依赖2.1 推荐的四层模型平台抽象层、内核层、功能层、游戏层架构设计的第一步从来不是选引擎还是造引擎而是先画一张模块依赖图。业界主流引擎的分层方式大同小异我个人习惯把它分成四层。层级包含内容依赖方向游戏层场景、玩家逻辑、AI、Gameplay规则只依赖功能层和内核层功能层渲染、物理、音频、动画、资源管理只依赖内核层和平台层内核层数学库、内存分配器、容器、事件系统、反射系统只依赖平台层平台抽象层窗口、输入、文件IO、线程、图形API封装不依赖任何上层这个分层的含义是上层的代码可以调用下层下层的代码绝不能反过来引用上层。比如游戏层可以让音频系统播放一段音效音频系统绝对不能反过来查询现在玩家的状态是不是陶醉。每一层内部的模块之间也尽量保持平级关系。比如功能层里渲染、物理、音频彼此都是兄弟模块它们之间不要互相直接叫对方的内部函数而是通过内核层的事件系统或数据容器来协作。2.2 为什么依赖方向必须是单向的很多初学者会觉得你让我渲染模块不访问游戏逻辑那我做根据血量变色这种效果不就麻烦了吗确实麻烦但麻烦换来的是可控性。举个反面例子。早期我的引擎里渲染模块为了拿到主角当前朝向直接引用了一个名为g_player的全局对象。当时觉得方便只隔一个文件嘛。三个月后玩家模块改成支持多人联机g_player变成了g_players[4]。渲染模块跟着改没问题问题是物理模块、音频模块、UI模块全都直接引用了g_player。一个改动动八个模块每个模块里还散落着十几个引用点。这就是边界不清晰的代价。正确的做法是游戏逻辑把结果写入一份数据渲染模块只读取这份数据。例如玩家朝向写进一个PlayerFrameData结构体渲染层拿到的是这个数据结构的只读引用。游戏层发生变化时只要字段不变渲染层一行代码都不用改。2.3 跨层接口应该长什么样分层之后跨层调用不能直接暴露具体实现类而是要暴露接口。比如功能层对外提供渲染能力通常长这样// 内核层定义的通用接口 class IModule { public: virtual bool Init() 0; virtual void Shutdown() 0; }; // 功能层渲染模块的对外接口 class IRenderer : public IModule { public: virtual void Submit(const RenderCommand cmd) 0; virtual void Present() 0; virtual void Resize(int width, int height) 0; };游戏层拿到的是一个IRenderer*指针它知道这个接口能发渲染命令、能翻页、能改分辨率但完全不知道底层是OpenGL还是Vulkan是微软的D3D12还是软件渲染。这样当你某天决定从某个API迁移到另一个API时游戏层和功能层其他模块可以全部保持不动只替换渲染模块的内部实现。判断分层是否健康最简单的方法就是看依赖图里有没有环。每写完一个模块我都习惯性地画一张我依赖谁的清单。如果某天发现资源系统依赖渲染系统、而渲染系统又依赖资源系统就说明边界画错了优先重构这里而不是继续在上面堆功能。3. 主循环与Tick设计引擎跳动的核心3.1 带时间戳的主循环骨架分层是骨架主循环就是引擎的心脏。一个可以用的主循环远不止while(true)那么简单。第一次做引擎时我连数据处理都设错了后来发现所有时序问题本质上都是时间数值用错了。看过各种引擎实现后我给出了一个够用的最小版本class GameEngine { public: void Run() { uint64_t lastTime platform-GetTimeMicroseconds(); double accumulator 0.0; const double fixedDt 1.0 / 60.0; while (!platform-ShouldQuit()) { uint64_t currTime platform-GetTimeMicroseconds(); double deltaTime (currTime - lastTime) / 1000000.0; lastTime currTime; // 上限保护防止调试器断点等极端情况造成物理爆炸 deltaTime std::min(deltaTime, 0.25); // 固定步长累加 accumulator deltaTime; while (accumulator fixedDt) { engineCore-Tick(fixedDt); accumulator - fixedDt; } // 渲染帧按真实时间去渲染 renderer-Present(); } } };这个骨架里有几个点是我后来花了很多时间才真正理解的。3.2 固定步长和可变步长物理与世界逻辑的选择游戏循环里最经典的争论就是逻辑更新到底用固定步长还是可变步长。可变步长的意思是每帧逻辑都按实际经过的时间来推进。比如这一帧跑了0.02秒逻辑就按0.02秒算。实现简单代码直观但有个大问题如果你的物理引擎里用数值积分帧间隔忽大忽小会导致模拟结果不稳定。今天跑60帧和明天跑30帧同一个小球弹跳的轨迹可能完全不一样。这对于单机RPG可能无所谓但对于格斗游戏、赛车游戏和任何需要确定性表现的网络游戏来说是没法接受的。固定步长的意思是逻辑永远按一个固定的时间间隔推进比如每秒60次每次固定0.016666…秒。多出来的帧时间不会让逻辑跑得更快而是累积到一个缓冲池里累积够了就多跑一次逻辑。这样无论画面是120帧还是30帧逻辑更新频率始终稳定在60Hz。评估维度可变步长固定步长实现难度低中逻辑/物理确定性差好低帧率下表现逻辑也跟着变慢体验崩坏逻辑稳定画面可能卡但行为一致网络同步友好度差好适合场景原型Demo、UI为主的小游戏物理较多、联机、竞技类我的建议是渲染帧用真实时间驱动逻辑Tick用固定步长驱动两者通过累加器分离。这也是Unity物理系统里Fixed Timestep默认0.02秒的做法Unreal的Gamethread和RenderThread分离也遵循了类似的思路。逻辑和渲染分开的最大好处是当机器帧率不足时你可以选择降低渲染频率但不降低逻辑频率玩家体验到的是画面有点掉帧而不是游戏物理开始乱飞。3.3 Tick执行顺序先输入、再逻辑、后渲染主循环里那个engineCore-Tick()内部到底先执行什么后执行什么也是架构设计的一部分。我常采用如下顺序输入采集把鼠标、键盘、手柄、触屏的原始状态收集到一份输入快照里。游戏逻辑更新玩法系统、AI、摄像机等读取输入快照并进行逻辑推进。物理模拟基于逻辑更新后产生的碰撞体位置进行物理求解。物理放在逻辑之后保证刚体位置的更新基于最新输入不容易出现按了跳跃键但跳不起来的延迟感。动画与粒子等表现更新基于最终的逻辑状态生成动画姿态、特效参数。渲染提交把最终需要绘制的内容写入渲染命令缓冲交给渲染线程或后端API。这套顺序不是唯一正确的但它有一条清晰的逻辑数据从输入流向玩法再从玩法流向物理和表现最后汇聚到渲染。如果你把物理放在逻辑之前玩家输入就会晚一个Tick才影响刚体高帧率下会感到一种微妙的肉感。3.4 逻辑帧与渲染帧分离带来的现实收益固定步长逻辑 可变步长渲染会带来一个初学者常困惑的问题逻辑跑了3次渲染只显示1帧中间两帧的逻辑状态去哪了答案是不显示但它们依然在推进世界。这其实是很多策略游戏后台计算能实现的架构基础哪怕你的显示器只有30Hz游戏世界依然以60Hz的精度在模拟。反过来如果你的显示器是144Hz但逻辑只有60Hz那么游戏渲染的画面会在相邻两帧显示同一个逻辑状态看起来依然是流畅的因为渲染插值会帮你过渡。在自研引擎中我会额外保存上一帧逻辑状态和当前帧逻辑状态渲染时根据渲染帧的真正时间戳在两份状态之间做插值。这让人物在低帧率下也不会出现明显卡顿代价仅仅是每个实体多保存一份变换数据。这个技巧在主机和手机上尤其值钱。4. 渲染子系统接入架构为什么渲染不能被其他模块绑架4.1 渲染子系统的特殊性在所有功能模块里渲染是最特立独行的一个。它有几个其他模块不太具备的特质更新频率极高每帧都要跑、对延迟极度敏感、高度依赖硬件抽象GPU队列、交换链、天然适合并行执行。所以它在架构里必须拥有清晰的边界否则会把其他模块拖下水。很多初学者会直接把render()写在主循环里然后让角色直接调用renderPlayer()、renderMonsters()。这个做法在50个物体以内的Demo里没有任何问题但到5000个物体时你会发现问题不在画画的代码而在谁组织绘制顺序、谁管理GPU资源、谁做合批。渲染架构要处理的核心是把决定画面内容和生成绘制指令完全分开。4.2 命令缓冲把想干什么和何时执行拆开现代渲染架构的核心设计是命令缓冲。物理上CPU生成绘制指令的速度和GPU执行指令的速度并不一致CPU可能比GPU快好几帧。如果每一帧都让CPU立即等待GPU执行完帧率会被拉得很低。使用命令缓冲之后CPU端的逻辑变成// 游戏层每帧调用把绘制愿望记录下来 commandBuffer-BeginFrame(); commandBuffer-DrawMesh(meshHandle, materialHandle, transform); commandBuffer-DrawMesh(meshHandle2, materialHandle2, transform2); commandBuffer-EndFrame();这些命令没有立即执行而是先进一个队列。渲染线程把整个队列提交给图形APIGPU按顺序执行。这样CPU和GPU就像两条流水线满了就在中间用缓冲隔开谁也犯不着干等谁。这套机制在底层实现上对应了Vulkan和D3D12的原生命令列表概念OpenGL时代没有类似机制所以游戏开发者被迫在CPU端手动做状态排序和提交效率天差地别。架构上重视命令缓冲等于给你的引擎预留了多线程和现代图形API的入场券。4.3 渲染层的唯一数据来源帧快照渲染层不能直接读取游戏对象那它的数据从哪来答案是帧快照。每帧逻辑推进完毕后游戏层会为渲染层生成一份经过整理的场景描述所有需要渲染的网格引用、材质引用、变换矩阵、灯光列表、相机参数、雾效开关。这份快照只包含渲染需要的数据不包含玩家的血量、AI的状态、当前任务是哪个这类和画面无关的东西。struct RenderFrameData { std::vectorMeshInstance meshes; std::vectorLightSource lights; CameraData camera; float timeOfDay; };渲染层拿到RenderFrameData之后想怎么组织绘制就怎么组织排序、合批、剔除、实例化全在渲染层内部完成。游戏层完全不关心这些细节。这样做还有一个隐藏好处为编辑器里的场景预览和实际游戏画面共用渲染层提供了基础。因为渲染层只认识快照数据编辑器只需把编辑器场景也转成同样的快照发过去就复用了整个渲染管线。4.4 双缓冲与帧数据的生命周期渲染层使用帧快照会牵出一个新问题GPU还在画上一帧CPU已经生成好这一帧了上一帧的数据不能提前销毁。我的做法是维护一个数量为2或3的环形缓冲CPU往第0号快照写入数据GPU正在读第1号快照第2号快照待命。当CPU提交完第0号它下一帧就写第1号GPU也依次往后走。这样双方永远不会写到同一个槽位。这就是双缓冲或三缓冲在架构层面的意义用一份数据的空间冗余换掉CPU和GPU之间的同步等待。切场景、动态加载地图时帧快照里会包含新资源。为了保证正在被GPU引用的资源不突然消失资源系统需要配合执行延迟释放标记待释放的资源等GPU确认已不再使用该资源的帧号之后才真正释放显存和内存。这类细节如果不在一开始就考虑引擎运行几天后会逐步累积显存碎片并最终报错。5. 资源管理架构里最容易欠下的技术债5.1 资源是什么实例是什么很多架构新手分不清资源Asset和实例Instance的区别。一份汽车模型资源是磁盘上的一份文件场景里可能放着三辆一样的汽车这三辆汽车各自的位置、颜色、受损状态属于实例数据。引擎资源系统的首要职责就是维护一份资源多份引用的映射关系。游戏层不应该直接操作文件路径而是通过**句柄Handle**来引用资源。句柄是一个数字ID资源系统维护着ID - 内存数据的映射表。这样当资源被移动、重新加载、甚至换了一个版本时游戏层持有的句柄不用变只有资源系统内部更新映射表。5.2 GUID与路径移动文件不崩溃的Architecture决策早期引擎里资源引用经常写成Models/Car.fbx这种相对路径。看着直观开发者一移动文件就全崩。游戏发版后要热更新一个模型路径变了所有引用它的场景都得跟着改改漏一处就出狐狸。稍微成熟一点的引擎会改用GUID全局唯一标识符。每个资源在导入时分配一个GUID场景文件里存的是GUID而不是路径。路径只是GUID到文件的索引即使文件搬家只要GUID对应的目标更新到新路径场景引用无需改动。Unreal的资产系统用的就是类似的思路。这个设计看起来跟架构关系不大实际却是资源子系统最重要的决策之一它保证了资产可移动、可重命名、可多人协作而不会引发一连串引用链断裂。5.3 加载、释放与引用计数资源生命周期管理遵循一个最朴素的规则谁持有引用资源就为谁存活。通常我会在资源系统里同时提供两种引用方式。强引用持有者用强引用资源在内存中加载引用计数1。销毁时-1计数归零则释放。弱引用只查询、不持有资源被卸载后自动变成空句柄持有者不会因此崩溃。但引用计数有个陷阱循环引用。比如A场景持有材质X材质X又持有一个回指A场景的引用。两者互相拉高计数谁也释放不了。解决方法是资源之间的依赖关系必须是有向无环图。加载一个地图时地图引用模型模型引用材质材质引用贴图依赖方向一致向下。用拓扑顺序依次加载释放时按逆拓扑顺序清理。这样就从根本上避免循环引用比事后写一套持引用检测器实在得多。5.4 异步加载和热重载对架构的硬性要求资源加载如果全部放在主线程同步执行个位数的模型还好一张大世界地图的加载会让游戏卡死好几秒。所以资源系统需要和主循环架构配合加载操作放到后台工作线程加载完成后通过事件系统通知主线程而不是让主线程空等。热重载也一样。编辑器里改了材质参数游戏运行中希望立即看到效果。资源系统的工作方式是监听文件变化 - 重新加载资源 - 广播资源已变更事件 - 所有持有该资源的系统在下一帧收到通知并重新绑定。这要求架构里必须有一个统一事件分发通道否则每个子系统各自实现一套文件监听和刷新逻辑代码就会迅速腐化。6. 从Demo到商用级架构演进的实战体会6.1 过度设计同样会毁掉引擎聊了这么多分层、边界、接口可能有人觉得引擎架构就是越复杂越好。我的经验恰恰相反第一版引擎的架构目标是让你在三个月内能改得动、不崩溃而不是让你一步到位设计出终极引擎。我见过一个团队上手就写插件式架构、消息总线、Epoch管理器、反射系统全套上马半年过去了一个能玩的小关卡都没跑起来。架构是为变化服务的不是为炫技服务的。正确做法是先用最简单的主循环跑通一个能旋转的立方体然后每加入一个新需求就往架构里补一层边界。架构是长出来的不是设计出来之后等实现的。6.2 三个反复踩到的架构坑第一个坑单例管理器泛滥。GameManager、RenderManager、AudioManager、UIManager全是全局单例彼此互相调用最后就是全局变量换了一张皮。我的判断标准是如果两个管理器之间的调用需要第三个管理器帮忙传话你的架构已经出问题了。第二个坑模块之间的隐式耦合。表面上看渲染模块没有直接引用游戏对象但通过一个全局的g_scene指针访问了场景树里的某个节点这其实还是耦合只是藏得更深。我后来强制的约束是跨系统数据只走显式的数据对象不走全局变量。全局变量一旦出现半个月内它就会蔓延到所有模块。第三个坑把性能优化前置到架构设计里。为了将来能多线程而设计的过度复杂调度往往在单线程下慢得没法用。真实的优化节奏应该是先让架构清晰再用分析工具找出热点最后针对热点做局部并行或数据结构调整。架构负责可维护性性能优化负责局部突破两件事不能混在一起办。6.3 动手检查你引擎架构健康的两招第一招画依赖图并找环。每新增一个模块只允许它依赖运行方向上下的层。如果出现环说明要么其中一侧用了另一侧不该有的数据要么边界定义错了。找环也不需要什么专业工具用include分析脚本或者纯手工列出头文件引用方向都能发现。第二招观察模块增删的代价。试着做一个删掉音频模块后引擎还能编译运行的实验。如果删掉一个模块导致十几个无关文件报错说明你的模块纷杂又耦合严重。模块化做得好删模块应该是只改启动注册代码就能完成的事。7. 写在最后从这一篇到下一篇这篇聊的引擎基础架构看起来没有任何炫酷的画面但它决定了你未来几年写代码时是如鱼得水还是如履薄冰。依赖方向、主循环的固定步长、渲染的解耦、资源生命周期这四个概念就像地基里的四根桩。我回看自己早年的引擎代码几乎所有痛彻心扉的重构根源都在这四根桩没打正。下一篇会深入渲染系统内部聊聊命令缓冲、GPU资源管理和渲染管线是怎么在框架约束下高效跑起来的。先把这里的边界感和时序感记在心里后面会轻松很多。
返回列表