imgui icon indicating copy to clipboard operation
imgui copied to clipboard

Maintain consistent scroll when zooming

Open Pontation opened this issue 6 years ago • 18 comments

  • Version/Branch of Dear ImGui: 1.50

  • My Issue/Question: I am trying to make a performance profiler for a game which displays a flame graph for the frames in some interval in a long horizontally scrolled window. The window can be scrolled using the scrollbar or with mouse drag and it supports zooming using the scroll wheel (plan to make this UI fit for mobile).

The problem comes when I zoom because I want the scroll to remain visually in the same place giving a jitter-free experience. From what I can tell, scroll isn't a normalized value, but rather is based on content size which changes greatly as I zoom but not until the frame after the graph has been rendered with the new size. I'm not sure this is correct but I imagine that if I can get the normalized scroll value before the zoom and apply it after the zoom but before anything gets rendered, I should get a smooth feel.

Here is some pseudo code showing what I tried:

RenderFlameGraph() {
    zoom = calcZoom(Imgui::scroll_wheel); 

    ImGui::BeginChild("FlameGraph", ImVec2(0, 0), true, ImGuiWindowFlags_HorizontalScrollbar);

    scroll_normalized = ImGui::GetScrollX()/ImGui::MaxScroll(); // based on old content size

    for(level : stackLevels) {
        for(event : level) {
            ImGui::Button(event.size);
            ImGui::SameLine();
            ImGui::InvisibleButton(event.space_until_next);
            ImGui::SameLine();
            // if zoom changed, buttons and spacing will be different than before
        }
    }

    if(zoomChanged)
        ImGui::SetScrollX(scroll_normalized * ImGui::MaxScroll()); // MaxScroll is still based on old content size

    ImGui::EndChild();
}

So with this code nothing really happens because content size has not yet been updated to take into account the new size of the graph bars, so I'm essentially just doing SetScroll(GetScroll());.

I tried caching scroll_normalized until the next frame and calling SetScroll after the call to Begin("flame") where contentSize is refreshed, and that seems to work decently but then obviously a frame gets rendered with the new content and an old scroll value first and that looks jittery.

I feel like I'm probably missing something but for the life of me I can't see how to solve this without writing my own scroll functionality. Any ideas?

  • Standalone, minimal, complete and verifiable example: I will provide one if you think it is necessary for this case. For now I will hope that you can understand my issue with the explanation and pseudo code.

Pontation avatar Apr 19 '18 12:04 Pontation

Hello,

There's no function called MaxScroll() but I assume you are calling GetScrollMaxX() under the hood.

What I would do is probably to calculate the max scroll yourself at the end of the frame.

if(zoomChanged)
   ImGui::SetScrollX(scroll_normalized * ImGui::MaxScroll()); // MaxScroll is still based on old content size

It'll however require you to take advantage if imgui_internal.h and write a little custom code, something like:

ImGuiWindow* window = g.CurrentWindow;
ImVec2 size_contents = CalcSizeContents(window);
return ImMax(0.0f, size_contentss.x - (window->SizeFull.x - window->ScrollbarSizes.x));

This code may change/break a little over subsequent update but it shouldn't be too hard to maintain. I will hopefully add official helpers to query this data.

Note that window->ScrollbarSizes.x may change and cause you glitches on transition. You may use the ImGuiWindowFlags_AlwaysHorizontalScrollbar flag to keep it stable.

I can't see how to solve this without writing my own scroll functionality.

Bound to note that the "scrolling" functionalities mostly does two things:

  • offset the cursor start position
  • display a scrollbar

So if you don't mind getting rid of the scrollbar (and using mouse button drags to scroll, or a custom scrollbar?) it's only a matter of you calling SetCursorPosX and you have kind of a replacement.

ocornut avatar Apr 19 '18 14:04 ocornut

Thanks for the reply, I did indeed mean GetScrollMaxX. I don't think I understood the custom code you mentioned, where in the sequence of a frame would I do such a thing, in what context?

I found a workaround by simply setting ScrollTarget to a zoomed version of Scroll:

auto* window = ImGui::FindWindowByName("Frame profiler.FlameGraph.E55EDEAD");
window->ScrollTarget.x = window->Scroll.x * newZoom / oldZoom;

