☰
引擎基础架构的关键决策:分层、主循环与内存管理
2026/10/12 3:43:54 网站建设 项目流程

做引擎架构这件事,最困惑我的一个问题是:明明整个项目都能跑,为什么读别人的引擎源码就像走进一栋没有电力标记的大楼,每扇门背后都通向一个未知的房间?后来我自己动手拆过几个轻量引擎,也接手过被改得乱七八糟的旧项目,才慢慢意识到,所谓"基础架构"并不是一堆代码文件的拼凑,而是几个非常干净的决策:谁在什么时机运行、谁允许知道谁的存在、以及谁掌握内存和资源的生死。

这篇内容适合两类人,一类是准备入行引擎开发、想知道那些大引擎内部到底怎么编排的初学者,另一类是写过游戏逻辑但屡屡被耦合拖累、想回头梳理架构边界的客户端开发者。我不会从DX或者Vulkan的接口讲起,那属于渲染模块的战役,这里只聊引擎底层的地基——也就是哪怕你做的是一个一夜之间流行的小游戏,也逃不开的那几个结构性问题。

1. 分层边界:引擎为什么不是一张网,而是一摞盘子

先回答一个最常见的问题:引擎到底是不是"把渲染、物理、音频全放一起然后互相调来调去"?如果你维护过一个项目,你肯定见过这种大杂烩的运行方式。但它不是架构,它只是堆代码。成熟的引擎架构,第一眼看起来会非常像一摞盘子,每一层只允许跟相邻的层打交道,不允许跨层乱拽。

1.1 我习惯从下往上画这五层

底层是最容易被忽略的平台抽象层(Platform Layer)。这个层专门包装操作系统差异:窗口系统、鼠标键盘输入、手柄、文件路径、线程同步原语。它的存在意义很简单:上层代码里永远不许出现DirectX专属句柄、Windows.h的打开文件对话框或者Linux的X11窗口句柄。我自己在做跨平台工具时,曾经因为一个文件监听模块里混进了平台API,导致后来换到另一套工具链时整个模块重写,这个教训很深刻。

平台抽象层之上是核心层(Core Layer),包含数学库(向量、矩阵、四元数)、容器(动态数组、哈希表、字符串)、内存分配器、日志与诊断工具。这一层是纯数据结构和算法,不关心你渲染的是人物还是方块。

再往上是功能模块层(Feature Layer),渲染器、物理引擎、音频引擎、动画系统、网络模块都住在这儿。它们一般以库的形式存在,每个库有明确的外向接口。比如渲染器对外只提供DrawMesh、SetCamera、SubmitFrame这类语义,物理引擎对外只提供AddRigidBody、Raycast这类语义。这两个模块彼此通常不应该认识对方。

功能模块层之上是场景管理(Scene Management)。场景是最容易跟"游戏逻辑"混淆的东西,它实际上是"世界状态的容器":哪个物体在哪、坐标多少、旋转多少、身上挂了几份组件。场景管理关心的是组织这些数据,而不是执行逻辑。再往上一层才是应用/游戏逻辑,也就是策划脚本、Boss AI、技能系统那些东西。

1.2 为什么必须是"相邻依赖",而不是"谁方便谁调"

很多人写代码有个习惯:反正渲染器拿不到物理结果,那我直接让渲染器去读刚体列表不就完了?反对这种做法的理由不是洁癖,而是依赖方向决定了项目能不能并行开发、能不能局部替换、能不能测试。

举个我实际遇到的例子。某个模拟项目为了省事,让渲染模块直接遍历物理模块的碰撞体列表来画调试线。结果某次物理引擎内部把碰撞体数据结构从链表改成紧凑数组,渲染模块那边就因为遍历方式不同直接崩了。问题不在于谁对谁错,而在于一个渲染层不该知道物理层的内部存储细节。正确的做法是物理模块对外提供"查询接口"或者"调试绘制回调",渲染模块只消费这些规范的输出。

我在架构里最信奉的一条规则是:数据所有权就近,访问权向下开放。场景管理者不直接向渲染模块暴露"我正在遍历的节点数组",而是暴露"场景里现在有哪些静态网格、哪些变换矩阵需要同步"。渲染模块不需要知道这些网格是从磁盘加载的还是脚本生成的,它拿到的是一份已经整理好的"渲染输入"。这样做分层,每一层都能单独替换、单独测试。

