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 <algorithm>
#include <cstdio> #include <cstdio>
#include <filesystem> #include <filesystem>
#include <unordered_set>
#include <vector> #include <vector>
#include <type_traits> #include <type_traits>
namespace 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 // InputText 需要 char 缓冲;提交时写回 std::string
bool InputTextString(const char* label, std::string& str) bool InputTextString(const char* label, std::string& str)
{ {
@@ -202,31 +239,7 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
// 模型资产槽(UE Details 资产槽语义):下拉列 assets/ 下全部 // 模型资产槽(UE Details 资产槽语义):下拉列 assets/ 下全部
// 模型文件,选中即 LoadModel 应用;已加载路径缓存去重复用 // 模型文件,选中即 LoadModel 应用;已加载路径缓存去重复用
{ {
namespace fs = std::filesystem; const auto& s_ModelFiles = AssetModelFiles();
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; std::vector<const char*> opts;
opts.push_back("<none>"); opts.push_back("<none>");
for (const auto& f : s_ModelFiles) 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); const auto* mr = reg.try_get<Engine::ECS::MeshRendererComponent>(selected);
// 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 <none> 即无效) // 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 <none> 即无效)
const Engine::Model* model = Engine::Model* model =
(mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model) (mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model)
: nullptr; : 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 的模型骨骼上 // 依赖提示:动画作用在 MeshRenderer 的模型骨骼上
if (mr == nullptr) if (mr == nullptr)
{ {
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
"! requires a MeshRenderer component"); "! 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(); const auto& clips = model->GetClips();
std::vector<const char*> clipNames; std::vector<const char*> clipNames;
@@ -161,6 +161,20 @@ namespace Engine
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); }
// ---- 外部动画并入(UE AnimSequence 归属 Skeleton 的简化版)----
// 把动画容器文件(无网格 FBX/GLB 等)里的 clip 按骨骼节点名
// 重映射进目标模型的 clip 表。前提 = 源/目标骨骼命名同源(如
// 同一 DCC 拆分/Mixamo 同角色);名字对不上的轨道跳过并计数。
// 幂等:目标 clips 已含该源(sourcePath)时不重复并入。
struct AnimAttachResult
{
u32 clipsAdded = 0;
u32 channelsMatched = 0;
u32 channelsSkipped = 0; // 骨骼名在目标模型找不到
};
AnimAttachResult AttachAnimationClips(ModelRef target,
const std::string& animPath);
// 程序化平面(纯色贴图):size 为半边长。带缓存,同参数共享一份。 // 程序化平面(纯色贴图):size 为半边长。带缓存,同参数共享一份。
ModelRef CreatePlane(f32 halfSize, u8 r, u8 g, u8 b); ModelRef CreatePlane(f32 halfSize, u8 r, u8 g, u8 b);
// 半透明平面(alpha 0-255,透明绘制队列):同参数缓存去重 // 半透明平面(alpha 0-255,透明绘制队列):同参数缓存去重
+5
View File
@@ -145,6 +145,11 @@ namespace Engine::ECS {
f32 fadeElapsed = 0.0f; // 已过渡时长 f32 fadeElapsed = 0.0f; // 已过渡时长
f32 fadeDuration = 0.3f; // 过渡总时长(秒) f32 fadeDuration = 0.3f; // 过渡总时长(秒)
// 外部动画资产(编辑器 attach 的动画容器文件,随场景持久化)。
// clips 本体运行时并入 Model 资产(共享);重启后由 Inspector
// 按此列表幂等重放
std::vector<std::string> attachedAnimPaths;
// 切换 clip(带 crossfade)。越界/空 clip 保护在消费端。 // 切换 clip(带 crossfade)。越界/空 clip 保护在消费端。
void Play(int index, f32 fadeSeconds = 0.3f) void Play(int index, f32 fadeSeconds = 0.3f)
{ {
+12
View File
@@ -71,6 +71,9 @@ namespace Engine
f32 duration = 0.0f; // 秒(已按 tps 换算) f32 duration = 0.0f; // 秒(已按 tps 换算)
f32 tps = 1.0f; // ticks per second(0 按 1,assimp 惯例) f32 tps = 1.0f; // ticks per second(0 按 1,assimp 惯例)
std::vector<AnimChannel> channels; std::vector<AnimChannel> channels;
// 外部动画文件来源路径(运行时并入的 clip 标记;空 = 模型内嵌)。
// 用于幂等去重:同一源文件不重复并入
std::string sourcePath;
}; };
// aiNode 树拍平表:骨骼 global 变换的累乘骨架(解析期 ProcessNode // aiNode 树拍平表:骨骼 global 变换的累乘骨架(解析期 ProcessNode
@@ -147,6 +150,15 @@ namespace Engine
bool HasAnimations() const { return !m_Clips.empty(); } bool HasAnimations() const { return !m_Clips.empty(); }
u32 GetBoneCount() const { return static_cast<u32>(m_Bones.size()); } u32 GetBoneCount() const { return static_cast<u32>(m_Bones.size()); }
const std::vector<AnimationClip>& GetClips() const { return m_Clips; } const std::vector<AnimationClip>& GetClips() const { return m_Clips; }
// 外部动画并入(编辑器 attach 操作):追加到 clip 表尾部,
// 既有 clipIndex 不受影响。仅运行时内存状态(重启后由场景
// 里 Animation.attachedAnimPaths 驱动重放)
void AppendClips(std::vector<AnimationClip>&& extra)
{
m_Clips.insert(m_Clips.end(),
std::make_move_iterator(extra.begin()),
std::make_move_iterator(extra.end()));
}
const std::vector<ModelNode>& GetNodes() const { return m_Nodes; } const std::vector<ModelNode>& GetNodes() const { return m_Nodes; }
const std::vector<ModelBone>& GetBones() const { return m_Bones; } const std::vector<ModelBone>& GetBones() const { return m_Bones; }
+89
View File
@@ -9,6 +9,8 @@
#include <yaml-cpp/yaml.h> #include <yaml-cpp/yaml.h>
#include <fstream> #include <fstream>
#include <filesystem>
#include <unordered_map>
namespace Engine::Assets { namespace Engine::Assets {
@@ -124,6 +126,93 @@ namespace Engine::Assets {
}); });
} }
AssetManager::AnimAttachResult AssetManager::AttachAnimationClips(
ModelRef target, const std::string& animPath)
{
AnimAttachResult res;
Model* tm = GetModel(target);
if (tm == nullptr) {
return res;
}
// 幂等:该源文件已并入过(自愈重放/重复 attach 直接返回)
for (const auto& c : tm->GetClips()) {
if (c.sourcePath == animPath) {
return res;
}
}
// 纯 CPU 解析:动画容器只要 nodes + clips,不建 GPU 资产
//(meshes 空的文件过不了 Model 构建,也不需要构建)
ParsedModel src = Model::ParseFromDisk(animPath);
if (src.clips.empty()) {
ENGINE_WARN("AttachAnimationClips: no clips in '{}'", animPath);
return res;
}
// 目标模型 骨骼节点名 → nodeIdx 重映射表
std::unordered_map<std::string, u32> nameToIdx;
nameToIdx.reserve(tm->GetNodes().size());
for (u32 i = 0; i < tm->GetNodes().size(); ++i) {
nameToIdx.emplace(tm->GetNodes()[i].name, i);
}
const std::string stem =
std::filesystem::path(animPath).stem().string();
std::vector<AnimationClip> extra;
extra.reserve(src.clips.size());
for (auto& clip : src.clips) {
AnimationClip out;
out.name = clip.name;
out.duration = clip.duration;
out.tps = clip.tps;
out.sourcePath = animPath;
// 同名冲突(内嵌 clip 或其它源):源文件名做前缀保持可辨识
for (const auto& c : tm->GetClips()) {
if (c.name == out.name) {
out.name = stem + "_" + out.name;
break;
}
}
for (auto& ch : clip.channels) {
if (ch.positions.empty() && ch.rotations.empty() &&
ch.scales.empty()) {
continue; // 空轨道无用
}
if (ch.nodeIdx >= src.nodes.size()) {
++res.channelsSkipped;
continue;
}
const auto it = nameToIdx.find(src.nodes[ch.nodeIdx].name);
if (it == nameToIdx.end()) {
++res.channelsSkipped; // 骨骼名不同源
continue;
}
AnimChannel remapped;
remapped.nodeIdx = it->second;
remapped.positions = std::move(ch.positions);
remapped.rotations = std::move(ch.rotations);
remapped.scales = std::move(ch.scales);
out.channels.push_back(std::move(remapped));
++res.channelsMatched;
}
// 全部轨道对不上 = 名字空间完全不同源,clip 不入表
if (out.channels.empty()) {
ENGINE_WARN("AttachAnimationClips: clip '{}' has no matching "
"bones in target (skipped)", clip.name);
continue;
}
extra.push_back(std::move(out));
++res.clipsAdded;
}
if (!extra.empty()) {
tm->AppendClips(std::move(extra));
}
ENGINE_INFO("AttachAnimationClips: '{}' -> {} clips "
"({} channels matched, {} skipped)",
animPath, res.clipsAdded, res.channelsMatched,
res.channelsSkipped);
return res;
}
ModelRef AssetManager::CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) ModelRef AssetManager::CreatePlane(f32 halfSize, u8 r, u8 g, u8 b)
{ {
// 程序化资产复用同一缓存:键 = 伪路径 + 参数 // 程序化资产复用同一缓存:键 = 伪路径 + 参数
@@ -236,6 +236,14 @@ namespace Engine::Serialization {
out << YAML::Key << "Loop" << YAML::Value << a->loop; out << YAML::Key << "Loop" << YAML::Value << a->loop;
out << YAML::Key << "Speed" << YAML::Value << a->speed; out << YAML::Key << "Speed" << YAML::Value << a->speed;
out << YAML::Key << "Active" << YAML::Value << a->active; out << YAML::Key << "Active" << YAML::Value << a->active;
// 外部动画源(编辑器 attach 列表):clip 本体运行时并入
// Model 资产不落盘,加载端按此列表幂等重放
out << YAML::Key << "Attached" << YAML::Value << YAML::Flow
<< YAML::BeginSeq;
for (const auto& p : a->attachedAnimPaths) {
out << p;
}
out << YAML::EndSeq;
out << YAML::EndMap; out << YAML::EndMap;
} }
@@ -556,6 +564,12 @@ namespace Engine::Serialization {
a.loop = n["Loop"].as<bool>(a.loop); a.loop = n["Loop"].as<bool>(a.loop);
a.speed = n["Speed"].as<f32>(a.speed); a.speed = n["Speed"].as<f32>(a.speed);
a.active = n["Active"].as<bool>(true); a.active = n["Active"].as<bool>(true);
if (const YAML::Node att = n["Attached"]) {
for (const auto& it : att) {
a.attachedAnimPaths.push_back(
it.as<std::string>(""));
}
}
} }
if (const YAML::Node n = comps["Camera"]) { if (const YAML::Node n = comps["Camera"]) {