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:
+101
-27
@@ -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,透明绘制队列):同参数缓存去重
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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"]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user