feat(animation): attach external animation files to a model

Animation-only FBX files (no mesh, clips only) could not be used:
clips are keyed by node index into the parsed model's node table, so
there was no path to get them onto a different model's skeleton.

Add AssetManager::AttachAnimationClips: parses the container with the
CPU-only path (no GPU upload -- empty meshes cannot build a Model),
remaps each clip's channels to the target model's node indices by
bone name, and appends to its clip list. Channels whose bone name is
absent are skipped and counted; clips with zero matching channels are
dropped. Same-source attach is idempotent via a new
AnimationClip::sourcePath marker; name clashes get the file stem
prefixed.

Inspector Animation block gains an attach slot (UE Details semantics)
listing model-container files plus a bullet list of attached sources;
already-attached paths are replayed idempotently when the entity is
selected, so clips survive restarts. The paths persist in the scene
(Animation.Attached); the clips themselves stay runtime state.

Limitations (documented, by design): same-skeleton retargeting only
-- bone names must match between source and target (same DCC export
or Mixamo-style per-animation downloads); cross-skeleton retargeting
is out of scope.
This commit is contained in:
2026-10-04 01:25:04 +08:00
parent 69671cf4eb
commit 90bf63e641
6 changed files with 235 additions and 27 deletions
+101 -27
View File
@@ -16,11 +16,48 @@
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <unordered_set>
#include <vector>
#include <type_traits>
namespace
{
// 递归收集 assets/ 下模型容器文件(模型槽与动画 attach 槽共用;
// 无法从扩展名区分"模型 fbx"与"纯动画 fbx",一并列出)。
// 会话级缓存:目录条目几十量级,避免每帧递归扫描
const std::vector<std::string>& AssetModelFiles()
{
static std::vector<std::string> s_Files;
static bool s_Cached = false;
if (!s_Cached)
{
s_Cached = true;
namespace fs = std::filesystem;
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_Files.push_back(entry.path().generic_string());
}
}
std::sort(s_Files.begin(), s_Files.end());
}
}
return s_Files;
}
// InputText 需要 char 缓冲;提交时写回 std::string
bool InputTextString(const char* label, std::string& str)
{
@@ -202,31 +239,7 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
// 模型资产槽(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());
}
const auto& s_ModelFiles = AssetModelFiles();
std::vector<const char*> opts;
opts.push_back("<none>");
for (const auto& f : s_ModelFiles)
@@ -742,17 +755,78 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
}
const auto* mr = reg.try_get<Engine::ECS::MeshRendererComponent>(selected);
// 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 <none> 即无效)
const Engine::Model* model =
Engine::Model* model =
(mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model)
: nullptr;
// ---- 外部动画并入(自愈重放 + attach 槽)----
// 重放:attachedAnimPaths 里未并入的源(重启后 Model 缓存是
// 空白)幂等重 attach;解析失败的路径会话内不再重试
if (model != nullptr)
{
static std::unordered_set<std::string> s_AttachFailed;
for (const auto& p : anim->attachedAnimPaths)
{
const bool done = std::any_of(
model->GetClips().begin(), model->GetClips().end(),
[&](const Engine::AnimationClip& c)
{ return c.sourcePath == p; });
if (!done && !s_AttachFailed.contains(p))
{
const auto res =
ctx.assets->AttachAnimationClips(mr->model, p);
if (res.clipsAdded == 0)
{
s_AttachFailed.insert(p);
}
}
}
}
// 依赖提示:动画作用在 MeshRenderer 的模型骨骼上
if (mr == nullptr)
{
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
"! requires a MeshRenderer component");
}
else if (model != nullptr && model->HasAnimations())
else if (model != nullptr)
{
// 动画资产槽:列全部模型容器文件(无法静态区分"模型
// fbx"与"纯动画 fbx",一并列出,无 clip 的会警告跳过)
const auto& files = AssetModelFiles();
std::vector<const char*> opts;
opts.push_back("<attach animation file...>");
for (const auto& f : files)
{
opts.push_back(f.c_str());
}
int sel = 0;
ImGui::SetNextItemWidth(-1.0f);
if (ImGui::Combo("##anim_slot", &sel, opts.data(),
static_cast<int>(opts.size())))
{
const std::string& path = files[static_cast<size_t>(sel) - 1];
const auto res =
ctx.assets->AttachAnimationClips(mr->model, path);
if (res.clipsAdded > 0 &&
std::none_of(anim->attachedAnimPaths.begin(),
anim->attachedAnimPaths.end(),
[&](const std::string& s)
{ return s == path; }))
{
anim->attachedAnimPaths.push_back(path);
}
}
// 已并入的源文件清单
for (const auto& p : anim->attachedAnimPaths)
{
ImGui::BulletText("%s", std::filesystem::path(p)
.filename()
.string()
.c_str());
}
}
if (model != nullptr && model->HasAnimations())
{
const auto& clips = model->GetClips();
std::vector<const char*> clipNames;