第 11 章:TextureHolder 与僵尸群

第 11 章:TextureHolder 与僵尸群:保留第三版项目代码讲解,并以源码—状态—输出切片、确定性轨迹和故障重放完成验收。

学习目标

  • 能解释“第 11 章:TextureHolder 与僵尸群”如何让 TextureHolder 集中加载、缓存并借出纹理,同时把单实例便利性与隐藏全局状态的代价写清
  • 能逐项定位 textureholder 类(textureholder class)、静态成员函数(static function)、单实例(single instance)、僵尸群(horde of zombies)、纹理缓存(texture cache),说明它们位于源码、运行状态还是可见输出边界
  • 能按 规范化键 → 查找缓存 → 加载纹理 → 原子插入 → 借出引用 重放“重复请求同一纹理”,持续检查“同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存”
  • 能注入“用 map 的 operator[] 先插入空纹理,再忽略 loadFromFile 失败并返回该条目”,从规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序找到第一个不一致并用同输入恢复

第三版来源、工具链与版本边界

“第 11 章:TextureHolder 与僵尸群”对齐 Packt 2024 年第三版的对应章节范围,并以官方公开代码仓库和 SFML 官方文档核对本页可公开验证的工程事实。本页是独立中文重写,不复现原书正文,也不把商品页、目录或代码仓库冒充完整原版。

在“第 11 章:TextureHolder 与僵尸群”中,第三版示例使用 SFML 2.6.1 时代 API。本页保留与本章有关的 2.6 系列合同;SFML 3 的事件、角度、时长和构造接口差异不会被静默回填。升级工具链时必须单独记录本章迁移补丁,不能把版本不匹配误判为“textureholder 类(textureholder class)”概念错误。

  • Packt:Beginning C++ Game Programming, Third Edition:在“第 11 章:TextureHolder 与僵尸群”中,核对 2024 年第三版、C++20、SFML、四个项目、21 个正式教学章节及章节次序;不把商品页当作正文全文。
  • PacktPublishing:第三版官方代码仓库:在“第 11 章:TextureHolder 与僵尸群”中,核对 Timber、Pong、ZombieShooter、Run 四个项目的公开代码与资源组织;代码许可证不等于原书正文授权。
  • SFML 2.6 官方教程:在“第 11 章:TextureHolder 与僵尸群”中,核对本书使用的 SFML 2.6 系列 Window、Graphics、View、VertexArray、Shader 与 Audio API 语义。

正式概念与运行状态合同

textureholder 类(textureholder class)

这个正式目录节点落在 资源请求 边界:路径键、调用点与静态访问入口。在本页中,它参与“让 TextureHolder 集中加载、缓存并借出纹理,同时把单实例便利性与隐藏全局状态的代价写清”。验收时保存规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序,不能只凭最终画面判断。

静态成员函数(static function)

这个正式目录节点落在 缓存所有权 边界:TextureHolder、map 与稳定对象寿命。在本页中,它参与“让 TextureHolder 集中加载、缓存并借出纹理,同时把单实例便利性与隐藏全局状态的代价写清”。验收时保存同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存,不能只凭最终画面判断。

单实例(single instance)

这个正式目录节点落在 借用结果 边界:Sprite 保存的 Texture 关系与加载错误。在本页中,它参与“让 TextureHolder 集中加载、缓存并借出纹理,同时把单实例便利性与隐藏全局状态的代价写清”。验收时保存规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序,不能只凭最终画面判断。

僵尸群(horde of zombies)

这个正式目录节点落在 资源请求 边界:路径键、调用点与静态访问入口。在本页中,它参与“让 TextureHolder 集中加载、缓存并借出纹理,同时把单实例便利性与隐藏全局状态的代价写清”。验收时保存同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存,不能只凭最终画面判断。

纹理缓存(texture cache)

这个正式目录节点落在 缓存所有权 边界:TextureHolder、map 与稳定对象寿命。在本页中,它参与“让 TextureHolder 集中加载、缓存并借出纹理,同时把单实例便利性与隐藏全局状态的代价写清”。验收时保存规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序,不能只凭最终画面判断。

