Text was point-sampled (`GL_NEAREST` plus `floor(v_uv * u_tex_size)`), which at a non-integer scale *deletes* texels instead of blending them. The library's own default -- a 12 px style over the 13 px embedded bake -- dropped one of every 8 columns and one of every 17 rows of every glyph, so whole strokes went missing and which ones depended on where the unsnapped pen fell. `kTextFS` now does a 2x2 bilinear fetch clamped to the instance's own cell: the clamp keeps the no-bleed guarantee the sampler mode protects (the atlas is a tight strip of cells with no padding), and at scale 1 with a whole pen the weights are exactly 0, so it stays as crisp as the point sample. Grid and axis lines were staged in data space and mapped in the vertex shader, so a 1 px line's centre landed on an arbitrary fraction -- x = 100.0 lights two columns at half coverage, x = 100.5 lights one crisply, and a frame mixed both. `u_snap` rounds an axis-aligned segment's ink *leading edge* onto a pixel boundary, which reduces to integer+0.5 for odd widths and to an integer for even ones with no case analysis. It is on for the chrome that frames a plot and off for anything drawn at a data position, since the rounding moves a line by up to half a pixel. Diagonals are untouched. `text_pixel`'s threshold drops to 160 because filtered glyphs legitimately peak at 0.844 coverage at 12/13; the 180 threshold found text only while the sampler was also deleting a column and a row of it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1551 lines
77 KiB
C++
1551 lines
77 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#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 <new>
|
|
#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.
|
|
/// One axis' pre-affine coordinate: the value itself on a linear axis, `log10`
|
|
/// of it on a log one -- 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.
|
|
f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; }
|
|
|
|
/// 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
|
|
|
|
Renderer::~Renderer() {
|
|
// The teardown contract (see renderer.hpp): the handles go while the context
|
|
// that owns their names is still current -- which it is, for a renderer
|
|
// destroyed before its context.
|
|
release_gl();
|
|
}
|
|
|
|
void Renderer::release_gl() noexcept {
|
|
ready_ = false;
|
|
// Assigning fresh objects releases every GL name through the handles'
|
|
// destructors: both programs, both vertex arrays and their instance buffers,
|
|
// and the atlas texture. Safe to call more than once; every assignment is a
|
|
// no-op while the renderer is uninitialised.
|
|
atlas_ = render::TextAtlas{};
|
|
line_program_ = render::LineProgram{};
|
|
text_program_ = render::TextProgram{};
|
|
line_vao_ = render::LineVertexArray{};
|
|
text_vao_ = render::TextVertexArray{};
|
|
lines_.clear();
|
|
glyphs_under_.clear();
|
|
glyphs_over_.clear();
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 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_under_.clear();
|
|
glyphs_over_.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. What the host had is saved first (R6) so `end_frame()` can hand
|
|
// the function and the enable bit back instead of leaving the frame's choice
|
|
// behind for the host's own draws to inherit.
|
|
gl::glGetIntegerv(gl::GL_BLEND_SRC_RGB, &blend_src_rgb_);
|
|
gl::glGetIntegerv(gl::GL_BLEND_SRC_ALPHA, &blend_src_a_);
|
|
gl::glGetIntegerv(gl::GL_BLEND_DST_RGB, &blend_dst_rgb_);
|
|
gl::glGetIntegerv(gl::GL_BLEND_DST_ALPHA, &blend_dst_a_);
|
|
blend_was_on_ = gl::glIsEnabled(gl::GL_BLEND) != gl::GL_FALSE;
|
|
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). Both
|
|
// batches draw from the same atlas at the same size, so both declare it.
|
|
glyphs_under_.set_cell(font_backend_.cell_uv());
|
|
glyphs_over_.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 over-text flush, after every line batch and after the under batch
|
|
// has already gone out inside draw_plot: readout text, tick labels and titles --
|
|
// what must sit over everything (see draw_plot).
|
|
flush_text(glyphs_over_);
|
|
}
|
|
|
|
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(glyphs_over_);
|
|
}
|
|
|
|
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 under text batch goes out here, so the readout's panel below it can
|
|
// occlude what it covers: legend names, cursor labels, measure labels and
|
|
// annotation captions are all chrome the panel is allowed to sit on.
|
|
flush_text(glyphs_under_);
|
|
// 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 over flush with the tick labels and titles.
|
|
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);
|
|
// The blend state is the host's, not the frame's: what `begin_frame()` saved
|
|
// comes back here, function and enable bit both (R6).
|
|
gl::glBlendFuncSeparate(static_cast<gl::GLenum>(blend_src_rgb_),
|
|
static_cast<gl::GLenum>(blend_dst_rgb_),
|
|
static_cast<gl::GLenum>(blend_src_a_),
|
|
static_cast<gl::GLenum>(blend_dst_a_));
|
|
if (!blend_was_on_) {
|
|
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 {
|
|
// Ten floats per instance is the text pipeline's layout (see TextInstance):
|
|
// the cell's corner, its atlas rect, the basis that rotates it and the tint.
|
|
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 10u));
|
|
}
|
|
|
|
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash,
|
|
bool snap) 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);
|
|
// Likewise set on every flush: the grid's snap must not follow the trace drawn
|
|
// after it onto the pixel grid.
|
|
line_program_.set_snap(snap);
|
|
line_program_.set_color(color);
|
|
// The AA fringe, too: the style's logical knob in device pixels, clamped so
|
|
// a 0 style still keeps a quarter pixel of ramp. 1.0 -- the default -- is
|
|
// the fringe the shaders used to hardcode, so the default frame is
|
|
// unchanged.
|
|
line_program_.set_aa(std::max(0.25f, style_.device(style_.line_aa_px)));
|
|
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(render::TextBatch& batch) noexcept {
|
|
const u32 glyphs = batch.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(batch.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);
|
|
// The tint rides on the instance (`a_colour`), so one draw call carries runs
|
|
// of several colours -- there is no colour uniform to set.
|
|
atlas_.bind();
|
|
text_vao_.bind();
|
|
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
|
|
static_cast<gl::GLsizei>(text_vao_.instance_count()));
|
|
++stats_.draw_calls;
|
|
}
|
|
batch.clear();
|
|
}
|
|
|
|
// ----------------------------------------------------------- coordinates
|
|
|
|
Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y,
|
|
f64 origin_x) 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.
|
|
//
|
|
// The staged x is f32(to_space(x) - to_space(origin_x)) (B1), so the origin is
|
|
// folded back into the offset here -- in f64, once, before the single
|
|
// narrowing -- and the pair reproduces `to_screen_x` exactly however large the
|
|
// absolute x is. y is staged absolute (in pre-affine space), so its offset is
|
|
// the transform's own.
|
|
const f64 origin_s = space(origin_x, tx.log_x);
|
|
return Mapping{Vec2{static_cast<f32>(tx.sx), static_cast<f32>(ty.sy)},
|
|
Vec2{static_cast<f32>(tx.sx * origin_s + 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 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x,
|
|
bool log_y) {
|
|
// Staged relative to `origin_x` in the pre-affine space (B1): the mapping folds
|
|
// the origin back in, in f64, so this f32 is O(visible pixels).
|
|
lines_.add_segment(Vec2{static_cast<f32>(space(x0, log_x) - space(origin_x, log_x)),
|
|
static_cast<f32>(space(y0, log_y))},
|
|
Vec2{static_cast<f32>(space(x1, log_x) - space(origin_x, log_x)),
|
|
static_cast<f32>(space(y1, log_y))});
|
|
}
|
|
|
|
void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x,
|
|
bool log_y) {
|
|
if (!log_x && !log_y) {
|
|
// The common case: reconstruct the absolute data x in f64 (`x0 + rel` --
|
|
// the gather contract's own reconstruction), stage it relative to the
|
|
// draw's origin, and the f32 is O(visible pixels), not O(epoch).
|
|
staged_.clear();
|
|
staged_.reserve(usize(n) * 2u);
|
|
const f64 origin_s = space(origin_x, log_x);
|
|
for (u32 i = 0; i < n; ++i) {
|
|
staged_.push_back(static_cast<f32>(xy[2u * i] + series_x0 - origin_s));
|
|
staged_.push_back(xy[2u * i + 1u]);
|
|
}
|
|
lines_.add_polyline_xy(staged_.data(), n);
|
|
return;
|
|
}
|
|
for (u32 i = 0; i + 1u < n; ++i) {
|
|
stage_segment(origin_x, xy[2u * i] + series_x0, xy[2u * i + 1u], xy[2u * i + 2u] + series_x0,
|
|
xy[2u * i + 3u], log_x, log_y);
|
|
}
|
|
}
|
|
|
|
Vec2 Renderer::stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color) {
|
|
// Text is staged in viewport pixels: the text program has no offset uniform.
|
|
return batch.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), *font_, {1.0f, 0.0f},
|
|
color);
|
|
}
|
|
|
|
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(render::TextBatch& batch, Vec2 pos_px, const char* utf8,
|
|
f32 budget_px, Vec2 axis, Color color) {
|
|
const f32 scale = font_backend_.scale();
|
|
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, *font_, scale, axis,
|
|
[this, &batch, scale, axis, color](Vec2 pen, const render::Glyph& glyph) {
|
|
batch.add_glyph(pen, glyph, scale, axis, color);
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------- grid
|
|
|
|
void Renderer::draw_grid(const Plot& plot) {
|
|
const Grid& grid = plot.grid();
|
|
// The grid is built from the primary axis pair, so it is mapped with their
|
|
// transforms even when a trace uses another pair. Its lines are staged
|
|
// relative to the x range's own minimum -- the natural origin for a whole-axis
|
|
// draw -- and the mapping folds it back in (B1).
|
|
const Range x_range = plot.x_range(0);
|
|
const Mapping map = mapping(plot, 0, 0, x_range.min);
|
|
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(x_range.min, line.x0, line.y0, line.x1, line.y1, log_x, log_y);
|
|
}
|
|
// Snapped: a grid line is chrome at a round *data* value, and a hairline that
|
|
// straddles two columns reads as a grey smear and shimmers as the view pans.
|
|
flush_lines(map, color, width, Vec2{}, true);
|
|
};
|
|
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. Staged relative to each range's own
|
|
// minimum and folded back in by the mapping (B1).
|
|
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, axis.range.min, y, axis.range.max, y, axis.log, log_y0);
|
|
// Snapped, like the grid: an axis line is the frame of the plot, and it is the
|
|
// one line a reader compares every other against, so a smeared one is the worst
|
|
// place for the half-coverage pair.
|
|
flush_lines(mapping(plot, i, 0, axis.range.min), style_.axis_line,
|
|
style_.device(style_.line_width_px), Vec2{}, true);
|
|
}
|
|
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, x, axis.range.min, x, axis.range.max, log_x0, axis.log);
|
|
flush_lines(mapping(plot, 0, i, x), style_.axis_line,
|
|
style_.device(style_.line_width_px), Vec2{}, true);
|
|
}
|
|
}
|
|
|
|
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(glyphs_over_,
|
|
{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(), style_.axis_text);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(glyphs_over_,
|
|
{layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top},
|
|
tick.label.c_str(), style_.axis_text);
|
|
}
|
|
}
|
|
}
|
|
|
|
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(glyphs_over_, {strip.x + 0.5f * (strip.w - width), y},
|
|
axis.label.c_str(), budget, kTextRight, style_.axis_text);
|
|
++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(glyphs_over_, {x, strip.bottom() - 0.5f * (strip.h - length)},
|
|
axis.label.c_str(), budget, kTextUp, style_.axis_text);
|
|
++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_, trace.mode);
|
|
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;
|
|
}
|
|
|
|
// Gathered x is relative to `series.x0()` (the gather contract); the staging
|
|
// reconstructs the absolute data x in f64 and emits `to_space(x_abs) -
|
|
// to_space(origin)` -- the visible x range's minimum is the natural origin
|
|
// for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The
|
|
// mapping folds the origin back into the offset uniform, in f64.
|
|
const bool log_x = plot.x_axis(trace.axis_x).log;
|
|
const bool log_y = plot.y_axis(trace.axis_y).log;
|
|
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
|
|
const u32 points = static_cast<u32>(gather_.size() / 2u);
|
|
|
|
// A trace's dash rides on the same per-draw uniform the crosshair uses, in
|
|
// device pixels; `dash_off <= 0` is the solid default.
|
|
const Vec2 dash = trace.dash_off > 0.0f
|
|
? Vec2{style_.device(trace.dash_on), style_.device(trace.dash_off)}
|
|
: Vec2{};
|
|
|
|
// 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. `width == 0` is scatter: the
|
|
// markers below are the whole trace.
|
|
if (trace.width > 0.0f) {
|
|
stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y);
|
|
flush_lines(map, trace.color, style_.device(trace.width), dash);
|
|
}
|
|
|
|
// Markers, staged as line segments through this same batch in *pixel* space
|
|
// (so their size is device pixels whatever the data scale is). The stride is
|
|
// the trace's own `marker_every` or -- when it is 0, the auto mode -- about
|
|
// one marker per pixel column: ceil(samples per pixel), so markers only
|
|
// appear where the samples are sparse enough to need them.
|
|
if (trace.marker != Marker::None) {
|
|
const u32 stride =
|
|
trace.marker_every != 0
|
|
? trace.marker_every
|
|
: static_cast<u32>(std::ceil(samples_per_pixel > 1.0 ? samples_per_pixel : 1.0));
|
|
const f32 half = style_.device(trace.marker_size_px);
|
|
const f64 origin_s = space(x_range.min, log_x);
|
|
u32 staged = 0;
|
|
for (u32 p = 0; p < points; p += stride) {
|
|
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + series.x0();
|
|
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
|
|
const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y));
|
|
const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y};
|
|
stage_marker(trace.marker, centre, half);
|
|
++staged;
|
|
}
|
|
if (staged != 0) {
|
|
flush_lines(pixel_mapping(), trace.color, style_.device(1.0f));
|
|
stats_.staged_markers += staged;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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, x_range.min, y_drawn, x_range.max, y_drawn, log_x,
|
|
plot.y_axis(cursor.axis_y).log);
|
|
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
|
|
on ? style_.cursor : cursor.color,
|
|
style_.device(on ? style_.handle_width_px : style_.line_width_px));
|
|
} else {
|
|
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
|
|
stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
|
|
plot.x_axis(cursor.axis_x).log, log_y);
|
|
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
|
|
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(glyphs_under_, pen, value, style_.axis_text);
|
|
return;
|
|
}
|
|
stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text);
|
|
stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y},
|
|
value, style_.axis_text);
|
|
return;
|
|
}
|
|
if (!cursor.label.empty()) {
|
|
stage_text(glyphs_under_, {at + gap, layout.axes.y + style_.device(2.0f)},
|
|
cursor.label.c_str(), style_.axis_text);
|
|
}
|
|
}
|
|
|
|
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(glyphs_under_, {region.label.x, region.label.y}, config.label.c_str(),
|
|
style_.axis_text);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 relative to the axis' visible
|
|
// minimum and folded back by the mapping (B1), so the band follows a pan or a
|
|
// zoom through the uniform alone.
|
|
const f64 origin = plot.x_range(a.axis_x).min;
|
|
for (f32 px = x0; px < x1; px += 1.0f) {
|
|
const f64 x = transform.to_data_x(static_cast<f64>(px) + 0.5);
|
|
stage_segment(origin, 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, origin), 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(glyphs_under_, {left, axes.y + line_h + style_.device(2.0f)}, readout,
|
|
style_.axis_text);
|
|
}
|
|
}
|
|
|
|
void Renderer::draw_annotations(const Plot& plot) {
|
|
const f32 line_h = font_backend_.height();
|
|
// The clip every other overlay is drawn under, read here because the caption is staged
|
|
// into the under-text batch -- flushed after `clear_clip()` -- so the scissor that
|
|
// keeps the rest of this pass inside the axes does not reach it.
|
|
const Rect& axes = plot.layout().axes;
|
|
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));
|
|
// A caption whose anchor is outside the axes is skipped with its cross, which the clip
|
|
// has already removed: a caption with no cross is a label for a point the frame does not
|
|
// show, and -- because the under-batch text is drawn *after* the clip is released -- it
|
|
// would otherwise paint outside the axes it is measured in, into the neighbouring row of a
|
|
// `Figure` whose rows tile the figure. The caption box is also clamped inside `axes`
|
|
// (R6), and skipped entirely when it cannot fit the rect at all -- an annotation pinned
|
|
// inside a sliver of axes keeps its cross but not a caption that would overhang it.
|
|
if (!annotation.text.empty() && axes.contains(px, py)) {
|
|
const f32 width = font_backend_.measure(annotation.text.c_str());
|
|
const f32 pad = style_.device(5.0f);
|
|
if (width <= axes.w && line_h <= axes.h) {
|
|
const f32 x = std::clamp(px + pad, axes.x, axes.right() - width);
|
|
const f32 y = std::clamp(py - 0.5f * line_h, axes.y, axes.bottom() - line_h);
|
|
stage_text(glyphs_under_, {x, y}, annotation.text.c_str(), annotation.color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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(glyphs_under_, {row.name.x, row.name.y}, kLegendEllipsis, style_.axis_text);
|
|
continue;
|
|
}
|
|
draw_swatch(plot, row.trace, row.swatch);
|
|
stage_text(glyphs_under_, {row.name.x, row.name.y}, plot.trace(row.trace).name.c_str(),
|
|
style_.axis_text);
|
|
}
|
|
}
|
|
|
|
void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
|
|
// Marker shapes as line segments, in viewport pixels: the trace's flush maps
|
|
// them with `pixel_mapping()`, so the size is device pixels whatever the data
|
|
// scale is. Cross: the two diagonals. Circle: an 8-gon. Square: the four sides.
|
|
switch (marker) {
|
|
case plot::Marker::Cross:
|
|
lines_.add_segment({centre_px.x - size_px, centre_px.y - size_px},
|
|
{centre_px.x + size_px, centre_px.y + size_px});
|
|
lines_.add_segment({centre_px.x - size_px, centre_px.y + size_px},
|
|
{centre_px.x + size_px, centre_px.y - size_px});
|
|
break;
|
|
case plot::Marker::Circle: {
|
|
// An octagon inscribed in the size box: 8 vertices, 8 segments.
|
|
Vec2 prev{};
|
|
for (u32 k = 0; k <= 8u; ++k) {
|
|
const f32 angle = static_cast<f32>(k) * (3.14159265f / 4.0f);
|
|
const Vec2 v{centre_px.x + size_px * std::cos(angle),
|
|
centre_px.y + size_px * std::sin(angle)};
|
|
if (k != 0) {
|
|
lines_.add_segment(prev, v);
|
|
}
|
|
prev = v;
|
|
}
|
|
break;
|
|
}
|
|
case plot::Marker::Square:
|
|
lines_.add_segment({centre_px.x - size_px, centre_px.y - size_px},
|
|
{centre_px.x + size_px, centre_px.y - size_px});
|
|
lines_.add_segment({centre_px.x + size_px, centre_px.y - size_px},
|
|
{centre_px.x + size_px, centre_px.y + size_px});
|
|
lines_.add_segment({centre_px.x + size_px, centre_px.y + size_px},
|
|
{centre_px.x - size_px, centre_px.y + size_px});
|
|
lines_.add_segment({centre_px.x - size_px, centre_px.y + size_px},
|
|
{centre_px.x - size_px, centre_px.y - size_px});
|
|
break;
|
|
case plot::Marker::None:
|
|
break;
|
|
}
|
|
}
|
|
|
|
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.
|
|
const Trace& source = plot.trace(trace);
|
|
Color swatch = source.color;
|
|
swatch.a = 1.0f;
|
|
if (source.dash_off > 0.0f && box.w > 0.0f && box.h > 0.0f) {
|
|
// A dashed trace's swatch *samples the dash*: a solid fill would describe the
|
|
// one thing the trace is not. One dashed stroke across the box's midline, in
|
|
// the trace's own pattern (converted to device pixels, as the trace itself is).
|
|
const f32 mid = box.y + 0.5f * box.h;
|
|
lines_.add_segment({box.x, mid}, {box.right(), mid});
|
|
flush_lines(pixel_mapping(), swatch, std::min(box.h, style_.device(source.width)),
|
|
Vec2{style_.device(source.dash_on), style_.device(source.dash_off)});
|
|
return;
|
|
}
|
|
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, plot.x_range(0u).min);
|
|
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);
|
|
// Both lines share the horizontal's origin (the visible x minimum), so the
|
|
// staged values and the one mapping agree (B1): the vertical is staged as
|
|
// to_space(x) - to_space(min), not relative to itself.
|
|
stage_segment(plot.x_range(0u).min, 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, 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(glyphs_over_, {row.label_box.x, row.label_box.y}, row.label.c_str(),
|
|
style_.axis_text);
|
|
// 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. It is
|
|
// drawn in the row trace's own colour, resolved here from the plot -- the one tint the text
|
|
// pipeline carries per instance, so labels and values of one draw call can differ.
|
|
stage_text(glyphs_over_, {row.value_box.x, row.value_box.y}, row.value.c_str(),
|
|
plot.trace(row.trace).color);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------- dump
|
|
|
|
bool Renderer::dump_frame_ppm(const char* path, Status* st) const noexcept {
|
|
if (path == nullptr || path[0] == '\0') {
|
|
return fail(st, ErrorCode::InvalidArgument, "PPM path is empty");
|
|
}
|
|
if (!ready_) {
|
|
return fail(st, ErrorCode::Unsupported, "renderer was never initialised");
|
|
}
|
|
const u32 width = static_cast<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");
|
|
}
|
|
|
|
// The two buffers are sized -- and can fail to size -- before anything is on disk, so an
|
|
// allocation failure is reported through the `Status` rather than throwing `std::bad_alloc`
|
|
// across the public boundary (which is `noexcept`) and leaving a half-written file behind.
|
|
// The readback hole is 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. The row
|
|
// is the PPM's own 3-byte stride, one line at a time.
|
|
std::vector<u8> row;
|
|
try {
|
|
readback_.assign(usize(width) * height * 4u, 0u);
|
|
row.assign(usize(width) * 3u, 0u);
|
|
} catch (const std::bad_alloc&) {
|
|
return fail(st, ErrorCode::OutOfMemory, "out of memory sizing the PPM readback");
|
|
}
|
|
|
|
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);
|
|
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
|