Files
HelloVulkan/App/editor/ViewportPanel.cpp
T
atdunbg 21b33d7d7f fix(editor): 缩放轴病态判据与平移互补,按 op 分派 AxisMask
缩放独有老问题根因(源码实证):缩放 hover(GetScaleType)与拖拽
(HandleScale ratio 分母)依赖"法线=轴"平面求交,交点距离分子 numer
= -dist·dot(camToObj, 轴)。正对视角相机与物体无横偏时 numer≡0 →
len=0/垃圾 → 杆 hover 不到、ratio 分子分母乱跳(数值随机猛变)。
平移 hover 是纯屏幕距离、平面法线经正交化自适应;旋转是屏幕角数学
——故唯独缩放有此病。SCALE op 下判据换 |dot(camToObj,轴)|<0.1,
ROTATE 不 mask,TRANSLATE 保持 sinθ<0.25。中心块等比缩放
(MT_SCALE_XYZ,屏幕空间数学)不受 mask 恒可用。
2026-10-03 12:11:16 +08:00

329 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;
// 缩放求交稳定性下限:|dot(camToObj, 轴)|。缩放的 hover 与拖拽
// 依赖"法线=轴"平面求交,交点距离分子 numer = -dist·该 dot——
// 轴⊥连线(正对视角相机无横偏)时 numer≈0,len→0/垃圾:杆
// hover 不到、ratio 分子分母乱跳(数值随机猛变)。≈84.3°
constexpr float kScaleMinDot = 0.1f;
// 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;
// 退化轴隐藏(UE 语义:可激活性决定可见性)。三种
// 操作病态几何不同,按 op 分派判据:
// - 平移:拖拽灵敏度 = sinθ(轴与相机连线夹角),θ→0
// 时鼠标位移在轴上投影→0("数值不变")
// - 缩放:hover(GetScaleType)与拖拽(HandleScale 的
// ratio 分母)依赖"法线=轴"平面求交,分子 numer =
// -dist·dot(camToObj, 轴)——正对视角相机无横偏时
// numer≡0,len→0/垃圾:杆 hover 不到、数值随机
// 猛变(缩放独有的老问题,平移/旋转不走此路径)
// - 旋转:屏幕空间角度数学,无退化,不 mask
const glm::vec3 camPos(glm::inverse(view)[3]);
glm::vec3 camToObj = glm::vec3(m[3]) - camPos;
const float camDist = glm::length(camToObj);
const glm::mat3 rotM(m);
bool maskAxis[3] = {false, false, false};
if (camDist > 1e-4f) {
camToObj /= camDist;
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) {
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::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);
}
}