2. 主循环:引擎心跳为什么必须由你亲自控制

如果说分层是引擎的空间结构,主循环(Game Loop)就是引擎的时间结构。所有引擎都围绕一个死循环转:处理输入、更新世界、提交渲染、等待垂直同步、再回到输入。看起来简单,但循环里的每一个决策都影响手感、性能和功耗。

2.1 固定步长还是可变步长,这是第一道分岔

可变步长最简单:每帧计算deltaTime = currentTime - lastTime,然后所有更新逻辑都用这个deltaTime去乘。好处是迭代快,逻辑好写;坏处是物理模拟不稳定——当deltaTime忽大忽小,弹簧系统会发散,角色会抖动,网络同步也需要特殊处理。

固定步长则是把时间切成固定的小块(常见是1/60秒,也就是大约16.67毫秒)。每次主循环先算"从上次到现在过去了多少时间",然后累加到一个时间缓冲器上,只要缓冲器里累积的时间超过一个固定步长,就执行一次固定更新,一次可能执行多次,直到时间不够为止。

我在实际项目里通常采用混合方案:逻辑更新用固定步长,渲染插值用可变因子。固定步长保证逻辑的确定性,渲染层根据"上一次逻辑更新和下一次逻辑更新之间的比例"做插值,这样哪怕逻辑每秒只跑 30 次,渲染仍然能撑到 60 帧,画面既稳定又顺滑。

这里有个容易忽略的细节:固定步长的值不能随意设。如果你的游戏是针对60Hz显示器做垂直同步,逻辑步长设成 1/60 秒会让你每帧恰好执行一次逻辑;但用户换到 144Hz 显示器,同样的固定步长时间参数,每帧就要执行两三次逻辑,性能开销直接翻倍。所以更稳的做法是把逻辑步长定在 1/120 秒甚至更低,让不同刷新率下执行次数趋于一致,再用插值补偿视觉效果。

2.2 输入采样时刻决定手感

很多自研引擎手感差,根源不在物理参数,而在主循环里没有固定输入采样点。我的经验是:输入必须在每一帧的"逻辑更新"之前统一采样一次,并作为这帧逻辑的输入快照。

如果不做快照,玩家在一个循环里按下跳跃、又在同一个循环末尾松开,逻辑模块在不同时机读取输入时会出现"这帧按了但没跳起来"的怪现象。我自己就遇到过:在回调里直接改键位状态,导致同一个事件被多个模块分别处理时各拿各的理解,最后角色行为看起来像抽风。后来把输入抽象成"每一帧生成的按键状态数组",各模块只读这份快照,问题就消失了。

2.3 循环顺序不能乱排

一个标准的引擎循环顺序是这样的:

  1. 采样输入(Input)
  2. 运行日志与调试界面(可选,但要在更新前)
  3. 推进动画(Animations)
  4. 唤醒与步进物理(Physics)
  5. 游戏逻辑脚本(Script/Gameplay)
  6. 处理相机(Camera)
  7. 渲染提交(Render)
  8. 呈现(Present)

物理必须在逻辑之前,因为大多数游戏逻辑需要依赖物理的结果(比如角色是否落地)。渲染必须在所有逻辑之后,因为渲染需要拿到最终的世界状态。动画则在物理之前,因为动画驱动骨骼,物理需要知道骨架的位置。

有一个坑是:如果你在更新逻辑里生成了新的物件,而这个物件需要立刻参与物理或渲染,那它的初始化顺序就会跟循环结构产生冲突。常见的处理办法是延迟生成,把所有"创建新实体"的请求收集起来,在循环末尾统一处理,避免在物理步进中途改世界。我在某次项目里没有延迟创建,结果一个机关的子弹在生成瞬间穿透了墙壁——因为物理步进时子弹还没被加进碰撞检测列表。

3. 内存策略:引擎性能的秘密藏在分配器里

游戏开发者跟普通后端开发者最大的不同,是对内存分配有近乎偏执的洁癖。原因很简单:malloc是慢的,还可能产生碎片;更可怕的是,大量小内存申请的缓存不友好,会让整个 CPU 变成等内存的空转机器。

3.1 帧内存与环形分配器

我第一个建议是,在引擎里单独划出一块"帧内存"(Frame Memory)。它的规则非常原始:只往里塞,不往外释放,每帧结束整体重置。用途是那些"这一帧用完就扔"的临时数据,比如 UI 排版产生的顶点、调试绘制的线条、碰撞查询的结果容器。

