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: HashMap<Guid, 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(SharedPtr<T> service); template <typename T> SharedPtr<T> Get(); private: HashMap<TypeId, SharedPtr<IService>> services_; }; // 启动时 engine.Register<PhysicsSystem>(std::make_shared<PhysicsSystem>()); engine.Register<RenderSystem>(std::make_shared<RenderSystem>()); // 任意模块查询 auto physics = engine.Get<PhysicsSystem>();严格限制的一点是,模块之间尽量不直接拿对方的大对象做深层调用,只通过服务接口的公开方法协作。比如任务系统需要查询物理碰撞结果,它拿到PhysicsSystem后只调用QueryOverlap,绝不允许它去遍历物理内部的碰撞体链表。接口粒度和边界,是服务注册表能不能起正向作用的关键。
3.2 事件系统,把帧循环里的通知解耦
引擎里大量的模块协作其实是“异步通知”:资源加载完了要通知场景刷新阴影,AI状态变化要通知动画改状态机,输入事件要通知UI响应。如果这些调用都做成直接引用依赖,模块之间的耦合会极其密集。事件系统就是在这种场景下出现的协调者。
我常采用一个简化版的事件系统模型,事件在帧内分为两个阶段:产生阶段,任意模块可以投递事件到队列;分发阶段,每帧固定时机统一处理。这样做的好处是,事件产生的顺序不影响特定模块的处理顺序,模块A不管在物理还是输入阶段产生事件,都在同帧统一处理,逻辑确定性大大增强。
struct EventContext { ... }; class EventSystem { public: using Handler = std::function<void(const EventContext&)>; void Publish(std::string_view topic, const EventContext& e); void Subscribe(std::string_view topic, Handler handler); void DispatchFrameEvents(); private: std::unordered_map<std::string, std::vector<Handler>> subscribers_; std::vector<EventContext> 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的数据布局会影响渲染、物理、动画的遍历方式,先定好容器和组件存储方式,后面系统才好围绕数据写逻辑。
第四步,逐步挂上渲染、物理、音频等具体功能模块。每挂一个模块时,都要检查它是否严格遵守分层和依赖方向。这个阶段最不要贪多,一个功能模块做到能跑通、能卸载、能替换,再动下一个。
这么一套走下来,引擎基础架构的骨架基本就立住了。后续的编辑器、工具链、资源管线都在这套骨架上生长,不会再像早期那样三天两头想推翻重新设计。架构这东西,最怕的不是慢,是边写边改边破功。定好骨架,守好边界,后面的事反而都是水到渠成的细节活。