The zoom calculations has no dependencies to the graph itself, so I can do all this at the start of the frame before calling ImGui::BeginChild("FlameGraph") which seem to give me a fairly jitter free zoom. Not sure if I should look into changing imgui internals instead.

I don't really need the scrollbar as you mention as long as I can drag and do a "scroll to" based on the frame id, in fact it seems that the scroll button doesn't follow my weird hacks so I will probably just disable it.

I'm happy for any additional input I can get but for now I think I can live with what I've got. Thanks a lot for your support and a great library!

Pontation avatar Apr 19 '18 14:04 Pontation

Thanks for the reply, I did indeed mean GetScrollMaxX. I don't think I understood the custom code you mentioned, where in the sequence of a frame would I do such a thing, in what context?

It would be a replacement for your call to ImGui::SetScrollX(scroll_normalized * ImGui::MaxScroll() before calling End().

As you said "nothing really happens because content size has not yet been updated to take into account the new size of the graph bars", I suggested code to compute maximum scroll value that will be applied next frame.

You shouldn't change imgui internals (you should edit any of the imgui files apart from imconfig.h) but those three lines would rely on calls/structured declared in imgui_internal.h, which is fine to occasionally use.

ocornut avatar Apr 19 '18 14:04 ocornut

Thanks for the clarification, I totally misunderstood you the first time. I tried the solution you suggested but assuming I got the details correct, this also suffers from jitter.

ImGui::BeginChild("FlameGraph", ImVec2(0, 0), true, ImGuiWindowFlags_HorizontalScrollbar);
mScroll = ImGui::GetScrollX() / ImGui::GetScrollMaxX();

// rendering...

if (zoomChanged) {
	ImGuiWindow* window = ImGui::GetCurrentWindow();
	const float size_contents = (float)(int)((window->DC.CursorMaxPos.x - window->Pos.x) + window->Scroll.x);
	const float scroll_max = ImMax(0.0f, size_contents - (window->SizeFull.x - window->ScrollbarSizes.x));
	window->Scroll.x = (mScroll* scroll_max);
}
ImGui::EndChild();

The jitter seems to come from that the scroll change is only really acted upon the next frame, which sort of makes sense, since window->Scroll isn't touched in ImGui::EndChild/End. So it seems I need to set window->ScrollTarget before BeginChild to get any effect there. Did you mean that I should write to a different variable than window->Scroll.x?

Pontation avatar Apr 20 '18 08:04 Pontation

I meant you should call ImGui::SetScrollX() as you did and the scroll will be updated on next frame. I probably also misread you because as I understand this will be problematic in this situation.

Your solution should work well: window->ScrollTarget.x = window->Scroll.x * newZoom / oldZoom;

ocornut avatar Apr 20 '18 08:04 ocornut

Yes ScrollTarget needs to be set before ImGui::BeginChild to work without jitter. Unless you recommend against it, I will keep that solution.

The only problem I have with it, is that the zoom center seems to be the left hand side of the window. It means if I have a flame graph to the left side of the window and I start zooming, the flame graph stays in place as expected, but if I scroll the window so that the flame graph is located on the right side of the window, the flame graphs position starts drifting as I zoom. Should I compensate the scroll target with half the window width or something?

Pontation avatar Apr 20 '18 08:04 Pontation

Should I compensate the scroll target with half the window width or something?

Yes probably. Or you can set ScrollTargetCenterRatio.x to specify how you want it to be aligned in the window.

ocornut avatar Apr 20 '18 09:04 ocornut

Maybe it's my old version of ImGui but it seems ScrollTargetCenterRatio.x is never read.

Pontation avatar Apr 20 '18 09:04 Pontation

Yes that's correct. I forgot you were using an old version. If you are going to use imgui_internal.h stuff you'd better update to latest master.

ocornut avatar Apr 20 '18 09:04 ocornut

Thanks, I was looking for a reason to update anyway :)

Pontation avatar Apr 20 '18 09:04 Pontation

It did not really solve the problem, changing center ratio makes the problem worse, regardless which direction I go. I'd need to investigate a lot more to give you anything concrete to comment on and it may be good enough for me right now, so let's close this issue.

Thank you again for your help!

Pontation avatar Apr 20 '18 12:04 Pontation