实现上可以用一个巨块数组加一个偏移指针,每次分配就是"给当前偏移,偏移往后挪"。一帧结束后,把头指针归零。这种分配器每秒可能执行几千次分配,但实际每次分配只是移动一个整数,速度比通用malloc快一到两个数量级。

当然它有个显而易见的限制:你不能在这块内存里存放跨帧存活的数据,否则下一帧指针回绕就把你上一帧的东西踩了。所以使用它的人需要心理清楚,"我这块数据生命周期有多长"。

3.2 对象池解决的是弹幕和粒子这类"频繁诞生又频繁死亡"的对象

游戏里最常见的开销杀手是粒子系统。每帧几百个粒子诞生、动画、消亡,如果每次都new/delete,分配器的锁和系统调用能吃掉不少帧时间。对象池的做法是:在启动时预分配好一定数量的粒子对象池,粒子"诞生"时从池中拿一个空闲元素,池子头尾指针移动一下;粒子消亡时把元素放回池子,不用释放内存。

我经常把对象池跟紧凑数组结合起来,让存活的粒子连续存放在内存里,方便渲染层整块读取。释放就采用"Swap-Remove":把要移除的元素和最后一个元素互换位置,然后让数组长度减一。这样遍历时颗粒度紧凑,CPU缓存命中率也更高。这个模式不仅适合粒子,还适合投射物、敌人尸体、标记点,只要对象类型具备"短暂、数量多、结构固定"的特征。

3.3 缓存友好性:真正的性能瓶颈是数据在内存里的摆放

代码优化的天花板往往不是算法复杂度,而是缓存行(Cache Line)。一个 CPU 缓存行通常是 64 字节,如果遍历一个对象数组时,每个对象的大小是 200 字节而用到的字段只有前 16 字节,那么每一次访问都会把整条缓存行拉进来,但只用了 8% 的数据,这是极大的浪费。

引擎架构层面的应对是 Data-Oriented Design(数据导向设计),核心思想是把相同的字段放到连续内存。比如所有角色的位置放在一个 float 数组里,所有速度放在另一个数组里,而不是给每个角色建一个结构体。这样物理系统暴算位置时,CPU 拉进来的每一个缓存行都是纯粹的位置数据,计算效率能提升好几倍。

我在重构一个上万AI角色的模拟系统时,把"对象数组"拆成了"位置数组+速度数组+朝向数组",同样的硬件配置,运行帧率从二十多帧提到了接近满帧。这不是玄学,是缓存命中率的胜利。

3.4 虚拟内存预留:避免大世界加载时的抖动

做大型场景加载时最容易踩的坑是:每次加载新区块都实时向操作系统申请大块虚拟内存,导致卡顿。成熟的做法是在引擎启动时提前向操作系统"预留"很大一段地址空间(Reserve),但不提交物理内存(Commit),运行时按需提交小段。

简单说,预留是一个签名,承诺"这块地址我以后要用",提交是真正把物理内存页挂上去。这样后续加载区块时,大部分操作是提交已经预留好的地址,不会频繁触发地址空间搜索和页表重构,能显著减少加载尖峰。做流式开放世界时这个设计几乎是标配。

4. 资源与生命周期:谁加载、谁持有、谁卸载

一个合格的引擎必须解决"资源管理"这个很无聊但十分致命的问题。游戏运行时需要加载模型、贴图、音频、动画、材质、配置文件,如果谁加载谁管理,项目一跑起来就是事故现场。

4.1 用ID取代路径引用

资源系统第一件事,是给每一个资源一个稳定、与文件系统无关的标识符(通常是一个哈希ID或GUID)。游戏逻辑里引用资源时不写"贴图路径/Icon/004.png",而是写一个哈希ID;渲染层拿这个ID到资源表中查询。

这么做的好处首先是重构友好,文件路径随便改,代码不用动。其次是引用语义清晰,不会出现两个模块各自加载同一资源、内存里存两份贴图的情况。资源系统内部有张全局映射表:GUID -> 资源的运行时描述符,描述符里存了物理数据指针、引用计数、加载状态等。

4.2 异步加载和依赖图:不要等文件慢慢读

