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
This commit is contained in:
@@ -342,6 +342,20 @@ void AssetBrowserPanel::DrawFileList(const EditorContext& ctx,
|
|||||||
state = "loaded";
|
state = "loaded";
|
||||||
}
|
}
|
||||||
ImGui::Text("state: %s", state);
|
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<unsigned>(m->GetMeshes().size()),
|
||||||
|
m->GetBoneCount(),
|
||||||
|
static_cast<unsigned>(m->GetClips().size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ImGui::TextDisabled("%s", assetPath.c_str());
|
ImGui::TextDisabled("%s", assetPath.c_str());
|
||||||
ImGui::EndTooltip();
|
ImGui::EndTooltip();
|
||||||
|
|||||||
@@ -329,9 +329,23 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
|||||||
model != nullptr)
|
model != nullptr)
|
||||||
{
|
{
|
||||||
const auto& clips = model->GetClips();
|
const auto& clips = model->GetClips();
|
||||||
ImGui::Text("model: valid (%u clips, %u bones)",
|
if (model->GetMeshes().empty())
|
||||||
static_cast<unsigned>(clips.size()),
|
{
|
||||||
static_cast<unsigned>(model->GetBoneCount()));
|
// 纯动画容器(无网格):不能作为渲染模型,提示
|
||||||
|
// 挂到 MeshRenderer 没有视觉效果
|
||||||
|
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
|
||||||
|
"model: animation-only "
|
||||||
|
"(%u clips, %u bones, no meshes)",
|
||||||
|
static_cast<unsigned>(clips.size()),
|
||||||
|
model->GetBoneCount());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ImGui::Text("model: valid (%u meshes, %u clips, %u bones)",
|
||||||
|
static_cast<unsigned>(model->GetMeshes().size()),
|
||||||
|
static_cast<unsigned>(clips.size()),
|
||||||
|
model->GetBoneCount());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -910,6 +924,45 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
|||||||
ImGui::Text("t: %.2f / %.2f s", anim->time,
|
ImGui::Text("t: %.2f / %.2f s", anim->time,
|
||||||
clips[anim->clipIndex].duration);
|
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<size_t>(
|
||||||
|
std::clamp(anim->clipIndex, 0,
|
||||||
|
static_cast<int>(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<unsigned>(c.channels.size()));
|
||||||
|
ImGui::NextColumn();
|
||||||
|
}
|
||||||
|
ImGui::Columns(1);
|
||||||
|
ImGui::TreePop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ namespace Engine
|
|||||||
return (e.generation == h.generation && e.alive) ? e.path : kEmpty;
|
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
|
u32 Count() const
|
||||||
{
|
{
|
||||||
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());
|
||||||
@@ -160,6 +171,12 @@ namespace Engine
|
|||||||
ModelRef LoadModel(const std::string& path, ParsedModel&& parsed);
|
ModelRef LoadModel(const std::string& path, ParsedModel&& parsed);
|
||||||
Model* GetModel(ModelRef h) const { return m_Models.Get(h); }
|
Model* GetModel(ModelRef h) const { return m_Models.Get(h); }
|
||||||
void UnloadModel(ModelRef h) { m_Models.Unload(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 的简化版)----
|
// ---- 外部动画并入(UE AnimSequence 归属 Skeleton 的简化版)----
|
||||||
// 把动画容器文件(无网格 FBX/GLB 等)里的 clip 按骨骼节点名
|
// 把动画容器文件(无网格 FBX/GLB 等)里的 clip 按骨骼节点名
|
||||||
|
|||||||
@@ -99,7 +99,10 @@ namespace Engine
|
|||||||
std::vector<ModelNode> nodes;
|
std::vector<ModelNode> nodes;
|
||||||
std::vector<ModelBone> bones;
|
std::vector<ModelBone> bones;
|
||||||
std::vector<AnimationClip> clips;
|
std::vector<AnimationClip> clips;
|
||||||
bool Valid() const { return !meshes.empty(); }
|
// 解析有效性:mesh 与 clip 至少其一非空。纯动画 FBX(无网格只有
|
||||||
|
// 骨骼动画,UE 的 Animation Sequence 资产同理)是合法资产——
|
||||||
|
// 过去只认 meshes 导致动画源文件在缓存层被整体拒载
|
||||||
|
bool Valid() const { return !meshes.empty() || !clips.empty(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
class Model
|
class Model
|
||||||
|
|||||||
@@ -140,8 +140,9 @@ namespace Engine::Assets {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 纯 CPU 解析:动画容器只要 nodes + clips,不建 GPU 资产
|
// 纯 CPU 解析:动画容器只要 nodes + clips。不复用缓存里可能已
|
||||||
//(meshes 空的文件过不了 Model 构建,也不需要构建)
|
// 预热的同名 Model——缓存实例的 clips nodeIdx 按它自己的 node 表
|
||||||
|
// 编号,attach 需要按目标模型的 node 表重映射,必须独立解析
|
||||||
ParsedModel src = Model::ParseFromDisk(animPath);
|
ParsedModel src = Model::ParseFromDisk(animPath);
|
||||||
if (src.clips.empty()) {
|
if (src.clips.empty()) {
|
||||||
ENGINE_WARN("AttachAnimationClips: no clips in '{}'", animPath);
|
ENGINE_WARN("AttachAnimationClips: no clips in '{}'", animPath);
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ ParsedModel Model::ParseFromDisk(const std::string& path)
|
|||||||
aiProcess_LimitBoneWeights); // 每顶点限 4 权重(= 顶点格式 boneIds 容量)
|
aiProcess_LimitBoneWeights); // 每顶点限 4 权重(= 顶点格式 boneIds 容量)
|
||||||
|
|
||||||
if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) {
|
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("Assimp warn: model dose not have mesh assets\nmodel has following asset:");
|
||||||
|
|
||||||
ENGINE_WARN("\tMeshes Count: {}", scene->mNumMeshes);
|
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("\tMaterials Count: {}", scene->mNumMaterials);
|
||||||
ENGINE_WARN("\tSkeletons Count: {}", scene->mNumSkeletons);
|
ENGINE_WARN("\tSkeletons Count: {}", scene->mNumSkeletons);
|
||||||
ENGINE_WARN("\tTextures Count: {}", scene->mNumTextures);
|
ENGINE_WARN("\tTextures Count: {}", scene->mNumTextures);
|
||||||
|
|
||||||
}
|
}
|
||||||
ENGINE_ERROR("Assimp error: {}", importer.GetErrorString());
|
ENGINE_ERROR("Assimp error: {}", importer.GetErrorString());
|
||||||
return out; // meshes 空 = 无效
|
return out; // meshes/clips 空 = 无效
|
||||||
}
|
}
|
||||||
|
|
||||||
// 骨骼动画三件套:node 表(global 累乘骨架)→ 动画轨道 → 骨骼注册
|
// 骨骼动画三件套:node 表(global 累乘骨架)→ 动画轨道 → 骨骼注册
|
||||||
|
|||||||
Reference in New Issue
Block a user