307 lines
14 KiB
C++
307 lines
14 KiB
C++
// The reference application: the whole library running as a real program, fed by
|
|
// a 1 MS/s producer thread through the GLFW adapter.
|
|
//
|
|
// What it demonstrates, and what the other three adapters copy:
|
|
//
|
|
// - a `StreamSeries` at the retention limit (1 << 20 samples, 1.048576 s at this
|
|
// rate) pushed from a background thread and acquired exactly once per frame on
|
|
// the render thread;
|
|
// - a signal whose decimation correctness is visible: a 437 Hz sine plus a small
|
|
// noise term plus a narrow spike every 100 ms. A bucket that kept only its
|
|
// first sample would drop the spike; a bucket that keeps its extrema cannot;
|
|
// - a plot the user can drive: grid, both axes titled and labelled (the time
|
|
// axis with `format_time` clock labels, the value axis with SI ones), one
|
|
// trace, two cursors and the measurement readout the plot layer draws itself
|
|
// (shaded band plus a `dx/dy/rms/mean` line) -- no drawing code here;
|
|
// - the headless smoke mode the adapter implements once
|
|
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), so CI can run this under
|
|
// `xvfb-run` and check the frame it wrote.
|
|
//
|
|
// The producer is joined before the stream it writes into is destroyed, and
|
|
// before the host closes the GL context (main's destruction order does that).
|
|
|
|
#include <glfw/glfw_host.hpp>
|
|
|
|
#include <plot/data/stream_series.hpp>
|
|
#include <plot/plot/ticks.hpp>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <numbers>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
using plot::f32;
|
|
using plot::f64;
|
|
using plot::u32;
|
|
using plot::u64;
|
|
|
|
/// One sample per microsecond: 1 MS/s.
|
|
constexpr f64 kSampleRate = 1.0e6;
|
|
constexpr f64 kDx = 1.0 / kSampleRate;
|
|
/// The retained window: 1 << 20 samples = 1.048576 s. A power of two, so the
|
|
/// ring never has to round it up.
|
|
constexpr u64 kCapacity = 1u << 20;
|
|
/// Producer wakeup: about a millisecond of samples per push batch.
|
|
constexpr u64 kBatch = 1u << 10;
|
|
/// Cap on one push batch, so a process that was stopped and resumed catches up
|
|
/// over several iterations instead of allocating a batch for the whole gap.
|
|
constexpr u64 kMaxBatch = 1u << 16;
|
|
|
|
/// The signal: a few hundred Hz sine, a small noise term, a narrow spike.
|
|
constexpr f64 kSineHz = 437.0;
|
|
constexpr f64 kNoiseAmp = 0.02;
|
|
constexpr f64 kSpikePeriodS = 0.1;
|
|
constexpr f64 kSpikeWidthS = 2.0e-6;
|
|
constexpr f64 kSpikeAmp = 3.0;
|
|
|
|
// The trace colour the render smoke test's blue-dominance predicate expects, so
|
|
// the same predicate reads this demo's screenshot.
|
|
constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
|
|
constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020);
|
|
constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8);
|
|
|
|
/// y(t) for sample index `i`: sine + deterministic noise + a two-sample spike.
|
|
///
|
|
/// The noise hash takes the low 32 bits of the index, so its pattern repeats
|
|
/// every ~4295 s at 1 MS/s -- far beyond any run of this demo, and it keeps the
|
|
/// sample path allocation-free and branch-light, unlike a PRNG.
|
|
f32 sample_at(u64 i) noexcept {
|
|
const f64 t = static_cast<f64>(i) * kDx;
|
|
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
|
|
|
|
u32 h = static_cast<u32>(i) * 2654435761u;
|
|
h ^= h >> 15;
|
|
h *= 2246822519u;
|
|
h ^= h >> 13;
|
|
h *= 3266489917u;
|
|
h ^= h >> 16;
|
|
y += kNoiseAmp * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0);
|
|
|
|
const f64 phase = std::fmod(t, kSpikePeriodS);
|
|
if (phase < kSpikeWidthS) {
|
|
y += kSpikeAmp * (1.0 - phase / kSpikeWidthS);
|
|
}
|
|
return static_cast<f32>(y);
|
|
}
|
|
|
|
/// Feeds a `StreamSeries` from its own thread at 1 MS/s on average, paced by
|
|
/// absolute deadlines off a steady clock: every wakeup produces every sample
|
|
/// whose time has already come, so a late wakeup is caught up instead of pushing
|
|
/// the phase back, and the achieved rate cannot drift the way a fixed
|
|
/// `sleep_for()` per batch does.
|
|
class Producer {
|
|
public:
|
|
explicit Producer(plot::data::StreamSeries& series)
|
|
: series_(&series), thread_([this] { run(); }) {}
|
|
~Producer() { stop(); }
|
|
|
|
Producer(const Producer&) = delete;
|
|
Producer& operator=(const Producer&) = delete;
|
|
|
|
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls
|
|
/// it, so the thread is never detached and never outlives the stream.
|
|
void stop() noexcept {
|
|
stop_.store(true, std::memory_order_relaxed);
|
|
if (thread_.joinable()) thread_.join();
|
|
}
|
|
|
|
/// Samples pushed so far. Written by the producer, read by the render thread.
|
|
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
|
|
|
|
private:
|
|
void run() {
|
|
using clock = std::chrono::steady_clock;
|
|
const auto start = clock::now();
|
|
u64 next = 0;
|
|
while (!stop_.load(std::memory_order_relaxed)) {
|
|
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
|
|
const u64 due = static_cast<u64>(elapsed / kDx) + 1; // samples whose time has come
|
|
if (due > next) {
|
|
u64 n = due - next;
|
|
if (n > kMaxBatch) n = kMaxBatch;
|
|
batch_.resize(static_cast<plot::usize>(n));
|
|
for (u64 k = 0; k < n; ++k) {
|
|
batch_[static_cast<plot::usize>(k)] = sample_at(next + k);
|
|
}
|
|
series_->push(batch_.data(), n);
|
|
next += n;
|
|
produced_.store(next, std::memory_order_relaxed);
|
|
}
|
|
// Sleep until one batch past the last produced sample: an absolute
|
|
// deadline, so the phase is fixed at `start` and never accumulates.
|
|
const f64 wake_s = static_cast<f64>(next + kBatch) * kDx;
|
|
std::this_thread::sleep_until(
|
|
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(wake_s)));
|
|
}
|
|
}
|
|
|
|
plot::data::StreamSeries* series_;
|
|
std::vector<f32> batch_;
|
|
std::atomic<u64> produced_{0};
|
|
std::atomic<bool> stop_{false};
|
|
std::thread thread_; ///< declared last: the lambda runs once everything is up
|
|
};
|
|
|
|
/// The live readout: the achieved push rate, the frame time and the frame count.
|
|
struct Meter {
|
|
f64 rate = 0.0; ///< samples/s, refreshed a few times a second
|
|
f64 frame_ms = 0.0; ///< exponential moving average of the frame time
|
|
f64 elapsed = 0.0; ///< seconds of frame callback time
|
|
f64 last_report = 0.0;
|
|
u64 last_produced = 0;
|
|
u64 frames = 0;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
plot::glfw::Host host;
|
|
plot::data::StreamSeries stream(kCapacity, 0.0, kDx);
|
|
Producer producer(stream);
|
|
|
|
plot::Plot& p = host.plot();
|
|
// Time axis: clock labels, which `TickFormat::Time` routes through
|
|
// `format_time`. The value axis keeps SI labels, which is `format_si`.
|
|
const u32 axis_x = p.add_x_axis(
|
|
{"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
|
const u32 axis_y = p.add_y_axis(
|
|
{"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
|
const u32 trace = p.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y});
|
|
|
|
// Snapped cursors sit exactly on a sample, so the readout measures samples
|
|
// rather than a rounded position between two of them.
|
|
const u32 cursor_a = p.add_cursor(0.0, kCursorAColor);
|
|
const u32 cursor_b = p.add_cursor(0.0, kCursorBColor);
|
|
p.set_cursor_snap(cursor_a, plot::SnapMode::NearestSample);
|
|
p.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample);
|
|
const u32 measure = p.add_measure(cursor_a, cursor_b, trace);
|
|
|
|
Meter meter;
|
|
// The cursors ride the live window until the user takes over: the first
|
|
// ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale,
|
|
// stops them where the user put them.
|
|
bool cursors_follow = true;
|
|
|
|
const plot::glfw::FrameFn frame = [&](plot::Plot& graph, plot::f64 dt) {
|
|
stream.acquire(); // once per frame, on the render thread
|
|
|
|
// Re-frame the live stream while the user has not overridden the ranges.
|
|
// `auto_fit_*` re-arms auto scale, so it may only run while auto scale is
|
|
// still on: calling it after a pan would undo the pan every frame.
|
|
if (graph.x_axis(axis_x).auto_scale) graph.auto_fit_x();
|
|
if (graph.y_axis(axis_y).auto_scale) graph.auto_fit_y(axis_y);
|
|
|
|
if (cursors_follow) {
|
|
if (host.controller().drag_mode() == plot::input::DragMode::MoveCursor ||
|
|
!graph.x_axis(axis_x).auto_scale) {
|
|
cursors_follow = false;
|
|
} else {
|
|
const plot::Range xr = graph.x_range(axis_x);
|
|
graph.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span());
|
|
graph.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span());
|
|
}
|
|
}
|
|
|
|
meter.elapsed += dt;
|
|
meter.frames += 1;
|
|
meter.frame_ms += 0.1 * (dt * 1.0e3 - meter.frame_ms); // EMA
|
|
if (meter.elapsed - meter.last_report >= 0.25) {
|
|
const u64 produced = producer.produced();
|
|
meter.rate = static_cast<f64>(produced - meter.last_produced) /
|
|
(meter.elapsed - meter.last_report);
|
|
meter.last_produced = produced;
|
|
meter.last_report = meter.elapsed;
|
|
|
|
// The title carries the live readout: samples/second, frame time, and the
|
|
// samples per pixel that say which rasterisation path the trace took. The
|
|
// stats are the previous frame's, since this runs before draw().
|
|
const plot::Renderer* r = host.renderer();
|
|
const plot::FrameStats prev = r != nullptr ? r->stats() : plot::FrameStats{};
|
|
const plot::Rect axes = std::as_const(graph).layout().axes;
|
|
const f64 spp = axes.w > 0.0f ? static_cast<f64>(prev.visible_samples) /
|
|
static_cast<f64>(axes.w)
|
|
: 0.0;
|
|
char title[192];
|
|
std::snprintf(title, sizeof title,
|
|
"plot glfw demo | %.3f MS/s | %.2f ms/frame | %.0f samples/px | %s | %llu frames",
|
|
meter.rate * 1.0e-6, meter.frame_ms, spp,
|
|
prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"),
|
|
static_cast<unsigned long long>(meter.frames));
|
|
host.set_title(title);
|
|
|
|
std::printf("[glfw_demo] t=%.1fs %.3f MS/s %.2f ms/frame %.0f samples/px %s\n",
|
|
meter.elapsed, meter.rate * 1.0e-6, meter.frame_ms, spp,
|
|
prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"));
|
|
std::fflush(stdout);
|
|
}
|
|
};
|
|
|
|
// vsync on: this is a real application, and the headless mode below is what
|
|
// makes it verifiable without a display.
|
|
plot::glfw::RunConfig cfg;
|
|
cfg.title = "plot glfw demo";
|
|
cfg.vsync = true;
|
|
|
|
plot::Status st;
|
|
const bool ok = host.run(cfg, frame, &st);
|
|
producer.stop(); // no thread pushes into the stream after this point
|
|
|
|
if (!ok) {
|
|
std::fprintf(stderr, "plot_glfw_demo: %s: %s\n", plot::to_string(st.code), st.message);
|
|
return 1;
|
|
}
|
|
|
|
const plot::Renderer* r = host.renderer();
|
|
const plot::FrameStats stats = r != nullptr ? r->stats() : plot::FrameStats{};
|
|
const plot::Rect axes = std::as_const(p).layout().axes;
|
|
const f64 spp =
|
|
axes.w > 0.0f ? static_cast<f64>(stats.visible_samples) / static_cast<f64>(axes.w) : 0.0;
|
|
const f64 dense = r != nullptr ? static_cast<f64>(r->style().dense_threshold_spp) : 0.0;
|
|
const plot::Measurement m = p.measurement(measure);
|
|
const f64 mean_ms =
|
|
meter.frames > 0 ? meter.elapsed / static_cast<f64>(meter.frames) * 1.0e3 : 0.0;
|
|
const plot::Range yr = stream.y_bounds();
|
|
|
|
std::printf("\n[glfw_demo] ---- run summary ----\n");
|
|
// The rate is the last metered interval, not produced/elapsed: this thread's
|
|
// clock starts when the first frame runs, and the producer starts ~0.4 s
|
|
// earlier, still building the window, so a cumulative division would report a
|
|
// rate the producer never sustained.
|
|
std::printf("[glfw_demo] producer %llu samples pushed, last metered %.3f MS/s\n",
|
|
static_cast<unsigned long long>(producer.produced()), meter.rate * 1.0e-6);
|
|
std::printf("[glfw_demo] frames %llu in %.3f s, mean %.2f ms/frame (%.1f fps),"
|
|
" last metered %.2f ms/frame\n",
|
|
static_cast<unsigned long long>(meter.frames), meter.elapsed, mean_ms,
|
|
mean_ms > 0.0 ? 1.0e3 / mean_ms : 0.0, meter.frame_ms);
|
|
std::printf("[glfw_demo] retained %llu samples, x [%.6f, %.6f] s, y [%.3f, %.3f]\n",
|
|
static_cast<unsigned long long>(stream.size()), stream.x_bounds().min,
|
|
stream.x_bounds().max, yr.min, yr.max);
|
|
std::printf("[glfw_demo] pixel spaces viewport (framebuffer px) %.0fx%.0f, window->framebuffer"
|
|
" scale %.2fx%.2f, content scale %.2fx%.2f\n",
|
|
static_cast<double>(p.viewport().w), static_cast<double>(p.viewport().h),
|
|
static_cast<double>(host.framebuffer_scale_x()),
|
|
static_cast<double>(host.framebuffer_scale_y()),
|
|
static_cast<double>(host.content_scale_x()),
|
|
static_cast<double>(host.content_scale_y()));
|
|
std::printf("[glfw_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s\n", spp,
|
|
dense, stats.lod_traces > 0 ? "LOD pyramid" : "raw samples");
|
|
std::printf("[glfw_demo] last frame visible_samples %llu, axes %.0fx%.0f, staged_segments %u"
|
|
", draw_calls %u, lod %u raw %u\n",
|
|
static_cast<unsigned long long>(stats.visible_samples),
|
|
static_cast<double>(axes.w), static_cast<double>(axes.h), stats.staged_segments,
|
|
stats.draw_calls, stats.lod_traces, stats.raw_traces);
|
|
std::printf("[glfw_demo] measurement %s dx %g s dy %g rms %g mean %g\n",
|
|
m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean);
|
|
std::printf("[glfw_demo] upload path %s, gl error on the last frame %d\n",
|
|
r != nullptr && r->using_persistent_upload() ? "persistent-mapped ring" : "orphan",
|
|
r != nullptr && r->last_frame_had_gl_error() ? 1 : 0);
|
|
|
|
return 0; // ~producer (no-op after stop), ~stream, then ~host closes GL
|
|
}
|