ECS框架-引入哈希字符串-重构资源管理器

引入哈希字符串

由于代码中存在大量的名字比如:UI的状态名normal/hover/pressed,音乐、音效ui_hover/battle_bgm,纹理/声音路径assets/textures/...、assets/audio/...等等,这些使用的都是std::string()来存、比较、查找,这会逐渐出现两个问题:

  • 性能写法 --- 字符串比较相对于哈希比较来说性能上开销大,而且路径很长,难以阅读
  • 维护性 --- 资源路径一旦发生变化,所有硬编码的地方都要进行修改

这章我们引入EnTT的哈希字符串entt::hashed_string),把“字符串 --> 整数ID(entt::id_type)”,将资源系统改造成"用整数做key存储/查找"。

image-20260324131427387

本节目标

  • 理解 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 从字符串变为整数

image-20260324144401061

之前的阳光岛,我们的资源管理模块中对于相应的资源采用的存储方式都是以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_idtexture_path

我们为Sprite新增了一点内容,包含两份信息:

  • texture_id_:渲染时真正用来查资源的key
  • texture_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查缓存。

posted @ 2026-03-24 19:12  wenyiGamecpp  阅读(1)  评论(0)    收藏  举报