2304 lines
119 KiB
C++
2304 lines
119 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 <cstring>
|
|
#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; }
|
|
|
|
/// 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: all three programs, the vertex arrays and their instance
|
|
// buffers, the glyph atlas texture and the density's own 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{};
|
|
density_program_ = render::DensityProgram{};
|
|
line_vao_ = render::LineVertexArray{};
|
|
text_vao_ = render::TextVertexArray{};
|
|
density_vao_ = gl::VertexArray{};
|
|
density_texture_ = gl::Texture{};
|
|
density_pixels_.clear();
|
|
density_w_ = 0;
|
|
density_h_ = 0;
|
|
density_uploaded_grid_ = nullptr;
|
|
density_uploaded_revision_ = 0;
|
|
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) || !density_program_.create(st)) {
|
|
return false;
|
|
}
|
|
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode) || !density_vao_.create(st)) {
|
|
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.plot_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);
|
|
// The holes over the grid and under the data: a band is chrome of the *background*, not an
|
|
// overlay, so everything a frame draws inside the axes is drawn on top of it.
|
|
draw_invalid_spans(layout);
|
|
// ... the density included: it is trace ink and not chrome, so it goes over the bands exactly
|
|
// where the traces did -- and, while it is on, *instead* of them (`draw_traces()` returns) -- and
|
|
// under them for the same reason they are under the data.
|
|
draw_density(plot, layout);
|
|
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);
|
|
draw_x_note(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);
|
|
// The selection frame last of all: it is the gesture still in progress, and nothing inside the axes
|
|
// should be able to sit on top of the area being chosen.
|
|
draw_selection_frame(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 {
|
|
flush_lines(lines_, map, color, width, dash, snap);
|
|
}
|
|
|
|
void Renderer::flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
|
|
Vec2 dash, bool snap) noexcept {
|
|
const u32 segments = batch.segment_count();
|
|
if (segments != 0 && ready_) {
|
|
upload_vertices(batch.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;
|
|
}
|
|
batch.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 origin_x, bool log_x, bool log_y) {
|
|
if (!log_x && !log_y) {
|
|
// The common case costs nothing at all: the gather emitted x relative to this very origin
|
|
// (`draw_traces` asks it for `x_range.min`, which is the draw's origin too) and a linear y
|
|
// needs no mapping, so the gathered buffer *is* the staged one.
|
|
lines_.add_polyline_xy(xy, n);
|
|
return;
|
|
}
|
|
for (u32 i = 0; i + 1u < n; ++i) {
|
|
stage_segment(origin_x, xy[2u * i] + origin_x, xy[2u * i + 1u], xy[2u * i + 2u] + origin_x,
|
|
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, Vec2 dash) {
|
|
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, dash, true);
|
|
};
|
|
draw_class(true, style_.grid_major_visible, style_.grid_major,
|
|
style_.device(style_.grid_major_width), Vec2{});
|
|
// The minor grid's dash rides the same per-draw uniform the crosshair and a dashed trace use,
|
|
// and `off == 0` is the solid default. Each line starts its own pattern (the dash distance is
|
|
// per-segment), and every segment here is built from the range's minimum, so the dashes of the
|
|
// vertical lines line up with each other -- the grid reads as one texture rather than as a
|
|
// jumble of unrelated dashes.
|
|
const Vec2 minor_dash = style_.grid_minor_dash_off > 0.0f
|
|
? Vec2{style_.device(style_.grid_minor_dash_on),
|
|
style_.device(style_.grid_minor_dash_off)}
|
|
: Vec2{};
|
|
draw_class(false, style_.grid_minor_visible, style_.grid_minor,
|
|
style_.device(style_.grid_minor_width), minor_dash);
|
|
}
|
|
|
|
// ------------------------------------------------------------- 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
|
|
|
|
u32 Renderer::stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
|
f64 x_origin, u32 points) {
|
|
const Transform xt = plot.x_transform(trace.axis_x);
|
|
const Transform yt = plot.y_transform(trace.axis_y);
|
|
const f32 left = layout.axes.x;
|
|
// One column per whole pixel of the axes, plus the partial one at the right-hand
|
|
// edge: `axes.w` is fractional, and the samples that land in that last fraction
|
|
// are inside the frame.
|
|
const u32 count = static_cast<u32>(layout.axes.w) + 1u;
|
|
constexpr f32 kInf = std::numeric_limits<f32>::infinity();
|
|
columns_.resize(2u * static_cast<usize>(count));
|
|
for (u32 c = 0; c < count; ++c) {
|
|
columns_[2u * c] = kInf; // the lowest screen y seen in this column
|
|
columns_[2u * c + 1u] = -kInf; // and the highest; `lo <= hi` is "present"
|
|
}
|
|
|
|
for (u32 k = 0; k < points; ++k) {
|
|
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
|
|
const f64 sx = xt.to_screen_x(x_abs);
|
|
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
|
|
// A NaN sample, and a non-positive one on a log axis, have no position: both
|
|
// arrive here as a non-finite screen coordinate.
|
|
if (!std::isfinite(sx) || !std::isfinite(sy)) continue;
|
|
const f64 column = std::floor(sx - static_cast<f64>(left));
|
|
// Clamped, not dropped. The gathered window carries one sample of slack on each
|
|
// side, and that sample's ink belongs to the edge column -- it is what the
|
|
// polyline drew there, under the same clip. Nothing further out can arrive: the
|
|
// window is the visible range's.
|
|
const u32 c = !(column > 0.0) ? 0u
|
|
: column >= static_cast<f64>(count - 1u) ? count - 1u
|
|
: static_cast<u32>(column);
|
|
const f32 y = static_cast<f32>(sy);
|
|
if (y < columns_[2u * c]) columns_[2u * c] = y;
|
|
if (y > columns_[2u * c + 1u]) columns_[2u * c + 1u] = y;
|
|
}
|
|
|
|
const f32 width = style_.device(trace.width);
|
|
u32 staged = 0;
|
|
f32 prev_lo = 0.0f;
|
|
f32 prev_hi = 0.0f;
|
|
bool joined = false; // the column before this one is present, so there is a join to make
|
|
for (u32 c = 0; c < count; ++c) {
|
|
f32 lo = columns_[2u * c];
|
|
f32 hi = columns_[2u * c + 1u];
|
|
if (!(lo <= hi)) {
|
|
// No sample landed here. A hole in the data stays a hole: bridging it would
|
|
// draw a vertical line over x the series says nothing about, where the
|
|
// polyline drew a diagonal across the gap at most one pixel wide.
|
|
joined = false;
|
|
continue;
|
|
}
|
|
// Connectivity. Two columns' extremes need not overlap -- a fast edge rises
|
|
// through a whole column -- and two disjoint vertical segments read as a broken
|
|
// trace, which is exactly what the polyline's connecting segment prevented. The
|
|
// later column is extended to meet the earlier one's nearest bound, so the join
|
|
// is made inside the column that moved and no ink appears left of it.
|
|
if (joined) {
|
|
if (hi < prev_lo) {
|
|
hi = prev_lo;
|
|
} else if (lo > prev_hi) {
|
|
lo = prev_hi;
|
|
}
|
|
}
|
|
prev_lo = lo;
|
|
prev_hi = hi;
|
|
joined = true;
|
|
// A span shorter than the stroke is drawn *as* the stroke: the fragment shader
|
|
// ramps on the distance to the segment's axis and does not cap its ends, so a
|
|
// vertical segment shorter than its own width rasterises a sliver of itself --
|
|
// and a column of a flat trace, where `lo == hi`, nothing at all.
|
|
if (hi - lo < width) {
|
|
const f32 mid = 0.5f * (lo + hi);
|
|
lo = mid - 0.5f * width;
|
|
hi = mid + 0.5f * width;
|
|
}
|
|
// The column's centre, in framebuffer pixels. Every segment therefore shares one
|
|
// sub-pixel phase, which is the whole point (see `draw_traces`), and the flush's
|
|
// snap puts that one phase on the grid.
|
|
const f32 x = left + static_cast<f32>(c) + 0.5f;
|
|
lines_.add_segment({x, lo}, {x, hi});
|
|
++staged;
|
|
}
|
|
return staged;
|
|
}
|
|
|
|
u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
|
f64 x_origin, u32 points) {
|
|
if (points < 2u) return 0u;
|
|
// At least two steps, or there is nothing to grade and the faintest step is also
|
|
// the brightest -- a one-step grading is the flat band drawn dimmer, which is not
|
|
// what any host asking for this wants.
|
|
const u32 steps = std::max(2u, style_.intensity_steps);
|
|
// Exactly `steps`, never merely at least: the flush loop reads `steps_.size()` as the
|
|
// step count and divides by it, so a batch left over from a larger setting would be
|
|
// flushed at the wrong alpha -- or, being uncleared, at all.
|
|
steps_.resize(steps);
|
|
for (u32 t = 0; t < steps; ++t) steps_[t].clear();
|
|
|
|
const Transform xt = plot.x_transform(trace.axis_x);
|
|
const Transform yt = plot.y_transform(trace.axis_y);
|
|
const f32 left = layout.axes.x;
|
|
const f32 top = layout.axes.y;
|
|
const u32 columns = static_cast<u32>(layout.axes.w) + 1u;
|
|
// One accumulator row per whole pixel of the axes, plus the partial one at the
|
|
// bottom edge, plus one past the end for a span's closing delta.
|
|
const u32 rows = static_cast<u32>(layout.axes.h) + 1u;
|
|
column_rows_.assign(static_cast<usize>(rows) + 1u, 0.0f);
|
|
|
|
const f32 width = style_.device(trace.width);
|
|
const f32 gamma = style_.intensity_gamma > 0.0f ? style_.intensity_gamma : 1.0f;
|
|
const f32 bottom = top + layout.axes.h;
|
|
|
|
u32 staged = 0;
|
|
u32 column = 0; // the column being accumulated
|
|
bool open = false; // it has at least one segment
|
|
u32 prev_column = 0; // the last column that emitted anything, for the join below
|
|
f32 prev_lo = 0.0f;
|
|
f32 prev_hi = 0.0f;
|
|
bool joined = false;
|
|
// The smallest deposit any segment made into the column being accumulated, which is
|
|
// what bounds the "lit at all" test below. Reset per column by `emit_column()`.
|
|
f32 w_min = std::numeric_limits<f32>::infinity();
|
|
|
|
// Reads one column's accumulator out as graded runs and clears it, so the next column
|
|
// starts empty. Two walks of it, which is nothing -- it is one column wide and in L1 --
|
|
// and the first is what makes the second's grading *relative to this column*:
|
|
//
|
|
// the brightest row of a column is always the top tier. That is deliberate and it is
|
|
// the difference between grading and dimming. The alternative, one scale for the whole
|
|
// frame, needs a pass over every column before any of them can be drawn (an
|
|
// `axes.w * axes.h` image, 33 MB at 4K) and makes a column the signal flew through
|
|
// *uniformly* faint -- a trace that fades out where it moves fastest, which is where a
|
|
// reader is most often looking. Per column, the dwell structure *inside* each one is
|
|
// what the tiers spend their range on: the rails of a sine are bright and its fast
|
|
// middle is faint, in every column.
|
|
//
|
|
// The runs are maximal stretches of equal tier, so this is O(rows) segments per column
|
|
// at worst and a handful in practice: a column of a clean waveform is a bright ridge
|
|
// and a faint tail.
|
|
const auto emit_column = [&]() {
|
|
const f32 x = left + static_cast<f32>(column) + 0.5f;
|
|
f32 peak = 0.0f;
|
|
{
|
|
f32 cover = 0.0f;
|
|
for (u32 r = 0; r < rows; ++r) {
|
|
cover += column_rows_[r];
|
|
if (cover > peak) peak = cover;
|
|
}
|
|
}
|
|
const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f;
|
|
// What counts as covered at all: **a row one segment covered is lit**. The deposits
|
|
// are fractions, so this cannot be an integer test, and it cannot be a share of the
|
|
// column's peak alone either -- that was this function's one real defect. A square
|
|
// wave's riser is a single segment spread over the whole axis, so it deposits
|
|
// `1/rows` per row, while a flat level in the same column is one deposit of 1.0 per
|
|
// sub-bucket piled on one row: at 105 samples a column the peak is ~50 and the share
|
|
// floor is ~0.5, eighty times the riser's own 0.006. Every row of the edge was
|
|
// unlit, and the connector could not bridge it either -- the two levels are both lit,
|
|
// so the column's band already spans the gap and neither gap test fires. Measured on
|
|
// a true square at 104.8 spp over a 312-row axis: 106 of 116 riser columns drawn as
|
|
// two disconnected marks with up to 311 dark rows between them, the other 10 kept by
|
|
// the phase that put the riser in a column of its own -- which is why it read as an
|
|
// edge *flickering* rather than as one missing. `intensity_floor` guards a lit row's
|
|
// alpha and was documented as preventing exactly this; it never got the chance.
|
|
//
|
|
// So the share term is kept only for what it is good for -- suppressing residue from
|
|
// the running-sum reconstruction -- and is capped by half the smallest deposit the
|
|
// column actually received, which no real segment's contribution can fall under.
|
|
const f32 lit_floor = std::min(0.01f * peak, 0.5f * w_min);
|
|
f32 cover = 0.0f;
|
|
bool in_run = false;
|
|
u32 run_tier = 0;
|
|
u32 run_start = 0;
|
|
f32 band_lo = 0.0f;
|
|
f32 band_hi = 0.0f;
|
|
bool any = false;
|
|
// One past the last row too: it closes a run that reaches the bottom edge.
|
|
for (u32 r = 0; r <= rows; ++r) {
|
|
cover += column_rows_[r];
|
|
column_rows_[r] = 0.0f;
|
|
const bool lit = r < rows && cover > lit_floor;
|
|
u32 tier = 0;
|
|
if (lit) {
|
|
const f32 share = std::min(1.0f, cover * inv);
|
|
const u32 t = static_cast<u32>(std::pow(share, gamma) * static_cast<f32>(steps));
|
|
tier = std::min(steps - 1u, t);
|
|
}
|
|
if (in_run && (!lit || tier != run_tier)) {
|
|
f32 lo = top + static_cast<f32>(run_start);
|
|
f32 hi = top + static_cast<f32>(r);
|
|
// A run shorter than the stroke is drawn *as* the stroke, for the reason
|
|
// `stage_trace_columns()` gives: the fragment shader ramps on the distance to
|
|
// the segment's axis and does not cap its ends.
|
|
if (hi - lo < width) {
|
|
const f32 mid = 0.5f * (lo + hi);
|
|
lo = mid - 0.5f * width;
|
|
hi = mid + 0.5f * width;
|
|
}
|
|
steps_[run_tier].add_segment({x, lo}, {x, hi});
|
|
++staged;
|
|
in_run = false;
|
|
}
|
|
if (lit && !in_run) {
|
|
run_start = r;
|
|
run_tier = tier;
|
|
in_run = true;
|
|
}
|
|
if (lit) {
|
|
if (!any) {
|
|
band_lo = top + static_cast<f32>(r);
|
|
any = true;
|
|
}
|
|
band_hi = top + static_cast<f32>(r) + 1.0f;
|
|
}
|
|
}
|
|
open = false;
|
|
w_min = std::numeric_limits<f32>::infinity();
|
|
if (!any) {
|
|
joined = false; // nothing visible here: a hole, like an uncovered column
|
|
return;
|
|
}
|
|
// Connectivity, the same rule as the flat path: two columns' bands need not
|
|
// overlap -- a fast edge rises through a whole column -- and two disjoint
|
|
// verticals read as a broken trace. The gap is bridged by a segment of its own at
|
|
// the **faintest** tier rather than by extending a run, because a connector is
|
|
// inferred geometry and not a place the signal was measured to dwell: grading it
|
|
// bright would claim the opposite of what it is.
|
|
if (joined && column == prev_column + 1u) {
|
|
if (band_hi < prev_lo) {
|
|
steps_[0].add_segment({x, band_hi}, {x, prev_lo});
|
|
++staged;
|
|
} else if (band_lo > prev_hi) {
|
|
steps_[0].add_segment({x, prev_hi}, {x, band_lo});
|
|
++staged;
|
|
}
|
|
}
|
|
prev_lo = band_lo;
|
|
prev_hi = band_hi;
|
|
prev_column = column;
|
|
joined = true;
|
|
};
|
|
|
|
f64 px = 0.0;
|
|
f64 py = 0.0;
|
|
bool have_prev = false;
|
|
for (u32 k = 0; k < points; ++k) {
|
|
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
|
|
const f64 sx = xt.to_screen_x(x_abs);
|
|
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
|
|
// A NaN sample, and a non-positive one on a log axis, have no position -- and it
|
|
// breaks the chain as well as skipping the point: a segment from the sample
|
|
// before it to the one after would cross x the series says nothing about.
|
|
if (!std::isfinite(sx) || !std::isfinite(sy)) {
|
|
have_prev = false;
|
|
continue;
|
|
}
|
|
if (have_prev) {
|
|
// The *segment*, not the point, is what carries dwell: it is a stretch of the
|
|
// waveform, and where it went slowly it covers few rows and where it went fast it
|
|
// covers many. This is where a height-based heuristic cannot reach -- a column's
|
|
// min and max cannot tell a ramp from a pulse, and a column's segments can (see
|
|
// `Style::intensity_grading`).
|
|
//
|
|
// It is charged to the column its earlier end falls in. The gather is asked at
|
|
// `intensity_sub_columns` buckets per column, so a segment is a fraction of a
|
|
// column wide and splitting one across a boundary would buy a fraction of one
|
|
// sub-bucket's dwell.
|
|
const f64 c = std::floor(px - static_cast<f64>(left));
|
|
// Clamped, not dropped, for the reason `stage_trace_columns()` gives: the
|
|
// gathered window carries one sample of slack on each side and that sample's
|
|
// ink belongs to the edge column.
|
|
const u32 ci = !(c > 0.0) ? 0u
|
|
: c >= static_cast<f64>(columns - 1u) ? columns - 1u
|
|
: static_cast<u32>(c);
|
|
if (open && ci != column) emit_column(); // x is increasing, so ci > column
|
|
if (!open) {
|
|
column = ci;
|
|
open = true;
|
|
}
|
|
const f64 lo = std::min(py, sy);
|
|
const f64 hi = std::max(py, sy);
|
|
// Wholly outside the axes is wholly clipped, and a clamp would move its ink to
|
|
// the edge and invent a ridge there. A segment that *crosses* the edge is
|
|
// clamped instead, which is the coverage the clip leaves -- so a column's shares
|
|
// are shares of what is visible in it, which is what is being graded.
|
|
if (hi >= static_cast<f64>(top) && lo <= static_cast<f64>(bottom)) {
|
|
const f64 ra = std::floor(lo - static_cast<f64>(top));
|
|
const f64 rb = std::floor(hi - static_cast<f64>(top));
|
|
const u32 r0 = !(ra > 0.0) ? 0u
|
|
: ra >= static_cast<f64>(rows - 1u) ? rows - 1u
|
|
: static_cast<u32>(ra);
|
|
const u32 r1 = !(rb > 0.0) ? 0u
|
|
: rb >= static_cast<f64>(rows - 1u) ? rows - 1u
|
|
: static_cast<u32>(rb);
|
|
// A segment deposits one unit of dwell, **spread over the rows it covers** --
|
|
// which is the whole measurement, and counting the rows instead was wrong in a
|
|
// way worth recording: a sub-bucket holds about as many samples as any other, so
|
|
// a segment crossing a hundred rows is those samples *passing through* each of
|
|
// them, while one covering two rows is the same samples *sitting* in them. Giving
|
|
// each crossed row a full unit makes a sine's fast middle as bright as its rails,
|
|
// which is the pattern grading exists to show.
|
|
//
|
|
// Two writes whatever the height: the running sum over the column is then the
|
|
// dwell at each row.
|
|
const f32 w = 1.0f / static_cast<f32>(r1 - r0 + 1u);
|
|
if (w < w_min) w_min = w;
|
|
column_rows_[r0] += w;
|
|
column_rows_[r1 + 1u] -= w;
|
|
}
|
|
}
|
|
px = sx;
|
|
py = sy;
|
|
have_prev = true;
|
|
}
|
|
if (open) emit_column();
|
|
return staged;
|
|
}
|
|
|
|
u32 Renderer::stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
|
|
f64 x_origin, bool log_x, bool log_y) {
|
|
if (points < 2u) return 0u;
|
|
// At least two, for the reason `stage_trace_density()` gives: a one-step grading is
|
|
// the whole trace drawn dimmer, which is not what a host asking for this wants.
|
|
const u32 steps = std::max(2u, style_.intensity_steps);
|
|
steps_.resize(steps);
|
|
for (u32 t = 0; t < steps; ++t) steps_[t].clear();
|
|
|
|
const Transform yt = plot.y_transform(trace.axis_y);
|
|
const f32 gamma = style_.intensity_gamma > 0.0f ? style_.intensity_gamma : 1.0f;
|
|
const f32 fsteps = static_cast<f32>(steps);
|
|
|
|
u32 staged = 0;
|
|
for (u32 i = 0; i + 1u < points; ++i) {
|
|
const f64 y0 = static_cast<f64>(xy[2u * i + 1u]);
|
|
const f64 y1 = static_cast<f64>(xy[2u * i + 3u]);
|
|
const f64 s0 = yt.to_screen_y(y0);
|
|
const f64 s1 = yt.to_screen_y(y1);
|
|
// A NaN sample, and a non-positive one on a log axis, have no position, and the
|
|
// *segment* is what is being staged -- so one unplaceable end drops it entirely
|
|
// rather than being clamped to somewhere the series says nothing about. The
|
|
// ungraded polyline reaches the same conclusion through `add_polyline_xy()`, which
|
|
// a non-finite vertex excludes from both of its segments.
|
|
if (!std::isfinite(s0) || !std::isfinite(s1)) continue;
|
|
|
|
// Dwell, the same quantity `stage_trace_density()` deposits and for the same
|
|
// reason: a segment is a stretch of the waveform, and the rows it spans are the
|
|
// rows the signal crossed *on its way* rather than sat in. `1 + span` so a flat
|
|
// segment -- span 0, the commonest case in a sparse window -- is 1 and not a
|
|
// division by zero, which also makes the measure dimensionless and absolute: no
|
|
// per-column peak exists to normalise against down here.
|
|
const f32 span = static_cast<f32>(std::fabs(s1 - s0));
|
|
const f32 dwell = 1.0f / (1.0f + span);
|
|
const u32 t = std::min(steps - 1u, static_cast<u32>(std::pow(dwell, gamma) * fsteps));
|
|
|
|
// Staged exactly as `stage_xy()` does, so the geometry is bit-for-bit the ungraded
|
|
// polyline's and only the batch differs. The linear case needs no mapping at all --
|
|
// the gather already emitted x relative to this origin.
|
|
if (!log_x && !log_y) {
|
|
steps_[t].add_segment(Vec2{xy[2u * i], xy[2u * i + 1u]},
|
|
Vec2{xy[2u * i + 2u], xy[2u * i + 3u]});
|
|
} else {
|
|
const f64 ox = space(x_origin, log_x);
|
|
steps_[t].add_segment(
|
|
Vec2{static_cast<f32>(space(static_cast<f64>(xy[2u * i]) + x_origin, log_x) - ox),
|
|
static_cast<f32>(space(y0, log_y))},
|
|
Vec2{static_cast<f32>(space(static_cast<f64>(xy[2u * i + 2u]) + x_origin, log_x) - ox),
|
|
static_cast<f32>(space(y1, log_y))});
|
|
}
|
|
++staged;
|
|
}
|
|
return staged;
|
|
}
|
|
|
|
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
|
// **No solid trace while the density is on.** The trace ink *is* the density image (see
|
|
// `Plot::set_density()` and `draw_density()`): every visible trace was deposited, the newest
|
|
// acquisition included, so drawing the polyline as well would put a solid, unfading stroke over
|
|
// its own faint image -- the "persistent image + last acquisition" a report described, where the
|
|
// newest acquisition never faded because it was never only the image. The traces still exist --
|
|
// the legend, the crosshair readout and the measurements read them -- they are simply not drawn
|
|
// as lines.
|
|
if (plot.density().enabled) return;
|
|
// 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;
|
|
|
|
// The visible x range onto sample indices, through the series' *own* x.
|
|
//
|
|
// Not the `(x - x0()) / dx()` grid: `dx()` is documented as a nominal spacing (the LOD
|
|
// level, a coarse snap) and for a series whose x is a streamed timestamp it is only the
|
|
// mean of the retained window. Walking that grid lands the window inside the visible range
|
|
// -- and the first and last *segments*, the ones that enter and leave the frame, are then
|
|
// not drawn at all, which is the empty margin at both edges of a zoomed-in view. A series
|
|
// with a uniform x answers the same question through the same call, so this is the one
|
|
// path. One sample of slack on each side keeps a partially visible end bucket whole, and
|
|
// the result is clamped to the series: a zoomed-out view never asks for more than exists,
|
|
// and a window that reaches past the data leaves the margin genuinely -- correctly -- empty.
|
|
const Range x_range = plot.x_range(trace.axis_x);
|
|
// `dx` is still read above for the samples-per-pixel estimate -- a density, where a nominal
|
|
// spacing is exactly right -- and never for the window itself.
|
|
u64 first = 0;
|
|
u64 last = n;
|
|
u64 i0 = 0;
|
|
u64 i1 = 0;
|
|
if (series.neighbours(x_range.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
|
|
if (series.neighbours(x_range.max, i0, i1)) last = std::min(n, i1 + 1);
|
|
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);
|
|
// Grading reads a column's *distribution*, which one envelope per column cannot
|
|
// carry (`Style::intensity_grading`), so the gather is asked for the level a
|
|
// fraction of a column implies. The *gate* is still the pixel grid's density: a
|
|
// window sparse enough to be drawn as a polyline is drawn as one, graded or not.
|
|
const bool grade = style_.intensity_grading && trace.mode == data::GatherMode::Envelope &&
|
|
style_.intensity_sub_columns > 1u && style_.intensity_steps > 1u;
|
|
const u32 flat_level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
|
|
? series.pyramid().level_for(n, samples_per_pixel)
|
|
: 0u;
|
|
// The finer level, floored at the pyramid's coarsest bucket level. Grading asks for a
|
|
// fraction of a column and `level_for` answers 0 -- raw samples -- as soon as that
|
|
// fraction falls under one bucket, so an unfloored ask turns every moderately dense
|
|
// frame into an O(visible) gather: measured at 1.3 MS/s over a 2 s window, 172 samples
|
|
// a pixel, where the flat path sits on the pyramid and grading dropped the whole figure
|
|
// to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 -> 6.4 ms a frame.
|
|
// The floor degrades the *look* instead: fewer sub-columns than asked for, down to
|
|
// whatever the coarsest buckets give, which at 64 samples a bucket is still two or three
|
|
// per column. Grading is a way of drawing a reduction and may not undo it.
|
|
const u32 level =
|
|
grade && flat_level > 0u
|
|
? std::max(1u, series.pyramid().level_for(
|
|
n, samples_per_pixel /
|
|
static_cast<f64>(style_.intensity_sub_columns)))
|
|
: flat_level;
|
|
|
|
gather_.clear();
|
|
// The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
|
|
// screen), so its ULP is a fixed 2^-24 of the axis at every zoom. `series.x0()` -- the origin
|
|
// before -- is the start of the *retained window*, so the quantisation was a fraction of data
|
|
// the view need not even hold, and a deep zoom drew a sample visibly off the x a readout
|
|
// quotes for it. See `Series::gather()`.
|
|
series.gather(level, first, last, x_range.min, gather_, trace.mode);
|
|
if (gather_.size() < 4u) continue; // fewer than two points is no segment
|
|
|
|
stats_.visible_samples += visible;
|
|
// Markers for a window sparse enough that its samples are the shape: one dot per raw sample,
|
|
// in the trace's own colour. Level 0 only -- above it the geometry is *buckets*, which are not
|
|
// points a reader can find. Drawn after the polyline they sit on, so a dot is never half
|
|
// covered by its own line, and inside the axes only (a marker is a place, and a place outside
|
|
// the frame is not one).
|
|
if (level == 0u && style_.marker_below_samples > 0.0f && visible > 0u &&
|
|
visible <= static_cast<u64>(style_.marker_below_samples)) {
|
|
const auto& xt = plot.x_transform(trace.axis_x);
|
|
const auto& yt = plot.y_transform(trace.axis_y);
|
|
const f32 half = 0.5f * style_.device(style_.marker_px);
|
|
for (u32 k = 0; k + 1 < gather_.size(); k += 2) {
|
|
const f64 x_abs = x_range.min + static_cast<f64>(gather_[k]);
|
|
const f64 y_abs = static_cast<f64>(gather_[k + 1]);
|
|
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
|
|
const f32 sx = static_cast<f32>(xt.to_screen_x(x_abs));
|
|
const f32 sy = static_cast<f32>(yt.to_screen_y(y_abs));
|
|
if (!(sx >= layout.axes.x && sx <= layout.axes.right() && sy >= layout.axes.y &&
|
|
sy <= layout.axes.bottom())) {
|
|
continue;
|
|
}
|
|
fill_pixels(Rect{sx - half, sy - half, 2.0f * half, 2.0f * half}, trace.color);
|
|
}
|
|
}
|
|
if (level == 0u) {
|
|
++stats_.raw_traces;
|
|
} else {
|
|
++stats_.lod_traces;
|
|
}
|
|
|
|
// Gathered x is relative to `x_range.min` (the gather contract, with the origin this
|
|
// function asked for); the staging reconstructs the absolute data x in f64 and emits
|
|
// `to_space(x_abs) - to_space(origin)` about that same origin, 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);
|
|
// Keep the longest **increasing suffix** of the gathered points, and drop what precedes it.
|
|
//
|
|
// A ring-backed series may answer a *clamped* stamp for the oldest slots of its window -- the ones
|
|
// the producer has already overwritten. The clamp is deliberate (the alternative was a sentinel
|
|
// that collapsed the whole index mapping), but it makes the stamps *non-increasing at the front*:
|
|
// the first few points carry the newest stamp, and everything after them is older. A polyline
|
|
// through that draws a segment from the newest sample back to the oldest -- a line across the
|
|
// whole panel, which is what a saturated ring shows as a flicker.
|
|
//
|
|
// A forward filter cannot express this: the bad points are at the *front* and everything behind
|
|
// them is legitimately smaller, so dropping "anything that does not advance" would delete the
|
|
// trace. Walking backwards keeps each point that is strictly older than the one kept after it,
|
|
// which stops at the first unreadable slot and leaves the real samples alone. The cost is one
|
|
// pass over the visible points, which the staging below is about to walk anyway.
|
|
{
|
|
const usize count = gather_.size() / 2u;
|
|
usize kept = 0;
|
|
f64 next = std::numeric_limits<f64>::infinity();
|
|
for (usize k = count; k > 0; --k) {
|
|
const usize p = k - 1u; // the point index; `i` above is the trace's
|
|
const f64 x = static_cast<f64>(gather_[2u * p]) + x_range.min;
|
|
if (!(x < next)) continue; // out of order with what follows: unreadable, and a NaN too
|
|
next = x;
|
|
gather_[2u * (count - 1u - kept)] = gather_[2u * p];
|
|
gather_[2u * (count - 1u - kept) + 1u] = gather_[2u * p + 1u];
|
|
++kept;
|
|
}
|
|
if (kept != count) {
|
|
// The kept points were written back-to-front into the tail; move them down to the front.
|
|
std::memmove(gather_.data(), gather_.data() + 2u * (count - kept), 2u * kept * sizeof(f32));
|
|
gather_.resize(2u * kept);
|
|
}
|
|
}
|
|
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) {
|
|
// A window denser than two gathered points per pixel column is drawn as *columns*
|
|
// rather than as a polyline: one exactly vertical, pixel-snapped segment per column,
|
|
// spanning what that column's own points reach.
|
|
//
|
|
// The polyline's geometry at this density is a stack of near-vertical hairlines at
|
|
// *fractional* x -- an envelope bucket spans (spp/2, spp] samples, so there are one to
|
|
// two buckets per column, each emitting its own min->max hairline plus a connector to
|
|
// the next, about three and a half per column. Each is composited with its own
|
|
// sub-pixel coverage, so a column's brightness is a non-linear function of those
|
|
// phases; the phases repeat at the beat between the bucket pitch and the pixel pitch,
|
|
// which is a **moire** across the trace, and panning sweeps the beat so the pattern
|
|
// crawls. It is strongest exactly where a scope is used hardest: a high sample rate, a
|
|
// high signal frequency, or a noisy signal, all of which make the ribbon thin enough
|
|
// for its per-column coverage to be visible as texture.
|
|
//
|
|
// Reducing to one segment per column removes the cause rather than the symptom: there
|
|
// is one phase on the whole trace (and the snap puts it on the grid), so there is
|
|
// nothing left to beat. It is also O(axes.w) geometry instead of O(buckets), and it
|
|
// loses nothing a reader could have seen -- a column cannot show more than the extremes
|
|
// of what it covers, which is what the band is.
|
|
//
|
|
// Two conditions, and each refuses a case this would damage.
|
|
// - `Envelope` only. `Nearest` and `Mean` are one-point-per-bucket modes chosen so the
|
|
// trace reads as *samples* or as a smoothed line; a min/max band would answer a
|
|
// different question from the one the host asked.
|
|
// - Two points per column. Below that the gather is sparser than the grid, and the
|
|
// polyline's segments are the shape: `Series::gather()` also falls back to raw samples
|
|
// when no complete bucket fits the range, and that window's trace is drawn by the two
|
|
// segments that enter and leave the frame (see
|
|
// `a_sparse_window_between_samples_still_inks_both_margins`). The real envelope path
|
|
// emits two to four points per column by construction, so the test holds exactly when
|
|
// it ran.
|
|
const bool columns = trace.mode == data::GatherMode::Envelope &&
|
|
static_cast<f64>(points) >= 2.0 * static_cast<f64>(layout.axes.w);
|
|
// `snap = true` on geometry that is not chrome, which is the one carve-out in that
|
|
// uniform's rule ("a line that reports a data position may not be moved off it"): a
|
|
// column *is* a pixel column, and its x is already a screen coordinate derived from
|
|
// one -- there is no data position to move it off. The y bounds, which are what the
|
|
// segment reports, are untouched by the snap. It holds for both staging paths.
|
|
const u32 staged =
|
|
!columns ? 0u
|
|
: grade ? stage_trace_density(plot, layout, trace, x_range.min, points)
|
|
: stage_trace_columns(plot, layout, trace, x_range.min, points);
|
|
if (staged != 0u && grade) {
|
|
// One draw call per alpha step, faintest first: the line pipeline's colour is a
|
|
// uniform, so a per-segment alpha is not expressible and a step is a batch. They
|
|
// are flushed in order so the bright ridge composites over the faint tail rather
|
|
// than under it -- the runs within a column do not overlap, but their AA fringes
|
|
// do, and that is the order a phosphor display puts them in.
|
|
for (u32 t = 0; t < steps_.size(); ++t) {
|
|
if (steps_[t].segment_count() == 0u) continue;
|
|
Color color = trace.color;
|
|
color.a *= style_.intensity_floor +
|
|
(1.0f - style_.intensity_floor) * static_cast<f32>(t) /
|
|
static_cast<f32>(steps_.size() - 1u);
|
|
flush_lines(steps_[t], pixel_mapping(), color, style_.device(trace.width), dash, true);
|
|
}
|
|
++stats_.column_traces;
|
|
++stats_.graded_traces;
|
|
} else if (staged != 0u) {
|
|
flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true);
|
|
++stats_.column_traces;
|
|
} else {
|
|
// The sparse half of the phosphor model. `grade` is not reused as the gate: it
|
|
// requires `Envelope`, because the *column reduction* turns a bucket's min/max
|
|
// into a band and that answers a different question from the one a `Nearest` or
|
|
// `Mean` host asked. A segment's slope is a property of the polyline itself, so
|
|
// no gather mode is excluded here.
|
|
const u32 sparse =
|
|
style_.intensity_grading && style_.intensity_steps > 1u
|
|
? stage_trace_slopes(plot, trace, gather_.data(), points, x_range.min, log_x,
|
|
log_y)
|
|
: 0u;
|
|
if (sparse != 0u) {
|
|
// Faintest first, and `snap = false` -- the same order as the graded columns
|
|
// for the same compositing reason, but these segments sit at *data* positions
|
|
// and the snap uniform's rule applies to them in full.
|
|
for (u32 t = 0; t < steps_.size(); ++t) {
|
|
if (steps_[t].segment_count() == 0u) continue;
|
|
Color color = trace.color;
|
|
color.a *= style_.intensity_floor +
|
|
(1.0f - style_.intensity_floor) * static_cast<f32>(t) /
|
|
static_cast<f32>(steps_.size() - 1u);
|
|
flush_lines(steps_[t], map, color, style_.device(trace.width), dash, false);
|
|
}
|
|
++stats_.slope_traces;
|
|
} else {
|
|
stage_xy(gather_.data(), points, 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]) + x_range.min;
|
|
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.
|
|
// The cursor's own width when it has one, else the style's; a hover or a grab can only make
|
|
// it thicker (the cue that the pointer has hold of something).
|
|
const f32 own = cursor.width > 0.0f ? style_.device(cursor.width)
|
|
: style_.device(style_.line_width_px);
|
|
const f32 width = on ? std::max(own, style_.device(style_.handle_width_px)) : own;
|
|
const f32 half = 0.5f * width;
|
|
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, width, cursor.dash);
|
|
} 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, width, cursor.dash);
|
|
}
|
|
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_invalid_spans(const PlotLayout& layout) {
|
|
for (const InvalidSpanLayout& band : layout.invalid_spans) {
|
|
fill_pixels(band.fill, band.repaired ? style_.invalid_repaired_fill : style_.invalid_fill);
|
|
}
|
|
}
|
|
|
|
void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
|
|
const DensityConfig& config = plot.density();
|
|
if (!config.enabled) {
|
|
// A plot that has stopped drawing a density is not holding a stale one: forget what the shared
|
|
// texture holds, so a re-enable -- which drops the grids and rebuilds them, possibly at the same
|
|
// addresses and with fresh revisions -- re-uploads rather than trusting a cache entry that
|
|
// happens to match.
|
|
density_uploaded_grid_ = nullptr;
|
|
return;
|
|
}
|
|
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) return;
|
|
if (!density_program_.valid()) return; // a pipeline that never linked draws nothing
|
|
// One quad per trace, in that trace's own colour: the plot holds one grid per trace (see
|
|
// `DensityConfig`), so where two traces agree the viewer sees both colours, and a saturated cell
|
|
// is the colour of the trace whose ink filled it and not a blend. A trace with no visible series
|
|
// was deposited into nothing (its grid is empty), so it is skipped.
|
|
for (u32 i = 0u; i < plot.trace_count(); ++i) {
|
|
const Trace& trace = plot.trace(i);
|
|
if (!trace.visible()) continue;
|
|
draw_density_grid(config, plot.density_grid(i), trace.color, layout);
|
|
}
|
|
}
|
|
|
|
void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
|
|
const PlotLayout& layout) {
|
|
const u32 columns = grid.columns();
|
|
const u32 rows = grid.rows();
|
|
if (columns == 0u || rows == 0u) return;
|
|
|
|
// The image as the shader reads it: one byte per cell, `full_scale` and `gamma` already
|
|
// applied. The conversion lives here rather than in the fragment shader so that a cell's
|
|
// *count* has exactly one reading -- the plot's, which is also what a host reads back through
|
|
// `DensityGrid::ink()`/`peak()`, and which decays once per frame on the CPU anyway.
|
|
//
|
|
// **One texture, re-filled per trace.** The cache is keyed by the grid's own address as well as
|
|
// its revision, so a plot drawn twice with nothing changed (the steady state of a display that
|
|
// has stopped being swept) skips the conversion *and* the upload -- while a row of several traces
|
|
// re-fills the one texture once per trace, which is what a figure of several rows already cost
|
|
// before the density was per trace.
|
|
const bool resized = columns != density_w_ || rows != density_h_;
|
|
if (resized) {
|
|
if (!density_texture_.create_r8(columns, rows, nullptr)) {
|
|
gl_error_ = true;
|
|
return;
|
|
}
|
|
// **Linear, and it used to be a point sample.** The filter is the thing that makes a cell
|
|
// bigger than a pixel acceptable: a cell is a *count*, a continuous quantity, so a fragment
|
|
// between two of them is between their two ink levels -- which is exactly what a linear tap
|
|
// returns, and what a `GL_NEAREST` fetch cannot (it would show the grid's own steps as blocks
|
|
// once the image is scaled up, `kDensityCellCap` cells spread across a wider axes rect). The
|
|
// nearest tap was right only while a cell *was* a device pixel and every fragment landed in
|
|
// its own; minification interpolates for the same reason, so a plot whose rect is under the
|
|
// cap (cell and pixel one and the same, up to the rounding of `ceil`) reads as it always did.
|
|
density_texture_.set_filter(gl::GL_LINEAR, gl::GL_LINEAR);
|
|
density_w_ = columns;
|
|
density_h_ = rows;
|
|
density_uploaded_grid_ = nullptr; // a fresh texture holds nothing
|
|
}
|
|
if (resized || density_uploaded_grid_ != &grid || grid.revision() != density_uploaded_revision_) {
|
|
const f32 full = config.full_scale > 0.0f ? config.full_scale : 1.0f;
|
|
const f32 gamma = config.gamma > 0.0f ? config.gamma : 1.0f;
|
|
// The gamma through a 256-entry table rather than a `pow` per cell: the output *is* eight
|
|
// bits, so quantising the count first and lifting the quantised value costs nothing a
|
|
// per-cell `pow` would have bought, and it keeps the per-frame pass a loop of multiplies.
|
|
u8 lut[256];
|
|
for (u32 i = 0u; i < 256u; ++i) {
|
|
const f32 v = static_cast<f32>(i) / 255.0f;
|
|
lut[i] = static_cast<u8>(std::clamp(std::pow(v, gamma), 0.0f, 1.0f) * 255.0f + 0.5f);
|
|
}
|
|
density_pixels_.resize(grid.cell_count());
|
|
const f32* cells = grid.cells().data();
|
|
for (usize i = 0; i < density_pixels_.size(); ++i) {
|
|
const f32 v = cells[i] / full;
|
|
const u32 quantised =
|
|
static_cast<u32>(std::clamp(v, 0.0f, 1.0f) * 255.0f + 0.5f);
|
|
density_pixels_[i] = lut[std::min(quantised, 255u)];
|
|
}
|
|
// `upload_r8` sets GL_UNPACK_ALIGNMENT to 1 and leaves it there (see gl::Texture), so the
|
|
// host's own value is saved and put back -- the same care `TextAtlas::sync()` takes around
|
|
// its own upload, since the two share a context and a frame.
|
|
gl::GLint alignment = 4;
|
|
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &alignment);
|
|
density_texture_.upload_r8(columns, rows, density_pixels_.data());
|
|
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, alignment);
|
|
density_uploaded_grid_ = &grid;
|
|
density_uploaded_revision_ = grid.revision();
|
|
// += and not =: a figure draws a density per trace per row, and what this field reports is the
|
|
// frame's upload, not the last one's.
|
|
stats_.density_upload_bytes += columns * rows;
|
|
}
|
|
|
|
// The quad covers the image's own **footprint**: the axes rect rounded up to whole device pixels,
|
|
// the rect `Plot::size_density_grids()` counted the cells on. While that rect is under
|
|
// `kDensityCellCap` it is exactly `columns` by `rows` pixels -- one texel a pixel, the mapping
|
|
// this has always had -- and past the cap the grid is smaller and the image is magnified back
|
|
// over it. The uv still runs 0..1 over the quad, so a fragment between two cells is a linear
|
|
// blend of them: the filter the texture was created with.
|
|
density_program_.bind();
|
|
density_program_.set_viewport({viewport_.w, viewport_.h});
|
|
density_program_.set_rect({layout.axes.x, layout.axes.y},
|
|
{std::ceil(layout.axes.w), std::ceil(layout.axes.h)});
|
|
// The tint is the trace's own colour: its alpha is the trace's, so a saturated cell reads as the
|
|
// trace at its own brightness, and the shader multiplies it by the cell's luminance.
|
|
density_program_.set_color(tint);
|
|
density_texture_.bind();
|
|
density_vao_.bind();
|
|
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
|
|
stats_.density_cells += columns * rows;
|
|
++stats_.draw_calls;
|
|
}
|
|
|
|
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::draw_x_note(const Plot& plot, const PlotLayout& layout) {
|
|
const std::string& text = plot.x_note();
|
|
if (text.empty()) return;
|
|
const Rect& axes = layout.axes;
|
|
const f32 line_h = font_backend_.height();
|
|
const f32 width = font_backend_.measure(text.c_str());
|
|
if (!(width > 0.0f) || !(line_h > 0.0f)) return;
|
|
// The pad is the legend's, so the two panels read as the same kind of chrome.
|
|
const f32 pad = style_.device(4.0f);
|
|
const Rect panel{axes.x + pad, axes.bottom() - pad - line_h - 2.0f * pad, width + 2.0f * pad,
|
|
line_h + 2.0f * pad};
|
|
// A sliver of axes gets no caption rather than one hanging over the frame: the panel is chrome
|
|
// *inside* the axes, and a backplate crossing the axis line would read as a second plot.
|
|
if (panel.w > axes.w || panel.h > axes.h) return;
|
|
draw_readout_panel(panel, style_.axis_line);
|
|
// Bottom-left, beside the labels it qualifies -- the reader adds the caption to a tick, so it
|
|
// belongs at the end of the tick row and not in a corner of its own. `glyphs_under_` because
|
|
// the panel is flushed with the frame's line geometry and the under-text batch goes out after
|
|
// it: text staged into the over batch would be drawn before its own backplate.
|
|
stage_text(glyphs_under_, {panel.x + pad, panel.y + pad}, text.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_selection_frame(const PlotLayout& layout) {
|
|
const Rect& frame = layout.selection;
|
|
// Two guards, not one: a drag swept flat in one direction has a real extent on one axis and none on
|
|
// the other, and a border on that is still the gesture's own answer -- the fill is what a zero
|
|
// extent has nothing of.
|
|
if (!(frame.w > 0.0f) && !(frame.h > 0.0f)) return;
|
|
if (frame.w > 0.0f && frame.h > 0.0f) fill_pixels(frame, style_.selection_fill);
|
|
// The border frames the pixels the fill covers and not the measured rect's own fractional edges:
|
|
// the readout panel's rounding and the readout panel's reason, since a hairline straddling a
|
|
// fractional edge is a frame bright on two sides and dim on the others.
|
|
const f32 left = std::ceil(frame.x - 0.5f) + 0.5f;
|
|
const f32 right = std::ceil(frame.right() - 0.5f) - 0.5f;
|
|
const f32 top = std::ceil(frame.y - 0.5f) + 0.5f;
|
|
const f32 bottom = std::ceil(frame.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(), style_.selection_edge, style_.device(1.0f));
|
|
}
|
|
|
|
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 cross at each trace's readout point, in the trace's own colour -- opaque, like a legend's
|
|
// swatch, so a trace drawn at 15% still has a mark that can be seen. The position is the
|
|
// layout's, and in the default `CrosshairValue::NearestSample` that is the *quoted sample*
|
|
// rather than the pointer's column: a mark for a sample outside the visible range is staged
|
|
// where that sample is and the axes clip keeps it inside, rather than being skipped by a range
|
|
// test of this layer's own.
|
|
//
|
|
// `crosshair_marker_px` is the cross' full extent and is larger than `marker_px`, which dots a
|
|
// trace's own samples at a deep zoom -- a snapped mark lands among those dots and has to read
|
|
// as the one the panel is quoting. `0` across draws nothing, so a caller can have the panel
|
|
// and the lines without the marks.
|
|
const f32 arm = 0.5f * style_.device(style_.crosshair_marker_px);
|
|
const f32 mark_stroke = style_.device(style_.crosshair_marker_width);
|
|
if (arm > 0.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, mark_stroke);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|