验收项本页合同
最小正常场景两个 Sprite 依次请求同一个规范化路径
边界或恢复场景请求一个不存在的资源路径
必须保持同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存
单一故障用 map 的 operator[] 先插入空纹理,再忽略 loadFromFile 失败并返回该条目
可观察证据规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序

先预测,再操作三个本页实验

实验一:从源码到可见结果

先预测“重复请求同一纹理”会怎样穿过 资源请求 → 缓存所有权 → 借用结果,再切换场景和正式概念。每次操作都必须能回到同一初始状态。

Source · state · visible result

第 11 章:TextureHolder 与僵尸群:可运行切片

让 TextureHolder 集中加载、缓存并借出纹理,同时把单实例便利性与隐藏全局状态的代价写清

选择输入或构建场景

定位正式概念

bcgp3-11 · 当前切片

textureholder 类(textureholder class)两个 Sprite 依次请求同一个规范化路径

边界 1资源请求

路径键、调用点与静态访问入口

边界 2缓存所有权

TextureHolder、map 与稳定对象寿命

边界 3借用结果

Sprite 保存的 Texture 关系与加载错误

可验收结果

只发生一次文件加载,两次获得同一稳定 Texture 对象

实验二:逐步执行状态轨迹

依次执行 规范化键 → 查找缓存 → 加载纹理 → 原子插入 → 借出引用。每一步只选中一个阶段,并持续检查“同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存”。

Deterministic state trace

第 11 章:TextureHolder 与僵尸群:状态执行轨迹

逐步执行当前 1 / 5
始终保持

同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存

本步证据

规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序

实验三:单一故障与同输入恢复

注入“用 map 的 operator[] 先插入空纹理,再忽略 loadFromFile 失败并返回该条目”,定位第一项不一致;撤销后用完全相同的“纹理文件缺失”重放。只有中间状态和最终输出一起恢复才算修复。

Fault injection · clean replay

第 11 章:TextureHolder 与僵尸群:故障注入与恢复

单一故障:用 map 的 operator[] 先插入空纹理,再忽略 loadFromFile 失败并返回该条目

1. 固定构建与输入一致

第 1 次重放使用同一源码、资源、初始状态和输入序列

2. 程序状态一致

同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存

3. 诊断证据一致

规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序

4. 恢复判断一致

同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致

易错边界与工程取舍

从“纹理缓存怎样保证永远只有一个事实来源”开始

第 10 章建立了纹理共享原则。第 11 章把查找、加载、地址稳定与销毁规则封装进

目标不是简单包一层 map,而是让“加载一次、失败回滚、使用期不删除”成为无法绕过的接口不变量。

缓存接口与稳定容器

可使用节点式 std::map 保持元素地址在插入其他键时稳定。接口返回 const sf::Texture&,调用者能绑定 Sprite 但不能修改缓存纹理。

class TextureHolder {
public:
    [[nodiscard]] const sf::Texture& get(const std::filesystem::path& path);
 
private:
    std::map<std::filesystem::path, sf::Texture> textures_;
};

路径键应先规范化到项目资源根;大小写与符号链接是否合并取决于平台和资产策略。不要无条件 canonical 一个尚不存在的路径后丢失原始错误上下文。

未命中加载必须事务化

直接 textures_[key].loadFromFile(...) 会先插入默认 Texture,加载失败后留下“存在但无效”的条目。应先加载局部候选,成功后提交。

const sf::Texture& TextureHolder::get(const std::filesystem::path& rawPath)
{
    const auto key{normalizeAssetPath(rawPath)};
    if (const auto found{textures_.find(key)}; found != textures_.end())
        return found->second;
 
    sf::Texture candidate;
    if (!candidate.loadFromFile(key.string()))
        throw std::runtime_error{"texture load failed: " + key.string()};
 
    auto [position, inserted]{textures_.try_emplace(key, std::move(candidate))};
    return position->second;
}

使异常路径可预测。示例未处理并发双重加载;本书约定纹理在渲染线程初始化阶段请求。

静态成员函数与单实例

可提供全局访问入口。

让所有 Sprite 从同一缓存取得纹理。

