diff --git a/App/editor/InspectorPanel.cpp b/App/editor/InspectorPanel.cpp index 63d64aa..eebda38 100644 --- a/App/editor/InspectorPanel.cpp +++ b/App/editor/InspectorPanel.cpp @@ -16,11 +16,48 @@ #include #include #include +#include #include #include namespace { + // 递归收集 assets/ 下模型容器文件(模型槽与动画 attach 槽共用; + // 无法从扩展名区分"模型 fbx"与"纯动画 fbx",一并列出)。 + // 会话级缓存:目录条目几十量级,避免每帧递归扫描 + const std::vector& AssetModelFiles() + { + static std::vector 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 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 opts; opts.push_back(""); 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(selected); // 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 即无效) - 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 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 opts; + opts.push_back(""); + 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(opts.size()))) + { + const std::string& path = files[static_cast(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 clipNames; diff --git a/Engine/include/Engine/Assets/AssetManager.h b/Engine/include/Engine/Assets/AssetManager.h index 68a3306..78f1fd3 100644 --- a/Engine/include/Engine/Assets/AssetManager.h +++ b/Engine/include/Engine/Assets/AssetManager.h @@ -161,6 +161,20 @@ namespace Engine Model* GetModel(ModelRef h) const { return m_Models.Get(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 为半边长。带缓存,同参数共享一份。 ModelRef CreatePlane(f32 halfSize, u8 r, u8 g, u8 b); // 半透明平面(alpha 0-255,透明绘制队列):同参数缓存去重 diff --git a/Engine/include/Engine/ECS/Components.h b/Engine/include/Engine/ECS/Components.h index 1eda4ee..0f7fdf4 100644 --- a/Engine/include/Engine/ECS/Components.h +++ b/Engine/include/Engine/ECS/Components.h @@ -145,6 +145,11 @@ namespace Engine::ECS { f32 fadeElapsed = 0.0f; // 已过渡时长 f32 fadeDuration = 0.3f; // 过渡总时长(秒) + // 外部动画资产(编辑器 attach 的动画容器文件,随场景持久化)。 + // clips 本体运行时并入 Model 资产(共享);重启后由 Inspector + // 按此列表幂等重放 + std::vector attachedAnimPaths; + // 切换 clip(带 crossfade)。越界/空 clip 保护在消费端。 void Play(int index, f32 fadeSeconds = 0.3f) { diff --git a/Engine/include/Engine/Renderer/Model.h b/Engine/include/Engine/Renderer/Model.h index 531780a..e5b0b10 100644 --- a/Engine/include/Engine/Renderer/Model.h +++ b/Engine/include/Engine/Renderer/Model.h @@ -71,6 +71,9 @@ namespace Engine f32 duration = 0.0f; // 秒(已按 tps 换算) f32 tps = 1.0f; // ticks per second(0 按 1,assimp 惯例) std::vector channels; + // 外部动画文件来源路径(运行时并入的 clip 标记;空 = 模型内嵌)。 + // 用于幂等去重:同一源文件不重复并入 + std::string sourcePath; }; // aiNode 树拍平表:骨骼 global 变换的累乘骨架(解析期 ProcessNode @@ -147,6 +150,15 @@ namespace Engine bool HasAnimations() const { return !m_Clips.empty(); } u32 GetBoneCount() const { return static_cast(m_Bones.size()); } const std::vector& GetClips() const { return m_Clips; } + // 外部动画并入(编辑器 attach 操作):追加到 clip 表尾部, + // 既有 clipIndex 不受影响。仅运行时内存状态(重启后由场景 + // 里 Animation.attachedAnimPaths 驱动重放) + void AppendClips(std::vector&& extra) + { + m_Clips.insert(m_Clips.end(), + std::make_move_iterator(extra.begin()), + std::make_move_iterator(extra.end())); + } const std::vector& GetNodes() const { return m_Nodes; } const std::vector& GetBones() const { return m_Bones; } diff --git a/Engine/src/Assets/AssetManager.cpp b/Engine/src/Assets/AssetManager.cpp index c21b65a..804f8db 100644 --- a/Engine/src/Assets/AssetManager.cpp +++ b/Engine/src/Assets/AssetManager.cpp @@ -9,6 +9,8 @@ #include #include +#include +#include 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 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 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) { // 程序化资产复用同一缓存:键 = 伪路径 + 参数 diff --git a/Engine/src/Serialization/SceneSerializer.cpp b/Engine/src/Serialization/SceneSerializer.cpp index 06e0a32..1e40c84 100644 --- a/Engine/src/Serialization/SceneSerializer.cpp +++ b/Engine/src/Serialization/SceneSerializer.cpp @@ -236,6 +236,14 @@ namespace Engine::Serialization { out << YAML::Key << "Loop" << YAML::Value << a->loop; out << YAML::Key << "Speed" << YAML::Value << a->speed; 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; } @@ -556,6 +564,12 @@ namespace Engine::Serialization { a.loop = n["Loop"].as(a.loop); a.speed = n["Speed"].as(a.speed); a.active = n["Active"].as(true); + if (const YAML::Node att = n["Attached"]) { + for (const auto& it : att) { + a.attachedAnimPaths.push_back( + it.as("")); + } + } } if (const YAML::Node n = comps["Camera"]) {