#pragma once #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 u64 visible_samples = 0; ///< samples the gathered windows covered, summed u32 staged_segments = 0; ///< line segments staged, all colour groups u32 draw_calls = 0; ///< draw calls issued, line and text }; /// 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; ~Renderer() = default; Renderer(const Renderer&) = delete; Renderer& operator=(const Renderer&) = delete; /// 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; /// 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. /// /// 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 -- the frame's blend and its neighbours' scissor already say /// 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); /// 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, blending off, scissoring off, and the /// viewport left at the frame's rect (the caller sets its own for the next pass). /// 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 or an /// unreadable pixel. /// /// The one entry point here that is not `noexcept`: it sizes its readback hole /// and its output row, so an allocation failure propagates `std::bad_alloc` /// rather than terminating a process that could have carried on without a /// screenshot. A dump is a diagnostic, and no `Status` code could say more than /// the exception already does. bool dump_frame_ppm(const char* path, Status* st = nullptr) const; private: /// The 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). 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) 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 interleaved) noexcept; /// Same, for the text pipeline's four-float glyph instances. void upload_text_vertices(std::span 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. void flush_lines(const Mapping& map, Color color, f32 width) noexcept; /// Draws every staged glyph in `style().axis_text` and empties the batch. Text /// is texture-bound, so this runs once per frame, after all line geometry. void flush_text() 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, taking the two axes' log flags into account. void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y); /// Stages `n` interleaved `x,y` data-space points (a gathered window). void stage_xy(const f32* xy, u32 n, bool log_x, bool log_y); /// Lays one run of text out at a framebuffer position and stages it, shifting /// it into viewport space. Returns the pen past the run. Vec2 stage_text(Vec2 pos_px, const char* utf8); /// 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(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis); void draw_grid(const Plot& plot); 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 belongs to `draw()` alone. 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); void draw_cursors(const Plot& plot, const PlotLayout& layout); /// 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); /// The built-in backend the renderer owns. Every use of it goes through the /// `TextBackend` interface -- `self`-typed nowhere -- so swapping it for /// another face is a change to this member alone. render::EmbeddedMonoFont default_font_; AxisFont font_backend_; render::Style style_; render::TextAtlas atlas_; render::LineProgram line_program_; render::TextProgram text_program_; render::LineVertexArray line_vao_; render::TextVertexArray text_vao_; render::LineBatch lines_; render::TextBatch glyphs_; std::vector gather_; ///< scratch for one gathered window, re-used mutable std::vector 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; 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