fix(plot,render,gl): live streaming measurements, handed-back GL state

Final whole-branch review fixes.

1 (Important). Plot::measurement() cached on (series pointer, sample count)
only, so a saturated StreamSeries -- whose size() stays pinned while x0()
advances every acquire() -- froze its readout on the first saturated frame
until a cursor moved. The cache key now carries the series' x0() (new
MeasureSlot::last_x0), so a sliding window recomputes and a static series
still does not: asserted with a new macro-gated counter hook
(plot/detail/measure_count.hpp, PLOT_COUNT_MEASURE_RECOMPUTES, wired like the
pyramid and ring counters, absent from the release library). New case
saturated_stream_measurement_tracks_the_sliding_window: saturated ring,
stationary cursors, ramp in absolute x -- zero recomputes for repeated reads
with nothing moving, one per acquire while it slides.

2-3, 9. Spec: 4.3 described a GL_LINE_STRIP path that does not exist (there is
one rasteriser -- instanced anti-aliased quads -- and dense_threshold_spp
selects the gather level, not a primitive); 8 named a PLOT_GL_CHECK() macro
that exists nowhere, and stated no exception policy. The table, the field's
comment and 8 now describe the real gather-level threshold, the real
glGetError() drain plus the stores' drop-to-orphan fallbacks, and the real
policy: library failures return Status/bool, allocating entry points propagate
std::bad_alloc.

4. The frame handed back neither GL_UNPACK_ALIGNMENT (init() sets 1 for the
atlas) nor the clear colour (queried once with a new glGetFloatv entry).
end_frame() restores both and renderer.hpp lists them in the residue
contract.

5. draw() kept a host TextBackend for layout while drawing with its own font.
The renderer now installs its font unconditionally -- strips and labels measure
and draw with one backend -- with a debug-build diagnostic if a different one
was set, and Plot::set_text_backend() is documented as the no-renderer path.
New case renderer_measures_the_labels_it_draws asserts layout, measuring and
the drawn label's ink agree.

6-10. draw()'s style overwrite stated at the call site; the load-sensitive
wall-clock bound in test_measure.cpp replaced by a counter assertion (same
interval, same fold count over a 512x length ratio); dump_frame_ppm drops
noexcept and says why it may allocate; error.txt ignored (not this project's,
not generated here) rather than deleted.

Verified: debug and release build clean (-Wall -Wextra -Wpedantic -Wconversion
-Wshadow), 4/4 ctest entries pass, 130 cases pass (was 127), umbrella and
header self-containment checks pass, and no counter symbol in the release
archive.
This commit is contained in:
Martino Ferrari committed 2026-09-28 03:42:34 +02:00
1 parent f363523195
commit a08b184481
15 files changed
+478 -54

No files matched your search