@Pontation, I'm having the exact same issue, trying to figure it out now. I'm building a pixel art editor and I'm having a lot of difficulty trying to handle zooming while maintaining the current scroll ratios, did you happen to find any solution?

foxnne avatar Oct 30 '22 16:10 foxnne

@foxnne Unfortunately my project was time boxed and I had to give up before I could figure out a way to solve this particular issue. I remember it being a horrible experience but this was four years ago so hopefully you will have a better time, good luck 😄

Pontation avatar Nov 01 '22 14:11 Pontation

@foxnne

This is possible. Some fiddling with frame code is necessary when using public API. Example is behaving quite ok when zoom step is 2. Using steps with fractional part will lead to odd behaviors due to ImGui flooring scroll values. Replacing use of built-in scrollbar with custom solution will make smooth zooming possible.

image

// Require imgui_internal include for ImVec2 operators, to keep sanity in check
// # define IMGUI_DEFINE_MATH_OPERATORS
// # include <imgui_internal.h>

if (ImGui::Begin("Test"))
{
    // Mock items
    static const int   item_rows    = 100;
    static const int   item_columns = 100;
    static const float item_width  = 100.0f;
    static const float item_height = 100.0f;

    static float zoom          = 1.0f;
    static float new_zoom      = 1.0f;
    static bool  zoom_changed  = false;

    ImGui::BeginChild("Items", ImVec2(0, 0), false,
        ImGuiWindowFlags_NoScrollWithMouse |
        ImGuiWindowFlags_AlwaysVerticalScrollbar |
        ImGuiWindowFlags_AlwaysHorizontalScrollbar
    );

    if (zoom_changed)
    {
        // Apply new zoom in new frame
        zoom         = new_zoom;
        zoom_changed = false;
    }
    else
    {
        if (ImGui::IsWindowHovered()) // Scroll only when cursor is over window
        {
            // Non-integer zoom steps cause artifacts. Zooming is based on
            // ImGui::GetScrollY(). This value is internally in ImGui is floored
            // to integer which make whole operation chunky due to errors.
            const float zoom_step = 2.0f;

            auto& io = ImGui::GetIO(); // Pick new zoom from mouse wheel
            if (io.MouseWheel > 0.0f)
            {
                new_zoom = zoom * zoom_step * io.MouseWheel;
                zoom_changed = true;
            }
            else if (io.MouseWheel < 0.0f)
            {
                new_zoom = zoom / (zoom_step * -io.MouseWheel);
                zoom_changed = true;
            }
        }

        if (zoom_changed)
        {
            auto mouse_position_on_window = ImGui::GetMousePos() - ImGui::GetWindowPos();
            // Nominator is large value, denominator is small. We multiply denominator by 'item_height'
            // to reduce floating point errors.
            auto mouse_position_on_list   = (ImVec2(ImGui::GetScrollX(), ImGui::GetScrollY()) + mouse_position_on_window) / (item_height * zoom);

            // Dummy widget expands window content size for next call to ImGui::BeginChild()
            // will use new dimensions.
            {
                auto origin = ImGui::GetCursorScreenPos();
                ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); // Get item spacing out of the equation.
                ImGui::Dummy(ImVec2(item_rows * ImFloor(item_width * new_zoom), item_columns * ImFloor(item_height * new_zoom)));
                ImGui::PopStyleVar();
                ImGui::SetCursorScreenPos(origin);
            }

            auto new_mouse_position_on_list = mouse_position_on_list * (item_height * new_zoom);
            auto new_scroll                 = new_mouse_position_on_list - mouse_position_on_window;

            // Set new scroll position for next to be used in next ImGui::BeginChild() call.
            ImGui::SetScrollX(new_scroll.x);
            ImGui::SetScrollY(new_scroll.y);
        }
    }

    // Draw items as normal
    ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); // Get item spacing out of the equation.
    ImGuiTextBuffer label;
    for (int row = 0; row < item_rows; ++row)
    {
        for (int column = 0; column < item_columns; ++column)
        {
            ImGui::SetCursorPos(ImFloor(ImVec2(item_width, item_height) * zoom) * ImVec2((float)column, (float)row));
            label.clear();
            label.appendf("%dx%d", column, row);
            ImGui::PushStyleColor(ImGuiCol_Button, (ImVec4)ImColor::HSV(column * 0.05f + row * 0.033f, 0.6f, 0.6f));
            ImGui::Button(label.c_str(), ImVec2(ImFloor(item_width * zoom), ImFloor(item_height * zoom)));
            ImGui::PopStyleColor();
        }
    }
    ImGui::PopStyleVar();

    ImGui::EndChild();
}
ImGui::End();

