
1. 引擎基础架构的分层设计与依赖方向2019年我接手团队自研引擎的时候代码库已经跑过两个项目。算上美术工具链和编辑器预览这套东西大概有几十万行但真正干起活来效率低得吓人。改一个渲染队列的需求要动到关卡数据格式想换一个物理后端牵扯出的文件能把git提交列表刷三屏。那段时间我反复问自己一个问题为什么Unity、Unreal的架构能扛住成千上万人的项目我们的引擎一改就崩后来答案变得很清楚——基础架构的边界画错了模块和模块之间像一锅粥什么都在互相调用什么都不独立。这也是我想写这一系列文章的起点从一开始就把引擎基础架构这件事讲透第一篇就先说清楚骨架怎么搭边界怎么画依赖方向怎么定。1.1 为什么要分层——一个“装修压垮地基”的真实故事我接手的引擎最开始是几个人为项目临时拼的。渲染代码里直接调用Windows API物理碰撞的结果写在全局变量里UI系统为了省事干脆在渲染循环里雷打不动地每帧遍历所有控件。小项目能跑但引擎一旦要多项目复用问题就全暴露了一个需求跨三个模块一个新同事想上线一个月就得看几万行代码美术想加一个自定义资源类型程序要改四个地方。这本质上是“地基”没分层所有功能像装修一样直接糊在承重墙上墙一动整栋楼都晃。所以做引擎基础架构第一原则就是把系统分成清晰的层并规定层之间的依赖方向。常见做法是从上到下分成工具层、功能层、核心层、平台抽象层。工具层对应编辑器、静态资源处理这些开发期工具功能层放着渲染、物理、音频、动画这些面向业务的功能模块核心层提供数学库、内存分配器、容器、资源管理、事件系统这些底子平台抽象层屏蔽操作系统和硬件差异。各层之间只允许上层依赖下层同层之间尽量通过接口沟通不能出现下层回头依赖上层的情况。这一条规矩看起来简单实战里特别难守住。最容易破功的是平台抽象层很多引擎写着写着就把“某个特定平台的处理”直接写进核心层。比如音频初始化代码调用了平台的独占接口而平台抽象层自己都还没把音频接口包一层。这类事情在项目里几乎每周都发生。所以我在团队里立了一条规矩所有平台相关的调用必须经过平台抽象层的包装谁都不许直接碰系统API。代码评审里一旦发现直接打回。分层带来的直接好处是每个模块的“领域”变干净了。渲染模块只需要知道资源模块给它的数据是什么格式物理模块只需要关心场景模块给它的碰撞体列表业务逻辑调功能模块的时候不用关心底层是D3D还是Metal。这样引擎才能在不同的项目、不同的平台上被重复使用不至于像绑死在单项目上的巨型脚本。1.2 一个可落地的模块划分以及为什么这么分分层是框架层面的思路落到具体模块划分才影响每天的开发。我一般把一个引擎的基础模块拆成下面这样模块核心职责依赖方向数学库向量、矩阵、四元数、几何算法无最底层内存系统分配器、对象池、字节流仅依赖数学库可选容器/算法引擎专用容器、哈希、排序无资源系统资源GUID管理、加载任务、缓存依赖文件系统和内存事件系统同步/异步消息分发依赖容器日志系统分级输出、远程日志依赖平台抽象层平台抽象层窗口、输入、线程、时间、文件仅依赖系统API渲染模块绘制指令、GPU资源管理依赖资源、数学、平台物理模块碰撞检测、刚体模拟依赖数学、资源音频模块播放控制、资源流式加载依赖资源、平台场景/实体系统场景图、组件存储依赖核心层所有模块工具层编辑器、数据导入、调试面板依赖功能层这个表看着规整实际设计中我最想强调的是依赖方向。功能模块可以调用核心层但不能反过来。比如物理模块要“画调试线”用来显示碰撞体它不该直接调渲染模块而是定义DebugDraw接口由渲染模块自己实现并注入。这样物理模块不依赖具体渲染后端换渲染器不用动物理系统。模块划分好之后我建议把目录结构也按照依赖方向建出来。一个比较顺手的结构是这样的engine/ platform/ win32/ linux/ android/ ios/ math/ vector.h matrix4.h quaternion.h core/ memory/ containers/ resource/ event/ log/ job/ systems/ render/ physics/ audio/ input/ scene/ entity.h component.h editor/ viewport/ inspector/目录结构和模块划分保持一致最大的价值是让新人在写代码之前先能“看见”架构。每次include头文件的时候只要路径跨了层你就能直观感受到是不是在破坏依赖规则。这是我实践中最有效的一条基础架构纪律比在文档里写一万字规范都有用。2. 基础架构里的资源系统与内存模型分层解决了模块之间的“空间关系”但引擎运行期间真正推动系统转起来的是数据和数据的管理方式。我把资源和内存这两块称为引擎基础架构的“蓄电池”因为它们直接影响所有系统的数据流动方式。很多引擎后期卡到没法用绝大多数不是因为算法不够好而是资源加载没有统一入口内存管理混乱到每个模块各搞一套。2.1 资源系统从路径字符串到GUID这步完全不同很多初学引擎的人不理解为什么资源非要搞GUID直接拿路径字符串加载不好吗路径字符串的查询效率低移动端上一条路径字符串协议解析很沉而且字符串散落在代码里重命名资源就要全局改引用这几乎是大型项目维护的地狱。GUID的好处是稳定、短、可比较资源改名只是改元数据引用不用动。资源系统的基础设计一般是这样的每个资源在导入阶段分配一个全局唯一的GUID整个引擎里拿GUID做索引所有跨模块引用都存GUID而不是路径。加载的时候资源请求交给ResourceManager由它统一处理缓存、加载和卸载。我常用的接口结构很轻class ResourceManager { public: ResourcePtr Load(Guid guid); void Unload(Guid guid); void AddRef(Guid guid); void Release(Guid guid); private: HashMapGuid, ResourceEntry registry_; JobQueue* async_load_queue_; };加载走异步队列请求方拿了ResourcePtr这个指针自带引用计数底层资源加载完成之前指针是空壳状态完成后自动换到真实数据。这套设计的核心是业务代码永远不用关心资源“什么时候加载完”只需要定义一个回调或者轮询状态即可。资源系统还有一个容易忽略的点引用计数到底用什么粒度。我见过很多引擎在资源粒度上做引用计数结果美术场景里一个模型三万个资源实例来回引用计数拆得稀碎。比较实用的做法是结合“资源包”做管理像关卡就是一个大资源包内部子资源跟着包走包加载时引用数1包卸载时整个子树状态统一回收。这样引擎基础架构能省掉大量细碎计数的开销内存也不容易出现零散的孤儿资源。2.2 内存布局为什么引擎不爱“随手new一个对象”几年前有个同事写射击游戏AI在打击回调里new了一个弹痕对象结果连着几个版本游戏都会偶发卡顿尤其是爆炸密集的时候掉帧特别严重。后来查出来就是频繁的堆分配和释放造成的。游戏固定帧循环里堆分配不是不能碰但高频路径上绝不能出现不可控的分配。这就是基础架构层面的内存模型设计要解决的把内存的分配方式从“随手new”变成“预先划分生命周期”。基于这个思路引擎里常见的分配策略有三种帧内存、对象池、线性分配器。帧内存每帧开头标记一个水位线帧结束整块回退所有本帧临时对象都在里面分配完全不需要释放。对象池适合子弹、伤害飘字这类反复创建销毁的小对象池子里取一个回收时放回。线性分配器适合大块连续数据比如场景静态网格一次性分配后长期存活。这些分配方式组合起来基本能覆盖游戏运行时的绝大多数内存需求。内存布局还会影响数据访问的局部性。组件系统如果把所有Transform组件放在一个连续数组里渲染模块每帧遍历时缓存友好要是每个实体像一个对象一样把所有组件散在堆里遍历的时候缓存miss率高到爆炸。同样的逻辑渲染批次能合并的最大瓶颈也常常是顶点数据的内存布局。基础架构在设计初期就应该定下“数据以数组为中心而不是以对象为中心”的准则。2.3 场景组织从节点树到ECS取舍的是可控性场景组织是基础架构里非常承上启下的一环。老的引擎喜欢节点树每个场景物体是一个节点节点上挂组件更新时按层级递归遍历。这个模型直观做编辑器特别舒服但运行时有三个问题组件之间的调用通常要通过虚函数、遍历节点树时缓存不友好、频繁的节点增删会让内存碎片比较严重。实体组件系统用扁平数组存同类组件实体只是GUID加一组组件索引。系统System按组件类型批量处理逻辑比如移动系统一次性遍历所有Transform组件和刚体组件。数据访问连续、逻辑集中性能天然占优。代价是代码写法比较“数据导向”业务逻辑经常被拆成若干个系统迭代初次上手的人会不太适应。我自己在引擎基础架构上采用的是“节点树做编辑器编辑模型、ECS做运行时数据模型”的混合方案。编辑器里美术看到的是层级节点导出阶段把节点展平成组件数组运行时完全走ECS路径。这套方案兼顾编辑器和运行时两端但导出的数据模型要做得比较规整序列化时要处理每类组件的平台对齐和版本兼容。3. 模块间的统筹接口、注册表、事件分层解决了模块之间的“空间关系”但引擎运行期间真正推动系统转起来的是数据和数据的管理方式。我把资源和内存这两块称为引擎基础架构的“蓄电池”因为它们直接影响所有系统的数据流动方式。很多引擎后期卡到没法用绝大多数不是因为算法不够好而是资源加载没有统一入口内存管理混乱到每个模块各搞一套。3.1 服务注册表替代到处飞的全局变量游戏引擎里各模块之间怎么拿到对应的服务是个看起来小、实则决定架构手感的问题。我见过一些团队直接定义全局变量比如gRenderSystem、gPhysicsSystem模块之间全部直接extern。项目小的时候还好稍微一大全局变量的初始化顺序根本不可控而且在编辑器环境里同时存在数据中心、预览场景、工具面板全局单例就全乱套了。我在引擎里更喜欢服务注册表的方式启动时每个系统把自己注册进一个中心表使用时通过类型token查询。这个模式算是服务定位器但比直接全局单一变量要安全得多因为生命周期由注册表统一管理可以按依赖顺序逐层启动也可以运行时临时替换某个服务比如切到测试桩。一块极简的实现大概是class EngineServices { public: template typename T void Register(SharedPtrT service); template typename T SharedPtrT Get(); private: HashMapTypeId, SharedPtrIService services_; }; // 启动时 engine.RegisterPhysicsSystem(std::make_sharedPhysicsSystem()); engine.RegisterRenderSystem(std::make_sharedRenderSystem()); // 任意模块查询 auto physics engine.GetPhysicsSystem();严格限制的一点是模块之间尽量不直接拿对方的大对象做深层调用只通过服务接口的公开方法协作。比如任务系统需要查询物理碰撞结果它拿到PhysicsSystem后只调用QueryOverlap绝不允许它去遍历物理内部的碰撞体链表。接口粒度和边界是服务注册表能不能起正向作用的关键。3.2 事件系统把帧循环里的通知解耦引擎里大量的模块协作其实是“异步通知”资源加载完了要通知场景刷新阴影AI状态变化要通知动画改状态机输入事件要通知UI响应。如果这些调用都做成直接引用依赖模块之间的耦合会极其密集。事件系统就是在这种场景下出现的协调者。我常采用一个简化版的事件系统模型事件在帧内分为两个阶段产生阶段任意模块可以投递事件到队列分发阶段每帧固定时机统一处理。这样做的好处是事件产生的顺序不影响特定模块的处理顺序模块A不管在物理还是输入阶段产生事件都在同帧统一处理逻辑确定性大大增强。struct EventContext { ... }; class EventSystem { public: using Handler std::functionvoid(const EventContext); void Publish(std::string_view topic, const EventContext e); void Subscribe(std::string_view topic, Handler handler); void DispatchFrameEvents(); private: std::unordered_mapstd::string, std::vectorHandler subscribers_; std::vectorEventContext pending_queue_; };实际项目里事件系统的数据设计比分发机制更关键。方法就是事件里尽量传数据结构而不是引用业务对象避免接受方去抓取发送方内部的成员。像“资源加载完成”这种事件就传GUID和加载状态接受方再通过资源管理器取数据而不是直接拿到一个资源对象就乱调。3.3 构建系统里的依赖纪律基础架构不只是运行时的目录和接口构建系统的依赖管理同样影响整个开发生态。我踩过一个很痛的坑头文件无限传递依赖改一个底层数学库头文件全引擎四十几个模块全部重新编译一次构建时间从三分钟变成十五分钟。后来我们强制要求每个模块的include必须显式声明不允许通过公共头文件隐式传递依赖。CMake在这种场景下给了我很大帮助。每个模块的CMakeLists都严格写清target_link_libraries用它来约束模块依赖而不是只靠头文件include隐式耦合。再加一个简单脚本定期扫描include依赖关系和CMake声明的依赖对比不一致就报警。这个脚本花了不到半天时间写但之后两年它至少拦住了四十多次不正当的跨层依赖。基础架构的约束只有可自动化验证才算真正落地。4. 常见问题与排查技巧实录做引擎基础架构架构设计本身是大方向但真正考验功底的是日常问题的排查。这一章我把自己这些年遇到的典型问题整理成速查表再挑几个我印象极深的坑展开说说希望能帮大家少走几年弯路。4.1 启动顺序的“初始化地雷”有一次引擎在Windows上跑得好好的同事抱来一台旧笔记本一跑就崩。排查半天发现崩溃点在物理模块物理模块初始化时要读取一份配置参数而这份参数从资源系统加载资源系统又依赖文件系统文件系统是平台层的东西。结果那台旧机器上平台层因某驱动加载慢文件系统还没就绪资源系统尝试打开配置文件直接失败返回了一个空资源物理模块拿到空数据后引发解引用崩溃。这类问题很像电路里的时序问题不同的机器上启动时间不同模块初始化顺序一旦依赖隐式时序十台机器有九台能跑剩下那台就是随机崩。我的排查思路分三步第一步每个模块启动时打印明确的begin/end日志比对日志时序第二步把模块初始化改成显式的依赖排序启动函数里按依赖表逐项初始化第三步每个模块初始化后做自检比如物理模块初始化完检查配置资源是否有效无效就直接报错并回滚而不是继续往下跑。从那次之后我把“显式初始化时序自检”定成了引擎的基础架构规范凡是新模块必须照做。4.2 循环依赖和跨层调用怎么清理另一个高频问题出现在调试功能上。物理模块想画碰撞体调试线它直接调用了渲染模块的DrawLine接口渲染模块又需要从物理模块拿碰撞体数据结果两个模块互相include头文件构建系统直接死循环。这种问题的本质是功能层之间的“同层横跳”没有处理好。我的惯例是这种跨层调用一律通过接口反转来解决。物理模块不直接依赖渲染而是定义DebugDrawInterface渲染模块实现这个接口并注册到物理模块里。物理模块调用时只走这个接口物理对渲染的具体类型一无所知。同理渲染需要物理数据时也通过一个只读的PhysicsQuery接口获取。这样模块双方都面向接口编程谁也不会在头文件上掐死谁。如果项目已经积累了大量跨层调用清理起来要渐进式。我的方法是一周一个模块的节奏先把某个功能模块对其它模块的直接调用全部列出来逐个判断归属是数据依赖就走资源系统是通知就走事件系统是服务访问就走服务注册表。混过两周之后模块之间的include循环基本就清除得差不多了。4.3 常见问题速查表我把自己和同事在引擎基础架构上遇到过的高频问题汇总成表方便直接对着排查现象可能原因排查方向启动崩溃且报错点随机模块初始化顺序问题打印启动时序日志显式初始化依赖编译循环依赖同层模块头文件互相include用接口反转切断跨层服务运行时偶发卡顿掉帧集中在特效密集场景高频路径堆分配把临时对象挪到帧内存资源重命名牵一发动全身代码里散落路径字符串统一走GUID引用切换平台后渲染崩溃平台相关API绕过抽象层扫描直接系统API调用收进平台抽象层编辑器打开关卡内存暴涨子资源和关卡包引用计数混乱按资源包统一管理引用计数新功能改动一个头文件全引擎重编头文件隐式依赖链过长引入include扫描和依赖自动校验这张表前前后后帮我省了不少事尤其在项目人员流动比较大的时期新同事遇到问题翻一下表不至于从头猜。这些内容我在团队内部文档里叫它“基础架构的应急手册”现在也分享出来给正在搭引擎或者维护自研引擎的朋友做个参考。5. 从零开始搭基础架构的路径建议如果让我回到过去重新搭一次引擎基础架构我会按下面的顺序走每一步都刻意控制复杂度不在前期过度设计。第一步先把平台抽象层和数学库立起来。这两个是整个引擎的“承重墙”平台抽象层决定了能跑多少平台数学库的布局和命名会影响所有模块的代码风格。这个阶段不要急着写渲染先用这个小骨架跑通空窗口和程序生命周期。第二步加资源系统和内存分配器。这是引擎的“血液系统”资源加载不统一后面所有功能模块都会拿自己的加载方式各搞一套。内存分配器可以先做帧内存和对象池别的等到性能分析需要再补。第三步搭出实体组件系统和场景管理。这步确定运行时数据的基本形态因为ECS的数据布局会影响渲染、物理、动画的遍历方式先定好容器和组件存储方式后面系统才好围绕数据写逻辑。第四步逐步挂上渲染、物理、音频等具体功能模块。每挂一个模块时都要检查它是否严格遵守分层和依赖方向。这个阶段最不要贪多一个功能模块做到能跑通、能卸载、能替换再动下一个。这么一套走下来引擎基础架构的骨架基本就立住了。后续的编辑器、工具链、资源管线都在这套骨架上生长不会再像早期那样三天两头想推翻重新设计。架构这东西最怕的不是慢是边写边改边破功。定好骨架守好边界后面的事反而都是水到渠成的细节活。