ECS框架-引入哈希字符串-重构资源管理器
引入哈希字符串
由于代码中存在大量的名字比如:UI的状态名normal/hover/pressed,音乐、音效ui_hover/battle_bgm,纹理/声音路径assets/textures/...、assets/audio/...等等,这些使用的都是std::string()来存、比较、查找,这会逐渐出现两个问题:
- 性能写法 --- 字符串比较相对于哈希比较来说性能上开销大,而且路径很长,难以阅读
- 维护性 --- 资源路径一旦发生变化,所有硬编码的地方都要进行修改
这章我们引入EnTT的哈希字符串(entt::hashed_string),把“字符串 --> 整数ID(entt::id_type)”,将资源系统改造成"用整数做key存储/查找"。

本节目标
- 理解
entt::hashed_string/entt::id_type的作用与使用场景 - 学会在系统里“存 ID、用 ID 比较/查找”,而不是到处传
std::string - 把资源管理器的容器 key 从字符串
std::string改为entt::id_type - 引入资源映射表:用短 key(如
battle_bgm)映射到真实路径(如assets/audio/...)
存ID vs 存字符串
哈希字符串的本质:为字符串提供一个稳定的整数身份证,entt::hashed_string主要做了两件事,根据输入字符串计算出一个哈希值(entt::id_type);保存对原字符串的轻量引用(调试/打印);总体就是一个id_type,一个std::string_view。
工程中,遵循对外传ID、对内存ID,字符串只负责“人类可读”
entt::hashed_string内部引用原字符串std::string_view(指针/长度),如果把它长期存起来,可能会踩生命周期的坑的。之前我们通过了_hs来绑定字符串字面量,其实现的就是一种编译期间将字符串字面量放到程序只读常量区,std::string_view只存放指针+长度,指向那里。
我们需要重构一下代码,实现:
- 容器key统一使用
entt::id_type(纯整数、无生命周期问题) entt::hashed_string主要用来生成 ID(value())以及需要时提供data()
资源模块的改造:key 从字符串变为整数