class TextureHolder {
public:
    static TextureHolder& instance()
    {
        static TextureHolder holder;
        return holder;
    }
 
    TextureHolder(const TextureHolder&) = delete;
    TextureHolder& operator=(const TextureHolder&) = delete;
    TextureHolder(TextureHolder&&) = delete;
    TextureHolder& operator=(TextureHolder&&) = delete;
 
    const sf::Texture& get(const std::filesystem::path& path);
 
private:
    TextureHolder() = default;
};

自 C++11 起初始化本身线程安全,但后续对 map 和 SFML Texture 的操作不因此自动线程安全。资源请求仍集中在渲染线程,或另加清楚同步协议。

单例的代价与测试边界

全局访问隐藏函数依赖:Zombie 构造表面只接路径,内部却可能做文件 I/O、抛异常并访问图形资源。测试也难替换失败、占位纹理或内存资源。更透明的设计让 Game 拥有 TextureHolder,并向构造函数传 TextureProvider& 或直接传 const Texture&

与本书单例并不冲突:应用入口可把 TextureHolder::instance() 作为生产实现传给核心逻辑,测试传替身。

单例销毁顺序也要审计。若另一个静态对象析构时访问已经销毁的 holder,会出现静态析构顺序问题。最稳妥是让游戏实体与 Sprite 在 main 结束前明确销毁,不在静态析构中请求纹理。

纹理借用不等于资源所有权

要求缓存条目在所有关联 Sprite 销毁前保持地址和生命周期。TextureHolder 不应公开任意 erase;关卡卸载要先销毁借用实体,再删除资源组。

const sf::Texture& zombieTexture{
    TextureHolder::instance().get("assets/graphics/zombie.png")};
 
Zombie zombie{zombieTexture, spawnPosition};

Zombie 构造只借用已经成功加载的纹理,不执行 I/O。这样对象构造可预测,TextureHolder 的失败集中在关卡初始化阶段。

热重载必须保持对象地址或通知全部 Sprite 重新绑定。把 map 条目 erase 后重新插入会让旧关联悬空,即使键相同也不是同一个对象。

规划僵尸群

构建前验证 count 不超过玩法和性能上限,竞技场足够大,并定义玩家出生安全半径。已知数量先 reserve,避免构造中重分配。

用距离平方比较,避免不必要开方。

bool farEnough(sf::Vector2f candidate, sf::Vector2f player, float minimum)
{
    const sf::Vector2f delta{candidate - player};
    return delta.x * delta.x + delta.y * delta.y >= minimum * minimum;
}

安全距离只是一个约束,还要保证点位在 arena 内、未落在墙块、与其它僵尸保持最小间隔或允许重叠的明确策略。

有上限的随机生成

随机拒绝采样若没有最大尝试次数,在约束不可满足时会无限循环。每只 Zombie 最多尝试固定次数,失败则让整次群体构建返回错误或缩小数量,不能悄悄把敌人放到非法点。

std::vector<Zombie> buildHorde(std::size_t count,
                               const sf::Texture& texture,
                               const sf::FloatRect& arena,
                               sf::Vector2f player,
                               std::mt19937& random)
{
    std::vector<Zombie> candidate;
    candidate.reserve(count);
 
    for (std::size_t index{0}; index < count; ++index) {
        const auto spawn{findSpawn(arena, player, random, 64)};
        if (!spawn)
            throw std::runtime_error{"unable to place zombie horde"};
        candidate.emplace_back(texture, *spawn);
    }
    return candidate;
}

是终止性契约。局部 vector 完整成功后按值返回;异常时自动销毁已构造 Zombie,调用者旧群体不受影响。

构建一千只僵尸时真正共享什么

每只 Zombie 有独立 Sprite,但 Sprite 关联同一 Texture 地址。共享纹理不等于共享变换:setPosition、rotation 和 color 属于 Sprite。测试可以让所有 sprite.getTexture() 返回同一地址,同时位置互不相同。

一千个对象仍可能产生一千次 draw。TextureHolder 解决重复加载和资源生命周期,不自动完成渲染批处理;性能问题要分层测量。先记录纹理加载次数应为 1,再记录更新和 draw 预算,后续才决定实例批处理或可见性裁剪。

