中EN
概念页

子系统启动与关闭

Subsystem Start-Up & Shut-Down

更新于 2026-10-13 约 7 分钟引擎架构单例初始化顺序管理器GEA

游戏引擎由许多相互依赖的子系统组成,启动时必须按依赖顺序初始化(B 依赖 A,则先启动 A),关闭时通常反序。C++ 全局/静态对象的构造与析构顺序不可预测,「首次调用时构造」的函数静态单例也无法控制析构顺序且时机不可控。GEA 推荐最朴素的方案:单例管理器的构造/析构函数什么也不做,提供显式的 startUp() / shutDown(),在 main() 里按顺序手动调用。

为什么不能依赖 C++ 的静态初始化(卷 I 6.1.1)

  • 全局和静态对象在入口点(main(),Windows 下 WinMain())之前构造,在其返回后析构,顺序完全不可预测。
  • 常见设计是每个子系统一个单例类(常叫 manager)。如果 C++ 能控制顺序,直接把单例定义成全局变量就好,无需动态分配——可惜不行。
  • 原书问题:「完全不可预测」说得过头。C++ 标准规定:同一翻译单元内的非局部静态对象按定义顺序动态初始化,只有跨翻译单元的顺序未指定(即所谓 static initialization order fiasco);析构顺序也不是随意的,而是构造完成顺序的逆序(cppreference「std::exit」、「Initialization – non-local variables」)。实际困难在于跨文件的全局管理器之间无法指定顺序,结论(不能依赖全局构造)仍成立。

按需构造及其问题(卷 I 6.1.1.1)

  • 技巧:函数内的 static 变量在第一次调用该函数时才构造。于是 static RenderManager& get() 内放 static RenderManager sSingleton;,并在构造函数里先调用依赖项的 get()(如 VideoManager::get()、TextureManager::get()),就能控制构造顺序。许多教科书推荐这种做法,或其动态分配变体(get() 里判断指针为空再 new)。
  • 问题:
    1. 仍不能控制析构顺序——RenderManager 关闭时依赖的管理器可能已被销毁;
      • 原书问题(书中观点与标准并列):对函数静态版本,标准保证静态对象按构造完成的逆序析构(局部静态对象视同在其构造完成时注册 atexit)。书中示例里 RenderManager 构造函数先调用 VideoManager::get()、TextureManager::get(),这两者先构造完成,因此会在 RenderManager 之后析构——析构顺序其实是可推导的(cppreference「std::exit」)。真正的风险是:依赖关系若不在构造函数中建立(如析构时才首次用到某管理器),或某静态对象已析构后又被别的析构函数访问(未定义行为),顺序就会出错。而动态分配变体(new 后从不 delete)根本不会析构,与书中「C++ 会先销毁依赖的管理器」的说法也不符。
    2. 构造时机难以预测(取决于谁第一次调用 get());
    3. 使用者想不到一个看起来无害的 get() 可能执行分配并初始化重量级单例这样昂贵的操作。
  • GEA 结论:这是不可预测且危险的设计。

简单有效的做法(卷 I 6.1.2)

class RenderManager {
public:
    RenderManager()  { /* 什么也不做 */ }
    ~RenderManager() { /* 什么也不做 */ }
    void startUp()  { /* 启动 */ }
    void shutDown() { /* 关闭 */ }
};
// 全局实例:gMemoryManager、gFileSystemManager、gVideoManager、gTextureManager、gRenderManager ...
int main(int, char**) {
    gMemoryManager.startUp(); gFileSystemManager.startUp(); gVideoManager.startUp();
    gTextureManager.startUp(); gRenderManager.startUp(); gAnimationManager.startUp();
    gPhysicsManager.startUp();
    gSimulationManager.run();
    gPhysicsManager.shutDown(); gAnimationManager.shutDown(); gRenderManager.shutDown();
    gFileSystemManager.shutDown(); gMemoryManager.shutDown();   // 反序
    return 0;
}

(据书中代码整理;原书问题:书中关闭部分只列出 Physics、Animation、Render、FileSystem、Memory,漏掉了 gTextureManager 和 gVideoManager 的 shutDown(),且 gSimulationManager 未在全局实例列表中声明——完整的反序应为 Physics→Animation→Render→Texture→Video→FileSystem→Memory。)下图是该例的启动顺序,关闭时沿箭头反向进行。

