ARTICLE DETAIL

资讯详情

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

游戏引擎基础架构设计:从平台抽象到模块系统的核心原理与实操

游戏引擎基础架构设计:从平台抽象到模块系统的核心原理与实操 1. 引擎基础架构到底在解决什么问题很多人第一次翻开游戏引擎源码看到的是满屏的Object、Actor、Component、World然后就开始一行行读代码读了三天还在main函数里打转。我当年也是这么干的结果就是——每个类都看懂了但完全不知道它们为什么要这样组织。后来带我的老哥说了一句话点醒我引擎架构不是设计出来的是被需求逼出来的。你先把需求想清楚架构自然就浮出来了。那引擎基础架构到底要解决什么问题我把它拆成四件事这四件事几乎决定了后面所有模块的形态。第一件事是对象的生命周期管理。游戏里一帧可能创建几千个临时对象子弹、粒子、伤害数字下一帧就销毁。如果每个对象都走new/delete光是内存分配器的锁竞争就能把主线程拖垮。所以引擎必须有一套自己的对象创建、追踪、销毁机制这就是后面要讲的Object系统和GarbageCollect的由来。第二件事是模块之间的通信。渲染模块要知道场景里有哪些物体物理模块要知道哪些物体在动音频模块要知道声源在哪。如果让渲染直接#include物理的头文件那编译依赖会变成一张蜘蛛网改一行代码全项目重编。所以引擎必须做分层和接口隔离这是Module系统和Subsystem设计的根本动机。第三件事是跨平台抽象。同一份游戏逻辑代码要在 Windows、Linux、主机、移动端上跑。文件路径、线程、原子操作、字节序、对齐方式全都不一样。引擎不能在每个业务代码里写#ifdef _WIN32必须把这些差异收敛到一层薄薄的平台抽象层PAL里。第四件事是性能可预测性。游戏最怕的不是平均帧率低而是卡顿。一帧 16.6ms 的预算如果某一帧突然花了 50ms玩家立刻能感觉到。所以引擎的内存分配、任务调度、资源加载都必须做到时间可控这就是为什么引擎里到处都是对象池、内存池、无锁队列。把这四件事想明白你再看引擎源码就不会迷路了。下面我按从底往上的顺序把基础架构一层层拆开讲。1.1 为什么引擎不直接用标准库的容器和智能指针这是新手最容易踩的坑。你可能会想std::vector、std::shared_ptr这么好用为什么引擎还要自己造轮子原因有三个而且都是硬需求。第一内存布局不可控。std::vector的扩容策略是实现定义的你不知道它什么时候会重新分配也不知道新容量是多少。在游戏里一次意外的扩容可能意味着一次 2ms 的卡顿。引擎需要的是可预测的分配行为所以会自己实现TArray、TInlineAllocator这类容器把扩容策略、对齐方式、分配器全部握在自己手里。第二shared_ptr的原子引用计数太贵。std::shared_ptr的引用计数是原子的每次拷贝都要做一次原子加多线程下还有缓存行争用。游戏里对象引用极其频繁这个开销累积起来很可观。引擎通常用非原子的引用计数 明确的线程归属来替代只有在真正跨线程共享时才用原子操作。第三标准库的异常和 RTTI 在游戏里基本是负担。异常会阻止编译器做某些优化RTTI 会增加二进制体积。引擎一般会关掉这两个特性自己实现一套轻量的类型系统比如 UE 的UObject反射系统。注意这不是说标准库不能用。工具链、编辑器、构建脚本里用标准库完全没问题。但在每帧都要跑的核心路径上引擎必须自己控制内存和调度。1.2 基础架构的四个层次我把引擎基础架构从下往上分成四层这个分层不是官方标准但我觉得比很多教材讲得清楚。层次职责典型模块平台抽象层屏蔽操作系统差异文件IO、线程、原子操作、时间、动态库加载核心基础层提供通用数据结构与工具容器、字符串、内存分配器、数学库、序列化对象与反射层管理对象生命周期与类型信息Object系统、GC、反射、属性系统模块与子系统层组织功能模块与初始化顺序Module管理器、Subsystem、World、Tick调度这四层是严格单向依赖的上层可以调下层下层绝不能反向依赖上层。一旦你发现核心基础层里#include了对象层的头文件那架构就已经开始腐化了。我见过不少项目一开始图省事让内存分配器直接回调对象系统的日志接口结果就是内存模块没法单独测试也没法在工具里复用。这种债后面还起来非常痛苦。2. 平台抽象层把操作系统的脾气关进笼子平台抽象层Platform Abstraction Layer简称 PAL是引擎最底下的一层也是最容易被忽视的一层。很多教程直接从引擎主循环开始讲但主循环里用到的线程、时间、文件操作全都依赖 PAL。这一层做不好上层全是坑。2.1 PAL 到底要抽象哪些东西我列一个实际项目里 PAL 需要覆盖的清单你可以对照自己的项目看看漏了哪些。内存虚拟内存申请/释放、内存保护属性修改、内存对齐分配、大页支持线程与同步线程创建、线程本地存储、互斥量、信号量、条件变量、原子操作时间高精度计时器、系统时间、时区转换文件系统文件读写、目录遍历、路径规范化、文件监控动态库加载/卸载、符号查找网络Socket 封装虽然网络通常单独成模块但底层还是 PALCPU 信息核心数、缓存行大小、SIMD 指令集检测调试断言、调用栈捕获、崩溃处理这份清单里最容易出问题的是时间、线程和文件路径这三块。时间的问题在于不同平台的计时器精度和单调性不一样。有的平台clock()返回的是 CPU 时间不是墙钟时间有的平台高精度计时器在系统休眠后会跳变。引擎必须封装一个单调递增的高精度计时器并且明确它的分辨率。我一般会要求 PAL 提供GetCycles()和GetSeconds()两个接口前者用于性能分析后者用于游戏逻辑。线程的问题在于不同平台的线程优先级语义、栈大小默认值、线程本地存储的析构时机都不一样。特别是线程本地存储的析构有的平台在线程退出时自动清理有的需要手动注册回调。如果引擎依赖 TLS 存对象指针析构时机不对就会导致悬空指针。文件路径的问题更隐蔽。Windows 用反斜杠Linux 用正斜杠macOS 默认文件系统大小写不敏感但可以配置成敏感。引擎必须统一成一种内部表示通常是正斜杠 大小写敏感在边界处做转换。2.2 一个真实的踩坑原子操作的平台差异我印象最深的一次踩坑是原子操作的内存序问题。当时我们在做一个无锁的任务队列在 x86 上跑得好好的一上 ARM 设备就偶发崩溃。排查了两天才发现x86 的内存模型比较强很多乱序执行不会真的发生所以代码里漏写的内存屏障在 x86 上碰巧能跑。但 ARM 是弱内存模型编译器和 CPU 都会重排指令漏掉的屏障立刻暴露。修复方案是在 PAL 里明确定义几组原子操作原语并且强制要求所有跨线程共享数据必须使用这些原语禁止直接用平台原生 API。比如// PAL 提供的原子操作接口示意 namespace pal { // 顺序一致性版本最安全但最慢 int32 AtomicLoad_SeqCst(const int32* ptr); void AtomicStore_SeqCst(int32* ptr, int32 value); // 获取-释放语义用于生产者-消费者场景 int32 AtomicLoad_Acquire(const int32* ptr); void AtomicStore_Release(int32* ptr, int32 value); // 宽松语义仅用于计数器等不涉及同步的场景 int32 AtomicLoad_Relaxed(const int32* ptr); void AtomicStore_Relaxed(int32* ptr, int32 value); }关键不是接口本身而是团队约定写并发代码时先想清楚需要哪种内存序然后选对应的接口。默认用SeqCst性能敏感的地方再降级到Acquire/Release。这个约定帮我们避免了很多在 x86 上能跑换平台就崩的问题。提示如果你现在维护的引擎里并发代码直接调用了平台原生的原子 API建议做一次全面审查。这类问题在单一平台上极难复现但一旦换平台就是灾难。2.3 PAL 的接口设计原则PAL 的接口设计有几条我踩过坑之后总结的原则。原则一接口要窄不要暴露平台细节。比如文件接口不要暴露HANDLE或fd而是返回一个不透明的FileHandle。这样上层代码不会意外依赖平台特性。原则二错误处理要统一。有的平台用返回码有的用errno有的抛异常。PAL 必须统一成一种方式。我倾向于返回错误码 可选的错误信息字符串因为异常在游戏核心路径上不可接受。原则三不要为了抽象而抽象。有些东西平台差异太大强行抽象反而增加复杂度。比如图形 APIVulkan、D3D12、Metal 的差异不是一层薄封装能抹平的所以图形通常单独成模块而不是塞进 PAL。原则四PAL 必须可测试。理想情况下PAL 应该有一个模拟实现可以在没有真实操作系统的环境下跑单元测试。这对持续集成非常重要。3. 核心基础层容器、内存与数学库的取舍核心基础层是引擎的工具箱里面装的是容器、字符串、内存分配器、数学库、序列化这些通用组件。这一层的设计直接决定了上层代码的写法和性能上限。3.1 引擎容器的设计哲学引擎容器和标准库容器最大的区别在于分配器策略和内存布局。先说分配器。标准库容器默认用全局new/delete而引擎容器通常把分配器作为模板参数。这样做的好处是你可以让某个容器从特定的内存池分配比如所有网络相关的容器都从网络内存池分配方便统计和调试。// 引擎容器的典型形态示意 templatetypename T, typename Allocator DefaultAllocator class TArray { T* data_; int32 size_; int32 capacity_; Allocator allocator_; public: void Reserve(int32 newCapacity); void Add(const T item); void RemoveAt(int32 index); // ... };再说内存布局。引擎容器通常会把大小和容量内联存储而不是像std::vector那样存三个指针。这样做的原因是游戏里大量容器只有几个元素用指针存储会浪费内存并且增加缓存未命中。内联存储 小对象优化Small Buffer Optimization能显著提升缓存友好性。还有一个细节是扩容策略。std::vector通常按 1.5 倍或 2 倍扩容但引擎容器往往允许你指定扩容策略甚至允许预分配固定容量永不扩容。后者在实时性要求高的场景非常有用因为扩容意味着分配和拷贝时间不可控。3.2 内存分配器的分层设计内存分配器是核心基础层里最复杂的部分也是最值得投入的部分。我把它分成三层。第一层是系统分配器直接调用平台的内存申请接口。这一层的特点是分配粒度大通常按页4KB 或 64KB开销高但能拿到原始内存。第二层是通用分配器在系统分配器之上实现提供任意大小的分配。常见的有几种自由链表分配器把空闲块串成链表分配时找合适大小的块。实现简单但容易产生碎片。伙伴系统把内存按 2 的幂次分割和合并。碎片可控但内部碎片较多。TLSFTwo-Level Segregated Fit用两级位图管理空闲块分配和释放都是 O(1)。实时性好是很多引擎的选择。Slab 分配器针对固定大小的对象优化预先分配一批同尺寸的块。适合对象池。第三层是专用分配器针对特定用途优化。比如帧分配器Frame Allocator每帧开始重置用于临时数据。分配就是移动指针释放就是重置指针极快。双缓冲分配器两份内存交替使用适合跨帧的临时数据。栈分配器后进先出适合作用域明确的临时数据。我一般建议项目里至少实现帧分配器和对象池这两个。帧分配器能解决大部分每帧临时数据的分配问题对象池能解决频繁创建销毁同类对象的问题。这两个加起来能覆盖 80% 的性能敏感场景。3.3 数学库精度、SIMD 与坐标系数学库看起来简单其实坑很多。我挑三个最关键的讲。第一个是精度。游戏里用float还是double大部分情况float够用但有几个例外世界坐标如果范围很大比如开放世界float的精度不够会出现物体抖动。解决方案是用双精度存储世界坐标单精度做局部计算或者用坐标原点平移Camera Relative Rendering。第二个是 SIMD。现代 CPU 都支持 SIMD 指令一次能算 4 个或 8 个浮点数。数学库如果不用 SIMD性能会差好几倍。但 SIMD 的坑在于对齐要求SIMD 加载通常要求 16 字节或 32 字节对齐如果数据没对齐会崩溃或降速。所以引擎的向量类型通常强制对齐// 强制 16 字节对齐的向量类型示意 struct alignas(16) Vector4 { float x, y, z, w; };第三个是坐标系。左手系还是右手系Y 轴向上还是 Z 轴向上这些选择会影响所有数学函数的实现而且一旦定下来就很难改。我建议在项目早期就明确写进文档并且在数学库的接口命名上体现出来比如CrossLH和CrossRH避免混淆。3.4 序列化为什么引擎不用 JSON序列化是引擎里另一个容易被低估的模块。很多新手会问为什么不用 JSON 或 XML 存游戏数据原因是性能和体积。JSON 是文本格式解析慢体积大。游戏里一个关卡可能有几十万个对象每个对象几十个属性用 JSON 存可能几百 MB加载要好几秒。引擎通常用二进制序列化配合反射系统自动生成序列化代码。二进制序列化的关键设计是版本兼容。游戏会不断更新旧存档要能在新版本里加载。所以序列化格式必须支持字段增删和类型变更。常见做法是给每个字段一个稳定的 ID加载时按 ID 匹配缺失的字段用默认值多余的字段跳过。注意二进制序列化虽然快但调试困难。我一般会同时提供二进制和文本两种格式开发期用文本方便调试发布版用二进制提升性能。4. 对象与反射层引擎的骨架对象与反射层是引擎基础架构里最核心的部分它决定了引擎怎么管理游戏对象、怎么做类型检查、怎么支持编辑器。这一层做得好上层模块写起来行云流水做得不好处处掣肘。4.1 为什么引擎需要自己的对象系统C 本身有对象为什么引擎还要造一个Object系统因为 C 的对象模型缺少游戏需要的几个关键能力。能力一运行时类型信息。C 的 RTTI 功能有限不能遍历一个对象的所有属性也不能根据字符串创建对象。而编辑器需要这些能力显示属性面板、保存加载、撤销重做全都依赖反射。能力二统一的生命周期管理。游戏对象需要被追踪、被垃圾回收、被序列化。如果每个类自己管理代码会重复且容易出错。统一的Object基类能把这些能力集中实现。能力三跨模块引用。游戏对象之间会互相引用比如一个角色引用它的武器。如果直接用裸指针对象销毁后引用就悬空了。Object系统通常提供弱引用和强引用两种机制配合 GC 解决这个问题。能力四网络复制。多人游戏里对象状态需要同步到其他客户端。反射系统能自动生成同步代码不需要手写。4.2 反射系统的实现方式反射系统的实现方式主要有三种各有取舍。第一种是宏 代码生成。用宏标注需要反射的类和属性然后用工具扫描源码生成反射代码。UE 的UCLASS/UPROPERTY就是这种方式。优点是性能好类型安全缺点是需要构建工具支持编译流程复杂。第二种是模板 类型擦除。用模板在编译期收集类型信息运行时通过类型擦除访问。优点是纯 C 实现不需要额外工具缺点是编译期开销大且难以支持根据字符串创建对象这种动态需求。第三种是外部描述文件。用独立的描述文件如 JSON、XML定义类型信息运行时加载。优点是灵活支持热更新缺点是运行时开销大且容易和代码不同步。我参与过的项目里宏 代码生成是主流选择因为它兼顾了性能和灵活性。代价是构建系统要复杂一些但一次投入长期受益。4.3 垃圾回收引用计数 vs 追踪式对象系统的另一个核心问题是垃圾回收。游戏里对象引用关系复杂手动管理内存容易出错所以引擎通常提供某种形式的自动回收。引用计数是最简单的方案每个对象维护一个引用计数引用加一解引用减一减到零就销毁。优点是实时性好对象销毁时机确定缺点是无法处理循环引用而且每次引用操作都有开销。追踪式 GCMark-Sweep、Mark-Compact 等能处理循环引用但需要暂停程序做回收会造成卡顿。游戏里通常用增量式 GC或分代 GC来减少暂停时间。实际引擎里常见的是混合方案对象之间用引用计数但定期做一次循环检测来清理循环引用。或者干脆不用 GC用明确的所有权模型配合对象池和生命周期约定。我个人的经验是对于游戏对象明确的所有权 对象池比 GC 更可控。GC 的不确定性在实时游戏里是很大的风险。但工具、编辑器这类非实时场景GC 能大幅提升开发效率。4.4 对象系统的性能陷阱对象系统虽然方便但有几个性能陷阱必须注意。陷阱一虚函数调用开销。如果每个对象操作都走虚函数调用开销会累积。解决方案是把热路径上的操作内联或特化只在冷路径上用虚函数。陷阱二缓存不友好。如果对象分散在堆上遍历对象时缓存命中率低。解决方案是把常用数据放在连续内存里比如用 SoAStructure of Arrays布局或者用对象池保证同类对象连续。陷阱三GC 暂停。前面说过追踪式 GC 会暂停程序。解决方案是增量回收把回收工作分摊到多帧。陷阱四反射开销。反射操作如按名字查找属性通常比直接访问慢很多。解决方案是缓存反射结果或者用代码生成把反射操作编译成直接访问。5. 模块与子系统层让引擎活起来前面三层都是静态的基础设施模块与子系统层才是让引擎真正运转起来的部分。它负责组织功能模块、管理初始化顺序、驱动主循环。5.1 模块系统的设计模块系统要解决的核心问题是如何把引擎拆成可独立开发、独立测试、按需加载的单元。一个典型的模块系统包含这几个概念模块Module一个功能单元比如渲染模块、物理模块、音频模块。每个模块有自己的初始化和关闭逻辑。模块管理器Module Manager负责模块的注册、加载、卸载、依赖解析。模块接口Module Interface模块对外暴露的 API通常是纯虚接口或函数表。模块之间的依赖必须显式声明模块管理器按拓扑排序决定初始化顺序。如果出现循环依赖应该在编译期或启动期报错而不是运行时崩溃。// 模块接口的典型形态示意 class IModule { public: virtual ~IModule() default; virtual const char* GetName() const 0; virtual const char** GetDependencies() const 0; virtual bool Initialize() 0; virtual void Shutdown() 0; virtual void Tick(float deltaTime) 0; };模块系统的关键设计决策是静态链接还是动态加载。静态链接简单启动快但无法热更新动态加载灵活支持热更新但启动慢且跨平台差异大。大部分商业引擎两者都支持开发期用动态加载方便迭代发布版用静态链接提升性能。5.2 子系统与服务的区别模块和子系统容易混淆我区分一下。模块是编译单元是代码组织的方式。一个模块可以包含多个子系统。子系统是运行时对象是功能组织的方式。子系统通常有明确的生命周期并且可以被替换。服务是子系统的一种特殊形式通常是全局唯一的提供跨模块的能力。比如日志服务、配置服务、任务调度服务。这三者的关系是模块包含子系统子系统可以实现服务。设计时要避免把所有东西都做成全局服务那样会导致隐式依赖和初始化顺序混乱。5.3 主循环与 Tick 调度主循环是引擎的心脏它决定了每帧做什么、按什么顺序做。一个典型的主循环长这样// 主循环的典型结构示意 while (!shouldExit) { float deltaTime CalculateDeltaTime(); // 1. 平台事件处理 Platform::PumpMessages(); // 2. 输入处理 InputSystem::Tick(deltaTime); // 3. 游戏逻辑 World::Tick(deltaTime); // 4. 物理模拟 PhysicsSystem::Tick(deltaTime); // 5. 动画更新 AnimationSystem::Tick(deltaTime); // 6. 渲染提交 RenderSystem::Tick(deltaTime); // 7. 帧末清理 FrameAllocator::Reset(); }这个顺序不是随便定的每一步都有理由。输入在最前因为游戏逻辑需要最新的输入状态。游戏逻辑在物理前因为逻辑可能修改物体的速度或位置物理需要基于最新状态模拟。物理在动画前因为动画可能需要物理结果比如布娃娃。渲染在最后因为渲染需要所有其他系统的最终状态。Tick 调度还有一个关键问题是固定时间步长 vs 可变时间步长。物理模拟通常需要固定步长比如 60Hz否则结果不可复现。游戏逻辑可以用可变步长但要注意处理大 deltaTime比如加载时的长帧。常见做法是固定步长物理 可变步长逻辑 时间累积器。5.4 初始化顺序的坑初始化顺序是引擎启动阶段最容易出问题的地方。我列几个常见的坑。坑一静态初始化顺序问题。C 的全局对象初始化顺序是不确定的如果模块 A 的全局对象依赖模块 B 的全局对象可能 B 还没初始化就被用了。解决方案是避免全局对象改用显式初始化。坑二日志系统初始化太晚。如果日志系统在很后面才初始化前面模块的日志就丢了。解决方案是日志系统最先初始化或者提供一个早期日志缓冲。坑三配置加载依赖文件系统。如果配置系统依赖文件系统而文件系统又依赖配置比如配置决定文件系统用哪个路径就死锁了。解决方案是分层配置底层配置硬编码或从命令行读取。坑四模块卸载顺序。卸载必须按初始化的逆序进行否则会出现依赖的模块已经卸载但自己还在用的情况。模块管理器应该自动处理这个。6. 从零搭建基础架构的实操建议讲了这么多原理最后落到实操。如果你要从零搭一个引擎基础架构我建议按这个顺序来。6.1 第一阶段能跑起来先实现最小可运行的系统平台抽象层文件、时间、线程、原子操作。先支持一个平台接口设计好。内存分配器先实现一个简单的系统分配器包装加上帧分配器。容器TArray、TMap、TString够用就行。日志系统支持分级输出支持控制台和文件。主循环能跑起来能 Tick。这个阶段的目标是能跑一个空场景不要追求功能完整。6.2 第二阶段能管理对象加上对象系统Object 基类带类型信息、生命周期管理。反射系统先支持基本的类型查询和属性访问。对象池支持频繁创建销毁的对象。序列化支持二进制保存加载。这个阶段的目标是能创建、销毁、保存、加载对象。6.3 第三阶段能组织模块加上模块系统模块接口定义模块的生命周期。模块管理器处理依赖和初始化顺序。子系统把功能拆成子系统支持替换。Tick 调度支持不同频率的 Tick。这个阶段的目标是能按需加载模块能独立测试模块。6.4 实操中的经验教训最后分享几条我踩过坑之后的经验。第一条不要过早优化。我见过太多项目一开始就追求极致性能结果架构复杂到没人能维护。先让它跑起来再根据 Profiler 数据优化。第二条接口要稳定实现可替换。基础架构的接口一旦定下来改动成本极高。所以设计接口时要多想几种实现方式确保接口能容纳它们。第三条测试要跟上。基础架构的 bug 影响面极大必须有单元测试。特别是内存分配器、容器、序列化这些模块测试覆盖率要高。第四条文档要写。基础架构的设计决策为什么这样分层、为什么用这种分配器必须写下来。否则半年后你自己都忘了为什么。第五条性能数据要留档。每次架构调整后跑一遍基准测试记录数据。这样你能知道调整是变快了还是变慢了而不是凭感觉。我在实际项目里发现基础架构的投入产出比是前期低、后期极高。前期你会觉得写这么多基础设施游戏逻辑一点没动但到了中后期好的基础架构能让功能开发速度提升好几倍而烂的基础架构会让每个新功能都变成一场噩梦。所以如果你正在做引擎在基础架构上多花时间是值得的。最后一个实用技巧如果你不确定某个设计决策去看看成熟引擎是怎么做的。UE、Unity、Godot 的源码都是公开的看它们怎么处理内存、怎么做反射、怎么组织模块比看任何教程都有用。但要注意不要照搬因为它们的需求和你不一样。理解它们为什么这样做然后根据你的需求做取舍这才是正确的学习方式。
返回列表