Zombie 删除不会影响 Texture,缓存仍拥有它。关卡切换释放纹理前先清空 horde 和其他 Sprite。若多个关卡共享同一纹理,可由资源组引用计数或更长会话缓存管理,但不要把 shared_ptr 塞进每个 Sprite 破坏 SFML 的既有借用模型。

TextureHolder 文件与接口组织

头文件暴露 instance/get 和不可复制声明,.cpp 包含 filesystem 规范化、map 与加载实现。若隐藏 map 需要 PImpl,可在规模与编译成本出现证据后再做;当前直接私有成员更易教学。

错误信息包含规范化键、原始请求和资源根。日志不应吞掉 SFML 自己的诊断。API 返回引用意味着调用者不能在 holder 销毁后保存;文档明确“有效到对应资源被卸载或 holder 销毁”。

测试替身可实现 TextureProvider 接口返回预先构造的小纹理,验证 Zombie/Horde 不依赖文件系统。TextureHolder 自身用临时资源测试命中只加载一次、失败不缓存、不同规范路径按策略合并。

验证缓存与群体

缓存测试同一路径调用两次,断言返回地址相同且加载计数为一;缺文件两次都应明确失败而非第二次命中空对象;加载新键后旧地址保持。复制和移动 TextureHolder 应编译失败。

群体测试 count 为 0、1、1000,断言数量准确、位置在 arena、距 Player 足够、所有纹理地址相同。用不可满足的小 arena 验证在尝试上限后失败且旧群体不变。固定种子得到可重放点位。

集成测试销毁 horde 后再销毁 holder,运行 AddressSanitizer 检查悬空;加入新纹理后旧 Sprite 仍可绘制。性能计数确认每种资源只上传一次,但不把平均 FPS 当唯一正确性证据。

先预测:textures_[path] 后 load 失败,为何下一次 get 可能不再尝试加载?下标已插入默认 Texture,查询看到键存在便误判命中;必须候选成功后再提交。

小结

  • TextureHolder 把规范键、命中、候选加载、成功提交和稳定借用封装为缓存不变量
  • 静态访问和函数局部 static 可建立单实例,但 map/GPU 操作不因此线程安全
  • 单例隐藏依赖并增加测试成本,核心逻辑宜显式接收 Texture 或 TextureProvider
  • Sprite 借用缓存 Texture,使用期不能 erase;关闭先销毁 horde,再销毁 holder
  • 僵尸群在局部 vector 完整构建,随机点满足 arena 与安全距离,并有最大尝试上限
  • 所有 Zombie 共享纹理地址但保留独立 Sprite 状态;缓存优化不等于 draw 批处理

练习

问题 1 为什么不能用 textures_[key].loadFromFile 直接加载?写出失败后缓存应保持的状态。

问题 2 函数局部 static 保证了什么,又没有保证什么?为什么还需要依赖注入?

问题 3 构建 1000 只僵尸怎样保证终止、异常安全和纹理共享?

名词解释

本章出现的专业名词,用大白话再讲一遍。

TextureHolder 类

按资源键缓存 Texture 并返回稳定只读引用的资源所有者。

纹理缓存

复用已加载纹理而不重复磁盘读取和 GPU 上传的机制。

候选提交

局部加载验证成功后一次插入缓存、失败不改变旧状态的过程。

静态成员函数

无 this 指针、通过类名调用的成员函数。

单实例模式

限制类型只有一个可访问实例并提供统一访问点的模式。

函数局部静态对象

首次经过声明时初始化并存活到正常进程终止的对象。

依赖注入

通过参数显式传入资源、时钟或随机源而非内部访问全局实例。

纹理借用

Sprite 使用但不拥有 Texture 的生命周期关系。

僵尸群

由容器拥有、共享纹理且各有独立状态的一组 Zombie。

出生安全距离

Player 周围禁止生成敌人的最小世界距离。

生成尝试上限

随机拒绝采样寻找合法点时保证终止的最大次数。

资料与写作方式声明

本章以Beginning C++ Game Programming, Third Edition, Chapter 11权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…