diff --git a/App/editor/AssetBrowserPanel.cpp b/App/editor/AssetBrowserPanel.cpp index 0749ae2..ccc70e6 100644 --- a/App/editor/AssetBrowserPanel.cpp +++ b/App/editor/AssetBrowserPanel.cpp @@ -10,6 +10,9 @@ #include #include +#include +#include +#include #include #include @@ -283,10 +286,65 @@ void AssetBrowserPanel::DrawFileList(const EditorContext& ctx, } } - // tooltip:完整相对路径(面板窄时文件名截断仍可辨识) + // hover 信息卡(UE Content Browser 式):类型/大小/修改时间/加载状态 if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("%s", assetPath.c_str()); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(name.c_str()); + ImGui::Separator(); + static const char* kKindNames[] = {"Model", "Texture/Environment", + "Scene", "File"}; + ImGui::Text("type: %s", kKindNames[static_cast(item.kind)]); + namespace fs = std::filesystem; + std::error_code ec; + const auto sz = fs::file_size(item.path, ec); + if (!ec) + { + char szBuf[32]; + if (sz >= 1024ull * 1024ull) + { + std::snprintf(szBuf, sizeof(szBuf), "%.1f MB", + static_cast(sz) / (1024.0 * 1024.0)); + } + else + { + std::snprintf(szBuf, sizeof(szBuf), "%.1f KB", + static_cast(sz) / 1024.0); + } + ImGui::Text("size: %s", szBuf); + } + const auto ftime = fs::last_write_time(item.path, ec); + if (!ec) + { + // file_clock → system_clock 近似转换(C++17 无官方转换) + const auto sctp = std::chrono::time_point_cast< + std::chrono::system_clock::duration>( + ftime - fs::file_time_type::clock::now() + + std::chrono::system_clock::now()); + const std::time_t tt = std::chrono::system_clock::to_time_t(sctp); + char tmBuf[32]; + std::strftime(tmBuf, sizeof(tmBuf), "%Y-%m-%d %H:%M", + std::localtime(&tt)); + ImGui::Text("modified: %s", tmBuf); + } + // 加载状态(只查询不触发加载;model/HDR 进过缓存才显示 loaded) + if (ctx.assets) + { + const char* state = "not loaded"; + if (item.kind == Kind::Model && + ctx.assets->IsModelLoaded(assetPath)) + { + state = "loaded"; + } + else if (item.kind == Kind::Texture && + ctx.assets->IsEnvironmentLoaded(assetPath)) + { + state = "loaded"; + } + ImGui::Text("state: %s", state); + } + ImGui::TextDisabled("%s", assetPath.c_str()); + ImGui::EndTooltip(); } ImGui::PopID(); } diff --git a/App/editor/InspectorPanel.cpp b/App/editor/InspectorPanel.cpp index 5e993c5..63d64aa 100644 --- a/App/editor/InspectorPanel.cpp +++ b/App/editor/InspectorPanel.cpp @@ -13,7 +13,9 @@ #include +#include #include +#include #include #include @@ -148,6 +150,8 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) ImGui::EndDisabled(); } }; + menuItem(static_cast(nullptr), + "Mesh Renderer"); menuItem(static_cast(nullptr), "Light"); menuItem(static_cast(nullptr), "Camera"); menuItem(static_cast(nullptr), "Animation"); @@ -194,19 +198,79 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) { ImGui::BeginDisabled(true); // 禁用 = 内容置灰(Unity 语义) } - if (const Engine::Model* model = ctx.assets->GetModel(mr->model); - model != nullptr) + const std::string cur = ctx.assets->Models().Path(mr->model); + // 模型资产槽(UE Details 资产槽语义):下拉列 assets/ 下全部 + // 模型文件,选中即 LoadModel 应用;已加载路径缓存去重复用 { - const auto& clips = model->GetClips(); - ImGui::Text("model: valid (%u clips, %u bones)", - static_cast(clips.size()), - static_cast(model->GetBoneCount())); - ImGui::TextDisabled("(mesh asset is read-only here)"); + namespace fs = std::filesystem; + static std::vector s_ModelFiles; + s_ModelFiles.clear(); + if (fs::exists("assets")) + { + for (const auto& entry : + fs::recursive_directory_iterator("assets")) + { + if (!entry.is_regular_file()) + { + continue; + } + std::string ext = entry.path().extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) + { return std::tolower(c); }); + if (ext == ".gltf" || ext == ".glb" || ext == ".fbx" || + ext == ".obj") + { + s_ModelFiles.push_back( + entry.path().generic_string()); + } + } + std::sort(s_ModelFiles.begin(), s_ModelFiles.end()); + } + std::vector opts; + opts.push_back(""); + for (const auto& f : s_ModelFiles) + { + opts.push_back(f.c_str()); + } + int sel = 0; + for (size_t k = 0; k < s_ModelFiles.size(); ++k) + { + if (s_ModelFiles[k] == cur) + { + sel = static_cast(k) + 1; + } + } + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::Combo("##model_slot", &sel, opts.data(), + static_cast(opts.size()))) + { + mr->model = (sel <= 0) + ? Engine::ModelRef{} + : ctx.assets->LoadModel(s_ModelFiles[static_cast(sel) - 1]); + } + ImGui::Spacing(); + } + // 无效句柄不进 GetModel(缓存对无效/悬空句柄 FATAL,是 bug 信号) + if (mr->model.Valid()) + { + if (const Engine::Model* model = ctx.assets->GetModel(mr->model); + model != nullptr) + { + const auto& clips = model->GetClips(); + ImGui::Text("model: valid (%u clips, %u bones)", + static_cast(clips.size()), + static_cast(model->GetBoneCount())); + } + else + { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "model: INVALID (dangling ref)"); + } } else { - ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), - "model: INVALID (dangling ref)"); + ImGui::TextDisabled("no model assigned"); } if (!mr->active) { @@ -677,8 +741,10 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) ImGui::BeginDisabled(true); } const auto* mr = reg.try_get(selected); + // 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 即无效) const Engine::Model* model = - mr ? ctx.assets->GetModel(mr->model) : nullptr; + (mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model) + : nullptr; // 依赖提示:动画作用在 MeshRenderer 的模型骨骼上 if (mr == nullptr) diff --git a/Engine/include/Engine/Assets/AssetManager.h b/Engine/include/Engine/Assets/AssetManager.h index eff380f..68a3306 100644 --- a/Engine/include/Engine/Assets/AssetManager.h +++ b/Engine/include/Engine/Assets/AssetManager.h @@ -122,6 +122,12 @@ namespace Engine return static_cast(m_Entries.size()) - static_cast(m_Free.size()); } + // 路径是否在缓存(含被卸载后路径清除的语义:卸载即不含) + bool Contains(const std::string& path) const + { + return m_ByPath.find(path) != m_ByPath.end(); + } + private: struct Entry { @@ -170,6 +176,16 @@ namespace Engine DecodedEquirect&& decoded); Cubemap* GetEnvironment(EnvRef h) const { return m_Environments.Get(h); } + // 路径缓存查询(面板显示加载状态用;不触发加载) + bool IsModelLoaded(const std::string& path) const + { + return m_Models.Contains(path); + } + bool IsEnvironmentLoaded(const std::string& path) const + { + return m_Environments.Contains(path); + } + AssetCache& Models() { return m_Models; } // ---- 资产 GUID 注册表(UE AssetRegistry 模式:中央注册表)---- diff --git a/Engine/src/Physics/PhysicsWorld.cpp b/Engine/src/Physics/PhysicsWorld.cpp index 1cf75b1..458a866 100644 --- a/Engine/src/Physics/PhysicsWorld.cpp +++ b/Engine/src/Physics/PhysicsWorld.cpp @@ -685,8 +685,8 @@ namespace Engine { // 同实体 MeshRenderer 提供网格;缺失 → 回退(CreateBody 判 null 走 Box) auto *mc = m_Scene.GetRegistry().try_get( static_cast(entityBits)); - if (m_Assets == nullptr || mc == nullptr) { - return nullptr; + if (m_Assets == nullptr || mc == nullptr || !mc->model.Valid()) { + return nullptr; // 无效句柄(未指派)不进 Get(对无效 FATAL) } const Model *model = m_Assets->GetModel(mc->model); if (model == nullptr) { diff --git a/Engine/src/Renderer/SceneRenderer.cpp b/Engine/src/Renderer/SceneRenderer.cpp index 281aa65..4b573b2 100644 --- a/Engine/src/Renderer/SceneRenderer.cpp +++ b/Engine/src/Renderer/SceneRenderer.cpp @@ -394,6 +394,7 @@ namespace Engine scene.Each( [&](Entity entity, MeshRendererComponent& mr, AnimationComponent& anim) { if (!anim.active) return; // 组件禁用 = 暂停采样(播放头不动) + if (!mr.model.Valid()) return; // 未指派/手清 = 无效句柄(Get 对无效 FATAL) const Model* model = assets.GetModel(mr.model); if (!model || !model->HasAnimations()) return; const auto& clips = model->GetClips(); @@ -516,6 +517,7 @@ namespace Engine scene.Each( [&](Entity entity, TransformComponent& t, MeshRendererComponent& mr) { if (!mr.active) return; // 组件禁用 = 不渲染不拾取 + if (!mr.model.Valid()) return; // 未指派 = 无效句柄(Get 对无效 FATAL) Model* model = assets.GetModel(mr.model); if (!model) return;