尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

游戏引擎架构深度解析:从底层设计到运行链路

游戏引擎架构深度解析:从底层设计到运行链路 搞明白引擎长成什么样子的那天我突然从“会用引擎的人”变成了“能改引擎的人”。很多人学了几年引擎API能跑通Demo能搓出小游戏但一看到引擎源码就头大不知道那些模块为什么存在为什么初始化顺序错了就崩溃为什么资源管理永远绕不开引用计数。这篇「游戏引擎架构深度解析一」我打算把引擎基础架构里最关键的那几根骨头拆开讲透分层设计、底层设施、运行时骨架、场景与资源管理以及从main函数到渲染第一帧的完整链路。如果你是已经能写点游戏逻辑、但想进一步理解引擎内核的开发或者正在考虑自己搞一个足够小又足够可维护的引擎这篇文章应该能直接帮你缩短一大段摸索时间。我会把我自己踩过的坑和后面想明白的东西都写出来尽量不绕弯子。1. 引擎基础架构到底在解决什么问题1.1 分层的世界观引擎代码的依赖方向在我见过的所有正经引擎里不管商业的还是开源的架构上都跑不出一个大概五到六层的金字塔。最底下是平台抽象层往上依次是核心层、功能模块层、游戏层和工具层。平台抽象层负责把Windows、Linux、iOS、PlayStation之类平台的差异抹平它提供窗口创建、输入设备、图形API接口、文件IO这些最基础能力。核心层在平台层之上内存分配器、数学库、容器、哈希、日志、线程池都住在这里。功能模块层是渲染、物理、音频、动画、场景管理这些能被游戏直接调用的子系统。最上面是游戏层存放玩法逻辑、状态机、AI行为这些和具体项目绑死的东西。工具链则属于伴生产物编辑器、资源预览、性能分析器都从这里长出来。真正决定一个引擎好不好维护的不是它有多少功能模块而是这些层之间是否严格遵守单向依赖。核心层绝对不能反向去引用功能模块更不能知道什么“玩家”、“怪物体力值”这种游戏概念功能模块只能向下依赖核心层提供的工具不能互相扯皮游戏层可以用所有引擎功能但引擎本体不能反过来关心某个游戏的玩法。这个规则看起来像常识实际项目里却经常被打破最常见的就是有人为了图方便给渲染模块塞了一个“if (gameMode xxx)”之类的判断短时间跑得挺欢等游戏逻辑膨胀到两三个玩法系统之后这个耦合点就会变成一颗随时引爆的雷。为什么分层会被反复强调三个非常现实的理由第一大型引擎动辄几百万行代码如果模块间没有清晰的编译依赖改一行公共头文件就要等半小时编译开发节奏直接崩掉第二跨平台能力来自底层抽象物理和渲染在静态模型上也想要移植到别的平台平台层隔离住了上层根本不用改第三渲染团队、物理团队、玩法团队可以并行开发只要接口稳定谁也碍不着谁。1.2 引擎层和游戏层的边界划在哪里新手对“引擎层”和“游戏层”的边界常常有一种误解以为引擎层就是所有代码都应该做成通用的什么系统都抽象一把。我见过有人把玩家弹簧臂相机、马里奥跳跃手感都做成引擎通用模块结果就是引擎越来越大越改越尴尬。更好的做法是引擎层只放那些跟某个具体玩法无关的基础能力和通用系统比如空间变换、资源加载、渲染队列、物理碰撞查询而跟玩法强相关的东西比如主角控制、关卡规则、特殊技能逻辑严格放进游戏层。这条边界还有一个更隐蔽的作用它决定了引擎迭代和游戏迭代能不能分开跑。如果玩法逻辑直接硬编码在引擎功能模块里那引擎升级一次所有项目都得跟着改一次。我自己在做引擎内部重构的时候就深有体会凡是历史遗留里把游戏逻辑混进引擎模块的地方每一次动刀都要小心翼翼地保证老项目还能跑非常痛苦。而边界划清楚了之后引擎层可以放心加新功能游戏层按需适配老项目不会因为引擎升级突然就无法编译。2. 底层基础设施地基模块的设计决策2.1 内存管理为什么引擎不能无脑 new很多从业务编程转过来的同事写游戏引擎代码时用的还是应用开发那套内存习惯需要对象就 new不需要就 delete或者干脆全丢给垃圾回收。但放到引擎里这样做第一轮性能测试就会教你做人。malloc 和 free 在频繁小对象分配时的碎片问题非常严重分配器底层维护的空闲列表还会让相邻内存对象在缓存里被打散引擎每帧可能有成千上万次分配碎片和缓存未命中叠加起来帧率波动会非常明显。更麻烦的是通用分配器给不了你分配语义上的信息你很难回答“这个帧是谁在分配内存分配了多少次”这种问题。所以正经引擎的底层几乎都会自研一套分配器体系最常见的是栈式分配器、池式分配器和区块分配器。栈式分配器的设计思路很像一个自动记录的堆栈一帧开始时压入一个标记点这一帧里所有临时对象都从栈顶分配帧结束统一弹出等于把“中间过程数据随手丢”这个语义用内存结构实现了一遍分配速度接近移动指针不需要析构时挨个回收。池式分配器面向的是大量等尺寸对象比如同一种组件、同一种粒子结构它会把空闲对象串成链表取用和归还都是 O(1)而且对象之间内存相邻遍历时对缓存极友好。区块分配器则把一个大的内存块切分给多种小尺寸对象目的是减少小对象分配零散落点。我自己的一个强烈建议是不管引擎规模多小都要建立“分配标签”机制。也就是说每次分配内存时额外传一个枚举标签表示这个分配来自渲染、物理还是音频。加上这个标签之后你在调试或者性能分析时才能真正看到谁在吃内存。这里我贴一段很简单的伪代码展示栈式分配器的核心思想class StackAllocator { uint8_t* buffer; size_t capacity; size_t offset; public: StackAllocator(size_t capacity); void* Allocate(size_t size, size_t alignment); void Mark() // 记录当前栈顶 void FreeToMark(size_t mark); // 回到之前的栈顶 };实际使用时你在帧开始时调用mark allocator.Mark()帧结束调用allocator.FreeToMark(mark)。注意一点栈式分配器上分配的对象绝不能跨帧跨线程保存因为下一帧Mark点的位置可能已经变掉了。如果发现某个模块偷偷保存了栈式分配器分配的对象那基本就埋下了一个时好时坏的崩溃种子。2.2 数学库与基础容器SIMD、对齐和可预测的分配游戏引擎里调用最频繁的模块数学库绝对排前三。向量、矩阵、四元数、包围盒这些看似简单的基础类型实现时有一堆细节讲究。坐标系就有讲究很多引擎使用左手坐标系变换矩阵采用行向量还是列向量直接决定了你乘法的写法。如果你在引擎里把行向量和列向量混着用旋转和缩放结果会乱成一团。所以基础架构层面必须从一开始就定死数学约定并且通过类型系统和代码审查来保证不扩散。另一个数学库的关键点是内存对齐和SIMD。现代CPU的SIMD指令允许你一次做四条浮点数的运算这对矩阵乘法、向量点乘简直是量身定做的加速。但SIMD要求数据地址对齐到16字节甚至32字节一个普通的new float[16]可能只对齐到8字节直接传给SIMD指令就会触发总线错误。引擎里的做法通常是重载operator new或使用对齐分配函数确保矩阵和向量类型默认对齐到16字节。还有结构体布局上的一个经典选择对一批运动物体数据如果采用“数组结构体”AoS每个物体的坐标和速度挨在一起那当你要遍历一堆物体的速度做统一运算时缓存会因为你跳过大量无用字段而效率变低反过来采用“结构体数组”SoA把所有x坐标放一个数组、所有y坐标放另一个数组遍历连续地址就能直接喂给SIMD性能会高出一截。引擎里粒子系统、蒙皮动画顶点流基本都是SoA布局。基础容器这块很多引擎不直接用标准库容器原因并不是标准库写得差而是通用容器的分配时机和内存行为不容易被引擎掌控。有些场合比如场景加载一瞬间要插入几十万条数据你不知道它会触发多少次realloc也不知道这些临时内存落在哪个堆上。要么你给项目引入EASTL那种为游戏定制的容器要么你在基础架构里实现一套带自定义分配器参数的容器模板。我的建议是引擎核心代码里尽量用固定容量数组或预分配的容器不要在热循环里做“边遍历边插入”的操作。一旦这个规律破坏了后面做性能分析时你根本找不到瓶颈在哪里因为到处都是瓶颈。2.3 虚拟文件系统把“读文件”变成一条管线如果引擎所有代码都直接调用fopen那么它的资源管理一定会稀碎。游戏发行后的资源通常不在标准文件夹里放着可能被打成一整个包文件可能分成多个patch文件也可能在主机平台上搞特殊访问路径。虚拟文件系统VFS要解决的就是这个统一性问题引擎内部只认一套符合逻辑的资产路径例如assets/characters/player_01/skin.mat至于这个路径背后对应的是真实目录里的文件、大包里的一段偏移还是远程流式加载的数据块VFS内部的挂载管理器会帮你翻译。VFS设计里最容易踩的坑是路径大小写、分隔符和alisa机制。我曾经遇到过一个同事写的资源加载路径直接用了\反斜杠结果在Linux平台编译运行全挂。引擎基础里应该把所有资源路径统一为正斜杠、统一小写化并且提供路径规范化函数任何一个模块要访问资源都必须走这个入口。在此基础上再做异步加载和文件监控。异步加载的是IO线程正在读文件主线程不能停下等它文件监控则是编辑器改保存了资源文件后引擎能自动重新加载。资源加载失败时的兜底逻辑也得在设计里提前想好。最怕的情况是材质文件缺失结果渲染系统拿着一个空指针直接画黑屏甚至崩溃调试时你根本看不到有效信息。我的习惯是给资源加载器做一个默认资源池任何加载不到的资产都回退到一个“默认白色材质”或“默认盒子网格”上同时在日志里打清楚完整路径和缺失原因。这样美术改错文件名时你看到的是缺了什么而不是一个莫名其妙的黑屏。3. 运行时骨架模块注册与引擎循环3.1 主循环逻辑时间、渲染时间和固定步长的取舍引擎运行的基础是一个永远不会结束的循环这是从DOS时代一路传到现在的游戏程序框架核心。直接看最朴素的伪代码int MainLoop() { while (!platform-IsQuitRequested()) { platform-PumpOSMessages(); float deltaTime timer-GetDeltaTime(); gameLogic-Update(deltaTime); rendering-Render(gameWorld); } return 0; }但这个朴素版本有很多问题。比如逻辑以真实帧间隔为步长在60Hz显示器上玩家角色移动速度看起来正常换到144Hz高刷屏上速度就翻倍了因为每帧更新的步长不一样。解决办法有固定步长和可变步长两种哲学。固定步长要求逻辑每次都以1/60秒为单位推进渲染帧之间可能调用多次逻辑这样物理模拟和角色控制都一样了但代价是如果逻辑太重玩家会觉得操作有延迟。可变步长则是按真实时间缩放运动量实现简单但物理仿真的稳定性会受影响。成熟引擎的常用方案是一种“半固定”策略逻辑步长固定比如一秒60步渲染循环每帧累计真实经过的时间时间不够走一步就继续渲染时间超过一步就连续跑两到三步逻辑最多补几步防止“死亡螺旋”。渲染依然以真实帧间隔去插值和提交保证画面的流畅感。这是我在架构设计里最愿意向大家推荐的一个折衷方案它既照顾了物理稳定性又不会把不同刷新率下的行为搞分裂。你还会发现渲染阶段通常被放到一帧的最后因为逻辑更新完的完整世界状态才是内帧一切数据的来源提前渲染反而会让用户看到半新半旧的画面。3.2 模块注册表初始化顺序和依赖关系怎么管大型引擎绝不会在main函数里手动去写“先初始化这个再初始化那个”那样没几个人能维护。它们普遍采用模块注册表模式所有引擎模块注册成一个有序列表每个模块描述自己的依赖、初始化优先级和生命周期。启动时引擎按照依赖解析结果按顺序初始化关闭时反序关闭。这本质上是把“模块A需要模块B先启动”这种关系变成了显式的数据声明。依赖解析怎么做最常见的做法是有向图排序比如模块A声明依赖模块B那么B的初始化顺序一定排在A前面。如果有循环依赖解析阶段直接报错而不是等到运行时崩了再查。这里我提醒一下不要只做“依赖顺序检查”还要在模块初始化失败时提供“失败回滚”机制。一个模块初始化失败前面已经初始化好的模块必须被正确关掉否则残留的窗口和后台线程会让重启后的引擎状态错乱。模块的生命周期方法一般高度对称初始化阶段调用Initialize()紧接着可能调用PostInitialize()关闭时调用Shutdown()。为什么需要PostInitialize这一档因为有些模块依赖其他模块完全初始化完之后的产物而不是仅仅依赖它“启动了”。举个例子资源系统要等文件系统挂载完所有容器包才能准备资源索引而文件系统自己只需要先开个底层IO句柄就够了。少了这层两阶段初始化你会在启动顺序上反复打补丁最终一定是脏补丁越打越多。3.3 对象与组件游戏世界的生命周期管理引擎里游戏对象怎么组织直接决定架构的玩法。早期做法是深度继承Entity派生CharacterCharacter再派生Player这种树在原型期挺好用但一旦出现“会飞的鱼”这种需要两种能力组合的对象继承树就往畸形里长。所以现代引擎普遍采用组件模式一个游戏对象本质上是一个带有ID、名字、Transform的容器行为全挂在各种组件上。PlayerController是一个组件Rigidbody是一个组件AudioSource也是一个组件组合它们的永远比继承它们自由。组件生命周期这套东西我建议牢牢记住几个关键节点构造、初始化、启用、逻辑帧更新、停用、销毁。组件刚构造完时只是内存里的数据初始化时才能拿到其他系统或场景配置传入的引用启用后每帧跟着主循环走停用后引擎不再调用它的更新函数但对象还活着销毁时才真正释放资源和从系统里移除。新手最容易犯的错误是在构造函数里访问其他组件或场景系统此时许多系统还没准备好拿到的引用往往是空的。把“构造”和“初始化”分开就是为了避免这种半初始化状态带来的混乱。组件之间的通信也不能都走“直接拿指针调用”否则网状依赖会很快缠成一团。很多引擎会引入事件总线或消息系统组件发出“角色死亡”事件任何关心这个事件的系统订阅它。事件系统虽然好用但真不能滥用因为那个调用栈会变得非常不直观。我自己定过一条规矩同一个update内的一次性同步事件可以用事件系统跨帧、跨系统频繁流动的实时状态尽量用共享数据块加脏标记或者用直接接口调用否则调试起来会“满世界找是谁调用了谁”。4. 场景与资源管理引擎眼中的世界4.1 场景图与坐标空间Transform 层级到底在算什么场景图听起来高大上本质就是一棵树。每个场景节点有一个Transform记录相对于父节点的位置、旋转和缩放。渲染一个带父节点的物体时你要把它模型空间的顶点通过“模型矩阵”变换到世界空间模型矩阵由层级递归合成局部矩阵 父世界矩阵 × 自身局部矩阵。这里的乘序特别关键反过来就直接算错。左手坐标系下面通常是把比例和旋转组合成旋转缩放矩阵再加上平移列最后和父级矩阵相乘。引擎里常见的坐标空间有模型空间、世界空间、观察空间、齐次裁剪空间。渲染管线的流水线就是把这些空间用矩阵串起来。场景图的优势不只是方便做父级跟随还让你可以在父节点做整体变换。举个例子一艘船上的所有NPC都挂在船节点下面船移动时NPC自然跟着移动完全不用一个个去更新坐标。我在第一次实现这个功能时犯过一个很低级的错每帧都重新遍历整棵树去计算每个节点的世界矩阵后来才意识到应该在节点发生改变时才去重算该节点子树的所有矩阵用脏标记跳过没变化的节点。这一改世界矩阵更新的开销直接掉了一个量级。还要提醒一句场景图不等于最终的渲染对象列表。渲染系统真正关心的是“这个节点要不要画、怎么画、插到哪个DrawCall队列”所以场景图更新完后引擎还需要一个剔除阶段把不可见的物体从渲染列表里摘掉。基础架构里这两个阶段的关系必须理清楚否则你会发现场景图里的物体明明存在渲染那边却找不到它。4.2 资源管理句柄、引用计数和异步加载资源系统是引擎基础架构里最容易写乱也最影响稳定性的部分。网格、贴图、材质、音频、动画片段这些本质都是资源需要被多个地方引用。一个模型可能在五个关卡中都被使用你不能每个场景都单独加载一份所以资源对象需要做引用计数。第一处加载到内存时计数为1新引用者增加计数引用者释放时减少计数计数归零时资源可以卸载。理论很干净实际麻烦主要在循环引用和异步加载的时序上。资源句柄这个设计特别重要。你不能让游戏逻辑直接持有资源裸指针因为资源可能在加载完成前还是空壳也可能在你用的过程中被人卸载。引擎通常会给每个资源分配一个全局ID游戏逻辑持有的是这个ID的强/弱句柄真正访问资源时通过资源系统按ID查表。这样资源系统做热重载、做引用管理、做版本替换都不会让上层逻辑立刻炸掉。异步加载的顺序坑我遇见过好几次关卡在异步加载场景模型玩家操作角色走进了还没加载完的区域此时模型是空引用。处理方式有两种要么让游戏逻辑等待加载完事件回调要么引擎把这个对象暂时从碰撞和渲染中隐藏直到资源就绪。我比较推荐后者因为它在用户体验上最平滑。稿件同步给喜欢偷懒写同步加载的人你图一时省事把异步改成同步阻塞结果是关卡加载画面一卡一卡玩家在切换场景时直接骂娘。它所省下来的那点开发时间完全不够弥补体验上的损失。5. 从 main 到第一帧完整启动链路拆解5.1 启动流程的六个阶段把整个引擎启动拆成阶段来看你会突然发现所有基础架构的知识都串起来了。一个典型引擎的启动流程大概是这个样子程序入口操作系统调用main或平台对应的入口函数这个阶段只做一件事创建平台应用对象准备日志系统。平台层初始化创建窗口获取显示设备信息初始化输入系统注册操作系统消息回调。核心层初始化内存分配器建立数学库自检基础容器池准备线程池启动日志系统接入文件输出。功能模块初始化渲染设备、物理场景、音频设备、资源系统依次Initialize资源系统在这个阶段把所有内置资源打包挂载进来。游戏层初始化读取启动配置根据默认关卡路径加载场景创建玩家对象和初始相机完成所有初始化注册表的依赖校验。进入主循环定时器归零开始执行帧循环直到系统收到退出消息再按启动时逆序关闭所有模块。第六步里有个小细节退出时必须等渲染提交完最后帧再销毁设备否则在窗口关闭的一瞬间可能因为资源被提前释放而出现黑屏闪烁。很多程序在窗口关闭时崩溃原因就是关闭顺序搞反了。5.2 一个帧内各阶段的职责速查把一帧内部再拆细一点你会得到下面这张常用阶段表。每个引擎的名字和排列可能不同但职责大体一致阶段职责典型工作内容Input收集玩家输入键盘鼠标手柄状态、触摸事件PreUpdate处理时序补偿和暂停逻辑对象池回收、缓存清理逻辑Update更新玩法数据和组件状态AI决策、角色移动、任务进度物理模拟步进固定步长模拟物理世界碰撞检测、刚体动力学积分LateUpdate依赖前序状态做收尾计算相机跟随、IK最终解算剔除与渲染准备确定可见物体列表视锥剔除、遮挡剔除、批次合并渲染指令提交把渲染数据提交给图形API生成DrawCall、更新GPU常量呈现与同步提交交换链、等待帧结束垂直同步、帧同步、性能统计收集你注意到没有物理模拟往往单独占一个阶段而且用的是固定步长这正好呼应了前面主循环里“逻辑和物理时间分离”的设计。渲染准备又在所有逻辑更新之后保证画出来的是这一帧最终的状态。一旦某个游戏功能在错误的阶段里插入计算最常见的症状就是角色明明已经转身了屏幕上渲染的还是转身前的姿势。遇到这种“半帧延迟”问题第一步就是检查对象是在哪个阶段更新的。6. 常见问题排查与架构演进建议6.1 三个我真正踩过的坑我在自己写引擎底层的时候遇到过几次特别典型的崩溃写出来给大家避避雷。第一个坑是初始化顺序崩。当时我在启动时先初始化了渲染模块渲染模块需要从资源系统加载默认shader但资源系统还没挂载VFS。结果就是引擎启动后一直出现“找不到默认shader”的报错可资源路径明明是对的。排查半天发现问题不是出在路径而是启动顺序。所以后来我规定资源系统和文件系统放在功能模块初始化的最前面渲染设备初始化必须排在它们之后依赖检查脚本直接在编译期守住这条规则。第二个坑是栈式分配器对象跨帧使用。之前在写调试绘制工具时画线的顶点数据用的是帧临时分配器结果把包含这些顶点的网格对象存在了缓存在容器里下一帧使用的时候数据已经被覆盖成垃圾。画面表现是有些帧能看到线有些帧完全看不到偶尔还会闪烁。定位到最后才发现是分配器生命周期的问题。从那以后所有临时分配器分配的内存我都要求开发者显式标注“不能跨帧持有”。第三个坑是引用计数资源在关闭时崩溃。整个场景销毁的时候很多组件都会释放它们持有的资源但我们没处理好资源系统内部的释放顺序导致一个资源已经被卸载另一个组件还试图访问它。这个问题看起来像是简单的计数不对实际是因为资源访问没有走句柄而是走了裸指针。从那以后凡是跨模块传递的资源一律要求传资源ID或者句柄禁止传裸指针。6.2 单线程到多线程引擎演进最值得先动哪里很多引擎一开始都是纯单线程的逻辑更新完后渲染系统一帧一帧地把数据提交给GPU简单稳定。但随着场景复杂度和CPU核数的增长单线程的负载会越来越吃紧于是大家开始想着怎么把系统拆到多线程上去。根据我自己的经验第一个不要先碰的地方是游戏逻辑线程它牵扯的状态太多了强行并行化大概率会陷入数据竞争和锁竞争的泥潭。更稳妥的路径是先做到“渲染与逻辑分离”逻辑在核心线程跑渲染线程只配合逻辑线程产出的数据做提交。逻辑帧结束时生成一份不可变的渲染快照渲染线程直接消费快照数据两个线程之间的同步点只放在每帧渲染提交之前。第二件值得动的事是资源加载。IO等待天然适合放进后台线程异步加载能显著减少主线程的卡顿。但你得先把资源系统改成先获取资源句柄、再异步填充数据的模型否则直接换成后台线程读文件主线程还是会在用到资源的地方傻等。第三件才是上Job System把物理、粒子、动画之类的可并行计算拆成细粒度任务。到那一步引擎里就得有像样的线程池、任务依赖和等待机制了。我的整体建议是不要为了炫技而过早引入多线程。很多小体量团队用单线程逻辑加上异步加载已经能跑出很好的效果。真正决定架构高度的不是你用了多少个线程而是你在模块边界、生命周期和资源流动这三件事上有没有守住干净的接口。从引擎基础架构这个角度看进去你其实掌握的不是某段代码而是一套思考游戏程序如何组织的框架。当你以后看到任何一款引擎不管它用什么语言、用什么平台都能迅速用这套框架去对照它的层怎么分模块怎么注册场景图怎么更新资源怎么管理帧循环怎么调度。把这些看懂之后再去啃渲染或物理那套具体算法你会发现脑袋里已经有一张可以挂载知识的地图了。我自己在带团队时最常说的话就是基础架构不是一个需要背的功能清单而是帮你在出事时快速定位边界的能力。它决定了你遇到一个诡异Bug时是在几分钟内缩小到某个模块里还是在几个模块的代码里来回撞墙。把这个基础打扎实了后面做功能就是往架子上放盒子越放越顺手。
返回列表