
1. 引擎基础架构到底在解决什么问题很多人第一次翻开游戏引擎源码看到的是满屏的Entity、Component、System、Allocator、Handle然后就开始一行行读代码读了三天还在原地打转。我自己当年也是这样抱着 OGRE 和 UE3 的源码啃结果连为什么要有这么多层抽象都没搞明白。后来才想通一件事引擎基础架构不是一堆类的堆砌它是对游戏运行时到底需要什么这个问题的系统性回答。游戏和普通桌面应用最大的区别在于它要在 16.6 毫秒60 FPS甚至 6.9 毫秒144 FPS的预算内同时完成渲染、物理、动画、音频、脚本、网络、资源流式加载等一大堆任务而且这些任务之间还有复杂的依赖关系。普通应用卡一下用户可能没感觉游戏卡一帧玩家立刻就能察觉。所以引擎基础架构的核心目标可以归结为三条确定性、可控性、可扩展性。确定性指的是同样的输入必须产生同样的输出。这在单机游戏里可能只是手感一致的问题但在联机游戏里就是生死攸关的事——如果物理模拟在不同机器上跑出不同结果那联机就没法做了。可控性指的是开发者能精确控制每一帧里发生了什么、按什么顺序发生、花了多少时间。可扩展性则是指当你要加一个新系统比如新的渲染管线、新的物理后端时不需要把整个引擎推倒重来。这三条目标决定了引擎基础架构必须包含几个核心模块内存管理、对象模型、任务调度、资源管理、时间管理。这五个模块构成了引擎的骨架其他所有系统渲染、物理、音频都是挂在这个骨架上的器官。理解了骨架再看器官就轻松多了。我见过不少独立开发者一上来就写渲染器结果写到一半发现内存碎片严重、对象生命周期混乱、多线程数据竞争到处都是最后不得不推倒重来。这就是没有先搭好基础架构的代价。所以这篇文章我会从这五个模块入手把每个模块的设计动机、常见方案、实操细节和踩坑经验都讲清楚。提示如果你现在正在从零写引擎建议先把这篇文章里的五个模块过一遍哪怕不立刻实现也要在心里有个谱。后面写具体系统时你会感谢自己提前做了这件事。2. 内存管理引擎性能的地基2.1 为什么不能用 new/delete 满天飞新手写引擎最容易犯的错误就是到处new和delete。在普通应用里这没什么问题但在游戏里new和delete有几个致命缺陷。第一是性能不可控。malloc和free的耗时是不确定的有时候几纳秒有时候几微秒取决于堆的状态。游戏里如果在一帧内做了几千次new那这一帧的耗时就会剧烈波动导致帧率不稳。第二是内存碎片。频繁分配释放不同大小的内存块会让堆变得千疮百孔最后即使总内存够用也找不到一块连续的大内存。第三是缓存不友好。new出来的对象在内存里是散落的CPU 访问时缓存命中率低性能自然差。所以引擎里几乎不会直接用new/delete而是用自定义分配器。分配器的核心思想是把内存分配从通用变成专用。不同的使用场景用不同的分配器每个分配器针对自己的场景做优化。2.2 几种必知的分配器类型线性分配器Linear Allocator是最简单的一种。它维护一块大内存和一个偏移指针每次分配就是把指针往前推释放的时候一次性把指针归零。这种分配器分配极快就是一次加法但只能整体释放。它适合生命周期一致的数据比如一帧内的临时数据——每帧开始分配帧结束全部释放。栈分配器Stack Allocator是线性分配器的升级版支持后进先出的释放。你可以标记一个位置然后回退到这个位置释放之后分配的所有内存。它适合有明确嵌套生命周期的场景比如递归下降解析器、场景树的遍历。池分配器Pool Allocator专门用来分配固定大小的对象。它把内存切成等大的块用一个空闲链表管理。分配和释放都是 O(1)而且没有碎片。它适合大量同类型对象比如粒子、子弹、网络包。通用堆分配器General Purpose Allocator是最复杂的要处理任意大小的分配和释放。常见实现有 TLSFTwo-Level Segregated Fit、dlmalloc、jemalloc。引擎里一般只在不得不用的地方才用它比如加载资源时的大块内存。下面是一个简化的线性分配器实现你可以直接抄去用class LinearAllocator { public: LinearAllocator(size_t size) { m_start static_castuint8_t*(malloc(size)); m_offset 0; m_capacity size; } void* alloc(size_t size, size_t alignment 8) { size_t alignedOffset (m_offset alignment - 1) ~(alignment - 1); if (alignedOffset size m_capacity) return nullptr; void* ptr m_start alignedOffset; m_offset alignedOffset size; return ptr; } void reset() { m_offset 0; } ~LinearAllocator() { free(m_start); } private: uint8_t* m_start; size_t m_offset; size_t m_capacity; };这个实现里有个细节值得说对齐处理。(m_offset alignment - 1) ~(alignment - 1)这行是标准的向上对齐公式要求 alignment 是 2 的幂。为什么必须对齐因为很多 CPU 指令要求数据按特定边界对齐比如 SSE 指令要求 16 字节对齐不对齐会直接崩溃或者性能暴跌。2.3 内存标签与调试光有分配器还不够你还需要知道内存都花在哪了。内存标签Memory Tag是个非常实用的技巧每次分配时传一个标签比如 Renderer、Physics、Audio分配器记录每个标签用了多少内存。这样你随时可以打印出内存分布一眼看出哪个系统在吃内存。enum class MemTag { Renderer, Physics, Audio, Script, Count }; struct AllocHeader { MemTag tag; size_t size; };在 Debug 构建里每个分配前面加一个 header 记录标签和大小在 Release 构建里可以去掉 header 省内存。这种Debug 加料、Release 减料的做法在引擎里非常常见。我踩过的一个坑是早期没做内存标签结果游戏跑久了内存一直涨但完全不知道是谁在泄漏。后来加上标签发现是某个粒子系统的对象池没有正确回收十分钟就定位了。所以内存标签这东西越早加越好别等到出问题才想起来。2.4 内存对齐与缓存友好现代 CPU 访问内存是按缓存行Cache Line通常 64 字节来的。如果你要访问的数据散落在不同的缓存行里每次访问都要从主存加载性能会差好几倍。所以引擎里有个重要原则把一起访问的数据放在一起。这就是所谓的AoSArray of Structsvs SoAStruct of Arrays问题。假设你要存一万个粒子每个粒子有位置、速度、颜色// AoS每个粒子是一个结构体 struct Particle { Vec3 pos; Vec3 vel; Color col; }; Particle particles[10000]; // SoA每个属性一个数组 struct ParticleSystem { Vec3 pos[10000]; Vec3 vel[10000]; Color col[10000]; };如果你要更新所有粒子的位置只用到 pos 和 velAoS 会把 col 也加载进缓存浪费带宽SoA 则只加载需要的数组缓存利用率高。但如果你的访问模式是随机取某个粒子的所有属性AoS 反而更好。所以选择哪种布局取决于你的访问模式。物理系统通常用 SoA因为要批量更新位置而 UI 系统通常用 AoS因为要频繁访问单个对象的多个属性。3. 对象模型从继承到组件3.1 深继承树的痛苦早期引擎比如 UE1/UE2 时代用的是深继承树Actor派生PawnPawn派生CharacterCharacter派生PlayerCharacter……一层套一层。这种设计在项目小的时候没问题但项目一大就出问题。最典型的问题是菱形继承和功能爆炸。假设你想让一个原本是静态的物体能移动你得把它从一个类改到另一个类假设你想让一个能移动的物体也能被拾取又得改继承关系。最后继承树变成一张蜘蛛网改一处动全身。我见过一个项目继承深度到了 8 层改基类的一个虚函数下面几十个子类全要重新编译一次编译二十分钟。这种开发体验是灾难性的。3.2 组件化组合优于继承组件化架构的核心思想是对象不再通过继承获得能力而是通过组合组件获得能力。一个游戏对象就是一个容器里面装着一堆组件每个组件负责一个功能。class GameObject { std::vectorstd::unique_ptrComponent m_components; public: templatetypename T T* getComponent(); templatetypename T void addComponent(std::unique_ptrT comp); }; class TransformComponent : public Component { Vec3 pos, rot, scale; }; class RenderComponent : public Component { Mesh* mesh; Material* mat; }; class PhysicsComponent : public Component { RigidBody* body; };这样你要让一个物体能移动就加一个TransformComponent要让它能渲染就加一个RenderComponent。功能是拼出来的不是继承来的。但组件化也有代价组件之间的通信变复杂了。比如渲染组件需要知道变换组件的位置物理组件需要修改变换组件的位置。如果组件之间直接互相引用又会变成一团乱麻。所以需要一套通信机制常见的有三种直接引用、消息传递、系统查询。3.3 ECS组件化的极致形态ECSEntity-Component-System是组件化的极致形态。它把数据和行为彻底分离Component 只存数据System 只处理逻辑Entity 只是一个 ID。using Entity uint32_t; struct Position { float x, y, z; }; struct Velocity { float dx, dy, dz; }; class MovementSystem { public: void update(float dt, std::vectorEntity entities, ComponentArrayPosition positions, ComponentArrayVelocity velocities) { for (auto e : entities) { if (positions.has(e) velocities.has(e)) { auto p positions.get(e); auto v velocities.get(e); p.x v.dx * dt; p.y v.dy * dt; p.z v.dz * dt; } } } };ECS 最大的优势是缓存友好。因为组件是连续存储的System 遍历时缓存命中率极高。而且 System 之间没有依赖可以并行执行。这就是为什么 Unity 的 DOTS、Unreal 的 Mass 都转向了 ECS。但 ECS 不是银弹。它的缺点是调试困难数据和行为分离断点不好打、学习曲线陡思维方式和 OOP 完全不同、不适合所有场景UI、脚本这类逻辑复杂的系统用 ECS 反而别扭。所以很多引擎是混合的核心系统用 ECS上层逻辑用传统 OOP。3.4 对象生命周期与句柄不管用哪种对象模型都要解决一个问题对象什么时候销毁销毁后怎么安全访问。直接用裸指针的话对象销毁后指针变成野指针访问就崩溃。解决方案是句柄Handle。句柄不是指针而是一个索引加版本号struct Handle { uint32_t index; uint32_t generation; }; templatetypename T class HandlePool { std::vectorT m_objects; std::vectoruint32_t m_generations; std::vectoruint32_t m_freeList; public: Handle add(T obj) { uint32_t idx; if (!m_freeList.empty()) { idx m_freeList.back(); m_freeList.pop_back(); } else { idx m_objects.size(); m_objects.push_back(obj); m_generations.push_back(0); } return {idx, m_generations[idx]}; } T* get(Handle h) { if (h.index m_objects.size()) return nullptr; if (m_generations[h.index] ! h.generation) return nullptr; return m_objects[h.index]; } void remove(Handle h) { m_generations[h.index]; m_freeList.push_back(h.index); } };关键在generation每次删除对象generation 加一。这样即使索引被复用旧句柄的 generation 也对不上get会返回 nullptr。这个技巧在资源管理、网络对象、UI 元素里都用得上。注意句柄的 generation 是 uint32理论上会溢出。实际项目中溢出概率极低要删除 40 亿次但严谨的做法是溢出时把该槽位永久标记为不可用或者用 uint64。4. 任务调度把多核用起来4.1 为什么需要任务系统现代 CPU 动辄 8 核 16 线程但很多游戏还是单线程跑满一个核其他核闲着。这不是开发者不想用多线程而是多线程太难写数据竞争、死锁、伪共享每一个都能让你调一整天。任务系统Task System的思路是把工作拆成一个个独立的任务交给线程池执行开发者不用直接管理线程。任务之间可以声明依赖关系调度器负责按依赖顺序执行。class TaskScheduler { public: void init(uint32_t numThreads); TaskHandle submit(Task task, TaskHandle dependency {}); void wait(TaskHandle handle); private: std::vectorstd::thread m_workers; std::queueTask m_queue; std::mutex m_mutex; std::condition_variable m_cv; };4.2 任务粒度太细和太粗都不行任务粒度是个关键问题。任务太细调度开销超过任务本身任务太粗负载不均衡有的线程忙死有的闲着。经验法则是单个任务的执行时间应该在 100 微秒到 1 毫秒之间。低于 100 微秒调度开销占比太高高于 1 毫秒负载均衡变差。怎么拆任务以渲染为例可以按每个渲染 pass 一个任务拆也可以按每个物体一个任务拆。前者粒度粗但开销小后者粒度细但开销大。实际项目里通常是分层的粗粒度任务内部再拆细粒度任务。4.3 依赖管理与工作窃取任务之间往往有依赖。比如物理更新必须在渲染之前完成动画更新必须在物理更新之前完成。调度器要能表达这些依赖。最简单的依赖表达是任务图Task Graph每个任务记录自己的前置任务前置任务全部完成后才能执行。复杂一点的是有向无环图DAG支持更灵活的依赖关系。工作窃取Work Stealing是负载均衡的常用技巧。每个线程有自己的任务队列当自己的队列空了就去别的线程队列尾部偷任务。这样既减少了锁竞争大部分时候操作自己的队列又能自动均衡负载。class WorkStealingQueue { std::dequeTask m_tasks; std::mutex m_mutex; public: void push(Task t) { std::lock_guardstd::mutex lock(m_mutex); m_tasks.push_back(t); } bool tryPop(Task t) { std::lock_guardstd::mutex lock(m_mutex); if (m_tasks.empty()) return false; t m_tasks.back(); m_tasks.pop_back(); return true; } bool trySteal(Task t) { std::lock_guardstd::mutex lock(m_mutex); if (m_tasks.empty()) return false; t m_tasks.front(); m_tasks.pop_front(); return true; } };注意这里 push 和 pop 都从尾部操作LIFOsteal 从头部操作FIFO。LIFO 让线程优先处理最近的任务缓存局部性好FIFO 让偷任务时拿到的是最老的任务减少竞争。4.4 主线程与渲染线程的协作游戏引擎里有个特殊问题渲染 API 通常只能在特定线程调用。所以引擎一般有一个主线程跑游戏逻辑和一个渲染线程跑渲染命令提交。两者之间通过命令缓冲区Command Buffer通信。主线程把渲染命令写进缓冲区渲染线程读取并执行。缓冲区要双缓冲甚至三缓冲避免读写冲突。这个模式在 Vulkan、D3D12 里是标配因为现代图形 API 都要求显式管理命令缓冲。我踩过的一个坑是早期没做命令缓冲主线程直接调渲染 API结果渲染线程一启动就崩溃。后来改成命令缓冲主线程只管写命令渲染线程只管执行问题就解决了。这个模式虽然多了一层间接但换来的是线程安全和并行能力非常值得。5. 资源管理加载、缓存与热重载5.1 资源生命周期与引用计数游戏里的资源纹理、模型、音频、着色器通常很大不能每个对象都加载一份。所以需要资源管理器统一管理同一个资源只加载一次多个对象共享。共享的经典方案是引用计数。每个资源记录被引用的次数加载时加一释放时减一减到零就真正卸载。class Resource { std::atomicuint32_t m_refCount{0}; public: void addRef() { m_refCount.fetch_add(1, std::memory_order_relaxed); } void release() { if (m_refCount.fetch_sub(1, std::memory_order_acq_rel) 1) { delete this; } } };这里用std::atomic是因为资源可能在多线程里被引用。fetch_sub返回旧值如果旧值是 1说明减到零了可以删除。内存序用acq_rel保证删除前的操作对其他线程可见。但引用计数有个经典问题循环引用。A 引用 BB 引用 A两者引用计数都不为零永远不释放。解决方案是用弱引用Weak Reference弱引用不加计数访问时先检查对象是否还活着。5.2 异步加载与流式加载大资源加载很慢如果同步加载会卡住主线程。所以需要异步加载在后台线程加载加载完成后通知主线程。class AsyncLoader { public: std::futureResource* loadAsync(const std::string path) { return std::async(std::launch::async, [path]() { return loadFromDisk(path); }); } };但异步加载有个问题加载完成时请求资源的对象可能已经销毁了。所以需要一套机制处理加载完成但请求者已死的情况。常见做法是请求者持有一个句柄加载完成后检查句柄是否还有效。流式加载Streaming是异步加载的进阶版用于开放世界游戏。它根据玩家位置动态加载和卸载资源保证内存里只保留附近的资源。流式加载的难点是优先级管理玩家正前方的资源要优先加载背后的可以晚点加载。这需要一个优先级队列根据距离、视角、重要性排序。5.3 热重载改完立刻看到效果热重载Hot Reload是提升开发效率的利器。改一行着色器代码不用重启游戏就能看到效果改一个模型场景里立刻更新。热重载的核心是文件监听 资源重建。文件监听用操作系统的 APIWindows 的ReadDirectoryChangesWLinux 的inotifymacOS 的FSEvents。文件变化时重新加载资源替换旧的。class FileWatcher { std::unordered_mapstd::string, std::functionvoid() m_callbacks; public: void watch(const std::string path, std::functionvoid() cb); void poll(); // 每帧调用检查文件变化 };热重载的坑在于状态保持。比如你改了一个材质的着色器重新加载后材质的参数颜色、纹理不能丢。所以资源重建时要保留运行时状态只替换底层数据。我个人的经验是热重载优先做着色器和脚本这两个改得最频繁。模型和纹理热重载相对少用因为改这两个通常意味着美术资源更新重启一下也无所谓。6. 时间管理固定步长与可变步长6.1 为什么时间管理是个大问题游戏循环里有个看似简单实则复杂的问题每帧的时间间隔怎么定如果直接用实际帧间隔可变步长物理模拟会不稳定——帧率波动时物理结果会不一致。如果用固定步长又可能出现一帧要跑多次物理或一帧跑不满一次物理的情况。6.2 固定步长 插值主流方案是固定步长物理 渲染插值。物理以固定步长比如 1/60 秒更新渲染用实际帧间隔。当渲染帧率高于物理帧率时用插值平滑显示。class GameLoop { const float FIXED_DT 1.0f / 60.0f; float m_accumulator 0.0f; public: void tick(float realDt) { m_accumulator realDt; while (m_accumulator FIXED_DT) { physicsUpdate(FIXED_DT); m_accumulator - FIXED_DT; } float alpha m_accumulator / FIXED_DT; render(alpha); // 用 alpha 插值 } };alpha是插值系数渲染时用prevPos * (1-alpha) currPos * alpha计算显示位置。这样即使物理是 60Hz、渲染是 144Hz画面也是平滑的。6.3 时间缩放与暂停游戏里经常需要时间缩放子弹时间、快进、暂停。实现方式是给时间乘一个系数float scaledDt realDt * m_timeScale;暂停就是m_timeScale 0。但要注意不是所有系统都受时间缩放影响。UI 动画、音频播放通常不受影响物理和游戏逻辑受影响。所以时间缩放要分层管理。我踩过的坑是早期把所有系统都用同一个 dt结果暂停时 UI 也停了玩家没法操作菜单。后来把时间分成游戏时间和真实时间两套UI 用真实时间游戏逻辑用游戏时间问题就解决了。7. 这些模块怎么串起来前面讲了五个模块但它们是孤立的吗不是。它们之间有紧密的依赖关系理解这些关系才能把引擎架构真正串起来。内存管理是地基其他所有模块都依赖它。对象模型的对象从池分配器来任务系统的任务从池分配器来资源管理器的资源从堆分配器来。所以内存管理必须最先做而且要做好。对象模型是骨架它定义了游戏世界里有什么。资源管理器管理的资源被对象引用任务系统处理的对象是游戏对象时间管理驱动对象的更新。任务调度是肌肉它让对象模型和资源管理能并行工作。物理、动画、渲染这些耗时操作都通过任务系统并行化。资源管理是血液它为对象提供数据。没有资源对象就是空壳。时间管理是心跳它驱动整个引擎运转。没有时间一切都静止。这五个模块的设计要一起考虑不能孤立设计。比如对象模型用 ECS那内存管理就要支持 SoA 布局任务系统要支持 ECS 的并行查询资源管理器要支持 ECS 的异步加载。这些是相互关联的。8. 从零搭引擎的实操建议如果你真的要从零写一个引擎我的建议是不要一上来就写完整架构。先写一个能跑起来的最小版本然后逐步迭代。第一步写一个窗口 主循环。用 GLFW 或 SDL 创建窗口写一个while (!shouldClose)循环每帧清屏。这一步的目标是能跑起来。第二步加内存管理。先实现一个线性分配器和一个池分配器把主循环里的临时数据用线性分配器管理。这一步的目标是内存可控。第三步加对象模型。先实现最简单的组件化不用 ECS一个GameObject加几个组件。这一步的目标是能创建游戏对象。第四步加资源管理。实现一个简单的资源管理器支持纹理和着色器加载。这一步的目标是能显示东西。第五步加任务系统。实现一个线程池把资源加载放到后台线程。这一步的目标是不卡主线程。第六步加时间管理。实现固定步长物理 插值渲染。这一步的目标是物理稳定。每一步都要能跑起来都要能验证。不要一次写五个模块然后一起调试那样出了问题你根本不知道是哪个模块的锅。提示每一步都写单元测试。内存分配器测试分配释放是否正确对象模型测试组件增删是否正确任务系统测试依赖顺序是否正确。测试是引擎开发的保险绳没有测试的引擎代码就是定时炸弹。9. 常见误区与避坑清单最后分享几个我在引擎开发中踩过的坑希望能帮你少走弯路。误区一过早优化。一开始就追求极致性能用了各种复杂的数据结构和算法结果代码难懂难改性能也没提升多少。正确做法是先写简单正确的版本用 profiler 找到瓶颈再优化。误区二忽视调试工具。引擎开发最痛苦的不是写代码是调试。没有内存查看器、没有 profiler、没有可视化调试工具出了问题只能靠 printf。所以调试工具要和引擎一起开发不能等出了问题再做。误区三多线程滥用。看到多核就想把所有东西并行化结果数据竞争、死锁、伪共享问题一大堆。正确做法是先单线程跑通找到真正的性能瓶颈再针对性地并行化。误区四资源管理太简单。一开始觉得资源管理就是加载和卸载结果项目一大就发现资源依赖、循环引用、异步加载、热重载、内存预算每一个都是坑。资源管理要早做要做好。误区五忽视平台差异。在 Windows 上跑得好好的到 Linux 或主机上就崩了。内存对齐、字节序、线程模型、文件系统每个平台都有差异。跨平台引擎要从一开始就考虑这些。我在实际项目中的体会是引擎架构没有银弹只有权衡。每个决策都有代价关键是想清楚你的项目需要什么。是做 3A 大作还是独立小游戏是单机还是联机是 PC 还是移动端不同的目标对应不同的架构选择。没有最好的架构只有最合适的架构。