thedmd avatar Nov 02 '22 04:11 thedmd

@thedmd , Thank you so much for such a detailed example.

I've tried to implement your example, though my project is in a different language (zig), I believe I translated it directly.

                    if (zgui.beginChild(file.path, .{
                        .h = 0.0,
                        .w = 0.0,
                        .border = false,
                        .flags = .{
                            .no_scroll_with_mouse = true,
                            .always_horizontal_scrollbar = true,
                            .always_vertical_scrollbar = true,
                        },
                    })) {
                        const image_width = @intToFloat(f32, file.width);
                        const image_height = @intToFloat(f32, file.height);


                        if (zoom_changed) {
                            file.zoom = new_zoom;
                            zoom_changed = false;
                        } else {
                            if (zgui.isWindowHovered(.{})) {
                                const zoom_step: f32 = 2.0;


                                if (pixi.state.controls.mouse.scrolled and pixi.state.controls.control()) {
                                    const sign = std.math.sign(pixi.state.controls.mouse.scroll);
                                    if (sign > 0.0) {
                                        new_zoom = file.zoom * zoom_step * pixi.state.controls.mouse.scroll;
                                        zoom_changed = true;
                                    } else {
                                        new_zoom = file.zoom / zoom_step * pixi.state.controls.mouse.scroll;
                                        zoom_changed = true;
                                    }
                                }
                            }


                            if (zoom_changed) {
                                const window_pos = zgui.getWindowPos();
                                const mouse_window: [2]f32 = .{ pixi.state.controls.mouse.position.x - window_pos[0], pixi.state.controls.mouse.position.y - window_pos[1] };
                                const scroll: [2]f32 = .{ zgui.getScrollX(), zgui.getScrollY() };
                                const mouse_image: [2]f32 = .{
                                    (scroll[0] + mouse_window[0]) / file.zoom,
                                    (scroll[1] + mouse_window[1]) / file.zoom,
                                };


                                {
                                    const origin = zgui.getCursorScreenPos();
                                    zgui.pushStyleVar2f(.{ .idx = zgui.StyleVar.item_spacing, .v = .{ 0.0, 0.0 } });
                                    defer zgui.popStyleVar(.{ .count = 1 });
                                    zgui.dummy(.{
                                        .w = @floor(image_width * new_zoom),
                                        .h = @floor(image_height * new_zoom),
                                    });
                                    zgui.setCursorScreenPos(origin);
                                }


                                const new_mouse_image: [2]f32 = .{
                                    mouse_image[0] * ((image_width / 10.0) * new_zoom),
                                    mouse_image[1] * ((image_height / 10.0) * new_zoom),
                                };
                                const new_scroll: [2]f32 = .{
                                    new_mouse_image[0] - mouse_window[0],
                                    new_mouse_image[1] - mouse_window[1],
                                };


                                zgui.setScrollX(new_scroll[0]);
                                zgui.setScrollY(new_scroll[1]);
                            }
                        }


                        pixi.state.controls.mouse.scrolled = false;


                        const origin = zgui.getCursorScreenPos();
                        zgui.pushStyleVar2f(.{ .idx = zgui.StyleVar.item_spacing, .v = .{ 0.0, 0.0 } });
                        defer zgui.popStyleVar(.{ .count = 1 });
                        var i: usize = file.layers.items.len;
                        while (i > 0) {
                            i -= 1;
                            const layer = file.layers.items[i];
                            if (pixi.state.gctx.lookupResource(layer.texture_view_handle)) |texture_id| {
                                zgui.setCursorScreenPos(origin);
                                zgui.image(texture_id, .{
                                    .w = image_width * file.zoom,
                                    .h = image_height * file.zoom,
                                    .border_col = .{ 1.0, 1.0, 1.0, 1.0 },
                                });
                            }
                        }
                    }
                }
                zgui.endChild();
            }

However, I'm still getting some really erratic behavior when zooming, the scroll bars jump all over the place and the image isn't predictable. Is there a chance yours works due to using many buttons on the layout and mine is just a singular image?