如果一个角色要等它的模型、贴图、骨骼动画全部加载完才出现,玩家会看到明显的卡屏。好一点的引擎都提供异步加载接口:发起加载请求后立刻返回一个句柄,加载在后台线程执行,等数据准备完毕后再通知主线程切换。

这里真正的复杂度是依赖图:加载一个地图区块可能要求先加载地形数据、然后加载引用该地形的植被模型、最后加载植被材质里引用的贴图。资源系统必须有能力追踪这种依赖链,并在所有依赖都就绪后回调"资源可用"。

我自己写过一个简化的做法:用一个资产清单文件描述每个资源的依赖,加载时先读清单,建一个待加载队列,用拓扑顺序依次加载,每个资源带一个"依赖就绪计数",归零时触发加载完成的回调。这个设计虽然简陋,但让我彻底理解了为什么引擎会花大量精力维护一个"资源工作图"。

4.3 引用计数与所有权:谁该负责卸载

资源卸载是另一个大坑,规则必须非常明确。我采用的原则是:加载资源的模块不一定是释放资源的模块,释放由资源管理器统一调度。所有资源都带引用计数,游戏逻辑在需要时增加引用,不再需要时释放引用。资源管理器会定期或按阈值清理引用计数归零的资源。

但引用计数有个麻烦:循环引用。A 资源引用 B,B 又引用 A,两边都不释放,内存就泄漏了。引擎层常用"强引用+弱引用"的组合来打破循环。比如材质强引用贴图,贴图对外只提供弱引用,材质销毁后贴图失去唯一强引用,自然被回收。

关于"流式加载"(Streaming),我的建议是:不要只做"加载卸载"两个状态,而要做"未加载->正在加载->可访问->待卸载"四个状态。待卸载状态允许引擎延迟几帧真正释放资源,这样玩家突然回头时资源还能快速复用,不会因为一次物理内存回收就卡顿。

5. 模块通信:让渲染、物理、脚本不互相"拉踩"

分层和内存搞定后,下一个真正影响团队协作和开发效率的问题是:模块之间怎么通信。很多引擎项目从外部看功能齐全,内部却是一团乱麻,正是因为模块之间在绕过边界直接调用。

5.1 直接调用、事件总线、数据驱动三种方式都有适用场景

我见过三种主流的模块通信方式。

第一种是直接接口调用,比如脚本里调用物理接口AddForce,这是最直白、性能最高、排错最容易的方式,适合"强关联、高频、实时"的调用。缺点是一旦调用关系泛滥,依赖就变成蜘蛛网,所以接口应该定义在模块的公共头文件里,模块内部实现不对外暴露。

第二种是事件总线(Event Bus),适合"谁关心谁去听"的场景。比如游戏里"机关被触发"这个事件,可能被粒子系统、音效系统、任务系统、关卡情绪系统共同关心,与其让"机关代码"分别调用四个系统的方法,不如只发一条TriggerEvent,各系统自己订阅。事件总线的优点是解耦,缺点是不容易跟踪调用链,调试时比较费劲,所以不适合用在每帧都会发生的性能敏感路径上。

第三种是数据驱动,即模块之间不直接调用,而是通过共享数据结构表达意图。比如物理模块每帧把"所有刚体变换"写入指定内存区,渲染模块直接读这块。这个方式最接近现代 ECS 的做法——数据是组件,逻辑是处理数据的系统。好处是缓存友好、并行度高,坏处是需要参与者严格遵守数据协议,一旦协议变动,整改成本很高。

5.2 一个推荐的通信姿势:接口放在公共层,实现放在私有层

与其纠结用哪种通信方式,不如先把规则定死:模块间可调用的接口只存在于公共头文件中,模块的类定义和函数实现完全私有。这样即使你用的是直接调用,也有办法通过接口实现换实现。

举个例子,渲染器对外只声明class RendererInterface,里面是void SubmitMesh(...),而真正的D3D12Renderer在 .cpp 文件内部私有地实现这个接口。脚本系统拿到的是接口指针,永远拿不到D3D12Renderer的定义。你随时可以换一套渲染后端,脚本那边一行不需要改。

5.3 更新顺序:先做什么后做什么不能由模块自己说了算

每个模块可能有自己的 Tick 函数,但Tick 的调用顺序必须由一个中央调度器决定,不允许物理模块在更新时主动调动画模块的更新接口,更不允许脚本系统在主循环之外偷偷开线程更新世界。

