如果你跟我一样,喜欢从源码角度看引擎,一定有过这种体验:打开某个引擎仓库,想搞清楚它到底怎么跑起来的,翻到最后停在 main 函数门口。渲染器在哪、物理系统在哪都好找,唯独 main 函数里那几十行初始化看着最不起眼,却决定了整个进程接下来的命运。我在自己折腾引擎的过程中,吃过不少基础架构的亏,尤其是时间管理和内存分配这两块,踩进去就爬不出来。所以系列写到这里,我想把基础系统和内存管理正面拆一遍:它们各自负责什么、为什么必须这么设计、有哪些直接能用的实现,以及我踩过的坑。这篇适合正在自研引擎、或者准备读引擎源码的读者。
1. 基础系统的边界:先搞清哪些代码属于“地基”
很多人把引擎理解成“渲染器 + 物理 + 动画 + 场景管理”,这没错,但是漏掉了一层,也是最容易被低估的一层:基础系统。它像是地基里的水电管线,平时没人看见,哪天停水断电,才知道一切都得围着它转。
1.1 基础系统到底包含哪几块
先说边界。我一般把引擎底层分成以下几块:
| 系统 | 核心职责 |
|---|---|
| 平台抽象层 | 窗口、消息循环、系统时间、设备接入的统一入口 |
| 时间系统 | 高精度时钟、帧时间计算、固定步长驱动 |
| 输入系统 | 键盘、鼠标、手柄的采集、事件派发、动作映射 |
| 文件系统 | 路径规范、打包资源读取、异步 IO、热重载 |
| 内存系统 | 分配器、内存跟踪、缓存友好布局、对象生命周期管理 |
| 调试基础设施 | 日志、断言、统计、崩溃转储 |
很多人会问日志和断言算什么基础系统。试想一下:没有日志系统,你在两个模块之间排查问题只能下断点;没有断言,你根本不知道业务层传进来的句柄早就被回收了。基础系统的范围不是听上去“有没有用”,而是它是否被多个子系统共同依赖。
这里要强调一条边界:基础系统是给所有模块用的,所以它的代码必须保持“底层、通用、无业务倾向”。比如文件系统只负责按路径把数据读进缓冲区,不应该知道这个数据是纹理还是音频;内存分配器只保证分配释放和对齐,不应该知道对象的名字。业务判断一旦渗进基础层,后面谁想复用它都会很难受。我见过一个引擎里的分配器为了“性能优化”单独处理某个资源类型,结果后续每次新增资源都要去改分配器代码,改动成本越来越高。
1.2 初始化顺序和依赖方向:引擎的“起床顺序”
引擎启动时的初始化顺序,决定了基础系统设计是不是真的合理。我采取的排序原则是:只有自己依赖别人最少的模块先起床。典型顺序如下:
平台层 → 内存系统 → 日志系统 → 时间系统 → 文件系统 → 输入系统 → 资源管理 → 业务系统
这样排列的原因很直白:内存系统被所有系统依赖,必须先就绪;日志又依赖内存;文件系统依赖平台层获取工作目录;资源管理依赖文件系统。任何反向依赖都说明某个模块抽得不对。
用伪码表示大概是:
int EngineMain(const EngineConfig& cfg) { Platform_Init(); Memory_Init(cfg.memoryBudget); Log_Init(); Timer_Init(); VFS_Init(); Input_Init(); Resource_Init(); return RunGameLoop(); }这个顺序保证了一个基本约束:每个系统初始化时,它需要的基础设施已经可用。如果你把输入放在文件系统前面,再遇到“输入配置从文件读取”的需求,就会倒逼输入模块自己去处理路径和解析,最后文件职责被越搞越乱。实际工作中,我不会把顺序绝对化,但会把“依赖方向”画成图贴到文档里,每次有人想加新模块,先对着图检查自己站在哪一层,不要成为整件事的突破点。
2. 时间管理:固定步长为何不能省,以及螺旋死亡的解法
游戏引擎里所有逻辑都围绕时间运转,可时间恰恰是一个很容易被忽视、但一旦错乱就全面失调的系统。锁帧、跳帧、时间缩放、暂停,每一个都让人头疼。
2.1 高精度时钟与帧时间测量
首先要确定一个稳定、单调的时间源。不同平台 API 不同,但至少满足两个条件:精度够高(微秒级),并且单调递增,不会因为系统校时而倒退回去。在 Windows 上常见的是 QueryPerformanceCounter,Linux 上有 chrono 的 steady_clock,移动端也有对应的系统接口,这里统一封装成一个 EngineClock 就可以了。
获取一帧的真实耗时一般是这样:
uint64_t lastTick = EngineClock_Now(); while (running) { uint64_t now = EngineClock_Now(); float frameTime = EngineClock_Seconds(now - lastTick); lastTick = now; // 限制单帧耗时,防止极端卡顿导致逻辑雪崩 frameTime = min(frameTime, 0.25f); // ... }这里的 clamp 非常重要。为什么?如果主线程被断点打断了三秒钟,返回后 frameTime 会变成 3 秒。如果不限制,所有基于 dt 的增量都会猛地跳一大截,物理约束崩溃、相机穿墙、动画瞬间结束,什么怪事都可能发生。限制为 0.25 秒至少让逻辑的恢复有认知范围。我叫它“孔隙保护”。
2.2 固定时间步长的累加器实现
游戏逻辑更新最好使用固定步长,而不是把当前帧的 frameTime 直接用。物理和网络往往按固定频率求解,如果用可变 dt,同样的落地动作在不同帧率上会有不同表现,复现 Bug 也难。行业里最常见方案是累加器:
float fixedDt = 1.0f / 60.0f; float accumulator = 0.0f; while (running) { uint64_t now = EngineClock_Now(); float frameTime = EngineClock_Seconds(now - lastTick); lastTick = now; frameTime = min(frameTime, 0.25f); accumulator += frameTime; int maxSteps = 4; while (accumulator >= fixedDt && maxSteps--) { FixedUpdate(fixedDt); accumulator -= fixedDt; } if (accumulator >= fixedDt) { accumulator = 0.0f; } float alpha = accumulator / fixedDt; Render(alpha); }注意几个细节。第一,FixedUpdate 和 Render 不再共享同一个相位,逻辑哪怕一次更新多个步长,渲染插值也可以平滑。第二,如果某一帧严重卡顿,就算有 0.25 的孔隙保护,也可能积压多个 FixedUpdate,所以我把最大迭代次数限制为 4,超出则丢弃累计时间,宁肯步调慢一点,也不让一帧内逻辑算上几十次,造成“时间崩塌”。这就是常说的螺旋死亡。限制值要看目标帧率,一般 2 到 5 都能接受。
第三,渲染插值 alpha 让视觉上更平滑。比如一个物体以每帧固定速度移动,逻辑位置只在 FixedUpdate 里变化,连续两帧逻辑位置相同,真实渲染出去物体是抖动的。用 alpha 做插值可以得到平滑的连续位置。这个技术不复杂,但很多人图省事省略掉,最后表现就差了。
2.3 时间缩放、暂停和冷却计时
游戏往往有暂停和子弹时间。暂停不是把 frameTime 变成 0 就行,而是需要一套状态机处理。我的处理方式是把时间分成“世界时间”和“真实时间”。世界时间受缩放系数和暂停状态控制,适合逻辑、动画、物理;真实时间不受任何控制,适合 UI 动画、在线服务、外部计时器。
实现上,逻辑用的 dt 应该经过时间管理器转一圈再做缩放:
class TimeSystem { float timeScale_ = 1.0f; bool paused_ = false; public: float GetWorldDelta(float realDelta) { if (paused_) return 0.0f; return realDelta * timeScale_; } bool IsPaused() const { return paused_; } };看起来简单,坑在于很多人全局用一套缩放,导致 UI 里的加载转圈也被放慢甚至暂停,体验非常差。正确做法是明确每个系统的时间语义:模拟层用世界时间,表现层和 UI 用真实时间。还有冷却类计时,推荐单独做成 TimerManager,统一管理到期回调,而不是把每帧数数散布在各模块里。这样暂停、缩放、退出时的回收都集中处理,不会漏。
3. 平台抽象与输入:隐藏差异的同时别把能力藏没了
自研引擎一定会面对“这个平台有,那个平台没有”的现实。平台抽象层的价值就在于此,门道在于隐藏差异的尺度。
3.1 平台抽象层的最小边界
平台层我建议只做很薄的封装:创建窗口、构造渲染上下文、处理系统消息、提供高精度时钟、获取设备类型信息、以及为文件系统和输入提供入口。它不承担任何游戏逻辑。很多新手引擎把平台层当作大杂烩,什么垂直同步模式、分辨率切换、多显示器枚举全塞进去,结果要么一个平台特性其他平台没有,要么接口臃肿。
我通常让平台层保留这些基础能力:
- 创建和销毁顶层窗口
- 消息循环和回调注册
- 显示模式切换
- 高精度时间获取
- 设备和缓存信息查询
其余不跨平台的特性,不要在抽象接口里死磕,宁可在下层保留扩展查询接口。比如手柄的震动反馈就不是每个平台都有,我倾向于把能力查询和能力调用分开:CapabilityHasForceFeedback() 返回 false,上层就别去调震动。这就是隐藏差异但不隐藏能力。
3.2 输入从硬件到游戏逻辑的三层链路
输入系统我会拆成三层:设备层、事件层、映射层。
设备层直接跟系统 API 打交道,每帧轮询键盘、鼠标、手柄状态,得到一张原始状态表。这张表以设备为单位组织:keyDown 表、鼠标坐标、按钮队列、手柄轴位置。
事件层在上面把“状态变化”提炼成事件,比如“某个键从抬起变成按下”“手柄 A 键按下”“鼠标从位置 A 移动到 B”。它不关心这个键在游戏里是什么含义。
映射层负责把事件或状态转成游戏动作,最大价值是让“切换按键”和“键位布局”成为配置,而不是代码。比如同一条移动逻辑,键盘上是 W,手柄上是左摇杆,映射层输出的是“移动方向向量”,而不是“W 是否按下”。
一个轻量事件缓冲结构可以这样设计:
struct InputEvent { uint32_t device; uint32_t id; float value; uint64_t timestamp; }; std::vector<InputEvent> pendingEvents_;设备层每帧清空并填充缓冲区,主逻辑更新之前统一播放一次。选事件还是轮询?游戏逻辑我建议用状态快照,因为事件容易丢失“持续按住”的信息;UI 层适合事件,对话框想感知的是点击那一下而不是按键状态。在引擎里同时保留两条路径,事件总线给 UI,状态快照给动作。
3.3 设备热插拔和控制器布局的问题
PC 上玩家随时可能拔手柄,一把游戏打到一半手柄断了,如果逻辑还在轮询摇杆,会出现视角疯转。设备层需要维护一张活跃设备表,检测接入和拔出事件,并在映射层自动回退到备用输入源。手柄坐标轴要提供死区,否则默认状态下左摇杆会有一点点抖动,游戏角色会慢慢漂移。死区不能只做一个线性截断,最好做可配置的饱和度曲线,这样手感差异才能调出来。
另一个容易被忽略的是控制器按钮布局差异。同是一个 A 按钮,不同牌子的手柄在其他平台可能代表不同位置。设备层应该向上暴露“逻辑按键索引”,映射层再做语义绑定。也就是说引擎内部存的是“确认键”,而不是“按钮 0 号”,平台差异留在底层解释。
4. 虚拟文件系统:资源路径规范化与异步加载
自研引擎早期,大家通常会直接读相对路径,先在本地跑通。打包后立刻出问题:路径大小写错了、工作目录不对、要读取的资源被包进一个大文件之后根本没路可读。这时候会很想有一套虚拟文件系统。
4.1 为什么不能直接碰物理路径
游戏资源的加载,本质上一条路径可能来自源目录,也可能来自打包后的 pak 或 zip,还可能来自网络分发缓存。如果每个资源模块都直接调 fopen,后续打包、加密、热更都没法做。虚拟文件系统做的事就是把逻辑路径和实际存储解耦。开发者写 asset:/textures/角色.tga,VFS 内部再决定这个 asset 根目录在哪个磁盘、还是扎在哪个包文件里。
我甚至建议在代码规范上禁止直接出现裸的相对路径。一个小团队引擎,一旦某个美术工具直接读了 ../../Textures/xxx.png,过一段时间就会有人问为什么打包后丢失资源。规则简单粗暴,但能省掉一大类 Bug 的排查时间。
4.2 路径规则:大小写、分隔符、别名
路径规则看着不起眼,实际是新老平台兼容的大坑。Windows 路径通常不区分大小写,Linux 普遍区分,macOS 默认也不区分。自研引擎如果拿 Windows 当开发标准,很容易写出大小写混乱的路径,打包到 Linux 服务器或某些安卓环境就崩。
我常用的规范是:
- 统一使用正斜杠 / 作为 VFS 内部路径分隔符
- 逻辑路径统一小写(除用户显示名外)
- 所有路径进入 VFS 后立即规范化并小写缓存
- 不使用绝对路径,一律走挂载点(别名)
把资源根目录挂载为一个逻辑盘符,代码里永远用 asset:/、shader:/、config:/ 这类别名。好处是迁移目录结构时,只需要改挂载表,不需要改业务代码。这也是我强调“别碰物理路径”的原因。
4.3 异步加载的落地姿势
资源加载如果放在游戏主线程同步做,小项目还好,大场景切换时画面直接卡住。引擎在设计上需要考虑异步。我采用的方案是:一个后台 IO 线程池,任务队列接收“按逻辑路径读取数据到内存”,完成后把缓冲区交给回调线程。注意两点:第一,回调要回到主线程,否则资源解析、上传 GPU 这些流程会产生线程竞争;第二,异步加载要做依赖追踪,一个模型文件可能依赖多个材质贴图,全部就绪后才会触发“资源就绪”。
异步加载的接口我会偏向“请求式”:
struct LoadRequest { std::string path; std::function<void(LoadResult)> callback; LoadPriority priority; }; LoadHandle VFS_AsyncLoad(LoadRequest&& req); void VFS_CancelLoad(LoadHandle h);请求式接口比裸线程池好在:可以统一做并发控制、优先级、取消、超时处理,还能统计加载耗时。取消操作尤其重要,玩家切场景时,上一场景的加载任务应该被标记取消,否则后台线程疯狂 IO,新场景也被拖慢。我见过不少引擎卡顿就卡在这,资源明明不用了,后台还在加载。
热重载也需要文件系统配合。开发期打开一个文件监视器,资源文件变更后触发“标记脏”事件,资源系统重新加载并通知引用者。这个能力强烈建议只在开发配置打开,打包后的运行版直接关掉,否则玩家电脑上任何一个文件变动都可能触发重新加载和崩溃。
5. 内存分配器选型:从 malloc 到线程安全的池化分配
内存管理是游戏引擎绕不开的一道坎。C++ 默认的 new 和 malloc 在游戏代码里用得舒服,到了规模化之后,性能与稳定性问题都会暴露。
5.1 直接走系统分配的代价
系统分配器通用、可靠,但有两个问题。一个是性能波动:malloc 和 free 在内部要维护空闲链表、处理锁和合并碎片,一次分配可能触发系统调用,耗时从几十纳秒到几微秒不等,不稳定。另一个是碎片化:大量不同大小对象申请释放后,堆上会出现边角料,即使总空间足够,分配大块内存也会失败。
游戏引擎的分配器不是为了替代系统分配器,而是为了在热路径上使用更可控的分配策略。最典型的做法是框架性分配:初始化时从系统申请一大块内存,引擎内部自管。分配不再直接触碰系统 API,速度和稳定性都能接受。
5.2 工程上常用的分配器对比
| 分配器类型 | 原理 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|---|
| 线性分配 | 一个指针向后拨,只能整块释放 | 每一帧的临时内存 | 极快、简单、缓存友好 | 不能单独释放对象 |
| 池化分配 | 固定大小空闲链表复用 | 同类对象高频创建销毁 | 常数时间、无碎片 | 不同大小需不同池 |
| 栈式分配 | 按先进后出分配释放 | 嵌套作用域、粒子临时 | 快、可逐个释放 | 只能 LIFO 释放 |
| 自由列表 | 不同块大小管理 | 通用中间层 | 较灵活 | 复杂,易碎片 |
真正的引擎很少只用一个分配器,通常是分层组合。整体上有个“全局堆”负责大块;场景对象用池化;每帧渲染数据用双缓冲线性分配器,一帧用完直接重置。
5.3 池化分配器的基本实现
池化分配器适合管理固定大小的对象,比如组件、事件、GPU 资源句柄。核心思路是:预先分配一块池子,把空闲槽位串成链表,分配时弹出头部,释放时插回头部,常数时间完成。
class SlotPool { struct Node { Node* next; }; public: void Init(size_t slotSize, size_t count) { // 一次性从系统申请 count * slotSize 字节 } void* Allocate() { Node* n = freeHead_; freeHead_ = n->next; return n; } void Free(void* p) { Node* n = static_cast<Node*>(p); n->next = freeHead_; freeHead_ = n; } private: Node* freeHead_; // 原始内存块 };这个简单实现有三个值得注意的地方。第一,槽位至少必须能容纳一个指针,所以对于小对象,槽位大小要按 16 字节起跳。第二,并发环境下需要加锁或使用线程本地池,主线程、渲染线程各自持有实例最省事。第三,释放时不校验是不是本池的对象,这个隐患我后面用句柄来堵。
5.4 分配器与线程模型
自研引擎发展到多线程后,同一个堆分配器会被多个线程撞击,锁和缓存一致性的开销会抵消掉分配策略带来的收益。两个常见思路:一是线程本地分配器,二是按帧、按系统划分分配器域。
我一般让主线程和渲染线程各有一条线性分配器,每帧结束后重置。前者受理逻辑层的临时请求,后者给渲染命令和资源上传用。同一线程内分配无锁,跨线程只发送最终数据。这样分配器的竞争降到最低,配合内存标签(下面会说),还能精确知道每个系统每帧分了多少内存。
6. 缓存友好性:换个存储顺序,帧耗时差出一大截
内存管理不只是分配释放。数据放在哪、怎么排列,直接影响 CPU 访问速度,这才是游戏性能的深层问题。
6.1 cache miss 为什么会成为主角
CPU 从缓存读数据,速度是量级差距:访问 L1 缓存只需几个周期,访问主存可能要几百个周期。一个缓存行常见 64 字节,也就是说,只要你用到某个地址,CPU 会把附近 64 字节一起带上。如果数据布局不合理,大量内存带宽和时间浪费在搬运用不到的数据上。运算本身往往很快,真正慢的是等待数据到达。
我早期优化一个碰撞剔除模块,发现单帧耗时高,性能分析器指向某个遍历。当时对象的大结构体把所有字段堆在一起,遍历时每次只判断 AABB,但一次 cache miss 就把后面几百字节都拽进缓存。把 AABB 从大结构体拆成连续数组后,遍历只需顺序访问两个浮点数组,帧时间一下降下来。
6.2 AoS 与 SoA:一个能直接看出来的差距
对象数组和字段数组就是典型的例子。假设表示一个移动对象:
AoS 方式:
struct Object { float px, py; float vx, vy; float mass, radius; }; std::vector<Object> objects; // 一个对象一个对象排SoA 方式:
struct ObjectSoA { std::vector<float> px, py; std::vector<float> vx, vy; std::vector<float> mass, radius; };假如某个系统只需要位置,SoA 能保证遍历时一股脑读两个数组,缓存利用率最高,AoS 则白白搬运速度和质量字段。反过来,如果逻辑确实需要紧凑访问一个对象的全部字段,AoS 更合适。关键不是“哪个绝对好”,而是要识别“哪个系统访问什么字段”。
组件系统为什么倾向于存成连续数组,而不是用指针链表,也是这个原因。指针链表的每个对象可能散布在堆的不同位置,遍历一次等于随机访问,缓存完全无用。连续数组配合对象池,既能快速分配,又能保持缓存友好。
6.3 缓存感知设计还影响渲染提交
渲染提交阶段也是一样的思路。渲染器每帧收集一堆 DrawCall 参数,如果使用单独的 DrawCall 数组按类型排序,而不是按游戏对象生成顺序,就可以减少状态切换,提交效率高。这里的数据结构设计比单纯“用那个快的容器”影响大得多。
理解缓存布局还有一点收获:传参时尽量传连续数据,避免函数每个对象都触发间接引用。例如,把一组变换矩阵作为数组传入,而不是每次传一个指针。这些都是写代码时需要时刻留意的。
7. 句柄与资源生命周期:防止悬空引用和泄漏的工程手段
资源卸载是最容易产生悬空指针的环节。你有一个子弹类型资源,在某个场景切换中被卸载了,但某个敌人还保留着它的裸指针,下一帧访问就直接崩。
7.1 裸指针工作流为什么不稳
裸指针在引擎里的问题不是“指针访问快”,而是“释放后谁来通知所有持有者”。资源系统关掉一个纹理,如果持有者是分散在各处的实体对象,根本不知道已经失效。要维持裸指针安全,就得保证释放时没有任何引用,这几乎只能靠人工检查,大型项目根本做不到。
所以现代引擎倾向使用句柄代替直接指针。句柄不是地址,而是一个索引和世代编号的组合。资源系统内部维护一张句柄表,访问时先查表,再拿到对应指针。这样即使资源已释放,访问也能被明确检测到并优雅报错,而不是访问野地址。
7.2 句柄表与世代计数
最简单的句柄表长这样:
struct ResourceHandle { uint32_t index; uint32_t generation; }; struct ResourceEntry { void* ptr; uint32_t generation; bool occupied; }; std::vector<ResourceEntry> table_;访问时:
Resource* GetResource(ResourceHandle h) { if (h.index >= table_.size()) return nullptr; auto& e = table_[h.index]; if (!e.occupied || e.generation != h.generation) return nullptr; return (Resource*)e.ptr; }核心就是世代计数。同一个索引被反复复用,每次复用时 generation 递增,旧句柄里的 generation 对不上,直接判失效。这是防止悬空引用最简单又便宜的办法。
分配资源的句柄要长期保存,释放资源后旧句柄自然失效。有一点要注意,句柄表本身也会占用连续内存,而且不能随便用哈希表,最好用支持索引复位的连续表,否则索引不稳定。清理句柄表时,把空闲索引串成链表,分配时从链表取头,效率很高。
7.3 引用计数、所有权和数据共享
句柄解决了悬空,但仍然没有解决泄漏。一个资源还需要有人负责释放。引用计数是常见方案,每次获取资源时引用加一,不再使用时减一,归零后从表中回收。不过引用计数最大的痛点是循环引用:纹理持有贴图,贴图又回调持有纹理,谁等谁都会泄漏。
我更喜欢显式所有权加句柄弱引用的组合:资源归资源管理器所有,业务层只拿句柄,不参与释放决策。需要长期持有的资源用资源引用管理,短期临时的直接从管理器获取。这比在业务层到处管理指针清晰得多。内存标签也可以挂在这层,每个资源记录自己分配在哪、多大、被谁申请,泄漏排查时直接打开清单看。
8. 落地顺序与踩坑备忘录
基础系统和内存管理如果一口气全做完,项目很可能陷入“基建综艺”的泥潭。我还是建议按主循环和内存先行的顺序来。
8.1 自研引擎里的优先级建议
第一优先级是时间系统和主循环,因为它们是一切的上游。没有稳定的帧步长,物理、动画、网络都没法对齐。第二优先级是内存标签和基础分配器,哪怕是先用一个简单的线性分配器,也要从一开始记录内存来源,否则后面优化无从下手。第三优先级是平台层和文件系统,这两个搞定后,才能谈资源加载和热重载。输入系统可以放在稍微靠后,先用一个简单轮询,功能跑通后再上映射层。
按这个顺序走,最大的好处是每个阶段都能用真项目验证:主循环跑通了,空场景可以稳定转;分配器有标签了,每帧内存曲线看得见;VFS 准备好了,美术资源随便塞目录。每一步都有反馈,不容易跑偏。
8.2 几个我反复踩的坑和方法论
写个简短的备忘,全是真金白银换来的:
- 工作目录坑:引擎启动时不要依赖当前工作目录,要在运行时显式记录资源根目录。
- 跨模块释放坑:在 Windows 上如果两个模块用不同堆,A 模块分配、B 模块释放可能直接崩。所有权要明确写在接口注释里。
- 多线程分配坑:别把同一个池分配器直接丢给所有线程,先想清楚哪些线程各自持有,哪些需要锁。
- 内存池过早优化坑:为了“快”把系统里每个小对象都池化,结果池太多反而丧失整体视角。先统计,再池化。
- 泄漏排查方法论:打开内存标签统计表,按照“分配总量 - 释放总量”锚定,然后看谁长时间没释放。没有标签,一切分析都是猜。
最后一条建议:任何引擎在早期都应该在崩溃处理入口输出当前帧号、当前场景和最近一次内存操作信息,日志带上下文比崩溃栈更能救你一命。
我自己在引擎里做内存管理时,最深的感受是:与其追求某个花哨的分配器,不如先把“谁分配、谁释放、谁持有、何时失效”这条链路摸清楚。时间系统也是,写好一个累加器并不难,难的是让团队所有人都理解为什么要固定步长、为什么要限制最大迭代次数、为什么 UI 要拿真实时间。基础系统这种东西,像水电管线,看起来平淡,但每一个设计决策都会在半年的项目周期里被反复放大。希望这篇能把你在引擎源码前的一些疑问解开,也给正在写引擎的你几条可以少走弯路的实践经验。