From cc837bc605630e172b6fcb31c094cfe7035c1413 Mon Sep 17 00:00:00 2001 From: atdunbg Date: Wed, 7 Oct 2026 01:50:04 +0800 Subject: [PATCH] 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 --- App/CMakeLists.txt | 8 +- App/editor/AssetBrowserPanel.cpp | 63 +- App/editor/ControllerEditorPanel.cpp | 786 +++++++++++++++++ App/editor/ControllerEditorPanel.h | 58 ++ App/editor/Editor.cpp | 12 + App/editor/Editor.h | 5 + App/editor/EditorContext.h | 12 + App/editor/InspectorPanel.cpp | 834 +++++++++++++++++- App/editor/MaterialAssetUtil.h | 173 ++++ App/editor/MaterialEditorPanel.cpp | 321 +++++++ App/editor/MaterialEditorPanel.h | 60 ++ App/editor/ViewportPanel.cpp | 98 +- App/main.cpp | 27 +- CMakeLists.txt | 6 + Engine/CMakeLists.txt | 15 + .../Engine/Animation/AnimationAttach.h | 59 ++ .../Engine/Animation/AnimationController.h | 146 +++ .../Engine/Animation/AnimationEvaluator.h | 97 ++ .../Engine/Animation/AnimationSystem.h | 75 ++ Engine/include/Engine/Animation/HumanoidRig.h | 71 ++ Engine/include/Engine/Assets/AssetManager.h | 73 +- Engine/include/Engine/Assets/IAssetUploader.h | 12 + Engine/include/Engine/Core/AssetRef.h | 2 + Engine/include/Engine/ECS/Components.h | 95 +- Engine/include/Engine/ImGui/ImGuiLayer.h | 15 + Engine/include/Engine/Renderer/Camera.h | 44 +- Engine/include/Engine/Renderer/Material.h | 42 + .../include/Engine/Renderer/MaterialAsset.h | 96 ++ Engine/include/Engine/Renderer/Model.h | 48 + .../Engine/Renderer/Passes/EditorGizmoPass.h | 74 ++ .../Engine/Renderer/Passes/ForwardPass.h | 8 +- .../Engine/Renderer/Passes/HitProxyPass.h | 17 + .../Engine/Renderer/Passes/ImGuiPass.h | 9 +- .../Renderer/Passes/MaterialPreviewPass.h | 279 ++++++ .../include/Engine/Renderer/Passes/SceneGpu.h | 24 +- Engine/include/Engine/Renderer/Renderer.h | 59 ++ .../include/Engine/Renderer/SceneRenderer.h | 65 +- Engine/include/Engine/Renderer/SceneTypes.h | 78 +- .../Engine/Renderer/VulkanAssetUploader.h | 14 + Engine/include/Engine/Vulkan/Texture.h | 7 + Engine/include/Engine/Vulkan/UniformBuffer.h | 5 + Engine/src/Animation/AnimationAttach.cpp | 223 +++++ Engine/src/Animation/AnimationController.cpp | 390 ++++++++ Engine/src/Animation/AnimationEvaluator.cpp | 226 +++++ Engine/src/Animation/AnimationSystem.cpp | 573 ++++++++++++ Engine/src/Animation/HumanoidRig.cpp | 246 ++++++ Engine/src/Assets/AssetManager.cpp | 120 +-- Engine/src/ImGui/ImGuiLayer.cpp | 24 +- Engine/src/Renderer/Camera.cpp | 156 ++-- Engine/src/Renderer/Material.cpp | 38 +- Engine/src/Renderer/MaterialAsset.cpp | 185 ++++ Engine/src/Renderer/Model.cpp | 196 +++- .../src/Renderer/Passes/EditorGizmoPass.cpp | 322 +++++++ Engine/src/Renderer/Passes/ForwardPass.cpp | 37 +- Engine/src/Renderer/Passes/HitProxyPass.cpp | 93 ++ Engine/src/Renderer/Passes/ImGuiPass.cpp | 9 +- .../Renderer/Passes/MaterialPreviewPass.cpp | 405 +++++++++ Engine/src/Renderer/Passes/SceneGpu.cpp | 68 +- Engine/src/Renderer/Renderer.cpp | 197 ++++- Engine/src/Renderer/SceneRenderer.cpp | 706 +++++++++------ Engine/src/Renderer/VulkanAssetUploader.cpp | 58 ++ Engine/src/Renderer/VulkanAssetUploader.h | 63 ++ Engine/src/Serialization/SceneSerializer.cpp | 130 ++- Engine/src/Vulkan/Texture.cpp | 21 + Engine/src/Vulkan/UniformBuffer.cpp | 6 + shaders/gizmo_billboard.frag | 84 ++ shaders/gizmo_billboard.vert | 22 + shaders/gizmo_pick.frag | 12 + shaders/gizmo_pick.vert | 21 + shaders/simple_shader.frag | 46 +- shaders/simple_shader_translucent.frag | 77 +- 71 files changed, 8198 insertions(+), 548 deletions(-) create mode 100644 App/editor/ControllerEditorPanel.cpp create mode 100644 App/editor/ControllerEditorPanel.h create mode 100644 App/editor/MaterialAssetUtil.h create mode 100644 App/editor/MaterialEditorPanel.cpp create mode 100644 App/editor/MaterialEditorPanel.h create mode 100644 Engine/include/Engine/Animation/AnimationAttach.h create mode 100644 Engine/include/Engine/Animation/AnimationController.h create mode 100644 Engine/include/Engine/Animation/AnimationEvaluator.h create mode 100644 Engine/include/Engine/Animation/AnimationSystem.h create mode 100644 Engine/include/Engine/Animation/HumanoidRig.h create mode 100644 Engine/include/Engine/Renderer/MaterialAsset.h create mode 100644 Engine/include/Engine/Renderer/Passes/EditorGizmoPass.h create mode 100644 Engine/include/Engine/Renderer/Passes/MaterialPreviewPass.h create mode 100644 Engine/src/Animation/AnimationAttach.cpp create mode 100644 Engine/src/Animation/AnimationController.cpp create mode 100644 Engine/src/Animation/AnimationEvaluator.cpp create mode 100644 Engine/src/Animation/AnimationSystem.cpp create mode 100644 Engine/src/Animation/HumanoidRig.cpp create mode 100644 Engine/src/Renderer/MaterialAsset.cpp create mode 100644 Engine/src/Renderer/Passes/EditorGizmoPass.cpp create mode 100644 Engine/src/Renderer/Passes/MaterialPreviewPass.cpp create mode 100644 Engine/src/Renderer/VulkanAssetUploader.h create mode 100644 shaders/gizmo_billboard.frag create mode 100644 shaders/gizmo_billboard.vert create mode 100644 shaders/gizmo_pick.frag create mode 100644 shaders/gizmo_pick.vert diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 84bf4ff..40524ec 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -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) diff --git a/App/editor/AssetBrowserPanel.cpp b/App/editor/AssetBrowserPanel.cpp index 6e4c6ec..9945318 100644 --- a/App/editor/AssetBrowserPanel.cpp +++ b/App/editor/AssetBrowserPanel.cpp @@ -3,6 +3,8 @@ // #include "AssetBrowserPanel.h" +#include "MaterialEditorPanel.h" // kMaterialDragPayload + 双击打开材质编辑器 +#include "ControllerEditorPanel.h" // kControllerDragPayload + 双击打开控制器编辑器 #include @@ -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(item.kind)]); namespace fs = std::filesystem; std::error_code ec; diff --git a/App/editor/ControllerEditorPanel.cpp b/App/editor/ControllerEditorPanel.cpp new file mode 100644 index 0000000..5654801 --- /dev/null +++ b/App/editor/ControllerEditorPanel.cpp @@ -0,0 +1,786 @@ +// +// ControllerEditorPanel 实现:见头文件注释。 +// + +#include "ControllerEditorPanel.h" + +#include +#include +#include + +#include + +#include +#include + +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 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(names.size()) - 1; + } + if (sel < 0) sel = 0; + if (ImGui::Combo(label, &sel, names.data(), + static_cast(names.size()))) + { + clip = model->GetClips()[static_cast(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(*ctx.selected)) + { + anim = a; + if (auto* mr = + reg.try_get( + *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(order.size())) + { + const auto& s = ctrl->GetStates().at(order[static_cast( + 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(m_SelState)]) + { + anim->CrossFade(order[static_cast(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> 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( + *ctx.selected)) + { + if (mr->model.Valid()) + { + model = ctx.assets->GetModel(mr->model); + } + } + } + + // 状态列表 + Add + int delIdx = -1; + for (int i = 0; i < static_cast(order.size()); ++i) + { + const bool isEntry = order[static_cast(i)] == + ctrl->GetEntryState(); + ImGui::PushID(i); + if (ImGui::Selectable( + (order[static_cast(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(order.size()) - 1; + s_NewName[0] = '\0'; + } + } + if (m_SelState >= static_cast(order.size())) + { + m_SelState = static_cast(order.size()) - 1; + } + if (m_SelState < 0 || order.empty()) + { + ImGui::TreePop(); + return; + } + + // ---- 选中状态详情 ---- + const std::string selName = order[static_cast(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(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(ei) + 1000); + ImGui::BulletText("%s", + st.transitionsOnEnd[ei].c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("x")) + { + st.transitionsOnEnd.erase( + st.transitionsOnEnd.begin() + + static_cast(ei)); + ImGui::PopID(); + break; + } + ImGui::PopID(); + } + if (!order.empty()) + { + static int s_OnEndSel = 0; + s_OnEndSel = std::clamp( + s_OnEndSel, 0, + static_cast(order.size()) - 1); + ImGui::SetNextItemWidth(120.0f); + ImGui::Combo( + "##onend", &s_OnEndSel, + [](void* d, int i) -> const char* { + return (*static_cast*>( + d))[static_cast(i)] + .c_str(); + }, + &order, static_cast(order.size())); + ImGui::SameLine(); + if (ImGui::SmallButton("+ exit")) + { + st.transitionsOnEnd.push_back( + order[static_cast(s_OnEndSel)]); + } + } + } + ImGui::TreePop(); + } + + // ---- 混合树分组 ---- + if (ImGui::TreeNode("Blend Tree")) + { + using BTType = Engine::Animation::BlendTree::Type; + // 树类型:Single 单 clip / 1D 阈值 / 2D 平面方向 + int tt = static_cast(st.tree.type); + if (ImGui::Combo("type", &tt, + "Single\0Directional 1D\0Directional 2D\0")) + { + st.tree.type = static_cast(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 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(pnames.size()); + ++k) + { + if (st.tree.xParam == + pnames[static_cast(k)]) + xi = k; + } + ImGui::SetNextItemWidth(140.0f); + if (ImGui::Combo("x param", &xi, pnames.data(), + static_cast(pnames.size()))) + { + st.tree.xParam = pnames[static_cast(xi)]; + } + } + if (st.tree.type == BTType::Directional2D) + { + int zi = 0; + for (int k = 0; k < static_cast(pnames.size()); + ++k) + { + if (st.tree.zParam == + pnames[static_cast(k)]) + zi = k; + } + ImGui::SetNextItemWidth(140.0f); + if (ImGui::Combo("z param", &zi, pnames.data(), + static_cast(pnames.size()))) + { + st.tree.zParam = pnames[static_cast(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(st.tree.children.size()); ++ci) + { + auto& c = st.tree.children[static_cast(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(delIdx)]; + states.erase(name); + order.erase(order.begin() + delIdx); + auto& trs = ctrl->TransitionsMut(); + for (int i = static_cast(trs.size()) - 1; i >= 0; --i) + { + if (trs[static_cast(i)].from == name || + trs[static_cast(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 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(snames.size()); ++k) + { + if (cur == snames[static_cast(k)]) sel = k; + } + ImGui::SetNextItemWidth(110.0f); + if (ImGui::Combo(label, &sel, snames.data(), + static_cast(snames.size()))) + { + cur = snames[static_cast(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(p.type); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::Combo("##ty", &ty, "float\0bool\0trigger\0")) + { + p.type = + static_cast(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(); +} diff --git a/App/editor/ControllerEditorPanel.h b/App/editor/ControllerEditorPanel.h new file mode 100644 index 0000000..041da35 --- /dev/null +++ b/App/editor/ControllerEditorPanel.h @@ -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 + +#include +#include + +// .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 m_CondBuf; // cond 输入缓冲(按行复用) +}; + +#endif //HELLOVULKAN_CONTROLLEREDITORPANEL_H diff --git a/App/editor/Editor.cpp b/App/editor/Editor.cpp index b000b46..a9a060a 100644 --- a/App/editor/Editor.cpp +++ b/App/editor/Editor.cpp @@ -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(); m_Stats = std::make_unique(); m_AssetBrowser = std::make_unique(); + m_MaterialEditor = std::make_unique(); + m_Ctx.materialEditor = m_MaterialEditor.get(); + m_ControllerEditor = std::make_unique(); + 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); } diff --git a/App/editor/Editor.h b/App/editor/Editor.h index 0b5bd5f..93ec0bf 100644 --- a/App/editor/Editor.h +++ b/App/editor/Editor.h @@ -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 m_Viewport; std::unique_ptr m_Stats; std::unique_ptr m_AssetBrowser; + std::unique_ptr m_MaterialEditor; + std::unique_ptr 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; diff --git a/App/editor/EditorContext.h b/App/editor/EditorContext.h index fbe518e..1ef843a 100644 --- a/App/editor/EditorContext.h +++ b/App/editor/EditorContext.h @@ -15,6 +15,12 @@ #include #include // kDisplayViewport / kDisplayDirect +#include + +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 相机 diff --git a/App/editor/InspectorPanel.cpp b/App/editor/InspectorPanel.cpp index 16b0f1a..87b2782 100644 --- a/App/editor/InspectorPanel.cpp +++ b/App/editor/InspectorPanel.cpp @@ -6,22 +6,38 @@ // #include "InspectorPanel.h" +#include "MaterialAssetUtil.h" // .material 枚举 + Create Override(与材质编辑器共享) +#include "MaterialEditorPanel.h" // 指派/编辑分离:双击槽位开独立面板 + +#include // 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 #include +#include #include +#include #include +#include + +#include "ControllerEditorPanel.h" #include #include #include 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 s_CtrlFiles; + static bool s_CtrlFilesCached = false; + void InvalidateControllerFiles() { s_CtrlFilesCached = false; } + const std::vector& 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& 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(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(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 opts; + opts.push_back(""); + 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(opts.size()); ++k) + { + if (std::strcmp(opts[static_cast(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(opts.size()))) + { + if (sel <= 0) + { + mr.materialOverrides.erase(i); + } + else + { + const char* chosen = opts[static_cast(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& 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(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(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( + 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( + 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(cam->camera.GetProjectionMode()); + if (ImGui::Combo("Projection", &pm, projModes, 2)) { - if (auto* tr = reg.try_get( - selected)) + cam->camera.SetProjectionMode( + static_cast(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 s_LastAttachRows; + // 时间拖动条的定格记忆:拖住瞬间是否在播(松手恢复用) + static bool s_ScrubWasPlaying = false; if (auto* anim = reg.try_get(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 copts; + copts.push_back(""); + 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(k) + 1; + } + } + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::Combo("##anim_ctrl_slot", &csel, copts.data(), + static_cast(copts.size()))) + { + anim->currentState.clear(); // 换绑 → 首帧回 entry + if (csel == 0) + { + anim->controllerPath.clear(); + } + else + { + const std::string& path = + cfiles[static_cast(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(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 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(f); + if (s_CreateOk) + { + f.write(yaml.data(), + static_cast( + 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(model->GetBoneCount()), + static_cast(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(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(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(s_LastAttachMatched), + static_cast(s_LastAttachRetargeted), + static_cast(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 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 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(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(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(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(clips.size())) - { - ImGui::Text("t: %.2f / %.2f s", anim->time, - clips[anim->clipIndex].duration); - } // 动画数据表:每个 clip 的时长与有效轨道数(attach 进来的 // 外部动画在此核对骨骼匹配结果——轨道数明显偏少 = 骨骼 // 命名不同源被跳过) diff --git a/App/editor/MaterialAssetUtil.h b/App/editor/MaterialAssetUtil.h new file mode 100644 index 0000000..970c794 --- /dev/null +++ b/App/editor/MaterialAssetUtil.h @@ -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 +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace EditorMaterialUtil { + + namespace fs = std::filesystem; + + // .material 文件枚举(会话级缓存 + 脏标)。inline 变量 = 所有 TU + // 共享同一份(Create Override 新建文件后置脏重扫) + inline std::vector s_MaterialFiles; + inline bool s_MaterialFilesDirty = true; + + inline void InvalidateMaterialFileList() { s_MaterialFilesDirty = true; } + + // 递归收集 assets/ 下纹理文件(材质编辑器纹理槽选择器;纹理文件 + // 不被编辑器创建,无需脏标)。会话级缓存 + inline const std::vector& AssetTextureFiles() + { + static std::vector 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& 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/_.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) + { + // 目标文件名:_.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(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 diff --git a/App/editor/MaterialEditorPanel.cpp b/App/editor/MaterialEditorPanel.cpp new file mode 100644 index 0000000..30b8dfc --- /dev/null +++ b/App/editor/MaterialEditorPanel.cpp @@ -0,0 +1,321 @@ +// +// MaterialEditorPanel.cpp:独立材质编辑器实现。 +// +// 编辑语义原样承接自原 Inspector "Edit Material" 内嵌块(行为不变的 +// 搬家):参数 EditParams 直写 UBO + IsItemDeactivatedAfterEdit 落盘; +// 纹理槽改动落盘 + ReloadMaterialAsset 重建(旧指针即刻悬空,本帧 +// 不再触碰 asset)。 +// + +#include "MaterialEditorPanel.h" +#include "MaterialAssetUtil.h" + +#include +#include + +#include + +#include +#include + +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(s)); + InputTextString(kSlotNames[s], tp); + if (ImGui::IsItemDeactivatedAfterEdit()) + { + pendingSlot = static_cast(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(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(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(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(s)); + InputTextString(kSlotNames[s], tp); + ImGui::PopID(); + } + ImGui::EndDisabled(); +} diff --git a/App/editor/MaterialEditorPanel.h b/App/editor/MaterialEditorPanel.h new file mode 100644 index 0000000..88ac0e5 --- /dev/null +++ b/App/editor/MaterialEditorPanel.h @@ -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 + +#include +#include + +// 材质资产拖拽 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 diff --git a/App/editor/ViewportPanel.cpp b/App/editor/ViewportPanel.cpp index 3242f2e..43cf2ef 100644 --- a/App/editor/ViewportPanel.cpp +++ b/App/editor/ViewportPanel.cpp @@ -143,6 +143,83 @@ std::optional 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(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 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 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 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 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(); diff --git a/App/main.cpp b/App/main.cpp index fa05dae..15c894a 100644 --- a/App/main.cpp +++ b/App/main.cpp @@ -24,10 +24,14 @@ #include #include -#include - +#include + +// 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(Input::Key::GraveAccent)) { SetImGuiEnabled(!GetImGuiEnabled()); } +#endif if (event.key == static_cast(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 m_Editor = std::make_unique(); +#endif }; } diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d6b874..c9971c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,12 @@ add_subdirectory(Lib/yaml-cpp) # Vulkan find_package(Vulkan REQUIRED) +# ---- editor/game 编译期分离(UE WITH_EDITOR / Unity UNITY_EDITOR 模式)---- +# ON(默认):HELLO_EDITOR=1,材质球预览 tile 池 + App/editor 编辑器 UI 全量编译 +# OFF:game 构建——预览 pass/atlas/请求池、previewEnvSets、App/editor 源码 +# 整体不进编译产物(宏裁剪,非运行时判断) +option(HELLOVULKAN_EDITOR "Build editor modules (material preview, inspector UI)" ON) + if(MINGW) set(CMAKE_CXX_STANDARD_LIBRARIES "${CMAKE_CXX_STANDARD_LIBRARIES} -Wl,-Bdynamic -lucrt") diff --git a/Engine/CMakeLists.txt b/Engine/CMakeLists.txt index 8ebeb86..43507cb 100644 --- a/Engine/CMakeLists.txt +++ b/Engine/CMakeLists.txt @@ -50,6 +50,15 @@ endif() # ============ Engine ============ file(GLOB_RECURSE ENGINE_SOURCES CONFIGURE_DEPENDS src/*.cpp) + +# game 构建(HELLOVULKAN_EDITOR=OFF):editor 专属 pass 整 TU 不进产物 +#(材质球预览 / Editor gizmo——对应头文件只含声明,无符号产出) +if(NOT HELLOVULKAN_EDITOR) + list(REMOVE_ITEM ENGINE_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/src/Renderer/Passes/MaterialPreviewPass.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/Renderer/Passes/EditorGizmoPass.cpp) +endif() + add_library(Engine STATIC ${ENGINE_SOURCES}) # PhysicsDebugDraw 派生 Jolt::DebugRendererSimple:Jolt 以 -fno-rtti 编译 @@ -79,3 +88,9 @@ target_link_libraries(Engine PUBLIC tracy) target_compile_definitions(Engine PRIVATE VULKAN_HPP_DISPATCH_LOADER_DYNAMIC=1) + +# editor/game 编译期分离:HELLO_EDITOR=1 时预览子系统 + 编辑器消费端编译 +#(PUBLIC——App 层 main.cpp 的编辑器装配同样按此宏裁剪) +if(HELLOVULKAN_EDITOR) + target_compile_definitions(Engine PUBLIC HELLO_EDITOR=1) +endif() diff --git a/Engine/include/Engine/Animation/AnimationAttach.h b/Engine/include/Engine/Animation/AnimationAttach.h new file mode 100644 index 0000000..b43fd1e --- /dev/null +++ b/Engine/include/Engine/Animation/AnimationAttach.h @@ -0,0 +1,59 @@ +// +// AnimationAttach:外部动画资产 → 目标模型并入(编辑器 attach 语义, +// 对应 UE "给 SkeletalMesh 指派 AnimSequence" 的跨资产绑定)。 +// +// 资产语义:纯动画 FBX(只有骨骼动画无网格)= 引擎的 AnimSequence +// 等价物。轨道映射两级:① 骨骼名同名直映(同源拆分资产零损耗); +// ② 名字不同源时走 HumanoidRig 槽位重定向(Unity Avatar 语义, +// 对每个映射骨骼烘焙 rest→anim 的旋转增量到目标骨架局部空间)。 +// 两级都落不上的轨道跳过并计数。 +// +// 并入只改运行时内存状态:持久化走场景里 Animation.attachedAnimPaths +//(重启后按列表幂等重放 attach),模型资产本身永不被回写。 +// + +#ifndef HELLOVULKAN_ANIMATIONATTACH_H +#define HELLOVULKAN_ANIMATIONATTACH_H + +#include + +#include +#include + +namespace Engine { + + class Model; + struct ParsedModel; + + namespace Animation { + + // 重定向明细行(调试:哪个源骨骼经哪个槽位映射到哪个目标骨骼)。 + // slot 为静态字符串(HumanoidSlotName 表),名字拷贝 + struct RetargetRow { + const char* slot = nullptr; + std::string srcName; + std::string dstName; + }; + + // attach 结果遥测(Inspector "匹配/跳过" 显示用; + // 幂等命中/解析失败 = 全 0) + struct AttachResult { + u32 clipsAdded = 0; + u32 channelsMatched = 0; // 同名直映轨道数 + u32 channelsRetargeted = 0; // 槽位重定向轨道数 + u32 channelsSkipped = 0; + std::vector retargetRows; // 重定向明细(调试) + }; + + // 把 src 的 clips 按目标模型 node 表重映射后追加。 + // 幂等:sourcePath 已并入过(自愈重放/重复 attach)→ 零操作。 + // src 数据被消费(move 语义);无 GPU 依赖,纯 CPU + AttachResult AttachClipsToModel(Model& target, ParsedModel&& src, + const std::string& sourcePath); + + } // namespace Animation + + +} // namespace Engine + +#endif diff --git a/Engine/include/Engine/Animation/AnimationController.h b/Engine/include/Engine/Animation/AnimationController.h new file mode 100644 index 0000000..8be641f --- /dev/null +++ b/Engine/include/Engine/Animation/AnimationController.h @@ -0,0 +1,146 @@ +// +// AnimationController:动画状态机资产(.animctrl,YAML)——Unity +// AnimatorController / UE AnimBlueprint 数据侧的对应物。 +// +// 职责:纯数据资产(参数声明 / 状态表 / 混合树 / 过渡规则),零求值 +// 逻辑零运行时状态。clip 按"名字"引用(不持骨骼数据)——同一 +// Controller 可复用到任何 clip 名匹配的模型(缺 clip 降级跳过该层, +// 不崩)。加载走 AssetManager 缓存(模式同 MaterialAsset)。 +// +// 与求值的接缝:AnimationSystem 每帧把"当前状态 + 组件参数值"翻译成 +// PoseLayer[](本资产只提供规则查询),N 层混合在 AnimationEvaluator。 +// + +#ifndef HELLOVULKAN_ANIMATIONCONTROLLER_H +#define HELLOVULKAN_ANIMATIONCONTROLLER_H + +#include + +#include + +#include +#include +#include + +namespace Engine::Animation { + + // 混合树子节点:clip 名 + 2D 平面定位点 + struct BlendTreeChild { + std::string clip; // clip 名(运行时按名查 Model::GetClips) + glm::vec2 pos{0.0f}; // 2D 平面定位(1D 时 x=阈值,y 忽略) + }; + + // 状态的运动源:单 clip 直采 or 混合树 + struct BlendTree { + enum class Type : u8 { + Single = 0, // 直接播一个 clip + Directional1D = 1,// 一轴阈值插值(Speed 站走跑) + Directional2D = 2,// 2D 方向混合(MoveX/MoveZ → 8 方向) + }; + Type type = Type::Single; + std::string clip; // Single:clip 名 + std::string xParam; // 1D/2D:横轴参数名 + std::string zParam; // 2D:纵轴参数名 + std::vector children; + }; + + // 过渡条件(Load 期解析 cond 字符串,运行时只做数值比较) + struct TransitionCond { + enum class Op : u8 { Gt, Lt, Ge, Le, Eq, Ne }; + std::string param; + Op op = Op::Gt; + f32 numOperand = 0.0f; // float 比较 + bool boolOperand = false; // bool 比较(true/false) + bool isBool = false; // bool 语义(含 trigger 名命中即真) + bool always = false; // cond 空 = 恒真(无条件出边) + bool valid = false; // 解析失败 = false(永不触发) + }; + + struct Transition { + std::string from; // "*" = AnyState + std::string to; + TransitionCond cond; + f32 fade = 0.25f; // 过渡时长(秒) + bool hasExitTime = false; // true = 当前状态非循环播完一轮才评估 + }; + + struct ControllerState { + std::string name; + BlendTree tree; + f32 speed = 1.0f; // 状态内播放速度倍率(乘组件 speed) + bool loop = true; // false = 播完一轮即停(transitionsOnEnd 生效) + std::vector transitionsOnEnd; // 播完出口(按序首个可用) + }; + + // 参数声明(类型 + 默认值;实例值在 AnimationComponent) + struct ControllerParam { + enum class Type : u8 { Float = 0, Bool = 1, Trigger = 2 }; + std::string name; + Type type = Type::Float; + f32 defFloat = 0.0f; + bool defBool = false; + }; + + // 2D 方向混合树权重求解(单一事实源:System 运行时采样与编辑器 + // 方向盘可视化共用同一套数学)——原点子(|pos|<ε)权重 = 1-|v|, + // 方向子找覆盖 v 角度的相邻扇区做角度插值 ×min(|v|,1)。只回有权重 + // >0 的子节点(下标入 children + 权重)。依赖 children 已按 Load + // 期角度排序 + void EvaluateBlendTree2D(const BlendTree& tree, f32 x, f32 z, + std::vector>& outWeights); + + class AnimationController { + public: + // .animctrl YAML 解析;失败返回 nullptr + ERROR 日志。 + // Version 缺省键向后兼容(同 MaterialAsset 惯例) + static Scope Load(const std::string& path); + // 写回 .animctrl(编辑器 Save 用:与 Load 对齐的 YAML 反序列化; + // 失败返回 false + ERROR 日志,缓存实例不动) + bool Save(const std::string& path) const; + // 条件字符串 <-> 编译结构(面板编辑 cond 输入框 Enter 时就地 + // 重编译,Save 时反推字符串) + static TransitionCond ParseCond(const std::string& s); + static std::string CondToString(const TransitionCond& c); + const std::vector& GetParams() const + { + return m_Params; + } + const std::unordered_map& + GetStates() const { return m_States; } + // 状态声明序(编辑器 UI 稳定枚举 + entry 兜底;unordered_map + // 迭代无序,UI/初始化一律走这里) + const std::vector& GetStateOrder() const + { + return m_StateOrder; + } + // 入口状态:显式 Entry 键优先,缺省 = states 声明序首个 + const std::string& GetEntryState() const { return m_Entry; } + const std::vector& GetTransitions() const + { + return m_Transitions; + } + const std::string& GetPath() const { return m_Path; } + + // ---- 编辑器可变访问(ControllerEditorPanel 专用;改完 + // Save 写回 + AssetManager::ReloadController 刷新缓存)---- + std::vector& ParamsMut() { return m_Params; } + std::unordered_map& StatesMut() + { + return m_States; + } + std::vector& StateOrderMut() { return m_StateOrder; } + std::vector& TransitionsMut() { return m_Transitions; } + void SetEntry(const std::string& e) { m_Entry = e; } + + private: + std::vector m_Params; + std::unordered_map m_States; + std::vector m_StateOrder; + std::vector m_Transitions; + std::string m_Entry; + std::string m_Path; + }; + +} // namespace Engine::Animation + +#endif diff --git a/Engine/include/Engine/Animation/AnimationEvaluator.h b/Engine/include/Engine/Animation/AnimationEvaluator.h new file mode 100644 index 0000000..5ed768c --- /dev/null +++ b/Engine/include/Engine/Animation/AnimationEvaluator.h @@ -0,0 +1,97 @@ +// +// AnimationEvaluator:骨骼动画姿态求值器(UE FAnimInstance 单节点求值 / +// Unity PlayableGraph 求值语义的极简对应物)。 +// +// 动画系统职责分层(2026-10-06 重构,对齐 UE/Unity): +// Model = 资产层:node/bone/clip 静态数据(assimp 解析产物) +// AnimationComponent = 状态层:播放头/播放设置/crossfade 计时(纯数据) +// AnimationEvaluator = 求值层:clip 时间 → 全节点 global 矩阵(本文件) +// AnimationSystem = 调度层:逐实体推进状态 + 调求值器 + 产 palette +// SceneRenderer = 消费层:只读 palette/entitySkinBases 填实例 +// +// 求值器无调度、无渲染依赖:同一姿态可在任意时刻重复求值(渲染主循环 +// 与编辑器骨骼可视化复用同一套数学,保证"所见即所渲")。 +// + +#ifndef HELLOVULKAN_ANIMATIONEVALUATOR_H +#define HELLOVULKAN_ANIMATIONEVALUATOR_H + +#include +#include + +#include +#include + +#include + +namespace Engine::Animation { + + // 单层采样的全节点局部 TRS(Layer 语义)。 + // touched = 被任意轨道触碰的节点标记(ComposeGlobals 决定重建或 + // 原样——FBX PreRotation 折在 localTransform 里,拆 TRS 重建会丢, + // 未触碰节点必须保持原矩阵)。容量跨帧复用,稳态零堆分配 + struct LocalPose { + std::vector pos; + std::vector rot; + std::vector scl; + std::vector touched; + }; + + // crossfade 求值快照(纯数据):src = 淡出中的来源 clip + //(nullptr = 无过渡 / 过渡已结束),wCur = 当前 clip 权重 0..1 + //(smoothstep 已算好)。计时推进与 fadeFrom 状态翻转归 AnimationSystem + struct CrossfadeState { + const AnimationClip* src = nullptr; + f32 srcTime = 0.0f; + f32 wCur = 1.0f; + }; + + // 单个混合层:一个 clip 采样实例 + 权重。权重语义 = 归一化前的 + // 相对权重(求值器内部按有效层权重和归一化;全部为 0 = rest) + struct PoseLayer { + const AnimationClip* clip = nullptr; // nullptr = 该层跳过 + f32 time = 0.0f; + f32 weight = 1.0f; + }; + + // 姿态求值器。内部持有 N 层 LocalPose scratch(跨帧复用容量), + // 单实例非线程安全——动画推进收敛在 AnimationSystem 单点调用 + class AnimationEvaluator { + public: + // cur = 当前 clip,curTime = 当前播放头(秒)。 + // xf.src 非空时双层局部 TRS 混合(位置/缩放 lerp、旋转 slerp, + // 权重 = xf.wCur)→ global 累乘。 + // outGlobals 尺寸 = model.GetNodes().size(),父序 DFS 累乘。 + // (crossfade 双层 = EvaluateLayers 的 count==2 特例,同一套 + // 加权混合数学,消掉历史双份代码) + void Evaluate(const Model& model, const AnimationClip& cur, + f32 curTime, const CrossfadeState& xf, + std::vector& outGlobals); + + // N 层加权混合(BlendTree / 状态机过渡的通用入口): + // 逐层采样局部 TRS → 权重归一化混合(pos/scl lerp、rot 渐进 + // slerp)→ global 累乘。全空/全零权重 = rest 姿态 + void EvaluateLayers(const Model& model, const PoseLayer* layers, + u32 count, std::vector& outGlobals); + + // 绑定姿势:全节点 untouched = localTransform 原样累乘。 + // 与渲染 identity palette 的表现一致(无动画/组件禁用实体的 + // 姿态)——骨骼可视化对非动画实体用它,保证可视化 = 渲染 + void ComputeRestPose(const Model& model, + std::vector& outGlobals); + + private: + // 混合循环的(pose, weight)配对视图:m_Layers 是采样存储, + // m_Sampled 指向其中的有效层并携带原权重 + struct SampledLayer { + const LocalPose* pose; + f32 weight; + }; + LocalPose m_A; // 混合累积层(= 直接模式当前层) + std::vector m_Layers; // N 层采样 scratch(容量跨帧复用) + std::vector m_Sampled; // 有效层配对 scratch + }; + +} // namespace Engine::Animation + +#endif diff --git a/Engine/include/Engine/Animation/AnimationSystem.h b/Engine/include/Engine/Animation/AnimationSystem.h new file mode 100644 index 0000000..754c53b --- /dev/null +++ b/Engine/include/Engine/Animation/AnimationSystem.h @@ -0,0 +1,75 @@ +// +// AnimationSystem:骨骼动画调度器(UE AnimInstance 求值管线 / Unity +// Animator Update 的分层对应物)。 +// +// Update 每帧做三件事: +// 1. 逐实体推进 AnimationComponent 播放头 / crossfade 计时(状态层, +// 组件是唯一持久状态,System 只改写这些字段) +// 2. 调 AnimationEvaluator 把 clip 时间变成全节点 global 矩阵(求值层) +// 3. palette bump 分配写进 FrameSceneData(渲染消费契约) +// +// 与渲染的单向契约:System 写 skinPalettes / entitySkinBases / +// skinPaletteTruncated(+ 编辑形态 boneWireVerts),SceneRenderer 收集段 +// 只读查表。动画推进与可见性无关——被剔除实体播放头恒走,回屏不跳变。 +// +// 无状态机、无混合树(按需再演进):crossfade 就是全部过渡语义, +// 与既有 Play(index, fadeSeconds) 接口一一对应。 +// + +#ifndef HELLOVULKAN_ANIMATIONSYSTEM_H +#define HELLOVULKAN_ANIMATIONSYSTEM_H + +#include +#include +#include + +#include + +#include + +namespace Engine { + + namespace ECS { class Scene; } + namespace Assets { class AssetManager; } + + namespace Animation { + + class AnimationSystem { + public: + // dt 单位秒。scene 只读遍历(组件状态字段除外——播放头 + // 推进是 System 的本职写入);frame 是唯一产出出口 + void Update(ECS::Scene& scene, Assets::AssetManager& assets, + f32 dt, FrameSceneData& frame); + + private: +#ifdef HELLO_EDITOR + // 选中实体骨骼线框(Part B 可视化,仅编辑形态存在): + // 动画实体用当帧求值姿态,非动画/禁用实体用绑定姿势。 + // 产出 frame.boneWireVerts,EditorGizmoPass 拼线绘制 + void EmitSelectedSkeleton(ECS::Scene& scene, + Assets::AssetManager& assets, + FrameSceneData& frame); +#endif + + AnimationEvaluator m_Evaluator; + std::vector m_Globals; // 求值输出 scratch + // Controller 模式混合层(BlendTree / 状态机过渡翻译产物) + std::vector m_LayerScratch; + std::vector m_FromLayers; // 过渡来源状态层 + std::vector> m_BlendWeights; // 2D 树权重 scratch + +#ifdef HELLO_EDITOR + // 选中实体当帧姿态暂存(求值循环内命中即拷,供 + // EmitSelectedSkeleton 用;未命中/非动画 → 绑定姿势路径) + std::vector m_SelGlobals; + bool m_SelHasPose = false; +#endif + + bool m_TruncWarned = false; // palette 预算溢出只告警一次 + }; + + } // namespace Animation + +} // namespace Engine + +#endif diff --git a/Engine/include/Engine/Animation/HumanoidRig.h b/Engine/include/Engine/Animation/HumanoidRig.h new file mode 100644 index 0000000..70fc7ee --- /dev/null +++ b/Engine/include/Engine/Animation/HumanoidRig.h @@ -0,0 +1,71 @@ +// +// HumanoidRig:Unity Avatar 语义的跨骨架重定向映射。 +// +// 资产组装现实:角色模型与动画 clip 是两套独立资产,骨骼命名完全 +// 不同源(英文/Mixamo/UE/Blender/MMD 日文)。Unity Humanoid 的解法 +// 是把任意命名骨架映射到标准人形槽位(Avatar bone mapping),动画 +// 经槽位转换后即可驱动任意人形角色。 +// +// 这里实现其中枢:名字归一化 → 槽位识别(关键词表,en/Mixamo/MMD) +// → 源/目标骨架间生成 node 下标映射对。同名直映是映射的特例。 +// + +#ifndef HELLOVULKAN_HUMANOIDRIG_H +#define HELLOVULKAN_HUMANOIDRIG_H + +#include +#include + +#include +#include +#include + +namespace Engine::Animation { + + // 标准人形槽位(最小主干集,不含手指/表情——主干驱动即可行走) + enum class HumanoidSlot : u32 { + Hips = 0, + Spine, + Chest, + UpperChest, + Neck, + Head, + LeftShoulder, + LeftUpperArm, + LeftLowerArm, + LeftHand, + RightShoulder, + RightUpperArm, + RightLowerArm, + RightHand, + LeftUpperLeg, + LeftLowerLeg, + LeftFoot, + RightUpperLeg, + RightLowerLeg, + RightFoot, + Count + }; + + // 槽位侧别(左右对称骨骼的识别结果) + enum class Side : u8 { None, Left, Right }; + + // 槽位显示名("Hips"/"LeftUpperArm"...;调试映射表用) + const char* HumanoidSlotName(HumanoidSlot slot); + + // 名字归一化 + 槽位识别。 + // "mixamorig:LeftForeArm" → {LowerArm, Left};"左ひじ" → {LowerArm, Left}; + // "Upper Arm_L" → {UpperArm, Left}。识别失败 ok=false。 + HumanoidSlot MatchHumanoidSlot(std::string_view name, Side& side, + bool& ok); + + // 跨骨架自动映射:返回 (srcNodeIdx, dstNodeIdx) 对列表。 + // 同名 node 直映优先(同源拆分资产零损耗),余下按槽位匹配; + // 一槽多候选时取语义关键词优先级表中的顺序(先高后低)。 + std::vector> AutoMapHumanoid( + const std::vector& srcNodes, + const std::vector& dstNodes); + +} // namespace Engine::Animation + +#endif // HELLOVULKAN_HUMANOIDRIG_H diff --git a/Engine/include/Engine/Assets/AssetManager.h b/Engine/include/Engine/Assets/AssetManager.h index 103ea62..61d5685 100644 --- a/Engine/include/Engine/Assets/AssetManager.h +++ b/Engine/include/Engine/Assets/AssetManager.h @@ -19,8 +19,11 @@ #include #include #include +#include +#include #include #include +#include #include #include @@ -109,6 +112,14 @@ namespace Engine m_Free.push_back(h.index); } + // 按路径卸载(未加载为 no-op;编辑器资产重建用—— + // 手上只有路径没有句柄的场景) + void UnloadByPath(const std::string& path) + { + auto it = m_ByPath.find(path); + if (it != m_ByPath.end()) Unload(it->second); + } + const std::string& Path(AssetRef h) const { static const std::string kEmpty; @@ -183,14 +194,19 @@ namespace Engine // 重映射进目标模型的 clip 表。前提 = 源/目标骨骼命名同源(如 // 同一 DCC 拆分/Mixamo 同角色);名字对不上的轨道跳过并计数。 // 幂等:目标 clips 已含该源(sourcePath)时不重复并入。 - struct AnimAttachResult - { - u32 clipsAdded = 0; - u32 channelsMatched = 0; - u32 channelsSkipped = 0; // 骨骼名在目标模型找不到 - }; + // 本方法只是 facade(取模型 + 解析磁盘),重映射算法在 + // Animation::AttachClipsToModel + using AnimAttachResult = Animation::AttachResult; AnimAttachResult AttachAnimationClips(ModelRef target, const std::string& animPath); + // 对称剥离(编辑器 detach):删除目标模型里来自 animPath 的 + // clip,返回删除数。facade(取模型 + 调 Model 方法) + u32 DetachAnimationClips(ModelRef target, + const std::string& animPath) + { + Model* m = GetModel(target); + return m ? m->RemoveClipsBySource(animPath) : 0u; + } // 程序化平面(纯色贴图):size 为半边长。带缓存,同参数共享一份。 ModelRef CreatePlane(f32 halfSize, u8 r, u8 g, u8 b); @@ -217,6 +233,49 @@ namespace Engine return m_Environments.Contains(path); } + // ---- 材质资产(.material,YAML → GPU)---- + // 同步加载(YAML 解析 + 纹理上传,主线程):路径缓存去重, + // 失败返回无效句柄。调用点收敛在两处——场景读后预载 + //(SceneSerializer,游戏形态也走)与编辑器指派/创建覆盖 + //(Inspector);渲染收集期只查不载(FindMaterialAsset) + MaterialAssetRef LoadMaterialAsset(const std::string& path); + // 按路径查已加载材质(未加载返回 null,不触发加载)—— + // 收集期 resolve / 面板展示用 + MaterialAsset* FindMaterialAsset(const std::string& path) const + { + return m_Materials.Find(path); + } + bool IsMaterialLoaded(const std::string& path) const + { + return m_Materials.Contains(path); + } + // 编辑器重建路径(纹理槽变化):GPU 全停飞 → 卸载旧资产 + //(句柄即刻失效,引用方下帧 resolve 自然拿到新实例)→ + // 重新加载。waitIdle 经 uploader 接缝(本类不感知 Vulkan) + MaterialAssetRef ReloadMaterialAsset(const std::string& path); + + // ---- 动画控制器资产(.animctrl,YAML → 状态机数据)---- + // 模式同 MaterialAsset:加载点收敛两处(场景读后预载 + + // 编辑器指派),System 收集期只 Find 不 Load。组件按路径 + // 引用(controllerPath),句柄仅指派点短暂持有 + using AnimCtrlRef = AssetRef; + AnimCtrlRef LoadController(const std::string& path); + // 强制重载(.animctrl = 纯文本 YAML,外部编辑器改完后调用; + // 失败时缓存已清,Find 落空 → rest 兜底,路径仍可再次重载) + AnimCtrlRef ReloadController(const std::string& path) + { + m_Controllers.UnloadByPath(path); + return LoadController(path); + } + Animation::AnimationController* FindController(const std::string& path) const + { + return m_Controllers.Find(path); + } + bool IsControllerLoaded(const std::string& path) const + { + return m_Controllers.Contains(path); + } + AssetCache& Models() { return m_Models; } // ---- 资产 GUID 注册表(UE AssetRegistry 模式:中央注册表)---- @@ -245,6 +304,8 @@ namespace Engine AssetCache m_Models; AssetCache m_Environments; + AssetCache m_Materials; + AssetCache m_Controllers; // GUID 注册表(双向);GetOrCreateGUID 标 dirty,FlushGUIDRegistry 落盘 // (Application 退出 / 场景保存时统一 flush,避免每次新增都全量重写 yaml) diff --git a/Engine/include/Engine/Assets/IAssetUploader.h b/Engine/include/Engine/Assets/IAssetUploader.h index bc7cdd3..4150337 100644 --- a/Engine/include/Engine/Assets/IAssetUploader.h +++ b/Engine/include/Engine/Assets/IAssetUploader.h @@ -19,6 +19,7 @@ #include #include +#include #include namespace Engine::Assets { @@ -47,6 +48,17 @@ namespace Engine::Assets { // 异步加载路径:解码已在后台线程完成,此调用只做 GPU 生成(主线程) virtual Scope LoadEnvironment(const std::string& path, DecodedEquirect&& decoded) = 0; + + // ---- 材质资产 GPU 构建(MaterialAsset 专用接缝)---- + // 数据段 → 纹理上传 + Material 描述符(失败/空槽折共享默认纹理, + // 纹理所有权归返回的 MaterialAsset)。失败返回 nullptr + virtual Scope CreateMaterial( + MaterialAssetData&& data) = 0; + + // GPU 全停飞(编辑器低频重建路径用:材质资产 Reload 前调用, + // 保证旧纹理/描述符无在飞引用后销毁——与 Renderer::SetEnvironment + // 的 waitIdle 换绑同一先例) + virtual void WaitDeviceIdle() = 0; }; } // namespace Engine::Assets diff --git a/Engine/include/Engine/Core/AssetRef.h b/Engine/include/Engine/Core/AssetRef.h index ff7c183..1cf6842 100644 --- a/Engine/include/Engine/Core/AssetRef.h +++ b/Engine/include/Engine/Core/AssetRef.h @@ -22,6 +22,7 @@ namespace Engine { class Cubemap; class Texture; class Material; + class MaterialAsset; } namespace Engine { @@ -41,6 +42,7 @@ namespace Engine { // 常用别名(新增资产类型时在这里加一行) using ModelRef = AssetRef; using EnvRef = AssetRef; // 环境(IBL 四件套) + using MaterialAssetRef = AssetRef; // 材质资产(.material) } // namespace Engine diff --git a/Engine/include/Engine/ECS/Components.h b/Engine/include/Engine/ECS/Components.h index 2be5255..c6ba98d 100644 --- a/Engine/include/Engine/ECS/Components.h +++ b/Engine/include/Engine/ECS/Components.h @@ -21,6 +21,7 @@ #include #include #include +#include #include namespace Engine { @@ -192,45 +193,121 @@ namespace Engine::ECS { // 网格渲染体:引用共享的 Model 资产。 // 存中性 AssetRef(Core 层纯值类型,Unity InstanceID 思路)—— // ECS 不感知资产系统内部;解析归 AssetManager,悬空由 generation 拦截。 + // + // 材质覆盖(UE Material Slot 语义):key = mesh 下标(Model::GetMeshes + // 序),value = .material 资产路径;无该项 = 用模型内建材质(只读)。 + // 存路径不存句柄:覆盖材质由渲染收集期按路径惰性 resolve(资产 + // 已被场景预载/编辑器指派,收集只查缓存),组件保持纯数据。 + // 资产被多实体共享时编辑同时生效;实体独立 = 每实体一份覆盖文件 struct MeshRendererComponent { ModelRef model; bool active = true; // 禁用 = 渲染/拾取跳过(Unity enabled / UE SetActive) + std::unordered_map materialOverrides; }; - // 骨骼动画播放状态(纯数据 + 便捷控制)。 - // 采样/局部层混合逻辑在 SceneRenderer::BuildFrame(渲染侧唯一消费者); - // ImGui 面板经 Play()/字段直接驱动。无此组件 = 按绑定姿势渲染。 + // 骨骼动画播放状态(纯数据 + 便捷控制)——Unity Animator 组件 / + // UE SkeletalMeshComponent 动画侧的对应物。 + // + // 职责分层(2026-10-06 重构,对齐 UE/Unity): + // Model(资产:node/bone/clip)→ AnimationComponent(状态,本结构) + // → Animation::AnimationSystem(调度:推进播放头 + 姿态求值 + + // palette 产出)→ SceneRenderer(渲染只读查表消费)。 + // 本组件不含任何求值逻辑;ImGui 面板经 Play()/字段直接驱动。 + // 无此组件 = 按绑定姿势渲染。 struct AnimationComponent { + // ---- 播放设置(序列化落盘)---- bool active = true; // 禁用 = 暂停采样(按当前帧姿态定格) int clipIndex = 0; // 当前 clip(Model::GetClips 下标) bool playing = true; bool loop = true; f32 speed = 1.0f; + + // ---- 播放头 / crossfade(瞬态,不落盘——重启从 0 重播)---- f32 time = 0.0f; // 当前 clip 播放头(秒,wrap 由消费端处理) - // ---- crossfade(clip 切换平滑过渡)---- - // 来源 clip 淡出期间与目标 clip 并行采样,局部 TRS 层混合 - //(palette 数量不变,只是求值端多采一组姿态) + // crossfade(clip 切换平滑过渡):来源 clip 淡出期间与目标 + // clip 并行采样,局部 TRS 层混合(palette 数量不变,只是求值 + // 端多采一组姿态) int fadeFrom = -1; // 来源 clip 下标(-1 = 无过渡) f32 fadeFromTime = 0.0f; // 来源 clip 播放头 f32 fadeElapsed = 0.0f; // 已过渡时长 f32 fadeDuration = 0.3f; // 过渡总时长(秒) + // ---- 资产引用(序列化落盘)---- // 外部动画资产(编辑器 attach 的动画容器文件,随场景持久化)。 // clips 本体运行时并入 Model 资产(共享);重启后由 Inspector // 按此列表幂等重放 std::vector attachedAnimPaths; - // 切换 clip(带 crossfade)。越界/空 clip 保护在消费端。 + // ---- Controller 模式(Unity Animator 语义)---- + // Direct = 现状单 clip 直控(clipIndex/playing 等);Controller = + // .animctrl 状态机驱动(参数 + 过渡 + BlendTree)。两模式字段 + // 独立,切换互不清除(来回切配置不丢) + enum class AnimMode : u8 { Direct = 0, Controller = 1 }; + AnimMode mode = AnimMode::Direct; // 落盘;缺键回退 Direct + + std::string controllerPath; // .animctrl 资产路径(落盘) + // 参数实例值(落盘 = 设计意图:调好的值重启还在;缺键 = 用 + // Controller 声明的默认值)。Trigger 是一次性信号不落盘 + std::unordered_map floatParams; + std::unordered_map boolParams; + std::vector pendingTriggers; // 消费即清(瞬态) + + // 状态机瞬态(不落盘,重启从 entry state 开始) + std::string currentState; // 空 = 未初始化(System 首帧置 entry) + f32 stateTime = 0.0f; // 当前状态播放头(秒) + std::string transFrom; // 过渡来源状态(空 = 无过渡) + f32 transFromTime = 0.0f; // 来源状态播放头(过渡期间继续推进) + f32 transElapsed = 0.0f; // 已过渡时长 + f32 transFade = 0.25f; // 过渡总时长(秒) + + // ---- 脚本/编辑器共用 API(Unity Animator.SetXxx 语义)---- + void SetFloat(const std::string& name, float value) + { + floatParams[name] = value; + } + void SetBool(const std::string& name, bool value) + { + boolParams[name] = value; + } + // 一次性信号:被状态机条件评估消费后清除(同名去重——误配资产 + // 无消费条件时防每帧 SetTrigger 无限堆积) + void SetTrigger(const std::string& name) + { + if (std::find(pendingTriggers.begin(), pendingTriggers.end(), + name) == pendingTriggers.end()) { + pendingTriggers.push_back(name); + } + } + // 强制切状态(CrossFade 语义:来源状态淡出、目标状态淡入, + // fade<=0 = 硬切)。编辑器下拉与脚本共用 + void CrossFade(const std::string& stateName, f32 fadeSeconds) + { + if (stateName == currentState) return; + transFrom = currentState; + transFromTime = stateTime; + transElapsed = 0.0f; + transFade = glm::max(fadeSeconds, 1e-3f); + currentState = stateName; + stateTime = 0.0f; + } + + // 切换 clip 并开始播放(Unity Animator.Play 语义:指派即播, + // 编辑态选中 clip 直接预览;crossfade 越界/空 clip 保护在消费端)。 + // 脚本 API 与编辑器 Inspector 共用同一入口 void Play(int index, f32 fadeSeconds = 0.3f) { - if (index == clipIndex) return; + if (index == clipIndex) { + playing = true; // 重复指派 = 确保在播 + return; + } fadeFrom = clipIndex; fadeFromTime = time; fadeElapsed = 0.0f; fadeDuration = fadeSeconds; clipIndex = index; time = 0.0f; + playing = true; } }; @@ -390,7 +467,7 @@ namespace Engine::ECS { // 环境光(UE SkyLight 语义:全局漫射环境照明)。多实例取第一个 active struct SkyLightComponent { bool active = true; - glm::vec4 ambientColor{0.07f, 0.07f, 0.09f, 0.0f}; // 旧 Settings 默认 + glm::vec4 ambientColor{1.0f}; // 旧 Settings 默认 }; } // namespace Engine::ECS diff --git a/Engine/include/Engine/ImGui/ImGuiLayer.h b/Engine/include/Engine/ImGui/ImGuiLayer.h index b427ed0..82f621b 100644 --- a/Engine/include/Engine/ImGui/ImGuiLayer.h +++ b/Engine/include/Engine/ImGui/ImGuiLayer.h @@ -48,6 +48,18 @@ namespace Engine { } void UpdateSceneTexture(u32 slot, vk::ImageView view); + // ---- 材质球预览 atlas(MaterialPreviewPass 输出 → 材质槽 UI; + // editor 专属,HELLO_EDITOR 裁剪)---- + // 注册/生命周期语义与场景纹理完全一致(按槽位各一个描述符) +#ifdef HELLO_EDITOR + ImTextureID GetPreviewTexture(u32 slot) const + { + return slot < kMaxFramesInFlight + ? m_PreviewTextures[slot] : ImTextureID_Invalid; + } + void UpdatePreviewTexture(u32 slot, vk::ImageView view); +#endif + // ---- 每帧三段式(由 Application::Run 驱动)---- // 开始 UI 构建(ImGui::NewFrame)。此后到 BuildDrawData 之间, @@ -72,6 +84,9 @@ namespace Engine { vk::DescriptorPool m_Pool; bool m_Enabled = true; ImTextureID m_SceneTextures[kMaxFramesInFlight]{}; +#ifdef HELLO_EDITOR + ImTextureID m_PreviewTextures[kMaxFramesInFlight]{}; +#endif }; } // namespace Engine diff --git a/Engine/include/Engine/Renderer/Camera.h b/Engine/include/Engine/Renderer/Camera.h index a4d41a4..344d300 100644 --- a/Engine/include/Engine/Renderer/Camera.h +++ b/Engine/include/Engine/Renderer/Camera.h @@ -28,8 +28,22 @@ namespace Engine { namespace ECS { struct TransformComponent; } + // ---- 相机参数默认值(每实体相机参数的唯一初始来源;编辑器 UI / + // 序列化缺省回退也引用它们,改默认值一处生效)---- + // 视场角(度,垂直 fov,Unity 语义;UE 默认水平 90° ≈ 垂直 45°@16:9) + inline constexpr f32 kDefaultCameraFovDeg = 45.0f; + // 裁剪面距离(米):near 主导深度精度,far 拉远到"无限"级不损近处 + inline constexpr f32 kDefaultCameraNear = 0.1f; + inline constexpr f32 kDefaultCameraFar = 10000.0f; + // 正交模式半垂直高度(Unity orthographic size 语义):世界单位米 + inline constexpr f32 kDefaultCameraOrthoSize = 10.0f; + class Camera { public: + // 投影模式(UE/Unity Camera 语义:透视 = 近大远小;正交 = + // 平行投影,工程图纸/2D/等距视角) + enum class ProjectionMode : u8 { Perspective = 0, Orthographic = 1 }; + // 输入驱动(键鼠查询统一走 Input 门面,零 Window 依赖)。 // flyRequiresRMB=true(编辑器模式):移动键仅在 RMB 按住时生效 // ——解决与 Gizmo W/E/R 工具热键的键位冲突(UE 语义:飞行时 @@ -44,8 +58,27 @@ namespace Engine { // 相机视图 = 实体世界矩阵的逆(要求 transform 已 Compose) glm::mat4 GetView(const ECS::TransformComponent& transform) const; - // Vulkan 投影:内部完成 Y 翻转(clip space Y 向下)和 [0,1] 深度 - glm::mat4 GetProjection(f32 aspect, f32 nearZ, f32 farZ) const; + // Vulkan 投影:内部完成 Y 翻转(clip space Y 向下)和 [0,1] 深度。 + // fov/near/far/orthoSize/模式全部走成员(每实体参数),aspect 由 + // 视口推导不落盘(UE/Unity 同构) + glm::mat4 GetProjection(f32 aspect) const; + + // ---- 投影参数(Inspector/序列化直接读写)---- + void SetFovDeg(f32 fov) { m_FovDeg = glm::clamp(fov, 1.0f, 179.0f); } + f32 GetFovDeg() const { return m_FovDeg; } + // near/far 成对写:内部保证 far > near(UI 拖动交叉时防穿透) + void SetClip(f32 nearZ, f32 farZ) + { + m_NearZ = glm::max(nearZ, 0.001f); + m_FarZ = glm::max(farZ, m_NearZ + 0.01f); + } + f32 GetNearZ() const { return m_NearZ; } + f32 GetFarZ() const { return m_FarZ; } + // 正交半垂直高度(>0;aspect 由视口推导出全宽) + void SetOrthoSize(f32 s) { m_OrthoSize = glm::max(s, 0.01f); } + f32 GetOrthoSize() const { return m_OrthoSize; } + void SetProjectionMode(ProjectionMode m) { m_ProjectionMode = m; } + ProjectionMode GetProjectionMode() const { return m_ProjectionMode; } void SetMoveSpeed(f32 speed) { m_MoveSpeed = speed; } void SetMouseSensitivity(f32 sens) { m_MouseSensitivity = sens; } @@ -63,6 +96,13 @@ namespace Engine { f32 m_MouseSensitivity = 0.1f; bool m_MouseLookEnabled = true; + // ---- 投影参数(每实体;Transform 只管位姿,这里只管投影)---- + f32 m_FovDeg = kDefaultCameraFovDeg; + f32 m_NearZ = kDefaultCameraNear; + f32 m_FarZ = kDefaultCameraFar; + f32 m_OrthoSize = kDefaultCameraOrthoSize; + ProjectionMode m_ProjectionMode = ProjectionMode::Perspective; + // 焦点环绕状态:pivot + 距离。首次环绕无 pivot 时自动取当前 // 视线上 m_OrbitDistance 处;FocusOn 显式设置 glm::vec3 m_OrbitPivot{0.0f}; diff --git a/Engine/include/Engine/Renderer/Material.h b/Engine/include/Engine/Renderer/Material.h index 516e5a8..d3f1c74 100644 --- a/Engine/include/Engine/Renderer/Material.h +++ b/Engine/include/Engine/Renderer/Material.h @@ -26,6 +26,7 @@ #define HELLOVULKAN_MATERIAL_H #include +#include #include #include #include @@ -42,11 +43,31 @@ namespace Engine { // 材质因子(乘贴图;无贴图时即最终值) // x = metallic 因子 y = roughness 因子 // z = 独立粗糙度图开关(1 = 用 Roughness 槽 R 通道替换 MR.g) + // 注意:与 shader MaterialParams UBO 严格同序同布局(全 vec4,std140 + // 无填充歧义),加字段必须双端同步(simple_shader*.frag) struct MaterialParams { // x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关 glm::vec4 factors{0.0f, 0.5f, 0.0f, 0.0f}; // glTF baseColorFactor:rgb 乘进 albedo,a 作 BLEND 透明度 glm::vec4 baseColor{1.0f, 1.0f, 1.0f, 1.0f}; + // glTF 表面扩展(KHR_materials_ior / transmission / volume / + // emissive_strength):x = 折射率(F0 = ((ior-1)/(ior+1))²), + // y = 透射率(0 = 普通表面),z = 自发光强度,w = 体积厚度 + //(折射屏幕偏移的世界距离,0 = 无弯曲直透) + glm::vec4 surface{1.5f, 0.0f, 1.0f, 0.0f}; + // 杂项语义:x = unlit(KHR_materials_unlit,shader 早退直出 albedo) + glm::vec4 misc{0.0f, 0.0f, 0.0f, 0.0f}; + // 每槽 KHR_texture_transform(glTF2 每个 textureInfo 独立声明 + // offset/scale/rotation——烘焙单份顶点 UV 无法表达"一材质多槽 + // 各异变换",收敛为 shader 侧线性式 uv' = A·uv + t,作用在本引擎 + // v-up 顶点 UV 空间,换算推导见 Model.cpp glTF 分支注释)。 + // uvA[slot] = (a11, a12, a21, a22)(行主 2×2),uvT[slot] = (tx, ty, 0, 0)。 + // 长度必须 = Material::kSlotCount;std140 vec4 数组步长 16B 与 + // CPU 布局天然对齐 + glm::vec4 uvA[6] = {{1.0f, 0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 1.0f}, + {1.0f, 0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 1.0f}, + {1.0f, 0.0f, 0.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 1.0f}}; + glm::vec4 uvT[6] = {}; }; class Material { @@ -79,16 +100,37 @@ namespace Engine { // 透明队列归属(glTF alphaMode=BLEND;CPU 收集侧用) bool IsTransparent() const { return m_Transparent; } + // ---- 编辑器调参入口(零 GPU 重建路径)---- + // 参数直写:全 in-flight 槽同步覆盖写,与 ctor 初始化同一绑定 + // 语义(binding 覆盖写,ring 槽位共用同一 descriptor binding)。 + // 理论上存在 in-flight 帧读到半新半旧参数的撕裂窗口(动态偏移 + // ring 是根治演进点)——UI 段低频编辑 + 48B 小块 memcpy 下实践 + // 无感。纹理槽变化不走这里(那是重建路径,见 MaterialAsset)。 + // 实现外置(UniformBuffer 头文件内仅前置声明) + void UpdateParams(const MaterialParams& params); + // 队列归属切换(收集侧 CPU 标志,描述符不变):透明 ↔ 不透明 + void SetTransparent(bool t) { m_Transparent = t; } + // 材质缓存键:同贴图组合+同因子+同透明语义共享一份(Model 层使用) static std::string MakeCacheKey(const std::string paths[kSlotCount], const MaterialParams& params, bool transparent); + // ---- 预览缩略图身份(editor tile 池的缓存键,game 不消费)---- + // serial:出生唯一号(进程内单调递增,不复用)——裸指针做键有 + // ABA 风险(旧材质析构后新材质恰好分配到同一地址,池误判命中) + u64 GetSerial() const { return m_Serial; } + // editVersion:参数编辑代数,UpdateParams 每次 ++ + //(tile 池以此判定"缓存的球已过期需重渲") + u32 GetEditVersion() const { return m_EditVersion; } + private: std::array m_Textures{}; vk::DescriptorSet m_DescriptorSet; Scope m_ParamsBuffer; // per-slot 参数 ring(创建时写死) bool m_Transparent = false; + u64 m_Serial = 0; // ctor 取号(定义见 Material.cpp) + u32 m_EditVersion = 0; }; } // namespace Engine diff --git a/Engine/include/Engine/Renderer/MaterialAsset.h b/Engine/include/Engine/Renderer/MaterialAsset.h new file mode 100644 index 0000000..9315d93 --- /dev/null +++ b/Engine/include/Engine/Renderer/MaterialAsset.h @@ -0,0 +1,96 @@ +// +// Created by atdunbg on 2026/10/5. +// +// MaterialAsset:独立材质资产(.material 文件)+ GPU 资源封装。 +// +// UE UMaterial 的资产化对应物。语义约定: +// - 模型内建材质(Model 材质缓存持有,随 glTF/FBX 解析生成)只读, +// 不存在"原地编辑"路径——改它会波及所有引用该模型的实体 +// - 可编辑材质 = .material 资产文件,被 MeshRenderer 的覆盖表 +// (mesh 下标 → 资产路径)按实体引用;资产被多实体共享时编辑 +// 同时生效(资产级语义),要实体独立就每实体一份文件 +// - 数据段(MaterialAssetData,磁盘 ↔ 内存纯值)与 GPU 段(纹理 + +// Material)分离:GPU 段由 VulkanAssetUploader 构建,资产层与 +// 本类都不感知 Vulkan——依赖方向与 Model/资产层完全一致 +// +// 扩展点(UV 动效/特效等后续迭代): +// - 数据段加字段 + Version 递增,Load 全部 as(default) 缺省 +// 兼容——旧文件永远可读,新字段对旧文件取默认值 +// - GPU 段参数经 Material::UpdateParams 直写 UBO,shader 侧 +// matParams 块同步扩展即可,本类结构不变 +// + +#ifndef HELLOVULKAN_MATERIALASSET_H +#define HELLOVULKAN_MATERIALASSET_H + +#include +#include +// Texture 完整类型(非前置声明):成员持 Scope 数组,若仅 +// 前置声明,所有实例化本类析构/构造的 TU 都需各自看到 Texture 完整 +// 类型——直接包含一劳永逸。Material.h 已含 vulkan.hpp,无新增重依赖 +#include + +#include +#include + +namespace Engine { + + class Device; + class MemoryAllocator; + + // 材质槽位默认纹理工厂(单点实现防漂移):Model 兜底纹理与 + // MaterialAsset 构建共用同一组 1x1 兜底语义 + //(albedo 白 / normal 平 / MR 中性 / emissive 黑 / AO 全遮蔽 / rough 0.5) + Scope CreateDefaultMaterialTexture( + Device& device, MemoryAllocator& allocator, Material::Slot slot); + + // 材质资产数据段(磁盘格式 ↔ 内存值,无 GPU 状态)。 + // texPaths = 文件系统相对 CWD 路径(与模型解析产物同源,glTF/FBX + // 纹理可直接快照);空串 = 该槽用共享默认纹理兜底。 + // glb 内嵌纹理无独立文件路径 → 对 glb 模型 Create Override 时 + // 纹理槽为空(回退默认纹理),参数/透明照常可编辑。 + struct MaterialAssetData { + std::array texPaths; + MaterialParams params; + bool transparent = false; + + bool operator==(const MaterialAssetData&) const = default; + }; + + class MaterialAsset { + public: + static constexpr u32 kVersion = 1; + + // ---- 磁盘序列化(.material,YAML)---- + // 失败(无法打开/解析)返回 false;Load 缺省键兼容 + static bool Save(const std::string& path, const MaterialAssetData& data); + static bool Load(const std::string& path, MaterialAssetData& out); + + // GPU 段注入(仅 VulkanAssetUploader 调用):ownedTextures = + // 本资产加载的纹理(独占所有权),material = 组装好的材质 + //(空槽已由 uploader 绑共享默认纹理) + MaterialAsset(MaterialAssetData&& data, + std::array, Material::kSlotCount>&& ownedTextures, + Scope&& material); + // 析构外置(成员为重资源句柄,销毁序与 GPU 生命周期解耦遵循 + // Model/Material 既有模式) + ~MaterialAsset(); + + const MaterialAssetData& GetData() const { return m_Data; } + Material* GetMaterial() const { return m_Material.get(); } + + // ---- 编辑入口(Inspector 调;数据段与 GPU 段同步更新)---- + // 写盘时机由编辑器决定(参数松手落盘,纹理变化走资产重建) + void EditParams(const MaterialParams& params); + void SetTransparent(bool t); + + private: + MaterialAssetData m_Data; + // 本资产独占的纹理(共享默认纹理不在其中,由 uploader 持有) + std::array, Material::kSlotCount> m_OwnedTextures; + Scope m_Material; + }; + +} // namespace Engine + +#endif //HELLOVULKAN_MATERIALASSET_H diff --git a/Engine/include/Engine/Renderer/Model.h b/Engine/include/Engine/Renderer/Model.h index ee35531..4e8a383 100644 --- a/Engine/include/Engine/Renderer/Model.h +++ b/Engine/include/Engine/Renderer/Model.h @@ -13,6 +13,7 @@ #include #include #include // 默认参数需完整类型(烘焙节点变换用) +#include #include #include #include @@ -43,6 +44,8 @@ namespace Engine MaterialParams params; bool transparent = false; // glTF alphaMode=BLEND(透明绘制队列) bool skinned = false; // 有骨骼(渲染走 palette 蒙皮) + std::string name; // UI 名字:材质名(AI_MATKEY_NAME)优先, + // 回退 aiMesh mName(可空 → UI 回退 Slot N) // 蒙皮 mesh 专属:绑定姿态渲染空间的位置副本(palette(bind) 按 // 顶点权重混合,与 shader 同式)。非空 = Mesh 的物理数据源与 @@ -51,6 +54,18 @@ namespace Engine std::vector cpuPositions; }; + // 内建材质源信息快照(per-mesh,与 GetMeshes() 下标对齐)。 + // 只读语义:这是"模型固有材质"的权威描述——编辑器用它展示内建 + // 材质并生成覆盖材质快照(Create Override),**永不回写**: + // 内建材质属于模型资产,原地修改会波及所有引用该模型的实体。 + // 可编辑材质走 MaterialAsset(.material 文件)+ 实体级覆盖表 + struct MeshMaterialInfo { + std::string name; // UI 名字:材质名优先,回退 mesh 名(空 → Slot N) + std::array texPaths; + MaterialParams params; + bool transparent = false; + }; + // ---- 骨骼动画数据(assimp aiNode/aiAnimation/aiBone 的引擎侧镜像)---- // 关键帧(time 单位 = clip 原生 tick,运行时按 tps 换算秒) struct PosKey { f32 time; glm::vec3 value; }; @@ -99,6 +114,10 @@ namespace Engine std::vector nodes; std::vector bones; std::vector clips; + // GLB 内嵌贴图(assimp "*" 约定):解析期从 aiScene 拷出 + // 原始编码字节(PNG/JPEG),实例期 GetOrLoadTexture 解码上传。 + // 解析线程无 GPU 上下文,只带数据不建纹理 + std::unordered_map> embeddedImages; // 解析有效性:mesh 与 clip 至少其一非空。纯动画 FBX(无网格只有 // 骨骼动画,UE 的 Animation Sequence 资产同理)是合法资产—— // 过去只认 meshes 导致动画源文件在缓存层被整体拒载 @@ -145,6 +164,11 @@ namespace Engine Model& operator=(const Model&) = delete; const std::vector>& GetMeshes() const { return m_Meshes; } + // 内建材质源信息(与 GetMeshes() 下标对齐;只读,见结构注释) + const std::vector& GetMeshMaterialInfos() const + { + return m_MeshMaterialInfos; + } // 全模型联合包围球(各 mesh bounds 的并集,局部空间): // 实体级粗剔除用——整个模型在视锥外直接跳过全部 mesh 循环 const Mesh::Bounds& GetBounds() const { return m_Bounds; } @@ -162,6 +186,19 @@ namespace Engine std::make_move_iterator(extra.begin()), std::make_move_iterator(extra.end())); } + // 外部动画剥离(编辑器 detach 操作):删除 sourcePath 匹配的 + // clip,返回删除数。clipIndex/fadeFrom 下标悬空由消费端自愈 + //(AnimationSystem 每帧 clamp) + u32 RemoveClipsBySource(const std::string& sourcePath) + { + const u32 before = static_cast(m_Clips.size()); + m_Clips.erase( + std::remove_if(m_Clips.begin(), m_Clips.end(), + [&](const AnimationClip& c) + { return c.sourcePath == sourcePath; }), + m_Clips.end()); + return before - static_cast(m_Clips.size()); + } const std::vector& GetNodes() const { return m_Nodes; } const std::vector& GetBones() const { return m_Bones; } @@ -191,6 +228,8 @@ namespace Engine bool isGltf, std::vector& bones, std::unordered_map& boneSlots, + std::unordered_map>& embeddedImages, const aiMatrix4x4& parentGlobal = aiMatrix4x4()); static ParsedMesh ProcessMesh(const aiMesh* mesh, const aiScene* scene, @@ -198,6 +237,8 @@ namespace Engine bool isGltf, std::vector& bones, std::unordered_map& boneSlots, + std::unordered_map>& embeddedImages, const aiMatrix4x4& globalTransform = aiMatrix4x4()); // mesh 的 aiBone 权重灌进顶点 + 模型级骨骼表注册去重 @@ -207,6 +248,10 @@ namespace Engine std::unordered_map& boneSlots); void InitDefaultTextures(); + // GLB 内嵌贴图字节("*N" → 编码数据):解析期拷出,纹理解析期 + // GetOrLoadTexture 消费(随实例生命周期,键同 texPaths 引用) + std::unordered_map> m_EmbeddedImages; + // 聚合各 mesh bounds 为全模型联合包围球(实体级粗剔除) void RebuildUnionBounds(); @@ -236,6 +281,9 @@ namespace Engine std::vector> m_Meshes; Mesh::Bounds m_Bounds; // mesh 建完后聚合(实体级粗剔除) + // 与 m_Meshes 平行的内建材质快照(每条建 mesh 路径同步填充: + // FromParsed / 同步 LoadModel / 程序化平面) + std::vector m_MeshMaterialInfos; std::string m_ModelPath; bool m_IsGltf = false; // glTF 家族:MR 合图语义 + 不翻转 UV diff --git a/Engine/include/Engine/Renderer/Passes/EditorGizmoPass.h b/Engine/include/Engine/Renderer/Passes/EditorGizmoPass.h new file mode 100644 index 0000000..eddadf2 --- /dev/null +++ b/Engine/include/Engine/Renderer/Passes/EditorGizmoPass.h @@ -0,0 +1,74 @@ +// +// EditorGizmoPass:Editor gizmo 可视化叠加(billboard 图标 + 视锥线框)。 +// editor 专属(HELLO_EDITOR):cpp TU 被 game 构建整体剔除 +//(Engine/CMakeLists REMOVE_ITEM),本头文件只含声明、不引用任何 +// 编辑器专属类型,game 构建可安全 include(零符号产出)。 +// +// 与 PhysicsDebugPass 同构: +// - 写 HDRColor(Load 承接 Forward/PhysicsDebug 结果,WAW 链排其后, +// TAA 之前——gizmo 进 TAA/Bloom/Tonemap 主链路) +// - 深度只测不写(LessOrEqual,被场景几何正确遮挡),DontCare 不污染 +// - Write(m_Depth) 声明深度 WAW 边:把本 pass 固定排在 HitProxy +// (深度附件 loadOp Clear)之前——gizmo 深度测试用的是场景深度 +// - 无 PrepareFrame:Renderer 在上传窗口直接把 FrameSceneData 的 +// gizmo 顶点灌进 ring VB(本 pass 只绑定绘制) +// - 空帧短路:两流顶点数全 0 直接 return,零 GPU 工作 +// +// 两流两状态(Shader Object 动态状态跨 pass 残留教训:公共状态无条件 +// 直写,差异状态各流内直写): +// - 线流(GizmoWireVertex):LineList + lineWidth(1.0) + 无混合直写, +// 复用 debug_line.vert/frag(世界空间线段 + push viewProj 同契约) +// - 图标流(GizmoQuadVertex):TriangleList + 预乘 alpha 混合 +// (ONE, ONE_MINUS_SRC_ALPHA),gizmo_billboard.vert/frag SDF 造型 +// + +#ifndef HELLOVULKAN_EDITORGIZMOPASS_H +#define HELLOVULKAN_EDITORGIZMOPASS_H + +#include +#include +#include +#include +#include + +namespace Engine { + + class Device; + class DescriptorManager; + class UniformBuffer; + + namespace Passes { + + class EditorGizmoPass final : public RenderPass { + public: + EditorGizmoPass(Device& device, ShaderManager& shaders, + DescriptorManager& descriptors, + FrameSceneData& frame, + RG::TextureHandle hdrColor, + RG::TextureHandle depth, + UniformBuffer& iconVB, UniformBuffer& wireVB); + ~EditorGizmoPass(); + + EditorGizmoPass(const EditorGizmoPass&) = delete; + EditorGizmoPass& operator=(const EditorGizmoPass&) = delete; + + void Register(RG::RenderGraph& rg) override; + + private: + Device& m_Device; + DescriptorManager& m_Descriptors; + FrameSceneData& m_Frame; + RG::TextureHandle m_HDRColor; + RG::TextureHandle m_Depth; + UniformBuffer& m_IconVB; // per-slot ring(Renderer 持有生命周期) + UniformBuffer& m_WireVB; + + vk::PipelineLayout m_Layout; // 无描述符,push = viewProj 64B + ShaderManager::ShaderRef m_WireVert, m_WireFrag; // debug_line 复用 + ShaderManager::ShaderRef m_IconVert, m_IconFrag; // gizmo_billboard + }; + + } // namespace Passes +} // namespace Engine + +#endif //HELLOVULKAN_EDITORGIZMOPASS_H diff --git a/Engine/include/Engine/Renderer/Passes/ForwardPass.h b/Engine/include/Engine/Renderer/Passes/ForwardPass.h index c3a2443..a5de070 100644 --- a/Engine/include/Engine/Renderer/Passes/ForwardPass.h +++ b/Engine/include/Engine/Renderer/Passes/ForwardPass.h @@ -20,16 +20,19 @@ namespace Engine::Passes { ForwardPass(SceneGpu& gpu, FrameSceneData& frame, RG::TextureHandle hdrColor, RG::TextureHandle velocity, RG::TextureHandle depth, + RG::TextureHandle sceneColor, const std::array& shadowMaps) : m_Gpu(gpu), m_Frame(frame), m_HDRColor(hdrColor), m_Velocity(velocity), - m_Depth(depth), m_ShadowMaps(shadowMaps) {} + m_Depth(depth), m_SceneColor(sceneColor), + m_ShadowMaps(shadowMaps) {} void Register(RG::RenderGraph& rg) override; + void OnTargetsRebuilt(RG::RenderGraph& rg) override; private: // 两段渲染共享的命令录制(shader/描述符绑定 + 几何/光栅/视口状态)。 - // translucent=true 绑定 gl_FragDepth=1.0 的透明 frag 变体; + // translucent=true 绑定透射变体(折射采样场景色副本); // 深度写与混合状态由各渲染实例作为初始态设置,见 ForwardPass.cpp void RecordSharedState(const RG::RenderGraphContext& ctx, vk::CommandBuffer cmd, bool translucent) const; @@ -39,6 +42,7 @@ namespace Engine::Passes { RG::TextureHandle m_HDRColor; RG::TextureHandle m_Velocity; // TAA 运动向量(第二颜色附件) RG::TextureHandle m_Depth; + RG::TextureHandle m_SceneColor; // 场景色副本(透射折射采样源) std::array m_ShadowMaps; }; diff --git a/Engine/include/Engine/Renderer/Passes/HitProxyPass.h b/Engine/include/Engine/Renderer/Passes/HitProxyPass.h index c9cfe97..85aa9f1 100644 --- a/Engine/include/Engine/Renderer/Passes/HitProxyPass.h +++ b/Engine/include/Engine/Renderer/Passes/HitProxyPass.h @@ -30,6 +30,7 @@ namespace Engine { class Device; class ShaderManager; class DescriptorManager; + class UniformBuffer; namespace Passes { @@ -54,6 +55,13 @@ namespace Engine { //(Renderer::TakePickResult 内 waitIdle) bool TakeResult(u32& outBits); +#ifdef HELLO_EDITOR + // Editor gizmo 拾取代理壳接入(editor 专属;Renderer + // BuildGraph 内 Attach——共享 gizmo 代理 ring VB,本 pass + // 用 gizmo_pick 管线重画进 R32Uint,与 mesh 同一深度解算) + void AttachGizmoProxy(UniformBuffer& vb) { m_GizmoProxyVB = &vb; } +#endif + private: Device& m_Device; SceneGpu& m_Gpu; @@ -67,6 +75,15 @@ namespace Engine { // 1×1 像素暂存(host visible,持久映射);拷贝录制时置位 Scope m_Staging; bool m_ResultValid = false; + +#ifdef HELLO_EDITOR + // gizmo 代理壳拾取(editor 专属,game 构建零成员):独立 + // layout(无描述符,push = viewProj)+ gizmo_pick shader 对 + + // 代理 ring VB 非拥有指针(Renderer 持有生命周期) + vk::PipelineLayout m_GizmoLayout; + ShaderManager::ShaderRef m_GizmoVert, m_GizmoFrag; + UniformBuffer* m_GizmoProxyVB = nullptr; +#endif }; } // namespace Passes diff --git a/Engine/include/Engine/Renderer/Passes/ImGuiPass.h b/Engine/include/Engine/Renderer/Passes/ImGuiPass.h index 3170fb0..40594fd 100644 --- a/Engine/include/Engine/Renderer/Passes/ImGuiPass.h +++ b/Engine/include/Engine/Renderer/Passes/ImGuiPass.h @@ -26,10 +26,14 @@ namespace Engine { // presentTarget:ImGui::Image 采样的场景纹理(tonemap 输出)。 // loadOp 按 displayMode 分支:Direct = Load(blit 已铺场景), // Viewport = Clear(唯一写入者,清"桌面底色") + // previewAtlas:材质球预览 SDR atlas(MaterialPreviewPass 写、 + // ImGui::Image 按槽位采样;声明读 = eSampled usage 推导 + + // ColorAttachment→ShaderRead barrier) ImGuiPass(ImGuiLayer** layer, FrameSceneData& frame, - RG::TextureHandle backbuffer, RG::TextureHandle presentTarget) + RG::TextureHandle backbuffer, RG::TextureHandle presentTarget, + RG::TextureHandle previewAtlas) : m_Layer(layer), m_Frame(frame), m_Backbuffer(backbuffer), - m_PresentTarget(presentTarget) {} + m_PresentTarget(presentTarget), m_PreviewAtlas(previewAtlas) {} void Register(RG::RenderGraph& rg) override; @@ -38,6 +42,7 @@ namespace Engine { FrameSceneData& m_Frame; // displayMode(帧契约) RG::TextureHandle m_Backbuffer; RG::TextureHandle m_PresentTarget; + RG::TextureHandle m_PreviewAtlas; }; } // namespace Passes diff --git a/Engine/include/Engine/Renderer/Passes/MaterialPreviewPass.h b/Engine/include/Engine/Renderer/Passes/MaterialPreviewPass.h new file mode 100644 index 0000000..dd13982 --- /dev/null +++ b/Engine/include/Engine/Renderer/Passes/MaterialPreviewPass.h @@ -0,0 +1,279 @@ +// +// Created by atdunbg on 2026/10/5. +// +// MaterialPreviewPass:材质球预览缩略图(editor 专属,HELLO_EDITOR 裁剪)。 +// +// 架构对齐 UE FAssetThumbnailPool / Unity AssetPreview: +// - 渲染一次缓存:tile 内容跨帧持久(HDR atlas loadOp=Load),只有 +// "脏 tile"(新材质 / 参数版本变化 / 镜像失效)消耗每帧渲染预算。 +// RG 物理镜像按帧槽位各一份(in-flight 三缓冲),tile 完成标准 = +// 三个槽位的镜像都画过(paintedSlots 位掩码)——否则 ImGui 按当前 +// 槽位采样会 1/3 帧有图 2/3 帧是垃圾(按帧率闪烁)。完成后干净帧 +// 两 pass 零 GPU 工作 +// - 只请求可见项:UI 对滚进视口的行发幂等 keepalive 请求,池按 +// Material 身份(serial + editVersion)判缓存命中/标脏 +// - 失效模型:Material::UpdateParams bump editVersion → 下帧重渲该 +// tile(拖滑块期间单 tile 实时,其余零 GPU 工作);材质销毁/换绑 +// 靠 lastUseFrame 老化 LRU 驱逐,池内不跨帧持有 Material 指针 +// +// 渲染本体(与场景零复制的 WYSIWYG 契约,原样保留): +// - 几何 = 程序化 UV 球(世界空间原点,无模型矩阵——预览 set0 的 +// ObjectBuffer 恒 identity,与 simple_shader.vert 的 GPUDriven +// 契约兼容:gl_InstanceIndex=0 → models[0]=I) +// - 光照 = 与场景完全相同的 simple_shader.vert/frag(同一 SPIR-V、 +// 同一描述符布局):set1 直接绑目标材质的描述符集 +// - set2 绑 SceneGpu::previewEnvSets(IBL 三件套锁定中性环境——UE +// ThumbnailRenderer 语义:观察条件恒定,金属球不随场景环境变黑; +// 级联/场景色副本与场景同步) +// - 固定棚拍布光(自建 set0 UBO,内容恒定只写一次) +// +// 两个固定尺寸 atlas(不随渲染分辨率缩放): +// PreviewHDR(R16G16B16A16Sfloat)← 本 pass 按脏 tile 清矩形 + 绘制 +// PreviewSDR(R8G8B8A8Unorm) ← tonemap(仅有渲染的帧执行), +// ImGuiPass 声明读,ImGui 按槽位采样 +// +// 本 pass 消费 Renderer 每帧换入的脏 tile 表(PreviewTilePool::TakeRenders +// 产出,预算 kPreviewRendersPerFrame);表空 = 两 pass 零 GPU 工作。 +// + +#ifndef HELLOVULKAN_MATERIALPREVIEWPASS_H +#define HELLOVULKAN_MATERIALPREVIEWPASS_H + +#include +#include // 完整类型:池内联函数读 serial/editVersion +#include +#include +#include +#include + +#include +#include +#include + +namespace Engine { + + class Device; + class MemoryAllocator; + class DescriptorManager; + class UniformBuffer; + class Mesh; + class Material; + class Cubemap; + class Buffer; + + namespace Passes { + + class SceneGpu; + class FullscreenGpu; + + // atlas 布局:5×5 tile × 256²。tile 总数 = 并发池容量(不是槽位 + // 上限):UI 只对可见行请求,池按 LRU 换页支撑任意材质数 + inline constexpr u32 kPreviewAtlasSize = 1280; + inline constexpr u32 kPreviewTileSize = 256; + inline constexpr u32 kPreviewTilesPerSide = 5; + inline constexpr u32 kPreviewTileCount = + kPreviewTilesPerSide * kPreviewTilesPerSide; + + // 本帧待渲染的脏 tile(Material* 来自本帧 UI 请求,活指针) + struct PreviewDirtyTile { + Material* material = nullptr; + u32 tile = 0; + }; + + // 编辑器 tile 池(Renderer 持有,UE FAssetThumbnailPool 语义)。 + // 缓存键 = Material serial(出生唯一号,防指针地址复用的 ABA) + // + editVersion(参数代数)。游戏构建整体编译裁剪,无运行时成本 + class PreviewTilePool { + public: + // 每帧渲染预算:脏 tile 多时逐帧消化(滚动列表渐进出图) + static constexpr u32 kPreviewRendersPerFrame = 2; + + // UI 构建段逐可见行调用(幂等 keepalive,刷新 LRU + 脏检测)。 + // 返回 tile:>=0 = 三槽位镜像内容齐备可显示(含重渲期间的旧 + // 内容——拖滑块先显示旧球,三帧内全部槽位收敛到新球,无闪烁); + // -1 = 池满不可驱逐 / 尚未画满三槽位(UI 显示占位) + i32 Request(Material* material) + { + if (!material) return -1; + const u64 serial = material->GetSerial(); + + Entry* slot = nullptr; + for (auto& e : m_Entries) { + if (e.serial == serial) { slot = &e; break; } + } + if (!slot) { + // 分配:空槽优先;满则驱逐最久未用(仅限本帧未使用 + // 者——可见行并发数 < 容量时不发生) + u64 oldest = ~0ull; + for (auto& e : m_Entries) { + if (e.serial == 0) { slot = &e; break; } + if (e.lastUseFrame < m_Frame && + e.lastUseFrame < oldest) { + oldest = e.lastUseFrame; + slot = &e; + } + } + if (!slot) return -1; + slot->serial = serial; + slot->paintedSlots = 0; + slot->trackedVersion = 0; + } + + const u32 tileIdx = + static_cast(slot - m_Entries.data()); + slot->lastUseFrame = m_Frame; + + const u32 ver = material->GetEditVersion(); + const bool displayable = + slot->paintedSlots == kAllSlotsMask; + if (!displayable || slot->trackedVersion != ver) { + // 标脏(同 tile 本帧去重——同一材质可被多行共享)。 + // 未画满三槽位的 tile 每帧重入队,直到逐槽位补齐; + // 重入队刷新活指针(跳帧滞留也不会引用已销毁材质) + bool queued = false; + for (auto& d : m_FrameDirty) { + if (d.tile == tileIdx) { + d.material = material; + queued = true; + break; + } + } + if (!queued) { + m_FrameDirty.push_back({material, tileIdx}); + } + } + return displayable ? static_cast(tileIdx) : -1; + } + + // Render 段取本帧预算内的脏 tile(frameSlot = 本帧渲染槽位, + // 该槽位镜像在本帧被写入)。渲染语义(清矩形+画球)由 pass + // 执行后该槽位内容即有效。剩余脏 tile 保持未取——下帧可见行 + // keepalive 请求会以新活指针重新入队,池绝不跨帧持有指针 + const std::vector& TakeRenders(u32 frameSlot) + { + ++m_Frame; + m_Active.clear(); + const u32 budget = std::min( + kPreviewRendersPerFrame, + static_cast(m_FrameDirty.size())); + for (u32 i = 0; i < budget; ++i) { + const auto& d = m_FrameDirty[i]; + Entry& e = m_Entries[d.tile]; + e.trackedVersion = d.material->GetEditVersion(); + e.paintedSlots |= (1u << frameSlot); + m_Active.push_back(d); + } + m_FrameDirty.clear(); + return m_Active; + } + + // 材质实例即将销毁/重建(ReloadMaterialAsset):清脏表引用 + // + 按 serial 清槽位。同 UI 段"Request → Reload"顺序下,脏 + // 表里的裸指针当帧 Render 段会被解引用——必须先 drop + void DropMaterial(const Material* material) + { + if (!material) return; + const u64 serial = material->GetSerial(); + std::erase_if(m_FrameDirty, + [&](const PreviewDirtyTile& d) + { return d.material == material; }); + for (auto& e : m_Entries) { + if (e.serial == serial) { + e.serial = 0; + e.paintedSlots = 0; + e.trackedVersion = 0; + } + } + } + + // RG 重编译后物理镜像全部换新(三槽位内容清零)——池记录的 + // paintedSlots 全部失效。可见行 keepalive 请求自然重入队补画 + void InvalidateAll() + { + for (auto& e : m_Entries) { + e.paintedSlots = 0; + e.trackedVersion = 0; + } + } + + private: + struct Entry { + u64 serial = 0; // 0 = 空槽(Material serial 从 1 起) + u32 trackedVersion = 0; // atlas 内容对应的参数版本 + u64 lastUseFrame = 0; // LRU / 老化驱逐判据 + u32 paintedSlots = 0; // 已画过的帧槽位镜像位掩码 + }; + // 全槽位掩码:三份 in-flight 镜像都画过 = 内容可显示 + static constexpr u32 kAllSlotsMask = + (1u << kMaxFramesInFlight) - 1; + std::array m_Entries; + std::vector m_FrameDirty; // 本帧标脏(UI 段) + std::vector m_Active; // 预算内(Render 段) + u64 m_Frame = 0; + }; + + class MaterialPreviewPass final : public RenderPass { + public: + // dirtyTiles = Renderer 持有的当帧脏表(Render 段换入,只读) + MaterialPreviewPass(Device& device, MemoryAllocator& allocator, + DescriptorManager& descriptors, + SceneGpu& gpu, FullscreenGpu& fullscreen, + RG::TextureHandle hdrAtlas, + RG::TextureHandle velocityAtlas, + RG::TextureHandle sdrAtlas, + const std::vector& + dirtyTiles); + ~MaterialPreviewPass() override = default; + + void Register(RG::RenderGraph& rg) override; + void OnTargetsRebuilt(RG::RenderGraph& rg) override; + // 调试视图 cube 槽(binding 8-11)绑真环境资源——cube 槽类型 + // 必须 Cube,2D atlas 占位会触发 VUID-07752 + void OnEnvironmentChanged(const Cubemap* env) override; + + private: + // 单个材质球:tile 视口/裁剪 + 材质 set1 + 混合态 + 球绘制 + void RecordBall(vk::CommandBuffer cmd, u32 tile, + const Material& material) const; + + Device& m_Device; + MemoryAllocator& m_Allocator; + DescriptorManager& m_Descriptors; + SceneGpu& m_Gpu; + FullscreenGpu& m_Fullscreen; + + RG::TextureHandle m_HDRAtlas; + RG::TextureHandle m_VelocityAtlas; + RG::TextureHandle m_SDRAtlas; + const std::vector& m_DirtyTiles; + + // ---- 预览专用 set0(与 uboSetLayout 同布局,内容恒定)---- + // UBO = 固定棚拍相机/布光;SSBO = GPUDriven 契约的最小占位 + //(1 实例 identity 矩阵 + skin slot0 identity + 实体 id 占位, + // vert 按 gl_InstanceIndex=0 读取) + Scope m_PreviewUbo; + Scope m_ObjectBuffer; + Scope m_PrevObjectBuffer; + Scope m_SkinOffsetBuffer; + Scope m_SkinPaletteBuffer; + Scope m_EntityIdBuffer; + vk::DescriptorSet m_Set0; + + // 程序化 UV 球(世界空间原点半径 1;材质非拥有 = nullptr, + // 材质在绘制时经 set1 绑定,不进 Mesh) + Scope m_Sphere; + + // tonemap per-slot set(FullscreenGpu 布局:binding0 = HDR atlas, + // binding1 同源占位——tonemap.frag 终值路径恒采样 bloom 图, + // 强度 0 丢弃) + std::array m_TonemapSets; + + // 当前环境(cube 调试槽源;注册时与 SetEnvironment 时由 + // Renderer 通知) + const Cubemap* m_Env = nullptr; + }; + + } // namespace Passes +} // namespace Engine + +#endif //HELLOVULKAN_MATERIALPREVIEWPASS_H diff --git a/Engine/include/Engine/Renderer/Passes/SceneGpu.h b/Engine/include/Engine/Renderer/Passes/SceneGpu.h index 11d815b..5a4b57d 100644 --- a/Engine/include/Engine/Renderer/Passes/SceneGpu.h +++ b/Engine/include/Engine/Renderer/Passes/SceneGpu.h @@ -57,7 +57,8 @@ namespace Engine { vk::DescriptorSetLayout uboSetLayout; // Set0 = {UBO, ObjectBuffer, PrevObjectBuffer, // SkinOffsets, SkinPalettes, EntityIds} vk::DescriptorSetLayout textureSetLayout; // Set1 = {材质 4 槽 + 参数 UBO} - vk::DescriptorSetLayout shadowSetLayout; // Set2 = {级联 0/1/2 阴影图, irradiance cube} + vk::DescriptorSetLayout shadowSetLayout; // Set2 = {级联 0/1/2 阴影图, irradiance cube, + // prefiltered cube, BRDF LUT, 场景色副本} vk::PipelineLayout forwardLayout; // 3 sets,无 push(GPUDriven 经 SSBO) vk::PipelineLayout shadowLayout; // Set0,push = lightVP 64B @@ -65,10 +66,23 @@ namespace Engine { std::vector uboSets; std::vector shadowSets; + // 材质预览锁定环境集(Set2 同布局,editor 专属——HELLO_EDITOR + // 裁剪):级联/场景色副本随 BindShadowMaps/BindSceneColor 与 + // shadowSets 同步重绑(几何资源,无环境语义);IBL 三件套只在 + // LockPreviewEnvironment 绑定一次后锁定——材质球观察条件不随 + // 场景 SetEnvironment 漂移(UE ThumbnailRenderer 语义) +#ifdef HELLO_EDITOR + std::vector previewEnvSets; +#endif + // 阴影采样器:clamp-to-border 白色 = 光视锥外自动无阴影 //(省 shader 出界分支) vk::Sampler shadowSampler; + // 场景色副本采样器(binding 6):普通线性 clamp + //(阴影采样器带硬件比较,不可用于常规 sampler2D) + vk::Sampler sceneSampler; + // ---- MDI per-slot ring ---- // 布局:[main | cascade0 | cascade1 | cascade2],每区 // kMaxInstances 条命令预算(每实例至多 1 条命令,必不越界)。 @@ -86,9 +100,17 @@ namespace Engine { void BindShadowMaps(RG::RenderGraph& rg, const std::array& maps); + // RG 重编译后重绑场景色副本(Set2 binding 6,透射采样源) + void BindSceneColor(RG::RenderGraph& rg, RG::TextureHandle sceneColor); + // 环境辐照度(Set2 binding 3):view 全程稳定,绑一次即可 void BindEnvironment(const Cubemap& env); + // 材质预览锁定环境:只绑 previewEnvSets 的 IBL 三件套 + //(binding 3/4/5),一次调用永久生效;须在 Renderer 初始化 + //(环境尚为中性兜底)时调用 + void LockPreviewEnvironment(const Cubemap& env); + private: Device& m_Device; MemoryAllocator& m_Allocator; diff --git a/Engine/include/Engine/Renderer/Renderer.h b/Engine/include/Engine/Renderer/Renderer.h index f845774..19e4895 100644 --- a/Engine/include/Engine/Renderer/Renderer.h +++ b/Engine/include/Engine/Renderer/Renderer.h @@ -23,6 +23,7 @@ #include #include #include +#include // PreviewTilePool/PreviewDirtyTile(完整类型:成员 vector) #include #include #include @@ -54,6 +55,7 @@ namespace Engine { namespace Passes { class RenderPass; class SceneGpu; class FullscreenGpu; class PostProcessParams; class PhysicsDebugPass; class HitProxyPass; + class EditorGizmoPass; } class Renderer { @@ -98,6 +100,19 @@ namespace Engine { // 渲染分辨率(PresentTarget 尺寸,等比适配用) vk::Extent2D GetRenderExtent() const { return m_Extent; } + // ---- 材质球预览(editor 专属,HELLO_EDITOR 裁剪;game 构建无此 API)---- + // UI 构建段只对可见行发幂等请求(UE FAssetThumbnailPool 语义): + // tile 池按 Material serial+参数版本判缓存,渲染一次跨帧持久, + // 脏 tile 走每帧预算。返回 tile:>=0 = 内容可显示(UV 子矩形取 + // 图);-1 = 首渲未完成/池满(UI 显示占位) +#ifdef HELLO_EDITOR + i32 RequestMaterialPreview(Material* material); + // 材质实例销毁/重建前调用(ReloadMaterialAsset 等):清池内 + // 脏表引用与槽位——脏 tile 本帧不再解引用已销毁材质(UAF) + void DropMaterialPreview(const Material* material); + void* GetMaterialPreviewTexture() const; +#endif + // ---- 后处理参数(ImGui 可调,转发到 pass 共享的 PostProcessParams)---- f32 GetExposure() const; void SetExposure(f32 e); @@ -118,6 +133,15 @@ namespace Engine { void SetBloomEnabled(bool b); bool GetLodEnabled() const; void SetLodEnabled(bool b); // 关 = 全部强制 LOD0 +#ifdef HELLO_EDITOR + // ---- 骨骼可视化(转发 SceneRenderer;编辑形态专属)---- + bool GetBoneWiresVisible() const; + void SetBoneWiresVisible(bool b); + bool GetBoneAllNodes() const; + void SetBoneAllNodes(bool b); + f32 GetBoneJointPx() const; + void SetBoneJointPx(f32 px); +#endif int GetDebugView() const; void SetDebugView(int v); // Passes::DebugView 枚举值 // 调试视图名表(下标 = DebugView 枚举值,ImGui combo 直接枚举) @@ -170,6 +194,11 @@ namespace Engine { // 不能拿位值兼作哨兵) void SetSelectedEntity(std::optional entityBits); + // ---- 帧截图(调试 / 无 UI 自动化验证)---- + // 请求 delayFrames 帧后把 PresentTarget(tonemap+outline 后最终 + // 画面)拷出存 PNG。触发帧内部 waitIdle;仅调试用途 + void RequestScreenshot(const std::string& path, u32 delayFrames = 60); + // 垂直同步开关(present mode 切换会重建 swapchain), // 只应在帧 UI 构建段调用(Render 之前) void SetVSync(bool vsync) { m_DesiredVSync = vsync; } // 帧首同步生效 @@ -207,6 +236,14 @@ namespace Engine { // UniformBuffer——PhysicsDebugPass::PrepareFrame 上传,pass 内绑定) Scope m_PhysicsLineVB; Scope m_PhysicsTriVB; + // Editor gizmo 图元 ring VB(editor 专属,game 构建不分配): + // 图标 billboard / 视锥线框 / 拾取代理壳三流——代理壳由 + // HitProxyPass 经 AttachGizmoProxy 消费(同 VB 换 shader 重画) +#ifdef HELLO_EDITOR + Scope m_GizmoIconVB; + Scope m_GizmoWireVB; + Scope m_GizmoProxyVB; +#endif // 环境预积分生成端(compute 链),先于 m_Environment/m_Passes 构造 Scope m_Ibl; // 中性兜底环境(IBL 四件套):Application 未加载/加载失败时保底; @@ -230,6 +267,7 @@ namespace Engine { RG::TextureHandle m_Depth{}; std::array m_ShadowMaps{}; // CSM 级联 RG::TextureHandle m_HDRColor{}; // Forward 输出(线性 HDR),TAA 归一 + RG::TextureHandle m_SceneColor{}; // 不透明段快照(透射折射采样源) RG::TextureHandle m_Velocity{}; // Forward 输出(clip 空间运动向量) RG::TextureHandle m_HitProxyId{}; // 拾取 id 目标(R32Uint,片元写实体位模式) RG::TextureHandle m_SelectionMask{}; // 选中描边 mask(R8Unorm,选中像素=1) @@ -237,6 +275,9 @@ namespace Engine { RG::TextureHandle m_BloomA{}; // 半分辨率:提取 + 垂直模糊结果 RG::TextureHandle m_BloomB{}; // 半分辨率:水平模糊中间结果 RG::TextureHandle m_TemporalDiff{}; // 半分辨率:帧历史差异(调试) + RG::TextureHandle m_PreviewHDR{}; // 材质球 atlas(线性 HDR,固定 1024²) + RG::TextureHandle m_PreviewVelocity{}; // 材质球 velocity 附件(forward.frag 双输出契约,无人读) + RG::TextureHandle m_PreviewSDR{}; // 材质球 atlas(tonemap 输出,ImGui 采样) // pass 共享的可调参数(Renderer 持有,App 层访问器转发) Scope m_PostParams; @@ -249,6 +290,16 @@ namespace Engine { const PhysicsDebugFrame* m_PhysicsDebug = nullptr; // HitProxy 拾取(非拥有指针:pass 在 m_Passes 内) Passes::HitProxyPass* m_HitProxyPass = nullptr; + // 材质球预览(editor 专属;HELLO_EDITOR 裁剪,game 构建零预览 + // 代码路径——pass 不创建、atlas 不创建、请求池不存在) +#ifdef HELLO_EDITOR + // 非拥有指针:pass 在 m_Passes 内,构造时持有 m_PreviewRenders 引用 + Passes::MaterialPreviewPass* m_PreviewPass = nullptr; + // tile 池:UI 段 Request 标脏(收集活指针),Render 段 + // TakeRenders 取预算内的脏 tile 换入 m_PreviewRenders(pass 只读) + Passes::PreviewTilePool m_PreviewPool; + std::vector m_PreviewRenders; +#endif // GPU timestamp(pass 级耗时,ImGui 可读) vk::QueryPool m_QueryPool; u32 m_QueriesPerFrame = 0; // 每帧槽位的查询数 = pass 数 × 2 @@ -266,6 +317,14 @@ namespace Engine { // ---- 帧首同步的意图缓冲(UI 段写入,下一帧帧首落定)---- int m_DesiredDisplayMode = kDisplayDirect; // 默认直接渲染, 视口渲染需要手动指定一下 vk::Extent2D m_DesiredViewport{}; // Editor 视口渲染分辨率(面板上报) + + // 截图意图:framesLeft 递减到 0 的那一帧末尾拷贝 PresentTarget 存 PNG + struct ScreenshotRequest + { + std::string path; + u32 framesLeft = 0; + }; + std::optional m_PendingShot; bool m_DesiredVSync = true; }; diff --git a/Engine/include/Engine/Renderer/SceneRenderer.h b/Engine/include/Engine/Renderer/SceneRenderer.h index 4c7fc5e..69f93ad 100644 --- a/Engine/include/Engine/Renderer/SceneRenderer.h +++ b/Engine/include/Engine/Renderer/SceneRenderer.h @@ -23,11 +23,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include @@ -70,6 +72,15 @@ namespace Engine { // primary 相机渲染进 sceneTex(Play 后 Scene view 显示游戏画面, // 编辑器 UI 保持可用)。kDisplayDirect 恒为游戏形态不受此影响 void SetGameView(bool g) { m_GameView = g; } +#ifdef HELLO_EDITOR + // ---- 骨骼可视化设置(Editor 面板 → Renderer 门面 → 落帧)---- + void SetBoneWiresVisible(bool b) { m_ShowBoneWires = b; } + void SetBoneAllNodes(bool b) { m_BoneAllNodes = b; } + void SetBoneJointPx(f32 px) { m_BoneJointPx = px; } + bool GetBoneWiresVisible() const { return m_ShowBoneWires; } + bool GetBoneAllNodes() const { return m_BoneAllNodes; } + f32 GetBoneJointPx() const { return m_BoneJointPx; } +#endif // 编辑器相机(视口 UI 状态,SceneRenderer 持有 = 渲染源在此分派); // Application 驱动 Update,ViewportPanel/Inspector 经 Renderer 门面访问 EditorCamera& GetEditorCamera() { return m_EditorCamera; } @@ -88,8 +99,24 @@ namespace Engine { private: // CSM:把相机视锥按实用分割切 N 段,每段独立拟合光源正交投影 //(含纹素对齐防阴影边缘闪烁)。写入(Shadow pass push)与采样 - //(Forward UBO)共用同一组矩阵。 - void ComputeCascades(const glm::vec3& shadowDir); + //(Forward UBO)共用同一组矩阵。camNear/camFar 取自当帧渲染源 + // 相机(每实体参数) + void ComputeCascades(const glm::vec3& shadowDir, + f32 camNear, f32 camFar); + +#ifdef HELLO_EDITOR + // Editor gizmo 可视化收集(billboard 图标 / 相机视锥线框 / + // HitProxy 代理壳)——只读场景写 FrameSceneData editor 段。 + // camPos/right/up 供 billboard 面向展开;aspect 供视锥角点; + // viewHeightPx 供代理 ribbon 恒定像素屏宽换算;viewTanHalf 为 + // 编辑器视角相机的 tan(fov/2)(billboard 尺寸基准) + void CollectEditorGizmos(ECS::Scene& scene, + const glm::vec3& camPos, + const glm::vec3& camRight, + const glm::vec3& camUp, + f32 aspect, f32 viewHeightPx, + f32 viewTanHalf); +#endif // MDI 打包:batch 按组键排序(megabuffer + 可选材质)后压成 // IndirectDrawCmd 连续数组 + IndirectGroup 区间表。 @@ -128,6 +155,13 @@ namespace Engine { // batch 查找:数组寻址(下标 = Mesh::GetSlot(),值 = 每 LOD 的 // batchIdx+1,0 = 未用)——逐实例 hash 在大场景是毫秒级开销 std::vector> lookup; + // 覆盖材质的 batch 查找(内建材质走 lookup 数组快路径): + // key = meshSlot<<32 | matVariant(matVariant = 覆盖材质的 + // 帧内序号,指针仅帧内有效),value = 每 LOD 的 batchIdx+1 + std::unordered_map> ovrLookup; + // 帧内覆盖材质 → 序号分配表(指针仅帧内有效——材质资产 + // Reload 会在帧间换代,禁止跨帧缓存) + std::unordered_map matVariants; void Clear() { @@ -138,6 +172,8 @@ namespace Engine { batchHead.clear(); batchDistSum.clear(); for (auto& e : lookup) e.fill(0); // 容量保留,置零复用 + ovrLookup.clear(); + matVariants.clear(); } }; ViewScratch m_MainScratch; @@ -155,16 +191,11 @@ namespace Engine { std::vector> m_PrevWorlds; std::vector> m_CurWorlds; - // ---- 骨骼动画 scratch(帧首重建/容量复用)---- - // entKey → palette 区段基址(第 2.5 节预采样填,收集段查) - std::unordered_map m_EntitySkinBase; - // 采样层 A(当前 clip)/ B(fade 来源 clip)与 global 累乘 - std::vector m_PosePosA, m_PosePosB; - std::vector m_PoseRotA, m_PoseRotB; - std::vector m_PoseSclA, m_PoseSclB; - std::vector m_TouchedA, m_TouchedB; - std::vector m_Globals; - bool m_SkinTruncWarned = false; // palette 预算溢出只告警一次 + // ---- 骨骼动画(调度收口 Animation::AnimationSystem,本类只 + // 转发调用 + 收集段只读查 frame.entitySkinBases)---- + Animation::AnimationSystem m_AnimSystem; + // 材质资产加载缺失的一次性告警节流(按路径;发现即插表) + std::unordered_set m_WarnedMaterialMisses; bool m_TruncationWarned = false; // 实例缓冲打满只告警一次 @@ -176,6 +207,16 @@ namespace Engine { int m_DebugView = 0; int m_DisplayMode = kDisplayViewport; // Editor 视口为默认形态 bool m_GameView = false; // PIE 内嵌播放(Editor Play/Stop 驱动) + +#ifdef HELLO_EDITOR + bool m_GizmoTruncWarned = false; // gizmo 顶点预算溢出只告警一次 + // 骨骼可视化设置(唯一持有者,BuildFrame 落帧)。默认关闭: + // 骨架是调试可视化(Inspector Animation 节手动打开),常显 + // 会污染编辑视图 + bool m_ShowBoneWires = false; + bool m_BoneAllNodes = false; + f32 m_BoneJointPx = 5.0f; +#endif }; } // namespace Engine diff --git a/Engine/include/Engine/Renderer/SceneTypes.h b/Engine/include/Engine/Renderer/SceneTypes.h index cfefefd..dbacc43 100644 --- a/Engine/include/Engine/Renderer/SceneTypes.h +++ b/Engine/include/Engine/Renderer/SceneTypes.h @@ -13,6 +13,7 @@ #include #include +#include #include namespace Engine { @@ -20,10 +21,13 @@ namespace Engine { class Mesh; class Material; - // GPUDriven:按 (mesh, LOD) 聚合的绘制批——同 mesh 不同 LOD 的实例 - // 索引区间不同,不能并成一次 draw + // GPUDriven:按 (mesh, 材质, LOD) 聚合的绘制批——同 mesh 不同 LOD + // 的实例索引区间不同不能并成一次 draw;同 mesh 不同材质(实体级 + // 覆盖)描述符集不同,同样必须分批 struct InstanceBatch { Mesh* mesh; + Material* material = nullptr; // 收集期 resolve 的实际材质 + //(覆盖材质或 mesh 内建;shadow 组不绑材质但同表记录) u32 first; // 在实例矩阵数组中的起始下标 u32 count; // 实例数 u32 lod = 0; // 绘制用索引区间(Mesh::Draw) @@ -92,6 +96,47 @@ namespace Engine { // 位模式,永不与有效实体冲突 inline constexpr u32 kPickMiss = 0xFFFFFFFFu; +#ifdef HELLO_EDITOR + // ---- Editor gizmo 可视化契约(SceneRenderer 生产,EditorGizmoPass / + // HitProxyPass 消费;game 构建整段不存在)---- + // 顶点预算:图标 ≈680 个(4096/6),线框覆盖 ~200 相机视锥 + 半径圆 + inline constexpr u32 kMaxGizmoIconVerts = 4096; + inline constexpr u32 kMaxGizmoWireVerts = 16384; + inline constexpr u32 kMaxGizmoProxyVerts = 32768; + + // billboard/拾取代理 quad 顶点(48B):pos 已由收集期面向相机展开, + // aux.x = 形状 id(shader SDF 分支),aux.yz = quad 本地 uv(-1..1) + struct GizmoQuadVertex { + glm::vec3 pos; + glm::vec4 color; // rgb 线性 HDR 主色,a = 不透明度 + glm::vec4 aux; + u32 entityId; // HitProxy 位模式(拾取 shader 顶点直读) + }; + static_assert(sizeof(GizmoQuadVertex) == 48); + + // 线框顶点(28B):布局与 debug_line.vert 输入同构 → 复用其 shader + struct GizmoWireVertex { + glm::vec3 pos; + glm::vec4 color; + }; + static_assert(sizeof(GizmoWireVertex) == 28); + + // 选中高亮色(与 OutlinePass 描边同款,UE 语义统一)。gizmo 收集 + // 与 Animation 骨骼可视化共用一份定义 + inline const glm::vec4 kGizmoSelectedColor{1.0f, 0.62f, 0.05f, 1.0f}; + // 非骨骼节点的暗色(骨骼线亮橙 / 普通节点线暗灰,层级一眼分层; + // LineList 无混合,alpha 无效,只能实色区分) + inline const glm::vec4 kGizmoNodeDimColor{0.42f, 0.42f, 0.46f, 1.0f}; + + // 骨骼关节标记(AnimationSystem 产出,ViewportPanel 投影到屏幕画 + // 圆点 + tooltip 骨骼名——Endfield-Poser overlay 范式) + struct BoneJointMark { + glm::vec3 pos; // 关节世界坐标 + u32 nodeIdx; // 查 Model::GetNodes()[nodeIdx].name 用 + u8 isBone; // 1 = 骨骼(bone 表成员),0 = 普通节点 + }; +#endif + // 点击拾取请求(UI 段写入 → SceneRenderer 落帧 → HitProxy pass 只读) struct PickRequest { bool active = false; @@ -162,6 +207,10 @@ namespace Engine { // palette 区段池:帧首 bump allocator 按动画实体分配 // [skinBase .. skinBase+boneCount) = 该实体全部骨骼的蒙皮矩阵 std::vector skinPalettes; + // 实体(entt integral)→ 该实体 palette 区段基址。AnimationSystem + // 预采样填,收集段查表(查不到 = 无动画/组件禁用 → identity 槽 + // = 绑定姿势)。渲染侧对动画零逻辑,只消费查表结果 + std::unordered_map entitySkinBases; // per-instance 实体 id(与 instanceModels 平行对齐; // HitProxy id pass 按 gl_InstanceIndex 取,片元写入 R32Uint 目标) @@ -203,6 +252,31 @@ namespace Engine { // palette 预算溢出(kMaxSkinMatrices):动画实体 palette 被截断 bool skinPaletteTruncated = false; +#ifdef HELLO_EDITOR + // ---- Editor gizmo 可视化(收集门控:kDisplayViewport 且非 PIE + // 时非空;每帧 clear() 复用容量,稳态零堆分配)---- + std::vector gizmoIconVerts; // 视觉 billboard(三角表) + std::vector gizmoWireVerts; // 线段对(LineList) + // 选中实体骨骼线框(AnimationSystem 产出,EditorGizmoPass 与 + // gizmoWireVerts 拼同一 LineList 绘制)。动画实体跟随当帧姿态, + // 非动画/禁用实体 = 绑定姿势(与渲染 identity palette 一致) + std::vector boneWireVerts; + std::vector gizmoProxyVerts; // HitProxy 代理壳 + + // ---- 骨骼可视化设置(ImGui 经 Renderer 门面写入)---- + bool showBoneWires = true; // 总开关(3D 骨架线 + 关节圆点) + bool pieActive = false; // PIE 运行态(Play/Stop 驱动):骨骼 + // 线/关节圆点全部停产出——PIE 画面 + // 是 primary 相机渲染的"游戏",编辑器 + // 可视化叠上去必然错位 + bool boneAllNodes = false; // true = 全节点层级(普通节点暗色); + // false = 仅骨骼链(bone 表成员) + f32 boneJointPx = 5.0f; // 关节圆点半径(屏幕像素,overlay 画) + // 关节标记(EmitSelectedSkeleton 产出,每帧重建;ViewportPanel + // 用编辑器相机投影画 overlay 圆点) + std::vector boneJoints; +#endif + f32 collectMs = 0.0f; // 收集+剔除 CPU 耗时(遥测) }; diff --git a/Engine/include/Engine/Renderer/VulkanAssetUploader.h b/Engine/include/Engine/Renderer/VulkanAssetUploader.h index 37da7e8..3172b3b 100644 --- a/Engine/include/Engine/Renderer/VulkanAssetUploader.h +++ b/Engine/include/Engine/Renderer/VulkanAssetUploader.h @@ -40,8 +40,22 @@ namespace Engine { Scope LoadEnvironment(const std::string& path) override; Scope LoadEnvironment(const std::string& path, DecodedEquirect&& decoded) override; + // 析构外置(成员持 Scope 数组等重资源,遵循 + // Model/Material 既有模式) + ~VulkanAssetUploader() override; + + // ---- 材质资产(.material 数据段 → GPU 段)---- + // 每槽路径建独占纹理(空/缺失回退共享默认纹理),再组 Material + Scope CreateMaterial(MaterialAssetData&& data) override; + // 设备空闲屏障(编辑器资产重建路径:卸载旧 GPU 资源前全停飞) + void WaitDeviceIdle() override; private: + // 共享默认纹理集(全部材质资产共用一份,惰性创建; + // 单点实现 = MaterialAsset.cpp 的 CreateDefaultMaterialTexture) + void InitSharedDefaultTextures(); + std::array, Material::kSlotCount> m_DefaultTextures{}; + Device& m_Device; MemoryAllocator& m_Allocator; DescriptorManager& m_Descriptors; diff --git a/Engine/include/Engine/Vulkan/Texture.h b/Engine/include/Engine/Vulkan/Texture.h index 4ca09a5..25d03e2 100644 --- a/Engine/include/Engine/Vulkan/Texture.h +++ b/Engine/include/Engine/Vulkan/Texture.h @@ -35,6 +35,13 @@ namespace Engine { static Scope CreateSolidColorRGBA(Device& device, MemoryAllocator& allocator, u8 r, u8 g, u8 b, u8 a, bool srgb = true); + // 内存编码图像(PNG/JPEG 原始字节)→ 纹理:GLB 内嵌贴图 + //(assimp "*" 约定)的解析端,复用 InitFromPixels + // 公共上传路径。解码失败返回空(调用方折到默认纹理) + static Scope CreateFromEncodedMemory( + Device& device, MemoryAllocator& allocator, + const void* data, u32 bytes, bool srgb); + ~Texture(); Texture(const Texture&) = delete; diff --git a/Engine/include/Engine/Vulkan/UniformBuffer.h b/Engine/include/Engine/Vulkan/UniformBuffer.h index d8fa848..204db63 100644 --- a/Engine/include/Engine/Vulkan/UniformBuffer.h +++ b/Engine/include/Engine/Vulkan/UniformBuffer.h @@ -41,6 +41,11 @@ namespace Engine { // 写入指定帧槽位的 UBO(仅在该槽位 fence 确认完成后调用) void Update(u32 frameSlot, const void* data, u64 size) const; + // 偏移写入(同槽位 fence 契约同上)。多段数据共享同一 buffer 时 + // 用——gizmo 线流 + 骨骼线流拼同一顶点缓冲 + void UpdateAt(u32 frameSlot, u64 offset, const void* data, + u64 size) const; + private: std::vector> m_Buffers; u64 m_Size = 0; diff --git a/Engine/src/Animation/AnimationAttach.cpp b/Engine/src/Animation/AnimationAttach.cpp new file mode 100644 index 0000000..de0672f --- /dev/null +++ b/Engine/src/Animation/AnimationAttach.cpp @@ -0,0 +1,223 @@ +// +// AnimationAttach 实现:外部动画资产 → 目标模型并入。 +// +// 轨道映射两级: +// ① 同名直映——同源拆分资产(角色/动画同一骨架导出)零损耗; +// ② Humanoid 槽位重定向——名字不同源的组装资产(角色 FBX + +// 动画 FBX 各自制作),经 Unity Avatar 语义的标准人形槽位配对, +// 逐 key 烘焙"rest→anim 的增量变换"到目标骨架局部空间: +// 平移 out.pos = dstRestPos + (srcPos - srcRestPos) +// 旋转 Δ = srcRot * inv(srcRestRot);out.rot = Δ * dstRestRot +// (Δ 父空间左乘 = 骨骼自身摆动在两套骨架间等价传递) +// 烘焙后 clip 即目标骨架空间数据,评估器零改动。 +// +// AssetManager 是 facade:取模型 + 解析磁盘,纯算法在这里。 +// + +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace Engine::Animation { + + namespace { + + // mat4 → TRS 分解(rest 局部变换;引擎 node 无 shear) + struct TRS { + glm::vec3 pos{}; + glm::quat rot{1.0f, 0.0f, 0.0f, 0.0f}; + glm::vec3 scl{1.0f}; + }; + + TRS Decompose(const glm::mat4& m) + { + TRS t; + t.pos = glm::vec3(m[3]); + const glm::vec3 colX(m[0]); + const glm::vec3 colY(m[1]); + const glm::vec3 colZ(m[2]); + t.scl = {glm::length(colX), glm::length(colY), + glm::length(colZ)}; + if (t.scl.x > 1e-8f && t.scl.y > 1e-8f && t.scl.z > 1e-8f) { + t.rot = glm::quat_cast(glm::mat3( + colX / t.scl.x, colY / t.scl.y, colZ / t.scl.z)); + } + return t; + } + + } // namespace + + AttachResult AttachClipsToModel(Model& target, ParsedModel&& src, + const std::string& sourcePath) + { + AttachResult res; + // 幂等:该源文件已并入过(自愈重放/重复 attach 直接返回) + for (const auto& c : target.GetClips()) { + if (c.sourcePath == sourcePath) { + return res; + } + } + if (src.clips.empty()) { + ENGINE_WARN("AttachClipsToModel: no clips in '{}'", sourcePath); + return res; + } + + const std::vector& dstNodes = target.GetNodes(); + const std::vector& srcNodes = src.nodes; + + // ① 同名直映表 + std::unordered_map nameToIdx; + nameToIdx.reserve(dstNodes.size()); + for (u32 i = 0; i < dstNodes.size(); ++i) { + nameToIdx.emplace(dstNodes[i].name, i); + } + // ② Humanoid 槽位映射表(src nodeIdx → dst nodeIdx) + std::unordered_map retargetMap; + std::unordered_map retargetSlot; + { + const auto pairs = AutoMapHumanoid(srcNodes, dstNodes); + for (const auto& [si, di] : pairs) { + retargetMap.emplace(si, di); + // 槽位回查(明细行显示用):源骨骼名识别即可 + Side side; + bool ok = false; + retargetSlot.emplace(si, MatchHumanoidSlot( + srcNodes[si].name, side, ok)); + } + } + // 重定向骨架的 rest(逐 nodeIdx 缓存,避免每 key 重分解) + std::unordered_map dstRestCache; + dstRestCache.reserve(retargetMap.size()); + for (const auto& [si, di] : retargetMap) { + dstRestCache.emplace(si, Decompose(dstNodes[di].localTransform)); + } + + const std::string stem = + std::filesystem::path(sourcePath).stem().string(); + std::vector extra; + extra.reserve(src.clips.size()); + for (auto& clip : src.clips) { + AnimationClip out; + out.name = clip.name; + out.duration = clip.duration; + out.tps = clip.tps; + out.sourcePath = sourcePath; + // 同名冲突(内嵌 clip 或其它源):源文件名循环加前缀直到 + // 唯一。单轮前缀在"前缀名也撞"(如两次 attach 同名资产)时 + // 仍会产出重复名——Controller/脚本按 clip 名引用,重名即歧义 + { + const auto collides = [&](const std::string& n) { + for (const auto& c : target.GetClips()) { + if (c.name == n) return true; + } + for (const auto& c : extra) { + if (c.name == n) return true; + } + return false; + }; + while (collides(out.name)) { + out.name = stem + "_" + out.name; + } + } + for (auto& ch : clip.channels) { + if (ch.positions.empty() && ch.rotations.empty() && + ch.scales.empty()) { + continue; // 空轨道无用 + } + if (ch.nodeIdx >= srcNodes.size()) { + ++res.channelsSkipped; + continue; + } + const std::string& boneName = srcNodes[ch.nodeIdx].name; + + AnimChannel remapped; + if (auto it = nameToIdx.find(boneName); + it != nameToIdx.end()) { + // ① 同名直搬 + remapped.nodeIdx = it->second; + remapped.positions = std::move(ch.positions); + remapped.rotations = std::move(ch.rotations); + remapped.scales = std::move(ch.scales); + out.channels.push_back(std::move(remapped)); + ++res.channelsMatched; + continue; + } + + // ② 槽位重定向:增量烘焙到目标骨架局部空间。 + // 平移增量按骨长比缩放(|dstRest| / |srcRest|)—— + // 两资产单位/比例不同源(FBX 常为 cm、glTF 为 m)时 + // 不缩放会把模型拽飞;rest≈0 的根骨骼比例取 1 + const auto mapIt = retargetMap.find(ch.nodeIdx); + if (mapIt == retargetMap.end()) { + ++res.channelsSkipped; // 两级都落不上(手指/道具等) + continue; + } + const TRS srcRest = Decompose( + srcNodes[ch.nodeIdx].localTransform); + const TRS& dstRest = dstRestCache[ch.nodeIdx]; + remapped.nodeIdx = mapIt->second; + const f32 srcLen = glm::length(srcRest.pos); + const f32 ratio = + (srcLen > 1e-6f) ? (glm::length(dstRest.pos) / srcLen) + : 1.0f; + + remapped.positions.reserve(ch.positions.size()); + for (const auto& k : ch.positions) { + remapped.positions.push_back( + {k.time, dstRest.pos + + (k.value - srcRest.pos) * ratio}); + } + remapped.rotations.reserve(ch.rotations.size()); + for (const auto& k : ch.rotations) { + const glm::quat delta = + k.value * glm::inverse(srcRest.rot); + remapped.rotations.push_back( + {k.time, delta * dstRest.rot}); + } + remapped.scales = std::move(ch.scales); + out.channels.push_back(std::move(remapped)); + ++res.channelsRetargeted; + + // 明细行(同骨骼多 clip 去重) + const bool newRow = std::none_of( + res.retargetRows.begin(), res.retargetRows.end(), + [&](const RetargetRow& r) + { return r.srcName == boneName; }); + if (newRow) { + res.retargetRows.push_back( + {HumanoidSlotName(retargetSlot[ch.nodeIdx]), + boneName, dstNodes[mapIt->second].name}); + } + } + // 全部轨道落不上 = 无可驱动骨骼,clip 不入表 + if (out.channels.empty()) { + ENGINE_WARN("AttachClipsToModel: clip '{}' has no matching " + "bones in target (skipped)", + clip.name); + continue; + } + extra.push_back(std::move(out)); + ++res.clipsAdded; + } + if (!extra.empty()) { + target.AppendClips(std::move(extra)); + } + ENGINE_INFO("AttachClipsToModel: '{}' -> {} clips " + "({} named, {} retargeted, {} skipped)", + sourcePath, res.clipsAdded, res.channelsMatched, + res.channelsRetargeted, res.channelsSkipped); + return res; + } + +} // namespace Engine::Animation diff --git a/Engine/src/Animation/AnimationController.cpp b/Engine/src/Animation/AnimationController.cpp new file mode 100644 index 0000000..a62c2b5 --- /dev/null +++ b/Engine/src/Animation/AnimationController.cpp @@ -0,0 +1,390 @@ +// +// AnimationController 实现:.animctrl YAML 解析。 +// +// 纯资产解析(无运行时状态):字符串条件在这里一次性编译成数值比较 +// 结构(TransitionCond),System 每帧只做查表比较,零字符串处理。 +// + +#include + +#include + +#include + +#include +#include +#include +#include +#include + +namespace Engine::Animation { + + // 2D 方向混合权重:原点层 1-|v| + 覆盖扇区角度插值 ×min(|v|,1)。 + // 数学与原 System 内联版一致,抽公共函数后编辑器方向盘可视化 + // 与运行时采样永不漂移。 + // kMinBlendWeight:方向层权重低于阈值即不输出——中心附近每个 + // 方向层都有"角度份额×|v|"的微量残留,不滤的话"读出显示 0.00 + // 却还在影响姿态"(四舍五入假象)+ 无效微量混合。原点层不受 + // 此限(它是中心默认动作,v 小时权重接近 1) + constexpr f32 kMinBlendWeight = 0.01f; + + void EvaluateBlendTree2D(const BlendTree& tree, f32 x, f32 z, + std::vector>& out) + { + out.clear(); + const auto& ch = tree.children; + const f32 r = glm::min(glm::length(glm::vec2(x, z)), 1.0f); + // 原点子节点:权重 = 1-|v| 随模长连续衰减(Unity 2D 语义)—— + // 待机到方向动作平滑过渡。v=0 时全量,拖到边缘衰减为 0; + // 任何实现里原点层都不允许"v≠0 即消失"(那样中心拖动会瞬切) + for (u32 i = 0; i < ch.size(); ++i) { + if (glm::length(ch[i].pos) < 1e-4f) { + if (1.0f - r > 1e-4f) out.push_back({i, 1.0f - r}); + break; + } + } + if (r <= 1e-4f || ch.empty()) return; + // 覆盖 v 角度的相邻扇区(角度归一 [0,2π),wrap 补全;方向子 + // ≤16 个,栈数组零堆分配) + constexpr f32 kTwoPi = 6.2831853f; + const f32 theta = glm::mod(glm::atan(z, x), kTwoPi); + u32 idx[16]; + f32 ang[16]; + u32 nd = 0; + for (u32 i = 0; i < ch.size() && nd < 16; ++i) { + if (glm::length(ch[i].pos) < 1e-4f) continue; + idx[nd] = i; + ang[nd] = glm::mod(glm::atan(ch[i].pos.y, ch[i].pos.x), kTwoPi); + ++nd; + } + for (u32 i = 0; i < nd; ++i) { + const u32 j = (i + 1) % nd; + const f32 ai = ang[i]; + const f32 aj = (j == 0) ? ang[0] + kTwoPi : ang[j]; + if (theta >= ai && theta <= aj) { + const f32 span = aj - ai; + const f32 t = span > 1e-6f ? (theta - ai) / span : 0.0f; + const f32 wi = (1.0f - t) * r; + const f32 wj = t * r; + if (wi >= kMinBlendWeight) out.push_back({idx[i], wi}); + if (wj >= kMinBlendWeight) out.push_back({idx[j], wj}); + break; + } + } + } + + // "Speed > 0.1" / "IsJump == true" / ""(恒真)→ 数值比较结构。 + // 未知参数/坏格式 = invalid(永不触发 + WARN,资产错误不崩运行时) + TransitionCond AnimationController::ParseCond(const std::string& s) + { + TransitionCond c; + if (s.empty()) { + c.always = true; + c.valid = true; + return c; + } + std::istringstream ss(s); + std::string param, op, operand; + ss >> param >> op >> operand; + c.param = param; + if (param.empty() || op.empty() || operand.empty()) { + ENGINE_WARN("AnimationController: bad cond '{}' (want 'P op X')", + s); + return c; + } + if (op == ">") c.op = TransitionCond::Op::Gt; + else if (op == "<") c.op = TransitionCond::Op::Lt; + else if (op == ">=") c.op = TransitionCond::Op::Ge; + else if (op == "<=") c.op = TransitionCond::Op::Le; + else if (op == "==") c.op = TransitionCond::Op::Eq; + else if (op == "!=") c.op = TransitionCond::Op::Ne; + else { + ENGINE_WARN("AnimationController: unknown op '{}' in '{}'", + op, s); + return c; + } + if (operand == "true") { + c.isBool = true; + c.boolOperand = true; + } else if (operand == "false") { + c.isBool = true; + c.boolOperand = false; + } else { + try { + size_t consumed = 0; + c.numOperand = std::stof(operand, &consumed); + if (consumed != operand.size()) throw std::invalid_argument(""); + } catch (const std::exception&) { + ENGINE_WARN("AnimationController: bad operand '{}' in '{}'", + operand, s); + return c; + } + } + c.valid = true; + return c; + } + + Scope + AnimationController::Load(const std::string& path) + { + YAML::Node root; + try { + root = YAML::LoadFile(path); + } catch (const std::exception& e) { + ENGINE_ERROR("AnimationController: failed to parse '{}': {}", + path, e.what()); + return nullptr; + } + + auto ctrl = CreateScope(); + ctrl->m_Path = path; + + // ---- Params(参数声明;类型缺省 float)---- + if (const YAML::Node params = root["Params"]) { + for (const auto& p : params) { + ControllerParam d; + d.name = p["name"].as(""); + const std::string type = + p["type"].as("float"); + if (type == "bool") d.type = ControllerParam::Type::Bool; + else if (type == "trigger") + d.type = ControllerParam::Type::Trigger; + if (d.type == ControllerParam::Type::Bool) + d.defBool = p["default"].as(false); + else + d.defFloat = p["default"].as(0.0f); + if (!d.name.empty()) { + ctrl->m_Params.push_back(std::move(d)); + } + } + } + + // ---- States(map:state 名 → 定义;文件序即声明序)---- + if (const YAML::Node states = root["States"]) { + for (const auto& it : states) { + ControllerState st; + st.name = it.first.as(""); + const YAML::Node sn = it.second; + if (st.name.empty()) continue; + if (const YAML::Node clip = sn["clip"]) { + st.tree.type = BlendTree::Type::Single; + st.tree.clip = clip.as(""); + } + if (const YAML::Node bt = sn["blendTree"]) { + const std::string btType = + bt["type"].as("2DDirectional"); + st.tree.type = + (btType == "1D" || btType == "1DDirectional") + ? BlendTree::Type::Directional1D + : BlendTree::Type::Directional2D; + st.tree.xParam = bt["x"].as(""); + if (const YAML::Node z = bt["z"]) { + st.tree.zParam = z.as(""); + } + if (const YAML::Node children = bt["children"]) { + for (const auto& c : children) { + BlendTreeChild ch; + ch.clip = c["clip"].as(""); + if (const YAML::Node at = c["at"]) { + if (at.IsSequence() && at.size() >= 1) { + ch.pos.x = at[0].as(0.0f); + if (at.size() >= 2) { + ch.pos.y = at[1].as(0.0f); + } + } + } + if (!ch.clip.empty()) { + st.tree.children.push_back(std::move(ch)); + } + } + } + } + st.speed = sn["speed"].as(1.0f); + st.loop = sn["loop"].as(true); + if (const YAML::Node te = sn["transitionsOnEnd"]) { + for (const auto& t : te) { + st.transitionsOnEnd.push_back( + t.as("")); + } + } + // BlendTree 子节点排序(Load 期一次,System 每帧直接 + // 扫描):1D 按阈值升序;2D 按角度升序(原点子节点 + // 角度未定义留在原位——扇区查找时按 |pos|<ε 排除) + if (st.tree.type == BlendTree::Type::Directional1D) { + std::sort(st.tree.children.begin(), + st.tree.children.end(), + [](const BlendTreeChild& a, + const BlendTreeChild& b) + { return a.pos.x < b.pos.x; }); + } else if (st.tree.type == + BlendTree::Type::Directional2D) { + std::sort(st.tree.children.begin(), + st.tree.children.end(), + [](const BlendTreeChild& a, + const BlendTreeChild& b) { + return std::atan2(a.pos.y, a.pos.x) < + std::atan2(b.pos.y, b.pos.x); + }); + } + ctrl->m_StateOrder.push_back(st.name); + ctrl->m_States.emplace(st.name, std::move(st)); + } + } + if (ctrl->m_States.empty()) { + ENGINE_ERROR("AnimationController: '{}' has no states", path); + return nullptr; + } + + // ---- Entry(显式键优先,缺省 = 声明序首个)---- + if (const YAML::Node entry = root["Entry"]) { + ctrl->m_Entry = entry.as(""); + } + if (ctrl->m_Entry.empty() || + !ctrl->m_States.count(ctrl->m_Entry)) { + if (!ctrl->m_Entry.empty()) { + ENGINE_WARN("AnimationController: entry '{}' not found in " + "'{}', falling back to first state", + ctrl->m_Entry, path); + } + ctrl->m_Entry = ctrl->m_StateOrder.front(); + } + + // ---- Transitions(目标不存在的行丢弃 + WARN)---- + if (const YAML::Node trans = root["Transitions"]) { + for (const auto& t : trans) { + Transition tr; + tr.from = t["from"].as("*"); + tr.to = t["to"].as(""); + tr.fade = t["fade"].as(0.25f); + tr.hasExitTime = t["exitTime"].as(false); + tr.cond = ParseCond(t["cond"].as("")); + if (tr.to.empty() || !ctrl->m_States.count(tr.to)) { + ENGINE_WARN("AnimationController: transition to " + "unknown state '{}' in '{}'", + tr.to, path); + continue; + } + ctrl->m_Transitions.push_back(std::move(tr)); + } + } + + ENGINE_INFO("AnimationController: loaded '{}' ({} states, " + "{} params, {} transitions)", + path, ctrl->m_States.size(), ctrl->m_Params.size(), + ctrl->m_Transitions.size()); + return ctrl; + } + + // Save:与 Load 对齐的 YAML 反序列化(f32 紧凑格式 %.4g;clip/状态 + // 名加引号——mixamorig: 前缀等含冒号名不破键值解析) + static std::string FmtF(f32 v) + { + char b[32]; + snprintf(b, sizeof(b), "%.4g", static_cast(v)); + return b; + } + + std::string AnimationController::CondToString(const TransitionCond& c) + { + const char* op = "="; + switch (c.op) { + case TransitionCond::Op::Gt: op = ">"; break; + case TransitionCond::Op::Lt: op = "<"; break; + case TransitionCond::Op::Ge: op = ">="; break; + case TransitionCond::Op::Le: op = "<="; break; + case TransitionCond::Op::Eq: op = "=="; break; + case TransitionCond::Op::Ne: op = "!="; break; + } + const std::string operand = + c.isBool ? (c.boolOperand ? "true" : "false") + : FmtF(c.numOperand); + return c.param + " " + op + " " + operand; + } + + bool AnimationController::Save(const std::string& path) const + { + std::string y = + "# 动画控制器(.animctrl)—— ControllerEditorPanel 保存产物\n" + "# 状态/参数/过渡全由本文件声明,引擎零硬编码\n"; + if (!m_Entry.empty()) { + y += "Entry: \"" + m_Entry + "\"\n"; + } + y += "Params:\n"; + for (const auto& p : m_Params) { + y += " - {name: \"" + p.name + "\", type: "; + if (p.type == ControllerParam::Type::Float) { + y += "float, default: " + FmtF(p.defFloat) + "}"; + } else if (p.type == ControllerParam::Type::Bool) { + y += std::string("bool, default: ") + + (p.defBool ? "true" : "false") + "}"; + } else { + y += "trigger}"; + } + y += "\n"; + } + y += "States:\n"; + for (const auto& n : m_StateOrder) { + const auto it = m_States.find(n); + if (it == m_States.end()) continue; + const auto& s = it->second; + y += " \"" + n + "\":\n"; + y += " speed: " + FmtF(s.speed) + "\n"; + y += std::string(" loop: ") + (s.loop ? "true" : "false") + + "\n"; + if (!s.transitionsOnEnd.empty()) { + y += " transitionsOnEnd: ["; + for (size_t i = 0; i < s.transitionsOnEnd.size(); ++i) { + // 状态名加引号(含冒号/逗号的名不破 flow 序列解析, + // 同 States 键与 Transitions from/to 惯例) + y += (i ? ", \"" : "\"") + s.transitionsOnEnd[i] + "\""; + } + y += "]\n"; + } + const auto& t = s.tree; + if (t.type == BlendTree::Type::Single) { + y += " clip: \"" + t.clip + "\"\n"; + } else { + y += " blendTree:\n type: "; + y += (t.type == BlendTree::Type::Directional1D) + ? "1DDirectional\n" + : "2DDirectional\n"; + y += " x: \"" + t.xParam + "\"\n"; + if (t.type == BlendTree::Type::Directional2D) { + y += " z: \"" + t.zParam + "\"\n"; + } + y += " children:\n"; + for (const auto& c : t.children) { + y += " - {clip: \"" + c.clip + "\", at: [" + + FmtF(c.pos.x); + if (t.type == BlendTree::Type::Directional2D) { + y += ", " + FmtF(c.pos.y); + } + y += "]}\n"; + } + } + } + y += "Transitions:\n"; + for (const auto& t : m_Transitions) { + y += " - {from: \"" + t.from + "\", to: \"" + t.to + "\""; + if (t.hasExitTime) { + y += ", exitTime: true"; + } + if (!t.cond.always && t.cond.valid) { + y += ", cond: \"" + CondToString(t.cond) + "\""; + } + y += ", fade: " + FmtF(t.fade) + "}\n"; + } + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) { + ENGINE_ERROR("AnimationController: cannot write '{}'", path); + return false; + } + f.write(y.data(), + static_cast(y.size())); + ENGINE_INFO("AnimationController: saved '{}' ({} states, " + "{} transitions)", + path, m_States.size(), m_Transitions.size()); + return true; + } + +} // namespace Engine::Animation diff --git a/Engine/src/Animation/AnimationEvaluator.cpp b/Engine/src/Animation/AnimationEvaluator.cpp new file mode 100644 index 0000000..20b926d --- /dev/null +++ b/Engine/src/Animation/AnimationEvaluator.cpp @@ -0,0 +1,226 @@ +// +// AnimationEvaluator 实现:采样内核自 SceneRenderer 原样迁移(数学零 +// 改动,行为不变),补 ComputeRestPose 供骨骼可视化用。 +// + +#include + +#include + +namespace Engine::Animation { + + namespace { + + // 静止 local TRS 分解(列主序 SRT 矩阵;假设无负缩放/剪切)。 + // 被动画轨道触碰的节点用关键帧重建,未触碰的保持原矩阵—— + // FBX PreRotation 折在 localTransform 里,拆 TRS 重建会丢 + void DecomposeTRS(const glm::mat4& m, glm::vec3& pos, glm::quat& rot, + glm::vec3& scl) + { + pos = glm::vec3(m[3]); + scl = {glm::length(glm::vec3(m[0])), + glm::length(glm::vec3(m[1])), + glm::length(glm::vec3(m[2]))}; + const glm::mat3 r(glm::vec3(m[0]) / scl.x, + glm::vec3(m[1]) / scl.y, + glm::vec3(m[2]) / scl.z); + rot = glm::quat_cast(r); + } + + // 关键帧采样:前后键插值(位置/缩放 lerp,旋转 slerp)。 + // key 数通常几十个,线性扫描对 cache 更友好 + glm::vec3 SampleKeys(const std::vector& keys, f32 t, + const glm::vec3& rest) + { + if (keys.empty()) return rest; + if (t <= keys.front().time) return keys.front().value; + for (size_t i = 0; i + 1 < keys.size(); ++i) { + if (t < keys[i + 1].time) { + const f32 a = keys[i].time, b = keys[i + 1].time; + return glm::mix(keys[i].value, keys[i + 1].value, + b > a ? (t - a) / (b - a) : 0.0f); + } + } + return keys.back().value; + } + + glm::vec3 SampleKeys(const std::vector& keys, f32 t, + const glm::vec3& rest) + { + if (keys.empty()) return rest; + if (t <= keys.front().time) return keys.front().value; + for (size_t i = 0; i + 1 < keys.size(); ++i) { + if (t < keys[i + 1].time) { + const f32 a = keys[i].time, b = keys[i + 1].time; + return glm::mix(keys[i].value, keys[i + 1].value, + b > a ? (t - a) / (b - a) : 0.0f); + } + } + return keys.back().value; + } + + glm::quat SampleKeys(const std::vector& keys, f32 t, + const glm::quat& rest) + { + if (keys.empty()) return rest; + if (t <= keys.front().time) return keys.front().value; + for (size_t i = 0; i + 1 < keys.size(); ++i) { + if (t < keys[i + 1].time) { + const f32 a = keys[i].time, b = keys[i + 1].time; + return glm::slerp(keys[i].value, keys[i + 1].value, + b > a ? (t - a) / (b - a) : 0.0f); + } + } + return keys.back().value; + } + + // 单 clip 在 time 的全节点局部 TRS(无轨道分量 = 静止分解值) + void SampleLocalPose(const Model& model, const AnimationClip& clip, + f32 time, LocalPose& out) + { + const auto& nodes = model.GetNodes(); + const size_t n = nodes.size(); + out.pos.resize(n); + out.rot.resize(n); + out.scl.resize(n); + out.touched.assign(n, 0); + for (u32 i = 0; i < n; ++i) { + DecomposeTRS(nodes[i].localTransform, out.pos[i], out.rot[i], + out.scl[i]); + } + if (clip.duration <= 0.0f) return; + const f32 t = glm::mod(time, clip.duration); + for (const auto& ch : clip.channels) { + if (ch.nodeIdx >= n) continue; + out.touched[ch.nodeIdx] = 1; + if (!ch.positions.empty()) + out.pos[ch.nodeIdx] = + SampleKeys(ch.positions, t, out.pos[ch.nodeIdx]); + if (!ch.rotations.empty()) + out.rot[ch.nodeIdx] = + SampleKeys(ch.rotations, t, out.rot[ch.nodeIdx]); + if (!ch.scales.empty()) + out.scl[ch.nodeIdx] = + SampleKeys(ch.scales, t, out.scl[ch.nodeIdx]); + } + } + + // 局部 TRS → 全节点 global 矩阵(层级累乘;父序 DFS 保证父先算) + void ComposeGlobals(const Model& model, const LocalPose& pose, + std::vector& globals) + { + const auto& nodes = model.GetNodes(); + globals.resize(nodes.size()); + for (u32 i = 0; i < nodes.size(); ++i) { + const glm::mat4 local = pose.touched[i] + ? glm::translate(glm::mat4(1.0f), pose.pos[i]) * + glm::mat4_cast(pose.rot[i]) * + glm::scale(glm::mat4(1.0f), pose.scl[i]) + : nodes[i].localTransform; + globals[i] = nodes[i].parent >= 0 + ? globals[static_cast(nodes[i].parent)] * local + : local; + } + } + + } // namespace + + void AnimationEvaluator::Evaluate(const Model& model, + const AnimationClip& cur, f32 curTime, + const CrossfadeState& xf, + std::vector& outGlobals) + { + // crossfade 双层 = N 层混合的特例(Direct 模式与 Controller + // 过渡共用同一套加权数学)。src 层权重 = 1 - wCur 与历史 + // mix(src, cur, wCur) 语义等价 + PoseLayer layers[2]; + layers[0] = {&cur, curTime, 1.0f}; + u32 count = 1; + if (xf.src != nullptr) { + layers[1] = {xf.src, xf.srcTime, 1.0f - xf.wCur}; + count = 2; + } + EvaluateLayers(model, layers, count, outGlobals); + } + + void AnimationEvaluator::EvaluateLayers(const Model& model, + const PoseLayer* layers, + u32 count, + std::vector& outGlobals) + { + // ---- 逐层采样(容量跨帧复用;nullptr/零权重层跳过)---- + if (m_Layers.size() < count) { + m_Layers.resize(count); // 只扩不缩,采样循环内元素地址稳定 + } + m_Sampled.clear(); + for (u32 i = 0; i < count; ++i) { + if (layers[i].clip == nullptr || layers[i].weight <= 0.0f) { + continue; + } + SampleLocalPose(model, *layers[i].clip, layers[i].time, + m_Layers[m_Sampled.size()]); + m_Sampled.push_back({&m_Layers[m_Sampled.size()], + layers[i].weight}); + } + + if (m_Sampled.empty()) { + // 无有效层 = rest(m_A untouched = localTransform 原样) + const size_t n = model.GetNodes().size(); + m_A.pos.resize(n); + m_A.rot.resize(n); + m_A.scl.resize(n); + m_A.touched.assign(n, 0); + ComposeGlobals(model, m_A, outGlobals); + return; + } + + // ---- 加权归一化混合进 m_A:渐进混合(基层 = 首个有效层, + // 逐层以 wi/(acc+wi) 混入;对线性量等价 Σwi·xi/Σwi, + // 对旋转是顺序稳定的渐进 slerp——层级混合的通行做法)---- + { + const LocalPose& base = *m_Sampled[0].pose; + const size_t n = base.pos.size(); + m_A.pos.resize(n); + m_A.rot.resize(n); + m_A.scl.resize(n); + m_A.touched.resize(n); + for (size_t k = 0; k < n; ++k) { + m_A.pos[k] = base.pos[k]; + m_A.rot[k] = base.rot[k]; + m_A.scl[k] = base.scl[k]; + m_A.touched[k] = base.touched[k]; + } + f32 acc = m_Sampled[0].weight; + for (size_t l = 1; l < m_Sampled.size(); ++l) { + const LocalPose& src = *m_Sampled[l].pose; + const f32 w = + m_Sampled[l].weight / (acc + m_Sampled[l].weight); + for (size_t k = 0; k < n; ++k) { + // touched = 各层轨道并集(重建集合只扩大) + m_A.touched[k] = + static_cast(m_A.touched[k] | src.touched[k]); + m_A.pos[k] = glm::mix(m_A.pos[k], src.pos[k], w); + m_A.scl[k] = glm::mix(m_A.scl[k], src.scl[k], w); + // 旋转必须 slerp(lerp 单位四元数会走样) + m_A.rot[k] = glm::slerp(m_A.rot[k], src.rot[k], w); + } + acc += m_Sampled[l].weight; + } + } + ComposeGlobals(model, m_A, outGlobals); + } + + void AnimationEvaluator::ComputeRestPose( + const Model& model, std::vector& outGlobals) + { + const auto& nodes = model.GetNodes(); + outGlobals.resize(nodes.size()); + for (u32 i = 0; i < nodes.size(); ++i) { + outGlobals[i] = nodes[i].parent >= 0 + ? outGlobals[static_cast(nodes[i].parent)] * + nodes[i].localTransform + : nodes[i].localTransform; + } + } + +} // namespace Engine::Animation diff --git a/Engine/src/Animation/AnimationSystem.cpp b/Engine/src/Animation/AnimationSystem.cpp new file mode 100644 index 0000000..64eb841 --- /dev/null +++ b/Engine/src/Animation/AnimationSystem.cpp @@ -0,0 +1,573 @@ +// +// AnimationSystem 实现:调度逻辑自 SceneRenderer::BuildFrame 第 2.5 节 +// 原样迁移(播放头/crossfade/palette 分配数学零改动),骨骼可视化段为 +// 新增(编辑形态专属)。 +// + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace Engine::Animation { + + void AnimationSystem::Update(ECS::Scene& scene, Assets::AssetManager& assets, + f32 dt, FrameSceneData& frame) + { + // palette 槽 0 固定 identity:非蒙皮静态模型直通(蒙皮但无 + // 动画采样的实体由下方 rest-pose 兜底覆盖,见第二个 Each) + frame.skinPalettes.clear(); + frame.skinPalettes.push_back(glm::mat4(1.0f)); + frame.entitySkinBases.clear(); + frame.skinPaletteTruncated = false; +#ifdef HELLO_EDITOR + frame.boneWireVerts.clear(); + frame.boneJoints.clear(); + m_SelHasPose = false; +#endif + + // ---- palette bump 分配(Direct/Controller 共用):按实体, + // 不按模型去重——同模型两实体可不同播放状态。预算打满 + // → 该实体退回绑定姿势(rest 兜底接手)---- + auto PushPalette = [&](ECS::Entity entity, const Model* model) { + const auto& bones = model->GetBones(); + const u32 base = static_cast(frame.skinPalettes.size()); + if (base + bones.size() > kMaxSkinMatrices) { + frame.skinPaletteTruncated = true; + if (!m_TruncWarned) { + m_TruncWarned = true; + ENGINE_WARN("AnimationSystem: skin palette full ({}), " + "animated entities fall back to bind pose", + kMaxSkinMatrices); + } + return; + } + for (const auto& b : bones) { + const u32 ni = b.nodeIdx < m_Globals.size() + ? b.nodeIdx : 0; + frame.skinPalettes.push_back(m_Globals[ni] * b.invBind); + } + const u32 entKey = static_cast(entt::to_integral(entity)); + frame.entitySkinBases[entKey] = base; +#ifdef HELLO_EDITOR + // 选中实体 = 暂存当帧姿态(骨骼可视化与渲染同源) + if (frame.selectedEntityValid && + frame.selectedEntityBits == entKey) { + m_SelGlobals = m_Globals; + m_SelHasPose = true; + } +#endif + }; + + // ---- 段 1:Direct 模式(单 clip 直控 + crossfade)---- + scene.Each( + [&](ECS::Entity entity, ECS::MeshRendererComponent& mr, + ECS::AnimationComponent& anim) { + if (anim.mode != ECS::AnimationComponent::AnimMode::Direct) + return; // Controller 实体走段 2 + if (!anim.active) return; // 组件禁用 = 暂停采样(播放头不动) + if (!mr.model.Valid()) return; // 未指派/手清 = 无效句柄(Get 对无效 FATAL) + const Model* model = assets.GetModel(mr.model); + if (!model || !model->HasAnimations()) return; + const auto& clips = model->GetClips(); + + // clip 下标合法性(模型资产可能已换) + if (anim.clipIndex < 0 || + anim.clipIndex >= static_cast(clips.size())) { + anim.clipIndex = 0; + anim.fadeFrom = -1; + if (clips.empty()) return; + } + if (anim.fadeFrom >= static_cast(clips.size())) { + anim.fadeFrom = -1; + } + + // ---- 播放头推进(wrap/clamp)---- + if (anim.playing && anim.speed != 0.0f) { + const f32 d = dt * anim.speed; + anim.time += d; + if (anim.fadeFrom >= 0) anim.fadeFromTime += d; + } + const auto wrapTime = [&](f32 t, const AnimationClip& c) { + if (c.duration <= 0.0f) return 0.0f; + return anim.loop ? glm::mod(t, c.duration) + : glm::clamp(t, 0.0f, c.duration - 1e-3f); + }; + const AnimationClip& cur = clips[anim.clipIndex]; + anim.time = wrapTime(anim.time, cur); + + // ---- crossfade 计时 → 求值快照(状态翻转在这里, + // 权重数学在求值器)---- + CrossfadeState xf; + if (anim.fadeFrom >= 0) { + // 淡出计时(暂停时冻结,与播放头一致) + if (anim.playing) anim.fadeElapsed += dt; + const f32 f = glm::min( + anim.fadeElapsed / glm::max(anim.fadeDuration, 1e-4f), + 1.0f); + xf.wCur = f * f * (3.0f - 2.0f * f); // smoothstep + if (f >= 1.0f) { + anim.fadeFrom = -1; // 过渡完成 + } else { + xf.src = &clips[anim.fadeFrom]; + anim.fadeFromTime = wrapTime(anim.fadeFromTime, *xf.src); + xf.srcTime = anim.fadeFromTime; + } + } + + // ---- 姿态求值(双层混合在求值器内部)---- + m_Evaluator.Evaluate(*model, cur, anim.time, xf, m_Globals); + PushPalette(entity, model); + }); + + // ---- 段 2:Controller 模式(状态机推进 + BlendTree 求值, + // Unity Animator Update 对应物)---- + // clip 名查找表按模型每帧重建(attach/detach 会改 clip 表, + // 每帧 O(clips) 重建最稳;大世界优化挂点 = 模型版本标记缓存) + std::unordered_map> clipLookup; + scene.Each( + [&](ECS::Entity entity, ECS::MeshRendererComponent& mr, + ECS::AnimationComponent& anim) { + if (anim.mode != + ECS::AnimationComponent::AnimMode::Controller) { + return; + } + if (!anim.active) return; + if (!mr.model.Valid()) return; + const Model* model = assets.GetModel(mr.model); + if (!model || !model->HasAnimations()) return; + const auto* ctrl = assets.FindController(anim.controllerPath); + if (!ctrl) return; // 未加载/路径无效 → rest 兜底 + const auto& states = ctrl->GetStates(); + if (states.empty()) return; + + // entry 初始化(首帧 / controller 换绑致状态失效) + if (anim.currentState.empty() || + !states.count(anim.currentState)) { + anim.currentState = ctrl->GetEntryState(); + anim.stateTime = 0.0f; + anim.transFrom.clear(); + anim.transElapsed = 0.0f; + } + + // 参数缺键补声明默认值(emplace 只补缺,调好的值不覆盖): + // 覆盖 controller 编辑期新增参数/换绑/手改场景——否则 + // 新参数在实例 map 缺键,条件读 0 而非声明默认 + for (const auto& p : ctrl->GetParams()) { + if (p.type == ControllerParam::Type::Bool) { + anim.boolParams.emplace(p.name, p.defBool); + } else if (p.type == ControllerParam::Type::Float) { + anim.floatParams.emplace(p.name, p.defFloat); + } + } + + // clip 名 → 下标表 + auto& lookup = clipLookup[model]; + if (lookup.empty()) { + const auto& clips = model->GetClips(); + for (u32 i = 0; i < clips.size(); ++i) { + lookup.emplace(clips[i].name, i); + } + } + const auto findClip = + [&](const std::string& name) -> const AnimationClip* { + const auto it = lookup.find(name); + return it != lookup.end() + ? &model->GetClips()[it->second] + : nullptr; + }; + + const ControllerState* st = + &states.at(anim.currentState); + // 状态代表 clip(exitTime 判定用:单 clip 直取; + // BlendTree 取首子节点——同树内时长通常一致) + const auto stateClipOf = + [&](const ControllerState& s) -> const AnimationClip* { + if (s.tree.type == BlendTree::Type::Single) { + return findClip(s.tree.clip); + } + return s.tree.children.empty() + ? nullptr + : findClip(s.tree.children.front().clip); + }; + const AnimationClip* stateClip = stateClipOf(*st); + + // ---- 时钟推进(组件 speed × 状态 speed)---- + if (anim.playing && anim.speed != 0.0f) { + anim.stateTime += dt * anim.speed * st->speed; + if (!anim.transFrom.empty()) { + anim.transElapsed += dt; + // 来源状态播放头照常推进(过渡期间两状态并行采样); + // 来源非 loop 时钳尾(与 Direct fadeFromTime 一致, + // 否则采样内部 mod 回绕、淡出期间来源动作循环) + if (auto it = states.find(anim.transFrom); + it != states.end()) { + anim.transFromTime += + dt * anim.speed * it->second.speed; + if (const AnimationClip* sc = + stateClipOf(it->second); + sc && !it->second.loop && + sc->duration > 0.0f) { + anim.transFromTime = glm::min( + anim.transFromTime, + sc->duration - 1e-3f); + } + } + } + } + // 非 loop 状态播放头钳尾("播完一轮"判定基准) + const bool finished = + stateClip && !st->loop && stateClip->duration > 0.0f && + anim.stateTime >= stateClip->duration - 1e-3f; + if (stateClip && !st->loop && stateClip->duration > 0.0f) { + anim.stateTime = glm::min( + anim.stateTime, stateClip->duration - 1e-3f); + } + + // ---- 条件评估(数值比较;bool 条件里 trigger 名命中 + // 视为 true 且一次性消费)---- + auto evalCond = [&](const TransitionCond& c) -> bool { + if (!c.valid) return false; + if (c.always) return true; + if (c.isBool) { + bool v = false; + if (auto it = anim.boolParams.find(c.param); + it != anim.boolParams.end()) { + v = it->second; + } + for (size_t i = 0; i < anim.pendingTriggers.size(); + ++i) { + if (anim.pendingTriggers[i] == c.param) { + v = true; // trigger 命中 = 已触发信号 + anim.pendingTriggers.erase( + anim.pendingTriggers.begin() + + static_cast(i)); + break; + } + } + return c.op == TransitionCond::Op::Eq + ? (v == c.boolOperand) + : (v != c.boolOperand); + } + f32 v = 0.0f; + if (auto it = anim.floatParams.find(c.param); + it != anim.floatParams.end()) { + v = it->second; + } + switch (c.op) { + case TransitionCond::Op::Gt: return v > c.numOperand; + case TransitionCond::Op::Lt: return v < c.numOperand; + case TransitionCond::Op::Ge: return v >= c.numOperand; + case TransitionCond::Op::Le: return v <= c.numOperand; + case TransitionCond::Op::Eq: return v == c.numOperand; + case TransitionCond::Op::Ne: return v != c.numOperand; + } + return false; + }; + // 开过渡(CrossFade 语义:来源冻结为淡出层,目标归零重播)。 + // to 必须存在:资产可能引用已删除/改名状态(编辑器删态、 + // 手改 yaml),states.at 越界是崩溃,跳过即可 + auto fire = [&](const std::string& to, f32 fade) { + if (to == anim.currentState || !states.count(to)) return; + anim.transFrom = anim.currentState; + anim.transFromTime = anim.stateTime; + anim.transElapsed = 0.0f; + anim.transFade = glm::max(fade, 1e-3f); + anim.currentState = to; + anim.stateTime = 0.0f; + st = &states.at(to); + stateClip = stateClipOf(*st); + }; + + // ---- 出边评估(过渡期间不叠新过渡)。顺序:AnyState + // → 当前态出边 → transitionsOnEnd,一帧至多一条 ---- + if (anim.transFrom.empty()) { + for (const auto& tr : ctrl->GetTransitions()) { + if (tr.from != "*" || tr.to == anim.currentState) + continue; + if (tr.hasExitTime && !finished) continue; + if (evalCond(tr.cond)) { + fire(tr.to, tr.fade); + break; + } + } + } + if (anim.transFrom.empty()) { + for (const auto& tr : ctrl->GetTransitions()) { + if (tr.from != anim.currentState || + tr.to == anim.currentState) { + continue; + } + if (tr.hasExitTime && !finished) continue; + if (evalCond(tr.cond)) { + fire(tr.to, tr.fade); + break; + } + } + } + if (anim.transFrom.empty() && finished && + !st->transitionsOnEnd.empty()) { + for (const auto& to : st->transitionsOnEnd) { + if (states.count(to) && to != anim.currentState) { + fire(to, 0.25f); // 播完出口缺省 fade + break; + } + } + } + + // ---- BlendTree → PoseLayer[](2D 方向树按角度扇区 + // 裁剪,通常仅 2-3 层权重>0)---- + auto buildLayers = + [&](std::vector& out, const ControllerState& s, + f32 time) { + out.clear(); + const BlendTree& tree = s.tree; + if (tree.type == BlendTree::Type::Single) { + if (const auto* c = findClip(tree.clip)) { + out.push_back({c, time, 1.0f}); + } + return; + } + const auto param = [&](const std::string& n) -> f32 { + auto it = anim.floatParams.find(n); + return it != anim.floatParams.end() ? it->second + : 0.0f; + }; + const auto& ch = tree.children; + if (ch.empty()) return; + + if (tree.type == BlendTree::Type::Directional1D) { + // 一轴阈值:端点外钳单层,区间内相邻对插值 + const f32 x = param(tree.xParam); + if (x <= ch.front().pos.x) { + if (const auto* c = + findClip(ch.front().clip)) { + out.push_back({c, time, 1.0f}); + } + return; + } + if (x >= ch.back().pos.x) { + if (const auto* c = findClip(ch.back().clip)) { + out.push_back({c, time, 1.0f}); + } + return; + } + for (size_t i = 0; i + 1 < ch.size(); ++i) { + if (x >= ch[i].pos.x && + x <= ch[i + 1].pos.x) { + const f32 span = + ch[i + 1].pos.x - ch[i].pos.x; + const f32 t = + span > 1e-6f + ? (x - ch[i].pos.x) / span + : 0.0f; + if (const auto* c = + findClip(ch[i].clip)) { + out.push_back({c, time, 1.0f - t}); + } + if (const auto* c = + findClip(ch[i + 1].clip)) { + out.push_back({c, time, t}); + } + return; + } + } + return; + } + + // 2D 方向混合:权重数学单一事实源 = + // EvaluateBlendTree2D(编辑器方向盘可视化共用, + // 两边永不漂移) + EvaluateBlendTree2D(tree, param(tree.xParam), + param(tree.zParam), + m_BlendWeights); + for (const auto& [ci, w] : m_BlendWeights) { + if (const auto* c = findClip(ch[ci].clip)) { + out.push_back({c, time, w}); + } + } + }; + + buildLayers(m_LayerScratch, *st, anim.stateTime); + if (m_LayerScratch.empty()) { + // 层全空兜底(防 T-pose 跳变):children 里首个 + // 有效 clip 单层顶上——典型场景 = 2D 树没有原点 + // 子节点时 v 回到中心(方向层权重全被最小阈值滤 + // 掉),或 clip 名全失效。rest 兜底(T-pose)只 + // 留给 children 全无效的配置错误 + const BlendTree& tree = st->tree; + const auto firstValid = [&](const auto& cs) + -> const AnimationClip* { + for (const auto& c : cs) { + if (const auto* clip = findClip(c.clip)) { + return clip; + } + } + return nullptr; + }; + const AnimationClip* fb = + tree.type == BlendTree::Type::Single + ? findClip(tree.clip) + : firstValid(tree.children); + if (fb != nullptr) { + m_LayerScratch.push_back({fb, anim.stateTime, 1.0f}); + } + } + if (!anim.transFrom.empty()) { + const f32 f = glm::min(anim.transElapsed / anim.transFade, + 1.0f); + if (f >= 1.0f) { + anim.transFrom.clear(); // 过渡完成 + } else { + // 当前层权重 ×t,来源层权重 ×(1-t),合并求值 + const f32 t = f * f * (3.0f - 2.0f * f); + buildLayers(m_FromLayers, + states.at(anim.transFrom), + anim.transFromTime); + for (auto& l : m_LayerScratch) l.weight *= t; + for (auto& l : m_FromLayers) { + l.weight *= (1.0f - t); + m_LayerScratch.push_back(l); + } + } + } + + m_Evaluator.EvaluateLayers(*model, m_LayerScratch.data(), + static_cast( + m_LayerScratch.size()), + m_Globals); + PushPalette(entity, model); + }); + + // ---- 蒙皮但无动画采样路径的实体:rest-pose palette 兜底 ---- + // identity 槽只对"顶点烘焙进模型空间"的资产等于绑定姿势; + // assimp 导入的蒙皮顶点在骨骼局部空间,identity 直通渲染成 + // 乱码(Xbot.glb / M1911.fbx 无动画组件实证)。绑定姿势的正确 + // 语义 = rest 姿态 palette(求值器 rest 求值同源)。rest 是模型 + // 静态属性:同模型全部实体共享同一槽位段,每模型每帧只算一次 + std::unordered_map restBase; + scene.Each( + [&](ECS::Entity entity, ECS::MeshRendererComponent& mr) { + if (!mr.model.Valid()) return; + const u32 entKey = + static_cast(entt::to_integral(entity)); + if (frame.entitySkinBases.count(entKey)) return; + const Model* model = assets.GetModel(mr.model); + if (!model || model->GetBones().empty()) return; + if (auto it = restBase.find(model); it != restBase.end()) { + frame.entitySkinBases[entKey] = it->second; + return; + } + const auto& bones = model->GetBones(); + const u32 base = + static_cast(frame.skinPalettes.size()); + if (base + bones.size() > kMaxSkinMatrices) { + frame.skinPaletteTruncated = true; + return; // 预算打满 = 该模型实体退回 identity 槽 + } + m_Evaluator.ComputeRestPose(*model, m_Globals); + for (const auto& b : bones) { + const u32 ni = b.nodeIdx < m_Globals.size() + ? b.nodeIdx : 0; + frame.skinPalettes.push_back( + m_Globals[ni] * b.invBind); + } + restBase.emplace(model, base); + frame.entitySkinBases[entKey] = base; + }); + +#ifdef HELLO_EDITOR + EmitSelectedSkeleton(scene, assets, frame); +#endif + } + +#ifdef HELLO_EDITOR + + void AnimationSystem::EmitSelectedSkeleton(ECS::Scene& scene, + Assets::AssetManager& assets, + FrameSceneData& frame) + { + // 总开关(3D 骨架线 + 关节圆点一起关;boneJoints 已在 Update 清) + if (!frame.showBoneWires) return; + // PIE 运行态整体停产:PIE 画面 = primary 相机渲染的游戏,编辑器 + // 可视化(骨架线/关节圆点)叠上去必然错位(同 gizmo 收集门控) + if (frame.pieActive) return; + if (!frame.selectedEntityValid) return; + const auto entity = static_cast(frame.selectedEntityBits); + auto& reg = scene.GetRegistry(); + if (!reg.valid(entity)) return; + const auto* t = reg.try_get(entity); + const auto* mr = reg.try_get(entity); + if (!t || !mr || !mr->model.Valid()) return; + const Model* model = assets.GetModel(mr->model); + if (!model || model->GetNodes().empty()) return; + + // 姿态来源:当帧求值命中 → 用暂存姿态;否则绑定姿势 + //(与渲染 identity palette 表现一致——组件禁用/无动画的实体 + // 在渲染里就是绑定姿势,可视化不得显示别的姿态) + if (!m_SelHasPose) { + m_Evaluator.ComputeRestPose(*model, m_SelGlobals); + } + + // 骨骼成员标记(boneAllNodes=false 时只画骨骼链;bone 表里的 + // node 才是"骨骼",纯 IK/辅助节点画出来只会糊) + const auto& nodes = model->GetNodes(); + const auto& bones = model->GetBones(); + std::vector isBone(nodes.size(), 0); + for (const auto& b : bones) { + if (b.nodeIdx < isBone.size()) isBone[b.nodeIdx] = 1; + } + + // ---- 3D 骨架线(EditorGizmoPass LineList 直绘)---- + // 骨骼线亮橙 / 普通节点线暗灰,层级一眼分层。"仅骨骼"模式 = + // 子节点是骨骼才画(其父链自然衔接,链不会断) + const glm::mat4& world = t->World(); + u32 pairCount = 0; + for (u32 i = 0; i < nodes.size(); ++i) { + if (nodes[i].parent >= 0 && + (frame.boneAllNodes || isBone[i])) { + ++pairCount; + } + } + frame.boneWireVerts.reserve(frame.boneWireVerts.size() + pairCount * 2); + for (u32 i = 0; i < nodes.size(); ++i) { + if (nodes[i].parent < 0) continue; + if (!frame.boneAllNodes && !isBone[i]) continue; + const u32 p = static_cast(nodes[i].parent); + const glm::vec3 a = + glm::vec3(world * m_SelGlobals[p] * glm::vec4(0, 0, 0, 1)); + const glm::vec3 b = + glm::vec3(world * m_SelGlobals[i] * glm::vec4(0, 0, 0, 1)); + const glm::vec4& color = (frame.boneAllNodes && !isBone[i]) + ? kGizmoNodeDimColor + : kGizmoSelectedColor; + frame.boneWireVerts.push_back({a, color}); + frame.boneWireVerts.push_back({b, color}); + } + + // ---- 关节标记(ViewportPanel overlay 圆点 + 骨骼名 tooltip, + // Endfield-Poser 范式:圆点是可读性主体)---- + frame.boneJoints.reserve(frame.boneJoints.size() + nodes.size()); + for (u32 i = 0; i < nodes.size(); ++i) { + if (!frame.boneAllNodes && !isBone[i]) continue; + frame.boneJoints.push_back( + {glm::vec3(world * m_SelGlobals[i] * glm::vec4(0, 0, 0, 1)), + i, isBone[i]}); + } + } + +#endif // HELLO_EDITOR + +} // namespace Engine::Animation diff --git a/Engine/src/Animation/HumanoidRig.cpp b/Engine/src/Animation/HumanoidRig.cpp new file mode 100644 index 0000000..d43140d --- /dev/null +++ b/Engine/src/Animation/HumanoidRig.cpp @@ -0,0 +1,246 @@ +// +// HumanoidRig 实现:关键词表 + 归一化 + 自动映射。 +// + +#include + +#include +#include +#include +#include + +namespace Engine::Animation { + + namespace { + + // ---- 名字归一化:拆 (body, side) ---- + // + // 规则: + // 1) 去 "mixamorig:" 等前缀 + // 2) 小写;分隔符(空格/_/.-:)一律删除 + // 3) left/right(Mixamo "LeftArm")与 左/右(MMD "左腕")统一 + // 抽成 side,body 里剔除;.L/.R、_L/_R 后缀同上 + // + // body 用**精确相等**匹配关键词(非子串)——"lowerarm" + // 不会误命中 "arm" + + Side ExtractSide(std::string& body) + { + Side s = Side::None; + auto cut = [&](std::string_view token, Side which) + { + size_t pos = 0; + while ((pos = body.find(token, pos)) != std::string::npos) { + body.erase(pos, token.size()); + if (s == Side::None) s = which; + } + }; + cut("left", Side::Left); + cut("right", Side::Right); + cut("左", Side::Left); // MMD 前缀 + cut("右", Side::Right); + cut(".l", Side::Left); // blender 惯例后缀 + cut(".r", Side::Right); + cut("_l", Side::Left); + cut("_r", Side::Right); + return s; + } + + // ① 前缀剥离 + 小写(分隔符**保留**——后缀式 side 标记 + // ".l"/"_l" 依赖分隔符存在,必须先于分隔符删除识别) + std::string NormalizeLower(std::string_view name) + { + std::string s(name); + constexpr std::string_view kMixamo = "mixamorig"; + if (s.rfind(kMixamo, 0) == 0) { + s.erase(0, kMixamo.size()); + if (!s.empty() && (s[0] == ':' || s[0] == '_' || + s[0] == '-' || s[0] == '.')) { + s.erase(0, 1); + } + } + for (char& c : s) { + c = static_cast(std::tolower( + static_cast(c))); + } + return s; + } + + // ② 分隔符删除(在 ExtractSide 之后调用;CJK 多字节原样过) + std::string StripSeparators(const std::string& s) + { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (std::isalnum(static_cast(c)) || + static_cast(c) >= 0x80) { + out.push_back(c); + } + } + return out; + } + + // ---- body → 左侧槽位 关键词表(右侧 = 左槽 + 段偏移)---- + // 精确相等匹配;顺序即优先级(先命中先用) + struct BodyRule { + std::string_view body; + HumanoidSlot left; + }; + + constexpr std::array kBodyRules{{ + // 主干(MMD 语义:下半身=hips、上半身链=spine→chest) + {"hips", HumanoidSlot::Hips}, + {"pelvis", HumanoidSlot::Hips}, + {"bip01", HumanoidSlot::Hips}, + {"下半身", HumanoidSlot::Hips}, + {"センター", HumanoidSlot::Hips}, + {"spine", HumanoidSlot::Spine}, + {"上半身", HumanoidSlot::Spine}, + {"chest", HumanoidSlot::Chest}, + {"spine1", HumanoidSlot::Chest}, + {"上半身1", HumanoidSlot::Chest}, + {"upperchest", HumanoidSlot::UpperChest}, + {"spine2", HumanoidSlot::UpperChest}, + {"上半身2", HumanoidSlot::UpperChest}, + {"neck", HumanoidSlot::Neck}, + {"首", HumanoidSlot::Neck}, + {"head", HumanoidSlot::Head}, + {"頭", HumanoidSlot::Head}, + // 臂段(MMD:腕=upper arm、ひじ/肘=elbow、手首=wrist) + {"shoulder", HumanoidSlot::LeftShoulder}, + {"clavicle", HumanoidSlot::LeftShoulder}, + {"肩", HumanoidSlot::LeftShoulder}, + {"arm", HumanoidSlot::LeftUpperArm}, + {"upperarm", HumanoidSlot::LeftUpperArm}, + {"bicep", HumanoidSlot::LeftUpperArm}, + {"腕", HumanoidSlot::LeftUpperArm}, + {"lowerarm", HumanoidSlot::LeftLowerArm}, + {"forearm", HumanoidSlot::LeftLowerArm}, + {"elbow", HumanoidSlot::LeftLowerArm}, + {"ひじ", HumanoidSlot::LeftLowerArm}, + {"肘", HumanoidSlot::LeftLowerArm}, + {"hand", HumanoidSlot::LeftHand}, + {"手首", HumanoidSlot::LeftHand}, + // 腿段(MMD:足=thigh、ひざ=knee、足首=ankle) + {"upperleg", HumanoidSlot::LeftUpperLeg}, + {"upleg", HumanoidSlot::LeftUpperLeg}, + {"thigh", HumanoidSlot::LeftUpperLeg}, + {"足", HumanoidSlot::LeftUpperLeg}, + {"lowerleg", HumanoidSlot::LeftLowerLeg}, + {"leg", HumanoidSlot::LeftLowerLeg}, // Mixamo:LeftLeg=小腿(大腿是 UpLeg) + {"knee", HumanoidSlot::LeftLowerLeg}, + {"calf", HumanoidSlot::LeftLowerLeg}, + {"ひざ", HumanoidSlot::LeftLowerLeg}, + {"foot", HumanoidSlot::LeftFoot}, + {"ankle", HumanoidSlot::LeftFoot}, + {"足首", HumanoidSlot::LeftFoot}, + }}; + + // 左→右槽位偏移:臂/肩段(Shoulder..Hand)+4,腿段(UpperLeg..Foot)+3 + HumanoidSlot ToSide(HumanoidSlot leftSlot, Side side) + { + if (side != Side::Right) return leftSlot; + const u32 s = static_cast(leftSlot); + const u32 legStart = static_cast(HumanoidSlot::LeftUpperLeg); + return static_cast(s + (s >= legStart ? 3 : 4)); + } + + } // namespace + + const char* HumanoidSlotName(HumanoidSlot slot) + { + static constexpr std::array kNames{ + "Hips", "Spine", "Chest", "UpperChest", "Neck", "Head", + "LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand", + "RightShoulder", "RightUpperArm", "RightLowerArm", "RightHand", + "LeftUpperLeg", "LeftLowerLeg", "LeftFoot", + "RightUpperLeg", "RightLowerLeg", "RightFoot", + }; + const u32 s = static_cast(slot); + return s < kNames.size() ? kNames[s] : "?"; + } + + HumanoidSlot MatchHumanoidSlot(std::string_view name, Side& side, + bool& ok) + { + side = Side::None; + ok = false; + std::string body = NormalizeLower(name); + side = ExtractSide(body); + body = StripSeparators(body); + if (body.empty()) return HumanoidSlot::Hips; + + for (const BodyRule& r : kBodyRules) { + if (body == r.body) { + ok = true; + return ToSide(r.left, side); + } + } + return HumanoidSlot::Hips; + } + + std::vector> AutoMapHumanoid( + const std::vector& srcNodes, + const std::vector& dstNodes) + { + std::vector> pairs; + + // 1) 同名直映(同源拆分资产零损耗路径) + std::unordered_map dstByName; + dstByName.reserve(dstNodes.size()); + for (u32 i = 0; i < dstNodes.size(); ++i) { + dstByName.emplace(dstNodes[i].name, i); + } + std::vector dstUsed(dstNodes.size(), 0); + std::vector srcMapped(srcNodes.size(), 0); + for (u32 i = 0; i < srcNodes.size(); ++i) { + if (auto it = dstByName.find(srcNodes[i].name); + it != dstByName.end() && !dstUsed[it->second]) { + pairs.emplace_back(i, it->second); + dstUsed[it->second] = 1; + srcMapped[i] = 1; + } + } + + // 2) 槽位匹配:两侧各自识别 (slot → nodeIdx),槽位一对一配对 + constexpr u32 kSlotCount = static_cast(HumanoidSlot::Count); + auto slotOf = [](const std::vector& nodes, + std::array& out) + { + out.fill(-1); + for (u32 i = 0; i < nodes.size(); ++i) { + Side side; + bool ok = false; + const HumanoidSlot slot = + MatchHumanoidSlot(nodes[i].name, side, ok); + if (!ok) { + continue; + } + const u32 s = static_cast(slot); + if (out[s] < 0) { + out[s] = static_cast(i); // 关键词先命中优先 + } + } + }; + std::array srcSlot{}; + std::array dstSlot{}; + slotOf(srcNodes, srcSlot); + slotOf(dstNodes, dstSlot); + + for (u32 s = 0; s < kSlotCount; ++s) { + if (srcSlot[s] < 0 || dstSlot[s] < 0) { + continue; + } + const u32 si = static_cast(srcSlot[s]); + const u32 di = static_cast(dstSlot[s]); + if (srcMapped[si] || dstUsed[di]) { + continue; // 同名直映已覆盖 + } + pairs.emplace_back(si, di); + dstUsed[di] = 1; + srcMapped[si] = 1; + } + return pairs; + } + +} // namespace Engine::Animation diff --git a/Engine/src/Assets/AssetManager.cpp b/Engine/src/Assets/AssetManager.cpp index abc3f63..fe37371 100644 --- a/Engine/src/Assets/AssetManager.cpp +++ b/Engine/src/Assets/AssetManager.cpp @@ -132,92 +132,52 @@ namespace Engine::Assets { }); } + // ---- 材质资产(.material)---- + + MaterialAssetRef AssetManager::LoadMaterialAsset(const std::string& path) + { + // YAML 解析 + GPU 构建同步串行(主线程低频路径:场景预载 / + // 编辑器指派)。解析失败或 GPU 构建失败 → 无效句柄 + return m_Materials.Load(path, [this](const std::string& p) { + MaterialAssetData data; + if (!MaterialAsset::Load(p, data)) { + return Scope{}; + } + return m_Uploader.CreateMaterial(std::move(data)); + }); + } + + AssetManager::AnimCtrlRef AssetManager::LoadController(const std::string& path) + { + // 纯 CPU 解析(无 GPU 资源),解析失败返回无效句柄 + return m_Controllers.Load(path, [](const std::string& p) { + return Animation::AnimationController::Load(p); + }); + } + + MaterialAssetRef AssetManager::ReloadMaterialAsset(const std::string& path) + { + // GPU 全停飞保证旧纹理/描述符无在飞引用(waitIdle 经 uploader + // 接缝,与 SetEnvironment 换绑同一先例);unload → reload, + // generation 递增让旧句柄自然失效,引用方下帧 resolve 拿新实例 + m_Uploader.WaitDeviceIdle(); + m_Materials.UnloadByPath(path); + return LoadMaterialAsset(path); + } + AssetManager::AnimAttachResult AssetManager::AttachAnimationClips( ModelRef target, const std::string& animPath) { - AnimAttachResult res; + // facade:取模型 + 独立解析动画容器(不复用缓存里可能已预热的 + // 同名 Model——缓存实例的 clips nodeIdx 按它自己的 node 表编号, + // attach 需要按目标模型的 node 表重映射),重映射算法在 + // Animation::AttachClipsToModel(幂等/去重/告警语义全在彼处) Model* tm = GetModel(target); if (tm == nullptr) { - return res; + return {}; } - // 幂等:该源文件已并入过(自愈重放/重复 attach 直接返回) - for (const auto& c : tm->GetClips()) { - if (c.sourcePath == animPath) { - return res; - } - } - // 纯 CPU 解析:动画容器只要 nodes + clips。不复用缓存里可能已 - // 预热的同名 Model——缓存实例的 clips nodeIdx 按它自己的 node 表 - // 编号,attach 需要按目标模型的 node 表重映射,必须独立解析 - ParsedModel src = Model::ParseFromDisk(animPath); - if (src.clips.empty()) { - ENGINE_WARN("AttachAnimationClips: no clips in '{}'", animPath); - return res; - } - - // 目标模型 骨骼节点名 → nodeIdx 重映射表 - std::unordered_map nameToIdx; - nameToIdx.reserve(tm->GetNodes().size()); - for (u32 i = 0; i < tm->GetNodes().size(); ++i) { - nameToIdx.emplace(tm->GetNodes()[i].name, i); - } - - const std::string stem = - std::filesystem::path(animPath).stem().string(); - std::vector extra; - extra.reserve(src.clips.size()); - for (auto& clip : src.clips) { - AnimationClip out; - out.name = clip.name; - out.duration = clip.duration; - out.tps = clip.tps; - out.sourcePath = animPath; - // 同名冲突(内嵌 clip 或其它源):源文件名做前缀保持可辨识 - for (const auto& c : tm->GetClips()) { - if (c.name == out.name) { - out.name = stem + "_" + out.name; - break; - } - } - for (auto& ch : clip.channels) { - if (ch.positions.empty() && ch.rotations.empty() && - ch.scales.empty()) { - continue; // 空轨道无用 - } - if (ch.nodeIdx >= src.nodes.size()) { - ++res.channelsSkipped; - continue; - } - const auto it = nameToIdx.find(src.nodes[ch.nodeIdx].name); - if (it == nameToIdx.end()) { - ++res.channelsSkipped; // 骨骼名不同源 - continue; - } - AnimChannel remapped; - remapped.nodeIdx = it->second; - remapped.positions = std::move(ch.positions); - remapped.rotations = std::move(ch.rotations); - remapped.scales = std::move(ch.scales); - out.channels.push_back(std::move(remapped)); - ++res.channelsMatched; - } - // 全部轨道对不上 = 名字空间完全不同源,clip 不入表 - if (out.channels.empty()) { - ENGINE_WARN("AttachAnimationClips: clip '{}' has no matching " - "bones in target (skipped)", clip.name); - continue; - } - extra.push_back(std::move(out)); - ++res.clipsAdded; - } - if (!extra.empty()) { - tm->AppendClips(std::move(extra)); - } - ENGINE_INFO("AttachAnimationClips: '{}' -> {} clips " - "({} channels matched, {} skipped)", - animPath, res.clipsAdded, res.channelsMatched, - res.channelsSkipped); - return res; + return Animation::AttachClipsToModel( + *tm, Model::ParseFromDisk(animPath), animPath); } ModelRef AssetManager::CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) diff --git a/Engine/src/ImGui/ImGuiLayer.cpp b/Engine/src/ImGui/ImGuiLayer.cpp index b0512d9..ecb9973 100644 --- a/Engine/src/ImGui/ImGuiLayer.cpp +++ b/Engine/src/ImGui/ImGuiLayer.cpp @@ -95,9 +95,12 @@ namespace Engine { // 后端销毁自己的管线/字体资源前先等 GPU 空闲(层先于 Renderer 析构) m_Device.GetDevice().waitIdle(); - // 场景纹理描述符先归还(后端 shutdown 前必须完成) + // 场景纹理 + 材质球预览 atlas 描述符先归还(后端 shutdown 前必须完成) for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { UpdateSceneTexture(slot, vk::ImageView{}); +#ifdef HELLO_EDITOR + UpdatePreviewTexture(slot, vk::ImageView{}); +#endif } ImGui_ImplVulkan_Shutdown(); @@ -129,6 +132,25 @@ namespace Engine { } } +#ifdef HELLO_EDITOR + void ImGuiLayer::UpdatePreviewTexture(u32 slot, vk::ImageView view) + { + if (slot >= kMaxFramesInFlight) return; + + // 语义与 UpdateSceneTexture 完全一致(回收旧描述符 + 注册新 view) + if (m_PreviewTextures[slot]) { + ImGui_ImplVulkan_RemoveTexture( + (VkDescriptorSet)(intptr_t)m_PreviewTextures[slot]); + m_PreviewTextures[slot] = ImTextureID_Invalid; + } + if (view) { + m_PreviewTextures[slot] = (ImTextureID)(intptr_t) + ImGui_ImplVulkan_AddTexture( + (VkImageView)view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + } + } +#endif + void ImGuiLayer::NewFrame() { ImGui_ImplVulkan_NewFrame(); diff --git a/Engine/src/Renderer/Camera.cpp b/Engine/src/Renderer/Camera.cpp index 7bfc912..71b14d6 100644 --- a/Engine/src/Renderer/Camera.cpp +++ b/Engine/src/Renderer/Camera.cpp @@ -14,19 +14,6 @@ namespace Engine { - namespace { - // 欧拉(度)→ 旋转矩阵:应用序 Y→X,与 TransformComponent:: - // Compose 一致(roll 不参与 FPS/编辑器相机) - glm::mat3 EulerRotMat(const glm::vec3& rotDeg) - { - return static_cast( - glm::rotate(glm::mat4(1.0f), glm::radians(rotDeg.y), - glm::vec3(0, 1, 0)) * - glm::rotate(glm::mat4(1.0f), glm::radians(rotDeg.x), - glm::vec3(1, 0, 0))); - } - } - void Camera::Update(ECS::TransformComponent& transform, const f32 deltaTime, const bool flyRequiresRMB) { @@ -51,57 +38,66 @@ namespace Engine { if (mouseDX != 0 || mouseDY != 0) { - glm::vec3 rot = transform.GetRotation(); - // GetRotation 是 0-360 显示域(UE Details 约定);pitch 的 - // clamp 需要连续域,wrap 回 [-180,180)(yaw 同步处理, - // 跨 ±180 继续累减不打折返) - for (int i = 0; i < 2; ++i) { - rot[i] = std::fmod(rot[i], 360.0f); - if (rot[i] >= 180.0f) rot[i] -= 360.0f; - if (rot[i] < -180.0f) rot[i] += 360.0f; - } - // 右手系绕 Y 正角度 = 向左转,鼠标右移(DX>0)应看向右 - // → yaw 取负。pitch 正角度 = 抬头,鼠标下移(DY>0)低头 - // → rot.x 直接用负号,两者符号已核对一致 - rot.y -= mouseDX * m_MouseSensitivity; - rot.x = glm::clamp( - rot.x - mouseDY * m_MouseSensitivity, -89.0f, 89.0f); - transform.SetRotation(rot); - - if (wantOrbit) + if (wantLook) { - if (Input::IsKeyDown(Input::Key::LeftShift)) + // ---- 6DOF 真自由视角:pitch/yaw 各绕本地轴右乘增量 + // 四元数(q = q·Δyaw·Δpitch),无欧拉读改写、无 + // ±89° 钳制——可过顶/翻滚,存储层本就是四元数, + // 不存在万向锁。符号沿用旧约定:鼠标右移向右看 + // (右手系绕 Y 正角向左 → yaw 取负)、鼠标下移 + // 低头(绕 X 正角抬头 → pitch 取负)---- + const glm::quat dyaw = glm::angleAxis( + glm::radians(-mouseDX * m_MouseSensitivity), + glm::vec3(0, 1, 0)); + const glm::quat dpitch = glm::angleAxis( + glm::radians(-mouseDY * m_MouseSensitivity), + glm::vec3(1, 0, 0)); + transform.SetRotationQuat( + transform.GetRotationQuat() * dyaw * dpitch); + } + else if (Input::IsKeyDown(Input::Key::LeftShift)) + { + // ---- 屏幕平面平移:内容跟手(拖右 → 相机左移、 + // 拖下 → 相机上移),pivot 同步平移保持环绕点的 + // 屏幕相对位置 ---- + const glm::mat3 r(transform.GetRotationQuat()); + const glm::vec3 delta = + (-r * glm::vec3(1, 0, 0)) * mouseDX + + (r * glm::vec3(0, 1, 0)) * mouseDY; + const f32 k = m_OrbitDistance * 0.002f; // m/px + transform.SetPosition(transform.GetPosition() + delta * k); + if (m_HasPivot) m_OrbitPivot += delta * k; + } + else + { + // ---- 焦点环绕:世界系左乘增量(yaw 绕世界 Y、pitch + // 绕相机右轴),roll 保持不变;位置绕 pivot 同 + // 角度刚体旋转 → 到 pivot 距离恒定。首次环绕无 + // pivot:取当前视线上 m_OrbitDistance 处 ---- + if (!m_HasPivot) { - // ---- 屏幕平面平移:内容跟手(拖右 → 相机左移、 - // 拖下 → 相机上移),pivot 同步平移保持环绕点的 - // 屏幕相对位置 ---- - const glm::mat3 r = EulerRotMat(rot); - const glm::vec3 delta = - (-r * glm::vec3(1, 0, 0)) * mouseDX + - (r * glm::vec3(0, 1, 0)) * mouseDY; - const f32 k = m_OrbitDistance * 0.002f; // m/px - transform.SetPosition(transform.GetPosition() + delta * k); - if (m_HasPivot) m_OrbitPivot += delta * k; - } - else - { - // ---- 焦点环绕:转向后保持到 pivot 的距离。首次 - // 环绕无 pivot:取当前视线上 m_OrbitDistance 处 - if (!m_HasPivot) - { - m_OrbitPivot = transform.GetPosition() + - EulerRotMat(transform.GetRotation()) * - glm::vec3(0, 0, -1) * - m_OrbitDistance; - m_HasPivot = true; - } - m_OrbitDistance = glm::length( - transform.GetPosition() - m_OrbitPivot); - const glm::vec3 orbitFwd = - EulerRotMat(rot) * glm::vec3(0, 0, -1); - transform.SetPosition(m_OrbitPivot - - orbitFwd * m_OrbitDistance); + const glm::mat3 r(transform.GetRotationQuat()); + m_OrbitPivot = transform.GetPosition() + + (r * glm::vec3(0, 0, -1)) * + m_OrbitDistance; + m_HasPivot = true; } + const glm::vec3 offset = + transform.GetPosition() - m_OrbitPivot; + m_OrbitDistance = glm::length(offset); + const glm::vec3 camRight = + glm::mat3(transform.GetRotationQuat()) * + glm::vec3(1, 0, 0); + const glm::quat dq = + glm::angleAxis( + glm::radians(-mouseDX * m_MouseSensitivity), + glm::vec3(0, 1, 0)) * + glm::angleAxis( + glm::radians(-mouseDY * m_MouseSensitivity), + camRight); + // 朝向与位置共用同一 dq:视线始终精确穿过 pivot + transform.SetRotationQuat(dq * transform.GetRotationQuat()); + transform.SetPosition(m_OrbitPivot + dq * offset); } } } @@ -111,7 +107,7 @@ namespace Engine { } // 基向量由(可能已更新的)实体旋转决定(本地 -Z 为视线方向) - const glm::mat3 r = EulerRotMat(transform.GetRotation()); + const glm::mat3 r(transform.GetRotationQuat()); const glm::vec3 forward = r * glm::vec3(0, 0, -1); const glm::vec3 right = r * glm::vec3(1, 0, 0); @@ -150,7 +146,7 @@ namespace Engine { void Camera::FocusOn(ECS::TransformComponent& transform, const glm::vec3& point) { const glm::vec3 fwd = - EulerRotMat(transform.GetRotation()) * glm::vec3(0, 0, -1); + glm::mat3(transform.GetRotationQuat()) * glm::vec3(0, 0, -1); m_OrbitPivot = point; m_HasPivot = true; transform.SetPosition(point - fwd * m_OrbitDistance); @@ -162,16 +158,34 @@ namespace Engine { return glm::inverse(t.World()); } - glm::mat4 Camera::GetProjection(const f32 aspect, const f32 nearZ, const f32 farZ) const + glm::mat4 Camera::GetProjection(const f32 aspect) const { - auto proj = glm::perspective(glm::radians(45.0f), aspect, nearZ, farZ); - proj[1][1] *= -1.0f; // Vulkan Y 翻转 + glm::mat4 proj; + if (m_ProjectionMode == ProjectionMode::Perspective) + { + proj = glm::perspective(glm::radians(m_FovDeg), aspect, + m_NearZ, m_FarZ); + proj[1][1] *= -1.0f; // Vulkan Y 翻转 - // Vulkan clip 深度 [0,1]:z' = 0.5*z_clip + 0.5*w_clip。 - // perspective 的 w_clip = -z_eye,偏移折算进 z 系数 - //(注意:与 ortho 的公式不同,ortho 的 w=1 才能直接加到 [3][2]) - proj[2][2] = proj[2][2] * 0.5f - 0.5f; - proj[3][2] *= 0.5f; + // Vulkan clip 深度 [0,1]:z' = 0.5*z_clip + 0.5*w_clip。 + // perspective 的 w_clip = -z_eye,偏移折算进 z 系数 + proj[2][2] = proj[2][2] * 0.5f - 0.5f; + proj[3][2] *= 0.5f; + } + else + { + // 正交(Unity orthographic size 语义 = 半垂直高度):全宽由 + // 视口 aspect 推导,长宽比跟随视口永不变形 + const f32 halfH = m_OrthoSize; + const f32 halfW = halfH * aspect; + proj = glm::ortho(-halfW, halfW, -halfH, halfH, m_NearZ, m_FarZ); + proj[1][1] *= -1.0f; // Vulkan Y 翻转(与透视同款) + + // ortho 的 w_clip = 1:偏移直接加进平移分量 + //(与 ComputeCascades 光源正交投影同一公式) + proj[2][2] *= 0.5f; + proj[3][2] = proj[3][2] * 0.5f + 0.5f; + } return proj; } diff --git a/Engine/src/Renderer/Material.cpp b/Engine/src/Renderer/Material.cpp index 7c331c1..b686626 100644 --- a/Engine/src/Renderer/Material.cpp +++ b/Engine/src/Renderer/Material.cpp @@ -11,8 +11,16 @@ #include #include +#include + namespace Engine { + namespace + { + // 材质出生唯一号:预览 tile 池的 ABA 防线(见 Material.h) + std::atomic s_NextMaterialSerial{1}; + } + Material::Material(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout layout, @@ -21,6 +29,7 @@ namespace Engine bool transparent) : m_Textures(textures) , m_Transparent(transparent) + , m_Serial(s_NextMaterialSerial.fetch_add(1, std::memory_order_relaxed)) { m_DescriptorSet = descriptorManager.AllocateDescriptorSet(layout); @@ -45,6 +54,16 @@ namespace Engine Material::~Material() = default; + // 编辑器调参:全 in-flight 槽同步覆盖写(语义与 ctor 初始化一致)。 + // 实现外置因 UniformBuffer 在 Material.h 仅前置声明 + void Material::UpdateParams(const MaterialParams& params) + { + for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { + m_ParamsBuffer->Update(slot, ¶ms, sizeof(params)); + } + ++m_EditVersion; // 预览 tile 池的失效信号(editor 缓存重渲判据) + } + std::string Material::MakeCacheKey(const std::string paths[kSlotCount], const MaterialParams& params, bool transparent) @@ -61,7 +80,24 @@ namespace Engine ",c:" + std::to_string(params.baseColor.x) + "," + std::to_string(params.baseColor.y) + "," + std::to_string(params.baseColor.z) + - "," + std::to_string(params.baseColor.w); + "," + std::to_string(params.baseColor.w) + + ",s:" + std::to_string(params.surface.x) + + "," + std::to_string(params.surface.y) + + "," + std::to_string(params.surface.z) + + "," + std::to_string(params.surface.w); + // per-slot UV 变换 + unlit 入键:同一张贴图配不同变换是不同材质 + //(KHR_texture_transform 测试模型:一张棋盘格被 82 个材质以不同 + // 变换共享,键漏了会串成同一份) + key += ",m:" + std::to_string(params.misc.x); + for (u32 slot = 0; slot < kSlotCount; ++slot) { + key += ",u" + std::to_string(slot) + ":" + + std::to_string(params.uvA[slot].x) + "," + + std::to_string(params.uvA[slot].y) + "," + + std::to_string(params.uvA[slot].z) + "," + + std::to_string(params.uvA[slot].w) + "," + + std::to_string(params.uvT[slot].x) + "," + + std::to_string(params.uvT[slot].y); + } key += transparent ? ":t" : ":o"; // 透明语义入键(描述符相同但队列不同) return key; } diff --git a/Engine/src/Renderer/MaterialAsset.cpp b/Engine/src/Renderer/MaterialAsset.cpp new file mode 100644 index 0000000..8df30bf --- /dev/null +++ b/Engine/src/Renderer/MaterialAsset.cpp @@ -0,0 +1,185 @@ +// +// Created by atdunbg on 2026/10/5. +// + +#include +#include +#include +#include + +#include +#include + +#include + +namespace Engine +{ + Scope CreateDefaultMaterialTexture( + Device& device, MemoryAllocator& allocator, Material::Slot slot) + { + // 与 Model::InitDefaultTextures 同语义(单点实现,两处共用); + // 颜色槽 sRGB / 数据槽线性 + switch (slot) { + case Material::Albedo: + return Texture::CreateSolidColor(device, allocator, 255, 255, 255); + case Material::Normal: + return Texture::CreateSolidColor(device, allocator, + 128, 128, 255, false); + case Material::MetallicRoughness: + return Texture::CreateSolidColor(device, allocator, + 0, 128, 0, false); + case Material::Emissive: + return Texture::CreateSolidColor(device, allocator, 0, 0, 0); + case Material::Ao: + return Texture::CreateSolidColor(device, allocator, + 255, 0, 0, false); + case Material::Roughness: + return Texture::CreateSolidColor(device, allocator, + 128, 128, 128, false); + } + return nullptr; + } + + namespace + { + // 槽位 ↔ YAML 键名(下标 = Material::Slot;改键名 = 破坏旧文件,勿动) + constexpr const char* kSlotKeys[Material::kSlotCount] = { + "Albedo", "Normal", "MetallicRoughness", + "Emissive", "Ao", "Roughness" + }; + + void EmitVec4(YAML::Emitter& e, const glm::vec4& v) + { + e << YAML::Flow << YAML::BeginSeq << v.x << v.y << v.z << v.w + << YAML::EndSeq; + } + + glm::vec4 ReadVec4(const YAML::Node& n, const glm::vec4& def) + { + if (!n || !n.IsSequence() || n.size() < 4) return def; + return {n[0].as(def.x), n[1].as(def.y), + n[2].as(def.z), n[3].as(def.w)}; + } + } + + bool MaterialAsset::Save(const std::string& path, + const MaterialAssetData& data) + { + YAML::Emitter out; + out << YAML::BeginMap; + out << YAML::Key << "Material" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "Version" << YAML::Value << kVersion; + + out << YAML::Key << "Textures" << YAML::Value << YAML::BeginMap; + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + out << YAML::Key << kSlotKeys[slot] + << YAML::Value << data.texPaths[slot]; + } + out << YAML::EndMap; // Textures + + // 因子块:flow 数组按 MaterialParams 分量序(shader UBO 同序) + out << YAML::Key << "Factors" << YAML::Value; + EmitVec4(out, data.params.factors); + out << YAML::Key << "BaseColor" << YAML::Value; + EmitVec4(out, data.params.baseColor); + out << YAML::Key << "Surface" << YAML::Value; + EmitVec4(out, data.params.surface); + out << YAML::Key << "Misc" << YAML::Value; + EmitVec4(out, data.params.misc); + // per-slot UV 变换(KHR_texture_transform):6 槽各 A(4 分量) + T + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + out << YAML::Key << ("UvA" + std::to_string(slot)) << YAML::Value; + EmitVec4(out, data.params.uvA[slot]); + out << YAML::Key << ("UvT" + std::to_string(slot)) << YAML::Value; + EmitVec4(out, data.params.uvT[slot]); + } + out << YAML::Key << "Transparent" << YAML::Value << data.transparent; + out << YAML::EndMap; // Material + out << YAML::EndMap; + + std::ofstream file(path); + if (!file.is_open()) { + ENGINE_ERROR("MaterialAsset::Save: cannot open '{}'", path); + return false; + } + file << out.c_str(); + return true; + } + + bool MaterialAsset::Load(const std::string& path, MaterialAssetData& out) + { + YAML::Node root; + try { + root = YAML::LoadFile(path); + } + catch (const YAML::Exception& e) { + ENGINE_ERROR("MaterialAsset::Load: parse failed '{}': {}", path, + e.what()); + return false; + } + const YAML::Node m = root["Material"]; + if (!m) { + ENGINE_ERROR("MaterialAsset::Load: missing 'Material' root '{}'", + path); + return false; + } + + // Version 向前兼容:当前只认 1;未来新字段全部走缺省键兼容 + //(as(default)),旧文件永远可读 + const u32 version = m["Version"].as(kVersion); + if (version > kVersion) { + ENGINE_WARN("MaterialAsset::Load: '{}' version {} > {} " + "(newer file, missing fields fall back to defaults)", + path, version, kVersion); + } + + if (const YAML::Node t = m["Textures"]) { + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + out.texPaths[slot] = t[kSlotKeys[slot]].as(""); + } + } + // 分量序与 Save 一致;缺块 = MaterialParams 缺省值 + out.params.factors = ReadVec4( + m["Factors"], MaterialParams{}.factors); + out.params.baseColor = ReadVec4( + m["BaseColor"], MaterialParams{}.baseColor); + out.params.surface = ReadVec4( + m["Surface"], MaterialParams{}.surface); + out.params.misc = ReadVec4( + m["Misc"], MaterialParams{}.misc); + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + out.params.uvA[slot] = ReadVec4( + m["UvA" + std::to_string(slot)], + MaterialParams{}.uvA[slot]); + out.params.uvT[slot] = ReadVec4( + m["UvT" + std::to_string(slot)], + MaterialParams{}.uvT[slot]); + } + out.transparent = m["Transparent"].as(false); + return true; + } + + MaterialAsset::MaterialAsset( + MaterialAssetData&& data, + std::array, Material::kSlotCount>&& ownedTextures, + Scope&& material) + : m_Data(std::move(data)) + , m_OwnedTextures(std::move(ownedTextures)) + , m_Material(std::move(material)) + { + } + + MaterialAsset::~MaterialAsset() = default; + + void MaterialAsset::EditParams(const MaterialParams& params) + { + m_Data.params = params; + m_Material->UpdateParams(params); + } + + void MaterialAsset::SetTransparent(bool t) + { + m_Data.transparent = t; + m_Material->SetTransparent(t); + } +} // namespace Engine diff --git a/Engine/src/Renderer/Model.cpp b/Engine/src/Renderer/Model.cpp index 3742369..03c0de4 100644 --- a/Engine/src/Renderer/Model.cpp +++ b/Engine/src/Renderer/Model.cpp @@ -4,6 +4,7 @@ #include #include +#include // CreateDefaultMaterialTexture #include #include #include @@ -78,21 +79,12 @@ void Model::RebuildUnionBounds() void Model::InitDefaultTextures() { - // 各材质槽位的兜底纹理(1x1,开销可忽略): - // albedo 白 / MR 平参数(roughness 0.5, metallic 0)/ emissive 黑 - m_DefaultTextures[Material::Albedo] = - Texture::CreateSolidColor(m_Device, m_Allocator, 255, 255, 255); - m_DefaultTextures[Material::MetallicRoughness] = - Texture::CreateSolidColor(m_Device, m_Allocator, 0, 128, 0, false); - m_DefaultTextures[Material::Normal] = - Texture::CreateSolidColor(m_Device, m_Allocator, 128, 128, 255, false); - m_DefaultTextures[Material::Emissive] = - Texture::CreateSolidColor(m_Device, m_Allocator, 0, 0, 0); - m_DefaultTextures[Material::Ao] = - Texture::CreateSolidColor(m_Device, m_Allocator, 255, 0, 0, false); - // 独立粗糙度槽兜底:0.5 灰(无图资产 = 中性粗糙度) - m_DefaultTextures[Material::Roughness] = - Texture::CreateSolidColor(m_Device, m_Allocator, 128, 128, 128, false); + // 各槽位兜底纹理(1x1,开销可忽略):语义单点维护在 + // CreateDefaultMaterialTexture(MaterialAsset 构建端共用同一组) + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + m_DefaultTextures[slot] = CreateDefaultMaterialTexture( + m_Device, m_Allocator, static_cast(slot)); + } } Model::Model(Device& device, MemoryAllocator& allocator, @@ -139,6 +131,10 @@ Model::Model(Device& device, MemoryAllocator& allocator, m_Meshes.push_back(CreateScope( m_Device, m_Allocator, vertices, lodIndices, mat)); + // 程序化平面同样登记内建材质快照(与 m_Meshes 下标对齐, + // 编辑器材质槽 UI 不越界) + m_MeshMaterialInfos.push_back( + {"Plane", {}, MaterialParams{}, transparent}); RebuildUnionBounds(); } @@ -188,6 +184,12 @@ ParsedModel Model::ParseFromDisk(const std::string& path) const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | (out.isGltf ? 0 : aiProcess_FlipUVs) | + // 注意:不用 aiProcess_TransformUVCoords 烘焙 KHR_texture_transform + // ——该步骤的复合顺序是"先平移后缩放"(TextureTransform.cpp + // matrix = m2·m4·R·m3·m5,m5 最右先作用),与 glTF2Importer 写出 + // AI_MATKEY_UVTRANSFORM 时假定的"先缩放旋转后平移"不一致, + // 顺序差在 scale≠1 时结果偏出整数倍 tile(ChronographWatch + // 背板实测)。改由 ProcessMesh 按正确约定自烘焙 aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | // 法线贴图 TBN aiProcess_JoinIdenticalVertices | @@ -218,8 +220,11 @@ ParsedModel Model::ParseFromDisk(const std::string& path) std::filesystem::path(path).parent_path(); // 骨骼注册表(boneName → palette slot):多 mesh 共享骨架去重 std::unordered_map boneSlots; + // GLB 内嵌贴图字节池("*N" → PNG/JPEG 原始数据) + std::unordered_map> embeddedImages; ProcessNode(scene->mRootNode, scene, out.meshes, modelDir, out.isGltf, - out.bones, boneSlots); + out.bones, boneSlots, embeddedImages); + out.embeddedImages = std::move(embeddedImages); // 骨骼 nodeIdx 回填(注册发生在各 mesh 的 ProcessMesh 内, // 这里按 node 表查 name 定位累乘路径) @@ -413,10 +418,13 @@ Scope Model::FromParsed(Device& device, MemoryAllocator& allocator, model->m_Nodes = std::move(parsed.nodes); model->m_Bones = std::move(parsed.bones); model->m_Clips = std::move(parsed.clips); + model->m_EmbeddedImages = std::move(parsed.embeddedImages); // 材质缓存去重 + 纹理上传 + mesh 缓冲(全部 GPU,主线程) for (auto& pm : parsed.meshes) { Material* mat = model->GetOrCreateMaterial(pm.texPaths.data(), pm.params); + model->m_MeshMaterialInfos.push_back( + {pm.name, pm.texPaths, pm.params, pm.transparent}); model->m_Meshes.push_back(CreateScope( device, allocator, pm.vertices, pm.lods, mat, pm.cpuPositions.empty() ? nullptr : &pm.cpuPositions)); @@ -439,8 +447,11 @@ void Model::LoadModel(const std::string& path) m_Nodes = std::move(parsed.nodes); m_Bones = std::move(parsed.bones); m_Clips = std::move(parsed.clips); + m_EmbeddedImages = std::move(parsed.embeddedImages); for (auto& pm : parsed.meshes) { Material* mat = GetOrCreateMaterial(pm.texPaths.data(), pm.params); + m_MeshMaterialInfos.push_back( + {pm.name, pm.texPaths, pm.params, pm.transparent}); m_Meshes.push_back(CreateScope( m_Device, m_Allocator, pm.vertices, pm.lods, mat, pm.cpuPositions.empty() ? nullptr : &pm.cpuPositions)); @@ -455,6 +466,8 @@ void Model::ProcessNode(const aiNode* node, const aiScene* scene, const std::filesystem::path& modelDir, bool isGltf, std::vector& bones, std::unordered_map& boneSlots, + std::unordered_map>& + embeddedImages, const aiMatrix4x4& parentGlobal) { // 累乘节点链全局变换:Blender/Sketchfab 导出的 FBX(未勾 Apply @@ -465,11 +478,11 @@ void Model::ProcessNode(const aiNode* node, const aiScene* scene, for (u32 i = 0; i < node->mNumMeshes; ++i) { aiMesh* mesh = scene->mMeshes[node->mMeshes[i]]; out.push_back(ProcessMesh(mesh, scene, modelDir, isGltf, - bones, boneSlots, global)); + bones, boneSlots, embeddedImages, global)); } for (u32 i = 0; i < node->mNumChildren; ++i) { ProcessNode(node->mChildren[i], scene, out, modelDir, isGltf, - bones, boneSlots, global); + bones, boneSlots, embeddedImages, global); } } @@ -486,6 +499,26 @@ Texture* Model::GetOrLoadTexture(const std::string& rawPath, bool srgb) return it->second.get(); } + // GLB 内嵌贴图("*N"):字节在解析期随 ParsedModel 带过来, + // 此处(主线程、GPU 上下文)解码上传 + if (!path.empty() && path[0] == '*') { + const auto emb = m_EmbeddedImages.find(path); + if (emb == m_EmbeddedImages.end()) { + ENGINE_WARN("Embedded texture bytes not carried: {}", path); + return nullptr; + } + auto tex = Texture::CreateFromEncodedMemory( + m_Device, m_Allocator, emb->second.data(), + static_cast(emb->second.size()), srgb); + if (!tex) { + ENGINE_WARN("Embedded texture decode failed: {}", path); + return nullptr; + } + Texture* ptr = tex.get(); + m_TextureCache.emplace(key, std::move(tex)); + return ptr; + } + // 文件不存在则用兜底纹理 if (!std::filesystem::exists(path)) { ENGINE_WARN("Texture not found, using default: {}", path); @@ -549,6 +582,8 @@ ParsedMesh Model::ProcessMesh(const aiMesh* mesh, const aiScene* scene, bool isGltf, std::vector& bones, std::unordered_map& boneSlots, + std::unordered_map>& + embeddedImages, const aiMatrix4x4& globalTransform) { ParsedMesh out; @@ -626,10 +661,21 @@ ParsedMesh Model::ProcessMesh(const aiMesh* mesh, const aiScene* scene, // 读取材质纹理路径(PBR metallic-roughness 槽位)+ 金属度/粗糙度因子 std::string texPaths[Material::kSlotCount]; MaterialParams params; + std::string materialName; // 材质资产名(UI 材质槽显示/覆盖文件命名优先用它) if (mesh->mMaterialIndex >= 0) { const aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; + // 材质名(glTF material name / FBX 材质名):Blender 导出的 mesh + // 名是 "Plane.023" 这类几何拓扑名,对"这是哪个材质"毫无信息量; + // 材质名才是 UE Material Slots 列表的语义 + aiString matName; + if (material && + material->Get(AI_MATKEY_NAME, matName) == aiReturn_SUCCESS && + matName.length > 0) { + materialName = matName.C_Str(); + } + // glTF2 贴图在 assimp 5.x 的落点:MR 合图 → METALNESS(15)/ // DIFFUSE_ROUGHNESS(16)(同图双槽);baseColor → BASE_COLOR(12)。 // OBJ:map_Ns → SHININESS(独立 roughness 图)、map_Ka 常绑 AO。 @@ -653,12 +699,46 @@ ParsedMesh Model::ProcessMesh(const aiMesh* mesh, const aiScene* scene, {aiTextureType_EMISSION_COLOR, Material::Emissive}, }; + // 每槽命中源类型:KHR_texture_transform 在 assimp 侧按 + // aiTextureType 键控(AI_MATKEY_UVTRANSFORM(type, 0)),必须按 + // 命中时的同款类型查询才能取到对应贴图的变换 + aiTextureType slotSrcType[Material::kSlotCount] = {}; + for (const auto& [type, slot] : slots) { if (!texPaths[slot].empty()) continue; // 高优先级已命中 aiString path; if (material->GetTexture(type, 0, &path) == AI_SUCCESS && path.length > 0) { - texPaths[slot] = (modelDir / path.C_Str()).string(); + slotSrcType[slot] = type; + if (path.C_Str()[0] == '*') { + // GLB/gltf 内嵌贴图:assimp 记作 "*"(aiTexture + // 表下标),不是磁盘路径——解析期拷出原始编码字节, + // 实例期 GetOrLoadTexture 解码上传(解析线程无 GPU) + texPaths[slot] = path.C_Str(); + if (!embeddedImages.count(path.C_Str())) { + const int texIdx = atoi(path.C_Str() + 1); + if (texIdx < 0 || + texIdx >= static_cast(scene->mNumTextures)) { + ENGINE_WARN("Embedded texture index out of range: " + "{}", path.C_Str()); + continue; + } + const aiTexture* t = scene->mTextures[texIdx]; + if (t->mHeight != 0) { + // 未压缩 ARGB8888 内嵌数据(罕见):折默认纹理 + ENGINE_WARN("Uncompressed embedded texture " + "unsupported: {}", path.C_Str()); + continue; + } + const u8* src = + reinterpret_cast(t->pcData); + embeddedImages[path.C_Str()].assign( + src, src + t->mWidth); + } + } + else { + texPaths[slot] = (modelDir / path.C_Str()).string(); + } } } // 注:不做文件名探测/自动补槽——资产缺什么就是什么; @@ -717,6 +797,81 @@ ParsedMesh Model::ProcessMesh(const aiMesh* mesh, const aiScene* scene, params.factors.w = 1.0f; } } + + // 表面扩展四键(assimp 仅在扩展存在时写出,缺省 = 物理默认): + // KHR_materials_ior → REFRACTI(缺省 1.5) + // KHR_materials_transmission → TRANSMISSION_FACTOR(缺省 0) + // KHR_materials_emissive_strength → EMISSIVE_INTENSITY(缺省 1) + // KHR_materials_volume → VOLUME_THICKNESS_FACTOR(缺省 0) + ai_real ior = 1.5f; + material->Get(AI_MATKEY_REFRACTI, ior); + ai_real transmission = 0.0f; + material->Get(AI_MATKEY_TRANSMISSION_FACTOR, transmission); + ai_real emissiveStrength = 1.0f; + material->Get(AI_MATKEY_EMISSIVE_INTENSITY, emissiveStrength); + ai_real thickness = 0.0f; + material->Get(AI_MATKEY_VOLUME_THICKNESS_FACTOR, thickness); + params.surface = glm::vec4(static_cast(ior), + static_cast(transmission), + static_cast(emissiveStrength), + static_cast(thickness)); + + // transmission > 0 的材质必须走透明队列:物理透射表面要采样 + // 不透明场景副本做折射。Khronos 新式玻璃写法是 alphaMode=OPAQUE + // + transmission=1(透明度语义全在 transmission,alpha 恒 1)—— + // 只认 alphaMode=BLEND 会把玻璃送进不透明队列,画成白 albedo + // 镜面实心盖板(表盘/指针全被挡住,正面"糊成一坨") + if (static_cast(transmission) > 0.0f) { + out.transparent = true; + } + + // KHR_texture_transform:每张贴图各带独立 offset/scale/rotation + //(AnimationPointerUVs 一致性模型:82 材质共享一张棋盘格、 + // 各槽变换各异),烘焙进顶点 UV 不可行(顶点 UV 只有一份), + // 改写入材质参数、shader 采样前逐槽应用。 + // + // importer 把 glTF 参数换算进 assimp 约定(缩放 → 绕 (0.5,0.5) + // 旋转 -rot → 平移)写入 UVTRANSFORM;该换算在 θ≠0 时与 glTF + // 规范语义不符(数值实测误差 0.44+),θ=0 才精确。这里先从 + // assimp 值精确反演回原始 glTF 参数,再用规范公式写入: + // rot = -mRotation + // off.x = mTrl.x - 0.5·sx·(-c + s + 1) + // off.y = 1 - sy - mTrl.y + 0.5·sy·(s + c - 1) + // spec(glTF v-down 空间 uv' = T(off)·R(rot)·S(scale)·uv, + // 折算进引擎 v-up 顶点 UV 空间 uv' = A·uv + t): + // A = [[c·sx, s·sy], [-s·sx, c·sy]](行主序摊平 vec4) + // t = (off.x - s·sy, 1 - off.y - c·sy) + // rot=0 时退化为已视觉验证的旧烘焙式 t=(off.x, 1-sy-off.y) + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + if (texPaths[slot].empty()) continue; + aiUVTransform at; + if (material->Get(AI_MATKEY_UVTRANSFORM(slotSrcType[slot], 0), + at) != AI_SUCCESS) { + continue; + } + const f32 sx = static_cast(at.mScaling.x); + const f32 sy = static_cast(at.mScaling.y); + const f32 rot = -static_cast(at.mRotation); + const f32 c = std::cos(rot); + const f32 s = std::sin(rot); + const f32 offx = static_cast(at.mTranslation.x) + - 0.5f * sx * (-c + s + 1.0f); + const f32 offy = 1.0f - sy + - static_cast(at.mTranslation.y) + + 0.5f * sy * (s + c - 1.0f); + params.uvA[slot] = glm::vec4(c * sx, s * sy, -s * sx, c * sy); + params.uvT[slot] = glm::vec4(offx - s * sy, + 1.0f - offy - c * sy, + 0.0f, 0.0f); + } + + // KHR_materials_unlit:misc.x=1 → shader 早退直出 albedo + int shadingModel = 0; + if (material->Get(AI_MATKEY_SHADING_MODEL, shadingModel) + == AI_SUCCESS && + shadingModel == aiShadingMode_Unlit) { + params.misc.x = 1.0f; + } } else { // FBX/OBJ 独立贴图工作流 const bool hasSepRough = !texPaths[Material::Roughness].empty(); @@ -745,6 +900,9 @@ ParsedMesh Model::ProcessMesh(const aiMesh* mesh, const aiScene* scene, } out.params = params; // out.transparent 已在 glTF 分支读取 + // UI 名字:材质名优先(语义对齐 UE Material Slots),无材质名 + //(罕见)回退 mesh 名 + out.name = !materialName.empty() ? materialName : mesh->mName.C_Str(); // ---- LOD 链(解析期生成,worker 线程免费时间):---- // LOD0 = 原始;每级目标索引数减半,相对误差 1e-2。顶点共享, diff --git a/Engine/src/Renderer/Passes/EditorGizmoPass.cpp b/Engine/src/Renderer/Passes/EditorGizmoPass.cpp new file mode 100644 index 0000000..941713f --- /dev/null +++ b/Engine/src/Renderer/Passes/EditorGizmoPass.cpp @@ -0,0 +1,322 @@ +// +// EditorGizmoPass.cpp:pass 装配(shader object)与录制。 +// editor 专属(HELLO_EDITOR):game 构建整 TU 剔除 +//(Engine/CMakeLists REMOVE_ITEM)。 +// + +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include + +namespace Engine::Passes +{ + namespace + { + // 线流布局:与 debug_line 同(location0 pos / location1 color)。 + // setVertexInputEXT 走 2EXT 结构(0 divisor 需 feature,不用) + vk::VertexInputBindingDescription2EXT MakeWireBinding() + { + vk::VertexInputBindingDescription2EXT b; + b.setBinding(0) + .setStride(sizeof(GizmoWireVertex)) + .setInputRate(vk::VertexInputRate::eVertex) + .setDivisor(1); + return b; + } + + std::array + MakeWireAttributes() + { + std::array a; + a[0].setLocation(0).setBinding(0) + .setFormat(vk::Format::eR32G32B32Sfloat) + .setOffset(offsetof(GizmoWireVertex, pos)); + a[1].setLocation(1).setBinding(0) + .setFormat(vk::Format::eR32G32B32A32Sfloat) + .setOffset(offsetof(GizmoWireVertex, color)); + return a; + } + + // 图标流布局:pos / color / aux(shapeId + uv)。entityId 槽位 + // 本 pass 不消费——拾取由 HitProxyPass 用同一 VB 全四属性画 + vk::VertexInputBindingDescription2EXT MakeIconBinding() + { + vk::VertexInputBindingDescription2EXT b; + b.setBinding(0) + .setStride(sizeof(GizmoQuadVertex)) + .setInputRate(vk::VertexInputRate::eVertex) + .setDivisor(1); + return b; + } + + std::array + MakeIconAttributes() + { + std::array a; + a[0].setLocation(0).setBinding(0) + .setFormat(vk::Format::eR32G32B32Sfloat) + .setOffset(offsetof(GizmoQuadVertex, pos)); + a[1].setLocation(1).setBinding(0) + .setFormat(vk::Format::eR32G32B32A32Sfloat) + .setOffset(offsetof(GizmoQuadVertex, color)); + a[2].setLocation(2).setBinding(0) + .setFormat(vk::Format::eR32G32B32A32Sfloat) + .setOffset(offsetof(GizmoQuadVertex, aux)); + return a; + } + + // 直写方程(线流):(ONE, ZERO) → dst = src,数学上等价无混合 + vk::ColorBlendEquationEXT MakeDirectEquation() + { + vk::ColorBlendEquationEXT eq; + eq.setSrcColorBlendFactor(vk::BlendFactor::eOne) + .setDstColorBlendFactor(vk::BlendFactor::eZero) + .setColorBlendOp(vk::BlendOp::eAdd) + .setSrcAlphaBlendFactor(vk::BlendFactor::eOne) + .setDstAlphaBlendFactor(vk::BlendFactor::eZero) + .setAlphaBlendOp(vk::BlendOp::eAdd); + return eq; + } + + // 预乘 alpha(图标流):shader 已输出预乘色,src 原样加权叠加 + vk::ColorBlendEquationEXT MakePremultEquation() + { + vk::ColorBlendEquationEXT eq; + eq.setSrcColorBlendFactor(vk::BlendFactor::eOne) + .setDstColorBlendFactor(vk::BlendFactor::eOneMinusSrcAlpha) + .setColorBlendOp(vk::BlendOp::eAdd) + .setSrcAlphaBlendFactor(vk::BlendFactor::eOne) + .setDstAlphaBlendFactor(vk::BlendFactor::eOneMinusSrcAlpha) + .setAlphaBlendOp(vk::BlendOp::eAdd); + return eq; + } + } // namespace + + EditorGizmoPass::EditorGizmoPass(Device& device, ShaderManager& shaders, + DescriptorManager& descriptors, + FrameSceneData& frame, + RG::TextureHandle hdrColor, + RG::TextureHandle depth, + UniformBuffer& iconVB, + UniformBuffer& wireVB) + : m_Device(device), m_Descriptors(descriptors), m_Frame(frame), + m_HDRColor(hdrColor), m_Depth(depth), m_IconVB(iconVB), + m_WireVB(wireVB) + { + // 无描述符 set:viewProj 走 push constant(两对 shader 同契约) + m_Layout = descriptors.CreatePipelineLayout( + std::vector{}, sizeof(glm::mat4)); + + const std::string shaderDir = "shaders/"; + auto wireVert = shaders.CreateFromFile( + shaderDir + "debug_line.vert.spv", + vk::ShaderStageFlagBits::eVertex, + std::vector{}, + sizeof(glm::mat4)); + auto wireFrag = shaders.CreateFromFile( + shaderDir + "debug_line.frag.spv", + vk::ShaderStageFlagBits::eFragment, + std::vector{}, + sizeof(glm::mat4)); + auto iconVert = shaders.CreateFromFile( + shaderDir + "gizmo_billboard.vert.spv", + vk::ShaderStageFlagBits::eVertex, + std::vector{}, + sizeof(glm::mat4)); + auto iconFrag = shaders.CreateFromFile( + shaderDir + "gizmo_billboard.frag.spv", + vk::ShaderStageFlagBits::eFragment, + std::vector{}, + sizeof(glm::mat4)); + + m_WireVert = std::move(wireVert); + m_WireFrag = std::move(wireFrag); + m_IconVert = std::move(iconVert); + m_IconFrag = std::move(iconFrag); + if (!m_WireVert || !m_WireFrag || !m_IconVert || !m_IconFrag) { + ENGINE_FATAL("EditorGizmoPass: failed to create shader objects"); + } + } + + EditorGizmoPass::~EditorGizmoPass() + { + // shader 随 ShaderRef 引用计数自动销毁,此处只清其余原生句柄 + m_Device.GetDevice().destroyPipelineLayout(m_Layout); + } + + void EditorGizmoPass::Register(RG::RenderGraph& rg) + { + rg.AddPass("EditorGizmo", [this](RG::RenderGraphContext& ctx) { + const u32 iconCount = static_cast( + m_Frame.gizmoIconVerts.size()); + const u32 wireCount = static_cast( + m_Frame.gizmoWireVerts.size()); + // 骨骼线(选中实体骨架):与 gizmo 线同 VB 同管线,画在 + // gizmo 段之后。截断公式与 Renderer 上传端一致(同一 VB + // 容量推导,天然对齐) + const u64 wireVBVerts = + m_WireVB.GetSize() / sizeof(GizmoWireVertex); + const u64 boneRoom = + wireVBVerts > wireCount ? wireVBVerts - wireCount : 0; + const u32 boneCount = static_cast(std::min( + m_Frame.boneWireVerts.size(), boneRoom)); + if (iconCount == 0 && wireCount == 0 && boneCount == 0) { + return; // 空帧零开销 + } + + ZoneScopedN("EditorGizmo"); + const vk::CommandBuffer cmd = ctx.cmd; + + // HDR:Load 承接 Forward/PhysicsDebug 结果叠加;深度:Load + // 承接场景深度做测试(只测不写),DontCare——HitProxy 稍后 + // Clear 重建 + vk::RenderingAttachmentInfo color; + color.setImageView(ctx.View(m_HDRColor)) + .setImageLayout(vk::ImageLayout::eColorAttachmentOptimal) + .setLoadOp(vk::AttachmentLoadOp::eLoad) + .setStoreOp(vk::AttachmentStoreOp::eStore); + + vk::RenderingAttachmentInfo depth; + depth.setImageView(ctx.View(m_Depth)) + .setImageLayout(vk::ImageLayout::eDepthAttachmentOptimal) + .setLoadOp(vk::AttachmentLoadOp::eLoad) + .setStoreOp(vk::AttachmentStoreOp::eDontCare); + + vk::RenderingInfo renderingInfo; + renderingInfo.setRenderArea({{0, 0}, ctx.extent}) + .setLayerCount(1) + .setColorAttachments(color) + .setPDepthAttachment(&depth); + + cmd.beginRendering(renderingInfo); + + const std::array stages = { + vk::ShaderStageFlagBits::eVertex, + vk::ShaderStageFlagBits::eFragment + }; + + // layout 的 push range 是 vertex|fragment(CreatePipelineLayout + // 默认),stageFlags 必须覆盖 range 全部 stage(VUID-01796) + cmd.pushConstants( + m_Layout, + vk::ShaderStageFlagBits::eVertex | + vk::ShaderStageFlagBits::eFragment, + 0, m_Frame.ubo.proj * m_Frame.ubo.view); + + // ---- 公共动态状态(shader object 无管线对象,无条件直写; + // 跨 pass 残留教训:公共状态块不进条件分支)---- + cmd.setRasterizerDiscardEnable(VK_FALSE); + + cmd.setDepthTestEnable(VK_TRUE); + cmd.setDepthCompareOp(vk::CompareOp::eLessOrEqual); + cmd.setDepthWriteEnable(VK_FALSE); // gizmo 不污染场景深度 + cmd.setDepthBoundsTestEnable(VK_FALSE); + cmd.setStencilTestEnable(VK_FALSE); + cmd.setDepthBiasEnable(VK_FALSE); + + cmd.setPolygonModeEXT(vk::PolygonMode::eFill); + cmd.setCullMode(vk::CullModeFlagBits::eNone); + cmd.setFrontFace(vk::FrontFace::eCounterClockwise); + cmd.setPrimitiveRestartEnable(VK_FALSE); + + cmd.setRasterizationSamplesEXT(vk::SampleCountFlagBits::e1); + constexpr vk::SampleMask sampleMask = 0xFFFFFFFF; + cmd.setSampleMaskEXT(vk::SampleCountFlagBits::e1, sampleMask); + cmd.setAlphaToCoverageEnableEXT(VK_FALSE); + cmd.setAlphaToOneEnableEXT(VK_FALSE); + + const vk::Viewport viewport( + 0.0f, 0.0f, static_cast(ctx.extent.width), + static_cast(ctx.extent.height), 0.0f, 1.0f); + cmd.setViewportWithCount(viewport); + cmd.setScissorWithCount(vk::Rect2D({0, 0}, ctx.extent)); + + constexpr vk::ColorComponentFlags colorWriteMask = + vk::ColorComponentFlagBits::eR | + vk::ColorComponentFlagBits::eG | + vk::ColorComponentFlagBits::eB | + vk::ColorComponentFlagBits::eA; + cmd.setColorWriteMaskEXT(0, colorWriteMask); + + // ---- 线流:视锥线框 + 选中实体骨骼(LineList 无混合直写, + // 同 VB 两段:[gizmo 线 | 骨骼线])---- + if (wireCount > 0 || boneCount > 0) { + const std::array wireShaders = { + m_WireVert.GetHandle(), m_WireFrag.GetHandle()}; + cmd.bindShadersEXT(stages, wireShaders); + + const vk::VertexInputBindingDescription2EXT binding = + MakeWireBinding(); + const auto attrs = MakeWireAttributes(); + cmd.setVertexInputEXT(binding, attrs); + + cmd.setPrimitiveTopology( + vk::PrimitiveTopology::eLineList); + cmd.setLineWidth(1.0f); // 线拓扑必须显式(VUID-08617) + + constexpr vk::Bool32 blendOff = VK_FALSE; + const vk::ColorBlendEquationEXT direct = + MakeDirectEquation(); + cmd.setColorBlendEnableEXT(0, blendOff); + cmd.setColorBlendEquationEXT(0, direct); + + const std::array bufs{ + m_WireVB.Get(ctx.frame.frameSlot)}; + const std::array offs{0}; + cmd.bindVertexBuffers(0, bufs, offs); + if (wireCount > 0) { + cmd.draw(wireCount, 1, 0, 0); + } + if (boneCount > 0) { + cmd.draw(boneCount, 1, wireCount, 0); + } + } + + // ---- 图标流:billboard(TriangleList 预乘 alpha 叠加)---- + if (iconCount > 0) { + const std::array iconShaders = { + m_IconVert.GetHandle(), m_IconFrag.GetHandle()}; + cmd.bindShadersEXT(stages, iconShaders); + + const vk::VertexInputBindingDescription2EXT binding = + MakeIconBinding(); + const auto attrs = MakeIconAttributes(); + cmd.setVertexInputEXT(binding, attrs); + + cmd.setPrimitiveTopology( + vk::PrimitiveTopology::eTriangleList); + + constexpr vk::Bool32 blendOn = VK_TRUE; + const vk::ColorBlendEquationEXT premult = + MakePremultEquation(); + cmd.setColorBlendEnableEXT(0, blendOn); + cmd.setColorBlendEquationEXT(0, premult); + + const std::array bufs{ + m_IconVB.Get(ctx.frame.frameSlot)}; + const std::array offs{0}; + cmd.bindVertexBuffers(0, bufs, offs); + cmd.draw(iconCount, 1, 0, 0); + } + + cmd.endRendering(); + }) + // Write use(WAW 链):HDR 排 Forward/PhysicsDebug 之后、TAA 之前; + // Depth WAW 把本 pass 固定排进深度链(HitProxy 清深度之前—— + // gizmo 深度测试消费场景深度,不是上帧残留) + .Write(m_HDRColor, RG::ResourceUse::ColorAttachment) + .Write(m_Depth, RG::ResourceUse::DepthAttachment); + } + +} // namespace Engine::Passes diff --git a/Engine/src/Renderer/Passes/ForwardPass.cpp b/Engine/src/Renderer/Passes/ForwardPass.cpp index 7debb95..1c62a26 100644 --- a/Engine/src/Renderer/Passes/ForwardPass.cpp +++ b/Engine/src/Renderer/Passes/ForwardPass.cpp @@ -177,7 +177,7 @@ namespace Engine::Passes for (const auto& batch : m_Frame.mainView.batches) { cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, m_Gpu.forwardLayout, 1, - batch.mesh->GetMaterial()->GetDescriptorSet(), {}); + batch.material->GetDescriptorSet(), {}); batch.mesh->Draw(cmd, batch.count, batch.first, batch.lod); } } @@ -194,6 +194,30 @@ namespace Engine::Passes .Read(m_ShadowMaps[2], RG::ResourceUse::ShaderRead, vk::ShaderStageFlagBits::eFragment); + // ---- 场景色副本:不透明段(含天空盒背景)的快照 ---- + // 透射折射的采样源。必须排在 Forward 之后(RAW 链)、 + // ForwardTrans 之前(后者对 HDRColor 的写入不能进折射样本, + // 且玻璃互相不可见——屏幕空间透射只含不透明场景,与 UE 一致)。 + rg.AddPass("SceneColorCopy", [this](RG::RenderGraphContext& ctx) { + const vk::CommandBuffer cmd = ctx.cmd; + + vk::ImageCopy region{}; + region.srcSubresource = + vk::ImageSubresourceLayers(vk::ImageAspectFlagBits::eColor, + 0, 0, 1); + region.dstSubresource = + vk::ImageSubresourceLayers(vk::ImageAspectFlagBits::eColor, + 0, 0, 1); + region.setExtent(vk::Extent3D(ctx.extent.width, ctx.extent.height, 1)); + cmd.copyImage(ctx.Image(m_HDRColor), + vk::ImageLayout::eTransferSrcOptimal, + ctx.Image(m_SceneColor), + vk::ImageLayout::eTransferDstOptimal, + {region}); + }) + .Read(m_HDRColor, RG::ResourceUse::TransferSrc) + .Write(m_SceneColor, RG::ResourceUse::TransferDst); + // ---- 透明段:独立渲染实例,UE/Unity 标准语义 ---- // 深度测试对不透明深度(eLess)+ 深度写入关闭——玻璃之间 // 互相不遮挡(谁先画都留在画面上,先后只影响 over 混合的 @@ -280,7 +304,7 @@ namespace Engine::Passes for (const auto& batch : m_Frame.translucentView.batches) { cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, m_Gpu.forwardLayout, 1, - batch.mesh->GetMaterial()->GetDescriptorSet(), {}); + batch.material->GetDescriptorSet(), {}); batch.mesh->Draw(cmd, batch.count, batch.first, batch.lod); } @@ -292,6 +316,9 @@ namespace Engine::Passes // barrier 覆盖透明段对不透明深度的测试读(Read-Write hazard // 由 src=Write 的内存域保证可见性) .Write(m_Depth, RG::ResourceUse::DepthAttachment) + // 场景色副本:透射折射采样源(translucent frag set2 binding 6) + .Read(m_SceneColor, RG::ResourceUse::ShaderRead, + vk::ShaderStageFlagBits::eFragment) .Read(m_ShadowMaps[0], RG::ResourceUse::ShaderRead, vk::ShaderStageFlagBits::eFragment) .Read(m_ShadowMaps[1], RG::ResourceUse::ShaderRead, @@ -300,4 +327,10 @@ namespace Engine::Passes vk::ShaderStageFlagBits::eFragment); } + void ForwardPass::OnTargetsRebuilt(RG::RenderGraph& rg) + { + // RG 物理镜像按槽位各一份:重编译后重绑场景色副本 + m_Gpu.BindSceneColor(rg, m_SceneColor); + } + } // namespace Engine::Passes diff --git a/Engine/src/Renderer/Passes/HitProxyPass.cpp b/Engine/src/Renderer/Passes/HitProxyPass.cpp index 6774f9a..ca9d354 100644 --- a/Engine/src/Renderer/Passes/HitProxyPass.cpp +++ b/Engine/src/Renderer/Passes/HitProxyPass.cpp @@ -12,6 +12,7 @@ #include #include #include +#include // gizmo 代理 VB 完整类型(editor) #include #include @@ -42,6 +43,39 @@ namespace Engine::Passes vk::ColorComponentFlagBits::eG | vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA; + +#ifdef HELLO_EDITOR + // gizmo 代理壳布局:GizmoQuadVertex 四属性流(entityId = loc3 + // R32Uint → flat varying → R32Uint 目标) + vk::VertexInputBindingDescription2EXT MakeGizmoProxyBinding() + { + vk::VertexInputBindingDescription2EXT b; + b.setBinding(0) + .setStride(sizeof(GizmoQuadVertex)) + .setInputRate(vk::VertexInputRate::eVertex) + .setDivisor(1); + return b; + } + + std::array + MakeGizmoProxyAttributes() + { + std::array a; + a[0].setLocation(0).setBinding(0) + .setFormat(vk::Format::eR32G32B32Sfloat) + .setOffset(offsetof(GizmoQuadVertex, pos)); + a[1].setLocation(1).setBinding(0) + .setFormat(vk::Format::eR32G32B32A32Sfloat) + .setOffset(offsetof(GizmoQuadVertex, color)); + a[2].setLocation(2).setBinding(0) + .setFormat(vk::Format::eR32G32B32A32Sfloat) + .setOffset(offsetof(GizmoQuadVertex, aux)); + a[3].setLocation(3).setBinding(0) + .setFormat(vk::Format::eR32Uint) + .setOffset(offsetof(GizmoQuadVertex, entityId)); + return a; + } +#endif } // namespace HitProxyPass::HitProxyPass(Device& device, MemoryAllocator& allocator, @@ -79,12 +113,39 @@ namespace Engine::Passes vk::BufferUsageFlagBits::eTransferDst, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent); + +#ifdef HELLO_EDITOR + // ---- gizmo 代理壳拾取装配(editor 专属):无描述符 layout + // (push = viewProj)+ gizmo_pick shader 对。VB 由 Renderer + // BuildGraph 内 AttachGizmoProxy 接入 ---- + m_GizmoLayout = descriptors.CreatePipelineLayout( + std::vector{}, sizeof(glm::mat4)); + + auto gizmoVert = shaders.CreateFromFile( + shaderDir + "gizmo_pick.vert.spv", + vk::ShaderStageFlagBits::eVertex, + std::vector{}, + sizeof(glm::mat4)); + auto gizmoFrag = shaders.CreateFromFile( + shaderDir + "gizmo_pick.frag.spv", + vk::ShaderStageFlagBits::eFragment, + std::vector{}, + sizeof(glm::mat4)); + m_GizmoVert = std::move(gizmoVert); + m_GizmoFrag = std::move(gizmoFrag); + if (!m_GizmoVert || !m_GizmoFrag) { + ENGINE_FATAL("HitProxyPass: failed to create gizmo pick shaders"); + } +#endif } HitProxyPass::~HitProxyPass() { // shader 随 ShaderRef 引用计数自动销毁,此处只清其余原生句柄 m_Device.GetDevice().destroyPipelineLayout(m_Layout); +#ifdef HELLO_EDITOR + m_Device.GetDevice().destroyPipelineLayout(m_GizmoLayout); +#endif } bool HitProxyPass::TakeResult(u32& outBits) @@ -196,6 +257,38 @@ namespace Engine::Passes batch.mesh->Draw(cmd, batch.count, batch.first, batch.lod); } +#ifdef HELLO_EDITOR + // ---- gizmo 拾取代理壳(editor 专属):billboard 图标 quad + + // 视锥棱 ribbon 用 gizmo_pick 管线重画进 R32Uint。与 mesh + // 同一深度解算(Less + write ON,最近者胜)——代理壳挡住 + // 的 mesh 拾不到、被 mesh 挡住的代理壳拾不到(深度测试 + // 与可见 gizmo 渲染一致,点击命中 = 所见即所得)---- + if (m_GizmoProxyVB && !m_Frame.gizmoProxyVerts.empty()) { + const std::array gizmoShaders = { + m_GizmoVert.GetHandle(), m_GizmoFrag.GetHandle()}; + cmd.bindShadersEXT(stages, gizmoShaders); + + const vk::VertexInputBindingDescription2EXT gizmoBinding = + MakeGizmoProxyBinding(); + const auto gizmoAttrs = MakeGizmoProxyAttributes(); + cmd.setVertexInputEXT(gizmoBinding, gizmoAttrs); + + // gizmo layout 无描述符:viewProj 经 push constant 下发 + cmd.pushConstants( + m_GizmoLayout, + vk::ShaderStageFlagBits::eVertex | + vk::ShaderStageFlagBits::eFragment, + 0, m_Frame.ubo.proj * m_Frame.ubo.view); + + const std::array gizmoBufs{ + m_GizmoProxyVB->Get(ctx.frame.frameSlot)}; + const std::array gizmoOffs{0}; + cmd.bindVertexBuffers(0, gizmoBufs, gizmoOffs); + cmd.draw( + static_cast(m_Frame.gizmoProxyVerts.size()), 1, 0, 0); + } +#endif + cmd.endRendering(); }) .Write(m_IdTarget, RG::ResourceUse::ColorAttachment) diff --git a/Engine/src/Renderer/Passes/ImGuiPass.cpp b/Engine/src/Renderer/Passes/ImGuiPass.cpp index 907f7bf..686e55f 100644 --- a/Engine/src/Renderer/Passes/ImGuiPass.cpp +++ b/Engine/src/Renderer/Passes/ImGuiPass.cpp @@ -48,7 +48,14 @@ namespace Engine::Passes // 场景纹理在本 pass 内被 ImGui::Image 的描述符采样:声明读让 RG // 补 ColorAttachment→ShaderRead barrier,并推导 eSampled usage .Read(m_PresentTarget, RG::ResourceUse::ShaderRead, - vk::ShaderStageFlagBits::eFragment); + vk::ShaderStageFlagBits::eFragment) +#ifdef HELLO_EDITOR + // 材质球预览 atlas 同理(材质槽行的 ImGui::Image 采样); + // game 构建不声明(atlas 不存在) + .Read(m_PreviewAtlas, RG::ResourceUse::ShaderRead, + vk::ShaderStageFlagBits::eFragment) +#endif + ; } } // namespace Engine::Passes diff --git a/Engine/src/Renderer/Passes/MaterialPreviewPass.cpp b/Engine/src/Renderer/Passes/MaterialPreviewPass.cpp new file mode 100644 index 0000000..c3033ae --- /dev/null +++ b/Engine/src/Renderer/Passes/MaterialPreviewPass.cpp @@ -0,0 +1,405 @@ +// +// Created by atdunbg on 2026/10/5. +// + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace Engine::Passes +{ + namespace + { + constexpr vk::ColorComponentFlags kColorWriteMask = + vk::ColorComponentFlagBits::eR | + vk::ColorComponentFlagBits::eG | + vk::ColorComponentFlagBits::eB | + vk::ColorComponentFlagBits::eA; + + // 直写方程(与 ForwardPass 不透明段一致) + vk::ColorBlendEquationEXT MakeDirectEquation() + { + vk::ColorBlendEquationEXT eq; + eq.setSrcColorBlendFactor(vk::BlendFactor::eOne) + .setDstColorBlendFactor(vk::BlendFactor::eZero) + .setColorBlendOp(vk::BlendOp::eAdd) + .setSrcAlphaBlendFactor(vk::BlendFactor::eOne) + .setDstAlphaBlendFactor(vk::BlendFactor::eZero) + .setAlphaBlendOp(vk::BlendOp::eAdd); + return eq; + } + + // 预乘 over(与 ForwardPass 透明段一致):HDR 域线性合成, + // alpha 不动(tile 背景不透明,tonemap 段 rgb×a 恒 ×1) + vk::ColorBlendEquationEXT MakePremulEquation() + { + vk::ColorBlendEquationEXT eq; + eq.setSrcColorBlendFactor(vk::BlendFactor::eSrcAlpha) + .setDstColorBlendFactor(vk::BlendFactor::eOneMinusSrcAlpha) + .setColorBlendOp(vk::BlendOp::eAdd) + .setSrcAlphaBlendFactor(vk::BlendFactor::eZero) + .setDstAlphaBlendFactor(vk::BlendFactor::eOne) + .setAlphaBlendOp(vk::BlendOp::eAdd); + return eq; + } + + // 程序化 UV 球(世界空间,原点,半径 1):32×16 分段。法线 = + // 单位位置;无切线(frag 退回几何法线);无骨骼(默认 weights + // 直通 palette[0] identity)。索引序经叉积校验为外向 CCW + void BuildSphere(std::vector& vertices, + std::vector& indices) + { + constexpr u32 kSlices = 32; + constexpr u32 kStacks = 16; + constexpr f32 kPi = 3.14159265358979f; + + vertices.reserve((kStacks + 1) * (kSlices + 1)); + for (u32 j = 0; j <= kStacks; ++j) { + const f32 phi = f32(j) / kStacks * kPi; // 0 = 顶极 + for (u32 i = 0; i <= kSlices; ++i) { + const f32 theta = f32(i) / kSlices * 2.0f * kPi; + const f32 sx = std::sin(phi) * std::cos(theta); + const f32 sy = std::cos(phi); + const f32 sz = std::sin(phi) * std::sin(theta); + vertices.push_back(Vertex{ + {sx, sy, sz}, // position + {sx, sy, sz}, // normal = 单位位置 + {f32(i) / kSlices, f32(j) / kStacks}}); + } + } + + const u32 row = kSlices + 1; + indices.reserve(kStacks * kSlices * 6); + for (u32 j = 0; j < kStacks; ++j) { + for (u32 i = 0; i < kSlices; ++i) { + const u32 v00 = j * row + i; + const u32 v10 = v00 + 1; // θ+ + const u32 v01 = v00 + row; // φ+ + const u32 v11 = v01 + 1; + indices.push_back(v00); + indices.push_back(v10); + indices.push_back(v11); + indices.push_back(v00); + indices.push_back(v11); + indices.push_back(v01); + } + } + } + } // namespace + + MaterialPreviewPass::MaterialPreviewPass( + Device& device, MemoryAllocator& allocator, + DescriptorManager& descriptors, SceneGpu& gpu, + FullscreenGpu& fullscreen, RG::TextureHandle hdrAtlas, + RG::TextureHandle velocityAtlas, RG::TextureHandle sdrAtlas, + const std::vector& dirtyTiles) + : m_Device(device) + , m_Allocator(allocator) + , m_Descriptors(descriptors) + , m_Gpu(gpu) + , m_Fullscreen(fullscreen) + , m_HDRAtlas(hdrAtlas) + , m_VelocityAtlas(velocityAtlas) + , m_SDRAtlas(sdrAtlas) + , m_DirtyTiles(dirtyTiles) + { + // ---- set0:棚拍 UBO + GPUDriven 契约占位(内容恒定,创建期 + // 一次写入;in-flight 帧并发只读同一份,无需 per-slot ring)---- + UniformBufferObject ubo{}; + // 相机距离 3.6:半径 1 球的角半径 atan(1/3.6)≈15.5° < fov/2 17.5°, + // 球完整内切 tile(3.0 时球超出视锥被上下裁切) + ubo.view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.6f), + glm::vec3(0.0f), + glm::vec3(0.0f, 1.0f, 0.0f)); + ubo.proj = glm::perspective(glm::radians(35.0f), 1.0f, 0.1f, 10.0f); + ubo.proj[1][1] *= -1.0f; // Vulkan clip Y 翻转(与主相机同语义) + ubo.projNoJitter = ubo.proj; + ubo.prevView = ubo.view; // 静止球:velocity 恒 0 + ubo.prevProj = ubo.proj; + ubo.ambientColor = glm::vec4(1.0f); // IBL 强度乘子 = 全量 + ubo.cascadeSplits = glm::vec4(0.0f); // w=0 → frag 阴影短路(不采阴影图) + ubo.cameraPos = glm::vec4(0.0f, 0.0f, 3.6f, 1.0f); + // 固定棚拍主光:暖白方向光(从左上前方打向球体)。灯光 1-3 留零 + //(color.a=0 → 辐射为零) + ubo.lights[0].color = glm::vec4(1.0f, 0.98f, 0.94f, 2.2f); + ubo.lights[0].direction = glm::vec4( + glm::normalize(glm::vec3(-0.45f, -0.7f, -0.55f)), 0.0f); + ubo.lights[0].params = glm::vec4(0.0f); // x=0 → 方向光 + + m_PreviewUbo = CreateScope( + device, allocator, sizeof(UniformBufferObject), 1); + m_PreviewUbo->Update(0, &ubo, sizeof(ubo)); + + const glm::mat4 identity(1.0f); + m_ObjectBuffer = CreateScope( + device, allocator, sizeof(glm::mat4), 1, + vk::BufferUsageFlagBits::eStorageBuffer); + m_ObjectBuffer->Update(0, &identity, sizeof(identity)); + m_PrevObjectBuffer = CreateScope( + device, allocator, sizeof(glm::mat4), 1, + vk::BufferUsageFlagBits::eStorageBuffer); + m_PrevObjectBuffer->Update(0, &identity, sizeof(identity)); + const u32 skinBase = 0; // 指向 palette slot0(identity) + m_SkinOffsetBuffer = CreateScope( + device, allocator, sizeof(u32), 1, + vk::BufferUsageFlagBits::eStorageBuffer); + m_SkinOffsetBuffer->Update(0, &skinBase, sizeof(skinBase)); + m_SkinPaletteBuffer = CreateScope( + device, allocator, sizeof(glm::mat4), 1, + vk::BufferUsageFlagBits::eStorageBuffer); + m_SkinPaletteBuffer->Update(0, &identity, sizeof(identity)); + const u32 entityId[4] = {0, 0, 0, 0}; + m_EntityIdBuffer = CreateScope( + device, allocator, sizeof(entityId), 1, + vk::BufferUsageFlagBits::eStorageBuffer); + m_EntityIdBuffer->Update(0, entityId, sizeof(entityId)); + + m_Set0 = descriptors.AllocateDescriptorSet(gpu.uboSetLayout); + descriptors.UpdateUniformBuffer(m_Set0, 0, m_PreviewUbo->Get(0), + m_PreviewUbo->GetSize()); + descriptors.UpdateStorageBuffer(m_Set0, 1, m_ObjectBuffer->Get(0), + m_ObjectBuffer->GetSize()); + descriptors.UpdateStorageBuffer(m_Set0, 2, m_PrevObjectBuffer->Get(0), + m_PrevObjectBuffer->GetSize()); + descriptors.UpdateStorageBuffer(m_Set0, 3, m_SkinOffsetBuffer->Get(0), + m_SkinOffsetBuffer->GetSize()); + descriptors.UpdateStorageBuffer(m_Set0, 4, m_SkinPaletteBuffer->Get(0), + m_SkinPaletteBuffer->GetSize()); + descriptors.UpdateStorageBuffer(m_Set0, 5, m_EntityIdBuffer->Get(0), + m_EntityIdBuffer->GetSize()); + + // ---- 球 mesh + tonemap per-slot set ---- + std::vector vertices; + std::vector indices; + BuildSphere(vertices, indices); + m_Sphere = CreateScope(device, allocator, vertices, + std::vector>{indices}, + /*material*/ nullptr); + + for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { + m_TonemapSets[slot] = fullscreen.AllocateSet(); + } + } + + void MaterialPreviewPass::Register(RG::RenderGraph& rg) + { + // ---- 球体渲染:HDR atlas(loadOp=Load 保留缓存内容,逐脏 tile + // 清矩形 + 视口绘制;表空帧 = 零 GPU 工作)---- + rg.AddPass("MaterialPreview", [this](RG::RenderGraphContext& ctx) { + const vk::CommandBuffer cmd = ctx.cmd; + + vk::ClearValue clearValue; + // 背景色(仅用于逐 tile clearAttachments;tile 内容跨帧持久, + // 只有脏 tile 被"清矩形 + 重画") + clearValue.color = vk::ClearColorValue( + std::array{0.06f, 0.06f, 0.07f, 1.0f}); + // velocity 附件:forward.frag 双输出接口契约(location 1), + // 内容无人读——DontCare 免掉全图清屏(缓存命中帧零全图操作) + vk::ClearValue velocityClear; + velocityClear.color = vk::ClearColorValue( + std::array{0.0f, 0.0f, 0.0f, 0.0f}); + + std::array attachments; + attachments[0].setImageView(ctx.View(m_HDRAtlas)) + .setImageLayout( + vk::ImageLayout::eColorAttachmentOptimal) + .setLoadOp(vk::AttachmentLoadOp::eLoad) + .setStoreOp(vk::AttachmentStoreOp::eStore) + .setClearValue(clearValue); + attachments[1].setImageView(ctx.View(m_VelocityAtlas)) + .setImageLayout( + vk::ImageLayout::eColorAttachmentOptimal) + .setLoadOp(vk::AttachmentLoadOp::eDontCare) + .setStoreOp(vk::AttachmentStoreOp::eDontCare) + .setClearValue(velocityClear); + + vk::RenderingInfo renderingInfo; + renderingInfo.setRenderArea({{0, 0}, ctx.extent}) + .setLayerCount(1) + .setColorAttachments(attachments); + + cmd.beginRendering(renderingInfo); + + // ---- 公共渲染状态(shader object 会残留上一 pass 的绑定, + // 必须无条件直写——放在请求条件内时自检/空帧会复用 + // Outline 等前一 pass 的 shader/矩阵画出 garbage)---- + // 与场景同一 shader 对(WYSIWYG 关键:PBR 数学零复制) + std::array stages = { + vk::ShaderStageFlagBits::eVertex, + vk::ShaderStageFlagBits::eFragment}; + std::array shaders = { + m_Gpu.forwardVert.GetHandle(), + m_Gpu.forwardFrag.GetHandle()}; + cmd.bindShadersEXT(stages, shaders); + + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, + m_Gpu.forwardLayout, 0, m_Set0, {}); + // 锁定环境集:IBL 三件套固定中性环境(观察条件恒定), + // 级联/场景色副本与场景同步(previewEnvSets) + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, + m_Gpu.forwardLayout, 2, + m_Gpu.previewEnvSets[ctx.frame.frameSlot], + {}); + + cmd.setRasterizerDiscardEnable(VK_FALSE); + // 无深度附件:凸球 + 背面剔除即无遮挡序 + cmd.setDepthTestEnable(VK_FALSE); + cmd.setDepthWriteEnable(VK_FALSE); + cmd.setDepthBoundsTestEnable(VK_FALSE); + cmd.setStencilTestEnable(VK_FALSE); + cmd.setDepthBiasEnable(VK_FALSE); + cmd.setPolygonModeEXT(vk::PolygonMode::eFill); + // 场景双面(cull none)对凸球会里外翻叠——预览球是封闭 + // 几何,剔背面才是正确遮挡语义 + cmd.setCullMode(vk::CullModeFlagBits::eBack); + cmd.setFrontFace(vk::FrontFace::eCounterClockwise); + cmd.setPrimitiveTopology( + vk::PrimitiveTopology::eTriangleList); + cmd.setPrimitiveRestartEnable(VK_FALSE); + cmd.setVertexInputEXT(Vertex::GetBindingDescription(), + Vertex::GetAttributeDescriptions()); + cmd.setRasterizationSamplesEXT(vk::SampleCountFlagBits::e1); + vk::SampleMask sampleMask = 0xFFFFFFFF; + cmd.setSampleMaskEXT(vk::SampleCountFlagBits::e1, sampleMask); + cmd.setAlphaToCoverageEnableEXT(VK_FALSE); + cmd.setAlphaToOneEnableEXT(VK_FALSE); + + if (!m_DirtyTiles.empty()) { + // 逐脏 tile:清矩形(擦掉旧材质残影)→ 画球。 + // 同 tile 重复入队:池内已去重,此处防御性再滤 + u32 tileMask = 0; + for (const auto& dirty : m_DirtyTiles) { + if (!dirty.material || + dirty.tile >= kPreviewTileCount || + (tileMask & (1u << dirty.tile))) { + continue; + } + tileMask |= 1u << dirty.tile; + + const u32 col = dirty.tile % kPreviewTilesPerSide; + const u32 row = dirty.tile / kPreviewTilesPerSide; + vk::ClearAttachment tileClear; + tileClear.setAspectMask( + vk::ImageAspectFlagBits::eColor) + .setColorAttachment(0) + .setClearValue(clearValue); + vk::ClearRect tileRect( + vk::Rect2D( + vk::Offset2D{i32(col * kPreviewTileSize), + i32(row * kPreviewTileSize)}, + vk::Extent2D{kPreviewTileSize, + kPreviewTileSize}), + 0, 1); + cmd.clearAttachments(tileClear, tileRect); + + RecordBall(cmd, dirty.tile, *dirty.material); + } + } + + cmd.endRendering(); + }).Write(m_HDRAtlas, RG::ResourceUse::ColorAttachment) + .Write(m_VelocityAtlas, RG::ResourceUse::ColorAttachment); + + // ---- tonemap:HDR atlas → SDR atlas(ImGui 采样目标)---- + // 固定曝光 + ACES:材质球观察条件恒定,不随场景曝光设置漂移。 + // 仅有渲染的帧执行(缓存命中帧 SDR 内容不变,跳过 = 零 GPU 工作) + rg.AddPass("MaterialPreviewTonemap", + [this](RG::RenderGraphContext& ctx) { + if (m_DirtyTiles.empty()) return; + m_Fullscreen.RecordFullscreen( + ctx, ctx.View(m_SDRAtlas), m_Fullscreen.tonemapFrag, + m_TonemapSets[ctx.frame.frameSlot], + FullscreenPush{1.0f, 1.0f, 0.0f, 0.0f}); + }) + .Read(m_HDRAtlas, RG::ResourceUse::ShaderRead, + vk::ShaderStageFlagBits::eFragment) + .Write(m_SDRAtlas, RG::ResourceUse::ColorAttachment); + } + + void MaterialPreviewPass::RecordBall(vk::CommandBuffer cmd, u32 tile, + const Material& material) const + { + const u32 col = tile % kPreviewTilesPerSide; + const u32 row = tile / kPreviewTilesPerSide; + const f32 x = f32(col * kPreviewTileSize); + const f32 y = f32(row * kPreviewTileSize); + + cmd.setViewportWithCount(vk::Viewport( + x, y, f32(kPreviewTileSize), f32(kPreviewTileSize), 0.0f, 1.0f)); + cmd.setScissorWithCount(vk::Rect2D( + vk::Offset2D{i32(col * kPreviewTileSize), + i32(row * kPreviewTileSize)}, + vk::Extent2D{kPreviewTileSize, kPreviewTileSize})); + + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, + m_Gpu.forwardLayout, 1, + material.GetDescriptorSet(), {}); + + // 混合态显式直写(shader object 动态状态跨 pass/请求残留) + cmd.setColorBlendEnableEXT( + 0, material.IsTransparent() ? VK_TRUE : VK_FALSE); + cmd.setColorBlendEquationEXT( + 0, material.IsTransparent() ? MakePremulEquation() + : MakeDirectEquation()); + cmd.setColorWriteMaskEXT(0, kColorWriteMask); + + m_Sphere->Draw(cmd, 1, 0, 0); + } + + void MaterialPreviewPass::OnTargetsRebuilt(RG::RenderGraph& rg) + { + // 终值路径(binding 0/1)= HDR atlas 物理镜像(重编译后重绑); + // binding1 同源占位——tonemap.frag 终值路径恒采样 bloom 图, + // 强度 0 丢弃。 + // binding 2-7 = 2D 调试视图槽:预览恒 debugView=0 不采样,但 + // 布局声明即需有效描述符(validation VUID-08114)——HDR atlas + // 占位(类型匹配 sampler2D)。binding 8-11(cube 三件套 + LUT) + // 走 OnEnvironmentChanged 绑真环境资源(cube 槽类型必须 Cube) + for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { + m_Fullscreen.UpdateSetInput(m_TonemapSets[slot], 0, rg, + m_HDRAtlas, slot); + m_Fullscreen.UpdateSetInput(m_TonemapSets[slot], 1, rg, + m_HDRAtlas, slot); + for (u32 b = 2; b < 8; ++b) { + m_Fullscreen.UpdateSetInput(m_TonemapSets[slot], b, rg, + m_HDRAtlas, slot); + } + } + } + + void MaterialPreviewPass::OnEnvironmentChanged(const Cubemap* env) + { + m_Env = env; + if (!m_Env) return; + + // 与 TonemapPass 相同的调试视图源(binding 8-11):cube 三件套 + // + LUT。Renderer 注册时与 SetEnvironment 时各通知一次,静态 + // 绑定超集模式——只在切换环境时重绑,每帧零成本 + for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { + m_Descriptors.UpdateTexture(m_TonemapSets[slot], 8, + m_Env->GetSampler(), + m_Env->GetView()); + m_Descriptors.UpdateTexture(m_TonemapSets[slot], 9, + m_Env->GetSampler(), + m_Env->GetIrradianceView()); + m_Descriptors.UpdateTexture(m_TonemapSets[slot], 10, + m_Env->GetSampler(), + m_Env->GetPrefilteredView()); + m_Descriptors.UpdateTexture(m_TonemapSets[slot], 11, + m_Env->GetBrdfLutSampler(), + m_Env->GetBrdfLutView()); + } + } + +} // namespace Engine::Passes diff --git a/Engine/src/Renderer/Passes/SceneGpu.cpp b/Engine/src/Renderer/Passes/SceneGpu.cpp index 487f782..a308241 100644 --- a/Engine/src/Renderer/Passes/SceneGpu.cpp +++ b/Engine/src/Renderer/Passes/SceneGpu.cpp @@ -61,14 +61,15 @@ namespace Engine::Passes textureSetLayout = descriptors.CreateLayout(texBindings); // Set 2: CSM 级联阴影图(0/1/2)+ IBL 环境三件套(3 irradiance - // / 4 pre-filtered / 5 BRDF LUT),均 fragment 采样 + // / 4 pre-filtered / 5 BRDF LUT)+ 场景色副本(6,透射折射采样, + // 仅 translucent frag 声明),均 fragment 采样 std::vector shadowBindings; for (u32 c = 0; c < kMaxCascades; ++c) { shadowBindings.push_back( {c, vk::DescriptorType::eCombinedImageSampler, vk::ShaderStageFlagBits::eFragment}); } - for (const u32 b : {3u, 4u, 5u}) { + for (const u32 b : {3u, 4u, 5u, 6u}) { shadowBindings.push_back( {b, vk::DescriptorType::eCombinedImageSampler, vk::ShaderStageFlagBits::eFragment}); @@ -141,9 +142,24 @@ namespace Engine::Passes .setCompareOp(vk::CompareOp::eLess); shadowSampler = device.GetDevice().createSampler(shadowSamplerInfo); + // 场景色副本采样器:普通线性 clamp-to-edge(折射采样用) + vk::SamplerCreateInfo sceneSamplerInfo; + sceneSamplerInfo.setMagFilter(vk::Filter::eLinear) + .setMinFilter(vk::Filter::eLinear) + .setMipmapMode(vk::SamplerMipmapMode::eNearest) + .setAddressModeU(vk::SamplerAddressMode::eClampToEdge) + .setAddressModeV(vk::SamplerAddressMode::eClampToEdge) + .setAddressModeW(vk::SamplerAddressMode::eClampToEdge) + .setUnnormalizedCoordinates(VK_FALSE); + sceneSampler = device.GetDevice().createSampler(sceneSamplerInfo); + // per-slot 描述符分配 + Set0 绑定(buffer 句柄全程稳定,不用重绑) uboSets.resize(kMaxFramesInFlight); shadowSets.resize(kMaxFramesInFlight); +#ifdef HELLO_EDITOR + // 材质预览锁定环境集(editor 专属,game 构建不分配) + previewEnvSets.resize(kMaxFramesInFlight); +#endif for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { uboSets[slot] = descriptors.AllocateDescriptorSet(uboSetLayout); descriptors.UpdateUniformBuffer( @@ -164,6 +180,10 @@ namespace Engine::Passes entityIdBuffer.GetSize()); shadowSets[slot] = descriptors.AllocateDescriptorSet(shadowSetLayout); +#ifdef HELLO_EDITOR + previewEnvSets[slot] = + descriptors.AllocateDescriptorSet(shadowSetLayout); +#endif } // shadowSets 的图片绑定在 Compile 后由 BindShadowMap 完成 @@ -192,6 +212,7 @@ namespace Engine::Passes // shader 随 ShaderRef 引用计数自动销毁,此处只清其余原生句柄 vkDevice.destroySampler(shadowSampler); + vkDevice.destroySampler(sceneSampler); vkDevice.destroyPipelineLayout(forwardLayout); vkDevice.destroyPipelineLayout(shadowLayout); vkDevice.destroyDescriptorSetLayout(uboSetLayout); @@ -203,15 +224,37 @@ namespace Engine::Passes const std::array& maps) { // 物理镜像按帧槽位各一份:每个槽位的描述符绑各自的 view + //(previewEnvSets 同步重绑——级联是几何资源,预览共用) for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { for (u32 c = 0; c < kMaxCascades; ++c) { m_Descriptors.UpdateTexture( shadowSets[slot], c, shadowSampler, rg.GetPhysicalView(maps[c], slot)); +#ifdef HELLO_EDITOR + m_Descriptors.UpdateTexture( + previewEnvSets[slot], c, shadowSampler, + rg.GetPhysicalView(maps[c], slot)); +#endif } } } + void SceneGpu::BindSceneColor(RG::RenderGraph& rg, + RG::TextureHandle sceneColor) + { + // 物理镜像按帧槽位各一份,随 RG 重编译重绑(同 BindShadowMaps) + for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { + m_Descriptors.UpdateTexture( + shadowSets[slot], 6, sceneSampler, + rg.GetPhysicalView(sceneColor, slot)); +#ifdef HELLO_EDITOR + m_Descriptors.UpdateTexture( + previewEnvSets[slot], 6, sceneSampler, + rg.GetPhysicalView(sceneColor, slot)); +#endif + } + } + // ---- MDI:per-slot 上传 + 组绘制 ---- void SceneGpu::UploadIndirect(u32 frameSlot, const FrameSceneData& frame) @@ -275,4 +318,25 @@ namespace Engine::Passes } } + void SceneGpu::LockPreviewEnvironment(const Cubemap& env) + { +#ifdef HELLO_EDITOR + // 只绑 previewEnvSets 的 IBL 三件套(binding 3/4/5),此后任何 + // SetEnvironment 都不影响预览——材质球观察条件恒定 + for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { + m_Descriptors.UpdateTexture(previewEnvSets[slot], 3, + env.GetSampler(), + env.GetIrradianceView()); + m_Descriptors.UpdateTexture(previewEnvSets[slot], 4, + env.GetSampler(), + env.GetPrefilteredView()); + m_Descriptors.UpdateTexture(previewEnvSets[slot], 5, + env.GetBrdfLutSampler(), + env.GetBrdfLutView()); + } +#else + (void)env; +#endif + } + } // namespace Engine::Passes diff --git a/Engine/src/Renderer/Renderer.cpp b/Engine/src/Renderer/Renderer.cpp index a9354bb..92092de 100644 --- a/Engine/src/Renderer/Renderer.cpp +++ b/Engine/src/Renderer/Renderer.cpp @@ -19,6 +19,9 @@ #include #include #include +#ifdef HELLO_EDITOR +#include +#endif #include #include #include @@ -111,6 +114,23 @@ namespace Engine u64(Passes::PhysicsDebugPass::kMaxTriVerts) * sizeof(PhysicsDebugVertex), kMaxFramesInFlight, vk::BufferUsageFlagBits::eVertexBuffer); +#ifdef HELLO_EDITOR + // 3.3 Editor gizmo 图元 ring VB(editor 专属;game 构建不分配)。 + // eVertexBuffer 复用 UniformBuffer 的 per-slot fence 语义, + // Render 上传窗口直灌(pass 无 PrepareFrame) + m_GizmoIconVB = CreateScope( + *m_Device, *m_Allocator, + u64(kMaxGizmoIconVerts) * sizeof(GizmoQuadVertex), + kMaxFramesInFlight, vk::BufferUsageFlagBits::eVertexBuffer); + m_GizmoWireVB = CreateScope( + *m_Device, *m_Allocator, + u64(kMaxGizmoWireVerts) * sizeof(GizmoWireVertex), + kMaxFramesInFlight, vk::BufferUsageFlagBits::eVertexBuffer); + m_GizmoProxyVB = CreateScope( + *m_Device, *m_Allocator, + u64(kMaxGizmoProxyVerts) * sizeof(GizmoQuadVertex), + kMaxFramesInFlight, vk::BufferUsageFlagBits::eVertexBuffer); +#endif // 3.5 环境预积分生成端(compute 链)+ 中性兜底四件套。 // 真实环境由 Application 经 AssetManager 加载后 SetEnvironment @@ -146,6 +166,10 @@ namespace Engine } // 环境辐照度绑定(view 在单个环境内全程稳定,绑一次) m_SceneGpu->BindEnvironment(*m_Environment); + // 材质预览锁定环境:此时 m_Environment 必为中性兜底(L119 刚建, + // SetEnvironment 只会发生在场景/资产加载之后)——材质球观察条件 + // 从此恒定,不随场景环境切换漂移(UE ThumbnailRenderer 语义) + m_SceneGpu->LockPreviewEnvironment(*m_Environment); // 环境消费端 pass(Skybox 背景 / Tonemap 调试视图)同步初始绑定 for (auto& pass : m_Passes) { pass->OnEnvironmentChanged(m_Environment.get()); @@ -224,6 +248,34 @@ namespace Engine } void Renderer::SetBloomEnabled(bool b) { m_SceneRenderer->SetBloomEnabled(b); } bool Renderer::GetLodEnabled() const { return m_SceneRenderer->Frame().lodEnabled; } + +#ifdef HELLO_EDITOR + // ---- 骨骼可视化:转发 SceneRenderer(编辑形态专属)---- + bool Renderer::GetBoneWiresVisible() const + { + return m_SceneRenderer->GetBoneWiresVisible(); + } + void Renderer::SetBoneWiresVisible(bool b) + { + m_SceneRenderer->SetBoneWiresVisible(b); + } + bool Renderer::GetBoneAllNodes() const + { + return m_SceneRenderer->GetBoneAllNodes(); + } + void Renderer::SetBoneAllNodes(bool b) + { + m_SceneRenderer->SetBoneAllNodes(b); + } + f32 Renderer::GetBoneJointPx() const + { + return m_SceneRenderer->GetBoneJointPx(); + } + void Renderer::SetBoneJointPx(f32 px) + { + m_SceneRenderer->SetBoneJointPx(px); + } +#endif void Renderer::SetLodEnabled(bool b) { m_SceneRenderer->SetLodEnabled(b); } int Renderer::GetDebugView() const { return m_SceneRenderer->Frame().debugView; } void Renderer::SetDebugView(int v) { m_SceneRenderer->SetDebugView(v); } @@ -368,6 +420,13 @@ namespace Engine "HDRColor", RG::TextureDesc{vk::Format::eR16G16B16A16Sfloat}); + // 场景色副本:不透明段快照(含天空盒背景),透射折射采样源。 + // 每帧一次 copyImage(Forward 后、ForwardTrans 前),独立于 + // HDRColor 生命周期(后者还会被透明段/TAA 继续写入) + m_SceneColor = m_RenderGraph->CreateTexture( + "SceneColor", + RG::TextureDesc{vk::Format::eR16G16B16A16Sfloat}); + // velocity:clip 空间运动向量(TAA 反推历史像素位置) m_Velocity = m_RenderGraph->CreateTexture( "Velocity", @@ -404,6 +463,29 @@ namespace Engine "TemporalDiff", RG::TextureDesc{vk::Format::eR16G16B16A16Sfloat, 0, 0, 0.5f}); + // 材质球预览 atlas:固定 1024²(不随渲染分辨率缩放——预览球 + // 256²/tile 观察条件恒定,编辑参数时对比才有意义)。 + // editor 专属:game 构建不创建(tile 内容跨帧持久,缓存语义) +#ifdef HELLO_EDITOR + m_PreviewHDR = m_RenderGraph->CreateTexture( + "PreviewHDR", + RG::TextureDesc{vk::Format::eR16G16B16A16Sfloat, + Passes::kPreviewAtlasSize, + Passes::kPreviewAtlasSize}); + m_PreviewSDR = m_RenderGraph->CreateTexture( + "PreviewSDR", + RG::TextureDesc{vk::Format::eR8G8B8A8Unorm, + Passes::kPreviewAtlasSize, + Passes::kPreviewAtlasSize}); + // velocity 附件:forward.frag 双输出接口契约(预览内容恒 0, + // 无读者——shader 零变体的代价是 1024² RGBA16F 显存) + m_PreviewVelocity = m_RenderGraph->CreateTexture( + "PreviewVelocity", + RG::TextureDesc{vk::Format::eR16G16B16A16Sfloat, + Passes::kPreviewAtlasSize, + Passes::kPreviewAtlasSize}); +#endif + // ---- pass 注册:执行序由拓扑排序决定,与注册顺序无关 ---- // 要加新 pass:new 一个 RenderPass 子类,在这里 push 一行。 FrameSceneData& frame = m_SceneRenderer->Frame(); @@ -414,7 +496,8 @@ namespace Engine *m_Device, *m_ShaderManager, *m_DescriptorManager, frame, m_HDRColor, m_Environment.get())); m_Passes.push_back(CreateScope( - *m_SceneGpu, frame, m_HDRColor, m_Velocity, m_Depth, m_ShadowMaps)); + *m_SceneGpu, frame, m_HDRColor, m_Velocity, m_Depth, + m_SceneColor, m_ShadowMaps)); // 物理调试叠加:写 HDRColor(WAW 链排 Forward 之后,RAW 链在 // TAA 之前)——调试图元进 TAA/Bloom/Tonemap 主链路 m_Passes.push_back(CreateScope( @@ -422,6 +505,14 @@ namespace Engine frame, m_HDRColor, m_Depth, *m_PhysicsLineVB, *m_PhysicsTriVB)); m_PhysicsDebugPass = static_cast(m_Passes.back().get()); +#ifdef HELLO_EDITOR + // Editor gizmo(billboard 图标 + 视锥线框):editor 专属 pass, + // game 构建不注册不分配。Write(Depth) 的深度 WAW 边把自己固定 + // 排在 HitProxy 清深度之前(gizmo 深度测试消费场景深度) + m_Passes.push_back(CreateScope( + *m_Device, *m_ShaderManager, *m_DescriptorManager, + frame, m_HDRColor, m_Depth, *m_GizmoIconVB, *m_GizmoWireVB)); +#endif // UE 式 GPU 拾取:请求帧把 id 画进 R32Uint + 拷贝请求像素进 // host 暂存(深度 WAW 链尾,排在 Forward/ForwardTrans/PhysicsDebug // 之后;App 层经 RequestPick/TakePickResult 消费) @@ -430,6 +521,10 @@ namespace Engine *m_SceneGpu, frame, m_HitProxyId, m_Depth)); m_HitProxyPass = static_cast(m_Passes.back().get()); +#ifdef HELLO_EDITOR + // gizmo 代理壳 ring VB 接入拾取 pass(请求帧与 mesh 同 pass 重画) + m_HitProxyPass->AttachGizmoProxy(*m_GizmoProxyVB); +#endif // 选中描边链:mask(只画选中实体,无深度测试=完整投影轮廓, // UE 透墙可见语义)→ Outline(叠加在 Tonemap 后的 PresentTarget // 上,恒定 LDR 描边色) @@ -453,13 +548,25 @@ namespace Engine m_Passes.push_back(CreateScope( *m_FullscreenGpu, *m_ShaderManager, frame, m_SelectionMask, m_PresentTarget)); + // 材质球预览(独立链:PreviewHDR → tonemap → PreviewSDR,与场景 + // 链无数据交叠;editor 专属,HELLO_EDITOR 裁剪。UI 段 Request 标 + // 脏,Render 段 TakeRenders 换入当帧脏表经引用传入) +#ifdef HELLO_EDITOR + m_Passes.push_back(CreateScope( + *m_Device, *m_Allocator, *m_DescriptorManager, *m_SceneGpu, + *m_FullscreenGpu, m_PreviewHDR, m_PreviewVelocity, m_PreviewSDR, + m_PreviewRenders)); + m_PreviewPass = + static_cast(m_Passes.back().get()); +#endif // Direct 模式适配器:PresentTarget → backbuffer(Viewport 模式空转)。 // 必须注册在 ImGui 之前:两者都写 backbuffer,WAW 边按声明序 // 连接(前 writer → 后 writer),反序会让 blit 盖掉 UI m_Passes.push_back(CreateScope( frame, m_PresentTarget, m_Backbuffer)); m_Passes.push_back(CreateScope( - &m_ImGuiLayer, frame, m_Backbuffer, m_PresentTarget)); + &m_ImGuiLayer, frame, m_Backbuffer, m_PresentTarget, + m_PreviewSDR)); m_Passes.push_back(CreateScope(m_Backbuffer)); for (auto& pass : m_Passes) { @@ -545,6 +652,32 @@ namespace Engine return tex; } + // ---- 材质球预览(editor 专属,HELLO_EDITOR 裁剪)---- + // UI 构建段逐可见行幂等请求 → 池标脏(收集本帧活指针); + // Render 段 TakeRenders 取预算内的脏 tile → pass 清矩形重画 + +#ifdef HELLO_EDITOR + i32 Renderer::RequestMaterialPreview(Material* material) + { + return m_PreviewPool.Request(material); + } + + void Renderer::DropMaterialPreview(const Material* material) + { + m_PreviewPool.DropMaterial(material); + } + + void* Renderer::GetMaterialPreviewTexture() const + { + void* tex = nullptr; + if (m_ImGuiLayer) { + tex = (void*)(intptr_t) + m_ImGuiLayer->GetPreviewTexture(m_CurrentFrame); + } + return tex; + } +#endif + // RG 物理镜像按帧槽位各一份:每槽位注册一个 ImGui 描述符。 // 首次编译后(SetImGuiLayer)与每次重编译后(HandleResize)调用 void Renderer::RefreshSceneTextures() @@ -553,7 +686,21 @@ namespace Engine for (u32 slot = 0; slot < kMaxFramesInFlight; ++slot) { m_ImGuiLayer->UpdateSceneTexture( slot, m_RenderGraph->GetPhysicalView(m_PresentTarget, slot)); + // 材质球 atlas 同槽位注册(语义与场景纹理一致) +#ifdef HELLO_EDITOR + if (m_PreviewSDR.Valid()) { + m_ImGuiLayer->UpdatePreviewTexture( + slot, m_RenderGraph->GetPhysicalView(m_PreviewSDR, slot)); + } +#endif } +#ifdef HELLO_EDITOR + // 重编译 = 物理镜像全部换新(三槽位 atlas 内容清零)——池的 + // paintedSlots 记录全部失效,可见行 keepalive 请求会重新补画 + if (m_PreviewSDR.Valid()) { + m_PreviewPool.InvalidateAll(); + } +#endif } void Renderer::ReadGpuTimes(u32 frameSlot) @@ -596,6 +743,8 @@ namespace Engine { ZoneScoped; // CPU 侧:UBO/SSBO 上传 + 录制 + 提交(GPU 耗时见 RG timestamp) // resize 已在 BeginFrameSync(UI 构建前)处理,此处无需再查 + //(预览脏表换入在本函数末尾、Execute 之前——所有跳帧路径之后, + // 保证"标记已画"的槽位镜像必然真的被写入) const u32 frameSlot = m_CurrentFrame; const auto& vkDevice = m_Device->GetDevice(); @@ -669,6 +818,43 @@ namespace Engine m_PhysicsDebugPass->PrepareFrame(frameSlot, *m_PhysicsDebug); } +#ifdef HELLO_EDITOR + // Editor gizmo 图元同窗口上传(fence 已等);空帧不写,pass 端 + // 顶点数 0 短路零开销。三流各落各槽位(代理壳给 HitProxy 消费) + if (!frame.gizmoIconVerts.empty()) { + m_GizmoIconVB->Update(frameSlot, frame.gizmoIconVerts.data(), + frame.gizmoIconVerts.size() * + sizeof(GizmoQuadVertex)); + } + if (!frame.gizmoWireVerts.empty()) { + m_GizmoWireVB->Update(frameSlot, frame.gizmoWireVerts.data(), + frame.gizmoWireVerts.size() * + sizeof(GizmoWireVertex)); + } + // 骨骼线与 gizmo 线共享同一 LineList 顶点缓冲:骨骼段追加在 + // gizmo 段之后(偏移写入),超容量截断(单实体骨架远打不满, + // 只是防御 gizmo 线占满时的 OOB 写) + if (!frame.boneWireVerts.empty()) { + const u64 wireUsed = frame.gizmoWireVerts.size(); + const u64 capVerts = + m_GizmoWireVB->GetSize() / sizeof(GizmoWireVertex); + const u64 room = capVerts > wireUsed ? capVerts - wireUsed : 0; + const u64 boneAvail = std::min( + frame.boneWireVerts.size(), room); + if (boneAvail > 0) { + m_GizmoWireVB->UpdateAt( + frameSlot, wireUsed * sizeof(GizmoWireVertex), + frame.boneWireVerts.data(), + boneAvail * sizeof(GizmoWireVertex)); + } + } + if (!frame.gizmoProxyVerts.empty()) { + m_GizmoProxyVB->Update(frameSlot, frame.gizmoProxyVerts.data(), + frame.gizmoProxyVerts.size() * + sizeof(GizmoQuadVertex)); + } +#endif + // 3. acquire swapchain image u32 imageIndex = 0; try { @@ -699,6 +885,13 @@ namespace Engine m_Swapchain->GetImages()[imageIndex], m_Swapchain->GetImageViews()[imageIndex]); + // UI 构建段标脏的预览请求换入当帧脏表(pass 引用读取;此处已过 + // 全部跳帧路径,标记 paintedSlots 的槽位镜像本帧必然被写入)。 + // 预算外的脏 tile 保持未取状态,下帧可见行 keepalive 请求重新入队 +#ifdef HELLO_EDITOR + m_PreviewRenders = m_PreviewPool.TakeRenders(frameSlot); +#endif + m_RenderGraph->Execute(frameCtx); m_CommandManager->EndFrame(frameSlot); diff --git a/Engine/src/Renderer/SceneRenderer.cpp b/Engine/src/Renderer/SceneRenderer.cpp index 5a28527..6a80940 100644 --- a/Engine/src/Renderer/SceneRenderer.cpp +++ b/Engine/src/Renderer/SceneRenderer.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -18,6 +19,7 @@ #include #include +#include #include #include @@ -31,17 +33,37 @@ namespace Engine namespace { - // 相机裁剪范围(UE 编辑器视口语义:near = 10cm;far 拉远到 - // "无限"级——UE 的 far clip 默认就是不裁剪,深度精度由 near 主导, - // far 取多大都不损近处精度)。阴影距离独立(UE DirectionalLight - // 的 shadow distance 同构):级联分割只覆盖 kShadowDistance, - // 更远无阴影,避免级联跟着 far 拉爆近处阴影分辨率 - constexpr f32 kCameraNear = 0.1f; - constexpr f32 kCameraFar = 10000.0f; + // 相机裁剪范围不再走常量:每实体相机自带 near/far(Camera.h + // 默认值 0.1/10000,UE 语义:near 主导深度精度,far 拉远到 + // "无限"级)。阴影距离独立(UE DirectionalLight 的 shadow + // distance 同构):级联分割只覆盖 kShadowDistance,更远无阴影, + // 避免级联跟着 far 拉爆近处阴影分辨率 constexpr f32 kShadowDistance = 100.0f; // 实用分割插值因子(0=均匀,1=对数;0.6 是业界常用值) constexpr f32 kCascadeLambda = 0.6f; +#ifdef HELLO_EDITOR + // ---- Editor gizmo 收集常量(CollectEditorGizmos 专用)---- + // 图标屏幕占比(相对视口高):恒定屏幕尺寸 = dist·tanHalf·frac + constexpr f32 kIconScreenFrac = 0.04f; + // 代理棱边屏宽(像素):HitProxy 可点选的 ribbon 宽度 + constexpr f32 kProxyWidthPx = 4.0f; + // 视锥线框可视远平面截断:far clip(10000m)全尺寸线框横贯场景 + // 无信息量,可视化统一用固定短距离(渲染语义不变,纯显示) + constexpr f32 kGizmoFrustumVisFar = 3.0f; + // 视锥棱 ribbon 细分段数(恒定屏宽的折线逼近) + constexpr u32 kRibbonSegsPerEdge = 12; + // 点光选中时的衰减半径圆段数 + constexpr u32 kRadiusCircleSegs = 24; + + // billboard 形状 id(gizmo_billboard.frag 的 SDF 分支约定) + constexpr f32 kShapeDirLight = 0.0f; + constexpr f32 kShapePointLight = 1.0f; + constexpr f32 kShapeCamera = 2.0f; + constexpr f32 kShapeSkybox = 3.0f; + constexpr f32 kShapeSkyLight = 4.0f; +#endif + // Halton 低差异序列(TAA 子像素抖动) f32 Halton(u32 index, u32 base) { @@ -53,134 +75,20 @@ namespace Engine } return r; } - - // ---- 骨骼动画采样(BuildFrame 第 2.5 节专用)---- - - // 静止 local TRS 分解(列主序 SRT 矩阵;假设无负缩放/剪切)。 - // 被动画轨道触碰的节点用关键帧重建,未触碰的保持原矩阵—— - // FBX PreRotation 折在 localTransform 里,拆 TRS 重建会丢 - void DecomposeTRS(const glm::mat4& m, glm::vec3& pos, glm::quat& rot, - glm::vec3& scl) - { - pos = glm::vec3(m[3]); - scl = {glm::length(glm::vec3(m[0])), - glm::length(glm::vec3(m[1])), - glm::length(glm::vec3(m[2]))}; - const glm::mat3 r(glm::vec3(m[0]) / scl.x, - glm::vec3(m[1]) / scl.y, - glm::vec3(m[2]) / scl.z); - rot = glm::quat_cast(r); - } - - // 关键帧采样:前后键插值(位置/缩放 lerp,旋转 slerp)。 - // key 数通常几十个,线性扫描对 cache 更友好 - glm::vec3 SampleKeys(const std::vector& keys, f32 t, - const glm::vec3& rest) - { - if (keys.empty()) return rest; - if (t <= keys.front().time) return keys.front().value; - for (size_t i = 0; i + 1 < keys.size(); ++i) { - if (t < keys[i + 1].time) { - const f32 a = keys[i].time, b = keys[i + 1].time; - return glm::mix(keys[i].value, keys[i + 1].value, - b > a ? (t - a) / (b - a) : 0.0f); - } - } - return keys.back().value; - } - - glm::vec3 SampleKeys(const std::vector& keys, f32 t, - const glm::vec3& rest) - { - if (keys.empty()) return rest; - if (t <= keys.front().time) return keys.front().value; - for (size_t i = 0; i + 1 < keys.size(); ++i) { - if (t < keys[i + 1].time) { - const f32 a = keys[i].time, b = keys[i + 1].time; - return glm::mix(keys[i].value, keys[i + 1].value, - b > a ? (t - a) / (b - a) : 0.0f); - } - } - return keys.back().value; - } - - glm::quat SampleKeys(const std::vector& keys, f32 t, - const glm::quat& rest) - { - if (keys.empty()) return rest; - if (t <= keys.front().time) return keys.front().value; - for (size_t i = 0; i + 1 < keys.size(); ++i) { - if (t < keys[i + 1].time) { - const f32 a = keys[i].time, b = keys[i + 1].time; - return glm::slerp(keys[i].value, keys[i + 1].value, - b > a ? (t - a) / (b - a) : 0.0f); - } - } - return keys.back().value; - } - - // 单 clip 在 time 的全节点局部 TRS(无轨道分量 = 静止分解值)。 - // touched = 被任意轨道触碰的节点标记(ComposeGlobals 决定重建或原样) - void SampleLocalPose(const Model& model, const AnimationClip& clip, - f32 time, std::vector& pos, - std::vector& rot, - std::vector& scl, - std::vector& touched) - { - const auto& nodes = model.GetNodes(); - pos.resize(nodes.size()); - rot.resize(nodes.size()); - scl.resize(nodes.size()); - touched.assign(nodes.size(), 0); - for (u32 i = 0; i < nodes.size(); ++i) { - DecomposeTRS(nodes[i].localTransform, pos[i], rot[i], scl[i]); - } - if (clip.duration <= 0.0f) return; - const f32 t = glm::mod(time, clip.duration); - for (const auto& ch : clip.channels) { - if (ch.nodeIdx >= nodes.size()) continue; - touched[ch.nodeIdx] = 1; - if (!ch.positions.empty()) - pos[ch.nodeIdx] = SampleKeys(ch.positions, t, pos[ch.nodeIdx]); - if (!ch.rotations.empty()) - rot[ch.nodeIdx] = SampleKeys(ch.rotations, t, rot[ch.nodeIdx]); - if (!ch.scales.empty()) - scl[ch.nodeIdx] = SampleKeys(ch.scales, t, scl[ch.nodeIdx]); - } - } - - // 局部 TRS → 全节点 global 矩阵(层级累乘;父序 DFS 保证父先算) - void ComposeGlobals(const Model& model, const std::vector& pos, - const std::vector& rot, - const std::vector& scl, - const std::vector& touched, - std::vector& globals) - { - const auto& nodes = model.GetNodes(); - globals.resize(nodes.size()); - for (u32 i = 0; i < nodes.size(); ++i) { - const glm::mat4 local = touched[i] - ? glm::translate(glm::mat4(1.0f), pos[i]) * - glm::mat4_cast(rot[i]) * glm::scale(glm::mat4(1.0f), scl[i]) - : nodes[i].localTransform; - globals[i] = nodes[i].parent >= 0 - ? globals[static_cast(nodes[i].parent)] * local - : local; - } - } } - void SceneRenderer::ComputeCascades(const glm::vec3& shadowDir) + void SceneRenderer::ComputeCascades(const glm::vec3& shadowDir, + const f32 camNear, const f32 camFar) { constexpr u32 N = kMaxCascades; // ---- 1. 实用分割:均匀与对数分割按 λ 插值(视图深度,正值)---- - //(分割覆盖 [near, kShadowDistance];kCameraFar 只管主投影裁剪) + //(分割覆盖 [camNear, kShadowDistance];camFar 只管主投影裁剪) f32 splits[N]; for (u32 i = 1; i <= N; ++i) { const f32 t = static_cast(i) / N; - const f32 uniform = kCameraNear + (kShadowDistance - kCameraNear) * t; - const f32 log = kCameraNear * glm::pow(kShadowDistance / kCameraNear, t); + const f32 uniform = camNear + (kShadowDistance - camNear) * t; + const f32 log = camNear * glm::pow(kShadowDistance / camNear, t); splits[i - 1] = glm::mix(uniform, log, kCascadeLambda); } for (u32 c = 0; c < N; ++c) { @@ -203,14 +111,14 @@ namespace Engine ? glm::vec3(0.0f, 0.0f, 1.0f) : glm::vec3(0.0f, 1.0f, 0.0f); for (u32 c = 0; c < N; ++c) { - const f32 d0 = (c == 0) ? kCameraNear : splits[c - 1]; + const f32 d0 = (c == 0) ? camNear : splits[c - 1]; const f32 d1 = splits[c]; // 视图深度 → 视锥棱边参数:棱边上 z_view 线性(near/far 角点 // 都在各自平面上),farC 反投影自主投影 far 面,故 t 相对 - // kCameraFar 归一;splits 只覆盖 [near, kShadowDistance], + // camFar 归一;splits 只覆盖 [camNear, kShadowDistance], // t 必然远小于 1 → 自然截出阴影范围内的切片 - const f32 t0 = (d0 - kCameraNear) / (kCameraFar - kCameraNear); - const f32 t1 = (d1 - kCameraNear) / (kCameraFar - kCameraNear); + const f32 t0 = (d0 - camNear) / (camFar - camNear); + const f32 t1 = (d1 - camNear) / (camFar - camNear); glm::vec3 slice[8]; for (u32 i = 0; i < 4; ++i) { @@ -258,6 +166,297 @@ namespace Engine } } +#ifdef HELLO_EDITOR + // ---- Editor gizmo 收集(编辑形态专属,只读场景写 editor 段)---- + void SceneRenderer::CollectEditorGizmos(ECS::Scene& scene, + const glm::vec3& camPos, + const glm::vec3& camRight, + const glm::vec3& camUp, + f32 aspect, f32 viewHeightPx, + f32 viewTanHalf) + { + auto& icons = m_Frame.gizmoIconVerts; + auto& wires = m_Frame.gizmoWireVerts; + auto& proxies = m_Frame.gizmoProxyVerts; + icons.clear(); + wires.clear(); + proxies.clear(); + + constexpr f32 kTwoPi = 6.28318530718f; + // viewTanHalf:编辑器视角相机的 tan(fov/2)(BuildFrame 传入), + // 只服务 billboard 恒定屏占比与 proxy ribbon 恒定像素宽——与 + // 相机实体自己的 fov 无关 + const f32 tanHalf = viewTanHalf; + const bool anySelected = m_Frame.selectedEntityValid; + const u32 selBits = m_Frame.selectedEntityBits; + auto IsSel = [&](u32 bits) + { return anySelected && bits == selBits; }; + + // 预算哨兵(一次性 WARN 复用截断标志语义) + bool truncated = false; + auto Fit = [](u32 cur, u32 add, u32 cap) + { return cur + add <= cap; }; + + // 图标 billboard:面向相机 quad(2 三角)+ 同角点进代理流(可点选) + auto EmitIcon = [&](const glm::vec3& pos, f32 shape, + const glm::vec4& color, u32 bits, f32 scaleMul) + { + if (!Fit(static_cast(icons.size()), 6, kMaxGizmoIconVerts) || + !Fit(static_cast(proxies.size()), 6, + kMaxGizmoProxyVerts)) + { + truncated = true; + return; + } + const f32 dist = + std::max(glm::length(pos - camPos), 0.1f); + const f32 half = dist * tanHalf * kIconScreenFrac * scaleMul; + const glm::vec3 r = camRight * half; + const glm::vec3 u = camUp * half; + const glm::vec3 p00 = pos - r - u, p10 = pos + r - u; + const glm::vec3 p11 = pos + r + u, p01 = pos - r + u; + const auto push = [&](std::vector& v, + const glm::vec3& p, f32 lu, f32 lv) + { + v.push_back({p, color, glm::vec4(shape, lu, lv, 0.0f), bits}); + }; + // 三角表:v00 v10 v11 / v00 v11 v01(uv 对应角点) + push(icons, p00, -1, -1); push(icons, p10, 1, -1); + push(icons, p11, 1, 1); + push(icons, p00, -1, -1); push(icons, p11, 1, 1); + push(icons, p01, -1, 1); + // 代理壳颜色占位(拾取 frag 只写 entityId) + const glm::vec4 any{1.0f}; + const auto pushP = [&](const glm::vec3& p) + { proxies.push_back({p, any, glm::vec4(shape, 0, 0, 0), bits}); }; + pushP(p00); pushP(p10); pushP(p11); + pushP(p00); pushP(p11); pushP(p01); + }; + + // 线段(LineList 成对 push) + auto EmitLine = [&](const glm::vec3& a, const glm::vec3& b, + const glm::vec4& color) + { + if (!Fit(static_cast(wires.size()), 2, kMaxGizmoWireVerts)) + { + truncated = true; + return; + } + wires.push_back({a, color}); + wires.push_back({b, color}); + }; + + // 拾取代理 ribbon:棱边 ab 细分 N 段,每段以段中点为基准的面向 + // 相机 quad(半宽恒定 ~kProxyWidthPx 屏幕像素)。线段在 R32Uint + // 光栅化里点不中,quad 才可拾取 + auto EmitRibbon = [&](const glm::vec3& a, const glm::vec3& b, + u32 bits) + { + const glm::vec3 ab = b - a; + const f32 len = glm::length(ab); + if (len < 1e-4f) return; + const glm::vec3 dir = ab / len; + for (u32 s = 0; s < kRibbonSegsPerEdge; ++s) + { + const f32 t0 = static_cast(s) / kRibbonSegsPerEdge; + const f32 t1 = static_cast(s + 1) / kRibbonSegsPerEdge; + const glm::vec3 m = + a + ab * (0.5f * (t0 + t1)); // 段中点 + const glm::vec3 toCam = camPos - m; + const f32 dist = std::max(glm::length(toCam), 0.1f); + glm::vec3 side = glm::cross(dir, toCam / dist); + const f32 sideLen = glm::length(side); + if (!(sideLen > 1e-6f) || + !(std::isfinite(side.x) && std::isfinite(side.y) && + std::isfinite(side.z))) + { + side = camUp; // 棱正对相机退化:用屏幕 up 兜底 + } + else + { + side /= sideLen; + } + const f32 half = + dist * tanHalf * (kProxyWidthPx / viewHeightPx); + const glm::vec3 halfDir = dir * (len / kRibbonSegsPerEdge * 0.5f); + const glm::vec3 halfSide = side * half; + if (!Fit(static_cast(proxies.size()), 6, + kMaxGizmoProxyVerts)) + { + truncated = true; + return; + } + const glm::vec4 any{1.0f}; + const glm::vec3 p0 = m - halfDir - halfSide; + const glm::vec3 p1 = m + halfDir - halfSide; + const glm::vec3 p2 = m + halfDir + halfSide; + const glm::vec3 p3 = m - halfDir + halfSide; + proxies.push_back({p0, any, glm::vec4(0, 0, 0, 0), bits}); + proxies.push_back({p1, any, glm::vec4(0, 0, 0, 0), bits}); + proxies.push_back({p2, any, glm::vec4(0, 0, 0, 0), bits}); + proxies.push_back({p0, any, glm::vec4(0, 0, 0, 0), bits}); + proxies.push_back({p2, any, glm::vec4(0, 0, 0, 0), bits}); + proxies.push_back({p3, any, glm::vec4(0, 0, 0, 0), bits}); + } + }; + + // 相机实体视锥线框(near 真值 / 远平面可视截断)+ 棱 ribbon。 + // 几何取实体相机自己的 fov/near/orthoSize(Inspector 参数改动 + // 即时反映到线框),aspect 用编辑器视口比例(可视化近似) + auto EmitFrustum = [&](const ECS::TransformComponent& t, + const ECS::CameraComponent& cc, + const glm::vec4& lineColor, u32 bits) + { + const glm::mat4 W = t.World(); + const auto Xform = [&](f32 x, f32 y, f32 z) + { return glm::vec3(W * glm::vec4(x, y, z, 1.0f)); }; + // 本地 -Z 为视线方向(Camera 单一事实源约定) + const f32 camNear = cc.camera.GetNearZ(); + // 可视远平面不小于 near 的 1.5 倍:用户把 near 拖大时防止 + // 远矩形反而落在 near 之前(线框翻转) + const f32 visFar = + glm::max(kGizmoFrustumVisFar, camNear * 1.5f); + f32 nH = 0.0f, vH = 0.0f; + if (cc.camera.GetProjectionMode() == + Camera::ProjectionMode::Orthographic) + { + // 正交视锥 = 长方体:near/远矩形同宽高(无透视收缩) + nH = cc.camera.GetOrthoSize(); + vH = nH; + } + else + { + const f32 tanHalfEnt = + std::tan(glm::radians(cc.camera.GetFovDeg()) * 0.5f); + nH = tanHalfEnt * camNear; + vH = tanHalfEnt * visFar; + } + const f32 nW = nH * aspect; + const f32 vW = vH * aspect; + const glm::vec3 n0 = Xform(-nW, -nH, -camNear); + const glm::vec3 n1 = Xform(nW, -nH, -camNear); + const glm::vec3 n2 = Xform(nW, nH, -camNear); + const glm::vec3 n3 = Xform(-nW, nH, -camNear); + const glm::vec3 f0 = Xform(-vW, -vH, -visFar); + const glm::vec3 f1 = Xform(vW, -vH, -visFar); + const glm::vec3 f2 = Xform(vW, vH, -visFar); + const glm::vec3 f3 = Xform(-vW, vH, -visFar); + // near 矩形 + 可视远矩形 + 4 棱 + 上轴刻度 + EmitLine(n0, n1, lineColor); EmitLine(n1, n2, lineColor); + EmitLine(n2, n3, lineColor); EmitLine(n3, n0, lineColor); + EmitLine(f0, f1, lineColor); EmitLine(f1, f2, lineColor); + EmitLine(f2, f3, lineColor); EmitLine(f3, f0, lineColor); + EmitLine(n0, f0, lineColor); EmitLine(n1, f1, lineColor); + EmitLine(n2, f2, lineColor); EmitLine(n3, f3, lineColor); + EmitLine(Xform(0.0f, nH, -camNear), + Xform(0.0f, nH + 0.3f, -camNear), lineColor); + // 12 棱的拾取 ribbon(图标 quad 之外的可点选面积) + EmitRibbon(n0, n1, bits); EmitRibbon(n1, n2, bits); + EmitRibbon(n2, n3, bits); EmitRibbon(n3, n0, bits); + EmitRibbon(f0, f1, bits); EmitRibbon(f1, f2, bits); + EmitRibbon(f2, f3, bits); EmitRibbon(f3, f0, bits); + EmitRibbon(n0, f0, bits); EmitRibbon(n1, f1, bits); + EmitRibbon(n2, f2, bits); EmitRibbon(n3, f3, bits); + }; + + // 灯光:方向光 = 太阳盘 + 照射方向箭头;点光 = 光晕盘 + //(选中追加衰减半径三正交圆,UE 语义——常显是视觉噪音) + scene.Each( + [&](Entity e, TransformComponent& t, LightComponent& light) + { + if (!light.active) return; + const u32 bits = static_cast(entt::to_integral(e)); + const glm::vec3 pos = t.GetWorldPosition(); + const glm::vec3 rgb(light.color); + const glm::vec4 color = + IsSel(bits) ? kGizmoSelectedColor + : glm::vec4(rgb * (0.6f + 0.4f * light.color.a), + 1.0f); + if (light.type == LightComponent::Type::Point) + { + EmitIcon(pos, kShapePointLight, color, bits, 1.0f); + } + else + { + EmitIcon(pos, kShapeDirLight, color, bits, 1.1f); + glm::vec3 dir = light.direction; + if (glm::length(dir) > 1e-4f) + { + dir = glm::normalize(dir); + const glm::vec4 lineColor = + IsSel(bits) ? kGizmoSelectedColor + : glm::vec4(rgb, 0.9f); + EmitLine(pos, pos + dir * 0.6f, lineColor); + } + } + if (IsSel(bits) && light.type == LightComponent::Type::Point) + { + const glm::vec4 ring = glm::vec4(rgb, 0.35f); + const f32 R = light.radius; + for (u32 s = 0; s < kRadiusCircleSegs; ++s) + { + const f32 a0 = kTwoPi * s / kRadiusCircleSegs; + const f32 a1 = kTwoPi * (s + 1) / + kRadiusCircleSegs; + const glm::vec3 d0(std::cos(a0), std::sin(a0), 0.0f); + const glm::vec3 d1(std::cos(a1), std::sin(a1), 0.0f); + EmitLine(pos + d0 * R, pos + d1 * R, ring); // XY + EmitLine(pos + glm::vec3(d0.x, 0, d0.y) * R, + pos + glm::vec3(d1.x, 0, d1.y) * R, ring); // XZ + EmitLine(pos + glm::vec3(0, d0.y, d0.x) * R, + pos + glm::vec3(0, d1.y, d1.x) * R, ring); // YZ + } + } + }); + + // 相机实体:图标 + 视锥线框(primary 提亮放大) + scene.Each( + [&](Entity e, TransformComponent& t, CameraComponent& cam) + { + if (!cam.active) return; + const u32 bits = static_cast(entt::to_integral(e)); + const bool sel = IsSel(bits); + const glm::vec4 color = + sel ? kGizmoSelectedColor + : glm::vec4(cam.primary ? 1.0f : 0.55f, 0.8f, 1.0f, 1.0f); + EmitIcon(t.GetWorldPosition(), kShapeCamera, color, bits, + cam.primary ? 1.3f : 1.0f); + EmitFrustum(t, cam, sel ? kGizmoSelectedColor + : glm::vec4(0.55f, 0.8f, 1.0f, 0.85f), + bits); + }); + + // 环境组件:位置无关天体的空间锚点标记 + scene.Each( + [&](Entity e, TransformComponent& t, SkyboxComponent& sb) + { + if (!sb.active) return; + const u32 bits = static_cast(entt::to_integral(e)); + EmitIcon(t.GetWorldPosition(), kShapeSkybox, + IsSel(bits) ? kGizmoSelectedColor + : glm::vec4(0.45f, 0.55f, 0.9f, 1.0f), + bits, 1.0f); + }); + scene.Each( + [&](Entity e, TransformComponent& t, SkyLightComponent& sl) + { + if (!sl.active) return; + const u32 bits = static_cast(entt::to_integral(e)); + EmitIcon(t.GetWorldPosition(), kShapeSkyLight, + IsSel(bits) ? kGizmoSelectedColor + : glm::vec4(0.5f, 0.75f, 0.95f, 1.0f), + bits, 1.0f); + }); + + if (truncated && !m_GizmoTruncWarned) + { + m_GizmoTruncWarned = true; + ENGINE_WARN("SceneRenderer: editor gizmo vertex budget " + "exceeded, clamped"); + } + } +#endif + bool SceneRenderer::BuildFrame(ECS::Scene& scene, Assets::AssetManager& assets, vk::Extent2D targetExtent, f32 dt) { @@ -303,19 +502,34 @@ namespace Engine const f32 aspect = static_cast(targetExtent.width) / static_cast(targetExtent.height); ubo.view = camera->GetView(*camTransform); - ubo.proj = camera->GetProjection(aspect, kCameraNear, kCameraFar); + // 投影参数(模式/fov/near/far/orthoSize)由相机自身承载: + // 编辑器形态 = 编辑器相机,游戏形态 = primary 相机实体 + ubo.proj = camera->GetProjection(aspect); // 未抖动投影:velocity 的当前帧 clip 位置用它(抖动不能进 velocity) ubo.projNoJitter = ubo.proj; // 子像素抖动(Halton×8):光栅化采样位置逐帧偏移,TAA 累积去锯齿。 - // NDC 偏移 = 像素偏移 × 2 / 尺寸。TAA 关闭时停抖(直通画面静止) + // NDC 偏移 = 像素偏移 × 2 / 尺寸。TAA 关闭时停抖(直通画面静止)。 + // 抖动分量必须按投影模式选(w_clip 语义不同): + // 透视 w_clip = -z_eye → 加 z 列([2][x]),被 w 除后 NDC 偏移 + // 恒定(z_eye 约掉);正交 w_clip = 1 → z 列偏移会随视图深度 + // 放大(far=10000 时抖动放大上万倍 = 切正交后画面狂震的根因), + // 必须加平移列([3][x]),与 w=1 相乘才是恒定 NDC 偏移 if (m_TAAEnabled) { const u32 idx = static_cast(m_FrameIndex % 8); const f32 jx = Halton(idx + 1, 2) - 0.5f; const f32 jy = Halton(idx + 1, 3) - 0.5f; - ubo.proj[2][0] += jx * 2.0f / static_cast(targetExtent.width); - ubo.proj[2][1] += jy * 2.0f / static_cast(targetExtent.height); + const f32 sx = 2.0f / static_cast(targetExtent.width); + const f32 sy = 2.0f / static_cast(targetExtent.height); + if (camera->GetProjectionMode() == + Camera::ProjectionMode::Orthographic) { + ubo.proj[3][0] += jx * sx; + ubo.proj[3][1] += jy * sy; + } else { + ubo.proj[2][0] += jx * sx; + ubo.proj[2][1] += jy * sy; + } } // ---- 环境组件落帧(UE SkyLight/Skybox Actor 语义):场景环境 @@ -353,6 +567,13 @@ namespace Engine m_Frame.lodEnabled = m_LodEnabled; m_Frame.debugView = m_DebugView; m_Frame.displayMode = m_DisplayMode; +#ifdef HELLO_EDITOR + // 骨骼可视化设置(ImGui 经 Renderer 门面 → 此处落帧) + m_Frame.showBoneWires = m_ShowBoneWires; + m_Frame.boneAllNodes = m_BoneAllNodes; + m_Frame.boneJointPx = m_BoneJointPx; + m_Frame.pieActive = m_GameView; // PIE 态骨骼可视化整体停产 +#endif ubo.cascadeSplits.w = m_ShadowsEnabled ? 1.0f : 0.0f; // 天空盒:视图去平移(只留旋转)× 投影 → 逆矩阵,push 给 Skybox pass; @@ -422,111 +643,37 @@ namespace Engine collected[0].params.x < 0.5f) { shadowDir = glm::vec3(collected[0].direction); } - ComputeCascades(shadowDir); + ComputeCascades(shadowDir, camera->GetNearZ(), camera->GetFarZ()); ubo.cameraPos = glm::vec4(camTransform->GetWorldPosition(), 1.0f); - // ---- 2.5 骨骼动画推进 + 姿态采样(与可见性无关:播放头恒走, - // 否则被剔除的实体回屏时动画跳变)---- - // palette 槽 0 固定 identity:静态模型/无动画实例的蒙皮直通 - m_Frame.skinPalettes.clear(); - m_Frame.skinPalettes.push_back(glm::mat4(1.0f)); - m_Frame.skinOffsets.clear(); - m_Frame.skinPaletteTruncated = false; - m_EntitySkinBase.clear(); - scene.Each( - [&](Entity entity, MeshRendererComponent& mr, AnimationComponent& anim) { - if (!anim.active) return; // 组件禁用 = 暂停采样(播放头不动) - if (!mr.model.Valid()) return; // 未指派/手清 = 无效句柄(Get 对无效 FATAL) - const Model* model = assets.GetModel(mr.model); - if (!model || !model->HasAnimations()) return; - const auto& clips = model->GetClips(); + // ---- 2.5 骨骼动画(调度收口 Animation::AnimationSystem:推进 + // 播放头/crossfade + 姿态求值 + palette 产出。与可见性无关: + // 播放头恒走,否则被剔除的实体回屏时动画跳变)---- + // 渲染侧零动画逻辑:收集段只查 frame.entitySkinBases(查不到 = + // identity 槽 = 非蒙皮静态;蒙皮实体由 AnimationSystem 兜底 + // rest-pose palette,无动画组件也成立) + m_AnimSystem.Update(scene, assets, dt, m_Frame); - // clip 下标合法性(模型资产可能已换) - if (anim.clipIndex < 0 || - anim.clipIndex >= static_cast(clips.size())) { - anim.clipIndex = 0; - anim.fadeFrom = -1; - if (clips.empty()) return; - } - if (anim.fadeFrom >= static_cast(clips.size())) { - anim.fadeFrom = -1; - } - - // ---- 播放头推进(wrap/clamp)---- - if (anim.playing && anim.speed != 0.0f) { - const f32 d = dt * anim.speed; - anim.time += d; - if (anim.fadeFrom >= 0) anim.fadeFromTime += d; - } - const auto wrapTime = [&](f32 t, const AnimationClip& c) { - if (c.duration <= 0.0f) return 0.0f; - return anim.loop ? glm::mod(t, c.duration) - : glm::clamp(t, 0.0f, c.duration - 1e-3f); - }; - const AnimationClip& cur = clips[anim.clipIndex]; - anim.time = wrapTime(anim.time, cur); - const AnimationClip* src = nullptr; - f32 srcTime = 0.0f; - f32 wCur = 1.0f; // 当前 clip 权重(crossfade 0→1) - if (anim.fadeFrom >= 0) { - // 淡出计时(暂停时冻结,与播放头一致) - if (anim.playing) anim.fadeElapsed += dt; - const f32 f = glm::min( - anim.fadeElapsed / glm::max(anim.fadeDuration, 1e-4f), - 1.0f); - wCur = f * f * (3.0f - 2.0f * f); // smoothstep - if (f >= 1.0f) { - anim.fadeFrom = -1; // 过渡完成 - } else { - src = &clips[anim.fadeFrom]; - anim.fadeFromTime = wrapTime(anim.fadeFromTime, *src); - srcTime = anim.fadeFromTime; - } - } - - // ---- 采样 + 局部 TRS 层混合 → global → palette ---- - SampleLocalPose(*model, cur, anim.time, - m_PosePosA, m_PoseRotA, m_PoseSclA, m_TouchedA); - if (src) { - SampleLocalPose(*model, *src, srcTime, - m_PosePosB, m_PoseRotB, m_PoseSclB, - m_TouchedB); - const size_t n = m_PosePosA.size(); - for (size_t i = 0; i < n; ++i) { - // touched = 两层轨道的并集(重建集合) - m_TouchedA[i] = static_cast( - m_TouchedA[i] | m_TouchedB[i]); - m_PosePosA[i] = glm::mix(m_PosePosB[i], m_PosePosA[i], wCur); - m_PoseSclA[i] = glm::mix(m_PoseSclB[i], m_PoseSclA[i], wCur); - // 旋转必须 slerp(lerp 单位四元数会走样) - m_PoseRotA[i] = glm::slerp(m_PoseRotB[i], m_PoseRotA[i], wCur); - } - } - ComposeGlobals(*model, m_PosePosA, m_PoseRotA, m_PoseSclA, - m_TouchedA, m_Globals); - - // palette bump 分配(按实体,不按模型去重——同模型两实体 - // 可不同播放状态)。预算打满 → 该实体退回绑定姿势 - const auto& bones = model->GetBones(); - const u32 base = static_cast(m_Frame.skinPalettes.size()); - if (base + bones.size() > kMaxSkinMatrices) { - m_Frame.skinPaletteTruncated = true; - if (!m_SkinTruncWarned) { - m_SkinTruncWarned = true; - ENGINE_WARN("GPUDriven: skin palette full ({}), " - "animated entities fall back to bind pose", - kMaxSkinMatrices); - } - return; - } - for (const auto& b : bones) { - const u32 ni = b.nodeIdx < m_Globals.size() - ? b.nodeIdx : 0; - m_Frame.skinPalettes.push_back(m_Globals[ni] * b.invBind); - } - const u32 entKey = static_cast(entt::to_integral(entity)); - m_EntitySkinBase[entKey] = base; - }); +#ifdef HELLO_EDITOR + // ---- 2.7 Editor gizmo 收集(编辑形态专属;纯 CPU 只读场景)。 + // billboard right/up 从视图矩阵行向量提取(相机无缩放)---- + if (m_DisplayMode == kDisplayViewport && !m_GameView) { + CollectEditorGizmos(scene, camTransform->GetWorldPosition(), + glm::vec3(ubo.view[0][0], ubo.view[1][0], ubo.view[2][0]), + glm::vec3(ubo.view[0][1], ubo.view[1][1], ubo.view[2][1]), + aspect, static_cast(targetExtent.height), + std::tan(glm::radians( + m_EditorCamera.GetController().GetFovDeg()) * 0.5f)); + } else { + m_Frame.gizmoIconVerts.clear(); + m_Frame.gizmoWireVerts.clear(); + m_Frame.gizmoProxyVerts.clear(); + // 骨骼可视化数组与 gizmo 同批清(AnimationSystem.Update 开头 + // 也会清,此处双保险:PIE 态/全屏直渲绝无残留绘制) + m_Frame.boneWireVerts.clear(); + m_Frame.boneJoints.clear(); + } +#endif // ---- 3. GPUDriven:单趟收集 + 多视图剔除 ---- // 同作用域第二个 zone:ZoneScopedN 变量名固定会重声明,用 ZoneNamedN @@ -576,10 +723,12 @@ namespace Engine } m_CurWorlds.emplace_back(entKey, t.World()); - // palette 基址(第 2.5 节预采样;无动画实体 = identity 槽) + // palette 基址(AnimationSystem 预采样:动画实体=当帧 + // 姿态、蒙皮无动画实体=rest 兜底;查不到=identity 槽, + // 只可能是非蒙皮静态模型) u32 skinBase = kIdentityPaletteSlot; - if (const auto it = m_EntitySkinBase.find(entKey); - it != m_EntitySkinBase.end()) { + if (const auto it = m_Frame.entitySkinBases.find(entKey); + it != m_Frame.entitySkinBases.end()) { skinBase = it->second; } @@ -606,12 +755,38 @@ namespace Engine } if (!anyVis) return; - for (auto& mesh : model->GetMeshes()) { + const auto& meshes = model->GetMeshes(); + for (size_t meshIdx = 0; meshIdx < meshes.size(); ++meshIdx) { + auto& mesh = meshes[meshIdx]; const auto& bounds = mesh->GetBounds(); const glm::vec3 worldCenter = glm::vec3(t.World() * glm::vec4(bounds.center, 1.0f)); const f32 worldRadius = bounds.radius * worldScale; + // ---- 实体级材质覆盖 resolve(UE Material Slot)---- + // 覆盖路径命中已加载资产 → 用其 GPU 材质;未加载/ + // 解析失败回退内建(一次性告警)。收集期只查缓存 + // 不建 GPU 资源——加载点收敛在场景预载(游戏形态 + // 唯一加载点)与编辑器指派(UI 只是编辑接口) + Material* resolved = mesh->GetMaterial(); + if (!mr.materialOverrides.empty()) { + if (const auto oIt = mr.materialOverrides.find( + static_cast(meshIdx)); + oIt != mr.materialOverrides.end()) { + if (MaterialAsset* asset = + assets.FindMaterialAsset(oIt->second); + asset != nullptr) { + resolved = asset->GetMaterial(); + } + else if (m_WarnedMaterialMisses.insert( + oIt->second).second) { + ENGINE_WARN("GPUDriven: material asset " + "'{}' not loaded, falling back " + "to built-in", oIt->second); + } + } + } + // LOD 选级:屏占比近似 = radius/距离(主视图按实例距离; // 阈值 10/20 倍半径,可调)。开关关闭 = 恒 LOD0 const f32 dist = @@ -634,20 +809,38 @@ namespace Engine instanceTruncated = true; return; } - // batch 查找 = 数组寻址(Mesh::GetSlot 槽位 + LOD 槽) - // batchIdx 存储 +1 偏移(0 = 该槽位尚无 batch) - const u32 slot = mesh->GetSlot(); - if (slot >= s.lookup.size()) { - s.lookup.resize(slot + 1); + // batch 查找:内建材质 = 数组寻址快路径 + //(Mesh::GetSlot 槽位 + LOD 槽);覆盖材质 = + // hash 寻址(键含材质变体:同 mesh 不同覆盖天然 + // 分批,同覆盖多实体仍合并——材质资产指针仅帧内 + // 有效,变体表每帧随 ViewScratch::Clear 清空) + const bool builtin = (resolved == mesh->GetMaterial()); + std::array* lodSlots = nullptr; + if (builtin) { + const u32 slot = mesh->GetSlot(); + if (slot >= s.lookup.size()) { + s.lookup.resize(slot + 1); + } + lodSlots = &s.lookup[slot]; } - auto& lodSlots = s.lookup[slot]; - u32 batchIdx = lodSlots[lod]; + else { + const auto [vIt, inserted] = s.matVariants.emplace( + resolved, + static_cast(s.matVariants.size())); + const u64 key = + (static_cast(mesh->GetSlot()) << 32) | + static_cast(vIt->second); + lodSlots = &s.ovrLookup[key]; + } + // batchIdx 存储 +1 偏移(0 = 该槽位尚无 batch) + u32 batchIdx = (*lodSlots)[lod]; if (batchIdx == 0) { batchIdx = static_cast(s.result.batches.size()) + 1; - lodSlots[lod] = batchIdx; + (*lodSlots)[lod] = batchIdx; // first 是占位:真实基址在 appendView 聚合时 // 回填(收集序下 batch 实例散布,见链表注释) - s.result.batches.push_back({mesh.get(), 0, 0, lod}); + s.result.batches.push_back( + {mesh.get(), resolved, 0, 0, lod}); s.batchDistSum.push_back(0.0f); s.batchHead.push_back(ViewScratch::kNoInstance); } @@ -668,9 +861,10 @@ namespace Engine }; // 透明材质 → 独立队列(主视图,混合绘制 + 排序; - // 不进阴影——alpha 阴影复杂度高收益低,注释后续演进) - const bool isTransparentMesh = - mesh->GetMaterial()->IsTransparent(); + // 不进阴影——alpha 阴影复杂度高收益低,注释后续演进)。 + // 队列归属按 resolve 后的实际材质:同一 mesh 可因 + // 覆盖不同而分属不透明/透明两队列 + const bool isTransparentMesh = resolved->IsTransparent(); ViewScratch& mainTarget = isTransparentMesh ? m_TranslucentScratch : m_MainScratch; @@ -851,16 +1045,16 @@ namespace Engine const u64 vb = reinterpret_cast(static_cast( b.mesh->GetVertexBuffer())); if (va != vb) return va < vb; - if (byMaterial && a.mesh->GetMaterial() != b.mesh->GetMaterial()) { - return reinterpret_cast(a.mesh->GetMaterial()) < - reinterpret_cast(b.mesh->GetMaterial()); + if (byMaterial && a.material != b.material) { + return reinterpret_cast(a.material) < + reinterpret_cast(b.material); } return a.mesh->GetSlot() < b.mesh->GetSlot(); }); for (const auto& b : view.batches) { const vk::Buffer vb = b.mesh->GetVertexBuffer(); - Material* mat = byMaterial ? b.mesh->GetMaterial() : nullptr; + Material* mat = byMaterial ? b.material : nullptr; bool newGroup = view.indirectGroups.empty(); if (!newGroup) { diff --git a/Engine/src/Renderer/VulkanAssetUploader.cpp b/Engine/src/Renderer/VulkanAssetUploader.cpp index 09a307c..7986b4e 100644 --- a/Engine/src/Renderer/VulkanAssetUploader.cpp +++ b/Engine/src/Renderer/VulkanAssetUploader.cpp @@ -5,9 +5,67 @@ #include #include #include +#include +#include +#include + +#include +#include namespace Engine { + VulkanAssetUploader::~VulkanAssetUploader() = default; + + void VulkanAssetUploader::InitSharedDefaultTextures() + { + if (m_DefaultTextures[0]) return; // 已初始化 + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + m_DefaultTextures[slot] = CreateDefaultMaterialTexture( + m_Device, m_Allocator, static_cast(slot)); + } + } + + Scope VulkanAssetUploader::CreateMaterial( + MaterialAssetData&& data) + { + InitSharedDefaultTextures(); + + // 每槽解析:路径(CWD 相对,反斜杠归一,与 Model::GetOrLoadTexture + // 同语义)→ 独占纹理;空/缺失 → 共享默认纹理(非拥有) + std::array, Material::kSlotCount> owned; + std::array bound{}; + // 槽位 sRGB 语义:颜色图 true,数据图 false(与 Model 同表) + const bool slotSrgb[Material::kSlotCount] = {true, false, false, + true, false, false}; + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + std::string path = data.texPaths[slot]; + std::replace(path.begin(), path.end(), '\\', '/'); + if (!path.empty() && std::filesystem::exists(path)) { + owned[slot] = CreateScope( + m_Device, m_Allocator, path, slotSrgb[slot]); + bound[slot] = owned[slot].get(); + } + else { + if (!path.empty()) { + ENGINE_WARN("MaterialAsset: texture not found '{}', " + "slot {} falls back to default", path, slot); + } + bound[slot] = m_DefaultTextures[slot].get(); + } + } + + auto mat = CreateScope( + m_Device, m_Allocator, m_Descriptors, m_TextureLayout, + bound, data.params, data.transparent); + return CreateScope(std::move(data), std::move(owned), + std::move(mat)); + } + + void VulkanAssetUploader::WaitDeviceIdle() + { + m_Device.GetDevice().waitIdle(); + } + Scope VulkanAssetUploader::LoadModel(const std::string& path) { // GPU 上传在此发生:解析(assimp)+ buffer/image 创建一步完成 diff --git a/Engine/src/Renderer/VulkanAssetUploader.h b/Engine/src/Renderer/VulkanAssetUploader.h new file mode 100644 index 0000000..9cfddef --- /dev/null +++ b/Engine/src/Renderer/VulkanAssetUploader.h @@ -0,0 +1,63 @@ +// +// Created by atdunbg on 2026/9/23. +// +// VulkanAssetUploader:IAssetUploader 的渲染层实现。 +// +// 属于渲染层对资产层提供的服务:持有 GPU 上下文,负责把解析后的 +// 资产数据创建为 Vulkan 资源。资产层(AssetManager)只看到接口, +// 不知道 Device/Allocator 的存在——依赖方向:Assets → IAssetUploader +// ← VulkanAssetUploader(渲染层),永远不出现 Assets → Vulkan 具体。 +// + +#ifndef HELLOVULKAN_VULKANASSETUPLOADER_H +#define HELLOVULKAN_VULKANASSETUPLOADER_H + +#include + +namespace Engine { + + class Device; + class MemoryAllocator; + class DescriptorManager; + class IblGenerator; + + class VulkanAssetUploader final : public Assets::IAssetUploader { + public: + VulkanAssetUploader(Device& device, MemoryAllocator& allocator, + DescriptorManager& descriptors, + vk::DescriptorSetLayout textureLayout, + IblGenerator& ibl) + : m_Device(device), m_Allocator(allocator), + m_Descriptors(descriptors), m_TextureLayout(textureLayout), + m_Ibl(ibl) {} + + Scope LoadModel(const std::string& path) override; + Scope LoadModel(const std::string& path, + ParsedModel&& parsed) override; + Scope CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) override; + Scope CreateTransparentPlane(f32 halfSize, + u8 r, u8 g, u8 b, u8 a) override; + Scope LoadEnvironment(const std::string& path) override; + Scope LoadEnvironment(const std::string& path, + DecodedEquirect&& decoded) override; + Scope CreateMaterial( + MaterialAssetData&& data) override; + void WaitDeviceIdle() override; + + private: + // 惰性创建的共享默认纹理集(空槽/加载失败兜底;全体材质资产 + // 共用一份,非拥有引用进各 MaterialAsset) + void InitSharedDefaultTextures(); + + Device& m_Device; + MemoryAllocator& m_Allocator; + DescriptorManager& m_Descriptors; + vk::DescriptorSetLayout m_TextureLayout; + IblGenerator& m_Ibl; // 环境 IBL 预积分的 compute 生成端 + + std::array, Material::kSlotCount> m_DefaultTextures; + }; + +} // namespace Engine + +#endif //HELLOVULKAN_VULKANASSETUPLOADER_H diff --git a/Engine/src/Serialization/SceneSerializer.cpp b/Engine/src/Serialization/SceneSerializer.cpp index be4f926..c3f32c7 100644 --- a/Engine/src/Serialization/SceneSerializer.cpp +++ b/Engine/src/Serialization/SceneSerializer.cpp @@ -169,6 +169,25 @@ namespace Engine::Serialization { return ref; } + // 材质覆盖路径解析:guid 反查优先(资产移动不断引用),path + // 兜底;解析成功即预载——"收集期只查缓存"不变量在此保证 + //(游戏形态无编辑器,场景读后预载是唯一自动加载点),未加载 + // 成功也返回路径(收集期回退内建材质,不丢实体) + std::string ResolveMaterialPath(Assets::AssetManager& assets, + u64 guid, const std::string& path) + { + std::string resolved; + if (guid != 0) resolved = assets.ResolveGUID(guid); + if (resolved.empty()) resolved = path; + if (resolved.empty()) return {}; + + if (!assets.IsMaterialLoaded(resolved)) { + assets.LoadMaterialAsset(resolved); + } + assets.GetOrCreateGUID(resolved); + return resolved; + } + // ---- 中间结构:解析全部成功才碰 registry ---- // comps 持 Components 节点(仅在键存在且为 map 时赋值—— // yaml-cpp 新版对僵尸节点做 operator= 会抛异常) @@ -229,6 +248,25 @@ namespace Engine::Serialization { out << YAML::Key << "Path" << YAML::Value << path; out << YAML::EndMap; // Model(flow) out << YAML::Key << "Active" << YAML::Value << mr->active; + // 材质覆盖表(UE Material Slot):seq of {Slot,Guid,Path} + if (!mr->materialOverrides.empty()) { + out << YAML::Key << "MaterialOverrides" + << YAML::Value << YAML::BeginSeq; + for (const auto& [slot, mpath] : + mr->materialOverrides) { + out << YAML::BeginMap; + out << YAML::Key << "Slot" << YAML::Value << slot; + if (const u64 guid = assets.GetOrCreateGUID(mpath); + guid != 0) { + out << YAML::Key << "Guid" + << YAML::Value << guid; + } + out << YAML::Key << "Path" + << YAML::Value << mpath; + out << YAML::EndMap; + } + out << YAML::EndSeq; + } out << YAML::EndMap; // MeshRenderer } else { @@ -244,7 +282,31 @@ namespace Engine::Serialization { out << YAML::Key << "Playing" << YAML::Value << a->playing; out << YAML::Key << "Loop" << YAML::Value << a->loop; out << YAML::Key << "Speed" << YAML::Value << a->speed; + out << YAML::Key << "FadeDuration" << YAML::Value + << a->fadeDuration; out << YAML::Key << "Active" << YAML::Value << a->active; + // Controller 模式(Unity Animator 语义):mode/路径/参数 + // 实例值落盘;状态机播放头瞬态不落盘(重启从 entry 开始) + out << YAML::Key << "Mode" << YAML::Value + << static_cast(a->mode); + out << YAML::Key << "ControllerPath" << YAML::Value + << a->controllerPath; + if (!a->floatParams.empty()) { + out << YAML::Key << "FloatParams" << YAML::Value + << YAML::BeginMap; + for (const auto& [k, v] : a->floatParams) { + out << YAML::Key << k << YAML::Value << v; + } + out << YAML::EndMap; + } + if (!a->boolParams.empty()) { + out << YAML::Key << "BoolParams" << YAML::Value + << YAML::BeginMap; + for (const auto& [k, v] : a->boolParams) { + out << YAML::Key << k << YAML::Value << v; + } + out << YAML::EndMap; + } // 外部动画源(编辑器 attach 列表):clip 本体运行时并入 // Model 资产不落盘,加载端按此列表幂等重放 out << YAML::Key << "Attached" << YAML::Value << YAML::Flow @@ -261,6 +323,17 @@ namespace Engine::Serialization { << YAML::BeginMap; out << YAML::Key << "Primary" << YAML::Value << c->primary; out << YAML::Key << "Active" << YAML::Value << c->active; + // 投影参数(Camera 成员):老场景缺键时读侧回退默认值 + out << YAML::Key << "ProjectionMode" << YAML::Value + << static_cast(c->camera.GetProjectionMode()); + out << YAML::Key << "FovDeg" << YAML::Value + << c->camera.GetFovDeg(); + out << YAML::Key << "NearZ" << YAML::Value + << c->camera.GetNearZ(); + out << YAML::Key << "FarZ" << YAML::Value + << c->camera.GetFarZ(); + out << YAML::Key << "OrthoSize" << YAML::Value + << c->camera.GetOrthoSize(); out << YAML::EndMap; } @@ -598,8 +671,25 @@ namespace Engine::Serialization { ref.Valid()) { handle.AddComponent( ECS::MeshRendererComponent{ref}); - handle.GetComponent().active = + auto& mrComp = + handle.GetComponent(); + mrComp.active = n["Active"].as(true); // 缺省 true 向前兼容 + // 材质覆盖表:逐项解析(guid 反查 → path 兜底) + // 并预载;加载失败保留路径(收集期回退内建材质) + if (const YAML::Node ovs = n["MaterialOverrides"]) { + for (const auto& ov : ovs) { + const u32 slot = ov["Slot"].as( + 0xFFFFFFFFu); + const auto opath = ResolveMaterialPath( + assets, ov["Guid"].as(0), + ov["Path"].as("")); + if (slot == 0xFFFFFFFFu || opath.empty()) { + continue; + } + mrComp.materialOverrides[slot] = opath; + } + } } else { ENGINE_WARN("SceneSerializer: model unresolved " @@ -616,7 +706,33 @@ namespace Engine::Serialization { a.playing = n["Playing"].as(a.playing); a.loop = n["Loop"].as(a.loop); a.speed = n["Speed"].as(a.speed); + a.fadeDuration = + n["FadeDuration"].as(a.fadeDuration); // 缺键回退 0.3 a.active = n["Active"].as(true); + // Controller 模式缺键回退 Direct(老场景零迁移); + // mode 越界值也视为 Direct + if (const int m = n["Mode"].as(0); m == 1) { + a.mode = ECS::AnimationComponent::AnimMode::Controller; + } + a.controllerPath = n["ControllerPath"].as(""); + // Controller 预载(收集期只查缓存的不变量在此保证; + // 解析失败保留路径,System Find 落空 → rest 兜底) + if (!a.controllerPath.empty() && + !assets.IsControllerLoaded(a.controllerPath)) { + assets.LoadController(a.controllerPath); + } + if (const YAML::Node fp = n["FloatParams"]) { + for (const auto& it : fp) { + a.floatParams[it.first.as("")] = + it.second.as(0.0f); + } + } + if (const YAML::Node bp = n["BoolParams"]) { + for (const auto& it : bp) { + a.boolParams[it.first.as("")] = + it.second.as(false); + } + } if (const YAML::Node att = n["Attached"]) { for (const auto& it : att) { a.attachedAnimPaths.push_back( @@ -629,6 +745,18 @@ namespace Engine::Serialization { auto& c = handle.AddComponent(); c.primary = n["Primary"].as(false); c.active = n["Active"].as(true); + // 投影参数缺省回退默认值(Version 向后兼容:老场景无新键 + // 可载)。ProjectionMode 越界值视为透视 + if (const int pm = n["ProjectionMode"].as(0); + pm == 1) { + c.camera.SetProjectionMode( + Engine::Camera::ProjectionMode::Orthographic); + } + c.camera.SetFovDeg(n["FovDeg"].as(c.camera.GetFovDeg())); + c.camera.SetClip(n["NearZ"].as(c.camera.GetNearZ()), + n["FarZ"].as(c.camera.GetFarZ())); + c.camera.SetOrthoSize( + n["OrthoSize"].as(c.camera.GetOrthoSize())); } if (const YAML::Node n = comps["Light"]) { diff --git a/Engine/src/Vulkan/Texture.cpp b/Engine/src/Vulkan/Texture.cpp index 8fe8223..ed58ced 100644 --- a/Engine/src/Vulkan/Texture.cpp +++ b/Engine/src/Vulkan/Texture.cpp @@ -72,6 +72,27 @@ namespace Engine {r, g, b, a}, srgb)); } + Scope Texture::CreateFromEncodedMemory( + Device& device, MemoryAllocator& allocator, + const void* data, u32 bytes, bool srgb) + { + int width = 0, height = 0, channels = 0; + stbi_uc* pixels = stbi_load_from_memory( + static_cast(data), static_cast(bytes), + &width, &height, &channels, STBI_rgb_alpha); + if (!pixels) { + return {}; + } + // 两段式:ProceduralTag ctor 先落 1x1 占位(确定 device/allocator + // 成员),随即 InitFromPixels 重走公共上传路径传真实像素 + Scope tex(new Texture(device, allocator, ProceduralTag{}, + {0, 0, 0, 255}, srgb)); + tex->InitFromPixels(pixels, static_cast(width), + static_cast(height), srgb); + stbi_image_free(pixels); + return tex; + } + void Texture::InitFromPixels(const void* pixels, u32 width, u32 height, bool srgb) { m_Width = width; diff --git a/Engine/src/Vulkan/UniformBuffer.cpp b/Engine/src/Vulkan/UniformBuffer.cpp index 7a84451..860746b 100644 --- a/Engine/src/Vulkan/UniformBuffer.cpp +++ b/Engine/src/Vulkan/UniformBuffer.cpp @@ -31,4 +31,10 @@ namespace Engine { m_Buffers[frameSlot]->Upload(data, size); } + void UniformBuffer::UpdateAt(const u32 frameSlot, const u64 offset, + const void* data, const u64 size) const + { + m_Buffers[frameSlot]->Upload(data, size, offset); + } + } // namespace Engine diff --git a/shaders/gizmo_billboard.frag b/shaders/gizmo_billboard.frag new file mode 100644 index 0000000..a09ecad --- /dev/null +++ b/shaders/gizmo_billboard.frag @@ -0,0 +1,84 @@ +#version 450 + +// Editor gizmo 图标片元:按 aux.x(形状 id,与 SceneRenderer 收集常量 +// 一一对应)在 uv ∈ [-1,1] 上做程序化 SDF 造型,预乘 alpha 输出 +//(pass 侧混合 ONE, ONE_MINUS_SRC_ALPHA)。 +// 0 方向光(太阳盘 + 8 向光芒) +// 1 点光(亮核 + 柔和光晕) +// 2 相机(机身挖孔 + 镜头环 + 取景器凸块) +// 3 天空盒(等距立方线框) +// 4 天空光(环 + 内核点) + +layout(location = 0) in vec4 inColor; +layout(location = 1) in vec4 inAux; + +layout(location = 0) out vec4 outColor; + +// 方框 SDF +float sdBox(vec2 p, vec2 b) { + vec2 d = abs(p) - b; + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); +} + +// 线段 SDF +float sdSegment(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); +} + +// 等边六边形 SDF(circumradius r,iq 惯用式) +float sdHexagon(vec2 p, float r) { + const vec3 k = vec3(-0.866025404, 0.5, 0.577350269); + p = abs(p); + p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy; + p -= vec2(clamp(p.x, -k.z * r, k.z * r), r); + return length(p) * sign(p.y); +} + +float ShapeSDF(vec2 p, int id) { + if (id == 0) { // 方向光:太阳盘 + 8 向光芒 + float r = length(p); + float disc = r - 0.38; + float a = atan(p.y, p.x); + float s45 = 0.78539816; // 45° 扇区 + float fa = abs(mod(a + s45 * 0.5, s45) - s45 * 0.5); + float ray = max(r * sin(fa), abs(r - 0.58) - 0.20); + return min(disc, ray); + } + if (id == 1) return length(p) - 0.22; // 点光亮核(光晕在 main) + if (id == 2) { // 相机 + float body = sdBox(p - vec2(0.05, 0.0), vec2(0.50, 0.36)); + float hole = max(body, -(length(p - vec2(0.05, 0.0)) - 0.20)); + float ring = abs(length(p - vec2(0.05, 0.0)) - 0.28) - 0.035; + float vf = sdBox(p - vec2(0.30, 0.44), vec2(0.14, 0.10)); + return min(min(hole, ring), vf); + } + if (id == 3) { // 天空盒:等距立方线框 + float hex = abs(sdHexagon(p, 0.72)) - 0.045; + float e1 = sdSegment(p, vec2(0.0), vec2(0.0, -0.72)) - 0.045; + float e2 = sdSegment(p, vec2(0.0), vec2(-0.6235, 0.36)) - 0.045; + float e3 = sdSegment(p, vec2(0.0), vec2(0.6235, 0.36)) - 0.045; + return min(hex, min(e1, min(e2, e3))); + } + // 天空光(id == 4):环 + 内核点 + return min(abs(length(p) - 0.52) - 0.09, length(p) - 0.14); +} + +void main() { + vec2 uv = inAux.yz; + int id = int(inAux.x + 0.5); + // uv 空间的像素级边缘宽度(quad 上 uv 随屏幕像素线性变化) + float aa = fwidth(uv.x) * 1.5 + 1e-4; + + float alpha = smoothstep(aa, -aa, ShapeSDF(uv, id)); + + if (id == 1) { // 点光:核心外叠加径向光晕 + float r = length(uv); + float halo = (1.0 - smoothstep(0.25, 1.0, r)) * 0.35; + alpha = max(alpha, halo); + } + + // 预乘 alpha:ONE / ONE_MINUS_SRC_ALPHA 混合下的正确形式 + outColor = vec4(inColor.rgb * alpha, alpha); +} diff --git a/shaders/gizmo_billboard.vert b/shaders/gizmo_billboard.vert new file mode 100644 index 0000000..258dd37 --- /dev/null +++ b/shaders/gizmo_billboard.vert @@ -0,0 +1,22 @@ +#version 450 + +// Editor gizmo billboard 顶点:世界空间 billboard quad(CPU 侧已面向相机 +// 展开),无描述符 set——viewProj 走 push constant(与 debug_line 同约定)。 +// aux = (shapeId, uv.x, uv.y, 0),uv ∈ [-1,1] 供片元 SDF 造型。 + +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec4 inColor; +layout(location = 2) in vec4 inAux; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outAux; + +layout(push_constant) uniform PC { + mat4 viewProj; +} pc; + +void main() { + gl_Position = pc.viewProj * vec4(inPos, 1.0); + outColor = inColor; + outAux = inAux; +} diff --git a/shaders/gizmo_pick.frag b/shaders/gizmo_pick.frag new file mode 100644 index 0000000..f324a65 --- /dev/null +++ b/shaders/gizmo_pick.frag @@ -0,0 +1,12 @@ +#version 450 + +// Editor gizmo 拾取片元:实体 id 原样写 R32Uint(与 hitproxy.frag 同 +// 语义)。flat = 无插值,像素值即命中图元的实体位模式。 + +layout(location = 0) flat in uint inEntityId; + +layout(location = 0) out uvec4 outColor; + +void main() { + outColor = uvec4(inEntityId, 0u, 0u, 0u); +} diff --git a/shaders/gizmo_pick.vert b/shaders/gizmo_pick.vert new file mode 100644 index 0000000..e8deac6 --- /dev/null +++ b/shaders/gizmo_pick.vert @@ -0,0 +1,21 @@ +#version 450 + +// Editor gizmo 拾取顶点:与 billboard 共享同一 ring VB(pos/color/aux +// + entityId 四属性流),画进 HitProxy 的 R32Uint 目标。只有 entityId +// 参与变换输出,color/aux 声明占位(属性流绑定时全开)。 + +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec4 inColor; // 占位(拾取不用) +layout(location = 2) in vec4 inAux; // 占位 +layout(location = 3) in uint inEntityId; + +layout(location = 0) flat out uint outEntityId; + +layout(push_constant) uniform PC { + mat4 viewProj; +} pc; + +void main() { + gl_Position = pc.viewProj * vec4(inPos, 1.0); + outEntityId = inEntityId; +} diff --git a/shaders/simple_shader.frag b/shaders/simple_shader.frag index 2a064e5..04c1978 100644 --- a/shaders/simple_shader.frag +++ b/shaders/simple_shader.frag @@ -44,8 +44,21 @@ layout(set = 1, binding = 5) uniform sampler2D roughnessMap; // 独立粗糙度 layout(set = 1, binding = 6) uniform MaterialParams { vec4 factors; // x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关 vec4 baseColor; // glTF baseColorFactor:rgb 调色 albedo,a = BLEND 透明度 + vec4 surface; // x = ior(F0 换算),y = transmission(不透明段恒 0 语义),z = 自发光强度,w = 体积厚度 + vec4 misc; // x = unlit(KHR_materials_unlit,1 = 早退直出 albedo) + vec4 uvA[6]; // 每槽 UV 线性变换(KHR_texture_transform,v-up 空间)(a11,a12,a21,a22),槽序 = binding + vec4 uvT[6]; // 每槽 UV 平移 (tx, ty, 0, 0) } matParams; +// 每槽 UV 变换:uv' = A·uv + t(identity 时退化为直通)。slot 必须 +// 字面量调用点(编译期展开索引);6 = Material::kSlotCount +vec2 ApplyUv(int slot, vec2 uv) { + vec4 a = matParams.uvA[slot]; + vec4 t = matParams.uvT[slot]; + return vec2(a.x * uv.x + a.y * uv.y + t.x, + a.z * uv.x + a.w * uv.y + t.y); +} + // CSM 阴影(sampler2DShadow:单次采样 = 硬件 2×2 深度比较 PCF) layout(set = 2, binding = 0) uniform sampler2DShadow shadowMap0; // CSM 级联 0(最近) layout(set = 2, binding = 1) uniform sampler2DShadow shadowMap1; // 级联 1 @@ -136,23 +149,40 @@ vec3 FresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) { } void main() { - vec4 albedoTex = texture(albedoMap, fragUV); + vec4 albedoTex = texture(albedoMap, ApplyUv(0, fragUV)); // alphaMode=MASK 镂空(表针/栅栏等硬边贴图):低于阈值的纹素直接丢弃 if (matParams.factors.w > 0.5 && albedoTex.a < 0.5) discard; vec3 albedo = albedoTex.rgb * matParams.baseColor.rgb; - vec4 mr = texture(metallicRoughnessMap, fragUV); + // KHR_materials_unlit:无光照语义(文字标签/示意图),albedo 直出 + if (matParams.misc.x > 0.5) { + outColor = vec4(albedo, albedoTex.a * matParams.baseColor.a); + outVelocity = fragVelocity; + return; + } + vec4 mr = texture(metallicRoughnessMap, ApplyUv(2, fragUV)); // 粗糙度源按 factors.z 切换:glTF 合图工作流读 MR.g; // FBX/OBJ specular 工作流(z=1)换用独立粗糙度图 R 通道 - float roughness = clamp(mix(mr.g, texture(roughnessMap, fragUV).r, + float roughness = clamp(mix(mr.g, texture(roughnessMap, ApplyUv(5, fragUV)).r, matParams.factors.z) * matParams.factors.y, 0.045, 1.0); float metallic = clamp(mr.b * matParams.factors.x, 0.0, 1.0); - vec3 emissive = texture(emissiveMap, fragUV).rgb; + // 自发光强度(KHR_materials_emissive_strength,缺省 1) + vec3 emissive = texture(emissiveMap, ApplyUv(3, fragUV)).rgb * matParams.surface.z; - vec3 N = MappedNormal(normalize(fragNormal), fragTangent, fragUV); + vec3 N = MappedNormal(normalize(fragNormal), fragTangent, ApplyUv(1, fragUV)); vec3 V = normalize(ubo.cameraPos.xyz - fragWorldPos); + // 双面法线:cull 关闭 + glTF doubleSided=true,背面片元(表壳后盖/ + // 内壁等从背面看到的表面)几何法线背向相机,NdotL≤0 → 直接光全灭、 + // IBL 采错半球 = 纯黑壳(three.js DoubleSide 对背面自动翻法线, + // 这里对齐该语义;与 translucent shader 的翻转同款) + if (dot(N, V) < 0.0) N = -N; float NdotV = max(dot(N, V), 1e-4); - vec3 F0 = mix(vec3(0.04), albedo, metallic); // 电介质基准反射率 4% + // F0 按折射率换算(KHR_materials_ior):((ior-1)/(ior+1))²; + // glTF 缺省 ior=1.5 → 0.04,与旧固定值一致 + float ior = max(matParams.surface.x, 1.0); + vec3 F0 = mix(vec3((ior - 1.0) / (ior + 1.0) + * (ior - 1.0) / (ior + 1.0)), + albedo, metallic); // 电介质 F0 / 金属 albedo vec3 Lo = vec3(0.0); float shade0 = 1.0f; // 第 0 盏光的阴影因子(环境光同步衰减用) @@ -213,7 +243,7 @@ void main() { vec3 kD_amb = (1.0 - F_amb) * (1.0 - metallic); vec3 irradiance = texture(irradianceMap, N).rgb; - float ao = texture(aoMap, fragUV).r; + float ao = texture(aoMap, ApplyUv(4, fragUV)).r; vec3 diffuse = kD_amb * irradiance * albedo * ao; vec3 R = reflect(-V, N); @@ -229,5 +259,7 @@ void main() { // alpha = albedo 贴图覆盖率 × baseColorFactor.a(glTF alphaMode=BLEND // 混合源);不透明资产两者恒 1 行为不变。预乘在 tonemap 段完成。 outColor = vec4(ambient + Lo + emissive, albedoTex.a * matParams.baseColor.a); + // ==== DEBUG B1: 纯 albedo 直出(验证贴图绑定/UV 采样)==== + // outColor = vec4(albedo, 1.0); outVelocity = fragVelocity; } diff --git a/shaders/simple_shader_translucent.frag b/shaders/simple_shader_translucent.frag index ea818bd..78c9338 100644 --- a/shaders/simple_shader_translucent.frag +++ b/shaders/simple_shader_translucent.frag @@ -44,8 +44,20 @@ layout(set = 1, binding = 5) uniform sampler2D roughnessMap; // 独立粗糙度 layout(set = 1, binding = 6) uniform MaterialParams { vec4 factors; // x = metallic 因子,y = roughness 因子,z = 独立粗糙度图开关,w = MASK 镂空开关 vec4 baseColor; // glTF baseColorFactor:rgb 调色 albedo,a = BLEND 透明度 + vec4 surface; // x = ior,y = transmission(KHR_materials_transmission),z = 自发光强度,w = 体积厚度 + vec4 misc; // x = unlit(透射材质不会是 unlit,仅为与 simple_shader 布局一致) + vec4 uvA[6]; // 每槽 UV 线性变换(KHR_texture_transform,v-up 空间)(a11,a12,a21,a22) + vec4 uvT[6]; // 每槽 UV 平移 (tx, ty, 0, 0) } matParams; +// 每槽 UV 变换:uv' = A·uv + t(与 simple_shader.frag 同款,6 = kSlotCount) +vec2 ApplyUv(int slot, vec2 uv) { + vec4 a = matParams.uvA[slot]; + vec4 t = matParams.uvT[slot]; + return vec2(a.x * uv.x + a.y * uv.y + t.x, + a.z * uv.x + a.w * uv.y + t.y); +} + // CSM 阴影(sampler2DShadow:单次采样 = 硬件 2×2 深度比较 PCF) layout(set = 2, binding = 0) uniform sampler2DShadow shadowMap0; // CSM 级联 0(最近) layout(set = 2, binding = 1) uniform sampler2DShadow shadowMap1; // 级联 1 @@ -53,6 +65,7 @@ layout(set = 2, binding = 2) uniform sampler2DShadow shadowMap2; // 级联 2 layout(set = 2, binding = 3) uniform samplerCube irradianceMap; // 漫反射环境光(IBL 阶段②) layout(set = 2, binding = 4) uniform samplerCube prefilteredMap; // 镜面环境(mip 分 roughness,阶段③) layout(set = 2, binding = 5) uniform sampler2D brdfLut; // BRDF 积分表 (NdotV, roughness) → (scale, bias) +layout(set = 2, binding = 6) uniform sampler2D sceneColor; // 不透明段快照(透射折射采样源) // 第 0 盏方向光(KeyLight)的 CSM 阴影因子。 // 级联按视图深度选择;3×3 纹素偏移采样(每 tap 硬件 2×2 PCF, @@ -136,21 +149,35 @@ vec3 FresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) { } void main() { - vec4 albedoTex = texture(albedoMap, fragUV); + vec4 albedoTex = texture(albedoMap, ApplyUv(0, fragUV)); vec3 albedo = albedoTex.rgb * matParams.baseColor.rgb; - vec4 mr = texture(metallicRoughnessMap, fragUV); + vec4 mr = texture(metallicRoughnessMap, ApplyUv(2, fragUV)); // 粗糙度源按 factors.z 切换:glTF 合图工作流读 MR.g; // FBX/OBJ specular 工作流(z=1)换用独立粗糙度图 R 通道 - float roughness = clamp(mix(mr.g, texture(roughnessMap, fragUV).r, + float roughness = clamp(mix(mr.g, texture(roughnessMap, ApplyUv(5, fragUV)).r, matParams.factors.z) * matParams.factors.y, 0.045, 1.0); float metallic = clamp(mr.b * matParams.factors.x, 0.0, 1.0); - vec3 emissive = texture(emissiveMap, fragUV).rgb; + // 自发光强度(KHR_materials_emissive_strength,缺省 1) + vec3 emissive = texture(emissiveMap, ApplyUv(3, fragUV)).rgb * matParams.surface.z; - vec3 N = MappedNormal(normalize(fragNormal), fragTangent, fragUV); + // glTF 表面扩展:透射率(KHR_materials_transmission)/ + // 折射率(ior,缺省 1.5)/ 体积厚度(KHR_materials_volume) + const float transmission = clamp(matParams.surface.y, 0.0, 1.0); + const float ior = max(matParams.surface.x, 1.0); + const float thickness = max(matParams.surface.w, 0.0); + + vec3 N = MappedNormal(normalize(fragNormal), fragTangent, ApplyUv(1, fragUV)); vec3 V = normalize(ubo.cameraPos.xyz - fragWorldPos); + // 双面法线:cull 关闭,背面片元(玻璃壳内壁)几何法线背向相机, + // 光照/折射方向全反——按外表面等价翻转到视侧 + if (dot(N, V) < 0.0) N = -N; float NdotV = max(dot(N, V), 1e-4); - vec3 F0 = mix(vec3(0.04), albedo, metallic); // 电介质基准反射率 4% + // F0 按折射率换算(KHR_materials_ior):((ior-1)/(ior+1))²; + // glTF 缺省 ior=1.5 → 0.04,与旧固定值一致 + vec3 F0 = mix(vec3((ior - 1.0) / (ior + 1.0) + * (ior - 1.0) / (ior + 1.0)), + albedo, metallic); // 电介质 F0 / 金属 albedo vec3 Lo = vec3(0.0); float shade0 = 1.0f; // 第 0 盏光的阴影因子(环境光同步衰减用) @@ -185,7 +212,10 @@ void main() { vec3 F = FresnelSchlick(NdotH2, F0); vec3 kS = F; - vec3 kD = (1.0 - kS) * (1.0 - metallic); // 金属不参与漫反射 + // 漫反射能量让位给透射:真玻璃无漫反射(入射光 = 反射 F + + // 透射 1-F),transmission=1 时 kD→0 = 纯镜面电介质;透射光 + // 由场景色副本折射采样补回(见下方 transmitted) + vec3 kD = (1.0 - kS) * (1.0 - metallic) * (1.0 - transmission); vec3 specular = (D * G * F) / max(4.0 * NdotV * NdotL, 1e-4); @@ -207,8 +237,9 @@ void main() { // 镜面:R 采样 pre-filtered cube(mip = roughness 分级) // × BRDF LUT 查表得 F 缩放/偏置 // ambientColor = 用户可调强度/色调乘子 + // 漫反射侧同步乘 (1-transmission)(同直接光能量重分配) vec3 F_amb = FresnelSchlickRoughness(NdotV, F0, roughness); - vec3 kD_amb = (1.0 - F_amb) * (1.0 - metallic); + vec3 kD_amb = (1.0 - F_amb) * (1.0 - metallic) * (1.0 - transmission); vec3 irradiance = texture(irradianceMap, N).rgb; float ao = texture(aoMap, fragUV).r; @@ -224,10 +255,34 @@ void main() { vec3 ambient = (diffuse + specular) * ubo.ambientColor.rgb * mix(0.4, 1.0, shade0); // 阴影区环境同步衰减(简化遮蔽) - // alpha = albedo 贴图覆盖率 × baseColorFactor.a(glTF alphaMode=BLEND - // 混合源;玻璃类无 baseColor 贴图材质的透明度全靠 factor.a)。 + // ---- 透射(KHR_materials_transmission):屏幕空间折射 ---- + // 采样源 = 不透明段快照(天空盒 + 不透明几何,Forward 后 + // ForwardTrans 前拷贝)。玻璃互相不可见(副本不含透明队列), + // 与 UE 屏幕空间透射同一限制。 + vec3 transmitted = vec3(0.0); + if (transmission > 0.0) { + // 进入介质 eta = 1/ior;全内反射(TIR)退化为反射方向 + vec3 refrDir = refract(-V, N, 1.0 / ior); + if (dot(refrDir, refrDir) < 1e-6) refrDir = reflect(-V, N); + // 世界空间步进 thickness 投回屏幕取折射像素(thickness=0 = + // 直透同位采样,无弯曲);projNoJitter 保证折射样本位置 + // 无 TAA 抖动(时域稳定收敛) + vec4 clip = ubo.projNoJitter * ubo.view * + vec4(fragWorldPos + refrDir * thickness, 1.0); + vec2 refrUV = (clip.xy / clip.w) * 0.5 + 0.5; + refrUV = clamp(refrUV, vec2(0.0), vec2(1.0)); + vec3 scene = texture(sceneColor, refrUV).rgb; + // albedo 染色 = 有色玻璃近似(Beer-Lambert 体积吸收后续演进) + transmitted = scene * albedo * transmission; + } + + // alpha = max(覆盖率, transmission):transmission 已烘进 rgb(透射 + // 背景是颜色的一部分),t≈1 的玻璃对混合方程是完全替换像素 + //(src*1 + dst*0)——不再有"白 albedo×a 混合"的奶白罩。 + // 非 transmission 的 BLEND 材质(软粒子/光效)行为不变。 // 预乘在 tonemap 段完成。 - outColor = vec4(ambient + Lo + emissive, albedoTex.a * matParams.baseColor.a); + outColor = vec4(ambient + Lo + transmitted + emissive, + max(albedoTex.a * matParams.baseColor.a, transmission)); outVelocity = fragVelocity; // 深度语义:真实深度测试 + 真实写入(depthWrite 状态保持 TRUE——