简单引擎子系统设计:从模块拆分到生命周期管理
从个人项目谈到“Building a Simple Engine -- Subsystems -- Introduction”我其实想先说一句实话这个系列叫“简单引擎”但“简单”两个字是最难做到的。尤其到了子系统这一层你很快会发现自己不是在写引擎而是在管理一堆互相较劲的模块。这篇是系列里“子系统”部分的开篇我会把整个引擎的骨架摊开讲清楚每个子系统为什么存在、它们之间怎么协作、又该怎么排生命周期。如果你正准备从零写一个可维护的引擎原型或者正在被自己越来越混乱的游戏项目折磨这篇文章应该能帮你少走不少弯路。我第一次意识到必须引入子系统是在一个模拟项目 X 推进到第三个月的时候。角色动画、碰撞检测、UI事件全部堆在同一个管理器里每天改代码的第一件事是先找上次改到哪了。那种状态不是“再忍忍就好”而是每加一个功能都在给下一周的自己埋雷。后来我花了整整两周时间做重构把所有职责拆开也就是从这个系列要讨论的“子系统”开始的。今天这篇先讲整体设计不深入到某个模块的具体实现但会把设计决策的前因后果讲透。1. 为什么非拆不可单体结构的隐性成本1.1 从“能跑”到“能改”之间的那道坎一个原型游戏在早期看起来总是充满希望的逻辑都写在几个类里调用链短全局变量随手一翻就能找到。这个阶段“能跑”就是唯一的验收标准代码组织方式无关紧要。但随着功能数量上升你会发现自己进入一个怪圈每改一处都要跑一遍全流程确认有没有弄坏别的东西因为模块之间的调用是跨着走的——输入回调里直接创建实体渲染循环里顺手加载贴图物理回调里改游戏分数。这种“扯不断理还乱”的关系最直接的成本不是运行效率而是认知负担。你同时要在脑子里维护十多条非显式的数据通道。每当你准备动手修改其实都要把所有通道在脑子里重放一遍。对个人项目来说这几乎等于把开发速度砍半。我在重构前的最后一次崩溃是调试一个“角色踩到机关后门没有开”的问题。排查到最后发现触发机关的音效播放函数把全局音量重置了而音效模块刚好和门开关逻辑共享了一个临时变量。两个人字面上看毫不相关运行起来却互相踩脚。从那天起我不再相信“小项目不需要模块化”开始强制按子系统拆分。1.2 子系统不是性能优化而是认知优化拆分子系统并不会让代码跑得更快甚至因为多了一层间接调用CPU 可能还会慢几个百分点。对一台现代机器来说这完全可以忽略。子系统真正优化的对象是人的脑子。每个子系统拥有独立的数据、独立的更新节奏、独立的状态机于是你可以把大脑的工作记忆从“整个引擎”缩小到“当前这个模块”。调试时也能更快划定嫌疑范围画面闪烁是渲染子系统操作没反应是输入或场景声音延迟是音频和资源。边界一旦清晰问题就无处可藏。我吃过亏之后总结出一个衡量标准如果你看到一行代码需要查三个以上其他模块才能搞懂它为什么存在这就是需要拆分的信号。不要试图一次性把分组设计到完美先按你目前真实遇到的混乱点来切让每个子系统都能回答“我在这个引擎里到底负责什么”。1.3 引擎与游戏骨架与血肉的关系在工程上我习惯把“引擎”定义为一套与具体玩法无关的基础能力集合把“游戏”定义为基于这些能力搭建的内容。子系统架构的作用就是给这两者之间划出清晰边界。引擎层提供窗口系统、渲染设备、资源管理、时间步进、消息分发等能力游戏层负责场景内容、玩法逻辑、UI 状态。子系统属于引擎层但它们必须留下足够开放的接口给游戏层注册。这有点像骨架和血肉的关系骨架管站立和运动血肉提供个性和表现。骨架如果设计得太“有性格”血肉就长不上去。这个边界听起来简单实际执行时很容易模糊。比如场景管理器它到底算引擎的还是游戏的我的取舍是引擎提供一个“场景容器”和“实体生命周期管理”的框架至于场景里有什么实体、实体做什么逻辑全交给游戏层。引擎只负责“什么时候加载、什么时候卸载、更新时怎么广播”不关心场景里的具体内容。2. 子系统地图与职责边界先画地图再动工2.1 我这套引擎的子系统清单在纸上画系统结构时我最初列了十几个候选模块后来砍到八个再后来合并成六个。如果一开始就把系统铺得太开收尾的时候你会被接口维护淹死。下面是我最终留在引擎核心里的子系统清单子系统核心职责主要对外接口窗口与上下文创建窗口、管理渲染上下文、处理平台事件RequestClose、BeginFrame、EndFrame资源管理器加载、缓存、释放各类资源贴图、模型、音频样本、着色器LoadAsset、UnloadAsset、GetStatus时间系统统一提供帧时间、固定步长逻辑时间、时间缩放GetDeltaTime、GetFrameIndex输入系统收集并归一化键盘/鼠标/手柄输入发布输入事件IsKeyDown、GetAxis、RegisterCallback场景图与实体管理场景中实体的层级关系、生命周期驱动组件更新CreateEntity、DestroyEntity、Enumerate渲染系统按场景数据生成渲染命令提交 GPU 并按序执行SubmitMesh、DrawScene事件总线解耦各子系统之间的消息传递Subscribe、Unsubscribe、Dispatch日志与调试记录运行状态输出统计和断言信息DebugLog、CheckCondition、ProfileScope这个清单不是一次定稿的。最初我把音频系统和物理系统也放进核心后来发现音频可以缩成“资源加载 播放回调”这样一颗小模块而物理系统在原型阶段甚至用不上。所以我的建议是引擎核心只保留那些几乎所有项目都会用到且互相纠缠的部分明显独立的功能如物理、音频、网络后置为可选插件。2.2 边界划分的两条准则第一个准则是单一职责——每个子系统应当只有一条“为什么存在”的理由。如果你解释某个子系统功能时需要用“还”“以及”“另外”这些词说明它管得太多了。例如最初的资源管理器还负责图像格式转换我后来把格式解码拆出去做成插件资源管理器只保留“存取和生命周期管理”整个模块的复杂度立刻降了一个档次。第二个准则是数据归属明确。任何一份数据只能有一个明确的“所有者”其他子系统只能通过接口访问不能直接持有并修改。最典型的矛盾点是材质数据材质里面有贴图引用、着色器参数、混合状态等渲染系统看起来“很需要”它但真正的数据所有者应该是资源管理器。渲染系统每次 draw 时向资源管理器请求材质句柄而不是自己存一份材质缓存否则就会出现“渲染系统改了贴图资源卸载却不知道”的脏数据问题。2.3 最容易被混淆的一对渲染与资源渲染和资源是新手最容易揉在一起的两个子系统。直观上看渲染要用模型、贴图资源负责加载它们好像天然就是一家子。但它们的生命周期完全不同资源管理器要应对“加载中”“就绪”“加载失败”“卸载”这些状态而渲染系统关心的是每一帧“谁该画、用什么状态画、按什么顺序画”。把加载缓存和帧提交混在一起通常会诞生一个巨型的渲染管理器里面一半是资源映射表一半是 draw call。我选择的分层方式是资源管理器产出“数据句柄”渲染系统只认句柄不认真实数据。每一帧渲染系统向场景图收集可见实体提取出它们的网格句柄、材质句柄然后把这些信息组装成渲染命令队列。至于数据什么时候从磁盘加载、什么时候释放渲染系统一概不管。这样既避免了渲染模块内嵌资源缓存也便于以后做异步加载。2.4 时间与输入两个“小而关键”的子系统时间系统听上去很小很多人会把它做成一个提供毫秒值的工具函数。真正把它做成子系统后我发现它应该具备三个能力维护可变帧时间、维护固定的逻辑步长、支持全局时间缩放例如慢动作回放。如果这些能力散落在主循环和各个游戏逻辑里你的“慢动作”功能一定改一个漏三个。输入系统则是所有子系统中和平台相关性最高的一个。在我的设计里窗口子系统收到底层平台事件后先把裸的按键码和鼠标位移交给输入系统输入系统再统一转换成引擎层的逻辑映射例如“确认键”“前进轴”。这样游戏逻辑不直接依赖 SDL 或 Windows API 的键码换到不同平台时只需要改输入映射表。3. 子系统之间如何协作三种通信方式的取舍3.1 直接调用、轮询、事件总线没有银弹子系统拆分完之后紧接着的问题就是它们怎么通信。我试过三种方式实际体验差别很大。直接调用最直观输入系统里检测到跳跃键直接调用角色系统的 Jump()。优点是调用链清晰调试时单步跟进去就行缺点是会让子系统之间形成编译期依赖改任何一个函数签名都要牵连一串模块。这种耦合在早期没问题但子系统数量一多依赖图会重新变得复杂。轮询适合状态类数据比如时间系统的时间值、输入系统的按键状态可以让使用方在每帧更新时主动查询。这种方式不会产生事件排队也不会出现“发完事件没人接”的情况但容易把判断逻辑散得到处都是而且对“动作应当只发生一次”的语义表达不够好。事件总线适合“通知式”协作甲方不关心谁响应、有没有响应只负责把消息投递出去。比如窗口收到关闭事件输入系统收到按键按下这些都非常适合走事件总线。代价是调试难度变高你没法通过静态代码看到谁订阅了需要运行时检查。3.2 我最终的选择查询为主事件为辅我的最终方案是一个组合策略持续性状态用查询瞬时动作用事件。每帧的输入轴值、时间增量、当前帧号统一走查询接口。按键按下、窗口关闭、资源加载完成这类“发生一次”的通知走事件总线。两个子系统之间如果已经有清晰的包含关系如渲染系统主动请求场景图数据仍可使用直接调用但不跨层级调用。这套策略把轮询带来的简单性、事件带来的解耦都保留了一些也避免了“什么事都走事件”导致的隐晦调用链。需要特别注意的是事件总线里的消息定义要尽量字段化而不是塞一个大的 void 指针。字段化消息让订阅方不需要反向排查发送方结构也让将来做断点过滤成为可能。3.3 一条真实的链路按键到角色跳跃拿一个常见流程举例玩家按下空格键。输入系统的底层轮询函数检测到键码变化解析为逻辑动作“跳跃按下”。输入系统向事件总线发布 InputEvent动作类型为 JumpPressed时间戳取当前帧帧号。游戏层的角色控制器订阅了 JumpPressed 事件收到消息后调用角色状态机的 StartJump()。角色状态机把速度等参数写入场景实体组件渲染系统在下一帧收集实体时自动带上新姿态。时间系统在这一帧结束前累加统计日志调试子系统记录了一次跳跃触发的关键耗时。这条链路里输入系统不认识角色控制器角色控制器也不关心输入怎么检测。中间的耦合点只有一个动作枚举名。只要动作枚举保持稳定两边各自演进都互不影响。// 事件消息示例结构化字段避免 void 指针 struct InputEvent { enum class Action : uint8_t { JumpPressed, JumpReleased, Pause, MoveForward, ... }; Action action; float timestamp; int frameIndex; };我会在后续的文章里单独讲事件总线的实现包括多线程安全、订阅者优先级、以及如何避免事件回调里做耗时操作。这里先记住一个铁律回调里只做逻辑判读和状态改写绝对不做 IO 和同步渲染。4. 生命周期管理启动与关闭顺序不能拍脑袋4.1 为什么初始化顺序必须固定子系统之间存在固有的依赖方向。比如资源管理器必须先于场景图存在因为场景加载时要请求资源事件总线必须先于几乎所有模块存在因为初始化过程本身可能就要发消息窗口模块通常第一个创建因为很多平台 API 依赖上下文。我采用的启动顺序是日志调试子系统先启动后续步骤都能记录窗口与上下文创建平台窗口和渲染上下文时间系统清零帧计数评估启动耗时事件总线订阅能力就位资源管理器准备加载路径和缓存输入系统绑定窗口消息场景图与实体系统空场景就绪渲染系统提交首帧前准备好渲染管线在代码上每个子系统都实现同一个生命周期接口由引擎壳统一按数组顺序调用struct ISubsystem { virtual bool Init() 0; virtual void Shutdown() 0; virtual void PreUpdate(float dt) {} virtual void Update(float dt) {} virtual void PostUpdate(float dt) {} virtual const char* Name() const 0; };统一生命周期接口会有“过度抽象”的嫌疑后面我会专门讲这个坑但它对启动顺序的价值是巨大的你可以在任意阶段插入一个子系统只要它声明自己在数组中的位置即可不用改引擎壳的代码。我在做“音频可选插件”时正是得益于这个统一的 Init/Shutdown 接口才能让插件注册为零侵入。4.2 更新顺序 vs 固定步长和时间相关还有个老生常谈的坑物理模拟要求固定时间步长渲染要求流畅的可变帧间隔。游戏逻辑应该插在哪一层往往决定你的手感。我采用三段式更新PreUpdate 让输入系统先把状态刷新出来同时让时间系统给出当前帧时长Update 阶段跑游戏逻辑读取输入、修改实体PostUpdate 阶段再让渲染系统整理可见集合、准备提交命令。物理如果真的需要我会把它放在 Update 与 PostUpdate 之间并且使用固定步长累加器。// 固定步长累加器伪代码 const float fixedStep 1.0f / 60.0f; float accumulator 0.0f; while (running) { float frameTime timeSystem-GetDeltaTime(); accumulator frameTime; while (accumulator fixedStep) { physicsSystem-Step(fixedStep); accumulator - fixedStep; } gameLogicSystem-Update(frameTime); renderSystem-Render(); }核心思想是物理消耗的是“逻辑时间”渲染消耗的是“墙钟时间”两者之间用累加器缓冲避免物理步长被帧率波动带乱。这个设计如果等工程大了再改会牵动几乎所有游戏逻辑的时序假设所以早期一定要定下来。4.3 关闭顺序逆向销毁不是机械地倒序关闭顺序比初始化更容易被忽略。我的经验法则是谁最后初始化谁最先关闭。但这不是机械回溯而是按“依赖方向”逆向销毁。比如渲染系统依赖资源管理器的纹理句柄那么关闭时必须先让渲染系统停止提交、刷完命令队列再卸载资源。否则会出现渲染线程还在读一块已释放显存的崩溃问题还很难复现。另一个容易被忽略的细节是窗口的 Destroy 时机。有时日志系统还要输出最后的崩溃信息如果把窗口日志先关掉你连错误输出都看不到。所以我通常把日志模块放在关闭链的最后让它见证所有模块体面退出之后再做收尾。5. 主循环与子系统挂载让每帧有序运转5.1 主循环的三段式骨架简单引擎的主循环可以归纳成一个非常固定的骨架轮询事件更新系统渲染画面。即使后面加再多功能也尽量不要把逻辑散到循环外。我用一个引擎壳EngineHost管理主循环循环里不感知任何具体子系统的实现细节只按注册表调用接口。核心代码大约长这样void EngineHost::Run() { while (!windowSystem-ShouldClose()) { windowSystem-PollEvents(); float dt timeSystem-Tick(); inputSystem-PreUpdate(); eventBus-DispatchPending(); for (auto* subsystem : subsystems) { subsystem-Update(dt); } renderSystem-BeginFrame(); renderSystem-RenderScene(); renderSystem-EndFrame(); debugSystem-EndOfFrameStats(); } }“主循环只做调度、不做业务”是我重构后最大的体会。如果主循环里开始出现“如果这个按键按下则进入下一关”之类的判断说明业务没有下沉到对应子系统迟早会变成无法维护的面条。5.2 子系统之间的性能拖累子系统拆开后往往会有人问这么多层调用性能会不会变差我的实测结论是在原型阶段完全不用担心但有两个坏味道必须警惕一是事件总线里出现密集的逐帧消息。逐帧消息会让分发成为热点更麻烦的是它会模糊“一次性通知”和“持续性状态”的边界。这类消息改成轮询接口后性能会明显提升。二是渲染系统在 Update 阶段被逻辑拖住。比如你在更新实体时做很多字符串拼接或者读文件帧率立刻掉给你看。我的原则是逻辑更新阶段绝不碰 IO资源加载统一异步排队渲染命令只在提交阶段组装。5.3 调试钩子与统计早期跑通的审计工具很多人会在引擎稳定后才加性能统计这是反过来的。我建议在第一个主循环跑通时就加上帧耗时、渲染命令数、事件队列长度、资源缓存命中率这四组基础统计。因为这些数据是判断后续每次改动是否值得的唯一依据。我在日志系统里内置了一个每帧末尾的统计槽输出格式很简单frame01234 | dt16.4ms | render_cmds1821 | events_dispatch37 | assets_cached214把统计做成可开关的默认关闭需要时开一个宏开启。用熟了之后你会发现自己能凭这五行字快速判断哪一个子系统出了问题而不是靠猜。6. 踩坑记录三次差点推翻设计的错误6.1 循环依赖最先冒出来的问题第一次把所有子系统接口画在一起后我发现了一个环渲染系统要资源管理器的材质资源管理器又要渲染系统提供格式解码能力格式解码又要读取渲染设备的 API 功能。三条线正好构成一个环。解决方法是先承认“渲染设备”和“资源管理器”之间一定存在交互然后把交互收敛到一个更小的第三方接口里而不是让两个大系统直接互相引用。我称它为“资源访问器”的小组件只负责格式查询渲染系统可以调用它资源管理器也依赖它但两者本身彼此不再可见。这个教训后来影响了我对所有依赖的评审任何两个子系统之间出现双向箭头首先要审视的往往是“哪一份职责放错了位置”。6.2 全局单例的诱惑子系统刚拆分完最自然的实现方式是每个子系统做成一个全局单例。写起来确实很爽随便哪里都能 InputSystem::Get().IsKeyDown()。但我很快发现单例把“模块解耦”重新变成了“隐式全局耦合”——所有代码都在依赖一个全局可达对象测试时想替换一个假输入系统根本没门路。我后来改成了“依赖注入 引擎上下文”的方式。每个子系统在 Init 时通过一个上下文结构拿到其他子系统的指针并且只允许存指针不允许持有全局对象。这样每个系统在日常使用时是显式的单元测试时又能手动构造哑对象。这个改动让整个代码库的模块感跃升了一个台阶。代价是每个函数都要多传一个上下文参数但为了可测试性这点代价完全值得。6.3 过度抽象的教训和单例相反我在另一个阶段又犯过“接口爆炸”的错误。为了让每个子系统都完全可替换我给几乎所有操作都定义了抽象接口有的接口只有一个实现类。结果代码里到处都是工厂、代理、接口适配器改一行功能要翻五个文件。后来我删掉了大约一千行这类抽象代码只保留了两类接口一是确有多个实现可能比如不同平台窗口二是生命周期必须统一比如 Init/Update/Shutdown。其他的全部退化为普通类直接使用。这件事让我明白了一个道理子系统的价值在于边界划分而不是接口数量。一个可以拿着源码直接读的简单类远胜过一个需要跳转三次才能找到实现的抽象接口。简单引擎的“简单”应该体现在读代码时不需要脑内建图。我现在的判断标准是如果某个接口在三个月内没有出现第二个实现类它就不配叫接口。等到真的需要第二个实现时再提取成本远比现在预判要低。最后再聊一点个人体会子系统设计永远不会一次到位。我搭建这套引擎的过程中至少大改了三次依赖关系每次重画结构图都是一次对“什么才是核心”的重新理解。但每次重构之后改需求的效率都在提升这说明方向是对的。如果你正打算开始自己的简单引擎我的建议是别迷信任何人的权威结构先按你当前真实项目里最痛的点拆一版跑起来然后使劲地不满意。这个“不满意”的过程就会把你引向属于你自己的架构答案。下一篇我会深入资源管理器的实现细节聊聊异步加载和缓存失效这两个容易让人头疼的话题。