ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

游戏引擎基础架构深度解析:模块分层与帧循环的实践指南

游戏引擎基础架构深度解析:模块分层与帧循环的实践指南 聊游戏引擎大多数人第一时间想到的都是渲染——PBR、体积光、全局光照一张截图发出去确实唬人。但真正动手写引擎或者进到一个引擎团队里第一个逼你拍板的事情往往跟画面半毛钱关系都没有代码按什么方式组织模块之间怎么说话帧循环里谁先谁后。这些被统称为“引擎基础架构”的东西决定了后续所有渲染、物理、动画、资源系统能不能站稳。这篇是“游戏引擎架构深度解析”系列的第一篇先把基础架构讲透——不是贴一份目录而是把你从零搭一个引擎时真正要做的决策、踩过的坑以及背后的为什么都摊开聊一遍。适合正准备自研引擎、或者想搞清楚UE、Unity底层是怎么运转的读者参考。1. 先聊聊“为什么”引擎基础架构到底在解决什么问题1.1 模块边界不清后面全是灾难我在团队里见过太多自研引擎项目开局三个月就陷入泥潭。最典型的情况是渲染模块直接去物理系统里拿数据动画模块随手改场景图节点资源系统在加载的时候顺手Push了一条渲染指令。看起来是“为了效率方便一下”实际上每条跨模块调用都在悄悄埋雷。三个月后需求一变改物理的部分牵动渲染改渲染的部分又动了场景管理每次重构都是一场大型手术。这就是架构问题最直白的体现。游戏引擎本质上是一个由几十个模块组成的复杂系统输入、窗口、时间、内存、数学、场景图、资源、渲染、物理、动画、音频、网络、脚本、UI、工具链。如果这些模块之间没有明确的边界和通信规则整个项目的有效工作量很快就会被沟通成本和返工吞噬。边界清晰这件事不是强迫症是让引擎能长期演进的底线。那什么叫“边界清晰”核心就两条一是每个模块对外只暴露必要的接口内部实现细节不让别人碰二是模块之间的依赖关系必须是单向的、有层次的不能A依赖B、B又依赖A形成环状依赖。第一点靠封装意识就能做到一大半第二点则需要从上到下设计分层架构并且通过代码Review和自动化检查把它守住。1.2 一条实用的分层逻辑讲到引擎分层很多教科书会给一张复杂的分层图层层叠叠十来个框看着高大上实际落到代码里往往变成一团浆糊。我自己长期用下来觉得一个够用且不会过度设计的引擎按四层来划分就差不多平台抽象层、核心层、功能层、工具层。平台抽象层跟操作系统、硬件打交道封装窗口创建、设备上下文、文件系统、输入事件这些“换个平台就完全不一样”的能力。核心层是引擎的骨架时间系统、内存分配器、数学库、基础容器、事件总线都在这层。这层的特点是不依赖任何游戏逻辑独立性最强几乎每个模块都会用到它们。功能层跑的是具体业务能力渲染器、物理引擎、动画系统、音频、场景管理、资源管理器、脚本系统。这一层的模块可以彼此独立也可以根据需要少量依赖但都建立在核心层之上。最上面是工具层编辑器面板、资源导入器、性能分析器挂在这里它们面向开发者功能层和核心层不需要知道它们的存咋。我特别想强调依赖方向这件事上层可以依赖下层下层绝不反向依赖上层。核心层的数学库不知道渲染器是什么功能层的渲染器可以用数学库但数学库绝不会因为渲染器而改变设计。这条规则听起来简单实际操作中极容易被潜移默化地打破。比如你为了渲染性能在数学库里加了一个专门为GPU准备的矩阵变换接口这个接口里塞了渲染器的类型定义数学库的独立性就在这一刻被污染了后面再想抽出来做单测、给工具链复用全得返工。1.3 架构不是一上来就要“最先进”这几年经常看到“微服务架构”“分布式架构”之类的词在各行各业刷屏游戏引擎领域也有人喜欢对标。说实话游戏引擎完全没必要照搬服务化那套思维它跟互联网后端问题域完全不同。引擎跑在单机进程里模块间调用频繁、延迟敏感、共享状态多强行拆成服务化的隔离体系不但没有收益还会把性能拖垮。真正适合引擎的架构风格是“分层单体”一个进程、一套模块、清晰的依赖方向在各模块内部再通过接口隔离出可替换的边界。UE、Unity、Godot本质上都是这个路子只是封装深度不同。另一个容易踩的坑是“一上来就多线程”。我看到不少新手引擎把架构图里塞满了线程池、Job Graph、无锁队列觉得这才现代。但你如果还没有把单线程的帧循环跑通多线程只会把问题放大十倍共享资源的竞态、渲染线程和逻辑线程的同步、加载线程的延迟注入每一个都够你调试一整周。我的建议是架构设计时留好并行化的接口和边界但第一版老老实实跑单线程把每一帧该做的事按顺序理清楚后面再逐步把可并行的大块工作摘出去。2. 引擎的地基四个绕不开的核心子系统2.1 时间系统所有模块都在等一个可靠时钟游戏和普通的业务程序有一个本质区别游戏世界有自己的时间流速并且需要在不同的帧率下表现一致。操作系统提供的时间接口只能给你“当前时刻”不能替你回答“上一帧到这一帧到底过去了多久”更不能帮你处理“帧率不稳时物理和逻辑该怎么办”。所以引擎基础架构里时间系统永远是第一个要定下来的核心子系统其他模块都要从它拿节奏。我在自研引擎里实现时间系统时的做法是维护一个全局单例逻辑上用“累计时间”和“帧间隔时间”两个变量。每一帧开始时从系统时钟读取高精度时间戳用当前时刻减去上一帧时刻得到原始帧间隔得到间隔后先做一个裁剪处理限制最大帧间隔不超过250毫秒。为什么必须裁剪因为当编辑器断点命中、窗口被拖动、系统短时阻塞时操作系统给你的帧间隔可能高达几百毫秒甚至几秒如果你不做限制让物理系统拿这个间隔去积分一帧之内物体就能直接穿墙飞出去整个模拟直接崩坏。在GLFW、SDL这类底层窗口系统里获取帧间隔通常是在主循环开头调用glfwGetTime()或者读取SDL_GetPerformanceCounter()然后和上一帧的值做差。这里有一个容易被忽略的细节“帧间隔”要在每一帧刚刚开始时才算不能在帧末算。因为帧末算出来的间隔包含了你这帧自身的渲染耗时逻辑系统拿到它做物理步进时相当于把渲染卡顿又叠加了一次延迟逻辑和渲染的节奏会互相拉扯表现出来就是鼠标轻微发飘、镜头跟手但世界延迟半拍。2.2 内存架构为什么引擎要自己管内存普通应用程序很少会操心内存分配但游戏引擎是一门“分配命运”的行当。帧循环里每秒钟要处理成百上千个临时对象、多帧要重用的网格资源、每帧都要诞生的粒子实例和临时变换矩阵。如果全走系统默认的malloc和new配合C常见的多线程分配器会产生两个问题碎片化越跑越严重分配耗时抖动导致帧时间不稳定。现代操作系统内存分配器做得很聪明但依然不是为“每帧大量分配小对象然后快速释放”这种模式设计的。引擎基础架构里的做法是搭一套多级内存分配体系。最常用的是栈式分配器Stack Allocator和池分配器Pool Allocator。栈式分配器逻辑上也叫线性分配器申请时只移动一个水位指针释放时整块回滚每一帧结束后把水位一帧重置归零。这样帧内临时对象的分配开销极小几乎就是一次指针加法而且天然避免碎片。池分配器则是预先把一批等尺寸的内存块挂在一个空闲链表上需要时取出一块用完归还按固定尺寸做缓存复用适合网格顶点、材质参数这类反复创建销毁的对象。这两种分配器加起来能覆盖引擎里七八成的高频分配场景。用自家分配器意味着所有类都要走统一的分配入口这就是为什么引擎代码里大量使用**new (allocator)** 这种放置式构造或者直接对底层内存块手动构造析构。给一个最简单的示意class StackAllocator { public: explicit StackAllocator(size_t totalBytes) : m_begin(static_castuint8_t*(std::malloc(totalBytes))), m_end(m_begin totalBytes), m_current(m_begin) {} uint8_t* allocate(size_t size, size_t align) { uintptr_t addr reinterpret_castuintptr_t(m_current); uintptr_t aligned (addr align - 1) ~(align - 1); m_current m_begin (aligned - reinterpret_castuintptr_t(m_begin)) size; if (m_current m_end) return nullptr; // 超限 return reinterpret_castuint8_t*(aligned); } void clear() { m_current m_begin; } // 帧末整块重置 private: uint8_t* m_begin; uint8_t* m_current; uint8_t* m_end; };注意这里有一个设计取舍栈式分配器不能单独释放中间某一块因为它根本不知道块与块之间的边界。所以它的典型生命周期是“一帧一清”配合每帧重建的临时数据使用。如果你在某个子系统中创建了一个需要跨帧存活的对象却顺手丢进了栈式分配器那下一帧整块重置时这个对象就变成了悬垂指针踩内存的坑几乎立刻出现。这是我在实际项目里烤过的第一块焦黑的代码。2.3 数学库与基础容器看起来不起眼实际不能省引擎基础架构里最容易被低估的是数学库。很多初学者觉得数学不就是一个Vector3、一个Matrix4再加上几个点乘叉乘吗两三周就能写完。但真正跑起来你会发现渲染要SIMD加速的矩阵运算物理要带误差容忍的近似函数动画要四元数插值的高级实现编辑器要双击拾取时的射线求交。如果数学库在设计阶段没有把内存布局、对齐方式、运算约定定清楚后面每个子系统都会被迫写一套自己的变体数学库从“公共底座”变成“四不像杂物间”。我在自研项目里给数学库定过几条规矩第一所有三维向量、矩阵、四元数统一采用与渲染API匹配的存储布局按列主序遍历float4内存对齐到16字节方便后续直接映射到GPU常量缓冲区第二数学库内部只依赖标准库的标量运算任何平台相关的优化都用条件编译包裹起来不让平台代码倒灌第三库里就提供一个够用的基础容器集合比如TArray、THashMap、TSet不优先追求接口花哨但一定保证连续内存布局以及和分配器的对接能力。这些规矩看着简单真正执行起来很考验定力。比如有人为了一个特殊算法在Vector3里塞了个double类型的临时成员整个类的内存大小从16B变成24B所有数组的对齐全部错位GPU上传数据时直接产生不可见的错乱。数学库是那种“改动小、爆炸慢”的模块一旦埋了错可能要过好几层系统之后才会以图形闪烁、物理跳变的形式暴露排查成本极高。所以基础库的代码Review标准我坚持比其他模块严一个档位。2.4 事件系统模块之间少一点“你拉着我、我拽着你”如果不同模块不能互相直接调用那它们要协作时怎么通信答案基本都落到事件系统上。事件系统的本质是解耦A模块发出“主角死亡”事件B模块和C模块各自去订阅、各自处理A完全不需要知道B和C的存在。这样新增一个对事件感兴趣的系统时不需要改动A的一行代码符合开闭原则。引擎里的事件系统有两种常见形态。一种是同步委托注册时绑定回调函数地址事件触发时挨个执行性能好但在回调里如果触发了别的事件容易形成重入和嵌套调用逻辑复杂时排查困难。另一种是延迟消息队列事件先封装成消息扔进队列在帧循环的固定阶段统一分发。这个方案避免了同步回调里的深度嵌套但事件不会立刻生效需要接受一个帧的延迟。我的建议是核心逻辑路径上的紧急信号用同步委托例如输入响应跨模块、低频但对于即时性要求不高的事件例如“资源加载完成”“关卡切换开始”走延迟消息队列。两种形态并存是商业引擎最普遍的选择。实现事件总线时要注意一个新手极容易犯的错订阅者在事件触发途中被销毁。比如A对象在事件回调里把B对象释放了而B恰好也订阅了这个事件事件总线下一个就该通知B结果拿到一个悬垂指针直接崩溃。经典解决方案是给每个订阅者一个唯一ID触发前对订阅者列表做一次有效性校验或者干脆采用智能指针持有订阅者确保回调期间对象不会早逝。这种细节只会在长跑项目里感受到它有多值钱。3. 帧循环与Tick机制引擎的心脏怎么跳3.1 可变步长和固定步长两个流派两种代价引擎能不能跑得“稳”决定性因素之一就是帧循环里物理和逻辑更新到底用可变步长还是固定步长。可变步长简单直接每帧的deltaTime是多少逻辑就用多少去推进代码少、反应快、实现难度低但物理模拟在这种方式下很容易因为帧间隔波动产生不稳定的积分结果同一次跳跃每次跳出的距离都略有差异角色移动也容易在帧率变化时出现可感知的“黏滞感”。固定步长则是把逻辑更新切成一个个等长的时间切片每个切片比如1/60秒或1/120秒物理和逻辑按固定步进累积渲染则可以按实际帧率插值呈现。这样做的好处是物理模拟确定性强、结果可复现多人联机时逻辑表现的差异更小代价是逻辑要跟真实时间对齐如果真实帧率低于固定步长同一帧内可能要执行多次逻辑更新容易形成“螺旋效果”帧率越低CPU开销反而越高。实际引擎里最常用的折中是做一个时间累加器Accumulator每帧把真实间隔累加进一个变量只要累加器超过固定步长就走一次逻辑更新并把累加器减去步长。核心代码大致长这样void EngineLoop::TickFrame(float realDeltaTime) { m_accumulator std::min(realDeltaTime, 0.25f); // 防螺旋 while (m_accumulator m_physicsStep) { World::Tick(m_physicsStep); // 固定步进物理/逻辑 m_accumulator - m_physicsStep; } float alpha m_accumulator / m_physicsStep; // 插值因子 Renderer::RenderScene(alpha); }这里alpha的意义很关键由于渲染的实际间隔往往不等于固定步长画面上一时刻的逻辑状态其实介于上一次和下一次更新之间。拿alpha对位置、旋转做一次轻量插值能显著平滑渲染表现。这也是为什么许多引擎里“渲染位置”和“逻辑位置”是两个分离的量逻辑位置按固定步长更新渲染位置在绘制前根据alpha做一次插值视觉上就丝滑得多。3.2 Tick顺序为什么渲染永远不是第一步新手写引擎主循环最容易写成的样子是先画背景、再更新逻辑、最后画模型因为大脑里“先画后动”的直觉根深蒂固。但成熟引擎的帧循环几乎无一例外把渲染放在最后先处理输入然后固定步长更新逻辑和物理再更新动画和场景图等这些状态都算完了最后才组织渲染指令提交。原因很简单渲染看到的必须是这一帧的最终状态如果你先渲再算画出来的是上一帧的逻辑结果玩家会明显感觉到操作和画面之间存在一帧的滞后在快速转动镜头时尤其严重。一个简化的Tick顺序表会是这样接收窗口和输入事件更新输入状态把移动、旋转等指令转化为逻辑意图然后跑固定步长的物理与逻辑更新包括刚体推动、触发器检测、玩法规则判断接着更新动画系统采样骨骼姿态并更新蒙皮矩阵再更新场景图的空间变换把父节点变化传递到子节点最后才进入渲染阶段视锥剔除、渲染对象收集、提交绘制列表到GPU。渲染完成之后还可以在帧末做一次性能统计和调试绘制丢给下一帧的开头去消费。这个顺序还有个隐藏好处如果渲染放在最后那么渲染阶段里做的任何耗时操作——比如某个着色器编译导致卡顿——都不会污染本帧之前的逻辑结果下一帧开始计数时会把这次耗时吸收进去逻辑和物理状态不会和渲染卡顿纠缠在一起。如果反过来把渲染放中间一次编译卡顿就会让后续逻辑更新拿到的间隔变得异常物理模拟跟着一起跳跳到什么结果完全不可预期。3.3 多线程起点渲染线程与Job System单线程帧循环跑通之后大多数人想迈出的第一步就是多线程。但引擎的并行化不是简单的“把循环拆开”更靠谱的路径是先拆两条线一条渲染线程一条逻辑线程。逻辑线程跑输入、物理、动画、玩法更新渲染线程专门做渲染命令提交、GPU资源更新。两线之间通过命令缓冲区通信逻辑线程在帧内产生一堆渲染命令塞进环形缓冲区渲染线程把它们消费掉并驱动GPU。这个设计最大收益是渲染的耗时波动不再直接拖慢逻辑更新镜头卡顿的传播范围被隔断了。现代引擎里更进阶的做法是引入Job System、Task Graph这样的细粒度并行框架。每个系统把大任务拆成若干小任务丢给线程池去执行任务之间没有依赖关系的可以并行。UE的Task Graph和Unity的Job System都是这个思路。在自研环节我不建议第一版就搞一整套无锁任务队列从一个主线程加一个工作线程池、用互斥锁保护任务队列开始就够了。实测下来先稳定跑通任务调度再逐步换成无锁实现比一步到位省事得多。我踩过的教训是分工之后交叉引用就成了重灾区。逻辑线程要读渲染线程的GPU资源状态、渲染线程要拿逻辑线程的场景数据这种双向依赖一旦出现除了加锁没有别的办法而锁一旦加多并行收益就被抹平了。所以架构上一定要强制约束数据的单向流动逻辑线程产出的数据是渲染线程的输入渲染线程绝不反向修改逻辑状态任何反馈统一走事件系统延迟回投。4. 平台抽象层与跨平台一门心思搞抽象4.1 三类最基础的平台能力窗口、输入、文件跨平台是自研引擎里最烦琐的工程窗口和输入首当其冲。Windows下建窗口要走Win32 APILinux下要么X11要么WaylandmacOS下则是Cocoa那一套。如果这些直接散落在主循环里那引擎就和某个特定平台焊死了后面想加一个新平台等于重写半个引擎。平台抽象层干的事情就是把“创建窗口”“处理消息”“读取输入设备”这些能力统一成一个接口各平台各自实现一套。窗口抽象上我的做法是先定义一个IWindow接口里面只有几件事创建窗口、设置标题、调整大小、翻转显示、获取原生句柄。然后在Win32、Linux、macOS各写一个子类实现。对于输入层我不直接暴露“按键按下”这种平台消息而是抽象成“输入事件流”鼠标移动产生位移增量键盘产生按键ID手柄产生轴值。游戏逻辑层只认这个经过归一化的事件流不关心底层是哪个系统上报的。文件系统的抽象同样关键。引擎的资源路径、存档路径、缓存路径在不同平台的约定完全不同随便写死一个C:/前缀的代码放到移动端就全崩。比较稳妥的做法是封装一个IFileSystem提供ReadFile、OpenStream这类接口内部按平台去映射实际根目录在Windows下返回当前工作目录在移动端返回沙盒目录。这个抽象还能顺手解决热更新和补丁的路径优先级问题实现“先查补丁目录再查原始资源目录”的统一资源定位。4.2 渲染API抽象为什么每家引擎都要做RHI渲染是引擎里最贴近硬件、同时又是最讲究可移植性的一块。OpenGL、Direct3D、Vulkan、Metal四套API的编程模型差别巨大D3D12和Vulkan讲究显式资源管理OpenGL和Metal各自有自己的一套状态机。如果渲染层直接和某一套API绑定跨平台就是一句空话。所以引擎基础架构一般在渲染模块里加一层渲染硬件接口层RHI对内提供统一的资源创建、渲染状态设置、绘制命令提交接口对外针对不同API写适配实现。RHI层至少要抽象这样几类对象着色器、顶点缓冲区、索引缓冲区、纹理、渲染目标、管线状态对象以及一个命令列表或命令缓冲的抽象。它不直接决定画什么那属于上层的渲染管线和Pass设计它只负责把“画点什么”翻译成具体API的调用。设计RHI时最大的坑是接口粒度太细每种API都要改一圈接口适配工作量爆炸太粗又无法发挥各API的特长比如无法暴露Vulkan的显式屏障管理性能就被压住了。我自己选择的度是“管好资源和状态提交放开同步细节”RHI层只管资源的生命周期和一次命令行提交的组装管线屏障和资源状态迁移由各平台后端自己处理。4.3 构建系统与模块依赖架构的另一半在代码之外架构不止存在于运行期编译期的构建组织同样是基础架构的重要组成部分。现代引擎普遍采用CMake或Premake这类构建生成工具来管理模块每个模块单独编译成静态库或动态库并显式声明自己对其他模块的依赖。依赖声明写清楚了编译器才能帮你守住模块边界——链接错误会毫不留情地指出你悄悄漏下或者多引的模块这相当于给架构上了一道机器检查的锁。我的习惯是给每个模块建立一个统一命名的CMake目标例如EngineCore、EngineRHI、EngineRenderer模块间的头文件引用只允许往依赖方向走。再配合一个简单的脚本扫描头文件包含关系把“谁包含了谁”生成依赖图挂在CI上。一旦发现渲染模块包含数学库的私有实现头或者版本上出现循环依赖构建直接失败。这套做法听着麻烦但在项目膨胀到几十个模块之后它会帮你节省海量的盘查时间。平台的宏定义管理也是构建层的重灾区。跨平台代码里常见的#ifdef _WIN32散落各处久了根本理不清。比较干净的做法是把平台判断集中在平台抽象层一个头文件里输出一套统一的宏或枚举比如PLATFORM_WINDOWS、PLATFORM_LINUX、PLATFORM_MAC。业务模块只管用统一宏不碰各平台SDK的原生宏。这样以后要支持一个新平台只需要在抽象层里补一份宏映射再补齐平台后端实现其余模块甚至不用改动。5. 实操中的坑与排查技巧5.1 常见问题速查表我梳理了几个自研引擎过程中几乎必遇的问题做成一个速查表方便你之后对照排查问题现象可能原因排查思路物理表现随帧率变化用了可变步长做物理积分改固定步长配合时间累加器偶发内存越界崩溃点随机栈式分配器对象跨帧存活检查对象的分配器归属确保跨帧对象不走帧分配器事件回调中对象销毁导致崩溃订阅者生命周期没管住给订阅者唯一ID触发前校验有效性多线程后画面撕裂、逻辑错乱逻辑线程和渲染线程互相修改状态强制单向数据流渲染只读逻辑产物打开新平台编译失败报错点遍地平台宏散落在业务模块里平台判断收敛到平台抽象层输出统一宏渲染帧率正常但输入延迟明显渲染提前于逻辑更新调整帧循环顺序渲染放最后长时间运行后内存占用缓慢上升某个池分配器没有归还对象在帧末做分配器统计检查未释放对象数量实际排查序列里我习惯先看时间系统好多怪异表现诸如攻击判定漂移、动画错位、物理穿透追根溯源最后都是时间步进策略没有统一。尤其在刚切换成固定步长时经常出现“逻辑一秒走60步动画一秒走30帧”的错位这时候优先确认动画采样是否用到了alpha插值。5.2 我自己的几条架构避坑心得第一不要过早抽象。每写一个接口都要考虑“我真的需要为它写第二套实现吗”如果只是理论上觉得以后可能跨平台、可能换渲染API现在只有一个平台一个API那就先不抽象用具体类等真出现第二个消费者再重构。过度抽象有一个隐蔽代价它会把原本简单直接的调用链拉长给新手理解代码增加负担也让调试时多出好几个跳板。第二先跑通再并行。我见过有人花了两周搭任务系统结果一启动渲染全黑最后发现场景图更新和渲染命令提交都在抢同一个容器根本原因是单线程版本还没把数据流理清楚。先把单线程帧循环跑出稳定画面再逐个把耗时大项搬进工作线程每一步都对照Profiler确认收益。这样每多一条线程系统都能保持可运行状态排查范围也被控制住了。第三为调试工具留钩子。基础架构阶段就要考虑时间系统要能暂停、单帧推进、加速内存分配器要能在启动参数下统计各类型分配总量事件总线要能录制事件流水。这些听起来像额外工作量但真到出问题那天你会庆幸留了这些后门。调试架构不是架构的附属品它本身就是架构的一部分。我在自研引擎里被一个隐藏很深的跨平台字节序问题折磨了两天最后靠分配器统计排除了内存问题又靠事件流水锁定了调用链。没有这些工具那两天会变成两周。写这个系列的第一篇我自己感触最深的是引擎基础架构不像渲染或者某个具体游戏玩法那样能立刻拿出一个让人眼前一亮的成果它更像是一栋楼的承重墙——看不见但没有它上一层刚盖好下面就得塌。你今天在时间系统、内存分配、事件总线、平台抽象这些看起来收发工具一样的模块上多花的心思会在几个月后的某次重构、某个跨平台适配、某次疑难性能问题里一笔一笔地还给你。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进