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:
1 parent
f363523195
commit
a08b184481
15 files changed
+478
-54
No files matched your search
@@ -1,2 +1,5 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
build/
|
build/
|
||||||
|
# Not this project's and not generated by it: a Solarus game-engine message that
|
||||||
|
# happens to sit in the repo root.
|
||||||
|
error.txt
|
||||||
+8
-1
@@ -78,9 +78,16 @@ if(PLOT_BUILD_TESTS)
|
|||||||
# and one fence wait at most, at the start of the frame -- are invisible in the
|
# and one fence wait at most, at the start of the frame -- are invisible in the
|
||||||
# pixels, so a test reads counters instead (see
|
# pixels, so a test reads counters instead (see
|
||||||
# include/plot/render/detail/ring_stats.hpp).
|
# include/plot/render/detail/ring_stats.hpp).
|
||||||
|
#
|
||||||
|
# PLOT_COUNT_MEASURE_RECOMPUTES is the same for the measurement cache: whether
|
||||||
|
# `Plot::measurement()` recomputed or returned a cached result is invisible in
|
||||||
|
# the value it returns, so a test that has to tell a live cache from one frozen
|
||||||
|
# on a stale window counts re-evaluations (see
|
||||||
|
# include/plot/plot/detail/measure_count.hpp).
|
||||||
target_compile_definitions(plot_core PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
target_compile_definitions(plot_core PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
||||||
PLOT_PYRAMID_COUNT_MERGES
|
PLOT_PYRAMID_COUNT_MERGES
|
||||||
PLOT_RING_STATS)
|
PLOT_RING_STATS
|
||||||
|
PLOT_COUNT_MEASURE_RECOMPUTES)
|
||||||
|
|
||||||
enable_testing()
|
enable_testing()
|
||||||
add_subdirectory(tests)
|
add_subdirectory(tests)
|
||||||
|
|||||||
@@ -127,14 +127,27 @@ samples, at every level, for every series that uses the measurement index.
|
|||||||
series rasterises ≈ `2 · viewport_width` vertices instead of 100M.
|
series rasterises ≈ `2 · viewport_width` vertices instead of 100M.
|
||||||
- Buckets preserve extremes, so spikes are never dropped by decimation.
|
- Buckets preserve extremes, so spikes are never dropped by decimation.
|
||||||
|
|
||||||
### 4.3 Hybrid line rasterisation
|
### 4.3 One rasteriser, two sampling levels
|
||||||
|
|
||||||
Chosen per trace, per frame, from observed samples-per-pixel:
|
Every trace, every frame, is drawn by one path: instanced anti-aliased quads.
|
||||||
|
One instance per segment, four corners expanded from `gl_VertexID` in the vertex
|
||||||
|
shader, a fragment shader that does distance-to-segment SDF for smooth edges and
|
||||||
|
joins. There is no second primitive anywhere in the renderer — in particular no
|
||||||
|
`GL_LINE_STRIP` path — so a sparse trace and a dense one differ in the geometry
|
||||||
|
*staged*, never in the call that draws it.
|
||||||
|
|
||||||
| samples/px | path |
|
What the samples-per-pixel threshold selects is therefore the gather level:
|
||||||
|
|
||||||
|
| samples/px | geometry staged |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `< 1.5` | `GL_LINE_STRIP`, 1px, no AA — dense data hides aliasing; ~10× cheaper |
|
| `< dense_threshold_spp` (1.5) | the visible window's raw samples: a level-0 `Series::gather()`, a polyline through the window, one instance per sample pair |
|
||||||
| `>= 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 |
|
| `>= dense_threshold_spp` (1.5) | the pyramid level whose buckets are ~1 per pixel (`pyramid().level_for(n, samples_per_pixel)`): a min/max envelope pair per bucket, so O(pixels) instances instead of O(samples) |
|
||||||
|
|
||||||
|
`Style::dense_threshold_spp` is that threshold and nothing more: it picks the
|
||||||
|
gather level, changes no draw call, and the two sides of it are the same shader.
|
||||||
|
The dense side is where the work is saved (a 100M-sample window stages O(pixels)
|
||||||
|
vertices); the sparse side stages the window itself, which at ~1 sample per pixel
|
||||||
|
is ~1 instance per column anyway.
|
||||||
|
|
||||||
A geometry-shader expansion path was rejected: poor performance and it is the
|
A geometry-shader expansion path was rejected: poor performance and it is the
|
||||||
one stage macOS cannot be relied on for.
|
one stage macOS cannot be relied on for.
|
||||||
@@ -364,11 +377,24 @@ namespace input {
|
|||||||
|
|
||||||
## 8. Error handling
|
## 8. Error handling
|
||||||
|
|
||||||
- No exceptions cross the public boundary. Fallible operations return `Status`
|
- Library-originated failures never throw: every one of them returns `Status`
|
||||||
or `bool` plus an out-parameter `Status*`.
|
(or `bool` plus an out-parameter `Status*`), and no code path reports an error
|
||||||
|
by throwing. An allocation failure is the host's process running out of memory,
|
||||||
|
not a library error, and the entry points that allocate host memory --
|
||||||
|
constructing a series, `Plot::add_*`, a frame that stages geometry,
|
||||||
|
`Renderer::dump_frame_ppm()` -- propagate `std::bad_alloc` from the container
|
||||||
|
that ran out. They are therefore not annotated `noexcept`; the entries that can
|
||||||
|
neither fail nor allocate are, so the surface says per entry point what it does
|
||||||
|
rather than claiming a blanket `noexcept`.
|
||||||
- GL failures are surfaced as `ErrorCode::GLFailure` from `Renderer::init` and
|
- 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
|
recorded for the frame: `Renderer::end_frame()` drains `glGetError()` in a loop
|
||||||
release builds.
|
and sets the flag `last_frame_had_gl_error()` reports, so the first error cannot
|
||||||
|
hide the ones after it and a stale error cannot be attributed to a later frame.
|
||||||
|
The instance stores report their own refusals through the same flag: a storage,
|
||||||
|
map or fence the driver refuses drops that store to the orphan-and-refill
|
||||||
|
baseline (see §4.5) and marks the frame. There is no `PLOT_GL_CHECK()` macro and
|
||||||
|
no build-dependent error checking -- the drain runs in every build, and it is
|
||||||
|
the flag, not a macro, that a host observes.
|
||||||
- Unsupported-but-optional capabilities (compute, persistent mapping) degrade
|
- Unsupported-but-optional capabilities (compute, persistent mapping) degrade
|
||||||
silently by selecting the 3.3 path; unsupported-and-required ones return
|
silently by selecting the 3.3 path; unsupported-and-required ones return
|
||||||
`ErrorCode::Unsupported` from `init`.
|
`ErrorCode::Unsupported` from `init`.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ enum : GLenum {
|
|||||||
|
|
||||||
// Clear state.
|
// Clear state.
|
||||||
GL_COLOR_BUFFER_BIT = 0x00004000,
|
GL_COLOR_BUFFER_BIT = 0x00004000,
|
||||||
|
GL_COLOR_CLEAR_VALUE = 0x0C22,
|
||||||
|
|
||||||
// Data types (unsigned short is the index/16-bit data type).
|
// Data types (unsigned short is the index/16-bit data type).
|
||||||
GL_UNSIGNED_BYTE = 0x1401,
|
GL_UNSIGNED_BYTE = 0x1401,
|
||||||
@@ -116,6 +117,7 @@ enum : GLenum {
|
|||||||
X(const GLubyte*, glGetString, (GLenum)) \
|
X(const GLubyte*, glGetString, (GLenum)) \
|
||||||
X(const GLubyte*, glGetStringi, (GLenum, GLuint)) \
|
X(const GLubyte*, glGetStringi, (GLenum, GLuint)) \
|
||||||
X(void, glGetIntegerv, (GLenum, GLint*)) \
|
X(void, glGetIntegerv, (GLenum, GLint*)) \
|
||||||
|
X(void, glGetFloatv, (GLenum, float*)) \
|
||||||
X(GLenum, glGetError, (void)) \
|
X(GLenum, glGetError, (void)) \
|
||||||
X(void, glFinish, (void)) \
|
X(void, glFinish, (void)) \
|
||||||
X(void, glViewport, (GLint, GLint, GLsizei, GLsizei)) \
|
X(void, glViewport, (GLint, GLint, GLsizei, GLsizei)) \
|
||||||
|
|||||||
@@ -172,7 +172,10 @@ class Texture final : public Handle<Texture> {
|
|||||||
void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
|
void upload_rgba(u32 width, u32 height, const void* pixels) const noexcept;
|
||||||
|
|
||||||
/// Replaces the whole image with `width * height` bytes of single-channel
|
/// 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;
|
void upload_r8(u32 width, u32 height, const void* pixels) const noexcept;
|
||||||
|
|
||||||
/// Binds to `unit` (e.g. `GL_TEXTURE0`).
|
/// 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
@@ -68,17 +68,28 @@ class Plot {
|
|||||||
void set_viewport(Rect px) noexcept { viewport_ = px; }
|
void set_viewport(Rect px) noexcept { viewport_ = px; }
|
||||||
const Rect& viewport() const noexcept { return viewport_; }
|
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; }
|
void set_style(const render::Style& s) noexcept { style_ = s; }
|
||||||
const render::Style& style() const noexcept { return style_; }
|
const render::Style& style() const noexcept { return style_; }
|
||||||
|
|
||||||
/// The font layout() measures tick labels with. Borrowed, not owned, and may
|
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
|
||||||
/// be null -- then the label areas fall back to PlotLayout's defaults and no
|
/// null -- then the label areas fall back to PlotLayout's defaults and no string
|
||||||
/// string is measured. The caller (the renderer) owns the backend; Plot never
|
/// is measured: a plot with no renderer lays out with whatever the host sets
|
||||||
/// creates one and never touches GL.
|
/// 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; }
|
void set_text_backend(const render::TextBackend* text) noexcept { text_ = text; }
|
||||||
|
|
||||||
/// The backend layout() measures labels with, or null when none is set. The
|
/// The backend layout() measures labels with, or null when none is set. A
|
||||||
/// renderer reads it to install its own font only when nobody else has.
|
/// 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_; }
|
const render::TextBackend* text_backend() const noexcept { return text_; }
|
||||||
|
|
||||||
// --- construction -----------------------------------------------------
|
// --- construction -----------------------------------------------------
|
||||||
@@ -157,10 +168,13 @@ class Plot {
|
|||||||
void set_cursor_snap(u32 id, SnapMode mode);
|
void set_cursor_snap(u32 id, SnapMode mode);
|
||||||
|
|
||||||
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
/// 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
|
/// measure was added, or when the series underneath it changed: a different
|
||||||
/// count) -- never once per frame. An unknown id, a null-series trace, an
|
/// series, a longer one, or a sliding window whose sample count stayed the same
|
||||||
/// inverted cursor pair or an empty series yields `valid == false`. const
|
/// (`x0()` is the third key -- see MeasureSlot). Never once per frame. A
|
||||||
/// because recomputing fills a cache: nothing a caller can observe changes.
|
/// 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;
|
const Measurement& measurement(u32 id) const;
|
||||||
|
|
||||||
/// The cursor pair `id` was created over. The renderer needs it to place the
|
/// 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_; }
|
const Grid& grid() const noexcept { return grid_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/// One measure plus its cache. The dirty flag is what keeps measurement()
|
/// One measure plus its cache. The dirty flag is what keeps measurement() from
|
||||||
/// from re-evaluating every frame; `last_series`/`last_size` catch a trace
|
/// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a
|
||||||
/// being pointed at a different (or grown) series without anyone telling us.
|
/// 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 {
|
struct MeasureSlot {
|
||||||
u32 cursor_a = 0;
|
u32 cursor_a = 0;
|
||||||
u32 cursor_b = 0;
|
u32 cursor_b = 0;
|
||||||
@@ -214,6 +231,7 @@ class Plot {
|
|||||||
mutable bool dirty = true;
|
mutable bool dirty = true;
|
||||||
mutable const data::Series* last_series = nullptr;
|
mutable const data::Series* last_series = nullptr;
|
||||||
mutable u64 last_size = 0;
|
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
|
/// Fit one axis to its traces' bounds, leaving auto_scale alone. False when the
|
||||||
|
|||||||
@@ -90,11 +90,13 @@ class Renderer {
|
|||||||
|
|
||||||
/// Draws `plot`, refreshing its layout, ticks and grid first.
|
/// Draws `plot`, refreshing its layout, ticks and grid first.
|
||||||
///
|
///
|
||||||
/// The renderer owns the presentation style: `draw()` copies `style()` into the
|
/// The renderer owns the presentation, style and text both: `draw()` copies
|
||||||
/// plot, so the strips and tick densities the plot lays out with are the values
|
/// `style()` into the plot and installs its own font backend on it, so the strips
|
||||||
/// the renderer draws with -- set colours and metrics here, not on the plot.
|
/// and tick densities the plot lays out with are the values the renderer draws
|
||||||
/// The plot also borrows this renderer's font (when it has no backend of its
|
/// with -- set colours and metrics here, not on the plot, and expect a backend
|
||||||
/// own), so the renderer must outlive the plot's use of it.
|
/// 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:
|
/// `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
|
/// 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
|
/// 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
|
/// 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).
|
/// 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
|
/// The framebuffer binding is never touched -- it is the caller's, and so is the
|
||||||
/// pixel-pack alignment if `dump_frame_ppm()` ran.
|
/// pixel-pack alignment if `dump_frame_ppm()` ran.
|
||||||
void end_frame() noexcept;
|
void end_frame() noexcept;
|
||||||
@@ -152,7 +161,13 @@ class Renderer {
|
|||||||
/// window. Reads the framebuffer that is bound now, which is the one the frame
|
/// 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
|
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an
|
||||||
/// unreadable pixel.
|
/// 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:
|
private:
|
||||||
/// The bitmap font as the plot layer sees it, at the size the text pipeline
|
/// 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
|
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
||||||
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
|
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 viewport_; ///< the frame's region, top-left origin, framebuffer px
|
||||||
Rect gl_viewport_; ///< the same rect flipped for GL, and for readback
|
Rect gl_viewport_; ///< the same rect flipped for GL, and for readback
|
||||||
u32 fb_w_ = 0;
|
u32 fb_w_ = 0;
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ struct Style {
|
|||||||
f32 axis_font_px = 12.0f;
|
f32 axis_font_px = 12.0f;
|
||||||
f32 grid_label_pad = 4.0f;
|
f32 grid_label_pad = 4.0f;
|
||||||
f32 line_width_px = 1.5f;
|
f32 line_width_px = 1.5f;
|
||||||
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per pixel above which
|
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per pixel at or above
|
||||||
///< a trace is drawn from the LOD pyramid.
|
///< 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.
|
f32 tick_min_px = 60.0f; ///< minimum spacing between major ticks.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -1,4 +1,5 @@
|
|||||||
#include <plot/plot/plot.hpp>
|
#include <plot/plot/plot.hpp>
|
||||||
|
#include <plot/plot/detail/measure_count.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -271,11 +272,20 @@ const Measurement& Plot::measurement(u32 id) const {
|
|||||||
const data::Series* series = nullptr;
|
const data::Series* series = nullptr;
|
||||||
if (slot.trace < traces_.size()) series = traces_[slot.trace].series;
|
if (slot.trace < traces_.size()) series = traces_[slot.trace].series;
|
||||||
const u64 samples = series != nullptr ? series->size() : 0;
|
const u64 samples = series != nullptr ? series->size() : 0;
|
||||||
|
// A saturated ring keeps `samples` constant while its window slides: x0() is
|
||||||
|
// what moves, and with it the sample each cursor snaps to and the interval they
|
||||||
|
// measure. Caching on (pointer, count) alone would freeze the readout at the
|
||||||
|
// first saturated frame until a cursor moved.
|
||||||
|
const f64 x0 = series != nullptr ? series->x0() : 0.0;
|
||||||
|
|
||||||
// Recompute only when someone moved a cursor, replaced the trace's series, or
|
// Recompute only when someone moved a cursor, replaced the trace's series, grew
|
||||||
// grew it. Everything else is a cache hit, which is what keeps a steady frame
|
// it, or slid the window under it (x0 above). Everything else is a cache hit,
|
||||||
// from re-evaluating every measure.
|
// which is what keeps a steady frame from re-evaluating every measure.
|
||||||
if (slot.dirty || series != slot.last_series || samples != slot.last_size) {
|
if (slot.dirty || series != slot.last_series || samples != slot.last_size ||
|
||||||
|
x0 != slot.last_x0) {
|
||||||
|
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES
|
||||||
|
detail::measure_recomputes().fetch_add(1, std::memory_order_relaxed);
|
||||||
|
#endif
|
||||||
slot.result = Measurement{};
|
slot.result = Measurement{};
|
||||||
if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() &&
|
if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() &&
|
||||||
slot.cursor_b < cursors_.size()) {
|
slot.cursor_b < cursors_.size()) {
|
||||||
@@ -287,6 +297,7 @@ const Measurement& Plot::measurement(u32 id) const {
|
|||||||
}
|
}
|
||||||
slot.last_series = series;
|
slot.last_series = series;
|
||||||
slot.last_size = samples;
|
slot.last_size = samples;
|
||||||
|
slot.last_x0 = x0;
|
||||||
slot.dirty = false;
|
slot.dirty = false;
|
||||||
}
|
}
|
||||||
return slot.result;
|
return slot.result;
|
||||||
|
|||||||
+53
-7
@@ -76,9 +76,21 @@ f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::B
|
|||||||
return used;
|
return used;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
#ifndef NDEBUG
|
||||||
|
/// A host that installed its own `TextBackend` is told its metrics are gone: the
|
||||||
|
/// renderer draws every label with its artwork's font, so laying the strips out
|
||||||
|
/// with a different one puts the labels wherever that font's metrics happen to
|
||||||
|
/// fall. A diagnostic rather than an abort -- a host's frame must still draw --
|
||||||
|
/// and debug-only, like `plot::gl::load()`'s unresolved-entry-point notice.
|
||||||
|
void warn_replaced_text_backend(const render::TextBackend* previous) noexcept {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"plot::Renderer::draw: replacing the Plot's TextBackend (%p) with the renderer's "
|
||||||
|
"own bitmap font; a plot drawn by a Renderer measures the labels it draws.\n",
|
||||||
|
static_cast<const void*>(previous));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// --------------------------------------------------------------- AxisFont
|
} // namespace
|
||||||
|
|
||||||
void Renderer::AxisFont::reset(const render::BitmapFont& font, f32 requested_px) noexcept {
|
void Renderer::AxisFont::reset(const render::BitmapFont& font, f32 requested_px) noexcept {
|
||||||
font_ = &font;
|
font_ = &font;
|
||||||
@@ -125,6 +137,12 @@ bool Renderer::init(Status* st) noexcept {
|
|||||||
|
|
||||||
font_backend_.reset(font_, style_.axis_font_px);
|
font_backend_.reset(font_, style_.axis_font_px);
|
||||||
|
|
||||||
|
// The atlas upload needs GL_UNPACK_ALIGNMENT 1 (single-channel rows have no
|
||||||
|
// padding), which is the only global state `init()` changes; the host's value is
|
||||||
|
// read first so `end_frame()` can hand it back rather than leave the host's own
|
||||||
|
// uploads to the alignment this renderer wanted.
|
||||||
|
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &host_unpack_alignment_);
|
||||||
|
|
||||||
if (!atlas_.create_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight, st)) {
|
if (!atlas_.create_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight, st)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -182,6 +200,19 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
|
|||||||
// already painted keeps everything outside the rect. The scissor box is in
|
// already painted keeps everything outside the rect. The scissor box is in
|
||||||
// framebuffer coordinates and is not affected by glViewport, which is why the
|
// framebuffer coordinates and is not affected by glViewport, which is why the
|
||||||
// clear happens before the viewport is set.
|
// clear happens before the viewport is set.
|
||||||
|
//
|
||||||
|
// The host's clear colour is queried once, before the first frame sets its own:
|
||||||
|
// GL keeps no history for it, so this is the only moment the value can be
|
||||||
|
// recovered for `end_frame()` to hand back. A host that changes its own clear
|
||||||
|
// colour *after* the first frame therefore gets the first frame's value restored
|
||||||
|
// -- the renderer cannot tell its own writes from the host's -- which is still
|
||||||
|
// the host's colour, and stated as such in end_frame()'s contract.
|
||||||
|
if (!host_clear_saved_) {
|
||||||
|
f32 rgba[4] = {0.0f, 0.0f, 0.0f, 0.0f};
|
||||||
|
gl::glGetFloatv(gl::GL_COLOR_CLEAR_VALUE, rgba);
|
||||||
|
host_clear_ = Color{rgba[0], rgba[1], rgba[2], rgba[3]};
|
||||||
|
host_clear_saved_ = true;
|
||||||
|
}
|
||||||
gl::glEnable(gl::GL_SCISSOR_TEST);
|
gl::glEnable(gl::GL_SCISSOR_TEST);
|
||||||
gl::glScissor(vx, vy, vw, vh);
|
gl::glScissor(vx, vy, vw, vh);
|
||||||
const Color& background = style_.background;
|
const Color& background = style_.background;
|
||||||
@@ -206,9 +237,18 @@ void Renderer::draw(Plot& plot) {
|
|||||||
// measure with the size the text pipeline draws with, and re-read the style
|
// measure with the size the text pipeline draws with, and re-read the style
|
||||||
// each frame so a changed axis_font_px takes effect on the next one.
|
// each frame so a changed axis_font_px takes effect on the next one.
|
||||||
font_backend_.reset(font_, style_.axis_font_px);
|
font_backend_.reset(font_, style_.axis_font_px);
|
||||||
if (plot.text_backend() == nullptr) {
|
#ifndef NDEBUG
|
||||||
plot.set_text_backend(&font_backend_);
|
// The renderer owns the text of the frames it draws: every label goes through
|
||||||
|
// the text pipeline in `font_`, so the plot is handed this font's metrics
|
||||||
|
// whatever a host installed. A host backend is therefore replaced on the first
|
||||||
|
// draw -- said out loud in a debug build, since its disappearance is otherwise
|
||||||
|
// silent -- and a Plot nobody draws keeps it (see Plot::set_text_backend()).
|
||||||
|
if (const render::TextBackend* previous = plot.text_backend();
|
||||||
|
previous != nullptr && previous != &font_backend_) {
|
||||||
|
warn_replaced_text_backend(previous);
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
plot.set_text_backend(&font_backend_);
|
||||||
// One style, not two: the plot reserves its strips and picks its tick density
|
// One style, not two: the plot reserves its strips and picks its tick density
|
||||||
// from the values the renderer draws with, so the label pad is never one number
|
// from the values the renderer draws with, so the label pad is never one number
|
||||||
// for the layout and another for the text placed inside it.
|
// for the layout and another for the text placed inside it.
|
||||||
@@ -245,8 +285,10 @@ void Renderer::end_frame() noexcept {
|
|||||||
gl_error_ = true;
|
gl_error_ = true;
|
||||||
}
|
}
|
||||||
// Leave the host's context as close to neutral as a frame can: nothing bound to
|
// Leave the host's context as close to neutral as a frame can: nothing bound to
|
||||||
// a pipeline stage, and off every state bit the frame turned on. What remains
|
// a pipeline stage, off every state bit the frame turned on, and the two values
|
||||||
// is the viewport (the caller's to set for its next pass), the pixel-pack
|
// the frame *changed* rather than merely used handed back -- the unpack
|
||||||
|
// alignment `init()` set for the atlas, and the host's clear colour. What
|
||||||
|
// remains is the viewport (the caller's to set for its next pass), the pixel-pack
|
||||||
// alignment a PPM dump may have set, and the vertex buffers' own contents.
|
// alignment a PPM dump may have set, and the vertex buffers' own contents.
|
||||||
gl::glBindVertexArray(0);
|
gl::glBindVertexArray(0);
|
||||||
gl::glUseProgram(0);
|
gl::glUseProgram(0);
|
||||||
@@ -254,6 +296,10 @@ void Renderer::end_frame() noexcept {
|
|||||||
gl::glActiveTexture(gl::GL_TEXTURE0);
|
gl::glActiveTexture(gl::GL_TEXTURE0);
|
||||||
gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
|
gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
|
||||||
gl::glDisable(gl::GL_BLEND);
|
gl::glDisable(gl::GL_BLEND);
|
||||||
|
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_unpack_alignment_);
|
||||||
|
if (host_clear_saved_) {
|
||||||
|
gl::glClearColor(host_clear_.r, host_clear_.g, host_clear_.b, host_clear_.a);
|
||||||
|
}
|
||||||
// Drain, do not peek: the first error hides every later one, and a frame that
|
// Drain, do not peek: the first error hides every later one, and a frame that
|
||||||
// produced several must be reported as having produced one.
|
// produced several must be reported as having produced one.
|
||||||
while (gl::glGetError() != gl::GL_NO_ERROR) {
|
while (gl::glGetError() != gl::GL_NO_ERROR) {
|
||||||
@@ -647,7 +693,7 @@ void Renderer::draw_annotations(const Plot& plot) {
|
|||||||
|
|
||||||
// -------------------------------------------------------------------- dump
|
// -------------------------------------------------------------------- dump
|
||||||
|
|
||||||
bool Renderer::dump_frame_ppm(const char* path, Status* st) const noexcept {
|
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
|
||||||
if (path == nullptr || path[0] == '\0') {
|
if (path == nullptr || path[0] == '\0') {
|
||||||
return fail(st, ErrorCode::InvalidArgument, "PPM path is empty");
|
return fail(st, ErrorCode::InvalidArgument, "PPM path is empty");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
|
|||||||
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
|
# behind them (see include/plot/data/detail/{cover,merge}_count.hpp).
|
||||||
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS
|
||||||
PLOT_PYRAMID_COUNT_MERGES
|
PLOT_PYRAMID_COUNT_MERGES
|
||||||
PLOT_RING_STATS)
|
PLOT_RING_STATS
|
||||||
|
PLOT_COUNT_MEASURE_RECOMPUTES)
|
||||||
plot_apply_warnings(plot_tests)
|
plot_apply_warnings(plot_tests)
|
||||||
|
|
||||||
add_test(NAME plot_tests COMMAND plot_tests)
|
add_test(NAME plot_tests COMMAND plot_tests)
|
||||||
|
|||||||
+31
-10
@@ -1,8 +1,9 @@
|
|||||||
#include "harness.hpp"
|
#include "harness.hpp"
|
||||||
#include <plot/plot/measure.hpp>
|
#include <plot/plot/measure.hpp>
|
||||||
#include <plot/data/array_series.hpp>
|
#include <plot/data/array_series.hpp>
|
||||||
|
#include <plot/data/detail/cover_count.hpp>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <atomic>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -25,15 +26,35 @@ PLOT_TEST(measurement_over_interval_matches_direct_computation) {
|
|||||||
PLOT_CHECK_NEAR(m.dx, 699.0, 1e-9);
|
PLOT_CHECK_NEAR(m.dx, 699.0, 1e-9);
|
||||||
}
|
}
|
||||||
PLOT_TEST(measurement_uses_pyramid_not_scan) {
|
PLOT_TEST(measurement_uses_pyramid_not_scan) {
|
||||||
// Guard against a future regression that linear-scans the series: a huge
|
// Guard against a future regression that linear-scans the series, counted rather
|
||||||
// series must evaluate a 1-sample-wide interval in well under a millisecond.
|
// than timed: a query's cost is the pyramid's covering decomposition -- at most
|
||||||
std::vector<f32> y(1u << 22, 1.0f); // 4M samples
|
// two entries per level -- so the entry count must depend on the *interval* and
|
||||||
plot::data::ArraySeries s(std::move(y), 0.0, 1.0);
|
// never on the series length. The wall-clock bound this replaced (1000 evals in
|
||||||
const auto t0 = std::chrono::steady_clock::now();
|
// < 50 ms) failed on a loaded machine and passed vacuously on a fast one, while
|
||||||
for (int i = 0; i < 1000; ++i) (void)plot::Measure::evaluate(s, 1'000'000, 1'000'001);
|
// saying nothing about the shape of the work.
|
||||||
const auto us = std::chrono::duration_cast<std::chrono::microseconds>(
|
std::vector<f32> big(1u << 22, 1.0f); // 4M samples
|
||||||
std::chrono::steady_clock::now() - t0).count();
|
plot::data::ArraySeries s_big(std::move(big), 0.0, 1.0);
|
||||||
PLOT_CHECK(us < 50'000); // 1000 evals in < 50 ms
|
std::vector<f32> small(1u << 13, 1.0f); // 8k samples: 512x shorter
|
||||||
|
plot::data::ArraySeries s_small(std::move(small), 0.0, 1.0);
|
||||||
|
|
||||||
|
auto covers = [](const plot::data::Series& s, u64 begin, u64 end) {
|
||||||
|
plot::data::detail::cover_entry_count().store(0, std::memory_order_relaxed);
|
||||||
|
(void)plot::Measure::evaluate(s, begin, end);
|
||||||
|
return plot::data::detail::cover_entry_count().load(std::memory_order_relaxed);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The same absolute interval over both series walks the same run of level-0
|
||||||
|
// buckets, so it must fold the same entries: a decomposition that degenerated
|
||||||
|
// with series length -- one bucket at a time, or a scan -- shows up here.
|
||||||
|
const u64 big_covers = covers(s_big, 1024, 7000);
|
||||||
|
PLOT_CHECK(big_covers == covers(s_small, 1024, 7000));
|
||||||
|
PLOT_CHECK(big_covers > 0u); // the interior came from the pyramid
|
||||||
|
PLOT_CHECK(big_covers <= 32u); // ~2 entries per level, few levels here
|
||||||
|
|
||||||
|
// A 1-sample interval needs no whole bucket at all: its answer is the two partial
|
||||||
|
// edge buckets a pyramid of complete buckets cannot cover (at most 2 * kBucket raw
|
||||||
|
// samples through copy_raw), so it folds nothing whatever the series holds.
|
||||||
|
PLOT_CHECK(covers(s_big, 1'000'000, 1'000'001) == 0u);
|
||||||
}
|
}
|
||||||
PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
|
PLOT_TEST(empty_and_single_sample_intervals_are_valid_false) {
|
||||||
std::vector<f32> y{3.0f};
|
std::vector<f32> y{3.0f};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "harness.hpp"
|
#include "harness.hpp"
|
||||||
#include <plot/plot/plot.hpp>
|
#include <plot/plot/plot.hpp>
|
||||||
|
#include <plot/plot/detail/measure_count.hpp>
|
||||||
#include <plot/data/array_series.hpp>
|
#include <plot/data/array_series.hpp>
|
||||||
#include <plot/data/stream_series.hpp>
|
#include <plot/data/stream_series.hpp>
|
||||||
#include <plot/render/bitmap_font.hpp>
|
#include <plot/render/bitmap_font.hpp>
|
||||||
@@ -310,6 +311,71 @@ PLOT_TEST(measurement_recomputes_when_the_series_grows) {
|
|||||||
PLOT_CHECK_NEAR(p.measurement(m).dx, 18.0, 1e-9);
|
PLOT_CHECK_NEAR(p.measurement(m).dx, 18.0, 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A saturated ring pins size() at capacity while x0() advances every acquire(),
|
||||||
|
/// so a cache keyed on (pointer, sample count) alone would freeze this measurement
|
||||||
|
/// on the first saturated frame -- the one case the demos never reach, because they
|
||||||
|
/// move their cursors (and therefore `dirty`) every frame.
|
||||||
|
///
|
||||||
|
/// The signal is a ramp in absolute x, so the value a cursor names once the window
|
||||||
|
/// has slid past it is the current window's oldest sample: a live cache tracks
|
||||||
|
/// x0(), a frozen one keeps reporting the first frame's numbers. The counter is the
|
||||||
|
/// other half of the contract -- a frame in which nothing moved is still a cache
|
||||||
|
/// hit, or the fix would have traded a stale readout for a per-frame recompute.
|
||||||
|
PLOT_TEST(saturated_stream_measurement_tracks_the_sliding_window) {
|
||||||
|
constexpr u64 kCapacity = 64;
|
||||||
|
plot::data::StreamSeries s(kCapacity, 0.0, 1.0);
|
||||||
|
for (u64 i = 0; i < kCapacity; ++i) s.push(f32(i)); // y == absolute x
|
||||||
|
s.acquire();
|
||||||
|
PLOT_CHECK(s.size() == kCapacity); // published window is full
|
||||||
|
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
|
||||||
|
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const u32 ay = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&s, plot::Color::hex(0xFFFFFF), 1.0f, ax, ay});
|
||||||
|
const u32 a = p.add_cursor(10.0, plot::Color::hex(0xFFB020));
|
||||||
|
const u32 b = p.add_cursor(60.0, plot::Color::hex(0xFFB020));
|
||||||
|
const u32 m = p.add_measure(a, b);
|
||||||
|
|
||||||
|
// First window, both cursors inside it: y over absolute x 10..60.
|
||||||
|
PLOT_CHECK(p.measurement(m).valid);
|
||||||
|
PLOT_CHECK_NEAR(p.measurement(m).ymin, 10.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(p.measurement(m).ymax, 60.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(p.measurement(m).mean, 35.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(p.measurement(m).dx, 50.0, 1e-9);
|
||||||
|
|
||||||
|
auto counter = [] {
|
||||||
|
return plot::detail::measure_recomputes().load(std::memory_order_relaxed);
|
||||||
|
};
|
||||||
|
plot::detail::measure_recomputes().store(0, std::memory_order_relaxed);
|
||||||
|
// Neither cursor moves and neither does the window: eight reads, no recompute.
|
||||||
|
for (int i = 0; i < 8; ++i) (void)p.measurement(m);
|
||||||
|
PLOT_CHECK(counter() == 0u);
|
||||||
|
|
||||||
|
// Slide the window past cursor `a` -- 40 more samples and an acquire, with the
|
||||||
|
// sample count unchanged (still capacity) and neither cursor touched.
|
||||||
|
for (u64 i = kCapacity; i < kCapacity + 40; ++i) s.push(f32(i));
|
||||||
|
s.acquire();
|
||||||
|
PLOT_CHECK(s.size() == kCapacity); // size() cannot be the key
|
||||||
|
PLOT_CHECK_NEAR(s.x0(), 40.0, 1e-9); // x0() moved under it
|
||||||
|
|
||||||
|
const plot::Measurement& slid = p.measurement(m); // one acquire, one recompute
|
||||||
|
PLOT_CHECK(counter() == 1u);
|
||||||
|
PLOT_CHECK(slid.valid);
|
||||||
|
PLOT_CHECK_NEAR(slid.ymin, 40.0, 1e-9); // `a` clamped onto the new x0()
|
||||||
|
PLOT_CHECK_NEAR(slid.ymax, 60.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(slid.mean, 50.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(slid.dx, 20.0, 1e-9);
|
||||||
|
|
||||||
|
// And it keeps tracking, one recompute per acquire with the cursors still.
|
||||||
|
for (u64 i = 0; i < 5; ++i) {
|
||||||
|
s.push(f32(kCapacity + 40 + static_cast<f64>(i)));
|
||||||
|
s.acquire();
|
||||||
|
PLOT_CHECK_NEAR(p.measurement(m).ymin, 41.0 + static_cast<f64>(i), 1e-9);
|
||||||
|
PLOT_CHECK(counter() == 2u + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PLOT_TEST(inverted_and_out_of_range_measurements_are_invalid) {
|
PLOT_TEST(inverted_and_out_of_range_measurements_are_invalid) {
|
||||||
plot::data::ArraySeries s(ramp(10), 0.0, 1.0);
|
plot::data::ArraySeries s(ramp(10), 0.0, 1.0);
|
||||||
plot::Plot p;
|
plot::Plot p;
|
||||||
|
|||||||
@@ -115,6 +115,50 @@ bool pixel_is(const plot::u8 px[4], plot::Color c) {
|
|||||||
return near(px[0], c.r) && near(px[1], c.g) && near(px[2], c.b);
|
return near(px[0], c.r) && near(px[1], c.g) && near(px[2], c.b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A host's `TextBackend` that measures one pixel per string, when the labels the
|
||||||
|
/// renderer draws are tens of pixels wide: installed on a plot, it asks for a label
|
||||||
|
/// strip far too narrow for anything the text pipeline draws.
|
||||||
|
class OnePxPerStringBackend final : public plot::render::TextBackend {
|
||||||
|
public:
|
||||||
|
plot::f32 measure(const char*) const override { return 1.0f; }
|
||||||
|
plot::f32 height() const override { return 1.0f; }
|
||||||
|
plot::f32 ascent() const override { return 1.0f; }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// No column held ink.
|
||||||
|
constexpr plot::u32 kNoColumn = 0xFFFFFFFFu;
|
||||||
|
|
||||||
|
/// What a text strip's pixels say: the leftmost column holding ink, and the widest
|
||||||
|
/// run of ink columns any single row holds -- one drawn label's extent, since a
|
||||||
|
/// label has a row band to itself.
|
||||||
|
struct TextInk {
|
||||||
|
plot::u32 leftmost = kNoColumn;
|
||||||
|
plot::u32 widest_row_run = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Scans the top-down pixels of [x0, x1) x [y, y + rows) for fully covered text
|
||||||
|
/// pixels in `text`.
|
||||||
|
TextInk scan_text_ink(plottest::HeadlessContext& ctx, plot::u32 x0, plot::u32 x1, plot::u32 y,
|
||||||
|
plot::u32 rows, plot::Color text) {
|
||||||
|
TextInk ink;
|
||||||
|
for (plot::u32 r = 0; r < rows; ++r) {
|
||||||
|
plot::u32 first = kNoColumn;
|
||||||
|
plot::u32 last = 0;
|
||||||
|
for (plot::u32 x = x0; x < x1; ++x) {
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (y + r), px));
|
||||||
|
if (!pixel_is(px, text)) continue;
|
||||||
|
if (first == kNoColumn) first = x;
|
||||||
|
last = x;
|
||||||
|
if (ink.leftmost == kNoColumn) ink.leftmost = x;
|
||||||
|
}
|
||||||
|
if (first != kNoColumn && last - first + 1u > ink.widest_row_run) {
|
||||||
|
ink.widest_row_run = last - first + 1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ink;
|
||||||
|
}
|
||||||
|
|
||||||
/// Holds one GL entry point at a stub for as long as it lives. The entry points
|
/// Holds one GL entry point at a stub for as long as it lives. The entry points
|
||||||
/// are the library's own function pointers (`plot/gl/api.hpp`), so a driver
|
/// are the library's own function pointers (`plot/gl/api.hpp`), so a driver
|
||||||
/// refusal -- a map or a fence that fails while the caps still advertise support
|
/// refusal -- a map or a fence that fails while the caps still advertise support
|
||||||
@@ -336,6 +380,124 @@ PLOT_TEST(clears_only_the_viewport_region) {
|
|||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A host embeds the plot in its own frame loop, so the two pieces of global state
|
||||||
|
// the renderer has to change must come back as the host left them: the unpack
|
||||||
|
// alignment `init()` sets for the glyph atlas (whose single-channel rows are
|
||||||
|
// 1-byte aligned), and the clear colour the frame paints its background with.
|
||||||
|
PLOT_TEST(frame_hands_back_the_hosts_clear_colour_and_unpack_alignment) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
// The host's clear colour, set before the first frame. Nothing touches the
|
||||||
|
// unpack alignment: the context starts at GL's default of 4, and that is what
|
||||||
|
// the frame has to leave behind -- with the atlas' 1 still in place, a host's
|
||||||
|
// next padded RGBA upload would read the wrong rows.
|
||||||
|
plot::gl::glClearColor(0.5f, 0.25f, 0.75f, 1.0f);
|
||||||
|
|
||||||
|
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||||
|
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
|
||||||
|
plot::f32 clear[4] = {};
|
||||||
|
plot::gl::glGetFloatv(plot::gl::GL_COLOR_CLEAR_VALUE, clear);
|
||||||
|
PLOT_CHECK_NEAR(clear[0], 0.5, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(clear[1], 0.25, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(clear[2], 0.75, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(clear[3], 1.0, 1e-6);
|
||||||
|
plot::gl::GLint alignment = 0;
|
||||||
|
plot::gl::glGetIntegerv(plot::gl::GL_UNPACK_ALIGNMENT, &alignment);
|
||||||
|
PLOT_CHECK(alignment == 4);
|
||||||
|
|
||||||
|
// Not vacuous: the frame really did clear to its own background rather than the
|
||||||
|
// host's grey, so the value above is a restoration and not an untouched leftover.
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
PLOT_CHECK(ctx.read_pixel(2, kH - 3u, px));
|
||||||
|
PLOT_CHECK(px[0] + px[1] + px[2] < 200);
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The renderer draws every label with its own bitmap font, so it has to lay the
|
||||||
|
// strips out with that font's metrics. A host backend whose metrics are nothing
|
||||||
|
// like the artwork's would otherwise reserve a strip too narrow for the labels the
|
||||||
|
// text pipeline then draws -- here, one pixel per string against ~40px labels.
|
||||||
|
PLOT_TEST(renderer_measures_the_labels_it_draws) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
// The sub-rect viewport the adapters use, so the label strip's left edge is not
|
||||||
|
// the framebuffer edge: with a narrower strip than the labels need, the renderer
|
||||||
|
// stages them left of the viewport, where GL's own clip removes all but their
|
||||||
|
// last few pixels.
|
||||||
|
const plot::Rect vp{60.0f, 0.0f, 500.0f, 300.0f};
|
||||||
|
p.set_viewport(vp);
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
||||||
|
// No y title: the title strip sits left of the label strip, and this test reads
|
||||||
|
// the label strip's own columns for ink.
|
||||||
|
const plot::u32 ay = p.add_y_axis({"", {-1000.0, 1000.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||||
|
OnePxPerStringBackend host_font;
|
||||||
|
p.set_text_backend(&host_font);
|
||||||
|
// With the host's metrics the strip is 1px of label: what the frame must replace.
|
||||||
|
PLOT_CHECK_NEAR(p.layout().y_label_area.w, 1.0f + 2.0f * p.style().grid_label_pad, 1e-6);
|
||||||
|
|
||||||
|
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||||
|
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
|
||||||
|
r.begin_frame(kW, kH, vp);
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
|
||||||
|
// Layout and drawing measure with one backend: the renderer's font, at the size
|
||||||
|
// the text pipeline draws (whole multiples of the glyph cell -- see AxisFont).
|
||||||
|
const plot::render::TextBackend* back = p.text_backend();
|
||||||
|
PLOT_CHECK(back != nullptr);
|
||||||
|
PLOT_CHECK(back != &host_font);
|
||||||
|
PLOT_CHECK_NEAR(back->height(), r.axis_font_px(), 1e-6);
|
||||||
|
plot::f32 widest = 0.0f;
|
||||||
|
for (const plot::Tick& tick : p.y_axis(ay).ticks) {
|
||||||
|
if (!tick.major || tick.label.empty()) continue;
|
||||||
|
widest = std::max(widest, back->measure(tick.label.c_str()));
|
||||||
|
}
|
||||||
|
PLOT_CHECK(widest > 8.0f); // real labels in the real font, not the host's 1px
|
||||||
|
const plot::Rect labels = p.layout().y_label_area;
|
||||||
|
PLOT_CHECK_NEAR(labels.w, widest + 2.0f * p.style().grid_label_pad, 1e-3);
|
||||||
|
|
||||||
|
// And the pixels agree with the strip: the drawn labels occupy the width the
|
||||||
|
// layout reserved and stay inside it. Kept on the host's 1px metrics the strip is
|
||||||
|
// ~9px wide while the renderer right-aligns its ~40px label against it, so all
|
||||||
|
// but the label's last few pixels land left of the viewport and are clipped --
|
||||||
|
// the ink run collapses instead of filling the width that was reserved.
|
||||||
|
const plot::Color text = r.style().axis_text;
|
||||||
|
const plot::u32 y = static_cast<plot::u32>(labels.y);
|
||||||
|
const plot::u32 rows = static_cast<plot::u32>(labels.h);
|
||||||
|
const plot::u32 strip_x = static_cast<plot::u32>(labels.x);
|
||||||
|
const TextInk ink =
|
||||||
|
scan_text_ink(ctx, strip_x, strip_x + static_cast<plot::u32>(labels.w) + 16u, y, rows, text);
|
||||||
|
PLOT_CHECK(ink.leftmost != kNoColumn); // labels were drawn
|
||||||
|
PLOT_CHECK(ink.leftmost >= strip_x); // inside their strip
|
||||||
|
PLOT_CHECK(static_cast<plot::f32>(ink.widest_row_run) >= 0.6f * widest); // full width drawn
|
||||||
|
PLOT_CHECK(static_cast<plot::f32>(ink.widest_row_run) <= widest + 1.0f); // and no wider
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
// The overlay layer: cursor markers in their own colours, the shaded measurement
|
// The overlay layer: cursor markers in their own colours, the shaded measurement
|
||||||
// band and its readout, and an annotation pin. These are the pixels the adapters'
|
// band and its readout, and an annotation pin. These are the pixels the adapters'
|
||||||
// demos are read for, and none of them goes through the trace path.
|
// demos are read for, and none of them goes through the trace path.
|
||||||
|
|||||||
Reference in new issue
Block a user