中EN
概念页

引擎文件系统与异步文件读写

Engine File System & Asynchronous File IO

资源管理器大量依赖文件系统。引擎通常把操作系统的文件 API 包装成自己的 API:一是跨平台、屏蔽硬件差异;二是操作系统缺少引擎需要的功能(如流式加载),主机还要统一访问记忆棒、硬盘、蓝光盘、网络文件系统等介质。引擎文件系统 API 通常涵盖:文件名与路径操作、打开/关闭/读写文件、扫描目录、处理异步 I/O 请求(流式加载)。

路径的结构与各操作系统差异(卷 I 7.1.1、7.1.1.1)

路径一般形如 volume/directory1/.../directoryN/file-name:可选的卷标识 + 以保留分隔符分隔的路径分量;根目录用卷标识加一个分隔符表示(UNIX 的 /,Windows 的 C:\)。

方面差异
分隔符UNIX 用 /;DOS 与旧版 Windows 用 \,新版 Windows 两者都接受(但有些程序仍不接受 /);Mac OS 8/9 用 :,Mac OS X 基于 BSD UNIX,用 /
大小写UNIX 系区分大小写,Windows 不区分;跨平台开发时 EnemyAnims.json 与 enemyanims.json 是否同一文件会成问题
卷UNIX 没有独立的卷,整个文件系统是一棵树,本地盘、网络盘挂载为子树,路径从无卷标识;Windows 用盘符(C:)或 UNC 形式 \\some-computer\some-share
文件名DOS/早期 Windows 为 8.3 格式;新版 Windows 与 UNIX 允许多个点,但很多程序(包括资源管理器)把最后一个点后的部分当扩展名
保留字符如 Windows/DOS 路径中冒号只能出现在盘符里;含空格的 Windows 路径某些场合需加双引号
当前工作目录UNIX 只有一个 CWD;Windows 每个卷各有一个 CWD,并有「当前工作卷」
主机用预定义路径前缀表示卷:PS3 的 /dev_bdvd/ 是蓝光驱动器,/dev_hddx/ 是硬盘;开发机上 /app_home/ 映射到开发主机上的用户自定义路径,开发期游戏通常从这里读资产

绝对路径、相对路径与搜索路径(卷 I 7.1.1.2–7.1.1.3)

  • 相对根目录的是绝对路径,相对其他目录的是相对路径。UNIX 和 Windows 的绝对路径都以分隔符开头;Windows 路径可带可选卷标识,省略时指当前工作卷。例:C:\Windows\System32、\game\assets\animation\walk.anim(当前卷)、\\joe-dell\Shared_Files\Images\foo.jpg(网络路径)是绝对路径;X:animation\walk.anim(相对 X: 卷的 CWD)是相对路径。
  • 搜索路径 (search path) 是用冒号或分号分隔的一组路径,查找文件时依次搜索(如 shell 环境变量中查找可执行文件)。OGRE 用 resources.cfg 列出依次搜索的目录和 ZIP 包。但运行时搜索资产很耗时;资产路径通常可预先知道,完全避免搜索显然更好。

路径 API(卷 I 7.1.1.4)

路径远比普通字符串复杂:提取目录/文件名/扩展名、规范化、绝对与相对互转等。Windows 有 shlwapi.dll(头文件 shlwapi.h),Sony 为 PS3/PS4 提供类似 API;但跨平台引擎不能直接用平台专用 API,也未必需要全部功能,所以引擎常实现精简的路径 API——每个平台上包装原生 API 或从头编写(路径类的价值另见 字符串与唯一标识符)。

基础文件 I/O(卷 I 7.1.2)

  • C 标准库提供带缓冲(stream I/O,API 替你管理缓冲,把文件抽象成字节流)和无缓冲(由程序员分配管理缓冲)两套 API:
操作带缓冲无缓冲
打开 / 关闭fopen() / fclose()open() / close()
读 / 写fread() / fwrite()read() / write()
定位 / 当前偏移fseek() / ftell()seek() / tell()
读写一行fgets() / fputs()无
格式化读写fscanf() / fprintf()无
查询状态fstat()stat()
  • UNIX 上无缓冲例程就是原生系统调用;Windows 上它们只是更底层 API 的包装:CreateFile()、ReadFile()、WriteFile()、CloseFile()。原生调用能暴露文件系统的全部细节,如查询和控制文件安全属性,这是 C 标准库做不到的。

