1. 引擎基础架构到底在解决什么问题
很多人第一次翻开游戏引擎源码,看到的是满屏的Object、Actor、Component、World,然后就开始一行行读代码,读了三天还在main函数里打转。我当年也是这么干的,结果就是——每个类都看懂了,但完全不知道它们为什么要这样组织。后来带我的老哥说了一句话点醒我:引擎架构不是设计出来的,是被需求逼出来的。你先把需求想清楚,架构自然就浮出来了。
那引擎基础架构到底要解决什么问题?我把它拆成四件事,这四件事几乎决定了后面所有模块的形态。
第一件事是对象的生命周期管理。游戏里一帧可能创建几千个临时对象(子弹、粒子、伤害数字),下一帧就销毁。如果每个对象都走new/delete,光是内存分配器的锁竞争就能把主线程拖垮。所以引擎必须有一套自己的对象创建、追踪、销毁机制,这就是后面要讲的Object系统和GarbageCollect的由来。
第二件事是模块之间的通信。渲染模块要知道场景里有哪些物体,物理模块要知道哪些物体在动,音频模块要知道声源在哪。如果让渲染直接#include物理的头文件,那编译依赖会变成一张蜘蛛网,改一行代码全项目重编。所以引擎必须做分层和接口隔离,这是Module系统和Subsystem设计的根本动机。
第三件事是跨平台抽象。同一份游戏逻辑代码,要在 Windows、Linux、主机、移动端上跑。文件路径、线程、原子操作、字节序、对齐方式全都不一样。引擎不能在每个业务代码里写#ifdef _WIN32,必须把这些差异收敛到一层薄薄的平台抽象层(PAL)里。
第四件事是性能可预测性。游戏最怕的不是平均帧率低,而是卡顿。一帧 16.6ms 的预算,如果某一帧突然花了 50ms,玩家立刻能感觉到。所以引擎的内存分配、任务调度、资源加载都必须做到时间可控,这就是为什么引擎里到处都是对象池、内存池、无锁队列。
把这四件事想明白,你再看引擎源码就不会迷路了。下面我按"从底往上"的顺序,把基础架构一层层拆开讲。
1.1 为什么引擎不直接用标准库的容器和智能指针
这是新手最容易踩的坑。你可能会想,std::vector、std::shared_ptr这么好用,为什么引擎还要自己造轮子?
原因有三个,而且都是硬需求。
第一,内存布局不可控。std::vector的扩容策略是实现定义的,你不知道它什么时候会重新分配,也不知道新容量是多少。在游戏里,一次意外的扩容可能意味着一次 2ms 的卡顿。引擎需要的是可预测的分配行为,所以会自己实现TArray、TInlineAllocator这类容器,把扩容策略、对齐方式、分配器全部握在自己手里。
第二,shared_ptr的原子引用计数太贵。std::shared_ptr的引用计数是原子的,每次拷贝都要做一次原子加,多线程下还有缓存行争用。游戏里对象引用极其频繁,这个开销累积起来很可观。引擎通常用非原子的引用计数 + 明确的线程归属来替代,只有在真正跨线程共享时才用原子操作。
第三,标准库的异常和 RTTI 在游戏里基本是负担。异常会阻止编译器做某些优化,RTTI 会增加二进制体积。引擎一般会关掉这两个特性,自己实现一套轻量的类型系统(比如 UE 的UObject反射系统)。
注意:这不是说标准库不能用。工具链、编辑器、构建脚本里用标准库完全没问题。但在每帧都要跑的核心路径上,引擎必须自己控制内存和调度。
1.2 基础架构的四个层次
我把引擎基础架构从下往上分成四层,这个分层不是官方标准,但我觉得比很多教材讲得清楚。
| 层次 | 职责 | 典型模块 |
|---|---|---|
| 平台抽象层 | 屏蔽操作系统差异 | 文件IO、线程、原子操作、时间、动态库加载 |
| 核心基础层 | 提供通用数据结构与工具 | 容器、字符串、内存分配器、数学库、序列化 |
| 对象与反射层 | 管理对象生命周期与类型信息 | Object系统、GC、反射、属性系统 |
| 模块与子系统层 | 组织功能模块与初始化顺序 | Module管理器、Subsystem、World、Tick调度 |
这四层是严格单向依赖的:上层可以调下层,下层绝不能反向依赖上层。一旦你发现核心基础层里#include了对象层的头文件,那架构就已经开始腐化了。
我见过不少项目,一开始图省事,让内存分配器直接回调对象系统的日志接口,结果就是内存模块没法单独测试,也没法在工具里复用。这种债后面还起来非常痛苦。
2. 平台抽象层:把操作系统的脾气关进笼子
平台抽象层(Platform Abstraction Layer,简称 PAL)是引擎最底下的一层,也是最容易被忽视的一层。很多教程直接从"引擎主循环"开始讲,但主循环里用到的线程、时间、文件操作,全都依赖 PAL。这一层做不好,上层全是坑。
2.1 PAL 到底要抽象哪些东西
我列一个实际项目里 PAL 需要覆盖的清单,你可以对照自己的项目看看漏了哪些。
- 内存:虚拟内存申请/释放、内存保护属性修改、内存对齐分配、大页支持
- 线程与同步:线程创建、线程本地存储、互斥量、信号量、条件变量、原子操作
- 时间:高精度计时器、系统时间、时区转换
- 文件系统:文件读写、目录遍历、路径规范化、文件监控
- 动态库:加载/卸载、符号查找
- 网络:Socket 封装(虽然网络通常单独成模块,但底层还是 PAL)
- CPU 信息:核心数、缓存行大小、SIMD 指令集检测
- 调试:断言、调用栈捕获、崩溃处理
这份清单里,最容易出问题的是时间、线程和文件路径这三块。
时间的问题在于,不同平台的计时器精度和单调性不一样。有的平台clock()返回的是 CPU 时间不是墙钟时间,有的平台高精度计时器在系统休眠后会跳变。引擎必须封装一个单调递增的高精度计时器,并且明确它的分辨率。我一般会要求 PAL 提供GetCycles()和GetSeconds()两个接口,前者用于性能分析,后者用于游戏逻辑。
线程的问题在于,不同平台的线程优先级语义、栈大小默认值、线程本地存储的析构时机都不一样。特别是线程本地存储的析构,有的平台在线程退出时自动清理,有的需要手动注册回调。如果引擎依赖 TLS 存对象指针,析构时机不对就会导致悬空指针。
文件路径的问题更隐蔽。Windows 用反斜杠,Linux 用正斜杠,macOS 默认文件系统大小写不敏感但可以配置成敏感。引擎必须统一成一种内部表示(通常是正斜杠 + 大小写敏感),在边界处做转换。
2.2 一个真实的踩坑:原子操作的平台差异
我印象最深的一次踩坑,是原子操作的内存序问题。
当时我们在做一个无锁的任务队列,在 x86 上跑得好好的,一上 ARM 设备就偶发崩溃。排查了两天才发现,x86 的内存模型比较强,很多乱序执行不会真的发生,所以代码里漏写的内存屏障在 x86 上"碰巧"能跑。但 ARM 是弱内存模型,编译器和 CPU 都会重排指令,漏掉的屏障立刻暴露。
修复方案是在 PAL 里明确定义几组原子操作原语,并且强制要求所有跨线程共享数据必须使用这些原语,禁止直接用平台原生 API。比如:
// PAL 提供的原子操作接口(示意) namespace pal { // 顺序一致性版本,最安全但最慢 int32 AtomicLoad_SeqCst(const int32* ptr); void AtomicStore_SeqCst(int32* ptr, int32 value); // 获取-释放语义,用于生产者-消费者场景 int32 AtomicLoad_Acquire(const int32* ptr); void AtomicStore_Release(int32* ptr, int32 value); // 宽松语义,仅用于计数器等不涉及同步的场景 int32 AtomicLoad_Relaxed(const int32* ptr); void AtomicStore_Relaxed(int32* ptr, int32 value); }关键不是接口本身,而是团队约定:写并发代码时,先想清楚需要哪种内存序,然后选对应的接口。默认用SeqCst,性能敏感的地方再降级到Acquire/Release。这个约定帮我们避免了很多"在 x86 上能跑,换平台就崩"的问题。
提示:如果你现在维护的引擎里,并发代码直接调用了平台原生的原子 API,建议做一次全面审查。这类问题在单一平台上极难复现,但一旦换平台就是灾难。
2.3 PAL 的接口设计原则
PAL 的接口设计有几条我踩过坑之后总结的原则。
原则一:接口要窄,不要暴露平台细节。比如文件接口,不要暴露HANDLE或fd,而是返回一个不透明的FileHandle。这样上层代码不会意外依赖平台特性。
原则二:错误处理要统一。有的平台用返回码,有的用errno,有的抛异常。PAL 必须统一成一种方式。我倾向于返回错误码 + 可选的错误信息字符串,因为异常在游戏核心路径上不可接受。
原则三:不要为了抽象而抽象。有些东西平台差异太大,强行抽象反而增加复杂度。比如图形 API,Vulkan、D3D12、Metal 的差异不是一层薄封装能抹平的,所以图形通常单独成模块,而不是塞进 PAL。
原则四:PAL 必须可测试。理想情况下,PAL 应该有一个"模拟实现",可以在没有真实操作系统的环境下跑单元测试。这对持续集成非常重要。
3. 核心基础层:容器、内存与数学库的取舍
核心基础层是引擎的"工具箱",里面装的是容器、字符串、内存分配器、数学库、序列化这些通用组件。这一层的设计直接决定了上层代码的写法和性能上限。
3.1 引擎容器的设计哲学
引擎容器和标准库容器最大的区别,在于分配器策略和内存布局。
先说分配器。标准库容器默认用全局new/delete,而引擎容器通常把分配器作为模板参数。这样做的好处是,你可以让某个容器从特定的内存池分配,比如"所有网络相关的容器都从网络内存池分配",方便统计和调试。
// 引擎容器的典型形态(示意) template<typename T, typename Allocator = DefaultAllocator> class TArray { T* data_; int32 size_; int32 capacity_; Allocator allocator_; public: void Reserve(int32 newCapacity); void Add(const T& item); void RemoveAt(int32 index); // ... };再说内存布局。引擎容器通常会把大小和容量内联存储,而不是像std::vector那样存三个指针。这样做的原因是,游戏里大量容器只有几个元素,用指针存储会浪费内存并且增加缓存未命中。内联存储 + 小对象优化(Small Buffer Optimization)能显著提升缓存友好性。
还有一个细节是扩容策略。std::vector通常按 1.5 倍或 2 倍扩容,但引擎容器往往允许你指定扩容策略,甚至允许"预分配固定容量,永不扩容"。后者在实时性要求高的场景非常有用,因为扩容意味着分配和拷贝,时间不可控。
3.2 内存分配器的分层设计
内存分配器是核心基础层里最复杂的部分,也是最值得投入的部分。我把它分成三层。
第一层是系统分配器,直接调用平台的内存申请接口。这一层的特点是分配粒度大(通常按页,4KB 或 64KB),开销高,但能拿到原始内存。
第二层是通用分配器,在系统分配器之上实现,提供任意大小的分配。常见的有几种:
- 自由链表分配器:把空闲块串成链表,分配时找合适大小的块。实现简单,但容易产生碎片。
- 伙伴系统:把内存按 2 的幂次分割和合并。碎片可控,但内部碎片较多。
- TLSF(Two-Level Segregated Fit):用两级位图管理空闲块,分配和释放都是 O(1)。实时性好,是很多引擎的选择。
- Slab 分配器:针对固定大小的对象优化,预先分配一批同尺寸的块。适合对象池。
第三层是专用分配器,针对特定用途优化。比如:
- 帧分配器(Frame Allocator):每帧开始重置,用于临时数据。分配就是移动指针,释放就是重置指针,极快。
- 双缓冲分配器:两份内存交替使用,适合跨帧的临时数据。
- 栈分配器:后进先出,适合作用域明确的临时数据。
我一般建议项目里至少实现帧分配器和对象池这两个。帧分配器能解决大部分"每帧临时数据"的分配问题,对象池能解决"频繁创建销毁同类对象"的问题。这两个加起来能覆盖 80% 的性能敏感场景。
3.3 数学库:精度、SIMD 与坐标系
数学库看起来简单,其实坑很多。我挑三个最关键的讲。
第一个是精度。游戏里用float还是double?大部分情况float够用,但有几个例外:世界坐标如果范围很大(比如开放世界),float的精度不够,会出现物体抖动。解决方案是用双精度存储世界坐标,单精度做局部计算,或者用坐标原点平移(Camera Relative Rendering)。
第二个是 SIMD。现代 CPU 都支持 SIMD 指令,一次能算 4 个或 8 个浮点数。数学库如果不用 SIMD,性能会差好几倍。但 SIMD 的坑在于对齐要求:SIMD 加载通常要求 16 字节或 32 字节对齐,如果数据没对齐会崩溃或降速。所以引擎的向量类型通常强制对齐:
// 强制 16 字节对齐的向量类型(示意) struct alignas(16) Vector4 { float x, y, z, w; };第三个是坐标系。左手系还是右手系?Y 轴向上还是 Z 轴向上?这些选择会影响所有数学函数的实现,而且一旦定下来就很难改。我建议在项目早期就明确写进文档,并且在数学库的接口命名上体现出来,比如CrossLH和CrossRH,避免混淆。
3.4 序列化:为什么引擎不用 JSON
序列化是引擎里另一个容易被低估的模块。很多新手会问,为什么不用 JSON 或 XML 存游戏数据?
原因是性能和体积。JSON 是文本格式,解析慢,体积大。游戏里一个关卡可能有几十万个对象,每个对象几十个属性,用 JSON 存可能几百 MB,加载要好几秒。引擎通常用二进制序列化,配合反射系统自动生成序列化代码。
二进制序列化的关键设计是版本兼容。游戏会不断更新,旧存档要能在新版本里加载。所以序列化格式必须支持字段增删和类型变更。常见做法是给每个字段一个稳定的 ID,加载时按 ID 匹配,缺失的字段用默认值,多余的字段跳过。
注意:二进制序列化虽然快,但调试困难。我一般会同时提供二进制和文本两种格式,开发期用文本方便调试,发布版用二进制提升性能。
4. 对象与反射层:引擎的"骨架"
对象与反射层是引擎基础架构里最核心的部分,它决定了引擎怎么管理游戏对象、怎么做类型检查、怎么支持编辑器。这一层做得好,上层模块写起来行云流水;做得不好,处处掣肘。
4.1 为什么引擎需要自己的对象系统
C++ 本身有对象,为什么引擎还要造一个Object系统?因为 C++ 的对象模型缺少游戏需要的几个关键能力。
能力一:运行时类型信息。C++ 的 RTTI 功能有限,不能遍历一个对象的所有属性,也不能根据字符串创建对象。而编辑器需要这些能力:显示属性面板、保存加载、撤销重做,全都依赖反射。
能力二:统一的生命周期管理。游戏对象需要被追踪、被垃圾回收、被序列化。如果每个类自己管理,代码会重复且容易出错。统一的Object基类能把这些能力集中实现。
能力三:跨模块引用。游戏对象之间会互相引用,比如一个角色引用它的武器。如果直接用裸指针,对象销毁后引用就悬空了。Object系统通常提供弱引用和强引用两种机制,配合 GC 解决这个问题。
能力四:网络复制。多人游戏里,对象状态需要同步到其他客户端。反射系统能自动生成同步代码,不需要手写。
4.2 反射系统的实现方式
反射系统的实现方式主要有三种,各有取舍。
第一种是宏 + 代码生成。用宏标注需要反射的类和属性,然后用工具扫描源码生成反射代码。UE 的UCLASS/UPROPERTY就是这种方式。优点是性能好,类型安全;缺点是需要构建工具支持,编译流程复杂。
第二种是模板 + 类型擦除。用模板在编译期收集类型信息,运行时通过类型擦除访问。优点是纯 C++ 实现,不需要额外工具;缺点是编译期开销大,且难以支持"根据字符串创建对象"这种动态需求。
第三种是外部描述文件。用独立的描述文件(如 JSON、XML)定义类型信息,运行时加载。优点是灵活,支持热更新;缺点是运行时开销大,且容易和代码不同步。
我参与过的项目里,宏 + 代码生成是主流选择,因为它兼顾了性能和灵活性。代价是构建系统要复杂一些,但一次投入长期受益。
4.3 垃圾回收:引用计数 vs 追踪式
对象系统的另一个核心问题是垃圾回收。游戏里对象引用关系复杂,手动管理内存容易出错,所以引擎通常提供某种形式的自动回收。
引用计数是最简单的方案:每个对象维护一个引用计数,引用加一,解引用减一,减到零就销毁。优点是实时性好,对象销毁时机确定;缺点是无法处理循环引用,而且每次引用操作都有开销。
追踪式 GC(Mark-Sweep、Mark-Compact 等)能处理循环引用,但需要暂停程序做回收,会造成卡顿。游戏里通常用增量式 GC或分代 GC来减少暂停时间。
实际引擎里,常见的是混合方案:对象之间用引用计数,但定期做一次循环检测来清理循环引用。或者干脆不用 GC,用明确的所有权模型,配合对象池和生命周期约定。
我个人的经验是,对于游戏对象,明确的所有权 + 对象池比 GC 更可控。GC 的不确定性在实时游戏里是很大的风险。但工具、编辑器这类非实时场景,GC 能大幅提升开发效率。
4.4 对象系统的性能陷阱
对象系统虽然方便,但有几个性能陷阱必须注意。
陷阱一:虚函数调用开销。如果每个对象操作都走虚函数,调用开销会累积。解决方案是把热路径上的操作内联或特化,只在冷路径上用虚函数。
陷阱二:缓存不友好。如果对象分散在堆上,遍历对象时缓存命中率低。解决方案是把常用数据放在连续内存里,比如用 SoA(Structure of Arrays)布局,或者用对象池保证同类对象连续。
陷阱三:GC 暂停。前面说过,追踪式 GC 会暂停程序。解决方案是增量回收,把回收工作分摊到多帧。
陷阱四:反射开销。反射操作(如按名字查找属性)通常比直接访问慢很多。解决方案是缓存反射结果,或者用代码生成把反射操作编译成直接访问。
5. 模块与子系统层:让引擎"活"起来
前面三层都是静态的基础设施,模块与子系统层才是让引擎真正运转起来的部分。它负责组织功能模块、管理初始化顺序、驱动主循环。
5.1 模块系统的设计
模块系统要解决的核心问题是:如何把引擎拆成可独立开发、独立测试、按需加载的单元。
一个典型的模块系统包含这几个概念:
- 模块(Module):一个功能单元,比如渲染模块、物理模块、音频模块。每个模块有自己的初始化和关闭逻辑。
- 模块管理器(Module Manager):负责模块的注册、加载、卸载、依赖解析。
- 模块接口(Module Interface):模块对外暴露的 API,通常是纯虚接口或函数表。
模块之间的依赖必须显式声明,模块管理器按拓扑排序决定初始化顺序。如果出现循环依赖,应该在编译期或启动期报错,而不是运行时崩溃。
// 模块接口的典型形态(示意) class IModule { public: virtual ~IModule() = default; virtual const char* GetName() const = 0; virtual const char** GetDependencies() const = 0; virtual bool Initialize() = 0; virtual void Shutdown() = 0; virtual void Tick(float deltaTime) = 0; };模块系统的关键设计决策是静态链接还是动态加载。静态链接简单,启动快,但无法热更新;动态加载灵活,支持热更新,但启动慢,且跨平台差异大。大部分商业引擎两者都支持,开发期用动态加载方便迭代,发布版用静态链接提升性能。
5.2 子系统与服务的区别
模块和子系统容易混淆,我区分一下。
模块是编译单元,是代码组织的方式。一个模块可以包含多个子系统。
子系统是运行时对象,是功能组织的方式。子系统通常有明确的生命周期,并且可以被替换。
服务是子系统的一种特殊形式,通常是全局唯一的,提供跨模块的能力。比如日志服务、配置服务、任务调度服务。
这三者的关系是:模块包含子系统,子系统可以实现服务。设计时要避免把所有东西都做成全局服务,那样会导致隐式依赖和初始化顺序混乱。
5.3 主循环与 Tick 调度
主循环是引擎的心脏,它决定了每帧做什么、按什么顺序做。一个典型的主循环长这样:
// 主循环的典型结构(示意) while (!shouldExit) { float deltaTime = CalculateDeltaTime(); // 1. 平台事件处理 Platform::PumpMessages(); // 2. 输入处理 InputSystem::Tick(deltaTime); // 3. 游戏逻辑 World::Tick(deltaTime); // 4. 物理模拟 PhysicsSystem::Tick(deltaTime); // 5. 动画更新 AnimationSystem::Tick(deltaTime); // 6. 渲染提交 RenderSystem::Tick(deltaTime); // 7. 帧末清理 FrameAllocator::Reset(); }这个顺序不是随便定的,每一步都有理由。
输入在最前,因为游戏逻辑需要最新的输入状态。游戏逻辑在物理前,因为逻辑可能修改物体的速度或位置,物理需要基于最新状态模拟。物理在动画前,因为动画可能需要物理结果(比如布娃娃)。渲染在最后,因为渲染需要所有其他系统的最终状态。
Tick 调度还有一个关键问题是固定时间步长 vs 可变时间步长。物理模拟通常需要固定步长(比如 60Hz),否则结果不可复现。游戏逻辑可以用可变步长,但要注意处理大 deltaTime(比如加载时的长帧)。常见做法是固定步长物理 + 可变步长逻辑 + 时间累积器。
5.4 初始化顺序的坑
初始化顺序是引擎启动阶段最容易出问题的地方。我列几个常见的坑。
坑一:静态初始化顺序问题。C++ 的全局对象初始化顺序是不确定的,如果模块 A 的全局对象依赖模块 B 的全局对象,可能 B 还没初始化就被用了。解决方案是避免全局对象,改用显式初始化。
坑二:日志系统初始化太晚。如果日志系统在很后面才初始化,前面模块的日志就丢了。解决方案是日志系统最先初始化,或者提供一个"早期日志"缓冲。
坑三:配置加载依赖文件系统。如果配置系统依赖文件系统,而文件系统又依赖配置(比如配置决定文件系统用哪个路径),就死锁了。解决方案是分层配置,底层配置硬编码或从命令行读取。
坑四:模块卸载顺序。卸载必须按初始化的逆序进行,否则会出现"依赖的模块已经卸载,但自己还在用"的情况。模块管理器应该自动处理这个。
6. 从零搭建基础架构的实操建议
讲了这么多原理,最后落到实操。如果你要从零搭一个引擎基础架构,我建议按这个顺序来。
6.1 第一阶段:能跑起来
先实现最小可运行的系统:
- 平台抽象层:文件、时间、线程、原子操作。先支持一个平台,接口设计好。
- 内存分配器:先实现一个简单的系统分配器包装,加上帧分配器。
- 容器:
TArray、TMap、TString,够用就行。 - 日志系统:支持分级输出,支持控制台和文件。
- 主循环:能跑起来,能 Tick。
这个阶段的目标是能跑一个空场景,不要追求功能完整。
6.2 第二阶段:能管理对象
加上对象系统:
- Object 基类:带类型信息、生命周期管理。
- 反射系统:先支持基本的类型查询和属性访问。
- 对象池:支持频繁创建销毁的对象。
- 序列化:支持二进制保存加载。
这个阶段的目标是能创建、销毁、保存、加载对象。
6.3 第三阶段:能组织模块
加上模块系统:
- 模块接口:定义模块的生命周期。
- 模块管理器:处理依赖和初始化顺序。
- 子系统:把功能拆成子系统,支持替换。
- Tick 调度:支持不同频率的 Tick。
这个阶段的目标是能按需加载模块,能独立测试模块。
6.4 实操中的经验教训
最后分享几条我踩过坑之后的经验。
第一条:不要过早优化。我见过太多项目,一开始就追求"极致性能",结果架构复杂到没人能维护。先让它跑起来,再根据 Profiler 数据优化。
第二条:接口要稳定,实现可替换。基础架构的接口一旦定下来,改动成本极高。所以设计接口时要多想几种实现方式,确保接口能容纳它们。
第三条:测试要跟上。基础架构的 bug 影响面极大,必须有单元测试。特别是内存分配器、容器、序列化这些模块,测试覆盖率要高。
第四条:文档要写。基础架构的设计决策(为什么这样分层、为什么用这种分配器)必须写下来。否则半年后你自己都忘了为什么。
第五条:性能数据要留档。每次架构调整后,跑一遍基准测试,记录数据。这样你能知道调整是变快了还是变慢了,而不是凭感觉。
我在实际项目里发现,基础架构的投入产出比是前期低、后期极高。前期你会觉得"写这么多基础设施,游戏逻辑一点没动",但到了中后期,好的基础架构能让功能开发速度提升好几倍,而烂的基础架构会让每个新功能都变成一场噩梦。所以如果你正在做引擎,在基础架构上多花时间是值得的。
最后一个实用技巧:如果你不确定某个设计决策,去看看成熟引擎是怎么做的。UE、Unity、Godot 的源码都是公开的,看它们怎么处理内存、怎么做反射、怎么组织模块,比看任何教程都有用。但要注意,不要照搬,因为它们的需求和你不一样。理解它们为什么这样做,然后根据你的需求做取舍,这才是正确的学习方式。