From cc908efa36279595e344d317b460973de5e95785 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 6 Oct 2026 03:02:36 +0200 Subject: [PATCH] feat(render): a reduced column can be graded by dwell, not drawn as one flat band A pixel column reduced from a dense envelope is honest about where the signal went and silent about how long it was there: its min and max are identical whether its samples fill the span or pile at its two ends, so a ramp and a pulse are one band. Style::intensity_grading grades it instead -- bright where the signal sits, faint where it only passes through -- by asking the gather for a level finer than the pixel grid and reading the distribution from those sub-buckets. Off by default: a change of look, not a correction of one. The measure is dwell per row, each segment depositing one unit spread over the rows it spans; counting +1 per row instead grades a sine's fast middle as brightly as its rails, which is the pattern inverted. The accumulator is one column wide (a full w*h density image is 33 MB at 4K) with each span written as two delta writes, and normalisation is against the column's own peak -- a frame-wide scale fades the trace out exactly where it moves fastest. Alpha is quantised into tiers, one LineBatch and one draw call each, because the line pipeline's colour is a uniform and a per-segment alpha is inexpressible. The finer ask is floored at level 1 whenever the flat path would have used the pyramid: level_for() answers 0 -- raw samples -- as soon as a sub-column falls under one bucket, so an unfloored ask turns a moderately dense frame into an O(visible) gather (measured at 172 samples a pixel: raw=1154 lod=0 and 6.4 ms a frame against raw=710 lod=453 and 2.3 ms). Grading is a way of drawing a reduction and may not undo it. Co-Authored-By: Claude Opus 4.6 --- README.md | 6 + docs/data-and-performance.md | 60 +++++++ include/plot/render/renderer.hpp | 38 +++++ include/plot/render/style.hpp | 76 +++++++++ src/render/renderer.cpp | 284 +++++++++++++++++++++++++++++-- tests/test_render_smoke.cpp | 178 +++++++++++++++++++ 6 files changed, 629 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d5524ca..46417a2 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ run touched a GPU, so read it as an upper bound and a record of the software pat column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat (`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps); +- and such a column can be drawn **graded by dwell** instead of as one flat band + (`Style::intensity_grading`, off by default, `FrameStats::graded_traces`): bright where the + signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is + what puts back the information the reduction throws away — a column's min and max are the same + whether its samples fill the span or pile at its two ends — so the gather is asked at a level + finer than the pixel grid and the distribution read from those sub-buckets; - sample **markers** when a window is sparse enough that its samples are the shape (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour); - grid with independent major/minor line classes (`Style::grid_major_visible`, diff --git a/docs/data-and-performance.md b/docs/data-and-performance.md index a6af8e0..fd99f49 100644 --- a/docs/data-and-performance.md +++ b/docs/data-and-performance.md @@ -216,6 +216,66 @@ polyline, over one sine period across 60 000 samples. A count of *partially cove always spills into its neighbour column, so the two renderings measure 577 and 652, which separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054. +## A reduced column can be graded by dwell + +`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band: +bright where the signal sits, faint where it only passes through, the way a digital-phosphor +scope reads. It is off by default — a change of look, not a correction of one — and +`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since +grading is a way of drawing the reduction and not a third path. + +What it puts back is the information the reduction throws away: a column's min and max are the +**same** whether its samples fill the span evenly or pile at its two ends, so a ramp and a pulse +are one band. The distribution cannot be recovered from the band, which is why the gather is +asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column) +and the dwell read from those sub-buckets. + +Five things carry it, and each was arrived at by being wrong first: + +- **The measure is dwell per row, each segment depositing one unit spread over the rows it + spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as + its rails, which is the pattern inverted — the middle is where the signal spends the *least* + time. +- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by + column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one + column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first + row and the negation one past its last, so it costs two writes whatever its height, and the + running sum over the column is the per-row dwell. +- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the + whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew + through uniformly comes out faint at every row, so the trace fades out exactly where it moves + fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find + the peak. +- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.** + The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of + geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and + the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier + visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of + existence — and `intensity_gamma` lifts the faint end. A connector between two disjoint columns + is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was + measured to dwell. +- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the + asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is + **floored at level 1** whenever the flat path would have used the pyramid. Without the floor + every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over + a 2 s window, 172 samples a pixel, the flat path sat 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 of draw + mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the + coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is + also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the + limit, not the number. + +`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It +profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the +flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle +at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma +0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its +area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at +one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the +flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634 +visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw +and the floor is never reached. + ## One sample or a statistic of many Every number the library reports is one the samples actually hold. What differs between diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index f03e58b..1c34ae6 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -34,6 +34,10 @@ struct FrameStats { /// path above -- the reduction is about the *density* of the gathered points, /// not about which level produced them. u32 column_traces = 0; + /// Traces whose columns were graded by dwell rather than drawn as one flat band + /// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of + /// drawing the reduction, not a third path. + u32 graded_traces = 0; u64 visible_samples = 0; ///< samples the gathered windows covered, summed u32 staged_segments = 0; ///< line segments staged, all colour groups u32 staged_markers = 0; ///< marker shapes staged over the traces @@ -409,6 +413,13 @@ class Renderer { /// axis-aligned line by up to half a pixel. Set on every flush, like `dash`. void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}, bool snap = false) noexcept; + /// The same, for a batch other than the renderer's own `lines_`. The graded trace + /// pass stages one batch per alpha step, and a step has to be a draw call of its + /// own because the line pipeline takes its colour from a uniform -- so it needs to + /// flush a batch it chose rather than the member one. Every other caller flushes + /// `lines_` and uses the overload above. + void flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width, + Vec2 dash = Vec2{}, bool snap = false) noexcept; /// Draws every staged glyph in `batch` -- each instance carries its own tint /// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs /// once per text batch, after the line geometry it belongs over. @@ -515,6 +526,19 @@ class Renderer { /// series for. See `draw_traces` for when this path is taken and why. u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace, f64 x_origin, u32 points); + /// The same reduction, graded by **dwell**: a column is drawn as a stack of runs, + /// each at the alpha step its own share of the column's samples earns, so a flat + /// top is bright and the edge leading to it is faint (`Style::intensity_grading`). + /// + /// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes + /// in turn -- the line pipeline's colour is a uniform, so a step is a draw call. + /// Returns the segments staged across all steps (0 stages nothing). + /// + /// `gather_` is the input and must have been asked for at the *finer* level + /// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable + /// from its own min and max (see `Style::intensity_grading`). + u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace, + f64 x_origin, u32 points); /// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own /// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole /// range, in its own colour -- or in the accent at handle width while it is hovered or @@ -631,6 +655,20 @@ class Renderer { /// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the /// window. std::vector columns_; + /// `stage_trace_density()`'s working set, and deliberately **one column wide**: the + /// gathered points arrive in x order, so a column can be accumulated, read and + /// cleared before the next one starts. A full-axes density image would be + /// `axes.w * axes.h` (33 MB at 4K); this is `axes.h` (a few kB) and stays in L1. + /// + /// Each sub-bucket's span is written as a +1 at its first row and a -1 one past + /// its last, so accumulating a span costs two writes whatever its height; the + /// running sum over the column is then how many sub-buckets cover each row, which + /// is the dwell. + std::vector column_rows_; + /// One batch per alpha step of the grading (`Style::intensity_steps`), re-used + /// across frames: the line pipeline takes its colour from a uniform, so segments + /// at different alphas cannot share a draw call and must be staged apart. + std::vector steps_; mutable std::vector readback_; ///< dump_frame_ppm's row buffer /// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index a4ceefa..1056357 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -177,6 +177,82 @@ struct Style { ///< sides draw through the one instanced ///< anti-aliased quad path. + // --- intensity grading: counts and ratios, never sizes ------------------- + + /// Draw a dense trace's pixel columns **graded by how long the signal dwells at + /// each height**, the way a digital-phosphor scope does, instead of as one flat + /// band per column: bright where samples pile up (a flat top, a peak), faint + /// where they only pass through (an edge, a noise excursion). + /// + /// Off by default, because it is a change of *look* and not a correction: the + /// flat band is the honest answer to "what did this column reach", and a host + /// that wants that -- or that draws few enough samples never to take the column + /// path at all -- should not have its traces restyled by an upgrade. A scope-like + /// host turns it on. + /// + /// It is not derivable from the flat band. A column's min and max are the same + /// whether its samples fill the span or sit piled at its two ends, which is + /// exactly the difference between a ramp and a pulse -- so grading needs the + /// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`) + /// and reads the distribution from those sub-buckets. + /// + /// The measure is *dwell per row*, and the weighting is the whole of it: each + /// sub-column segment deposits **one unit spread over the rows it spans**, so a + /// tall crossing leaves a thin smear and a short one at a turning point leaves a + /// bright mark. Counting a row at +1 per segment that touches it instead -- the + /// obvious thing -- grades a sine's fast middle as brightly as its rails, which + /// is the pattern inverted. + bool intensity_grading = false; + + /// Sub-buckets per pixel column the gather is asked for when grading: the level + /// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's + /// density is read from this many envelopes instead of from one. + /// + /// It buys resolution in *dwell*, linearly, for the same factor in gathered + /// points -- and the points stay bounded by the screen, which is the property + /// that matters (8 of them over a 1000 px axis is 16 000 points whatever the + /// window holds). Below about 4 a pulse's flat levels and its edge are too few + /// samples apart to separate. + /// + /// It is a *want*, not a promise: the renderer floors the level it asks for at the + /// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets + /// a column is graded from however many it does hold. Without that floor `level_for` + /// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which + /// turns a frame the flat path draws off the pyramid into an O(visible) gather. So + /// raising this past about 8 costs resolution nothing and buys it nothing either: the + /// bucket size is the limit, not this number. + u32 intensity_sub_columns = 8; + + /// Quantisation of the grading, in alpha steps. Each step is one draw call (the + /// line pipeline's colour is a uniform, so a per-segment alpha would mean a + /// per-instance one), and the segments are bounded by the columns, so the cost + /// of a step is a draw call over a few hundred instances. + /// + /// Four is enough to read a waveform's structure and few enough that a step is a + /// visible tier rather than a gradient -- the point is to separate "dwelt here" + /// from "passed through", not to measure. + u32 intensity_steps = 4; + + /// Alpha the faintest graded step is drawn at, as a fraction of the trace's own. + /// + /// Not 0: a column the signal merely passed through is still a column the signal + /// *reached*, and a riser dimmed out of existence is a square wave drawn as two + /// disconnected lines. This is the floor that keeps the trace continuous. + f32 intensity_floor = 0.3f; + + /// Exponent the normalised dwell is raised to before it is quantised. Below 1 it + /// lifts the faint end, which is what makes a fast edge visible beside a flat top + /// whose dwell is three orders of magnitude greater. + /// + /// The dwell is normalised **against the column's own peak**, not against a scale + /// for the whole frame, so the steps spend their range on the structure *inside* + /// each column. One scale for the frame needs the whole `w * h` density image + /// (33 MB at 4K, which is why the accumulator is one column wide) and reads + /// worse: a column the signal flew straight through holds little dwell at any + /// height, so it would grade uniformly faint -- a trace that fades out exactly + /// where it moves fastest. + f32 intensity_gamma = 0.45f; + /// `logical` in device pixels: the single conversion point for every logical /// metric above, so the arithmetic appears once per consumer instead of a `* /// ui_scale` scattered over the renderer and the layout. diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index b844b42..e7f84ee 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -616,9 +616,14 @@ void Renderer::upload_text_vertices(std::span interleaved) noexcept { void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash, bool snap) noexcept { - const u32 segments = lines_.segment_count(); + 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(lines_.data()); + upload_vertices(batch.data()); line_program_.bind(); line_program_.set_scale(map.scale); line_program_.set_offset(map.offset); @@ -643,7 +648,7 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash stats_.staged_segments += segments; ++stats_.draw_calls; } - lines_.clear(); // keeps the capacity, so the next group re-uses the store + batch.clear(); // keeps the capacity, so the next group re-uses the store } void Renderer::flush_text(render::TextBatch& batch) noexcept { @@ -1057,6 +1062,219 @@ u32 Renderer::stage_trace_columns(const Plot& plot, const PlotLayout& layout, co 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(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(layout.axes.h) + 1u; + column_rows_.assign(static_cast(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; + + // 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(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. The deposits are fractions, so this cannot be an + // integer test: it is a share of the column's own peak, small enough to keep a row a + // single thin segment grazed and large enough not to light a row on rounding alone. + const f32 lit_floor = 0.01f * peak; + 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(std::pow(share, gamma) * static_cast(steps)); + tier = std::min(steps - 1u, t); + } + if (in_run && (!lit || tier != run_tier)) { + f32 lo = top + static_cast(run_start); + f32 hi = top + static_cast(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(r); + any = true; + } + band_hi = top + static_cast(r) + 1.0f; + } + } + open = false; + 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(gather_[2u * k]); + const f64 sx = xt.to_screen_x(x_abs); + const f64 sy = yt.to_screen_y(static_cast(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(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(columns - 1u) ? columns - 1u + : static_cast(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(top) && lo <= static_cast(bottom)) { + const f64 ra = std::floor(lo - static_cast(top)); + const f64 rb = std::floor(hi - static_cast(top)); + const u32 r0 = !(ra > 0.0) ? 0u + : ra >= static_cast(rows - 1u) ? rows - 1u + : static_cast(ra); + const u32 r1 = !(rb > 0.0) ? 0u + : rb >= static_cast(rows - 1u) ? rows - 1u + : static_cast(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(r1 - r0 + 1u); + column_rows_[r0] += w; + column_rows_[r1 + 1u] -= w; + } + } + px = sx; + py = sy; + have_prev = true; + } + if (open) emit_column(); + return staged; +} + void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { // Both dimensions: a zero-width rect selects no level, and a zero-height one // would flatten every trace onto a single row -- a frame that looks like a @@ -1098,9 +1316,30 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { // This is the whole performance contract: the window, never the series. const u64 visible = last - first; const f64 samples_per_pixel = static_cast(visible) / static_cast(layout.axes.w); - const u32 level = samples_per_pixel >= static_cast(style_.dense_threshold_spp) - ? series.pyramid().level_for(n, samples_per_pixel) - : 0u; + // 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(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(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 @@ -1230,13 +1469,32 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { // it ran. const bool columns = trace.mode == data::GatherMode::Envelope && static_cast(points) >= 2.0 * static_cast(layout.axes.w); - const u32 staged = columns ? stage_trace_columns(plot, layout, trace, x_range.min, points) : 0u; - if (staged != 0u) { - // `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. + // `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(t) / + static_cast(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 { diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 8916904..963d44f 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -38,6 +38,23 @@ constexpr plot::u32 kH = 360; /// 1M-wide window holds ~16 of them (the dense envelope spans the full height in /// every column) while a 640-wide one is a nearly flat line (the sparse path's /// polyline crosses every column exactly once). +/// A sine of **one period per pixel column** for the window +/// `a_dense_trace_can_be_graded_by_dwell` draws (60 000 x-units over a 572 px axis, so +/// 104.9 samples a column). That is the one density at which a column *has* a dwell +/// structure to grade: its envelope spans the full amplitude, so the band is tall, and +/// the sub-buckets inside it are a fraction of a period each, so the ones at the sine's +/// turning points are short and the ones crossing its middle are long. A period of many +/// columns leaves every band a thin ribbon with nothing to distinguish, and a period of a +/// fraction of a column fills every sub-bucket to the full amplitude -- also nothing. +std::vector fast_sine_samples() { + std::vector y(1'000'000); + const double w = 2.0 * 3.14159265358979323846 / 104.9; + for (plot::u64 i = 0; i < y.size(); ++i) { + y[i] = static_cast(std::sin(static_cast(i) * w)); + } + return y; +} + std::vector sine_samples() { std::vector y(1'000'000); for (plot::u64 i = 0; i < y.size(); ++i) { @@ -961,6 +978,167 @@ PLOT_TEST(a_dense_trace_is_drawn_as_whole_pixel_columns) { ctx.destroy(); } +/// `Style::intensity_grading`: a reduced column is drawn **graded by dwell** -- bright +/// where the signal sits, faint where it only passes through -- which is the second half +/// of the moire answer. The columns alone removed the beat; this is what puts back the +/// information a flat band throws away, the way a phosphor display does. +/// +/// The observable is the thing the feature claims and nothing weaker: within one column, +/// the rows at the **edges** of the band are brighter than the rows in its **middle**. +/// For a sine of one period per column that is exactly right -- it dwells at its turning +/// points and crosses its middle at full speed -- and it is a property of the *rendering* +/// rather than of the waveform, because the same frame drawn ungraded puts the band's +/// edge and its middle at the same coverage. Both frames are measured here, so what is +/// asserted is the difference between them. +/// +/// It also pins what grading may *not* cost. The reduction still runs (`column_traces`), +/// so the beat is still gone; the geometry is still bounded by the axis' width times a +/// small factor, because a run is a stretch of equal tier and not a row; and the draw +/// calls are bounded by the steps, since the line pipeline's colour is a uniform. +PLOT_TEST(a_dense_trace_can_be_graded_by_dwell) { + plottest::HeadlessContext ctx(kW, kH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + + plot::data::ArraySeries s(fast_sine_samples(), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + + // The band's edges against its middle, averaged over columns across the axis. The + // shares are of the band's own height, so the reading does not depend on where the + // band happens to sit or on how tall it is: the outer fifth at each end against the + // middle fifth. + struct Profile { + plot::u32 edge = 0; ///< mean peak coverage over the band's two outer fifths + plot::u32 middle = 0; ///< mean peak coverage over its middle fifth + plot::u32 columns = 0; ///< columns that held a band at all + }; + auto profile = [&](const plot::Rect& a) -> Profile { + const plot::u32 rows = static_cast(a.h); + std::vector cover(rows, 0); + plot::u64 edge_sum = 0; + plot::u64 middle_sum = 0; + Profile out; + for (plot::u32 col = 8; col + 8 < static_cast(a.w); col += 16) { + plot::u32 lo = rows; + plot::u32 hi = 0; + for (plot::u32 row = 0; row < rows; ++row) { + plot::u8 px[4] = {}; + const plot::u32 x = static_cast(a.x) + col; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast(a.y) + row), px)); + cover[row] = static_cast(px[2]) - static_cast(px[0]); + if (cover[row] > 20) { // the faintest step is 0.3 of 191, so this is under it + if (row < lo) lo = row; + hi = row; + } + } + // A band of fewer than 15 rows has no middle fifth to speak of; at this density + // there is none, but the reading would be meaningless if there were. + if (lo >= hi || hi - lo < 15u) continue; + const plot::u32 h = hi - lo + 1u; + const plot::u32 fifth = h / 5u; + int edge = 0; + int middle = 0; + for (plot::u32 row = lo; row <= hi; ++row) { + const plot::u32 d = row - lo; + if (d < fifth || d >= h - fifth) { + if (cover[row] > edge) edge = cover[row]; + } else if (d >= 2u * fifth && d < 3u * fifth) { + if (cover[row] > middle) middle = cover[row]; + } + } + edge_sum += static_cast(edge); + middle_sum += static_cast(middle); + ++out.columns; + } + if (out.columns != 0) { + out.edge = static_cast(edge_sum / out.columns); + out.middle = static_cast(middle_sum / out.columns); + } + return out; + }; + + // Ungraded first: the reference the grading is read against. + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + dump_if_requested(r, "ungraded.ppm"); + const plot::Rect a = p.layout().axes; + const Profile flat = profile(a); + const plot::u32 flat_segments = r.stats().staged_segments; + const plot::u32 flat_draws = r.stats().draw_calls; + PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0); + PLOT_CHECK(!r.last_frame_had_gl_error()); + + r.style().intensity_grading = true; + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + dump_if_requested(r, "graded.ppm"); + const Profile lit = profile(a); + + std::printf("[graded] axes=[%.1f %.1f %.1f %.1f] flat edge=%u middle=%u cols=%u seg=%u " + "draws=%u | graded edge=%u middle=%u cols=%u seg=%u draws=%u columns=%u " + "graded_traces=%u\n", + a.x, a.y, a.w, a.h, flat.edge, flat.middle, flat.columns, flat_segments, + flat_draws, lit.edge, lit.middle, lit.columns, r.stats().staged_segments, + r.stats().draw_calls, r.stats().column_traces, r.stats().graded_traces); + + // Both frames measured a band in most of the columns they sampled, or the two + // profiles are not comparable and everything below is vacuous. + PLOT_CHECK(flat.columns > 20u && lit.columns == flat.columns); + // The flat band is flat: its edge and its middle are one ink (191 and 191). + PLOT_CHECK(flat.middle > 0u && flat.edge * 100u < flat.middle * 115u); + // The graded one is not, and in the direction a sine dwells in: 191 against 131, a + // middle at 0.69 of the edge. The figure is a *tier* ratio and not the dwell ratio -- + // the per-row dwell at the rails is several times the crossing's, and + // `intensity_gamma` (0.45) and `intensity_floor` (0.3) are there to compress exactly + // that into a range an eye reads as shading rather than as a gap. So the threshold is + // what the quantised ink does, with room for a step either way. + PLOT_CHECK(lit.middle > 0u && lit.middle * 100u < lit.edge * 80u); + // The reduction still ran, so the beat the columns removed is still gone. + PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1); + // A run is a stretch of equal tier, not a row: the geometry stays bounded by the + // axis' width times a small factor, nothing like `w * h`. Measured 8303 over a 572 px + // axis, 14.5 runs a column -- the dwell profile's own structure (four tiers from the + // dim middle out to each rail) plus the quantiser's dither at each boundary. The flat + // path's 627 is the other end of the scale, and 572 * 156 rows is what this is not. + PLOT_CHECK(r.stats().staged_segments < 24u * static_cast(a.w)); + // And the draw calls by the steps: one per tier that staged anything, over the flat + // frame's single trace draw. + PLOT_CHECK(r.stats().draw_calls <= flat_draws + r.style().intensity_steps); + + // And the level floor, which the window above cannot see: at 105 samples a pixel the + // flat path is already on raw samples, so grading asks for nothing finer. A wider + // window puts the flat path on the pyramid, and there a sub-column is `spp / 8` -- which + // falls under one bucket long before the column does, so an unfloored ask answers level + // 0 and gathers the whole visible window. That is a real cost and it was measured: at + // 1.3 MS/s over a 2 s window udpscope's own frame report went from `raw=710 lod=453` to + // `raw=1154 lod=0` and 2.3 -> 6.4 ms a frame. The floor is one bucket level, so the + // reading is simply that a graded frame stays on the pyramid. + p.set_x_range(ax, {0.0, 200'000.0}); + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + std::printf("[graded] wide: raw=%u lod=%u columns=%u graded=%u seg=%u visible=%llu\n", + r.stats().raw_traces, r.stats().lod_traces, r.stats().column_traces, + r.stats().graded_traces, r.stats().staged_segments, + static_cast(r.stats().visible_samples)); + PLOT_CHECK(r.stats().visible_samples > 100'000u); + PLOT_CHECK(r.stats().lod_traces == 1 && r.stats().raw_traces == 0); + PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1); + PLOT_CHECK(r.stats().staged_segments < 24u * static_cast(a.w)); + + PLOT_CHECK(!r.last_frame_had_gl_error()); + ctx.destroy(); +} + // Resolution #1 of the brief: the renderer draws into whatever framebuffer is // bound and clears only its viewport, so a host that embeds the plot in a // sub-rect (Qt/GTK/ImGui) keeps the rest of its surface. Coordinates are given