+2
View File
@@ -37,6 +37,7 @@ enum : GLenum {
// Clear state.
GL_COLOR_BUFFER_BIT = 0x00004000,
GL_COLOR_CLEAR_VALUE = 0x0C22,
// Data types (unsigned short is the index/16-bit data type).
GL_UNSIGNED_BYTE = 0x1401,
@@ -116,6 +117,7 @@ enum : GLenum {
X(const GLubyte*, glGetString, (GLenum)) \
X(const GLubyte*, glGetStringi, (GLenum, GLuint)) \
X(void, glGetIntegerv, (GLenum, GLint*)) \
X(void, glGetFloatv, (GLenum, float*)) \
X(GLenum, glGetError, (void)) \
X(void, glFinish, (void)) \
X(void, glViewport, (GLint, GLint, GLsizei, GLsizei)) \
+4 -1
View File
@@ -172,7 +172,10 @@ class Texture final : public Handle<Texture> {
void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
/// Replaces the whole image with `width * height` bytes of single-channel
/// data (row padding is disabled, so any width is valid).
/// data (row padding is disabled, so any width is valid). It sets
/// `GL_UNPACK_ALIGNMENT` to 1 to do so and leaves it there -- a caller that
/// shares the context with other uploaders saves and restores it, as
/// `Renderer` does around its atlas and its frames.
void upload_r8(u32 width, u32 height, const void* pixels) const noexcept;
/// Binds to `unit` (e.g. `GL_TEXTURE0`).
@@ -0,0 +1,30 @@
#pragma once
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
#include <atomic>
#include <plot/core/types.hpp>
namespace plot::detail {
/// Test-only instrumentation. Counts the measurements `Plot::measurement()` has
/// actually re-evaluated since the counter was last reset.
///
/// Whether a call was a cache hit is invisible in the result -- a measurement
/// frozen on an old window and a current one are both "a Measurement" -- so a
/// test that has to tell "the cache recomputed because the window slid" from
/// "the cache is stuck on the first saturated frame" counts re-evaluations. The
/// counter exists only when PLOT_COUNT_MEASURE_RECOMPUTES is defined (the test
/// build defines it), so every normal build carries no counter and no increment
/// on the measurement path.
///
/// Not part of the public surface and not meant for production use.
inline std::atomic<u64>& measure_recomputes() {
static std::atomic<u64> count{0};
return count;
}
} // namespace plot::detail
#endif // PLOT_COUNT_MEASURE_RECOMPUTES
+31 -13
View File
@@ -68,17 +68,28 @@ class Plot {
void set_viewport(Rect px) noexcept { viewport_ = px; }
const Rect& viewport() const noexcept { return viewport_; }
/// Set from the renderer, which owns presentation: `Renderer::draw()` copies
/// its own `style()` in here every frame, so a style set on the plot is what a
/// plot drawn *without* a renderer lays out with -- a frame drawn by one always
/// uses the renderer's values.
void set_style(const render::Style& s) noexcept { style_ = s; }
const render::Style& style() const noexcept { return style_; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may
/// be null -- then the label areas fall back to PlotLayout's defaults and no
/// string is measured. The caller (the renderer) owns the backend; Plot never
/// creates one and never touches GL.
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string
/// is measured: a plot with no renderer lays out with whatever the host sets
/// here, which is for consumers that draw the plot themselves.
///
/// A plot drawn by a `Renderer` is the resolution the other way round: the
/// renderer draws the labels with its own bitmap font, so it installs that font
/// here on every `draw()` and the strips are measured with the metrics the
/// labels are actually drawn with. A backend installed before the first draw is
/// therefore replaced, with a debug-build diagnostic naming the mismatch.
void set_text_backend(const render::TextBackend* text) noexcept { text_ = text; }
/// The backend layout() measures labels with, or null when none is set. The
/// renderer reads it to install its own font only when nobody else has.
/// The backend layout() measures labels with, or null when none is set. A
/// renderer replaces whatever it finds here with its own font (see
/// set_text_backend()); a plot nobody draws keeps it.
const render::TextBackend* text_backend() const noexcept { return text_; }
// --- construction -----------------------------------------------------
@@ -157,10 +168,13 @@ class Plot {
void set_cursor_snap(u32 id, SnapMode mode);
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when its series changed (its pointer or its sample
/// count) -- never once per frame. An unknown id, a null-series trace, an
/// inverted cursor pair or an empty series yields `valid == false`. const
/// because recomputing fills a cache: nothing a caller can observe changes.
/// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same
/// (`x0()` is the third key -- see MeasureSlot). Never once per frame. A
/// saturated stream therefore keeps reporting live values with a stationary
/// cursor pair. An unknown id, a null-series trace, an inverted cursor pair or
/// an empty series yields `valid == false`. const because recomputing fills a
/// cache: nothing a caller can observe changes.
const Measurement& measurement(u32 id) const;
/// The cursor pair `id` was created over. The renderer needs it to place the
@@ -203,9 +217,12 @@ class Plot {
const Grid& grid() const noexcept { return grid_; }
private:
/// One measure plus its cache. The dirty flag is what keeps measurement()
/// from re-evaluating every frame; `last_series`/`last_size` catch a trace
/// being pointed at a different (or grown) series without anyone telling us.
/// One measure plus its cache. The dirty flag is what keeps measurement() from
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
/// trace being pointed at a different series, a series that grew, and a ring
/// whose window slid under a constant sample count -- the last of which neither
/// of the other two sees, since a saturated stream keeps its size while every
/// sample it holds changes.
struct MeasureSlot {
u32 cursor_a = 0;
u32 cursor_b = 0;
@@ -214,6 +231,7 @@ class Plot {
mutable bool dirty = true;
mutable const data::Series* last_series = nullptr;
mutable u64 last_size = 0;
mutable f64 last_x0 = 0.0;
};
/// Fit one axis to its traces' bounds, leaving auto_scale alone. False when the
+30 -6
View File
@@ -90,11 +90,13 @@ class Renderer {
/// Draws `plot`, refreshing its layout, ticks and grid first.
///
/// The renderer owns the presentation style: `draw()` copies `style()` into the
/// plot, so the strips and tick densities the plot lays out with are the values
/// the renderer draws with -- set colours and metrics here, not on the plot.
/// The plot also borrows this renderer's font (when it has no backend of its
/// own), so the renderer must outlive the plot's use of it.
/// The renderer owns the presentation, style and text both: `draw()` copies
/// `style()` into the plot and installs its own font backend on it, so the strips
/// and tick densities the plot lays out with are the values the renderer draws
/// with -- set colours and metrics here, not on the plot, and expect a backend
/// set with `Plot::set_text_backend()` to be replaced on the first frame (a
/// debug build names the one it dropped). The renderer must therefore outlive the
/// plot's use of it.
///
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
/// it runs the auto scale, the strip fixed-point loop and the grid build, so
@@ -112,6 +114,13 @@ class Renderer {
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and
/// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the
/// viewport left at the frame's rect (the caller sets its own for the next pass).
/// Two pieces of global state the frame *changed* are handed back rather than
/// left where the frame wanted them: `GL_UNPACK_ALIGNMENT`, which `init()` set
/// to 1 for the glyph atlas' single-channel rows, and the clear colour, which is
/// `style().background` only for the duration of the frame. GL keeps no history
/// for the clear colour, so the host's value is queried on the first
/// `begin_frame()`; that first value is what comes back here, so a colour the
/// host changes after the first frame is restored over by it.
/// The framebuffer binding is never touched -- it is the caller's, and so is the
/// pixel-pack alignment if `dump_frame_ppm()` ran.
void end_frame() noexcept;
@@ -152,7 +161,13 @@ class Renderer {
/// window. Reads the framebuffer that is bound now, which is the one the frame
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an
/// unreadable pixel.
bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept;
///
/// The one entry point here that is not `noexcept`: it sizes its readback hole
/// and its output row, so an allocation failure propagates `std::bad_alloc`
/// rather than terminating a process that could have carried on without a
/// screenshot. A dump is a diagnostic, and no `Status` code could say more than
/// the exception already does.
bool dump_frame_ppm(const char* path, Status* st = nullptr) const;
private:
/// The bitmap font as the plot layer sees it, at the size the text pipeline
@@ -250,6 +265,15 @@ class Renderer {
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
/// The host's `GL_UNPACK_ALIGNMENT`, saved by `init()` before the atlas upload
/// set it to 1 and handed back by `end_frame()`. GL's default is 4.
gl::GLint host_unpack_alignment_ = 4;
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
/// has no other way to recover it once the frame has set it) and handed back by
/// `end_frame()`.
Color host_clear_{};
bool host_clear_saved_ = false;
Rect viewport_; ///< the frame's region, top-left origin, framebuffer px
Rect gl_viewport_; ///< the same rect flipped for GL, and for readback
u32 fb_w_ = 0;
+6 -2
View File
@@ -17,8 +17,12 @@ struct Style {
f32 axis_font_px = 12.0f;
f32 grid_label_pad = 4.0f;
f32 line_width_px = 1.5f;
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per pixel above which
///< a trace is drawn from the LOD pyramid.
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per pixel at or above
///< which a trace is gathered from the LOD
///< pyramid instead of from its raw samples.
///< It selects the gather level, never a
///< primitive: both sides draw through the one
///< instanced anti-aliased quad path.
f32 tick_min_px = 60.0f; ///< minimum spacing between major ticks.
};