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:
2026-10-07 01:50:04 +08:00
parent a941207ae3
commit cc837bc605
71 changed files with 8198 additions and 548 deletions
+797 -37
View File
@@ -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 进来的
// 外部动画在此核对骨骼匹配结果——轨道数明显偏少 = 骨骼
// 命名不同源被跳过)