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:
2026-10-04 02:31:30 +08:00
parent 2577809ee4
commit 175ce35def
6 changed files with 97 additions and 9 deletions
@@ -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<u32>(m_Entries.size()) - static_cast<u32>(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 按骨骼节点名
+4 -1
View File
@@ -99,7 +99,10 @@ namespace Engine
std::vector<ModelNode> nodes;
std::vector<ModelBone> bones;
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
+3 -2
View File
@@ -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);
+3 -3
View File
@@ -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 累乘骨架)→ 动画轨道 → 骨骼注册