fix(render): bind identity into the epoch, titles into reserved strips, one layout per frame

Four review findings on the Task 7 renderer:

- InstanceStore::epoch() is derived from gl::Buffer::generation() -- advanced by
  every allocation, deletion and move -- plus the slot the instances were written
  to, so the orphan fallback of grow_persistent() can no longer leave the vertex
  array's attribute pointers bound to a deleted buffer: the epoch reports the new
  identity and upload() re-points them before the draw.
- PlotLayout reserves y_title_area/x_title_area outside axes (sized from the text
  metrics) and draw_titles() draws in them, so a title can no longer land on the
  data, whatever the envelope does.
- draw() computes plot.layout() once and hands it to the helpers, instead of six
  times per frame, each of which rebuilt the ticks and the grid.
- A refused fence is no longer dropped: mark_drawn() falls back to the orphan path
  and reports the failure rather than rotating through an unsynchronised slot.

New smoke cases force both refusals through the GL entry points (glMapBufferRange,
glFenceSync) and assert the pixels plus the live transfer mode; the title case
asserts the title colour is absent from the bands inside axes and present in both
strips. All three fail against the pre-fix sources.
This commit is contained in:
Martino Ferrari committed 2026-09-27 23:19:31 +02:00
1 parent 3555ab63d7
commit 8f45108559
11 files changed
+492 -70

No files matched your search

