diff --git a/README.md b/README.md index 0880a95..de2219c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ built on top of it. | Qt6 | `plot::qt6` | `adapters/qt6/` | | GTK4 | `plot::gtk4` | `adapters/gtk4/` | +The visible x range is mapped onto sample indices through the series' own x +(`Series::neighbours()`, a binary search over the real stamps), never through the +`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed +timestamp would otherwise have its draw window land inside the visible range and lose the two +segments that enter and leave the frame — the empty left and right margins of a zoomed-in view. Per-frame rendering cost is O(visible pixels + samples produced since the last frame), not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index f35e52f..83bed8a 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -42,15 +42,6 @@ bool fail(Status* st, ErrorCode code, const char* message) noexcept { /// clamp, so it lands off-scale rather than as NaN. f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; } -/// Sample index for a (possibly fractional, possibly non-finite) position in a -/// series' index space, clamped to [0, n]: NaN and -inf to 0, +inf to n, so a -/// range the axis cannot map still yields a valid window. -u64 clamp_index(f64 v, u64 n) noexcept { - if (!(v > 0.0)) return 0; - if (v >= static_cast(n)) return n; - return static_cast(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; @@ -994,15 +985,26 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) { const u64 n = series.size(); if (!(dx > 0.0) || n == 0) continue; - // Map the visible x range onto sample indices. One sample of slack on each - // side covers a partially visible end bucket; the range is clamped to the - // series, so a zoomed-out view never asks for more than exists. + // 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); - f64 lo = (x_range.min - series.x0()) / dx; - f64 hi = (x_range.max - series.x0()) / dx; - if (hi < lo) std::swap(lo, hi); - const u64 first = clamp_index(std::floor(lo) - 1.0, n); - const u64 last = clamp_index(std::ceil(hi) + 1.0, n); + // `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. diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index f793f73..32bec94 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -695,6 +695,125 @@ PLOT_TEST(renders_visible_trace_across_viewport) { ctx.destroy(); } +/// A series whose `dx()` understates its spacing. +/// +/// That is every ring-backed series: x is a streamed timestamp, so `dx()` is only a nominal +/// grid (an LOD level, a coarse snap) while `x_at()`/`neighbours()` answer from the real time +/// ring. The samples here sit ten apart and `dx()` claims one, with the plotted window +/// `[12, 18]` falling strictly *between* two of them -- no sample is inside the window at all, +/// so the only way to light its margins is the segment that crosses them. +class LyingGridSeries final : public plot::data::Series { + public: + LyingGridSeries() { + pyramid_.reserve(kN); + for (u64 i = 0; i < kN; ++i) pyramid_.append(kY); + } + + u64 size() const override { return kN; } + f64 x0() const override { return 0.0; } + f64 dx() const override { return 1.0; } // the lie: the real spacing is x_at()'s ten + plot::Range x_bounds() const override { return {0.0, x_at(kN - 1u)}; } + plot::Range y_bounds() const override { return {kY, kY}; } + const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; } + f64 x_at(u64 i) const override { return 10.0 * static_cast(i); } + bool neighbours(f64 x, u64& i0, u64& i1) const override { + if (x < 0.0 || x > x_at(kN - 1u)) return false; + i0 = static_cast(x / 10.0); + i1 = i0; + if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u; + return true; + } + void gather(u32, u64 begin, u64 end, std::vector& out, + plot::data::GatherMode) const override { + ++gather_calls_; + for (u64 i = begin; i < end; ++i) { + out.push_back(static_cast(x_at(i) - x0())); + out.push_back(kY); + } + } + + u64 gather_calls() const noexcept { return gather_calls_; } + + protected: + void copy_raw(u64 begin, u64 end, f32* out) const override { + for (u64 i = begin; i < end; ++i) out[i - begin] = kY; + } + + private: + static constexpr u64 kN = 5u; + static constexpr f32 kY = 1.0f; + plot::data::MinMaxPyramid pyramid_; + mutable u64 gather_calls_ = 0; +}; + +/// The window falls between two samples: the trace must still be drawn to both margins. +/// +/// Found by zooming in: the renderer mapped the visible x range onto sample indices with +/// `(x - x0()) / dx()`, and on a series whose `dx()` is nominal that lands *inside* the visible +/// range -- here it lands past the end of a five-sample series entirely, so `last <= first` and +/// the frame held no trace at all. The segment entering the window from the sample before it and +/// leaving to the one after are what draw the margins, and a series answers where they are +/// through `neighbours()`, over its own x. +PLOT_TEST(a_sparse_window_between_samples_still_inks_both_margins) { + 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)); + + LyingGridSeries s; + plot::Plot p; + p.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + // `auto_scale` off on both: the window has to be the one the test names, or the axis takes + // the series' own bounds and the window falls outside the frame -- a test of nothing. + const plot::u32 ax = p.add_x_axis({"t", {12.0, 18.0}, false, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 3.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(p); + r.end_frame(); + dump_if_requested(r, "margins.ppm"); + + const plot::Rect a = p.layout().axes; + // The window's own evidence: the samples that bracket it are sampled even though neither is + // inside it, and the reverted grid walk staged *none* (`(12 - 0) / 1` is past the end of a + // five-sample series, so `last <= first` and the frame held no trace at all). + const plot::Range drawn_x = p.x_range(ax); + const plot::u32 covered = covered_columns(ctx, a, 24); + std::printf("[margins] window=[%.3f..%.3f] axes=[%.1f %.1f %.1f %.1f] visible=%llu " + "raw=%llu lod=%llu segments=%llu gathers=%llu trace_visible=%d covered=%u/24\n", + drawn_x.min, drawn_x.max, a.x, a.y, a.w, a.h, + static_cast(r.stats().visible_samples), + static_cast(r.stats().raw_traces), + static_cast(r.stats().lod_traces), + static_cast(r.stats().staged_segments), + static_cast(s.gather_calls()), int(p.trace(0).visible()), + covered); + PLOT_CHECK(r.stats().visible_samples >= 2u); + PLOT_CHECK(covered == 24u); + PLOT_CHECK(covered_columns(ctx, a, 24) == 24); // every column, margins included + + // The margins by name, on the line's own row: the reported symptom was these two being blank + // while the middle drew. `y = 1` in the window `[-1, 3]` is the middle row of the band. + const plot::u32 row = static_cast(a.y + a.h / 2.0f); + for (const plot::u32 x : {static_cast(a.x) + 1u, + static_cast(a.x + a.w) - 2u}) { + bool inked = false; + for (plot::u32 dy = 0; dy < 4u && !inked; ++dy) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (row + dy), px)); + inked = trace_pixel(px); + } + PLOT_CHECK(inked); + } + + PLOT_CHECK(!r.last_frame_had_gl_error()); + ctx.destroy(); +} + PLOT_TEST(dense_and_sparse_paths_agree_on_extent) { plottest::HeadlessContext ctx(kW, kH); plot::Status st;