PvZ-Portable 优化实录:从 6 秒到 1.5 秒的启动速度提升

记一次针对类 Unix 平台下的文件大小写不敏感 I/O 性能优化

Posted by wszqkzqk on February 6, 2026
本文字数:4026

背景:大小写敏感的历史包袱

之前的文章中,我介绍了 PvZ-Portable 项目。作为一个以 100% 还原原版体验为目标的项目,我们必须面对大量历史遗留问题。其中最让人头疼的一个,就是资源文件名的大小写混乱

原版游戏是为 Windows 开发的。众所周知,Windows 默认是大小写不敏感的。这意味着代码里写 LoadImage("Reanim/Zombie.png"),而在硬盘上文件名叫 reanim/zombie.PNG,Windows 也能照常读取。

然而,当我们要将游戏移植到 Linux 或 macOS 时,问题就来了。这些操作系统默认通常是大小写敏感的。直接移植的代码会因为 File Not Found 而崩溃。

由于版权原因,我们不能分发修改文件名后的游戏资源包(main.pak 等),必须要求用户提供原版正版资源,也无法修正原版资源中的错误。因此,兼容这些混乱的文件名成为了引擎层的责任。

初始方案:fcaseopen

为了解决这个问题,笔者最初引入了一个通用的跨平台解决方案 —— fcaseopen

它的原理非常简单直接:首先尝试直接打开文件。如果失败,则进入错误恢复流程,假设这是由大小写不匹配引起的。此时,函数会对目标路径进行分解,从根目录开始逐级遍历(opendir/readdir),将每一层目录名与路径片段进行不区分大小写的比对(strcasecmp)。如果找到匹配项,就将其拼凑成真实存在的路径,直到构建出完整路径或查找失败为止。

这个方案虽然能工作,但性能开销是巨大的。每一次失败的打开操作都会触发多次系统调用和目录遍历。

在早期的未提交测试中,笔者在一台 AMD Ryzen 5800H 的笔记本上(运行 Arch Linux),游戏的资源加载时间(即启动黑屏时间)竟然高达 6 秒!这对于一个 2D 游戏来说是不可接受的。

阶段一:抛弃 chdir,引入 fcaseopenat

在最早的版本中,为了省事,游戏启动时会直接 chdir(改变工作目录)到资源所在目录。这虽然方便了相对路径的编写,并且避免了处理大小写不敏感路径的开销,但在现代软件工程中是个坏习惯,不仅影响了后续功能的扩展,还带来了潜在的线程安全风险。

因此,笔者决定重构这部分逻辑。引入了命令行参数 -resdir,允许手动指定资源路径,废弃了全局 chdir

为了配合这个改动,笔者实现了一套基于 fcaseopenat 的机制。利用 GetResourceFolder() 获取已知的资源根目录(Base Directory),然后在进行文件查找时,只对相对路径部分进行大小写修正。

具体来说,函数首先尝试直接打开组合后的完整路径。只有当这一次尝试失败,且我们确定基础资源目录存在时,才启用大小写修正逻辑。这种做法能够确保修正过程仅作用于游戏资源目录内部的相对路径,从而彻底避免了对 /usr~ 等上层系统目录进行无效且耗时的递归扫描。

效果: 这一改动将启动时间从 6 秒 降低到了 2 秒 左右。这是因为我们避免了对系统根路径的大量冗余扫描,仅仅在确定的游戏资源目录内进行查找。

确认 CPU 瓶颈

将启动时间优化到 2 秒后,笔者发现了一个有趣的现象:

  • 使用 -O3 编译的版本启动耗时约 1.9s - 2.0s
  • 使用 -O2 编译的版本启动耗时约 2.0s - 2.2s
  • 更关键的是,当拔掉笔记本电源(CPU 降频)时,启动时间会大幅增加到 4~6 秒

这强烈的暗示:瓶颈不再主要在于磁盘 I/O,而在于 CPU 计算

经过分析,瓶颈主要集中在大量的字符串操作负面查找(Negative Lookup)上。

资源架构与 Alpha 通道探测的冲突

要理解为什么会有性能瓶颈,首先需要明确 PvZ-Portable 的资源加载架构,它主要由两部分组成:

  1. PAK 资源包 (main.pak)
    • 这是宝开(PopCap)官方分发游戏资源的方式。
    • 它是一个巨大的压缩包,包含了游戏中 99.9% 的图片、音效和数据。
    • 特点:作为官方只读数据,其内部文件名是规范的,且已被我们读入内存红黑树(std::map),查找速度极快(O(log n)),不存在 IO 性能问题。
  2. 松散文件 (Loose Files)
    • 这是指直接散落在游戏目录下的文件。
    • 作用:主要用于或热更新或者贴图替换
    • 引擎设计的逻辑是:如果磁盘上存在某个文件(如 images/Zombie.png),它会优先于 main.pak 中的同名文件被加载。这允许玩家通过简单的复制粘贴来修改游戏图片。

性能杀手:Alpha 遮罩探测

PvZ 的旧版引擎有一个遗留特性:在加载每一张图片(比如 Zombie.png)时,都会自动尝试寻找是否存在独立的透明度遮罩文件(通常命名为 _Zombie.pngZombie_.png),用于合成最终图像。