+26 -2
View File
@@ -27,26 +27,50 @@ class Handle {
GLuint id() const noexcept { return id_; }
explicit operator bool() const noexcept { return id_ != 0; }
/// Identity of the object this handle names, as a counter that only ever
/// advances: every `reset()` that deleted a name, every allocating `create()`
/// and every move advances it. A driver is free to hand out a name it has
/// already used, so state that captured the old object -- the attribute
/// pointers of a vertex array above all -- must compare this counter rather
/// than the name (see `render::detail::InstanceStore::epoch()`).
u32 generation() const noexcept { return generation_; }
/// Deletes the GL object, if any, and empties the handle.
void reset() noexcept {
if (id_ != 0) {
Derived::destroy(id_);
id_ = 0;
++generation_;
}
}
protected:
Handle(Handle&& other) noexcept : id_(other.id_) { other.id_ = 0; }
Handle(Handle&& other) noexcept : id_(other.id_), generation_(other.generation_) {
other.id_ = 0;
++generation_; // the object is this handle's now
++other.generation_; // and no longer the other's
}
Handle& operator=(Handle&& other) noexcept {
if (this != &other) {
reset();
reset(); // advances the generation when it deleted a name
id_ = other.id_;
other.id_ = 0;
++generation_;
++other.generation_;
}
return *this;
}
/// Advances the generation for a name the derived class has just allocated and
/// stored: every allocating `create()` calls it, so the counter is a complete
/// record of the handle's identity. A failed allocation needs no call -- it has
/// already gone through `reset()`, which advanced it.
void note_allocated() noexcept { ++generation_; }
GLuint id_ = 0;
private:
u32 generation_ = 0;
};
/// Vertex buffer object. All fallible operations report through `Status`;
+31 -2
View File
@@ -16,16 +16,32 @@
namespace plot {
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
/// `Rect` (origin top-left). `axes` is the area data is drawn into; the two label
/// areas are the strips the tick labels live in.
/// `Rect` (origin top-left). `axes` is the area data is drawn into; the label
/// areas are the strips the tick labels live in and the title areas the strips
/// the axis titles live in, each outside `axes` and so never over the data.
///
/// `y_*` strips are left of `axes` and `x_*` ones below it, whatever an axis'
/// `place` says: an axis placed on the opposite edge still has its labels and
/// its title on the side the layout reserves, exactly as it always has.
struct PlotLayout {
Rect axes;
Rect y_label_area;
Rect x_label_area;
/// One text line each, just outside the matching label area. Zero-sized (and
/// unoccupied) when no axis of that direction has a title.
Rect y_title_area;
Rect x_title_area;
/// Left strip width. The default is used when no text backend has been set.
f32 y_axis_width = 56.0f;
/// Bottom strip height. The default is used when no text backend has been set.
f32 x_axis_height = 22.0f;
/// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing
/// is reserved. An x title is one text line in a strip as wide as the axes; a y
/// title is a horizontal run in a vertical strip, so the strip is as wide as the
/// widest y title (plus the label padding) -- one text line would cut it off.
/// With no text backend there is no title to measure and both stay 0.0f.
f32 y_title_width = 0.0f;
f32 x_title_height = 0.0f;
};
/// The public façade: axes, traces, cursors, annotations and their layout.
@@ -155,6 +171,12 @@ class Plot {
/// layout. The label strips come from the text backend's metrics when one is
/// set (`measure()` of the widest y tick label, `height()` for the x strip) and
/// from PlotLayout's defaults otherwise.
///
/// `axes` is inset by the label strip of each occupied edge and, when that
/// direction has a titled axis, by the strip that title lives in (`y_title_width`
/// and `x_title_height` say how much that is): a title can then only be drawn
/// outside the data, whatever the title says. Call this once per frame -- it
/// rebuilds the ticks and the grid -- and pass the result down.
const PlotLayout& layout();
/// The layout as of the last refresh. The const overload never recomputes, so
/// a lookup on a const Plot cannot move the axes out from under the caller.
@@ -197,6 +219,13 @@ class Plot {
/// Widest y tick label plus the label padding on both sides: the left strip the
/// text backend asks for. Zero with no backend or no y axis.
f32 y_label_width() const;
/// True when any axis of that direction carries a title, i.e. when layout()
/// must reserve the strip `Renderer::draw_titles()` draws it in.
bool has_x_title() const noexcept;
bool has_y_title() const noexcept;
/// Widest y axis title plus the label padding on both sides: the vertical strip
/// a horizontal y title fits in. Zero with no backend or no titled y axis.
f32 y_title_strip_width() const;
Rect viewport_{};
render::Style style_{};
+20 -7
View File
@@ -71,9 +71,13 @@ class InstanceStore {
/// 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;
/// draw. True when the slot is guarded, and always in `Orphan` mode, where
/// nothing reads the store after the orphan.
///
/// False when the driver refused the fence: the slot would stay unsynchronised,
/// so the store drops the mapped ring for the orphan path instead of rotating
/// through it (the caller reports the failure -- see `Renderer`).
bool 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
@@ -84,7 +88,14 @@ class InstanceStore {
/// 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_; }
///
/// Derived from the two things that can make them stale rather than bumped by
/// hand at every site that replaces the buffer object: `gl::Buffer::generation()`
/// advances on every allocation and deletion (so a fallback that re-creates the
/// buffer cannot leave the vertex array bound to a deleted one), and `slot_epoch_`
/// advances on every upload that lands in a new slot. Both counters only ever
/// increase, so their sum changes whenever either does, and never repeats.
u32 epoch() const noexcept { return buffer_.generation() + slot_epoch_; }
void bind() const noexcept { buffer_.bind(); }
bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; }
@@ -103,7 +114,9 @@ class InstanceStore {
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;
/// Times an upload landed in a new slot. Kept apart from the buffer's own
/// identity (see `epoch()`) so neither has to know about the other.
u32 slot_epoch_ = 0;
u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode
gl::Fence fences_[kSlots];
};
@@ -119,7 +132,7 @@ class LineBuffer {
void upload(const f32* interleaved, u32 count) noexcept {
store_.upload(interleaved, count, sizeof(LineInstance));
}
void mark_drawn() noexcept { store_.mark_drawn(); }
bool mark_drawn() noexcept { return 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(); }
@@ -138,7 +151,7 @@ class TextBuffer {
void upload(const f32* interleaved, u32 count) noexcept {
store_.upload(interleaved, count, sizeof(TextInstance));
}
void mark_drawn() noexcept { store_.mark_drawn(); }
bool mark_drawn() noexcept { return 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(); }
+28 -11
View File
@@ -58,7 +58,9 @@ struct FrameStats {
/// 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.
/// machine. A driver that refuses the storage, a map or a fence drops the store
/// that hit it to the baseline path -- the ring is never rotated through without
/// a guard -- and the frame reports it (see `last_frame_had_gl_error()`).
///
/// Not thread-safe, and confined to the thread that owns the current context.
class Renderer {
@@ -87,6 +89,10 @@ class Renderer {
/// 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.
///
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
/// it runs the auto scale, the strip fixed-point loop and the grid build, so
/// re-deriving it per helper would rebuild the ticks and the grid per helper.
void draw(Plot& plot);
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
@@ -107,7 +113,10 @@ class Renderer {
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
/// True when the last frame reported a GL failure: an error drained from
/// `glGetError()` in `end_frame()`, or a fence the driver refused, which the
/// store reports when the draw that should have been guarded is issued (such a
/// frame has already fallen back to the baseline path). Errors are drained in a
/// loop, so a stale one cannot be attributed to a later frame.
bool last_frame_had_gl_error() const noexcept { return gl_error_; }
@@ -115,9 +124,14 @@ class Renderer {
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_; }
/// True while both pipelines are uploading through the persistent-mapped ring:
/// `init()` selects it from the caps and the entry points (4.3+ with
/// ARB_buffer_storage, and `PLOT_FORCE_GL33_UPLOAD` unset), and a driver failure
/// later -- a refused map or fence -- leaves the store that hit it on the
/// baseline path, which this then says.
bool using_persistent_upload() const noexcept {
return line_vao_.persistent() && text_vao_.persistent();
}
/// The axis text size the renderer actually draws with: `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.
@@ -191,11 +205,15 @@ class Renderer {
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);
/// The chrome helpers take the frame's layout: `plot.layout()` recomputes the
/// ticks, the strips and the grid, so it belongs to `draw()` alone.
void draw_tick_labels(const Plot& plot, const PlotLayout& layout);
void draw_titles(const Plot& plot, const PlotLayout& layout);
/// The data helpers take it too; they read the axes rect and the transforms it
/// pins.
void draw_traces(const Plot& plot, const PlotLayout& layout);
void draw_cursors(const Plot& plot, const PlotLayout& layout);
void draw_measures(const Plot& plot, const PlotLayout& layout);
void draw_annotations(const Plot& plot);
render::BitmapFont font_;
@@ -218,7 +236,6 @@ class Renderer {
u32 fb_w_ = 0;
u32 fb_h_ = 0;
bool ready_ = false;
bool persistent_ = false;
bool gl_error_ = false;
FrameStats stats_{};
};
+10 -4
View File
@@ -31,9 +31,14 @@ class LineVertexArray {
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(); }
/// which is what lets the mapped path reuse it safely. True in the baseline
/// mode, so a caller always calls it the same way. False only when the driver
/// refused the fence: the buffer has then fallen back to the baseline path, and
/// `persistent()` says so from the next call on.
bool mark_drawn() noexcept { return buffer_.mark_drawn(); }
/// True while this array's instance buffer is the mapped ring (see
/// `UploadMode`); a failure that falls back clears it.
bool persistent() const noexcept { return buffer_.persistent(); }
bool valid() const noexcept { return vao_.id() != 0; }
u32 instance_count() const noexcept { return instances_; }
@@ -57,7 +62,8 @@ class TextVertexArray {
void upload(const f32* interleaved, u32 glyphs) noexcept;
void bind() const noexcept { vao_.bind(); }
void mark_drawn() noexcept { buffer_.mark_drawn(); }
bool mark_drawn() noexcept { return buffer_.mark_drawn(); }
bool persistent() const noexcept { return buffer_.persistent(); }
bool valid() const noexcept { return vao_.id() != 0; }
u32 instance_count() const noexcept { return instances_; }