From 4b649a27837e7ceb93d2ea3a0b27380a1a149056 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 27 Sep 2026 23:44:30 +0200 Subject: [PATCH] feat(glfw): adapter and 1 MS/s streaming demo --- CMakeLists.txt | 11 ++ CMakePresets.json | 8 +- adapters/glfw/CMakeLists.txt | 51 ++++++ adapters/glfw/glfw_host.cpp | 346 +++++++++++++++++++++++++++++++++++ adapters/glfw/glfw_host.hpp | 154 ++++++++++++++++ examples/CMakeLists.txt | 10 + examples/glfw_demo.cpp | 306 +++++++++++++++++++++++++++++++ 7 files changed, 884 insertions(+), 2 deletions(-) create mode 100644 adapters/glfw/CMakeLists.txt create mode 100644 adapters/glfw/glfw_host.cpp create mode 100644 adapters/glfw/glfw_host.hpp create mode 100644 examples/CMakeLists.txt create mode 100644 examples/glfw_demo.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 46b1ca2..0685e31 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,3 +43,14 @@ if(PLOT_BUILD_TESTS) enable_testing() add_subdirectory(tests) endif() + +# Adapters own the surface and translate their host's events; examples link the +# adapter they demo. Both are opt-in, so the core library and its tests still +# build on a bare toolchain. +if(PLOT_BUILD_GLFW) + add_subdirectory(adapters/glfw) +endif() + +if(PLOT_BUILD_EXAMPLES) + add_subdirectory(examples) +endif() diff --git a/CMakePresets.json b/CMakePresets.json index 605785c..97cb565 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -12,7 +12,9 @@ "generator": "Ninja", "binaryDir": "${sourceDir}/build/debug", "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug" + "CMAKE_BUILD_TYPE": "Debug", + "PLOT_BUILD_GLFW": true, + "PLOT_BUILD_EXAMPLES": true } }, { @@ -21,7 +23,9 @@ "generator": "Ninja", "binaryDir": "${sourceDir}/build/release", "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release" + "CMAKE_BUILD_TYPE": "Release", + "PLOT_BUILD_GLFW": true, + "PLOT_BUILD_EXAMPLES": true } } ], diff --git a/adapters/glfw/CMakeLists.txt b/adapters/glfw/CMakeLists.txt new file mode 100644 index 0000000..9ee7345 --- /dev/null +++ b/adapters/glfw/CMakeLists.txt @@ -0,0 +1,51 @@ +# 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}") + +add_library(plot_glfw STATIC glfw_host.cpp) +add_library(plot::glfw ALIAS plot_glfw) +# 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_glfw PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..") +# GLFW stays PRIVATE: the header forward-declares GLFWwindow and includes no GLFW +# header, so a consumer of plot::glfw does not inherit the toolkit. +target_link_libraries(plot_glfw PUBLIC plot::core PRIVATE ${plot_glfw_dep}) +plot_apply_warnings(plot_glfw) diff --git a/adapters/glfw/glfw_host.cpp b/adapters/glfw/glfw_host.cpp new file mode 100644 index 0000000..aed7157 --- /dev/null +++ b/adapters/glfw/glfw_host.cpp @@ -0,0 +1,346 @@ +#include + +// Before GLFW's header, so it does not include : that header's macros +// (GL_COLOR_BUFFER_BIT and friends) would rewrite `plot::gl`'s enumerators of the +// same name. The adapter needs GLFW for the window, the context and the events +// only -- every GL entry point comes from plot::gl::load(). +#define GLFW_INCLUDE_NONE +#include + +#include + +#include +#include +#include + +namespace plot::glfw { +namespace { + +/// GLFW_MOD_* and `plot::input::Mod` are the same bits, by design: the +/// controller documents `Mod` as the host-translatable mask. A key event's mask +/// therefore passes through, and only a pointer or wheel event, whose callbacks +/// carry no mask, has to ask the window for the current key state. +static_assert(static_cast(GLFW_MOD_SHIFT) == static_cast(input::Mod::Shift)); +static_assert(static_cast(GLFW_MOD_CONTROL) == static_cast(input::Mod::Ctrl)); +static_assert(static_cast(GLFW_MOD_ALT) == static_cast(input::Mod::Alt)); +static_assert(static_cast(GLFW_MOD_SUPER) == static_cast(input::Mod::Super)); + +u32 mods_now(GLFWwindow* w) noexcept { + u32 m = 0; + if (glfwGetKey(w, GLFW_KEY_LEFT_SHIFT) == GLFW_PRESS || + glfwGetKey(w, GLFW_KEY_RIGHT_SHIFT) == GLFW_PRESS) { + m |= GLFW_MOD_SHIFT; + } + if (glfwGetKey(w, GLFW_KEY_LEFT_CONTROL) == GLFW_PRESS || + glfwGetKey(w, GLFW_KEY_RIGHT_CONTROL) == GLFW_PRESS) { + m |= GLFW_MOD_CONTROL; + } + if (glfwGetKey(w, GLFW_KEY_LEFT_ALT) == GLFW_PRESS || + glfwGetKey(w, GLFW_KEY_RIGHT_ALT) == GLFW_PRESS) { + m |= GLFW_MOD_ALT; + } + if (glfwGetKey(w, GLFW_KEY_LEFT_SUPER) == GLFW_PRESS || + glfwGetKey(w, GLFW_KEY_RIGHT_SUPER) == GLFW_PRESS) { + m |= GLFW_MOD_SUPER; + } + return m; +} + +bool any_button_down(GLFWwindow* w) noexcept { + return glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_LEFT) == GLFW_PRESS || + glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_RIGHT) == GLFW_PRESS || + glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_MIDDLE) == GLFW_PRESS; +} + +/// `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; +} + +bool fail(Status* st, ErrorCode code, const char* message) noexcept { + if (st != nullptr) { + st->code = code; + st->message = message; + } + return false; +} + +/// `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 + +Host::Host() noexcept : controller_(plot_) {} + +Host::~Host() { close(); } + +void Host::close() noexcept { + // The renderer's RAII handles call glDelete* from their destructors, so the + // context has to be current -- and alive -- when they run. + if (renderer_) { + if (window_ != nullptr) glfwMakeContextCurrent(window_); + renderer_.reset(); + } + if (window_ != nullptr) { + glfwDestroyWindow(window_); + window_ = nullptr; + } + glfwMakeContextCurrent(nullptr); + glfwTerminate(); + have_pointer_ = false; +} + +bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) { + if (st != nullptr) *st = Status{}; + close(); // a second run() starts from a clean window + + if (glfwInit() != GLFW_TRUE) { + return fail(st, ErrorCode::Unsupported, "glfwInit() failed"); + } + + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + // Required on macOS 10.7+, ignored elsewhere. + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); + // A run with a deadline is the automated path, so it stays off-screen: a + // bounded CI run must never flash a window on a real display. + const f64 exit_after_s = exit_after_seconds(); + if (exit_after_s > 0.0) glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + + GLFWwindow* w = glfwCreateWindow(cfg.width > 0 ? cfg.width : 1, + cfg.height > 0 ? cfg.height : 1, + cfg.title != nullptr ? cfg.title : "plot", nullptr, nullptr); + if (w == nullptr) { + const char* desc = nullptr; + (void)glfwGetError(&desc); + std::fprintf(stderr, "plot::glfw: glfwCreateWindow() failed: %s\n", + desc != nullptr ? desc : "no error description"); + close(); + return fail(st, ErrorCode::Unsupported, + "glfwCreateWindow() failed: no OpenGL 3.3 core context (see stderr)"); + } + + window_ = w; + glfwSetWindowUserPointer(w, this); + glfwMakeContextCurrent(w); + glfwSwapInterval(cfg.vsync ? 1 : 0); + glfwSetCursorPosCallback(w, &Host::cursor_pos_cb); + glfwSetMouseButtonCallback(w, &Host::mouse_button_cb); + glfwSetScrollCallback(w, &Host::scroll_cb); + glfwSetKeyCallback(w, &Host::key_cb); + glfwSetFramebufferSizeCallback(w, &Host::framebuffer_size_cb); + refresh_sizes(); + + if (!plot::gl::load(&resolve_gl)) { + close(); + return fail(st, ErrorCode::Unsupported, + "plot::gl::load() failed: the context is missing a required OpenGL 3.3 entry point"); + } + + renderer_.emplace(); + if (!renderer_->init(st)) { + close(); + if (st != nullptr && st->code == ErrorCode::Ok) { + *st = Status{ErrorCode::GLFailure, "Renderer::init() failed"}; + } + return false; + } + + const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT"); + const f64 start = glfwGetTime(); + f64 previous = start; + + while (glfwWindowShouldClose(window_) == GLFW_FALSE) { + glfwPollEvents(); + refresh_sizes(); + + if (fb_width_ == 0 || fb_height_ == 0) { + // Minimised, or a size the compositor reports as zero: nothing to draw + // into. Idle briefly rather than spin, so an exit deadline still lands. + glfwWaitEventsTimeout(0.05); + continue; + } + + // The plot's region is the whole window here, which is what the controller + // and `begin_frame` must agree on: one rect, in framebuffer pixels, set + // before the frame callback runs so it sees the layout it draws into. + const Rect viewport{0.0f, 0.0f, static_cast(fb_width_), static_cast(fb_height_)}; + controller_.set_viewport(viewport); // also Plot::set_viewport, then layout() + + const f64 now = glfwGetTime(); + const f64 dt = now - previous; + previous = now; + if (frame) frame(plot_, dt); + + const bool exiting = exit_after_s > 0.0 && (now - start) >= exit_after_s; + + renderer_->begin_frame(fb_width_, fb_height_, viewport); + renderer_->draw(plot_); + if (exiting && screenshot != nullptr && screenshot[0] != '\0') { + Status shot; + const bool dumped = renderer_->dump_frame_ppm(screenshot, &shot); + std::printf("[glfw] screenshot %s: %s\n", screenshot, + dumped ? "written" : shot.message); + std::fflush(stdout); + } + renderer_->end_frame(); + glfwSwapBuffers(window_); + + if (exiting) break; + } + return true; +} + +void Host::set_title(const char* title) noexcept { + if (window_ != nullptr) glfwSetWindowTitle(window_, title != nullptr ? title : ""); +} + +void Host::refresh_sizes() noexcept { + if (window_ == nullptr) return; + int fw = 0; + int fh = 0; + int ww = 0; + int wh = 0; + glfwGetFramebufferSize(window_, &fw, &fh); + glfwGetWindowSize(window_, &ww, &wh); + float sx = 1.0f; + float sy = 1.0f; + glfwGetWindowContentScale(window_, &sx, &sy); + content_scale_x_ = sx; + content_scale_y_ = sy; + if (fw > 0) fb_width_ = static_cast(fw); + if (fh > 0) fb_height_ = static_cast(fh); + if (ww > 0 && fw > 0) fb_scale_x_ = static_cast(fw) / static_cast(ww); + if (wh > 0 && fh > 0) fb_scale_y_ = static_cast(fh) / static_cast(wh); +} + +// ------------------------------------------------------------------ events + +Host* Host::from(GLFWwindow* w) noexcept { + return w != nullptr ? static_cast(glfwGetWindowUserPointer(w)) : nullptr; +} + +void Host::cursor_pos_cb(GLFWwindow* w, double x, double y) { + if (Host* h = from(w)) h->on_cursor_pos(x, y); +} + +void Host::mouse_button_cb(GLFWwindow* w, int button, int action, int mods) { + if (Host* h = from(w)) h->on_mouse_button(button, action, mods); +} + +void Host::scroll_cb(GLFWwindow* w, double dx, double dy) { + if (Host* h = from(w)) h->on_scroll(dx, dy); +} + +void Host::key_cb(GLFWwindow* w, int key, int scancode, int action, int mods) { + (void)scancode; + if (Host* h = from(w)) h->on_key(key, action, mods); +} + +void Host::framebuffer_size_cb(GLFWwindow* w, int width, int height) { + (void)width; + (void)height; + if (Host* h = from(w)) h->refresh_sizes(); +} + +void Host::on_cursor_pos(f64 wx, f64 wy) noexcept { + // Window coordinates -> framebuffer pixels: the same translation the viewport + // and every other Rect here use, so a gesture lands where it was aimed. + const f32 x = static_cast(wx) * fb_scale_x_; + const f32 y = static_cast(wy) * fb_scale_y_; + input::PointerEvent e; + e.x = x; + e.y = y; + if (have_pointer_) { + e.dx = x - last_pointer_x_; + e.dy = y - last_pointer_y_; + } + last_pointer_x_ = x; + last_pointer_y_ = y; + have_pointer_ = true; + e.mods = mods_now(window_); + e.down = any_button_down(window_); + controller_.on_pointer(e); +} + +void Host::on_mouse_button(int button, int action, int mods) noexcept { + input::Button b; + switch (button) { + case GLFW_MOUSE_BUTTON_LEFT: b = input::Button::Left; break; + case GLFW_MOUSE_BUTTON_MIDDLE: b = input::Button::Middle; break; + case GLFW_MOUSE_BUTTON_RIGHT: b = input::Button::Right; break; + default: return; // the controller has no gesture for the auxiliary buttons + } + double wx = 0.0; + double wy = 0.0; + glfwGetCursorPos(window_, &wx, &wy); + input::PointerEvent e; + e.x = static_cast(wx) * fb_scale_x_; + e.y = static_cast(wy) * fb_scale_y_; + // The press/release is also a pointer sample: the next move's delta starts + // here, or a drag's first motion would include the jump to the click. + last_pointer_x_ = e.x; + last_pointer_y_ = e.y; + have_pointer_ = true; + e.button = b; + e.mods = static_cast(mods); + e.pressed = action == GLFW_PRESS; + e.released = action == GLFW_RELEASE; + e.down = any_button_down(window_); + controller_.on_pointer(e); +} + +void Host::on_scroll(f64 dx, f64 dy) noexcept { + double wx = 0.0; + double wy = 0.0; + glfwGetCursorPos(window_, &wx, &wy); + input::WheelEvent e; + e.x = static_cast(wx) * fb_scale_x_; + e.y = static_cast(wy) * fb_scale_y_; + e.dx = static_cast(dx); + e.dy = static_cast(dy); // GLFW's positive y (scroll up) is the zoom-in sign + e.mods = mods_now(window_); + controller_.on_wheel(e); +} + +void Host::on_key(int key, int action, int mods) noexcept { + int mapped = 0; + switch (key) { + // The controller zooms on '+', which on most layouts is shift+'='; the '=' + // key is mapped to it as well, so both spellings zoom in. + case GLFW_KEY_EQUAL: + case GLFW_KEY_KP_ADD: mapped = input::kKeyZoomIn; break; + case GLFW_KEY_MINUS: + case GLFW_KEY_KP_SUBTRACT: mapped = input::kKeyZoomOut; break; + case GLFW_KEY_F: mapped = input::kKeyFit; break; + case GLFW_KEY_ESCAPE: + if (action == GLFW_PRESS) glfwSetWindowShouldClose(window_, GLFW_TRUE); + return; + default: return; + } + input::KeyEvent e; + e.key = mapped; + e.mods = static_cast(mods); + // GLFW reports auto-repeat as its own action; a held key keeps acting, exactly + // as holding the key on a keyboard does. + e.down = action != GLFW_RELEASE; + controller_.on_key(e); +} + +} // namespace plot::glfw diff --git a/adapters/glfw/glfw_host.hpp b/adapters/glfw/glfw_host.hpp new file mode 100644 index 0000000..ac1b921 --- /dev/null +++ b/adapters/glfw/glfw_host.hpp @@ -0,0 +1,154 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +/// GLFW's window type. Forward-declared on purpose: this header is +/// self-contained and needs no GLFW include directory, so a consumer of the +/// adapter does not inherit the toolkit's headers (and their ``, whose +/// macros would rewrite this library's enumerators). +struct GLFWwindow; + +namespace plot::glfw { + +/// What `Host::run()` opens the window with. +struct RunConfig { + int width = 1280; + int height = 720; + const char* title = "plot"; + bool vsync = true; +}; + +/// One frame of application code, called by `Host::run()` after the viewport is +/// set and before the frame is staged, so it is where data is refreshed. +/// `dt` is the wall time since the previous call, in seconds. +using FrameFn = std::function; + +/// The reference host: owns the GLFW window and its OpenGL 3.3 core context, the +/// GL entry points, the `Renderer`, the `Plot`, the `input::Controller` and the +/// frame loop, so nothing above it names GLFW or GL. +/// +/// Per-frame sequence, in this order: +/// +/// poll events -> controller.set_viewport(framebuffer-pixel rect) -> +/// frame(plot, dt) -> begin_frame -> draw -> [screenshot] -> end_frame -> +/// swap buffers +/// +/// Two pixel spaces meet here and must not be confused. GLFW reports the cursor +/// and the window in *window* coordinates, while the framebuffer -- what +/// `begin_frame` takes and what every `Rect` in this library speaks -- can be +/// larger under a fractional or HiDPI scale, so `glfwGetFramebufferSize()` and +/// `glfwGetWindowSize()` disagree. The host calls `begin_frame` with the +/// framebuffer size and scales every pointer coordinate by the ratio +/// `framebuffer / window`, so a gesture lands on the pixels the plot was laid out +/// in. On an unscaled display the ratio is 1.0 and nothing changes. +/// +/// Headless smoke mode, read once per `run()`: +/// +/// `PLOT_DEMO_EXIT_AFTER_MS=` stop the loop after n ms and return +/// (default 0: run until the window is closed) +/// `PLOT_DEMO_SCREENSHOT=` on that final frame, write it with +/// `Renderer::dump_frame_ppm()` before the swap +/// +/// That is what lets a CI run a GUI library under `xvfb-run`: no display access +/// and no screen capture, just a PPM on disk. +/// +/// A run with a deadline is that automated path, so its window is created hidden +/// (`GLFW_VISIBLE` false): an automated run must not flash a window on whatever +/// display it lands on. It also means the loop is not throttled by a display +/// refresh, so the frame times it reports are the CPU's, not a vblank's. A run +/// without a deadline shows its window, as an interactive application must. +/// +/// Not thread-safe, and confined to the thread that runs `run()`. +class Host { + public: + Host() noexcept; + ~Host(); + Host(const Host&) = delete; + Host& operator=(const Host&) = delete; + + /// Opens the window, makes a 3.3 core context current, resolves the GL entry + /// points and initialises the renderer, then runs the loop until the window is + /// closed or the exit deadline passes. Returns false with `st` filled in when + /// GLFW, the context or `Renderer::init()` fails; a failed run leaves nothing + /// open. + /// + /// On success the window *stays open*, so a caller can stop and join whatever + /// feeds the plot before the GL resources go away: `close()` (or the + /// destructor) releases the renderer, the context and the window, in that + /// order, on this thread. Calling `run()` again reopens the window and keeps + /// the plot. + bool run(const RunConfig& cfg, const FrameFn& frame, Status* st = nullptr); + + /// Releases the renderer and the window. Idempotent, and safe on a host that + /// never ran. + void close() noexcept; + + /// The plot the frame callback populates. Valid for the host's whole life. + Plot& plot() noexcept { return plot_; } + /// The gesture controller the host feeds. Valid for the host's whole life. + input::Controller& controller() noexcept { return controller_; } + + /// The renderer, or null while no window is open. Valid from inside the frame + /// callback and after a successful `run()`; `close()` releases it. + Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; } + + /// Retitles the open window -- from the frame callback, for a live + /// samples/second and frame-time readout. A no-op while no window is open. + void set_title(const char* title) noexcept; + + /// Framebuffer pixels per window pixel, one ratio per axis, as of the last + /// event or frame. 1.0 on an unscaled display. + f32 framebuffer_scale_x() const noexcept { return fb_scale_x_; } + f32 framebuffer_scale_y() const noexcept { return fb_scale_y_; } + /// The window's content scale, one per axis, as of the last frame. + f32 content_scale_x() const noexcept { return content_scale_x_; } + f32 content_scale_y() const noexcept { return content_scale_y_; } + + private: + /// Reads the framebuffer and window sizes and the content scale, refreshing + /// the window -> framebuffer ratios the pointer translation uses. + void refresh_sizes() noexcept; + + /// Callbacks find their host through GLFW's per-window user pointer. + static Host* from(GLFWwindow* w) noexcept; + static void cursor_pos_cb(GLFWwindow* w, double x, double y); + static void mouse_button_cb(GLFWwindow* w, int button, int action, int mods); + static void scroll_cb(GLFWwindow* w, double dx, double dy); + static void key_cb(GLFWwindow* w, int key, int scancode, int action, int mods); + static void framebuffer_size_cb(GLFWwindow* w, int width, int height); + + void on_cursor_pos(f64 wx, f64 wy) noexcept; + void on_mouse_button(int button, int action, int mods) noexcept; + void on_scroll(f64 dx, f64 dy) noexcept; + void on_key(int key, int action, int mods) noexcept; + + Plot plot_; + input::Controller controller_; ///< forwards its viewport to `plot_` + std::optional renderer_; + GLFWwindow* window_ = nullptr; + + u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes() + u32 fb_height_ = 0; + /// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports + /// rather than from the content scale, which under a fractional scale does not + /// round back to the framebuffer size. + f32 fb_scale_x_ = 1.0f; + f32 fb_scale_y_ = 1.0f; + f32 content_scale_x_ = 1.0f; + f32 content_scale_y_ = 1.0f; + /// Last pointer position in framebuffer pixels: the deltas the controller pans + /// with are the movements since the previous event, which GLFW does not report. + f32 last_pointer_x_ = 0.0f; + f32 last_pointer_y_ = 0.0f; + bool have_pointer_ = false; +}; + +} // namespace plot::glfw diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..da86c30 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,10 @@ +# The example programs. Each one is guarded by the adapter it needs, so +# -DPLOT_BUILD_EXAMPLES=ON without -DPLOT_BUILD_GLFW=ON is a no-op rather than a +# configure error. +find_package(Threads REQUIRED) # the GLFW demo's producer thread + +if(TARGET plot::glfw) + add_executable(plot_glfw_demo glfw_demo.cpp) + target_link_libraries(plot_glfw_demo PRIVATE plot::glfw Threads::Threads) + plot_apply_warnings(plot_glfw_demo) +endif() diff --git a/examples/glfw_demo.cpp b/examples/glfw_demo.cpp new file mode 100644 index 0000000..a0b7fbc --- /dev/null +++ b/examples/glfw_demo.cpp @@ -0,0 +1,306 @@ +// The reference application: the whole library running as a real program, fed by +// a 1 MS/s producer thread through the GLFW adapter. +// +// What it demonstrates, and what the other three adapters copy: +// +// - a `StreamSeries` at the retention limit (1 << 20 samples, 1.048576 s at this +// rate) pushed from a background thread and acquired exactly once per frame on +// the render thread; +// - a signal whose decimation correctness is visible: a 437 Hz sine plus a small +// noise term plus a narrow spike every 100 ms. A bucket that kept only its +// first sample would drop the spike; a bucket that keeps its extrema cannot; +// - a plot the user can drive: grid, both axes titled and labelled (the time +// axis with `format_time` clock labels, the value axis with SI ones), one +// trace, two cursors and the measurement readout the plot layer draws itself +// (shaded band plus a `dx/dy/rms/mean` line) -- no drawing code here; +// - the headless smoke mode the adapter implements once +// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), so CI can run this under +// `xvfb-run` and check the frame it wrote. +// +// The producer is joined before the stream it writes into is destroyed, and +// before the host closes the GL context (main's destruction order does that). + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using plot::f32; +using plot::f64; +using plot::u32; +using plot::u64; + +/// One sample per microsecond: 1 MS/s. +constexpr f64 kSampleRate = 1.0e6; +constexpr f64 kDx = 1.0 / kSampleRate; +/// The retained window: 1 << 20 samples = 1.048576 s. A power of two, so the +/// ring never has to round it up. +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 that was stopped and resumed catches up +/// over several iterations instead of allocating a batch for the whole gap. +constexpr u64 kMaxBatch = 1u << 16; + +/// The signal: a few hundred Hz sine, a small noise term, a narrow spike. +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 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); +constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020); +constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8); + +/// y(t) for sample index `i`: sine + deterministic noise + a two-sample spike. +/// +/// The noise hash takes the low 32 bits of the index, so its pattern repeats +/// every ~4295 s at 1 MS/s -- far beyond any run of this demo, and it keeps the +/// sample path allocation-free and branch-light, unlike a PRNG. +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 1 MS/s on average, 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, and the achieved rate cannot drift the way a fixed +/// `sleep_for()` per batch does. +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 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 u64 due = static_cast(elapsed / kDx) + 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); + } + series_->push(batch_.data(), n); + next += n; + produced_.store(next, std::memory_order_relaxed); + } + // Sleep until one batch past the last produced sample: an absolute + // deadline, so the phase is fixed at `start` and never accumulates. + const f64 wake_s = static_cast(next + kBatch) * kDx; + std::this_thread::sleep_until( + start + std::chrono::duration_cast(std::chrono::duration(wake_s))); + } + } + + plot::data::StreamSeries* series_; + std::vector batch_; + std::atomic produced_{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 rate = 0.0; ///< samples/s, refreshed a few times a second + f64 frame_ms = 0.0; ///< exponential moving average of the frame time + f64 elapsed = 0.0; ///< seconds of frame callback time + f64 last_report = 0.0; + u64 last_produced = 0; + u64 frames = 0; +}; + +} // namespace + +int main() { + plot::glfw::Host host; + plot::data::StreamSeries stream(kCapacity, 0.0, kDx); + Producer producer(stream); + + plot::Plot& p = host.plot(); + // Time axis: clock labels, which `TickFormat::Time` routes through + // `format_time`. The value axis keeps SI labels, which is `format_si`. + const u32 axis_x = p.add_x_axis( + {"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time}); + const u32 axis_y = p.add_y_axis( + {"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si}); + const u32 trace = p.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y}); + + // Snapped cursors sit exactly on a sample, so the readout measures samples + // rather than a rounded position between two of them. + const u32 cursor_a = p.add_cursor(0.0, kCursorAColor); + const u32 cursor_b = p.add_cursor(0.0, kCursorBColor); + p.set_cursor_snap(cursor_a, plot::SnapMode::NearestSample); + p.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample); + const u32 measure = p.add_measure(cursor_a, cursor_b, trace); + + Meter meter; + // The cursors ride the live window until the user takes over: the first + // ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale, + // stops them where the user put them. + bool cursors_follow = true; + + const plot::glfw::FrameFn frame = [&](plot::Plot& graph, plot::f64 dt) { + stream.acquire(); // once per frame, on the render thread + + // 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 auto scale is + // still on: calling it after a pan would undo the pan every frame. + 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); + + if (cursors_follow) { + if (host.controller().drag_mode() == plot::input::DragMode::MoveCursor || + !graph.x_axis(axis_x).auto_scale) { + cursors_follow = false; + } else { + const plot::Range xr = graph.x_range(axis_x); + graph.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span()); + graph.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span()); + } + } + + meter.elapsed += dt; + meter.frames += 1; + meter.frame_ms += 0.1 * (dt * 1.0e3 - meter.frame_ms); // EMA + if (meter.elapsed - meter.last_report >= 0.25) { + 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; + + // The title carries the live readout: samples/second, frame time, and the + // samples per pixel that say which rasterisation path the trace took. The + // stats are the previous frame's, since this runs before draw(). + const plot::Renderer* r = host.renderer(); + const plot::FrameStats prev = r != nullptr ? r->stats() : plot::FrameStats{}; + const plot::Rect axes = std::as_const(graph).layout().axes; + const f64 spp = axes.w > 0.0f ? static_cast(prev.visible_samples) / + static_cast(axes.w) + : 0.0; + char title[192]; + std::snprintf(title, sizeof title, + "plot glfw demo | %.3f MS/s | %.2f ms/frame | %.0f samples/px | %s | %llu frames", + meter.rate * 1.0e-6, meter.frame_ms, spp, + prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"), + static_cast(meter.frames)); + host.set_title(title); + + std::printf("[glfw_demo] t=%.1fs %.3f MS/s %.2f ms/frame %.0f samples/px %s\n", + meter.elapsed, meter.rate * 1.0e-6, meter.frame_ms, spp, + prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-")); + std::fflush(stdout); + } + }; + + // vsync on: this is a real application, and the headless mode below is what + // makes it verifiable without a display. + plot::glfw::RunConfig cfg; + cfg.title = "plot glfw demo"; + cfg.vsync = true; + + plot::Status st; + const bool ok = host.run(cfg, frame, &st); + producer.stop(); // no thread pushes into the stream after this point + + if (!ok) { + std::fprintf(stderr, "plot_glfw_demo: %s: %s\n", plot::to_string(st.code), st.message); + return 1; + } + + const plot::Renderer* r = host.renderer(); + const plot::FrameStats stats = r != nullptr ? r->stats() : plot::FrameStats{}; + const plot::Rect axes = std::as_const(p).layout().axes; + const f64 spp = + axes.w > 0.0f ? static_cast(stats.visible_samples) / static_cast(axes.w) : 0.0; + const f64 dense = r != nullptr ? static_cast(r->style().dense_threshold_spp) : 0.0; + const plot::Measurement m = p.measurement(measure); + const f64 mean_ms = + meter.frames > 0 ? meter.elapsed / static_cast(meter.frames) * 1.0e3 : 0.0; + const plot::Range yr = stream.y_bounds(); + + std::printf("\n[glfw_demo] ---- run summary ----\n"); + // The rate is the last metered interval, not produced/elapsed: this thread's + // clock starts when the first frame runs, and the producer starts ~0.4 s + // earlier, still building the window, so a cumulative division would report a + // rate the producer never sustained. + std::printf("[glfw_demo] producer %llu samples pushed, last metered %.3f MS/s\n", + static_cast(producer.produced()), meter.rate * 1.0e-6); + std::printf("[glfw_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); + std::printf("[glfw_demo] retained %llu samples, x [%.6f, %.6f] s, y [%.3f, %.3f]\n", + static_cast(stream.size()), stream.x_bounds().min, + stream.x_bounds().max, yr.min, yr.max); + std::printf("[glfw_demo] pixel spaces viewport (framebuffer px) %.0fx%.0f, window->framebuffer" + " scale %.2fx%.2f, content scale %.2fx%.2f\n", + static_cast(p.viewport().w), static_cast(p.viewport().h), + static_cast(host.framebuffer_scale_x()), + static_cast(host.framebuffer_scale_y()), + static_cast(host.content_scale_x()), + static_cast(host.content_scale_y())); + std::printf("[glfw_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s\n", spp, + dense, stats.lod_traces > 0 ? "LOD pyramid" : "raw samples"); + std::printf("[glfw_demo] last frame visible_samples %llu, axes %.0fx%.0f, staged_segments %u" + ", draw_calls %u, lod %u raw %u\n", + static_cast(stats.visible_samples), + static_cast(axes.w), static_cast(axes.h), stats.staged_segments, + stats.draw_calls, stats.lod_traces, stats.raw_traces); + std::printf("[glfw_demo] measurement %s dx %g s dy %g rms %g mean %g\n", + m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean); + std::printf("[glfw_demo] upload path %s, gl error on the last frame %d\n", + r != nullptr && r->using_persistent_upload() ? "persistent-mapped ring" : "orphan", + r != nullptr && r->last_frame_had_gl_error() ? 1 : 0); + + return 0; // ~producer (no-op after stop), ~stream, then ~host closes GL +}