feat(glfw): adapter and 1 MS/s streaming demo

This commit is contained in:
Martino Ferrari committed 2026-09-27 23:44:30 +02:00
1 parent 1f5aa11de2
commit 4b649a2783
7 files changed
+884 -2

No files matched your search

+11
View File
@@ -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()
+6 -2
View File
@@ -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
}
}
],
+51
View File
@@ -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
# <glfw/glfw_host.hpp> -- 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)
+346
View File
@@ -0,0 +1,346 @@
#include <glfw/glfw_host.hpp>
// Before GLFW's header, so it does not include <GL/gl.h>: 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 <GLFW/glfw3.h>
#include <plot/gl/loader.hpp>
#include <cstdio>
#include <cstdlib>
#include <cstring>
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<u32>(GLFW_MOD_SHIFT) == static_cast<u32>(input::Mod::Shift));
static_assert(static_cast<u32>(GLFW_MOD_CONTROL) == static_cast<u32>(input::Mod::Ctrl));
static_assert(static_cast<u32>(GLFW_MOD_ALT) == static_cast<u32>(input::Mod::Alt));
static_assert(static_cast<u32>(GLFW_MOD_SUPER) == static_cast<u32>(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<f32>(fb_width_), static_cast<f32>(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<u32>(fw);
if (fh > 0) fb_height_ = static_cast<u32>(fh);
if (ww > 0 && fw > 0) fb_scale_x_ = static_cast<f32>(fw) / static_cast<f32>(ww);
if (wh > 0 && fh > 0) fb_scale_y_ = static_cast<f32>(fh) / static_cast<f32>(wh);
}
// ------------------------------------------------------------------ events
Host* Host::from(GLFWwindow* w) noexcept {
return w != nullptr ? static_cast<Host*>(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<f32>(wx) * fb_scale_x_;
const f32 y = static_cast<f32>(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<f32>(wx) * fb_scale_x_;
e.y = static_cast<f32>(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<u32>(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<f32>(wx) * fb_scale_x_;
e.y = static_cast<f32>(wy) * fb_scale_y_;
e.dx = static_cast<f32>(dx);
e.dy = static_cast<f32>(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<u32>(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
+154
View File
@@ -0,0 +1,154 @@
#pragma once
#include <functional>
#include <optional>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/input/controller.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
/// 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 `<GL/gl.h>`, 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<void(plot::Plot&, f64 dt)>;
/// 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=<n>` stop the loop after n ms and return
/// (default 0: run until the window is closed)
/// `PLOT_DEMO_SCREENSHOT=<path>` 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> 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
+10
View File
@@ -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()
+306
View File
@@ -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 <glfw/glfw_host.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/plot/ticks.hpp>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <numbers>
#include <thread>
#include <utility>
#include <vector>
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<f64>(i) * kDx;
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
u32 h = static_cast<u32>(i) * 2654435761u;
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
y += kNoiseAmp * (static_cast<f64>(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<f32>(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<f64>(clock::now() - start).count();
const u64 due = static_cast<u64>(elapsed / kDx) + 1; // samples whose time has come
if (due > next) {
u64 n = due - next;
if (n > kMaxBatch) n = kMaxBatch;
batch_.resize(static_cast<plot::usize>(n));
for (u64 k = 0; k < n; ++k) {
batch_[static_cast<plot::usize>(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<f64>(next + kBatch) * kDx;
std::this_thread::sleep_until(
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(wake_s)));
}
}
plot::data::StreamSeries* series_;
std::vector<f32> batch_;
std::atomic<u64> produced_{0};
std::atomic<bool> 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<f64>(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<f64>(prev.visible_samples) /
static_cast<f64>(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<unsigned long long>(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<f64>(stats.visible_samples) / static_cast<f64>(axes.w) : 0.0;
const f64 dense = r != nullptr ? static_cast<f64>(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<f64>(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<unsigned long long>(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<unsigned long long>(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<unsigned long long>(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<double>(p.viewport().w), static_cast<double>(p.viewport().h),
static_cast<double>(host.framebuffer_scale_x()),
static_cast<double>(host.framebuffer_scale_y()),
static_cast<double>(host.content_scale_x()),
static_cast<double>(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<unsigned long long>(stats.visible_samples),
static_cast<double>(axes.w), static_cast<double>(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
}