Files
HelloVulkan/App/editor/ViewportPanel.cpp
T
atdunbg 9ff08651f0 editor: revert self-drawn scale gizmo, clip degenerate axes instead
User feedback: the self-drawn scale handles clash visually with the
ImGuizmo translate/rotate rods (different size/style, clearly not one
widget). Keep the library widget for all three ops for visual
consistency and prevent mis-drags by masking degenerate axes via
SetAxisMask:

- translate: axis ~ parallel to camera-to-object line (sin < 0.25)
- scale: axis ~ perpendicular to camera-to-object line (|dot| < 0.1,
  intersection numerator identically zero -> grab fails/values jump)
  OR axis ~ parallel to view direction (|dot| > 0.98, zero screen
  displacement -> value never changes)
- rotate: screen-space math, no degeneracy, no mask

Masked axes are neither drawn nor activatable; a slight view change
restores them. Center uniform-scale square (MT_SCALE_XYZ, pure screen
space) stays available in any view.
2026-10-03 12:46:07 +08:00

335 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ViewportPanel:场景渲染输出面板。
//
// tonemap 输出经 ImGui::Image 显示(Editor 模式),并提供
// Editor Viewport / Fullscreen 显示模式切换。面板可用区域
// (物理像素)上报为渲染分辨率 → 画面精确填满面板。
//
// 点击拾取(UE 式 GPU HitProxy):左键单击 → 鼠标 NDC 经
// Renderer::RequestPick 请求下一帧 id 渲染 + 像素拷贝;Draw 每帧
// 轮询 TakePickResult 取走结果。像素级精度,与视觉画面一致
//(视觉几何语义:与 Collider 无关,无 collider 实体同样可选中)。
//
#include "ViewportPanel.h"
#include <imgui.h>
#include <ImGuizmo.h>
#include <Engine/ECS/Components.h> // MeshRenderer / Name / RigidBody(日志对照)
#include <Engine/Renderer/Renderer.h> // RequestPick / TakePickResult 门面
#include <Engine/Renderer/SceneTypes.h> // kPickMiss
#include <Engine/Core/Log.h> // 命中打印
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <cstdio>
// GTX 扩展(实验性)需显式开启:matrix_decompose(矩阵→SRT 分解,
// 旋转以四元数输出,直接写回 TransformComponent)
#define GLM_ENABLE_EXPERIMENTAL
#include <glm/gtx/matrix_decompose.hpp>
using namespace Engine; // u32 / f32(Engine/Core/Types.h)
namespace
{
// Ctrl 吸附步长(UE Grid Snap 语义:按住 Ctrl 拖拽才吸附)
constexpr float kSnapTranslate = 0.25f; // 米/格
constexpr float kSnapRotateDeg = 15.0f; // 度
constexpr float kSnapScale = 0.1f; // 缩放步进
// 平移拖拽灵敏度下限:sinθ(轴与"相机→物体"连线夹角)。低于该值
// 时拖拽投影系数 <0.25 且激活平面与鼠标射线近共面——视觉上
// "看得见、抓了没反应"。≈14.5°,略宽于库默认可见角(≈11.5°),
// 只藏真正病态的轴,不动可用轴
constexpr float kMoveMinSin = 0.25f;
// 缩放退化轴裁剪(防误拖动)阈值。库内缩放 hover/拖拽依赖
// "法线=轴"平面求交,两种病态几何:
// - 轴⊥"相机→物体"连线(正对视角无横偏):交点分子 numer =
// -dist·dot(camToObj, 轴) ≡ 0 → 杆抓不到、数值随机猛跳
// - 轴∥视线(轴指向相机):屏幕零位移 → 拖了数值不变
constexpr float kScaleMinDot = 0.1f; // ≈84.3°
constexpr float kScaleMaxFwdDot = 0.98f; // ≈11.5°,与库藏杆角一致
// Vulkan proj → ImGuizmo 期望的 OpenGL 形式(Y-up、Z∈[-1,1]):
// Y 行翻回(抵消 Vulkan clip Y 向下翻转);z 行 = 2*z - w 即
// 深度 [0,1] → [-1,1]。glm 列主序 m[col][row],逐列改 row1/row2
glm::mat4 ProjVulkanToGL(const glm::mat4& projVulkan)
{
glm::mat4 p = projVulkan;
for (int c = 0; c < 4; ++c) {
p[c][1] *= -1.0f;
p[c][2] = 2.0f * p[c][2] - p[c][3];
}
return p;
}
}
std::optional<entt::entity> ViewportPanel::Draw(const EditorContext &ctx,
ViewportInputState &out,
GizmoState &gizmo,
const entt::entity selected) const {
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
std::optional<entt::entity> picked;
{
ImGui::Begin("Viewport", nullptr, ImGuiWindowFlags_NoCollapse);
// 输入焦点上报(悬停计入遮挡:模态框盖住时不误报)
out.hovered = ImGui::IsWindowHovered();
out.focused = ImGui::IsWindowFocused();
auto &renderer = *ctx.renderer;
const int mode = renderer.GetDisplayMode();
// ---- 拾取结果轮询(先于新请求:先取走上帧结果再发新请求)。
// 拷贝随上一帧命令缓冲提交,TakePickResult 内 waitIdle 保证
// 暂存可见(仅拷贝就绪帧阻塞,点击低频不可感知)----
if (auto bits = renderer.TakePickResult()) {
auto &reg = ctx.scene->GetRegistry();
const entt::entity hit = static_cast<entt::entity>(*bits);
if (*bits != kPickMiss && reg.valid(hit)) {
picked = hit;
LogPick(ctx, hit);
} else {
// 空白(kPickMiss)或悬空位模式:清选中(与旧语义一致)
picked = entt::null;
if (*bits == kPickMiss) {
ENGINE_INFO("[pick] miss (sky/empty)");
} else {
ENGINE_WARN("[pick] dangling entity bits {:#x}", *bits);
}
}
}
const void *sceneTex = renderer.GetSceneTexture();
if (sceneTex != nullptr && mode == kDisplayViewport) {
// 面板可用区域(物理像素)上报为渲染分辨率 → 画面精确填满
// 面板;下一帧帧首 BeginFrameSync 生效(一帧延迟,不可见)
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 fb = ImGui::GetIO().DisplayFramebufferScale;
const u32 wantW = static_cast<u32>(avail.x * fb.x + 0.5f);
const u32 wantH = static_cast<u32>(avail.y * fb.y + 0.5f);
if (wantW >= 16 && wantH >= 16) {
renderer.SetViewportSize(wantW, wantH);
}
// 纹理 = 渲染分辨率:等比适配面板可用区域
const vk::Extent2D ext = renderer.GetRenderExtent();
const f32 aspect =
ext.height != 0 ? f32(ext.width) / f32(ext.height) : 1.0f;
ImVec2 size = avail;
if (avail.x / avail.y > aspect) size.x = avail.y * aspect;
else size.y = avail.x / aspect;
ImGui::Image((ImTextureID) (intptr_t) sceneTex, size);
// ---- W/E/R 切换 Gizmo 模式(UE 语义:W 平移 / E 旋转 /
// R 缩放),T 切换 local/world 操纵空间。仅 Viewport 持焦时
// 生效——输入框打字(WantTextInput)由 BlockingKeyboard 拦
// 游戏侧,但 ImGui 自身仍收键,故必须限定持焦面板,避免在
// Inspector 拖数值时误切 ----
if (out.focused && !ImGui::GetIO().WantTextInput) {
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;
}
// ---- Gizmo 操纵杆(ImGui drawlist 叠加,无 GPU pass)----
// 选中实体 + 有 Transform 才出杆;矩阵分解直接写回
// TransformComponent(与 Inspector 同源,下一帧数值自动跟随)
auto &reg = ctx.scene->GetRegistry();
ECS::TransformComponent *tr =
selected != entt::null
? reg.try_get<ECS::TransformComponent>(selected)
: nullptr;
if (tr != nullptr) {
// 相机矩阵:primary 相机 view + Vulkan proj(转 GL 形式),
// 参数与渲染侧一致(aspect = 渲染分辨率、near/far 0.1/100)
ECS::TransformComponent *camT = nullptr;
if (const auto *camComp = ctx.scene->GetPrimaryCamera(&camT);
camComp != nullptr && camT != nullptr) {
const glm::mat4 view = camComp->camera.GetView(*camT);
const glm::mat4 projGL = ProjVulkanToGL(
camComp->camera.GetProjection(aspect, 0.1f, 100.0f));
ImGuizmo::SetDrawlist(ImGui::GetWindowDrawList());
// 杆画在 Image 区域内(等比适配后的 item rect)
const ImVec2 mn = ImGui::GetItemRectMin();
ImGuizmo::SetRect(mn.x, mn.y, size.x, size.y);
// 不调 SetPlaneLimit/SetAxisLimit——可见度保持库默认
glm::mat4 m = tr->World();
// 操作空间:local(模型轴,旋转/缩放随物体朝向直观)/
// world(T 键切换)。无父层级时两种模式 world 矩阵同源,
// decompose 写回链路不变
const ImGuizmo::MODE mode =
gizmo.localSpace ? ImGuizmo::LOCAL : ImGuizmo::WORLD;
const glm::mat3 rotM(m);
// 退化轴裁剪(防误拖动):被裁轴不可见、不可激活,
// 转个视角即可用。三操作病态几何不同,按 op 分派:
// - 平移:拖拽灵敏度 = sinθ(轴与相机连线夹角),θ→0
// 时鼠标位移在轴上投影→0("数值不变")
// - 缩放:库内 hover/拖拽依赖"法线=轴"平面求交——
// 轴⊥相机连线(正对无横偏)时交点分子 numer≡0
// (抓不到/数值乱跳);轴∥视线时屏幕零位移(数值
// 不变)。两判据并集裁剪
// - 旋转:屏幕空间角度数学,无退化,不裁
const bool isTranslate = (gizmo.op
& int(ImGuizmo::TRANSLATE)) == int(ImGuizmo::TRANSLATE);
const bool isScale = (gizmo.op
& int(ImGuizmo::SCALE)) == int(ImGuizmo::SCALE);
if (isTranslate || isScale) {
bool maskAxis[3] = {false, false, false};
const glm::vec3 camPos(glm::inverse(view)[3]);
glm::vec3 camToObj = glm::vec3(m[3]) - camPos;
const float camDist = glm::length(camToObj);
if (camDist > 1e-4f) {
camToObj /= camDist;
const glm::vec3 viewFwd = -glm::normalize(
glm::vec3(view[0][2], view[1][2],
view[2][2]));
for (int i = 0; i < 3; ++i) {
const glm::vec3 axis = gizmo.localSpace
? glm::normalize(rotM[i])
: glm::vec3(float(i == 0), float(i == 1),
float(i == 2));
maskAxis[i] = isScale
? glm::abs(glm::dot(camToObj, axis))
< kScaleMinDot ||
glm::abs(glm::dot(viewFwd, axis))
> kScaleMaxFwdDot
: glm::length(glm::cross(camToObj, axis))
< kMoveMinSin;
}
}
ImGuizmo::SetAxisMask(maskAxis[0], maskAxis[1],
maskAxis[2]);
}
// Ctrl 吸附(UE Grid Snap 语义)。库的约定
//(docs/documentation.md):平移 = 世界单位 float[3]
// 逐轴;旋转 = snap[0] 按度(库内自行 DEG2RAD,传弧
// 度会被解释成极小步进);缩放 = 只取 snap[0]
float snap[3] = {0.0f, 0.0f, 0.0f};
const bool snapping = ImGui::GetIO().KeyCtrl;
if (snapping) {
if (gizmo.op == ImGuizmo::TRANSLATE) {
snap[0] = snap[1] = snap[2] = kSnapTranslate;
} else if (gizmo.op == ImGuizmo::ROTATE) {
snap[0] = kSnapRotateDeg; // 度,非弧度
} else if (gizmo.op == ImGuizmo::SCALE) {
snap[0] = kSnapScale;
}
}
ImGuizmo::Manipulate(&view[0][0], &projGL[0][0],
static_cast<ImGuizmo::OPERATION>(gizmo.op),
mode, &m[0][0], nullptr,
snapping ? snap : nullptr);
gizmo.dragging = ImGuizmo::IsUsing();
// 拖拽生效:分解 world 矩阵写回 SRT(Set* 标脏 →
// UpdateTransforms 重算 → 渲染下帧生效)。旋转用
// 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)) {
tr->SetPosition(transl)
.SetRotationQuat(rot)
.SetScale(scale);
}
}
}
}
// Gizmo 状态提示(左上角 drawlist 叠加,原因同下:不占布局行)
{
char tip[64];
std::snprintf(tip, sizeof(tip), "Space: %s [T] Snap: Ctrl",
gizmo.localSpace ? "Local" : "World");
const ImVec2 imgMin = ImGui::GetItemRectMin();
ImGui::GetWindowDrawList()->AddText(
ImVec2(imgMin.x + 8.0f, imgMin.y + 8.0f),
IM_COL32(255, 255, 255, 150), tip);
}
// Dynamic 刚体提示:物理每帧把姿态写回 Transform,拖拽即刻
// 被覆盖(Static/Kinematic 编辑器权威,可直接拖)。
// 用 drawlist 叠加而非 ImGui::TextDisabled:后者在 Image 下方
// 占一行布局 → 滚动条出现/消失循环 → avail 抖动 → 渲染分辨率
// 每帧重报(视口疯狂顶动);且默认字体无中文字形(显示 ?)
if (selected != entt::null) {
if (const auto *rb = reg.try_get<ECS::RigidBodyComponent>(selected);
rb != nullptr &&
rb->type == ECS::RigidBodyComponent::BodyType::Dynamic) {
const ImVec2 imgMin = ImGui::GetItemRectMin();
ImGui::GetWindowDrawList()->AddText(
ImVec2(imgMin.x + 8.0f, imgMin.y + size.y - 20.0f),
IM_COL32(255, 255, 255, 160),
"(Dynamic body: transform driven by physics)");
}
}
// 点击拾取:单击 Image 且未命中 UI 遮挡(IsItemHovered 已含)。
// 抓杆时不出 pick(IsOver 悬停杆/IsUsing 拖拽中)——否则按在
// 操纵杆上的点击会穿透发拾取请求
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0) &&
!ImGuizmo::IsOver() && !ImGuizmo::IsUsing()) {
RequestPickAt(ctx, size);
}
} else {
ImGui::TextDisabled("(scene texture not ready)");
}
ImGui::End();
}
ImGui::PopStyleVar();
return picked;
}
void ViewportPanel::RequestPickAt(const EditorContext &ctx,
const ImVec2 &size) const {
// 鼠标在 Image 内的位置 → NDC。Vulkan NDC y 与屏幕 v 同向
//(向下为正,与渲染同构;skybox.frag 反投影交叉验证过方向)
const ImVec2 mouse = ImGui::GetMousePos();
const ImVec2 mn = ImGui::GetItemRectMin();
const glm::vec2 ndc{
(mouse.x - mn.x) / size.x * 2.0f - 1.0f,
(mouse.y - mn.y) / size.y * 2.0f - 1.0f,
};
// UE 式 GPU HitProxy:下一帧 id 渲染 + 请求像素 1×1 拷贝
ctx.renderer->RequestPick(ndc);
}
void ViewportPanel::LogPick(const EditorContext &ctx, entt::entity e) {
// 命中详情:实体名;有物理体则附 collider 对照(诊断"物理盒 ≠
// 视觉"的错位,如 M1911 的默认 0.5 半尺寸盒)
auto &reg = ctx.scene->GetRegistry();
const auto &name = reg.get<ECS::NameComponent>(e).name;
if (const auto *rb = reg.try_get<ECS::RigidBodyComponent>(e)) {
const auto &tr = reg.get<ECS::TransformComponent>(e);
ENGINE_INFO("[pick] '{}' (hitproxy) | collider={} type={} "
"boxHalf=({:.2f},{:.2f},{:.2f}) sphereR={:.2f} "
"bodyPos=({:.2f},{:.2f},{:.2f})",
name,
rb->collider == ECS::RigidBodyComponent::ColliderType::Box
? "box" : "sphere",
rb->type == ECS::RigidBodyComponent::BodyType::Static
? "static" : "dynamic",
rb->boxHalfExtents.x, rb->boxHalfExtents.y,
rb->boxHalfExtents.z, rb->sphereRadius,
tr.GetPosition().x, tr.GetPosition().y, tr.GetPosition().z);
} else {
ENGINE_INFO("[pick] '{}' (hitproxy) | (no collider)", name);
}
}