The persistent-mapped instance ring had three slots and recorded a fence per upload, so a frame recycled a slot whose fence was recorded a few draws earlier *in that same frame*: from the fourth of the demos' nine uploads on, each one waited on the frame's own work. It is free on a driver that retires draws promptly (the GLFW host, 1.07 ms/frame) and ~4 ms of the Qt widget's 7.11 ms under llvmpipe, and it grows with the trace and cursor counts this library exists for. * The ring grows to cover a frame's upload count, the frame's write cursor is the ring index (so a frame writes 0, 1, 2, ... and never a slot twice), one fence is recorded after the frame's last draw, and the only wait is at the start of the next frame. A wait that times out, or a slot written with no fence ever recorded behind it, drops the store to the orphan path -- the treatment a refused fence already had -- so nothing is ever written into storage the GPU may still be reading. * New test-only counters (PLOT_RING_STATS, the PLOT_PYRAMID_COUNT_* pattern: free functions, no member, so no consumer's layout changes) pin a frame's upload and wait counts. The new tests assert the frame's slot offsets are distinct, that the ring grew past its floor to cover the frame, and that a frame waits at most once per store -- read from the counters and from glClientWaitSync itself. * qt6: print the unresolved entry-point names before the load-failure branch, which used to return first and leave the one failure this widget can have reporting nothing but a generic message. * qt6: the cached pointer anchor is now in logical pixels and a move's delta is the logical difference scaled by the DPR in effect, so a scale change between two move events (the window dragged onto another monitor mid-drag) is no longer a cross-scale jump applied as a pan. The anchor moved into a toolkit-free unit (adapters/qt6/qt_pointer.hpp) with its own test. GLFW demo headless: 1.07 -> 0.73 ms/frame. Qt widget under xvfb/llvmpipe: 7.1 -> 3.8 ms at dpr 1.0, 6.7 -> 3.6 ms at 1.25, with both upload paths still rendering and the demo's scripted pan/box-zoom checks passing at both scales.
916 lines
41 KiB
C++
916 lines
41 KiB
C++
// The integration test: the whole stack on the real driver. A 1M-sample series,
|
|
// the plot layer's layout/ticks/grid, the renderer's visible-range LOD selection
|
|
// and both vertex-transfer strategies, all asserted by reading pixels back from
|
|
// the headless EGL framebuffer rather than by inspecting anything the renderer
|
|
// says about itself.
|
|
#include "harness.hpp"
|
|
#include "egl_headless.hpp"
|
|
|
|
#include <plot/gl/api.hpp>
|
|
#include <plot/gl/loader.hpp>
|
|
#include <plot/data/array_series.hpp>
|
|
#include <plot/render/buffer.hpp>
|
|
#include <plot/render/detail/ring_stats.hpp>
|
|
#include <plot/render/renderer.hpp>
|
|
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
constexpr plot::u32 kW = 640;
|
|
constexpr plot::u32 kH = 360;
|
|
|
|
/// The series every case draws: a slow sine whose period is 62 832 samples, so a
|
|
/// 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).
|
|
std::vector<plot::f32> sine_samples() {
|
|
std::vector<plot::f32> y(1'000'000);
|
|
for (plot::u64 i = 0; i < y.size(); ++i) {
|
|
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * 0.0001));
|
|
}
|
|
return y;
|
|
}
|
|
|
|
/// Columns of the axes rect (sampled at `columns` evenly spaced positions) that
|
|
/// hold at least one trace-coloured pixel (0x40C0FF over the 0x101418
|
|
/// background).
|
|
///
|
|
/// The predicate is blue *dominance*, not merely "blue-ish": the axis line is
|
|
/// 0x8A94A6, which any threshold on blue alone would accept, and the axis line
|
|
/// crosses every column of the rect -- a loose predicate would make every
|
|
/// coverage assertion here vacuous. Blending the trace over the background keeps
|
|
/// blue - red at `alpha * 191`, so even the AA fringe of a 1.5px stroke clears
|
|
/// the bound.
|
|
bool trace_pixel(const plot::u8 px[4]) {
|
|
return px[2] > 120 && px[2] - px[0] > 100;
|
|
}
|
|
|
|
/// Predicates for the overlay layer, each chosen so that no other element of the
|
|
/// frame can satisfy it (see the colours the test picks). A marker drawn on a
|
|
/// pixel boundary blends at about half coverage, so they are dominance tests with
|
|
/// room for that rather than exact colour matches.
|
|
bool green_pixel(const plot::u8 px[4]) { return px[1] > px[0] + 60 && px[1] > px[2] + 60; }
|
|
bool magenta_pixel(const plot::u8 px[4]) { return px[0] > px[1] + 60 && px[2] > px[1] + 60; }
|
|
/// The measure band: 0xFFB020 at 15% over the background, so a warm tint.
|
|
bool band_pixel(const plot::u8 px[4]) {
|
|
return px[0] > 35 && px[0] > px[1] && px[1] > px[2];
|
|
}
|
|
bool text_pixel(const plot::u8 px[4]) { return px[0] > 180 && px[1] > 180 && px[2] > 180; }
|
|
/// A grey marker: the annotation colour at any coverage, which no data or chrome
|
|
/// colour here matches (they are either dark or strongly coloured).
|
|
bool marker_pixel(const plot::u8 px[4]) {
|
|
const plot::u8 lo = std::min(px[0], std::min(px[1], px[2]));
|
|
const plot::u8 hi = std::max(px[0], std::max(px[1], px[2]));
|
|
return lo > 90 && static_cast<plot::u8>(hi - lo) < 30;
|
|
}
|
|
|
|
plot::u32 covered_columns(plottest::HeadlessContext& ctx, const plot::Rect& a,
|
|
plot::u32 columns) {
|
|
// `a` is a Rect, so its rows are top-down; read_pixel() speaks GL's bottom-left
|
|
// convention. Flipping here is the same single conversion the renderer does for
|
|
// its viewport, and scanning the rows without it would test the 24 rows above
|
|
// the axes instead of the axes itself.
|
|
const plot::u32 first_row = static_cast<plot::u32>(a.y);
|
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
|
plot::u32 hit = 0;
|
|
for (plot::u32 c = 0; c < columns; ++c) {
|
|
const plot::u32 x =
|
|
static_cast<plot::u32>(a.x + a.w * (static_cast<plot::f64>(c) + 0.5) /
|
|
static_cast<plot::f64>(columns));
|
|
bool found = false;
|
|
for (plot::u32 yy = 0; yy < rows && !found; ++yy) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (first_row + yy), px));
|
|
found = trace_pixel(px);
|
|
}
|
|
if (found) ++hit;
|
|
}
|
|
return hit;
|
|
}
|
|
|
|
/// Honours PLOT_DUMP_FRAMES: a non-empty value is the PPM path, an empty one
|
|
/// dumps next to the test binary. Nothing is written unless it is set.
|
|
void dump_if_requested(const plot::Renderer& r, const char* default_name) {
|
|
const char* path = std::getenv("PLOT_DUMP_FRAMES");
|
|
if (path == nullptr) return;
|
|
plot::Status st;
|
|
const bool ok = r.dump_frame_ppm(path[0] != '\0' ? path : default_name, &st);
|
|
std::printf("[dump] %s: %s\n", path[0] != '\0' ? path : default_name,
|
|
ok ? "written" : st.message);
|
|
PLOT_CHECK(ok);
|
|
}
|
|
|
|
/// True when `px` is (within a couple of levels) the straight-alpha colour `c`,
|
|
/// which is what a fully covered text pixel is. The colour comes from the style,
|
|
/// so no predicate here has to guess what the renderer draws with.
|
|
bool pixel_is(const plot::u8 px[4], plot::Color c) {
|
|
const auto near = [](plot::u8 got, plot::f32 want) {
|
|
const int diff = static_cast<int>(got) - static_cast<int>(want * 255.0f + 0.5f);
|
|
return diff >= -3 && diff <= 3;
|
|
};
|
|
return near(px[0], c.r) && near(px[1], c.g) && near(px[2], c.b);
|
|
}
|
|
|
|
/// Holds one GL entry point at a stub for as long as it lives. The entry points
|
|
/// are the library's own function pointers (`plot/gl/api.hpp`), so a driver
|
|
/// refusal -- a map or a fence that fails while the caps still advertise support
|
|
/// -- is testable without a broken driver. The pointer is saved on construction
|
|
/// and put back in the destructor, which also runs while a failed `PLOT_CHECK`
|
|
/// unwinds, so a case can never leak its stub into the next one.
|
|
class RefusePersistentMap {
|
|
public:
|
|
RefusePersistentMap() noexcept : saved_(plot::gl::glMapBufferRange) {
|
|
plot::gl::glMapBufferRange = &refuse;
|
|
}
|
|
~RefusePersistentMap() { plot::gl::glMapBufferRange = saved_; }
|
|
RefusePersistentMap(const RefusePersistentMap&) = delete;
|
|
RefusePersistentMap& operator=(const RefusePersistentMap&) = delete;
|
|
|
|
private:
|
|
static void* refuse(plot::gl::GLenum, plot::gl::GLintptr, plot::gl::GLsizeiptr,
|
|
plot::gl::GLbitfield) noexcept {
|
|
return nullptr;
|
|
}
|
|
decltype(plot::gl::glMapBufferRange) saved_;
|
|
};
|
|
|
|
/// Same, for `glFenceSync`: the sync object the ring's slot guard needs is
|
|
/// refused while the caps and the entry point still say the mapped path is on.
|
|
class RefuseFenceSync {
|
|
public:
|
|
RefuseFenceSync() noexcept : saved_(plot::gl::glFenceSync) { plot::gl::glFenceSync = &refuse; }
|
|
~RefuseFenceSync() { plot::gl::glFenceSync = saved_; }
|
|
RefuseFenceSync(const RefuseFenceSync&) = delete;
|
|
RefuseFenceSync& operator=(const RefuseFenceSync&) = delete;
|
|
|
|
private:
|
|
static plot::gl::GLsync refuse(plot::gl::GLenum, plot::gl::GLbitfield) noexcept {
|
|
return nullptr;
|
|
}
|
|
decltype(plot::gl::glFenceSync) saved_;
|
|
};
|
|
|
|
/// Counts the calls the ring's fence waits make through `glClientWaitSync` -- the
|
|
/// one entry point every wait goes through, whatever the store's own bookkeeping
|
|
/// says. The test-only counters in <plot/render/detail/ring_stats.hpp> report how
|
|
/// often a store decides to wait; this reports what reached the driver, so the two
|
|
/// cannot agree by construction.
|
|
u64 g_fence_wait_calls = 0;
|
|
decltype(plot::gl::glClientWaitSync) g_fence_wait_saved = nullptr;
|
|
|
|
plot::gl::GLenum count_fence_wait(plot::gl::GLsync sync, plot::gl::GLbitfield flags,
|
|
plot::gl::GLuint64 timeout) {
|
|
++g_fence_wait_calls;
|
|
return g_fence_wait_saved(sync, flags, timeout);
|
|
}
|
|
|
|
class CountFenceWaits {
|
|
public:
|
|
CountFenceWaits() noexcept : saved_(plot::gl::glClientWaitSync) {
|
|
g_fence_wait_saved = saved_;
|
|
plot::gl::glClientWaitSync = &count_fence_wait;
|
|
}
|
|
~CountFenceWaits() { plot::gl::glClientWaitSync = saved_; }
|
|
CountFenceWaits(const CountFenceWaits&) = delete;
|
|
CountFenceWaits& operator=(const CountFenceWaits&) = delete;
|
|
|
|
/// Calls made since the counter was installed.
|
|
u64 calls() const noexcept { return g_fence_wait_calls; }
|
|
|
|
private:
|
|
decltype(plot::gl::glClientWaitSync) saved_;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
PLOT_TEST(renders_visible_trace_across_viewport) {
|
|
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(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, 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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
dump_if_requested(r, "smoke.ppm");
|
|
|
|
// Trace colour must appear in every one of 16 sampled columns across the plot
|
|
// area; background must dominate the corners.
|
|
const plot::Rect a = p.layout().axes;
|
|
PLOT_CHECK(covered_columns(ctx, a, 16) >= 15); // >= 95% of columns covered
|
|
|
|
plot::u8 corner[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(2, 2, corner));
|
|
PLOT_CHECK(corner[0] + corner[1] + corner[2] < 200); // dark background
|
|
|
|
// The 1M-sample window is ~1700 samples per pixel, so it must have gone
|
|
// through a pyramid level rather than through raw samples.
|
|
PLOT_CHECK(r.stats().lod_traces == 1);
|
|
PLOT_CHECK(r.stats().raw_traces == 0);
|
|
PLOT_CHECK(r.stats().staged_segments < 10000); // O(pixels), not O(samples)
|
|
|
|
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;
|
|
PLOT_CHECK(ctx.create(&st));
|
|
PLOT_CHECK(plot::gl::load(nullptr));
|
|
plot::Renderer r;
|
|
PLOT_CHECK(r.init(&st));
|
|
|
|
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, 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});
|
|
|
|
auto covered = [&](plot::Range xr) -> plot::u32 {
|
|
p.set_x_range(ax, xr);
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
return covered_columns(ctx, p.layout().axes, 32);
|
|
};
|
|
|
|
// Sparse first: 640 samples over 640 x-units -> ~1 sample/pixel -> raw polyline.
|
|
const plot::u32 fine = covered({0.0, 640.0});
|
|
PLOT_CHECK(fine >= 30); // >= 95% of 32 columns
|
|
PLOT_CHECK(r.stats().raw_traces == 1 && r.stats().lod_traces == 0);
|
|
const plot::u32 fine_segments = r.stats().staged_segments;
|
|
const plot::u64 fine_samples = r.stats().visible_samples;
|
|
|
|
// Then dense: 1M samples over 1M x-units -> ~1700 samples/pixel -> LOD buckets.
|
|
// It stages several times what the sparse frame did, so on the persistent path
|
|
// this frame also grows the mapped ring -- re-creating the store, dropping the
|
|
// fence of the draw that may still be reading it and moving the attribute
|
|
// offsets -- rather than only ever allocating on a renderer's first frame.
|
|
const plot::u32 coarse = covered({0.0, 1'000'000.0});
|
|
PLOT_CHECK(coarse >= 30);
|
|
PLOT_CHECK(r.stats().lod_traces == 1 && r.stats().raw_traces == 0);
|
|
const plot::u32 coarse_segments = r.stats().staged_segments;
|
|
const plot::u64 coarse_samples = r.stats().visible_samples;
|
|
|
|
// The two windows really did take different paths: the dense one saw orders of
|
|
// magnitude more samples while staging far fewer segments than it saw samples,
|
|
// which is the whole point of the LOD pyramid. The sparse one staged the window
|
|
// itself.
|
|
PLOT_CHECK(coarse_samples > 100 * fine_samples);
|
|
PLOT_CHECK(coarse_segments < 10000);
|
|
PLOT_CHECK(coarse_segments * 100 < coarse_samples);
|
|
PLOT_CHECK(fine_segments + 1 >= fine_samples); // one segment per sample pair
|
|
|
|
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
|
|
// in the plot's top-left pixel space, and the renderer shifts them by the
|
|
// viewport origin exactly once.
|
|
PLOT_TEST(clears_only_the_viewport_region) {
|
|
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(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
const plot::Rect vp{80.0f, 40.0f, 400.0f, 240.0f};
|
|
p.set_viewport(vp);
|
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, 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});
|
|
p.set_x_range(ax, {0.0, 300.0}); // ~1 sample / pixel: every column is crossed
|
|
|
|
// The host paints its whole surface first; the frame must leave all of it that
|
|
// is outside the viewport alone.
|
|
plot::gl::glClearColor(0.5f, 0.5f, 0.5f, 1.0f);
|
|
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
|
|
|
|
r.begin_frame(kW, kH, vp);
|
|
r.draw(p);
|
|
r.end_frame();
|
|
|
|
plot::u8 px[4] = {};
|
|
auto gl_y = [](plot::f32 top) { return static_cast<plot::u32>(kH) - 1u - static_cast<plot::u32>(top); };
|
|
|
|
// Left of the viewport, below it and past its right edge: untouched host grey.
|
|
PLOT_CHECK(ctx.read_pixel(10, 10, px));
|
|
PLOT_CHECK(px[0] > 100 && px[0] < 160 && px[1] > 100 && px[2] > 100);
|
|
PLOT_CHECK(ctx.read_pixel(20, gl_y(300.0f), px));
|
|
PLOT_CHECK(px[0] > 100 && px[0] < 160);
|
|
PLOT_CHECK(ctx.read_pixel(600, 300, px));
|
|
PLOT_CHECK(px[0] > 100 && px[0] < 160);
|
|
|
|
// Inside the viewport, above the axes: the renderer's own background.
|
|
const plot::Rect a = p.layout().axes;
|
|
PLOT_CHECK(a.x >= vp.x && a.right() <= vp.right() && a.y >= vp.y && a.bottom() <= vp.bottom());
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(vp.x) + 2u, gl_y(vp.y + 2.0f), px));
|
|
PLOT_CHECK(px[0] + px[1] + px[2] < 200);
|
|
|
|
// And the trace is inside the axes rect in framebuffer coordinates, so the
|
|
// viewport origin was subtracted exactly once.
|
|
PLOT_CHECK(covered_columns(ctx, a, 16) >= 15);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
ctx.destroy();
|
|
}
|
|
|
|
// The overlay layer: cursor markers in their own colours, the shaded measurement
|
|
// band and its readout, and an annotation pin. These are the pixels the adapters'
|
|
// demos are read for, and none of them goes through the trace path.
|
|
PLOT_TEST(draws_cursors_measure_band_and_annotations) {
|
|
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(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
|
const plot::u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: a flat, cheap frame
|
|
|
|
// Colours no other element of the frame uses, so each assertion below can only
|
|
// be satisfied by the element it names.
|
|
const plot::u32 ca = p.add_cursor(100.0, plot::Color::hex(0x00FF00)); // green
|
|
const plot::u32 cb = p.add_cursor(300.0, plot::Color::hex(0xFF00FF)); // magenta
|
|
PLOT_CHECK(p.add_measure(ca, cb, tr) == 0);
|
|
plot::Annotation pin;
|
|
pin.x = 200.0;
|
|
pin.y = 0.5;
|
|
pin.text = "A";
|
|
pin.color = plot::Color::hex(0xE8E8E8);
|
|
pin.axis_x = ax;
|
|
pin.axis_y = ay;
|
|
p.add_annotation(pin);
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
const plot::Rect a = p.layout().axes;
|
|
const plot::Transform tx = p.x_transform(ax);
|
|
const plot::Transform ty = p.y_transform(ay);
|
|
|
|
// Scans one column of the axes rect, in top-down rows like every Rect.
|
|
auto column_has = [&](plot::u32 x, bool (*pred)(const plot::u8*)) {
|
|
for (plot::u32 row = 0; row < static_cast<plot::u32>(a.h); ++row) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - static_cast<plot::u32>(a.y) - row, px));
|
|
if (pred(px)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
// Scans a square around one framebuffer position, clipped to the framebuffer so
|
|
// a square that reaches an edge simply covers fewer pixels.
|
|
auto box_has = [&](plot::f32 fx, plot::f32 fy, plot::i32 radius,
|
|
bool (*pred)(const plot::u8*)) {
|
|
const plot::i32 cx = static_cast<plot::i32>(fx);
|
|
const plot::i32 cy = static_cast<plot::i32>(fy);
|
|
for (plot::i32 row = cy - radius; row <= cy + radius; ++row) {
|
|
if (row < 0 || row >= static_cast<plot::i32>(kH)) continue;
|
|
for (plot::i32 column = cx - radius; column <= cx + radius; ++column) {
|
|
if (column < 0 || column >= static_cast<plot::i32>(kW)) continue;
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(column),
|
|
ctx.height - 1u - static_cast<plot::u32>(row), px));
|
|
if (pred(px)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const plot::u32 x_a = static_cast<plot::u32>(tx.to_screen_x(100.0));
|
|
const plot::u32 x_b = static_cast<plot::u32>(tx.to_screen_x(300.0));
|
|
const plot::u32 mid = static_cast<plot::u32>(tx.to_screen_x(200.0));
|
|
PLOT_CHECK(column_has(x_a, green_pixel)); // the cursor markers themselves
|
|
PLOT_CHECK(column_has(x_b, magenta_pixel));
|
|
// Nothing of either colour where neither cursor is.
|
|
PLOT_CHECK(!column_has(mid, green_pixel));
|
|
PLOT_CHECK(!column_has(mid, magenta_pixel));
|
|
|
|
// The measurement band tints the columns between the cursors and only those.
|
|
PLOT_CHECK(column_has(mid, band_pixel));
|
|
PLOT_CHECK(!column_has(static_cast<plot::u32>(tx.to_screen_x(40.0)), band_pixel));
|
|
// ... with its numeric readout drawn near the top of it.
|
|
PLOT_CHECK(box_has(static_cast<plot::f32>(mid), a.y + 8.0f, 20, text_pixel));
|
|
|
|
// The annotation's cross pins data (200, 0.5) in its own colour.
|
|
PLOT_CHECK(box_has(static_cast<plot::f32>(tx.to_screen_x(200.0)),
|
|
static_cast<plot::f32>(ty.to_screen_y(0.5)), 4, marker_pixel));
|
|
|
|
ctx.destroy();
|
|
}
|
|
|
|
// Finding 2 of the review: `draw_titles()` placed both titles inside
|
|
// `layout.axes`, because the layout reserved no room outside it for them, so in
|
|
// the dense frame -- whose envelope spans every column's full height -- a title
|
|
// sat on the trace and no pixel test could see it. The layout now insets `axes` by
|
|
// a strip per direction and the titles are drawn inside those strips, which is
|
|
// what makes a title over the data impossible. This draws known titles over that
|
|
// same dense envelope and asserts both halves: no title-coloured pixel in a band
|
|
// just inside either horizontal edge of `axes`, and the title colour inside both
|
|
// strips. The colour is the style's own `axis_text` -- what the text pipeline is
|
|
// tinted with -- never a guess, and no other element of the frame can match it
|
|
// (the trace is blue-dominant, the grid grey-dark, the axis line mid-grey).
|
|
PLOT_TEST(titles_stay_out_of_the_axes_band) {
|
|
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(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, true, 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<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "titles.ppm");
|
|
|
|
const plot::PlotLayout& layout = p.layout();
|
|
const plot::Rect& a = layout.axes;
|
|
const plot::Color title = r.style().axis_text;
|
|
|
|
// Reads a Rect -- whose rows run top-down, like every Rect here -- and reports
|
|
// whether any pixel in it is the title colour.
|
|
auto area_has_title = [&](const plot::Rect& area) {
|
|
for (plot::u32 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
|
|
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
|
|
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px));
|
|
if (pixel_is(px, title)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// One text line plus the two pixels the old placement inset itself by: tall
|
|
// enough for the whole title line that used to be drawn there.
|
|
const plot::f32 band = r.axis_font_px() + 2.0f;
|
|
PLOT_CHECK(!area_has_title({a.x, a.y, a.w, band}));
|
|
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}));
|
|
|
|
// ... while the strips the layout reserved hold both titles.
|
|
PLOT_CHECK(layout.x_title_area.h > 0.0f);
|
|
PLOT_CHECK(layout.y_title_area.w > 0.0f);
|
|
PLOT_CHECK(layout.axes.bottom() <= layout.x_title_area.y); // outside the data
|
|
PLOT_CHECK(area_has_title(layout.x_title_area));
|
|
PLOT_CHECK(area_has_title(layout.y_title_area));
|
|
ctx.destroy();
|
|
}
|
|
|
|
// Finding 1 of the review: when the mapping failed, the store re-created its
|
|
// buffer as the orphan path's -- and nothing told the vertex array, so it stayed
|
|
// bound to a buffer whose attribute bindings `glDeleteBuffers` had already
|
|
// cleared, and every later frame drew nothing at all. The epoch now carries the
|
|
// buffer's identity, so the array re-points itself before the draw. The refusal is
|
|
// forced through the entry point (`glMapBufferRange` returns null while the caps
|
|
// and the pointer still advertise the mapped path, which is exactly the state the
|
|
// fallback exists for), and the frame after it must still rasterise the trace.
|
|
PLOT_TEST(mapped_ring_fallback_still_draws_after_refused_map) {
|
|
plottest::HeadlessContext ctx(kW, kH);
|
|
plot::Status st;
|
|
PLOT_CHECK(ctx.create(&st));
|
|
PLOT_CHECK(plot::gl::load(nullptr));
|
|
|
|
plot::Renderer r;
|
|
{
|
|
RefusePersistentMap guard; // before init(): the mapped path is still selected
|
|
PLOT_CHECK(r.init(&st));
|
|
const bool forced = r.using_persistent_upload();
|
|
|
|
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.set_x_range(ax, {0.0, 640.0}); // one sample per pixel: an unmistakable polyline
|
|
|
|
// The first upload is the one that grows the ring, so it is the one whose map
|
|
// is refused and which ends up on the orphan buffer.
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
// Geometry first: this is the assertion the deleted buffer's attribute
|
|
// bindings broke, leaving the draw with no attributes to read at all.
|
|
PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15);
|
|
if (forced) {
|
|
PLOT_CHECK(!r.using_persistent_upload()); // the fallback really ran
|
|
}
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
// ... and the frame after it, which is where the deleted buffer's attribute
|
|
// bindings used to leave the pipeline with nothing bound at all.
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
}
|
|
ctx.destroy();
|
|
}
|
|
|
|
// Fix pass 2, finding 1: the reserved strips were clamped only to the viewport, so
|
|
// a y title wider than a narrow panel took the rest of it and `axes` collapsed to
|
|
// no width -- and `draw_traces()` returns on a zero-width axes, so the frame drew
|
|
// no trace at all and reported nothing. The chrome is capped now (the axes keeps
|
|
// half the panel, the title strip takes what is left of that allowance, and the
|
|
// title is clipped to the strip), so a narrow panel still draws its data. The
|
|
// titles here are long on purpose: ~24 glyphs against a 240 px panel is past the
|
|
// point where the reserve used to consume the plot.
|
|
PLOT_TEST(narrow_panel_with_a_long_y_title_still_draws) {
|
|
constexpr plot::u32 kPanelW = 240;
|
|
constexpr plot::u32 kPanelH = 200;
|
|
plottest::HeadlessContext ctx(kPanelW, kPanelH);
|
|
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(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kPanelW), static_cast<plot::f32>(kPanelH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay =
|
|
p.add_y_axis({"millivolts per division", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.set_x_range(ax, {0.0, 150.0}); // a sample every few pixels: one polyline, all columns
|
|
|
|
r.begin_frame(kPanelW, kPanelH,
|
|
{0, 0, static_cast<plot::f32>(kPanelW), static_cast<plot::f32>(kPanelH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
const plot::PlotLayout& layout = p.layout();
|
|
// The chrome gave way, not the plot: the axes kept its half of the panel...
|
|
PLOT_CHECK(layout.axes.w >= 0.5f * static_cast<plot::f32>(kPanelW));
|
|
PLOT_CHECK(layout.axes.h > 0.0f);
|
|
// ... and the title is still drawn, inside the strip it was cut to.
|
|
PLOT_CHECK(layout.y_title_area.w > 0.0f);
|
|
PLOT_CHECK(covered_columns(ctx, layout.axes, 16) >= 15);
|
|
|
|
const plot::Color title = r.style().axis_text;
|
|
auto area_has_title = [&](const plot::Rect& area) {
|
|
for (plot::u32 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
|
|
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
|
|
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px));
|
|
if (pixel_is(px, title)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
PLOT_CHECK(area_has_title(layout.y_title_area)); // present, though cut short
|
|
PLOT_CHECK(!area_has_title(layout.axes)); // and never over the data
|
|
ctx.destroy();
|
|
}
|
|
|
|
// The same finding in the x direction: `x_title_height` collapsed `axes.h` on a
|
|
// short panel, and the old guard tested only the width, so every trace was
|
|
// flattened onto one row instead of being skipped where a caller could see it.
|
|
// A 48 px panel is the case where the two strips used to be all of it.
|
|
PLOT_TEST(short_panel_with_an_x_title_still_draws) {
|
|
constexpr plot::u32 kPanelW = 320;
|
|
constexpr plot::u32 kPanelH = 48;
|
|
plottest::HeadlessContext ctx(kPanelW, kPanelH);
|
|
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(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kPanelW), static_cast<plot::f32>(kPanelH)});
|
|
const plot::u32 ax =
|
|
p.add_x_axis({"time since the first sample, in seconds", s.x_bounds(), true,
|
|
plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.set_x_range(ax, {0.0, 150.0});
|
|
|
|
r.begin_frame(kPanelW, kPanelH,
|
|
{0, 0, static_cast<plot::f32>(kPanelW), static_cast<plot::f32>(kPanelH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
const plot::PlotLayout& layout = p.layout();
|
|
PLOT_CHECK(layout.axes.h >= 0.5f * static_cast<plot::f32>(kPanelH));
|
|
PLOT_CHECK(layout.axes.w > 0.0f);
|
|
PLOT_CHECK(covered_columns(ctx, layout.axes, 16) >= 15);
|
|
ctx.destroy();
|
|
}
|
|
|
|
// Fix pass 2, finding 2: every titled y axis was staged at the same centred
|
|
// position inside the one strip, so a Left "volts" and a Right "amps" drew over
|
|
// each other -- the overprint the pre-strip placement did not have. Each titled
|
|
// axis now owns a text line, so a titled pair of either direction shows two
|
|
// separated runs of title-coloured pixels where it used to show one.
|
|
PLOT_TEST(titled_axis_pairs_get_their_own_rows) {
|
|
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(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
|
p.add_x_axis({"samples", s.x_bounds(), true, plot::AxisPlace::Top, false});
|
|
const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
p.add_y_axis({"amps", {-1.0, 1.0}, true, plot::AxisPlace::Right, false});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
dump_if_requested(r, "pairs.ppm");
|
|
|
|
const plot::PlotLayout& layout = p.layout();
|
|
const plot::Color title = r.style().axis_text;
|
|
// The rows of one strip that hold title-coloured pixels, as the runs of rows
|
|
// they form: one run per titled axis, because the two runs of a pair are a line
|
|
// apart and each covers several pixel rows of glyph ink.
|
|
auto row_bands = [&](const plot::Rect& strip) {
|
|
plot::u32 bands = 0;
|
|
bool previous = false;
|
|
plot::u32 rows = 0;
|
|
for (plot::u32 row = 0; row < static_cast<plot::u32>(strip.h); ++row) {
|
|
bool has = false;
|
|
for (plot::u32 column = 0; column < static_cast<plot::u32>(strip.w) && !has; ++column) {
|
|
plot::u8 px[4] = {};
|
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(strip.x) + column,
|
|
ctx.height - 1u - (static_cast<plot::u32>(strip.y) + row), px));
|
|
has = pixel_is(px, title);
|
|
}
|
|
if (has && !previous) ++bands;
|
|
if (has) ++rows;
|
|
previous = has;
|
|
}
|
|
PLOT_CHECK(rows >= 4u); // real glyph ink, not one stray pixel
|
|
return bands;
|
|
};
|
|
|
|
PLOT_CHECK(layout.x_title_area.h > 0.0f);
|
|
PLOT_CHECK(layout.y_title_area.w > 0.0f);
|
|
PLOT_CHECK(row_bands(layout.x_title_area) == 2u); // "seconds" above "samples"
|
|
PLOT_CHECK(row_bands(layout.y_title_area) == 2u); // "volts" above "amps"
|
|
ctx.destroy();
|
|
}
|
|
|
|
// Finding 4 of the review: `mark_drawn()` dropped a failed fence's status, so the
|
|
// slot stayed unsynchronised and the next upload memcpy'd into storage the GPU may
|
|
// still have been reading -- exactly the tearing the ring exists to prevent. A
|
|
// refused fence now drops the ring for the orphan path and reports it, instead of
|
|
// rotating through a slot nothing guards. `glFenceSync` is stubbed to hand back no
|
|
// sync object while the caps still advertise the mapped path, so the fence is the
|
|
// only thing that fails.
|
|
PLOT_TEST(refused_fence_drops_the_mapped_ring) {
|
|
plottest::HeadlessContext ctx(kW, kH);
|
|
plot::Status st;
|
|
PLOT_CHECK(ctx.create(&st));
|
|
PLOT_CHECK(plot::gl::load(nullptr));
|
|
|
|
plot::Renderer r;
|
|
{
|
|
RefuseFenceSync guard;
|
|
PLOT_CHECK(r.init(&st));
|
|
const bool forced = r.using_persistent_upload();
|
|
|
|
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.set_x_range(ax, {0.0, 640.0});
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
if (forced) {
|
|
// The frame whose slot could not be fenced has stopped using the ring: the
|
|
// next upload must not write storage the draw may still be reading, and the
|
|
// frame reports the failure rather than swallowing it.
|
|
PLOT_CHECK(!r.using_persistent_upload());
|
|
PLOT_CHECK(r.last_frame_had_gl_error());
|
|
}
|
|
PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15); // it still drew
|
|
|
|
// The next frames draw through the orphan path, which needs no fence.
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(covered_columns(ctx, p.layout().axes, 16) >= 15);
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
}
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// The ring must absorb a frame that issues more uploads than it starts with
|
|
/// slots. The shape it replaced -- three slots and a fence per upload -- wrapped
|
|
/// within such a frame: from the fourth upload on, each one waited on a fence
|
|
/// recorded a few draws *earlier in the same frame*, i.e. an effective per-frame
|
|
/// GPU sync. That is free when the driver retires draws promptly (the GLFW host,
|
|
/// 1.07 ms a frame) and ~4 ms of the Qt widget's 7.11 ms under llvmpipe, which is
|
|
/// exactly the difference `PLOT_FORCE_GL33_UPLOAD=1` shows -- and it is a cliff
|
|
/// that grows with the trace and cursor counts this library exists for.
|
|
///
|
|
/// The ring now grows to cover the frame's uploads, records one fence after the
|
|
/// frame's last draw, and waits once, at the start of the next frame, so a frame
|
|
/// never synchronises with work it issued itself. None of that is visible in the
|
|
/// pixels -- a syncing ring draws the same image -- so the test pins the three
|
|
/// properties directly: a frame writes distinct slots (the offsets it hands the
|
|
/// vertex array), the ring grows past its floor to cover them, and a frame
|
|
/// performs at most one wait.
|
|
PLOT_TEST(ring_grows_to_cover_a_frame_and_waits_once_at_its_start) {
|
|
plottest::HeadlessContext ctx(kW, kH);
|
|
plot::Status st;
|
|
PLOT_CHECK(ctx.create(&st));
|
|
PLOT_CHECK(plot::gl::load(nullptr));
|
|
|
|
plot::render::detail::InstanceStore store;
|
|
PLOT_CHECK(store.create(plot::render::UploadMode::PersistentMapped, &st));
|
|
if (!store.persistent()) {
|
|
ctx.destroy(); // no persistent-mapping path on this driver: nothing to pin
|
|
return;
|
|
}
|
|
|
|
constexpr plot::u32 kUploadsPerFrame = 6; // more than the ring's three-slot floor
|
|
plot::f32 data[4 * kUploadsPerFrame];
|
|
for (plot::u32 i = 0; i < 4 * kUploadsPerFrame; ++i) data[i] = static_cast<plot::f32>(i);
|
|
|
|
auto counter = [](const std::atomic<plot::u64>& c) {
|
|
return c.load(std::memory_order_relaxed);
|
|
};
|
|
plot::render::detail::ring_uploads().store(0, std::memory_order_relaxed);
|
|
plot::render::detail::ring_waits().store(0, std::memory_order_relaxed);
|
|
plot::render::detail::ring_slots().store(0, std::memory_order_relaxed);
|
|
|
|
// The first frame has no fence outstanding, so it must not wait at all -- and it
|
|
// must have grown the ring past its floor, because six uploads do not fit in
|
|
// three slots.
|
|
PLOT_CHECK(store.begin_frame());
|
|
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
|
|
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
|
|
}
|
|
PLOT_CHECK(store.mark_drawn());
|
|
PLOT_CHECK(counter(plot::render::detail::ring_uploads()) == kUploadsPerFrame);
|
|
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 0);
|
|
PLOT_CHECK(counter(plot::render::detail::ring_slots()) > store.kInitialSlots);
|
|
PLOT_CHECK(counter(plot::render::detail::ring_slots()) >= kUploadsPerFrame);
|
|
|
|
// The second frame, measured upload by upload. The ring covers it now, so the
|
|
// slot each upload lands in -- the offset the vertex array re-points at -- is
|
|
// readable, and the invariant is the offsets being distinct: a slot written
|
|
// twice in a frame would be one the GPU could still be reading the first copy
|
|
// from, because the only fence is the one at the end of the frame.
|
|
PLOT_CHECK(store.begin_frame());
|
|
plot::usize offsets[kUploadsPerFrame] = {};
|
|
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
|
|
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
|
|
offsets[i] = store.slot_offset();
|
|
}
|
|
PLOT_CHECK(store.mark_drawn());
|
|
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
|
|
for (plot::u32 j = i + 1u; j < kUploadsPerFrame; ++j) {
|
|
PLOT_CHECK(offsets[i] != offsets[j]); // no slot reused within the frame
|
|
}
|
|
}
|
|
// ... and this frame waited exactly once: on the fence the first one recorded,
|
|
// never on its own work.
|
|
PLOT_CHECK(counter(plot::render::detail::ring_uploads()) == 2u * kUploadsPerFrame);
|
|
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 1);
|
|
|
|
// A third frame the same, so the wait is per frame and not per upload: six
|
|
// uploads a frame would be five waits a frame under the shape this replaced.
|
|
PLOT_CHECK(store.begin_frame());
|
|
for (plot::u32 i = 0; i < kUploadsPerFrame; ++i) {
|
|
store.upload(data, kUploadsPerFrame, sizeof(plot::render::LineInstance));
|
|
}
|
|
PLOT_CHECK(store.mark_drawn());
|
|
PLOT_CHECK(counter(plot::render::detail::ring_waits()) == 2);
|
|
ctx.destroy();
|
|
}
|
|
|
|
/// The same property at the level the demos run at. A frame of this plot stages
|
|
/// what the demos stage -- two grid colour groups, the axis lines, tick labels and
|
|
/// titles, the trace, two cursors and the measurement band -- nine-ish uploads, or
|
|
/// three times the slots the ring used to have, so the old ring wrapped within the
|
|
/// frame several times over. The frame must absorb them with at most one wait per
|
|
/// store (two stores), and the upload counter must agree with the draw calls the
|
|
/// frame reports, so it is counting this frame's staging and nothing else.
|
|
///
|
|
/// The wait is checked twice over: once from the store's own counter, and once at
|
|
/// `glClientWaitSync`, the call every wait is made through. A nine-upload frame
|
|
/// under the shape this replaced made six mid-frame wait sites, each costing the
|
|
/// non-blocking probe plus the bounded wait behind it.
|
|
PLOT_TEST(a_dense_frame_uploads_more_than_the_ring_floor_without_waiting) {
|
|
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));
|
|
if (!r.using_persistent_upload()) {
|
|
ctx.destroy(); // no persistent-mapping path on this driver: nothing to pin
|
|
return;
|
|
}
|
|
|
|
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
|
plot::Plot p;
|
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
|
const plot::u32 tr = p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
|
p.set_x_range(ax, {0.0, 640.0});
|
|
const plot::u32 ca = p.add_cursor(100.0, plot::Color::hex(0x00FF00));
|
|
const plot::u32 cb = p.add_cursor(300.0, plot::Color::hex(0xFF00FF));
|
|
PLOT_CHECK(p.add_measure(ca, cb, tr) == 0);
|
|
|
|
auto counter = [](const std::atomic<plot::u64>& c) {
|
|
return c.load(std::memory_order_relaxed);
|
|
};
|
|
plot::render::detail::ring_uploads().store(0, std::memory_order_relaxed);
|
|
plot::render::detail::ring_waits().store(0, std::memory_order_relaxed);
|
|
plot::render::detail::ring_slots().store(0, std::memory_order_relaxed);
|
|
CountFenceWaits driver_waits; // installed after load(), restored with the test
|
|
|
|
for (plot::u32 index = 0; index < 3u; ++index) {
|
|
const plot::u64 uploads_before = counter(plot::render::detail::ring_uploads());
|
|
const plot::u64 waits_before = counter(plot::render::detail::ring_waits());
|
|
const plot::u64 calls_before = driver_waits.calls();
|
|
|
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
|
r.draw(p);
|
|
r.end_frame();
|
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
|
|
|
const plot::u64 uploads = counter(plot::render::detail::ring_uploads()) - uploads_before;
|
|
const plot::u64 waits = counter(plot::render::detail::ring_waits()) - waits_before;
|
|
// Every upload lands in a draw this frame reported, so the counter describes
|
|
// this frame's staging and no other store's.
|
|
PLOT_CHECK(uploads == r.stats().draw_calls);
|
|
// The scenario: more uploads than the ring's floor of slots.
|
|
PLOT_CHECK(uploads > plot::render::detail::InstanceStore::kInitialSlots);
|
|
// ... and not one of them waited: the only wait is the next frame's start,
|
|
// one per store.
|
|
PLOT_CHECK(waits <= 2);
|
|
if (index == 0) PLOT_CHECK(waits == 0);
|
|
// At the driver call too: one wait site per store, and a site costs at most
|
|
// two calls -- the non-blocking probe, then the bounded wait behind it. Six
|
|
// uploads past the old ring's three slots used to cost six sites on their own,
|
|
// inside the frame that made them.
|
|
const plot::u64 calls = driver_waits.calls() - calls_before;
|
|
PLOT_CHECK(calls <= 4u);
|
|
if (index == 0) PLOT_CHECK(calls == 0u);
|
|
}
|
|
// The ring really grew past its floor: what covers a nine-upload frame is not
|
|
// what three slots covered.
|
|
PLOT_CHECK(counter(plot::render::detail::ring_slots()) >
|
|
plot::render::detail::InstanceStore::kInitialSlots);
|
|
ctx.destroy();
|
|
}
|