From 175ce35def52e978c45311998f4d581f85c5f70a Mon Sep 17 00:00:00 2001 From: atdunbg Date: Sun, 4 Oct 2026 02:31:30 +0800 Subject: [PATCH] fix: animation-only FBX assets loadable; clip data viewer Pure-animation FBX (no meshes, skeleton clips only) was rejected at the cache layer: ParsedModel::Valid() required non-empty meshes, so Model::FromParsed/LoadModel returned null and the asset never made it into the cache. Animation assets are now first-class: - ParsedModel::Valid() = meshes or clips non-empty; parse failures (both empty) still rejected. Empty-mesh consumers already guarded (rendering skips empty mesh loop, mesh collider falls back to Box) - ParseFromDisk: fix null-deref when assimp returns a null scene - AssetCache::Find(path) + AssetManager::GetModelByPath for panels - Asset Browser hover card shows meshes/bones/clips for loaded models - Inspector model line flags "animation-only" when meshes == 0 - Animation section: collapsible clip data table (name/duration/ channel count) to verify bone-name matching of attached clips --- App/editor/AssetBrowserPanel.cpp | 14 +++++ App/editor/InspectorPanel.cpp | 59 +++++++++++++++++++-- Engine/include/Engine/Assets/AssetManager.h | 17 ++++++ Engine/include/Engine/Renderer/Model.h | 5 +- Engine/src/Assets/AssetManager.cpp | 5 +- Engine/src/Renderer/Model.cpp | 6 +-- 6 files changed, 97 insertions(+), 9 deletions(-) diff --git a/App/editor/AssetBrowserPanel.cpp b/App/editor/AssetBrowserPanel.cpp index ccc70e6..ab779b2 100644 --- a/App/editor/AssetBrowserPanel.cpp +++ b/App/editor/AssetBrowserPanel.cpp @@ -342,6 +342,20 @@ void AssetBrowserPanel::DrawFileList(const EditorContext& ctx, state = "loaded"; } ImGui::Text("state: %s", state); + // 已加载的 model 展开资产数据概览(meshes=0 即纯动画 + // 容器;未加载不显示——查询不触发加载) + if (item.kind == Kind::Model) + { + if (const Engine::Model* m = + ctx.assets->GetModelByPath(assetPath); + m != nullptr) + { + ImGui::Text("meshes: %u, bones: %u, clips: %u", + static_cast(m->GetMeshes().size()), + m->GetBoneCount(), + static_cast(m->GetClips().size())); + } + } } ImGui::TextDisabled("%s", assetPath.c_str()); ImGui::EndTooltip(); diff --git a/App/editor/InspectorPanel.cpp b/App/editor/InspectorPanel.cpp index 7e2eb6f..b66eb5f 100644 --- a/App/editor/InspectorPanel.cpp +++ b/App/editor/InspectorPanel.cpp @@ -329,9 +329,23 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) model != nullptr) { const auto& clips = model->GetClips(); - ImGui::Text("model: valid (%u clips, %u bones)", - static_cast(clips.size()), - static_cast(model->GetBoneCount())); + if (model->GetMeshes().empty()) + { + // 纯动画容器(无网格):不能作为渲染模型,提示 + // 挂到 MeshRenderer 没有视觉效果 + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), + "model: animation-only " + "(%u clips, %u bones, no meshes)", + static_cast(clips.size()), + model->GetBoneCount()); + } + else + { + ImGui::Text("model: valid (%u meshes, %u clips, %u bones)", + static_cast(model->GetMeshes().size()), + static_cast(clips.size()), + model->GetBoneCount()); + } } else { @@ -910,6 +924,45 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) ImGui::Text("t: %.2f / %.2f s", anim->time, clips[anim->clipIndex].duration); } + // 动画数据表:每个 clip 的时长与有效轨道数(attach 进来的 + // 外部动画在此核对骨骼匹配结果——轨道数明显偏少 = 骨骼 + // 命名不同源被跳过) + if (ImGui::TreeNode("clip data")) + { + ImGui::Columns(3, nullptr, false); + ImGui::SetColumnWidth(0, 150.0f); + ImGui::SetColumnWidth(1, 90.0f); + ImGui::TextUnformatted("name"); + ImGui::NextColumn(); + ImGui::TextUnformatted("dur (s)"); + ImGui::NextColumn(); + ImGui::TextUnformatted("channels"); + ImGui::NextColumn(); + ImGui::Separator(); + for (const auto& c : clips) + { + const bool curClip = + &c == &clips[static_cast( + std::clamp(anim->clipIndex, 0, + static_cast(clips.size()) - 1))]; + if (curClip) + { + ImGui::TextColored(ImVec4(0.3f, 0.85f, 0.4f, 1.0f), + ">%s", c.name.c_str()); + } + else + { + ImGui::TextUnformatted(c.name.c_str()); + } + ImGui::NextColumn(); + ImGui::Text("%.2f", c.duration); + ImGui::NextColumn(); + ImGui::Text("%u", static_cast(c.channels.size())); + ImGui::NextColumn(); + } + ImGui::Columns(1); + ImGui::TreePop(); + } } else { diff --git a/Engine/include/Engine/Assets/AssetManager.h b/Engine/include/Engine/Assets/AssetManager.h index 78f1fd3..8201edd 100644 --- a/Engine/include/Engine/Assets/AssetManager.h +++ b/Engine/include/Engine/Assets/AssetManager.h @@ -117,6 +117,17 @@ namespace Engine return (e.generation == h.generation && e.alive) ? e.path : kEmpty; } + // 按路径查活资产(未加载/已卸载返回 nullptr;不触发加载、 + // 不对坏句柄 FATAL)——面板展示用 + T* Find(const std::string& path) const + { + auto it = m_ByPath.find(path); + if (it == m_ByPath.end()) return nullptr; + const Entry& e = m_Entries[it->second.index]; + return (e.alive && e.generation == it->second.generation) + ? e.asset.get() : nullptr; + } + u32 Count() const { return static_cast(m_Entries.size()) - static_cast(m_Free.size()); @@ -160,6 +171,12 @@ namespace Engine ModelRef LoadModel(const std::string& path, ParsedModel&& parsed); Model* GetModel(ModelRef h) const { return m_Models.Get(h); } void UnloadModel(ModelRef h) { m_Models.Unload(h); } + // 按路径查已加载的模型(未加载返回 null,不触发加载)—— + // 面板 hover 信息卡用 + Model* GetModelByPath(const std::string& path) const + { + return m_Models.Find(path); + } // ---- 外部动画并入(UE AnimSequence 归属 Skeleton 的简化版)---- // 把动画容器文件(无网格 FBX/GLB 等)里的 clip 按骨骼节点名 diff --git a/Engine/include/Engine/Renderer/Model.h b/Engine/include/Engine/Renderer/Model.h index e5b0b10..ee35531 100644 --- a/Engine/include/Engine/Renderer/Model.h +++ b/Engine/include/Engine/Renderer/Model.h @@ -99,7 +99,10 @@ namespace Engine std::vector nodes; std::vector bones; std::vector clips; - bool Valid() const { return !meshes.empty(); } + // 解析有效性:mesh 与 clip 至少其一非空。纯动画 FBX(无网格只有 + // 骨骼动画,UE 的 Animation Sequence 资产同理)是合法资产—— + // 过去只认 meshes 导致动画源文件在缓存层被整体拒载 + bool Valid() const { return !meshes.empty() || !clips.empty(); } }; class Model diff --git a/Engine/src/Assets/AssetManager.cpp b/Engine/src/Assets/AssetManager.cpp index 804f8db..cde61c9 100644 --- a/Engine/src/Assets/AssetManager.cpp +++ b/Engine/src/Assets/AssetManager.cpp @@ -140,8 +140,9 @@ namespace Engine::Assets { return res; } } - // 纯 CPU 解析:动画容器只要 nodes + clips,不建 GPU 资产 - //(meshes 空的文件过不了 Model 构建,也不需要构建) + // 纯 CPU 解析:动画容器只要 nodes + clips。不复用缓存里可能已 + // 预热的同名 Model——缓存实例的 clips nodeIdx 按它自己的 node 表 + // 编号,attach 需要按目标模型的 node 表重映射,必须独立解析 ParsedModel src = Model::ParseFromDisk(animPath); if (src.clips.empty()) { ENGINE_WARN("AttachAnimationClips: no clips in '{}'", animPath); diff --git a/Engine/src/Renderer/Model.cpp b/Engine/src/Renderer/Model.cpp index 85a14f7..0bf2026 100644 --- a/Engine/src/Renderer/Model.cpp +++ b/Engine/src/Renderer/Model.cpp @@ -194,7 +194,8 @@ ParsedModel Model::ParseFromDisk(const std::string& path) aiProcess_LimitBoneWeights); // 每顶点限 4 权重(= 顶点格式 boneIds 容量) if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) { - if (!scene->mNumMeshes) { + // scene 可能为 null(文件损坏/格式不支持),先判空再打印资产清单 + if (scene != nullptr) { ENGINE_WARN("Assimp warn: model dose not have mesh assets\nmodel has following asset:"); ENGINE_WARN("\tMeshes Count: {}", scene->mNumMeshes); @@ -204,10 +205,9 @@ ParsedModel Model::ParseFromDisk(const std::string& path) ENGINE_WARN("\tMaterials Count: {}", scene->mNumMaterials); ENGINE_WARN("\tSkeletons Count: {}", scene->mNumSkeletons); ENGINE_WARN("\tTextures Count: {}", scene->mNumTextures); - } ENGINE_ERROR("Assimp error: {}", importer.GetErrorString()); - return out; // meshes 空 = 无效 + return out; // meshes/clips 空 = 无效 } // 骨骼动画三件套:node 表(global 累乘骨架)→ 动画轨道 → 骨骼注册