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

+22 -6
View File
@@ -58,7 +58,10 @@ bool InstanceStore::grow_persistent(usize bytes) noexcept {
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.
// buffer the orphan path needs and let the next upload use it. The vertex
// array's attribute pointers went out with the buffer they captured, and the
// epoch reports it (`gl::Buffer::generation()`), which is what re-points them
// at this buffer before the draw.
release_storage();
mode_ = UploadMode::Orphan;
capacity_ = 0;
@@ -68,7 +71,6 @@ bool InstanceStore::grow_persistent(usize bytes) noexcept {
}
slot_bytes_ = bytes;
slot_ = kSlots - 1u; // fresh storage: the first upload takes slot 0
++epoch_;
return true;
}
@@ -102,16 +104,30 @@ void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) no
}
// MAP_COHERENT storage needs no explicit flush, so this is the whole transfer.
std::memcpy(mapped_ + usize(slot_) * slot_bytes_, data, bytes);
++epoch_;
++slot_epoch_;
}
void InstanceStore::mark_drawn() noexcept {
bool InstanceStore::mark_drawn() noexcept {
if (mode_ != UploadMode::PersistentMapped || mapped_ == nullptr) {
return;
return true; // nothing reads the store after the draw
}
Status st;
fences_[slot_].reset();
(void)fences_[slot_].create(&st);
if (fences_[slot_].create(&st)) {
return true;
}
// A slot whose fence the driver refused can never be reused safely: the next
// upload would memcpy into storage the GPU may still be reading, which is the
// tearing the ring exists to prevent. So the ring is abandoned rather than
// rotated through -- the map failure's treatment, one step later -- and the
// fresh buffer the orphan path needs is created. Deleting the mapped buffer is
// safe while the draw still reads it: GL keeps the storage alive until then.
release_storage();
mode_ = UploadMode::Orphan;
capacity_ = 0;
Status retry;
(void)buffer_.create(&retry); // a failed retry leaves nothing to draw with
return false;
}
} // namespace detail
+47 -32
View File
@@ -91,10 +91,11 @@ bool Renderer::init(Status* st) noexcept {
// 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 bool 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;
persistent ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan;
font_backend_.reset(font_, style_.axis_font_px);
@@ -176,17 +177,20 @@ void Renderer::draw(Plot& plot) {
// 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
// One layout for the whole frame: autoscale, the strip fixed-point loop with
// its tick rebuilds, and the grid build all run in here, and every helper
// below reads the strips, the axes rect and the transforms it pins.
const PlotLayout& layout = plot.layout();
// 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_tick_labels(plot, layout);
draw_titles(plot, layout);
draw_traces(plot, layout);
draw_cursors(plot, layout);
draw_measures(plot, layout);
draw_annotations(plot);
flush_text();
}
@@ -241,8 +245,12 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(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();
// mapped path can let a fence follow it. A driver that refused the fence has
// already dropped this store to the baseline path; the frame reports it here
// rather than rotating through a slot nothing guards.
if (!line_vao_.mark_drawn()) {
gl_error_ = true;
}
stats_.staged_segments += segments;
++stats_.draw_calls;
}
@@ -265,7 +273,9 @@ void Renderer::flush_text() noexcept {
text_vao_.bind();
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(text_vao_.instance_count()));
text_vao_.mark_drawn();
if (!text_vao_.mark_drawn()) {
gl_error_ = true;
}
++stats_.draw_calls;
}
glyphs_.clear();
@@ -363,8 +373,7 @@ void Renderer::draw_axis_lines(const Plot& plot) {
}
}
void Renderer::draw_tick_labels(const Plot& plot) {
const PlotLayout& layout = plot.layout();
void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 line_h = font_backend_.height();
// X labels: centred under their tick, on the strip's single text line.
@@ -397,34 +406,41 @@ void Renderer::draw_tick_labels(const Plot& plot) {
}
}
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;
void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
// Every title goes in the strip its direction reserved for it, one text line
// outside the tick labels: below the x labels for an x axis, beside the y labels
// for a y axis. The strips are what makes a title over the data impossible --
// `layout.axes` is inset by them -- rather than merely unlikely, so nothing here
// may fall back to an inside-the-axes position.
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;
// Centred on the strip: the tick labels have their own row above it, so the
// title needs no alignment of its own.
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());
stage_text({layout.x_title_area.x + 0.5f * (layout.x_title_area.w - width),
layout.x_title_area.y + 0.5f * (layout.x_title_area.h - line_h)},
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;
// A y title is a horizontal run in the vertical strip the layout sized for
// the widest of them, centred on the axes' height: it reads like the x title,
// only beside the plot instead of under it. A run longer than the axes is
// clipped by the viewport, never folded back over the data.
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());
stage_text({layout.y_title_area.x + 0.5f * (layout.y_title_area.w - width),
layout.y_title_area.y + 0.5f * (layout.y_title_area.h - line_h)},
axis.label.c_str());
}
}
// ------------------------------------------------------------------- data
void Renderer::draw_traces(const Plot& plot) {
const PlotLayout& layout = plot.layout();
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
if (!(layout.axes.w > 0.0f)) {
return; // a collapsed plot area has no pixel to select a level for
}
@@ -472,8 +488,7 @@ void Renderer::draw_traces(const Plot& plot) {
}
}
void Renderer::draw_cursors(const Plot& plot) {
const PlotLayout& layout = plot.layout();
void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
const Range y_range = plot.y_range(0);
const bool log_y = plot.y_axis(0).log;
@@ -495,8 +510,7 @@ void Renderer::draw_cursors(const Plot& plot) {
}
}
void Renderer::draw_measures(const Plot& plot) {
const PlotLayout& layout = plot.layout();
void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
const Rect& axes = layout.axes;
const Range y_range = plot.y_range(0);
const bool log_y = plot.y_axis(0).log;
@@ -544,8 +558,9 @@ void Renderer::draw_measures(const Plot& plot) {
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.
// cursor labels, and a wide readout clamped to the left edge would overprint
// them. The y axis title is not a consideration here -- it lives in its own
// strip outside the axes.
stage_text({left, axes.y + line_h + 2.0f}, readout);
}
}