Add animation controller system, material assets and skeleton visualization
- .animctrl state machine asset (params/transitions/blend trees, 1D+2D directional blending) with Unity Animator-style editor panel: state list, transitions, params, directional pad preview driving live CrossFade on the selected entity - Split animation evaluation out of SceneRenderer into AnimationSystem/AnimationEvaluator; skin palettes with rest-pose fallback and blend-weight epsilon filtering - MaterialAsset (.material) with reload-safe preview tile pool (DropMaterial clears dirty refs before instance destruction) - EditorGizmoPass skeleton wires + viewport joint overlay; camera projection rework (baked Vulkan Y-flip, ortho support) - Editor/game build split via HELLOVULKAN_EDITOR option
This commit is contained in:
+797
-37
@@ -6,22 +6,38 @@
|
||||
//
|
||||
|
||||
#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/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;
|
||||
|
||||
// 递归收集 assets/ 下模型容器文件(模型槽与动画 attach 槽共用;
|
||||
// 无法从扩展名区分"模型 fbx"与"纯动画 fbx",一并列出)。
|
||||
// 会话级缓存:目录条目几十量级,避免每帧递归扫描
|
||||
@@ -58,6 +74,42 @@ namespace
|
||||
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()
|
||||
{
|
||||
@@ -104,6 +156,217 @@ namespace
|
||||
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" 对齐
|
||||
@@ -146,11 +409,13 @@ namespace
|
||||
return changed;
|
||||
}
|
||||
|
||||
// 组件区块头(Unity 语义):折叠标题 + 右缘"启用"勾选与"删除"按钮。
|
||||
// active 传 nullptr = 无启停(结构性组件);removable = 显示删除钮,
|
||||
// 点击时置 *removeFlag,调用方在区块末尾统一移除(避免边画边删)
|
||||
// 组件区块头(Unity 语义):折叠标题 + 右缘"启用"勾选与"..."菜单。
|
||||
// active 传 nullptr = 无启停(结构性组件);removable = 显示"...",
|
||||
// 菜单内含 Remove;extraMenu(可空)= 组件专属菜单项(如 Camera 的
|
||||
// 视角联动),画在 Remove 之前
|
||||
bool ComponentHeader(const char* label, bool* active, bool removable,
|
||||
bool* removeFlag)
|
||||
bool* removeFlag,
|
||||
const std::function<void()>& extraMenu = {})
|
||||
{
|
||||
ImGui::PushID(label);
|
||||
bool open;
|
||||
@@ -163,11 +428,22 @@ namespace
|
||||
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)))
|
||||
if (ImGui::Button("...", ImVec2(24.0f, rowH - 4.0f)))
|
||||
{
|
||||
*removeFlag = true;
|
||||
ImGui::OpenPopup("comp_menu");
|
||||
}
|
||||
if (ImGui::BeginPopup("comp_menu"))
|
||||
{
|
||||
if (extraMenu) extraMenu();
|
||||
if (ImGui::MenuItem("Remove Component"))
|
||||
{
|
||||
*removeFlag = true;
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
x -= 30.0f;
|
||||
}
|
||||
@@ -366,6 +642,9 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
mr->model = (sel <= 0)
|
||||
? Engine::ModelRef{}
|
||||
: ctx.assets->LoadModel(s_ModelFiles[static_cast<size_t>(sel) - 1]);
|
||||
// 模型换绑后 mesh 下标语义失效:覆盖表显式清空
|
||||
//(残留会错挂到新模型的槽位上)
|
||||
mr->materialOverrides.clear();
|
||||
}
|
||||
ImGui::Spacing();
|
||||
}
|
||||
@@ -404,6 +683,16 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
{
|
||||
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();
|
||||
@@ -529,7 +818,34 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
if (auto* cam = reg.try_get<Engine::ECS::CameraComponent>(selected))
|
||||
{
|
||||
bool removeCam = false;
|
||||
if (ComponentHeader("Camera", &cam->active, true, &removeCam))
|
||||
// "..." 菜单组件专属项(双向同步,均只取位置+朝向,缩放不动):
|
||||
// ① 编辑器相机 → 飞到本实体机位(编辑器视角立即从该相机处看)
|
||||
// ② 本实体 → 摆到编辑器当前机位(摆实体相机,省手动对位)
|
||||
// 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)
|
||||
{
|
||||
@@ -549,17 +865,46 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
});
|
||||
}
|
||||
|
||||
// 对齐跳转:把本实体位姿复制给编辑器相机,Scene view 立即
|
||||
// 从该位置观看(只取位姿,不接管渲染——渲染源分派在
|
||||
// SceneRenderer,编辑器形态恒用编辑器相机)
|
||||
if (ImGui::Button("Align Viewport to Camera") && ctx.editorCamera)
|
||||
// ---- 投影参数(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))
|
||||
{
|
||||
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
|
||||
selected))
|
||||
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"))
|
||||
{
|
||||
ctx.editorCamera->AlignTo(*tr);
|
||||
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();
|
||||
@@ -951,6 +1296,14 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity 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;
|
||||
@@ -966,6 +1319,32 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
(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;解析失败的路径会话内不再重试
|
||||
@@ -990,6 +1369,298 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
{
|
||||
@@ -1002,6 +1673,13 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
}
|
||||
else
|
||||
{
|
||||
// ---- Skeleton 资产信息(只读)----骨骼数 = palette
|
||||
// 槽位数;节点数 = 蒙皮数学的全层级表(骨骼 ⊂ 节点,
|
||||
// 两数不同是正常的)。选中实体时视口内画骨架线框
|
||||
ImGui::Text("bones: %u nodes: %u",
|
||||
static_cast<unsigned>(model->GetBoneCount()),
|
||||
static_cast<unsigned>(model->GetNodes().size()));
|
||||
|
||||
// ---- Clips:动画资产槽(列全部模型容器文件——无法静态
|
||||
// 区分"模型 fbx"与"纯动画 fbx",一并列出,无 clip 的会
|
||||
// 警告跳过)----
|
||||
@@ -1029,14 +1707,67 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
{
|
||||
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;
|
||||
}
|
||||
// 已并入的源文件清单
|
||||
for (const auto& p : anim->attachedAnimPaths)
|
||||
// 已并入的源文件清单(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())
|
||||
{
|
||||
@@ -1045,32 +1776,61 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
|
||||
}
|
||||
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)
|
||||
// ---- Playback:播放控制。Direct 专属控件(clip 选择/
|
||||
// playing/loop/时间拖动条)只在 Direct 模式渲染——
|
||||
// Controller 模式下播放由状态机调度,这些字段不参
|
||||
// 与求值(Unity Animator 语义:Controller 挂上后
|
||||
// Animation 组件的 clip 字段灰置)----
|
||||
const bool ctrlMode =
|
||||
anim->mode ==
|
||||
Engine::ECS::AnimationComponent::AnimMode::Controller;
|
||||
if (!ctrlMode)
|
||||
{
|
||||
clipNames.push_back(c.name.c_str());
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
// 两模式通用:全局速度 / 过渡时长(Controller 的
|
||||
// CrossFade 与 Direct 的 crossfade 共用 fade)
|
||||
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 进来的
|
||||
// 外部动画在此核对骨骼匹配结果——轨道数明显偏少 = 骨骼
|
||||
// 命名不同源被跳过)
|
||||
|
||||
Reference in New Issue
Block a user