feat(render): Renderer with LOD selection, 3.3 and 4.3+ upload paths, headless smoke test

This commit is contained in:
Martino Ferrari committed 2026-09-27 23:03:08 +02:00
1 parent e0cdd95a3b
commit 3555ab63d7
20 files changed
+1544 -25

No files matched your search

+80 -10
View File
@@ -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:
+4
View File
@@ -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<u32>(v_.size() / 4u); }
+226
View File
@@ -0,0 +1,226 @@
#pragma once
#include <span>
#include <vector>
#include <plot/core/color.hpp>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/handle.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/bitmap_font.hpp>
#include <plot/render/buffer.hpp>
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
#include <plot/render/style.hpp>
#include <plot/render/vertex_array.hpp>
namespace plot {
/// What one frame staged and drew. It exists because pixels alone cannot say
/// which of the two rasterisation paths a trace took: a 1M-sample series drawn
/// from the LOD pyramid and one drawn from raw samples can cover the same
/// columns, so the smoke test asserts the path here and the pixels there.
struct FrameStats {
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
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<const f32> interleaved) noexcept;
/// Same, for the text pipeline's four-float glyph instances.
void upload_text_vertices(std::span<const f32> interleaved) noexcept;
/// Uploads the staged line batch, maps it with (`scale`, `offset`), rasterises
/// it in one colour and empties the batch. A no-op for an empty batch, so the
/// caller can flush unconditionally.
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<f32> gather_; ///< scratch for one gathered window, re-used
mutable std::vector<u8> 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
+20 -2
View File
@@ -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