Skip to content

FIX: keep gizmo screen size constant under a perspective camera - #11960

Open
vvzvlad wants to merge 1 commit into
bambulab:masterfrom
vvzvlad:fix/gizmo-screen-size-perspective
Open

FIX: keep gizmo screen size constant under a perspective camera#11960
vvzvlad wants to merge 1 commit into
bambulab:masterfrom
vvzvlad:fix/gizmo-screen-size-perspective

Conversation

@vvzvlad

@vvzvlad vvzvlad commented Aug 20, 2026

Copy link
Copy Markdown

The problem

With default settings the gizmos (move / rotate / scale) do not keep a constant size on screen. Their size depends on where the selected object is relative to the point the camera orbits around. In a project with many plates, the gizmo of an object on a far plate becomes tiny, and it becomes huge when the object is closer to the camera than the orbit point. It also changes while panning the view, without touching the zoom.

Default settings here means gizmo_keep_screen_size = true and the perspective camera, both are defaults (AppConfig.cpp sets gizmo_keep_screen_size to true, Camera.hpp has EType m_type{ EType::Perspective }).

Reports that look like this problem: #5845, #3753, #6806, #5035, #11785.

Where it comes from

Gizmos compute a world size and cancel the zoom with INV_ZOOM = 1 / camera.get_zoom():

// GLGizmoBase.cpp, get_grabber_size()
grabber_size = Grabber::FixedGrabberSize * Grabber::GrabberSizeFactor * INV_ZOOM;

// GLGizmoBase.cpp, modify_radius()
radius  = 0.2f * std::min(t_width, t_height);
radius *= GLGizmoBase::INV_ZOOM;

This cancels the projection only when the projection is orthographic. In Camera::apply_projection() the perspective branch does:

// scale near plane to keep w and h constant on the plane at z = m_distance
const double scale = m_frustrum_zs.first / m_distance;
w *= scale;
h *= scale;

If you expand the resulting frustum matrix, near_z cancels out and the projected size of a world space object becomes

screen_px = world_size * zoom * (m_distance / depth)

zoom is cancelled by INV_ZOOM, but m_distance / depth is not cancelled by anything. m_distance is the distance from the camera to its orbit target, depth is the eye space depth of the object. They are equal only for an object sitting exactly on the orbit plane, and the camera orbits the center of the plate, not the selection. So the further the selected object is from that plane, the more wrong the size is.

Two more things make it move on its own:

  • panning changes the target (camera.set_target(...) in GLCanvas3D.cpp), and with zoom_to_mouse the wheel calls camera.translate(displacement), so depth changes on almost every navigation action;
  • Camera::calc_tight_frustrum_zs_around() calls set_distance(m_distance + delta) when the near plane hits FrustrumMinNearZ, so m_distance itself changes depending on what is inside the frustum.

Numbers

This does not need a build. The script below builds the same glOrtho / glFrustum matrices from the values used in apply_projection(), and projects the gizmo radius produced by modify_radius():

proof.py
#!/usr/bin/env python3
# Reproduces Camera::apply_projection() + GLGizmoBase::modify_radius() with plain arithmetic.
# No OpenGL and no build needed: it only builds the same glOrtho / glFrustum terms the code builds.

VP, ORBIT = (2560, 1440), 1000.0      # viewport in px, Camera::DefaultDistance in mm


def ring_px(depth, perspective, zoom=3.0):
    inv_zoom = 1.0 / zoom
    radius = 0.2 * min(VP) * inv_zoom                  # GLGizmoBase::modify_radius()
    w = 0.5 * VP[0] * inv_zoom                         # Camera::apply_projection()
    near = max(depth - 60.0, 100.0)                    # tight frustum, FrustrumMinNearZ = 100
    if perspective:
        w *= near / ORBIT                              # "scale near plane to keep w and h constant"
        clip_x, clip_w = near * radius / w, depth      # glFrustum
    else:
        clip_x, clip_w = radius / w, 1.0               # glOrtho
    return clip_x / clip_w * 0.5 * VP[0]


