feat(render): Renderer with LOD selection, 3.3 and 4.3+ upload paths, headless smoke test
This commit is contained in:
1 parent
e0cdd95a3b
commit
3555ab63d7
20 files changed
+1544
-25
No files matched your search
@@ -35,6 +35,7 @@ add_executable(plot_tests
|
||||
test_ticks.cpp
|
||||
test_measure.cpp
|
||||
test_plot.cpp
|
||||
test_render_smoke.cpp
|
||||
test_controller.cpp
|
||||
egl_headless.cpp
|
||||
)
|
||||
|
||||
@@ -43,6 +43,30 @@ PLOT_TEST(line_batch_stages_segment_endpoints) {
|
||||
PLOT_CHECK_NEAR(b.data()[7], 1.0, 0.0);
|
||||
}
|
||||
|
||||
// `add_polyline_xy` takes the layout `Series::gather` appends to, so this pins
|
||||
// that a gathered window becomes the segments it says it does -- an off-by-one in
|
||||
// the stride here would draw a diagonal where the data has a corner.
|
||||
PLOT_TEST(line_batch_stages_interleaved_points) {
|
||||
plot::render::LineBatch b;
|
||||
b.begin();
|
||||
const plot::f32 xy[8] = {0.0f, 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f};
|
||||
b.add_polyline_xy(xy, 4); // 3 segments
|
||||
PLOT_CHECK(b.segment_count() == 3);
|
||||
PLOT_CHECK(b.vertex_count() == 6);
|
||||
PLOT_CHECK(b.data().size() == 12);
|
||||
PLOT_CHECK_NEAR(b.data()[0], 0.0, 0.0);
|
||||
PLOT_CHECK_NEAR(b.data()[1], 1.0, 0.0);
|
||||
PLOT_CHECK_NEAR(b.data()[2], 2.0, 0.0);
|
||||
PLOT_CHECK_NEAR(b.data()[3], 3.0, 0.0);
|
||||
PLOT_CHECK_NEAR(b.data()[10], 6.0, 0.0);
|
||||
PLOT_CHECK_NEAR(b.data()[11], 7.0, 0.0);
|
||||
// Fewer than two points, or none: nothing to draw, and nothing to crash on.
|
||||
b.clear();
|
||||
b.add_polyline_xy(xy, 1);
|
||||
b.add_polyline_xy(nullptr, 4);
|
||||
PLOT_CHECK(b.vertex_count() == 0);
|
||||
}
|
||||
|
||||
PLOT_TEST(line_batch_ignores_degenerate_polylines) {
|
||||
plot::render::LineBatch b;
|
||||
b.begin();
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// 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/renderer.hpp>
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
} // 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();
|
||||
}
|
||||
Reference in new issue
Block a user