第 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 依次请求同一个规范化路径
路径键、调用点与静态访问入口
↓
TextureHolder、map 与稳定对象寿命
↓
Sprite 保存的 Texture 关系与加载错误
可验收结果
只发生一次文件加载,两次获得同一稳定 Texture 对象
实验二:逐步执行状态轨迹
依次执行 规范化键 → 查找缓存 → 加载纹理 → 原子插入 → 借出引用。每一步只选中一个阶段,并持续检查“同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存”。
Deterministic state trace
第 11 章:TextureHolder 与僵尸群:状态执行轨迹
同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存
规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序
实验三:单一故障与同输入恢复
注入“用 map 的 operator[] 先插入空纹理,再忽略 loadFromFile 失败并返回该条目”,定位第一项不一致;撤销后用完全相同的“纹理文件缺失”重放。只有中间状态和最终输出一起恢复才算修复。
Fault injection · clean replay
第 11 章:TextureHolder 与僵尸群:故障注入与恢复
单一故障:用 map 的 operator[] 先插入空纹理,再忽略 loadFromFile 失败并返回该条目
第 1 次重放使用同一源码、资源、初始状态和输入序列
同一路径只加载一次,返回的 Texture 引用在所有 Sprite 使用期间稳定有效,失败项不进入缓存
规范化资源键、缓存命中/未命中、加载返回值、Texture 地址和销毁顺序
同输入重放后,所有权、状态更新、可见输出和诊断证据重新一致
易错边界与工程取舍
从“纹理缓存怎样保证永远只有一个事实来源”开始
第 10 章建立了纹理共享原则。第 11 章把查找、加载、地址稳定与销毁规则封装进
↡按规范化资源键缓存 sf::Texture、命中时返回稳定只读引用并统一管理生命周期的类。目标不是简单包一层 map,而是让“加载一次、失败回滚、使用期不删除”成为无法绕过的接口不变量。
缓存接口与稳定容器
↡把已加载纹理按资源键保存,后续相同请求直接复用对象而不重复磁盘读取和 GPU 上传的机制。可使用节点式 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;
}使异常路径可预测。示例未处理并发双重加载;本书约定纹理在渲染线程初始化阶段请求。
静态成员函数与单实例
↡不依赖某个对象 this 指针、通过类名调用的成员函数,只能直接访问静态成员或显式传入对象。可提供全局访问入口。
↡限制某类型在进程内只有一个可访问实例,并提供统一访问点的设计模式。让所有 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 保存 Texture 关联并在绘制时使用,但不负责销毁 Texture 的非拥有关系。要求缓存条目在所有关联 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 后重新插入会让旧关联悬空,即使键相同也不是同一个对象。
规划僵尸群
↡当前波次中由容器拥有的一组 Zombie 对象,共享纹理但各自拥有位置、速度、血量和 Sprite。构建前验证 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 周围禁止生成敌人的最小世界距离。
- 生成尝试上限
随机拒绝采样寻找合法点时保证终止的最大次数。