From c9d4eed043c2076c1c10ee72014fe7d2a1e4930a Mon Sep 17 00:00:00 2001 From: atdunbg Date: Sat, 3 Oct 2026 12:23:00 +0800 Subject: [PATCH] editor: scale gizmo fully self-drawn (screen-space, view-independent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImGuizmo scale hover/drag relies on ray-plane intersection with the axis as plane normal; in front views (axis ⟂ camera-to-object) the intersection numerator is identically zero, so handles can't be grabbed and values jump randomly; axes parallel to the view have zero screen displacement. Submodule is read-only and AxisMask can only hide degenerate axes, not replace them. Replace the library scale path entirely with a screen-space gizmo: - three axis rods (line + end square) + center uniform-scale square, constant screen size in any view - axis drag: mouse delta projected onto the axis' screen direction - axis pointing at camera: radial mapping (drag away = grow) - center square: uniform scale (library MT_SCALE_XYZ semantics) - Ctrl snapping quantizes the result; scale clamped >= 0.01 - pick suppressed while hovering/dragging the self-drawn handles --- App/editor/ViewportPanel.cpp | 396 ++++++++++++++++++++++++++++------- App/editor/ViewportPanel.h | 16 +- 2 files changed, 337 insertions(+), 75 deletions(-) diff --git a/App/editor/ViewportPanel.cpp b/App/editor/ViewportPanel.cpp index c210f5d..96ee131 100644 --- a/App/editor/ViewportPanel.cpp +++ b/App/editor/ViewportPanel.cpp @@ -25,6 +25,8 @@ #include #include +#include +#include // GTX 扩展(实验性)需显式开启:matrix_decompose(矩阵→SRT 分解, // 旋转以四元数输出,直接写回 TransformComponent) @@ -46,11 +48,19 @@ namespace // 只藏真正病态的轴,不动可用轴 constexpr float kMoveMinSin = 0.25f; - // 缩放求交稳定性下限:|dot(camToObj, 轴)|。缩放的 hover 与拖拽 - // 依赖"法线=轴"平面求交,交点距离分子 numer = -dist·该 dot—— - // 轴⊥连线(正对视角相机无横偏)时 numer≈0,len→0/垃圾:杆 - // hover 不到、ratio 分子分母乱跳(数值随机猛变)。≈84.3° - constexpr float kScaleMinDot = 0.1f; + // ---- 自绘缩放 gizmo 常量(SCALE op 整体自绘,不走 ImGuizmo; + // 库内缩放数学在正对视角结构性退化,submodule 禁改)---- + constexpr float kScaleAxisLenFrac = 0.11f; // 杆长 = 视口高比例 + //(库 gizmoSize 视觉近似) + constexpr float kScaleDegenerateCos = 0.98f; // 轴与视线 |cos|>此值 = + // 指向相机(≈11.5°,与 + // 库藏杆角一致) + constexpr float kScaleAxisHitPx = 6.0f; // 杆线命中半径 px + constexpr float kScaleGrabHalfPx = 6.0f; // 端点方块半边 px + constexpr float kScaleCenterHalfPx = 9.0f; // 中心等比方块半边 px + constexpr float kScaleDragPerPx = 0.01f; // 灵敏度 1/px(库中心 + // 块同款) + constexpr float kScaleMinScale = 0.01f; // scale 下限(防 0/负) // Vulkan proj → ImGuizmo 期望的 OpenGL 形式(Y-up、Z∈[-1,1]): // Y 行翻回(抵消 Vulkan clip Y 向下翻转);z 行 = 2*z - w 即 @@ -64,6 +74,232 @@ namespace } return p; } + + // ImGuizmo directionColorRGB 同款轴色(X 红 / Y 黄 / Z 青), + // hot = 悬停/拖拽中 → 向白提亮 + ImU32 ScaleAxisColor(const int i, const bool hot) + { + static constexpr float kCol[3][3] = { + {0.666f, 0.000f, 0.000f}, + {0.533f, 0.533f, 0.000f}, + {0.000f, 0.666f, 0.666f}}; + const auto ch = [&](const float c) { + const float v = hot ? c + (1.0f - c) * 0.35f : c; + return static_cast(v * 255.0f + 0.5f); + }; + return IM_COL32(ch(kCol[i][0]), ch(kCol[i][1]), ch(kCol[i][2]), 255); + } + + // ---- 自绘缩放 gizmo(SCALE op 专用,替代 ImGuizmo 缩放路径)---- + // 为什么自绘:库内缩放的 hover(GetScaleType) 与拖拽(HandleScale + // ratio 除法)依赖"法线=轴"的射线-平面求交,正对视角(轴⊥相机→ + // 物体连线)时求交分子 numer = -dist·dot(camToObj, 轴) ≡ 0 → + // 杆 hover 不到、数值随机猛变;轴∥视线时屏幕零位移,任何投影 + // 式交互都退化。库层不可改(submodule 红线),AxisMask 只能"藏" + // 不能"补"(用户要求缩放真正可用)→ 整体自绘,纯屏幕空间数学: + // - 三轴杆(线+端点方块)+ 中心等比方块,杆长恒定屏幕尺寸 + // - 普通轴:鼠标位移在轴屏幕方向上的投影 → scale 增量(0.01/px, + // 与库中心块同灵敏度) + // - 轴指向相机(|cos(axis, 视线)|>0.98):径向映射——拖离杆心 = + // 放大、拖近 = 缩小(沿视线零屏幕位移的几何必然,UE 同款处理: + // 该轴退化为径向可拖而非不可用) + // - 中心方块:等比缩放(库 MT_SCALE_XYZ 同款 x 位移数学) + // - Ctrl 吸附 = 结果值按 kSnapScale 量化 + void ScaleGizmoSelf(ImDrawList* dl, const ImVec2& org, + const ImVec2& vsize, const glm::mat4& view, + const glm::mat4& projGL, const glm::mat4& world, + ECS::TransformComponent* tr, GizmoState& gizmo) + { + const ImGuiIO& io = ImGui::GetIO(); + + // 世界 → 面板屏幕坐标(GL clip y 向上,ImGui y 向下 → 翻转; + // 与 ProjVulkanToGL 后的 projGL 配套) + const glm::mat4 vp = projGL * view; + const auto toScreen = [&vp, &org, &vsize](const glm::vec3& w, + ImVec2& out) { + const glm::vec4 c = vp * glm::vec4(w, 1.0f); + if (c.w <= 1e-5f) return false; // 相机后 + out = ImVec2(org.x + (c.x / c.w * 0.5f + 0.5f) * vsize.x, + org.y + (0.5f - c.y / c.w * 0.5f) * vsize.y); + return true; + }; + + const glm::vec3 posW(world[3]); + const glm::vec3 viewFwd = -glm::normalize( + glm::vec3(view[0][2], view[1][2], view[2][2])); + ImVec2 cScr; + if (!toScreen(posW, cScr)) { + // 杆心在相机后:收拖拽防卡死,本帧不画不响应 + gizmo.scaleDragActive = false; + gizmo.scaleDragAxis = -1; + gizmo.scaleHover = false; + return; + } + + const float axisLen = kScaleAxisLenFrac * vsize.y; + const glm::mat3 rotM(world); + + // 每轴屏幕几何:轴方向 / 端点 / 是否指向相机(退化) + struct AxisVis { + bool degenerate = false; + ImVec2 tip{0.0f, 0.0f}; // 非退化 = 杆端点;退化 = 杆心 + ImVec2 dir{0.0f, 0.0f}; // 非退化 = 杆方向(单位 px 向量) + } vis[3]; + 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)); + vis[i].degenerate = + glm::abs(glm::dot(viewFwd, axis)) > kScaleDegenerateCos; + if (!vis[i].degenerate) { + ImVec2 u; + if (toScreen(posW + axis, u)) { + const float dx = u.x - cScr.x, dy = u.y - cScr.y; + const float len = std::sqrt(dx * dx + dy * dy); + if (len > 1e-4f) { + vis[i].dir = ImVec2(dx / len, dy / len); + vis[i].tip = ImVec2(cScr.x + vis[i].dir.x * axisLen, + cScr.y + vis[i].dir.y * axisLen); + } else { + vis[i].degenerate = true; // 投影意外退化兜底 + } + } else { + vis[i].degenerate = true; // 端点在相机后兜底 + } + } + if (vis[i].degenerate) vis[i].tip = cScr; + } + + // ---- 命中检测(优先级:退化轴小方块 > 轴杆 > 中心等比)---- + const float mx = io.MousePos.x, my = io.MousePos.y; + const auto inSquare = [mx, my](const ImVec2& c, const float half) { + return mx >= c.x - half && mx <= c.x + half && + my >= c.y - half && my <= c.y + half; + }; + const auto distToSeg = [mx, my](const ImVec2& a, const ImVec2& b) { + const float abx = b.x - a.x, aby = b.y - a.y; + const float t = std::clamp( + ((mx - a.x) * abx + (my - a.y) * aby) / + (abx * abx + aby * aby), 0.0f, 1.0f); + const float dx = mx - (a.x + abx * t), + dy = my - (a.y + aby * t); + return std::sqrt(dx * dx + dy * dy); + }; + + int hover = -1; // 0/1/2 轴;-2 中心等比;-1 无 + for (int i = 0; i < 3; ++i) { + if (vis[i].degenerate + ? inSquare(cScr, kScaleGrabHalfPx) + : (inSquare(vis[i].tip, kScaleGrabHalfPx + 1.0f) || + distToSeg(cScr, vis[i].tip) <= kScaleAxisHitPx)) { + hover = i; + break; + } + } + if (hover == -1 && inSquare(cScr, kScaleCenterHalfPx)) hover = -2; + + // ---- 交互:启动 / 更新 / 结束 ---- + gizmo.scaleHover = hover != -1 || gizmo.scaleDragActive; + if (ImGui::IsWindowHovered() && !gizmo.scaleDragActive && + hover != -1 && ImGui::IsMouseClicked(0)) { + gizmo.scaleDragActive = true; + gizmo.scaleDragAxis = hover; + gizmo.scaleStartMouse[0] = mx; + gizmo.scaleStartMouse[1] = my; + const glm::vec3 s = tr->GetScale(); + gizmo.scaleStartScale[0] = s.x; + gizmo.scaleStartScale[1] = s.y; + gizmo.scaleStartScale[2] = s.z; + const float ddx = mx - cScr.x, ddy = my - cScr.y; + gizmo.scaleStartDist = std::sqrt(ddx * ddx + ddy * ddy); + } + + if (gizmo.scaleDragActive) { + if (!io.MouseDown[0]) { + gizmo.scaleDragActive = false; + gizmo.scaleDragAxis = -1; + } else { + const int a = gizmo.scaleDragAxis; + float d = 0.0f; // 缩放倍率增量(乘法语义,等手感于库) + if (a == -2) { + // 中心等比:x 位移(库 MT_SCALE_XYZ 同款数学) + d = (mx - gizmo.scaleStartMouse[0]) * kScaleDragPerPx; + } else if (vis[a].degenerate) { + // 轴指向相机:径向——拖离杆心放大、拖近缩小 + const float ddx = mx - cScr.x, ddy = my - cScr.y; + d = (std::sqrt(ddx * ddx + ddy * ddy) - + gizmo.scaleStartDist) * kScaleDragPerPx; + } else { + // 鼠标位移在轴屏幕方向上的投影 + d = ((mx - gizmo.scaleStartMouse[0]) * vis[a].dir.x + + (my - gizmo.scaleStartMouse[1]) * vis[a].dir.y) * + kScaleDragPerPx; + } + const float k = 1.0f + d; + glm::vec3 s(gizmo.scaleStartScale[0], gizmo.scaleStartScale[1], + gizmo.scaleStartScale[2]); + for (int i = 0; i < 3; ++i) { + if (a == -2 || i == a) { + s[i] = glm::max(kScaleMinScale, s[i] * k); + if (io.KeyCtrl) { // 吸附 = 结果按步长量化 + s[i] = glm::max(kScaleMinScale, + std::round(s[i] / kSnapScale) * + kSnapScale); + } + } + } + tr->SetScale(s); // 标脏 → UpdateTransforms 下帧生效 + } + } + + // ---- 绘制:三轴杆 → 中心等比方块 → 退化轴小方块(压顶)---- + for (int i = 0; i < 3; ++i) { + if (vis[i].degenerate) continue; + const bool hot = + (gizmo.scaleDragActive && gizmo.scaleDragAxis == i) || + (!gizmo.scaleDragActive && hover == i); + const ImU32 col = ScaleAxisColor(i, hot); + dl->AddLine(cScr, vis[i].tip, col, 2.0f); + dl->AddRectFilled( + ImVec2(vis[i].tip.x - kScaleGrabHalfPx, + vis[i].tip.y - kScaleGrabHalfPx), + ImVec2(vis[i].tip.x + kScaleGrabHalfPx, + vis[i].tip.y + kScaleGrabHalfPx), + col, 2.0f); + } + { + const bool hot = + (gizmo.scaleDragActive && gizmo.scaleDragAxis == -2) || + (!gizmo.scaleDragActive && hover == -2); + dl->AddRectFilled( + ImVec2(cScr.x - kScaleCenterHalfPx, + cScr.y - kScaleCenterHalfPx), + ImVec2(cScr.x + kScaleCenterHalfPx, + cScr.y + kScaleCenterHalfPx), + hot ? IM_COL32(255, 255, 255, 255) + : IM_COL32(230, 230, 230, 210), + 3.0f); + } + for (int i = 0; i < 3; ++i) { + if (!vis[i].degenerate) continue; + const bool hot = + (gizmo.scaleDragActive && gizmo.scaleDragAxis == i) || + (!gizmo.scaleDragActive && hover == i); + const ImU32 col = ScaleAxisColor(i, hot); + dl->AddRectFilled( + ImVec2(cScr.x - kScaleGrabHalfPx, + cScr.y - kScaleGrabHalfPx), + ImVec2(cScr.x + kScaleGrabHalfPx, + cScr.y + kScaleGrabHalfPx), + col, 2.0f); + dl->AddRect( + ImVec2(cScr.x - kScaleGrabHalfPx, + cScr.y - kScaleGrabHalfPx), + ImVec2(cScr.x + kScaleGrabHalfPx, + cScr.y + kScaleGrabHalfPx), + IM_COL32(20, 20, 20, 200), 2.0f); + } + } } std::optional ViewportPanel::Draw(const EditorContext &ctx, @@ -143,6 +379,12 @@ std::optional ViewportPanel::Draw(const EditorContext &ctx, selected != entt::null ? reg.try_get(selected) : nullptr; + // 自绘缩放拖拽中选中丢失(清选/实体销毁):复位,防止 + // dragging 永久置位锁死鼠标 + if (gizmo.scaleDragActive && tr == nullptr) { + gizmo.scaleDragActive = false; + gizmo.scaleDragAxis = -1; + } if (tr != nullptr) { // 相机矩阵:primary 相机 view + Vulkan proj(转 GL 形式), // 参数与渲染侧一致(aspect = 渲染分辨率、near/far 0.1/100) @@ -166,81 +408,86 @@ std::optional ViewportPanel::Draw(const EditorContext &ctx, // 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) { + + // 退化轴隐藏(仅平移):拖拽灵敏度 = sinθ(轴与相机 + // 连线夹角),θ→0 时鼠标位移在轴上投影→0("数值不 + // 变")。旋转 = 屏幕空间角度数学无退化;缩放整体 + // 自绘(见下),两者不 mask + const bool isTranslate = (gizmo.op + & int(ImGuizmo::TRANSLATE)) == int(ImGuizmo::TRANSLATE); + const bool isScale = (gizmo.op + & int(ImGuizmo::SCALE)) == int(ImGuizmo::SCALE); + if (!isScale) { + // 拖拽中切 op(R→W/E):打断自绘缩放拖拽 + gizmo.scaleDragActive = false; + gizmo.scaleDragAxis = -1; + 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 (isTranslate && camDist > 1e-4f) { + camToObj /= camDist; 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; + maskAxis[i] = + glm::length(glm::cross(camToObj, axis)) + < kMoveMinSin; + } + } + ImGuizmo::SetAxisMask(maskAxis[0], maskAxis[1], + maskAxis[2]); + } + + if (isScale) { + // ---- 缩放 = 自绘 gizmo(不走 ImGuizmo)。 + // 库内缩放 hover/拖拽依赖"法线=轴"平面求交, + // 正对视角(轴⊥相机连线)时分子 numer≡0:杆 + // hover 不到、数值随机猛变;轴∥视线无屏幕位 + // 移——AxisMask 只能藏不能补 → 整体自绘,屏 + // 幕空间数学任意视角良构 ---- + ScaleGizmoSelf(ImGui::GetWindowDrawList(), mn, size, + view, projGL, m, tr, gizmo); + } else { + // Ctrl 吸附(UE Grid Snap 语义)。库的约定 + //(docs/documentation.md):平移 = 世界单位 + // float[3] 逐轴;旋转 = snap[0] 按度(库内自行 + // DEG2RAD,传弧度会被解释成极小步进) + 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; // 度,非弧度 + } + } + + ImGuizmo::Manipulate(&view[0][0], &projGL[0][0], + static_cast(gizmo.op), + mode, &m[0][0], nullptr, + snapping ? snap : nullptr); + + // 拖拽生效:分解 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); } } } - 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 矩阵写回 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.dragging = ImGuizmo::IsUsing() || + gizmo.scaleDragActive; } } @@ -273,10 +520,11 @@ std::optional ViewportPanel::Draw(const EditorContext &ctx, } // 点击拾取:单击 Image 且未命中 UI 遮挡(IsItemHovered 已含)。 - // 抓杆时不出 pick(IsOver 悬停杆/IsUsing 拖拽中)——否则按在 - // 操纵杆上的点击会穿透发拾取请求 + // 抓杆时不出 pick(IsOver 悬停杆/IsUsing 拖拽中/自绘缩放 + // 手柄悬停或拖拽中)——否则按在操纵杆上的点击会穿透发拾取 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0) && - !ImGuizmo::IsOver() && !ImGuizmo::IsUsing()) { + !ImGuizmo::IsOver() && !ImGuizmo::IsUsing() && + !gizmo.scaleHover) { RequestPickAt(ctx, size); } } else { diff --git a/App/editor/ViewportPanel.h b/App/editor/ViewportPanel.h index e0c65f6..50fa609 100644 --- a/App/editor/ViewportPanel.h +++ b/App/editor/ViewportPanel.h @@ -35,7 +35,21 @@ struct GizmoState { int op = 7; // ImGuizmo::TRANSLATE(TRANSLATE_X|Y|Z) bool localSpace = true; // 操纵空间:true=模型轴(旋转/缩放语义), // T 键切 world - bool dragging = false; // 拖拽中(IsUsing)→ 鼠标归 Gizmo 不给游戏侧 + bool dragging = false; // 拖拽中(IsUsing 或自绘缩放拖拽)→ 鼠标归 + // Gizmo 不给游戏侧(Editor::BlockingMouse) + + // ---- 自绘缩放 gizmo(op==SCALE 整体不走 ImGuizmo)---- + // 库内缩放的 hover/拖拽依赖"法线=轴"平面求交:正对视角(轴⊥ + // 相机连线)时求交分子恒 0 → 杆抓不到、数值随机猛变;轴∥视线 + // 时又无屏幕位移。submodule 禁改,AxisMask 只能"藏"不能"补" + // → 调用层整体自绘三轴杆 + 中心等比方块(纯屏幕空间数学,任意 + // 视角良构),实现见 ViewportPanel.cpp ScaleGizmoSelf + bool scaleDragActive = false; // 自绘缩放拖拽中 + int scaleDragAxis = -1; // 拖拽目标:0/1/2 轴;-2 = 中心等比 + bool scaleHover = false; // 本帧悬停/拖拽自绘手柄 → 抑制 pick + float scaleStartMouse[2] = {0.0f, 0.0f}; // 拖拽起始鼠标(屏幕系) + float scaleStartDist = 0.0f; // 起始鼠标到杆心距离(径向映射用) + float scaleStartScale[3] = {1.0f, 1.0f, 1.0f}; // 拖拽起始 scale }; class ViewportPanel {