foxnne avatar Nov 02 '22 20:11 foxnne

if (sign > 0.0) and if (sign < 0.0) was here to make sure both 0 and -0 are ignored. If you can confirm that else work in same way, it is ok.

Where in (image_width / 10.0) * new_zoom 10.0 comes from?

thedmd avatar Nov 02 '22 20:11 thedmd

I can confirm that if (pixi.state.controls.mouse.scrolled and pixi.state.controls.control()) is only true when there was an actual mouse scroll with the zoom key pressed, so the else there should be fine.

I wasn't sure about the item_height in your code, it appears to me to be one button's width and height, which would be 1/10 of the overall dummy width, correct? I was trying to get as close as I could to your code without using individual buttons, as your code here: auto mouse_position_on_list = (ImVec2(ImGui::GetScrollX(), ImGui::GetScrollY()) + mouse_position_on_window) / (item_height * zoom); uses one button's height.

foxnne avatar Nov 02 '22 20:11 foxnne

auto mouse_position_on_list = (ImVec2(ImGui::GetScrollX(), ImGui::GetScrollY()) + mouse_position_on_window) / (item_height * zoom);

item_height can be removed from here and here: auto new_mouse_position_on_list = mouse_position_on_list * (item_height * new_zoom);

In math they just cancel each other. This can be any number, any number can be 1, and if number is one it can be removed from the code because it does not change equation in any way.

This constant is there to help with float precision. When ImGui::GetScrollX/Y() gets huge and zoom values are small math is coming close to loosing precision. In division with big number in nominator, small number in denominator is is not hard to hit precision limit of the float and rounding errors will be introduced. Which mean when you zoom out on big image you will not get back to point you started after zooming in. To mitigate that small denominator is multiplied by some constant. item_height was laying around.

I could to your code without using individual buttons

I made a grid of colored buttons to help be see if zooming works correctly. With one huge button I would not have any clue. :)

In your case you have exactly one "button", so you can easily remove all loops related to row/columns.

thedmd avatar Nov 02 '22 22:11 thedmd

For anyone else stumbling over this helpful example (thanks!).

Using pow allows for frational steps in MouseWheel and results in smoother zooming atleast on my touchpad.

if (io.MouseWheel != 0) {
    new_zoom = zoom * pow(zoom_step, io.MouseWheel);
    zoom_changed = true;
}

nsch0e avatar Oct 09 '23 09:10 nsch0e

FYI i am working on a tiled assets browser example for 1.90 and this is what I am using:

// Zooming with CTRL+Wheel
ImGuiIO& io = ImGui::GetIO();
if (ImGui::IsWindowAppearing())
    ZoomWheelAccum = 0.0f;
if (io.MouseWheel != 0.0f && ImGui::IsKeyDown(ImGuiMod_Shortcut) && ImGui::IsAnyItemActive() == false)
{
    ZoomWheelAccum += io.MouseWheel;
    if (fabsf(ZoomWheelAccum) >= 1.0f)
    {
        IconSize *= powf(1.1f, (float)(int)ZoomWheelAccum);
        IconSize = IM_CLAMP(IconSize, 16.0f, 128.0f);
        ZoomWheelAccum -= (int)ZoomWheelAccum;
    }
}

This will work even better with smooth scrolling mouse.

I will add the scroll tracking on zoom in that demo soon.

ocornut avatar Oct 09 '23 09:10 ocornut

what is the benefit of waiting until the wheel has accumulated 1 full step? (I have no problems using each tiny step)

Also: is there a way to use a pinch gesture for the zooming?

nsch0e avatar Oct 09 '23 15:10 nsch0e

what is the benefit of waiting until the wheel has accumulated 1 full step? (I have no problems using each tiny step)

Right, this isn't fully necessary here. I got the habit to use that idiom when the target value was rounded but it only makes sense the linear modifications anyhow. I'll toy with this again when I get access to a smooth wheel mouse.

Also: is there a way to use a pinch gesture for the zooming?

Pinches are generally converted by OS or backends into mouse wheel events. For example on Windows on a touch-screen, a 2 fingers pinch would emit mouse wheel events unless the application is programmed to handle touch events itself.

ocornut avatar Oct 09 '23 15:10 ocornut