原书问题(7.1.2 与表 7.1):

  • Win32 并没有 CloseFile();CreateFile() 返回的句柄用 CloseHandle() 关闭(Microsoft Learn「CreateFileA function」「CloseHandle function」)。
  • 表中「无缓冲」一列(open/read/write/close)并不属于 C 标准库,而是 POSIX 接口(MSVC CRT 以 _open/_read 等形式提供,见 Microsoft Learn「Low-level I/O」);C 标准(<stdio.h>)只定义了带缓冲的 FILE* 流 API(cppreference「File input/output」)。
  • 定位函数在 POSIX 中是 lseek(),没有 seek()/tell()(当前偏移用 lseek(fd, 0, SEEK_CUR);MSVC 有 _lseek/_tell)。
  • fstat() 也不是带缓冲 API:stat() 按路径、fstat() 按文件描述符查询,二者都是 POSIX 函数,C 标准库没有文件状态查询函数(POSIX/Open Group「fstat」「stat」)。书中把二者放在两列是混淆。
  • 示例代码用 void main(标准 C/C++ 要求 int main),并用 %u 打印 size_t(应为 %zu);属示意代码瑕疵(cppreference「Main function」「printf」)。
  • 自己管理缓冲的例子:EA《红色警戒 3》团队发现写日志文件严重拖慢性能,于是改为先累积到内存缓冲、满了才写盘,并把写盘例程移到单独线程,避免阻塞主循环。

包装还是不包装(卷 I 7.1.2.1)

包装操作系统 I/O API 的三个好处:

  1. 保证各目标平台行为一致,即使某平台原生库不一致或有 bug;
  2. API 精简为引擎真正需要的函数,降低维护成本;
  3. 提供扩展功能,如统一处理硬盘、主机蓝光盘、网络文件(Xbox Live 或 PSN 管理的远程文件)、记忆棒等可移动介质。

同步 I/O(卷 I 7.1.2.2)

C 标准库两套 I/O 都是同步的:发出请求的程序必须等数据完全传输完才能继续。书中 syncReadFile() 用 fopen("rb") + fread() 把整个文件读入缓冲,读完才返回。

异步 I/O 与流式加载(卷 I 7.1.3)

  • 流式加载 (streaming):主程序运行时在后台加载数据。许多游戏在游玩时从蓝光盘或硬盘流式加载即将到来的关卡数据,实现无读取画面的无缝体验。最常流式加载的是音频和纹理,但几何、关卡布局、动画片段等任何数据都可以。
  • 这需要异步文件 I/O 库:如 Windows CLR 的 System.IO.BeginRead() / BeginWrite();PS3/PS4/PS5 有 fios。平台没有就自己写;即使有,也最好包装以便移植。
  • 两种用法:
    • 回调:asyncReadFile(hFile, buffer, size, asyncReadComplete) 立即返回,数据在 I/O 库调用回调后才可用,回调中用 asyncWasSuccessful()、asyncGetBytesReadOrWritten() 检查结果;
    • 稍后等待:不给回调,先做有限的其他工作,需要数据时 asyncWait(hRequest)。
  • 有些库可估计操作所需时间,或为请求设截止时间(相当于设定相对优先级),并指定错过截止时间时的处理(取消、通知并继续重试等)。

优先级(卷 I 7.1.3.1)

文件 I/O 是实时系统,和游戏其他部分一样受截止时间约束。例如边读边播的流式音频,读取下一缓冲区的音频数据显然比加载纹理或一块关卡更优先。异步 I/O 系统必须能挂起低优先级请求,让高优先级请求按时完成。

异步 I/O 的工作原理(卷 I 7.1.3.2)

下图是 GEA 描述的典型结构:主线程只把请求放入队列就返回,I/O 线程按顺序用阻塞调用处理。

sequenceDiagram
  participant Main as 主线程
  participant Q as 请求队列
  participant IO as I/O 线程
  Main->>Q: asyncReadFile() 入队后立即返回
  IO->>Q: 取出请求
  IO->>IO: 阻塞调用 read()/fread()
  IO-->>Main: 完成后调用回调
  IO-->>Main: 或发信号给该请求的信号量(主线程可在 asyncWait 中睡眠等待)
  • 每个请求关联一个信号量,主线程等待时睡眠在该信号量上,I/O 线程完成后发信号(见 线程同步原语)。
  • 几乎任何同步操作都可以通过移到单独线程、或在物理上独立的处理器(如 PS4/PS5 的某个 CPU 核)上运行而变成异步操作(GEA 8.6 节详述)。

PS5 的 AMPR(卷 I 7.1.4)

  • PS5 以超高速 M.2 SSD 著称,理论加载速度超过 5.5 GiB/s(原书问题:Sony 官方数据是 5.5 GB/s 原始读带宽、经硬件 Kraken 解压后典型有效约 8–9 GB/s,单位是 GB 而非 GiB——PlayStation.Blog「PS5: The Ultimate FAQ」、Digital Foundry「Inside PlayStation 5」规格表),得益于一颗专用处理器,同时加速虚拟内存映射以及从 SSD 读取并解压数据。
  • 应用可预留虚拟地址范围,再让专用处理器映射/取消映射物理页来「支撑」这些地址,然后把数据从 SSD 读入并解压到这些物理页中。
  • PS5 SDK 中的异步内存映射器 (AMM) 与异步包读取器 (APR) 合称 AMPR,程序员通过它们向专用处理器提交命令列表。文档属于专有资料,GEA 不能详述。

与 UE5 的对照

UE5 侧的打包与加载见 烘焙、打包与 Zen 加载器,大世界流送见 世界分区;它们是 GEA 所说「流式加载 + 引擎自有文件系统层」在 UE 中的具体实现(具体机制以各页 UE 官方来源为准)。

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

