
想写这个系列已经很久了。不是因为我自认为对引擎有多精通而是因为在过去几年里我见过太多项目——也包括我自己早期带团队时——因为引擎基础架构没想清楚最后在玩法、画面都没问题时被自己的代码拖垮。游戏引擎这个领域表面上是渲染、物理、动画、音频这些能直接看到的功能模块在支撑但真正决定一个引擎能走多远、能撑住多大项目的往往是那些看不见的东西模块怎么划分、依赖朝哪个方向流动、对象怎么组织、内存怎么管理、生命周期怎么设计。这一篇是这个系列的第一篇我打算先把基础架构这层最关键的骨架讲清楚。1. 为什么引擎必须先讲分层依赖的单向性决定维护成本1.1 分层不是装饰三层模型与“不敢动的代码”我见过太多游戏项目开始时只有一两个模块跑得飞快。半年后模块数量到了二三十个开始出问题改一个渲染管线的接口发现逻辑模块、UI模块、技能模块、音频模块全都在直接调用它想调整一下资源加载的时机发现物理模块也在自己加载模型。这时候项目进入“不敢动”状态任何改动都可能踩到其他模块的地雷编译时间越来越长测试范围越来越大最终大家选择绕过问题——新功能不走旧系统自己新起一套然后模块更多更不敢动。这个问题的根源不是模块太多而是依赖方向没有控制。引擎架构里最基础的原则只有一个依赖必须是单向的。底层的东西可以被上层调用但上层的东西永远不能被底层依赖。这句话听起来像废话但绝大多数架构腐烂都是从某次“临时”打破依赖方向开始的——为了省事让资源模块直接调了渲染模块的一个工具函数或者让数学库访问了日志系统。一次两次没什么十次之后层与层之间的边界就消失了。一个健康的引擎基础架构至少要分成三层平台抽象层、核心层Core、引擎层Engine。平台抽象层封装操作系统、窗口、输入、图形API、文件系统等平台差异。核心层数学库、容器、内存分配器、日志、事件系统、平台封装等“和游戏类型无关”的基础设施。引擎层场景管理、组件系统、资源管理、渲染器抽象、物理系统、动画系统等一切“假设存在一个游戏世界”的模块。这三层的关系是单向的引擎层依赖核心层核心层依赖平台抽象层平台抽象层不依赖上面任何东西。每一层内部可以有任意多个模块但模块之间的依赖不能向上或跨层。举个具体的例子。我在一个自研引擎项目里早期把文件系统封装直接放在核心层底层用平台抽象层的接口实现。这样引擎层的资源模块可以调用核心层的文件接口但文件接口不知道资源模块的存在。后来有人图方便在文件系统里加了一个“加载完资源后通知渲染线程”的逻辑文件系统直接依赖了引擎层的资源模块和渲染模块。这个改动绕过了两层边界当时确实省了半小时的事但六个月后文件系统只要一改渲染和资源的代码就要重新编译一大片。最终我们花了两天时间把这条依赖拆掉代价是当时正在赶的版本延期了三天。但如果再拖下去这个延期只会更长。1.2 平台抽象层一头接系统一头接引擎中间别夹游戏逻辑平台抽象层在基础架构里地位特殊很多人不把它当成独立一层而是一堆零散的函数散布在引擎各模块。这很危险。一旦平台代码被分散到各个模块就会出现类似这样的局面输入模块用自己的方式读取Windows消息渲染模块用另一套方式读取窗口尺寸逻辑模块想要监听按键事件还得直接和系统API打交道。平台相关的代码像毛细血管一样在项目里蔓延将来换平台时你根本不知道要拆多少地方。正确做法是把平台差异收敛到一个稳定的接口面上做好这几件事窗口与上下文管理创建窗口、设置窗口属性、创建图形上下文、处理窗口事件循环。输入抽象统一键盘、鼠标、手柄输入向引擎层提供事件而不是让引擎层直接调用系统输入API。文件系统抽象屏蔽路径差异、大小写敏感性、工作目录问题。时间与系统信息获取时间、休眠、获取CPU核数、内存信息。线程与同步原语线程、互斥锁、条件变量的封装。有一个原则值得记住平台抽象层的接口应该小而稳只暴露“这台机器能做什么”不暴露“游戏需要什么”。比如不要设计一个“加载关卡文件”的接口放在平台层——那是引擎层该做的事。平台层只需要提供“按路径读取字节流”这个能力就够了。如果哪天你发现平台抽象层里出现了GameMode、Actor、Scene这类词说明边界已经失守了。2. Core与Engine的分界哪些代码“万年不变”哪些代码允许频繁改2.1 Core层数学、容器、内存、日志不允许依赖游戏类型核心层的定位是所有模块的地基。它应该是整个引擎里最稳定、历史包袱最少的部分。具体包含哪些东西每个项目会有差异但最基本的这些不会少数学库向量、矩阵、四元数、变换、几何计算。数学库是整个引擎里被依赖最多的模块没有之一所以它的接口设计必须极其谨慎——改一个函数签名所有模块都要重新编译。容器与数据结构数组、哈希表、字符串、队列、树、图。这部分往往不是直接用STL而是自研或基于STL二次封装原因我后面第3章详细展开。内存分配器通用分配器、内存池、帧内存、栈式分配器。内存管理的架构级选择直接影响性能和稳定性。日志系统控制台输出、文件日志、崩溃日志。日志是唯一的“系统级容错”手段很多诡异问题没有日志根本无法定位。事件系统同步/异步事件分发。事件系统本身不依赖任何游戏模块但所有模块都能向它注册监听。基础工具库ID生成器、时间戳、随机数、位运算辅助。这些模块有一个共同特点它们不管外面跑的是RPG还是FPS不管物理引擎是Bullet还是PhysX不管渲染器是Forward还是Deferred。它们只解决“计算机底层怎么运转”的问题。正因为如此Core层的代码应该像标准库一样被对待接口稳定、文档充分、单元测试覆盖率高。我在团队里定过一条规矩凡是下沉到Core层的代码必须经过review确认它不依赖任何引擎层概念一旦违反立刻打回。2.2 Engine层场景、组件、实体引擎对“游戏长什么样”的默认假设引擎层就不一样了。它允许频繁变化允许为了适配不同游戏类型而不断调整。引擎层里通常有这些模块场景图/世界管理管理实体、组件、层级的组织关系。组件系统组件的注册、创建、销毁、查询。资源管理资源加载、卸载、引用计数、热重载、异步加载。渲染器抽象不直接暴露图形API对外提供Mesh、Material、Camera、Light等高层概念。物理系统碰撞检测、刚体模拟、触发器、射线检测。动画系统骨骼动画、动画状态机、混合、蒙皮。音频系统声音资源、播放控制、空间化。引擎层和Core层最大的区别在于引擎层包含了对“游戏世界”的假设——有场景、有实体、有组件、有相机、有光照。这些概念在Core层里不存在。这带来一个重要推论如果你要做编辑器编辑器本身应该依赖引擎层的接口而不是绕过引擎层直接操作Core层。最常见的问题是编辑器为了显示场景里的物体直接去读渲染器的内部缓冲区绕过了引擎层的场景管理。这样短期内实现快长期来看编辑器与运行时之间会形成巨大的“影子系统”编辑器里改一套状态运行时又维护一套状态两套状态互相不同步。2.3 什么时候该把功能下沉到Core层复用与稳定性之间的博弈实际工作中模块边界不会一开始就天衣无缝很多功能一开始放在引擎层后来发现多个模块都要用需要判断是否下沉。我判断“该不该下沉到Core层”的标准很粗但很有效如果这个功能不依赖场景、实体、组件、资源、渲染、物理等任何游戏世界概念如果放到Core层后能让两个以上完全不同的项目复用如果它在未来一年内不会有大的语义变化。三条都满足就下沉。否则宁可先留在引擎层“观察”一段时间。举个例子。我们引擎早期有一个“路径点寻路”的功能放在引擎层的AI模块里依赖场景图。后来物理模块做射线检测时也想用路径点系统发现它有个隐藏依赖PathNode里存了一个实体指针用于拿位置信息。这导致物理模块不敢调用它。最终我们把“路径点”本身抽出来改成纯数据容器放在Core层只维护点列表具体和场景、实体的关联留在引擎层的AI模块。物理模块只关心点和点之间的连线不关心这些点是谁放进去的。问题就解了。这个例子说明下沉的关键不是“这个功能能不能拆”而是“这个功能的核心本质是不是游戏世界概念”。路径点本身是几何数据是Core路径点与游戏实体的关联是引擎层。3. 引擎基础库不用STL不是装是分配策略和确定性说了算3.1 为什么游戏引擎不直接用STL三个绕不开的理由这个章节可能会让很多人有异议因为不少小团队引擎直接用STL也跑得好好的。我先说明结论再说条件原型阶段直接用STL完全没问题但一旦引擎进入正式迭代STL的三个特性会成为瓶颈。第一个是分配策略不可控。STL容器默认使用std::allocator即直接调用operator new/delete。游戏引擎的性能大头常常不在某个算法本身而在内存分配行为的不可预期。一次std::vector扩容可能触发大块堆分配堆分配在长时间运行后产生碎片碎片会导致后续分配变慢最终表现为游戏每隔一段时间卡一下。你必须把分配行为握在自己手里而不是交给默认分配器。第二个是ABI稳定性和版本语义问题。C标准库在不同编译器版本、不同STL实现下容器布局和迭代器行为有差异。如果你要出SDK给第三方团队使用但SDK内部使用了不同版本STL编译的容器跨模块传递数据可能踩坏内存。解决方法是自己定义基础容器类型保证数据结构在DLL边界内外一致。第三个是调试和内省能力。STL容器在调试器里能看但看不了多少内容。引擎需要知道当前内存被谁占着、哪个容器在疯长、哪个分配器池子快空了、某块内存对应的分配栈是什么。这些信息STL给不了而自己的容器可以打上调试标签、记录分配调用栈、统计活跃对象数量。当然我不建议一上来就砍掉STL。正确做法是保留STL作为内部实现手段但对外暴露的是自研容器接口并彻底替换掉默认分配器。3.2 自研容器的正确姿势分配器穿透、对齐、语义约束如果你决定自研容器下面这些经验可以帮你少走弯路。第一分配器必须是容器构造参数而不是全局单例。template typename T, typename Allocator EngineAllocatorT class Array { public: explicit Array(const Allocator alloc Allocator()) : m_allocator(alloc), m_data(nullptr), m_size(0), m_capacity(0) {} // ... private: Allocator m_allocator; T* m_data; size_t m_size; size_t m_capacity; };这样每个容器都能选择落在哪个内存池里。比如物理系统的接触点数组放在帧内存池里资源缓存表放在堆池里UI布局数组放在帧临时栈里。把分配器作为构造参数传入可以保证同一份代码在不同场景下使用不同的内存策略。第二对齐必须显式处理。引擎里大量数据是SIMD类型比如Math库的Vector4可能要求16字节对齐。如果你用operator new分配内存默认对齐通常是最大对齐值但如果你自己写内存池按max_align_t对齐或按某个固定字节对齐就会出问题。分配器的alloc接口一定要接受对齐参数容器也要默认请求alignof(T)的对齐。第三不要想着把STL的所有语义都实现一遍。只实现你用得到的定长数组、动态数组、哈希表、字符串。别去实现链表——绝大多数情况下数组就够了链表的缓存不友好且调试困难游戏里处处追求连续内存和链表天生不对付。第四容器要有测试代码和调试可视化。这段经验来自一次惨痛教训我们的自定义哈希表在特定负载因子下会把元素放到错误的桶里品牌和版本上线后偶发黑屏查了三天才定位到是哈希表的rehash逻辑有一处边界错误。从那以后我要求所有基础容器必须有单元测试并且维护一个简单的内存统计工具能列出所有Live容器的类型、大小和分配栈。3.3 数组访问之外字符串的不变量字符串处理也很容易翻车。引擎里字符串的使用极其频繁——资源路径、材质参数名、动画状态名、事件类型名。如果每次比较都做strcmp性能会非常难看。常见做法是使用字符串池String Intern相同内容的字符串只存一份比较字符串先比较哈希值哈希值相同再比较指针。碰撞概率可以通过使用64位哈希来降到很低工程上已经足够。这里有个细节哈希字符串池返回的是稳定ID不是指针。因为字符串池可能扩容对象地址会变但ID是稳定的。把稳定ID传给GPU渲染、指令缓冲或网络同步都不会有悬垂问题。4. 对象模型怎么搭从类继承到实体组件的数据导向之争4.1 为什么纯面向对象让游戏代码越写越僵十年前很多引擎的实体系统是典型的面向对象设计一个Actor基类下面派生出Player、Enemy、Projectile、SpawnVolume每个类内部再组合一堆组件。一开始挺好后来出问题的是扩展性。你做一个角色需要可移动、可受击、可播放动画、可加Buff。如果每次新需求都要往基类加虚函数或者从某个子类再派生一层继承链会越拉越长。到最后一个Player类的虚函数表里可能有上百个条目其中有三分之一根本没用上。更深一层的问题是数据访问模式变差。你希望遍历所有玩家的可移动组件、更新它们的位置但每个玩家对象分散在内存各处你只能一个一个跳着访问。现代CPU的缓存行只有64字节你跳到第100个对象时可能已经换了几十次缓存行。于是很多引擎开始转向实体组件系统也就是ECS。4.2 实体是ID组件是纯数据系统是逻辑ECS的架构本质ECS的核心很简单实体Entity一个整数ID不包含任何数据。组件Component纯数据容器。比如Position { x, y, z }、Velocity { vx, vy, vz }。系统System处理组件数据的逻辑。比如“移动系统”遍历所有同时拥有Position和Velocity的实体修改它们的位置。逻辑和数据的分离是ECS和传统面向对象的最大区别。传统世界里数据和逻辑绑定在同一个类里ECS世界里数据在组件里逻辑在系统里实体只是把它们关联起来的一个ID。这套模式带来的好处非常明显遍历性能好组件按类型连续存储遍历时会按顺序读取内存完美命中缓存。易扩展想给角色加“浮空”能力不用改角色类只需新增一个Floating组件再写一个浮空系统。易序列化组件是纯数据天然适合存储、网络同步、热重载。我们在实际项目中用过一个混合方案场景图仍然保留“父子层级关系”这玩意儿用纯ECS实现很别扭但具体到每个实体的游戏行为全部走组件系统。场景图解决“在哪里、互相依附”的结构问题组件系统解决“行为如何组织”的逻辑问题。两者共存的边界是场景图节点可以挂载一个实体ID实体ID对应的一组组件负责游戏逻辑。4.3 组件存储的布局选择AoS与SoA缓存的胜负手同样是存1000个位置组件布局方式不同遍历性能差距巨大。AoSArray of Structures是这样的Entity[0]: pos.x pos.y pos.z vel.x vel.y vel.z Entity[1]: pos.x pos.y pos.z vel.x vel.y vel.zSoAStructure of Arrays是这样的pos.x[0] pos.x[1] pos.x[2] ... pos.y[0] pos.y[1] pos.y[2] ... pos.z[0] pos.z[1] pos.z[2] ... vel.x[0] vel.x[1] vel.x[2] ...如果你的移动系统只读Position和Velocity两个组件用AoS布局时每遍历一个实体要读6个浮点数如果还有一个不需要的组件夹在中间读缓存行时就会浪费带宽。用SoA布局系统可以只拉取自己需要的数组带宽利用率显著提高。在自研组件系统时我建议默认采用SoA存储组件数据同时保留按实体ID索引到组件数据的映射表。映射表本身可以用哈希表或稀疏数组实现。实际写的时候可以先实现AoS版本跑通逻辑再做一次SoA优化。架构上预留好“把组件数据单独抽出”的接口将来优化时就不需要大改业务代码。需要提醒的是ECS不是万能锤。复杂的动画状态机、AI行为树这类“本来就需要大量内部状态且相互关联”的逻辑硬拆成组件和系统反而别扭。一个引擎里完全可以不依赖于点对点既要组件系统也得保留行为树模块。架构的价值在于给不同问题提供不同的解决工具而不是逼迫所有问题都往一个模式上套。5. 内存管理的架构级选择池化、帧内存与缓存友好的协奏5.1 帧内存与双缓冲每帧分配释放不该成为重灾区游戏逻辑里大量临时数据是每帧产生、每帧消亡的。比如每帧的可见集合列表、每帧的UI布局数据、每帧的物理接触点。如果这些都在堆上频繁分配释放性能会非常难看碎片也会越来越多。正确做法是帧内存Frame Allocatorclass FrameAllocator { public: FrameAllocator(size_t size) : m_buffer(operator new(size)), m_offset(0), m_capacity(size) {} void* Allocate(size_t size, size_t alignment alignof(max_align_t)) { size_t alignedOffset AlignUp(m_offset, alignment); if (alignedOffset size m_capacity) return nullptr; // 或者按需扩容 void* ptr static_castchar*(m_buffer) alignedOffset; m_offset alignedOffset size; return ptr; } void Reset() { m_offset 0; } private: void* m_buffer; size_t m_offset; size_t m_capacity; };每帧开始时调用Reset()本帧内所有临时对象都在这个缓冲区里分配帧结束时整块内存一次性回收。这比无数个小new/delete快得多。在渲染线程和逻辑线程并行的架构里帧内存必须使用双缓冲逻辑线程写上一帧的数据渲染线程读当前帧的数据两帧之间的内存不能复用。这个“双帧内存”策略能避免经典的数据竞争问题。5.2 内存池与对象生命周期你知道对象什么时候死架构才算清晰帧内存解决的是短生命周期对象但游戏里还有一些长生命周期对象任务对象、角色实体、材质实例。它们会被反复创建和销毁如果在堆上频繁分配碎片和缓存不友好会越来越严重。内存池Memory Pool是常见解法templatetypename T class ObjectPool { public: T* Acquire() { if (m_freeListHead) { T* obj m_freeListHead; m_freeListHead obj-nextFree; return obj; } if (m_blocks.empty() || m_blocks.back().used m_blockSize) { m_blocks.emplace_back(new Block(m_blockSize)); } Block block m_blocks.back(); return block.objects[block.used]; } void Release(T* obj) { obj-~T(); obj-nextFree m_freeListHead; m_freeListHead obj; } private: T* m_freeListHead nullptr; std::vectorBlock m_blocks; };对象池的好处是同一类对象的存储是连续的遍历时缓存友好分配和释放是O(1)且不会产生堆碎片。但对象池有个使用前提池里的对象一旦还回池中所有引用它的地方都不能再碰它。这就要谈到一个架构设计点对象引用。裸指针在引擎里是风险源。一个对象被删了可能还有其他模块拿着它的指针。更稳的做法是用句柄Handle句柄里包含索引和版本号客户端通过句柄池间接访问对象。struct Handle { uint32_t index; uint32_t version; }; class HandlePool { struct Slot { T* object; uint32_t version; }; std::vectorSlot m_slots; };访问句柄时先检查version是否匹配不匹配说明对象已经释放直接返回空。这样杜绝了一大类use-after-free问题。引擎里所有跨模块的实体引用、资源引用我都建议走句柄模式而不是裸指针。5.3 缓存友好性为什么连续内存是性能的隐形地基内存访问延迟差异很大。一个简单的参考量级L1缓存命中约1nsL2缓存命中约4nsL3缓存命中约12ns主存访问约80-120ns存储磁盘/SSD毫秒级这个差异意味着一个在工作集内连续遍历1000个对象的系统和另一个在内存里随机跳转遍历1000个对象的系统运行时间可能相差5到10倍。这就是为什么组件设计尽量用SoA、对象用池、每帧临时数据用线性分配器。基础架构层面把内存访问模式设计好比在局部做各种“优化技巧”有效得多。6. 生命周期设计启动、主循环与卸载的三次心跳6.1 启动顺序为什么初始化顺序错了就像多米诺骨牌引擎启动时模块有依赖关系初始化必须按依赖顺序进行。我把启动阶段分成三步第一步平台层与核心层初始化。日志系统、内存系统、平台抽象、文件系统必须最先起。日志最早因为后面的所有错误都要靠日志记录内存系统其次因为后面所有模块的分配都依赖它。第二步引擎层子系统初始化。渲染设备、物理引擎、音频设备、资源管理器……这些模块互不依赖可以先并行或按顺序初始化但它们都依赖第一阶段的某些组件。第三步游戏模块加载。读取游戏配置、初始化场景、加载初始资源建立主循环。最常见的错误就是模块在构造函数里做重活。构造函数里写了资源加载、窗口创建、网络连接结果初始化顺序一变就崩或者模块A构造时B还没初始化调用B的接口时静默失败。所以我在项目里立了一条规矩构造函数只做字段初始化一切涉及外部依赖的操作放到显式的Init()方法里。这样做的好处是你可以显式控制初始化顺序也能在测试时单独初始化一个模块而不触发全部依赖。6.2 主循环Tick固定步长、可变步长与插值的三角关系主循环是引擎的“心跳”。每帧要处理输入、更新逻辑、模拟物理、渲染然后把结果提交到屏幕。最简单的循环是变步长模式while (running) { float deltaTime timer.GetDeltaSeconds(); Update(deltaTime); Render(); }变步长的问题在于物理和网络。如果某帧卡了一下deltaTime突然变大物理的稳定性会崩——大步长下的刚体碰撞可能穿透。所以物理模拟一般用固定步长。常用做法是累积器加固定步长const float fixedStep 1.0f / 60.0f; float acc 0.0f; while (running) { float dt timer.GetDeltaSeconds(); acc dt; while (acc fixedStep) { FixedUpdate(fixedStep); acc - fixedStep; } float alpha acc / fixedStep; Interpolate(alpha); Render(); }固定步长保证了模拟的确定性alpha用于做渲染插值避免画面卡顿。这个模式是很多商业引擎的默认选择值得抄。有一点容易忽略如果某帧的dt特别大累积器会在一个循环里连续多次执行FixedUpdate这会造成“死亡螺旋”——物理模拟越慢FixedUpdate补丁越多帧越慢。所以一定要设一个最大帧时间上限超过上限的dt直接截断。6.3 卸载顺序很多人直到崩溃才想起的逆初始化卸载是启动的逆过程很多引擎只重视启动卸载时直接进程退出结果各种资源泄漏、崩溃信息全无。卸载必须逆着启动顺序来。游戏模块先卸载再卸载引擎层最后卸载核心层。原因很简单一个依赖某模块的系统必须先停止使用它才能安全释放它。这里有个隐藏的坑反初始化阶段多个模块可能互相引用。比如物理模块在销毁刚体时需要访问渲染模块的调试绘制而渲染模块已经先关掉了。解决的思路是把卸载分成两个阶段第一阶段通知所有模块“马上要卸载了”各模块清理自己的跨模块引用第二阶段才真正释放资源。用一句话总结先切断引用再释放对象。如果支持插件或热更新还要处理模块卸载后代码残留问题。最常见的崩溃就是插件被卸载后某个全局回调还指向插件内的函数地址。正确的做法是注册回调时同时登记“哪个模块注册的”卸载模块时自动把这些回调摘掉。这个机制可以放在事件模块里实现。7. 架构走偏的早期信号与我的避坑经验7.1 循环依赖和“全局单例狂欢”是怎么发生的循环依赖几乎是每个引擎都会遇到的病。最典型的症状是模块A需要B的工具函数B又需要A的数据。比如物理模块想要渲染模块的调试绘制接口渲染模块又想从物理模块拿碰撞结果做表现。很多人选择直接互相调用两个模块紧耦合编译依赖也成了循环。解决循环依赖只有两条路一是把公共部分下沉到更低层比如把调试绘制做成一个独立组件让渲染和物理都依赖它二是引入事件或接口层切断直接依赖物理模块发布“碰撞事件”渲染模块注册监听两个模块完全不互相引用。全局单例也是类似的病。引擎里有一堆xxxManager::GetInstance()看起来方便但隐藏了很多问题初始化顺序不可控、测试时很难替换、模块之间天然形成蜘蛛网。更关键的是全局单例会掩盖“这个模块到底被谁依赖”的信息。当你一眼扫过代码看到某个系统被20个模块直接调用你对这个系统的改动会变得束手束脚。我现在的做法是限制全局单例的数量只允许那几个真正全引擎唯一的系统保留单例日志、内存、平台其余系统一律通过启动时注入方式获取依赖。维护者看代码时能一眼看出依赖关系比“靠记忆找全局单例”安全一百倍。7.2 从“过度抽象”到“打地鼠”心态失衡的两种表现写引擎的人容易走另一个极端过度抽象。一个读取文件的操作前面套一层接口、后面套一层缓存、中间再来一层装饰器。出现问题时你沿着调用链跳了五个文件其实底层就一句话“读一个文件”。这种抽象不仅拖垮性能更拖垮调试效率。我见过一个项目代码注释里写“这是分层架构为了实现可扩展性”但实际每个模块都被抽了三个中间层开发一个功能要同时改四个地方的接口签名。这样的架构不是“架构”是“障碍”。反过来也见过从过度抽象反弹到“什么都要直接访问”的项目。大家为了不被抽象磨死干脆谁都能直接操纵内部状态结果回到本章开头说的“不敢动”。这两种情况本质上是同一种病架构和实际业务需求脱节了。架构的价值是服务于改动频率、性能和可维护性不是为了“架构感”而存在。7.3 判断架构好坏的唯一标准一次需求改动波及几个模块怎么客观评价一个引擎的基础架构标准很简单完成一个需求你新增了多少代码改了多少旧代码如果你的引擎基础架构是健康的那么加一个新功能比如新增一种角色Buff类型应该是大量“新增代码”、极少“修改旧代码”。新增模块、新增组件、新增系统接入而不是跑到旧系统里打补丁。反过来如果加一个很普通的需求你需要改五个模块的接口、给三个类加参数、在业务逻辑里塞一堆分支判断那么架构多半已经腐化了。这时候应该停下来先理清依赖关系和模块边界而不是继续往上堆。还有一个辅助指标编译时间变化趋势。如果每次小改动都会导致全引擎长时间重编说明模块依赖已经不正常了。正常引擎的理想状态是改逻辑模块不会触发渲染模块重编改核心层才会引发大范围重编。7.4 几个可以马上动手的实验方法想验证自己的引擎基础架构到底处于什么状态我从实际经验出发推荐几个实验。第一模块隔离测试。把某个引擎层模块头文件从所有源文件里摘掉然后把源码里的引用注释掉看编译失败集中在哪些地方。失败越分散说明依赖控制越差。第二模块替换测试。比如把物理模块从PhysX切到Bullet如果替换时只需改少量代码说明物理抽象层是健康的如果替换时发现业务代码和物理模块到处耦合说明抽象层形同虚设。第三“新人接手实验”。让一个没有参与架构设计的人去读某个模块的完整代码让他画出依赖关系说清楚这个模块依赖哪些其他模块、被谁依赖。如果画不出来模块边界一定有问题。这些实验成本都很低但往往能炸出很多平时看不见的问题。8. 关于基础架构我最后想说的几句实在话写引擎基础架构这些年我的最大体会是架构设计拼的不是“一开始多完美”而是“演进过程中能不能持续保持边界清晰”。每次新增功能时强制自己问一句——这个代码该放哪一层它的依赖会破坏单向原则吗它和谁耦合了这个问题问上一百次架构就烂不到哪里去。如果你现在正在做一个新引擎哪怕是个小玩具也建议从第一天就把分层和依赖方向立好因为后期重构的成本一定比你想象的贵十倍。如果你在维护一个老引擎已经被各种循环依赖搞得很痛苦也建议从最小的模块开始拆不必一步到位。先把一个模块的跨界依赖断掉再断下一个。每断一个编译速度、改动的安全感都会回来一点。引擎基础架构这件事说到底是选择选择让代码在边界清晰的方向上生长还是让它烂在一起。我见过太多项目是用“先跑起来再说”的心态写完的最后死在“跑不动了”的某一天。希望这篇内容能帮你少踩几个坑在基础架构这一步就打好地基。