Files
HelloVulkan/App/editor/InspectorPanel.cpp
T
atdunbg 5ca5ac2a1e Editor: group Details parameters and reorganize Stats panel
Details: multi-parameter components (RigidBody/Joint/CharacterController/
Animation) now use SeparatorText category headers in UE Details style;
all parameter labels normalized to Pascal Case.

Stats: rearranged into View / Scene / Rendering / Physics / Performance
collapsing groups. Clear Color moved to Scene, Debug View to Rendering,
GPU Timings + Frame merged into Performance; bare top-level widgets and
the now-obsolete Environment group removed.
2026-10-05 01:20:00 +08:00

1127 lines
46 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 "Engine/ECS/Components.h"
#include "Engine/Renderer/Model.h"
#include "Engine/Physics/PhysicsWorld.h" // Apply Impulse 调试(GetPhysics 门面)
#include "Engine/Scripting/Script.h" // 脚本下拉(ScriptRegistry::Names)
#include <imgui.h>
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <unordered_set>
#include <vector>
#include <type_traits>
namespace
{
// 递归收集 assets/ 下模型容器文件(模型槽与动画 attach 槽共用;
// 无法从扩展名区分"模型 fbx"与"纯动画 fbx",一并列出)。
// 会话级缓存:目录条目几十量级,避免每帧递归扫描
const std::vector<std::string>& AssetModelFiles()
{
static std::vector<std::string> s_Files;
static bool s_Cached = false;
if (!s_Cached)
{
s_Cached = true;
namespace fs = std::filesystem;
if (fs::exists("assets"))
{
for (const auto& entry :
fs::recursive_directory_iterator("assets"))
{
if (!entry.is_regular_file())
{
continue;
}
std::string ext = entry.path().extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c)
{ return std::tolower(c); });
if (ext == ".gltf" || ext == ".glb" || ext == ".fbx" ||
ext == ".obj")
{
s_Files.push_back(entry.path().generic_string());
}
}
std::sort(s_Files.begin(), s_Files.end());
}
}
return s_Files;
}
// 递归收集 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;
}
// 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 = 显示删除钮,
// 点击时置 *removeFlag,调用方在区块末尾统一移除(避免边画边删)
bool ComponentHeader(const char* label, bool* active, bool removable,
bool* removeFlag)
{
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)
{
ImGui::SameLine(x);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1.0f);
if (ImGui::Button("-", ImVec2(24.0f, rowH - 4.0f)))
{
*removeFlag = true;
}
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]);
}
ImGui::Spacing();
}
// 无效句柄不进 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");
}
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;
if (ComponentHeader("Camera", &cam->active, true, &removeCam))
{
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;
}
});
}
// 对齐跳转:把本实体位姿复制给编辑器相机,Scene view 立即
// 从该位置观看(只取位姿,不接管渲染——渲染源分派在
// SceneRenderer,编辑器形态恒用编辑器相机)
if (ImGui::Button("Align Viewport to Camera") && ctx.editorCamera)
{
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
selected))
{
ctx.editorCamera->AlignTo(*tr);
}
}
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);
// ---- 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;
}
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 ----
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;
// ---- 外部动画并入(自愈重放 + attach 槽)----
// 重放:attachedAnimPaths 里未并入的源(重启后 Model 缓存是
// 空白)幂等重 attach;解析失败的路径会话内不再重试
if (model != nullptr)
{
static std::unordered_set<std::string> s_AttachFailed;
for (const auto& p : anim->attachedAnimPaths)
{
const bool done = std::any_of(
model->GetClips().begin(), model->GetClips().end(),
[&](const Engine::AnimationClip& c)
{ return c.sourcePath == p; });
if (!done && !s_AttachFailed.contains(p))
{
const auto res =
ctx.assets->AttachAnimationClips(mr->model, p);
if (res.clipsAdded == 0)
{
s_AttachFailed.insert(p);
}
}
}
}
// 依赖提示:动画作用在 MeshRenderer 的模型骨骼上
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
{
// ---- 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);
}
}
// 已并入的源文件清单
for (const auto& p : anim->attachedAnimPaths)
{
ImGui::BulletText("%s", std::filesystem::path(p)
.filename()
.string()
.c_str());
}
if (!model->HasAnimations())
{
ImGui::TextDisabled("(no animation clips on model)");
}
}
if (model != nullptr && model->HasAnimations())
{
// ---- Playback:播放控制(切换 clip 触发 crossfade)----
ImGui::SeparatorText("Playback");
const auto& clips = model->GetClips();
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);
ImGui::SliderFloat("Speed", &anim->speed, -2.0f, 2.0f);
ImGui::SliderFloat("Fade", &anim->fadeDuration, 0.0f, 1.0f);
if (anim->clipIndex >= 0 &&
anim->clipIndex < static_cast<int>(clips.size()))
{
ImGui::Text("t: %.2f / %.2f s", anim->time,
clips[anim->clipIndex].duration);
}
// 动画数据表:每个 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();
}