之前的阳光岛,我们的资源管理模块中对于相应的资源采用的存储方式都是以std::string字符串作为key的,现在我们用哈希字符串中的id_type作为key。
ResourceManager:统一入口 + 两套调用方式
ResourceManager仍然作为"统一入口",对外提供load/get/unload/clear,内部把工作委托给三个子管理器。
采用两套接口:
- (id, file_path):适用于“有一个较短的key,但也可以知道它对应的真实路径”(比如从配置文件中读取的内容)
- (hashed_string):适用于“直接用字符串字母量(路径)生成ID并调用”
这两种方式都是挺有用处的,举个例子:
// 采用直接以hashed_string的方式存储一个资源
// assets/textures/Buildings/Castle.png
SDL_Texture* loadTexture(entt::hashed_string str_hs); // 通过字符串哈希值
// 用的时候直接会以一长串的路径作为hashed_string,存储的方式是一长串的哈希字符串变成的id,然后值是一个SDL_Texture的资源
SDL_Texture* loadTexture(entt::id_type id, std::string_view file_path);
// 还有一种方式就是通过一个短的哈希字符串id,对应一个SDL_Textrue资源,这种方式可以解决硬编码的问题,比如我的音乐想修改,可以从配置文件中进行替换路径,但其key仍然不变
SDL_Texture* getTexture(entt::id_type id, std::string_view file_path = "");
SDL_Texture* getTexture(entt::hashed_string str_hs);
相应的,我们也有两种方式去get,需要注意的是,getTexture(id) 在没缓存且没提供 file_path 时会返回 nullptr,这需要注意。如果传入资源的路径是支持懒加载的。
子管理器:unordered_map<entt::id_type, ...> 缓存资源
刚才用Texture作为例子,这里就用audio做例子。
std::unordered_map<entt::id_type, std::unique_ptr<Mix_Chunk, SDLMixChunkDeleter>> sounds_;
// 加载方法 也是两种
Mix_Chunk* loadSound(entt::id_type id, std::string_view path); // 加载音效
Mix_Chunk* loadSound(entt::hashed_string str_hs); // 加载音效
先通过id查缓存,如果有就返回;没有就通过path加载,并把结果放进sounds_[id]
同样的方法也存在texture、font等等
自定义资源映射:短key代替长路径
我们会出现这样的问题,由于路径较长,复制书写比较麻烦
// 真实路径太长,不好写;换资源也麻烦
context.getAudioPlayer().playMusic("assets/audio/4 Battle Track INTRO TomMusic.ogg"_hs);
我们希望引入一个"资源映射表",把 短key 映射到 真实路径,在代码中就可以通过短key来映射资源了。
context_.getAudioPlayer().playMusic("battle_bgm"_hs);
可以通过创建一个映射文件resource_mapping.json
{
"sound": {
"ui_hover": "assets/audio/Fantasy_UI (1).wav",
"ui_click": "assets/audio/Piano_Ui (2).wav",
"unit_placed": "assets/audio/Fantasy_UI (10).wav",
"unit_upgrade": "assets/audio/Fantasy UI - Twilight (2).wav",
"arrow_shoot": "assets/audio/Bow Attack.wav",
"arrow_hit": "assets/audio/Bow Impact Hit 1.ogg",
"sword_hit": "assets/audio/Sword Impact Hit 1.ogg",
"spell_shoot": "assets/audio/Fireball 1.ogg",
"spell_hit": "assets/audio/fire-ball.wav",
"heal": "assets/audio/healing-balm.wav"
},
"music": {
"title_bgm": "assets/audio/HEROICCC(chosic.com).mp3",
"battle_bgm": "assets/audio/4 Battle Track INTRO TomMusic.ogg",
"win": "assets/audio/level-win.mp3",
"lose": "assets/audio/violin-lose-4.mp3"
}
}
资源管理模块resourcemanager中写一个加载函数,实现数据驱动
//
bool loadResources(std::string_view path); // 初始化资源映射
bool ResourceManager::loadResources(std::string_view path)
{
// 文件路径查找
std::filesystem::path file_path(path);
if (!std::filesystem::exists(file_path)) {
spdlog::warn("资源映射文件不存在: {}", path);
return false;
} // 读取json文件内容
std::ifstream file(file_path);
nlohmann::json json;
file >> json;
try { // 尝试将资源预加载
if (json.contains("sound")) {
for (const auto& [key, value] : json["sound"].items()) {
loadSound(entt::hashed_string(key.c_str()), value.get<std::string>());
}
}
if (json.contains("music")) {
for (const auto& [key, value] : json["music"].items()) {
loadMusic(entt::hashed_string(key.c_str()), value.get<std::string>());
}
}
if (json.contains("texture")) {
for (const auto& [key, value] : json["texture"].items()) {
loadTexture(entt::hashed_string(key.c_str()), value.get<std::string>());
}
}
if (json.contains("font")) {
for (const auto& [key, value] : json["font"].items()) {
loadFont(entt::hashed_string(key.c_str()), value.get<int>(), value.get<std::string>());
}
}
return true;
} catch (const nlohmann::json::exception& e) {
spdlog::error("资源映射文件解析失败: {}", e.what());
}
return false;
}
在GameApp初始化资源模块的时候进行预加载
bool GameApp::initResourceManager()
{
try {
resource_manager_ = std::make_unique<engine::resource::ResourceManager>(sdl_renderer_);
} catch (const std::exception& e){
spdlog::error("初始化ResourceManager失败: {}", e.what());
return false;
}
if(resource_manager_->loadResources("assets/data/resource_mapping.json")){
spdlog::info("资源预加载成功");
} else spdlog::info("资源预加载失败");
return true;
}
这样我们就实现了,将相应的短字符串变量转为entt::id_type作为容器的key,缓存的内容依然是我们的真实路径资源
渲染&UI重构,将Sprite/UIImage/UIButton也改成ID
资源缓存key变了,顺带的渲染侧也要跟着调整,否则你编译也过不了,*—*
Sprite:同时保存texture_id与texture_path
我们为Sprite新增了一点内容,包含两份信息:
texture_id_:渲染时真正用来查资源的keytexture_path_:可选,用于调试/日志/初始化场景std::optional<engine::utils::Rect> source_rect_;:顺带将原来的SDL_FRect修改成自己定义的rect,保证分层架构
现在的构造函数:
Sprite(std::string_view texture_path,
std::optional<engine::utils::Rect> source_rect = std::nullopt,
bool is_flipped = false)
: texture_path_(texture_path.data()),
texture_id_(entt::hashed_string(texture_path.data())), //转化为标识符
source_rect_(std::move(source_rect)),
is_flipped_(is_flipped)
{}
/**
* @brief 构造一个精灵
* @note 这里需要确保 资源已经加载到资源管理器中,所以不用包含 texture_path
*/
Sprite(entt::id_type texture_id,
std::optional<engine::utils::Rect> source_rect = std::nullopt,
bool is_flipped = false)
: texture_id_(texture_id),
source_rect_(std::move(source_rect)),
is_flipped_(is_flipped)
{}
注意这两种方式,不传路径的需要确保资源是已经加载的,因为在后边渲染器获得texture是:
auto texture = resource_manager_->getTexture(sprite.getTextureId());
这样我们可以只关心纹理ID,而不用担心纹理路径。
UIImage:使用纹理ID构造
UIImage 新增了 entt::id_type 的构造函数。所以,目前的方式是,会先显式的进行预加载的方式,将图片内容先缓存到资源管理器中,之后直接调用的方式加载图片。
void GameScene::testResourceManager()
{ // 显式预加载
context_.getResourceManager().loadTexture("assets/textures/Buildings/Castle.png"_hs);
// 播放音乐
context_.getAudioPlayer().playMusic("battle_bgm"_hs);
// 测试UI元素(使用载入的资源)
ui_manager_->addElement(std::make_unique<engine::ui::UIImage>("assets/textures/Buildings/Castle.png"_hs)); // 通过哈希id加载
ui_manager_->addElement(std::make_unique<engine::ui::UILabel>(
&context_.getTextRenderer(),
"Hello, World!",
"assets/fonts/VonwaonBitmap-16px.ttf"
));
}
UIInteractive / UIButton:UI 状态名也用 _hs 做 key
可交互 UI(按钮)需要管理一组 sprite(normal/hover/pressed)和音效(hover/click),所以也会将映射改成整数 key:
sprites_:unordered_map<entt::id_type, Sprite>sounds_:unordered_map<entt::id_type, entt::id_type>--- 音效资源记录id就可以通过资源管理器获取到
值得注意的一点是,current_sprite也将其作为entt::id_type,后面关于状态切换只要进行类似setSprite("hover"_hs);的操作即可。
关于构造方面,则是类似这样的写法:
// Add sprites
addSprite("normal"_hs, engine::render::Sprite(normal_sprite_id));
// 声音添加
addSound("hover"_hs, sound_hover_path);
章节总结
这节主要实现了代码的重构,将资源管理器方面通过std::string作为key统一改为entt::id_type,降低字符串比较/哈希成本,也让接口更清晰;entt::hashed_string可以把字符串映射到整数ID;工程上推荐存entt::id_type;引入一个resource_mapping.json的配置文件,完成数据驱动,通过短key映射到真实路径,避免硬编码;渲染方面,sprite/uiimage/uibutton,也将其改造成id查缓存。

浙公网安备 33010602011771号