Files
plotter/src/render/renderer.cpp
T

1308 lines
64 KiB
C++

#include <plot/render/renderer.hpp>
#include <plot/gl/api.hpp>
#include <plot/gl/caps.hpp>
#include <plot/gl/loader.hpp>
#include <plot/render/detail/text_scale.hpp>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <vector>
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<f32>(log_x ? Transform::log10_positive(x) : x),
static_cast<f32>(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<f64>(n)) return n;
return static_cast<u64>(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;
/// The run bases the axis titles are drawn with. A title reads left to right in
/// the x strip (`kTextRight`), and bottom-to-top in the y strip (`kTextUp`: a
/// quarter turn anticlockwise on screen, where y grows downward). Both are unit
/// vectors, which is what `walk_text_clipped()` spends its budget along and what
/// `TextBatch` builds the glyph cell from (see `TextInstance`).
constexpr Vec2 kTextRight{1.0f, 0.0f};
constexpr Vec2 kTextUp{0.0f, -1.0f};
/// How much of its alpha the legend's panel keeps. The panel is the frame's own
/// background, so at 1.0 it would be a hole in the plot with the traces cut off at
/// its edge; at this share the data underneath stays faintly visible and the panel
/// reads as something laid *over* the plot -- which is what the border is for. A
/// host whose background is already translucent gets a proportionally fainter
/// panel, which is the same statement about its own theme.
constexpr f32 kLegendPanelAlpha = 0.85f;
/// Walks the longest prefix of `utf8` that fits `budget_px` along `axis`, calling
/// `emit(pen, glyph)` for every glyph it keeps -- `pen` in viewport pixels,
/// `glyph` the backend's own entry for that codepoint -- and returning the length
/// of that prefix in pixels. Both the measuring and the staging of a clipped run
/// walk this, so the cut lands on the same pixel either way; the codepoints, the
/// advances and the scale are the ones `TextBatch::add_text()` uses, so a clipped
/// run still ends where the plot layer measured its title to end.
///
/// A codepoint the backend has no glyph for is skipped, exactly as `add_text`
/// skips it, so what is measured here is what is drawn.
///
/// `axis` is the run's own direction in viewport pixels -- a unit vector, the same
/// basis the cells are staged with (see `TextInstance`): the pen advances along it
/// and the budget is spent along it too, so a run is clipped to the strip's extent
/// *in its own direction* rather than to the strip's width. A horizontal x title
/// asks for (1, 0) and spends the strip's width; the y title asks for (0,-1) and
/// spends the strip's height, which is the only reason a rotated run can be cut at
/// the strip's ends at all.
template <class Emit>
f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::TextBackend& font,
f32 scale, Vec2 axis, Emit emit) {
if (utf8 == nullptr || !(budget_px > 0.0f)) {
return 0.0f; // no room at all: a strip clipped away stages nothing
}
f32 used = 0.0f;
for (const char* p = utf8; *p != '\0';) {
const render::Glyph* glyph = font.glyph(render::next_codepoint(p));
if (glyph == nullptr) continue; // nothing to draw, nothing to reserve
const f32 advance = glyph->advance * scale;
if (used + advance > budget_px) break; // the next glyph does not fit: cut here
emit(pen, *glyph);
pen = pen + axis * advance;
used += advance;
}
return used;
}
#ifndef NDEBUG
/// A host that installed its own `TextBackend` is told its metrics are gone: the
/// renderer draws every label with its artwork's font, so laying the strips out
/// with a different one puts the labels wherever that font's metrics happen to
/// fall. A diagnostic rather than an abort -- a host's frame must still draw --
/// and debug-only, like `plot::gl::load()`'s unresolved-entry-point notice.
void warn_replaced_text_backend(const render::TextBackend* previous) noexcept {
std::fprintf(stderr,
"plot::Renderer::draw: replacing the Plot's TextBackend (%p) with the renderer's "
"own font; a plot drawn by a Renderer measures the labels it draws -- install the "
"face with Renderer::set_text_backend() instead.\n",
static_cast<const void*>(previous));
}
#endif
} // namespace
void Renderer::AxisFont::reset(const render::TextBackend& font, f32 requested_px) noexcept {
font_ = &font;
// An anti-aliased face is legible at any ratio, so a style asking for 12 px
// from a 13 px bake is drawn at 12/13 rather than snapped to a whole multiple
// of the cell. `detail::text_scale` owns that rule, including the 1:1 fallback
// for a backend that cannot state an em -- it is one function, and the test
// suite asserts it without a GL context.
scale_ = render::detail::text_scale(requested_px, font.em_px());
cell_px_ = font.height() * scale_;
}
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;
}
f32 Renderer::AxisFont::em_px() const {
// Scaled with the rest of the metrics, not forwarded raw: `TextBackend::em_px()`
// is documented to be in the same units as `height()` and `ascent()`, and this
// wrapper's are all screen pixels. A caller that reads the backend back off the
// plot and draws at `requested_px / em_px()` therefore gets 1:1 rather than
// scaling a second time (see the base-class contract).
return font_ != nullptr ? font_->em_px() * scale_ : 0.0f;
}
const render::Glyph* Renderer::AxisFont::glyph(u32 codepoint) const {
if (font_ == nullptr) {
return nullptr; // nothing to draw with, so nothing to describe
}
const render::Glyph* glyph = font_->glyph(codepoint);
if (glyph == nullptr) {
return nullptr;
}
// The rect is the atlas', and the atlas is not scaled; the bearing and the
// advance are font pixels, and this backend's caller works in screen pixels.
scaled_ = *glyph;
scaled_.xoff *= scale_;
scaled_.yoff *= scale_;
scaled_.advance *= scale_;
return &scaled_;
}
Vec2 Renderer::AxisFont::cell_uv() const { return font_ != nullptr ? font_->cell_uv() : Vec2{}; }
const u8* Renderer::AxisFont::atlas_pixels() const {
return font_ != nullptr ? font_->atlas_pixels() : nullptr;
}
u32 Renderer::AxisFont::atlas_width() const { return font_ != nullptr ? font_->atlas_width() : 0u; }
u32 Renderer::AxisFont::atlas_height() const {
return font_ != nullptr ? font_->atlas_height() : 0u;
}
u32 Renderer::AxisFont::revision() const { return font_ != nullptr ? font_->revision() : 0u; }
// ------------------------------------------------------------------ init
void Renderer::set_text_backend(const render::TextBackend* font) noexcept {
const render::TextBackend* next = font != nullptr ? font : &default_font_;
if (next == font_) {
return; // the same face: no upload, and nothing changed
}
// A replacement is a different *source* of atlas bytes, which `revision()`
// cannot express on its own: two faces may legitimately number their first bake
// the same. So the cache is dropped rather than trusted. Assigning a fresh
// atlas releases the texture the last frame sampled and forgets the revision
// that was uploaded, which makes the next `begin_frame_text()` upload whatever
// the new backend exposes, at whatever size it says. That is one upload on the
// frame a host deliberately swapped the font -- and it is the direction that
// cannot draw the old face's glyphs under the new face's rects.
atlas_ = render::TextAtlas{};
font_ = next;
}
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();
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;
font_backend_.reset(*font_, style_.device(style_.axis_font_px));
// The atlas is the backend's: `sync()` uploads whatever it exposes, at whatever
// size it says, and restores the host's unpack alignment around the upload
// itself -- so this is the only place in the renderer that has to know a font
// atlas exists at all.
if (!atlas_.sync(font_backend_, st)) {
return false;
}
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<f32>(fb_h) - (viewport_px.y + viewport_px.h),
viewport_px.w, viewport_px.h};
if (!ready_) {
return;
}
// One wait per store, before any slot of this frame is written: the ring never
// synchronises with the work its own frame issued, only with the frame before
// it. A store that cannot retire that frame has dropped to the baseline path,
// which the frame reports.
const bool lines_retired = line_vao_.begin_frame();
const bool glyphs_retired = text_vao_.begin_frame();
if (!lines_retired || !glyphs_retired) {
gl_error_ = true;
}
const gl::GLint vx = static_cast<gl::GLint>(gl_viewport_.x);
const gl::GLint vy = static_cast<gl::GLint>(gl_viewport_.y);
const gl::GLsizei vw = static_cast<gl::GLsizei>(gl_viewport_.w);
const gl::GLsizei vh = static_cast<gl::GLsizei>(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.
//
// The same clip stays engaged for the whole frame as its baseline -- set here, and put
// back by `clear_clip()` -- so a pass that is not narrowed to the axes is still bounded
// by the region the host gave the renderer. `end_frame()` turns the test off again.
//
// The host's clear colour is queried once, before the first frame sets its own:
// GL keeps no history for it, so this is the only moment the value can be
// recovered for `end_frame()` to hand back. A host that changes its own clear
// colour *after* the first frame therefore gets the first frame's value restored
// -- the renderer cannot tell its own writes from the host's -- which is still
// the host's colour, and stated as such in end_frame()'s contract.
if (!host_clear_saved_) {
f32 rgba[4] = {0.0f, 0.0f, 0.0f, 0.0f};
gl::glGetFloatv(gl::GL_COLOR_CLEAR_VALUE, rgba);
host_clear_ = Color{rgba[0], rgba[1], rgba[2], rgba[3]};
host_clear_saved_ = true;
}
set_clip(viewport_px);
const Color& background = style_.background;
gl::glClearColor(background.r, background.g, background.b, background.a);
gl::glClear(gl::GL_COLOR_BUFFER_BIT);
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);
}
// --------------------------------------------------------------- the frame's clip
void Renderer::set_clip(const Rect& rect) noexcept {
// Nested, never replacing: the box is the *intersection* of `rect` with the frame's
// viewport, so the clip can only ever be smaller than the region this frame owns --
// which is the same region `begin_frame()`'s clear covered and `clear_clip()` puts back.
clip_to(std::max(rect.x, viewport_.x), std::max(rect.y, viewport_.y),
std::min(rect.right(), viewport_.right()),
std::min(rect.bottom(), viewport_.bottom()));
}
void Renderer::clear_clip() noexcept {
clip_to(viewport_.x, viewport_.y, viewport_.right(), viewport_.bottom());
}
void Renderer::clip_to(f32 x0, f32 y0, f32 x1, f32 y1) noexcept {
// The one conversion of a rect to a scissor box, and both halves of it matter here: GL's
// box is integral and its origin is bottom-left, while a `Rect` is fractional and
// top-left. The rounding is *inward* -- `ceil` the near edges, `floor` the far ones --
// so the box holds only the pixels `rect` entirely contains. Rounding outward would let a
// trace's half-pixel fringe over the edge it is being kept behind (a `Figure` row
// bleeding a single row into its neighbour is exactly that, and so is a marker's fringe
// over a label strip), while rounding inward at most drops the boundary half-pixel of a
// stroke that a fractional axes rect does not fully contain either.
//
// An unordered rect -- empty, inverted, or NaN, whose every comparison is false -- sets
// an empty box, which rejects every fragment: the honest reading of "clip to nothing",
// and the reason a degenerate axes draws no data instead of drawing all of it.
gl::GLint left = 0;
gl::GLint top = 0;
gl::GLsizei w = 0;
gl::GLsizei h = 0;
if (x0 <= x1 && y0 <= y1) {
const gl::GLint x_min = static_cast<gl::GLint>(std::ceil(x0));
const gl::GLint x_max = static_cast<gl::GLint>(std::floor(x1));
const gl::GLint y_min = static_cast<gl::GLint>(std::ceil(y0));
const gl::GLint y_max = static_cast<gl::GLint>(std::floor(y1));
left = x_min;
top = y_min;
w = static_cast<gl::GLsizei>(std::max(0, x_max - x_min));
h = static_cast<gl::GLsizei>(std::max(0, y_max - y_min));
}
gl::glEnable(gl::GL_SCISSOR_TEST);
// Top-left to bottom-left: the frame's last row is GL's first.
gl::glScissor(left, static_cast<gl::GLint>(fb_h_) - top - h, w, h);
}
bool Renderer::begin_frame_text() noexcept {
// 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_.device(style_.axis_font_px));
// The cell every glyph staged this frame is drawn in, declared from the backend
// rather than left to whichever glyph is staged first: the instances carry rect
// origins, and the draw carries this size (see TextBackend::cell_uv).
glyphs_.set_cell(font_backend_.cell_uv());
// A backend whose revision moved -- a font swapped, or re-baked at another size
// -- is re-uploaded here, before anything is staged from it, so the frame draws
// its glyphs from the atlas they were baked into. The first sync happened in
// `init()` and a steady-state frame only compares one counter, so a font that
// never changes costs a comparison a frame. A driver that refuses the re-upload
// is reported the way the stores report a refused fence: the frame draws on (it
// has the previous atlas), and `last_frame_had_gl_error()` says so.
return atlas_.sync(font_backend_);
}
void Renderer::prepare_plot(Plot& plot) {
#ifndef NDEBUG
// The renderer owns the text of the frames it draws: every label goes through
// the text pipeline in `font_` -- the embedded face, or whatever a host put
// there with Renderer::set_text_backend() -- so the plot is handed this
// backend's metrics whatever it was given. A backend installed on the *Plot* is
// therefore replaced on the first draw -- said out loud in a debug build, since
// its disappearance is otherwise silent -- and a Plot nobody draws keeps it (see
// Plot::set_text_backend()).
if (const render::TextBackend* previous = plot.text_backend();
previous != nullptr && previous != &font_backend_) {
warn_replaced_text_backend(previous);
}
#endif
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_);
// The plot's chrome is laid out in device pixels and it converts the style's
// logical metrics itself, so it needs the scale *before* `layout()`: this is the
// propagation point the contract names, and it is what keeps the strips the
// layout reserves and the strokes drawn into them at one scale.
plot.set_ui_scale(style_.ui_scale);
}
void Renderer::draw(Plot& plot) {
stats_ = FrameStats{};
if (!ready_) {
return;
}
if (!begin_frame_text()) {
gl_error_ = true;
}
prepare_plot(plot);
// The plot's one layout for the 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.
draw_plot(plot, plot.layout());
// The frame's single text flush, after every line batch of the frame: text is
// texture-bound, so it is one draw call whatever it holds (see draw_plot).
flush_text();
}
void Renderer::draw(Figure& figure) {
stats_ = FrameStats{};
if (!ready_) {
return;
}
if (!begin_frame_text()) {
gl_error_ = true;
}
// Every row is prepared before the figure lays itself out, because a row's strips
// are measured from the metrics this installs: a row measuring with one backend and
// drawing with another is the defect the propagation point exists to prevent.
const u32 rows = figure.row_count();
for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row));
// The figure's one layout for the frame. It hands every row its rect and its
// x-strip mode, advances the link groups, and lays every row out against its own
// rect -- which is why the rows below are drawn with the layout it produced rather
// than laying each row out a second time. Nothing draws before this: a row's
// viewport is the figure's to set.
//
// The figure's one x axis needs no pass of its own: it is the bottom row's own, so
// that row stages it in its own strip through `draw_tick_labels()` like any other
// plot's axis -- and every row above it reserved no x chrome, which is what keeps
// the labels from appearing once per row without a second staging here.
figure.layout();
for (u32 row = 0; row < rows; ++row) {
Plot& plot = figure.plot(row);
// The layout the figure just computed, read through the non-recomputing
// overload -- the one layout of this frame, not a second derivation of it.
draw_plot(plot, static_cast<const Plot&>(plot).layout());
}
flush_text();
}
void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// Draw order: chrome, then data, then the overlays over it, and the text last (it
// is texture-bound, so it is one draw call after every line batch). The legend
// closes the line batches: its panel is a backplate, so it is drawn over
// everything geometric -- a legend under a trace is a legend nobody can read --
// while its names wait for the frame's single text flush with every other label,
// which is what keeps the two-pipeline order the whole frame is built on.
//
// The flush itself belongs to the frame, not to one plot: `draw(Plot&)` flushes
// after this returns, and `draw(Figure&)` after it has drawn every row and the
// figure's shared x axis, so a figure is one text draw call rather than one per
// row -- and the shared axis' labels join the same batch as the labels above them.
//
// The clip alternates around those passes, and every transition is one of the two
// helpers -- no scissor is set anywhere else -- so the sequence is this: the passes that
// draw *inside* the axes run narrowed to `layout.axes` (intersected with the frame's
// viewport, which is the only clip a hostile rect could otherwise escape), the passes
// that draw outside it run with the frame's viewport clip. A sample outside the visible
// range, or a segment the gathered window's slack hangs past the end of the window, is
// therefore never drawn over the label strips or, in a `Figure`, into the row above.
//
// Two of the three releases are load-bearing rather than tidiness. The axis lines are
// chrome drawn *on* the axes' edge: half of each stroke lies outside the rect by
// construction, so clipping them would thin them at the frame's border. The tick labels
// and the axis titles live outside `axes` by design, and clipping them would erase them.
// (They are staged last here, but their pixels are not: text is one batch flushed after
// the frame's line geometry, in the order it was staged, so reordering these two calls
// against the line passes would not change a pixel.)
set_clip(layout.axes);
draw_grid(plot);
clear_clip();
draw_axis_lines(plot);
set_clip(layout.axes);
draw_traces(plot, layout);
// The crosshair's lines and markers over the data, but *under* the handles a pointer can
// grab: a hovered or grabbed cursor marker or region bound is feedback for a gesture in
// progress, and the crosshair -- which is drawn wherever the pointer is, including on top of
// the handle being grabbed -- must not hide it. Its readout panel, which is a backplate for
// text rather than a stroke, is drawn last of all (see below).
draw_crosshair_lines(plot, layout);
// Regions over the traces and under the cursors: an area of interest the data is
// still legible through, with the markers that bound it on top of it.
draw_regions(plot, layout);
draw_cursors(plot, layout);
draw_measures(plot, layout);
draw_annotations(plot);
draw_legend(plot, layout);
// The crosshair's readout last of the interior passes: it is what the pointer is on, so its
// panel is drawn over the legend and every other overlay rather than under them, and its text
// joins the frame's single flush with everything else.
draw_crosshair_readout(plot, layout);
clear_clip();
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
}
void Renderer::end_frame() noexcept {
if (!ready_) {
return;
}
// The frame's single fence per store, behind every draw that read the ring: the
// next frame's begin_frame() waits for it, and until then nothing is written to
// a slot these draws may still be reading. Recording it here -- after the last
// draw, not after each upload -- is what keeps a frame from waiting on itself.
const bool lines_guarded = line_vao_.mark_drawn();
const bool glyphs_guarded = text_vao_.mark_drawn();
if (!lines_guarded || !glyphs_guarded) {
gl_error_ = true;
}
// Leave the host's context as close to neutral as a frame can: nothing bound to
// a pipeline stage, off every state bit the frame turned on, and the one value
// the frame *changed* rather than merely used handed back -- the host's clear
// colour. 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_UNPACK_ALIGNMENT` is not in this list: `render::TextAtlas`
// saves and restores it around every atlas upload, so the frame never leaves it
// moved for the host's own uploads to inherit (see end_frame's contract in
// renderer.hpp).
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);
// The scissor, too: the frame engaged it (its baseline clip) and each plot narrowed and
// released it, so what is left behind is the test *off* -- the state the residue list
// promises -- rather than whichever rect the last pass was clipped to. The box stays
// where `clear_clip()` put it, the frame's viewport rect.
gl::glDisable(gl::GL_SCISSOR_TEST);
if (host_clear_saved_) {
gl::glClearColor(host_clear_.r, host_clear_.g, host_clear_.b, host_clear_.a);
}
// 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<const f32> 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<u32>(interleaved.size() / 4u));
}
void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
// Six floats per instance is the text pipeline's layout (see TextInstance):
// the cell's corner, its atlas rect and the basis that rotates it.
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 6u));
}
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash) 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);
// Set on *every* flush, solid included, so the crosshair's dash cannot leak into the
// grid, a trace or a cursor drawn after it (see draw_crosshair_lines()).
line_program_.set_dash(dash);
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<gl::GLsizei>(line_vao_.instance_count()));
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();
// No atlas is no text: a backend that publishes none has nothing to sample, and
// drawing its instances would read whatever texture happened to be bound.
if (glyphs != 0 && ready_ && !atlas_.empty()) {
upload_text_vertices(glyphs_.data());
text_program_.bind();
text_program_.set_viewport({viewport_.w, viewport_.h});
// The cell comes from the backend and the atlas it uploaded -- never from a
// constant, and never from whichever glyph happened to be staged first -- so a
// font swap moves the quads with the artwork: `glyph_uv` is the rect every
// instance is read through and `px_range` is that same cell on screen.
const Vec2 cell_uv = font_backend_.cell_uv();
const Vec2 atlas_texels = atlas_.texel_size();
text_program_.set_glyph_uv(cell_uv);
text_program_.set_px_range({cell_uv.x * atlas_texels.x * font_backend_.scale(),
cell_uv.y * atlas_texels.y * font_backend_.scale()});
text_program_.set_texture_size(atlas_texels);
text_program_.set_color(style_.axis_text);
atlas_.bind();
text_vao_.bind();
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(text_vao_.instance_count()));
++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<f32>(tx.sx), static_cast<f32>(ty.sy)},
Vec2{static_cast<f32>(tx.tx) - viewport_.x,
static_cast<f32>(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::fill_pixels(const Rect& rect, Color color) {
if (!(rect.w > 0.0f) || !(rect.h > 0.0f)) return;
// The line pipeline has no filled primitive and does not need one: a stroke's
// quad is its perpendicular width, and the fragment shader ramps on that
// perpendicular alone -- a stroke is not capped at its ends -- so a *horizontal*
// stroke `rect.h` wide from the rect's left edge to its right edge is the rect
// itself, opaque everywhere but the half pixel at each horizontal edge where the
// ramp lives.
//
// A fill per pixel column, the way the measurement band fills its interval, is the
// other way to read this, and it is the wrong one here: a legend panel is
// *measured*, so its edges never land on the pixel grid, and a column fill of a
// rect that is half a pixel off lights every one of its columns at 0.8 -- a swatch
// that is a washed-out version of the trace it is supposed to stand for. One
// instance instead of one per column is the same answer for the cost.
lines_.add_segment({rect.x, rect.y + 0.5f * rect.h}, {rect.right(), rect.y + 0.5f * rect.h});
flush_lines(pixel_mapping(), color, rect.h);
}
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(utf8, to_viewport(pos_px), font_backend_.scale(), *font_);
}
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const {
return walk_text_clipped(Vec2{}, utf8, budget_px, *font_, font_backend_.scale(), axis,
[](Vec2, const render::Glyph&) {});
}
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) {
const f32 scale = font_backend_.scale();
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, *font_, scale, axis,
[this, scale, axis](Vec2 pen, const render::Glyph& glyph) {
glyphs_.add_glyph(pen, glyph, scale, axis);
});
}
// -------------------------------------------------------------------- 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 classes, one pass over the segment list each: the tick set decides *where*
// the lines are and this decides only how one is drawn -- its class' colour and
// its class' logical width, converted to device pixels here, the one conversion
// point (spec §11: a grid line is a size a user perceives, the same hairline on a
// 1x panel and a 2x one). A class the style switched off stages nothing at all:
// the pass is skipped before the first segment, so a disabled class costs nothing
// to draw rather than being drawn transparent. Majors first, minors over them, as
// the single group order always did. `LineBatch::clear()` keeps its capacity, so
// the second pass re-uses the first one's storage.
const auto draw_class = [&](bool major, bool visible, Color color, f32 width) {
if (!visible) {
return;
}
for (const GridLine& line : grid.lines()) {
if (line.major != major) 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, color, width);
};
draw_class(true, style_.grid_major_visible, style_.grid_major,
style_.device(style_.grid_major_width));
draw_class(false, style_.grid_minor_visible, style_.grid_minor,
style_.device(style_.grid_minor_width));
}
// ------------------------------------------------------------- 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_.device(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_.device(style_.line_width_px));
}
}
void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 line_h = font_backend_.height();
// X labels: unless the plot gave its x label strip away -- a row of a `Figure`
// that reserves no x chrome, because the figure draws one shared axis in the bottom
// row's own strip instead -- in which case it has no strip to put them in and their
// absence is the point. A plot with no strip would otherwise stage its labels just
// above `axes`' bottom edge, i.e. over its own data. Centred under their tick on the
// strip's single text line, which is the one rule for a plot's labels and a figure's
// shared axis alike -- the figure's axis is drawn by this same pass, in the bottom
// row's own `x_label_area`.
if (x_strip_has_labels(plot.x_strip_mode())) {
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<f32>(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<f32>(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_.device(style_.grid_label_pad) - width, top},
tick.label.c_str());
}
}
}
void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
// Every title goes in the strip its direction reserved for it, and every titled
// axis gets a text line of its own inside that strip -- a row for an x axis, a
// column for a y one -- which is what keeps a Left/Right (or Bottom/Top) pair of
// titles from being drawn over each other, where the pass before that put them
// all on one centred run. Each run is also cut to its strip, so a title longer
// than the strip -- which the layout caps, because a title may shrink the plot
// but never erase it -- stops at the strip's padding instead of spilling over the
// labels or the data.
//
// A line is staged only if the cell it occupies *fits* the strip, and that is a
// check this function owns: a run's clip cuts along the run (`walk_text_clipped()`),
// the layout clamps the strip against the viewport's chrome allowance without
// knowing what a line is (`Plot::layout()`), and the cell's other dimension -- a
// row's height, a column's width -- is one text line. Rows are staged top down and
// columns left to right, so the lines a clamped strip cut short are the trailing
// ones: keep the leading lines that fit and stage no more. A dropped title is
// chrome that had no room, which is the same bargain the label strips already make
// -- never drawn outside its strip, and never inside `axes`.
const f32 pad = style_.device(style_.grid_label_pad);
const f32 line_h = font_backend_.height();
// X titles: the strip's rows top down, one per titled axis, each read left to
// right on the row it owns. A row must fit the strip's *height* -- with this basis
// the cell is [pen.y, pen.y + line_h] -- and the x strip is the bottom-most, so a
// row that does not fit leaves the target entirely (nothing is over it) and is
// clipped rather than overprinted. It is dropped all the same: a title is drawn in
// the strip the layout reserved, or not at all, and this is the dimension the
// layout's clamp is applied to.
for (u32 i = 0, row = 0; i < plot.x_axis_count(); ++i) {
const Axis& axis = plot.x_axis(i);
if (axis.label.empty()) continue;
const Rect& strip = layout.x_title_area;
const f32 y = strip.y + pad + static_cast<f32>(row) * line_h;
if (y + line_h > strip.bottom()) break;
const f32 budget = strip.w - 2.0f * pad;
const f32 width = clipped_text_width(axis.label.c_str(), budget, kTextRight);
// Centred on the strip: the tick labels have their own rows above it, so the
// title needs no alignment of its own.
stage_text_clipped({strip.x + 0.5f * (strip.w - width), y}, axis.label.c_str(), budget,
kTextRight);
++row;
}
// Y titles: the same strips, cut the same way, but each run is rotated a quarter
// turn so it reads bottom-to-top -- axis (0,-1), the direction the run advances
// in -- which is what lets the strip be one line *wide* instead of as wide as the
// title: a rotation, not a reflection, so the text is not mirrored (the shader
// builds the cell's other axis as the basis' perpendicular).
//
// Each titled axis gets its own column, the mirror of the x rows above, so a
// Left/Right pair of titled y axes is drawn side by side rather than over each
// other. The pen starts at the column's bottom-left: with this basis the first
// cell occupies the pen's own corner and grows up and to the right, so the run
// rises out of the strip's bottom edge and is centred along the height -- and cut
// to it, which is the budget this direction needs. A title longer than the axes
// is therefore clipped at the strip's ends exactly like an over-long x title.
for (u32 i = 0, column = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i);
if (axis.label.empty()) continue;
const Rect& strip = layout.y_title_area;
// A column must fit the strip's *width* -- with this basis the cell is
// [pen.x, pen.x + line_h] -- and a column that does not fit is unlike an x row,
// which would leave the target and be clipped: this one is drawn beside the
// columns that do fit, over the tick labels the next strip holds, and past `axes`
// once the columns reach it (a 64 px viewport does both). See the rule above.
const f32 x = strip.x + pad + static_cast<f32>(column) * line_h;
if (x + line_h > strip.right()) break;
const f32 budget = strip.h - 2.0f * pad;
const f32 length = clipped_text_width(axis.label.c_str(), budget, kTextUp);
stage_text_clipped({x, strip.bottom() - 0.5f * (strip.h - length)}, axis.label.c_str(), budget,
kTextUp);
++column;
}
}
// ------------------------------------------------------------------- data
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// Both dimensions: a zero-width rect selects no level, and a zero-height one
// would flatten every trace onto a single row -- a frame that looks like a
// rendering choice rather than the collapsed plot it is.
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) {
return;
}
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<f64>(visible) / static_cast<f64>(layout.axes.w);
const u32 level = samples_per_pixel >= static_cast<f64>(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<u32>(gather_.size() / 2u),
plot.x_axis(trace.axis_x).log, plot.y_axis(trace.axis_y).log);
// A trace's width is a logical size like every other stroke here, so it is
// converted too: left raw, a 1.5 px trace would be drawn 1.5 device px against a
// 3 device px axis line at scale 2 -- half the logical weight, which inverts the
// trace/axis hierarchy on a high-DPI panel.
flush_lines(mapping(plot, trace.axis_x, trace.axis_y), trace.color,
style_.device(trace.width));
}
}
void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
const Range x_range = plot.x_range(0);
const Range y_range = plot.y_range(0);
const bool log_x = plot.x_axis(0).log;
const bool log_y = plot.y_axis(0).log;
// The handles the input layer last found under the pointer: a marker hovered or
// being dragged is drawn in the style's cursor colour and as a *tab*, so the cue
// does not depend on the marker's own colour differing from the accent -- a cursor
// created in the accent colour is otherwise indistinguishable from an idle one.
const HotHandles hot = plot.hot_handles();
for (u32 i = 0; i < plot.cursor_count(); ++i) {
const Cursor& cursor = plot.cursor(i);
if (!cursor.visible) continue;
const u64 id = static_cast<u64>(i);
const bool on = id == hot.cursor || id == hot.grabbed_cursor;
// The two orientations differ in exactly two things: which coordinate places the
// stroke (and which axis maps the span it is drawn along), and where the label is
// written. Everything else -- the skip at the far edge of the axes, the highlight, the
// text path -- is this one loop's, which is the point of one type rather than two.
const bool horizontal = cursor.is_horizontal();
const f32 at = plot.cursor_screen_coordinate(i);
// A cursor has no position on the plot when it is strictly outside its range's mapped
// span -- a marker scrolled past the right edge, a ruler past the bottom -- and the
// tolerance is the stroke's own half-width: a coordinate *at* a bound is on the edge and
// belongs to the plot, and `to_screen_y()` of a range maximum can undershoot `axes.y` by
// a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
// beyond that margin are skipped.
const f32 half = 0.5f * style_.device(on ? style_.handle_width_px : style_.line_width_px);
if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
: (at < layout.axes.x - half || at > layout.axes.right() + half)) {
continue;
}
// The stroke is centred on the coordinate, so a cursor on a bound would paint only the
// half of itself inside `axes` -- and where the edge is fractional the clip's inward
// rounding can leave that half a sub-pixel sliver or nothing at all, because the
// rasteriser keeps a fragment only when its centre lies within the stroke. Clamp the
// *drawn* centre by half the stroke so a marker parked on an edge stays visible at it;
// the cursor's coordinate, the hit test and its label's value are untouched.
const f32 lo_v = layout.axes.x + half;
const f32 hi_v = layout.axes.right() - half;
const f32 lo_h = layout.axes.y + half;
const f32 hi_h = layout.axes.bottom() - half;
const f32 drawn =
horizontal ? std::clamp(at, std::min(lo_h, hi_h), std::max(lo_h, hi_h))
: std::clamp(at, std::min(lo_v, hi_v), std::max(lo_v, hi_v));
if (horizontal) {
const f64 y_drawn = plot.y_transform(cursor.axis_y).to_data_y(static_cast<f64>(drawn));
stage_segment(x_range.min, y_drawn, x_range.max, y_drawn, log_x,
plot.y_axis(cursor.axis_y).log);
} else {
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
stage_segment(x_drawn, y_range.min, x_drawn, y_range.max, plot.x_axis(cursor.axis_x).log,
log_y);
}
flush_lines(horizontal ? mapping(plot, 0, cursor.axis_y) : mapping(plot, cursor.axis_x, 0),
on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px));
draw_cursor_label(plot, layout, cursor, drawn);
}
}
void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, const Cursor& cursor,
f32 at) {
const f32 gap = style_.device(3.0f);
if (cursor.is_horizontal()) {
// A ruler's text is the value it is *for*, written the way the y axis writes its own
// tick labels, so a value read off a ruler reads exactly like the tick beside it. The
// caller's caption, when there is one, is staged first on the same line, so neither the
// caption nor the value has to be dropped -- and both stay one text run's worth of
// glyphs, since the pen advances per glyph either way.
const Vec2 pen{layout.axes.x + gap, at + style_.device(2.0f)};
char value[32];
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
if (cursor.label.empty()) {
stage_text(pen, value);
return;
}
stage_text(pen, cursor.label.c_str());
stage_text({pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y}, value);
return;
}
if (!cursor.label.empty()) {
stage_text({at + gap, layout.axes.y + style_.device(2.0f)}, cursor.label.c_str());
}
}
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
const HotHandles hot = plot.hot_handles();
const f32 edge_w = style_.device(style_.line_width_px);
for (const RegionLayout& region : layout.regions) {
const Region& config = plot.region(region.region);
// The fill first, so both bounds are drawn on top of it: one instance of the
// line pipeline (see fill_pixels), which blends at the region's own alpha and
// therefore leaves the trace under it readable.
if (region.fill.w > 0.0f) fill_pixels(region.fill, config.fill);
// A bound is emphasized only when that bound is the hot zone: a body hover draws
// no edge highlight -- the fill is the body, and the host's own cursor says what
// a drag there would do (Controller::region_grab()).
const bool hot_region = static_cast<u64>(region.region) == hot.region;
const bool hot_left = hot_region && hot.zone == RegionGrab::Left;
const bool hot_right = hot_region && hot.zone == RegionGrab::Right;
// A hot bound is drawn in the cursor colour and at its tab's own width: the tab is
// the target the controller hit-tested, so the highlight a grab or a hover lights up
// is exactly the thing a drag was aimed at, and the grab column and the drawn column
// are one number rather than two that would have to be kept in step.
draw_region_edge(region.left_handle, layout.axes, hot_left ? style_.cursor : config.edge,
hot_left ? region.left_handle.w : edge_w);
draw_region_edge(region.right_handle, layout.axes, hot_right ? style_.cursor : config.edge,
hot_right ? region.right_handle.w : edge_w);
if (!config.label.empty() && region.label.w > 0.0f) {
stage_text({region.label.x, region.label.y}, config.label.c_str());
}
}
}
void Renderer::draw_region_edge(const Rect& tab, const Rect& axes, Color color, f32 width) {
const f32 x = handle_column(tab);
if (x < axes.x || x >= axes.right()) return; // scrolled out: the strip is not ours
lines_.add_segment({x, tab.y}, {x, tab.bottom()});
flush_lines(pixel_mapping(), color, width);
}
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;
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<f32>(left_px), axes.x);
const f32 x1 = std::min(static_cast<f32>(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<f64>(px) + 0.5);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
}
// One device pixel, not a logical one: the band is a *fill* made of one
// stroke per framebuffer column (`px += 1.0f` above), so its width is the
// column pitch itself. Scaling it would overlap the columns it does not
// match and leave gaps in the ones it does.
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 + style_.device(2.0f), axes.right() - width - style_.device(2.0f));
const f32 left = std::clamp(0.5f * (x0 + x1) - 0.5f * width, axes.x + style_.device(2.0f), limit);
// The second line of the plot area, not the first: the first belongs to the
// 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 + style_.device(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<f32>(plot.x_transform(annotation.axis_x).to_screen_x(annotation.x));
const f32 py = static_cast<f32>(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 - style_.device(3.0f), py}, {px + style_.device(3.0f), py});
lines_.add_segment({px, py - style_.device(3.0f)}, {px, py + style_.device(3.0f)});
flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f));
if (!annotation.text.empty()) {
stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str());
}
}
}
void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
const LegendLayout& legend = layout.legend;
if (legend.rows.empty()) return;
// The panel first, so every entry is on top of it. A legend's border is the frame's axis colour;
// the readout's is the crosshair's own (see draw_crosshair_readout), which is the only thing that
// differs between the two panels -- so it is the one parameter the shared helper takes.
if (plot.legend().frame) draw_readout_panel(legend.panel, style_.axis_line);
for (const LegendRow& row : legend.rows) {
// The truncation row names no trace: it has no swatch to colour and its text is
// the marker itself, so both branches read their string from one place -- the
// layout measured it, this stages it.
if (row.ellipsis) {
stage_text({row.name.x, row.name.y}, kLegendEllipsis);
continue;
}
draw_swatch(plot, row.trace, row.swatch);
stage_text({row.name.x, row.name.y}, plot.trace(row.trace).name.c_str());
}
}
void Renderer::draw_readout_panel(const Rect& panel, Color border) {
if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return;
// The frame's own background at a partial alpha, which dims whatever it covers instead of erasing
// it -- the one backplate both readout panels use.
Color background = style_.background;
background.a *= kLegendPanelAlpha;
fill_pixels(panel, background);
// The border frames the pixels the fill covers, not the measured rect's own fractional edges:
// those are wherever the content happened to measure to, and a hairline drawn on one is lit
// half-heartedly -- a frame bright on three sides and dim on the fourth reads as a mistake. These
// are the outermost lit columns and rows the fill's span rounds out to (the same rounding the fill
// itself does), so every side of the frame is one crisp hairline.
const f32 left = std::ceil(panel.x - 0.5f) + 0.5f;
const f32 right = std::ceil(panel.right() - 0.5f) - 0.5f;
const f32 top = std::ceil(panel.y - 0.5f) + 0.5f;
const f32 bottom = std::ceil(panel.bottom() - 0.5f) - 0.5f;
lines_.add_segment({left, top}, {right, top});
lines_.add_segment({left, bottom}, {right, bottom});
lines_.add_segment({left, top}, {left, bottom});
lines_.add_segment({right, top}, {right, bottom});
flush_lines(pixel_mapping(), border, style_.device(1.0f));
}
void Renderer::draw_swatch(const Plot& plot, u32 trace, const Rect& box) {
// Opaque whatever the trace's own alpha is: a swatch drawn at a trace's 15% would be the least
// legible thing in the frame, and the swatch is the one element that says which colour an entry or
// a readout row *is*. One rule, used by the legend and by the crosshair's readout alike.
Color swatch = plot.trace(trace).color;
swatch.a = 1.0f;
fill_pixels(box, swatch);
}
void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (crosshair.empty()) return;
// The stroke and dash the layout already resolved from the crosshair's own override and the
// global style, in device pixels -- one source, so the boxes the layout held inside `axes` and
// the strokes drawn here cannot disagree (spec §17).
const f32 stroke = crosshair.stroke;
// The two lines. Each is staged as one data-space segment at the *centre* of the stroke box
// the layout placed, spanning that box's own extent, so the pixels this draws are the box
// rather than a second placement of the same rule: the layout held the box inside `axes`,
// and the clip keeps the half pixel the rasteriser puts outside the box's fractional edges.
//
// The dash rides on the same segment: `v_along` is measured from the axes edge the line is
// staged from, so no geometry is added and the phase does not depend on the transform.
const Mapping data = mapping(plot, 0u, 0u);
if (crosshair.vertical.w > 0.0f) {
const f64 x = plot.x_transform(0u).to_data_x(
static_cast<f64>(crosshair.vertical.x + 0.5f * crosshair.vertical.w));
const Range y_range = plot.y_range(0u);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(0u).log, plot.y_axis(0u).log);
flush_lines(data, style_.crosshair, stroke, crosshair.dash);
}
if (crosshair.horizontal.h > 0.0f) {
const f64 y = plot.y_transform(0u).to_data_y(
static_cast<f64>(crosshair.horizontal.y + 0.5f * crosshair.horizontal.h));
const Range x_range = plot.x_range(0u);
stage_segment(x_range.min, y, x_range.max, y, plot.x_axis(0u).log, plot.y_axis(0u).log);
flush_lines(data, style_.crosshair, stroke, crosshair.dash);
}
// A small cross at each trace's intersection, in the trace's own colour -- opaque, like a
// legend's swatch, so a trace drawn at 15% still has a marker that can be seen. The arm
// length is a logical size like every other marker's, and the position is the layout's: a
// marker for a sample far outside the visible y range is staged where the trace is and the
// axes clip keeps it inside, rather than being skipped by a range test of this layer's own.
const f32 arm = style_.device(3.0f);
for (const CrosshairRow& row : crosshair.rows) {
if (!row.valid) continue;
Color color = plot.trace(row.trace).color;
color.a = 1.0f;
lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y});
lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm});
flush_lines(pixel_mapping(), color, style_.device(1.0f));
}
}
void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair;
if (crosshair.empty()) return;
// The readout: the same backplate a legend has (the shared `draw_readout_panel()`), with its
// border in the crosshair's own colour -- which is what makes the panel read as part of the
// instrument that placed it rather than as a second legend.
draw_readout_panel(crosshair.panel, style_.crosshair);
for (const CrosshairRow& row : crosshair.rows) {
if (!row.listed) continue;
draw_swatch(plot, row.trace, row.swatch);
stage_text({row.label_box.x, row.label_box.y}, row.label.c_str());
// The value is a number, or `kCrosshairNoValue` for a trace with no sample at this x (the plot
// put it there): either way there is a run to stage, so a row is never a blank cell.
stage_text({row.value_box.x, row.value_box.y}, row.value.c_str());
}
}
// -------------------------------------------------------------------- dump
bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
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<u32>(gl_viewport_.w);
const u32 height = static_cast<u32>(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::GLint>(gl_viewport_.x), static_cast<gl::GLint>(gl_viewport_.y),
static_cast<gl::GLsizei>(width), static_cast<gl::GLsizei>(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<u8> 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