从零写游戏引擎:子系统划分、通信与生命周期踩坑复盘
写这篇的时候Simple Engine 刚好跑通了我给它规划的最后一版 Demo一个 2D 平台跳跃小游戏约 800 个实体同时在场包含碰撞、粒子、音效、UI 弹窗、场景切换右上角悬浮的 Debug 面板实时显示帧耗时与内存占用。从敲下第一行窗口代码到这个状态前后跨了四个月中间推倒重写过两次。这一篇是 Building a Simple Engine 系列的收尾。前面的文章按模块拆过窗口、渲染器、资源加载、实体管理这篇想调高视角专门聊“子系统”层面一个引擎到底该拆成哪些子系统子系统之间怎么通信启动与关停顺序为什么不能乱以及这四个月里因为子系统边界不清踩过哪些坑。如果你也在考虑从零写一个小引擎或者正给现有项目重构模块边界这篇的参考价值会比单讲某个渲染技巧更集中。自己动手写引擎最大的收获不是“我写出了一款引擎”这件事本身而是你会被迫把所有模糊的设计问题变成具体的代码决策。渲染器要不要接管窗口资源系统允不允许在帧中间加载物理要不要独立线程这些问题网上能找到一百种答案但只有落到你自己的依赖图和主循环里才能真正想明白取舍的来龙去脉。这篇就是把我的决策过程与事后复盘摊开讲包括哪些设计改了、为什么改、改完解决了什么。1. 四个月后Simple Engine 的子系统地图先给结论这个引擎最终保留了九个子系统总计约 1.4 万行 C。比起商业引擎动辄上百个模块这个规模小得可怜但对我来说已经足够把每个系统都做到“能说清楚职责、能画出边界、能单独测试”的程度。写 Demo 期间我反复改动的不是某个算法而是子系统之间的接线方式这本身就是最值得复盘的部分。1.1 最终保留下来的九个子系统九个系统按职责划分如下子系统核心职责对外主要接口大概体量Time固定步长、帧间隔、时间缩放tick / consumeFixedStep / fixedDt200 行Window创建窗口、轮询平台事件open / pollEvents / close400 行Input键鼠状态查询与事件分发isKeyDown / mousePos / poll350 行Asset纹理、音频、字体加载与缓存loadTexture / loadSound / loadFont800 行Scene实体与组件容器、遍历查询createEntity / destroyEntity / forEachDrawable900 行Render合批、提交、绘制输出beginFrame / submit / endFrame1500 行PhysicsAABB 碰撞、位移积分、查询step / addBody / raycast700 行Audio音效播放、音量混音、状态更新playSound / setVolume / update500 行Debug日志、帧统计、调试绘制log / drawStats / drawLine600 行我不建议照抄这张表因为每个项目的瓶颈不一样。比如我这版把 Scene 做成了扁平的实体数组而不是场景树是因为目标游戏里实体数量多但层级浅如果你的项目是编辑器类工具场景树反而更顺手。表格的价值是让你看到一个“最小可用”的引擎需要覆盖哪些能力而不是一个标准答案。有个细节值得说Debug 子系统几乎被所有其他系统引用按理说它应该排在依赖的最底层。我一开始把日志功能放在一个全局函数里后来发现全局函数的日志没办法按子系统过滤、也没法在发布版里统一关闭于是单独拆出 Debug 系统。这个拆分让后续排查问题轻松太多崩溃时的调用栈能直接定位到“哪个系统在什么阶段做了什么”。1.2 依赖方向与主循环骨架这九个系统不是平级关系依赖方向是单向的。我最后整理的依赖关系大致是这样Debug - 被所有系统引用 Time - 无依赖 Window - Debug Input - Window, Debug Render - Window, Debug Asset - Render, Debug负责创建 GPU 资源 Scene - Asset, Input, Debug Physics - Scene, Debug读取 Transform Audio - Asset, Debug依赖方向是我在第二次重写时强行修正的。第一版里 Render 直接调 Asset 加载纹理Scene 又直接调 Render 拿相机参数看起来方便实际上改一个接口要联动三个系统。现在 Asset 依赖 Render 只为了创建纹理对象但 Render 永远不会反向调 Asset这个单向约束靠代码规范维持不靠运行时检查。主循环是我全项目里最不愿意再改的一段代码因为所有子系统的时序都压在里面while (mRunning) { mTime-tick(); // 记录真实帧间隔 mWindow-pollEvents(); // 拉取平台事件 mInput-poll(mWindow-events()); // 把事件转成输入状态 mDebug-beginFrame(); while (mTime-consumeFixedStep()) { mScene-update(mTime-fixedDt()); // 游戏逻辑固定步长 mPhysics-step(mTime-fixedDt()); // 物理步进 mAudio-update(); // 音频状态推进 } mRenderer-beginFrame(); mScene-forEachDrawable([](Drawable d) { mRenderer-submit(d); }); mRenderer-endFrame(); mDebug-drawStats(); }固定步长内部循环那段是后来加的。最早的版本直接拿真实帧间隔做 update结果就是在 60Hz 和 144Hz 显示器上游戏速度完全不同物理抖动也明显。改成固定步长加累积器之后游戏逻辑变得可预测物理表现和帧率脱钩。代价是逻辑层必须接受“一帧内可能跑多次 update”这个事实所有输入查询都不能在读完后立刻消费否则会漏事件这个坑我在后面专门讲。2. 子系统之间怎么通信三种模式我都用了但没乱用子系统划分清楚之后下一个问题就是它们之间怎么说话。我试过三种模式直接调用、事件总线、共享状态。最后三种都在项目里留着但各自有明确的使用边界这是踩过一堆坑才形成的共识。2.1 直接调用只允许沿着依赖方向走最常见也最容易被滥用的就是直接调用。调用方拿到被调用方的接口调用一个成员函数完事。问题在于调用方向。我定的规则是只允许沿着依赖方向调用也就是 Debug 可以被任何人调但 Window 不能反向去调 Scene。举例来说Asset 系统对外暴露的是同步接口但内部做了两级缓存class AssetSystem { public: TextureHandle loadTexture(const char* path) { auto it mTextureCache.find(path); if (it ! mTextureCache.end()) { return it-second; } // 真正加载解码图片 - 创建 GPU 纹理 - 放入缓存 TextureHandle handle mRenderer-createTexture(decodedData); mTextureCache[path] handle; return handle; } private: std::unordered_mapstd::string, TextureHandle mTextureCache; };调用方拿到的永远是缓存里的句柄同一个路径第二次加载不会再有 IO 开销。这个设计看起来简单但它强制了一个约定所有纹理加载都走 Asset不允许有人直接调用底层渲染接口去创建纹理。否则缓存就失效了资源管理也失控。直接调用最大的优点是直观、好调试。断点打进去就是一条清晰的调用链。缺点是耦合会在不知不觉中变高。我的经验是两个系统之间如果一天内要改三次接口要么是职责没切干净要么是应该加一层抽象。真出现这种情况不要硬调停下来重新看边界。2.2 事件总线什么时候值得上这套机制事件总线是我在第二版引入的。起因是输入事件要同时通知游戏逻辑、UI、Debug 面板三处音效播放又经常由碰撞、UI、场景切换多个来源触发。如果全部用直接调用Scene 就得持有 Input、Audio、UI 的引用耦合立刻爆炸。事件总线的核心是一个简单的发布订阅机制struct KeyEvent { enum Type { Press, Release } type; int keyCode; Vec2 mousePos; }; mEvents-subscribeKeyEvent([](const KeyEvent e) { if (e.type KeyEvent::Press e.keyCode KEY_SPACE) { mPlayer-jump(); } });发布方完全不知道谁在监听监听方也不知道除了自己还有谁关心这个事件。输入系统只管把事件投递出去游戏逻辑、UI、音效各自消费。这个解耦非常值。但事件总线也带来了一个隐患事件回调里的操作很难追踪而且如果回调里再去发布事件就会形成难以察觉的递归。我在项目里定了两条规矩第一事件回调里不允许做耗时操作只做状态标记和轻量逻辑第二回调中禁止再次发布同类事件。这两条没有靠技术手段强制只在代码评审时人工把关。小项目可以靠自觉大项目就得考虑事件队列的延迟分发方案了。2.3 共享状态实体属性不该让子系统互相传参真正让我纠结的是实体位置、速度这类高频读写的数据。如果 Physics 算完位移后通过事件广播给 Render那每帧要发几千个事件显然不合理。这种数据天然应该被多个系统直接读写我最终把它们放在 Scene 的组件里Physics 直接改组件数据Render 提交时直接读组件数据。共享状态的核心问题是谁拥有数据、谁能写、谁能读。我的规则很粗暴组件数据归 Scene 系统所有Physics 只能通过 Scene 提供的接口更新 TransformRender 只读不被允许写。这样数据只有 Scene 自己能改其他系统都是借用者避免了多处写同一块数据导致的竞态。听上去很简单但第一版里我让 Physics 直接持有实体指针并随意修改结果场景切换时实体被销毁Physics 还在引用直接悬空。后来所有对实体的修改都收口到 Scene 接口这类问题基本绝迹。3. 启动与关停顺序我把这张顺序表贴在显示器上了3.1 启动序列为什么必须是这个顺序启动顺序的推导逻辑其实很简单被依赖者先启动。Debug 最先因为后面所有系统初始化时都可能打日志然后 Window因为它提供平台句柄Renderer 需要 Window 的绘制表面Asset 需要 Renderer 创建纹理Input 需要 Window 的事件来源Scene 需要 Asset 才能创建带贴图的实体Physics 和 Audio 在最外层。顺序系统启动时做什么1Debug打开日志文件、初始化统计面板2Time校准时间基准、归零累积器3Window创建窗口、拿到事件队列4Render创建渲染上下文、初始化默认资源5Asset加载内置默认纹理、字体6Input注册事件监听、初始化键位映射7Audio初始化音频设备、加载基础音效8Scene建根场景、创建默认相机9Physics创建碰撞世界、注册碰撞回调这个顺序表我贴在了显示器边框上。不是因为记不住而是因为每次新增系统都要回来对照一次看看插入点在哪、会不会破坏已有依赖。第四版加 Audio 时我把顺序调成了 7插在 Scene 前面理由是 Audio 只需要 Asset不依赖 Scene但如果音效要绑定实体位置就得挪到 Scene 后面。这类调整永远跟着依赖关系走不能按“感觉差不多的位置”拍脑袋。3.2 关停顺序与资源释放的反向原则关停顺序可以理解为启动的逆序但有例外。第一次写关停代码时我只做了“逆序析构”结果在关停阶段崩溃了两次。原因其实不复杂有些系统在析构时还会请求其他系统的服务。举一个具体例子Scene 析构时实体组件里的音频组件会给 Audio 发送停止播放的通知。如果 Audio 已经先一步析构这个通知就会访问到已经被释放的内存。逆序想当然地以为“先创建的后销毁”就安全但忽略了析构动作本身可能引发的跨系统调用。正确的关停顺序应该是先让所有可能调用别人的系统进入“只读憋气”状态再释放资源提供方。我最后的顺序是先停游戏逻辑更新再清空 Scene 中的实体然后停 Physics 的步进线程接着停 Audio 的播放队列最后才销毁 Renderer、Window、Debug。每两步之间都检查一遍日志确认没有系统还在请求已经被释放的服务。3.3 崩溃日志暴露出的两次顺序错误第一次错误发生在关停阶段。当时我把 Audio 放在 Scene 之前析构结果 Scene 析构时一个粒子系统的音效组件还在播放。崩溃日志显示断言失败Audio 播放队列不为空但设备已关闭。修复方式是在 Scene 析构前先统一发送 StopAllSounds 事件强制让所有音效先停再走析构流程。第二次错误更隐蔽发生在启动阶段。Window 初始化成功后紧接着初始化 Renderer但 Renderer 创建默认纹理时需要 Asset 提供服务而 Asset 当时还没启动。我当时的处理是把默认纹理创建推迟到 Asset 启动后再做而不是在 Renderer 里内联创建。这个改动让 Renderer 的初始化逻辑更干净也避免了一个循环依赖Asset 依赖 RendererRenderer 又想在启动时用 Asset。这两次的共同教训是子系统的生命周期函数本身也是跨系统调用点不能只盯着接口签名还要看每个系统在 init 和 shutdown 时到底碰了谁。我后来在每个系统的生命周期函数里都加了一行日志记录“当前阶段 正在调用的服务名”排查顺序问题的时间从几小时降到了几分钟。4. 踩坑记录这些 Bug 都出在子系统边界上四个月里让我熬夜的 Bug 不少挑四个最有代表性的都跟子系统边界直接相关。每个都经历了一遍完整的排查链路这里把我的定位过程写出来比直接给结论更有参考价值。4.1 帧率被拖垮的元凶Update 里同步加载资源现象是 Demo 跑到第二关时每次进入新区域都会卡顿 200 到 400 毫秒帧耗时曲线出现明显的尖峰。我最初怀疑是渲染批量太大但用 Debug 面板看清楚后发现卡顿来自 Scene 里某个游戏逻辑在 update 中调用了asset.loadTexture()这个调用会同步解码一张 1024 分辨率的 PNG。排查链路是这样的先在 Debug 面板开启每帧耗时分布统计发现 Render 耗时稳定在 5 毫秒以内但 update 阶段偶尔会跳到 300 毫秒。再往 update 里逐项加计时最后定位到loadTexture调用。根因是 Asset 缓存中没有这张纹理触发了一次完整的磁盘读取和解码。修复方案分两步。第一步把所有会用到的纹理做成清单在关卡加载阶段预加载不放在 update 里。第二步把 Asset 接口的语义改成“帧内只命中缓存命中失败就返回默认纹理并触发异步加载加载完后通过事件通知”。这两步让 update 里永远不再出现阻塞式 IO。异步加载事件和事件总线的结合恰好是前面说的订阅机制的典型场景。4.2 音频播放器被提前析构生命周期嵌套问题现象是偶尔崩溃声音播放到一半程序直接退出崩溃点在 Audio 内部。查崩溃栈发现Audio 在自己 update 里遍历播放队列准备释放一个已经播放完的声音资源但该资源被 Scene 里的实体组件引用着释放时调用了一个回调回调反过来访问 Audio 内部的另一块状态触发了悬空访问。这个问题的本质是Audio 播放器是引擎的子系统资源但它的生命周期被实体组件持有两边都以为自己拥有所有权。修复方式是在实体析构时显式通知 Audio 系统释放对应播放器而不是让播放器在无人引用时自行回收。此后我统一了规则凡是跨系统的资源生命周期归创建它的系统管理其他系统只能借用不得隐式释放。实际开发里这种问题最坑的地方在于它不是每次必现只在特定的释放顺序下触发。建议在 Debug 构建里开启引用计数检查每次跨系统资源交接都记录 owner崩溃时一眼就能看到谁释放了不属于自己的资源。4.3 输入状态跨越帧边界固定的步长循环里的数据丢失第三个 Bug 的现象是快速连按跳跃键时偶尔会漏掉一次跳跃。刚开始以为是输入检测的消抖问题后来发现是输入状态在固定步长循环里被错误消费了。我的输入系统分两层事件层在每帧开头调用pollEvents时更新状态层在while (consumeFixedStep())内部被逻辑读取。问题在于如果一帧里累积器足够大内部循环会执行两次 update第一次 update 读取并消费了“按下”事件的状态第二次 update 面对的就是空状态。跳跃只触发了一次但玩家明明只按了一次键理论上是合理的问题出在另一种情况一帧里只有一次 update但玩家在这一帧里按了两下键第二次按键事件被合并成同一个状态位第二次跳跃就丢了。修复方案是输入系统不再用“当前是否按下”的单一状态而是维护一个边沿触发的按键队列。逻辑层用consumeKeyPress()来逐个消费事件可以在一帧里处理多次按下也不会漏事件。这个改动也顺带解决了按住与连按的语义混乱。4.4 场景切换后粒子系统引用已释放纹理最后一个坑来自资源释放时机。场景切换时旧场景的纹理会被 Asset 系统统一卸载但粒子系统仍然持有旧纹理句柄等它把自己身上所有粒子绘制完才释放。卸载发生之后、粒子绘制完成之前渲染器尝试采样一个已经被销毁的 GPU 纹理轻则花屏重则驱动崩溃。排查时我一度以为是 GPU 驱动的问题因为报错信息含糊不清。后来用 Debug 面板把“纹理引用数”和“当前帧提交的绘制批次”两个指标叠加才发现在切换后的第 2 帧仍然有 30 多个批次引用旧纹理。修复方式是给纹理句柄加引用计数粒子系统持有时计数加一释放时减一Asset 卸载只在引用数归零时真正执行。这个机制并不新鲜但真的在自己的引擎里落地后才体会到“延迟删除”对稳定性的意义。5. 做减法哪些子系统做到 80 分就被我喊停自己写引擎最容易犯的毛病是贪多求全。我一开始也列了一张很长的清单物理要支持刚体旋转、关节、碰撞分组渲染要做光影、后处理、骨骼动画。四个月后回头看这些大部分都没做而且庆幸没做。做减法不是妥协而是把有限的精力放到真正影响 Demo 效果的地方。5.1 物理子系统被缩减为 AABB 简单位移最初我照着教科书实现了圆形刚体和 AABB 的碰撞检测还加了冲量响应。但写到第三周发现我要做的平台跳跃游戏根本不需要旋转刚体所有实体都是矩形的碰撞响应只需要“推出去”和“站在上面”两个语义。于是我把 Physics 重写为 AABB 检测加垂直方向的位移约束代码量从 700 行降到了不到 300 行帧耗时还下降了 20%。这个选择的关键不是“功能少等于好”而是“功能与你的游戏语义匹配”。平台跳跃里角色不会被旋转撞飞所以复杂的响应逻辑不仅用不上还会让手感变得不可预测。同样的逻辑也适用于粒子系统我只需要简单的速度衰减和重力加速度不需要力场、扭曲、流体模拟。5.2 脚本系统被推迟到第二期第二版重构时我犹豫了很久要不要给实体加上 Lua 脚本支持。理论上脚本能加快原型迭代但当时我的核心目标是先验证引擎本身能稳定跑起来。引入脚本意味着要处理脚本与 C 的生命周期绑定、性能边界、调试工具三块额外工作这些都会分散主力开发精力。最后的选择是先用 C 写游戏逻辑把逻辑做成一个个挂在实体上的组件通过组件接口统一管理。这个方案让我在不动脚本的情况下已经把逻辑层和数据层解耦得足够好。如果后续确实需要热更新再把脚本层加在现有组件系统上面而不是重写一套体系。先不做脚本是我这个阶段最正确的决定之一。5.3 渲染合批与性能预算渲染子系统我没敢偷懒这是全引擎里唯一值得花心思优化的部分。我的做法是所有精灵先提交到一个批次列表按纹理和深度排序后合批绘制。800 个实体最多产生约 120 个批次在集成显卡上也能稳定跑满 60 帧。性能预算的分配上我给自己定了硬性指标一帧总耗时不超过 16 毫秒其中渲染不超过 8 毫秒逻辑加物理不超过 5 毫秒剩下 3 毫秒作为余量。这些数字不是拍脑袋来的是先跑完所有 Demo 场景后统计 10 分钟的平均耗时分出来的。有了预算表加新功能前先估算它落在哪个子系统、会不会挤占对应预算。这个习惯让我后来很少遇到“不知道谁把帧率拖垮了”的情况。6. 如果重新开始我的架构调整清单收尾部分我想复盘得更直白一些。如果让我从零再来一次有四件事会在第一天就做对有两件事会换一种做法。6.1 会保留的设计第一单向依赖约束这是整个项目里最值钱的决定。只要依赖方向清晰接口怎么改都是局部问题。第二固定步长的逻辑更新。虽然初期理解成本高一点但它带来的可预测性让物理、动画、碰撞的表现都不依赖帧率后面的调试省了无数时间。第三Debug 系统从一开始就铺好。我不会再等到“后面有空再加”。日志分级、帧耗时统计、调试绘制这三个能力应该在引擎还只有 2000 行时就存在。第四资源缓存统一收口在 Asset 系统。所有加载调用都走一个口子缓存策略才能全局生效异步加载、引用计数、预加载这些机制都不用散落在各子系统里。6.2 会推翻的设计需要推翻的是两块。第一块是事件总线的使用范围。我在第一版里把事件总线当成万能解药连“纹理加载完成”这样低频但明确的事件也走总线结果接口里到处都是回调排查问题要跳五六层。后来收窄为只有跨系统广播需求才用事件总线明确“谁关心谁订阅”的规则设计清爽了很多。第二块是场景数据结构。第一版用了树形结构管理实体父子层级确实灵活但遍历和查询都麻烦。后来改成扁平数组加组件索引性能更好代码更直白。如果重来我会一开始就明确简单引擎的实体管理用扁平结构除非某类功能比如 UI 嵌套布局明确需要层级否则不做树。6.3 给想自己写引擎的人一份检查清单如果你也有类似计划这几条建议是我反复确认过值得带走的开工前先画一张粗糙的依赖图画完贴在墙上。系统之间的箭头只允许从底层指向高层发现交叉就停下来重新切边界。生命周期顺序用表格写出来启动和关停都要写。每次新增或重排系统都更新表不要相信自己的记忆。任何跨系统资源调用一定要明确 owner。谁创建、谁释放、谁只能借用三条缺一不可。做减法要大胆。每个子系统先做最贴近目标场景的 80 分版本跑通后再考虑扩展。Debug 系统的优先级和渲染器一样高它就是你写引擎时光的十倍杠杆。四个月下来Simple Engine 换来的不是一个“引擎”而是一整套关于如何拆分复杂系统的判断力。这种判断力一旦有了看不看自己的代码都不重要因为你会用同样的眼光去审视任何项目里的模块边界是否合理。写引擎这件事本质上不是写渲染和物理而是写边界。