feat(editor): manual mesh renderer setup + asset hover info card

Mesh Renderer was missing from the Add Component menu and its model
reference was read-only, so a mesh could only be attached by loading
a saved scene. Add it to the menu (Unity Add Component semantics) and
give the block a UE-Details-style asset slot: a dropdown listing all
model files under assets/, selecting applies LoadModel (cache deduped).

Asset browser files now show a UE Content Browser style hover card:
kind, size, modified time, and loaded state (new AssetCache::Contains
query, read-only -- hovering never triggers loading).

Fix latent FATALs unlocked by manual assignment: AssetCache::Get
aborts on invalid handles, so rendering, animation sampling, and mesh
collider build now guard mr.model.Valid() before calling it.
This commit is contained in:
2026-10-04 01:07:38 +08:00
parent 7d02d344e5
commit 69671cf4eb
5 changed files with 156 additions and 14 deletions
+60 -2
View File
@@ -10,6 +10,9 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <chrono>
#include <cstdio>
#include <ctime>
#include <filesystem> #include <filesystem>
#include <vector> #include <vector>
@@ -283,10 +286,65 @@ void AssetBrowserPanel::DrawFileList(const EditorContext& ctx,
} }
} }
// tooltip:完整相对路径(面板窄时文件名截断仍可辨识) // hover 信息卡(UE Content Browser 式):类型/大小/修改时间/加载状态
if (ImGui::IsItemHovered()) 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<int>(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<double>(sz) / (1024.0 * 1024.0));
}
else
{
std::snprintf(szBuf, sizeof(szBuf), "%.1f KB",
static_cast<double>(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(); ImGui::PopID();
} }
+68 -2
View File
@@ -13,7 +13,9 @@
#include <imgui.h> #include <imgui.h>
#include <algorithm>
#include <cstdio> #include <cstdio>
#include <filesystem>
#include <vector> #include <vector>
#include <type_traits> #include <type_traits>
@@ -148,6 +150,8 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ImGui::EndDisabled(); ImGui::EndDisabled();
} }
}; };
menuItem(static_cast<Engine::ECS::MeshRendererComponent*>(nullptr),
"Mesh Renderer");
menuItem(static_cast<Engine::ECS::LightComponent*>(nullptr), "Light"); menuItem(static_cast<Engine::ECS::LightComponent*>(nullptr), "Light");
menuItem(static_cast<Engine::ECS::CameraComponent*>(nullptr), "Camera"); menuItem(static_cast<Engine::ECS::CameraComponent*>(nullptr), "Camera");
menuItem(static_cast<Engine::ECS::AnimationComponent*>(nullptr), "Animation"); menuItem(static_cast<Engine::ECS::AnimationComponent*>(nullptr), "Animation");
@@ -194,6 +198,62 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
{ {
ImGui::BeginDisabled(true); // 禁用 = 内容置灰(Unity 语义) ImGui::BeginDisabled(true); // 禁用 = 内容置灰(Unity 语义)
} }
const std::string cur = ctx.assets->Models().Path(mr->model);
// 模型资产槽(UE Details 资产槽语义):下拉列 assets/ 下全部
// 模型文件,选中即 LoadModel 应用;已加载路径缓存去重复用
{
namespace fs = std::filesystem;
static std::vector<std::string> 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<const char*> opts;
opts.push_back("<none>");
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<int>(k) + 1;
}
}
ImGui::SetNextItemWidth(-1.0f);
if (ImGui::Combo("##model_slot", &sel, opts.data(),
static_cast<int>(opts.size())))
{
mr->model = (sel <= 0)
? Engine::ModelRef{}
: ctx.assets->LoadModel(s_ModelFiles[static_cast<size_t>(sel) - 1]);
}
ImGui::Spacing();
}
// 无效句柄不进 GetModel(缓存对无效/悬空句柄 FATAL,是 bug 信号)
if (mr->model.Valid())
{
if (const Engine::Model* model = ctx.assets->GetModel(mr->model); if (const Engine::Model* model = ctx.assets->GetModel(mr->model);
model != nullptr) model != nullptr)
{ {
@@ -201,13 +261,17 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ImGui::Text("model: valid (%u clips, %u bones)", ImGui::Text("model: valid (%u clips, %u bones)",
static_cast<unsigned>(clips.size()), static_cast<unsigned>(clips.size()),
static_cast<unsigned>(model->GetBoneCount())); static_cast<unsigned>(model->GetBoneCount()));
ImGui::TextDisabled("(mesh asset is read-only here)");
} }
else else
{ {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"model: INVALID (dangling ref)"); "model: INVALID (dangling ref)");
} }
}
else
{
ImGui::TextDisabled("no model assigned");
}
if (!mr->active) if (!mr->active)
{ {
ImGui::EndDisabled(); ImGui::EndDisabled();
@@ -677,8 +741,10 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
ImGui::BeginDisabled(true); ImGui::BeginDisabled(true);
} }
const auto* mr = reg.try_get<Engine::ECS::MeshRendererComponent>(selected); const auto* mr = reg.try_get<Engine::ECS::MeshRendererComponent>(selected);
// 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 <none> 即无效)
const Engine::Model* model = const Engine::Model* model =
mr ? ctx.assets->GetModel(mr->model) : nullptr; (mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model)
: nullptr;
// 依赖提示:动画作用在 MeshRenderer 的模型骨骼上 // 依赖提示:动画作用在 MeshRenderer 的模型骨骼上
if (mr == nullptr) if (mr == nullptr)
@@ -122,6 +122,12 @@ namespace Engine
return static_cast<u32>(m_Entries.size()) - static_cast<u32>(m_Free.size()); return static_cast<u32>(m_Entries.size()) - static_cast<u32>(m_Free.size());
} }
// 路径是否在缓存(含被卸载后路径清除的语义:卸载即不含)
bool Contains(const std::string& path) const
{
return m_ByPath.find(path) != m_ByPath.end();
}
private: private:
struct Entry struct Entry
{ {
@@ -170,6 +176,16 @@ namespace Engine
DecodedEquirect&& decoded); DecodedEquirect&& decoded);
Cubemap* GetEnvironment(EnvRef h) const { return m_Environments.Get(h); } 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<Model>& Models() { return m_Models; } AssetCache<Model>& Models() { return m_Models; }
// ---- 资产 GUID 注册表(UE AssetRegistry 模式:中央注册表)---- // ---- 资产 GUID 注册表(UE AssetRegistry 模式:中央注册表)----
+2 -2
View File
@@ -685,8 +685,8 @@ namespace Engine {
// 同实体 MeshRenderer 提供网格;缺失 → 回退(CreateBody 判 null 走 Box) // 同实体 MeshRenderer 提供网格;缺失 → 回退(CreateBody 判 null 走 Box)
auto *mc = m_Scene.GetRegistry().try_get<ECS::MeshRendererComponent>( auto *mc = m_Scene.GetRegistry().try_get<ECS::MeshRendererComponent>(
static_cast<entt::entity>(entityBits)); static_cast<entt::entity>(entityBits));
if (m_Assets == nullptr || mc == nullptr) { if (m_Assets == nullptr || mc == nullptr || !mc->model.Valid()) {
return nullptr; return nullptr; // 无效句柄(未指派)不进 Get(对无效 FATAL)
} }
const Model *model = m_Assets->GetModel(mc->model); const Model *model = m_Assets->GetModel(mc->model);
if (model == nullptr) { if (model == nullptr) {
+2
View File
@@ -394,6 +394,7 @@ namespace Engine
scene.Each<MeshRendererComponent, AnimationComponent>( scene.Each<MeshRendererComponent, AnimationComponent>(
[&](Entity entity, MeshRendererComponent& mr, AnimationComponent& anim) { [&](Entity entity, MeshRendererComponent& mr, AnimationComponent& anim) {
if (!anim.active) return; // 组件禁用 = 暂停采样(播放头不动) if (!anim.active) return; // 组件禁用 = 暂停采样(播放头不动)
if (!mr.model.Valid()) return; // 未指派/手清 = 无效句柄(Get 对无效 FATAL)
const Model* model = assets.GetModel(mr.model); const Model* model = assets.GetModel(mr.model);
if (!model || !model->HasAnimations()) return; if (!model || !model->HasAnimations()) return;
const auto& clips = model->GetClips(); const auto& clips = model->GetClips();
@@ -516,6 +517,7 @@ namespace Engine
scene.Each<TransformComponent, MeshRendererComponent>( scene.Each<TransformComponent, MeshRendererComponent>(
[&](Entity entity, TransformComponent& t, MeshRendererComponent& mr) { [&](Entity entity, TransformComponent& t, MeshRendererComponent& mr) {
if (!mr.active) return; // 组件禁用 = 不渲染不拾取 if (!mr.active) return; // 组件禁用 = 不渲染不拾取
if (!mr.model.Valid()) return; // 未指派 = 无效句柄(Get 对无效 FATAL)
Model* model = assets.GetModel(mr.model); Model* model = assets.GetModel(mr.model);
if (!model) return; if (!model) return;