Files
HelloVulkan/App/editor/panels/InspectorPanel.cpp
T

2073 lines
91 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// InspectorPanel.cpp:组件编辑实现。
//
// Transform 编辑必须走 Set* 入口(标脏 → 帧循环统一 Compose 重算
// world),直接改私有成员是编译错误——面板天然遵守引擎约束。
//
#include "InspectorPanel.h"
#include "MaterialAssetUtil.h" // .material 枚举 + Create Override(与材质编辑器共享)
#include "MaterialEditorPanel.h" // 指派/编辑分离:双击槽位开独立面板
#include <functional> // 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 <imgui.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include "ControllerEditorPanel.h"
#include <unordered_set>
#include <vector>
#include <type_traits>
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<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;
}
// 递归收集 assets/ 下动画控制器资产(.animctrl,Controller 槽用;
// 会话级缓存,Create 新文件后经 InvalidateControllerFiles 失效)
static std::vector<std::string> s_CtrlFiles;
static bool s_CtrlFilesCached = false;
void InvalidateControllerFiles() { s_CtrlFilesCached = false; }
const std::vector<std::string>& 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<std::string>& AssetEnvFiles()
{
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 == ".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<int>(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<const char*>(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<const char*> opts;
opts.push_back("<none>");
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<int>(opts.size()); ++k)
{
if (std::strcmp(opts[static_cast<size_t>(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<int>(opts.size())))
{
if (sel <= 0)
{
mr.materialOverrides.erase(i);
}
else
{
const char* chosen = opts[static_cast<size_t>(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<void()>& 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<Engine::ECS::NameComponent>(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<decltype(typeTag)>;
const bool has = reg.all_of<T>(selected);
if (has)
{
ImGui::BeginDisabled(true);
}
if (ImGui::MenuItem(label))
{
reg.emplace<T>(selected);
}
if (has)
{
ImGui::EndDisabled();
}
};
menuItem(static_cast<Engine::ECS::MeshRendererComponent*>(nullptr),
"Mesh Renderer");
menuItem(static_cast<Engine::ECS::LightComponent*>(nullptr), "Light");
menuItem(static_cast<Engine::ECS::SkyboxComponent*>(nullptr), "Skybox");
menuItem(static_cast<Engine::ECS::SkyLightComponent*>(nullptr),
"Sky Light");
menuItem(static_cast<Engine::ECS::CameraComponent*>(nullptr), "Camera");
menuItem(static_cast<Engine::ECS::AnimationComponent*>(nullptr), "Animation");
menuItem(static_cast<Engine::ECS::NameComponent*>(nullptr), "Name");
menuItem(static_cast<Engine::ECS::RigidBodyComponent*>(nullptr), "RigidBody");
menuItem(static_cast<Engine::ECS::JointComponent*>(nullptr), "Joint");
menuItem(static_cast<Engine::ECS::CharacterControllerComponent*>(nullptr),
"Character Controller");
menuItem(static_cast<Engine::ECS::ScriptComponent*>(nullptr), "Script");
ImGui::EndPopup();
}
ImGui::Separator();
// ---- Transform(结构性组件:不可删、无启停)----
if (auto* t = reg.try_get<Engine::ECS::TransformComponent>(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<Engine::ECS::NameComponent>(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<unsigned>(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<Engine::ECS::MeshRendererComponent>(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<const char*> opts;
opts.push_back("<none>");
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<int>(k) + 1;
}
}
ImGui::SetNextItemWidth(-1.0f);
if (ImGui::Combo("##model_slot", &sel, opts.data(),
static_cast<int>(opts.size())))
{
mr->model = (sel <= 0)
? Engine::ModelRef{}
: ctx.assets->LoadModel(s_ModelFiles[static_cast<size_t>(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<unsigned>(clips.size()),
model->GetBoneCount());
}
else
{
ImGui::Text("model: valid (%u meshes, %u clips, %u bones)",
static_cast<unsigned>(model->GetMeshes().size()),
static_cast<unsigned>(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<Engine::ECS::MeshRendererComponent>(selected);
}
}
// ---- Light ----
if (auto* light = reg.try_get<Engine::ECS::LightComponent>(selected))
{
bool removeLight = false;
if (ComponentHeader("Light", &light->active, true, &removeLight))
{
if (!light->active)
{
ImGui::BeginDisabled(true);
}
int type = static_cast<int>(light->type);
if (ImGui::Combo("Type", &type, "Directional\0Point\0"))
{
light->type = static_cast<Engine::ECS::LightComponent::Type>(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<Engine::ECS::LightComponent>(selected);
}
}
// ---- Skybox(UE Skybox Actor 语义:环境贴图 = 实体资产槽)----
if (auto* sb = reg.try_get<Engine::ECS::SkyboxComponent>(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<const char*> opts;
opts.push_back("<none>");
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<int>(k) + 1;
}
}
ImGui::SetNextItemWidth(-1.0f);
if (ImGui::Combo("##env_slot", &sel, opts.data(),
static_cast<int>(opts.size())))
{
sb->envPath = (sel <= 0)
? std::string{}
: s_EnvFiles[static_cast<size_t>(sel) - 1];
}
ImGui::TextDisabled("(empty = clear color only)");
}
if (!sb->active)
{
ImGui::EndDisabled();
}
}
if (removeSb)
{
reg.remove<Engine::ECS::SkyboxComponent>(selected);
}
}
// ---- SkyLight(UE SkyLight 语义:全局环境光)----
if (auto* sl = reg.try_get<Engine::ECS::SkyLightComponent>(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<Engine::ECS::SkyLightComponent>(selected);
}
}
// ---- Camera ----
if (auto* cam = reg.try_get<Engine::ECS::CameraComponent>(selected))
{
bool removeCam = false;
// "..." 菜单组件专属项(双向同步,均只取位置+朝向,缩放不动):
// ① 编辑器相机 → 飞到本实体机位(编辑器视角立即从该相机处看)
// ② 本实体 → 摆到编辑器当前机位(摆实体相机,省手动对位)
// SetWorldPose 沿父链反解 local(层级实体不破坏父关系)
auto camMenu = [&]()
{
if (ImGui::MenuItem("Move Editor Camera Here") && ctx.editorCamera)
{
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
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<Engine::ECS::TransformComponent>(
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<Engine::ECS::CameraComponent>().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<int>(cam->camera.GetProjectionMode());
if (ImGui::Combo("Projection", &pm, projModes, 2))
{
cam->camera.SetProjectionMode(
static_cast<Engine::Camera::ProjectionMode>(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<Engine::ECS::CameraComponent>(selected);
}
}
// ---- RigidBody(Jolt 刚体:动态体 Transform 由物理每帧写回)----
// type/collider/mass 变更 → PhysicsWorld 重建 body;其余参数即时生效
if (auto* rb = reg.try_get<Engine::ECS::RigidBodyComponent>(selected))
{
bool removeRb = false;
if (ComponentHeader("RigidBody", &rb->active, true, &removeRb))
{
if (!rb->active)
{
ImGui::BeginDisabled(true);
}
// 冲突提示:同实体挂 CharacterController 会同时建 body 与
// CharacterVirtual,两个"运动主体"互相打架
if (reg.all_of<Engine::ECS::CharacterControllerComponent>(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<int>(rb->type);
if (ImGui::Combo("Type", &type, "Static\0Dynamic\0Kinematic\0"))
{
rb->type = static_cast<Engine::ECS::RigidBodyComponent::BodyType>(type);
}
int collider = static_cast<int>(rb->collider);
if (ImGui::Combo("Collider", &collider,
"Box\0Sphere\0Mesh\0Capsule\0Cylinder\0Compound\0"))
{
rb->collider = static_cast<Engine::ECS::RigidBodyComponent::ColliderType>(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<int>(rb->compound.size()));
ImGui::SameLine();
if (ImGui::Button("+ Add"))
{
rb->compound.emplace_back();
}
int remove = -1;
for (int i = 0; i < static_cast<int>(rb->compound.size()); ++i)
{
auto& c = rb->compound[i];
ImGui::PushID(i);
ImGui::Separator();
int sh = static_cast<int>(c.shape);
if (ImGui::Combo("Shape", &sh, "Box\0Sphere\0Capsule\0"))
{
c.shape = static_cast<Engine::ECS::RigidBodyComponent::CompoundChild::Shape>(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<uint32_t>(entt::to_integral(selected)),
s_debugImpulse);
}
}
if (!rb->active)
{
ImGui::EndDisabled();
}
}
if (removeRb)
{
reg.remove<Engine::ECS::RigidBodyComponent>(selected);
}
}
// ---- Joint(约束:两端 body 就绪后由 PhysicsWorld 对账创建)----
if (auto* jc = reg.try_get<Engine::ECS::JointComponent>(selected))
{
bool removeJc = false;
if (ComponentHeader("Joint", &jc->active, true, &removeJc))
{
if (!jc->active)
{
ImGui::BeginDisabled(true);
}
// 依赖提示:约束挂在两端刚体上,本体无 RigidBody 无法创建
if (!reg.all_of<Engine::ECS::RigidBodyComponent>(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<int>(jc->type);
if (ImGui::Combo("Type", &type, "Fixed\0Hinge\0Ball\0"))
{
jc->type = static_cast<Engine::ECS::JointComponent::Type>(type);
}
// target 实体下拉:实体名列表,选中态按 UUID 匹配(变更写回)
std::string items;
std::vector<Engine::UUID> uuids;
int current = -1;
reg.view<Engine::ECS::IdComponent, Engine::ECS::NameComponent>().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<int>(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", &current, items.c_str()) && current >= 0)
{
jc->target = uuids[static_cast<size_t>(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<Engine::ECS::JointComponent>(selected);
}
}
// ---- CharacterController(参数编辑;状态在 PhysicsWorld 运行时)----
if (auto* cc = reg.try_get<Engine::ECS::CharacterControllerComponent>(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<Engine::ECS::RigidBodyComponent>(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<uint32_t>(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<Engine::ECS::CharacterControllerComponent>(selected);
}
}
// ---- Script(按名解析注册表脚本;实例生命周期在 ScriptRuntime)----
if (auto* sc = reg.try_get<Engine::ECS::ScriptComponent>(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<int>(sc->items.size()); ++i)
{
auto& item = sc->items[static_cast<size_t>(i)];
ImGui::PushID(i);
// 去重下拉(DisallowMultipleComponent 语义):可选项 =
// "<none>" + 未挂名 + 自身名(已挂的别人的名不出现,结构上
// 选不出重复);自身名置于末尾保证已挂项正常回显
std::vector<const char*> options;
options.push_back("<none>");
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<int>(k);
}
}
ImGui::SetNextItemWidth(
ImGui::GetContentRegionAvail().x - 64.0f);
if (ImGui::Combo("##script", &cur, options.data(),
static_cast<int>(options.size())))
{
item.name = (cur <= 0) ? "" : options[static_cast<size_t>(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<float>(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<Engine::i32>(p->value.v);
if (ImGui::DragInt(d.name.c_str(), &v))
{
p->value = Engine::Scripting::ParamValue{
static_cast<Engine::i32>(v)};
changed = true;
}
break;
}
case Engine::Scripting::ParamType::Bool:
changed = ImGui::Checkbox(
d.name.c_str(), &std::get<bool>(p->value.v));
break;
case Engine::Scripting::ParamType::String:
{
char buf[256] = {};
std::snprintf(buf, sizeof(buf), "%s",
std::get<std::string>(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<glm::vec2>(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<glm::vec3>(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<uint32_t>(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<Engine::ECS::ScriptComponent>(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<Engine::Animation::RetargetRow> s_LastAttachRows;
// 时间拖动条的定格记忆:拖住瞬间是否在播(松手恢复用)
static bool s_ScrubWasPlaying = false;
if (auto* anim = reg.try_get<Engine::ECS::AnimationComponent>(selected))
{
bool removeAnim = false;
if (ComponentHeader("Animation", &anim->active, true, &removeAnim))
{
if (!anim->active)
{
ImGui::BeginDisabled(true);
}
const auto* mr = reg.try_get<Engine::ECS::MeshRendererComponent>(selected);
// 句柄无效不进 GetModel(缓存对无效句柄 FATAL;手选 <none> 即无效)
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<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);
}
}
}
}
// ---- 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<const char*> copts;
copts.push_back("<none>");
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<int>(k) + 1;
}
}
ImGui::SetNextItemWidth(-1.0f);
if (ImGui::Combo("##anim_ctrl_slot", &csel, copts.data(),
static_cast<int>(copts.size())))
{
anim->currentState.clear(); // 换绑 → 首帧回 entry
if (csel == 0)
{
anim->controllerPath.clear();
}
else
{
const std::string& path =
cfiles[static_cast<size_t>(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<const char*>(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<std::string> 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<bool>(f);
if (s_CreateOk)
{
f.write(yaml.data(),
static_cast<std::streamsize>(
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<unsigned>(model->GetBoneCount()),
static_cast<unsigned>(model->GetNodes().size()));
// ---- Clips:动画资产槽(列全部模型容器文件——无法静态
// 区分"模型 fbx"与"纯动画 fbx",一并列出,无 clip 的会
// 警告跳过)----
ImGui::SeparatorText("Clips");
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);
}
// 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<int>(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<ptrdiff_t>(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<unsigned>(s_LastAttachMatched),
static_cast<unsigned>(s_LastAttachRetargeted),
static_cast<unsigned>(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<const char*> 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<int>(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<int>(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<size_t>(
std::clamp(anim->clipIndex, 0,
static_cast<int>(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<unsigned>(c.channels.size()));
ImGui::NextColumn();
}
ImGui::Columns(1);
ImGui::TreePop();
}
}
if (!anim->active)
{
ImGui::EndDisabled();
}
}
if (removeAnim)
{
reg.remove<Engine::ECS::AnimationComponent>(selected);
}
}
ImGui::End();
}