Files
plotter/include/plot/render/renderer.hpp
T

797 lines
49 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/color.hpp>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/embedded_mono_font.hpp>
#include <plot/render/buffer.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
#include <plot/render/style.hpp>
#include <plot/render/text_atlas.hpp>
#include <plot/render/vertex_array.hpp>
namespace plot {
/// What one frame staged and drew. It exists because pixels alone cannot say
/// which of the two rasterisation paths a trace took: a 1M-sample series drawn
/// from the LOD pyramid and one drawn from raw samples can cover the same
/// columns, so the smoke test asserts the path here and the pixels there.
struct FrameStats {
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
/// per column, lit from that column's own extremes. Counts a trace from either
/// path above -- the reduction is about the *density* of the gathered points,
/// not about which level produced them.
u32 column_traces = 0;
/// Traces whose columns were graded by dwell rather than drawn as one flat band
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
/// drawing the reduction, not a third path.
u32 graded_traces = 0;
/// Traces drawn as a polyline whose *segments* were graded by slope -- the sparse
/// half of the same phosphor model (`Renderer::stage_trace_slopes`). **Disjoint from
/// `column_traces`**, and therefore from `graded_traces`: a window is either dense
/// enough to reduce onto the pixel grid or it is drawn as a polyline, never both. A
/// host asserting `graded_traces == column_traces` keeps that reading unchanged.
u32 slope_traces = 0;
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces
u32 draw_calls = 0; ///< draw calls issued, line and text
/// The phosphor density's own cells, when one was drawn: the **sum** of every trace's
/// `Plot::density_grid()` resolution, zero for a plot with the density off. It is what the
/// *frame* drew, so a host reading the density's cost off a frame reads it here rather than off
/// the grids, which say what is accumulated and not what was spent -- and it is the number that
/// scales with the trace count, since the image is one grid per trace (see `DensityConfig`).
u32 density_cells = 0;
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
/// since the last frame and 0 when it had not. It is the one field that separates the two
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
/// with the display on and off needs to tell apart.
u32 density_upload_bytes = 0;
};
/// Draws a `Plot` into whatever framebuffer is bound.
///
/// The renderer never creates, binds or resizes a framebuffer: `begin_frame()`
/// takes the framebuffer size for the projection and a `Rect` for the region the
/// plot occupies, clears exactly that region (`GL_SCISSOR_TEST` around the clear,
/// so a host that has already painted the rest of its surface keeps it) and sets
/// `glViewport` to the flipped rect. That is the whole embedding contract of the
/// adapters (Qt/GTK/ImGui widgets).
///
/// Two properties drive the rest of the design:
///
/// - **Only the visible window is gathered.** A trace's x range is mapped onto
/// sample indices and `Series::gather` is asked for that window -- never the
/// whole series -- at `pyramid().level_for(n, samples_per_pixel)` whenever a
/// pixel covers `dense_threshold_spp` samples or more. A 100M-sample series
/// therefore stages O(pixels) vertices, and `FrameStats` records which way each
/// trace went.
/// - **Vertex data stays in data space.** Segments are staged in data
/// coordinates and mapped to pixels by the line program's `u_scale`/`u_offset`
/// uniforms, so nothing is ever baked into screen space per primitive; a log
/// axis is staged in the transform's pre-affine coordinate (`log10`), which is
/// what the affine part expects. Pan and zoom are uniform updates.
///
/// The vertex transfer itself is one of two strategies, chosen once in `init()`
/// from `plot::gl::caps()` and invisible to every caller afterwards: a
/// persistent-mapped, fence-guarded ring on 4.3+/ARB_buffer_storage, and the
/// orphan-and-refill baseline on 3.3. Setting `PLOT_FORCE_GL33_UPLOAD=1` in the
/// environment forces the baseline path, which is how both are exercised on one
/// machine. A driver that refuses the storage, a map or a fence drops the store
/// that hit it to the baseline path -- the ring is never rotated through without
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
///
/// Not thread-safe, and confined to the thread that owns the current context.
class Renderer {
public:
Renderer() noexcept = default;
/// Releases every GL handle first (`release_gl()`), so the destructor is safe
/// whenever the context that owns them is still current -- the normal case for
/// a member renderer destroyed by its host's widget teardown.
~Renderer();
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
/// Destroys every GL object this renderer created: all three programs, the vertex arrays and
/// their instance buffers, the glyph atlas texture and the density's own texture. The
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
/// called again on the same object.
///
/// **Teardown contract:** call this while the context that owns the names is
/// still current, before the host destroys that context -- a GL name deleted
/// after its context is gone is leaked, not deleted. The destructor calls this
/// first for the common case of a member renderer whose context outlives it;
/// a host that tears the context down before its renderer must call
/// `release_gl()` explicitly.
void release_gl() noexcept;
/// Compiles both programs, uploads the font's atlas and allocates the vertex
/// buffers. The calling thread must have the context current and
/// `plot::gl::load()` must have succeeded, otherwise the returned `Status` is
/// `Unsupported`; anything the driver refuses is `GLFailure` plus its log.
bool init(Status* st = nullptr) noexcept;
/// The face every frame this renderer draws uses: the embedded one until a host
/// installs another here.
/// `init()` binds `render::EmbeddedMonoFont`, and `draw()` installs the binding
/// on the plot it is given (replacing whatever a host set with
/// `Plot::set_text_backend()`, which is how a plot with no renderer keeps its
/// own), so this is the way to draw with a face of your own -- a
/// `plot::text::TrueTypeFont` from the optional target, say, or any other
/// `TextBackend`. Passing null restores the embedded face. The swap takes
/// effect on the next frame, which re-reads the backend's metrics at
/// `style().device(axis_font_px)` and re-uploads its atlas -- so nothing here
/// has to know whether the replacement is a different face, the same face
/// re-baked at another size, or a backend whose pixels changed: the revision
/// comparison in `render::TextAtlas` decides, and a replacement whose atlas is
/// already on the GPU under the same revision is uploaded anyway (see the
/// implementation for why that is the safe direction).
///
/// The backend is borrowed, not owned: it must outlive the frames drawn with it,
/// which is the same lifetime rule `Plot::set_text_backend()` states.
void set_text_backend(const render::TextBackend* font) noexcept;
/// The backend this renderer *borrows*: whatever `set_text_backend()` was given,
/// or the embedded face when nothing was installed. It is deliberately **not**
/// the `AxisFont` wrapper the renderer builds around it -- after a `draw()`,
/// `Plot::text_backend()` reads back that wrapper, because the wrapper is what
/// the plot measured with -- so a caller checking or re-pointing the face itself
/// asks here.
const render::TextBackend* text_backend() const noexcept { return font_; }
/// Starts a frame: records `fb_w` x `fb_h` for the projection, flips
/// `viewport_px` (top-left origin, like every `Rect` in this library) into GL's
/// bottom-left origin once, clears that region to `style().background` and
/// empties the staging batches.
///
/// The scissor that bounds the clear is the frame's *baseline clip*: it stays engaged,
/// set to the same viewport rect, so every pass of the frame is bounded by the region
/// the host gave the renderer -- and `draw()` narrows and releases it again around the
/// passes that draw inside a plot's axes (see `set_clip()`). `end_frame()` turns the
/// scissor test off again, so a host never inherits a clip it did not ask for.
///
/// It is also where the instance stores wait -- once each -- for the previous
/// frame's draws to retire the ring they read, before this frame writes any
/// slot (see `InstanceStore::begin_frame`): a frame never synchronises with the
/// work it issued itself. A store that cannot retire that frame drops to the
/// baseline path, which this frame reports (see `last_frame_had_gl_error()`).
void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept;
/// Draws `plot`, refreshing its layout, ticks and grid first.
///
/// The renderer owns the presentation, style and text both: `draw()` copies
/// `style()` into the plot, hands it the style's `ui_scale` *before* it lays out
/// (`Plot::set_ui_scale()`) 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.
///
/// Every metric in `style()` is a logical unit, so this is also where the scale
/// becomes pixels: the font is reset at `style().device(axis_font_px)` and each
/// stroke and padding is converted through the same call. A host therefore states
/// its display once, in `style().ui_scale`, and the frame it gets back is the same
/// physical size on a 1x panel and a 2x one.
///
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
/// it runs the auto scale, the strip fixed-point loop, the grid build and the
/// region geometry, so re-deriving it per helper would rebuild all of that per
/// helper.
///
/// The handles a pointer is on are drawn highlighted -- a cursor marker is widened
/// into a tab, a region bound is drawn in the style's cursor colour at the width
/// `PlotLayout` sized its handle at -- and what is hot comes from
/// `Plot::hot_handles()`, which the input layer publishes. The renderer therefore
/// never sees a pointer, a button or a gesture: it draws what the plot says is
/// being manipulated, and only when a host drives the plot with a controller is
/// anything ever hot.
///
/// A legend the plot has is part of the frame: the panel and its swatches are
/// line geometry staged with everything else, the names are glyphs on the text
/// path, and both read the boxes `PlotLayout::legend` measured. It is the last
/// line batch, so the legend cannot be drawn under a trace, and it leaves no GL
/// state of its own: it is drawn inside the same axes clip the interior passes share,
/// and the frame's blend already says everything a fill and a hairline border need
/// (see end_frame's residue list).
///
/// Regions are part of that same evidence: the fill is one instance of the line
/// pipeline (`fill_pixels()`, the primitive the legend panel uses), the bounds are
/// vertical strokes and the label is a staged glyph run, so a frame with regions
/// leaves behind exactly what a frame without them does.
void draw(Plot& plot);
///
/// **The clip** is the one piece of state the frame moves to keep data inside the plot
/// area (spec §13): the passes that draw *inside* `axes` -- grid, traces, regions,
/// cursors, the measurement band, annotations, the legend and the crosshair -- run with the scissor
/// narrowed to `layout().axes`, intersected with the frame's viewport, while the axis
/// lines (chrome drawn on the rect's own edge, which overhangs it by half a stroke) and
/// the tick labels and titles (outside the rect by design) run with the frame's viewport
/// clip. A sample outside the visible range, or a segment the gathered window's slack
/// hangs past the end of the window, therefore cannot be drawn over the label strips --
/// or, in a `Figure`, into the row above.
///
/// Nothing moves to make that happen: the clip is a GL state change and not a geometry
/// one, so the vertices staged, the transforms and the data-space invariant are exactly
/// what they were.
/// Draws a whole `Figure`: every row in its own rect, then the figure's one shared
/// x axis.
/// Each row is drawn by the *same* path as `draw(Plot&)` -- the viewport and the
/// x-label policy come from `Figure::layout()`, which the figure hands out and
/// which lays every row out against its rect -- so a row is chrome and data drawn
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
/// single-plot hosts.
/// A shared x axis is the **bottom row's own** (of a run of rows with the same
/// columns), so it is drawn by that row's normal path: when the figure says labels
/// belong on the bottom only (the default), the rows above that match the row below
/// reserve no x chrome and the bottom row stages its own labels and title once,
/// inside its own strip -- the rect `FigureLayout::x_axis_strip` reads back. A row
/// whose columns differ keeps and draws its own; with that switched off, every row
/// does and there is no shared axis.
/// Styling, the text backend and `ui_scale` are installed on **every** row before
/// the figure computes its layout, so the strips the rows measure and the strokes
/// this draws are one set of numbers, exactly as they are for one plot.
void draw(Figure& figure);
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
/// attributed to a later frame (see `last_frame_had_gl_error()`).
///
/// It also records each instance store's single fence for the frame, behind
/// every draw that read the ring, which is what the next `begin_frame()` waits
/// on (see `InstanceStore::mark_drawn`).
///
/// The GL state it leaves behind, so a host can embed the plot without guessing:
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and
/// nothing bound to `GL_TEXTURE_2D` on it, scissoring off, and the
/// viewport left at the frame's rect (the caller sets its own for the next pass).
///
/// The scissor is the other piece of state a frame moves, and it ends the frame *off*:
/// `begin_frame()` engages it on the frame's viewport -- the box the clear uses --
/// `draw()` narrows it to a plot's `axes` for the interior passes and releases it back to
/// the viewport for the labels and titles, and this turns the test off. A host is
/// therefore never left holding a clip it did not ask for: not the axes rect of the last
/// interior pass, and not the viewport's either. The box itself is left at the viewport's
/// own rect, which is where that release puts it, so a host that enables the scissor
/// again without setting one gets the region the frame drew in.
/// The one piece of global state the frame *changed* is handed back rather than
/// left where the frame wanted it: the clear colour, which is
/// `style().background` only for the duration of the frame. GL keeps no history
/// for it, 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.
///
/// `GL_UNPACK_ALIGNMENT` is deliberately not in that list any more: the atlas
/// upload needs it at 1 for single-channel rows, and `render::TextAtlas` saves
/// and restores it around every upload it performs, so no frame -- initial or
/// mid-run -- leaves it moved for the host's own uploads to inherit.
/// 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;
/// The frame's colours and metrics; `draw()` hands them to the plot as well --
/// style and `ui_scale` both -- so the layout it computes matches what is drawn.
/// Every metric here is a logical unit (`Style`'s unit rule): the host writes
/// `ui_scale` from the display it is on and this converts each metric with
/// `device()`. `line_width_px` is the width of the strokes framing the data (axis
/// lines, cursor markers); the grid carries a width *per class*
/// (`grid_major_width` / `grid_minor_width`) and every trace its own, and a grid
/// class the style switches off stages no geometry at all.
render::Style& style() noexcept { return style_; }
const render::Style& style() const noexcept { return style_; }
/// True when the last frame reported a GL failure: an error drained from
/// `glGetError()` in `end_frame()`, or a fence the driver refused, which the
/// store reports when the frame's fence is recorded or waited on (such a
/// frame has already fallen back to the baseline path). Errors are drained in a
/// loop, so a stale one cannot be attributed to a later frame.
bool last_frame_had_gl_error() const noexcept { return gl_error_; }
/// Statistics of the most recently drawn frame.
const FrameStats& stats() const noexcept { return stats_; }
bool ready() const noexcept { return ready_; }
/// True while both pipelines are uploading through the persistent-mapped ring:
/// `init()` selects it from the caps and the entry points (4.3+ with
/// ARB_buffer_storage, and `PLOT_FORCE_GL33_UPLOAD` unset), and a driver failure
/// later -- a refused map or fence -- leaves the store that hit it on the
/// baseline path, which this then says.
bool using_persistent_upload() const noexcept {
return line_vao_.persistent() && text_vao_.persistent();
}
/// The axis text size the renderer actually draws with, in **device** pixels: the
/// on-screen cell height of the backend it draws with, which is
/// `style().device(Style::axis_font_px)`'s own cell rather than the logical request
/// itself. `Style::axis_font_px` is an em size, so with an ascent-to-descent cell
/// it is this that a host reads back as the line height it got -- and it is the
/// backend's own `height()` scaled, so it always agrees with the metrics the plot
/// laid the labels out with.
f32 axis_font_px() const noexcept { return font_backend_.cell_px(); }
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
/// channel, rows top-down) so a headless render can be looked at without a
/// 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, an
/// unreadable pixel, or an allocation the readback could not make -- which is
/// reported as `ErrorCode::OutOfMemory`, exactly as `TrueTypeFont::from_file()`
/// reports the same failure. `noexcept` like every other entry point here: the
/// buffers are sized before anything is on disk, so the two failures that can
/// arise from sizing them are a `Status`, not a `std::bad_alloc` across the API
/// boundary and a half-written file.
bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept;
private:
/// The backend the plot layer sees, at the size the text pipeline draws it:
/// `measure`/`height`/`ascent` scale with the run, so a label can never be laid
/// out for one size and drawn at another.
///
/// It wraps a `TextBackend` and nothing more: the scale is
/// `axis_font_px / em_px()`, a plain ratio, so an anti-aliased face is drawn at
/// the size the style asked for rather than at a whole multiple of its cell,
/// and any backend -- the embedded font, a host's TrueType face -- is drawn the
/// same way. It forwards the whole interface: the metrics and the glyph
/// bearings and advances are scaled, while the artwork itself -- the atlas
/// bytes, its size and its revision -- is the backend's own and passes through
/// untouched.
class AxisFont final : public render::TextBackend {
public:
void reset(const render::TextBackend& font, f32 requested_px) noexcept;
f32 measure(const char* utf8) const override;
f32 height() const override;
f32 ascent() const override;
f32 em_px() const override;
/// The font's glyph at this wrapper's scale: the atlas rect is unchanged (it
/// is scale-free) and the bearing and advance are multiplied by it, so one
/// `Glyph` describes the glyph this backend draws. The result lives in the
/// wrapper until the next call, which is enough for a caller measuring or
/// laying out one glyph at a time.
const render::Glyph* glyph(u32 codepoint) const override;
/// The font's cell: the atlas is the font's own, and a cell's size is a
/// property of the atlas, not of the size text is drawn at, so this forwards
/// unchanged -- which is what makes it the authoritative value the staging
/// path declares and the draw is told.
Vec2 cell_uv() const override;
const u8* atlas_pixels() const override;
u32 atlas_width() const override;
u32 atlas_height() const override;
u32 revision() const override;
/// On-screen size of one glyph cell, in pixels.
f32 cell_px() const noexcept { return cell_px_; }
/// On-screen pixels per font pixel: the factor `measure()` and `glyph()` are
/// scaled by, and what `TextBatch::add_text` is handed as its size.
f32 scale() const noexcept { return scale_; }
private:
const render::TextBackend* font_ = nullptr;
f32 cell_px_ = 1.0f;
f32 scale_ = 1.0f;
/// `glyph()`'s scaled result, returned by pointer because the interface is
/// `const Glyph*` and the scale is the one thing this wrapper adds.
mutable render::Glyph scaled_{};
};
/// One axis pair's data -> pixel mapping, already shifted so the viewport's
/// top-left corner is the origin (the shaders emit NDC relative to the GL
/// viewport, not the framebuffer).
///
/// `origin_x` is the data-space reference the caller staged its geometry
/// relative to: every data-space x reaches this layer as the f32
/// `to_space(x) - to_space(origin_x)` (B1), so the mapping folds the origin
/// back into the offset uniform -- `offset_x = sx*to_space(origin_x) + tx -
/// viewport.x`, computed in f64 and narrowed once -- and the staged values
/// stay O(visible pixels) however large the absolute x is (an epoch axis).
struct Mapping {
Vec2 scale{1.0f, 1.0f};
Vec2 offset{0.0f, 0.0f};
};
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y, f64 origin_x) const noexcept;
/// The identity mapping for geometry already staged in viewport pixels.
Mapping pixel_mapping() const noexcept;
/// Shifts one framebuffer-space pixel position into viewport space.
Vec2 to_viewport(Vec2 px) const noexcept;
/// The single transfer point of the line pipeline: the staged `LineBatch`
/// payload (four floats per instance) goes to whichever strategy `init()`
/// selected. Callers of this helper never see which one is live.
void upload_vertices(std::span<const f32> interleaved) noexcept;
/// Same, for the text pipeline's four-float glyph instances.
void upload_text_vertices(std::span<const f32> interleaved) noexcept;
/// Uploads the staged line batch, maps it with (`scale`, `offset`), rasterises
/// it in one colour and empties the batch. A no-op for an empty batch, so the
/// caller can flush unconditionally.
///
/// `dash` is the pattern in pixels for the fragment shader's discard: `x` the ink
/// length, `y` the gap, and a non-positive `y` -- the default every caller but the
/// crosshair passes and a dashed trace -- means **solid**. It is set on every flush,
/// so one draw's dash can never leak into the next. The AA fringe (`u_aa`) is
/// likewise set on every flush, from `style().line_aa_px` in device pixels.
///
/// `snap` is `LineProgram::set_snap()`'s pixel-grid rounding: on for the chrome
/// that frames a plot -- the grid, the axis lines, a panel or legend border --
/// and off for everything drawn at a data position, since it moves an
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
bool snap = false) noexcept;
/// The same, for a batch other than the renderer's own `lines_`. The graded trace
/// pass stages one batch per alpha step, and a step has to be a draw call of its
/// own because the line pipeline takes its colour from a uniform -- so it needs to
/// flush a batch it chose rather than the member one. Every other caller flushes
/// `lines_` and uses the overload above.
void flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
Vec2 dash = Vec2{}, bool snap = false) noexcept;
/// Draws every staged glyph in `batch` -- each instance carries its own tint
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
/// once per text batch, after the line geometry it belongs over.
void flush_text(render::TextBatch& batch) noexcept;
/// Narrows the frame's clip to `rect` -- framebuffer pixels, top-left origin, like every
/// `Rect` here -- and *intersects* it with the frame's viewport, so the clip can only
/// ever be smaller than the region the frame owns: a rect the layout got wrong, or one
/// that reaches past the frame, clips the interior passes away rather than letting them
/// paint over the host's surface. Enables `GL_SCISSOR_TEST`; `clear_clip()` puts it back.
///
/// The box is integral and GL's origin is bottom-left, so this is also where a `Rect`
/// becomes a scissor box, and it rounds *inward* -- see `clip_to()`. Called once per
/// plot with `layout().axes`, and released again before the passes that draw outside the
/// axes (see `draw_plot()`).
void set_clip(const Rect& rect) noexcept;
/// Puts the frame's clip back to its viewport: the state `begin_frame()` leaves, and the
/// one the axis lines, the tick labels and the axis titles run with -- chrome that lives
/// outside `axes` by design, and would be erased under the axes clip.
void clear_clip() noexcept;
/// The one place a scissor box is computed from a rect's four edges: clamped to the
/// framebuffer's rows, rounded *inward* (`ceil` the near edges, `floor` the far ones) so
/// the box holds only pixels the rect entirely contains -- rounding outward would let a
/// trace's half-pixel fringe past the edge it is being kept behind -- and flipped to GL's
/// bottom-left origin. An empty or unordered rect (a degenerate axes, or a NaN) sets an
/// empty box, which rejects every fragment: the honest meaning of "clip to nothing".
void clip_to(f32 x0, f32 y0, f32 x1, f32 y1) noexcept;
/// Fills `rect` -- in framebuffer pixels, like `Rect` -- with `color`, in one
/// instance: a horizontal stroke whose width is the rect's height *is* the rect,
/// because a stroke's quad is its perpendicular width and the fragment shader
/// ramps on that perpendicular alone -- the ends are not capped (see
/// `fill_pixels()` in the implementation for why a per-column fill, the way the
/// measurement band fills, is the wrong primitive here). A rect with no width or
/// no height stages nothing.
void fill_pixels(const Rect& rect, Color color);
/// Stages one data-space segment as f32 values **relative to `origin_x`** in
/// the axis' pre-affine space (`log10` of it on a log axis): the mapping folds
/// `origin_x` back in, in f64, before the single f32 narrowing (B1), so a
/// large-magnitude x -- an epoch axis -- never loses sub-pixel precision.
void stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
/// Stages `n` interleaved `x,y` points of a gathered window (see `Series::gather`): x
/// **relative to `origin_x`**, which the caller asked the gather for, y absolute. On linear
/// axes that is already what the batch wants and the buffer is handed over as it is; a log
/// axis reconstructs the absolute x in f64 per segment and stages
/// `to_space(x_abs) - to_space(origin_x)` -- O(visible pixels), not O(epoch).
void stage_xy(const f32* xy, u32 n, f64 origin_x, bool log_x, bool log_y);
/// Lays one run of text out at a framebuffer position and stages it into
/// `batch`, shifting it into viewport space and tinting it `color` (the
/// per-instance `a_colour`). Returns the pen past the run.
Vec2 stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color);
/// Width of the longest prefix of `utf8` that fits `budget_px` along `axis`, at
/// the size the text pipeline draws with: exactly what `stage_text_clipped()`
/// stages.
f32 clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const;
/// Stages as much of `utf8` as fits `budget_px` along `axis` at `pos_px` and
/// returns the length it staged. Titles are the runs that need this: a title
/// longer than the strip the layout reserved for it is cut there, rather than
/// being allowed to grow the strip (which would shrink the plot) or to spill
/// over the tick labels and the data (which is the defect the strips exist to
/// prevent).
///
/// `axis` is the run's own direction and a unit vector: (1, 0) for the
/// horizontal x title, which spends the strip's width, and (0,-1) for the y
/// title, which reads bottom-to-top and spends the strip's height. `pos_px` is
/// the pen -- for the rotated run, the bottom-left corner of its first cell.
f32 stage_text_clipped(render::TextBatch& batch, Vec2 pos_px, const char* utf8, f32 budget_px,
Vec2 axis, Color color);
/// Pins the text pipeline for a frame: the plot layer's backend at the size the
/// style asks for, the cell every staged glyph is drawn in, and the atlas the
/// backend publishes -- re-uploaded when its revision moved. False when a driver
/// refused the re-upload, which the frame reports (see `last_frame_had_gl_error()`)
/// and carries on from, drawing with the atlas it already had.
bool begin_frame_text() noexcept;
/// Hands `plot` everything the frame draws with: the renderer's text backend (a
/// host's is replaced here, named in a debug build, as it always was), the style,
/// and the `ui_scale` the plot converts that style's logical metrics by. Called
/// before a plot is laid out, which is why it is separate from the drawing.
void prepare_plot(Plot& plot);
/// Draws one plot's frame with the layout the caller already computed:
/// `draw(Plot&)` passes `plot.layout()`, and `draw(Figure&)` the layout
/// `Figure::layout()` computed for that row -- so a figure's rows are drawn by
/// exactly this path and neither entry point lays a plot out twice.
void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot);
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: one textured quad per
/// trace over the axes rect, each in **that trace's own colour** -- a saturated cell is the trace
/// at its own brightness -- drawn over the grid and the invalid-span bands. The traces themselves
/// are not drawn while it is on (`draw_traces()` returns), so this is the whole of the data ink
/// and the newest acquisition is inside it.
///
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here
/// from the grid's counts -- the scale and the gamma are the plot's and this stage derives
/// neither, so the numbers a host reads back and the picture it sees are one derivation. The
/// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
/// cell is several device pixels across. A frame whose grids did not change skips the conversion
/// *and* the uploads and costs the draw calls, which `FrameStats::density_upload_bytes` says per
/// frame.
void draw_density(const Plot& plot, const PlotLayout& layout);
/// One grid's quad: fills the shared `GL_R8` texture from `grid` (skipping when its revision and
/// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
/// and not the grid's own cell count, so a capped grid is scaled back over the area it is an
/// image of. The one place the density conversion lives, shared by every trace of
/// `draw_density()`.
void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
/// a hole reads as a property of the plot's background -- the grid shows through it and a trace
/// that does have data in those columns is drawn on top, which is what distinguishes a reserved
/// hole from a trace that has gone quiet.
void draw_invalid_spans(const PlotLayout& layout);
void draw_axis_lines(const Plot& plot);
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
/// down -- never re-derived per helper.
void draw_tick_labels(const Plot& plot, const PlotLayout& layout);
void draw_titles(const Plot& plot, const PlotLayout& layout);
/// The data helpers take it too; they read the axes rect and the transforms it
/// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout);
/// One gathered window reduced onto the pixel grid and staged in framebuffer
/// pixels: for each integer column of `layout.axes`, one exactly vertical
/// segment from the lowest to the highest screen y any gathered point put in
/// it. Returns how many columns were staged (0 stages nothing); the caller
/// flushes with `pixel_mapping()` and `snap = true`.
///
/// `gather_` is the input, read with the same origin `draw_traces` asked the
/// series for. See `draw_traces` for when this path is taken and why.
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points);
/// The same reduction, graded by **dwell**: a column is drawn as a stack of runs,
/// each at the alpha step its own share of the column's samples earns, so a flat
/// top is bright and the edge leading to it is faint (`Style::intensity_grading`).
///
/// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes
/// in turn -- the line pipeline's colour is a uniform, so a step is a draw call.
/// Returns the segments staged across all steps (0 stages nothing).
///
/// `gather_` is the input and must have been asked for at the *finer* level
/// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable
/// from its own min and max (see `Style::intensity_grading`).
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
f64 x_origin, u32 points);
/// The **sparse** window's polyline, graded by the same measure: each segment is
/// staged into the step its own *slope* earns, so a near-vertical segment between two
/// adjacent samples -- a discontinuity, which the signal crossed without dwelling
/// anywhere in it -- is faint and a near-flat one is bright.
///
/// It exists because grading used to be gated on the column reduction, and that
/// reduction is gated on a *density* (two gathered points per pixel column) whose only
/// purpose is removing the moire beat. Dwell has nothing to do with that density, so
/// zooming in past it switched the whole phosphor model off and drew an edge as
/// brightly as a flat top -- the edge got brighter the further in you zoomed.
///
/// The measure agrees with `stage_trace_density()`'s at the boundary but is **absolute
/// rather than per column**: below two points a column there is no column peak to
/// normalise against, so a segment's dwell is `1 / (1 + rows it spans)` outright. A
/// flat segment earns the top step and one crossing the whole axis earns
/// `Style::intensity_floor`, which is what a riser's column already gets on the dense
/// side.
///
/// Geometry is staged exactly as `stage_xy()` stages it -- in the pre-affine space,
/// relative to `x_origin` -- so only *which batch* a segment lands in differs from the
/// ungraded polyline, and the caller flushes with the trace's own `Mapping` and
/// `snap = false`. Returns the segments staged across all steps (0 stages nothing).
u32 stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
f64 x_origin, bool log_x, bool log_y);
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
/// range, in its own colour -- or in the accent at handle width while it is hovered or
/// grabbed, which is the same cue a region's hot bound gets. A cursor more than half a
/// stroke outside its range's mapped span (a marker scrolled past the right edge, a ruler
/// past the bottom) is skipped; one on or within half a stroke of a bound is drawn, with
/// its stroke centre held half a stroke inside the rect -- so a marker parked on an edge
/// stays visible there instead of losing its outside half, and the inside sliver of it, to
/// the clip, and a range maximum that undershoots the edge by a ulp is not mistaken for
/// off the plot.
void draw_cursors(const Plot& plot, const PlotLayout& layout);
/// One cursor's label: the caller's caption for either orientation, and for a horizontal
/// ruler the value itself, written in its y axis' own tick format (`Axis::format_value()`)
/// -- the number a ruler exists to be read off. A vertical cursor's caption starts at the
/// top of the axes beside its marker; a ruler's text starts at the axes' left edge on its
/// own row, with the caption first on that line when there is one.
void draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at);
/// The regions: the fill, one bound per edge, the optional label. Every rect comes
/// from `PlotLayout::regions` -- the same geometry the controller hit-tests -- so
/// a region is drawn where it can be grabbed, and neither side derives the other's
/// rects.
void draw_regions(const Plot& plot, const PlotLayout& layout);
/// One region bound: a vertical stroke of `width` down the tab's own column, in
/// `color`. The column is `handle_column()` of the tab -- the bound the layout
/// placed -- and a bound outside the axes' columns is skipped, exactly as a cursor
/// marker out of the visible window is.
void draw_region_edge(const Rect& tab, const Rect& axes, Color color, f32 width);
void draw_measures(const Plot& plot, const PlotLayout& layout);
void draw_annotations(const Plot& plot);
/// The legend: the panel and its swatches as line geometry, the names on the text
/// path. It reads the boxes `Plot::layout()` measured rather than deriving any of
/// them again, so the panel a test asserts and the panel this draws are the same
/// numbers.
void draw_legend(const Plot& plot, const PlotLayout& layout);
/// `Plot::x_note()`, in the bottom-left corner of the axes on the legend's backplate. It is
/// measured here rather than in `Plot::layout()` on purpose: unlike a legend it reserves no
/// space and nothing can be picked on it, so there is no measurement a host or a test needs to
/// read back, and a caption that explains the x labels must not be able to move the layout the
/// labels were measured into.
void draw_x_note(const Plot& plot, const PlotLayout& layout);
/// The backplate the legend and the crosshair's readout share: the frame's background at
/// `kLegendPanelAlpha` over `panel`, with a one-device-pixel border in `border` around the
/// outermost pixels the fill covers (the crisp-hairline rule -- a border drawn on the measured
/// rect's fractional edges is lit half-heartedly, and a frame bright on three sides and dim on the
/// fourth reads as a mistake). One helper, two callers: the border's colour is the only thing a
/// legend panel and a readout panel do not share.
void draw_readout_panel(const Rect& panel, Color border);
/// One trace's colour swatch in `box`, opaque whatever the trace's own alpha is: a swatch drawn at
/// a trace's 15% would be the least legible thing in the frame, and the swatch is the one element
/// that says which colour an entry or a readout row *is*. The legend's entries and the crosshair's
/// rows both read it from here.
void draw_swatch(const Plot& plot, u32 trace, const Rect& box);
/// The crosshair's **lines and markers**: the vertical and horizontal strokes and a small
/// cross at each trace's intersection.
///
/// Every rect -- the two stroke boxes and the markers' positions -- comes from
/// `PlotLayout::crosshair`, which placed them inside `axes`; this stages exactly that
/// geometry, so the crosshair a test measures and the crosshair a frame paints are one set of
/// numbers. Clipped to `axes` with the rest of the interior passes, which is what keeps a
/// marker for a sample far outside the visible y range inside the rect instead of over a
/// label strip.
///
/// Drawn over the data and *under* the handles -- regions, cursors -- because the crosshair
/// sits wherever the pointer is, including on top of the handle a gesture is manipulating, and
/// a hover or grab highlight must not be hidden by the instrument that happens to share its
/// column. Its readout panel is the other half (`draw_crosshair_readout()`), drawn last.
void draw_crosshair_lines(const Plot& plot, const PlotLayout& layout);
/// The crosshair's **readout panel**: the backplate, its border and one swatch, label and
/// value per listed row, all read from `PlotLayout::crosshair` -- the plot formatted the
/// values (in each trace's own y axis' format) and placed every box, so this layer formats
/// nothing and derives no rect. A row that is not listed is skipped; a row whose trace has no
/// sample at the crosshair's x stages no value at all rather than a fabricated one.
///
/// Drawn after every other interior pass -- the legend included -- because it is what the
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
/// the area being framed is legible over the traces, the regions and the readout alike.
void draw_selection_frame(const PlotLayout& layout);
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
/// pixel space, at `size_px` half-extent, so a marker's size is device pixels
/// whatever the data scale is.
void stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px);
/// The built-in backend the renderer owns, and the face the frames draw with:
/// `set_text_backend()` writes `font_`, which starts here and comes back here
/// when a host passes null. Every use of the face goes through the
/// `TextBackend` interface -- `self`-typed nowhere -- so a host's backend and
/// this one are interchangeable, and the only thing that has to happen on a swap
/// is the atlas re-upload `set_text_backend()` arranges.
render::EmbeddedMonoFont default_font_;
const render::TextBackend* font_ = &default_font_;
AxisFont font_backend_;
render::Style style_;
render::TextAtlas atlas_;
render::LineProgram line_program_;
render::TextProgram text_program_;
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture a grid is
/// uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and is re-filled
/// for each trace in turn -- a trace's grid is drawn before the next trace's replaces it -- so a
/// row of several traces still costs the one texture. It is re-created when the grid's size moves
/// (a resized plot is a differently sized image, and the plot has already dropped what the old one
/// held).
render::DensityProgram density_program_;
gl::VertexArray density_vao_;
gl::Texture density_texture_;
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
/// nothing: one byte per cell.
std::vector<u8> density_pixels_;
u32 density_w_ = 0;
u32 density_h_ = 0;
/// The grid the texture currently holds, by address, and its revision. Compared per frame and per
/// trace, so a frame whose grids did not move skips the conversion *and* the upload -- the pause
/// in a persistence display. The address is part of the key because the texture is shared: two
/// traces of one plot have two grids with two revisions, and without the address a skip would
/// draw one trace's image under another's colour.
const DensityGrid* density_uploaded_grid_ = nullptr;
u64 density_uploaded_revision_ = 0;
render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_;
render::LineBatch lines_;
/// The frame's two text batches (B6): `glyphs_under_` collects the text the
/// crosshair readout's panel may legitimately occlude (legend names, cursor
/// labels, measure labels, annotation captions) and is flushed immediately
/// before `draw_crosshair_readout()`; `glyphs_over_` collects what must sit
/// over everything (readout text, tick labels, titles) and is flushed at
/// `end_frame()`. One batch per z-order, one draw call each.
render::TextBatch glyphs_under_;
render::TextBatch glyphs_over_;
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
/// One (lowest y, highest y) pair per pixel column of the axes, re-used:
/// `stage_trace_columns()`'s working set. Two floats per column, so an 8K-wide
/// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the
/// window.
std::vector<f32> columns_;
/// `stage_trace_density()`'s working set, and deliberately **one column wide**: the
/// gathered points arrive in x order, so a column can be accumulated, read and
/// cleared before the next one starts. A full-axes density image would be
/// `axes.w * axes.h` (33 MB at 4K); this is `axes.h` (a few kB) and stays in L1.
///
/// Each sub-bucket's span is written as a +1 at its first row and a -1 one past
/// its last, so accumulating a span costs two writes whatever its height; the
/// running sum over the column is then how many sub-buckets cover each row, which
/// is the dwell.
std::vector<f32> column_rows_;
/// One batch per alpha step of the grading (`Style::intensity_steps`), re-used
/// across frames: the line pipeline takes its colour from a uniform, so segments
/// at different alphas cannot share a draw call and must be staged apart.
std::vector<render::LineBatch> steps_;
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
/// 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;
/// The host's blend state, saved by `begin_frame()` and handed back by
/// `end_frame()` (R6): a frame sets blending for its straight-alpha pipelines
/// and must not leave its function -- or the enable bit -- behind for the
/// host's own draws to inherit.
gl::GLint blend_src_rgb_ = gl::GL_ONE;
gl::GLint blend_src_a_ = gl::GL_ONE;
gl::GLint blend_dst_rgb_ = gl::GL_ZERO;
gl::GLint blend_dst_a_ = gl::GL_ZERO;
bool blend_was_on_ = 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;
u32 fb_h_ = 0;
bool ready_ = false;
bool gl_error_ = false;
FrameStats stats_{};
};
} // namespace plot