我在自己维护的引擎里维护了一个双维护表:一个表按逻辑顺序记录"每帧需要被 Tick 的系统"及其优先级,另一个表专门记录"渲染帧完整提交前必须完成的最后一批操作"。比如相机变换和剔除必须在渲染提交之前,但触发一场粒子爆炸则可以在渲染提交之后排队算。

这里还有一个很深的设计:有些模块有"两阶段更新"需求。例如动画系统先计算逻辑层的骨骼位置,然后在渲染提交前再计算最终的蒙皮矩阵。如果动画系统只有一个 Tick,就很难在渲染阶段拿到最新数据。我的做法是给关键模块提供BeginFrame/Update/PreRenderSubmit三个生命周期钩子,调度器按固定顺序执行,模块自己控制三个阶段里各自干什么。

6. 实战中的耦合治理:我是怎么拆掉一个"缠绕引擎"的

理论上限说够多了,讲一段我的真实经历。

我接手过一个模拟引擎项目,规模不大,但功能模块极度缠绕:渲染器能直接访问游戏角色类的成员变量,物理引擎的步骤里会调用相机脚本,而脚本系统在载入关卡时会直接创建图形资源。每次改动一个小系统,其他系统里就冒出一堆编译错误,修完编译错误又发现行为不对。

我做的第一件事不是换架构,而是先画"依赖图"。把每个模块的顶层文件之间的 include 关系画出来,这不是为了看继承,而是为了看清谁依赖谁。结果图画完,所有箭头都交织在一起,那就不叫图,叫团。

第一刀:把平台层剥离。把窗口、输入、文件访问从功能模块里全部抽到一个独立的Platform包里,用统一接口包装。这一步大约改了十几个文件的 include。

第二刀:把实体数据的访问权限收回。游戏逻辑不再直接暴露内部的PlayerComponent地址,而是只提供只读快照接口。渲染器和物理器通过快照拿数据,而不是直接拿组件指针。这个改动很痛苦,因为原本很多代码在直接写player->pos,但我咬着牙全改成world->GetTransform(entityId)。

第三刀:建立事件总线并迁移跨模块通知。原来机关触发时直接遍历场景里的音效对象去播放,迁移之后只发一个trigger_event,音效系统自己订阅。这个迁移花了三天,但之后新增一个"机关发光"的效果,一行系统代码不用改,只需要音效系统或特效系统多订阅一条事件就行。

拆完之后,我印象最深的不是性能提升(其实逻辑帧率变化不大),而是改 bug 的速度明显变快。原来一个 bug 往往要翻三个模块才能定位,现在看一眼事件日志就能确认是哪个模块出的问题。这才是架构带来的最大红利:它降低的不是单次操作的开销,而是整个团队持续开发的熵增。

7. 基础架构的验收清单

最后给准备搭引擎或者重构引擎工作的你一份我自己的验收清单,不搞虚的,每一条都是我踩过的坑。

  • 主循环里,输入快照、固定步长逻辑、渲染插值这三件事是否被清晰分开?混在一起的话,第一步先拆它们。
  • 所有模块是否只能依赖相邻层级?画依赖图时如果出现跨层箭头,说明边界缺接口或者缺数据协议。
  • 是否有一块独立的帧内存,专供临时数据使用?如果没有,可以考虑先加一个简单的头尾指针分配器。
  • 粒子、投射物、特效这类高频短命对象是否走了对象池?还是每帧都在new/delete?后者通常会在Profiler中表现为"分配开销"和"内存碎片"。
  • 资源的加载是否支持异步?加载资源时会不会阻塞主线程?如果会,是否有明确的加载状态机(未加载/加载中/可用/待卸载)?
  • 模块间通信是否至少有一条"可追踪的路径"?也就是出 bug 时,你能不能仅凭日志就知道谁调用了谁、触发了什么事件。
  • 最后一条,也是我认为最重要的一条:当你改一个模块的内部实现时,另一个模块的代码需要改动吗?如果只需要在接口层变化,你的架构就是健康的;如果需要一行行去修调用方,那架构就是债,越早越疼。

引擎基础架构不是一个能"一步到位"的成果,它是你在每个项目里反复拆换、打磨出来的习惯。我写这套系列的第一篇,就是想把这些习惯先摊开讲清楚。下一篇我准备深入渲染提交概览的细节,聊一聊从场景数据到渲染指令之间那条真正的修罗之路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询