引擎文件系统与异步文件读写
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 的三个好处:
- 保证各目标平台行为一致,即使某平台原生库不一致或有 bug;
- API 精简为引擎真正需要的函数,降低维护成本;
- 提供扩展功能,如统一处理硬盘、主机蓝光盘、网络文件(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 官方来源为准)。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 7 章 7.1 节