flowchart LR
  Mem["内存管理器"] --> FS["文件系统"] --> Vid["视频"] --> Tex["纹理"] --> Ren["渲染"] --> Anim["动画"] --> Phys["物理"] --> Run["运行游戏"]
  • 「更优雅」的替代:管理器注册到全局优先级队列再遍历;让每个管理器列出依赖并自动计算拓扑顺序;或用按需构造。
  • 作者经验是蛮力法总是胜出:简单易实现;显式——看代码就知道顺序;易调试维护——启动太早或太晚只需挪一行。
  • 小缺点:手写的关闭顺序可能不严格是启动的逆序。作者认为不必为此担心,只要能成功启动和关闭即可。

真实引擎的例子(卷 I 6.1.3)

  • OGRE(作者自称是渲染引擎而非游戏引擎):一切由单例 Ogre::Root 控制,它持有所有子系统(LogManager、DynLibManager、ArchiveManager、MaterialManager、MeshManager、ResourceGroupManager 等)的指针并负责创建和销毁;程序员只需 new 一个 Ogre::Root。所有管理器派生自模板基类 Ogre::Singleton,但它不做延迟构造,而是由 Root 显式 new 每个单例,以保证确定的创建和销毁顺序。
  • Naughty Dog(《神秘海域》《最后生还者》系列):同样显式启动。启动并不只是分配单例:要初始化异常处理、为物理分配 16 字节对齐的专用堆、初始化文本数据库与 SPU 管理器、创建初始化线程、读取主配置文件、查询启动光盘信息、初始化文件系统和语言管理器等操作系统服务和第三方库。尽量避免动态分配,所以许多单例是静态分配的对象(如 g_fileSystem、g_languageMgr)。作者评价「不总是漂亮,但能完成任务」。

与 UE5 的对照

UE5 的 子系统 由引擎按宿主对象(Engine、GameInstance、World 等)的生命周期自动创建和销毁,属于 GEA 所说的「更优雅」的自动化路线;GEA 本身未讨论 UE。两者取舍可概括为:GEA 的蛮力法显式、可调试,UE5 子系统省去手写顺序但依赖引擎管理的生命周期。(UE5 部分以该页及其来源为准。)单例模式本身的利弊另见 单例与服务定位器。

  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 6 章 6.1 节

相关页面

游戏开发运行时引擎分层架构Runtime Engine ArchitectureGregory 把游戏视为“软实时、交互式、基于代理的计算机模拟”,把游戏引擎定义为数据驱动、可复用来做多款游戏的软件。运行时引擎按层组织:从目标硬件、驱动、操作系统、第三方 SDK、平台无关层、核心系统、资源管理器,到渲染、物理、动画、音频、网络等子系统,再到游戏性基础层和游戏专属子系统;上层依赖下层,避免循环依赖。游戏开发单例与服务定位器Singleton & Service Locator单例 (Singleton) 同时承诺两件事:一个类只有一个实例,并且提供全局访问点。Nystrom 认为它在游戏里弊大于利,因为单例本质上就是全局变量。服务定位器 (Service Locator) 是更灵活的“近亲”:它同样给出全局访问点,但调用者只依赖抽象服务接口,具体实现由定位器在运行时或编译期决定,还可以退化为空服务。两者都应当慎用,首选仍是直接把对象传进去。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发引擎配置系统Engine Configuration游戏引擎总会积累大量可配置选项:有的通过选项菜单暴露给玩家(画质、音量、手柄设置),有的只供开发团队使用、发售前隐藏或去除(如玩家最大行走速度在开发期可调、发售前改成硬编码)。GEA 讨论选项的存取方式(文本文件、压缩二进制、注册表、命令行、环境变量、在线档案)、全局与每用户选项,以及 Quake cvar、OGRE、Naughty Dog 三个真实例子。游戏开发 · UE5子系统Subsystems子系统 (Subsystem) 是自动实例化、生命周期绑定在宿主对象上的类:Engine、Editor、GameInstance、World、LocalPlayer 五种。宿主创建时自动创建并 Initialize(),宿主关闭时 Deinitialize(),之后无引用即被 GC。它替代了「继承 GameInstance 塞管理器」的老做法,自带按类型的蓝图节点和 Python 访问,特别适合插件。官方编程页只列四种,World 子系统依据 5.8 Python API 补充。游戏开发错误处理与断言Error Handling & AssertionsGEA 把错误分为用户错误和程序员错误,两者处理方式相反:用户错误要尽量平稳地告知并继续,程序员错误应立即停机并给出详细的底层信息。“用户”又分玩家和制作游戏的开发者。实现手段有错误返回码、异常和断言。Naughty Dog、EA、Midway、Insomniac 等在运行时代码中通常关闭 C++ 异常,靠断言和静态断言 (staticassert) 捕获 bug,并用 RAII(Naughty Dog 称为 janitor)管理资源。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原