From 3555ab63d763cf22f5f1839facc778e6259701a0 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 27 Sep 2026 23:02:48 +0200 Subject: [PATCH] feat(render): Renderer with LOD selection, 3.3 and 4.3+ upload paths, headless smoke test --- CMakeLists.txt | 1 + include/plot/gl/api.hpp | 17 + include/plot/gl/handle.hpp | 15 + include/plot/gl/loader.hpp | 3 + include/plot/plot/measure.hpp | 11 + include/plot/plot/plot.hpp | 9 + include/plot/render/buffer.hpp | 90 +++- include/plot/render/primitives.hpp | 4 + include/plot/render/renderer.hpp | 226 ++++++++++ include/plot/render/vertex_array.hpp | 22 +- src/gl/handle.cpp | 19 + src/gl/loader.cpp | 6 + src/plot/plot.cpp | 10 + src/render/buffer.cpp | 104 ++++- src/render/primitives.cpp | 13 + src/render/renderer.cpp | 619 +++++++++++++++++++++++++++ src/render/vertex_array.cpp | 30 +- tests/CMakeLists.txt | 1 + tests/test_primitives.cpp | 24 ++ tests/test_render_smoke.cpp | 345 +++++++++++++++ 20 files changed, 1544 insertions(+), 25 deletions(-) create mode 100644 include/plot/render/renderer.hpp create mode 100644 src/render/renderer.cpp create mode 100644 tests/test_render_smoke.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 948f7b0..46b1ca2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,7 @@ add_library(plot_core src/render/buffer.cpp src/render/vertex_array.cpp src/render/program.cpp + src/render/renderer.cpp src/plot/ticks.cpp src/plot/axis.cpp src/plot/grid.cpp diff --git a/include/plot/gl/api.hpp b/include/plot/gl/api.hpp index fcc83d8..92b7210 100644 --- a/include/plot/gl/api.hpp +++ b/include/plot/gl/api.hpp @@ -59,6 +59,7 @@ enum : GLenum { // Pixel store state. GL_UNPACK_ALIGNMENT = 0x0CF5, + GL_PACK_ALIGNMENT = 0x0D05, // Shaders and programs. GL_FRAGMENT_SHADER = 0x8B30, @@ -76,6 +77,9 @@ enum : GLenum { GL_MAP_PERSISTENT_BIT = 0x0040 /* 4.4 */, GL_MAP_COHERENT_BIT = 0x0080 /* 4.4 */, + // Rasterisation state (1.0). + GL_SCISSOR_TEST = 0x0C11, + // Textures. GL_TEXTURE_2D = 0x0DE1, GL_TEXTURE0 = 0x84C0, @@ -120,6 +124,8 @@ enum : GLenum { X(void, glEnable, (GLenum)) \ X(void, glBlendFunc, (GLenum, GLenum)) \ X(void, glPixelStorei, (GLenum, GLint)) \ + X(void, glDisable, (GLenum)) \ + X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \ /* shaders and programs */ \ X(GLuint, glCreateShader, (GLenum)) \ X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*)) \ @@ -148,6 +154,8 @@ enum : GLenum { X(void, glBindBuffer, (GLenum, GLuint)) \ X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \ X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \ + X(void*, glMapBufferRange, (GLenum, GLintptr, GLsizeiptr, GLbitfield)) \ + X(GLboolean, glUnmapBuffer, (GLenum)) \ /* vertex arrays */ \ X(void, glGenVertexArrays, (GLsizei, GLuint*)) \ X(void, glDeleteVertexArrays, (GLsizei, const GLuint*)) \ @@ -182,8 +190,17 @@ enum : GLenum { X(GLenum, glClientWaitSync, (GLsync, GLbitfield, GLuint64)) \ X(void, glDeleteSync, (GLsync)) +// Entry points above the 3.3 baseline, resolved best-effort: a driver that lacks +// one leaves it null and `load()` still succeeds, so the library keeps working on +// a 3.3-only context. Every one of these is reached only after `caps()` -- or an +// explicit null check -- says the context can do it (see caps.hpp). +#define PLOT_GL_OPTIONAL_FUNCS(X) \ + /* persistent buffer mapping, GL 4.4 / ARB_buffer_storage */ \ + X(void, glBufferStorage, (GLenum, GLsizeiptr, const void*, GLbitfield)) + #define PLOT_GL_DECL(ret, name, args) extern ret (*name) args; PLOT_GL_FUNCS(PLOT_GL_DECL) +PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_DECL) #undef PLOT_GL_DECL } // namespace plot::gl diff --git a/include/plot/gl/handle.hpp b/include/plot/gl/handle.hpp index bdaef97..123dffc 100644 --- a/include/plot/gl/handle.hpp +++ b/include/plot/gl/handle.hpp @@ -66,6 +66,21 @@ class Buffer final : public Handle { /// Overwrites `bytes` bytes starting at `offset`; the store must already be /// at least `offset + bytes` large. void upload_sub(const void* data, usize bytes, usize offset) const noexcept; + /// Replaces the store with `bytes` bytes of immutable storage carrying `flags` + /// (`GL_MAP_WRITE_BIT` and friends). GL 4.4 / ARB_buffer_storage: only legal on + /// a context whose caps() report `buffer_storage`, and the entry point may be + /// null even on such a context (it is resolved best-effort, see api.hpp), so a + /// caller checks `glBufferStorage != nullptr` first. The contents start + /// undefined. + void storage(usize bytes, GLbitfield flags) const noexcept; + + /// Maps `bytes` bytes from `offset` with `access`, returning the pointer or + /// null. GL 3.0 core, but a `GL_MAP_PERSISTENT_BIT` map needs the same + /// capability as `storage()` above. + void* map_range(usize offset, usize bytes, GLbitfield access) const noexcept; + + /// Unmaps the store's data; the pointer from map_range() is invalid after this. + bool unmap() const noexcept; /// Exposed for the CRTP base. Deleting an unknown name is a GL no-op. static void destroy(GLuint id) noexcept; diff --git a/include/plot/gl/loader.hpp b/include/plot/gl/loader.hpp index b1b005d..abcafb0 100644 --- a/include/plot/gl/loader.hpp +++ b/include/plot/gl/loader.hpp @@ -10,6 +10,9 @@ using ProcLoader = void* (*)(const char*); /// Resolves every entry point of `PLOT_GL_FUNCS` (see api.hpp) into the global /// table declared there, so the rest of the library can call `gl*` directly. +/// The above-baseline entries of `PLOT_GL_OPTIONAL_FUNCS` are resolved the same +/// way but never fail the load: a driver without them leaves those pointers +/// null, and callers reach them only behind `caps()`. /// /// Returns false when any required name cannot be found; entries that did /// resolve keep their pointers, so a failed load() never breaks a table that diff --git a/include/plot/plot/measure.hpp b/include/plot/plot/measure.hpp index 224213c..481fbee 100644 --- a/include/plot/plot/measure.hpp +++ b/include/plot/plot/measure.hpp @@ -32,6 +32,17 @@ struct Measurement { bool valid = false; }; +/// The cursor pair a measure spans, as the plot stored it. `valid` is false for +/// an id that names no measure and for a measure whose cursors are not both +/// registered, so a caller can tell "no band to shade" from "a band between +/// cursors 0 and 0". The renderer uses it to place the band a measurement's +/// readout belongs to. +struct MeasureSpan { + u32 cursor_a = 0; + u32 cursor_b = 0; + bool valid = false; +}; + /// Stateless interval measurement over a data::Series. struct Measure { /// Evaluates [begin, end). Every quantity comes from `Series::reduce`/`sum`/ diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 7ffaa85..6ca9f04 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -58,6 +58,10 @@ class Plot { /// creates one and never touches GL. void set_text_backend(const render::TextBackend* text) noexcept { text_ = text; } + /// The backend layout() measures labels with, or null when none is set. The + /// renderer reads it to install its own font only when nobody else has. + const render::TextBackend* text_backend() const noexcept { return text_; } + // --- construction ----------------------------------------------------- u32 add_x_axis(const AxisDesc& desc); @@ -135,6 +139,11 @@ class Plot { /// because recomputing fills a cache: nothing a caller can observe changes. const Measurement& measurement(u32 id) const; + /// The cursor pair `id` was created over. The renderer needs it to place the + /// shaded band the measurement readout belongs to; the statistics themselves + /// are in measurement(). + MeasureSpan measure_span(u32 id) const noexcept; + /// Drops every cached measurement. Needed after a series is mutated in place /// (data::ArraySeries::commit() with the same sample count), which a pointer /// and size check cannot detect. diff --git a/include/plot/render/buffer.hpp b/include/plot/render/buffer.hpp index b24e41e..6d8de9a 100644 --- a/include/plot/render/buffer.hpp +++ b/include/plot/render/buffer.hpp @@ -28,26 +28,84 @@ struct TextInstance { static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats"); static_assert(sizeof(TextInstance) == 4 * sizeof(f32), "text instance must stay 4 floats"); +/// How a store's staged instances reach the GPU. Semantics are identical either +/// way -- only the transfer differs, and `Renderer::init()` picks one from +/// `plot::gl::caps()`: +/// +/// - `Orphan`: the GL 3.3 baseline. Every upload re-specifies the store with a +/// null `glBufferData` (orphaning it, so the driver never has to wait for the +/// draw that is still reading the previous one) and then refills it. +/// - `PersistentMapped`: GL 4.4 / ARB_buffer_storage. Three slots of immutable +/// storage are mapped once and written directly, and the fence recorded after +/// each slot's draw guards its reuse, so the render thread never waits on the +/// GPU. The vertex array's attribute pointers move with the slot. +enum class UploadMode { Orphan, PersistentMapped }; + namespace detail { /// The growth half of an instance buffer, shared by both pipelines so neither -/// duplicates the GL ownership or the "grow only when the frame needs it" rule. +/// duplicates the GL ownership or the transfer strategy. This is the one place +/// vertex data is handed to the GPU: `LineBuffer::upload` and +/// `TextBuffer::upload` both land here, and no caller of either can tell which +/// `UploadMode` is live. class InstanceStore { public: - /// Creates the vertex buffer. Reports `ErrorCode::Unsupported` while the GL - /// entry points are unresolved, `GLFailure` if the driver hands out no name. - bool create(Status* st = nullptr) noexcept; + /// Slots the persistent ring rotates through. Three is the smallest ring that + /// lets a frame's draw finish while the CPU fills two more. + static constexpr u32 kSlots = 3; + /// Upper bound on the wait for a slot's draw to retire: a driver that has + /// stopped making progress must not stall the render thread forever. + static constexpr u64 kFenceWaitNs = 1'000'000'000ull; - /// Uploads `count` instances of `instance_bytes`. The store only reallocates - /// when the frame's workload outgrows it, so steady-state frames write into - /// the existing allocation. + /// Creates the vertex buffer in `mode`. Reports `ErrorCode::Unsupported` while + /// the GL entry points are unresolved and `GLFailure` if the driver hands out + /// no name. A caller asking for `PersistentMapped` must already have checked + /// that the context and the entry points can do it (see Renderer::init): a + /// store created that way on a machine without them would never draw. + bool create(UploadMode mode, Status* st = nullptr) noexcept; + + /// Uploads `count` instances of `instance_bytes`. `Orphan` mode orphans and + /// refills; `PersistentMapped` mode writes the next slot of the ring, waiting + /// out any draw still reading it. void upload(const void* data, u32 count, usize instance_bytes) noexcept; + /// Records that the draw the caller just issued read the current slot, so the + /// fence guarding that slot's reuse ends up in the command stream behind the + /// draw. A no-op in `Orphan` mode, where nothing reads the store after the + /// orphan. + void mark_drawn() noexcept; + + /// Byte offset the current slot's instances start at: 0 in `Orphan` mode, + /// `slot * slot_bytes` in `PersistentMapped` mode. The vertex array re-points + /// its attribute pointers whenever `epoch()` changes. + usize slot_offset() const noexcept { + return mode_ == UploadMode::PersistentMapped ? usize(slot_) * slot_bytes_ : 0u; + } + + /// Changes whenever the staged instances moved (a new slot) or the GL buffer + /// was recreated, i.e. whenever attribute pointers into this store went stale. + u32 epoch() const noexcept { return epoch_; } + void bind() const noexcept { buffer_.bind(); } + bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; } private: + /// Grows the mapped ring to `bytes` per slot. False when the driver refuses the + /// storage or the map, in which case the store has fallen back to a fresh + /// `Orphan` buffer and the frame still draws. + bool grow_persistent(usize bytes) noexcept; + /// Deletes every fence, unmaps and releases the GL buffer. `slot_bytes_` drops + /// to 0 so the next upload regrows. + void release_storage() noexcept; + gl::Buffer buffer_; - u32 capacity_ = 0; ///< instances the store can hold + UploadMode mode_ = UploadMode::Orphan; + u32 capacity_ = 0; ///< instances the orphan store can hold + usize slot_bytes_ = 0; ///< bytes per slot of the persistent ring + u32 slot_ = kSlots - 1u; ///< last slot written; an upload advances it first + u32 epoch_ = 0; + u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode + gl::Fence fences_[kSlots]; }; } // namespace detail @@ -55,10 +113,16 @@ class InstanceStore { /// Instance buffer of the line pipeline: one `LineInstance` per segment. class LineBuffer { public: - bool create(Status* st = nullptr) noexcept { return store_.create(st); } + bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept { + return store_.create(mode, st); + } void upload(const f32* interleaved, u32 count) noexcept { store_.upload(interleaved, count, sizeof(LineInstance)); } + void mark_drawn() noexcept { store_.mark_drawn(); } + usize slot_offset() const noexcept { return store_.slot_offset(); } + u32 epoch() const noexcept { return store_.epoch(); } + bool persistent() const noexcept { return store_.persistent(); } void bind() const noexcept { store_.bind(); } private: @@ -68,10 +132,16 @@ class LineBuffer { /// Instance buffer of the text pipeline: one `TextInstance` per glyph. class TextBuffer { public: - bool create(Status* st = nullptr) noexcept { return store_.create(st); } + bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept { + return store_.create(mode, st); + } void upload(const f32* interleaved, u32 count) noexcept { store_.upload(interleaved, count, sizeof(TextInstance)); } + void mark_drawn() noexcept { store_.mark_drawn(); } + usize slot_offset() const noexcept { return store_.slot_offset(); } + u32 epoch() const noexcept { return store_.epoch(); } + bool persistent() const noexcept { return store_.persistent(); } void bind() const noexcept { store_.bind(); } private: diff --git a/include/plot/render/primitives.hpp b/include/plot/render/primitives.hpp index 5acab47..410a636 100644 --- a/include/plot/render/primitives.hpp +++ b/include/plot/render/primitives.hpp @@ -31,6 +31,10 @@ class LineBatch { /// Stages `n - 1` segments joining consecutive points. A null pointer or /// fewer than two points stages nothing. void add_polyline(const Vec2* pts, u32 n); + /// Same, for `n` points already interleaved as `x,y` floats -- the layout + /// `data::Series::gather` appends to, so a gathered window is staged without a + /// copy or a reinterpret_cast of its storage. + void add_polyline_xy(const f32* xy, u32 n); /// Segments staged so far, one instance each. u32 segment_count() const noexcept { return static_cast(v_.size() / 4u); } diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp new file mode 100644 index 0000000..0eaf2bd --- /dev/null +++ b/include/plot/render/renderer.hpp @@ -0,0 +1,226 @@ +#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. +/// +/// 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, builds the glyph 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. + 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: `draw()` copies `style()` into the + /// plot, so the strips and tick densities the plot lays out with are the values + /// the renderer draws with -- set colours and metrics here, not on the plot. + /// The plot also borrows this renderer's font (when it has no backend of its + /// own), so the renderer must outlive the plot's use of it. + 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()`). + /// + /// 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 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, so + /// the layout it computes matches what is drawn. `line_width_px` is the width of + /// the strokes framing the data (axis lines, cursor markers) -- grid lines stay + /// hairlines and every trace carries its own width. + render::Style& style() noexcept { return style_; } + const render::Style& style() const noexcept { return style_; } + + /// True when the last `end_frame()` found a GL error. 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 when `init()` selected the persistent-mapped transfer (4.3+ with + /// ARB_buffer_storage, and `PLOT_FORCE_GL33_UPLOAD` unset). + bool using_persistent_upload() const noexcept { return persistent_; } + /// The axis text size the renderer actually draws with: `style().axis_font_px` + /// rounded to the nearest whole multiple of `render::BitmapFont::kGlyphPx`, + /// because a 1-bit glyph only scales cleanly by whole factors. + 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. + bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept; + + private: + /// The bitmap font as the plot layer sees it, at the size the text pipeline + /// draws it: `measure`/`height`/`ascent` scale with the glyph cell, so a label + /// can never be laid out for one size and drawn at another. The 8x8 font scales + /// by whole multiples of its cell only -- anything else lands a 1-bit glyph's + /// strokes unevenly -- so `reset()` picks the multiple nearest the requested + /// size. + class AxisFont final : public render::TextBackend { + public: + void reset(const render::BitmapFont& font, f32 requested_px) noexcept; + f32 measure(const char* utf8) const override; + f32 height() const override; + f32 ascent() const override; + /// On-screen size of one glyph cell, in pixels. + f32 cell_px() const noexcept { return cell_px_; } + + private: + const render::BitmapFont* font_ = nullptr; + f32 cell_px_ = 1.0f; + f32 scale_ = 1.0f; + }; + + /// 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; + + /// 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); + + void draw_grid(const Plot& plot); + void draw_axis_lines(const Plot& plot); + void draw_tick_labels(const Plot& plot); + void draw_titles(const Plot& plot); + void draw_traces(const Plot& plot); + void draw_cursors(const Plot& plot); + void draw_measures(const Plot& plot); + void draw_annotations(const Plot& plot); + + render::BitmapFont font_; + AxisFont font_backend_; + render::Style style_; + + gl::Texture 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 + + 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 persistent_ = false; + bool gl_error_ = false; + FrameStats stats_{}; +}; + +} // namespace plot diff --git a/include/plot/render/vertex_array.hpp b/include/plot/render/vertex_array.hpp index 7f984df..8158827 100644 --- a/include/plot/render/vertex_array.hpp +++ b/include/plot/render/vertex_array.hpp @@ -12,16 +12,29 @@ namespace plot::render { /// location 0 and `a_p1` at location 1, two floats each, advancing once per /// instance. A caller never writes a location, stride or offset; it stages a /// `LineBatch` and uploads its `data()`. +/// +/// The instance buffer may sit behind either transfer strategy (see +/// `UploadMode`). Only the mapped one moves the instances between slots, so +/// `upload()` re-points the attributes when `InstanceStore::epoch()` changes and +/// otherwise leaves them alone; the caller never has to know which path is live. class LineVertexArray { public: /// Creates the vertex array, the instance buffer and the attribute layout. - bool create(Status* st = nullptr) noexcept; + /// `mode` selects the instance buffer's transfer strategy and defaults to the + /// GL 3.3 baseline; a caller asking for `UploadMode::PersistentMapped` must + /// have checked the context can do it (see `Renderer::init()`). + bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept; /// Uploads `segments` instances from interleaved `x0,y0,x1,y1` floats and /// records how many the next draw should rasterise. void upload(const f32* interleaved, u32 segments) noexcept; void bind() const noexcept { vao_.bind(); } + /// Records that the draw just issued read the instance buffer's current slot, + /// which is what lets the mapped path reuse it safely. A no-op on the baseline + /// path, so a caller always calls it the same way. + void mark_drawn() noexcept { buffer_.mark_drawn(); } + bool valid() const noexcept { return vao_.id() != 0; } u32 instance_count() const noexcept { return instances_; } @@ -29,6 +42,8 @@ class LineVertexArray { LineBuffer buffer_; gl::VertexArray vao_; u32 instances_ = 0; + /// Instance-buffer generation the attribute pointers currently point into. + u32 epoch_ = 0; }; /// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0 @@ -36,12 +51,14 @@ class LineVertexArray { /// advancing once per instance. class TextVertexArray { public: - bool create(Status* st = nullptr) noexcept; + bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept; /// Uploads `glyphs` instances from interleaved `x,y,u,v` floats. void upload(const f32* interleaved, u32 glyphs) noexcept; void bind() const noexcept { vao_.bind(); } + void mark_drawn() noexcept { buffer_.mark_drawn(); } + bool valid() const noexcept { return vao_.id() != 0; } u32 instance_count() const noexcept { return instances_; } @@ -49,6 +66,7 @@ class TextVertexArray { TextBuffer buffer_; gl::VertexArray vao_; u32 instances_ = 0; + u32 epoch_ = 0; }; } // namespace plot::render diff --git a/src/gl/handle.cpp b/src/gl/handle.cpp index 2f4e6e3..33d8372 100644 --- a/src/gl/handle.cpp +++ b/src/gl/handle.cpp @@ -109,6 +109,25 @@ void Buffer::upload_sub(const void* data, usize bytes, usize offset) const noexc data); } +void Buffer::storage(usize bytes, GLbitfield flags) const noexcept { + if (glBufferStorage == nullptr) { + return; // not resolved on this driver: the caller checked caps() first + } + bind(); + glBufferStorage(GL_ARRAY_BUFFER, static_cast(bytes), nullptr, flags); +} + +void* Buffer::map_range(usize offset, usize bytes, GLbitfield access) const noexcept { + bind(); + return glMapBufferRange(GL_ARRAY_BUFFER, static_cast(offset), + static_cast(bytes), access); +} + +bool Buffer::unmap() const noexcept { + bind(); + return glUnmapBuffer(GL_ARRAY_BUFFER) == GL_TRUE; +} + void Buffer::destroy(GLuint id) noexcept { glDeleteBuffers(1, &id); } // -------------------------------------------------------------- VertexArray diff --git a/src/gl/loader.cpp b/src/gl/loader.cpp index c981553..176e552 100644 --- a/src/gl/loader.cpp +++ b/src/gl/loader.cpp @@ -26,6 +26,7 @@ namespace plot::gl { // The one definition of the entry point table declared in api.hpp. #define PLOT_GL_DEF(ret, name, args) ret (*name) args = nullptr; PLOT_GL_FUNCS(PLOT_GL_DEF) +PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_DEF) #undef PLOT_GL_DEF namespace { @@ -171,6 +172,11 @@ bool load(ProcLoader loader) noexcept { PLOT_GL_FUNCS(PLOT_GL_RESOLVE) + // Above-baseline entries: resolved the same way, but a name the driver does not + // have leaves its pointer null instead of failing the load, so a 3.3-only + // context keeps working. Callers gate them on caps() (see api.hpp). + PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_RESOLVE) + #undef PLOT_GL_RESOLVE // The table may have changed, so whatever caps() cached for the old one is diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index d73a64b..57c4df6 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -284,6 +284,16 @@ const Measurement& Plot::measurement(u32 id) const { void Plot::invalidate_measurements() noexcept { mark_measures_dirty(); } +MeasureSpan Plot::measure_span(u32 id) const noexcept { + // An id that names nothing, or a measure built from cursor ids that were never + // registered, is not a span between two markers: `valid` says so rather than + // letting a caller shade a band between cursors 0 and 1 by accident. + if (id >= measures_.size()) return MeasureSpan{}; + const MeasureSlot& slot = measures_[id]; + if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{}; + return MeasureSpan{slot.cursor_a, slot.cursor_b, true}; +} + const PlotLayout& Plot::layout() { // Axes still on auto scale refit here, once per layout, so a growing stream // stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range), diff --git a/src/render/buffer.cpp b/src/render/buffer.cpp index dedecbc..c2b536b 100644 --- a/src/render/buffer.cpp +++ b/src/render/buffer.cpp @@ -1,9 +1,13 @@ #include +#include + +#include + namespace plot::render { namespace detail { -bool InstanceStore::create(Status* st) noexcept { +bool InstanceStore::create(UploadMode mode, Status* st) noexcept { // plot::gl::Buffer::create() already reports Unsupported while the entry // points are unresolved and GLFailure when the driver hands out no name, so // the wrapper only has to propagate its answer. @@ -11,21 +15,103 @@ bool InstanceStore::create(Status* st) noexcept { return false; } capacity_ = 0; + slot_bytes_ = 0; + slot_ = kSlots - 1u; // the first upload takes slot 0 + // The mapped path additionally needs its entry points, which are resolved + // best-effort (see api.hpp): without them the store would never be able to + // write anything, so it stays on the baseline path instead. + mode_ = mode == UploadMode::PersistentMapped && gl::glBufferStorage != nullptr && + gl::glMapBufferRange != nullptr + ? UploadMode::PersistentMapped + : UploadMode::Orphan; + return true; +} + +void InstanceStore::release_storage() noexcept { + if (mapped_ != nullptr) { + // A persistent mapping outlives the frame that created it, so it has to be + // dropped before the buffer it belongs to is deleted. + buffer_.unmap(); + mapped_ = nullptr; + } + for (gl::Fence& fence : fences_) { + fence.reset(); + } + buffer_.reset(); + slot_bytes_ = 0; +} + +bool InstanceStore::grow_persistent(usize bytes) noexcept { + // Deleting the old buffer is safe while the GPU may still be reading it: GL + // keeps the storage alive until the last command that references it retires. + release_storage(); + Status st; + if (!buffer_.create(&st)) { + mode_ = UploadMode::Orphan; // nothing left to draw with at all + capacity_ = 0; + return false; + } + const gl::GLbitfield flags = + gl::GL_MAP_WRITE_BIT | gl::GL_MAP_PERSISTENT_BIT | gl::GL_MAP_COHERENT_BIT; + buffer_.storage(kSlots * bytes, flags); + mapped_ = static_cast(buffer_.map_range(0, kSlots * bytes, flags)); + if (mapped_ == nullptr) { + // The driver refused the storage or the mapping. A frame that draws through + // the baseline path beats one that draws nothing, so re-create the empty + // buffer the orphan path needs and let the next upload use it. + release_storage(); + mode_ = UploadMode::Orphan; + capacity_ = 0; + Status retry; + (void)buffer_.create(&retry); // a failed retry leaves nothing to draw with + return false; + } + slot_bytes_ = bytes; + slot_ = kSlots - 1u; // fresh storage: the first upload takes slot 0 + ++epoch_; return true; } void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) noexcept { - if (buffer_.id() == 0 || count == 0 || instance_bytes == 0) { + if (buffer_.id() == 0 || data == nullptr || count == 0 || instance_bytes == 0) { return; } - if (count > capacity_) { - // Only a workload larger than anything staged so far pays for an - // allocation; a frame that repeats the previous one writes into the store - // that is already there. - buffer_.upload(nullptr, static_cast(count) * instance_bytes, gl::GL_STREAM_DRAW); - capacity_ = count; + const usize bytes = usize(count) * instance_bytes; + if (mode_ == UploadMode::PersistentMapped && bytes > slot_bytes_ && !grow_persistent(bytes)) { + // The fallback already replaced the store with an orphan buffer. } - buffer_.upload_sub(data, static_cast(count) * instance_bytes, 0); + + if (mode_ == UploadMode::Orphan) { + if (count > capacity_) { + capacity_ = count; // grow only when the workload outgrows the store + } + buffer_.upload(nullptr, usize(capacity_) * instance_bytes, gl::GL_STREAM_DRAW); + buffer_.upload_sub(data, bytes, 0); + return; + } + + // Advance the ring, then make sure the draw that last read this slot has + // retired. The non-blocking check is the common case; the bounded wait only + // costs anything when three frames' geometry is still in flight. + slot_ = (slot_ + 1u) % kSlots; + if (fences_[slot_]) { + if (!fences_[slot_].wait(0)) { + (void)fences_[slot_].wait(kFenceWaitNs); + } + fences_[slot_].reset(); + } + // MAP_COHERENT storage needs no explicit flush, so this is the whole transfer. + std::memcpy(mapped_ + usize(slot_) * slot_bytes_, data, bytes); + ++epoch_; +} + +void InstanceStore::mark_drawn() noexcept { + if (mode_ != UploadMode::PersistentMapped || mapped_ == nullptr) { + return; + } + Status st; + fences_[slot_].reset(); + (void)fences_[slot_].create(&st); } } // namespace detail diff --git a/src/render/primitives.cpp b/src/render/primitives.cpp index 51c4756..f2f1501 100644 --- a/src/render/primitives.cpp +++ b/src/render/primitives.cpp @@ -27,6 +27,19 @@ void LineBatch::add_polyline(const Vec2* pts, u32 n) { } } +void LineBatch::add_polyline_xy(const f32* xy, u32 n) { + if (xy == nullptr || n < 2u) { + return; + } + v_.reserve(v_.size() + 4u * (n - 1u)); + for (u32 i = 1; i < n; ++i) { + v_.push_back(xy[2u * (i - 1u)]); + v_.push_back(xy[2u * (i - 1u) + 1u]); + v_.push_back(xy[2u * i]); + v_.push_back(xy[2u * i + 1u]); + } +} + // ----------------------------------------------------------------- TextBatch void TextBatch::begin() noexcept { v_.clear(); } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp new file mode 100644 index 0000000..7d917ab --- /dev/null +++ b/src/render/renderer.cpp @@ -0,0 +1,619 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace plot { +namespace { + +/// `PLOT_FORCE_GL33_UPLOAD=1` forces the baseline transfer, like +/// `PLOT_HEADLESS_FORCE_PBUFFER` forces the EGL fallback: presence is the switch, +/// so both strategies can be exercised on one machine. +bool force_gl33_upload() noexcept { return std::getenv("PLOT_FORCE_GL33_UPLOAD") != nullptr; } + +bool fail(Status* st, ErrorCode code, const char* message) noexcept { + if (st != nullptr) { + st->code = code; + st->message = message; + } + return false; +} + +/// A data-space point in the coordinate the line program's affine part expects: +/// the value itself on a linear axis, `log10` of it on a log axis -- exactly what +/// `math::Transform::to_screen_*` applies before scaling. A non-positive value on +/// a log axis takes the transform's own clamp, so it lands off-scale rather than +/// as NaN. +Vec2 pre_affine(f64 x, f64 y, bool log_x, bool log_y) noexcept { + return Vec2{static_cast(log_x ? Transform::log10_positive(x) : x), + static_cast(log_y ? Transform::log10_positive(y) : y)}; +} + +/// Sample index for a (possibly fractional, possibly non-finite) position in a +/// series' index space, clamped to [0, n]: NaN and -inf to 0, +inf to n, so a +/// range the axis cannot map still yields a valid window. +u64 clamp_index(f64 v, u64 n) noexcept { + if (!(v > 0.0)) return 0; + if (v >= static_cast(n)) return n; + return static_cast(v); +} + +/// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for +/// three significant digits plus a prefix and sign. +constexpr usize kNumberChars = 24; +constexpr usize kReadoutChars = 160; + +} // namespace + +// --------------------------------------------------------------- AxisFont + +void Renderer::AxisFont::reset(const render::BitmapFont& font, f32 requested_px) noexcept { + font_ = &font; + const f32 native = static_cast(render::BitmapFont::kGlyphPx); + // Whole multiples of the native cell only: a 1-bit glyph drawn at 1.5x lands + // its strokes on uneven pixel counts, and the layout has to agree with what the + // pipeline draws, so the requested size picks the nearest multiple. + f32 steps = std::round(requested_px / native); + if (!(steps >= 1.0f)) steps = 1.0f; // also catches NaN + cell_px_ = steps * native; + scale_ = cell_px_ / native; +} + +f32 Renderer::AxisFont::measure(const char* utf8) const { + return font_ != nullptr ? font_->measure(utf8) * scale_ : 0.0f; +} + +f32 Renderer::AxisFont::height() const { + return font_ != nullptr ? font_->height() * scale_ : 0.0f; +} + +f32 Renderer::AxisFont::ascent() const { + return font_ != nullptr ? font_->ascent() * scale_ : 0.0f; +} + +// ------------------------------------------------------------------ init + +bool Renderer::init(Status* st) noexcept { + if (!gl::available()) { + return fail(st, ErrorCode::Unsupported, + "plot::gl::load() has not resolved the GL entry points"); + } + + // The transfer strategy is decided once, from the capability probe, and never + // revisited. Both pointers are checked as well as the caps: they are resolved + // best-effort (see api.hpp), so a context can advertise the extension without + // the entry points being reachable. + const gl::Caps& caps = gl::caps(); + persistent_ = caps.buffer_storage && caps.persistent_map && gl::glBufferStorage != nullptr && + gl::glMapBufferRange != nullptr && !force_gl33_upload(); + const render::UploadMode mode = + persistent_ ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan; + + font_backend_.reset(font_, style_.axis_font_px); + + if (!atlas_.create_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight, st)) { + return false; + } + atlas_.upload_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight, + render::BitmapFont::atlas_image()); + // Texel-for-texel: the text shader samples the nearest texel centre of the + // glyph cell, and linear filtering would smear one cell into its neighbour. + atlas_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST); + + if (!line_program_.create(st) || !text_program_.create(st)) { + return false; + } + if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) { + return false; + } + ready_ = true; + return true; +} + +// ------------------------------------------------------------ frame cycle + +void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept { + fb_w_ = fb_w; + fb_h_ = fb_h; + viewport_ = viewport_px; + lines_.clear(); + glyphs_.clear(); + stats_ = FrameStats{}; + gl_error_ = false; + + // Rect is top-left origin, GL is bottom-left: flip once, here, and never again. + gl_viewport_ = Rect{viewport_px.x, + static_cast(fb_h) - (viewport_px.y + viewport_px.h), + viewport_px.w, viewport_px.h}; + if (!ready_) { + return; + } + + const gl::GLint vx = static_cast(gl_viewport_.x); + const gl::GLint vy = static_cast(gl_viewport_.y); + const gl::GLsizei vw = static_cast(gl_viewport_.w); + const gl::GLsizei vh = static_cast(gl_viewport_.h); + + // Clear only the plot's region: a host that embeds the plot in a surface it has + // already painted keeps everything outside the rect. The scissor box is in + // framebuffer coordinates and is not affected by glViewport, which is why the + // clear happens before the viewport is set. + gl::glEnable(gl::GL_SCISSOR_TEST); + gl::glScissor(vx, vy, vw, vh); + const Color& background = style_.background; + gl::glClearColor(background.r, background.g, background.b, background.a); + gl::glClear(gl::GL_COLOR_BUFFER_BIT); + gl::glDisable(gl::GL_SCISSOR_TEST); + gl::glViewport(vx, vy, vw, vh); + + // The pipelines write straight alpha and rely on blending being on; setting it + // per frame costs nothing and makes the renderer independent of what the host + // left behind. + gl::glEnable(gl::GL_BLEND); + gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA); +} + +void Renderer::draw(Plot& plot) { + stats_ = FrameStats{}; + if (!ready_) { + return; + } + // The plot reserves its label strips from TextBackend metrics, so it must + // 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. + font_backend_.reset(font_, style_.axis_font_px); + if (plot.text_backend() == nullptr) { + plot.set_text_backend(&font_backend_); + } + // 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 + // for the layout and another for the text placed inside it. + plot.set_style(style_); + (void)plot.layout(); // autoscale, ticks and grid for the final axes size + + // Draw order: chrome, then data, then the annotations over it, and the text + // last (it is texture-bound, so it is one draw call after every line batch). + draw_grid(plot); + draw_axis_lines(plot); + draw_tick_labels(plot); + draw_titles(plot); + draw_traces(plot); + draw_cursors(plot); + draw_measures(plot); + draw_annotations(plot); + flush_text(); +} + +void Renderer::end_frame() noexcept { + if (!ready_) { + return; + } + // 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 + // 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. + gl::glBindVertexArray(0); + gl::glUseProgram(0); + gl::glBindBuffer(gl::GL_ARRAY_BUFFER, 0); + gl::glActiveTexture(gl::GL_TEXTURE0); + gl::glBindTexture(gl::GL_TEXTURE_2D, 0); + gl::glDisable(gl::GL_BLEND); + // Drain, do not peek: the first error hides every later one, and a frame that + // produced several must be reported as having produced one. + while (gl::glGetError() != gl::GL_NO_ERROR) { + gl_error_ = true; + } +} + +// ------------------------------------------------------------ submission + +void Renderer::upload_vertices(std::span interleaved) noexcept { + // Four floats per instance is the line pipeline's layout (see LineInstance). + // Both transfer strategies live behind the vertex array, so this is the only + // place the line pipeline's staged bytes reach the GPU and no caller of it can + // tell which path ran. + line_vao_.upload(interleaved.data(), static_cast(interleaved.size() / 4u)); +} + +void Renderer::upload_text_vertices(std::span interleaved) noexcept { + text_vao_.upload(interleaved.data(), static_cast(interleaved.size() / 4u)); +} + +void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept { + const u32 segments = lines_.segment_count(); + if (segments != 0 && ready_) { + upload_vertices(lines_.data()); + line_program_.bind(); + line_program_.set_scale(map.scale); + line_program_.set_offset(map.offset); + line_program_.set_viewport({viewport_.w, viewport_.h}); + line_program_.set_width(width); + line_program_.set_color(color); + line_vao_.bind(); + // One triangle strip per instance, four corners from gl_VertexID. + gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4, + static_cast(line_vao_.instance_count())); + // The draw is in the command stream now, so this is the point at which the + // mapped path can let a fence follow it. + line_vao_.mark_drawn(); + stats_.staged_segments += segments; + ++stats_.draw_calls; + } + lines_.clear(); // keeps the capacity, so the next group re-uses the store +} + +void Renderer::flush_text() noexcept { + const u32 glyphs = glyphs_.glyph_count(); + if (glyphs != 0 && ready_) { + upload_text_vertices(glyphs_.data()); + text_program_.bind(); + text_program_.set_viewport({viewport_.w, viewport_.h}); + const f32 cell = font_backend_.cell_px(); + text_program_.set_px_range({cell, cell}); + text_program_.set_texture_size( + {static_cast(render::BitmapFont::kAtlasWidth), + static_cast(render::BitmapFont::kAtlasHeight)}); + text_program_.set_color(style_.axis_text); + atlas_.bind(); + text_vao_.bind(); + gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4, + static_cast(text_vao_.instance_count())); + text_vao_.mark_drawn(); + ++stats_.draw_calls; + } + glyphs_.clear(); +} + +// ----------------------------------------------------------- coordinates + +Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept { + const Transform tx = plot.x_transform(axis_x); + const Transform ty = plot.y_transform(axis_y); + // The plot's transforms already map data to framebuffer pixels; the shaders + // emit NDC relative to the GL viewport, so the viewport origin comes off here + // -- once per mapping, never per primitive. + return Mapping{Vec2{static_cast(tx.sx), static_cast(ty.sy)}, + Vec2{static_cast(tx.tx) - viewport_.x, + static_cast(ty.ty) - viewport_.y}}; +} + +Renderer::Mapping Renderer::pixel_mapping() const noexcept { + // Geometry already in framebuffer pixels needs no scaling, and the line + // program's offset uniform does the viewport shift. (The text pipeline has no + // offset uniform, so stage_text() shifts its positions itself.) + return Mapping{Vec2{1.0f, 1.0f}, Vec2{-viewport_.x, -viewport_.y}}; +} + +Vec2 Renderer::to_viewport(Vec2 px) const noexcept { + return Vec2{px.x - viewport_.x, px.y - viewport_.y}; +} + +void Renderer::stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y) { + lines_.add_segment(pre_affine(x0, y0, log_x, log_y), pre_affine(x1, y1, log_x, log_y)); +} + +void Renderer::stage_xy(const f32* xy, u32 n, bool log_x, bool log_y) { + if (!log_x && !log_y) { + // The common case: gathered points are already interleaved `x,y`, which is + // the batch's own payload, so this is a straight copy. + lines_.add_polyline_xy(xy, n); + return; + } + for (u32 i = 0; i + 1u < n; ++i) { + stage_segment(xy[2u * i], xy[2u * i + 1u], xy[2u * i + 2u], xy[2u * i + 3u], log_x, log_y); + } +} + +Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) { + // Text is staged in viewport pixels: the text program has no offset uniform. + return glyphs_.add_text(to_viewport(pos_px), utf8, font_, font_backend_.cell_px()); +} + +// -------------------------------------------------------------------- grid + +void Renderer::draw_grid(const Plot& plot) { + const Grid& grid = plot.grid(); + const Mapping map = mapping(plot, 0, 0); + // The grid is built from the primary axis pair, so it is mapped with their + // transforms even when a trace uses another pair. + const bool log_x = plot.x_axis(0).log; + const bool log_y = plot.y_axis(0).log; + + // Two colour groups, one pass over the segments each: `LineBatch::clear()` + // keeps its capacity, so the second group re-uses the first one's storage. + for (u32 pass = 0; pass < 2u; ++pass) { + const bool majors = pass == 0u; + for (const GridLine& line : grid.lines()) { + if (line.major != majors) continue; // grid.build() already dropped non-finite ones + stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y); + } + flush_lines(map, majors ? style_.grid_major : style_.grid_minor, 1.0f); + } +} + +// ------------------------------------------------------------- axis chrome + +void Renderer::draw_axis_lines(const Plot& plot) { + const Range y_range = plot.y_range(0); + const Range x_range = plot.x_range(0); + const bool log_x0 = plot.x_axis(0).log; + const bool log_y0 = plot.y_axis(0).log; + + // An x axis draws along its edge of the axes rect, spanning its own range; the + // perpendicular extent is the primary y axis' range, which is exactly what + // maps to the rect's top and bottom. + for (u32 i = 0; i < plot.x_axis_count(); ++i) { + const Axis& axis = plot.x_axis(i); + const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min; + stage_segment(axis.range.min, y, axis.range.max, y, axis.log, log_y0); + flush_lines(mapping(plot, i, 0), style_.axis_line, style_.line_width_px); + } + for (u32 i = 0; i < plot.y_axis_count(); ++i) { + const Axis& axis = plot.y_axis(i); + const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min; + stage_segment(x, axis.range.min, x, axis.range.max, log_x0, axis.log); + flush_lines(mapping(plot, 0, i), style_.axis_line, style_.line_width_px); + } +} + +void Renderer::draw_tick_labels(const Plot& plot) { + const PlotLayout& layout = plot.layout(); + const f32 line_h = font_backend_.height(); + + // X labels: centred under their tick, on the strip's single text line. + for (u32 i = 0; i < plot.x_axis_count(); ++i) { + const Transform transform = plot.x_transform(i); + for (const Tick& tick : plot.x_axis(i).ticks) { + if (!tick.major || tick.label.empty()) continue; + const f32 width = font_backend_.measure(tick.label.c_str()); + stage_text({static_cast(transform.to_screen_x(tick.value)) - 0.5f * width, + layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)}, + tick.label.c_str()); + } + } + + // Y labels: right-aligned in their strip, centred on the tick and pulled back + // inside the strip at the two extremes (a label centred on the top tick would + // otherwise hang above the plot area). + const f32 top_limit = + std::max(layout.y_label_area.y, layout.y_label_area.bottom() - line_h); + for (u32 i = 0; i < plot.y_axis_count(); ++i) { + const Transform transform = plot.y_transform(i); + for (const Tick& tick : plot.y_axis(i).ticks) { + if (!tick.major || tick.label.empty()) continue; + const f32 width = font_backend_.measure(tick.label.c_str()); + const f32 centre = static_cast(transform.to_screen_y(tick.value)); + const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit); + stage_text({layout.y_label_area.right() - style_.grid_label_pad - width, top}, + tick.label.c_str()); + } + } +} + +void Renderer::draw_titles(const Plot& plot) { + // The layout reserves one text line per strip for the tick labels, so an axis + // title has no room outside the axes rect. It hugs the inside of its axis line + // instead, at the far end of the axis, where it needs no extra layout space and + // cannot collide with the labels of the axis it names. + const Rect& axes = plot.layout().axes; + const f32 line_h = font_backend_.height(); + + for (u32 i = 0; i < plot.x_axis_count(); ++i) { + const Axis& axis = plot.x_axis(i); + if (axis.label.empty()) continue; + const f32 width = font_backend_.measure(axis.label.c_str()); + const f32 top = axis.place == AxisPlace::Top ? axes.y + 2.0f : axes.bottom() - line_h - 2.0f; + stage_text({axes.right() - width - 2.0f, top}, axis.label.c_str()); + } + for (u32 i = 0; i < plot.y_axis_count(); ++i) { + const Axis& axis = plot.y_axis(i); + if (axis.label.empty()) continue; + const f32 width = font_backend_.measure(axis.label.c_str()); + const f32 left = axis.place == AxisPlace::Right ? axes.right() - width - 2.0f : axes.x + 2.0f; + stage_text({left, axes.y + 2.0f}, axis.label.c_str()); + } +} + +// ------------------------------------------------------------------- data + +void Renderer::draw_traces(const Plot& plot) { + const PlotLayout& layout = plot.layout(); + if (!(layout.axes.w > 0.0f)) { + return; // a collapsed plot area has no pixel to select a level for + } + + for (u32 i = 0; i < plot.trace_count(); ++i) { + const Trace& trace = plot.trace(i); + if (!trace.visible()) continue; // null series, or one with no sample yet + const data::Series& series = *trace.series; + const f64 dx = series.dx(); + const u64 n = series.size(); + if (!(dx > 0.0) || n == 0) continue; + + // Map the visible x range onto sample indices. One sample of slack on each + // side covers a partially visible end bucket; the range is clamped to the + // series, so a zoomed-out view never asks for more than exists. + const Range x_range = plot.x_range(trace.axis_x); + f64 lo = (x_range.min - series.x0()) / dx; + f64 hi = (x_range.max - series.x0()) / dx; + if (hi < lo) std::swap(lo, hi); + const u64 first = clamp_index(std::floor(lo) - 1.0, n); + const u64 last = clamp_index(std::ceil(hi) + 1.0, n); + if (last <= first) continue; + + // This is the whole performance contract: the window, never the series. + const u64 visible = last - first; + const f64 samples_per_pixel = static_cast(visible) / static_cast(layout.axes.w); + const u32 level = samples_per_pixel >= static_cast(style_.dense_threshold_spp) + ? series.pyramid().level_for(n, samples_per_pixel) + : 0u; + + gather_.clear(); + series.gather(level, first, last, gather_); + if (gather_.size() < 4u) continue; // fewer than two points is no segment + + stats_.visible_samples += visible; + if (level == 0u) { + ++stats_.raw_traces; + } else { + ++stats_.lod_traces; + } + + stage_xy(gather_.data(), static_cast(gather_.size() / 2u), + plot.x_axis(trace.axis_x).log, plot.y_axis(trace.axis_y).log); + flush_lines(mapping(plot, trace.axis_x, trace.axis_y), trace.color, trace.width); + } +} + +void Renderer::draw_cursors(const Plot& plot) { + const PlotLayout& layout = plot.layout(); + const Range y_range = plot.y_range(0); + const bool log_y = plot.y_axis(0).log; + + for (u32 i = 0; i < plot.cursor_count(); ++i) { + const Cursor& cursor = plot.cursor(i); + if (!cursor.visible) continue; + const Transform transform = plot.x_transform(cursor.axis_x); + const f32 px = static_cast(transform.to_screen_x(cursor.x)); + // A cursor scrolled out of the visible x range is off the plot: drawing it + // would put a marker over the label strip. + if (px < layout.axes.x || px >= layout.axes.right()) continue; + + stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log, + log_y); + flush_lines(mapping(plot, cursor.axis_x, 0), cursor.color, style_.line_width_px); + if (!cursor.label.empty()) { + stage_text({px + 3.0f, layout.axes.y + 2.0f}, cursor.label.c_str()); + } + } +} + +void Renderer::draw_measures(const Plot& plot) { + const PlotLayout& layout = plot.layout(); + const Rect& axes = layout.axes; + const Range y_range = plot.y_range(0); + const bool log_y = plot.y_axis(0).log; + const f32 line_h = font_backend_.height(); + + for (u32 i = 0; i < plot.measure_count(); ++i) { + const MeasureSpan span = plot.measure_span(i); + const Measurement& measure = plot.measurement(i); + if (!span.valid || !measure.valid) continue; + + const Cursor& a = plot.cursor(span.cursor_a); + const Cursor& b = plot.cursor(span.cursor_b); + const Transform transform = plot.x_transform(a.axis_x); + f64 left_px = transform.to_screen_x(a.x); + f64 right_px = transform.to_screen_x(b.x); + if (right_px < left_px) std::swap(left_px, right_px); + const f32 x0 = std::max(static_cast(left_px), axes.x); + const f32 x1 = std::min(static_cast(right_px), axes.right()); + + if (x1 > x0) { + // The shaded band: one vertical stroke per pixel column of the interval. + // The line pipeline has no filled primitive, and the band is bounded by the + // plot's width, so a per-column fill stays O(pixels) and needs no second + // pipeline. Strokes are staged in data space like everything else, so the + // band follows a pan or a zoom through the uniform alone. + for (f32 px = x0; px < x1; px += 1.0f) { + const f64 x = transform.to_data_x(static_cast(px) + 0.5); + stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y); + } + flush_lines(mapping(plot, a.axis_x, 0), style_.measure_fill, 1.0f); + } + + char dx[kNumberChars]; + char dy[kNumberChars]; + char rms[kNumberChars]; + char mean[kNumberChars]; + format_si(measure.dx, dx, kNumberChars); + format_si(measure.dy, dy, kNumberChars); + format_si(measure.rms, rms, kNumberChars); + format_si(measure.mean, mean, kNumberChars); + char readout[kReadoutChars]; + std::snprintf(readout, kReadoutChars, "dx %s dy %s rms %s mean %s", dx, dy, rms, mean); + + const f32 width = font_backend_.measure(readout); + const f32 limit = std::max(axes.x + 2.0f, axes.right() - width - 2.0f); + const f32 left = std::clamp(0.5f * (x0 + x1) - 0.5f * width, axes.x + 2.0f, limit); + // The second line of the plot area, not the first: the first belongs to the + // y axis title (and to cursor labels), and a wide readout clamped to the left + // edge would overprint them. + stage_text({left, axes.y + line_h + 2.0f}, readout); + } +} + +void Renderer::draw_annotations(const Plot& plot) { + const f32 line_h = font_backend_.height(); + for (u32 i = 0; i < plot.annotation_count(); ++i) { + const Annotation& annotation = plot.annotation(i); + if (!annotation.visible) continue; + const f32 px = static_cast(plot.x_transform(annotation.axis_x).to_screen_x(annotation.x)); + const f32 py = static_cast(plot.y_transform(annotation.axis_y).to_screen_y(annotation.y)); + + // A small cross pins the point; the caption sits to its right. Both are in + // framebuffer pixels, hence the identity mapping for the marker. + lines_.add_segment({px - 3.0f, py}, {px + 3.0f, py}); + lines_.add_segment({px, py - 3.0f}, {px, py + 3.0f}); + flush_lines(pixel_mapping(), annotation.color, 1.0f); + if (!annotation.text.empty()) { + stage_text({px + 5.0f, py - 0.5f * line_h}, annotation.text.c_str()); + } + } +} + +// -------------------------------------------------------------------- dump + +bool Renderer::dump_frame_ppm(const char* path, Status* st) const noexcept { + if (path == nullptr || path[0] == '\0') { + return fail(st, ErrorCode::InvalidArgument, "PPM path is empty"); + } + if (!ready_) { + return fail(st, ErrorCode::Unsupported, "renderer was never initialised"); + } + const u32 width = static_cast(gl_viewport_.w); + const u32 height = static_cast(gl_viewport_.h); + if (width == 0 || height == 0) { + return fail(st, ErrorCode::InvalidArgument, "no frame has been drawn yet"); + } + + // RGBA, not RGB: 4-byte pixels have no row padding whatever the pack alignment, + // so a host's GL_PACK_ALIGNMENT cannot make the read overrun the buffer. + readback_.assign(usize(width) * height * 4u, 0u); + gl::glPixelStorei(gl::GL_PACK_ALIGNMENT, 4); + gl::glReadPixels(static_cast(gl_viewport_.x), static_cast(gl_viewport_.y), + static_cast(width), static_cast(height), gl::GL_RGBA, + gl::GL_UNSIGNED_BYTE, readback_.data()); + + std::FILE* file = std::fopen(path, "wb"); + if (file == nullptr) { + return fail(st, ErrorCode::InvalidArgument, "cannot open the PPM path for writing"); + } + std::fprintf(file, "P6\n%u %u\n255\n", width, height); + std::vector row(usize(width) * 3u); + bool ok = true; + for (u32 y = 0; y < height && ok; ++y) { + // glReadPixels hands back rows bottom-up; a PPM's rows run top-down. + const u8* src = readback_.data() + usize(height - 1u - y) * usize(width) * 4u; + for (u32 x = 0; x < width; ++x) { + row[usize(x) * 3u] = src[usize(x) * 4u]; + row[usize(x) * 3u + 1u] = src[usize(x) * 4u + 1u]; + row[usize(x) * 3u + 2u] = src[usize(x) * 4u + 2u]; + } + ok = std::fwrite(row.data(), 1, row.size(), file) == row.size(); + } + if (std::fclose(file) != 0) ok = false; + if (!ok) { + return fail(st, ErrorCode::InvalidArgument, "writing the PPM failed"); + } + return true; +} + +} // namespace plot diff --git a/src/render/vertex_array.cpp b/src/render/vertex_array.cpp index c645956..09ab18f 100644 --- a/src/render/vertex_array.cpp +++ b/src/render/vertex_array.cpp @@ -14,11 +14,11 @@ constexpr gl::GLuint kPerInstance = 1; } // namespace -bool LineVertexArray::create(Status* st) noexcept { +bool LineVertexArray::create(Status* st, UploadMode mode) noexcept { if (!vao_.create(st)) { return false; } - if (!buffer_.create(st)) { + if (!buffer_.create(st, mode)) { vao_.reset(); // do not leave a vertex array behind on the failure path return false; } @@ -32,6 +32,7 @@ bool LineVertexArray::create(Status* st) noexcept { gl::glVertexAttribDivisor(0u, kPerInstance); gl::glVertexAttribDivisor(1u, kPerInstance); gl::glBindVertexArray(0); // leave the binding as we found it + epoch_ = buffer_.epoch(); return true; } @@ -43,14 +44,26 @@ void LineVertexArray::upload(const f32* interleaved, u32 segments) noexcept { return; } buffer_.upload(interleaved, segments); + // Only the mapped transfer moves the instances between slots, and the + // attribute offsets have to follow them. The generation changes for a new slot + // or a re-created buffer, never otherwise, so the baseline path re-points at + // most once. + if (buffer_.epoch() != epoch_) { + const usize base = buffer_.slot_offset(); + vao_.bind(); + buffer_.bind(); + vao_.attrib(0u, 2, sizeof(LineInstance), base + offsetof(LineInstance, x0)); + vao_.attrib(1u, 2, sizeof(LineInstance), base + offsetof(LineInstance, x1)); + epoch_ = buffer_.epoch(); + } instances_ = segments; } -bool TextVertexArray::create(Status* st) noexcept { +bool TextVertexArray::create(Status* st, UploadMode mode) noexcept { if (!vao_.create(st)) { return false; } - if (!buffer_.create(st)) { + if (!buffer_.create(st, mode)) { vao_.reset(); return false; } @@ -61,6 +74,7 @@ bool TextVertexArray::create(Status* st) noexcept { gl::glVertexAttribDivisor(0u, kPerInstance); gl::glVertexAttribDivisor(1u, kPerInstance); gl::glBindVertexArray(0); + epoch_ = buffer_.epoch(); return true; } @@ -70,6 +84,14 @@ void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept { return; } buffer_.upload(interleaved, glyphs); + if (buffer_.epoch() != epoch_) { + const usize base = buffer_.slot_offset(); + vao_.bind(); + buffer_.bind(); + vao_.attrib(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x)); + vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u)); + epoch_ = buffer_.epoch(); + } instances_ = glyphs; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 50bd7d8..78741b3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,6 +35,7 @@ add_executable(plot_tests test_ticks.cpp test_measure.cpp test_plot.cpp + test_render_smoke.cpp test_controller.cpp egl_headless.cpp ) diff --git a/tests/test_primitives.cpp b/tests/test_primitives.cpp index e936b7f..266aad6 100644 --- a/tests/test_primitives.cpp +++ b/tests/test_primitives.cpp @@ -43,6 +43,30 @@ PLOT_TEST(line_batch_stages_segment_endpoints) { PLOT_CHECK_NEAR(b.data()[7], 1.0, 0.0); } +// `add_polyline_xy` takes the layout `Series::gather` appends to, so this pins +// that a gathered window becomes the segments it says it does -- an off-by-one in +// the stride here would draw a diagonal where the data has a corner. +PLOT_TEST(line_batch_stages_interleaved_points) { + plot::render::LineBatch b; + b.begin(); + const plot::f32 xy[8] = {0.0f, 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f}; + b.add_polyline_xy(xy, 4); // 3 segments + PLOT_CHECK(b.segment_count() == 3); + PLOT_CHECK(b.vertex_count() == 6); + PLOT_CHECK(b.data().size() == 12); + PLOT_CHECK_NEAR(b.data()[0], 0.0, 0.0); + PLOT_CHECK_NEAR(b.data()[1], 1.0, 0.0); + PLOT_CHECK_NEAR(b.data()[2], 2.0, 0.0); + PLOT_CHECK_NEAR(b.data()[3], 3.0, 0.0); + PLOT_CHECK_NEAR(b.data()[10], 6.0, 0.0); + PLOT_CHECK_NEAR(b.data()[11], 7.0, 0.0); + // Fewer than two points, or none: nothing to draw, and nothing to crash on. + b.clear(); + b.add_polyline_xy(xy, 1); + b.add_polyline_xy(nullptr, 4); + PLOT_CHECK(b.vertex_count() == 0); +} + PLOT_TEST(line_batch_ignores_degenerate_polylines) { plot::render::LineBatch b; b.begin(); diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp new file mode 100644 index 0000000..0ea9dc8 --- /dev/null +++ b/tests/test_render_smoke.cpp @@ -0,0 +1,345 @@ +// The integration test: the whole stack on the real driver. A 1M-sample series, +// the plot layer's layout/ticks/grid, the renderer's visible-range LOD selection +// and both vertex-transfer strategies, all asserted by reading pixels back from +// the headless EGL framebuffer rather than by inspecting anything the renderer +// says about itself. +#include "harness.hpp" +#include "egl_headless.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +constexpr plot::u32 kW = 640; +constexpr plot::u32 kH = 360; + +/// The series every case draws: a slow sine whose period is 62 832 samples, so a +/// 1M-wide window holds ~16 of them (the dense envelope spans the full height in +/// every column) while a 640-wide one is a nearly flat line (the sparse path's +/// polyline crosses every column exactly once). +std::vector sine_samples() { + std::vector y(1'000'000); + for (plot::u64 i = 0; i < y.size(); ++i) { + y[i] = static_cast(std::sin(static_cast(i) * 0.0001)); + } + return y; +} + +/// Columns of the axes rect (sampled at `columns` evenly spaced positions) that +/// hold at least one trace-coloured pixel (0x40C0FF over the 0x101418 +/// background). +/// +/// The predicate is blue *dominance*, not merely "blue-ish": the axis line is +/// 0x8A94A6, which any threshold on blue alone would accept, and the axis line +/// crosses every column of the rect -- a loose predicate would make every +/// coverage assertion here vacuous. Blending the trace over the background keeps +/// blue - red at `alpha * 191`, so even the AA fringe of a 1.5px stroke clears +/// the bound. +bool trace_pixel(const plot::u8 px[4]) { + return px[2] > 120 && px[2] - px[0] > 100; +} + +/// Predicates for the overlay layer, each chosen so that no other element of the +/// frame can satisfy it (see the colours the test picks). A marker drawn on a +/// pixel boundary blends at about half coverage, so they are dominance tests with +/// room for that rather than exact colour matches. +bool green_pixel(const plot::u8 px[4]) { return px[1] > px[0] + 60 && px[1] > px[2] + 60; } +bool magenta_pixel(const plot::u8 px[4]) { return px[0] > px[1] + 60 && px[2] > px[1] + 60; } +/// The measure band: 0xFFB020 at 15% over the background, so a warm tint. +bool band_pixel(const plot::u8 px[4]) { + return px[0] > 35 && px[0] > px[1] && px[1] > px[2]; +} +bool text_pixel(const plot::u8 px[4]) { return px[0] > 180 && px[1] > 180 && px[2] > 180; } +/// A grey marker: the annotation colour at any coverage, which no data or chrome +/// colour here matches (they are either dark or strongly coloured). +bool marker_pixel(const plot::u8 px[4]) { + const plot::u8 lo = std::min(px[0], std::min(px[1], px[2])); + const plot::u8 hi = std::max(px[0], std::max(px[1], px[2])); + return lo > 90 && static_cast(hi - lo) < 30; +} + +plot::u32 covered_columns(plottest::HeadlessContext& ctx, const plot::Rect& a, + plot::u32 columns) { + // `a` is a Rect, so its rows are top-down; read_pixel() speaks GL's bottom-left + // convention. Flipping here is the same single conversion the renderer does for + // its viewport, and scanning the rows without it would test the 24 rows above + // the axes instead of the axes itself. + const plot::u32 first_row = static_cast(a.y); + const plot::u32 rows = static_cast(a.h); + plot::u32 hit = 0; + for (plot::u32 c = 0; c < columns; ++c) { + const plot::u32 x = + static_cast(a.x + a.w * (static_cast(c) + 0.5) / + static_cast(columns)); + bool found = false; + for (plot::u32 yy = 0; yy < rows && !found; ++yy) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (first_row + yy), px)); + found = trace_pixel(px); + } + if (found) ++hit; + } + return hit; +} + +/// Honours PLOT_DUMP_FRAMES: a non-empty value is the PPM path, an empty one +/// dumps next to the test binary. Nothing is written unless it is set. +void dump_if_requested(const plot::Renderer& r, const char* default_name) { + const char* path = std::getenv("PLOT_DUMP_FRAMES"); + if (path == nullptr) return; + plot::Status st; + const bool ok = r.dump_frame_ppm(path[0] != '\0' ? path : default_name, &st); + std::printf("[dump] %s: %s\n", path[0] != '\0' ? path : default_name, + ok ? "written" : st.message); + PLOT_CHECK(ok); +} + +} // namespace + +PLOT_TEST(renders_visible_trace_across_viewport) { + 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; + p.set_viewport({0, 0, static_cast(kW), static_cast(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}, true, 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(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + dump_if_requested(r, "smoke.ppm"); + + // Trace colour must appear in every one of 16 sampled columns across the plot + // area; background must dominate the corners. + const plot::Rect a = p.layout().axes; + PLOT_CHECK(covered_columns(ctx, a, 16) >= 15); // >= 95% of columns covered + + plot::u8 corner[4] = {}; + PLOT_CHECK(ctx.read_pixel(2, 2, corner)); + PLOT_CHECK(corner[0] + corner[1] + corner[2] < 200); // dark background + + // The 1M-sample window is ~1700 samples per pixel, so it must have gone + // through a pyramid level rather than through raw samples. + PLOT_CHECK(r.stats().lod_traces == 1); + PLOT_CHECK(r.stats().raw_traces == 0); + PLOT_CHECK(r.stats().staged_segments < 10000); // O(pixels), not O(samples) + + PLOT_CHECK(!r.last_frame_had_gl_error()); + ctx.destroy(); +} + +PLOT_TEST(dense_and_sparse_paths_agree_on_extent) { + 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; + p.set_viewport({0, 0, static_cast(kW), static_cast(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}); + + auto covered = [&](plot::Range xr) -> plot::u32 { + p.set_x_range(ax, xr); + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + return covered_columns(ctx, p.layout().axes, 32); + }; + + // Sparse first: 640 samples over 640 x-units -> ~1 sample/pixel -> raw polyline. + const plot::u32 fine = covered({0.0, 640.0}); + PLOT_CHECK(fine >= 30); // >= 95% of 32 columns + PLOT_CHECK(r.stats().raw_traces == 1 && r.stats().lod_traces == 0); + const plot::u32 fine_segments = r.stats().staged_segments; + const plot::u64 fine_samples = r.stats().visible_samples; + + // Then dense: 1M samples over 1M x-units -> ~1700 samples/pixel -> LOD buckets. + // It stages several times what the sparse frame did, so on the persistent path + // this frame also grows the mapped ring -- re-creating the store, dropping the + // fence of the draw that may still be reading it and moving the attribute + // offsets -- rather than only ever allocating on a renderer's first frame. + const plot::u32 coarse = covered({0.0, 1'000'000.0}); + PLOT_CHECK(coarse >= 30); + PLOT_CHECK(r.stats().lod_traces == 1 && r.stats().raw_traces == 0); + const plot::u32 coarse_segments = r.stats().staged_segments; + const plot::u64 coarse_samples = r.stats().visible_samples; + + // The two windows really did take different paths: the dense one saw orders of + // magnitude more samples while staging far fewer segments than it saw samples, + // which is the whole point of the LOD pyramid. The sparse one staged the window + // itself. + PLOT_CHECK(coarse_samples > 100 * fine_samples); + PLOT_CHECK(coarse_segments < 10000); + PLOT_CHECK(coarse_segments * 100 < coarse_samples); + PLOT_CHECK(fine_segments + 1 >= fine_samples); // one segment per sample pair + + ctx.destroy(); +} + +// Resolution #1 of the brief: the renderer draws into whatever framebuffer is +// bound and clears only its viewport, so a host that embeds the plot in a +// sub-rect (Qt/GTK/ImGui) keeps the rest of its surface. Coordinates are given +// in the plot's top-left pixel space, and the renderer shifts them by the +// viewport origin exactly once. +PLOT_TEST(clears_only_the_viewport_region) { + 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; + const plot::Rect vp{80.0f, 40.0f, 400.0f, 240.0f}; + p.set_viewport(vp); + 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}); + p.set_x_range(ax, {0.0, 300.0}); // ~1 sample / pixel: every column is crossed + + // The host paints its whole surface first; the frame must leave all of it that + // is outside the viewport alone. + plot::gl::glClearColor(0.5f, 0.5f, 0.5f, 1.0f); + plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT); + + r.begin_frame(kW, kH, vp); + r.draw(p); + r.end_frame(); + + plot::u8 px[4] = {}; + auto gl_y = [](plot::f32 top) { return static_cast(kH) - 1u - static_cast(top); }; + + // Left of the viewport, below it and past its right edge: untouched host grey. + PLOT_CHECK(ctx.read_pixel(10, 10, px)); + PLOT_CHECK(px[0] > 100 && px[0] < 160 && px[1] > 100 && px[2] > 100); + PLOT_CHECK(ctx.read_pixel(20, gl_y(300.0f), px)); + PLOT_CHECK(px[0] > 100 && px[0] < 160); + PLOT_CHECK(ctx.read_pixel(600, 300, px)); + PLOT_CHECK(px[0] > 100 && px[0] < 160); + + // Inside the viewport, above the axes: the renderer's own background. + const plot::Rect a = p.layout().axes; + PLOT_CHECK(a.x >= vp.x && a.right() <= vp.right() && a.y >= vp.y && a.bottom() <= vp.bottom()); + PLOT_CHECK(ctx.read_pixel(static_cast(vp.x) + 2u, gl_y(vp.y + 2.0f), px)); + PLOT_CHECK(px[0] + px[1] + px[2] < 200); + + // And the trace is inside the axes rect in framebuffer coordinates, so the + // viewport origin was subtracted exactly once. + PLOT_CHECK(covered_columns(ctx, a, 16) >= 15); + PLOT_CHECK(!r.last_frame_had_gl_error()); + ctx.destroy(); +} + +// 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' +// demos are read for, and none of them goes through the trace path. +PLOT_TEST(draws_cursors_measure_band_and_annotations) { + 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; + p.set_viewport({0, 0, static_cast(kW), static_cast(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}); + const plot::u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame + + // Colours no other element of the frame uses, so each assertion below can only + // be satisfied by the element it names. + const plot::u32 ca = p.add_cursor(100.0, plot::Color::hex(0x00FF00)); // green + const plot::u32 cb = p.add_cursor(300.0, plot::Color::hex(0xFF00FF)); // magenta + PLOT_CHECK(p.add_measure(ca, cb, tr) == 0); + plot::Annotation pin; + pin.x = 200.0; + pin.y = 0.5; + pin.text = "A"; + pin.color = plot::Color::hex(0xE8E8E8); + pin.axis_x = ax; + pin.axis_y = ay; + p.add_annotation(pin); + + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + + const plot::Rect a = p.layout().axes; + const plot::Transform tx = p.x_transform(ax); + const plot::Transform ty = p.y_transform(ay); + + // Scans one column of the axes rect, in top-down rows like every Rect. + auto column_has = [&](plot::u32 x, bool (*pred)(const plot::u8*)) { + for (plot::u32 row = 0; row < static_cast(a.h); ++row) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast(a.y) - row, px)); + if (pred(px)) return true; + } + return false; + }; + // Scans a square around one framebuffer position, clipped to the framebuffer so + // a square that reaches an edge simply covers fewer pixels. + auto box_has = [&](plot::f32 fx, plot::f32 fy, plot::i32 radius, + bool (*pred)(const plot::u8*)) { + const plot::i32 cx = static_cast(fx); + const plot::i32 cy = static_cast(fy); + for (plot::i32 row = cy - radius; row <= cy + radius; ++row) { + if (row < 0 || row >= static_cast(kH)) continue; + for (plot::i32 column = cx - radius; column <= cx + radius; ++column) { + if (column < 0 || column >= static_cast(kW)) continue; + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(static_cast(column), + ctx.height - 1u - static_cast(row), px)); + if (pred(px)) return true; + } + } + return false; + }; + + const plot::u32 x_a = static_cast(tx.to_screen_x(100.0)); + const plot::u32 x_b = static_cast(tx.to_screen_x(300.0)); + const plot::u32 mid = static_cast(tx.to_screen_x(200.0)); + PLOT_CHECK(column_has(x_a, green_pixel)); // the cursor markers themselves + PLOT_CHECK(column_has(x_b, magenta_pixel)); + // Nothing of either colour where neither cursor is. + PLOT_CHECK(!column_has(mid, green_pixel)); + PLOT_CHECK(!column_has(mid, magenta_pixel)); + + // The measurement band tints the columns between the cursors and only those. + PLOT_CHECK(column_has(mid, band_pixel)); + PLOT_CHECK(!column_has(static_cast(tx.to_screen_x(40.0)), band_pixel)); + // ... with its numeric readout drawn near the top of it. + PLOT_CHECK(box_has(static_cast(mid), a.y + 8.0f, 20, text_pixel)); + + // The annotation's cross pins data (200, 0.5) in its own colour. + PLOT_CHECK(box_has(static_cast(tx.to_screen_x(200.0)), + static_cast(ty.to_screen_y(0.5)), 4, marker_pixel)); + + ctx.destroy(); +}