From 15931a2405701a1d508c3b48009c87037481b562 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 00:38:31 +0200 Subject: [PATCH] feat(imgui): embeddable plot widget and demo --- CMakeLists.txt | 4 + CMakePresets.json | 2 + adapters/glfw/CMakeLists.txt | 42 +-- adapters/imgui/CMakeLists.txt | 54 ++++ adapters/imgui/imgui_plot.cpp | 237 ++++++++++++++ adapters/imgui/imgui_plot.hpp | 147 +++++++++ cmake/plot_options.cmake | 50 +++ examples/CMakeLists.txt | 13 + examples/imgui_demo.cpp | 561 ++++++++++++++++++++++++++++++++++ 9 files changed, 1072 insertions(+), 38 deletions(-) create mode 100644 adapters/imgui/CMakeLists.txt create mode 100644 adapters/imgui/imgui_plot.cpp create mode 100644 adapters/imgui/imgui_plot.hpp create mode 100644 examples/imgui_demo.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e2f7a9e..7980263 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,10 @@ if(PLOT_BUILD_GLFW) add_subdirectory(adapters/glfw) endif() +if(PLOT_BUILD_IMGUI) + add_subdirectory(adapters/imgui) +endif() + if(PLOT_BUILD_EXAMPLES) add_subdirectory(examples) endif() diff --git a/CMakePresets.json b/CMakePresets.json index 97cb565..d7b8d4c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -14,6 +14,7 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "PLOT_BUILD_GLFW": true, + "PLOT_BUILD_IMGUI": true, "PLOT_BUILD_EXAMPLES": true } }, @@ -25,6 +26,7 @@ "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "PLOT_BUILD_GLFW": true, + "PLOT_BUILD_IMGUI": true, "PLOT_BUILD_EXAMPLES": true } } diff --git a/adapters/glfw/CMakeLists.txt b/adapters/glfw/CMakeLists.txt index 9ee7345..b7f3790 100644 --- a/adapters/glfw/CMakeLists.txt +++ b/adapters/glfw/CMakeLists.txt @@ -1,44 +1,10 @@ # The GLFW adapter: the reference host for the library. It owns the window, the # context and the loop, and translates GLFW's events into plot::input events. # -# Dependency discovery follows the design spec's order: find_package first, -# pkg-config next, FetchContent only when neither can supply GLFW (the last -# resort, because it needs the network). -set(plot_glfw_dep "") - -find_package(glfw3 3.3 QUIET) -if(TARGET glfw) - set(plot_glfw_dep glfw) -elseif(TARGET glfw3::glfw3) - set(plot_glfw_dep glfw3::glfw3) -endif() - -if(NOT plot_glfw_dep) - find_package(PkgConfig QUIET) - if(PkgConfig_FOUND) - pkg_check_modules(GLFW_PC QUIET IMPORTED_TARGET glfw3) - if(TARGET PkgConfig::GLFW_PC) - set(plot_glfw_dep PkgConfig::GLFW_PC) - endif() - endif() -endif() - -if(NOT plot_glfw_dep) - message(STATUS "plot: glfw3 not found via find_package or pkg-config; fetching it") - include(FetchContent) - set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE) - set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE) - set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) - set(GLFW_INSTALL OFF CACHE BOOL "" FORCE) - FetchContent_Declare(glfw - GIT_REPOSITORY https://github.com/glfw/glfw.git - GIT_TAG 3.4 - GIT_SHALLOW TRUE) - FetchContent_MakeAvailable(glfw) - set(plot_glfw_dep glfw) -endif() - -message(STATUS "plot: GLFW adapter dependency: ${plot_glfw_dep}") +# GLFW is resolved by the shared plot_find_glfw() (cmake/plot_options.cmake), so a +# build that also enables the Dear ImGui adapter -- which needs GLFW for +# imgui_impl_glfw -- discovers (and at worst fetches) a single copy. +plot_find_glfw(plot_glfw_dep) add_library(plot_glfw STATIC glfw_host.cpp) add_library(plot::glfw ALIAS plot_glfw) diff --git a/adapters/imgui/CMakeLists.txt b/adapters/imgui/CMakeLists.txt new file mode 100644 index 0000000..fd5a496 --- /dev/null +++ b/adapters/imgui/CMakeLists.txt @@ -0,0 +1,54 @@ +# The Dear ImGui adapter: a widget that draws a plot into a child region of the +# framebuffer ImGui is rendering its own chrome into, and feeds that plot +# ImGui's mouse and keyboard state. +# +# ImGui is not a packaging dependency here: it is fetched pinned to a release +# tag (never a branch, so a build is reproducible) and only the sources this +# adapter needs are compiled -- the core plus the GLFW and OpenGL 3 backends. +# The fetched sources are third-party code: they are not held to this project's +# warning set, and the adapter and its example are. +include(FetchContent) +FetchContent_Declare(imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.91.9b + GIT_SHALLOW TRUE) +# imgui has no top-level CMakeLists.txt, so MakeAvailable populates it and stops; +# the sources are added to the target below by name. +FetchContent_MakeAvailable(imgui) + +if(NOT imgui_SOURCE_DIR OR NOT EXISTS "${imgui_SOURCE_DIR}/imgui.cpp") + message(FATAL_ERROR + "plot: the Dear ImGui sources for tag v1.91.9b are missing from " + "'${imgui_SOURCE_DIR}'. The FetchContent clone failed, so PLOT_BUILD_IMGUI " + "cannot link anything; either restore network access or pre-populate the " + "source with -DFETCHCONTENT_SOURCE_DIR_IMGUI=.") +endif() + +add_library(imgui STATIC + "${imgui_SOURCE_DIR}/imgui.cpp" + "${imgui_SOURCE_DIR}/imgui_draw.cpp" + "${imgui_SOURCE_DIR}/imgui_tables.cpp" + "${imgui_SOURCE_DIR}/imgui_widgets.cpp" + "${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp" + "${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp") +# Both include roots are public: a consumer of the backends -- the adapter and +# the example -- reaches them as and . +target_include_directories(imgui PUBLIC + "${imgui_SOURCE_DIR}" + "${imgui_SOURCE_DIR}/backends") + +# imgui_impl_glfw.cpp needs GLFW; a build that also enables the GLFW adapter +# gets the same target, never a second copy. +plot_find_glfw(plot_imgui_glfw_dep) +target_link_libraries(imgui PUBLIC ${plot_imgui_glfw_dep}) +target_compile_features(imgui PUBLIC cxx_std_20) + +add_library(plot_imgui STATIC imgui_plot.cpp) +add_library(plot::imgui ALIAS plot_imgui) +# The public include root is `adapters/`, so the header is reached as +# -- the toolkit's name first, like every other adapter's. +target_include_directories(plot_imgui PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..") +# ImGui stays PRIVATE: the adapter's header includes no ImGui header, so a +# consumer of plot::imgui does not inherit the toolkit's headers. +target_link_libraries(plot_imgui PUBLIC plot::core PRIVATE imgui ${plot_imgui_glfw_dep}) +plot_apply_warnings(plot_imgui) diff --git a/adapters/imgui/imgui_plot.cpp b/adapters/imgui/imgui_plot.cpp new file mode 100644 index 0000000..ab9bded --- /dev/null +++ b/adapters/imgui/imgui_plot.cpp @@ -0,0 +1,237 @@ +#include + +#include + +namespace plot::imgui { +namespace { + +/// ImGui's button indices are left, right, middle; the controller's enum is the +/// same three, spelled out. +input::Button button_from_index(int index) noexcept { + switch (index) { + case 1: + return input::Button::Right; + case 2: + return input::Button::Middle; + default: + return input::Button::Left; + } +} + +/// ImGui's modifier booleans as the controller's translatable mask. The bit +/// values are `input::Mod`'s by definition: it is documented as the mask a host +/// translates into, and the GLFW adapter's static_asserts pin the same layout. +u32 mods_from(const ImGuiIO& io) noexcept { + u32 mods = 0; + if (io.KeyShift) mods |= static_cast(input::Mod::Shift); + if (io.KeyCtrl) mods |= static_cast(input::Mod::Ctrl); + if (io.KeyAlt) mods |= static_cast(input::Mod::Alt); + if (io.KeySuper) mods |= static_cast(input::Mod::Super); + return mods; +} + +} // namespace + +void PlotWidget::draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer) { + ImGuiIO& io = ImGui::GetIO(); + + if (!controller_ || plot_ != &plot) { + // The controller holds a Plot& (and the drag state that goes with it), so a + // different plot is a different controller; re-binding drops any drag rather + // than replaying its edges against the new plot. + controller_.emplace(plot); + plot_ = &plot; + reset_pointer(); + } + + // The whole translation below rests on the backend having filled in a sane + // scale; a zero one would collapse the region and silently draw nothing. + IM_ASSERT(io.DisplayFramebufferScale.x > 0.0f && io.DisplayFramebufferScale.y > 0.0f); + + // The child is a plot surface, not a scrollable panel: no scrollbar, whatever + // the item inside it measures. The wheel belongs to the plot while it is + // hovered, and is taken away from window scrolling by the key owner below. + const ImGuiWindowFlags child_flags = ImGuiWindowFlags_NoScrollbar; + + // The child is the plot's surface: it must paint nothing of its own over it. + // ChildBg is transparent in every stock theme, but a host may have pushed a + // panel colour; forcing it here means the widget can never cover its own plot. + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + const bool shown = + ImGui::BeginChild(id, ImVec2(size.x, size.y), ImGuiChildFlags_None, child_flags); + if (!shown) { + // Clipped away entirely (or collapsed): there is no region to draw into, and + // no rect to aim a gesture at. EndChild is still owed. + ImGui::EndChild(); + ImGui::PopStyleColor(); + return; + } + + // A borderless child has no padding and no border, so the content region is + // the child's rectangle itself: the pixels the plot draws are exactly the ones + // the host laid out for it. + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const ImVec2 avail = ImGui::GetContentRegionAvail(); + if (avail.x <= 0.0f || avail.y <= 0.0f) { + // A child with no room (a degenerate layout): there is no region to draw + // into and no rect to aim a gesture at, and InvisibleButton wants a size. + ImGui::EndChild(); + ImGui::PopStyleColor(); + return; + } + + // An invisible item covering the region is what makes it *the* hovered thing: + // `IsItemHovered()` is false while a drag elsewhere in the host is active, so + // the plot does not pan when the user drags a slider next to it, and + // `SetItemKeyOwner()` -- ImGui's documented way for a widget to take the wheel + // away from window scrolling -- needs an item to own it. Both buttons are + // enabled: the controller's left and right drags are the plot's own, and ImGui + // only needs to know that the pointer is interacting with this region. + ImGui::InvisibleButton("surface", ImVec2(avail.x, avail.y), + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | + ImGuiButtonFlags_MouseButtonMiddle); + const bool hovered = ImGui::IsItemHovered(); + ImGui::SetItemKeyOwner(ImGuiKey_MouseWheelY); + + const ImVec2 scale = io.DisplayFramebufferScale; + const f32 fb_w_f = io.DisplaySize.x * scale.x; + const f32 fb_h_f = io.DisplaySize.y * scale.y; + // Points -> framebuffer pixels: the assumption documented on the class. State + // it in the arithmetic so a reader sees where the mapping enters. + const Rect region{origin.x * scale.x, origin.y * scale.y, avail.x * scale.x, avail.y * scale.y}; + + if (region.w > 0.0f && region.h > 0.0f && fb_w_f >= 1.0f && fb_h_f >= 1.0f) { + // One viewport for the gesture geometry and the frame: the controller + // forwards it to the plot (and re-lays it out), and `begin_frame` gets the + // same rect and the whole framebuffer it is a sub-rectangle of. + controller_->set_viewport(region); + forward_input(hovered); + + const u32 fb_w = static_cast(fb_w_f + 0.5f); + const u32 fb_h = static_cast(fb_h_f + 0.5f); + renderer.begin_frame(fb_w, fb_h, region); + renderer.draw(plot); + renderer.end_frame(); + // `end_frame` leaves the GL state neutral and the viewport at the plot's + // rect; ImGui's backend sets its own viewport, blend function and so on + // before its first draw, so the chrome renders correctly after this. + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void PlotWidget::forward_input(bool hovered) { + ImGuiIO& io = ImGui::GetIO(); + const ImVec2 scale = io.DisplayFramebufferScale; + + const f32 px = io.MousePos.x * scale.x; + const f32 py = io.MousePos.y * scale.y; + const bool down[3] = {io.MouseDown[0], io.MouseDown[1], io.MouseDown[2]}; + const bool any_down = down[0] || down[1] || down[2]; + const u32 mods = mods_from(io); + + if (!hovered && !capturing_) { + // Nowhere near the plot: remember the levels anyway, so re-entering the + // region does not synthesise an edge out of the state it left with. + prev_down_[0] = down[0]; + prev_down_[1] = down[1]; + prev_down_[2] = down[2]; + prev_x_ = px; + prev_y_ = py; + have_prev_pos_ = true; + return; + } + + // The movement since the previous frame is what a pan consumes; it rides the + // first event of this frame and nothing else, so one frame's motion can never + // be counted twice. + const f32 dx = have_prev_pos_ ? px - prev_x_ : 0.0f; + const f32 dy = have_prev_pos_ ? py - prev_y_ : 0.0f; + bool delta_pending = true; + + const auto deliver = [&](input::Button button, bool pressed, bool released) { + input::PointerEvent e; + e.x = px; + e.y = py; + if (delta_pending) { + e.dx = dx; + e.dy = dy; + delta_pending = false; + } + e.button = button; + e.mods = mods; + e.down = any_down; + e.pressed = pressed; + e.released = released; + controller_->on_pointer(e); + }; + + // ImGui reports levels; the controller wants edges. One event per button whose + // level changed, which is the press or the release the host's callback would + // have carried. + for (int i = 0; i < 3; ++i) { + if (down[i] == prev_down_[i]) continue; + deliver(button_from_index(i), down[i], !down[i]); + } + if (delta_pending) { + // No transition this frame: one plain move sample, so a pan in progress + // advances by exactly the movement since the last one and + // Controller::hovered_cursor() sees where the pointer is. + deliver(input::Button::Left, false, false); + } + + if (hovered && io.MouseWheel != 0.0f) { + input::WheelEvent e; + e.x = px; + e.y = py; + e.dx = io.MouseWheelH; + e.dy = io.MouseWheel; // ImGui's positive y (scroll up) is the zoom-in sign + e.mods = mods; + controller_->on_wheel(e); + } + + // Keyboard gestures, on the same hover gate as the pointer: a key pressed + // while the plot is under the pointer is aimed at the plot. `+` on most + // layouts is shift+`=`, and either spelling zooms in, as in the GLFW adapter. + if (hovered && !io.WantTextInput) { + const auto key = [&](int code) { + input::KeyEvent e; + e.key = code; + e.mods = mods; + e.down = true; // ImGui already edge-detected the press + controller_->on_key(e); + }; + if (ImGui::IsKeyPressed(ImGuiKey_Equal, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadAdd, false)) { + key(input::kKeyZoomIn); + } + if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) { + key(input::kKeyZoomOut); + } + if (ImGui::IsKeyPressed(ImGuiKey_F, false)) key(input::kKeyFit); + } + + prev_down_[0] = down[0]; + prev_down_[1] = down[1]; + prev_down_[2] = down[2]; + prev_x_ = px; + prev_y_ = py; + have_prev_pos_ = true; + // Capture: a press delivered while the pointer was over the child keeps the + // release flowing even once the pointer has left it. + capturing_ = any_down && (capturing_ || hovered); +} + +void PlotWidget::reset_pointer() noexcept { + prev_down_[0] = false; + prev_down_[1] = false; + prev_down_[2] = false; + prev_x_ = 0.0f; + prev_y_ = 0.0f; + have_prev_pos_ = false; + capturing_ = false; +} + +} // namespace plot::imgui diff --git a/adapters/imgui/imgui_plot.hpp b/adapters/imgui/imgui_plot.hpp new file mode 100644 index 0000000..3ec1b76 --- /dev/null +++ b/adapters/imgui/imgui_plot.hpp @@ -0,0 +1,147 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace plot::imgui { + +/// Embeds a `Plot` in a Dear ImGui window as a child region, and drives that plot +/// from ImGui's own input state. +/// +/// `draw()` opens a child window of the requested size, translates its screen +/// rectangle into framebuffer pixels, hands that rectangle to +/// `Renderer::begin_frame()` and draws the plot into it: a scissor- and +/// viewport-bounded sub-rectangle of the very framebuffer ImGui is about to draw +/// its chrome into, not an offscreen texture. Nothing is rendered through a +/// texture or an FBO, and ImGui's frame is left undisturbed -- the renderer +/// clears only its own rect and restores the GL state it changed, and ImGui's +/// backend re-establishes its viewport and blend state before its first draw. +/// +/// Two pixel spaces meet here, exactly as they do in the GLFW adapter. ImGui +/// lays out and reports in logical *points* -- `io.DisplaySize`, the cursor's +/// screen position, a child's content region -- while this library speaks +/// *framebuffer pixels*. The bridge is `io.DisplayFramebufferScale`, which the +/// platform backend fills in from the window and framebuffer sizes; on an +/// unscaled display it is (1, 1) and nothing changes. +/// +/// ## The coordinate assumption +/// +/// Points -> pixels through `io.DisplayFramebufferScale` is *the whole +/// framebuffer's* mapping only when both of these hold: +/// +/// * the GL viewport the host is drawing its frame with is the whole +/// framebuffer -- origin (0, 0), size `DisplaySize * DisplayFramebufferScale` +/// -- and +/// * the bound draw target is the default framebuffer, not an offscreen one. +/// +/// Under that assumption -- the one every ImGui platform backend documents, and +/// what a window that spans its framebuffer gives -- the child's point rectangle +/// is the pixel rectangle the plot is laid out and scissored into, and a gesture +/// lands on the pixel it was aimed at. A host that renders into an FBO, or with +/// a viewport that is not the whole framebuffer (a tiled compositor, a scaled +/// surface, a pass into a texture), breaks the assumption. This widget cannot +/// detect it: it has no view of the host's viewport and no way to ask. Such a +/// host must place the plot itself -- `Renderer::begin_frame()` takes the +/// framebuffer size and the plot's rect directly, so it needs no widget -- or +/// apply its own points -> pixels mapping on top of `plot.set_viewport()`. +/// +/// ## What the widget draws over +/// +/// The widget renders *immediately*, from inside the child, before ImGui renders +/// its draw data (see the frame order in the demo). It therefore writes the +/// child's pixels before every ImGui command of that frame, and ImGui has no way +/// to be told the child is opaque. The host must not paint over the child's +/// rectangle: +/// +/// * the hosting window must not fill its background across the child -- use +/// `ImGui::SetNextWindowBgAlpha(0.0f)` or `ImGuiWindowFlags_NoBackground` +/// (`ImGuiCol_WindowBg`'s default alpha would otherwise dim the plot), and +/// * no other window may overlap the rectangle with opaque content. +/// +/// The child's own background is forced transparent by the widget, whatever the +/// host's `ImGuiCol_ChildBg`, so the widget itself never paints over the plot. +/// +/// ## Input +/// +/// Pointer, wheel and key state are translated into `plot::input` events and +/// delivered to the plot's `Controller` -- the one the widget owns, reachable +/// through `controller()`. ImGui reports levels (`io.MouseDown`) where the +/// controller wants edges (`PointerEvent::pressed`/`released`), so the widget +/// remembers the previous frame's buttons and synthesises the transitions. +/// +/// Events are forwarded while an invisible item covering the child's region is +/// hovered -- `IsItemHovered()` rather than `IsWindowHovered()`, because the +/// item is the child's whole rectangle (the child has no padding) and its hover +/// is false while the host is dragging some other widget, so dragging a slider +/// next to the plot cannot pan it. A press that started over the child keeps +/// being delivered until every button is up again, so a drag that leaves the +/// region still ends instead of leaving the plot mid-pan. +/// +/// Wheel input goes to the plot while it is hovered, and the plot's zoom wins +/// over ImGui's window scrolling: `SetItemKeyOwner(ImGuiKey_MouseWheelY)` on that +/// invisible item makes ImGui withhold the wheel from the hosting window. The +/// claim takes effect from the frame after it is made, because ImGui decides +/// window scrolling in its own `NewFrame()` before any widget runs -- so a +/// hosting window with a scroll range can still scroll on the very first wheel of +/// a hover that has only just started. (The child's +/// `ImGuiWindowFlags_NoScrollWithMouse` would not achieve this on its own: ImGui +/// deliberately bubbles the wheel up to the parent when a child refuses it.) +/// ImGui consumes `io.MouseWheel` itself at the end of the frame, so a widget +/// cannot consume it any later than this. +/// +/// Not thread-safe: like everything else in the render thread's frame. +class PlotWidget { + public: + PlotWidget() noexcept = default; + PlotWidget(const PlotWidget&) = delete; + PlotWidget& operator=(const PlotWidget&) = delete; + + /// Allocates a child region of `size` (ImGui points) identified by `id`, and + /// renders `plot` into the framebuffer pixels it covers with `renderer`, which + /// must already be initialised on the current context. + /// + /// Must be called between `ImGui::NewFrame()` and `ImGui::Render()`, from a + /// window; the plot is drawn (GL calls issued) during the call, inside the + /// child, so the host's later ImGui commands never have to restore anything. + /// + /// The plot's viewport is the child's content rectangle, so it fills the child + /// exactly; the widget sets it every frame through the controller, and the plot + /// is re-laid out to match. A degenerate region (a collapsed or clipped child) + /// draws nothing and forwards nothing. + void draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer); + + /// The controller the widget feeds, or null before the first `draw()`. It is + /// bound to whichever plot the last `draw()` was given; a `draw()` for a + /// different plot re-binds it and drops any drag in progress. + input::Controller* controller() noexcept { return controller_ ? &*controller_ : nullptr; } + const input::Controller* controller() const noexcept { + return controller_ ? &*controller_ : nullptr; + } + + private: + /// Synthesises this frame's `plot::input` events from ImGui's levels. + void forward_input(bool hovered); + + /// Forgets the previous frame's pointer state, so a re-bound plot cannot see an + /// edge that belongs to the old one. + void reset_pointer() noexcept; + + Plot* plot_ = nullptr; + std::optional controller_; + + /// The previous frame's button levels, in ImGui's order (left, right, middle). + bool prev_down_[3] = {false, false, false}; + f32 prev_x_ = 0.0f; ///< previous pointer position, in framebuffer pixels + f32 prev_y_ = 0.0f; + bool have_prev_pos_ = false; + /// True while a button pressed over the child is still down: keeps the release + /// flowing even after the pointer has left the region. + bool capturing_ = false; +}; + +} // namespace plot::imgui diff --git a/cmake/plot_options.cmake b/cmake/plot_options.cmake index 93ef95e..25e951b 100644 --- a/cmake/plot_options.cmake +++ b/cmake/plot_options.cmake @@ -31,3 +31,53 @@ function(plot_apply_warnings target) -Wshadow) endif() endfunction() + +# Resolves GLFW once for every target that needs it -- the GLFW host adapter and +# the Dear ImGui backend both do. Discovery follows the design spec's order: +# find_package first, pkg-config next, FetchContent only when neither can supply +# GLFW (the last resort, because it needs the network). The result is cached in +# PLOT_GLFW_DEP, so a build with several adapters enabled discovers -- and at +# worst fetches -- exactly one copy. Sets to the target to link. +function(plot_find_glfw out_var) + if(NOT DEFINED PLOT_GLFW_DEP) + set(plot_glfw_dep "") + + find_package(glfw3 3.3 QUIET) + if(TARGET glfw) + set(plot_glfw_dep glfw) + elseif(TARGET glfw3::glfw3) + set(plot_glfw_dep glfw3::glfw3) + endif() + + if(NOT plot_glfw_dep) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(GLFW_PC QUIET IMPORTED_TARGET glfw3) + if(TARGET PkgConfig::GLFW_PC) + set(plot_glfw_dep PkgConfig::GLFW_PC) + endif() + endif() + endif() + + if(NOT plot_glfw_dep) + message(STATUS "plot: glfw3 not found via find_package or pkg-config; fetching it") + include(FetchContent) + set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE) + set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(GLFW_INSTALL OFF CACHE BOOL "" FORCE) + FetchContent_Declare(glfw + GIT_REPOSITORY https://github.com/glfw/glfw.git + GIT_TAG 3.4 + GIT_SHALLOW TRUE) + FetchContent_MakeAvailable(glfw) + set(plot_glfw_dep glfw) + endif() + + set(PLOT_GLFW_DEP "${plot_glfw_dep}" CACHE INTERNAL + "GLFW target the plot adapters link against") + message(STATUS "plot: GLFW adapter dependency: ${plot_glfw_dep}") + endif() + + set(${out_var} "${PLOT_GLFW_DEP}" PARENT_SCOPE) +endfunction() diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index da86c30..03ccea5 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -8,3 +8,16 @@ if(TARGET plot::glfw) target_link_libraries(plot_glfw_demo PRIVATE plot::glfw Threads::Threads) plot_apply_warnings(plot_glfw_demo) endif() + +if(TARGET plot::imgui) + # The demo owns the window, the ImGui context and the frame loop itself, so it + # links the toolkit and its backends directly; the adapter is only the widget. + plot_find_glfw(plot_imgui_demo_glfw_dep) + add_executable(plot_imgui_demo imgui_demo.cpp) + target_link_libraries(plot_imgui_demo PRIVATE + plot::imgui + imgui + ${plot_imgui_demo_glfw_dep} + Threads::Threads) + plot_apply_warnings(plot_imgui_demo) +endif() diff --git a/examples/imgui_demo.cpp b/examples/imgui_demo.cpp new file mode 100644 index 0000000..4e911e7 --- /dev/null +++ b/examples/imgui_demo.cpp @@ -0,0 +1,561 @@ +// The Dear ImGui application: the same stream the GLFW demo draws, embedded as a +// widget inside an ImGui window that also owns a few controls, with ImGui drawing +// its own chrome into the same framebuffer. +// +// What it demonstrates: +// +// - `plot::imgui::PlotWidget`: a child region whose framebuffer rectangle the +// plot renders into directly (no texture, no FBO), while ImGui's chrome is +// drawn around it in the same pass; +// - the demo-side controls the widget deliberately knows nothing about: a +// slider for the producer's ingest rate, a button that injects a spike into +// the `StreamSeries`, and a checkbox toggling the LOD statistics overlay; +// - the frame order the embedding needs -- NewFrame, build UI (the widget draws +// the plot here), ImGui::Render, then the backend's draw data -- and that +// ImGui still renders correctly after the plot renderer has come and gone; +// - the headless smoke mode every demo here supports +// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), whose screenshot this +// demo writes as the *whole* framebuffer (dump_full_framebuffer_ppm below), so +// the PPM holds the ImGui chrome as well as the plot. +// +// The producer is joined before the stream it writes into is destroyed, and +// before the GL context goes away. + +// Before GLFW's header, so it does not include : that header's macros +// would rewrite the enumerators of plot/gl/api.hpp, which this file includes. +#define GLFW_INCLUDE_NONE +#include + +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using plot::f32; +using plot::f64; +using plot::u32; +using plot::u64; + +/// The sample *spacing* of the series: 1 microsecond, as in the GLFW demo. The +/// rate slider below changes how fast samples arrive, not this spacing (a +/// `StreamSeries`' dx is fixed at construction), so a slower ingest simply +/// advances the window more slowly. +constexpr f64 kDx = 1.0e-6; +/// The retained window: 1 << 20 samples = 1.048576 s of samples. +constexpr u64 kCapacity = 1u << 20; +/// Producer wakeup: about a millisecond of samples per push batch. +constexpr u64 kBatch = 1u << 10; +/// Cap on one push batch, so a process stopped and resumed catches up over +/// several iterations instead of allocating a batch for the whole gap. +constexpr u64 kMaxBatch = 1u << 16; +/// Longest the producer sleeps in one go, so a slider move is picked up promptly. +constexpr f64 kMaxSleepS = 0.05; + +constexpr f64 kSineHz = 437.0; +constexpr f64 kNoiseAmp = 0.02; +constexpr f64 kSpikePeriodS = 0.1; +constexpr f64 kSpikeWidthS = 2.0e-6; +constexpr f64 kSpikeAmp = 3.0; +/// The manual spike the button injects: tall enough to be the maximum of every +/// LOD bucket it falls in, so it survives decimation. +constexpr f64 kInjectedSpikeAmp = 5.0; + +/// The trace colour the render smoke test's blue-dominance predicate expects, so +/// the same predicate reads this demo's screenshot. +constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF); +/// The host's own background, behind everything ImGui and the plot do: kept +/// distinct from the renderer's background so the PPM shows that the plot cleared +/// only its own rectangle. +constexpr plot::Color kHostClearColor = plot::Color::hex(0x263145); + +constexpr f32 kWindowWidth = 960.0f; +constexpr f32 kWindowHeight = 680.0f; + +/// y(t) for sample index `i`: sine + deterministic noise + a periodic narrow +/// spike. Same signal as the GLFW demo, so the two screenshots show the same +/// trace from the same source. +f32 sample_at(u64 i) noexcept { + const f64 t = static_cast(i) * kDx; + f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t); + + u32 h = static_cast(i) * 2654435761u; + h ^= h >> 15; + h *= 2246822519u; + h ^= h >> 13; + h *= 3266489917u; + h ^= h >> 16; + y += kNoiseAmp * (static_cast(h & 0xFFFFFFu) / 8388608.0 - 1.0); + + const f64 phase = std::fmod(t, kSpikePeriodS); + if (phase < kSpikeWidthS) { + y += kSpikeAmp * (1.0 - phase / kSpikeWidthS); + } + return static_cast(y); +} + +/// Feeds a `StreamSeries` from its own thread at a settable rate, paced by +/// absolute deadlines off a steady clock: every wakeup produces every sample +/// whose time has already come, so a late wakeup is caught up instead of pushing +/// the phase back. The rate is settable from the render thread, and a requested +/// spike rides the middle sample of the batch in flight. +class Producer { + public: + explicit Producer(plot::data::StreamSeries& series) + : series_(&series), thread_([this] { run(); }) {} + ~Producer() { stop(); } + + Producer(const Producer&) = delete; + Producer& operator=(const Producer&) = delete; + + /// Sets the stop flag and joins the thread. Idempotent; the destructor calls + /// it, so the thread is never detached and never outlives the stream. + void stop() noexcept { + stop_.store(true, std::memory_order_relaxed); + if (thread_.joinable()) thread_.join(); + } + + /// Samples per second the producer aims for. Written by the render thread. + void set_rate(f64 samples_per_second) noexcept { + rate_.store(samples_per_second > 0.0 ? samples_per_second : 1.0, std::memory_order_relaxed); + } + + /// Asks the next full batch to carry one tall sample. Accumulates, so a rapid + /// double click injects two. + void request_spike() noexcept { spikes_.fetch_add(1, std::memory_order_relaxed); } + + /// Samples pushed so far. Written by the producer, read by the render thread. + u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); } + + private: + void run() { + using clock = std::chrono::steady_clock; + const auto start = clock::now(); + u64 next = 0; + while (!stop_.load(std::memory_order_relaxed)) { + const f64 elapsed = std::chrono::duration(clock::now() - start).count(); + const f64 rate = rate_.load(std::memory_order_relaxed); + const u64 due = static_cast(elapsed * rate) + 1; // samples whose time has come + if (due > next) { + u64 n = due - next; + if (n > kMaxBatch) n = kMaxBatch; + batch_.resize(static_cast(n)); + for (u64 k = 0; k < n; ++k) { + batch_[static_cast(k)] = sample_at(next + k); + } + if (n > 0 && spikes_.load(std::memory_order_relaxed) > 0) { + batch_[static_cast(n / 2)] += static_cast(kInjectedSpikeAmp); + spikes_.fetch_sub(1, std::memory_order_relaxed); + } + series_->push(batch_.data(), n); + next += n; + produced_.store(next, std::memory_order_relaxed); + } + // Sleep until a batch past the last produced sample, capped so a rate + // change is noticed quickly instead of at the old rate's next deadline. + const f64 wake_s = + (static_cast(next) + static_cast(kBatch)) / rate; + const f64 sleep_s = wake_s < kMaxSleepS ? wake_s : kMaxSleepS; + std::this_thread::sleep_until( + start + std::chrono::duration_cast(std::chrono::duration(sleep_s))); + } + } + + plot::data::StreamSeries* series_; + std::vector batch_; + std::atomic rate_{1.0e6}; + std::atomic produced_{0}; + std::atomic spikes_{0}; + std::atomic stop_{false}; + std::thread thread_; ///< declared last: the lambda runs once everything is up +}; + +/// The live readout: the achieved push rate, the frame time and the frame count. +struct Meter { + f64 frame_ms = 0.0; ///< exponential moving average of the whole frame + f64 elapsed = 0.0; ///< seconds of loop time + f64 last_report = 0.0; + u64 last_produced = 0; + u64 frames = 0; + f64 rate = 0.0; ///< samples/s over the last metered interval +}; + +/// `glfwGetProcAddress` returns GLFW's `void (*)(void)`, `plot::gl::ProcLoader` +/// returns `void*`: a function pointer respelled as an object pointer. POSIX +/// guarantees the two have the same representation, and the bit copy states that +/// where a `reinterpret_cast` would be merely conditionally supported. +void* resolve_gl(const char* name) noexcept { + GLFWglproc p = glfwGetProcAddress(name); + void* out = nullptr; + static_assert(sizeof(out) == sizeof(p), "function and object pointers must match"); + std::memcpy(&out, &p, sizeof(out)); + return out; +} + +void report_failure(const plot::Status& st, const char* what) noexcept { + std::fprintf(stderr, "plot_imgui_demo: %s: %s: %s\n", what, plot::to_string(st.code), st.message); +} + +/// Writes the *whole* bound framebuffer as a PPM (P6). +/// +/// Demo-side, not library API: `Renderer::dump_frame_ppm()` covers the plot's +/// viewport only, and this smoke mode has to show the ImGui chrome as well. Reads +/// RGBA (4-byte pixels have no row padding whatever the pack alignment) and +/// writes the rows top-down, since glReadPixels hands them back bottom-up. +bool dump_full_framebuffer_ppm(const char* path, int fb_w, int fb_h) { + if (path == nullptr || path[0] == '\0' || fb_w <= 0 || fb_h <= 0) return false; + + const plot::usize w = static_cast(fb_w); + const plot::usize h = static_cast(fb_h); + std::vector pixels(w * h * 4u, 0u); + plot::gl::glPixelStorei(plot::gl::GL_PACK_ALIGNMENT, 4); + plot::gl::glReadPixels(0, 0, fb_w, fb_h, plot::gl::GL_RGBA, plot::gl::GL_UNSIGNED_BYTE, + pixels.data()); + + std::FILE* file = std::fopen(path, "wb"); + if (file == nullptr) return false; + std::fprintf(file, "P6\n%d %d\n255\n", fb_w, fb_h); + + std::vector row(w * 3u); + for (plot::usize y = 0; y < h; ++y) { + const unsigned char* src = pixels.data() + (h - 1u - y) * w * 4u; + for (plot::usize x = 0; x < w; ++x) { + row[x * 3u] = src[x * 4u]; + row[x * 3u + 1u] = src[x * 4u + 1u]; + row[x * 3u + 2u] = src[x * 4u + 2u]; + } + if (std::fwrite(row.data(), 1u, row.size(), file) != row.size()) { + std::fclose(file); + return false; + } + } + return std::fclose(file) == 0; +} + +/// `PLOT_DEMO_EXIT_AFTER_MS` as seconds: 0.0 when unset, unparsable or not +/// positive, which is "run until the window is closed". +f64 exit_after_seconds() noexcept { + const char* v = std::getenv("PLOT_DEMO_EXIT_AFTER_MS"); + if (v == nullptr || v[0] == '\0') return 0.0; + char* end = nullptr; + const f64 ms = std::strtod(v, &end); + if (end == v || !(ms > 0.0)) return 0.0; + return ms * 1.0e-3; +} + +} // namespace + +int main() { + const f64 exit_after_s = exit_after_seconds(); + const bool automated = exit_after_s > 0.0; + const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT"); + + // --- window and 3.3 core context ------------------------------------ + if (glfwInit() != GLFW_TRUE) { + std::fprintf(stderr, "plot_imgui_demo: glfwInit() failed\n"); + return 1; + } + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); + // A run with a deadline is the automated path: it stays off-screen, so a CI run + // never flashes a window on a real display. + if (automated) glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + + GLFWwindow* const window = + glfwCreateWindow(1280, 720, "plot imgui demo", nullptr, nullptr); + if (window == nullptr) { + const char* desc = nullptr; + (void)glfwGetError(&desc); + std::fprintf(stderr, "plot_imgui_demo: glfwCreateWindow() failed: %s\n", + desc != nullptr ? desc : "no error description"); + glfwTerminate(); + return 1; + } + glfwMakeContextCurrent(window); + // The bounded run is not throttled by a display refresh, so its frame times are + // the CPU's and not a vblank's. + glfwSwapInterval(automated ? 0 : 1); + + plot::Status st; + if (!plot::gl::load(&resolve_gl)) { + st = plot::Status{plot::ErrorCode::Unsupported, + "plot::gl::load() failed: missing OpenGL 3.3 entry point"}; + report_failure(st, "gl::load()"); + glfwDestroyWindow(window); + glfwTerminate(); + return 1; + } + + std::optional renderer; + renderer.emplace(); + if (!renderer->init(&st)) { + if (st.code == plot::ErrorCode::Ok) { + st = plot::Status{plot::ErrorCode::GLFailure, "Renderer::init() failed"}; + } + report_failure(st, "Renderer::init()"); + renderer.reset(); + glfwDestroyWindow(window); + glfwTerminate(); + return 1; + } + + // --- Dear ImGui ----------------------------------------------------- + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGui::GetIO().IniFilename = nullptr; // no imgui.ini written next to the binary + ImGui::StyleColorsDark(); + + const bool platform_ok = ImGui_ImplGlfw_InitForOpenGL(window, true); + const bool backend_ok = platform_ok && ImGui_ImplOpenGL3_Init("#version 330 core"); + if (!backend_ok) { + st = plot::Status{plot::ErrorCode::Unsupported, + "Dear ImGui backend init failed (ImGui_ImplGlfw_InitForOpenGL / " + "ImGui_ImplOpenGL3_Init): no GL loader for the current context"}; + report_failure(st, "imgui backend"); + if (platform_ok) ImGui_ImplGlfw_Shutdown(); + ImGui::DestroyContext(); + renderer.reset(); + glfwDestroyWindow(window); + glfwTerminate(); + return 1; + } + + // --- the plot and its stream ---------------------------------------- + plot::data::StreamSeries stream(kCapacity, 0.0, kDx); + Producer producer(stream); + producer.set_rate(1.0e6); + + plot::Plot graph; + const u32 axis_x = graph.add_x_axis( + {"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); + const u32 axis_y = graph.add_y_axis( + {"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); + graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y}); + + plot::imgui::PlotWidget widget; + + // Demo state: the widget knows about none of it. + f32 rate_msamples = 1.0f; + bool show_lod_stats = true; + Meter meter; + const f64 start = glfwGetTime(); + f64 previous = start; + + while (glfwWindowShouldClose(window) == GLFW_FALSE) { + glfwPollEvents(); + + int fb_w = 0; + int fb_h = 0; + glfwGetFramebufferSize(window, &fb_w, &fb_h); + if (fb_w <= 0 || fb_h <= 0) { + // Minimised or otherwise undrawable: nothing to render and no screenshot to + // take. Still honour the deadline, so a bounded run ends rather than idling + // past the one it was given. + if (automated && glfwGetTime() - start >= exit_after_s) { + std::fprintf(stderr, + "[imgui_demo] exit deadline reached with no drawable (framebuffer 0x0);" + " nothing to dump\n"); + break; + } + glfwWaitEventsTimeout(0.05); + continue; + } + + stream.acquire(); // once per frame, on the render thread + + const f64 now = glfwGetTime(); + const f64 dt = now - previous; + previous = now; + + // The host clears its own frame first: the renderer clears only the plot's + // rectangle, so everything outside it is ours to paint. + plot::gl::glViewport(0, 0, fb_w, fb_h); + plot::gl::glClearColor(kHostClearColor.r, kHostClearColor.g, kHostClearColor.b, + kHostClearColor.a); + plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); + + ImGui_ImplGlfw_NewFrame(); + ImGui_ImplOpenGL3_NewFrame(); // builds the font atlas/texture on its first call + ImGui::NewFrame(); + + // Re-frame the live stream while the user has not overridden the ranges: + // auto_fit_* re-arms auto scale, so it may only run while that is still on. + if (graph.x_axis(axis_x).auto_scale) graph.auto_fit_x(); + if (graph.y_axis(axis_y).auto_scale) graph.auto_fit_y(axis_y); + + const std::chrono::steady_clock::time_point frame_begin = + std::chrono::steady_clock::now(); + + ImGui::SetNextWindowPos(ImVec2(24.0f, 24.0f), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(kWindowWidth, kWindowHeight), ImGuiCond_Once); + // NoBackground: the window's own fill would otherwise be drawn over the + // child's rectangle after the widget has already rendered the plot there. + // This is the host's side of the widget's documented precondition. + ImGui::Begin("plot imgui demo", nullptr, ImGuiWindowFlags_NoBackground); + + ImGui::Text("%.3f Msamples/s | %.2f ms/frame | %llu frames | %dx%d fb", + meter.rate * 1.0e-6, meter.frame_ms, + static_cast(meter.frames), fb_w, fb_h); + ImGui::SliderFloat("ingest rate", &rate_msamples, 0.05f, 2.0f, "%.2f Msamples/s"); + ImGui::SameLine(); + if (ImGui::Button("inject spike")) producer.request_spike(); + ImGui::Checkbox("LOD statistics overlay", &show_lod_stats); + ImGui::Separator(); + + producer.set_rate(static_cast(rate_msamples) * 1.0e6); + + // The widget takes the rest of the window, and draws the plot into exactly + // that rectangle of the framebuffer, now, before ImGui renders its chrome. + const ImVec2 plot_origin = ImGui::GetCursorScreenPos(); + const ImVec2 plot_size = ImGui::GetContentRegionAvail(); + widget.draw("plot", graph, plot::Vec2{plot_size.x, plot_size.y}, *renderer); + + if (show_lod_stats) { + // Demo-side overlay: the renderer's own statistics for the frame it just + // drew, painted on top of the plot through ImGui's draw list. Nothing here + // is library API, and the widget is unaware of it. + const plot::FrameStats stats = renderer->stats(); + const plot::Rect axes = std::as_const(graph).layout().axes; + const f64 spp = axes.w > 0.0f ? static_cast(stats.visible_samples) / + static_cast(axes.w) + : 0.0; + char line_a[160]; + char line_b[160]; + std::snprintf(line_a, sizeof line_a, + "LOD %s | %llu samples in window | %.0f samples/px | axes %.0fx%.0f", + stats.lod_traces > 0 ? "pyramid" : (stats.raw_traces > 0 ? "raw" : "--"), + static_cast(stats.visible_samples), spp, + static_cast(axes.w), static_cast(axes.h)); + std::snprintf(line_b, sizeof line_b, + "%u segments | %u draw calls | lod %u raw %u | %.2f ms/frame", + stats.staged_segments, stats.draw_calls, stats.lod_traces, stats.raw_traces, + meter.frame_ms); + + const ImVec2 size_a = ImGui::CalcTextSize(line_a); + const ImVec2 size_b = ImGui::CalcTextSize(line_b); + const ImVec2 pad{6.0f, 4.0f}; + const ImVec2 box{std::max(size_a.x, size_b.x), size_a.y + size_b.y}; + const ImVec2 at{plot_origin.x + 10.0f, plot_origin.y + 10.0f}; + ImDrawList* const dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(ImVec2(at.x - pad.x, at.y - pad.y), + ImVec2(at.x + box.x + pad.x, at.y + box.y + pad.y), + IM_COL32(8, 12, 18, 190), 3.0f); + dl->AddText(at, IM_COL32(235, 240, 248, 255), line_a); + dl->AddText(ImVec2(at.x, at.y + size_a.y), IM_COL32(150, 200, 240, 255), line_b); + } + + ImGui::End(); + + ImGui::Render(); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + meter.frames += 1; + const f64 frame_ms = + std::chrono::duration(std::chrono::steady_clock::now() - frame_begin).count() * 1.0e3; + meter.frame_ms += 0.1 * (frame_ms - meter.frame_ms); // EMA + meter.elapsed += dt; + if (meter.elapsed - meter.last_report >= 0.5) { + const u64 produced = producer.produced(); + meter.rate = static_cast(produced - meter.last_produced) / + (meter.elapsed - meter.last_report); + meter.last_produced = produced; + meter.last_report = meter.elapsed; + const plot::Range xr = graph.x_range(axis_x); + const bool auto_x = graph.x_axis(axis_x).auto_scale; + const bool auto_y = graph.y_axis(axis_y).auto_scale; + std::printf("[imgui_demo] t=%.1fs %.3f Msamples/s %.2f ms/frame %llu frames" + " x=[%.4f, %.4f] s (span %.4f) auto x=%d y=%d\n", + meter.elapsed, meter.rate * 1.0e-6, meter.frame_ms, + static_cast(meter.frames), xr.min, xr.max, xr.span(), + auto_x ? 1 : 0, auto_y ? 1 : 0); + std::fflush(stdout); + } + + const bool exiting = automated && (glfwGetTime() - start) >= exit_after_s; + if (exiting && screenshot != nullptr && screenshot[0] != '\0') { + const bool dumped = dump_full_framebuffer_ppm(screenshot, fb_w, fb_h); + std::printf("[imgui_demo] screenshot %s: %s\n", screenshot, + dumped ? "written" : "FAILED"); + std::fflush(stdout); + } + + glfwSwapBuffers(window); + if (exiting) break; + } + + // --- teardown, in the order the resources depend on each other -------- + int last_fb_w = 0; + int last_fb_h = 0; + int last_window_w = 0; + int last_window_h = 0; + float scale_x = 1.0f; + float scale_y = 1.0f; + glfwGetFramebufferSize(window, &last_fb_w, &last_fb_h); + glfwGetWindowSize(window, &last_window_w, &last_window_h); + glfwGetWindowContentScale(window, &scale_x, &scale_y); + const plot::FrameStats last_stats = renderer->stats(); + const f64 dense_spp = renderer->style().dense_threshold_spp; + const bool persistent_upload = renderer->using_persistent_upload(); + const bool gl_error = renderer->last_frame_had_gl_error(); + + producer.stop(); // no thread pushes into the stream after this point + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImGui::DestroyContext(); + // The renderer's RAII handles call glDelete*, so the context must be current -- + // and alive -- when they run. + renderer.reset(); + glfwDestroyWindow(window); + glfwTerminate(); + + const f64 mean_ms = + meter.frames > 0 ? meter.elapsed / static_cast(meter.frames) * 1.0e3 : 0.0; + std::printf("\n[imgui_demo] ---- run summary ----\n"); + std::printf("[imgui_demo] producer %llu samples pushed, last metered %.3f Msamples/s\n", + static_cast(producer.produced()), meter.rate * 1.0e-6); + std::printf("[imgui_demo] frames %llu in %.3f s, mean %.2f ms/frame (%.1f fps)," + " last metered %.2f ms/frame\n", + static_cast(meter.frames), meter.elapsed, mean_ms, + mean_ms > 0.0 ? 1.0e3 / mean_ms : 0.0, meter.frame_ms); + const plot::Rect last_axes = std::as_const(graph).layout().axes; + const f64 last_spp = last_axes.w > 0.0f ? static_cast(last_stats.visible_samples) / + static_cast(last_axes.w) + : 0.0; + std::printf("[imgui_demo] retained %llu samples, x [%.6f, %.6f] s\n", + static_cast(stream.size()), stream.x_bounds().min, + stream.x_bounds().max); + std::printf("[imgui_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s\n", last_spp, + dense_spp, last_stats.lod_traces > 0 ? "LOD pyramid" : "raw samples"); + std::printf("[imgui_demo] last frame visible_samples %llu, axes %.0fx%.0f, staged_segments %u," + " draw_calls %u, lod %u raw %u\n", + static_cast(last_stats.visible_samples), + static_cast(last_axes.w), static_cast(last_axes.h), + last_stats.staged_segments, last_stats.draw_calls, last_stats.lod_traces, + last_stats.raw_traces); + std::printf("[imgui_demo] pixel spaces framebuffer %dx%d, window %dx%d, content scale %.2fx%.2f\n", + last_fb_w, last_fb_h, last_window_w, last_window_h, static_cast(scale_x), + static_cast(scale_y)); + std::printf("[imgui_demo] upload path %s, gl error on the last frame %d\n", + persistent_upload ? "persistent-mapped ring" : "orphan", gl_error ? 1 : 0); + return 0; +}