// // ViewportPanel:场景渲染输出面板。 // // tonemap 输出经 ImGui::Image 显示(Editor 模式),并提供 // Editor Viewport / Fullscreen 显示模式切换。面板可用区域 // (物理像素)上报为渲染分辨率 → 画面精确填满面板。 // // 点击拾取(UE 式 GPU HitProxy):左键单击 → 鼠标 NDC 经 // Renderer::RequestPick 请求下一帧 id 渲染 + 像素拷贝;Draw 每帧 // 轮询 TakePickResult 取走结果。像素级精度,与视觉画面一致 //(视觉几何语义:与 Collider 无关,无 collider 实体同样可选中)。 // #include "ViewportPanel.h" #include #include #include // MeshRenderer / Name / RigidBody(日志对照) #include // RequestPick / TakePickResult 门面 #include // kPickMiss #include // 命中打印 #include #include #include // GTX 扩展(实验性)需显式开启:matrix_decompose(矩阵→SRT 分解, // 旋转以四元数输出,直接写回 TransformComponent) #define GLM_ENABLE_EXPERIMENTAL #include using namespace Engine; // u32 / f32(Engine/Core/Types.h) namespace { // Ctrl 吸附步长(UE Grid Snap 语义:按住 Ctrl 拖拽才吸附) constexpr float kSnapTranslate = 0.25f; // 米/格 constexpr float kSnapRotateDeg = 15.0f; // 度 constexpr float kSnapScale = 0.1f; // 缩放步进 // 平移拖拽灵敏度下限:sinθ(轴与"相机→物体"连线夹角)。低于该值 // 时拖拽投影系数 <0.25 且激活平面与鼠标射线近共面——视觉上 // "看得见、抓了没反应"。≈14.5°,略宽于库默认可见角(≈11.5°), // 只藏真正病态的轴,不动可用轴 constexpr float kMoveMinSin = 0.25f; // 缩放退化轴裁剪(防误拖动)阈值。库内缩放 hover/拖拽依赖 // "法线=轴"平面求交,两种病态几何: // - 轴⊥"相机→物体"连线(正对视角无横偏):交点分子 numer = // -dist·dot(camToObj, 轴) ≡ 0 → 杆抓不到、数值随机猛跳 // - 轴∥视线(轴指向相机):屏幕零位移 → 拖了数值不变 constexpr float kScaleMinDot = 0.1f; // ≈84.3° constexpr float kScaleMaxFwdDot = 0.98f; // ≈11.5°,与库藏杆角一致 // Vulkan proj → ImGuizmo 期望的 OpenGL 形式(Y-up、Z∈[-1,1]): // Y 行翻回(抵消 Vulkan clip Y 向下翻转);z 行 = 2*z - w 即 // 深度 [0,1] → [-1,1]。glm 列主序 m[col][row],逐列改 row1/row2 glm::mat4 ProjVulkanToGL(const glm::mat4& projVulkan) { glm::mat4 p = projVulkan; for (int c = 0; c < 4; ++c) { p[c][1] *= -1.0f; p[c][2] = 2.0f * p[c][2] - p[c][3]; } return p; } // 视图相机分派(必须与 SceneRenderer::BuildFrame 渲染源同一规则, // 否则 Gizmo 杆与画面错位):编辑器形态 = EditorCamera(Scene view // 专用,与相机实体无关);游戏形态(kDisplayDirect 全屏预览或 PIE // 内嵌播放)= primary 相机实体。 // 返回 nullptr = 游戏形态且无 primary 相机(本帧无 Gizmo) Engine::Camera* ViewCamera(const EditorContext& ctx, ECS::TransformComponent*& outT) { const bool playing = ctx.playState && *ctx.playState; if (!playing && ctx.renderer->GetDisplayMode() == kDisplayViewport) { outT = &ctx.editorCamera->GetTransform(); return &ctx.editorCamera->GetController(); } auto* cc = ctx.scene->GetPrimaryCamera(&outT); return cc ? &cc->camera : nullptr; } } std::optional ViewportPanel::Draw(const EditorContext &ctx, ViewportInputState &out, GizmoState &gizmo, const entt::entity selected) const { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); std::optional picked; { ImGui::Begin("Viewport", nullptr, ImGuiWindowFlags_NoCollapse); // 输入焦点上报(悬停计入遮挡:模态框盖住时不误报) out.hovered = ImGui::IsWindowHovered(); out.focused = ImGui::IsWindowFocused(); auto &renderer = *ctx.renderer; const int mode = renderer.GetDisplayMode(); // ---- 拾取结果轮询(先于新请求:先取走上帧结果再发新请求)。 // 拷贝随上一帧命令缓冲提交,TakePickResult 内 waitIdle 保证 // 暂存可见(仅拷贝就绪帧阻塞,点击低频不可感知)---- if (auto bits = renderer.TakePickResult()) { auto ® = ctx.scene->GetRegistry(); const entt::entity hit = static_cast(*bits); if (*bits != kPickMiss && reg.valid(hit)) { picked = hit; LogPick(ctx, hit); } else { // 空白(kPickMiss)或悬空位模式:清选中(与旧语义一致) picked = entt::null; if (*bits == kPickMiss) { ENGINE_INFO("[pick] miss (sky/empty)"); } else { ENGINE_WARN("[pick] dangling entity bits {:#x}", *bits); } } } const void *sceneTex = renderer.GetSceneTexture(); if (sceneTex != nullptr && mode == kDisplayViewport) { // 面板可用区域(物理像素)上报为渲染分辨率 → 画面精确填满 // 面板;下一帧帧首 BeginFrameSync 生效(一帧延迟,不可见) const ImVec2 avail = ImGui::GetContentRegionAvail(); const ImVec2 fb = ImGui::GetIO().DisplayFramebufferScale; const u32 wantW = static_cast(avail.x * fb.x + 0.5f); const u32 wantH = static_cast(avail.y * fb.y + 0.5f); if (wantW >= 16 && wantH >= 16) { renderer.SetViewportSize(wantW, wantH); } // 纹理 = 渲染分辨率:等比适配面板可用区域 const vk::Extent2D ext = renderer.GetRenderExtent(); const f32 aspect = ext.height != 0 ? f32(ext.width) / f32(ext.height) : 1.0f; ImVec2 size = avail; if (avail.x / avail.y > aspect) size.x = avail.y * aspect; else size.y = avail.x / aspect; ImGui::Image((ImTextureID) (intptr_t) sceneTex, size); // ---- 骨骼关节 overlay(Endfield-Poser 范式):3D 骨架线 // 给结构,屏幕圆点给可读性。世界→屏幕与渲染同相机, // Vulkan NDC y 向下与 ImGui 屏幕坐标同构 → 线性映射 // image 矩形即可。hover 关节 = 白圈高亮 + 骨骼名 { const auto &frameData = renderer.GetFrameSceneData(); if (frameData.showBoneWires && !frameData.pieActive && !frameData.boneJoints.empty() && ctx.editorCamera != nullptr) { const ImVec2 rmin = ImGui::GetItemRectMin(); const ImVec2 rmax = ImGui::GetItemRectMax(); const glm::mat4 vp = ctx.editorCamera->GetController().GetProjection(aspect) * ctx.editorCamera->GetView(); ImDrawList *dl = ImGui::GetWindowDrawList(); dl->PushClipRect(rmin, rmax, true); const f32 r = frameData.boneJointPx; const ImGuiIO &io = ImGui::GetIO(); const ImVec2 mouse = io.MousePos; int hovered = -1; float hoveredD = 0.0f; for (size_t ji = 0; ji < frameData.boneJoints.size(); ++ji) { const auto &jm = frameData.boneJoints[ji]; const glm::vec4 c = vp * glm::vec4(jm.pos, 1.0f); if (c.w <= 0.0f) continue; // 相机身后 const ImVec2 p( rmin.x + (c.x / c.w * 0.5f + 0.5f) * size.x, rmin.y + (c.y / c.w * 0.5f + 0.5f) * size.y); // 骨骼 = 蓝(Endfield humanoid 色),普通节点 = 青 const ImU32 fill = jm.isBone ? IM_COL32(70, 160, 255, 230) : IM_COL32(70, 190, 190, 190); dl->AddCircleFilled(p, r, fill); dl->AddCircle(p, r + 1.5f, IM_COL32(255, 255, 255, 140), 0, 1.2f); // hover 命中(半径 +3px 容差,取最近) const float dx = p.x - mouse.x, dy = p.y - mouse.y; const float d = dx * dx + dy * dy; const float hit = (r + 3.0f) * (r + 3.0f); if (d <= hit && (hovered < 0 || d < hoveredD)) { hovered = static_cast(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 语义):Q 纯选择(不出杆)、W/E/R 切 // Gizmo 模式、T 切 local/world、F 焦点选中实体。仅 Viewport // 持焦时生效——输入框打字(WantTextInput)由 BlockingKeyboard // 拦游戏侧,但 ImGui 自身仍收键,故必须限定持焦面板。RMB 按 // 住 = 相机飞行模式(键盘归相机),工具热键全部抑制——W 前 // 进与 W 平移 Gizmo 的键位冲突就此解耦 ---- auto ® = ctx.scene->GetRegistry(); ECS::TransformComponent *tr = selected != entt::null ? reg.try_get(selected) : nullptr; // PIE 运行态:输入归游戏(脚本读 GLFW 轮询),Gizmo/编辑器 // 热键全部让位(UE Play 语义:Scene view 编辑操作冻结) const bool playing = ctx.playState && *ctx.playState; // PIE 启动瞬间:视口抢焦点(UE Play 语义——游戏视角接管)。 // 不抢的话焦点停在工具栏 Play 按钮上,编辑器相机飞行等 // 持焦操作下一局全失灵 static bool s_wasPlaying = false; if (playing && !s_wasPlaying) { ImGui::SetWindowFocus(); } s_wasPlaying = playing; if (!playing && out.focused && !ImGui::GetIO().WantTextInput && !ImGui::IsMouseDown(ImGuiMouseButton_Right)) { // Q = 纯选择模式(UE Q 工具语义):op=0 无任何轴位, // ImGuizmo 不绘制不响应——选中实体只高亮不出杆 if (ImGui::IsKeyPressed(ImGuiKey_Q)) gizmo.op = 0; if (ImGui::IsKeyPressed(ImGuiKey_W)) gizmo.op = ImGuizmo::TRANSLATE; if (ImGui::IsKeyPressed(ImGuiKey_E)) gizmo.op = ImGuizmo::ROTATE; if (ImGui::IsKeyPressed(ImGuiKey_R)) gizmo.op = ImGuizmo::SCALE; if (ImGui::IsKeyPressed(ImGuiKey_T)) gizmo.localSpace = !gizmo.localSpace; const bool fCombo = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift; // F = 焦点选中实体(frame-selection):编辑器相机 // 环绕 pivot 设为选中实体位置,沿视线平移对准(环 // 绕距离沿用当前值,滚轮可调)。世界位姿(层级 // 实体 = 父链组合结果)。裸 F 才触发(组合键排他) if (ImGui::IsKeyPressed(ImGuiKey_F) && !fCombo && tr != nullptr) { ctx.editorCamera->FocusOn(tr->GetWorldPosition()); } } // ---- Gizmo 操纵杆(ImGui drawlist 叠加,无 GPU pass)---- // 选中实体 + 有 Transform 才出杆;分解 world 矩阵经 // SetWorldPose 写回(与 Inspector 同源,下一帧数值自动跟随) // PIE 运行态不出杆(编辑操作冻结) if (tr != nullptr && !playing) { // 相机矩阵:跟随渲染源(编辑器形态 = EditorCamera,游戏 // 形态 = primary 相机实体;与 SceneRenderer::BuildFrame // 同一分派),投影参数由相机自身承载(与渲染侧一致) ECS::TransformComponent *camT = nullptr; if (Engine::Camera *cam = ViewCamera(ctx, camT); cam != nullptr && camT != nullptr) { const glm::mat4 view = cam->GetView(*camT); const glm::mat4 projGL = ProjVulkanToGL( cam->GetProjection(aspect)); ImGuizmo::SetDrawlist(ImGui::GetWindowDrawList()); // 杆画在 Image 区域内(等比适配后的 item rect) const ImVec2 mn = ImGui::GetItemRectMin(); ImGuizmo::SetRect(mn.x, mn.y, size.x, size.y); // 不调 SetPlaneLimit/SetAxisLimit——可见度保持库默认 glm::mat4 m = tr->World(); // 操作空间:local(模型轴,旋转/缩放随物体朝向直观)/ // world(T 键切换)。无父层级时两种模式 world 矩阵同源, // decompose 写回链路不变 const ImGuizmo::MODE mode = gizmo.localSpace ? ImGuizmo::LOCAL : ImGuizmo::WORLD; const glm::mat3 rotM(m); // 退化轴裁剪(防误拖动):被裁轴不可见、不可激活, // 转个视角即可用。三操作病态几何不同,按 op 分派: // - 平移:拖拽灵敏度 = sinθ(轴与相机连线夹角),θ→0 // 时鼠标位移在轴上投影→0("数值不变") // - 缩放:库内 hover/拖拽依赖"法线=轴"平面求交—— // 轴⊥相机连线(正对无横偏)时交点分子 numer≡0 // (抓不到/数值乱跳);轴∥视线时屏幕零位移(数值 // 不变)。两判据并集裁剪 // - 旋转:屏幕空间角度数学,无退化,不裁 const bool isTranslate = (gizmo.op & int(ImGuizmo::TRANSLATE)) == int(ImGuizmo::TRANSLATE); const bool isScale = (gizmo.op & int(ImGuizmo::SCALE)) == int(ImGuizmo::SCALE); if (isTranslate || isScale) { bool maskAxis[3] = {false, false, false}; const glm::vec3 camPos(glm::inverse(view)[3]); glm::vec3 camToObj = glm::vec3(m[3]) - camPos; const float camDist = glm::length(camToObj); if (camDist > 1e-4f) { camToObj /= camDist; const glm::vec3 viewFwd = -glm::normalize( glm::vec3(view[0][2], view[1][2], view[2][2])); for (int i = 0; i < 3; ++i) { const glm::vec3 axis = gizmo.localSpace ? glm::normalize(rotM[i]) : glm::vec3(float(i == 0), float(i == 1), float(i == 2)); maskAxis[i] = isScale ? glm::abs(glm::dot(camToObj, axis)) < kScaleMinDot || glm::abs(glm::dot(viewFwd, axis)) > kScaleMaxFwdDot : glm::length(glm::cross(camToObj, axis)) < kMoveMinSin; } } ImGuizmo::SetAxisMask(maskAxis[0], maskAxis[1], maskAxis[2]); } // Ctrl 吸附(UE Grid Snap 语义)。库的约定 //(docs/documentation.md):平移 = 世界单位 float[3] // 逐轴;旋转 = snap[0] 按度(库内自行 DEG2RAD,传弧 // 度会被解释成极小步进);缩放 = 只取 snap[0] float snap[3] = {0.0f, 0.0f, 0.0f}; const bool snapping = ImGui::GetIO().KeyCtrl; if (snapping) { if (gizmo.op == ImGuizmo::TRANSLATE) { snap[0] = snap[1] = snap[2] = kSnapTranslate; } else if (gizmo.op == ImGuizmo::ROTATE) { snap[0] = kSnapRotateDeg; // 度,非弧度 } else if (gizmo.op == ImGuizmo::SCALE) { snap[0] = kSnapScale; } } ImGuizmo::Manipulate(&view[0][0], &projGL[0][0], static_cast(gizmo.op), mode, &m[0][0], nullptr, snapping ? snap : nullptr); gizmo.dragging = ImGuizmo::IsUsing(); // 拖拽生效:分解 world 矩阵 → world SRT → 沿父链反解 // local 写回(SetWorldPose;根实体 = 直写)。旋转用 // decompose 出的 quat 直写(UE FTransform 语义)—— // 不经欧拉往返,拖拽零漂移、无万向锁 if (ImGuizmo::IsUsing()) { glm::vec3 scale, transl, skew; glm::quat rot; glm::vec4 persp; if (glm::decompose(m, scale, rot, transl, skew, persp)) { ctx.scene->SetWorldPose(selected, transl, rot, scale); } } } } // Gizmo 状态提示(左上角 drawlist 叠加,原因同下:不占布局行) // PIE 运行态 = 黄色 PLAYING/PAUSED 提示替代编辑状态 { char tip[128]; if (playing) { const bool paused = ctx.pausedState && *ctx.pausedState; std::snprintf(tip, sizeof(tip), paused ? "PAUSED - Ctrl+P to stop" : "PLAYING - Ctrl+P to stop"); } else { std::snprintf(tip, sizeof(tip), "Q/W/E/R: Sel/Move/Rot/Scale T: %s " "Snap: Ctrl F: Focus", gizmo.localSpace ? "Local" : "World"); } const ImVec2 imgMin = ImGui::GetItemRectMin(); ImGui::GetWindowDrawList()->AddText( ImVec2(imgMin.x + 8.0f, imgMin.y + 8.0f), playing ? IM_COL32(255, 184, 46, 220) : IM_COL32(255, 255, 255, 150), tip); } // Dynamic 刚体提示:物理每帧把姿态写回 Transform,拖拽即刻 // 被覆盖(Static/Kinematic 编辑器权威,可直接拖)。 // 用 drawlist 叠加而非 ImGui::TextDisabled:后者在 Image 下方 // 占一行布局 → 滚动条出现/消失循环 → avail 抖动 → 渲染分辨率 // 每帧重报(视口疯狂顶动);且默认字体无中文字形(显示 ?) if (playing) { // 运行态无 primary 相机实体:渲染回退编辑器视角(保持帧 // 管线节拍),提示补相机 ECS::TransformComponent *camT = nullptr; if (ViewCamera(ctx, camT) == nullptr) { const ImVec2 imgMin = ImGui::GetItemRectMin(); ImGui::GetWindowDrawList()->AddText( ImVec2(imgMin.x + 8.0f, imgMin.y + size.y * 0.5f), IM_COL32(255, 120, 80, 230), "No primary camera entity - showing editor " "view. Add a Camera component and check " "'Primary'"); } } // Dynamic 刚体提示(drawlist 叠加,理由同上) if (selected != entt::null) { if (const auto *rb = reg.try_get(selected); rb != nullptr && rb->type == ECS::RigidBodyComponent::BodyType::Dynamic) { const ImVec2 imgMin = ImGui::GetItemRectMin(); ImGui::GetWindowDrawList()->AddText( ImVec2(imgMin.x + 8.0f, imgMin.y + size.y - 20.0f), IM_COL32(255, 255, 255, 160), "(Dynamic body: transform driven by physics)"); } } // 点击拾取:单击 Image 且未命中 UI 遮挡(IsItemHovered 已含)。 // 抓杆时不出 pick(IsOver 悬停杆/IsUsing 拖拽中)——否则按在 // 操纵杆上的点击会穿透发拾取请求。PIE 运行态不出拾取: // 点击 = 游戏输入(UE Play 语义,编辑操作冻结) if (!playing && ImGui::IsItemHovered() && ImGui::IsMouseClicked(0) && !ImGuizmo::IsOver() && !ImGuizmo::IsUsing()) { RequestPickAt(ctx, size); } } else { ImGui::TextDisabled("(scene texture not ready)"); } ImGui::End(); } ImGui::PopStyleVar(); return picked; } void ViewportPanel::RequestPickAt(const EditorContext &ctx, const ImVec2 &size) const { // 鼠标在 Image 内的位置 → NDC。Vulkan NDC y 与屏幕 v 同向 //(向下为正,与渲染同构;skybox.frag 反投影交叉验证过方向) const ImVec2 mouse = ImGui::GetMousePos(); const ImVec2 mn = ImGui::GetItemRectMin(); const glm::vec2 ndc{ (mouse.x - mn.x) / size.x * 2.0f - 1.0f, (mouse.y - mn.y) / size.y * 2.0f - 1.0f, }; // UE 式 GPU HitProxy:下一帧 id 渲染 + 请求像素 1×1 拷贝 ctx.renderer->RequestPick(ndc); } void ViewportPanel::LogPick(const EditorContext &ctx, entt::entity e) { // 命中详情:实体名;有物理体则附 collider 对照(诊断"物理盒 ≠ // 视觉"的错位,如 M1911 的默认 0.5 半尺寸盒) auto ® = ctx.scene->GetRegistry(); const auto &name = reg.get(e).name; if (const auto *rb = reg.try_get(e)) { const auto &tr = reg.get(e); ENGINE_INFO("[pick] '{}' (hitproxy) | collider={} type={} " "boxHalf=({:.2f},{:.2f},{:.2f}) sphereR={:.2f} " "bodyPos=({:.2f},{:.2f},{:.2f})", name, rb->collider == ECS::RigidBodyComponent::ColliderType::Box ? "box" : "sphere", rb->type == ECS::RigidBodyComponent::BodyType::Static ? "static" : "dynamic", rb->boxHalfExtents.x, rb->boxHalfExtents.y, rb->boxHalfExtents.z, rb->sphereRadius, tr.GetWorldPosition().x, tr.GetWorldPosition().y, tr.GetWorldPosition().z); } else { ENGINE_INFO("[pick] '{}' (hitproxy) | (no collider)", name); } }