ARTICLE DETAIL

资讯详情

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

游戏引擎基础架构拆解:模块分层、时间系统与主循环

游戏引擎基础架构拆解:模块分层、时间系统与主循环 做游戏引擎这些年我最常被问的一句话是你想做一个引擎那第一步到底该写什么这个问题我当年也困惑了很久。市面上UE、Unity、CryEngine源码各有各的写法但剥开那些花里胡哨的渲染特效和编辑器界面真正撑起一个引擎的其实就是那套最基础的架构模块怎么分、生命周期怎么管、数据怎么流动。这篇内容我打算从零开始把引擎基础架构拆开来讲不聊渲染管线那些高深技术只聊每个引擎都绕不开的骨架部分。如果你是一个想自己写引擎的开发者或者已经在用商业引擎但总感觉哪里没吃透这篇文章应该正合适。我会重点说清楚这样几个事引擎为什么非要分层、时间系统在引擎里到底是干什么的、主循环每一帧究竟在忙什么以及那些我在实际项目里踩过的坑。内容不追求面面俱到但会尽量做到每一个点都有实际经验在里面不是对着架构图念概念。1. 引擎基础架构的设计思路模块划分与分层1.1 引擎分层不是设计出来的是业务逼出来的很多初学者问我的第一件事是引擎的架构图看起来挺规整的渲染、物理、音频、网络各占一层是不是一开始就要按这个方向设计我的答案是别急着画分层图先想想业务跑起来之后会发生什么。我最早做的那个引擎demo其实没有明确分层。写了一个App类里面既有窗口创建又有资源加载物理检测和一个简单的精灵渲染函数全混在一起。跑单个demo完全没问题看起来还很灵活。但当我加入第二十个功能模块、第三个场景切换逻辑之后问题就来了每次改资源加载都要小心翼翼怕碰坏渲染代码。那时候我才意识到分层不是优雅的装点而是给代码找明确的责任边界。引擎分层的核心动机是让每一层只依赖比自己更稳定的层。渲染层可以调用底层的内存分配和数学库但底层绝不应该反过来依赖某个渲染API游戏玩法逻辑可以调用引擎功能但引擎内核不应该为某个具体玩法写死逻辑。这就是所谓的单向依赖原则。听起来有点抽象但你可以把它类比成公司组织架构前台对客户后勤对保洁各自有分工不交叉指挥。从实操角度说我的做法是先理清依赖方向再画架构图。我会把引擎划分为几大类Math和底层容器这类基础库、内存和时间这类核心基础服务、资源系统和场景管理这类中层能力、然后是渲染物理音频这些子系统、最上层才是给玩法脚本用的接口层。这个分层顺序基本就是团队里新人上手时该从哪读代码的顺序。1.2 我常用的模块划分方式说到具体划分我最推荐的并不是某个固定目录结构而是一种按依赖层级放置的思路。假设我的项目叫DemoEngine我通常会在源码目录下建这些模块Core内存、时间、日志、断言、数学库、通用容器。Platform窗口、输入、文件系统、线程相关的平台抽象。Resource资源加载、资源缓存、类型注册、导入管线。Scene场景图、实体组件、Transform继承、碰撞包围体。Render渲染器、材质、Shader管理、Mesh、相机。Input输入事件抽象、按键映射。Audio音频播放资源封装。Physic物理碰撞检测、刚体模拟。Game生命周期接口、Tick函数、游戏逻辑入口。这十来个模块看起来多但真正启动引擎时并不是所有模块都要互相认识。Resource知道怎么读文件它不关心Render长什么样Render需要Mesh它去找Resource要资源而不是自己直接解析模型文件。这种依赖倒置关系在实际写代码时必须严格遵守。这样做带来的最大好处是测试和替换非常方便。我可以在没有窗口平台的纯命令行环境下跑起物理模块和场景模块做自动化测试也可以把某个渲染器实现替换成另一种API而不用改动上层逻辑。这种模块边界清晰带来的自由度比任何架构图都值钱。相反如果一开始模块之间互相握着手后面每动一处都是一场灾难。2. 核心基础模块拆解没有它们引擎就是空壳2.1 时间系统一切动效与逻辑的节拍器时间系统往往是新人最容易忽略的模块但它在引擎里的地位比想象中高得多。你有一个角色要移动、一个动画要播放、一个物理要模拟所有这些都离不开一件事这一帧到底过了多少时间。在游戏里我们不能直接拿系统中的绝对时间来做逻辑因为不同机器的帧率不同。60Hz和30Hz下如果角色每帧移动固定距离那会一个跑得快一个跑得慢。所以引擎必须提供帧间隔时间DeltaTime和累加的游戏运行时间。我做的第一版时间系统还加了一个缩放系数这样游戏暂停和慢动作效果一个变量就解决了。具体实现上我会维护一个高频计时器通常是std::chrono::steady_clock然后在主循环开头采样当前时间和上一帧的采样时间相减得到这一帧的真实流逝时间。接着对这个原始值做钳制防止玩家Alt-Tab切出去太久导致回来时物理爆炸同时乘以时间缩放系数得到逻辑用的DeltaTime。这看起来简单但有几个细节很容易踩坑如果直接用QueryPerformanceCounter的原始计数值做累积时间长了精度会漂移用system_clock会因为系统改时间而跳变。steady_clock虽然慢一点但稳定性好。我还要提醒一点不要把时间系统做成全局的单例字符串到处get。在架构设计上时间、内存、日志这类核心服务确实需要全局可访问但你最好把它们封装成独立的Service结构放在Core层的入口里。这样以后做多线程渲染时每个工作线程还能拿到自己的时间快照不至于大家抢一个全局变量。2.2 内存管理从malloc到对象池你在管理什么内存模块看上去是所有模块里最没存在感的但恰恰是它决定了你的引擎在高负载下是稳定运行还是频繁卡顿。C里如果每个小对象都直接new/delete碎片化会很快出现而且分配器本身就有锁和系统调用开销。所以成熟引擎通常自带内存分配器而不是把malloc撒得到处都是。我的做法是封装一个内存接口内部默认用malloc实现然后在此基础上实现几个常用分配模式固定尺寸对象池、线性分配器、以及栈式分配器。尤其对象池太常用了比如粒子、子弹、伤害数字这些短生命周期的对象每次复用池子里已有的内存块能明显减少掉帧。但这里有一个架构层面的坑就是所有权和释放时机。我的经验是明确一条规则谁创建谁释放跨模块传指针时必须说明生命周期由谁管理。比如一个Mesh资源被场景和粒子系统同时引用你不能让两个模块都去释放它也不能寄希望于没人管它。更好的方式是给资源类加引用计数或者交给资源系统统一管理让渲染层只短暂借用。从引擎整体角度说内存管理还和帧循环强相关。很多临时数据其实可以在帧内分配、帧末回收这种一次性内存用栈式分配器非常爽。我在引擎里专门做了一个FrameAllocator渲染、物理、动画产生的临时数据全走它每帧开始重置一下也不用费心去释放。这个设计让我的代码干净了很多但注意不能在FrameAllocator里存跨帧引用的对象否则就会踩到悬空指针。2.3 资源层让磁盘数据变成运行时对象资源系统负责的事情非常明确把磁盘上的模型、贴图、音频、预制体等等加载成运行时能直接用的对象同时管理这些对象的生命周期避免同一个资源被反复加载到内存里浪费空间。第一步是资源标识。我喜欢用字符串路径作为资源的唯一ID。例如assets/models/player.fbx引擎启动时会扫描资源目录构建一个资源表后续所有模块都通过这个ID申请资源。如果资源文件变化我可以通过文件哈希判断是否需要重新导入。第二步是导入流程。原始美术资源通常不能直接被渲染器使用比如FBX模型需要转成引擎内部的Mesh格式纹理需要压缩并生成mipmap。我的引擎有个Import阶段格式转换完成后生成一个.bin文件引擎启动只加载.bin而不是实时解析FBX。这一点太重要了因为FBX解析不仅慢而且容易被不同版本的SDK坑到。至于加载策略我建议分为同步和异步两种。场景启动时关键资源要同步加载保证一次到位而大世界的贴图和音频可以用异步加载避免卡顿。异步加载需要一个任务队列和线程池资源系统在后台线程读取文件、解析数据完成后把结果提交回主线程再通知相关模块。这里最容易出现的问题是资源还没加载完渲染模块就上来取Mesh了。解决方案是给资源对象加状态Loading状态、Ready状态、Failed状态调用方每次都要检查状态不要一脸信任地往下走。3. 启动流程与主循环引擎的一天怎么过3.1 初始化阶段的顺序为什么这么敏感引擎的启动流程看起来只是一堆Init调用排排队但顺序错了后面各种诡异问题就来了。我的启动流程大致是这样的初始化日志系统和断言这样后面任何模块报错都能被记录。初始化内存系统把全局分配器和FrameAllocator准备好。初始化平台层创建窗口和OpenGL或Vulkan上下文。初始化资源系统扫描目录加载基础配置。初始化渲染器把渲染API封装中的缓冲、Shader编译跑起来。初始化其他子系统输入、音频、物理、场景管理器。加载启动场景创建游戏世界对象。进入主循环。每一步的顺序都有逻辑日志先开是常识内存系统必须先就位是因为后面所有模块的new都可能走自定义分配器。窗口可以早一点也行但渲染器必须等窗口Context创建成功之后才能初始化这个顺序不能倒。还要特别提醒一个我自己踩过的坑渲染器和资源系统之间的初始化耦合。因为渲染器内部的Shader编译需要资源系统已经提供基础Shader文件如果资源系统没扫完目录就去编译就会出现黑屏或错误Shader。所以我会把基础资源和全量资源扫描拆成两个阶段渲染初始化时只需要基础资源在手即可。3.2 主循环里到底该干几件事主循环是引擎的心脏所有系统每一帧都在这里被驱动一次。我见过最糟糕的写法是在while循环里把每个系统从头到位都更新一遍输入、逻辑、物理、渲染然后结束。这确实能跑但扩展性和帧率控制都会出问题。我推荐的主循环结构是固定时间步长加可变渲染频率。核心思路是物理和逻辑更新使用固定的DeltaTime例如1/60秒保证物理稳定渲染则按实际帧间隔来帧率高时渲染得更频繁但逻辑更新依然按固定步长累积。这样的好处是物理不会因为帧率波动而崩溃逻辑也相对可预测。实际循环写出来大概是先处理操作系统事件包括窗口消息和输入事件然后根据当前时间和上次Tick时间把累积时间分成若干个固定步长每走一步就调用游戏逻辑的Update和物理的Step甚至如果累积时间过长还要做钳制防止螺旋死亡。最后才渲染一帧交换缓冲区。很多引擎还会在主循环里塞入资源异步加载的处理和帧末回收。我的做法是把这些也作为主循环里的步骤执行每帧检查异步资源完成队列、处理待销毁对象、重置FrameAllocator。这样所有系统对一帧的理解保持一致。4. 实操中三天两头踩的坑4.1 模块循环依赖最隐蔽的架构炸弹模块化架构最经典的敌人就是循环依赖。A依赖BB依赖CC又依赖A虽然编译可能通过但逻辑上会变得纠缠不清。我遇到过一个很典型的例子渲染模块想通过资源系统拿Mesh但资源系统又想加载完成时通知渲染器刷新预览。这俩互相依赖后面一改就有连锁反应。规避循环依赖的办法很多最简单的是引入一个事件总线或者回调注册机制。资源系统并不需要知道渲染器是谁它只负责在资源加载完成后发送一个资源完成的回调任何系统都可以订阅这个回调。这样依赖就从资源系统依赖渲染器变成了渲染器依赖资源系统提供的事件接口单向依赖就成立了。另一个我常用的手段是依赖注入或者服务定位器。引擎启动时把各模块的接口指针注册到一个ServiceManager里使用时直接查表获取而不是在构造函数里传参。这虽然有点违背依赖注入的纯净性但在游戏引擎这种高频更新的环境里非常实用。加上模块换代频繁接口查表比到处传递依赖更方便调试。4.2 热重载写着写着脚本崩了热重载是现代引擎的标配功能但实现起来坑很多。最常见的坑就是资源文件改了引擎还在用旧版本的数据脚本代码改了旧逻辑还没被替换。为了支持热重载资源系统都会监控文件变化当检测到文件变更时把资源置为Dirty状态等待下一帧重新加载。但这个重新加载必须在安全时机做。如果你在物理刚好遍历到某个刚体时突然把它的Mesh释放世界就会炸。我的做法是把所有资源的卸载统一延后到帧末先标记旧资源待回收再在帧末统一替换。同时给资源状态机加上重载中阶段任何系统在帧中请求资源时如果遇到这个状态就先返回旧资源等重载完成后再切换到新资源。除此之外热重载还考验资源引用的一致性。如果场景里有100个物件引用了同一个Mesh你替换资源时不能只把Mesh对象本身换掉而不管这些引用。我建议所有对资源的引用都通过资源句柄间接持有句柄内部保存资源版本号。这样资源重载后句柄仍指向新资源但旧版本会被自动标记不会出现悬空引用。4.3 文件路径中的脏路径和缓存不干不净的地基还有一个经常被忽视的坑就是路径问题。很多开发者喜欢在代码里直接写绝对路径比如D:/MyGame/Assets/Textures/xxx.png。这在单机demo里没问题但一旦项目打包到另一台电脑或者多人协作路径就全废了。我的做法是定义统一的资源路径根目录并只允许使用相对路径。引擎内部再维护一个虚拟路径到实际物理路径的映射表。比如逻辑里永远写assets/xxx实际加载时由平台层替换为项目根目录。这样打包发布时只需改一份配置无需改代码。另外资源缓存也很重要频繁读取模型或贴图的元数据会显著拖慢启动速度在编辑器模式下我会把资源目录的扫描结果缓存成序列化文件下次启动直接读缓存省去很多IO时间。5. 那些不会写进代码注释的经验5.1 引擎架构的价值要到项目后期才体现很多刚接触引擎开发的朋友看到简单游戏demo只需要一个while循环加几张图就能跑会怀疑我前面说的这些架构是不是过度设计了。我承认对于单个小demo做严格分层的意义确实不大。但游戏引擎的价值恰恰在于承受复杂度当场景从一张地图变成十张、从单角色变成大规模NPC、从单机变成网络同步早期架构的孱弱会被成百上千倍放大。我见过不少项目一开始飞快到中期加了玩法、联网、编辑器后主循环变得一团乱麻每个功能都要改核心代码。反观那些一开始就做了基础架构的引擎后期加新系统时可以插模块而不是改旧代码。这就是我说的价值要到后期才体现。所以我的建议是不要一上来就追求全宇宙最强的引擎架构那会让你陷入设计泥潭。但至少要保证核心的依赖方向、时间系统、资源生命周期这些骨架是健康的它们才是你中后期还能继续快乐的资本。5.2 一个小小的调试钩子引擎自身可观测性引擎架构里还有一个容易被忽视的层面可观测性。也就是说引擎自己能不能把内部状态暴露出来方便开发和多人协作时调试。我强烈建议在基础架构里加一个调试控制台和性能统计接口哪怕是只输出到日志或命令行都行。我会在Core层放一个实时性能环记录每帧主循环各子系统占用的毫秒数这样一出问题就能立刻看出是渲染卡了还是物理卡了。再配合一个简单的Image-Based日志系统资源加载失败、Shader编译失败这些错误能被统一捕获并展示。这些看起来不直接参与游戏逻辑但会极大提升你迭代引擎的效率。5.3 工具链和依赖库选型的一点参考最后给你一个很实用的建议基础架构里能少依赖第三方库就少依赖第三方库尤其是平台相关的库。我只在必要处用几个轻量库数学库用glm、窗口和输入这块要么直接调各平台原生API要么上SDL。我这里必须提一句平台封装非常重要。你刚开始可能只在Windows平台跑但如果以后想发Mac、Linux甚至主机你不想在渲染层看到一堆#ifdef。所以我在架构里抽象了一个Platform接口窗口创建、输入监听、文件读取、线程创建全走这个接口。这样各个平台只要实现一套接口即可引擎核心代码保持平台无关。我个人在实际写代码时的体会是引擎基础架构更像是一套纪律而不是一堆功能。你强迫自己遵守依赖方向、明确生命周期、做好资源管理这些纪律会在复杂的项目里回报你十倍。最后再分享一个小技巧当你想让某个模块独立测试时看一看它包含多少个直接影响全局的头文件如果超过三五个那大概率是模块边界没划干净。先把边界理清再谈功能这条路不会错。
返回列表