这个逻辑对于通过 main.pak 加载的官方图片来说是灾难性的:

  1. 引擎加载了 main.pak 中的 Zombie.png
  2. 引擎为了确认有没有针对这张图片的松散 Alpha 遮罩 Mod,会去磁盘上查找 _Zombie.png
  3. 99% 的情况下,这种文件是不存在的
  4. fcaseopen 的逻辑里,查找不存在的文件的代价是最高的 —— 为了确认它真的不存在(而不是仅仅大小写没对上),它必须遍历整个目录。

这意味着加载 1000 张 PAK 内的图片,就会产生 2000 次针对磁盘的、必然失败的、高成本的文件查找。

阶段二:引入内存索引与快速失败路径

为了榨干最后的性能,笔者引入了更深度的优化方案。

引入 FastFileExists 与 PAK 优先策略

既然 90% 的 Alpha 遮罩查找都是失败的,我们需要让失败来得更快一点。笔者引入了 CheckSinglePath 函数(即 FastFileExists 的实现),其逻辑如下:

首先,优先查询内存中的 PAK 索引。由于 PAK 内的文件列表已被预先加载到内存红黑树 std::map 中,我们只需将路径规范化并大写,即可在 O(log n) 的时间内完成查找,完全规避了磁盘 I/O。

其次,仅当 PAK 中未找到时,才查询物理文件系统。但这里进行了一个关键的权衡(Trade-off):我们使用标准的大小写敏感的 stat 系统调用(Sexy::FileExists),而不是昂贵的 fcaseopen。这意味着,如果 Alpha 遮罩作为一个松散文件存在于磁盘上,但文件名大小写与代码不匹配,它将被视为不存在。

这样做是因为这主要用于探测大概率不存在的 Alpha 辅助文件。为了兼容极少数文件名大小写写错的松散 Alpha 文件而对 90% 的不存在情况调用昂贵的错误恢复流程是极不划算的。对于这类松散文件,我们要求用户/开发者保证文件名大小写正确。

通过优先查询内存中的 PAK 索引表,大量的无效文件探测瞬间完成,完全规避了磁盘 I/O 和目录遍历。同时,对于磁盘文件查找,我们采取了主资源保兼容,辅助资源保性能的策略,这背后的逻辑是:

  1. 历史遗留 vs 用户行为main.pak 是宝开官方打包的,其中的内容受到版权保护,不可自行分发,里面的大小写混乱是真正的历史债务,我们必须兼容。
  2. 松散文件无包袱: 松散文件(如修改的贴图或者 Alpha 通道图)是由现在的玩家或开发者手动放入游戏目录的,并不存在历史遗留问题。作为新加入的资源,完全有理由要求创作者遵循目标平台的文件命名规范(即大小写正确)。
  3. 性能权衡: 为了兼容极少数用户自己犯下的命名错误,而去惩罚 99% 的正常玩家的启动速率,显然是不划算的。

因此,最终的实现方案为:

  • 主资源加载 (Main Image):经过 TryLoadByExt 尝试加载,最终会调用底层的 fcaseopen,虽然慢但保证了全兼容,即使磁盘文件名大小写错了也能找到。
  • 辅助资源探测 (Alpha Mask):使用 FastFileExists 预检。如果 PAK 里没有,且磁盘文件名严格大小写不匹配,直接放弃加载。这避免了对海量不存在文件进行递归目录扫描。

优化 NormalizePakPath

对于路径规范化这一热点函数,笔者进行了针对性优化:

  • 减少分配:接口改为接收 std::string_view,减少入参时的临时字符串构造。
  • 按需转换:仅在必要时才调用 std::filesystem 的重型操作。

数据驱动的格式探测

原先的代码在加载图片时,是硬编码的一连串 if-else

// 旧代码
if (Load("foo.png")) ...
else if (Load("foo.jpg")) ...
else if (Load("foo.gif")) ...

每一次 Load 调用(即使失败)都可能触发复杂的路径处理。新代码将其重构为数据驱动的查找表结构,配合 std::string_view,使得逻辑更加紧凑且易于分支预测。

阶段三:目录级负面缓存

在上述优化之后,虽然启动速度已经很快,但对于文件系统的系统调用仍然有优化的空间。笔者进一步引入了目录级的负面缓存(Negative Directory Cache)

其核心思想很简单:如果探测文件 Foo/Bar/Baz.png 失败,并且原因是因为 Foo/Bar/ 目录本身甚至都不存在,那么就没有必要再尝试去探测 Foo/Bar/Qux.png 了。

在实现中,我们在文件检查前增加了缓存查验:首先检查父目录是否已被记录在 gMissingDirCache 中,如果是,则直接返回 false。如果文件不存在,我们会顺便检查其父目录是否存在;如果父目录也不存在,则将其加入缺失目录缓存,避免后续对该目录下其他文件的无效探测。

⚠️ 版权与说明

PvZ-Portable 严格遵守版权协议。游戏的 IP(植物大战僵尸)属于 PopCap/EA。

要研究或使用此项目,你必须拥有正版游戏(如果没有,请在 SteamEA 官网 上购买)。你需要从正版游戏中提取以下文件放到 PvZ-Portable 的程序所在目录中:

  • main.pak
  • properties/ 目录

本项目仅提供引擎代码,用于技术学习,不包含上述任何游戏资源文件,任何游戏资源均需要用户自行提供正版游戏文件。

本项目的源代码以 LGPL-3.0-or-later 许可证开源,欢迎学习和贡献。