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:
+7
-1
@@ -6,7 +6,13 @@ file(GLOB EDITOR_SOURCES CONFIGURE_DEPENDS
|
||||
file(GLOB SCRIPT_SOURCES CONFIGURE_DEPENDS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/scripts/*.cpp)
|
||||
|
||||
add_executable(HelloVulkan main.cpp ${EDITOR_SOURCES} ${SCRIPT_SOURCES})
|
||||
# editor 形态(HELLOVULKAN_EDITOR=ON)编入 editor/(docking 编辑器);
|
||||
# game 形态只有 main + scripts(编辑器源码整体不进产物)
|
||||
if(HELLOVULKAN_EDITOR)
|
||||
add_executable(HelloVulkan main.cpp ${EDITOR_SOURCES} ${SCRIPT_SOURCES})
|
||||
else()
|
||||
add_executable(HelloVulkan main.cpp ${SCRIPT_SOURCES})
|
||||
endif()
|
||||
|
||||
target_include_directories(HelloVulkan PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/editor)
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//
|
||||
|
||||
#include "AssetBrowserPanel.h"
|
||||
#include "MaterialEditorPanel.h" // kMaterialDragPayload + 双击打开材质编辑器
|
||||
#include "ControllerEditorPanel.h" // kControllerDragPayload + 双击打开控制器编辑器
|
||||
|
||||
#include <Engine/Core/Log.h>
|
||||
|
||||
@@ -23,11 +25,12 @@ namespace {
|
||||
constexpr const char* kRootPaths[] = {"assets", "scenes"};
|
||||
|
||||
// 类型过滤(下标即身份):0 = All
|
||||
constexpr const char* kTypeNames[] = {"All", "Models", "Textures",
|
||||
"Scenes"};
|
||||
constexpr const char* kTypeNames[] = {"All", "Models",
|
||||
"Textures", "Scenes",
|
||||
"Materials", "Controllers"};
|
||||
|
||||
// 资产类别:按扩展名分派(过滤用)
|
||||
enum class Kind { Model, Texture, Scene, Other };
|
||||
enum class Kind { Model, Texture, Scene, Material, AnimClip, Other };
|
||||
|
||||
Kind KindOf(const std::filesystem::path& p)
|
||||
{
|
||||
@@ -48,6 +51,14 @@ namespace {
|
||||
{
|
||||
return Kind::Scene;
|
||||
}
|
||||
if (ext == ".material")
|
||||
{
|
||||
return Kind::Material;
|
||||
}
|
||||
if (ext == ".animctrl")
|
||||
{
|
||||
return Kind::AnimClip;
|
||||
}
|
||||
return Kind::Other;
|
||||
}
|
||||
|
||||
@@ -58,6 +69,8 @@ namespace {
|
||||
case 1: return kind == Kind::Model;
|
||||
case 2: return kind == Kind::Texture;
|
||||
case 3: return kind == Kind::Scene;
|
||||
case 4: return kind == Kind::Material;
|
||||
case 5: return kind == Kind::AnimClip;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +83,8 @@ namespace {
|
||||
case Kind::Model: return "[M]";
|
||||
case Kind::Texture: return "[T]";
|
||||
case Kind::Scene: return "[S]";
|
||||
case Kind::Material: return "[Ma]";
|
||||
case Kind::AnimClip: return "[An]";
|
||||
default: return "[ ]";
|
||||
}
|
||||
}
|
||||
@@ -106,7 +121,7 @@ void AssetBrowserPanel::Draw(const EditorContext& ctx)
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(110.0f);
|
||||
ImGui::Combo("##type", &m_TypeFilter, kTypeNames, 4);
|
||||
ImGui::Combo("##type", &m_TypeFilter, kTypeNames, 6);
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(140.0f);
|
||||
ImGui::InputTextWithHint("##name", "filter", m_NameFilter,
|
||||
@@ -262,12 +277,41 @@ void AssetBrowserPanel::DrawFileList(const EditorContext& ctx,
|
||||
ImGui::SameLine();
|
||||
ImGui::Selectable(name.c_str());
|
||||
|
||||
// 双击预热:model/HDR 进缓存(路径去重,重复双击零成本);
|
||||
// 场景加载含环境恢复/选中清理流程,提示走 File 菜单
|
||||
// .material 拖拽源:拖到 Details 材质槽球上指派(kMaterialDragPayload)
|
||||
if (item.kind == Kind::Material && ImGui::BeginDragDropSource())
|
||||
{
|
||||
ImGui::SetDragDropPayload(kMaterialDragPayload,
|
||||
assetPath.c_str(), assetPath.size() + 1);
|
||||
ImGui::TextUnformatted(name.c_str());
|
||||
ImGui::EndDragDropSource();
|
||||
}
|
||||
|
||||
// .animctrl 拖拽源:拖到 Details Controller 槽指派
|
||||
//(kControllerDragPayload,对齐材质槽交互)
|
||||
if (item.kind == Kind::AnimClip && ImGui::BeginDragDropSource())
|
||||
{
|
||||
ImGui::SetDragDropPayload(kControllerDragPayload,
|
||||
assetPath.c_str(), assetPath.size() + 1);
|
||||
ImGui::TextUnformatted(name.c_str());
|
||||
ImGui::EndDragDropSource();
|
||||
}
|
||||
|
||||
// 双击:.material 打开 Material Editor;.animctrl 打开
|
||||
// Controller Editor;model/HDR 进缓存预热(路径去重,重复
|
||||
// 双击零成本);场景加载含环境恢复/选中清理流程,提示走
|
||||
// File 菜单
|
||||
if (ImGui::IsItemHovered() &&
|
||||
ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))
|
||||
{
|
||||
if (item.kind == Kind::Model && ctx.assets)
|
||||
if (item.kind == Kind::Material && ctx.materialEditor)
|
||||
{
|
||||
ctx.materialEditor->OpenAsset(assetPath);
|
||||
}
|
||||
else if (item.kind == Kind::AnimClip && ctx.controllerEditor)
|
||||
{
|
||||
ctx.controllerEditor->Open(assetPath);
|
||||
}
|
||||
else if (item.kind == Kind::Model && ctx.assets)
|
||||
{
|
||||
const auto ref = ctx.assets->LoadModel(assetPath);
|
||||
ENGINE_INFO("AssetBrowser: model '{}' loaded (valid={})",
|
||||
@@ -292,8 +336,11 @@ void AssetBrowserPanel::DrawFileList(const EditorContext& ctx,
|
||||
ImGui::BeginTooltip();
|
||||
ImGui::TextUnformatted(name.c_str());
|
||||
ImGui::Separator();
|
||||
// 与 Kind 枚举逐位对齐(Kind::Other = 5)——漏项 = tooltip
|
||||
// 数组越界读
|
||||
static const char* kKindNames[] = {"Model", "Texture/Environment",
|
||||
"Scene", "File"};
|
||||
"Scene", "Material",
|
||||
"Controller", "File"};
|
||||
ImGui::Text("type: %s", kKindNames[static_cast<int>(item.kind)]);
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
//
|
||||
// ControllerEditorPanel 实现:见头文件注释。
|
||||
//
|
||||
|
||||
#include "ControllerEditorPanel.h"
|
||||
|
||||
#include <Engine/Animation/AnimationController.h>
|
||||
#include <Engine/ECS/Components.h>
|
||||
#include <Engine/Renderer/Model.h>
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
namespace {
|
||||
|
||||
ImVec4 WarnCol() { return ImVec4(1.0f, 0.75f, 0.2f, 1.0f); }
|
||||
|
||||
// 模型首个 clip 名(children/树新建时的默认引用;无模型 = 空)
|
||||
std::string FirstClipName(const Engine::Model* model)
|
||||
{
|
||||
if (model == nullptr || model->GetClips().empty()) return "";
|
||||
return model->GetClips()[0].name;
|
||||
}
|
||||
|
||||
// clip 引用编辑:选中实体有模型 → combo(模型 clip 列表);
|
||||
// 无模型 → InputText 手填(clip 缺失 = 该层 rest 兜底,不崩)
|
||||
bool ClipRefEdit(const char* label, std::string& clip,
|
||||
const Engine::Model* model)
|
||||
{
|
||||
if (model == nullptr || model->GetClips().empty())
|
||||
{
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf), "%s", clip.c_str());
|
||||
ImGui::SetNextItemWidth(-1.0f);
|
||||
if (ImGui::InputText(label, buf, sizeof(buf)))
|
||||
{
|
||||
clip = buf;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
std::vector<const char*> names;
|
||||
names.reserve(model->GetClips().size());
|
||||
int sel = -1;
|
||||
for (const auto& c : model->GetClips())
|
||||
{
|
||||
names.push_back(c.name.c_str());
|
||||
if (c.name == clip) sel = static_cast<int>(names.size()) - 1;
|
||||
}
|
||||
if (sel < 0) sel = 0;
|
||||
if (ImGui::Combo(label, &sel, names.data(),
|
||||
static_cast<int>(names.size())))
|
||||
{
|
||||
clip = model->GetClips()[static_cast<size_t>(sel)].name;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ControllerEditorPanel::Draw(const EditorContext& ctx)
|
||||
{
|
||||
if (!m_Open || m_Path.empty()) return;
|
||||
|
||||
Engine::Animation::AnimationController* ctrl =
|
||||
ctx.assets->FindController(m_Path);
|
||||
if (!ImGui::Begin("Controller Editor", &m_Open))
|
||||
{
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
if (ctrl == nullptr)
|
||||
{
|
||||
ImGui::TextColored(WarnCol(), "'%s' failed to load",
|
||||
m_Path.c_str());
|
||||
if (ImGui::Button("Reload"))
|
||||
{
|
||||
ctx.assets->ReloadController(m_Path);
|
||||
}
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 头部:路径 + Save(落盘 + Reload 重编译条件)----
|
||||
ImGui::TextUnformatted(m_Path.c_str());
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Save"))
|
||||
{
|
||||
if (ctrl->Save(m_Path))
|
||||
{
|
||||
ctx.assets->ReloadController(m_Path);
|
||||
ctrl = ctx.assets->FindController(m_Path);
|
||||
}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("edits apply live; Save writes the file");
|
||||
ImGui::Separator();
|
||||
|
||||
DrawPreview(ctx);
|
||||
ImGui::Separator();
|
||||
DrawStates(ctx, ctrl);
|
||||
ImGui::Separator();
|
||||
DrawTransitions(ctx, ctrl);
|
||||
ImGui::Separator();
|
||||
DrawParams(ctx, ctrl);
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// ---- Preview:方向盘(拖动 = SetFloat 到选中实体,摇杆回中)----
|
||||
void ControllerEditorPanel::DrawPreview(const EditorContext& ctx)
|
||||
{
|
||||
// 选中实体的 AnimationComponent(Controller 模式才驱动)
|
||||
Engine::ECS::AnimationComponent* anim = nullptr;
|
||||
const Engine::Model* model = nullptr;
|
||||
if (ctx.selected != nullptr && ctx.scene != nullptr &&
|
||||
ctx.scene->GetRegistry().valid(*ctx.selected))
|
||||
{
|
||||
auto& reg = ctx.scene->GetRegistry();
|
||||
if (auto* a =
|
||||
reg.try_get<Engine::ECS::AnimationComponent>(*ctx.selected))
|
||||
{
|
||||
anim = a;
|
||||
if (auto* mr =
|
||||
reg.try_get<Engine::ECS::MeshRendererComponent>(
|
||||
*ctx.selected))
|
||||
{
|
||||
if (mr->model.Valid())
|
||||
{
|
||||
model = ctx.assets->GetModel(mr->model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anim == nullptr)
|
||||
{
|
||||
ImGui::TextDisabled("select an entity with an Animation component "
|
||||
"to preview");
|
||||
return;
|
||||
}
|
||||
|
||||
Engine::Animation::AnimationController* ctrl =
|
||||
anim->controllerPath.empty()
|
||||
? nullptr
|
||||
: ctx.assets->FindController(anim->controllerPath);
|
||||
if (ctrl == nullptr) return;
|
||||
|
||||
// 面板选中状态的 2D 树(预览读出;拖动写的是实体参数)
|
||||
const Engine::Animation::ControllerState* padState = nullptr;
|
||||
const auto& order = ctrl->GetStateOrder();
|
||||
if (m_SelState >= 0 && m_SelState < static_cast<int>(order.size()))
|
||||
{
|
||||
const auto& s = ctrl->GetStates().at(order[static_cast<size_t>(
|
||||
m_SelState)]);
|
||||
if (s.tree.type ==
|
||||
Engine::Animation::BlendTree::Type::Directional2D)
|
||||
{
|
||||
padState = &s;
|
||||
}
|
||||
}
|
||||
if (padState == nullptr)
|
||||
{
|
||||
ImGui::TextDisabled("preview: select a 2DDirectional state");
|
||||
return;
|
||||
}
|
||||
|
||||
const float S = 190.0f;
|
||||
const float rad = S * 0.5f - 14.0f;
|
||||
const ImVec2 org = ImGui::GetCursorScreenPos();
|
||||
const ImVec2 ctr(org.x + S * 0.5f, org.y + S * 0.5f);
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddCircleFilled(ctr, rad, IM_COL32(30, 30, 34, 255));
|
||||
dl->AddCircle(ctr, rad, IM_COL32(90, 90, 100, 255), 48, 1.5f);
|
||||
dl->AddLine(ImVec2(ctr.x - rad, ctr.y), ImVec2(ctr.x + rad, ctr.y),
|
||||
IM_COL32(60, 60, 68, 255));
|
||||
dl->AddLine(ImVec2(ctr.x, ctr.y - rad), ImVec2(ctr.x, ctr.y + rad),
|
||||
IM_COL32(60, 60, 68, 255));
|
||||
const auto& ch = padState->tree.children;
|
||||
for (const auto& c : ch)
|
||||
{
|
||||
const bool isOrigin =
|
||||
c.pos.x * c.pos.x + c.pos.y * c.pos.y < 1e-8f;
|
||||
const ImVec2 p(ctr.x + c.pos.x * rad, ctr.y - c.pos.y * rad);
|
||||
dl->AddCircleFilled(p, isOrigin ? 5.0f : 4.0f,
|
||||
isOrigin ? IM_COL32(90, 140, 255, 255)
|
||||
: IM_COL32(220, 220, 225, 255));
|
||||
}
|
||||
// mode=Direct 的实体不跑状态机——参数写进去没有任何效果,
|
||||
// 直接禁用方向盘(Unity:Animator 组件没挂 Controller 时
|
||||
// 参数面板不可编辑,同语义)
|
||||
const bool isCtrlMode =
|
||||
anim->mode == Engine::ECS::AnimationComponent::AnimMode::Controller;
|
||||
if (!isCtrlMode)
|
||||
{
|
||||
ImGui::TextColored(WarnCol(),
|
||||
"entity is in Direct mode - switch Mode to "
|
||||
"Controller to preview");
|
||||
return;
|
||||
}
|
||||
ImGui::InvisibleButton("##dirpad", ImVec2(S, S));
|
||||
const auto getF = [&](const std::string& n) -> float {
|
||||
auto it = anim->floatParams.find(n);
|
||||
return it != anim->floatParams.end() ? it->second : 0.0f;
|
||||
};
|
||||
float cx = getF(padState->tree.xParam);
|
||||
float cz = getF(padState->tree.zParam);
|
||||
if (ImGui::IsItemActivated())
|
||||
{
|
||||
// 预览闭环(Unity Animator 窗口语义:选中状态即可预览该
|
||||
// 状态)——实体停在别的状态时拖盘自动切到面板选中的 2D
|
||||
// 树状态,否则 SetFloat 对 Single 状态完全无效
|
||||
if (anim->currentState != order[static_cast<size_t>(m_SelState)])
|
||||
{
|
||||
anim->CrossFade(order[static_cast<size_t>(m_SelState)], 0.1f);
|
||||
}
|
||||
}
|
||||
if (ImGui::IsItemActive())
|
||||
{
|
||||
const ImVec2 m = ImGui::GetIO().MousePos;
|
||||
float vx = (m.x - ctr.x) / rad;
|
||||
float vz = -(m.y - ctr.y) / rad;
|
||||
const float len = glm::length(glm::vec2(vx, vz));
|
||||
if (len > 1.0f)
|
||||
{
|
||||
vx /= len;
|
||||
vz /= len;
|
||||
}
|
||||
anim->SetFloat(padState->tree.xParam, vx);
|
||||
anim->SetFloat(padState->tree.zParam, vz);
|
||||
cx = vx;
|
||||
cz = vz;
|
||||
}
|
||||
else if (ImGui::IsItemDeactivatedAfterEdit())
|
||||
{
|
||||
anim->SetFloat(padState->tree.xParam, 0.0f);
|
||||
anim->SetFloat(padState->tree.zParam, 0.0f);
|
||||
cx = cz = 0.0f;
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
{
|
||||
const ImVec2 m = ImGui::GetIO().MousePos;
|
||||
for (const auto& c : ch)
|
||||
{
|
||||
const ImVec2 p(ctr.x + c.pos.x * rad, ctr.y - c.pos.y * rad);
|
||||
const float dx = m.x - p.x;
|
||||
const float dy = m.y - p.y;
|
||||
if (dx * dx + dy * dy < 64.0f)
|
||||
{
|
||||
ImGui::SetTooltip("%s", c.clip.c_str());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const ImVec2 cur(ctr.x + cx * rad, ctr.y - cz * rad);
|
||||
dl->AddCircleFilled(cur, 6.0f, IM_COL32(255, 170, 60, 255));
|
||||
dl->AddCircle(cur, 9.0f, IM_COL32(255, 170, 60, 160), 0, 1.5f);
|
||||
|
||||
// 右侧读出:当前参数 + 选中状态树的权重(运行时同一数学)
|
||||
ImGui::SameLine();
|
||||
ImGui::BeginGroup();
|
||||
ImGui::Text("preview: %s", anim->currentState.c_str());
|
||||
ImGui::Text("%.2f / %.2f", cx, cz);
|
||||
std::vector<std::pair<Engine::u32, float>> ws;
|
||||
Engine::Animation::EvaluateBlendTree2D(padState->tree, cx, cz, ws);
|
||||
std::sort(ws.begin(), ws.end(),
|
||||
[](const auto& a, const auto& b)
|
||||
{ return a.second > b.second; });
|
||||
for (const auto& [idx, w] : ws)
|
||||
{
|
||||
ImGui::Text("%s %.2f", ch[idx].clip.c_str(), w);
|
||||
}
|
||||
ImGui::EndGroup();
|
||||
}
|
||||
|
||||
// ---- States:列表(entry ★)+ 选中状态详情 + children 编辑 ----
|
||||
void ControllerEditorPanel::DrawStates(
|
||||
const EditorContext& ctx, Engine::Animation::AnimationController* ctrl)
|
||||
{
|
||||
if (!ImGui::TreeNodeEx("States", ImGuiTreeNodeFlags_DefaultOpen))
|
||||
{
|
||||
return;
|
||||
}
|
||||
auto& order = ctrl->StateOrderMut();
|
||||
auto& states = ctrl->StatesMut();
|
||||
|
||||
// 选中实体模型(children 的 clip 下拉来源)
|
||||
const Engine::Model* model = nullptr;
|
||||
if (ctx.selected != nullptr && ctx.scene != nullptr &&
|
||||
ctx.scene->GetRegistry().valid(*ctx.selected))
|
||||
{
|
||||
if (auto* mr =
|
||||
ctx.scene->GetRegistry()
|
||||
.try_get<Engine::ECS::MeshRendererComponent>(
|
||||
*ctx.selected))
|
||||
{
|
||||
if (mr->model.Valid())
|
||||
{
|
||||
model = ctx.assets->GetModel(mr->model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 状态列表 + Add
|
||||
int delIdx = -1;
|
||||
for (int i = 0; i < static_cast<int>(order.size()); ++i)
|
||||
{
|
||||
const bool isEntry = order[static_cast<size_t>(i)] ==
|
||||
ctrl->GetEntryState();
|
||||
ImGui::PushID(i);
|
||||
if (ImGui::Selectable(
|
||||
(order[static_cast<size_t>(i)] +
|
||||
std::string(isEntry ? " [entry]" : ""))
|
||||
.c_str(),
|
||||
i == m_SelState))
|
||||
{
|
||||
m_SelState = i;
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
{
|
||||
static char s_NewName[64] = "";
|
||||
ImGui::SetNextItemWidth(120.0f);
|
||||
ImGui::InputText("##new_state", s_NewName, sizeof(s_NewName));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("+ Add") && s_NewName[0] != '\0' &&
|
||||
states.find(s_NewName) == states.end())
|
||||
{
|
||||
const std::string n = s_NewName;
|
||||
states.emplace(n, Engine::Animation::ControllerState{n});
|
||||
order.push_back(n);
|
||||
m_SelState = static_cast<int>(order.size()) - 1;
|
||||
s_NewName[0] = '\0';
|
||||
}
|
||||
}
|
||||
if (m_SelState >= static_cast<int>(order.size()))
|
||||
{
|
||||
m_SelState = static_cast<int>(order.size()) - 1;
|
||||
}
|
||||
if (m_SelState < 0 || order.empty())
|
||||
{
|
||||
ImGui::TreePop();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 选中状态详情 ----
|
||||
const std::string selName = order[static_cast<size_t>(m_SelState)];
|
||||
auto it = states.find(selName);
|
||||
if (it == states.end())
|
||||
{
|
||||
ImGui::TreePop();
|
||||
return;
|
||||
}
|
||||
auto& st = it->second;
|
||||
ImGui::Indent();
|
||||
|
||||
ImGui::TextUnformatted("editing:");
|
||||
ImGui::SameLine();
|
||||
ImGui::TextUnformatted(selName.c_str());
|
||||
if (selName != ctrl->GetEntryState() &&
|
||||
ImGui::SmallButton("Set as Entry"))
|
||||
{
|
||||
ctrl->SetEntry(selName);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::PushID("delstate");
|
||||
if (ImGui::SmallButton("x Delete"))
|
||||
{
|
||||
delIdx = m_SelState;
|
||||
}
|
||||
ImGui::PopID();
|
||||
// 重命名(级联:transitions / transitionsOnEnd / entry 引用同步)
|
||||
{
|
||||
static char s_Rename[64] = "";
|
||||
ImGui::SetNextItemWidth(120.0f);
|
||||
ImGui::InputText("##rename", s_Rename, sizeof(s_Rename));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("Rename") && s_Rename[0] != '\0' &&
|
||||
states.find(s_Rename) == states.end())
|
||||
{
|
||||
const std::string oldN = selName;
|
||||
const std::string newN = s_Rename;
|
||||
auto node = states.extract(oldN);
|
||||
if (node)
|
||||
{
|
||||
node.key() = newN;
|
||||
states.insert(std::move(node));
|
||||
order[static_cast<size_t>(m_SelState)] = newN;
|
||||
for (auto& t : ctrl->TransitionsMut())
|
||||
{
|
||||
if (t.from == oldN) t.from = newN;
|
||||
if (t.to == oldN) t.to = newN;
|
||||
}
|
||||
for (auto& [n, s] : states)
|
||||
{
|
||||
for (auto& e : s.transitionsOnEnd)
|
||||
{
|
||||
if (e == oldN) e = newN;
|
||||
}
|
||||
}
|
||||
if (ctrl->GetEntryState() == oldN)
|
||||
{
|
||||
ctrl->SetEntry(newN);
|
||||
}
|
||||
s_Rename[0] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 播放分组(speed/loop/播完出口)----
|
||||
if (ImGui::TreeNode("Playback"))
|
||||
{
|
||||
ImGui::DragFloat("speed", &st.speed, 0.05f, 0.1f, 10.0f, "%.2f");
|
||||
ImGui::Checkbox("loop", &st.loop);
|
||||
if (!st.loop)
|
||||
{
|
||||
// 播完出口列表(按序首个可用)+ 下拉追加
|
||||
for (size_t ei = 0; ei < st.transitionsOnEnd.size(); ++ei)
|
||||
{
|
||||
ImGui::PushID(static_cast<int>(ei) + 1000);
|
||||
ImGui::BulletText("%s",
|
||||
st.transitionsOnEnd[ei].c_str());
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("x"))
|
||||
{
|
||||
st.transitionsOnEnd.erase(
|
||||
st.transitionsOnEnd.begin() +
|
||||
static_cast<long>(ei));
|
||||
ImGui::PopID();
|
||||
break;
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (!order.empty())
|
||||
{
|
||||
static int s_OnEndSel = 0;
|
||||
s_OnEndSel = std::clamp(
|
||||
s_OnEndSel, 0,
|
||||
static_cast<int>(order.size()) - 1);
|
||||
ImGui::SetNextItemWidth(120.0f);
|
||||
ImGui::Combo(
|
||||
"##onend", &s_OnEndSel,
|
||||
[](void* d, int i) -> const char* {
|
||||
return (*static_cast<std::vector<std::string>*>(
|
||||
d))[static_cast<size_t>(i)]
|
||||
.c_str();
|
||||
},
|
||||
&order, static_cast<int>(order.size()));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("+ exit"))
|
||||
{
|
||||
st.transitionsOnEnd.push_back(
|
||||
order[static_cast<size_t>(s_OnEndSel)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::TreePop();
|
||||
}
|
||||
|
||||
// ---- 混合树分组 ----
|
||||
if (ImGui::TreeNode("Blend Tree"))
|
||||
{
|
||||
using BTType = Engine::Animation::BlendTree::Type;
|
||||
// 树类型:Single 单 clip / 1D 阈值 / 2D 平面方向
|
||||
int tt = static_cast<int>(st.tree.type);
|
||||
if (ImGui::Combo("type", &tt,
|
||||
"Single\0Directional 1D\0Directional 2D\0"))
|
||||
{
|
||||
st.tree.type = static_cast<BTType>(tt);
|
||||
st.tree.children.clear();
|
||||
if (st.tree.type == BTType::Single)
|
||||
{
|
||||
if (st.tree.clip.empty())
|
||||
{
|
||||
st.tree.clip = FirstClipName(model);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 默认一个原点 child(clip 取模型首个;无模型 = 手填)
|
||||
st.tree.children.push_back(
|
||||
{FirstClipName(model), glm::vec2{0.0f, 0.0f}});
|
||||
}
|
||||
}
|
||||
// x/z 参数绑定(float 参数下拉)
|
||||
{
|
||||
std::vector<const char*> pnames;
|
||||
for (const auto& p : ctrl->GetParams())
|
||||
{
|
||||
if (p.type ==
|
||||
Engine::Animation::ControllerParam::Type::Float)
|
||||
{
|
||||
pnames.push_back(p.name.c_str());
|
||||
}
|
||||
}
|
||||
if (!pnames.empty())
|
||||
{
|
||||
if (st.tree.type != BTType::Single)
|
||||
{
|
||||
int xi = 0;
|
||||
for (int k = 0; k < static_cast<int>(pnames.size());
|
||||
++k)
|
||||
{
|
||||
if (st.tree.xParam ==
|
||||
pnames[static_cast<size_t>(k)])
|
||||
xi = k;
|
||||
}
|
||||
ImGui::SetNextItemWidth(140.0f);
|
||||
if (ImGui::Combo("x param", &xi, pnames.data(),
|
||||
static_cast<int>(pnames.size())))
|
||||
{
|
||||
st.tree.xParam = pnames[static_cast<size_t>(xi)];
|
||||
}
|
||||
}
|
||||
if (st.tree.type == BTType::Directional2D)
|
||||
{
|
||||
int zi = 0;
|
||||
for (int k = 0; k < static_cast<int>(pnames.size());
|
||||
++k)
|
||||
{
|
||||
if (st.tree.zParam ==
|
||||
pnames[static_cast<size_t>(k)])
|
||||
zi = k;
|
||||
}
|
||||
ImGui::SetNextItemWidth(140.0f);
|
||||
if (ImGui::Combo("z param", &zi, pnames.data(),
|
||||
static_cast<int>(pnames.size())))
|
||||
{
|
||||
st.tree.zParam = pnames[static_cast<size_t>(zi)];
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (st.tree.type != BTType::Single)
|
||||
{
|
||||
ImGui::TextColored(
|
||||
WarnCol(), "need a float param for the blend axis");
|
||||
}
|
||||
}
|
||||
if (st.tree.type == BTType::Single)
|
||||
{
|
||||
ClipRefEdit("clip", st.tree.clip, model);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 原点点缺失警告:v 回到中心时方向层权重全被最小阈值滤
|
||||
// 掉,层空 → 引擎侧 fallback 到首个有效 child 单层(不是
|
||||
// T-pose),但"中心=默认动作"的直觉语义要靠原点点承载
|
||||
if (st.tree.type == BTType::Directional2D)
|
||||
{
|
||||
bool hasOrigin = false;
|
||||
for (const auto& c : st.tree.children)
|
||||
{
|
||||
if (c.pos.x * c.pos.x + c.pos.y * c.pos.y < 1e-8f)
|
||||
hasOrigin = true;
|
||||
}
|
||||
if (!hasOrigin)
|
||||
{
|
||||
ImGui::TextColored(
|
||||
WarnCol(), "! no origin child (at 0,0) - center "
|
||||
"falls back to first child");
|
||||
}
|
||||
}
|
||||
// children 表:clip + 定位 + 删除(2D = (x,y) 平面定位点;
|
||||
// 1D = x 即阈值)。编辑即时生效(System 下帧直读缓存实例)
|
||||
int delChild = -1;
|
||||
for (int ci = 0;
|
||||
ci < static_cast<int>(st.tree.children.size()); ++ci)
|
||||
{
|
||||
auto& c = st.tree.children[static_cast<size_t>(ci)];
|
||||
ImGui::PushID(ci);
|
||||
ClipRefEdit("##clip", c.clip, model);
|
||||
if (st.tree.type == BTType::Directional2D)
|
||||
{
|
||||
ImGui::SetNextItemWidth(70.0f);
|
||||
ImGui::DragFloat("##atx", &c.pos.x, 0.01f, -1.0f,
|
||||
1.0f, "x %.2f");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(70.0f);
|
||||
ImGui::DragFloat("##aty", &c.pos.y, 0.01f, -1.0f,
|
||||
1.0f, "y %.2f");
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui::SetNextItemWidth(120.0f);
|
||||
ImGui::DragFloat("##thr", &c.pos.x, 0.05f, -10.0f,
|
||||
10.0f, "thr %.2f");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("x")) delChild = ci;
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (delChild >= 0)
|
||||
{
|
||||
st.tree.children.erase(
|
||||
st.tree.children.begin() + delChild);
|
||||
}
|
||||
if (ImGui::SmallButton("+ Add Child"))
|
||||
{
|
||||
st.tree.children.push_back(
|
||||
{FirstClipName(model),
|
||||
st.tree.type == BTType::Directional2D
|
||||
? glm::vec2{1.0f, 0.0f}
|
||||
: glm::vec2{0.0f, 0.0f}});
|
||||
}
|
||||
}
|
||||
ImGui::TreePop(); // Blend Tree
|
||||
}
|
||||
ImGui::Unindent();
|
||||
|
||||
if (delIdx >= 0)
|
||||
{
|
||||
const std::string name = order[static_cast<size_t>(delIdx)];
|
||||
states.erase(name);
|
||||
order.erase(order.begin() + delIdx);
|
||||
auto& trs = ctrl->TransitionsMut();
|
||||
for (int i = static_cast<int>(trs.size()) - 1; i >= 0; --i)
|
||||
{
|
||||
if (trs[static_cast<size_t>(i)].from == name ||
|
||||
trs[static_cast<size_t>(i)].to == name)
|
||||
{
|
||||
trs.erase(trs.begin() + i);
|
||||
}
|
||||
}
|
||||
for (auto& [n, s] : states)
|
||||
{
|
||||
std::erase_if(s.transitionsOnEnd,
|
||||
[&](const std::string& e)
|
||||
{ return e == name; });
|
||||
}
|
||||
if (ctrl->GetEntryState() == name)
|
||||
{
|
||||
ctrl->SetEntry(order.empty() ? "" : order[0]);
|
||||
}
|
||||
m_SelState = 0;
|
||||
}
|
||||
ImGui::TreePop();
|
||||
}
|
||||
|
||||
// ---- Transitions:行编辑(from/to 下拉 + cond 字符串 Enter 编译)----
|
||||
void ControllerEditorPanel::DrawTransitions(
|
||||
const EditorContext& ctx, Engine::Animation::AnimationController* ctrl)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!ImGui::TreeNode("Transitions")) return;
|
||||
const auto& order = ctrl->GetStateOrder();
|
||||
std::vector<const char*> snames;
|
||||
snames.reserve(order.size() + 1);
|
||||
snames.push_back("*"); // AnyState
|
||||
for (const auto& n : order) snames.push_back(n.c_str());
|
||||
auto stateCombo = [&](const char* label, std::string& cur) {
|
||||
int sel = 0;
|
||||
for (int k = 0; k < static_cast<int>(snames.size()); ++k)
|
||||
{
|
||||
if (cur == snames[static_cast<size_t>(k)]) sel = k;
|
||||
}
|
||||
ImGui::SetNextItemWidth(110.0f);
|
||||
if (ImGui::Combo(label, &sel, snames.data(),
|
||||
static_cast<int>(snames.size())))
|
||||
{
|
||||
cur = snames[static_cast<size_t>(sel)];
|
||||
}
|
||||
};
|
||||
|
||||
int delIdx = -1;
|
||||
int ti = 0;
|
||||
for (auto& t : ctrl->TransitionsMut())
|
||||
{
|
||||
ImGui::PushID(ti++);
|
||||
// 行 1:来源 -> 目标(+ 删除)
|
||||
stateCombo("##from", t.from);
|
||||
ImGui::SameLine();
|
||||
ImGui::TextUnformatted("->");
|
||||
ImGui::SameLine();
|
||||
stateCombo("##to", t.to);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("x")) delIdx = ti - 1;
|
||||
// 行 2:条件 + fade + exitTime(缩进——同一过渡的属性组)
|
||||
ImGui::Indent(18.0f);
|
||||
// cond:反推字符串 → Enter(DeactivatedAfterEdit)就地重编译
|
||||
{
|
||||
const std::string s =
|
||||
t.cond.always ? "" :
|
||||
Engine::Animation::AnimationController::CondToString(
|
||||
t.cond);
|
||||
char buf[96];
|
||||
snprintf(buf, sizeof(buf), "%s", s.c_str());
|
||||
ImGui::SetNextItemWidth(170.0f);
|
||||
if (ImGui::InputText("##cond", buf, sizeof(buf),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue) ||
|
||||
ImGui::IsItemDeactivatedAfterEdit())
|
||||
{
|
||||
t.cond = Engine::Animation::AnimationController::ParseCond(
|
||||
buf);
|
||||
}
|
||||
if (!t.cond.always && !t.cond.valid)
|
||||
{
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(WarnCol(), "!");
|
||||
}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(60.0f);
|
||||
ImGui::DragFloat("##fade", &t.fade, 0.02f, 0.0f, 5.0f, "%.2f");
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox("##exit", &t.hasExitTime);
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("exit time");
|
||||
ImGui::Unindent(18.0f);
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (delIdx >= 0)
|
||||
{
|
||||
auto& trs = ctrl->TransitionsMut();
|
||||
trs.erase(trs.begin() + delIdx);
|
||||
}
|
||||
if (ImGui::SmallButton("+ Add") && !order.empty())
|
||||
{
|
||||
Engine::Animation::Transition tr;
|
||||
tr.from = order[0];
|
||||
tr.to = order[order.size() > 1 ? 1 : 0];
|
||||
ctrl->TransitionsMut().push_back(tr);
|
||||
}
|
||||
ImGui::TreePop();
|
||||
}
|
||||
|
||||
// ---- Params:参数声明编辑(禁改名——被 tree/cond 引用,改了断链)----
|
||||
void ControllerEditorPanel::DrawParams(
|
||||
const EditorContext& ctx, Engine::Animation::AnimationController* ctrl)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!ImGui::TreeNode("Params")) return;
|
||||
auto& ps = ctrl->ParamsMut();
|
||||
int delIdx = -1;
|
||||
int pi = 0;
|
||||
for (auto& p : ps)
|
||||
{
|
||||
ImGui::PushID(pi++);
|
||||
ImGui::TextUnformatted(p.name.c_str());
|
||||
ImGui::SameLine();
|
||||
int ty = static_cast<int>(p.type);
|
||||
ImGui::SetNextItemWidth(80.0f);
|
||||
if (ImGui::Combo("##ty", &ty, "float\0bool\0trigger\0"))
|
||||
{
|
||||
p.type =
|
||||
static_cast<Engine::Animation::ControllerParam::Type>(ty);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (p.type ==
|
||||
Engine::Animation::ControllerParam::Type::Float)
|
||||
{
|
||||
ImGui::SetNextItemWidth(90.0f);
|
||||
ImGui::DragFloat("##def", &p.defFloat, 0.05f);
|
||||
}
|
||||
else if (p.type ==
|
||||
Engine::Animation::ControllerParam::Type::Bool)
|
||||
{
|
||||
ImGui::Checkbox("##def", &p.defBool);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("x")) delIdx = pi - 1;
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (delIdx >= 0) ps.erase(ps.begin() + delIdx);
|
||||
{
|
||||
static char s_NewName[64] = "";
|
||||
ImGui::SetNextItemWidth(120.0f);
|
||||
ImGui::InputText("##new_param", s_NewName, sizeof(s_NewName));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("+ Add") && s_NewName[0] != '\0')
|
||||
{
|
||||
bool dup = false;
|
||||
for (const auto& p : ps)
|
||||
{
|
||||
if (p.name == s_NewName) dup = true;
|
||||
}
|
||||
if (!dup)
|
||||
{
|
||||
Engine::Animation::ControllerParam d;
|
||||
d.name = s_NewName;
|
||||
ps.push_back(d);
|
||||
s_NewName[0] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::TreePop();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// ControllerEditorPanel:动画控制器编辑器窗口(Unity Animator 状态机
|
||||
// 面板范式,交互对齐 MaterialEditorPanel 的"指派/编辑分离")。
|
||||
//
|
||||
// Details 的 Controller 区只做指派(槽/Create/双击打开本面板);
|
||||
// 结构编辑全在这里:
|
||||
// - Preview:方向盘(2D 混合树可视化驱动)+ 权重实时读出,拖动
|
||||
// SetFloat 到当前选中实体(Controller 模式)实时预览
|
||||
// - States:状态列表(增删/重命名/entry),选中状态详情 = 播放
|
||||
// 参数 + 混合树类型 + children 逐行编辑(clip 指定/定位/删除)
|
||||
// - Transitions / Params:列表编辑,cond 字符串 Enter 就地编译
|
||||
//
|
||||
// 编辑直接写缓存资产实例(System 下一帧生效,无需保存);Save 落盘
|
||||
// + ReloadController 重编译(实例换新,本面板每帧重解析不持指针)。
|
||||
//
|
||||
|
||||
#ifndef HELLOVULKAN_CONTROLLEREDITORPANEL_H
|
||||
#define HELLOVULKAN_CONTROLLEREDITORPANEL_H
|
||||
|
||||
#include "EditorContext.h"
|
||||
|
||||
#include <Engine/Core/Types.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// .animctrl 拖拽 payload(AssetBrowser → Details Controller 槽指派)
|
||||
inline constexpr char kControllerDragPayload[] = "CTRL_PATH";
|
||||
|
||||
class ControllerEditorPanel {
|
||||
public:
|
||||
// 打开 .animctrl 资产(Details 槽位 Edit 按钮 / Content Browser
|
||||
// 双击入口)
|
||||
void Open(const std::string& path)
|
||||
{
|
||||
m_Path = path;
|
||||
m_Open = true;
|
||||
m_SelState = 0;
|
||||
}
|
||||
bool IsOpen() const { return m_Open; }
|
||||
void Draw(const EditorContext& ctx);
|
||||
|
||||
private:
|
||||
void DrawPreview(const EditorContext& ctx);
|
||||
void DrawStates(const EditorContext& ctx,
|
||||
Engine::Animation::AnimationController* ctrl);
|
||||
void DrawTransitions(const EditorContext& ctx,
|
||||
Engine::Animation::AnimationController* ctrl);
|
||||
void DrawParams(const EditorContext& ctx,
|
||||
Engine::Animation::AnimationController* ctrl);
|
||||
|
||||
bool m_Open = false;
|
||||
std::string m_Path;
|
||||
int m_SelState = 0; // 选中状态(StateOrder 下标)
|
||||
std::vector<char> m_CondBuf; // cond 输入缓冲(按行复用)
|
||||
};
|
||||
|
||||
#endif //HELLOVULKAN_CONTROLLEREDITORPANEL_H
|
||||
@@ -15,6 +15,8 @@
|
||||
#include "AssetBrowserPanel.h"
|
||||
#include "HierarchyPanel.h"
|
||||
#include "InspectorPanel.h"
|
||||
#include "MaterialEditorPanel.h"
|
||||
#include "ControllerEditorPanel.h"
|
||||
#include "ViewportPanel.h"
|
||||
#include "StatsPanel.h"
|
||||
|
||||
@@ -47,6 +49,11 @@ void Editor::Init(const EditorContext& ctx)
|
||||
m_Viewport = std::make_unique<ViewportPanel>();
|
||||
m_Stats = std::make_unique<StatsPanel>();
|
||||
m_AssetBrowser = std::make_unique<AssetBrowserPanel>();
|
||||
m_MaterialEditor = std::make_unique<MaterialEditorPanel>();
|
||||
m_Ctx.materialEditor = m_MaterialEditor.get();
|
||||
m_ControllerEditor = std::make_unique<ControllerEditorPanel>();
|
||||
m_Ctx.controllerEditor = m_ControllerEditor.get();
|
||||
m_Ctx.selected = &m_Selected;
|
||||
|
||||
// 旧布局一次性迁移:面板改名(Hierarchy→Outliner / Inspector→Details /
|
||||
// Asset Browser→Content Browser)= docking 身份全变,旧 imgui.ini 对
|
||||
@@ -192,6 +199,8 @@ void Editor::Draw()
|
||||
}
|
||||
if (m_ShowStats) m_Stats->Draw(m_Ctx);
|
||||
if (m_ShowAssetBrowser) m_AssetBrowser->Draw(m_Ctx);
|
||||
if (m_ShowMaterialEditor) m_MaterialEditor->Draw(m_Ctx);
|
||||
m_ControllerEditor->Draw(m_Ctx); // 面板内部 m_Open 门控
|
||||
|
||||
DrawQuitModal();
|
||||
DrawSceneModals();
|
||||
@@ -269,6 +278,7 @@ void Editor::DrawMenuBar()
|
||||
ImGui::MenuItem("Viewport", nullptr, &m_ShowViewport);
|
||||
ImGui::MenuItem("Stats", nullptr, &m_ShowStats);
|
||||
ImGui::MenuItem("Content Browser", nullptr, &m_ShowAssetBrowser);
|
||||
ImGui::MenuItem("Material Editor", nullptr, &m_ShowMaterialEditor);
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
@@ -399,6 +409,8 @@ void Editor::DrawDockSpace()
|
||||
ImGui::DockBuilderDockWindow("Stats", dockCenter); // Viewport 旁 tab
|
||||
ImGui::DockBuilderDockWindow("Content Browser", dockBottom);
|
||||
ImGui::DockBuilderDockWindow("Details", dockRight);
|
||||
ImGui::DockBuilderDockWindow("Material Editor", dockRight); // Details 旁 tab
|
||||
ImGui::DockBuilderDockWindow("Controller Editor", dockRight); // Details 旁 tab
|
||||
|
||||
ImGui::DockBuilderFinish(dockspaceId);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ class InspectorPanel;
|
||||
class ViewportPanel;
|
||||
class StatsPanel;
|
||||
class AssetBrowserPanel;
|
||||
class MaterialEditorPanel;
|
||||
class ControllerEditorPanel;
|
||||
|
||||
class Editor {
|
||||
public:
|
||||
@@ -108,6 +110,8 @@ private:
|
||||
std::unique_ptr<ViewportPanel> m_Viewport;
|
||||
std::unique_ptr<StatsPanel> m_Stats;
|
||||
std::unique_ptr<AssetBrowserPanel> m_AssetBrowser;
|
||||
std::unique_ptr<MaterialEditorPanel> m_MaterialEditor;
|
||||
std::unique_ptr<ControllerEditorPanel> m_ControllerEditor;
|
||||
|
||||
entt::entity m_Selected{entt::null};
|
||||
|
||||
@@ -117,6 +121,7 @@ private:
|
||||
bool m_ShowViewport = true;
|
||||
bool m_ShowStats = true;
|
||||
bool m_ShowAssetBrowser = true;
|
||||
bool m_ShowMaterialEditor = true;
|
||||
|
||||
// 默认布局只建一次(imgui.ini 已有布局则跳过)
|
||||
bool m_LayoutBuilt = false;
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
#include <Engine/Renderer/EditorCamera.h>
|
||||
#include <Engine/Renderer/SceneTypes.h> // kDisplayViewport / kDisplayDirect
|
||||
|
||||
#include <entt/entt.hpp>
|
||||
|
||||
class MaterialEditorPanel; // 面板间联动通道(Details 双击槽位 / Content
|
||||
// Browser 双击 .material → 打开材质编辑器)
|
||||
class ControllerEditorPanel; // Details Controller 槽 Edit → 打开控制器编辑器
|
||||
|
||||
// 沙盒特有状态(面板可选读取,空指针 = 该面板区块不显示)
|
||||
struct EditorContext {
|
||||
// 引擎门面
|
||||
@@ -25,6 +31,12 @@ struct EditorContext {
|
||||
// 编辑器相机(Scene view 专用,归 Renderer 持有——与场景相机实体
|
||||
// 无关;面板经此做 F 焦点/Gizmo 视图/对齐跳转)
|
||||
Engine::EditorCamera *editorCamera = nullptr;
|
||||
// 材质编辑器面板(Editor 持有;指派/编辑分离的联动入口)
|
||||
MaterialEditorPanel *materialEditor = nullptr;
|
||||
// 控制器编辑器面板(Editor 持有;指派/编辑分离的联动入口)
|
||||
ControllerEditorPanel *controllerEditor = nullptr;
|
||||
// 当前选中实体(Controller 编辑器 Preview 的驱动目标)
|
||||
entt::entity *selected = nullptr;
|
||||
|
||||
// PIE 运行态(Play-In-Editor):Editor 持有,只读指针。
|
||||
// 面板门控用——运行态禁 Gizmo/编辑器热键,渲染源已是 primary 相机
|
||||
|
||||
+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 进来的
|
||||
// 外部动画在此核对骨骼匹配结果——轨道数明显偏少 = 骨骼
|
||||
// 命名不同源被跳过)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// MaterialAssetUtil.h:材质资产编辑共用工具(Inspector / Material Editor
|
||||
// 面板共享)。
|
||||
//
|
||||
// 单一事实来源(原 InspectorPanel.cpp 匿名 ns 实现上提):
|
||||
// - .material 文件枚举缓存(inline 变量跨 TU 唯一——面板各自创建
|
||||
// Override 后缓存失效即时可见)
|
||||
// - Create Override:内建材质快照 → assets/materials/*.material →
|
||||
// GUID 登记 → 同步加载 → 挂实体覆盖表
|
||||
//
|
||||
|
||||
#ifndef HELLOVULKAN_MATERIALASSETUTIL_H
|
||||
#define HELLOVULKAN_MATERIALASSETUTIL_H
|
||||
|
||||
#include <Engine/Assets/AssetManager.h>
|
||||
#include <Engine/Core/Log.h>
|
||||
#include <Engine/ECS/Components.h>
|
||||
#include <Engine/Renderer/MaterialAsset.h>
|
||||
#include <Engine/Renderer/Model.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace EditorMaterialUtil {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// .material 文件枚举(会话级缓存 + 脏标)。inline 变量 = 所有 TU
|
||||
// 共享同一份(Create Override 新建文件后置脏重扫)
|
||||
inline std::vector<std::string> s_MaterialFiles;
|
||||
inline bool s_MaterialFilesDirty = true;
|
||||
|
||||
inline void InvalidateMaterialFileList() { s_MaterialFilesDirty = true; }
|
||||
|
||||
// 递归收集 assets/ 下纹理文件(材质编辑器纹理槽选择器;纹理文件
|
||||
// 不被编辑器创建,无需脏标)。会话级缓存
|
||||
inline const std::vector<std::string>& AssetTextureFiles()
|
||||
{
|
||||
static std::vector<std::string> s_Files;
|
||||
static bool s_Cached = false;
|
||||
if (!s_Cached)
|
||||
{
|
||||
s_Cached = true;
|
||||
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 == ".jpg" || ext == ".jpeg" || ext == ".png" ||
|
||||
ext == ".tga" || ext == ".bmp" || ext == ".hdr")
|
||||
{
|
||||
s_Files.push_back(entry.path().generic_string());
|
||||
}
|
||||
}
|
||||
std::sort(s_Files.begin(), s_Files.end());
|
||||
}
|
||||
}
|
||||
return s_Files;
|
||||
}
|
||||
|
||||
inline const std::vector<std::string>& AssetMaterialFiles()
|
||||
{
|
||||
if (s_MaterialFilesDirty)
|
||||
{
|
||||
s_MaterialFilesDirty = false;
|
||||
s_MaterialFiles.clear();
|
||||
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 == ".material")
|
||||
{
|
||||
s_MaterialFiles.push_back(
|
||||
entry.path().generic_string());
|
||||
}
|
||||
}
|
||||
std::sort(s_MaterialFiles.begin(), s_MaterialFiles.end());
|
||||
}
|
||||
}
|
||||
return s_MaterialFiles;
|
||||
}
|
||||
|
||||
// Create Override:内建材质快照 → assets/materials/<model>_<mesh>.material
|
||||
// → GUID 登记 → 同步加载 → 挂实体覆盖表。失败返回空串(不挂槽)。
|
||||
// 数据段 = 内建材质只读快照(纹理路径 + 因子 + 透明语义),此后
|
||||
// 该资产独立演化,内建材质不受影响
|
||||
inline std::string CreateMaterialOverride(
|
||||
const std::string& modelPath,
|
||||
const Engine::MeshMaterialInfo& info,
|
||||
Engine::u32 meshIdx,
|
||||
Engine::ECS::MeshRendererComponent& mr,
|
||||
Engine::Assets::AssetManager& assets)
|
||||
{
|
||||
// 目标文件名:<modelStem>_<meshName|idx>.material;mesh 名非
|
||||
// [A-Za-z0-9] 字符折叠 '_',空名回退下标;重名追加 _2/_3/...
|
||||
std::string stem = fs::path(modelPath).stem().string();
|
||||
std::string leaf = info.name;
|
||||
if (leaf.empty())
|
||||
{
|
||||
leaf = std::to_string(meshIdx);
|
||||
}
|
||||
for (char& c : leaf)
|
||||
{
|
||||
if (!std::isalnum(static_cast<unsigned char>(c)))
|
||||
{
|
||||
c = '_';
|
||||
}
|
||||
}
|
||||
|
||||
const fs::path dir = "assets/materials";
|
||||
std::error_code ec;
|
||||
fs::create_directories(dir, ec);
|
||||
if (ec)
|
||||
{
|
||||
ENGINE_WARN("MaterialUtil: cannot create {}: {}", dir.string(),
|
||||
ec.message());
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string base = stem + "_" + leaf;
|
||||
std::string rel = (dir / (base + ".material")).generic_string();
|
||||
for (int n = 2; fs::exists(rel); ++n)
|
||||
{
|
||||
rel = (dir / (base + "_" + std::to_string(n) + ".material"))
|
||||
.generic_string();
|
||||
}
|
||||
|
||||
Engine::MaterialAssetData data;
|
||||
data.texPaths = info.texPaths;
|
||||
data.params = info.params;
|
||||
data.transparent = info.transparent;
|
||||
if (!Engine::MaterialAsset::Save(rel, data))
|
||||
{
|
||||
ENGINE_WARN("MaterialUtil: failed to save material override '{}'",
|
||||
rel);
|
||||
return {};
|
||||
}
|
||||
|
||||
assets.GetOrCreateGUID(rel);
|
||||
if (!assets.LoadMaterialAsset(rel).Valid())
|
||||
{
|
||||
ENGINE_WARN("MaterialUtil: override '{}' saved but failed to load",
|
||||
rel);
|
||||
return {};
|
||||
}
|
||||
mr.materialOverrides[meshIdx] = rel;
|
||||
InvalidateMaterialFileList(); // 新文件进下拉列表
|
||||
return rel;
|
||||
}
|
||||
|
||||
} // namespace EditorMaterialUtil
|
||||
|
||||
#endif //HELLOVULKAN_MATERIALASSETUTIL_H
|
||||
@@ -0,0 +1,321 @@
|
||||
//
|
||||
// MaterialEditorPanel.cpp:独立材质编辑器实现。
|
||||
//
|
||||
// 编辑语义原样承接自原 Inspector "Edit Material" 内嵌块(行为不变的
|
||||
// 搬家):参数 EditParams 直写 UBO + IsItemDeactivatedAfterEdit 落盘;
|
||||
// 纹理槽改动落盘 + ReloadMaterialAsset 重建(旧指针即刻悬空,本帧
|
||||
// 不再触碰 asset)。
|
||||
//
|
||||
|
||||
#include "MaterialEditorPanel.h"
|
||||
#include "MaterialAssetUtil.h"
|
||||
|
||||
#include <Engine/ECS/Components.h>
|
||||
#include <Engine/Renderer/Model.h>
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
|
||||
namespace {
|
||||
|
||||
// InputText 需要 char 缓冲;提交时写回 std::string(与 Inspector 同款)
|
||||
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;
|
||||
}
|
||||
|
||||
constexpr float kPreviewSize = 128.0f; // 编辑器顶部大球(Inspector 槽位为 48²)
|
||||
} // namespace
|
||||
|
||||
void MaterialEditorPanel::OpenAsset(const std::string& path)
|
||||
{
|
||||
m_AssetMode = true;
|
||||
m_Path = path;
|
||||
m_LoadTried = false;
|
||||
m_Owner = entt::null;
|
||||
m_Open = true;
|
||||
}
|
||||
|
||||
void MaterialEditorPanel::OpenBuiltin(entt::entity owner, Engine::u32 meshIdx,
|
||||
const std::string& displayName)
|
||||
{
|
||||
m_AssetMode = false;
|
||||
m_Owner = owner;
|
||||
m_MeshIdx = meshIdx;
|
||||
m_DisplayName = displayName;
|
||||
m_Path.clear();
|
||||
m_Open = true;
|
||||
}
|
||||
|
||||
void MaterialEditorPanel::Draw(const EditorContext& ctx)
|
||||
{
|
||||
if (!m_Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!ImGui::Begin("Material Editor", &m_Open))
|
||||
{
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_AssetMode)
|
||||
{
|
||||
DrawAssetMode(ctx);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawBuiltinMode(ctx);
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// 顶部预览球(tile 池请求幂等——窗口开着才请求,池按可见项换页)
|
||||
void MaterialEditorPanel::DrawPreview(const EditorContext& ctx,
|
||||
Engine::Material* mat)
|
||||
{
|
||||
Engine::i32 tile = -1;
|
||||
void* atlasTex = nullptr;
|
||||
if (ctx.renderer && mat)
|
||||
{
|
||||
tile = ctx.renderer->RequestMaterialPreview(mat);
|
||||
atlasTex = ctx.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
|
||||
{
|
||||
ImGui::Dummy(ImVec2(kPreviewSize, kPreviewSize));
|
||||
}
|
||||
}
|
||||
|
||||
void MaterialEditorPanel::DrawAssetMode(const EditorContext& ctx)
|
||||
{
|
||||
if (!ctx.assets)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 每帧重解析:ReloadMaterialAsset 换实例 / 未加载自动 Load 一次
|
||||
Engine::MaterialAsset* asset = ctx.assets->FindMaterialAsset(m_Path);
|
||||
if (!asset && !m_LoadTried)
|
||||
{
|
||||
m_LoadTried = true;
|
||||
ctx.assets->LoadMaterialAsset(m_Path);
|
||||
asset = ctx.assets->FindMaterialAsset(m_Path);
|
||||
}
|
||||
if (!asset)
|
||||
{
|
||||
ImGui::TextDisabled("material not loaded: %s", m_Path.c_str());
|
||||
if (ImGui::Button("Retry Load"))
|
||||
{
|
||||
m_LoadTried = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string& path = m_Path;
|
||||
const std::string name =
|
||||
std::filesystem::path(path).filename().generic_string();
|
||||
|
||||
// ---- 头部:预览球 + 资产名/路径 ----
|
||||
DrawPreview(ctx, asset->GetMaterial());
|
||||
ImGui::SameLine();
|
||||
ImGui::BeginGroup();
|
||||
ImGui::TextUnformatted(name.c_str());
|
||||
ImGui::TextDisabled("%s", path.c_str());
|
||||
ImGui::TextDisabled("shared asset: edits apply to all entities "
|
||||
"referencing it");
|
||||
ImGui::EndGroup();
|
||||
ImGui::Separator();
|
||||
|
||||
// ---- 透明队列归属(收集侧 CPU 标志)----
|
||||
bool trans = asset->GetMaterial()->IsTransparent();
|
||||
if (ImGui::Checkbox("Transparent", &trans))
|
||||
{
|
||||
asset->SetTransparent(trans);
|
||||
Engine::MaterialAsset::Save(path, asset->GetData());
|
||||
}
|
||||
|
||||
// ---- 参数(变化直写 UBO 实时预览;松手落盘,拖拽中不产生每帧 IO)----
|
||||
Engine::MaterialParams p = asset->GetData().params;
|
||||
auto EditParam = [&](bool changed)
|
||||
{
|
||||
if (changed)
|
||||
{
|
||||
asset->EditParams(p);
|
||||
}
|
||||
if (ImGui::IsItemDeactivatedAfterEdit())
|
||||
{
|
||||
Engine::MaterialAsset::Save(path, asset->GetData());
|
||||
}
|
||||
};
|
||||
EditParam(
|
||||
ImGui::DragFloat("Metallic", &p.factors.x, 0.01f, 0.0f, 1.0f));
|
||||
EditParam(
|
||||
ImGui::DragFloat("Roughness", &p.factors.y, 0.01f, 0.0f, 1.0f));
|
||||
EditParam(ImGui::ColorEdit4("Base Color", &p.baseColor.x));
|
||||
EditParam(ImGui::DragFloat("IOR", &p.surface.x, 0.01f, 1.0f, 3.0f));
|
||||
EditParam(ImGui::DragFloat("Transmission", &p.surface.y, 0.01f, 0.0f,
|
||||
1.0f));
|
||||
EditParam(ImGui::DragFloat("Emissive Strength", &p.surface.z, 0.05f,
|
||||
0.0f, 100.0f));
|
||||
EditParam(ImGui::DragFloat("Thickness", &p.surface.w, 0.01f, 0.0f,
|
||||
10.0f));
|
||||
|
||||
// ---- 纹理槽(变化 → 落盘 + waitIdle 重建)----
|
||||
ImGui::SeparatorText("Textures");
|
||||
static constexpr const char* kSlotNames[Engine::Material::kSlotCount] = {
|
||||
"Albedo", "Normal", "MetallicRoughness",
|
||||
"Emissive", "Ao", "Roughness"};
|
||||
int pendingSlot = -1;
|
||||
std::string pendingPath;
|
||||
for (Engine::u32 s = 0; s < Engine::Material::kSlotCount; ++s)
|
||||
{
|
||||
std::string tp = asset->GetData().texPaths[s];
|
||||
ImGui::PushID(static_cast<int>(s));
|
||||
InputTextString(kSlotNames[s], tp);
|
||||
if (ImGui::IsItemDeactivatedAfterEdit())
|
||||
{
|
||||
pendingSlot = static_cast<int>(s);
|
||||
pendingPath = tp;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("..."))
|
||||
{
|
||||
ImGui::OpenPopup("tex_pick");
|
||||
}
|
||||
if (ImGui::BeginPopup("tex_pick"))
|
||||
{
|
||||
for (const auto& f : EditorMaterialUtil::AssetTextureFiles())
|
||||
{
|
||||
if (ImGui::Selectable(f.c_str()))
|
||||
{
|
||||
pendingSlot = static_cast<int>(s);
|
||||
pendingPath = f;
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (pendingSlot >= 0)
|
||||
{
|
||||
// 拷出数据段 → 落盘 → 重建资产(旧指针即刻悬空:本帧到此不再
|
||||
// 触碰 asset,下帧 Find 取新实例)。旧材质已在本帧 Request 进
|
||||
// 预览池脏表(新 tile 未画满三槽位时每帧重入队)——先 drop 再
|
||||
// 销毁,否则 Render 段解引用悬空指针
|
||||
Engine::MaterialAssetData d = asset->GetData();
|
||||
d.texPaths[static_cast<Engine::u32>(pendingSlot)] = pendingPath;
|
||||
Engine::MaterialAsset::Save(path, d);
|
||||
if (ctx.renderer)
|
||||
{
|
||||
ctx.renderer->DropMaterialPreview(asset->GetMaterial());
|
||||
}
|
||||
ctx.assets->ReloadMaterialAsset(path);
|
||||
}
|
||||
}
|
||||
|
||||
void MaterialEditorPanel::DrawBuiltinMode(const EditorContext& ctx)
|
||||
{
|
||||
// 每帧重解析:实体可销毁(PIE Stop 换血)/ 模型可卸载
|
||||
if (!ctx.scene || !ctx.assets)
|
||||
{
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
auto& reg = ctx.scene->GetRegistry();
|
||||
auto* mr = reg.valid(m_Owner)
|
||||
? reg.try_get<Engine::ECS::MeshRendererComponent>(m_Owner)
|
||||
: nullptr;
|
||||
const Engine::Model* model =
|
||||
(mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model) : nullptr;
|
||||
const Engine::MeshMaterialInfo* info =
|
||||
(model && m_MeshIdx < model->GetMeshMaterialInfos().size())
|
||||
? &model->GetMeshMaterialInfos()[m_MeshIdx]
|
||||
: nullptr;
|
||||
if (!mr || !model || !info)
|
||||
{
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
|
||||
"built-in material no longer available");
|
||||
ImGui::TextDisabled("(entity destroyed or model unloaded)");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string meshName =
|
||||
(!info->name.empty())
|
||||
? info->name
|
||||
: "Slot " + std::to_string(m_MeshIdx);
|
||||
|
||||
// ---- 头部:预览球 + 名称/来源 ----
|
||||
DrawPreview(ctx, model->GetMeshes()[m_MeshIdx]->GetMaterial());
|
||||
ImGui::SameLine();
|
||||
ImGui::BeginGroup();
|
||||
ImGui::TextUnformatted(meshName.c_str());
|
||||
ImGui::TextDisabled("built-in material of %s",
|
||||
std::filesystem::path(
|
||||
ctx.assets->Models().Path(mr->model))
|
||||
.filename()
|
||||
.generic_string()
|
||||
.c_str());
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.3f, 1.0f),
|
||||
"read-only: built-in materials belong to the model");
|
||||
ImGui::EndGroup();
|
||||
ImGui::Separator();
|
||||
|
||||
// ---- 一键转可编辑:Create Override → 自动切到资产模式 ----
|
||||
if (ImGui::Button("Create Override"))
|
||||
{
|
||||
const std::string rel = EditorMaterialUtil::CreateMaterialOverride(
|
||||
ctx.assets->Models().Path(mr->model), *info, m_MeshIdx, *mr,
|
||||
*ctx.assets);
|
||||
if (!rel.empty())
|
||||
{
|
||||
OpenAsset(rel); // 数据已快照进资产,无缝进入编辑
|
||||
}
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
// ---- 只读展示(与资产模式同款控件,锁编辑看值)----
|
||||
ImGui::BeginDisabled(true);
|
||||
bool trans = info->transparent;
|
||||
ImGui::Checkbox("Transparent", &trans);
|
||||
Engine::MaterialParams p = info->params;
|
||||
ImGui::DragFloat("Metallic", &p.factors.x, 0.01f, 0.0f, 1.0f);
|
||||
ImGui::DragFloat("Roughness", &p.factors.y, 0.01f, 0.0f, 1.0f);
|
||||
ImGui::ColorEdit4("Base Color", &p.baseColor.x);
|
||||
ImGui::DragFloat("IOR", &p.surface.x, 0.01f, 1.0f, 3.0f);
|
||||
ImGui::DragFloat("Transmission", &p.surface.y, 0.01f, 0.0f, 1.0f);
|
||||
ImGui::DragFloat("Emissive Strength", &p.surface.z, 0.05f, 0.0f, 100.0f);
|
||||
ImGui::DragFloat("Thickness", &p.surface.w, 0.01f, 0.0f, 10.0f);
|
||||
ImGui::SeparatorText("Textures");
|
||||
static constexpr const char* kSlotNames[Engine::Material::kSlotCount] = {
|
||||
"Albedo", "Normal", "MetallicRoughness",
|
||||
"Emissive", "Ao", "Roughness"};
|
||||
for (Engine::u32 s = 0; s < Engine::Material::kSlotCount; ++s)
|
||||
{
|
||||
std::string tp = info->texPaths[s];
|
||||
ImGui::PushID(static_cast<int>(s));
|
||||
InputTextString(kSlotNames[s], tp);
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// MaterialEditorPanel:独立材质编辑器窗口(UE Material Editor 语义)。
|
||||
//
|
||||
// 与 Details 面板职责分离:Details 材质槽行只做"指派"(换绑/还原/拖拽
|
||||
// 覆盖资产),双击槽位预览球或 Content Browser 的 .material 条目 →
|
||||
// 本面板打开做"编辑"(参数全平铺 + 纹理槽)。
|
||||
//
|
||||
// 两种模式:
|
||||
// - 资产模式(可编辑):编辑 .material 资产。参数走
|
||||
// EditParams 直写 UBO(松手落盘),纹理槽变化走 ReloadMaterialAsset
|
||||
// (waitIdle 全停重建,低频路径)——与原 Inspector 内嵌编辑完全
|
||||
// 同一套语义,此处原样搬运
|
||||
// - 内建模式(只读):Model 内建材质无 CPU 参数副本(直写 GPU UBO),
|
||||
// 只读展示 MeshMaterialInfo 快照 + 站内一键 Create Override(生成
|
||||
// 资产后自动切换到资产模式编辑)
|
||||
//
|
||||
// 打开目标每帧重解析(实体可销毁 / 模型可卸载 / 资产可 Reload 换实例),
|
||||
// 面板不跨帧持有任何指针。
|
||||
//
|
||||
|
||||
#ifndef HELLOVULKAN_MATERIALEDITORPANEL_H
|
||||
#define HELLOVULKAN_MATERIALEDITORPANEL_H
|
||||
|
||||
#include "EditorContext.h"
|
||||
|
||||
#include <Engine/Core/Types.h>
|
||||
|
||||
#include <entt/entt.hpp>
|
||||
#include <string>
|
||||
|
||||
// 材质资产拖拽 payload(Content Browser 拖 .material → Details 槽位)
|
||||
inline constexpr const char* kMaterialDragPayload = "MAT_PATH";
|
||||
|
||||
class MaterialEditorPanel {
|
||||
public:
|
||||
// 打开 .material 资产(可编辑模式)
|
||||
void OpenAsset(const std::string& path);
|
||||
// 打开内建材质(只读模式):owner 实体 + mesh 槽位每帧重解析
|
||||
void OpenBuiltin(entt::entity owner, Engine::u32 meshIdx,
|
||||
const std::string& displayName);
|
||||
|
||||
void Draw(const EditorContext& ctx);
|
||||
|
||||
private:
|
||||
void DrawPreview(const EditorContext& ctx, Engine::Material* mat);
|
||||
void DrawAssetMode(const EditorContext& ctx);
|
||||
void DrawBuiltinMode(const EditorContext& ctx);
|
||||
|
||||
bool m_Open = false;
|
||||
bool m_AssetMode = false;
|
||||
// 资产模式目标
|
||||
std::string m_Path;
|
||||
bool m_LoadTried = false; // 未加载时自动 Load 一次(防每帧重试)
|
||||
// 内建模式目标
|
||||
entt::entity m_Owner{entt::null};
|
||||
Engine::u32 m_MeshIdx = 0;
|
||||
std::string m_DisplayName;
|
||||
};
|
||||
|
||||
#endif //HELLOVULKAN_MATERIALEDITORPANEL_H
|
||||
@@ -143,6 +143,83 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
|
||||
else size.y = avail.x / aspect;
|
||||
ImGui::Image((ImTextureID) (intptr_t) sceneTex, size);
|
||||
|
||||
// ---- 骨骼关节 overlay(Endfield-Poser 范式):3D 骨架线
|
||||
// 给结构,屏幕圆点给可读性。世界→屏幕与渲染同相机,
|
||||
// Vulkan NDC y 向下与 ImGui 屏幕坐标同构 → 线性映射
|
||||
// image 矩形即可。hover 关节 = 白圈高亮 + 骨骼名
|
||||
{
|
||||
const auto &frameData = renderer.GetFrameSceneData();
|
||||
if (frameData.showBoneWires && !frameData.pieActive &&
|
||||
!frameData.boneJoints.empty() &&
|
||||
ctx.editorCamera != nullptr) {
|
||||
const ImVec2 rmin = ImGui::GetItemRectMin();
|
||||
const ImVec2 rmax = ImGui::GetItemRectMax();
|
||||
const glm::mat4 vp =
|
||||
ctx.editorCamera->GetController().GetProjection(aspect) *
|
||||
ctx.editorCamera->GetView();
|
||||
ImDrawList *dl = ImGui::GetWindowDrawList();
|
||||
dl->PushClipRect(rmin, rmax, true);
|
||||
const f32 r = frameData.boneJointPx;
|
||||
const ImGuiIO &io = ImGui::GetIO();
|
||||
const ImVec2 mouse = io.MousePos;
|
||||
int hovered = -1;
|
||||
float hoveredD = 0.0f;
|
||||
for (size_t ji = 0; ji < frameData.boneJoints.size(); ++ji) {
|
||||
const auto &jm = frameData.boneJoints[ji];
|
||||
const glm::vec4 c = vp * glm::vec4(jm.pos, 1.0f);
|
||||
if (c.w <= 0.0f) continue; // 相机身后
|
||||
const ImVec2 p(
|
||||
rmin.x + (c.x / c.w * 0.5f + 0.5f) * size.x,
|
||||
rmin.y + (c.y / c.w * 0.5f + 0.5f) * size.y);
|
||||
// 骨骼 = 蓝(Endfield humanoid 色),普通节点 = 青
|
||||
const ImU32 fill = jm.isBone
|
||||
? IM_COL32(70, 160, 255, 230)
|
||||
: IM_COL32(70, 190, 190, 190);
|
||||
dl->AddCircleFilled(p, r, fill);
|
||||
dl->AddCircle(p, r + 1.5f,
|
||||
IM_COL32(255, 255, 255, 140), 0, 1.2f);
|
||||
// hover 命中(半径 +3px 容差,取最近)
|
||||
const float dx = p.x - mouse.x, dy = p.y - mouse.y;
|
||||
const float d = dx * dx + dy * dy;
|
||||
const float hit = (r + 3.0f) * (r + 3.0f);
|
||||
if (d <= hit && (hovered < 0 || d < hoveredD)) {
|
||||
hovered = static_cast<int>(ji);
|
||||
hoveredD = d;
|
||||
}
|
||||
}
|
||||
// hover 高亮圈 + 骨骼名 tooltip(名字查选中实体模型
|
||||
// 节点表;查不到 = 显示节点下标)
|
||||
if (hovered >= 0 && ImGui::IsItemHovered()) {
|
||||
const auto &jm = frameData.boneJoints[hovered];
|
||||
const glm::vec4 c = vp * glm::vec4(jm.pos, 1.0f);
|
||||
const ImVec2 p(
|
||||
rmin.x + (c.x / c.w * 0.5f + 0.5f) * size.x,
|
||||
rmin.y + (c.y / c.w * 0.5f + 0.5f) * size.y);
|
||||
dl->AddCircle(p, r + 3.5f,
|
||||
IM_COL32(255, 255, 255, 255), 0, 2.0f);
|
||||
const char *name = nullptr;
|
||||
if (selected != entt::null) {
|
||||
// 本段在 reg 声明之前,就地取 registry
|
||||
const auto *mrSel =
|
||||
ctx.scene->GetRegistry().try_get<
|
||||
Engine::ECS::MeshRendererComponent>(
|
||||
selected);
|
||||
if (mrSel && mrSel->model.Valid()) {
|
||||
const Model *mdl =
|
||||
ctx.assets->GetModel(mrSel->model);
|
||||
if (mdl && jm.nodeIdx < mdl->GetNodes().size())
|
||||
name = mdl->GetNodes()[jm.nodeIdx]
|
||||
.name.c_str();
|
||||
}
|
||||
}
|
||||
ImGui::SetTooltip(
|
||||
"%s%s", name ? name : "(node)",
|
||||
jm.isBone ? "" : " [non-bone]");
|
||||
}
|
||||
dl->PopClipRect();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 工具热键(UE 语义):W/E/R 切 Gizmo 模式、T 切
|
||||
// local/world、F 焦点选中实体。仅 Viewport 持焦时生效——
|
||||
// 输入框打字(WantTextInput)由 BlockingKeyboard 拦游戏侧,
|
||||
@@ -163,11 +240,12 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_E)) gizmo.op = ImGuizmo::ROTATE;
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_R)) gizmo.op = ImGuizmo::SCALE;
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_T)) gizmo.localSpace = !gizmo.localSpace;
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_F) && tr != nullptr) {
|
||||
// F = 焦点选中实体(frame-selection):编辑器相机
|
||||
// 环绕 pivot 设为选中实体位置,沿视线平移对准(环
|
||||
// 绕距离沿用当前值,滚轮可调)。世界位姿(层级
|
||||
// 实体 = 父链组合结果)
|
||||
const bool fCombo = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift;
|
||||
// F = 焦点选中实体(frame-selection):编辑器相机
|
||||
// 环绕 pivot 设为选中实体位置,沿视线平移对准(环
|
||||
// 绕距离沿用当前值,滚轮可调)。世界位姿(层级
|
||||
// 实体 = 父链组合结果)。裸 F 才触发(组合键排他)
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_F) && !fCombo && tr != nullptr) {
|
||||
ctx.editorCamera->FocusOn(tr->GetWorldPosition());
|
||||
}
|
||||
}
|
||||
@@ -179,14 +257,13 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
|
||||
if (tr != nullptr && !playing) {
|
||||
// 相机矩阵:跟随渲染源(编辑器形态 = EditorCamera,游戏
|
||||
// 形态 = primary 相机实体;与 SceneRenderer::BuildFrame
|
||||
// 同一分派),参数与渲染侧一致(aspect = 渲染分辨率、
|
||||
// near/far 0.1/100)
|
||||
// 同一分派),投影参数由相机自身承载(与渲染侧一致)
|
||||
ECS::TransformComponent *camT = nullptr;
|
||||
if (Engine::Camera *cam = ViewCamera(ctx, camT);
|
||||
cam != nullptr && camT != nullptr) {
|
||||
const glm::mat4 view = cam->GetView(*camT);
|
||||
const glm::mat4 projGL = ProjVulkanToGL(
|
||||
cam->GetProjection(aspect, 0.1f, 100.0f));
|
||||
cam->GetProjection(aspect));
|
||||
|
||||
ImGuizmo::SetDrawlist(ImGui::GetWindowDrawList());
|
||||
// 杆画在 Image 区域内(等比适配后的 item rect)
|
||||
@@ -285,7 +362,7 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
|
||||
// Gizmo 状态提示(左上角 drawlist 叠加,原因同下:不占布局行)
|
||||
// PIE 运行态 = 黄色 PLAYING/PAUSED 提示替代编辑状态
|
||||
{
|
||||
char tip[64];
|
||||
char tip[128];
|
||||
if (playing) {
|
||||
const bool paused = ctx.pausedState && *ctx.pausedState;
|
||||
std::snprintf(tip, sizeof(tip),
|
||||
@@ -293,7 +370,8 @@ std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
|
||||
: "PLAYING - Ctrl+P to stop");
|
||||
} else {
|
||||
std::snprintf(tip, sizeof(tip),
|
||||
"Space: %s [T] Snap: Ctrl",
|
||||
"Space: %s [T] Snap: Ctrl F: Focus "
|
||||
"Ctrl+Shift+F: To Selection",
|
||||
gizmo.localSpace ? "Local" : "World");
|
||||
}
|
||||
const ImVec2 imgMin = ImGui::GetItemRectMin();
|
||||
|
||||
+25
-2
@@ -24,10 +24,14 @@
|
||||
#include <Engine/Renderer/Renderer.h>
|
||||
#include <Engine/Renderer/EditorCamera.h>
|
||||
|
||||
#include <Engine/Core/Input.h>
|
||||
|
||||
#include <Engine/Core/Input.h>
|
||||
|
||||
// editor/game 编译期分离(HELLOVULKAN_EDITOR CMake option):editor 形态
|
||||
// 挂接 docking Editor;game 形态 = Direct 直渲 + primary 相机,零编辑器代码
|
||||
#ifdef HELLO_EDITOR
|
||||
#include "Editor.h"
|
||||
#include "EditorContext.h"
|
||||
#endif
|
||||
|
||||
using namespace Engine;
|
||||
|
||||
@@ -51,6 +55,7 @@ namespace
|
||||
void OnInit() override
|
||||
{
|
||||
Renderer& renderer = GetRenderer();
|
||||
#ifdef HELLO_EDITOR
|
||||
renderer.SetDisplayMode(kDisplayViewport);
|
||||
|
||||
|
||||
@@ -59,10 +64,15 @@ namespace
|
||||
|
||||
// Editor 注入:引擎门面 + 沙盒能力回调(环境异步切换等)
|
||||
InitEditor();
|
||||
#else
|
||||
// game 形态:全窗口 Direct 直渲(Renderer 默认即 kDisplayDirect),
|
||||
// 相机由场景 primary camera 提供,无 UI
|
||||
#endif
|
||||
}
|
||||
|
||||
void OnUpdate(const FrameTime& frameTime) override
|
||||
{
|
||||
#ifdef HELLO_EDITOR
|
||||
// 编辑器形态:驱动编辑器相机(RMB 飞行 / MMB 焦点环绕 /
|
||||
// 滚轮推拉)。暂停(timeScale=0)时仍可飞行——unscaled dt。
|
||||
// PIE 内嵌播放:渲染源已切 primary 相机,编辑器相机停驱
|
||||
@@ -73,31 +83,42 @@ namespace
|
||||
{
|
||||
GetRenderer().GetEditorCamera().Update(frameTime.UnscaledDt());
|
||||
}
|
||||
#else
|
||||
(void)frameTime; // game 形态:本沙盒无游戏逻辑
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- UI(全部委托 docking Editor;本文件零 ImGui 控件)----
|
||||
|
||||
void OnImGuiRender() override
|
||||
{
|
||||
#ifdef HELLO_EDITOR
|
||||
m_Editor->Draw();
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- 事件 ----
|
||||
|
||||
void OnWindowClose(const Event& event) override
|
||||
{
|
||||
#ifdef HELLO_EDITOR
|
||||
// 退出确认走 Editor 的模态框(ImGui 必须可见才有 UI)
|
||||
SetImGuiEnabled(true);
|
||||
m_Editor->RequestQuit();
|
||||
#else
|
||||
(void)event;
|
||||
#endif
|
||||
}
|
||||
|
||||
void OnKeyPress(const Event& event) override
|
||||
{
|
||||
// 键码统一走 Input::Key(App 层零 GLFW 依赖)
|
||||
#ifdef HELLO_EDITOR
|
||||
if (event.key == static_cast<int>(Input::Key::GraveAccent))
|
||||
{
|
||||
SetImGuiEnabled(!GetImGuiEnabled());
|
||||
}
|
||||
#endif
|
||||
if (event.key == static_cast<int>(Input::Key::F1))
|
||||
{
|
||||
STATS.PrintComparison();
|
||||
@@ -111,6 +132,7 @@ namespace
|
||||
}
|
||||
|
||||
private:
|
||||
#ifdef HELLO_EDITOR
|
||||
// Editor 注入:门面指针 + 反向能力回调(面板 → 沙盒)
|
||||
void InitEditor()
|
||||
{
|
||||
@@ -136,6 +158,7 @@ namespace
|
||||
|
||||
private:
|
||||
std::unique_ptr<Editor> m_Editor = std::make_unique<Editor>();
|
||||
#endif
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user