ARTICLE DETAIL

资讯详情

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

游戏引擎架构深度解析:从分层到主循环的全局地图

游戏引擎架构深度解析:从分层到主循环的全局地图 做游戏引擎最难的从来不是某个特效怎么写而是当你打开代码库的那一刻不知道从哪里看起。UE4 光引擎源码就百万行量级Unity 的内部实现虽然看不到源码但它的模块划分和运行机制也能从编辑器行为里反推出一大半。这些年我带过不少新人也看过不少初学者写的 toy engine我发现大家最容易卡住的不是某个算法而是没有一个全局的“地图”引擎这位大厨的厨房备菜在哪、炉头在哪、上菜口在哪完全没概念。这篇《游戏引擎架构深度解析一》要做的就是把这张地图画出来重点落在引擎基础架构上。这篇内容适合三种人看想自己写一个引擎或 toy engine 的开发者想读 UE4/Unity 源码但被类关系网络劝退的人以及写了几年游戏、却对引擎内部认知还停留在“黑盒”层面的客户端程序。我会尽量用从业者的视角把一套引擎从启动到渲染一帧画面的骨架掰开讲清楚每一层解决什么问题、为什么这样设计、常见的坑在哪。系列后续再逐步展开渲染、资源、物理、动画这些子系统。1. 引擎的分层先搞懂谁在给谁打工1.1 为什么所有商业引擎都长得很像你可能注意到Unity、Unreal、Godot、甚至 Frostbite虽然代码风格千差万别但抽象层次惊人地相似。这不是巧合而是游戏引擎要解决的问题集合基本相同跑在多个平台、要管理海量资源、要实时渲染一帧 16ms 内完成、要支撑策划不停改数值而不重编引擎。所以引擎架构本质上是对“通用游戏开发问题”的行业共识谁偏离这个共识谁就会在后续迭代里付出额外成本。用一张简化的层次示意图来看------------------------------------------------------------ | 游戏逻辑层Gameplay玩法脚本、角色控制器、关卡逻辑 | ------------------------------------------------------------ | 框架/场景层Scene/World、实体管理、组件系统、主循环 | ------------------------------------------------------------ | 功能模块层渲染、物理、动画、音频、AI、网络、输入 | ------------------------------------------------------------ | 核心服务层内存、数学库、容器、反射、任务调度、日志 | ------------------------------------------------------------ | 平台抽象层Windows / Linux / macOS / iOS / Android / 主机| ------------------------------------------------------------分层不是一种审美偏好而是现实约束。底层平台差异必须被隔离否则一套渲染后端会写死到 Windows 上渲染、物理、动画往往由不同团队维护相互之间不能推倒重来游戏逻辑需要能调用引擎接口但引擎不能反向依赖任何一段游戏代码。每一层只允许向下依赖向上不感知这是整个引擎架构的第一条纪律。1.2 用六大模块理解任何引擎面对一款不熟悉的引擎我会把它切到六个功能块里模块核心职责在 Unreal 中的影子在 Unity 中的影子平台层屏蔽 OS/硬件差异PLATFORM_* 宏、LaunchEngineLoop 的平台封装UnityEngine.Platform、IL2CPP 运行时适配核心层基础类型与基础设施FMemory、FString、TArray、FName、GC 反射系统Core 库、Mono/IL2CPP 底层、NativeContainer资源层导入、加载、缓存、卸载UPackage、FStreamableManager、UAssetManagerResources、AssetBundle、Addressables框架层场景组织与生命周期UWorld、AActor、UActorComponentSceneManager、GameObject、Component功能模块层具体算法与硬件交互Renderer、PhysX/Chaos、AudioMixerRendering Pipeline、PhysX、AudioSystem游戏层项目特有的玩法内容游戏子类的 GameMode、PlayerController项目里的 MonoBehaviour 脚本这个对照表不是要你把两个引擎一一对应而是告诉你一件事无论看哪个引擎先找这六个模块的位置然后在心里画一条依赖路径——游戏逻辑调框架层框架层调功能模块功能模块调核心服务核心服务通过平台层访问硬件。我能看懂 UE4 源码靠的就是先认出“谁在给谁打工”再去看具体的类。1.3 一个必须避开的坑把引擎改造成“游戏”现实项目里最常见的架构事故是把玩法特有的东西过早塞进引擎层。比如“角色必须有血量和背包”听起来像通用能力但一旦放进引擎核心后续换玩法、改战斗逻辑就要动引擎代码回归测试范围瞬间扩大。我自己的判断标准很简单如果这块逻辑只服务当前项目、且大概率会随玩法推翻重写那它就属于游戏层如果它是解决某一类问题的通用能力比如材质变体系统、寻路网格生成、资源异步加载框架才值得下沉到引擎层。引擎的稳定性和扩展性是一对矛盾而分层是唯一的解药。围绕这个分层还有一条实践建议在引擎代码和游戏代码的关键接口处维护一份“依赖方向清单”在代码评审时专门检查游戏层有没有反向引用引擎内部实现。靠自觉总有一天会破功靠脚本扫描和 CI 检查才能真正守住边界。2. 从 main 到第一帧引擎启动顺序里的依赖哲学2.1 入口点不是 main而是平台壳很多人学引擎照着网上的例子在 main 函数里写初始化写完发现换个平台就废了。真实引擎里入口点是一个平台壳它的唯一职责是把各种平台的入口差异压缩掉然后调用一个与平台无关的 EngineMain。#if defined(_WIN32) int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { return EngineMain(::GetCommandLineA()); } #else int main(int argc, char** argv) { return EngineMain(argc, argv); } #endifEngineMain 内部的第一步绝对不是创建窗口也不是加载资源而是做三件基础得不能再基础的事挂崩溃处理器、初始化日志系统、给内存管理器打标签。因为从这一秒开始引擎随时可能出错你需要第一时间知道错在哪。Windows 上的结构化异常、Linux 上的 signal、移动端的 backtrace都要在这里统一封装成一份崩溃 dump。2.2 子系统初始化顺序依赖的拓扑排序启动顺序是整个引擎里最容易被忽视的部分很多崩在启动阶段的 bug根源不是代码写错而是初始化顺序违反了依赖关系。我给一个典型顺序表附上理由顺序子系统为什么必须在这个位置1崩溃处理与日志所有后续失败都要有出口2内存管理一切 new/delete 都走这里越早替代 malloc 越好3文件系统后续要读配置、读资源4配置系统引擎可能根据配置决定启动哪些模块5线程池 / Job System异步加载资源需要工作线程6窗口与渲染器创建窗口、GPU 设备、交换链7资源系统上传 GPU 资源需要渲染器已就绪8场景/世界对象反序列化场景需要资源系统9游戏模块装载最后加载保证游戏逻辑看到的是一个完整引擎这里的本质是依赖的拓扑排序渲染器依赖 GPU 设备资源上传依赖渲染器场景反序列化依赖资源系统。顺序一旦反了轻则拿到空指针重则因为某个系统还在半初始化状态而被当成正常状态使用这种 bug 往往比直接崩溃更难查。还有个容易踩的点关闭顺序必须与初始化顺序完全相反。我踩过最经典的一次退出游戏时先关了渲染设备结果音频模块还在异步回调里读取环境混响数据直接访问了已释放的交换链崩溃发生在退出阶段用户体感就是“关闭游戏时随机闪退”。从那以后我把启动和关闭做成了同一个依赖图上的双向遍历而不是手写两段顺序。2.3 启动阶段的可观测性启动流程还有一个隐性要求可观测。如果某一步失败应该给出“这个模块依赖的 X 还没初始化”的明确日志而不是让用户看到黑屏。我习惯在每个子系统初始化入口打印带时间戳的日志并定义错误码。这样玩家反馈问题时我们只需要看启动日志的最后几行就能定位是哪一层断了。不要小看这一步它等于给引擎装了一个黑匣子对后续所有系统接入都有效。3. 主循环一帧一秒里的节奏与分工3.1 最朴素的主循环while 里 update render基础架构的另一块骨架是主循环。几乎所有引擎最终都会收敛到一个类似这样的大循环while (!quit) { pumpEvents(); // 处理输入和系统消息 update(deltaTime); // 更新世界、物理、动画、AI render(); // 渲染一帧 }这段代码是引擎的心脏一眼看上去简单到不值一提但真正的复杂度在于“时间”两个字。游戏需要模拟稳定的物理步进可帧渲染的时间是不稳定的游戏需要跟随屏幕刷新率可逻辑更新又要求固定频率。如果你直接拿上一帧的实际耗时去驱动物理帧率一变物理表现就会漂移甚至出现抖跳。3.2 固定时间步与 accumulator别再让物理看帧率脸色业界通用解法是半固定时间步进也叫 accumulator 模式constexpr float kFixedStep 1.0f / 60.0f; float acc 0.0f; float lastTime GetCurrentTimeSeconds(); while (running) { float now GetCurrentTimeSeconds(); float frameTime std::min(now - lastTime, 0.25f); lastTime now; acc frameTime; while (acc kFixedStep) { UpdateGameplay(kFixedStep); acc - kFixedStep; } float alpha acc / kFixedStep; Render(alpha); }这里有几个细节值得注意。frameTime 被 clamp 到 0.25 秒是为了防止调试器卡住或断点命中的那几十秒让引擎在恢复后陷入“追赶物理”的死亡螺旋物理永远不会因为一帧卡顿而连跳几百步。alpha 用于插值因为两个固定步之间的渲染帧需要算一个“渲染时刻位于两个逻辑状态之间的比重”比如角色移动的平滑显示靠的就是这个 alpha而不是直接拿上一帧的位置硬画。Unity 的 FixedUpdate/Update 和渲染插值本质也是这套模式。这个模式的代价是你至少要保存上一帧和当前帧的插值状态。比如移动系统要保存旧位置和新位置渲染才能画在中间点。这是引擎架构里“为了确定性愿意多花一倍内存”的典型例子值得新人好好体会。3.3 多线程主循环游戏线程、渲染线程与 Job System到了现代引擎主循环已经不是一个 while 走到底了。基础架构通常拆成三条生产者/消费者链游戏线程产生状态渲染线程消费渲染命令工作线程池处理物理、动画、资源解码等耗时任务。// 游戏线程 while (!quit) { input-Poll(); world-Update(kFixedStep); renderCommands-Push(FrameCommand{ ... }); } // 渲染线程 while (!quit) { auto cmd renderCommands-Pop(); if (!cmd) { WaitForRenderCommand(); continue; } rhi-Execute(cmd); }这类设计的核心思想是把一帧 16.6ms 的预算切成可以重叠的时间片。游戏线程花 8ms 更新完逻辑渲染线程再花 10ms 渲染两条线程理想情况下可以并行最终帧率由最慢那条腿决定。渲染命令缓冲的作用是让渲染线程不用等游戏线程把画面算完它拿到的是“上一帧末的状态”这种一帧延迟在绝大多数游戏里完全无感。但要提醒你多线程主循环的复杂度不在于线程本身而在于共享数据。我见过太多死锁和偶发崩溃最后定位到某个动画回调里偷偷改了渲染线程正在读的骨骼数据。现代引擎的解法有两个方向要么用复杂的加锁策略保护共享资源要么干脆用 ECS 这样的数据所有权机制让每帧的任务之间没有共享可变数据。后者是现在的主流趋势后面我单独展开。4. 资源系统加载、绑定与卸载的三段式人生4.1 一段资源从磁盘到 GPU 的旅程引擎基础架构里资源系统是“粮草官”。没有它渲染、物理、动画都拿不到数据。一个模型资源在磁盘上是美术用的 FBX 或 glTF 源文件引擎导入器把它转换成运行时的中间格式比如带压缩顶点数据、LOD 级别、网格包围盒的专属二进制格式然后资源系统负责加载进内存再把顶点缓冲上传到 GPU 显存。为什么要做这么复杂的“中转”而不是直接读原始文件因为美术源文件的内存布局对运行时完全不友好动辄几百 MB 的贴图 TGA 格式也没法直接被 GPU 使用。引擎通常会压缩成 DDS/BC7 或 ASTC让 GPU 能直接采样同时把资源细化成可分批加载的块。这背后的权衡是文件体积、加载速度、运行时内存、GPU 带宽四者相互牵制没有标准的唯一答案只有针对目标平台做的优先级排序。4.2 资源管理器GUID、引用计数与异步加载资源管理器要解决的第一个问题是身份问题。不要让资源的身份依赖文件路径否则重命名、移动文件、不同目录下的同名资源都会引发连锁灾难。商业引擎的做法是给每个资源分配全局唯一 IDGUID文件路径只是“查找到 ID 的索引之一”所有运行时引用都以 GUID 为准。改路径只影响加载表不影响游戏逻辑。第二个问题是生命周期。一份贴图纹理可能同时被 5 个材质引用一个材质可能被 3 个网格体引用网格体又被场景中的实体引用。如果每个系统都自己管内存漏删和二次释放肯定会发生。所以资源系统统一维护强引用和弱引用创建资源时登记被引用时引用计数加一引用释放时减一归零后进入可卸载列表。这套机制看起来简单但真正常被忽略的是循环引用A 资源引用了 BB 又引用了 A计数永远不为零内存就悄悄泄漏。我建议在资源模块里做一个周期性的泄漏检测遍历所有资源在 GC 时的引用关系把循环引用当成错误打日志。第三个问题是异步加载。主线程在执行游戏逻辑时绝对不能因为一张贴图没加载完就停下来等磁盘。正确做法是让资源系统在 Job System 的后台线程里读取和解析数据完成后通过回调或事件通知主线程resourceSystem-LoadAsync(meshes/hero.asset, [](ResourceHandle h) { meshInstancer-BindToMesh(h); });这套异步模型要特别注意回调线程问题回调可能发生在工作线程而它要触发的代码可能在主线程。成熟的引擎会把回调分派到请求时的上下文否则一个跨线程的 UI 修改就能把引擎搞崩。在 Unity 里Resources.Load 和 Addressables 的异步接口差异本质上就是对“回调落到哪个线程”的不同处理策略。4.3 打大包与资源的“热替换”还有一个被很多人低估的工程细节文件数量本身是性能杀手。磁盘上的随机读比顺序读慢很多想象你要读取一万个小文件哪怕每个只有几 KB磁盘寻道时间也远超实际传输时间。所以引擎会把大量资源打包成一个大文件包类似 UE 的 PAK 或 Unity 的 AssetBundle配合包内索引表用顺序读的方式一次性拉取一批资源进内存。热更新本质上也是利用这层文件系统的重定向在资源加载入口处先查缓存目录再回退到只读安装包。我见过最惨的案例是小团队在项目里按美术命名规范直接加载零散文件开发期一切正常到上线打版本时才发现启动要加载 8000 个文件加载时间十几秒。如果基础架构从一开始就规定“资源只走资源管理器所有文件访问必须通过资源句柄”这个问题根本不会出现。5. 反射与数据驱动为什么引擎要“看着数据做事”5.1 C 的“盲”与引擎的“档案系统”C 是一门没有内省能力的语言。运行时你拿到一个对象指针无法知道它有哪些成员变量、成员变量的类型是什么、能不能在编辑器里显示并修改。但引擎的编辑器、序列化、脚本绑定、配置系统全部需要这种能力。所以引擎基础架构里通常会自己实现一套“反射系统”给每个类建立一份档案类名、父类、成员属性列表、属性类型、属性偏移量、读写回调。以 Unreal 的 UPROPERTY 为典型代表其本质是宏展开加代码生成把 C 源码变成既包含运行时逻辑、又包含元数据描述的双层结构。自己写一个简化版也不难class Actor : public Object { DECLARE_CLASS(Actor); public: float Health; std::string DisplayName; RTTI_PROPERTY(float, Health, 生命值) RTTI_PROPERTY(std::string, DisplayName, 显示名) };宏展开后引擎启动时会把“Actor 这个类型的属性表和所属信息”注册进一个全局类型库。这套类型库就是引擎的档案系统编辑器靠它画 Inspector序列化靠它读写文件脚本绑定靠它把 C 属性暴露给脚本语言。5.2 序列化把对象变成文件再变回来反射最大的收益体现在序列化。一个关卡文件里可能嵌套着海量的实体对象每个对象有几十个属性有的属性还引用其他对象。如果没有反射每加一个角色字段都要手写一次 Save/Load 代码团队规模一大这种手工维护就会失控。有了反射序列化核心只需要做一件事遍历类型属性表把每个属性按类型标签写入流读取时再按类型标签反推。伪代码大概这样for (const auto* prop : typeInfo-GetProperties()) { prop-Serialize(stream, rawObject); }至于对象之间的引用序列化时要区分强引用和软引用强引用会连带序列化引用的对象软引用只保存一个资源路径或 GUID加载时再解析。调优的关键是引用方式的选择一个建模粗糙的序列化系统最喜欢把所有东西都搞成强引用导致保存一个空关卡也会拉进一整片资源包。5.3 数据驱动给策划和调优的自由反射系统带来的真正革命是数据驱动。技能参数从 if-else 常量改成配置表掉落概率从一行代码变成策划表难度曲线从重新编译变成运行时可调。引擎只负责提供数据解释器游戏内容的具体数值全部交给配置数据。有人会问既然要数据驱动直接用 JSON 不就好了反射的价值在于它有 schema有类型约束。JSON 是无类型的结构一变所有加载代码都可能崩反射序列化的版本化机制能让你在结构升级时写出兼容迁移这是纯文本配置做不到的。我自己在一个 5 年生命周期项目里靠反射的版本号字段迁移了三次存档结构每次都是无声升级玩家无感这就是基础架构的投资回报。6. 内存管理与缓存引擎性能的秘密也在数据布局里6.1 为什么不放心直接用全局 new/malloc游戏引擎的高性能需求让“内存”成为基础架构的第一公民。直接调用 malloc 遇到的问题有三个分配时不可控碎片化会随运行时间越来越严重没有统计信息想排查哪块内存泄漏或者超支非常痛苦默认对齐宽度不可控SIMD 指令和数据缓存都需要特定的内存对齐。内存碎片可以用停车位来类比停车场有 100 个连续车位一批车停停走走最后剩下的空隙可能分散各处即使总空闲车位足够也无法提供一段连续的大车位。长时间运行的游戏中模型、贴图、对象不断创建销毁堆碎片化会让一次大型资源加载失败表现为“明明内存还够却分配不出来”。所以引擎通常建立统一分配器接口所有 new/delete 都重定向到这里class IAllocator { public: virtual void* Allocate(size_t size, size_t align) 0; virtual void Free(void* ptr) 0; virtual void* Reallocate(void* old, size_t newSize) 0; };在这个接口下面有一组不同策略的分配器分配器适用场景优缺点通用堆分配器长期存在的大对象灵活但有碎片风险栈式分配器帧内临时数据一次分配、一帧后整体回滚极快不能单独释放中间对象池式分配器固定大小的对象组件、粒子节点无碎片释放 O(1)但可能浪费部分空间线性分配器按顺序分配的临时数据顺序分配、一次释放适合每帧动画临时缓冲我从项目实践里得到的教训是分配器不是越高深越好而是要在热点路径用对策略。每帧都会创建的物体绝不要用通用堆去分配长期存活的大资源也绝不要放在帧分配器里。6.2 缓存友好数据布局有时候比算法更关键很多年轻开发者以为 CPU 快是理所当然的实际上内存访问速度比 CPU 计算速度慢一个数量级以上。基础的 CPU 缓存线大约 64 字节CPU 每次从内存读数据实际会拉回一整条缓存线。如果你的数据结构把不相关的字段混在一个对象里遍历时就会把大量无用数据也拖进缓存白白浪费带宽。举个例子。传统 Entity 组织方式是把所有属性塞进一个 structstruct Entity { Transform transform; Health health; Mesh mesh; AIState ai; };一次遍历所有实体且只看碰撞半径时虽然只用了 transform 一个字段缓存却把 health、mesh、ai 全拉进来了。实体数量一多缓存命中率直线下降。现代 ECS 的 SoAStructure-of-Arrays布局把同类型组件连续存储所有 Transform 放在一个数组所有 Health 放在另一个数组。系统更新时只顺着你要的那一条数组顺序读。这就把一个“读 A 却被迫加载 B/C/D”的问题变成了“只加载 A满载使用缓存利用率最高”的问题。基础架构如果能在一开始就支持这种数据布局后续做大规模实体游戏时的性能上限会高很多。7. 跨平台抽象把平台差异关进笼子里7.1 平台抽象层文件、输入、系统事件引擎要跑在 Windows、Linux、macOS、iOS、Android 和主机上每个平台的文件路径格式、输入设备类型、系统 API 完全不同。平台抽象层负责把这些差异全部封装在统一接口后面。核心代码里不写任何平台的 #ifdef只调用接口。平台层通常不止“文件系统”一件事还包括窗口创建与生命周期管理、输入设备枚举与事件分发、高精度计时器、CPU 核心数与缓存行大小查询、系统崩溃信息获取等。输入这块尤其复杂Steam 手柄、Xbox 手柄、触屏手势、VR 控制器看起来是一堆设备但上层需要的只是“移动轴、按键按下、触摸点坐标”这些统一抽象的输入事件。跨平台工程里我见过最糟糕的做法是把平台相关的 #if defined(_WIN32) 散落在引擎各个角落每个类里都有几行 Windows Only 代码。正确的做法是把平台实现放进独立文件比如#if defined(_WIN32) class Win32Platform : public IPlatform { ... }; #elif defined(__APPLE__) class ApplePlatform : public IPlatform { ... }; #endif7.2 RHI渲染接入层不是可选项渲染系统跨平台的抽象接口常被称为 RHIRendering Hardware Interface。它把 D3D11、D3D12、Vulkan、Metal、OpenGL 之间的差异全部封装起来向上只暴露一套资源创建和绘制调用的接口。一个最小 RHI 要覆盖的工作大概包括创建纹理/缓冲区/着色器、设置视口、提交绘制命令、呈现到交换链。UE 的 RHI、Unity 的底层图形接口抽象原理都是一样的。为什么非要这么一层因为渲染 API 之间的 GPU 资源状态管理、着色器编译模型、命令提交方式完全不同。如果你直接在高层代码里散布 D3D12 和 Vulkan 调用一个跨平台渲染功能要写两套逻辑改动一次要测两遍。按照我的经验新项目直接对 Vulkan 是有代价的Vulkan 的初始化极其繁琐命令缓冲和同步原语设计复杂很多小团队根本扛不住这一层学习成本。合理折中是引擎核心面对 RHI 接口先用 D3D11 或 OpenGL 跑通功能等团队成熟了再补一个 Vulkan 后端。RHI 的价值正是让你可以在不推翻上层架构的前提下换后端。7.3 跨平台坑路径、大小写、GPU 驱动差异跨平台最容易被忽视的坑是文件系统的大小写敏感性。Windows 的文件系统不区分大小写Linux 却严格区分。你可以在 Windows 上开发得很爽一发布 Linux 服务器或 Linux 云主机之前所有按“Maps/Level01”写的路径全部失效。我自己就吃过这个亏最后在平台层加了一条强制检查规则开发期所有资源路径统一小写上传资源时自动校验路径命名合法性。另一个跨平台差异来自 GPU 驱动。同一个着色器代码在 N 卡和 A 卡上表现可能完全不同更别说移动端各厂商的驱动 bug。跨平台引擎必须建好 GPU 信息上报能力让线上用户把 GPU 型号、驱动版本、渲染后端版本一起上报否则遇到“部分安卓机型贴图发黑”这类问题你将无从定位。这个环节不是功能但它是平台抽象层的“售后体系”。8. 让架构不跑偏的几条实战守则8.1 用依赖方向约束所有代码而不是靠自觉引擎架构最容易失控的时刻是项目交付压力大的时候。某个游戏逻辑今天就要能跑高级程序员一眼看到引擎内部有个现成函数直接 public 掉用完再说。一两次看起来没事十次之后引擎层与游戏层的边界就只剩下一个名字。我团队里的实际做法是在构建脚本里放一个静态检查步骤扫描模块间的 include 依赖和符号引用一旦发现游戏模块反向依赖引擎模块内部实现构建失败并输出违规调用链。架构评审不靠人眼靠机器拦截这是能长期有效的唯一方式。8.2 抽象要等“第二次需求”时再做但基础设施一次到位架构设计有个经典矛盾过度抽象会带来可读性灾难不抽象会在未来付出高额重构成本。我的经验是把两类东西分开对待。面向市场需求的功能比如“复杂 AI 行为树”“特定类型的技能系统”我会等确实有了第二个使用场景再抽象但面向工程能力的基础设施比如资源管理器、日志、RHI、内存分配器、反射系统必须在第一天就按通用架构设计因为它们后续串着无数子系统返工代价极高。换句话说引擎基础架构不该是“边写边看”的草稿它值得在动手前认真画依赖图写明接口边界。而游戏层代码则完全允许 YAGNI跑通一次再考虑通用化。8.3 架构文档不写类图写一条数据流故事如果让我只留一份架构文档我会选择画“一条请求从 A 走到 B 的路径”而不是类图。类图描述了“有什么”数据流描述了“怎么活起来”。玩家按一下 W 键输入系统把它变成输入事件角色控制器读取事件更新移动速度物理系统推进碰撞动画系统驱动骨骼姿态最后渲染系统把更新后的模型绘制到屏幕。这条链路里每个节点的实现都可以很复杂但只要这条链路清晰新人就能快速找到自己该改的模块。我通常对团队的要求是新同学入职第一周不看任何类图只看三份数据流故事——启动流程、一帧更新、资源加载。等这三条链路走通引擎在他们眼里就不再是黑盒了。架构的真正价值说到底是让团队在“看不见的地方”也保持一致的方向感。我写这篇基础架构解析就是希望把你对引擎的认知先拉到同一张地图上。等这张地图在你脑子里清晰了后续聊渲染管线、资源流送、ECS 实体系统、物理架构这些子系统时你就能自然知道它们各自应该在棋盘上站在哪个位置。第二篇我准备先挑渲染架构讲因为它是大多数游戏开发者最熟悉的模块也是性能投入最大的模块。在下一篇出来之前建议你先把自己的引擎或项目里那套主循环和资源管理器理一遍哪怕只是纸上画一下当前依赖关系也会有不少新发现。
返回列表