从个人项目谈到“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 调试钩子与统计:早期跑通的审计工具
很多人会在引擎稳定后才加性能统计,这是反过来的。我建议在第一个主循环跑通时,就加上帧耗时、渲染命令数、事件队列长度、资源缓存命中率这四组基础统计。因为这些数据是判断后续每次改动是否值得的唯一依据。
我在日志系统里内置了一个每帧末尾的统计槽,输出格式很简单:
frame=01234 | dt=16.4ms | render_cmds=1821 | events_dispatch=37 | assets_cached=214把统计做成可开关的,默认关闭,需要时开一个宏开启。用熟了之后,你会发现自己能凭这五行字快速判断哪一个子系统出了问题,而不是靠猜。
6. 踩坑记录:三次差点推翻设计的错误
6.1 循环依赖:最先冒出来的问题
第一次把所有子系统接口画在一起后,我发现了一个环:渲染系统要资源管理器的材质,资源管理器又要渲染系统提供格式解码能力,格式解码又要读取渲染设备的 API 功能。三条线正好构成一个环。
解决方法是先承认“渲染设备”和“资源管理器”之间一定存在交互,然后把交互收敛到一个更小的第三方接口里,而不是让两个大系统直接互相引用。我称它为“资源访问器”的小组件,只负责格式查询,渲染系统可以调用它,资源管理器也依赖它,但两者本身彼此不再可见。
这个教训后来影响了我对所有依赖的评审:任何两个子系统之间出现双向箭头,首先要审视的往往是“哪一份职责放错了位置”。
6.2 全局单例的诱惑
子系统刚拆分完,最自然的实现方式是每个子系统做成一个全局单例。写起来确实很爽:随便哪里都能 InputSystem::Get().IsKeyDown()。但我很快发现,单例把“模块解耦”重新变成了“隐式全局耦合”——所有代码都在依赖一个全局可达对象,测试时想替换一个假输入系统根本没门路。
我后来改成了“依赖注入 + 引擎上下文”的方式。每个子系统在 Init 时通过一个上下文结构拿到其他子系统的指针,并且只允许存指针,不允许持有全局对象。这样每个系统在日常使用时是显式的,单元测试时又能手动构造哑对象。
这个改动让整个代码库的模块感跃升了一个台阶。代价是每个函数都要多传一个上下文参数,但为了可测试性,这点代价完全值得。
6.3 过度抽象的教训
和单例相反,我在另一个阶段又犯过“接口爆炸”的错误。为了让每个子系统都完全可替换,我给几乎所有操作都定义了抽象接口,有的接口只有一个实现类。结果代码里到处都是工厂、代理、接口适配器,改一行功能要翻五个文件。
后来我删掉了大约一千行这类抽象代码,只保留了两类接口:一是确有多个实现可能(比如不同平台窗口),二是生命周期必须统一(比如 Init/Update/Shutdown)。其他的全部退化为普通类,直接使用。
这件事让我明白了一个道理:子系统的价值在于边界划分,而不是接口数量。一个可以拿着源码直接读的简单类,远胜过一个需要跳转三次才能找到实现的抽象接口。简单引擎的“简单”应该体现在读代码时不需要脑内建图。
我现在的判断标准是:如果某个接口在三个月内没有出现第二个实现类,它就不配叫接口。等到真的需要第二个实现时再提取,成本远比现在预判要低。
最后再聊一点个人体会:子系统设计永远不会一次到位。我搭建这套引擎的过程中,至少大改了三次依赖关系,每次重画结构图都是一次对“什么才是核心”的重新理解。但每次重构之后,改需求的效率都在提升,这说明方向是对的。如果你正打算开始自己的简单引擎,我的建议是别迷信任何人的权威结构,先按你当前真实项目里最痛的点拆一版,跑起来,然后使劲地不满意。这个“不满意”的过程,就会把你引向属于你自己的架构答案。下一篇我会深入资源管理器的实现细节,聊聊异步加载和缓存失效这两个容易让人头疼的话题。