323 lines
14 KiB
Markdown
323 lines
14 KiB
Markdown
# `plot` — embeddable OpenGL time-series plotting library
|
||
|
||
**Date:** 2026-09-27
|
||
**Status:** approved (design reviewed section-by-section with the requester)
|
||
|
||
## Goal
|
||
|
||
A C++20 library that renders real-time, high-sample-rate time-series plots with
|
||
grid, axes, cursors and measurements, and that can be embedded into an existing
|
||
Qt6, GTK4, Dear ImGui, or raw GLFW application without owning the window, the GL
|
||
context, or the event loop.
|
||
|
||
## Non-goals (this cut)
|
||
|
||
- 3D surfaces, scatter/bar/pie charts, polar plots.
|
||
- Data I/O (CSV/HDF5 readers), file formats, network sources.
|
||
- Font rendering beyond the built-in bitmap atlas.
|
||
- Any host API not listed above.
|
||
|
||
These are explicitly out of scope so the first cut is a coherent vertical slice.
|
||
The layering (§3) leaves room for them without redesign.
|
||
|
||
## Constraints (fixed by the requester)
|
||
|
||
| Constraint | Value |
|
||
|---|---|
|
||
| Core dependencies | **zero.** No GLM, glad, GLEW, or bundled Khronos headers. |
|
||
| GL baseline | **3.3 core**, with a runtime-selected 4.3+ fast path. |
|
||
| Reference host | GLFW, plus ImGui and Qt6 and GTK4 adapters in the same cut. |
|
||
| First deliverable | buildable **end-to-end** skeleton: no stub bodies on the demo path. |
|
||
| Language | C++20, GCC/Clang, Linux first (no portability blockers introduced). |
|
||
|
||
## 1. Environment (verified on the development machine)
|
||
|
||
GCC 16.2.1, CMake 4.4.3, Ninja 1.13.2. Qt6 6.11.2 with `Qt6OpenGLWidgets`,
|
||
GTK4 4.22.5 with `gtkglarea.h`, GLFW 3.5.1, EGL 1.5 + `libEGL_mesa` (surfaceless
|
||
headless contexts available), `glm` and `GL/glew.h` installed but **unused**.
|
||
Dear ImGui is absent and is provided via `FetchContent` (network verified).
|
||
|
||
## 2. The embeddability contract
|
||
|
||
The library never creates a window, never creates or shares a GL context, and
|
||
never runs an event loop. Its render entry point is:
|
||
|
||
```cpp
|
||
plot::Renderer r; // constructed with the caller's context current
|
||
r.init();
|
||
r.begin_frame(fb_w, fb_h, viewport_rect_in_pixels); // in the caller's FBO
|
||
r.draw(plot);
|
||
r.end_frame();
|
||
```
|
||
|
||
Input arrives as plain value types (`PointerEvent`, `WheelEvent`, `KeyEvent`) that
|
||
each adapter translates from its host toolkit. Nothing in `plot::core` names a
|
||
toolkit, so a consumer links only the adapter they need.
|
||
|
||
## 3. Layering
|
||
|
||
```
|
||
adapters/{glfw,imgui,qt6,gtk4} own the surface; translate host events
|
||
↓
|
||
plot/ Plot · Axis · Trace · Grid · Cursor · Measure · Annotation
|
||
↓
|
||
render/ Renderer · Style · TextBackend · primitive batches
|
||
↓
|
||
data/ Series · ArraySeries · StreamSeries · MinMaxPyramid
|
||
↓
|
||
gl/ runtime loader · RAII handles · capability probe
|
||
↓
|
||
core/ + math/ types · Color · Rect · vec2 · mat3 · Transform
|
||
```
|
||
|
||
Each layer depends only downward. `adapters/*` and `examples/*` are optional
|
||
CMake targets guarded by `PLOT_BUILD_*` options and by dependency discovery.
|
||
|
||
## 4. Performance architecture
|
||
|
||
These four decisions are what make 1M+ samples/frame viable. Each replaces an
|
||
approach that fails at high sample rates.
|
||
|
||
### 4.1 Data-space vertices, uniform-only pan and zoom
|
||
|
||
Vertex buffers hold raw *data* coordinates. Screen mapping is
|
||
`x' = x * sx + tx`, `y' = y * sy + ty` evaluated in the vertex shader from a
|
||
`Transform` uniform. Panning and zooming therefore touch one uniform and zero
|
||
vertex data. Only new samples or an LOD-level change cause an upload.
|
||
|
||
Log axes are handled inside the same transform (`log10` before scaling) so no
|
||
separate code path or pre-transformed buffer is needed.
|
||
|
||
### 4.2 Min/max LOD pyramid
|
||
|
||
Every series owns a mip chain: level 0 buckets `kBucket = 64` consecutive samples
|
||
and stores one `(min, max)` pair per bucket; each subsequent level halves the
|
||
bucket count. Memory cost is `n/8` bytes at level 0 (2 floats per 64 samples) times the geometric sum `1 + ½ + ¼ + …`, giving `n/4` bytes total — ≈6% of the 4n bytes of raw samples, not 2×.
|
||
|
||
- Built incrementally: `append()` updates only the last bucket and the O(log n)
|
||
chain above it — amortised O(1) per sample.
|
||
- The renderer picks the level where samples-per-pixel ≈ 2, so a 100M-sample
|
||
series rasterises ≈ `2 · viewport_width` vertices instead of 100M.
|
||
- Buckets preserve extremes, so spikes are never dropped by decimation.
|
||
|
||
### 4.3 Hybrid line rasterisation
|
||
|
||
Chosen per trace, per frame, from observed samples-per-pixel:
|
||
|
||
| samples/px | path |
|
||
|---|---|
|
||
| `< 1.5` | `GL_LINE_STRIP`, 1px, no AA — dense data hides aliasing; ~10× cheaper |
|
||
| `>= 1.5` | instanced anti-aliased quads: one instance per segment, 4 verts expanded in the vertex shader, fragment shader does distance-to-segment SDF for smooth edges and joins |
|
||
|
||
A geometry-shader expansion path was rejected: poor performance and it is the
|
||
one stage macOS cannot be relied on for.
|
||
|
||
### 4.4 Interval reduction reuses the pyramid
|
||
|
||
Cursor measurements (`Δx`, `Δy`, slope, RMS, mean, min/max, integral over a
|
||
dragged region) walk the same min/max chain and return in `O(log n)`; the series
|
||
is never scanned linearly. `pyramid().reduce(a, b)` is the single implementation
|
||
used both by measurement and by auto-scale.
|
||
|
||
### 4.5 Decimation approach — decision
|
||
|
||
| | Approach | Verdict |
|
||
|---|---|---|
|
||
| **A** | **CPU min/max pyramid, incremental** | **chosen** — works on GL 3.3, deterministic, unit-testable with no GPU, O(1) amortised per sample, and it doubles as the measurement index. Cost: one CPU pass per sample, plus ≈6% memory. |
|
||
| B | GPU compute decimation every frame | rejected — re-reads the whole series each frame (real bandwidth at 1M samples/frame), no 3.3 fallback, untestable headlessly. |
|
||
| C | no decimation | rejected — dies above ~500k samples. |
|
||
|
||
The 4.3+ fast path therefore applies to *transfer*, not to decimation: a
|
||
persistent-mapped, `glFenceSync`-guarded triple-buffered ring replaces the
|
||
staging-upload path. Both paths share identical semantics and are selected by
|
||
the capability probe.
|
||
|
||
## 5. Zero-dependency mechanisms
|
||
|
||
- **Math** — hand-written `vec2`, `mat3`, `Transform`: ~250 lines, no templates
|
||
in public headers, no consumer-visible dependency.
|
||
- **GL loader** — a hand-maintained X-macro list `PLOT_GL_FUNCS(X)` covering only
|
||
the ≈60 entry points actually used, with our own typedefs and enums. No
|
||
`glcorearb.h` bundling and no code-generation step. Resolution order:
|
||
context-provided `getProcAddress` (glX / EGL / wgl) → `dlsym`/`GetProcAddress`
|
||
on the system GL library.
|
||
- **Text** — a `TextBackend` interface plus a built-in 8×16 bitmap-glyph atlas.
|
||
Axis labelling needs no third-party code; a FreeType or stb_truetype backend
|
||
can be added behind the same interface without touching callers.
|
||
|
||
There is no `third_party/` directory. Adapters use `find_package` first and
|
||
`FetchContent` only as a fallback (ImGui; GLFW when absent).
|
||
|
||
## 6. Public API sketch
|
||
|
||
Signatures here are the contract the implementation plan and adapters rely on.
|
||
|
||
```cpp
|
||
namespace plot {
|
||
|
||
using f32 = float; using f64 = double;
|
||
using u32 = std::uint32_t; using u64 = std::uint64_t;
|
||
constexpr u64 kInvalid = ~u64{0};
|
||
|
||
struct Range { f64 min, max; };
|
||
struct Rect { f32 x, y, w, h; }; // pixels, origin top-left
|
||
struct Color { f32 r, g, b, a; static Color hex(u32 rgb, f32 a = 1.0f); };
|
||
|
||
enum class ErrorCode { Ok, InvalidArgument, OutOfMemory, GLFailure, Unsupported, NotImplemented };
|
||
struct Status { ErrorCode code = ErrorCode::Ok; const char* message = ""; explicit operator bool() const; };
|
||
|
||
// math/transform.hpp
|
||
struct Transform {
|
||
f64 sx, tx, sy, ty; bool log_x = false, log_y = false;
|
||
f64 to_screen_x(f64) const; f64 to_data_x(f64) const;
|
||
f64 to_screen_y(f64) const; f64 to_data_y(f64) const;
|
||
};
|
||
|
||
namespace gl {
|
||
using ProcLoader = void* (*)(const char*);
|
||
bool load(ProcLoader); // nullptr → platform default
|
||
struct Caps { int major, minor; bool compute, persistent_map, buffer_storage; };
|
||
const Caps& caps();
|
||
class Buffer; class VertexArray; class Program; class Texture; class Framebuffer; class Fence;
|
||
}
|
||
|
||
namespace data {
|
||
struct MinMax { f32 min, max; };
|
||
constexpr u32 kBucket = 64;
|
||
|
||
class MinMaxPyramid {
|
||
public:
|
||
void reserve(u64 n); void append(f32 v); void clear();
|
||
u64 size() const;
|
||
Range reduce(u64 begin, u64 end) const; // O(log n)
|
||
u32 level_for(u64 n, f64 samples_per_pixel) const;
|
||
std::span<const MinMax> level(u32 l) const;
|
||
u64 snap(f64 x, f64 x0, f64 dx) const; // nearest sample index
|
||
};
|
||
|
||
class Series { // abstract
|
||
public:
|
||
virtual ~Series() = default;
|
||
virtual u64 size() const = 0;
|
||
virtual Range x_bounds() const = 0;
|
||
virtual Range y_bounds() const = 0;
|
||
virtual const MinMaxPyramid& pyramid() const = 0;
|
||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const = 0;
|
||
};
|
||
|
||
class ArraySeries final : public Series { // own storage, uniform dx
|
||
public:
|
||
ArraySeries(std::vector<f32> y, f64 x0, f64 dx);
|
||
void reset(std::vector<f32> y, f64 x0, f64 dx);
|
||
void commit(); // rebuild the pyramid
|
||
};
|
||
|
||
class StreamSeries final : public Series { // SPSC, producer + consumer
|
||
public:
|
||
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
|
||
void push(f32 y); // producer thread only
|
||
void push(const f32* ys, u64 n); // producer thread only
|
||
u64 produced() const; // atomic, monotonic
|
||
void acquire(); // consumer: snapshot + extend pyramid
|
||
};
|
||
}
|
||
|
||
enum class AxisPlace { Bottom, Top, Left, Right };
|
||
|
||
struct AxisDesc { std::string label; Range range; bool auto_scale = true; AxisPlace place; bool log = false; };
|
||
struct TraceDesc { const data::Series* series; Color color; f32 width = 1.5f; u32 axis_x = 0, axis_y = 0; };
|
||
|
||
class Plot {
|
||
public:
|
||
u32 add_x_axis(AxisDesc); u32 add_y_axis(AxisDesc);
|
||
u32 add_trace(TraceDesc);
|
||
u32 add_cursor(f64 x, Color);
|
||
u32 add_measure(u32 cursor_a, u32 cursor_b);
|
||
void set_viewport(Rect px);
|
||
void set_x_range(u32 axis, Range); void set_y_range(u32 axis, Range);
|
||
void auto_fit_x(); void auto_fit_y(u32 axis);
|
||
struct Measurement { f64 dx, dy, slope, rms, mean, ymin, ymax, integral; bool valid; };
|
||
const Measurement& measurement(u32 id) const; // recomputed on cursor move
|
||
};
|
||
|
||
class Renderer {
|
||
public:
|
||
bool init(Status* = nullptr); // context must be current
|
||
void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px);
|
||
void draw(Plot&);
|
||
void end_frame();
|
||
Style& style();
|
||
};
|
||
|
||
namespace input {
|
||
enum class Button { Left, Middle, Right };
|
||
enum class Mod { None = 0, Shift = 1, Ctrl = 2, Alt = 4, Super = 8 };
|
||
struct PointerEvent { f32 x, y, dx, dy; Button button; u32 mods; bool down, pressed, released; };
|
||
struct WheelEvent { f32 x, y, dx, dy; u32 mods; };
|
||
struct KeyEvent { int key; u32 mods; bool down; };
|
||
|
||
enum class DragMode { None, Pan, BoxZoom, MoveCursor, AxisZoomX, AxisZoomY };
|
||
class Controller {
|
||
public:
|
||
explicit Controller(Plot&);
|
||
void set_viewport(Rect);
|
||
void on_pointer(const PointerEvent&);
|
||
void on_wheel(const WheelEvent&);
|
||
void on_key(const KeyEvent&);
|
||
DragMode drag_mode() const;
|
||
Rect selection_rect() const; // valid while BoxZoom
|
||
u64 hovered_cursor() const; // kInvalid when none
|
||
};
|
||
}
|
||
} // namespace plot
|
||
```
|
||
|
||
## 7. Threading model
|
||
|
||
- All GL calls happen on the thread that has the context current; `Renderer` is
|
||
not thread-safe and does not need to be.
|
||
- Producers write to `data::StreamSeries` from an acquisition thread through a
|
||
lock-free SPSC ring. No mutex on the sample path.
|
||
- A frame begins with `StreamSeries::acquire()`, which publishes a consistent
|
||
`[0, produced)` snapshot and extends the pyramid by exactly the newly produced
|
||
samples. Readers use the snapshot; the producer never blocks.
|
||
- `Renderer`, `Plot`, and `Controller` are confined to the render thread.
|
||
|
||
## 8. Error handling
|
||
|
||
- No exceptions cross the public boundary. Fallible operations return `Status`
|
||
or `bool` plus an out-parameter `Status*`.
|
||
- GL failures are surfaced as `ErrorCode::GLFailure` from `Renderer::init` and
|
||
recorded on the first occurrence per frame; `PLOT_GL_CHECK()` is a no-op in
|
||
release builds.
|
||
- Unsupported-but-optional capabilities (compute, persistent mapping) degrade
|
||
silently by selecting the 3.3 path; unsupported-and-required ones return
|
||
`ErrorCode::Unsupported` from `init`.
|
||
|
||
## 9. Verification
|
||
|
||
| Layer | How it is verified |
|
||
|---|---|
|
||
| core/math, data, plot/ticks, input | CTest unit tests, no GL required: ring-buffer wrap/overwrite/tearing, pyramid build and `reduce()` vs. brute force, level selection, tick generation, transform round-trip, zoom-anchor invariance, pan invariance. |
|
||
| render, plot | **headless EGL surfaceless context + FBO**, rasterise a known sine, `glReadPixels` and assert ink present in expected columns and background elsewhere; assert the trace covers ≥95% of viewport width at 1M samples. |
|
||
| adapters | Launch each demo (`glfw`, `imgui`, `qt`, `gtk`) on the development display, screenshot, confirm grid + axes + trace + cursor + measurement readout render. |
|
||
|
||
Headless-EGL risk: if Mesa surfaceless context creation fails on this machine the
|
||
fallback is GLX on a hidden window; this is validated in the first GL task, before
|
||
three of the four adapters depend on it.
|
||
|
||
## 10. Milestones
|
||
|
||
1. Repo skeleton, CMake, core+math with tests.
|
||
2. GL loader, handles, capability probe; headless EGL harness proven.
|
||
3. Data layer: ring buffer, pyramid, series.
|
||
4. Render layer: programs, buffers, batching, bitmap text.
|
||
5. Plot layer: axes, ticks, grid, trace, cursor, measurement.
|
||
6. Input layer: events, controller.
|
||
7. Renderer + headless render smoke test (pixel assertions).
|
||
8. GLFW adapter + 1M-sample demo.
|
||
9. ImGui adapter + demo.
|
||
10. Qt6 adapter + demo.
|
||
11. GTK4 adapter + demo.
|
||
12. Install/export, full verification pass, screenshots.
|