Add animation controller system, material assets and skeleton visualization

- .animctrl state machine asset (params/transitions/blend trees, 1D+2D
  directional blending) with Unity Animator-style editor panel: state
  list, transitions, params, directional pad preview driving live
  CrossFade on the selected entity
- Split animation evaluation out of SceneRenderer into
  AnimationSystem/AnimationEvaluator; skin palettes with rest-pose
  fallback and blend-weight epsilon filtering
- MaterialAsset (.material) with reload-safe preview tile pool
  (DropMaterial clears dirty refs before instance destruction)
- EditorGizmoPass skeleton wires + viewport joint overlay; camera
  projection rework (baked Vulkan Y-flip, ortho support)
- Editor/game build split via HELLOVULKAN_EDITOR option
This commit is contained in:
2026-10-07 01:50:04 +08:00
parent a941207ae3
commit cc837bc605
71 changed files with 8198 additions and 548 deletions
+7 -1
View File
@@ -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)
+55 -8
View File
@@ -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;
+786
View File
@@ -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();
}
+58
View File
@@ -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
+12
View File
@@ -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);
}
+5
View File
@@ -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;
+12
View File
@@ -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
View File
@@ -6,22 +6,38 @@
//
#include "InspectorPanel.h"
#include "MaterialAssetUtil.h" // .material 枚举 + Create Override(与材质编辑器共享)
#include "MaterialEditorPanel.h" // 指派/编辑分离:双击槽位开独立面板
#include <functional> // ComponentHeader 组件专属菜单项
#include "Engine/ECS/Components.h"
#include "Engine/Core/Types.h"
#include "Engine/Animation/AnimationController.h" // Controller 模式 UI(参数/状态枚举)
#include "Engine/Renderer/Model.h"
#include "Engine/Renderer/MaterialAsset.h" // 材质覆盖槽(.material 资产)
#include "Engine/Physics/PhysicsWorld.h" // Apply Impulse 调试(GetPhysics 门面)
#include "Engine/Scripting/Script.h" // 脚本下拉(ScriptRegistry::Names)
#include "Engine/Core/Log.h" // Create Override 失败告警
#include <imgui.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include "ControllerEditorPanel.h"
#include <unordered_set>
#include <vector>
#include <type_traits>
namespace
{
// 材质槽循环索引用(u32 定义于 namespace Engine,App 层显式引入)
using Engine::u32;
// 递归收集 assets/ 下模型容器文件(模型槽与动画 attach 槽共用;
// 无法从扩展名区分"模型 fbx"与"纯动画 fbx",一并列出)。
// 会话级缓存:目录条目几十量级,避免每帧递归扫描
@@ -58,6 +74,42 @@ namespace
return s_Files;
}
// 递归收集 assets/ 下动画控制器资产(.animctrl,Controller 槽用;
// 会话级缓存,Create 新文件后经 InvalidateControllerFiles 失效)
static std::vector<std::string> s_CtrlFiles;
static bool s_CtrlFilesCached = false;
void InvalidateControllerFiles() { s_CtrlFilesCached = false; }
const std::vector<std::string>& AssetControllerFiles()
{
if (!s_CtrlFilesCached)
{
s_CtrlFilesCached = true;
s_CtrlFiles.clear();
namespace fs = std::filesystem;
if (fs::exists("assets"))
{
for (const auto& entry :
fs::recursive_directory_iterator("assets"))
{
if (!entry.is_regular_file())
{
continue;
}
std::string ext = entry.path().extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c)
{ return std::tolower(c); });
if (ext == ".animctrl")
{
s_CtrlFiles.push_back(entry.path().generic_string());
}
}
std::sort(s_CtrlFiles.begin(), s_CtrlFiles.end());
}
}
return s_CtrlFiles;
}
// 递归收集 assets/ 下环境贴图(.hdr,Skybox 资产槽用;会话级缓存同上)
const std::vector<std::string>& AssetEnvFiles()
{
@@ -104,6 +156,217 @@ namespace
return false;
}
// MeshRenderer 材质槽列表(UE Material Slots 语义):内建材质只读,
// 覆盖槽 = .material 资产引用,可换绑/还原。参数/纹理"编辑"在独立
// Material Editor 面板(双击槽位球打开)——指派与编辑分离,
// 本行只做指派(UE Details 材质槽交互)
void DrawMaterialSlots(const EditorContext& ctx,
const Engine::Model& model,
Engine::ECS::MeshRendererComponent& mr,
entt::entity owner,
Engine::Assets::AssetManager& assets,
Engine::Renderer& renderer)
{
const auto& meshes = model.GetMeshes();
const auto& infos = model.GetMeshMaterialInfos();
ImGui::SeparatorText("Material Slots");
constexpr float kPreviewSize = 48.0f;
for (u32 i = 0; i < meshes.size(); ++i)
{
ImGui::PushID(static_cast<int>(i));
const std::string meshName =
(i < infos.size() && !infos[i].name.empty())
? infos[i].name
: "Slot " + std::to_string(i);
// ---- 材质球预览(行左列 48²,tile = 池分配)----
// 材质来源与渲染收集期寻址一致:覆盖资产 > 内建。
// UE FAssetThumbnailPool 语义:只对滚进视口的行发幂等请求
//(IsRectVisible 探测,132 槽模型只处理可见行),池按
// serial+参数版本判缓存——渲染一次跨帧持久,EditParams bump
// 版本后下帧重渲该 tile;-1 = 首渲未完成/池满(占位排队)
auto it = mr.materialOverrides.find(i);
Engine::Material* slotMat = nullptr;
const char* slotSource = "built-in";
if (it != mr.materialOverrides.end())
{
if (Engine::MaterialAsset* asset =
assets.FindMaterialAsset(it->second))
{
slotMat = asset->GetMaterial();
slotSource = it->second.c_str();
}
}
if (!slotMat)
{
slotMat = meshes[i]->GetMaterial();
}
// 可见性探测先行:请求只来自可见行(池 LRU 换页的前提)
const bool rowVisible =
ImGui::IsRectVisible(ImVec2(kPreviewSize, kPreviewSize));
Engine::i32 tile = -1;
if (slotMat && rowVisible)
{
tile = renderer.RequestMaterialPreview(slotMat);
}
// ---- 球 + 拖放目标(Content Browser 拖 .material 指派)----
void* atlasTex = renderer.GetMaterialPreviewTexture();
if (atlasTex && tile >= 0)
{
const float uvStep =
1.0f / float(Engine::Passes::kPreviewTilesPerSide);
const float u0 =
float(tile % Engine::Passes::kPreviewTilesPerSide) * uvStep;
const float v0 =
float(tile / Engine::Passes::kPreviewTilesPerSide) * uvStep;
ImGui::Image(atlasTex, ImVec2(kPreviewSize, kPreviewSize),
ImVec2(u0, v0),
ImVec2(u0 + uvStep, v0 + uvStep));
}
else
{
// atlas 未就绪(首帧前)/ tile 首渲排队中:占位保持行高
ImGui::Dummy(ImVec2(kPreviewSize, kPreviewSize));
}
if (ImGui::IsItemHovered())
{
ImGui::SetTooltip("%s\nSource: %s\nDouble-click to edit",
meshName.c_str(), slotSource);
}
// ---- 双击球 = 打开 Material Editor(指派/编辑分离)----
if (ctx.materialEditor && ImGui::IsItemHovered() &&
ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))
{
if (it != mr.materialOverrides.end())
{
ctx.materialEditor->OpenAsset(it->second);
}
else
{
ctx.materialEditor->OpenBuiltin(owner, i, meshName);
}
}
if (ImGui::BeginDragDropTarget())
{
if (const ImGuiPayload* pl = ImGui::AcceptDragDropPayload(
kMaterialDragPayload))
{
const std::string dropped(
static_cast<const char*>(pl->Data));
if (assets.LoadMaterialAsset(dropped).Valid())
{
mr.materialOverrides[i] = dropped;
}
it = mr.materialOverrides.find(i);
}
ImGui::EndDragDropTarget();
}
ImGui::SameLine();
ImGui::BeginGroup();
ImGui::TextUnformatted(meshName.c_str());
if (it == mr.materialOverrides.end())
{
// 内建只读:一键生成覆盖资产(成功即自动进编辑器)
if (i < infos.size() &&
ImGui::SmallButton("Create Override"))
{
const std::string rel =
EditorMaterialUtil::CreateMaterialOverride(
assets.Models().Path(mr.model), infos[i], i, mr,
assets);
if (!rel.empty() && ctx.materialEditor)
{
ctx.materialEditor->OpenAsset(rel);
}
}
}
else
{
const std::string& cur = it->second;
// 资产文件下拉(当前路径不在枚举里也插入显示,防错位)
const auto& files =
EditorMaterialUtil::AssetMaterialFiles();
std::vector<const char*> opts;
opts.push_back("<none>");
bool curListed = false;
for (const auto& f : files)
{
opts.push_back(f.c_str());
curListed |= (f == cur);
}
if (!curListed)
{
opts.insert(opts.begin() + 1, cur.c_str());
}
int sel = -1;
for (int k = 0; k < static_cast<int>(opts.size()); ++k)
{
if (std::strcmp(opts[static_cast<size_t>(k)],
cur.c_str()) == 0)
{
sel = k;
}
}
const float revertW = ImGui::CalcTextSize("Revert").x +
ImGui::GetStyle().FramePadding.x * 2.0f;
ImGui::SetNextItemWidth(
ImGui::GetContentRegionAvail().x - revertW -
ImGui::GetStyle().ItemSpacing.x);
if (ImGui::Combo("##mat", &sel, opts.data(),
static_cast<int>(opts.size())))
{
if (sel <= 0)
{
mr.materialOverrides.erase(i);
}
else
{
const char* chosen = opts[static_cast<size_t>(sel)];
if (assets.LoadMaterialAsset(chosen).Valid())
{
mr.materialOverrides[i] = chosen;
}
}
it = mr.materialOverrides.find(i);
}
ImGui::SameLine();
if (ImGui::SmallButton("Revert"))
{
mr.materialOverrides.erase(i);
it = mr.materialOverrides.end();
}
// 覆盖资产未加载(罕见):就地补救(编辑器打开会自动 Load)
if (it != mr.materialOverrides.end() &&
assets.FindMaterialAsset(it->second) == nullptr)
{
ImGui::TextDisabled(" override not loaded");
if (ImGui::SmallButton("Retry Load"))
{
assets.LoadMaterialAsset(it->second);
}
}
}
ImGui::EndGroup();
ImGui::PopID();
}
ImGui::TextDisabled(
"double-click a sphere to edit; edits on shared .material "
"apply to all entities referencing it");
}
// UE 风变换行:label + 三轴色 DragFloat(轴色对齐 Gizmo:X 红 /
// Y 绿 / Z 蓝)。拖拽粗调;Ctrl+Click 进输入框键入精确值。
// 任一轴变化返回 true。fmt = 显示格式(旋转行传 "%.1f" 对齐
@@ -146,11 +409,13 @@ namespace
return changed;
}
// 组件区块头(Unity 语义):折叠标题 + 右缘"启用"勾选与"删除"按钮。
// active 传 nullptr = 无启停(结构性组件);removable = 显示删除钮,
// 点击时置 *removeFlag,调用方在区块末尾统一移除(避免边画边删)
// 组件区块头(Unity 语义):折叠标题 + 右缘"启用"勾选与"..."菜单。
// active 传 nullptr = 无启停(结构性组件);removable = 显示"...",
// 菜单内含 Remove;extraMenu(可空)= 组件专属菜单项(如 Camera 的
// 视角联动),画在 Remove 之前
bool ComponentHeader(const char* label, bool* active, bool removable,
bool* removeFlag)
bool* removeFlag,
const std::function<void()>& extraMenu = {})
{
ImGui::PushID(label);
bool open;
@@ -163,11 +428,22 @@ namespace
float x = ImGui::GetWindowWidth() - 32.0f;
if (removable)
{
// "..." 菜单(UE Details 组件头语义):移除等组件操作收进
// 下拉,头部不再放裸删除钮(防误触)
ImGui::SameLine(x);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1.0f);
if (ImGui::Button("-", ImVec2(24.0f, rowH - 4.0f)))
if (ImGui::Button("...", ImVec2(24.0f, rowH - 4.0f)))
{
*removeFlag = true;
ImGui::OpenPopup("comp_menu");
}
if (ImGui::BeginPopup("comp_menu"))
{
if (extraMenu) extraMenu();
if (ImGui::MenuItem("Remove Component"))
{
*removeFlag = true;
}
ImGui::EndPopup();
}
x -= 30.0f;
}
@@ -366,6 +642,9 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
mr->model = (sel <= 0)
? Engine::ModelRef{}
: ctx.assets->LoadModel(s_ModelFiles[static_cast<size_t>(sel) - 1]);
// 模型换绑后 mesh 下标语义失效:覆盖表显式清空
//(残留会错挂到新模型的槽位上)
mr->materialOverrides.clear();
}
ImGui::Spacing();
}
@@ -404,6 +683,16 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
{
ImGui::TextDisabled("no model assigned");
}
// ---- 材质槽(UE Material Slots:内建只读 + .material 覆盖)----
if (mr->model.Valid())
{
if (const Engine::Model* model = ctx.assets->GetModel(mr->model);
model != nullptr && !model->GetMeshes().empty())
{
DrawMaterialSlots(ctx, *model, *mr, selected, *ctx.assets,
*ctx.renderer);
}
}
if (!mr->active)
{
ImGui::EndDisabled();
@@ -529,7 +818,34 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
if (auto* cam = reg.try_get<Engine::ECS::CameraComponent>(selected))
{
bool removeCam = false;
if (ComponentHeader("Camera", &cam->active, true, &removeCam))
// "..." 菜单组件专属项(双向同步,均只取位置+朝向,缩放不动):
// ① 编辑器相机 → 飞到本实体机位(编辑器视角立即从该相机处看)
// ② 本实体 → 摆到编辑器当前机位(摆实体相机,省手动对位)
// SetWorldPose 沿父链反解 local(层级实体不破坏父关系)
auto camMenu = [&]()
{
if (ImGui::MenuItem("Move Editor Camera Here") && ctx.editorCamera)
{
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
selected))
{
ctx.editorCamera->AlignTo(*tr);
}
}
if (ImGui::MenuItem("Move to Editor Camera Position") &&
ctx.editorCamera)
{
const auto& ed = ctx.editorCamera->GetTransform();
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
selected))
{
ctx.scene->SetWorldPose(selected, ed.GetWorldPosition(),
ed.GetWorldRotationQuat(),
tr->GetWorldScale());
}
}
};
if (ComponentHeader("Camera", &cam->active, true, &removeCam, camMenu))
{
if (!cam->active)
{
@@ -549,17 +865,46 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
});
}
// 对齐跳转:把本实体位姿复制给编辑器相机,Scene view 立即
// 从该位置观看(只取位姿,不接管渲染——渲染源分派在
// SceneRenderer,编辑器形态恒用编辑器相机)
if (ImGui::Button("Align Viewport to Camera") && ctx.editorCamera)
// ---- 投影参数(UE/Unity Camera 语义):模式二选一,fov 与
// ortho size 按模式互斥显示;near/far 成对写回(SetClip
// 内部保证 far > near)----
const char* projModes[] = {"Perspective", "Orthographic"};
int pm = static_cast<int>(cam->camera.GetProjectionMode());
if (ImGui::Combo("Projection", &pm, projModes, 2))
{
if (auto* tr = reg.try_get<Engine::ECS::TransformComponent>(
selected))
cam->camera.SetProjectionMode(
static_cast<Engine::Camera::ProjectionMode>(pm));
}
if (cam->camera.GetProjectionMode() ==
Engine::Camera::ProjectionMode::Perspective)
{
float fov = cam->camera.GetFovDeg();
if (ImGui::DragFloat("FOV (deg)", &fov, 0.1f, 1.0f, 179.0f,
"%.1f"))
{
ctx.editorCamera->AlignTo(*tr);
cam->camera.SetFovDeg(fov);
}
}
else
{
float orthoSize = cam->camera.GetOrthoSize();
if (ImGui::DragFloat("Ortho Size", &orthoSize, 0.1f, 0.01f,
10000.0f, "%.2f"))
{
cam->camera.SetOrthoSize(orthoSize);
}
}
float nearZ = cam->camera.GetNearZ();
float farZ = cam->camera.GetFarZ();
const bool nearChanged = ImGui::DragFloat(
"Near Z", &nearZ, 0.01f, 0.001f, 1000.0f, "%.3f");
const bool farChanged = ImGui::DragFloat(
"Far Z", &farZ, 1.0f, 0.1f, 100000.0f, "%.1f");
if (nearChanged || farChanged)
{
cam->camera.SetClip(nearZ, farZ);
}
if (!cam->active)
{
ImGui::EndDisabled();
@@ -951,6 +1296,14 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
}
// ---- Animation ----
// attach 遥测(会话级 static:最近一次并入的骨骼匹配结果,
// 面板内显示"matched/skipped")
static std::string s_LastAttachFile;
static unsigned s_LastAttachMatched = 0, s_LastAttachSkipped = 0;
static unsigned s_LastAttachRetargeted = 0;
static std::vector<Engine::Animation::RetargetRow> s_LastAttachRows;
// 时间拖动条的定格记忆:拖住瞬间是否在播(松手恢复用)
static bool s_ScrubWasPlaying = false;
if (auto* anim = reg.try_get<Engine::ECS::AnimationComponent>(selected))
{
bool removeAnim = false;
@@ -966,6 +1319,32 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
(mr && mr->model.Valid()) ? ctx.assets->GetModel(mr->model)
: nullptr;
// ---- 骨骼可视化开关(Endfield-Poser 三件套)----全局编辑器
// 设置(经 Renderer 门面),非实体数据、不落场景序列化。
// 开 = 3D 骨架线 + 视口关节圆点;All Nodes = 显示全部节点
// 层级(普通节点暗色),关 = 仅骨骼链;圆点大小按手感调
{
bool show = ctx.renderer->GetBoneWiresVisible();
if (ImGui::Checkbox("Show Bones", &show))
{
ctx.renderer->SetBoneWiresVisible(show);
}
ImGui::SameLine();
bool allNodes = ctx.renderer->GetBoneAllNodes();
if (ImGui::Checkbox("All Nodes", &allNodes))
{
ctx.renderer->SetBoneAllNodes(allNodes);
}
ImGui::SameLine();
float px = ctx.renderer->GetBoneJointPx();
ImGui::SetNextItemWidth(130.0f);
if (ImGui::SliderFloat("##bone_joint_px", &px, 2.0f, 14.0f,
"joint %.0fpx"))
{
ctx.renderer->SetBoneJointPx(px);
}
}
// ---- 外部动画并入(自愈重放 + attach 槽)----
// 重放:attachedAnimPaths 里未并入的源(重启后 Model 缓存是
// 空白)幂等重 attach;解析失败的路径会话内不再重试
@@ -990,6 +1369,298 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
}
}
// ---- Mode:Direct(单 clip 直控)/ Controller(.animctrl
// 状态机)----Unity Animator 语义:Controller 下播放由
// 状态机调度(Direct 字段不参与求值),切回 Direct 配置
// 不丢
{
ImGui::SeparatorText("Mode");
using AnimMode = Engine::ECS::AnimationComponent::AnimMode;
int modeSel =
anim->mode == AnimMode::Controller ? 1 : 0;
ImGui::SetNextItemWidth(-1.0f);
if (ImGui::Combo("##anim_mode", &modeSel,
"Direct\0Controller\0"))
{
anim->mode =
modeSel == 1 ? AnimMode::Controller : AnimMode::Direct;
}
if (anim->mode == AnimMode::Controller)
{
// ---- Controller 资产槽(指派即预载)----
const auto& cfiles = AssetControllerFiles();
std::vector<const char*> copts;
copts.push_back("<none>");
for (const auto& f : cfiles)
{
copts.push_back(f.c_str());
}
int csel = 0;
for (size_t k = 0; k < cfiles.size(); ++k)
{
if (cfiles[k] == anim->controllerPath)
{
csel = static_cast<int>(k) + 1;
}
}
ImGui::SetNextItemWidth(-1.0f);
if (ImGui::Combo("##anim_ctrl_slot", &csel, copts.data(),
static_cast<int>(copts.size())))
{
anim->currentState.clear(); // 换绑 → 首帧回 entry
if (csel == 0)
{
anim->controllerPath.clear();
}
else
{
const std::string& path =
cfiles[static_cast<size_t>(csel) - 1];
anim->controllerPath = path;
ctx.assets->LoadController(path);
// 参数默认值初始化(只补缺键——调好的值不覆盖)
if (const auto* ctrl =
ctx.assets->FindController(path))
{
for (const auto& p : ctrl->GetParams())
{
if (p.type ==
Engine::Animation::ControllerParam::
Type::Bool)
{
anim->boolParams.emplace(p.name,
p.defBool);
}
else if (p.type ==
Engine::Animation::
ControllerParam::Type::Float)
{
anim->floatParams.emplace(p.name,
p.defFloat);
}
}
}
}
}
// 双击槽 = 打开 Controller Editor(Unity 双击
// Controller 资产打开 Animator 窗口语义)
if (csel > 0 && ImGui::IsItemHovered() &&
ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) &&
ctx.controllerEditor != nullptr)
{
ctx.controllerEditor->Open(anim->controllerPath);
}
// 拖放指派:AssetBrowser 的 .animctrl 拖进来
//(对齐材质槽交互)
if (ImGui::BeginDragDropTarget())
{
if (const ImGuiPayload* pl =
ImGui::AcceptDragDropPayload(
kControllerDragPayload))
{
const std::string path(
static_cast<const char*>(pl->Data));
anim->currentState.clear(); // 首帧回 entry
anim->controllerPath = path;
ctx.assets->LoadController(path);
if (const auto* c =
ctx.assets->FindController(path))
{
for (const auto& p : c->GetParams())
{
if (p.type ==
Engine::Animation::
ControllerParam::Type::Float)
{
anim->floatParams.emplace(
p.name, p.defFloat);
}
else if (p.type ==
Engine::Animation::
ControllerParam::Type::
Bool)
{
anim->boolParams.emplace(p.name,
p.defBool);
}
}
}
}
ImGui::EndDragDropTarget();
}
const auto* ctrl =
anim->controllerPath.empty()
? nullptr
: ctx.assets->FindController(
anim->controllerPath);
// ---- Create / Reload 工具行:.animctrl = 纯文本
// YAML 资产——Create 从当前模型 clip 自动生成
// 可跑模板;外部编辑器改完点 Reload 热重载 ----
{
static char s_NewName[96] = "my_controller";
static bool s_CreateOk = true;
ImGui::SetNextItemWidth(150.0f);
ImGui::InputText("##ctrl_new_name", s_NewName,
sizeof(s_NewName));
ImGui::SameLine();
if (ImGui::Button("Create") && s_NewName[0] != '\0')
{
// 模板 = Idle(单 clip)+ Locomotion(2D 方向
// 树:原点 + 4 方向 walk 占位)+ Speed 过渡
// ——生成即有方向盘,打开 Controller Editor
// 再逐点指定 clip;无模型时用占位名(clip 查
// 不到 = rest 兜底)
std::vector<std::string> clips;
if (model != nullptr)
{
for (const auto& c : model->GetClips())
{
clips.push_back(c.name);
}
}
if (clips.empty())
{
clips.push_back("clip_name");
}
const auto clipAt = [&](size_t i) {
return clips.empty()
? "clip_name"
: clips[i % clips.size()];
};
std::string yaml =
"# 动画控制器:状态/参数/过渡全由本文件声明,"
"引擎零硬编码。\n"
"# 纯文本 YAML —— 也可在 Controller Editor "
"面板可视化编辑后 Save。\n"
"# 条件语法:\"参数 op 值\"(> < >= <= == "
"!=;值 = 数字或 true/false)\n"
"Version: 1\n"
"Params:\n"
" - {name: MoveX, type: float, default: 0}\n"
" - {name: MoveZ, type: float, default: 0}\n"
" - {name: Speed, type: float, default: 0}\n"
"States:\n"
" Idle:\n"
" clip: \"" +
clips[0] + "\"\n"
" loop: true\n"
" Locomotion:\n"
" blendTree:\n"
" type: 2DDirectional\n"
" x: MoveX\n"
" z: MoveZ\n"
" children:\n"
" - {clip: \"" +
clips[0] + "\", at: [0, 0]}\n"
" - {clip: \"" +
clipAt(1) + "\", at: [0, 1]}\n"
" - {clip: \"" +
clipAt(2) + "\", at: [1, 0]}\n"
" - {clip: \"" +
clipAt(3) + "\", at: [0, -1]}\n"
" - {clip: \"" +
clipAt(4) + "\", at: [-1, 0]}\n"
"Transitions:\n"
" - {from: Idle, to: Locomotion, cond: "
"\"Speed > 0.1\", fade: 0.25}\n"
" - {from: Locomotion, to: Idle, cond: "
"\"Speed < 0.05\", fade: 0.3}\n";
const std::string path =
std::string("assets/animations/") +
s_NewName + ".animctrl";
std::filesystem::create_directories(
"assets/animations");
std::ofstream f(path, std::ios::binary |
std::ios::trunc);
s_CreateOk = static_cast<bool>(f);
if (s_CreateOk)
{
f.write(yaml.data(),
static_cast<std::streamsize>(
yaml.size()));
f.close();
InvalidateControllerFiles();
anim->controllerPath = path;
anim->currentState.clear(); // 首帧回 entry
ctx.assets->LoadController(path);
// 参数默认值初始化(只补缺键,同槽指派)
if (const auto* c =
ctx.assets->FindController(path))
{
for (const auto& p : c->GetParams())
{
if (p.type ==
Engine::Animation::
ControllerParam::Type::Float)
{
anim->floatParams.emplace(
p.name, p.defFloat);
}
}
}
}
}
ImGui::SameLine();
if (!anim->controllerPath.empty() &&
ImGui::SmallButton("Reload"))
{
ctx.assets->ReloadController(
anim->controllerPath);
anim->currentState.clear(); // 回 entry
}
if (!s_CreateOk)
{
ImGui::TextColored(
ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
"! create failed (disk?)");
}
}
if (ctrl == nullptr)
{
if (!anim->controllerPath.empty())
{
ImGui::TextColored(
ImVec4(1.0f, 0.75f, 0.2f, 1.0f),
"! controller failed to load");
}
else
{
ImGui::TextDisabled("(no controller assigned)");
}
}
else
{
const auto& order = ctrl->GetStateOrder();
// ---- 当前状态(Parameters 之前:先看"现在在哪",
// 再调参数)。过渡中显示 来源 -> 目标(百分比)----
if (!anim->transFrom.empty())
{
const float pct =
glm::min(anim->transElapsed /
anim->transFade,
1.0f) *
100.0f;
ImGui::Text("state: %s -> %s (%.0f%%)",
anim->transFrom.c_str(),
anim->currentState.c_str(), pct);
}
else
{
ImGui::Text("state: %s",
anim->currentState.c_str());
}
// ---- Edit:结构编辑全在独立 Controller
// Editor 面板(材质面板同范式——Details
// 只留指派与运行状态)----
if (ctx.controllerEditor != nullptr &&
ImGui::SmallButton("Edit..."))
{
ctx.controllerEditor->Open(anim->controllerPath);
}
}
}
}
// 依赖提示:动画作用在 MeshRenderer 的模型骨骼上
if (mr == nullptr)
{
@@ -1002,6 +1673,13 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
}
else
{
// ---- Skeleton 资产信息(只读)----骨骼数 = palette
// 槽位数;节点数 = 蒙皮数学的全层级表(骨骼 ⊂ 节点,
// 两数不同是正常的)。选中实体时视口内画骨架线框
ImGui::Text("bones: %u nodes: %u",
static_cast<unsigned>(model->GetBoneCount()),
static_cast<unsigned>(model->GetNodes().size()));
// ---- Clips:动画资产槽(列全部模型容器文件——无法静态
// 区分"模型 fbx"与"纯动画 fbx",一并列出,无 clip 的会
// 警告跳过)----
@@ -1029,14 +1707,67 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
{
anim->attachedAnimPaths.push_back(path);
}
// attach 遥测(骨骼匹配结果,面板下方显示)
s_LastAttachFile =
std::filesystem::path(path).filename().string();
s_LastAttachMatched =
res.channelsMatched + res.channelsRetargeted;
s_LastAttachRetargeted = res.channelsRetargeted;
s_LastAttachSkipped = res.channelsSkipped;
// 重定向明细快照(映射表调试显示)
s_LastAttachRows = res.retargetRows;
}
// 已并入的源文件清单
for (const auto& p : anim->attachedAnimPaths)
// 已并入的源文件清单(x = detach:剥离模型 clip 表 +
// 移除持久化列表;一帧只删一个,下帧重绘自然继续)
for (size_t i = 0; i < anim->attachedAnimPaths.size(); ++i)
{
const auto& p = anim->attachedAnimPaths[i];
ImGui::BulletText("%s", std::filesystem::path(p)
.filename()
.string()
.c_str());
ImGui::SameLine();
ImGui::PushID(static_cast<int>(i));
const bool doDetach = ImGui::SmallButton("x");
ImGui::PopID();
if (doDetach)
{
ctx.assets->DetachAnimationClips(mr->model, p);
anim->attachedAnimPaths.erase(
anim->attachedAnimPaths.begin() +
static_cast<ptrdiff_t>(i));
break;
}
}
// 最近一次 attach 的骨骼匹配遥测(同名直映 + Humanoid
// 槽位重定向分列;跳过 = 手指/道具等无对应骨骼)。
// 注意:重启后自愈重放路径幂等命中返回全 0,那里不更新
// 遥测避免误导
if (!s_LastAttachFile.empty())
{
ImGui::TextColored(
ImVec4(0.55f, 0.75f, 1.0f, 1.0f),
"%s: %u matched (%u retargeted), %u skipped",
s_LastAttachFile.c_str(),
static_cast<unsigned>(s_LastAttachMatched),
static_cast<unsigned>(s_LastAttachRetargeted),
static_cast<unsigned>(s_LastAttachSkipped));
}
// 重定向明细表(调试黑盒白盒化):源骨骼 → 槽位 → 目标
// 骨骼。行错了 = 哪根骨乱转一眼可查(Unity Configure
// Avatar 的只读版)
if (!s_LastAttachRows.empty())
{
if (ImGui::TreeNode("Retarget Map"))
{
for (const auto& r : s_LastAttachRows)
{
ImGui::BulletText("%s [%s] -> %s",
r.srcName.c_str(), r.slot,
r.dstName.c_str());
}
ImGui::TreePop();
}
}
if (!model->HasAnimations())
{
@@ -1045,32 +1776,61 @@ void InspectorPanel::Draw(const EditorContext& ctx, entt::entity selected)
}
if (model != nullptr && model->HasAnimations())
{
// ---- Playback:播放控制(切换 clip 触发 crossfade)----
ImGui::SeparatorText("Playback");
const auto& clips = model->GetClips();
std::vector<const char*> clipNames;
clipNames.reserve(clips.size());
for (const auto& c : clips)
// ---- Playback:播放控制。Direct 专属控件(clip 选择/
// playing/loop/时间拖动条)只在 Direct 模式渲染——
// Controller 模式下播放由状态机调度,这些字段不参
// 与求值(Unity Animator 语义:Controller 挂上后
// Animation 组件的 clip 字段灰置)----
const bool ctrlMode =
anim->mode ==
Engine::ECS::AnimationComponent::AnimMode::Controller;
if (!ctrlMode)
{
clipNames.push_back(c.name.c_str());
ImGui::SeparatorText("Playback");
std::vector<const char*> clipNames;
clipNames.reserve(clips.size());
for (const auto& c : clips)
{
clipNames.push_back(c.name.c_str());
}
int cur = anim->clipIndex;
if (ImGui::Combo("Clip", &cur, clipNames.data(),
static_cast<int>(clipNames.size())))
{
anim->Play(cur); // 触发 crossfade(fade 秒)
}
ImGui::Checkbox("Playing", &anim->playing);
ImGui::SameLine();
ImGui::Checkbox("Loop", &anim->loop);
if (anim->clipIndex >= 0 &&
anim->clipIndex <
static_cast<int>(clips.size()))
{
// 时间拖动条(Unity Animation 窗口 scrub 语义):
// 拖住定格播放头逐帧看姿态,松手恢复拖前播放状
// 态。暂停时 AnimationSystem 照样求值(只有
// playing 才推进播放头),所以拖动即实时预览
const float dur =
clips[anim->clipIndex].duration;
ImGui::SliderFloat("Time", &anim->time, 0.0f, dur,
"t: %.2f / %.2f s");
if (ImGui::IsItemActivated())
{
s_ScrubWasPlaying = anim->playing;
anim->playing = false;
}
if (ImGui::IsItemDeactivatedAfterEdit() &&
s_ScrubWasPlaying)
{
anim->playing = true;
}
}
}
int cur = anim->clipIndex;
if (ImGui::Combo("Clip", &cur, clipNames.data(),
static_cast<int>(clipNames.size())))
{
anim->Play(cur); // 触发 crossfade(fade 秒)
}
ImGui::Checkbox("Playing", &anim->playing);
ImGui::SameLine();
ImGui::Checkbox("Loop", &anim->loop);
// 两模式通用:全局速度 / 过渡时长(Controller 的
// CrossFade 与 Direct 的 crossfade 共用 fade)
ImGui::SliderFloat("Speed", &anim->speed, -2.0f, 2.0f);
ImGui::SliderFloat("Fade", &anim->fadeDuration, 0.0f, 1.0f);
if (anim->clipIndex >= 0 &&
anim->clipIndex < static_cast<int>(clips.size()))
{
ImGui::Text("t: %.2f / %.2f s", anim->time,
clips[anim->clipIndex].duration);
}
// 动画数据表:每个 clip 的时长与有效轨道数(attach 进来的
// 外部动画在此核对骨骼匹配结果——轨道数明显偏少 = 骨骼
// 命名不同源被跳过)
+173
View File
@@ -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
+321
View File
@@ -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();
}
+60
View File
@@ -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
+88 -10
View File
@@ -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
View File
@@ -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
};
}