Reorganize directory layout: editor panels/, grouped shaders, drop stale header

- App/editor: move 7 panels into panels/ (Editor core stays at root)
- shaders/: group into pbr/ post/ debug/ env/ (runtime load paths updated)
- Delete outdated duplicate Engine/src/Renderer/VulkanAssetUploader.h
This commit is contained in:
2026-10-07 09:52:17 +08:00
parent cc837bc605
commit f73e52926f
55 changed files with 23 additions and 83 deletions
+412
View File
@@ -0,0 +1,412 @@
//
// AssetBrowserPanel.cpp:项目资产浏览器实现。
//
#include "AssetBrowserPanel.h"
#include "MaterialEditorPanel.h" // kMaterialDragPayload + 双击打开材质编辑器
#include "ControllerEditorPanel.h" // kControllerDragPayload + 双击打开控制器编辑器
#include <Engine/Core/Log.h>
#include <imgui.h>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdio>
#include <ctime>
#include <filesystem>
#include <vector>
namespace {
// 固定根:下标与 Editor 无耦合,仅面板内部使用
constexpr const char* kRootNames[] = {"Assets", "Scenes"};
constexpr const char* kRootPaths[] = {"assets", "scenes"};
// 类型过滤(下标即身份):0 = All
constexpr const char* kTypeNames[] = {"All", "Models",
"Textures", "Scenes",
"Materials", "Controllers"};
// 资产类别:按扩展名分派(过滤用)
enum class Kind { Model, Texture, Scene, Material, AnimClip, Other };
Kind KindOf(const std::filesystem::path& p)
{
std::string ext = p.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return std::tolower(c); });
if (ext == ".gltf" || ext == ".glb" || ext == ".fbx" ||
ext == ".obj")
{
return Kind::Model;
}
if (ext == ".hdr" || ext == ".png" || ext == ".jpg" ||
ext == ".jpeg")
{
return Kind::Texture;
}
if (ext == ".scene")
{
return Kind::Scene;
}
if (ext == ".material")
{
return Kind::Material;
}
if (ext == ".animctrl")
{
return Kind::AnimClip;
}
return Kind::Other;
}
bool PassFilter(int typeFilter, Kind kind)
{
switch (typeFilter)
{
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;
}
}
// 列表条目前缀符号(ImGui 默认字体无图标字形,用 ASCII 标签)
const char* KindTag(Kind kind)
{
switch (kind)
{
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 "[ ]";
}
}
// 相对路径规范化为正斜杠(AssetManager 键与场景文件统一 POSIX 风格)
std::string ToAssetPath(const std::filesystem::path& p)
{
std::string s = p.generic_string();
std::replace(s.begin(), s.end(), '\\', '/');
return s;
}
} // namespace
void AssetBrowserPanel::Draw(const EditorContext& ctx)
{
if (!ImGui::Begin("Content Browser"))
{
ImGui::End();
return;
}
// ---- 顶栏:根切换 + 类型过滤 + 名字过滤 ----
if (ImGui::BeginTabBar("##roots"))
{
for (int i = 0; i < 2; ++i)
{
if (ImGui::BeginTabItem(kRootNames[i]))
{
m_Root = i;
ImGui::EndTabItem();
}
}
ImGui::EndTabBar();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(110.0f);
ImGui::Combo("##type", &m_TypeFilter, kTypeNames, 6);
ImGui::SameLine();
ImGui::SetNextItemWidth(140.0f);
ImGui::InputTextWithHint("##name", "filter", m_NameFilter,
sizeof(m_NameFilter));
ImGui::Separator();
const std::filesystem::path root(kRootPaths[m_Root]);
// 根切换 / 首帧:当前目录回根。前缀比较手写(path::starts_with
// 是 libstdc++ 11 才补齐的成员,本机 GCC 没有);补 '/' 分隔校验
// 防 "assets_x" 之类误匹配
const std::string rootStr = root.generic_string();
const std::string curStr = m_CurrentDir.generic_string();
const bool under = curStr == rootStr ||
(curStr.size() > rootStr.size() &&
curStr.compare(0, rootStr.size(), rootStr) == 0 &&
curStr[rootStr.size()] == '/');
if (m_CurrentDir.empty() || !under)
{
m_CurrentDir = root;
}
// ---- 顶栏下:面包屑(当前目录相对根路径 + 回根按钮)----
if (ImGui::Button("Root"))
{
m_CurrentDir = root;
}
ImGui::SameLine();
std::string rel = std::filesystem::relative(m_CurrentDir, root)
.generic_string();
ImGui::TextDisabled("%s/%s", kRootNames[m_Root],
rel.empty() ? "" : rel.c_str());
ImGui::Separator();
// ---- 左:目录树 | 右:当前目录文件列表 ----
const float treeWidth = 180.0f;
ImGui::BeginChild("##tree", ImVec2(treeWidth, 0.0f),
ImGuiChildFlags_ResizeX | ImGuiChildFlags_Borders);
if (std::filesystem::exists(root))
{
// 根节点也画进树(可点击选中)
ImGui::SetNextItemOpen(true, ImGuiCond_Once);
if (ImGui::TreeNodeEx(kRootNames[m_Root],
ImGuiTreeNodeFlags_OpenOnArrow))
{
if (ImGui::IsItemClicked(ImGuiMouseButton_Left))
{
m_CurrentDir = root;
}
DrawDirTree(ctx, root);
ImGui::TreePop();
}
}
else
{
ImGui::TextDisabled("(no '%s' dir)", kRootPaths[m_Root]);
}
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("##files", ImVec2(0.0f, 0.0f),
ImGuiChildFlags_Borders);
if (std::filesystem::exists(m_CurrentDir))
{
DrawFileList(ctx, m_CurrentDir);
}
ImGui::EndChild();
ImGui::End();
}
void AssetBrowserPanel::DrawDirTree(const EditorContext& ctx,
const std::filesystem::path& dir)
{
for (const auto& entry : std::filesystem::directory_iterator(dir))
{
if (!entry.is_directory())
{
continue;
}
const std::string name = entry.path().filename().string();
// OpenOnArrow:单击行 = 选中(列表切换),箭头 = 折叠
const bool open = ImGui::TreeNodeEx(
name.c_str(), ImGuiTreeNodeFlags_OpenOnArrow |
ImGuiTreeNodeFlags_SpanFullWidth);
if (ImGui::IsItemClicked(ImGuiMouseButton_Left))
{
m_CurrentDir = entry.path();
}
if (open)
{
DrawDirTree(ctx, entry.path());
ImGui::TreePop();
}
}
}
void AssetBrowserPanel::DrawFileList(const EditorContext& ctx,
const std::filesystem::path& dir)
{
// 收集 + 过滤 + 排序(名字序;目录迭代序不稳定)
struct Item
{
std::filesystem::path path;
Kind kind;
};
std::vector<Item> items;
const std::string filter = m_NameFilter;
for (const auto& entry : std::filesystem::directory_iterator(dir))
{
if (entry.is_directory())
{
// 子目录也进列表:单行点击进入(树/列表双向导航)
const std::string dname = entry.path().filename().string();
ImGui::PushID(dname.c_str());
ImGui::TextUnformatted("[D]");
ImGui::SameLine();
if (ImGui::Selectable(dname.c_str()))
{
m_CurrentDir = entry.path();
}
ImGui::PopID();
continue;
}
const Kind kind = KindOf(entry.path());
if (!PassFilter(m_TypeFilter, kind))
{
continue;
}
if (!filter.empty())
{
const std::string name = entry.path().filename().string();
if (name.find(filter) == std::string::npos)
{
continue;
}
}
items.push_back({entry.path(), kind});
}
std::sort(items.begin(), items.end(),
[](const Item& a, const Item& b)
{
return a.path.filename().string() <
b.path.filename().string();
});
for (const auto& item : items)
{
const std::string name = item.path.filename().string();
const std::string assetPath = ToAssetPath(item.path);
ImGui::PushID(name.c_str());
ImGui::TextUnformatted(KindTag(item.kind));
ImGui::SameLine();
ImGui::Selectable(name.c_str());
// .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::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={})",
assetPath, ref.Valid());
}
else if (item.kind == Kind::Texture && ctx.assets)
{
const auto ref = ctx.assets->LoadEnvironment(assetPath);
ENGINE_INFO("AssetBrowser: env '{}' loaded (valid={})",
assetPath, ref.Valid());
}
else if (item.kind == Kind::Scene)
{
ENGINE_INFO("AssetBrowser: load scenes via File menu ({})",
assetPath);
}
}
// hover 信息卡(UE Content Browser 式):类型/大小/修改时间/加载状态
if (ImGui::IsItemHovered())
{
ImGui::BeginTooltip();
ImGui::TextUnformatted(name.c_str());
ImGui::Separator();
// 与 Kind 枚举逐位对齐(Kind::Other = 5)——漏项 = tooltip
// 数组越界读
static const char* kKindNames[] = {"Model", "Texture/Environment",
"Scene", "Material",
"Controller", "File"};
ImGui::Text("type: %s", kKindNames[static_cast<int>(item.kind)]);
namespace fs = std::filesystem;
std::error_code ec;
const auto sz = fs::file_size(item.path, ec);
if (!ec)
{
char szBuf[32];
if (sz >= 1024ull * 1024ull)
{
std::snprintf(szBuf, sizeof(szBuf), "%.1f MB",
static_cast<double>(sz) / (1024.0 * 1024.0));
}
else
{
std::snprintf(szBuf, sizeof(szBuf), "%.1f KB",
static_cast<double>(sz) / 1024.0);
}
ImGui::Text("size: %s", szBuf);
}
const auto ftime = fs::last_write_time(item.path, ec);
if (!ec)
{
// file_clock → system_clock 近似转换(C++17 无官方转换)
const auto sctp = std::chrono::time_point_cast<
std::chrono::system_clock::duration>(
ftime - fs::file_time_type::clock::now() +
std::chrono::system_clock::now());
const std::time_t tt = std::chrono::system_clock::to_time_t(sctp);
char tmBuf[32];
std::strftime(tmBuf, sizeof(tmBuf), "%Y-%m-%d %H:%M",
std::localtime(&tt));
ImGui::Text("modified: %s", tmBuf);
}
// 加载状态(只查询不触发加载;model/HDR 进过缓存才显示 loaded)
if (ctx.assets)
{
const char* state = "not loaded";
if (item.kind == Kind::Model &&
ctx.assets->IsModelLoaded(assetPath))
{
state = "loaded";
}
else if (item.kind == Kind::Texture &&
ctx.assets->IsEnvironmentLoaded(assetPath))
{
state = "loaded";
}
ImGui::Text("state: %s", state);
// 已加载的 model 展开资产数据概览(meshes=0 即纯动画
// 容器;未加载不显示——查询不触发加载)
if (item.kind == Kind::Model)
{
if (const Engine::Model* m =
ctx.assets->GetModelByPath(assetPath);
m != nullptr)
{
ImGui::Text("meshes: %u, bones: %u, clips: %u",
static_cast<unsigned>(m->GetMeshes().size()),
m->GetBoneCount(),
static_cast<unsigned>(m->GetClips().size()));
}
}
}
ImGui::TextDisabled("%s", assetPath.c_str());
ImGui::EndTooltip();
}
ImGui::PopID();
}
}
+42
View File
@@ -0,0 +1,42 @@
//
// AssetBrowserPanel:项目资产浏览器(Unity Project 窗口简化版)。
//
// 左侧目录树 + 右侧文件列表(类型过滤 + 名字过滤)。与 AssetManager
// 的集成点是"双击预热":model/HDR 进缓存去重(重复双击零成本),
// .scene 提示走 File 菜单(加载含环境恢复/选中清理等流程,不在面板
// 重复)。拖拽到视口/Inspector 属后续迭代。
//
// 目录每帧扫描:项目级目录条目有限(几十量级),filesystem 迭代开销
// 可忽略;资产规模上来后再加缓存脏标。
//
#ifndef HELLOVULKAN_ASSETBROWSERPANEL_H
#define HELLOVULKAN_ASSETBROWSERPANEL_H
#include "EditorContext.h"
#include <filesystem>
#include <string>
class AssetBrowserPanel {
public:
void Draw(const EditorContext& ctx);
private:
// 目录树节点(递归);右侧列表画 dir 下的文件
void DrawDirTree(const EditorContext& ctx,
const std::filesystem::path& dir);
void DrawFileList(const EditorContext& ctx,
const std::filesystem::path& dir);
// 固定根(下标即身份):项目资产 / 场景文件
int m_Root = 0;
// 当前浏览目录(点目录树切换;列表显示这里的文件)
std::filesystem::path m_CurrentDir;
// 类型过滤(0 = All;其余见 kTypeNames)
int m_TypeFilter = 0;
// 名字子串过滤(空 = 不过滤)
char m_NameFilter[64] = "";
};
#endif //HELLOVULKAN_ASSETBROWSERPANEL_H
+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
+214
View File
@@ -0,0 +1,214 @@
//
// HierarchyPanel.cpp:实体层级树实现(UE Outliner 语义)。
//
// 树形展示(根 = 无有效父链接的实体,子 = HierarchyComponent 挂载序)、
// 点击选中、拖拽改父/拖到空白区挂根(keepWorld = 挂载前后世界位姿
// 不变)、右键 Create Child / Detach / Delete(级联随葬,延迟销毁)。
//
#include "HierarchyPanel.h"
#include "Engine/ECS/Components.h"
#include "Engine/ECS/EntityHandle.h" // CreateEntity 返回完整 EntityHandle
#include <imgui.h>
#include <cstring>
#include <string>
#include <vector>
using namespace Engine;
namespace {
// 拖拽 payload 类型名(Editor 内部约定;实体位模式 4 字节)
constexpr const char* kHierPayload = "HIER_ENTITY";
// 实体显示名
std::string DisplayName(const entt::registry& reg, entt::entity e)
{
if (const auto* n = reg.try_get<Engine::ECS::NameComponent>(e);
n && !n->name.empty()) {
return n->name;
}
return "(unnamed)";
}
// 递归绘制单节点(含子树)。返回值无意义,选中写 selected
void DrawEntityNode(const EditorContext& ctx, entt::entity e,
entt::entity& selected)
{
auto& reg = ctx.scene->GetRegistry();
// 稳定行 ID(UUID):折叠态跨帧保持 + 与 Inspector PushID 隔离
u64 rowId = static_cast<u64>(static_cast<u32>(e));
if (const auto* id = reg.try_get<Engine::ECS::IdComponent>(e)) {
rowId = id->uuid.Value();
}
ImGui::PushID(reinterpret_cast<const void*>(rowId));
// 子表(过滤悬空 + 无 Name 的防御性兜底)
const auto* h = reg.try_get<Engine::ECS::HierarchyComponent>(e);
std::vector<entt::entity> kids;
if (h) {
for (entt::entity c : h->children) {
if (reg.valid(c) && reg.all_of<Engine::ECS::NameComponent>(c)) {
kids.push_back(c);
}
}
}
const std::string label = DisplayName(reg, e);
const ImGuiTreeNodeFlags flags =
ImGuiTreeNodeFlags_OpenOnArrow |
ImGuiTreeNodeFlags_SpanAvailWidth |
(kids.empty() ? ImGuiTreeNodeFlags_Leaf : 0) |
(selected == e ? ImGuiTreeNodeFlags_Selected : 0);
const bool opened = ImGui::TreeNodeEx(label.c_str(), flags);
const bool toggled = ImGui::IsItemToggledOpen();
if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !toggled) {
selected = e;
}
// ---- 拖拽源:拖动本实体去改父 ----
if (ImGui::BeginDragDropSource()) {
const u32 bits = static_cast<u32>(entt::to_integral(e));
ImGui::SetDragDropPayload(kHierPayload, &bits, sizeof(bits));
ImGui::TextUnformatted(label.c_str());
ImGui::EndDragDropSource();
}
// ---- 拖放目标:挂到本实体下(keepWorld,世界位姿不变)----
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* pl =
ImGui::AcceptDragDropPayload(kHierPayload)) {
u32 bits = 0;
std::memcpy(&bits, pl->Data, sizeof(bits));
const entt::entity dragged = static_cast<entt::entity>(bits);
// 拒挂自身/自身后代(SetParent 内部同样防环,这里提前
// 静默拦截,避免"看起来挂上了"的错觉)
if (dragged != e && !ctx.scene->IsDescendantOf(dragged, e)) {
ctx.scene->SetParent(dragged, e);
}
}
ImGui::EndDragDropTarget();
}
// ---- 右键菜单 ----
if (ImGui::BeginPopupContextItem("hier_ctx")) {
if (ImGui::MenuItem("Create Child")) {
// keepWorld=true:新实体出现在父的位置(UE attach 语义)
const auto child = ctx.scene->CreateEntity("child entity");
ctx.scene->SetParent(child, e);
selected = child;
}
if (ctx.scene->GetParent(e) != entt::null &&
ImGui::MenuItem("Detach to Root")) {
ctx.scene->SetParent(e, entt::null);
}
if (ImGui::MenuItem("Delete")) {
// 级联随葬:选中若是被删子树的一部分,一并清除
const bool selInSubtree = ctx.scene->IsDescendantOf(selected, e);
ctx.scene->DestroyEntity(e);
if (selInSubtree) {
selected = entt::null;
}
}
ImGui::EndPopup();
}
// ---- 组件徽标(与行内同级显示)----
std::string badge;
if (reg.all_of<Engine::ECS::MeshRendererComponent>(e)) badge += "[M]";
if (reg.all_of<Engine::ECS::AnimationComponent>(e)) badge += "[A]";
if (reg.all_of<Engine::ECS::CameraComponent>(e)) badge += "[C]";
if (reg.all_of<Engine::ECS::LightComponent>(e)) badge += "[L]";
if (reg.all_of<Engine::ECS::RigidBodyComponent>(e)) badge += "[P]";
if (!badge.empty()) {
ImGui::SameLine();
ImGui::TextDisabled("%s", badge.c_str());
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"M=Mesh A=Animation C=Camera L=Light P=Physics");
}
}
if (opened) {
for (entt::entity c : kids) {
DrawEntityNode(ctx, c, selected);
}
ImGui::TreePop();
}
ImGui::PopID();
}
} // namespace
void HierarchyPanel::Draw(const EditorContext& ctx, entt::entity& selected)
{
ImGui::Begin("Outliner");
// 工具栏:创建实体(在遍历之前,不修改遍历中的注册表结构)。
// Place Actors 简化语义:常用类型一键带组件,其余 Add Component 补
if (ImGui::Button("+ Create"))
{
ImGui::OpenPopup("create_entity");
}
if (ImGui::BeginPopup("create_entity"))
{
if (ImGui::MenuItem("Empty"))
{
selected = ctx.scene->CreateEntity("Entity");
}
if (ImGui::MenuItem("Light"))
{
auto e = ctx.scene->CreateEntity("Light");
e.AddComponent<Engine::ECS::LightComponent>();
selected = e;
}
if (ImGui::MenuItem("Camera"))
{
auto e = ctx.scene->CreateEntity("Camera");
e.AddComponent<Engine::ECS::CameraComponent>();
selected = e;
}
if (ImGui::MenuItem("Environment"))
{
// UE 新关卡环境集简化:Skybox(背景)+ SkyLight(环境光)一体
auto e = ctx.scene->CreateEntity("Environment");
e.AddComponent<Engine::ECS::SkyboxComponent>();
e.AddComponent<Engine::ECS::SkyLightComponent>();
selected = e;
}
ImGui::EndPopup();
}
ImGui::Separator();
auto& reg = ctx.scene->GetRegistry();
// 树形遍历:根 = 无有效父链接的实体(creation 序),子按挂载序递归
reg.view<Engine::ECS::NameComponent>().each(
[&](const entt::entity e, const Engine::ECS::NameComponent&)
{
if (ctx.scene->GetParent(e) != entt::null) return; // 非根
DrawEntityNode(ctx, e, selected);
});
// ---- 空白区 = 挂根落点(UE Outliner 拖到空白 = 顶层)----
ImVec2 rem = ImGui::GetContentRegionAvail();
if (rem.y > 4.0f) {
ImGui::InvisibleButton("##hier_drop_root", rem);
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* pl =
ImGui::AcceptDragDropPayload(kHierPayload)) {
u32 bits = 0;
std::memcpy(&bits, pl->Data, sizeof(bits));
ctx.scene->SetParent(static_cast<entt::entity>(bits),
entt::null);
}
ImGui::EndDragDropTarget();
}
}
ImGui::End();
}
+21
View File
@@ -0,0 +1,21 @@
//
// HierarchyPanel:实体层级树面板。
//
// 树形展示父子层级(UE Outliner)、点击选中、拖拽改父/拖到空白挂根
//(keepWorld)、右键 Create Child / Detach / Delete(级联随葬,帧尾
// flush,遍历安全)。每项尾缀显示组件构成徽标。
//
#ifndef HELLOVULKAN_HIERARCHYPANEL_H
#define HELLOVULKAN_HIERARCHYPANEL_H
#include "EditorContext.h"
#include <entt/entt.hpp>
class HierarchyPanel {
public:
void Draw(const EditorContext& ctx, entt::entity& selected);
};
#endif //HELLOVULKAN_HIERARCHYPANEL_H
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
//
// InspectorPanel:选中实体的组件检查/编辑面板。
//
// 泛化编辑:任何实体(不是沙盒特例)的 Name / Transform /
// MeshRenderer / Light / Camera / Animation 全部在此编辑。
// 沙盒原 Scene Debug 里的 KeyLight、动画 clip、gun scale 控件
// 全部由这里的通用控件替代。
//
#ifndef HELLOVULKAN_INSPECTORPANEL_H
#define HELLOVULKAN_INSPECTORPANEL_H
#include "EditorContext.h"
#include <entt/entt.hpp>
class InspectorPanel {
public:
void Draw(const EditorContext& ctx, entt::entity selected);
};
#endif //HELLOVULKAN_INSPECTORPANEL_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
+214
View File
@@ -0,0 +1,214 @@
//
// StatsPanel.cpp:遥测与全局设置实现(原 main.cpp Scene Debug 搬迁)。
// 面板按 View / Scene / Rendering / Physics / Performance 分组——
// 环境贴图/环境光已实体化(Skybox/SkyLight 组件,Details 配置),
// 此处只留场景级 ClearColor。
//
#include "StatsPanel.h"
#include "Engine/ECS/Components.h"
#include "Engine/Physics/PhysicsWorld.h"
#include "Engine/Core/Time.h"
#include <imgui.h>
#include <glm/gtc/type_ptr.hpp>
using namespace Engine; // f32(Engine/Core/Types.h)
void StatsPanel::Draw(const EditorContext& ctx)
{
ImGui::Begin("Stats");
auto& renderer = *ctx.renderer;
auto& app = *ctx.app;
// ---- View:看哪里(渲染目标)+ 相机位姿(跟随渲染源分派)----
if (ImGui::CollapsingHeader("View", ImGuiTreeNodeFlags_DefaultOpen))
{
// 渲染源分派与 SceneRenderer::BuildFrame 同规则:PIE 内嵌播放
// 或 kDisplayDirect 全屏预览 = primary 相机,否则编辑器相机
const bool playing = ctx.playState && *ctx.playState;
static const char* displayTarget[] = {
"Fullscreen",
"Viewport",
};
int mode = renderer.GetDisplayMode();
if (ImGui::Combo("Display Target", &mode, displayTarget,
IM_ARRAYSIZE(displayTarget)))
{
renderer.SetDisplayMode(mode);
}
if ((playing || renderer.GetDisplayMode() == kDisplayDirect))
{
Engine::ECS::TransformComponent* camT = nullptr;
if (ctx.scene->GetPrimaryCamera(&camT) != nullptr && camT != nullptr)
{
// world 位姿(相机实体可能挂父层级下)
const glm::vec3& pos = camT->GetWorldPosition();
ImGui::Text("Game Camera Pos");
ImGui::Text("(%.3f, %.3f, %.3f)", pos.x, pos.y, pos.z);
}
else
{
ImGui::TextDisabled("(no primary camera)");
}
}
else if (ctx.editorCamera)
{
const glm::vec3& pos = ctx.editorCamera->GetTransform().GetPosition();
ImGui::Text("Editor Camera Pos");
ImGui::Text("(%.3f, %.3f, %.3f)", pos.x, pos.y, pos.z);
}
else
{
ImGui::TextDisabled("(no camera)");
}
}
// ---- Scene:模拟控制 + 场景级设置。PIE 暂停在主工具栏(UE 语义)----
if (ImGui::CollapsingHeader("Scene", ImGuiTreeNodeFlags_DefaultOpen))
{
ImGui::ColorEdit4("Clear Color",
glm::value_ptr(ctx.scene->GetSettings().ClearColor));
}
// ---- Rendering:渲染管线参数与开关 ----
if (ImGui::CollapsingHeader("Rendering", ImGuiTreeNodeFlags_DefaultOpen))
{
f32 exposure = renderer.GetExposure();
if (ImGui::SliderFloat("Exposure", &exposure, 0.1f, 4.0f))
{
renderer.SetExposure(exposure);
}
bool aces = renderer.GetUseACES();
if (ImGui::Checkbox("ACES", &aces))
{
renderer.SetUseACES(aces);
}
// Bloom:开关即显隐——勾上才显示参数
bool bloom = renderer.GetBloomEnabled();
if (ImGui::Checkbox("Bloom", &bloom))
{
renderer.SetBloomEnabled(bloom);
}
if (bloom)
{
f32 threshold = renderer.GetBloomThreshold();
if (ImGui::SliderFloat("Threshold", &threshold, 0.0f, 8.0f,
"%.1f", ImGuiSliderFlags_Logarithmic))
{
renderer.SetBloomThreshold(threshold);
}
f32 strength = renderer.GetBloomStrength();
if (ImGui::SliderFloat("Strength", &strength, 0.0f, 2.0f))
{
renderer.SetBloomStrength(strength);
}
}
// 无参数的纯开关:横排平铺
bool shadows = renderer.GetShadowsEnabled();
if (ImGui::Checkbox("Shadows", &shadows))
{
renderer.SetShadowsEnabled(shadows);
}
ImGui::SameLine();
bool taa = renderer.GetTAAEnabled();
if (ImGui::Checkbox("TAA", &taa))
{
renderer.SetTAAEnabled(taa);
}
ImGui::SameLine();
bool lod = renderer.GetLodEnabled();
if (ImGui::Checkbox("LOD", &lod))
{
renderer.SetLodEnabled(lod); // 关 = 恒绘原网格
}
// 调试视图:单看某个 pass 的中间输出(类 UE pass debug)
const auto& viewNames = renderer.GetDebugViewNames();
int view = renderer.GetDebugView();
if (ImGui::Combo("Debug View", &view, viewNames.data(),
static_cast<int>(viewNames.size())))
{
renderer.SetDebugView(view);
}
}
// ---- Physics:调试可视化(collider/约束/重力/速度/加速度)----
if (ImGui::CollapsingHeader("Physics"))
{
auto& physics = app.GetPhysics();
u32 bits = physics.GetDebugDrawBits();
bool master = bits != 0;
if (ImGui::Checkbox("Debug Draw##Phys", &master))
{
bits = master ? kPhyDbgDefault : 0;
}
if (master)
{
ImGui::Indent();
auto toggle = [&bits](const char* label, u32 bit) {
bool on = (bits & bit) != 0;
if (ImGui::Checkbox(label, &on))
{
bits = on ? (bits | bit) : (bits & ~bit);
}
};
toggle("Colliders", kPhyDbgColliders);
toggle("Constraints", kPhyDbgConstraints);
toggle("Gravity", kPhyDbgGravity);
toggle("Velocity", kPhyDbgVelocity);
toggle("Acceleration", kPhyDbgAccel);
ImGui::Unindent();
}
physics.SetDebugDrawBits(bits);
// 碰撞事件计数(最近一次步进;掉落物触地时应 > 0)
ImGui::Text("contacts (frame): %zu", physics.GetEvents().size());
}
// ---- Performance:帧率控制 + GPU 遥测(默认折叠,不占面板高度)----
if (ImGui::CollapsingHeader("Performance"))
{
bool vsync = app.GetVSync();
if (ImGui::Checkbox("VSync", &vsync))
{
app.SetVSync(vsync);
if (!vsync)
{
const f32 target = app.GetTargetFps();
app.SetTargetFps(target);
}
}
if (!vsync)
{
f32 target = app.GetTargetFps();
if (ImGui::SliderFloat("Target FPS", &target, 24.0f, 360.0f, "%.0f"))
{
app.SetTargetFps(target);
}
}
ImGui::Text("fps: %.1f frame: %llu",
1.0f / Time::Current().UnscaledDt(),
static_cast<unsigned long long>(app.GetFrameCount()));
ImGui::Separator();
ImGui::Text("instances: %u main / %u shadow",
renderer.GetMainInstanceCount(),
renderer.GetShadowInstanceCount());
if (renderer.GetInstancesTruncated())
{
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"TRUNCATED: instance buffer full!");
}
const auto& names = renderer.GetPassNames();
const auto& times = renderer.GetPassGpuMs();
for (size_t i = 0; i < names.size() && i < times.size(); ++i)
{
ImGui::Text(" %-12s %.3f ms", names[i].c_str(), times[i]);
}
}
ImGui::End();
}
+19
View File
@@ -0,0 +1,19 @@
//
// StatsPanel:遥测与全局设置面板(原 Scene Debug 拆入)。
//
// Camera 位姿 / Rendering 开关 / Environment(IBL+DebugView+ClearColor) /
// GPU Timings / Frame(VSync、fps)。KeyLight、动画 clip、gun scale
// 等沙盒特例编辑已泛化进 Inspector,此处不再重复。
//
#ifndef HELLOVULKAN_STATSPANEL_H
#define HELLOVULKAN_STATSPANEL_H
#include "EditorContext.h"
class StatsPanel {
public:
void Draw(const EditorContext& ctx);
};
#endif //HELLOVULKAN_STATSPANEL_H
+473
View File
@@ -0,0 +1,473 @@
//
// ViewportPanel:场景渲染输出面板。
//
// tonemap 输出经 ImGui::Image 显示(Editor 模式),并提供
// Editor Viewport / Fullscreen 显示模式切换。面板可用区域
// (物理像素)上报为渲染分辨率 → 画面精确填满面板。
//
// 点击拾取(UE 式 GPU HitProxy):左键单击 → 鼠标 NDC 经
// Renderer::RequestPick 请求下一帧 id 渲染 + 像素拷贝;Draw 每帧
// 轮询 TakePickResult 取走结果。像素级精度,与视觉画面一致
//(视觉几何语义:与 Collider 无关,无 collider 实体同样可选中)。
//
#include "ViewportPanel.h"
#include <imgui.h>
#include <ImGuizmo.h>
#include <Engine/ECS/Components.h> // MeshRenderer / Name / RigidBody(日志对照)
#include <Engine/Renderer/Renderer.h> // RequestPick / TakePickResult 门面
#include <Engine/Renderer/SceneTypes.h> // kPickMiss
#include <Engine/Core/Log.h> // 命中打印
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <cstdio>
// GTX 扩展(实验性)需显式开启:matrix_decompose(矩阵→SRT 分解,
// 旋转以四元数输出,直接写回 TransformComponent)
#define GLM_ENABLE_EXPERIMENTAL
#include <glm/gtx/matrix_decompose.hpp>
using namespace Engine; // u32 / f32(Engine/Core/Types.h)
namespace
{
// Ctrl 吸附步长(UE Grid Snap 语义:按住 Ctrl 拖拽才吸附)
constexpr float kSnapTranslate = 0.25f; // 米/格
constexpr float kSnapRotateDeg = 15.0f; // 度
constexpr float kSnapScale = 0.1f; // 缩放步进
// 平移拖拽灵敏度下限:sinθ(轴与"相机→物体"连线夹角)。低于该值
// 时拖拽投影系数 <0.25 且激活平面与鼠标射线近共面——视觉上
// "看得见、抓了没反应"。≈14.5°,略宽于库默认可见角(≈11.5°),
// 只藏真正病态的轴,不动可用轴
constexpr float kMoveMinSin = 0.25f;
// 缩放退化轴裁剪(防误拖动)阈值。库内缩放 hover/拖拽依赖
// "法线=轴"平面求交,两种病态几何:
// - 轴⊥"相机→物体"连线(正对视角无横偏):交点分子 numer =
// -dist·dot(camToObj, 轴) ≡ 0 → 杆抓不到、数值随机猛跳
// - 轴∥视线(轴指向相机):屏幕零位移 → 拖了数值不变
constexpr float kScaleMinDot = 0.1f; // ≈84.3°
constexpr float kScaleMaxFwdDot = 0.98f; // ≈11.5°,与库藏杆角一致
// Vulkan proj → ImGuizmo 期望的 OpenGL 形式(Y-up、Z∈[-1,1]):
// Y 行翻回(抵消 Vulkan clip Y 向下翻转);z 行 = 2*z - w 即
// 深度 [0,1] → [-1,1]。glm 列主序 m[col][row],逐列改 row1/row2
glm::mat4 ProjVulkanToGL(const glm::mat4& projVulkan)
{
glm::mat4 p = projVulkan;
for (int c = 0; c < 4; ++c) {
p[c][1] *= -1.0f;
p[c][2] = 2.0f * p[c][2] - p[c][3];
}
return p;
}
// 视图相机分派(必须与 SceneRenderer::BuildFrame 渲染源同一规则,
// 否则 Gizmo 杆与画面错位):编辑器形态 = EditorCamera(Scene view
// 专用,与相机实体无关);游戏形态(kDisplayDirect 全屏预览或 PIE
// 内嵌播放)= primary 相机实体。
// 返回 nullptr = 游戏形态且无 primary 相机(本帧无 Gizmo)
Engine::Camera* ViewCamera(const EditorContext& ctx,
ECS::TransformComponent*& outT)
{
const bool playing = ctx.playState && *ctx.playState;
if (!playing && ctx.renderer->GetDisplayMode() == kDisplayViewport)
{
outT = &ctx.editorCamera->GetTransform();
return &ctx.editorCamera->GetController();
}
auto* cc = ctx.scene->GetPrimaryCamera(&outT);
return cc ? &cc->camera : nullptr;
}
}
std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
ViewportInputState &out,
GizmoState &gizmo,
const entt::entity selected) const {
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
std::optional<entt::entity> picked;
{
ImGui::Begin("Viewport", nullptr, ImGuiWindowFlags_NoCollapse);
// 输入焦点上报(悬停计入遮挡:模态框盖住时不误报)
out.hovered = ImGui::IsWindowHovered();
out.focused = ImGui::IsWindowFocused();
auto &renderer = *ctx.renderer;
const int mode = renderer.GetDisplayMode();
// ---- 拾取结果轮询(先于新请求:先取走上帧结果再发新请求)。
// 拷贝随上一帧命令缓冲提交,TakePickResult 内 waitIdle 保证
// 暂存可见(仅拷贝就绪帧阻塞,点击低频不可感知)----
if (auto bits = renderer.TakePickResult()) {
auto &reg = ctx.scene->GetRegistry();
const entt::entity hit = static_cast<entt::entity>(*bits);
if (*bits != kPickMiss && reg.valid(hit)) {
picked = hit;
LogPick(ctx, hit);
} else {
// 空白(kPickMiss)或悬空位模式:清选中(与旧语义一致)
picked = entt::null;
if (*bits == kPickMiss) {
ENGINE_INFO("[pick] miss (sky/empty)");
} else {
ENGINE_WARN("[pick] dangling entity bits {:#x}", *bits);
}
}
}
const void *sceneTex = renderer.GetSceneTexture();
if (sceneTex != nullptr && mode == kDisplayViewport) {
// 面板可用区域(物理像素)上报为渲染分辨率 → 画面精确填满
// 面板;下一帧帧首 BeginFrameSync 生效(一帧延迟,不可见)
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 fb = ImGui::GetIO().DisplayFramebufferScale;
const u32 wantW = static_cast<u32>(avail.x * fb.x + 0.5f);
const u32 wantH = static_cast<u32>(avail.y * fb.y + 0.5f);
if (wantW >= 16 && wantH >= 16) {
renderer.SetViewportSize(wantW, wantH);
}
// 纹理 = 渲染分辨率:等比适配面板可用区域
const vk::Extent2D ext = renderer.GetRenderExtent();
const f32 aspect =
ext.height != 0 ? f32(ext.width) / f32(ext.height) : 1.0f;
ImVec2 size = avail;
if (avail.x / avail.y > aspect) size.x = avail.y * aspect;
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 拦游戏侧,
// 但 ImGui 自身仍收键,故必须限定持焦面板。RMB 按住 = 相机
// 飞行模式(键盘归相机),工具热键全部抑制——W 前进与
// W 平移 Gizmo 的键位冲突就此解耦 ----
auto &reg = ctx.scene->GetRegistry();
ECS::TransformComponent *tr =
selected != entt::null
? reg.try_get<ECS::TransformComponent>(selected)
: nullptr;
// PIE 运行态:输入归游戏(脚本读 GLFW 轮询),Gizmo/编辑器
// 热键全部让位(UE Play 语义:Scene view 编辑操作冻结)
const bool playing = ctx.playState && *ctx.playState;
if (!playing && out.focused && !ImGui::GetIO().WantTextInput &&
!ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
if (ImGui::IsKeyPressed(ImGuiKey_W)) gizmo.op = ImGuizmo::TRANSLATE;
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;
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());
}
}
// ---- Gizmo 操纵杆(ImGui drawlist 叠加,无 GPU pass)----
// 选中实体 + 有 Transform 才出杆;分解 world 矩阵经
// SetWorldPose 写回(与 Inspector 同源,下一帧数值自动跟随)
// PIE 运行态不出杆(编辑操作冻结)
if (tr != nullptr && !playing) {
// 相机矩阵:跟随渲染源(编辑器形态 = EditorCamera,游戏
// 形态 = primary 相机实体;与 SceneRenderer::BuildFrame
// 同一分派),投影参数由相机自身承载(与渲染侧一致)
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));
ImGuizmo::SetDrawlist(ImGui::GetWindowDrawList());
// 杆画在 Image 区域内(等比适配后的 item rect)
const ImVec2 mn = ImGui::GetItemRectMin();
ImGuizmo::SetRect(mn.x, mn.y, size.x, size.y);
// 不调 SetPlaneLimit/SetAxisLimit——可见度保持库默认
glm::mat4 m = tr->World();
// 操作空间:local(模型轴,旋转/缩放随物体朝向直观)/
// world(T 键切换)。无父层级时两种模式 world 矩阵同源,
// decompose 写回链路不变
const ImGuizmo::MODE mode =
gizmo.localSpace ? ImGuizmo::LOCAL : ImGuizmo::WORLD;
const glm::mat3 rotM(m);
// 退化轴裁剪(防误拖动):被裁轴不可见、不可激活,
// 转个视角即可用。三操作病态几何不同,按 op 分派:
// - 平移:拖拽灵敏度 = sinθ(轴与相机连线夹角),θ→0
// 时鼠标位移在轴上投影→0("数值不变")
// - 缩放:库内 hover/拖拽依赖"法线=轴"平面求交——
// 轴⊥相机连线(正对无横偏)时交点分子 numer≡0
// (抓不到/数值乱跳);轴∥视线时屏幕零位移(数值
// 不变)。两判据并集裁剪
// - 旋转:屏幕空间角度数学,无退化,不裁
const bool isTranslate = (gizmo.op
& int(ImGuizmo::TRANSLATE)) == int(ImGuizmo::TRANSLATE);
const bool isScale = (gizmo.op
& int(ImGuizmo::SCALE)) == int(ImGuizmo::SCALE);
if (isTranslate || isScale) {
bool maskAxis[3] = {false, false, false};
const glm::vec3 camPos(glm::inverse(view)[3]);
glm::vec3 camToObj = glm::vec3(m[3]) - camPos;
const float camDist = glm::length(camToObj);
if (camDist > 1e-4f) {
camToObj /= camDist;
const glm::vec3 viewFwd = -glm::normalize(
glm::vec3(view[0][2], view[1][2],
view[2][2]));
for (int i = 0; i < 3; ++i) {
const glm::vec3 axis = gizmo.localSpace
? glm::normalize(rotM[i])
: glm::vec3(float(i == 0), float(i == 1),
float(i == 2));
maskAxis[i] = isScale
? glm::abs(glm::dot(camToObj, axis))
< kScaleMinDot ||
glm::abs(glm::dot(viewFwd, axis))
> kScaleMaxFwdDot
: glm::length(glm::cross(camToObj, axis))
< kMoveMinSin;
}
}
ImGuizmo::SetAxisMask(maskAxis[0], maskAxis[1],
maskAxis[2]);
}
// Ctrl 吸附(UE Grid Snap 语义)。库的约定
//(docs/documentation.md):平移 = 世界单位 float[3]
// 逐轴;旋转 = snap[0] 按度(库内自行 DEG2RAD,传弧
// 度会被解释成极小步进);缩放 = 只取 snap[0]
float snap[3] = {0.0f, 0.0f, 0.0f};
const bool snapping = ImGui::GetIO().KeyCtrl;
if (snapping) {
if (gizmo.op == ImGuizmo::TRANSLATE) {
snap[0] = snap[1] = snap[2] = kSnapTranslate;
} else if (gizmo.op == ImGuizmo::ROTATE) {
snap[0] = kSnapRotateDeg; // 度,非弧度
} else if (gizmo.op == ImGuizmo::SCALE) {
snap[0] = kSnapScale;
}
}
ImGuizmo::Manipulate(&view[0][0], &projGL[0][0],
static_cast<ImGuizmo::OPERATION>(gizmo.op),
mode, &m[0][0], nullptr,
snapping ? snap : nullptr);
gizmo.dragging = ImGuizmo::IsUsing();
// 拖拽生效:分解 world 矩阵 → world SRT → 沿父链反解
// local 写回(SetWorldPose;根实体 = 直写)。旋转用
// decompose 出的 quat 直写(UE FTransform 语义)——
// 不经欧拉往返,拖拽零漂移、无万向锁
if (ImGuizmo::IsUsing()) {
glm::vec3 scale, transl, skew;
glm::quat rot;
glm::vec4 persp;
if (glm::decompose(m, scale, rot, transl, skew, persp)) {
ctx.scene->SetWorldPose(selected, transl, rot,
scale);
}
}
}
}
// Gizmo 状态提示(左上角 drawlist 叠加,原因同下:不占布局行)
// PIE 运行态 = 黄色 PLAYING/PAUSED 提示替代编辑状态
{
char tip[128];
if (playing) {
const bool paused = ctx.pausedState && *ctx.pausedState;
std::snprintf(tip, sizeof(tip),
paused ? "PAUSED - Ctrl+P to stop"
: "PLAYING - Ctrl+P to stop");
} else {
std::snprintf(tip, sizeof(tip),
"Space: %s [T] Snap: Ctrl F: Focus "
"Ctrl+Shift+F: To Selection",
gizmo.localSpace ? "Local" : "World");
}
const ImVec2 imgMin = ImGui::GetItemRectMin();
ImGui::GetWindowDrawList()->AddText(
ImVec2(imgMin.x + 8.0f, imgMin.y + 8.0f),
playing ? IM_COL32(255, 184, 46, 220)
: IM_COL32(255, 255, 255, 150),
tip);
}
// Dynamic 刚体提示:物理每帧把姿态写回 Transform,拖拽即刻
// 被覆盖(Static/Kinematic 编辑器权威,可直接拖)。
// 用 drawlist 叠加而非 ImGui::TextDisabled:后者在 Image 下方
// 占一行布局 → 滚动条出现/消失循环 → avail 抖动 → 渲染分辨率
// 每帧重报(视口疯狂顶动);且默认字体无中文字形(显示 ?)
if (playing) {
// 运行态无 primary 相机实体:渲染回退编辑器视角(保持帧
// 管线节拍),提示补相机
ECS::TransformComponent *camT = nullptr;
if (ViewCamera(ctx, camT) == nullptr) {
const ImVec2 imgMin = ImGui::GetItemRectMin();
ImGui::GetWindowDrawList()->AddText(
ImVec2(imgMin.x + 8.0f,
imgMin.y + size.y * 0.5f),
IM_COL32(255, 120, 80, 230),
"No primary camera entity - showing editor "
"view. Add a Camera component and check "
"'Primary'");
}
}
// Dynamic 刚体提示(drawlist 叠加,理由同上)
if (selected != entt::null) {
if (const auto *rb = reg.try_get<ECS::RigidBodyComponent>(selected);
rb != nullptr &&
rb->type == ECS::RigidBodyComponent::BodyType::Dynamic) {
const ImVec2 imgMin = ImGui::GetItemRectMin();
ImGui::GetWindowDrawList()->AddText(
ImVec2(imgMin.x + 8.0f, imgMin.y + size.y - 20.0f),
IM_COL32(255, 255, 255, 160),
"(Dynamic body: transform driven by physics)");
}
}
// 点击拾取:单击 Image 且未命中 UI 遮挡(IsItemHovered 已含)。
// 抓杆时不出 pick(IsOver 悬停杆/IsUsing 拖拽中)——否则按在
// 操纵杆上的点击会穿透发拾取请求。PIE 运行态不出拾取:
// 点击 = 游戏输入(UE Play 语义,编辑操作冻结)
if (!playing && ImGui::IsItemHovered() && ImGui::IsMouseClicked(0) &&
!ImGuizmo::IsOver() && !ImGuizmo::IsUsing()) {
RequestPickAt(ctx, size);
}
} else {
ImGui::TextDisabled("(scene texture not ready)");
}
ImGui::End();
}
ImGui::PopStyleVar();
return picked;
}
void ViewportPanel::RequestPickAt(const EditorContext &ctx,
const ImVec2 &size) const {
// 鼠标在 Image 内的位置 → NDC。Vulkan NDC y 与屏幕 v 同向
//(向下为正,与渲染同构;skybox.frag 反投影交叉验证过方向)
const ImVec2 mouse = ImGui::GetMousePos();
const ImVec2 mn = ImGui::GetItemRectMin();
const glm::vec2 ndc{
(mouse.x - mn.x) / size.x * 2.0f - 1.0f,
(mouse.y - mn.y) / size.y * 2.0f - 1.0f,
};
// UE 式 GPU HitProxy:下一帧 id 渲染 + 请求像素 1×1 拷贝
ctx.renderer->RequestPick(ndc);
}
void ViewportPanel::LogPick(const EditorContext &ctx, entt::entity e) {
// 命中详情:实体名;有物理体则附 collider 对照(诊断"物理盒 ≠
// 视觉"的错位,如 M1911 的默认 0.5 半尺寸盒)
auto &reg = ctx.scene->GetRegistry();
const auto &name = reg.get<ECS::NameComponent>(e).name;
if (const auto *rb = reg.try_get<ECS::RigidBodyComponent>(e)) {
const auto &tr = reg.get<ECS::TransformComponent>(e);
ENGINE_INFO("[pick] '{}' (hitproxy) | collider={} type={} "
"boxHalf=({:.2f},{:.2f},{:.2f}) sphereR={:.2f} "
"bodyPos=({:.2f},{:.2f},{:.2f})",
name,
rb->collider == ECS::RigidBodyComponent::ColliderType::Box
? "box" : "sphere",
rb->type == ECS::RigidBodyComponent::BodyType::Static
? "static" : "dynamic",
rb->boxHalfExtents.x, rb->boxHalfExtents.y,
rb->boxHalfExtents.z, rb->sphereRadius,
tr.GetWorldPosition().x, tr.GetWorldPosition().y, tr.GetWorldPosition().z);
} else {
ENGINE_INFO("[pick] '{}' (hitproxy) | (no collider)", name);
}
}
+59
View File
@@ -0,0 +1,59 @@
//
// ViewportPanel:场景渲染输出面板。
//
// tonemap 输出经 ImGui::Image 显示(Editor 模式),并提供
// Editor Viewport / Fullscreen 显示模式切换。面板可用区域
// (物理像素)上报为渲染分辨率 → 画面精确填满面板。
//
// 点击拾取(UE 式 GPU HitProxy):单击 → NDC 请求 id 渲染;结果
// 由 Draw 每帧轮询取走(像素级,与视觉画面一致)。
//
#ifndef HELLOVULKAN_VIEWPORTPANEL_H
#define HELLOVULKAN_VIEWPORTPANEL_H
#include "EditorContext.h"
#include <imgui.h> // ImVec2(RequestPickAt 签名)
#include <entt/entt.hpp>
#include <optional>
// Viewport 面板每帧上报的输入焦点状态(Hazel 模式:编辑器据此决定
// 游戏侧输入是否放行——悬停/持焦 Viewport 才拥有 WASD/鼠标)
struct ViewportInputState {
bool hovered = false; // 鼠标悬停在 Viewport 面板上
bool focused = false; // Viewport 面板持有键盘焦点
};
// Gizmo 交互状态(Editor 持有、ViewportPanel 写):
// op 取 ImGuizmo::OPERATION 位掩码(TRANSLATE=7=三轴位或运算,非 0
// 起始枚举;0 = 无任何轴位 → Gizmo 完全不绘制),int 存储避免本
// 头文件 include ImGuizmo.h(拖入 imgui 宏依赖顺序)
// W/E/R 切换 op、T 切换 local/world 空间(UE 语义);dragging 拉起
// Editor::BlockingMouse 门控
struct GizmoState {
int op = 7; // ImGuizmo::TRANSLATE(TRANSLATE_X|Y|Z)
bool localSpace = true; // 操纵空间:true=模型轴(旋转/缩放语义),
// T 键切 world
bool dragging = false; // 拖拽中(IsUsing)→ 鼠标归 Gizmo 不给游戏侧
};
class ViewportPanel {
public:
// 返回值:本帧拾取命中的实体(无点击/未命中 = nullopt 或 null);
// Editor 据此 SelectEntity(面板不持有选中状态)
std::optional<entt::entity> Draw(const EditorContext& ctx,
ViewportInputState& out,
GizmoState& gizmo,
entt::entity selected) const;
private:
// Image 区域内的鼠标位置 → NDC → Renderer::RequestPick
//(结果下一帧由 Draw 轮询 TakePickResult 取走)
void RequestPickAt(const EditorContext& ctx, const ImVec2& size) const;
// 命中打印:实体名 + collider 参数对照(诊断物理盒 ≠ 视觉用)
static void LogPick(const EditorContext& ctx, entt::entity e);
};
#endif //HELLOVULKAN_VIEWPORTPANEL_H