// // InspectorPanel.cpp:组件编辑实现。 // // Transform 编辑必须走 Set* 入口(标脏 → 帧循环统一 Compose 重算 // world),直接改私有成员是编译错误——面板天然遵守引擎约束。 // #include "InspectorPanel.h" #include "MaterialAssetUtil.h" // .material 枚举 + Create Override(与材质编辑器共享) #include "MaterialEditorPanel.h" // 指派/编辑分离:双击槽位开独立面板 #include // ComponentHeader 组件专属菜单项 #include "Engine/ECS/Components.h" #include "Engine/Core/Types.h" #include "Engine/Animation/AnimationController.h" // Controller 模式 UI(参数/状态枚举) #include "Engine/Renderer/Model.h" #include "Engine/Renderer/MaterialAsset.h" // 材质覆盖槽(.material 资产) #include "Engine/Physics/PhysicsWorld.h" // Apply Impulse 调试(GetPhysics 门面) #include "Engine/Scripting/Script.h" // 脚本下拉(ScriptRegistry::Names) #include "Engine/Scripting/ScriptRuntime.h" // 参数编辑 → RefreshParams #include "Engine/Serialization/SceneSerializer.h" // ResolveProcedural(程序化入口) #include "Engine/Core/Log.h" // Create Override 失败告警 #include #include #include #include #include #include #include #include "ControllerEditorPanel.h" #include #include #include namespace { // 材质槽循环索引用(u32 定义于 namespace Engine,App 层显式引入) using Engine::u32; // 脚本参数对齐 schema:缺失 → 声明默认补齐;类型不符 → 重置默认 //(换脚本类/手编档防呆)。返回对齐后的参数项指针(每帧幂等) Engine::Scripting::ScriptParam* EnsureParam( Engine::ECS::ScriptComponent::Item& item, const Engine::Scripting::ParamBuilder::Desc& d) { auto it = std::find_if( item.params.begin(), item.params.end(), [&](const Engine::Scripting::ScriptParam& p) { return p.name == d.name; }); if (it == item.params.end()) { item.params.push_back({d.name, d.def}); return &item.params.back(); } if (it->value.Type() != d.type) { it->value = d.def; } return &*it; } // 递归收集 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; } // 递归收集 assets/ 下动画控制器资产(.animctrl,Controller 槽用; // 会话级缓存,Create 新文件后经 InvalidateControllerFiles 失效) static std::vector s_CtrlFiles; static bool s_CtrlFilesCached = false; void InvalidateControllerFiles() { s_CtrlFilesCached = false; } const std::vector& AssetControllerFiles() { if (!s_CtrlFilesCached) { s_CtrlFilesCached = true; s_CtrlFiles.clear(); 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 == ".animctrl") { s_CtrlFiles.push_back(entry.path().generic_string()); } } std::sort(s_CtrlFiles.begin(), s_CtrlFiles.end()); } } return s_CtrlFiles; } // 递归收集 assets/ 下环境贴图(.hdr,Skybox 资产槽用;会话级缓存同上) const std::vector& AssetEnvFiles() { 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 == ".hdr") { 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) { char buf[256]; std::snprintf(buf, sizeof(buf), "%s", str.c_str()); if (ImGui::InputText(label, buf, sizeof(buf))) { str = buf; return true; } return false; } // MeshRenderer 材质槽列表(UE Material Slots 语义):内建材质只读, // 覆盖槽 = .material 资产引用,可换绑/还原。参数/纹理"编辑"在独立 // Material Editor 面板(双击槽位球打开)——指派与编辑分离, // 本行只做指派(UE Details 材质槽交互) void DrawMaterialSlots(const EditorContext& ctx, const Engine::Model& model, Engine::ECS::MeshRendererComponent& mr, entt::entity owner, Engine::Assets::AssetManager& assets, Engine::Renderer& renderer) { const auto& meshes = model.GetMeshes(); const auto& infos = model.GetMeshMaterialInfos(); ImGui::SeparatorText("Material Slots"); constexpr float kPreviewSize = 48.0f; for (u32 i = 0; i < meshes.size(); ++i) { ImGui::PushID(static_cast(i)); const std::string meshName = (i < infos.size() && !infos[i].name.empty()) ? infos[i].name : "Slot " + std::to_string(i); // ---- 材质球预览(行左列 48²,tile = 池分配)---- // 材质来源与渲染收集期寻址一致:覆盖资产 > 内建。 // UE FAssetThumbnailPool 语义:只对滚进视口的行发幂等请求 //(IsRectVisible 探测,132 槽模型只处理可见行),池按 // serial+参数版本判缓存——渲染一次跨帧持久,EditParams bump // 版本后下帧重渲该 tile;-1 = 首渲未完成/池满(占位排队) auto it = mr.materialOverrides.find(i); Engine::Material* slotMat = nullptr; const char* slotSource = "built-in"; if (it != mr.materialOverrides.end()) { if (Engine::MaterialAsset* asset = assets.FindMaterialAsset(it->second)) { slotMat = asset->GetMaterial(); slotSource = it->second.c_str(); } } if (!slotMat) { slotMat = meshes[i]->GetMaterial(); } // 可见性探测先行:请求只来自可见行(池 LRU 换页的前提) const bool rowVisible = ImGui::IsRectVisible(ImVec2(kPreviewSize, kPreviewSize)); Engine::i32 tile = -1; if (slotMat && rowVisible) { tile = renderer.RequestMaterialPreview(slotMat); } // ---- 球 + 拖放目标(Content Browser 拖 .material 指派)---- void* atlasTex = renderer.GetMaterialPreviewTexture(); if (atlasTex && tile >= 0) { const float uvStep = 1.0f / float(Engine::Passes::kPreviewTilesPerSide); const float u0 = float(tile % Engine::Passes::kPreviewTilesPerSide) * uvStep; const float v0 = float(tile / Engine::Passes::kPreviewTilesPerSide) * uvStep; ImGui::Image(atlasTex, ImVec2(kPreviewSize, kPreviewSize), ImVec2(u0, v0), ImVec2(u0 + uvStep, v0 + uvStep)); } else { // atlas 未就绪(首帧前)/ tile 首渲排队中:占位保持行高 ImGui::Dummy(ImVec2(kPreviewSize, kPreviewSize)); } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%s\nSource: %s\nDouble-click to edit", meshName.c_str(), slotSource); } // ---- 双击球 = 打开 Material Editor(指派/编辑分离)---- if (ctx.materialEditor && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { if (it != mr.materialOverrides.end()) { ctx.materialEditor->OpenAsset(it->second); } else { ctx.materialEditor->OpenBuiltin(owner, i, meshName); } } if (ImGui::BeginDragDropTarget()) { if (const ImGuiPayload* pl = ImGui::AcceptDragDropPayload( kMaterialDragPayload)) { const std::string dropped( static_cast(pl->Data)); if (assets.LoadMaterialAsset(dropped).Valid()) { mr.materialOverrides[i] = dropped; } it = mr.materialOverrides.find(i); } ImGui::EndDragDropTarget(); } ImGui::SameLine(); ImGui::BeginGroup(); ImGui::TextUnformatted(meshName.c_str()); if (it == mr.materialOverrides.end()) { // 内建只读:一键生成覆盖资产(成功即自动进编辑器) if (i < infos.size() && ImGui::SmallButton("Create Override")) { const std::string rel = EditorMaterialUtil::CreateMaterialOverride( assets.Models().Path(mr.model), infos[i], i, mr, assets); if (!rel.empty() && ctx.materialEditor) { ctx.materialEditor->OpenAsset(rel); } } } else { const std::string& cur = it->second; // 资产文件下拉(当前路径不在枚举里也插入显示,防错位) const auto& files = EditorMaterialUtil::AssetMaterialFiles(); std::vector opts; opts.push_back(""); bool curListed = false; for (const auto& f : files) { opts.push_back(f.c_str()); curListed |= (f == cur); } if (!curListed) { opts.insert(opts.begin() + 1, cur.c_str()); } int sel = -1; for (int k = 0; k < static_cast(opts.size()); ++k) { if (std::strcmp(opts[static_cast(k)], cur.c_str()) == 0) { sel = k; } } const float revertW = ImGui::CalcTextSize("Revert").x + ImGui::GetStyle().FramePadding.x * 2.0f; ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x - revertW - ImGui::GetStyle().ItemSpacing.x); if (ImGui::Combo("##mat", &sel, opts.data(), static_cast(opts.size()))) { if (sel <= 0) { mr.materialOverrides.erase(i); } else { const char* chosen = opts[static_cast(sel)]; if (assets.LoadMaterialAsset(chosen).Valid()) { mr.materialOverrides[i] = chosen; } } it = mr.materialOverrides.find(i); } ImGui::SameLine(); if (ImGui::SmallButton("Revert")) { mr.materialOverrides.erase(i); it = mr.materialOverrides.end(); } // 覆盖资产未加载(罕见):就地补救(编辑器打开会自动 Load) if (it != mr.materialOverrides.end() && assets.FindMaterialAsset(it->second) == nullptr) { ImGui::TextDisabled(" override not loaded"); if (ImGui::SmallButton("Retry Load")) { assets.LoadMaterialAsset(it->second); } } } ImGui::EndGroup(); ImGui::PopID(); } ImGui::TextDisabled( "double-click a sphere to edit; edits on shared .material " "apply to all entities referencing it"); } // UE 风变换行:label + 三轴色 DragFloat(轴色对齐 Gizmo:X 红 / // Y 绿 / Z 蓝)。拖拽粗调;Ctrl+Click 进输入框键入精确值。 // 任一轴变化返回 true。fmt = 显示格式(旋转行传 "%.1f" 对齐 // UE Details 默认 1 位小数——欧拉显示端有固有反解噪声,见 // TransformComponent::GetRotation 注释) bool DragVec3Row(const char* label, glm::vec3& v, float speed, float mn = -3.4e38f, float mx = 3.4e38f, const char* fmt = "%.2f", bool* editing = nullptr) { ImGui::TextUnformatted(label); ImGui::SameLine(72.0f); // 固定标签列宽 → 三行输入框对齐 ImGui::PushID(label); constexpr ImVec4 axisCol[3] = { {0.72f, 0.24f, 0.24f, 1.00f}, // X 红 {0.30f, 0.68f, 0.34f, 1.00f}, // Y 绿 {0.24f, 0.40f, 0.88f, 1.00f}, // Z 蓝 }; const float boxW = (ImGui::GetContentRegionAvail().x - 2.0f * ImGui::GetStyle().ItemInnerSpacing.x) / 3.0f; bool changed = false; for (int i = 0; i < 3; ++i) { ImGui::PushStyleColor(ImGuiCol_FrameBg, axisCol[i]); ImGui::SetNextItemWidth(boxW); changed |= ImGui::DragFloat( i == 0 ? "##x" : i == 1 ? "##y" : "##z", &v[i], speed, mn, mx, fmt); if (editing != nullptr) { // 拖拽中/输入框打开中(含无拖动的按住帧) *editing |= ImGui::IsItemActive(); } ImGui::PopStyleColor(); if (i < 2) ImGui::SameLine(); } ImGui::PopID(); return changed; } // 组件区块头(Unity 语义):折叠标题 + 右缘"启用"勾选与"..."菜单。 // active 传 nullptr = 无启停(结构性组件);removable = 显示"...", // 菜单内含 Remove;extraMenu(可空)= 组件专属菜单项(如 Camera 的 // 视角联动),画在 Remove 之前 bool ComponentHeader(const char* label, bool* active, bool removable, bool* removeFlag, const std::function& extraMenu = {}) { ImGui::PushID(label); bool open; if (active != nullptr) { open = ImGui::CollapsingHeader( label, ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_AllowOverlap); const float rowH = ImGui::GetItemRectSize().y; float x = ImGui::GetWindowWidth() - 32.0f; if (removable) { // "..." 菜单(UE Details 组件头语义):移除等组件操作收进 // 下拉,头部不再放裸删除钮(防误触) ImGui::SameLine(x); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1.0f); if (ImGui::Button("...", ImVec2(24.0f, rowH - 4.0f))) { ImGui::OpenPopup("comp_menu"); } if (ImGui::BeginPopup("comp_menu")) { if (extraMenu) extraMenu(); if (ImGui::MenuItem("Remove Component")) { *removeFlag = true; } ImGui::EndPopup(); } x -= 30.0f; } ImGui::SameLine(x); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1.0f); ImGui::Checkbox("##active", active); } else { open = ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen); } ImGui::PopID(); return open; } } void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected) { ImGui::Begin("Details"); auto& reg = ctx.scene->GetRegistry(); if (selected == entt::null || !reg.valid(selected)) { ImGui::TextDisabled("(no entity selected)"); ImGui::End(); return; } // ---- Name ---- if (auto* name = reg.try_get(selected)) { InputTextString("Name", name->name); } ImGui::Separator(); // ---- Add Component(已挂载项置灰:Unity Add Component 语义)---- if (ImGui::Button("Add Component")) { ImGui::OpenPopup("add_component"); } if (ImGui::BeginPopup("add_component")) { auto menuItem = [&](auto typeTag, const char* label) { using T = std::remove_pointer_t; const bool has = reg.all_of(selected); if (has) { ImGui::BeginDisabled(true); } if (ImGui::MenuItem(label)) { reg.emplace(selected); } if (has) { ImGui::EndDisabled(); } }; menuItem(static_cast(nullptr), "Mesh Renderer"); menuItem(static_cast(nullptr), "Light"); menuItem(static_cast(nullptr), "Skybox"); menuItem(static_cast(nullptr), "Sky Light"); menuItem(static_cast(nullptr), "Camera"); menuItem(static_cast(nullptr), "Animation"); menuItem(static_cast(nullptr), "Name"); menuItem(static_cast(nullptr), "RigidBody"); menuItem(static_cast(nullptr), "Joint"); menuItem(static_cast(nullptr), "Character Controller"); menuItem(static_cast(nullptr), "Script"); ImGui::EndPopup(); } ImGui::Separator(); // ---- Transform(结构性组件:不可删、无启停)---- if (auto* t = reg.try_get(selected)) { if (ComponentHeader("Transform", nullptr, false, nullptr)) { // 父级提示(UE Details 语义:此处编辑的是 Relative 值) if (ctx.scene) { const Engine::ECS::Entity p = ctx.scene->GetParent(selected); if (p != entt::null) { std::string pname = "(unnamed)"; if (const auto* n = reg.try_get(p)) { pname = n->name; } ImGui::TextDisabled("parent: %s", pname.c_str()); } } glm::vec3 pos = t->GetPosition(); if (DragVec3Row("Position", pos, 0.05f)) { t->SetPosition(pos); } // ---- rotation 行:编辑 mirror(UE TransformDetails 会话语义)---- // 每帧回读 quat 反解 + wrap 会让拖动乱跳(360 → 0 折返)。 // UE 做法 = 编辑会话内显示本地累计值(正拖无限增大、反拖 // 无限减小、永不折返),只有"非编辑来源"的姿态改动(Gizmo、 // 物理、重选)才刷新显示。ImGui 无状态 → static mirror: // 切换实体重置;非编辑态用 quat 位级等价检测判定外部改动 static unsigned s_MirrorEntity = 0; static glm::vec3 s_RotMirror(0.0f); static bool s_MirrorValid = false; static bool s_RotEditingLast = false; // 上一帧是否在编辑 const unsigned entityId = static_cast(entt::to_integral(selected)); if (entityId != s_MirrorEntity) { s_MirrorEntity = entityId; s_MirrorValid = false; } bool editingRot = false; if (s_MirrorValid) { if (!s_RotEditingLast) { // 非编辑帧才检测外部改动(编辑帧 mirror 领先 quat 一个 // 拖动增量,等价检测必触发误重置)。编辑态取上一帧 // ——检测先于本帧 DragVec3Row 执行。 // 判据 = 逐分量相等:QuatFromEuler 与 SetRotation 是 // 同一函数,mirror 未被外部动过时位级相同(== 无浮点 // 噪声问题,比 dot 阈值严——Gizmo 极慢拖 0.05°/帧 // 的 quat 差 ~1e-7,dot 阈值法会漏检导致显示不跟手) const glm::quat mq = Engine::ECS::TransformComponent::QuatFromEuler(s_RotMirror); const glm::quat& aq = t->GetRotationQuat(); const bool untouched = mq.x == aq.x && mq.y == aq.y && mq.z == aq.z && mq.w == aq.w; if (!untouched) { s_RotMirror = t->GetRotation(); // 外部改动 → 刷新 } } } else { s_RotMirror = t->GetRotation(); s_MirrorValid = true; } glm::vec3 rot = s_RotMirror; // "%.1f" = UE Details 默认显示精度。mirror 值直显(无 wrap、 // 无限累计);Ctrl+Click 手输全精度写入 if (DragVec3Row("Rotation", rot, 0.5f, -3.4e38f, 3.4e38f, "%.1f", &editingRot)) { t->SetRotation(rot); s_RotMirror = rot; } s_RotEditingLast = editingRot; glm::vec3 scl = t->GetScale(); if (DragVec3Row("Scale", scl, 0.02f, 0.001f, 100.0f)) { t->SetScale(scl); } } } // ---- MeshRenderer ---- if (auto* mr = reg.try_get(selected)) { bool removeMr = false; if (ComponentHeader("MeshRenderer", &mr->active, true, &removeMr)) { if (!mr->active) { ImGui::BeginDisabled(true); // 禁用 = 内容置灰(Unity 语义) } const std::string cur = ctx.assets->Models().Path(mr->model); // 模型资产槽(UE Details 资产槽语义):下拉列 assets/ 下全部 // 模型文件,选中即 LoadModel 应用;已加载路径缓存去重复用 { const auto& s_ModelFiles = AssetModelFiles(); std::vector opts; opts.push_back(""); for (const auto& f : s_ModelFiles) { opts.push_back(f.c_str()); } int sel = 0; for (size_t k = 0; k < s_ModelFiles.size(); ++k) { if (s_ModelFiles[k] == cur) { sel = static_cast(k) + 1; } } ImGui::SetNextItemWidth(-1.0f); if (ImGui::Combo("##model_slot", &sel, opts.data(), static_cast(opts.size()))) { mr->model = (sel <= 0) ? Engine::ModelRef{} : ctx.assets->LoadModel(s_ModelFiles[static_cast(sel) - 1]); // 模型换绑后 mesh 下标语义失效:覆盖表显式清空 //(残留会错挂到新模型的槽位上) mr->materialOverrides.clear(); } ImGui::Spacing(); // 程序化模型入口:伪资产不在磁盘(下拉框列不到),由 // 弹窗组键生成 → ResolveProcedural(与场景反解同一 // 键格式唯一定义点;确定性键必然命中缓存) if (ImGui::SmallButton("Procedural...")) { ImGui::OpenPopup("##proc_model"); } if (ImGui::BeginPopup("##proc_model")) { static int shape = 0; // 0=cube 1=plane 2=transparent plane static float half = 0.5f; static float col[3] = {0.55f, 0.36f, 0.28f}; static float alpha = 0.5f; ImGui::RadioButton("Cube", &shape, 0); ImGui::SameLine(); ImGui::RadioButton("Plane", &shape, 1); ImGui::SameLine(); ImGui::RadioButton("Trans.Plane", &shape, 2); ImGui::SetNextItemWidth(-1.0f); ImGui::SliderFloat("half size", &half, 0.01f, 10.0f, "%.2f"); ImGui::ColorEdit3("color", col); if (shape == 2) { ImGui::SetNextItemWidth(-1.0f); ImGui::SliderFloat("alpha", &alpha, 0.0f, 1.0f, "%.2f"); } if (ImGui::Button("Create & Assign")) { const int r = int(col[0] * 255.0f + 0.5f); const int g = int(col[1] * 255.0f + 0.5f); const int b = int(col[2] * 255.0f + 0.5f); char key[96]; if (shape == 2) { const int a = int(alpha * 255.0f + 0.5f); std::snprintf( key, sizeof(key), "procedural://transparent-plane/%.3g/%d,%d,%d,%d", half, r, g, b, a); } else { std::snprintf( key, sizeof(key), "procedural://%s/%.3g/%d,%d,%d", shape == 0 ? "cube" : "plane", half, r, g, b); } if (auto ref = Engine::Serialization:: SceneSerializer::ResolveProcedural( *ctx.assets, key); ref.Valid()) { mr->model = ref; mr->materialOverrides.clear(); ImGui::CloseCurrentPopup(); } } ImGui::EndPopup(); } } // 无效句柄不进 GetModel(缓存对无效/悬空句柄 FATAL,是 bug 信号) if (mr->model.Valid()) { if (const Engine::Model* model = ctx.assets->GetModel(mr->model); model != nullptr) { const auto& clips = model->GetClips(); if (model->GetMeshes().empty()) { // 纯动画容器(无网格):不能作为渲染模型,提示 // 挂到 MeshRenderer 没有视觉效果 ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "model: animation-only " "(%u clips, %u bones, no meshes)", static_cast(clips.size()), model->GetBoneCount()); } else { ImGui::Text("model: valid (%u meshes, %u clips, %u bones)", static_cast(model->GetMeshes().size()), static_cast(clips.size()), model->GetBoneCount()); } } else { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "model: INVALID (dangling ref)"); } } else { ImGui::TextDisabled("no model assigned"); } // ---- 材质槽(UE Material Slots:内建只读 + .material 覆盖)---- if (mr->model.Valid()) { if (const Engine::Model* model = ctx.assets->GetModel(mr->model); model != nullptr && !model->GetMeshes().empty()) { DrawMaterialSlots(ctx, *model, *mr, selected, *ctx.assets, *ctx.renderer); } } if (!mr->active) { ImGui::EndDisabled(); } } if (removeMr) { reg.remove(selected); } } // ---- Light ---- if (auto* light = reg.try_get(selected)) { bool removeLight = false; if (ComponentHeader("Light", &light->active, true, &removeLight)) { if (!light->active) { ImGui::BeginDisabled(true); } int type = static_cast(light->type); if (ImGui::Combo("Type", &type, "Directional\0Point\0")) { light->type = static_cast(type); } ImGui::ColorEdit3("Color", &light->color.x); ImGui::DragFloat("Intensity", &light->color.a, 0.05f, 0.0f, 100.0f); if (light->type == Engine::ECS::LightComponent::Type::Directional) { ImGui::SliderFloat3("Direction", &light->direction.x, -1.0f, 1.0f); ImGui::Checkbox("Cast Shadows", &light->castShadow); } else { ImGui::DragFloat("Radius", &light->radius, 0.1f, 0.0f, 100.0f); ImGui::TextDisabled("(position = entity Transform)"); } if (!light->active) { ImGui::EndDisabled(); } } if (removeLight) { reg.remove(selected); } } // ---- Skybox(UE Skybox Actor 语义:环境贴图 = 实体资产槽)---- if (auto* sb = reg.try_get(selected)) { bool removeSb = false; if (ComponentHeader("Skybox", &sb->active, true, &removeSb)) { if (!sb->active) { ImGui::BeginDisabled(true); } // 环境贴图资产槽(模型槽同款:下拉列 assets/ 下 .hdr,选中 // 即加载;空 = 无环境,只露清屏色)。渲染侧由 Renderer 按 // 帧数据差量同步(路径变化才重载),此处零额外同步 { const auto& s_EnvFiles = AssetEnvFiles(); std::vector opts; opts.push_back(""); for (const auto& f : s_EnvFiles) { opts.push_back(f.c_str()); } int sel = 0; for (size_t k = 0; k < s_EnvFiles.size(); ++k) { if (s_EnvFiles[k] == sb->envPath) { sel = static_cast(k) + 1; } } ImGui::SetNextItemWidth(-1.0f); if (ImGui::Combo("##env_slot", &sel, opts.data(), static_cast(opts.size()))) { sb->envPath = (sel <= 0) ? std::string{} : s_EnvFiles[static_cast(sel) - 1]; } ImGui::TextDisabled("(empty = clear color only)"); } if (!sb->active) { ImGui::EndDisabled(); } } if (removeSb) { reg.remove(selected); } } // ---- SkyLight(UE SkyLight 语义:全局环境光)---- if (auto* sl = reg.try_get(selected)) { bool removeSl = false; if (ComponentHeader("Sky Light", &sl->active, true, &removeSl)) { if (!sl->active) { ImGui::BeginDisabled(true); } ImGui::ColorEdit4("Ambient", &sl->ambientColor.x); if (!sl->active) { ImGui::EndDisabled(); } } if (removeSl) { reg.remove(selected); } } // ---- Camera ---- if (auto* cam = reg.try_get(selected)) { bool removeCam = false; // "..." 菜单组件专属项(双向同步,均只取位置+朝向,缩放不动): // ① 编辑器相机 → 飞到本实体机位(编辑器视角立即从该相机处看) // ② 本实体 → 摆到编辑器当前机位(摆实体相机,省手动对位) // SetWorldPose 沿父链反解 local(层级实体不破坏父关系) auto camMenu = [&]() { if (ImGui::MenuItem("Move Editor Camera Here") && ctx.editorCamera) { if (auto* tr = reg.try_get( selected)) { ctx.editorCamera->AlignTo(*tr); } } if (ImGui::MenuItem("Move to Editor Camera Position") && ctx.editorCamera) { const auto& ed = ctx.editorCamera->GetTransform(); if (auto* tr = reg.try_get( selected)) { ctx.scene->SetWorldPose(selected, ed.GetWorldPosition(), ed.GetWorldRotationQuat(), tr->GetWorldScale()); } } }; if (ComponentHeader("Camera", &cam->active, true, &removeCam, camMenu)) { if (!cam->active) { ImGui::BeginDisabled(true); } if (ImGui::Checkbox("Primary", &cam->primary) && cam->primary) { // primary 互斥:启用本相机时清掉其余实体的 primary //(引擎取第一个 primary 作为游戏形态渲染视角) reg.view().each( [&](entt::entity e, Engine::ECS::CameraComponent& c) { if (e != selected) { c.primary = false; } }); } // ---- 投影参数(UE/Unity Camera 语义):模式二选一,fov 与 // ortho size 按模式互斥显示;near/far 成对写回(SetClip // 内部保证 far > near)---- const char* projModes[] = {"Perspective", "Orthographic"}; int pm = static_cast(cam->camera.GetProjectionMode()); if (ImGui::Combo("Projection", &pm, projModes, 2)) { cam->camera.SetProjectionMode( static_cast(pm)); } if (cam->camera.GetProjectionMode() == Engine::Camera::ProjectionMode::Perspective) { float fov = cam->camera.GetFovDeg(); if (ImGui::DragFloat("FOV (deg)", &fov, 0.1f, 1.0f, 179.0f, "%.1f")) { cam->camera.SetFovDeg(fov); } } else { float orthoSize = cam->camera.GetOrthoSize(); if (ImGui::DragFloat("Ortho Size", &orthoSize, 0.1f, 0.01f, 10000.0f, "%.2f")) { cam->camera.SetOrthoSize(orthoSize); } } float nearZ = cam->camera.GetNearZ(); float farZ = cam->camera.GetFarZ(); const bool nearChanged = ImGui::DragFloat( "Near Z", &nearZ, 0.01f, 0.001f, 1000.0f, "%.3f"); const bool farChanged = ImGui::DragFloat( "Far Z", &farZ, 1.0f, 0.1f, 100000.0f, "%.1f"); if (nearChanged || farChanged) { cam->camera.SetClip(nearZ, farZ); } if (!cam->active) { ImGui::EndDisabled(); } } if (removeCam) { reg.remove(selected); } } // ---- RigidBody(Jolt 刚体:动态体 Transform 由物理每帧写回)---- // type/collider/mass 变更 → PhysicsWorld 重建 body;其余参数即时生效 if (auto* rb = reg.try_get(selected)) { bool removeRb = false; if (ComponentHeader("RigidBody", &rb->active, true, &removeRb)) { if (!rb->active) { ImGui::BeginDisabled(true); } // 冲突提示:同实体挂 CharacterController 会同时建 body 与 // CharacterVirtual,两个"运动主体"互相打架 if (reg.all_of(selected)) { ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! conflicts with Character Controller " "(disable or remove one)"); } // ---- Body:类型与碰撞体选择(变更 → PhysicsWorld 重建 body)---- ImGui::SeparatorText("Body"); int type = static_cast(rb->type); if (ImGui::Combo("Type", &type, "Static\0Dynamic\0Kinematic\0")) { rb->type = static_cast(type); } int collider = static_cast(rb->collider); if (ImGui::Combo("Collider", &collider, "Box\0Sphere\0Mesh\0Capsule\0Cylinder\0Compound\0")) { rb->collider = static_cast(collider); } // ---- Shape:碰撞体尺寸(随类型切换;× Transform 缩放烘焙)---- ImGui::SeparatorText("Shape"); if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Box) { ImGui::DragFloat3("Half Extents", &rb->boxHalfExtents.x, 0.05f, 0.01f, 500.0f); } else if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Sphere) { ImGui::DragFloat("Radius", &rb->sphereRadius, 0.05f, 0.01f, 500.0f); } else if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Capsule) { // 半高 = 圆柱段半高(不含半球端盖),总高 = 2*(halfHeight + radius) ImGui::DragFloat("Radius", &rb->capsuleRadius, 0.05f, 0.01f, 500.0f); ImGui::DragFloat("Half Height", &rb->capsuleHalfHeight, 0.05f, 0.0f, 500.0f); } else if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Cylinder) { ImGui::DragFloat("Radius", &rb->cylinderRadius, 0.05f, 0.01f, 500.0f); ImGui::DragFloat("Half Height", &rb->cylinderHalfHeight, 0.05f, 0.01f, 500.0f); } else if (rb->collider == Engine::ECS::RigidBodyComponent::ColliderType::Compound) { // 子形状列表:尺寸/位置变更 → PhysicsWorld 重建 body(既有模式) ImGui::Text("Children: %d", static_cast(rb->compound.size())); ImGui::SameLine(); if (ImGui::Button("+ Add")) { rb->compound.emplace_back(); } int remove = -1; for (int i = 0; i < static_cast(rb->compound.size()); ++i) { auto& c = rb->compound[i]; ImGui::PushID(i); ImGui::Separator(); int sh = static_cast(c.shape); if (ImGui::Combo("Shape", &sh, "Box\0Sphere\0Capsule\0")) { c.shape = static_cast(sh); } if (c.shape == Engine::ECS::RigidBodyComponent::CompoundChild::Shape::Box) { ImGui::DragFloat3("Half Extents", &c.boxHalfExtents.x, 0.05f, 0.01f, 500.0f); } else { ImGui::DragFloat("Radius", &c.radius, 0.05f, 0.01f, 500.0f); if (c.shape == Engine::ECS::RigidBodyComponent::CompoundChild::Shape::Capsule) { ImGui::DragFloat("Half Height", &c.capsuleHalfHeight, 0.05f, 0.0f, 500.0f); } } ImGui::DragFloat3("Offset", &c.position.x, 0.05f); if (ImGui::Button("Remove")) { remove = i; } ImGui::PopID(); } if (remove >= 0) { rb->compound.erase(rb->compound.begin() + remove); } } else { // Mesh:跟随 MeshRenderer 视觉网格(Static=精确网格, // Dynamic=凸包近似),无尺寸可调 ImGui::TextDisabled("follows MeshRenderer geometry"); } // ---- Simulation:动态体物理参数 ---- ImGui::SeparatorText("Simulation"); ImGui::DragFloat("Mass", &rb->mass, 0.1f, 0.1f, 1000.0f, "%.1f"); ImGui::SliderFloat("Gravity Scale", &rb->gravityScale, -1.0f, 5.0f); ImGui::SliderFloat("Friction", &rb->friction, 0.0f, 2.0f); ImGui::SliderFloat("Restitution", &rb->restitution, 0.0f, 1.0f); ImGui::Checkbox("Is Trigger", &rb->isTrigger); // ---- Debug:遥测与驱动调试(冲量瞬态不序列化)---- if (rb->bodyValid && rb->type == Engine::ECS::RigidBodyComponent::BodyType::Dynamic) { ImGui::SeparatorText("Debug"); ImGui::Text("vel: %.2f, %.2f, %.2f m/s", rb->velocity.x, rb->velocity.y, rb->velocity.z); static glm::vec3 s_debugImpulse{0.0f, 5.0f, 0.0f}; ImGui::DragFloat3("Impulse", &s_debugImpulse.x, 0.1f); if (ImGui::Button("Apply Impulse")) { ctx.app->GetPhysics().AddImpulse( static_cast(entt::to_integral(selected)), s_debugImpulse); } } if (!rb->active) { ImGui::EndDisabled(); } } if (removeRb) { reg.remove(selected); } } // ---- Joint(约束:两端 body 就绪后由 PhysicsWorld 对账创建)---- if (auto* jc = reg.try_get(selected)) { bool removeJc = false; if (ComponentHeader("Joint", &jc->active, true, &removeJc)) { if (!jc->active) { ImGui::BeginDisabled(true); } // 依赖提示:约束挂在两端刚体上,本体无 RigidBody 无法创建 if (!reg.all_of(selected)) { ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! requires a RigidBody on this entity"); } // ---- Constraint:类型与目标体 ---- ImGui::SeparatorText("Constraint"); int type = static_cast(jc->type); if (ImGui::Combo("Type", &type, "Fixed\0Hinge\0Ball\0")) { jc->type = static_cast(type); } // target 实体下拉:实体名列表,选中态按 UUID 匹配(变更写回) std::string items; std::vector uuids; int current = -1; reg.view().each( [&](entt::entity e, const Engine::ECS::IdComponent& id, const Engine::ECS::NameComponent& n) { if (e == selected) { return; // 自连无意义 } if (id.uuid == jc->target) { current = static_cast(uuids.size()); } uuids.push_back(id.uuid); items.append(n.name); items.push_back('\0'); }); items.push_back('\0'); // ImGui Combo 双 \0 结尾 if (ImGui::Combo("Target", ¤t, items.c_str()) && current >= 0) { jc->target = uuids[static_cast(current)]; } // ---- Anchors:各体局部系锚点 ---- ImGui::SeparatorText("Anchors"); ImGui::DragFloat3("Anchor A", &jc->anchorA.x, 0.05f); ImGui::DragFloat3("Anchor B", &jc->anchorB.x, 0.05f); // ---- Hinge:转轴与角度限位(度)---- if (jc->type == Engine::ECS::JointComponent::Type::Hinge) { ImGui::SeparatorText("Hinge"); ImGui::DragFloat3("Hinge Axis", &jc->hingeAxis.x, 0.05f); ImGui::Checkbox("Enable Limit", &jc->enableLimit); if (jc->enableLimit) { ImGui::DragFloat("Limit Min", &jc->limitMin, 1.0f, -180.0f, 0.0f); ImGui::DragFloat("Limit Max", &jc->limitMax, 1.0f, 0.0f, 180.0f); } } if (!jc->active) { ImGui::EndDisabled(); } } if (removeJc) { reg.remove(selected); } } // ---- CharacterController(参数编辑;状态在 PhysicsWorld 运行时)---- if (auto* cc = reg.try_get(selected)) { bool removeCc = false; if (ComponentHeader("Character Controller", &cc->active, true, &removeCc)) { if (!cc->active) { ImGui::BeginDisabled(true); } // 冲突提示:同实体挂 RigidBody 会同时建 CharacterVirtual 与 body if (reg.all_of(selected)) { ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! conflicts with RigidBody " "(disable or remove one)"); } // ---- Body:胶囊体(实体原点 = 脚底,总高 = 2*(halfHeight + radius))---- ImGui::SeparatorText("Body"); ImGui::DragFloat("Radius", &cc->radius, 0.02f, 0.05f, 5.0f); ImGui::DragFloat("Half Height", &cc->capsuleHalfHeight, 0.02f, 0.0f, 5.0f); ImGui::DragFloat("Mass", &cc->mass, 1.0f, 1.0f, 500.0f, "%.0f"); // ---- Movement:扫掠运动学参数 ---- ImGui::SeparatorText("Movement"); ImGui::DragFloat("Max Slope (deg)", &cc->maxSlopeAngle, 1.0f, 1.0f, 89.0f); ImGui::DragFloat("Step Height", &cc->stepHeight, 0.02f, 0.0f, 2.0f); ImGui::DragFloat("Gravity Scale", &cc->gravityScale, 0.1f, 0.0f, 10.0f); // ---- Debug:遥测(只读,运行时状态在 PhysicsWorld)---- if (auto& phy = ctx.app->GetPhysics(); phy.IsEnabled()) { ImGui::SeparatorText("Debug"); const uint32_t bits = static_cast(entt::to_integral(selected)); ImGui::Text("grounded: %s", phy.IsCharacterGrounded(bits) ? "yes" : "no"); const glm::vec3 v = phy.GetCharacterVelocity(bits); ImGui::Text("vel: %.2f, %.2f, %.2f m/s", v.x, v.y, v.z); // 跳跃调试按钮 if (ImGui::Button("Jump")) { phy.Jump(bits, 5.0f); } } if (!cc->active) { ImGui::EndDisabled(); } } if (removeCc) { reg.remove(selected); } } // ---- Script(按名解析注册表脚本;实例生命周期在 ScriptRuntime)---- if (auto* sc = reg.try_get(selected)) { bool removeSc = false; if (ComponentHeader("Script", &sc->active, true, &removeSc)) { if (!sc->active) { ImGui::BeginDisabled(true); } // 多脚本列表(Unity 语义):每行 = 脚本下拉 + 独立启停 + 删除; // 实例生命周期由 ScriptRuntime 对账(删行/改名 → 下帧 OnDestroy) const auto& names = Engine::Scripting::ScriptRegistry::Names(); int removeIdx = -1; for (int i = 0; i < static_cast(sc->items.size()); ++i) { auto& item = sc->items[static_cast(i)]; ImGui::PushID(i); // 去重下拉(DisallowMultipleComponent 语义):可选项 = // "" + 未挂名 + 自身名(已挂的别人的名不出现,结构上 // 选不出重复);自身名置于末尾保证已挂项正常回显 std::vector options; options.push_back(""); for (const auto& n : names) { if (n == item.name) { continue; } const bool attached = std::any_of( sc->items.begin(), sc->items.end(), [&](const Engine::ECS::ScriptComponent::Item& it) { return it.name == n; }); if (!attached) { options.push_back(n.c_str()); } } if (!item.name.empty()) { options.push_back(item.name.c_str()); } int cur = 0; for (size_t k = 0; k < options.size(); ++k) { if (item.name == options[k]) { cur = static_cast(k); } } ImGui::SetNextItemWidth( ImGui::GetContentRegionAvail().x - 64.0f); if (ImGui::Combo("##script", &cur, options.data(), static_cast(options.size()))) { item.name = (cur <= 0) ? "" : options[static_cast(cur)]; } ImGui::SameLine(); ImGui::Checkbox("##en", &item.active); ImGui::SameLine(); if (ImGui::Button("-", ImVec2(24.0f, 0.0f))) { removeIdx = i; } // ---- 参数区(按脚本 schema 自动生成;缺省补齐 + 类型 // 防呆;编辑 → RefreshParams 重灌实例成员,PIE 当帧生效)---- if (!item.name.empty() && !Engine::Scripting::ScriptRegistry::Schema(item.name) .empty()) { const auto& schema = Engine::Scripting::ScriptRegistry::Schema(item.name); ImGui::Indent(); bool anyEdited = false; for (const auto& d : schema) { auto* p = EnsureParam(item, d); bool changed = false; // 局部变量禁用 f32(glm::f32 歧义前科),用 float switch (d.type) { case Engine::Scripting::ParamType::F32: { float v = std::get(p->value.v); if (ImGui::DragFloat(d.name.c_str(), &v, 0.1f)) { p->value = Engine::Scripting::ParamValue{v}; changed = true; } break; } case Engine::Scripting::ParamType::I32: { int v = std::get(p->value.v); if (ImGui::DragInt(d.name.c_str(), &v)) { p->value = Engine::Scripting::ParamValue{ static_cast(v)}; changed = true; } break; } case Engine::Scripting::ParamType::Bool: changed = ImGui::Checkbox( d.name.c_str(), &std::get(p->value.v)); break; case Engine::Scripting::ParamType::String: { char buf[256] = {}; std::snprintf(buf, sizeof(buf), "%s", std::get(p->value.v) .c_str()); if (ImGui::InputText(d.name.c_str(), buf, sizeof(buf))) { p->value = Engine::Scripting::ParamValue{ std::string(buf)}; changed = true; } break; } case Engine::Scripting::ParamType::Vec2: { const glm::vec2 g = std::get(p->value.v); float v[2] = {g.x, g.y}; if (ImGui::DragFloat2(d.name.c_str(), v, 0.1f)) { p->value = Engine::Scripting::ParamValue{ glm::vec2(v[0], v[1])}; changed = true; } break; } case Engine::Scripting::ParamType::Vec3: { const glm::vec3 g = std::get(p->value.v); float v[3] = {g.x, g.y, g.z}; if (ImGui::DragFloat3(d.name.c_str(), v, 0.1f)) { p->value = Engine::Scripting::ParamValue{ glm::vec3(v[0], v[1], v[2])}; changed = true; } break; } } if (changed) { anyEdited = true; } } if (anyEdited) { ctx.app->GetScripts().RefreshParams( static_cast(entt::to_integral(selected))); } ImGui::Unindent(); } ImGui::PopID(); // 已填名但注册表查无此类:场景加载早于脚本注册/改名都会走到这 if (!item.name.empty() && cur == 0) { ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! not registered: %s", item.name.c_str()); } } if (removeIdx >= 0) { sc->items.erase(sc->items.begin() + removeIdx); } // 已注册脚本全部挂上 → 禁止再加(去重语义,无意义项) const bool allAttached = !names.empty() && std::all_of(names.begin(), names.end(), [&](const std::string& n) { return std::any_of( sc->items.begin(), sc->items.end(), [&](const Engine::ECS::ScriptComponent::Item& it) { return it.name == n; }); }); if (allAttached) { ImGui::BeginDisabled(true); ImGui::Button("+ Add Script"); ImGui::EndDisabled(); ImGui::SameLine(); ImGui::TextDisabled("all registered scripts attached"); } else if (ImGui::Button("+ Add Script")) { sc->items.push_back({}); } if (!sc->active) { ImGui::EndDisabled(); } } if (removeSc) { reg.remove(selected); } } // ---- Animation ---- // attach 遥测(会话级 static:最近一次并入的骨骼匹配结果, // 面板内显示"matched/skipped") static std::string s_LastAttachFile; static unsigned s_LastAttachMatched = 0, s_LastAttachSkipped = 0; static unsigned s_LastAttachRetargeted = 0; static std::vector s_LastAttachRows; // 时间拖动条的定格记忆:拖住瞬间是否在播(松手恢复用) static bool s_ScrubWasPlaying = false; if (auto* anim = reg.try_get(selected)) { bool removeAnim = false; if (ComponentHeader("Animation", &anim->active, true, &removeAnim)) { if (!anim->active) { ImGui::BeginDisabled(true); } const auto* mr = reg.try_get(selected); // 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 即无效) Engine::Model* model = (mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model) : nullptr; // ---- 骨骼可视化开关(Endfield-Poser 三件套)----全局编辑器 // 设置(经 Renderer 门面),非实体数据、不落场景序列化。 // 开 = 3D 骨架线 + 视口关节圆点;All Nodes = 显示全部节点 // 层级(普通节点暗色),关 = 仅骨骼链;圆点大小按手感调 { bool show = ctx.renderer->GetBoneWiresVisible(); if (ImGui::Checkbox("Show Bones", &show)) { ctx.renderer->SetBoneWiresVisible(show); } ImGui::SameLine(); bool allNodes = ctx.renderer->GetBoneAllNodes(); if (ImGui::Checkbox("All Nodes", &allNodes)) { ctx.renderer->SetBoneAllNodes(allNodes); } ImGui::SameLine(); float px = ctx.renderer->GetBoneJointPx(); ImGui::SetNextItemWidth(130.0f); if (ImGui::SliderFloat("##bone_joint_px", &px, 2.0f, 14.0f, "joint %.0fpx")) { ctx.renderer->SetBoneJointPx(px); } } // ---- 外部动画并入(自愈重放 + 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); } } } } // ---- Mode:Direct(单 clip 直控)/ Controller(.animctrl // 状态机)----Unity Animator 语义:Controller 下播放由 // 状态机调度(Direct 字段不参与求值),切回 Direct 配置 // 不丢 { ImGui::SeparatorText("Mode"); using AnimMode = Engine::ECS::AnimationComponent::AnimMode; int modeSel = anim->mode == AnimMode::Controller ? 1 : 0; ImGui::SetNextItemWidth(-1.0f); if (ImGui::Combo("##anim_mode", &modeSel, "Direct\0Controller\0")) { anim->mode = modeSel == 1 ? AnimMode::Controller : AnimMode::Direct; } if (anim->mode == AnimMode::Controller) { // ---- Controller 资产槽(指派即预载)---- const auto& cfiles = AssetControllerFiles(); std::vector copts; copts.push_back(""); for (const auto& f : cfiles) { copts.push_back(f.c_str()); } int csel = 0; for (size_t k = 0; k < cfiles.size(); ++k) { if (cfiles[k] == anim->controllerPath) { csel = static_cast(k) + 1; } } ImGui::SetNextItemWidth(-1.0f); if (ImGui::Combo("##anim_ctrl_slot", &csel, copts.data(), static_cast(copts.size()))) { anim->currentState.clear(); // 换绑 → 首帧回 entry if (csel == 0) { anim->controllerPath.clear(); } else { const std::string& path = cfiles[static_cast(csel) - 1]; anim->controllerPath = path; ctx.assets->LoadController(path); // 参数默认值初始化(只补缺键——调好的值不覆盖) if (const auto* ctrl = ctx.assets->FindController(path)) { for (const auto& p : ctrl->GetParams()) { if (p.type == Engine::Animation::ControllerParam:: Type::Bool) { anim->boolParams.emplace(p.name, p.defBool); } else if (p.type == Engine::Animation:: ControllerParam::Type::Float) { anim->floatParams.emplace(p.name, p.defFloat); } } } } } // 双击槽 = 打开 Controller Editor(Unity 双击 // Controller 资产打开 Animator 窗口语义) if (csel > 0 && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && ctx.controllerEditor != nullptr) { ctx.controllerEditor->Open(anim->controllerPath); } // 拖放指派:AssetBrowser 的 .animctrl 拖进来 //(对齐材质槽交互) if (ImGui::BeginDragDropTarget()) { if (const ImGuiPayload* pl = ImGui::AcceptDragDropPayload( kControllerDragPayload)) { const std::string path( static_cast(pl->Data)); anim->currentState.clear(); // 首帧回 entry anim->controllerPath = path; ctx.assets->LoadController(path); if (const auto* c = ctx.assets->FindController(path)) { for (const auto& p : c->GetParams()) { if (p.type == Engine::Animation:: ControllerParam::Type::Float) { anim->floatParams.emplace( p.name, p.defFloat); } else if (p.type == Engine::Animation:: ControllerParam::Type:: Bool) { anim->boolParams.emplace(p.name, p.defBool); } } } } ImGui::EndDragDropTarget(); } const auto* ctrl = anim->controllerPath.empty() ? nullptr : ctx.assets->FindController( anim->controllerPath); // ---- Create / Reload 工具行:.animctrl = 纯文本 // YAML 资产——Create 从当前模型 clip 自动生成 // 可跑模板;外部编辑器改完点 Reload 热重载 ---- { static char s_NewName[96] = "my_controller"; static bool s_CreateOk = true; ImGui::SetNextItemWidth(150.0f); ImGui::InputText("##ctrl_new_name", s_NewName, sizeof(s_NewName)); ImGui::SameLine(); if (ImGui::Button("Create") && s_NewName[0] != '\0') { // 模板 = Idle(单 clip)+ Locomotion(2D 方向 // 树:原点 + 4 方向 walk 占位)+ Speed 过渡 // ——生成即有方向盘,打开 Controller Editor // 再逐点指定 clip;无模型时用占位名(clip 查 // 不到 = rest 兜底) std::vector clips; if (model != nullptr) { for (const auto& c : model->GetClips()) { clips.push_back(c.name); } } if (clips.empty()) { clips.push_back("clip_name"); } const auto clipAt = [&](size_t i) { return clips.empty() ? "clip_name" : clips[i % clips.size()]; }; std::string yaml = "# 动画控制器:状态/参数/过渡全由本文件声明," "引擎零硬编码。\n" "# 纯文本 YAML —— 也可在 Controller Editor " "面板可视化编辑后 Save。\n" "# 条件语法:\"参数 op 值\"(> < >= <= == " "!=;值 = 数字或 true/false)\n" "Version: 1\n" "Params:\n" " - {name: MoveX, type: float, default: 0}\n" " - {name: MoveZ, type: float, default: 0}\n" " - {name: Speed, type: float, default: 0}\n" "States:\n" " Idle:\n" " clip: \"" + clips[0] + "\"\n" " loop: true\n" " Locomotion:\n" " blendTree:\n" " type: 2DDirectional\n" " x: MoveX\n" " z: MoveZ\n" " children:\n" " - {clip: \"" + clips[0] + "\", at: [0, 0]}\n" " - {clip: \"" + clipAt(1) + "\", at: [0, 1]}\n" " - {clip: \"" + clipAt(2) + "\", at: [1, 0]}\n" " - {clip: \"" + clipAt(3) + "\", at: [0, -1]}\n" " - {clip: \"" + clipAt(4) + "\", at: [-1, 0]}\n" "Transitions:\n" " - {from: Idle, to: Locomotion, cond: " "\"Speed > 0.1\", fade: 0.25}\n" " - {from: Locomotion, to: Idle, cond: " "\"Speed < 0.05\", fade: 0.3}\n"; const std::string path = std::string("assets/animations/") + s_NewName + ".animctrl"; std::filesystem::create_directories( "assets/animations"); std::ofstream f(path, std::ios::binary | std::ios::trunc); s_CreateOk = static_cast(f); if (s_CreateOk) { f.write(yaml.data(), static_cast( yaml.size())); f.close(); InvalidateControllerFiles(); anim->controllerPath = path; anim->currentState.clear(); // 首帧回 entry ctx.assets->LoadController(path); // 参数默认值初始化(只补缺键,同槽指派) if (const auto* c = ctx.assets->FindController(path)) { for (const auto& p : c->GetParams()) { if (p.type == Engine::Animation:: ControllerParam::Type::Float) { anim->floatParams.emplace( p.name, p.defFloat); } } } } } ImGui::SameLine(); if (!anim->controllerPath.empty() && ImGui::SmallButton("Reload")) { ctx.assets->ReloadController( anim->controllerPath); anim->currentState.clear(); // 回 entry } if (!s_CreateOk) { ImGui::TextColored( ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! create failed (disk?)"); } } if (ctrl == nullptr) { if (!anim->controllerPath.empty()) { ImGui::TextColored( ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! controller failed to load"); } else { ImGui::TextDisabled("(no controller assigned)"); } } else { const auto& order = ctrl->GetStateOrder(); // ---- 当前状态(Parameters 之前:先看"现在在哪", // 再调参数)。过渡中显示 来源 -> 目标(百分比)---- if (!anim->transFrom.empty()) { const float pct = glm::min(anim->transElapsed / anim->transFade, 1.0f) * 100.0f; ImGui::Text("state: %s -> %s (%.0f%%)", anim->transFrom.c_str(), anim->currentState.c_str(), pct); } else { ImGui::Text("state: %s", anim->currentState.c_str()); } // ---- Edit:结构编辑全在独立 Controller // Editor 面板(材质面板同范式——Details // 只留指派与运行状态)---- if (ctx.controllerEditor != nullptr && ImGui::SmallButton("Edit...")) { ctx.controllerEditor->Open(anim->controllerPath); } } } } // 依赖提示:动画作用在 MeshRenderer 的模型骨骼上 if (mr == nullptr) { ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "! requires a MeshRenderer component"); } else if (model == nullptr) { ImGui::TextDisabled("(no model assigned)"); } else { // ---- Skeleton 资产信息(只读)----骨骼数 = palette // 槽位数;节点数 = 蒙皮数学的全层级表(骨骼 ⊂ 节点, // 两数不同是正常的)。选中实体时视口内画骨架线框 ImGui::Text("bones: %u nodes: %u", static_cast(model->GetBoneCount()), static_cast(model->GetNodes().size())); // ---- Clips:动画资产槽(列全部模型容器文件——无法静态 // 区分"模型 fbx"与"纯动画 fbx",一并列出,无 clip 的会 // 警告跳过)---- ImGui::SeparatorText("Clips"); 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); } // attach 遥测(骨骼匹配结果,面板下方显示) s_LastAttachFile = std::filesystem::path(path).filename().string(); s_LastAttachMatched = res.channelsMatched + res.channelsRetargeted; s_LastAttachRetargeted = res.channelsRetargeted; s_LastAttachSkipped = res.channelsSkipped; // 重定向明细快照(映射表调试显示) s_LastAttachRows = res.retargetRows; } // 已并入的源文件清单(x = detach:剥离模型 clip 表 + // 移除持久化列表;一帧只删一个,下帧重绘自然继续) for (size_t i = 0; i < anim->attachedAnimPaths.size(); ++i) { const auto& p = anim->attachedAnimPaths[i]; ImGui::BulletText("%s", std::filesystem::path(p) .filename() .string() .c_str()); ImGui::SameLine(); ImGui::PushID(static_cast(i)); const bool doDetach = ImGui::SmallButton("x"); ImGui::PopID(); if (doDetach) { ctx.assets->DetachAnimationClips(mr->model, p); anim->attachedAnimPaths.erase( anim->attachedAnimPaths.begin() + static_cast(i)); break; } } // 最近一次 attach 的骨骼匹配遥测(同名直映 + Humanoid // 槽位重定向分列;跳过 = 手指/道具等无对应骨骼)。 // 注意:重启后自愈重放路径幂等命中返回全 0,那里不更新 // 遥测避免误导 if (!s_LastAttachFile.empty()) { ImGui::TextColored( ImVec4(0.55f, 0.75f, 1.0f, 1.0f), "%s: %u matched (%u retargeted), %u skipped", s_LastAttachFile.c_str(), static_cast(s_LastAttachMatched), static_cast(s_LastAttachRetargeted), static_cast(s_LastAttachSkipped)); } // 重定向明细表(调试黑盒白盒化):源骨骼 → 槽位 → 目标 // 骨骼。行错了 = 哪根骨乱转一眼可查(Unity Configure // Avatar 的只读版) if (!s_LastAttachRows.empty()) { if (ImGui::TreeNode("Retarget Map")) { for (const auto& r : s_LastAttachRows) { ImGui::BulletText("%s [%s] -> %s", r.srcName.c_str(), r.slot, r.dstName.c_str()); } ImGui::TreePop(); } } if (!model->HasAnimations()) { ImGui::TextDisabled("(no animation clips on model)"); } } if (model != nullptr && model->HasAnimations()) { const auto& clips = model->GetClips(); // ---- Playback:播放控制。Direct 专属控件(clip 选择/ // playing/loop/时间拖动条)只在 Direct 模式渲染—— // Controller 模式下播放由状态机调度,这些字段不参 // 与求值(Unity Animator 语义:Controller 挂上后 // Animation 组件的 clip 字段灰置)---- const bool ctrlMode = anim->mode == Engine::ECS::AnimationComponent::AnimMode::Controller; if (!ctrlMode) { ImGui::SeparatorText("Playback"); std::vector clipNames; clipNames.reserve(clips.size()); for (const auto& c : clips) { clipNames.push_back(c.name.c_str()); } int cur = anim->clipIndex; if (ImGui::Combo("Clip", &cur, clipNames.data(), static_cast(clipNames.size()))) { anim->Play(cur); // 触发 crossfade(fade 秒) } ImGui::Checkbox("Playing", &anim->playing); ImGui::SameLine(); ImGui::Checkbox("Loop", &anim->loop); if (anim->clipIndex >= 0 && anim->clipIndex < static_cast(clips.size())) { // 时间拖动条(Unity Animation 窗口 scrub 语义): // 拖住定格播放头逐帧看姿态,松手恢复拖前播放状 // 态。暂停时 AnimationSystem 照样求值(只有 // playing 才推进播放头),所以拖动即实时预览 const float dur = clips[anim->clipIndex].duration; ImGui::SliderFloat("Time", &anim->time, 0.0f, dur, "t: %.2f / %.2f s"); if (ImGui::IsItemActivated()) { s_ScrubWasPlaying = anim->playing; anim->playing = false; } if (ImGui::IsItemDeactivatedAfterEdit() && s_ScrubWasPlaying) { anim->playing = true; } } } // 两模式通用:全局速度 / 过渡时长(Controller 的 // CrossFade 与 Direct 的 crossfade 共用 fade) ImGui::SliderFloat("Speed", &anim->speed, -2.0f, 2.0f); ImGui::SliderFloat("Fade", &anim->fadeDuration, 0.0f, 1.0f); // 根运动(Unity Animator 同名开关):关 = 原地播放, // 位移归脚本/物理驱动(CharacterController 角色用) ImGui::Checkbox("Apply Root Motion", &anim->applyRootMotion); // 动画数据表:每个 clip 的时长与有效轨道数(attach 进来的 // 外部动画在此核对骨骼匹配结果——轨道数明显偏少 = 骨骼 // 命名不同源被跳过) if (ImGui::TreeNode("Clip Data")) { ImGui::Columns(3, nullptr, false); ImGui::SetColumnWidth(0, 150.0f); ImGui::SetColumnWidth(1, 90.0f); ImGui::TextUnformatted("name"); ImGui::NextColumn(); ImGui::TextUnformatted("dur (s)"); ImGui::NextColumn(); ImGui::TextUnformatted("channels"); ImGui::NextColumn(); ImGui::Separator(); for (const auto& c : clips) { const bool curClip = &c == &clips[static_cast( std::clamp(anim->clipIndex, 0, static_cast(clips.size()) - 1))]; if (curClip) { ImGui::TextColored(ImVec4(0.3f, 0.85f, 0.4f, 1.0f), ">%s", c.name.c_str()); } else { ImGui::TextUnformatted(c.name.c_str()); } ImGui::NextColumn(); ImGui::Text("%.2f", c.duration); ImGui::NextColumn(); ImGui::Text("%u", static_cast(c.channels.size())); ImGui::NextColumn(); } ImGui::Columns(1); ImGui::TreePop(); } } if (!anim->active) { ImGui::EndDisabled(); } } if (removeAnim) { reg.remove(selected); } } ImGui::End(); }