相关页面

游戏开发资源管理器Resource Manager游戏由网格、材质、纹理、着色器、动画、音频、关卡布局、碰撞与物理参数等大量资源(也叫资产、媒体)构成。内存有限,所以引擎要保证每个资源在内存中只有一份(五个网格共用一张纹理时只载入一份)。每个引擎都有某种资源管理器:有的是统一集中的子系统,有的散布在多年间由不同人写的各个子系统中,但职责和要解决的问题是一样的。本页讲运行时部分:职责、文件组织与归档、文件格式、资源 GUID、资源注册表、生命周期(引用计数)和资源的内存管理;交叉引用与加载后初始化见 资源交叉引用与加载后初始化,离线工具链见 资产管线与工具链。游戏开发字符串与唯一标识符Strings & Unique Identifiers字符串在 C/C++ 中只是字符数组,变长意味着要么硬编码长度上限、要么动态分配;比较和复制都是 O(n)。引擎内部大量用字符串做资源名和对象 ID,处理不当会严重拖慢游戏。GEA 的建议:运行时代码尽量避免字符串类,若用则了解其成本并按引用传递;路径类是值得专门做的例外;对象与资产的唯一标识用哈希字符串 ID(string id),兼得字符串的可读性与整数的比较速度。游戏开发线程同步原语Thread Synchronization Primitives并发编程的中心问题是数据竞争 (Data Race):多个控制流交错地读改写同一共享对象。解决思路是让同一对象上的关键操作具备原子性(看起来瞬间完成,从而被串行化)。最简单可靠的工具是操作系统提供的线程同步原语:互斥锁、临界区、条件变量、信号量、Windows 事件。它们好用但昂贵(需要内核调用,上下文切换可超过 1000 个时钟周期)。游戏开发进程、线程与纤程Processes, Threads & Fibers并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发 · UE5世界分区World Partition世界分区 (World Partition) 是 UE5 的自动数据管理与基于距离的关卡流送 (Level Streaming) 系统:整个世界存为一个持久关卡,按可配置的运行时网格划分为单元格 (Cell),由流送源 (Streaming Source,默认是玩家控制器) 按距离加载/卸载。它配合一 Actor 一文件 (One File Per Actor, OFPA) 支持多人协作编辑,用数据层 (Data Layers) 按玩法状态切换内容,用分层细节层次 (Hierarchical Level of Detail, HLOD) 在远处以代理网格显示未加载的单元格。游戏开发 · UE5烘焙、打包与 Zen 加载器Cooking, Packaging & Zen Loader打包是一组构建操作:编译 (Build) → 烘焙 (Cook,把资产转成目标平台格式) → 暂存 (Stage) → 打包 (Package) → 可选的部署 (Deploy)。UE5 的运行时加载器 Zen Loader 会在暂存阶段离线算好包与对象的依赖图,并把数据装进 .utoc/.ucas 容器(IoStore),由 I/O 分发器以很低的 CPU 开销定位数据块;.pak 只用于不常访问的零散文件。游戏开发日志与调试绘制Logging & Debug Drawing几乎所有专业团队都会为引擎打造一套调试与开发工具。本页记录其中最基础的两类:日志与追踪(格式化调试输出、详细级别、通道、镜像到文件、崩溃报告)和调试绘制(在游戏世界或屏幕上画线、形状和文字以可视化数学计算)。(卷 I 第 10 章 引言、10.1–10.2 节)游戏开发音频引擎架构Audio Engine Architecture音频渲染软件和多数系统一样是分层的"栈":硬件 → 驱动 → 底层 API → 3D 音频引擎,功能集通常模仿录音棚的多通道调音台。每个 2D/3D 声音是一条语音 (voice),沿语音总线经过编解码、增益、预发送滤波、辅助发送分出干湿两路、混响、后发送滤波和声像,然后由主混音器合成多声道信号,再经主输出总线(预放、LFE 生成、EQ、压缩、主音量)送到扬声器。资产上分片段、音效提示 (cue)、声音库和流式声音;混音靠分组、闪避、总线预设与混音快照、实例限制和语音抢占(GEA4 卷 II 15.5)。游戏开发游戏世界的加载与流送Loading & Streaming Game Worlds世界加载系统负责两件事:把世界块和所需资源从磁盘读进内存的文件 I/O,以及这些资源的内存分配与释放;另外还要管理游戏对象的生成与销毁。GEA 按演进顺序介绍:一次只载一个关卡的栈式加载(LSR 资源压栈底),用小块气闸 (air lock) 区域实现基本无缝的过渡,再到把所有资产切成等大块、用块池分配器按关卡加载区域流送的细粒度方案(Naughty Dog);对象生成的内存问题有"不许运行时分配"、按尺寸分池、小内存分配器和内存重定位四种对策;存档则分检查点和随处存档(GEA4 卷 II 17.4)。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。

被这些页面引用1 篇

输入关键词开始搜索

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