资讯动态

从零构建简单游戏引擎:子系统设计与架构核心

发布时间:2026/10/15 4:43:16 来源:尧图企业网站定制
很多人一听到“游戏引擎”四个字第一反应就是那些商业大厂的产品动辄百万行代码功能包罗万象。但当我真正开始琢磨“引擎”这个概念时我逐渐意识到引擎的核心价值不在于功能的堆砌而在于结构的清晰。这也是我写下这个系列——“Building a Simple Engine”的原因。我不打算复刻任何商业引擎也不想把架构搞得像星系一样复杂。相反我想从零开始用一个最小但完整的代码骨架把引擎“是什么”“怎么运转”“为什么拆分”这三件事讲明白。这篇正文是整个系列的引言章节专门聊“子系统”。可能有人不理解为什么开始动手前要先花一整章讲概念因为我踩过太多坑了。早些年我写过一个几千行的Demo功能都塞在一个大类里结果每隔几天就在改一个函数时顺手震碎了三个模块。那种挫败感是真实的。后来我意识到问题不在于功能而在于结构。当你把引擎拆解成边界清晰、职责单一的子系统时改动才会变得可控调试才会变得有迹可循。这篇文章适合三类人看第一类是想从零实现引擎核心的新手开发者第二类是使用商业引擎但对底层架构感到好奇的从业者第三类是正在搭建业务框架、想借鉴模块化思路的程序员。如果符合请接着往下看。1. 为什么第一课先讲“子系统”而不是渲染或物理1.1 从一次翻车经历说起说个我自己的真实经历。两年前我接了一个小项目要在两个星期内做一个2D横版原型。当时我图省事把负责绘制精灵的函数、处理键盘输入的回调、播放音效的SDK调用全部写在了一个叫“GameManager”的类里。前三天确实飞快一天一个功能。到了第五天我开始觉得不对劲——每次调整音量都要翻那个快两千行的文件每次加一个新输入按键都要小心别覆盖掉原来的逻辑。第七天我在实现暂停功能时顺手把“暂停”写在了“隐藏主角”之后结果音效还在播人物却消失了。那一刻我知道这个Demo已经不可能往回改了。推倒重来用子系统去组织代码虽然前期多花了半天时间梳理但后面每一步都是踏实的。这不是说非要为了架构而架构。而是当一个引擎从启动到渲染、从输入到音效涉及到的关联越来越多时子系统的价值才真正体现出来。你不可能永远活在“只有一个类”的世界里。哪怕再简单的引擎至少要面对图形渲染、输入处理、逻辑更新三个不同的频率和生命周期。这个事实本身就已经在逼你拆分。1.2 子系统的本质是“隔离复杂”那么什么是子系统我更喜欢把它理解成“引擎内部的一组独立服务”。每个子系统对外暴露清晰的能力接口对内隐藏实现细节。渲染子系统负责把场景数据变成屏幕上的像素音效子系统负责把音频资源变成可播放的声音。它们各有各的数据、各有各的更新节奏通过引擎的“调度层”协同工作。这里的关键词是“隔离”。隔离带来两个直接的好处一是并行开发二是故障隔离。团队里可以让一个人只关注物理模拟另一个人专注资源管理两人约定好接口各自实现内部细节极大减少冲突。而在运行时如果音效子系统崩了理论上不影响渲染帧的正常提交——当然这是理想情况现实可能需要更多容错设计但比全部耦合在一起已经优雅太多。用生活化的话来说子系统这个概念就像你家里的水电网络。电力系统出问题了可以单独检修不影响自来水供应。如果你把电线和水管绑成一根线缆那一旦出问题你就要全部切开。引擎也是一栋房子子系统化就是预先规划好管道井。1.3 绪论这篇到底讲什么所以这篇“Introduction”我要完成的事情是定义引擎的骨架讲清为什么核心是“一组有序启动、稳定运行的子系统”列出最小引擎中常见的子系统清单并说明它们各自负责什么重点剖析子系统之间的通信策略、生命周期管理以及依赖关系设计给出最小可用的代码骨架让你这篇读完就能搭出最初始的引擎壳子。这些内容不会一次性让人头大。我尽量用“先分块、再接线”的方式来讲。整个系列你会看到每个子系统怎么单独构建而这篇“引荐信”就是整个系统设计的路线图。2. 一个简单引擎到底需要哪些子系统2.1 功能清单别多也别少在设计“简单引擎”时最大的诱惑是“什么都往里塞”。物理、粒子、动画、网络……我见过有人连聊天系统都塞进引擎里。但记住我们的前提是“Simple Engine”。判断一个功能是否需要独立成子系统我常用三问有没有独立的数据状态有没有独立的更新节奏有没有独立的资源类型三者中满足两个就该成子系统。最小引擎里我推荐一开始只规划这几类子系统子系统名核心职责典型数据结构固定更新频率窗口与上下文创建显示窗口、绑定图形API上下文窗口句柄、渲染上下文事件循环渲染接收绘制指令提交到GPU命令缓冲、材质表、网格资源每帧输入捕获键盘鼠标生成语义化事件事件队列、按键映射表每帧开始前音频管理声音播放、衰减和混音声音实例、音频源缓冲每帧或独立音频线程资源加载、缓存、释放文件资产资源句柄、加载队列异步/按需场景管理存储游戏对象管理它们的生命周期实体表、组件容器每帧时间生成帧间隔、累计时间驱动全局逻辑时间戳、增量时间系统时钟这七个就是我要交付的“最小标配”。它不包含物理因为简单引擎可以先把碰撞交给场景内的组件自己处理也不包含动画因为动画可以暂由渲染器直接驱动。这个列表不是终点而是可扩展的起点。2.2 每个子系统的核心数据流和职责边界也许有读者觉得表格太抽象我展开讲两个最有代表性的。渲染子系统是引擎的“门面”。它的输入是场景中所有可见物体的描述——网格、材质、变换矩阵——输出是一帧图像。隔离的关键在于游戏逻辑不能直接调用显卡命令。逻辑层只能往渲染命令列表里“提交”而真正的绘制由渲染子系统统一调度。这样游戏逻辑层保持纯粹不关心背面剔除、深度排序这些GPU侧细节而渲染系统也不需要理解“角色库存”是什么业务数据。另一个是场景管理子系统。它保存一份运行时实体注册表。在简单引擎中我们可以用“实体表组件表”的索引结构。实体是一个整数ID组件是放在数组里的具体数据。这个系统对外提供的API是“创建实体”“销毁实体”“查询组件的引用”内部用两个哈希表维护。它不执行任何业务逻辑只是完成“谁还存在、谁持有什么”的管理职责。看每个系统的边界都非常清楚。数据怎么流动谁往谁那里递数据一目了然。2.3 “简单”的三个评判标准设计过程中要不断质问自己这还简单吗我有三个评判标准分享给大家。第一启动顺序不超过两次依赖传递。如果A依赖于BB依赖于C那启动顺序就是C → B → A。这个依赖链长度超过两次传递时说明子系统切割太碎需要合并。第二每个子系统头文件里不出现“别的子系统”的私有类型。只能出现别人冒测试过的公开接口。比如渲染子系统头文件只包含基础指令结构不会包含音频的缓冲区句柄类型。第三删除一个子系统时其他子系统依然能编译运行。这就像电路板上的可拆卸模块——拔一个一片还能亮。如果删掉资源管理整个程序就启动不了说明这个“子系统”实际只是另一者的附属或者依赖耦合过深。我个人认为能轻松满足这三条的引擎才算担得起“Simple”这个词。3. 子系统之间怎么通信依赖、生命周期与事件3.1 依赖关系要有向无环如果我拿出小本本画一张引擎子系统之间的协作图你会看到它像一张有向无环的瀑布窗口在最底层渲染、输入依附着它资源管理系统被渲染、音频依赖场景管理又挂在渲染之上时间系统则像河水贯穿全员。任何一个熟悉架构设计的人都知道“依赖倒置”这个词但我想强调的点更朴实——控制依赖的方向。依赖只能顺着“底层能力”流向“上层业务”不能再反向流回来。渲染系统可以依赖资源系统去拿网格数据但资源系统绝不能反过来调用渲染管线去“显示加载进度”否则就是一个循环依赖炸弹。循环依赖一旦出现就会带来第二个问题启动顺序无法确定。引擎不像普通程序一样只有一个main函数入口它更像一支交响乐团各个子系统必须在正确的拍子进场。明明需要资源系统先起但资源系统又发现自己需要窗口句柄才行这就是经典僵局。在设计篇中我的解决方案是引入“服务定位器”模式。简单说引擎内部维护一个静态的“子系统表”启动阶段按注册顺序依次initialize所有子系统。任何子系统想访问别人就通过这个定位器来获取接口。它保证依赖项总是先于使用者的初始化完成。那怎么保证注册顺序呢就按我上面表格的从下到上来。窗口、时间、资源这些底层先注册渲染和音频中间场景和输入最上层。3.2 生命周期四部曲初始化、加载、启动、更新子系统不是一注册就能工作的。我习惯把生命周期拆成四个阶段Initialize分配基础资源创建窗口、初始化图形API。此阶段不依赖业务内容。Load确保子系统的默认资产就位。比如渲染系统在这个时候创建内置着色器和默认纹理。Start激活子系统逻辑使其开始接收调度。Update每帧被引擎主循环调用执行功能。这四个阶段正好和引擎的启动路径对应。上图是启动序列窗口初始化 → 渲染初始化 → 资源初始化 → 音频初始化 → 时间初始化 → 场景初始化 → 输入初始化 → 加载默认资源 → 创建初始场景 → 开始主循环这样的顺序有着严谨的逻辑推演。窗口必须是第一个没有它图形API无从谈起。渲染先于资源是为了在资源加载前能注册默认白纹理、错误网格而资源又早于场景让场景创建时能立刻拿到素材。你观察这个顺序会发现每一条依赖都有一个明确的“为什么”。3.3 事件系统削峰填谷的通信带除了直接的接口调用子系统之间还需要一种更低耦合的通信方式——事件系统。举个最简单的场景玩家按了“退出”键输入子系统捕获到按键但它不该直接调用窗口子系统的“关闭”方法虽然我们设计上允许更好的做法是广播一个“QuitRequested”事件。窗口子系统订阅了这个事件收到之后开始顺滑地结束主循环。事件系统的只需要三要素事件类型、事件通道、订阅回调。引擎核心只提供“发布”和“订阅”两个API。发布是广播式的任何订阅了该事件的系统都能收到。订阅是声明式的每个系统把自己的回调函数注册到特定事件类型上。这样做的价值是一个“软解耦”——输入系统不知道窗口的存在但它依然能和窗口通信。未来哪怕我重新实现一个输入设备的SDK窗口通信依然不受影响。事件不用内部包装成统一结构。注意事件系统虽好也不能滥用。如果每个函数调用都走事件广播代码就变成一张复杂的事件图难调试。经验是不可预测的“很多对很多”通信才用事件单向且确定的一对一调用还是用直接接口。4. 第一行代码用Interfaces搭起子系统骨架4.1 先出门定义通用的子系统接口我们真正开始写代码。为了让七个子系统像积木一样拼接到引擎里第一步是定义一个公共接口——ISubsystem。所有子系统都继承它引擎才能统一调度。C里我的做法是// Subsystem.h #pragma once enum class SubsystemState { Uninitialized, Initialized, Loaded, Started, Error }; class ISubsystem { public: virtual ~ISubsystem() default; virtual void Initialize() 0; virtual void Load() 0; virtual void Start() 0; virtual void Update(float deltaSeconds) 0; virtual void Shutdown() 0; const char* GetName() const { return name_.c_str(); } SubsystemState GetState() const { return state_; } protected: void SetName(const char* name) { name_ name; } void SetState(SubsystemState newState) { state_ newState; } private: std::string name_; SubsystemState state_ SubsystemState::Uninitialized; };接口里还有两个值名字和状态。名字用于日志打印和错误定位状态用于跟踪生命周期。你很快会发现这两个值就像工作人员胸前的工牌和值班表一旦调度出问题看一眼日志就知道卡在哪一步。4.2 实现一个具体的子系统以时间子系统为例理论讲再多不如拿来一个实例。我想用“时间子系统”来演示因为它的逻辑最简单、依赖最少最容易被新手复现。时间子系统的职责是记录每一帧从启动到现在耗时多久计算出帧间隔deltaSeconds并保证当玩家离开窗口焦点时时间不会“井喷式”跳动。它的实现大约就十几行// TimeSystem.h #pragma once #include Subsystem.h #include chrono class TimeSystem final : public ISubsystem { public: TimeSystem() { SetName(TimeSystem); } void Initialize() override { last_ std::chrono::steady_clock::now(); SetState(SubsystemState::Initialized); } void Load() override { SetState(SubsystemState::Loaded); } void Start() override { start_ std::chrono::steady_clock::now(); SetState(SubsystemState::Started); } void Update(float /*deltaSeconds*/) override { auto now std::chrono::steady_clock::now(); delta_ std::chrono::durationfloat(now - last_).count(); last_ now; } void Shutdown() override { SetState(SubsystemState::Uninitialized); } float GetDeltaSeconds() const { return delta_; } float GetTotalSeconds() const { return std::chrono::durationfloat( std::chrono::steady_clock::now() - start_).count(); } private: std::chrono::steady_clock::time_point start_; std::chrono::steady_clock::time_point last_; float delta_ 0.0f; };你来留意两个细节第一我用steady_clock而不是system_clock因为车辆引擎的定时测速不需要墙上挂钟那样追求“真实日期”它只需要稳定递增的间隔不受用户调系统时间影响第二我在Update里不调用基类的Update而是直接重算delta_——在这个设计里基类不包含默认逻辑纯接口实现自由度更高。4.3 服务定位器的实现与启动序列有了接口和具体实现接下来需要一个能把它们串起来的“舞台监督”——Engine类。它的核心任务有三个注册所有子系统、按顺序走完生命周期、每帧调用更新。// Engine.h #pragma once #include vector #include unordered_map #include memory #include Subsystem.h class Engine { public: templatetypename T T* AddSubsystem() { static_assert(std::is_base_of_vISubsystem, T, T must derive from ISubsystem); auto ptr std::make_uniqueT(); T* raw ptr.get(); subsystems_.push_back(std::move(ptr)); lookup_[raw-GetName()] raw; return raw; } templatetypename T T* GetSubsystem() { for (auto sub : subsystems_) { if (auto casted dynamic_castT*(sub.get())) { return casted; } } return nullptr; } void Startup() { for (auto sub : subsystems_) sub-Initialize(); for (auto sub : subsystems_) sub-Load(); for (auto sub : subsystems_) sub-Start(); running_ true; } void Run() { while (running_) { for (auto sub : subsystems_) sub-Update(lastDelta_); } } void Shutdown() { for (auto it subsystems_.rbegin(); it ! subsystems_.rend(); it) { (*it)-Shutdown(); } subsystems_.clear(); } void RequestExit() { running_ false; } private: std::vectorstd::unique_ptrISubsystem subsystems_; std::unordered_mapstd::string, ISubsystem* lookup_; bool running_ false; float lastDelta_ 1.0f / 60.0f; };注意Run()里的一个小技巧我先把上一帧的delta_默认设为 1/60然后每帧无条件调用所有子系统的Update。至于lastDelta_怎么更新其实在这个简单骨架里时间子系统的Update会把自己内部计算的增量保存在engine变量里下一帧才被传递。这里我简化了假设TimeSystem的Update会写一个全局时间。不过更好的做法是Engine在遍历子系统时记录时间差作为全局参数——但这就涉及后面的“帧同步”设计本次不展开。4.4 最小可用入口函数现在把这些串起来一个引擎的壳子就活了。入口文件长这样// main.cpp #include Engine.h #include TimeSystem.h #include WindowSystem.h #include RenderSystem.h #include InputSystem.h int main() { Engine engine; engine.AddSubsystemWindowSystem(); engine.AddSubsystemTimeSystem(); engine.AddSubsystemRenderSystem(); engine.AddSubsystemInputSystem(); engine.Startup(); engine.Run(); engine.Shutdown(); return 0; }注册顺序和Startup()里的三级循环是匹配的Initialize/Load/Start完全按注册序执行。所以你把窗口排最前完全合理。Run()则是一个最简单的“永远跑下去”的循环直到某个子系统触发RequestExit()把它打断。虽然这个壳子还不能画出一个三角形但它已经具备了引擎最重要的能力——一套可编排的子系统骨架。我下一步写窗口和渲染时只需要把真正的功能填回各自类里面其他系统一概不动。5. 这些坑我提前帮你试过了常见问题与排查实录5.1 初始化顺序错误引发的“半崩状态”在我自己写这个引擎时最经典的报错是这样的我在Startup()里按顺序写成了Window → Render → Time → Input结果RenderSystem::Initialize()里调用了TimeSystem的引用去获取起始时间。但在窗口和渲染初始化时时间子系统还是零值渲染器打印的初始耗时永远是0于是某些基于耗时计算的动态等待全都失效。这种问题不容易挂掉程序只会导致行为“怪怪的”。排查方式是打开日志开关在每一个Initialize和Start里打印“系统名 状态”。如果看到某个系统在“未初始化”状态下被访问日志就会立刻暴露顺序问题。这里提醒一个设计原则子系统内部不能假设某个系统必然已经初始化。哪怕依赖关系再明确也应该在获取依赖时做一个有效性检查发现失败及时抛出清晰错误而不是让莫名其妙的行为蔓延。5.2 事件订阅的生命周期隐患事件系统好用但有一类非常隐蔽的崩溃常常来自它订阅者已经销毁事件仍然被广播。比如场景管理器在清空当前场景时顺手把一个实体身上的脚本组件销毁了但脚本组件之前订阅了“角色死亡”事件。当另一处逻辑广播“角色死亡”时事件系统向一个已悬空的回调函数发送了消息。解决这个问题我在事件总线的设计上加了一个“弱回调”概念订阅时记录订阅者对象的弱引用广播前尝试弱引用升为强引用如果失败就说明对象已销毁静默移除订阅项。这是一个极小但回报极高的改动记得加上。5.3 更新顺序对帧内体验的影响另一个实战里常见的坑是更新顺序不同玩家“手感”大不同。比如你先更新输入再更新逻辑最后渲染那逻辑看到的是“最新输入”体验合适。如果你先渲染后输入那当帧的画面一定滞后于输入至少一帧玩家的瞄准就感觉“飘”。解决方案是把输入映射成一个“可重放的事件缓冲”渲染帧开始时先消费输入事件生成逻辑输入状态再交给整个帧管线。只要维持整个生命周期的调用顺序稳定输入→场景→渲染→窗口提交游戏手感就会明显扎实。我把这部分最容易犯的问题汇总成一个速查表方便你对照症状可能原因排查手段首帧画面一黑渲染在窗口上下文创建前提交命令检查注册顺序窗口必须最先Initialize点击无响应输入事件缓冲区未清空看看InputSystem的Update是清空还是累积音效和画面不同步音频系统未收到delta时间给音频Update传实际帧间隔别让它自己读全局时钟切窗口超时事件循环没有把“失焦”通知给时间系统在窗口事件里广播Pause/Break事件崩溃在Shutdown某个子系统先释放资源但我还在用按逆序Shutdown让依赖方先退场5.4 日志与可视化调试的提早布局最后一条建议从第一天就给每个子系统写状态日志。不要觉得只有引擎大了才需要日志。我见过太多Demo崩溃时连“哪个模块初始化失败”都分不清。用上面的GetName()和GetState()组合起来打印一句[Engine] Subsystem [RenderSystem] - Initialized别小看这一行。有了它遇到问题你先后看五分钟日志基本上能定位90%的初始化期故障。你也可以顺手设计一个控制台命令来切换“每帧打印所有子系统状态”不过别默认开启否则日志量会巨大。6. 后续系列的地图接下来我们往哪走6.1 从框架到画面按顺序逐个实现这一篇搭建的框架相当于一个空灶台。后续我计划按照依赖顺序一节节把灶上的锅装满。首先是WindowSystem和图形上下文绑定这部分会涉及原生窗口API和渲染API的初始化然后是RenderSystem先把三角形画出来再画四边形、贴图逐步扩展材质系统再然后是ResourceSystem实现图片解阅与缓存紧接着是InputSystem和SceneSystem把游戏对象、组件体系和实体管理串起来。当每个子系统都成为独立的文章后你回头再看这篇“Introduction”会真正体会到“脉络”的感觉。6.2 不断做减法演进路上始终问自己引擎做久了你会不断想加新功能阴影、粒子、骨骼动画。这些东西每个听起来都值得。但每次加入新东西时我建议你做一次“减法评审”这一步有没有让系统的边界更模糊是否引入了新的依赖循环是否破坏了启动顺序的简单性如果一个新增功能只是往某个子系统里塞几十行代码没影响任何别的模块那它就该留在子系统内部如果它要同时拉上四个子系统开会那你就要好好掂量了。保持简单不是一个容易的事比起加法减法更难执行。但我个人的体会是一旦你真的把基础骨架搭得足够清爽后续每一个子系统的实现都像在流水线上装配零件每一步都清楚每一步都安心。6.3 任何人都能拥有简单的引擎回看这个系列的开始我想再说一次构建引擎其实不神秘真正核心的功夫是把复杂的系统拆解成简单而清晰的模块组合并且严格执行边界纪律。你不需要一开始就建立一个世界级引擎。先做一台简单引擎让循环跑起来、让三角形飘起来、让事件传起来你的工程经验会在动手过程中以万倍速积累。下一篇我们正式进入第一个子系统窗口与图形上下文的实现不见不散。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