print("gizmo ring, expected constant %d px
" % (0.2 * min(VP)))
print("depth mm |    ortho | perspective")
for depth in (250, 500, 1000, 2000, 3000, 4000):
    ortho, persp = ring_px(depth, False), ring_px(depth, True)
    print("%8d | %6.1f px | %7.1f px  (%.2fx)" % (depth, ortho, persp, persp / ortho))

print("
same object at depth 3000, user zooms in and out:")
for zoom in (1.0, 2.0, 4.0, 8.0):
    print("  zoom %4.1f -> %5.1f px" % (zoom, ring_px(3000, True, zoom)))

Output:

gizmo ring, expected constant 288 px

depth mm |    ortho | perspective
     250 |  288.0 px |  1152.0 px  (4.00x)
     500 |  288.0 px |   576.0 px  (2.00x)
    1000 |  288.0 px |   288.0 px  (1.00x)
    2000 |  288.0 px |   144.0 px  (0.50x)
    3000 |  288.0 px |    96.0 px  (0.33x)
    4000 |  288.0 px |    72.0 px  (0.25x)

same object at depth 3000, user zooms in and out:
  zoom  1.0 ->  96.0 px
  zoom  2.0 ->  96.0 px
  zoom  4.0 ->  96.0 px
  zoom  8.0 ->  96.0 px

The orthographic column is exactly 0.2 * min(viewport) at any depth, which is what the code intends. The perspective column is off by exactly orbit_distance / depth, from 4x too big to 4x too small in one scene. The second block changes the zoom by 8x and the size does not move at all, which shows INV_ZOOM compensates the zoom and only the zoom.

Steps to reproduce in the app

  1. View -> Use Perspective View (default).
  2. Put the same object on plate 1 and on a far plate, say plate 10.
  3. Select the one on plate 1, press R, look at the ring. Then select the one on the far plate, press R again.
  4. The ring is much smaller on the far plate, and the arrow heads too.
  5. Now View -> Use Orthogonal View and repeat: both rings are the same size.

Step 5 is the check that this is the projection and not something else, because the sizing code is the same in both cases.

The fix

Cancel the missing term: multiply the screen space sizes by depth / orbit_distance, where depth is the eye space depth of the selection center.

Camera::get_distance() and Camera::get_target() are not const, so they cannot be called from the const render path. But the perspective branch already stores m_gui_scale = near_z / m_distance, so the orbit distance is get_near_z() / get_gui_scale(), and both getters are const.

The correction only applies when it is needed, and stays 1.0f otherwise:

  • orthographic camera: nothing to cancel, the old math is already exact;
  • gizmo_keep_screen_size == false: that path is left bit identical, see the known limits below;
  • nothing selected, or the anchor is in front of the near plane.

The factor is clamped to [0.1, 8.0]. The lower end keeps the grabber model matrix from becoming degenerate (normal_matrix takes an inverse of it), the upper end keeps the gizmo AABB from inflating too much, because it is merged into _max_bounding_box() and ends up in apply_projection() again.

Selection::get_screen_scalling_matrix() gets the same factor. It sizes the sidebar position and rotation hints under the same flag, from the same anchor, and they are drawn one line before the gizmo in the same frame, so leaving it out would make the hint arrows and the gizmo arrows differ in length by the same factor.

Because of that the update moved from _render_current_gizmo() up into render(), right after apply_projection() fills in near_z and gui_scale. Both consumers now read the value of the current frame. _render_current_gizmo() is the only call site of the old assignment, so nothing else loses the refresh.

Existing preferences keep their meaning, grabber_size_factor still scales the same way.

Known limits of this patch

  • The correction uses one anchor per frame, the selection center. Grabbers that sit away from it, the Scale corners, the Cut plane grabber, the Text/SVG cube, keep a residual error proportional to their own depth offset. One anchor keeps the parts of a gizmo proportional to each other, which looked more important than making each grabber exact.
  • With gizmo_keep_screen_size off nothing changes at all. That path is also foreshortened, but there the grabber size and the arrow offset both scale as 1/zoom and stay consistent with each other, so correcting only one of them would make it worse.
  • The gizmo geometry used by the picking pass is still built in on_render(), which runs after _picking_pass(), so hit boxes lag one frame as before. Both passes use the same world sizes, so this patch does not change that, but it may be part of why grabbers are hard to hit while the camera moves (Visual and "object" issue #6806).

Gizmo sizes in the screen size mode are computed in world units and compensated
with INV_ZOOM = 1/zoom, which cancels the projection only when the projection is
orthographic. In Camera::apply_projection the perspective branch scales the
frustum by near_z / m_distance, so the projected size of a world space object is
world_size * zoom * (m_distance / depth). The m_distance / depth term is left
uncompensated, and m_distance is the distance to the camera orbit target, not to
the selection, so the gizmo of an object on a far plate is drawn several times
too small and grows when the object is closer than the orbit point.

Cancel that term with a DEPTH_CORRECTION factor built from the eye space depth
of the selection center. m_gui_scale already holds near_z / m_distance under
perspective, so the orbit distance is available from const getters.

The factor stays 1.0 for an orthographic camera, when gizmo_keep_screen_size is
off, and when there is no selection, so those paths keep the current behaviour.
It is clamped so that a degenerate grabber matrix or an inflated gizmo AABB
cannot feed back into apply_projection.

@Haidiye00 Haidiye00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, please record the GIFs before and after the changes, and then submit them. Thanks.

@guanyun-gudujian guanyun-gudujian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, we already have a constant gizmo screen size. Although there are some artifacts, it is good enough for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants