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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 模式:中央注册表)----
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user