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
@@ -19,6 +19,7 @@ add_library(plot_core
|
|||||||
src/render/buffer.cpp
|
src/render/buffer.cpp
|
||||||
src/render/vertex_array.cpp
|
src/render/vertex_array.cpp
|
||||||
src/render/program.cpp
|
src/render/program.cpp
|
||||||
|
src/render/renderer.cpp
|
||||||
src/plot/ticks.cpp
|
src/plot/ticks.cpp
|
||||||
src/plot/axis.cpp
|
src/plot/axis.cpp
|
||||||
src/plot/grid.cpp
|
src/plot/grid.cpp
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ enum : GLenum {
|
|||||||
|
|
||||||
// Pixel store state.
|
// Pixel store state.
|
||||||
GL_UNPACK_ALIGNMENT = 0x0CF5,
|
GL_UNPACK_ALIGNMENT = 0x0CF5,
|
||||||
|
GL_PACK_ALIGNMENT = 0x0D05,
|
||||||
|
|
||||||
// Shaders and programs.
|
// Shaders and programs.
|
||||||
GL_FRAGMENT_SHADER = 0x8B30,
|
GL_FRAGMENT_SHADER = 0x8B30,
|
||||||
@@ -76,6 +77,9 @@ enum : GLenum {
|
|||||||
GL_MAP_PERSISTENT_BIT = 0x0040 /* 4.4 */,
|
GL_MAP_PERSISTENT_BIT = 0x0040 /* 4.4 */,
|
||||||
GL_MAP_COHERENT_BIT = 0x0080 /* 4.4 */,
|
GL_MAP_COHERENT_BIT = 0x0080 /* 4.4 */,
|
||||||
|
|
||||||
|
// Rasterisation state (1.0).
|
||||||
|
GL_SCISSOR_TEST = 0x0C11,
|
||||||
|
|
||||||
// Textures.
|
// Textures.
|
||||||
GL_TEXTURE_2D = 0x0DE1,
|
GL_TEXTURE_2D = 0x0DE1,
|
||||||
GL_TEXTURE0 = 0x84C0,
|
GL_TEXTURE0 = 0x84C0,
|
||||||
@@ -120,6 +124,8 @@ enum : GLenum {
|
|||||||
X(void, glEnable, (GLenum)) \
|
X(void, glEnable, (GLenum)) \
|
||||||
X(void, glBlendFunc, (GLenum, GLenum)) \
|
X(void, glBlendFunc, (GLenum, GLenum)) \
|
||||||
X(void, glPixelStorei, (GLenum, GLint)) \
|
X(void, glPixelStorei, (GLenum, GLint)) \
|
||||||
|
X(void, glDisable, (GLenum)) \
|
||||||
|
X(void, glScissor, (GLint, GLint, GLsizei, GLsizei)) \
|
||||||
/* shaders and programs */ \
|
/* shaders and programs */ \
|
||||||
X(GLuint, glCreateShader, (GLenum)) \
|
X(GLuint, glCreateShader, (GLenum)) \
|
||||||
X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*)) \
|
X(void, glShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*)) \
|
||||||
@@ -148,6 +154,8 @@ enum : GLenum {
|
|||||||
X(void, glBindBuffer, (GLenum, GLuint)) \
|
X(void, glBindBuffer, (GLenum, GLuint)) \
|
||||||
X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \
|
X(void, glBufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \
|
||||||
X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \
|
X(void, glBufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \
|
||||||
|
X(void*, glMapBufferRange, (GLenum, GLintptr, GLsizeiptr, GLbitfield)) \
|
||||||
|
X(GLboolean, glUnmapBuffer, (GLenum)) \
|
||||||
/* vertex arrays */ \
|
/* vertex arrays */ \
|
||||||
X(void, glGenVertexArrays, (GLsizei, GLuint*)) \
|
X(void, glGenVertexArrays, (GLsizei, GLuint*)) \
|
||||||
X(void, glDeleteVertexArrays, (GLsizei, const GLuint*)) \
|
X(void, glDeleteVertexArrays, (GLsizei, const GLuint*)) \
|
||||||
@@ -182,8 +190,17 @@ enum : GLenum {
|
|||||||
X(GLenum, glClientWaitSync, (GLsync, GLbitfield, GLuint64)) \
|
X(GLenum, glClientWaitSync, (GLsync, GLbitfield, GLuint64)) \
|
||||||
X(void, glDeleteSync, (GLsync))
|
X(void, glDeleteSync, (GLsync))
|
||||||
|
|
||||||
|
// Entry points above the 3.3 baseline, resolved best-effort: a driver that lacks
|
||||||
|
// one leaves it null and `load()` still succeeds, so the library keeps working on
|
||||||
|
// a 3.3-only context. Every one of these is reached only after `caps()` -- or an
|
||||||
|
// explicit null check -- says the context can do it (see caps.hpp).
|
||||||
|
#define PLOT_GL_OPTIONAL_FUNCS(X) \
|
||||||
|
/* persistent buffer mapping, GL 4.4 / ARB_buffer_storage */ \
|
||||||
|
X(void, glBufferStorage, (GLenum, GLsizeiptr, const void*, GLbitfield))
|
||||||
|
|
||||||
#define PLOT_GL_DECL(ret, name, args) extern ret (*name) args;
|
#define PLOT_GL_DECL(ret, name, args) extern ret (*name) args;
|
||||||
PLOT_GL_FUNCS(PLOT_GL_DECL)
|
PLOT_GL_FUNCS(PLOT_GL_DECL)
|
||||||
|
PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_DECL)
|
||||||
#undef PLOT_GL_DECL
|
#undef PLOT_GL_DECL
|
||||||
|
|
||||||
} // namespace plot::gl
|
} // namespace plot::gl
|
||||||
@@ -66,6 +66,21 @@ class Buffer final : public Handle<Buffer> {
|
|||||||
/// Overwrites `bytes` bytes starting at `offset`; the store must already be
|
/// Overwrites `bytes` bytes starting at `offset`; the store must already be
|
||||||
/// at least `offset + bytes` large.
|
/// at least `offset + bytes` large.
|
||||||
void upload_sub(const void* data, usize bytes, usize offset) const noexcept;
|
void upload_sub(const void* data, usize bytes, usize offset) const noexcept;
|
||||||
|
/// Replaces the store with `bytes` bytes of immutable storage carrying `flags`
|
||||||
|
/// (`GL_MAP_WRITE_BIT` and friends). GL 4.4 / ARB_buffer_storage: only legal on
|
||||||
|
/// a context whose caps() report `buffer_storage`, and the entry point may be
|
||||||
|
/// null even on such a context (it is resolved best-effort, see api.hpp), so a
|
||||||
|
/// caller checks `glBufferStorage != nullptr` first. The contents start
|
||||||
|
/// undefined.
|
||||||
|
void storage(usize bytes, GLbitfield flags) const noexcept;
|
||||||
|
|
||||||
|
/// Maps `bytes` bytes from `offset` with `access`, returning the pointer or
|
||||||
|
/// null. GL 3.0 core, but a `GL_MAP_PERSISTENT_BIT` map needs the same
|
||||||
|
/// capability as `storage()` above.
|
||||||
|
void* map_range(usize offset, usize bytes, GLbitfield access) const noexcept;
|
||||||
|
|
||||||
|
/// Unmaps the store's data; the pointer from map_range() is invalid after this.
|
||||||
|
bool unmap() const noexcept;
|
||||||
|
|
||||||
/// Exposed for the CRTP base. Deleting an unknown name is a GL no-op.
|
/// Exposed for the CRTP base. Deleting an unknown name is a GL no-op.
|
||||||
static void destroy(GLuint id) noexcept;
|
static void destroy(GLuint id) noexcept;
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ using ProcLoader = void* (*)(const char*);
|
|||||||
|
|
||||||
/// Resolves every entry point of `PLOT_GL_FUNCS` (see api.hpp) into the global
|
/// Resolves every entry point of `PLOT_GL_FUNCS` (see api.hpp) into the global
|
||||||
/// table declared there, so the rest of the library can call `gl*` directly.
|
/// table declared there, so the rest of the library can call `gl*` directly.
|
||||||
|
/// The above-baseline entries of `PLOT_GL_OPTIONAL_FUNCS` are resolved the same
|
||||||
|
/// way but never fail the load: a driver without them leaves those pointers
|
||||||
|
/// null, and callers reach them only behind `caps()`.
|
||||||
///
|
///
|
||||||
/// Returns false when any required name cannot be found; entries that did
|
/// Returns false when any required name cannot be found; entries that did
|
||||||
/// resolve keep their pointers, so a failed load() never breaks a table that
|
/// resolve keep their pointers, so a failed load() never breaks a table that
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ struct Measurement {
|
|||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The cursor pair a measure spans, as the plot stored it. `valid` is false for
|
||||||
|
/// an id that names no measure and for a measure whose cursors are not both
|
||||||
|
/// registered, so a caller can tell "no band to shade" from "a band between
|
||||||
|
/// cursors 0 and 0". The renderer uses it to place the band a measurement's
|
||||||
|
/// readout belongs to.
|
||||||
|
struct MeasureSpan {
|
||||||
|
u32 cursor_a = 0;
|
||||||
|
u32 cursor_b = 0;
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
/// Stateless interval measurement over a data::Series.
|
/// Stateless interval measurement over a data::Series.
|
||||||
struct Measure {
|
struct Measure {
|
||||||
/// Evaluates [begin, end). Every quantity comes from `Series::reduce`/`sum`/
|
/// Evaluates [begin, end). Every quantity comes from `Series::reduce`/`sum`/
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ class Plot {
|
|||||||
/// creates one and never touches GL.
|
/// creates one and never touches GL.
|
||||||
void set_text_backend(const render::TextBackend* text) noexcept { text_ = text; }
|
void set_text_backend(const render::TextBackend* text) noexcept { text_ = text; }
|
||||||
|
|
||||||
|
/// The backend layout() measures labels with, or null when none is set. The
|
||||||
|
/// renderer reads it to install its own font only when nobody else has.
|
||||||
|
const render::TextBackend* text_backend() const noexcept { return text_; }
|
||||||
|
|
||||||
// --- construction -----------------------------------------------------
|
// --- construction -----------------------------------------------------
|
||||||
|
|
||||||
u32 add_x_axis(const AxisDesc& desc);
|
u32 add_x_axis(const AxisDesc& desc);
|
||||||
@@ -135,6 +139,11 @@ class Plot {
|
|||||||
/// because recomputing fills a cache: nothing a caller can observe changes.
|
/// because recomputing fills a cache: nothing a caller can observe changes.
|
||||||
const Measurement& measurement(u32 id) const;
|
const Measurement& measurement(u32 id) const;
|
||||||
|
|
||||||
|
/// The cursor pair `id` was created over. The renderer needs it to place the
|
||||||
|
/// shaded band the measurement readout belongs to; the statistics themselves
|
||||||
|
/// are in measurement().
|
||||||
|
MeasureSpan measure_span(u32 id) const noexcept;
|
||||||
|
|
||||||
/// Drops every cached measurement. Needed after a series is mutated in place
|
/// Drops every cached measurement. Needed after a series is mutated in place
|
||||||
/// (data::ArraySeries::commit() with the same sample count), which a pointer
|
/// (data::ArraySeries::commit() with the same sample count), which a pointer
|
||||||
/// and size check cannot detect.
|
/// and size check cannot detect.
|
||||||
|
|||||||
@@ -28,26 +28,84 @@ struct TextInstance {
|
|||||||
static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats");
|
static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats");
|
||||||
static_assert(sizeof(TextInstance) == 4 * sizeof(f32), "text instance must stay 4 floats");
|
static_assert(sizeof(TextInstance) == 4 * sizeof(f32), "text instance must stay 4 floats");
|
||||||
|
|
||||||
|
/// How a store's staged instances reach the GPU. Semantics are identical either
|
||||||
|
/// way -- only the transfer differs, and `Renderer::init()` picks one from
|
||||||
|
/// `plot::gl::caps()`:
|
||||||
|
///
|
||||||
|
/// - `Orphan`: the GL 3.3 baseline. Every upload re-specifies the store with a
|
||||||
|
/// null `glBufferData` (orphaning it, so the driver never has to wait for the
|
||||||
|
/// draw that is still reading the previous one) and then refills it.
|
||||||
|
/// - `PersistentMapped`: GL 4.4 / ARB_buffer_storage. Three slots of immutable
|
||||||
|
/// storage are mapped once and written directly, and the fence recorded after
|
||||||
|
/// each slot's draw guards its reuse, so the render thread never waits on the
|
||||||
|
/// GPU. The vertex array's attribute pointers move with the slot.
|
||||||
|
enum class UploadMode { Orphan, PersistentMapped };
|
||||||
|
|
||||||
namespace detail {
|
namespace detail {
|
||||||
|
|
||||||
/// The growth half of an instance buffer, shared by both pipelines so neither
|
/// The growth half of an instance buffer, shared by both pipelines so neither
|
||||||
/// duplicates the GL ownership or the "grow only when the frame needs it" rule.
|
/// duplicates the GL ownership or the transfer strategy. This is the one place
|
||||||
|
/// vertex data is handed to the GPU: `LineBuffer::upload` and
|
||||||
|
/// `TextBuffer::upload` both land here, and no caller of either can tell which
|
||||||
|
/// `UploadMode` is live.
|
||||||
class InstanceStore {
|
class InstanceStore {
|
||||||
public:
|
public:
|
||||||
/// Creates the vertex buffer. Reports `ErrorCode::Unsupported` while the GL
|
/// Slots the persistent ring rotates through. Three is the smallest ring that
|
||||||
/// entry points are unresolved, `GLFailure` if the driver hands out no name.
|
/// lets a frame's draw finish while the CPU fills two more.
|
||||||
bool create(Status* st = nullptr) noexcept;
|
static constexpr u32 kSlots = 3;
|
||||||
|
/// Upper bound on the wait for a slot's draw to retire: a driver that has
|
||||||
|
/// stopped making progress must not stall the render thread forever.
|
||||||
|
static constexpr u64 kFenceWaitNs = 1'000'000'000ull;
|
||||||
|
|
||||||
/// Uploads `count` instances of `instance_bytes`. The store only reallocates
|
/// Creates the vertex buffer in `mode`. Reports `ErrorCode::Unsupported` while
|
||||||
/// when the frame's workload outgrows it, so steady-state frames write into
|
/// the GL entry points are unresolved and `GLFailure` if the driver hands out
|
||||||
/// the existing allocation.
|
/// no name. A caller asking for `PersistentMapped` must already have checked
|
||||||
|
/// that the context and the entry points can do it (see Renderer::init): a
|
||||||
|
/// store created that way on a machine without them would never draw.
|
||||||
|
bool create(UploadMode mode, Status* st = nullptr) noexcept;
|
||||||
|
|
||||||
|
/// Uploads `count` instances of `instance_bytes`. `Orphan` mode orphans and
|
||||||
|
/// refills; `PersistentMapped` mode writes the next slot of the ring, waiting
|
||||||
|
/// out any draw still reading it.
|
||||||
void upload(const void* data, u32 count, usize instance_bytes) noexcept;
|
void upload(const void* data, u32 count, usize instance_bytes) noexcept;
|
||||||
|
|
||||||
|
/// Records that the draw the caller just issued read the current slot, so the
|
||||||
|
/// fence guarding that slot's reuse ends up in the command stream behind the
|
||||||
|
/// draw. A no-op in `Orphan` mode, where nothing reads the store after the
|
||||||
|
/// orphan.
|
||||||
|
void mark_drawn() noexcept;
|
||||||
|
|
||||||
|
/// Byte offset the current slot's instances start at: 0 in `Orphan` mode,
|
||||||
|
/// `slot * slot_bytes` in `PersistentMapped` mode. The vertex array re-points
|
||||||
|
/// its attribute pointers whenever `epoch()` changes.
|
||||||
|
usize slot_offset() const noexcept {
|
||||||
|
return mode_ == UploadMode::PersistentMapped ? usize(slot_) * slot_bytes_ : 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes whenever the staged instances moved (a new slot) or the GL buffer
|
||||||
|
/// was recreated, i.e. whenever attribute pointers into this store went stale.
|
||||||
|
u32 epoch() const noexcept { return epoch_; }
|
||||||
|
|
||||||
void bind() const noexcept { buffer_.bind(); }
|
void bind() const noexcept { buffer_.bind(); }
|
||||||
|
bool persistent() const noexcept { return mode_ == UploadMode::PersistentMapped; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
/// Grows the mapped ring to `bytes` per slot. False when the driver refuses the
|
||||||
|
/// storage or the map, in which case the store has fallen back to a fresh
|
||||||
|
/// `Orphan` buffer and the frame still draws.
|
||||||
|
bool grow_persistent(usize bytes) noexcept;
|
||||||
|
/// Deletes every fence, unmaps and releases the GL buffer. `slot_bytes_` drops
|
||||||
|
/// to 0 so the next upload regrows.
|
||||||
|
void release_storage() noexcept;
|
||||||
|
|
||||||
gl::Buffer buffer_;
|
gl::Buffer buffer_;
|
||||||
u32 capacity_ = 0; ///< instances the store can hold
|
UploadMode mode_ = UploadMode::Orphan;
|
||||||
|
u32 capacity_ = 0; ///< instances the orphan store can hold
|
||||||
|
usize slot_bytes_ = 0; ///< bytes per slot of the persistent ring
|
||||||
|
u32 slot_ = kSlots - 1u; ///< last slot written; an upload advances it first
|
||||||
|
u32 epoch_ = 0;
|
||||||
|
u8* mapped_ = nullptr; ///< base of the mapped ring, null in `Orphan` mode
|
||||||
|
gl::Fence fences_[kSlots];
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace detail
|
} // namespace detail
|
||||||
@@ -55,10 +113,16 @@ class InstanceStore {
|
|||||||
/// Instance buffer of the line pipeline: one `LineInstance` per segment.
|
/// Instance buffer of the line pipeline: one `LineInstance` per segment.
|
||||||
class LineBuffer {
|
class LineBuffer {
|
||||||
public:
|
public:
|
||||||
bool create(Status* st = nullptr) noexcept { return store_.create(st); }
|
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept {
|
||||||
|
return store_.create(mode, st);
|
||||||
|
}
|
||||||
void upload(const f32* interleaved, u32 count) noexcept {
|
void upload(const f32* interleaved, u32 count) noexcept {
|
||||||
store_.upload(interleaved, count, sizeof(LineInstance));
|
store_.upload(interleaved, count, sizeof(LineInstance));
|
||||||
}
|
}
|
||||||
|
void mark_drawn() noexcept { store_.mark_drawn(); }
|
||||||
|
usize slot_offset() const noexcept { return store_.slot_offset(); }
|
||||||
|
u32 epoch() const noexcept { return store_.epoch(); }
|
||||||
|
bool persistent() const noexcept { return store_.persistent(); }
|
||||||
void bind() const noexcept { store_.bind(); }
|
void bind() const noexcept { store_.bind(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -68,10 +132,16 @@ class LineBuffer {
|
|||||||
/// Instance buffer of the text pipeline: one `TextInstance` per glyph.
|
/// Instance buffer of the text pipeline: one `TextInstance` per glyph.
|
||||||
class TextBuffer {
|
class TextBuffer {
|
||||||
public:
|
public:
|
||||||
bool create(Status* st = nullptr) noexcept { return store_.create(st); }
|
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept {
|
||||||
|
return store_.create(mode, st);
|
||||||
|
}
|
||||||
void upload(const f32* interleaved, u32 count) noexcept {
|
void upload(const f32* interleaved, u32 count) noexcept {
|
||||||
store_.upload(interleaved, count, sizeof(TextInstance));
|
store_.upload(interleaved, count, sizeof(TextInstance));
|
||||||
}
|
}
|
||||||
|
void mark_drawn() noexcept { store_.mark_drawn(); }
|
||||||
|
usize slot_offset() const noexcept { return store_.slot_offset(); }
|
||||||
|
u32 epoch() const noexcept { return store_.epoch(); }
|
||||||
|
bool persistent() const noexcept { return store_.persistent(); }
|
||||||
void bind() const noexcept { store_.bind(); }
|
void bind() const noexcept { store_.bind(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class LineBatch {
|
|||||||
/// Stages `n - 1` segments joining consecutive points. A null pointer or
|
/// Stages `n - 1` segments joining consecutive points. A null pointer or
|
||||||
/// fewer than two points stages nothing.
|
/// fewer than two points stages nothing.
|
||||||
void add_polyline(const Vec2* pts, u32 n);
|
void add_polyline(const Vec2* pts, u32 n);
|
||||||
|
/// Same, for `n` points already interleaved as `x,y` floats -- the layout
|
||||||
|
/// `data::Series::gather` appends to, so a gathered window is staged without a
|
||||||
|
/// copy or a reinterpret_cast of its storage.
|
||||||
|
void add_polyline_xy(const f32* xy, u32 n);
|
||||||
|
|
||||||
/// Segments staged so far, one instance each.
|
/// Segments staged so far, one instance each.
|
||||||
u32 segment_count() const noexcept { return static_cast<u32>(v_.size() / 4u); }
|
u32 segment_count() const noexcept { return static_cast<u32>(v_.size() / 4u); }
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <span>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <plot/core/color.hpp>
|
||||||
|
#include <plot/core/rect.hpp>
|
||||||
|
#include <plot/core/status.hpp>
|
||||||
|
#include <plot/core/types.hpp>
|
||||||
|
#include <plot/gl/handle.hpp>
|
||||||
|
#include <plot/math/vec2.hpp>
|
||||||
|
#include <plot/plot/plot.hpp>
|
||||||
|
#include <plot/render/bitmap_font.hpp>
|
||||||
|
#include <plot/render/buffer.hpp>
|
||||||
|
#include <plot/render/primitives.hpp>
|
||||||
|
#include <plot/render/program.hpp>
|
||||||
|
#include <plot/render/style.hpp>
|
||||||
|
#include <plot/render/vertex_array.hpp>
|
||||||
|
|
||||||
|
namespace plot {
|
||||||
|
|
||||||
|
/// What one frame staged and drew. It exists because pixels alone cannot say
|
||||||
|
/// which of the two rasterisation paths a trace took: a 1M-sample series drawn
|
||||||
|
/// from the LOD pyramid and one drawn from raw samples can cover the same
|
||||||
|
/// columns, so the smoke test asserts the path here and the pixels there.
|
||||||
|
struct FrameStats {
|
||||||
|
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
||||||
|
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
||||||
|
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
||||||
|
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
||||||
|
u32 draw_calls = 0; ///< draw calls issued, line and text
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Draws a `Plot` into whatever framebuffer is bound.
|
||||||
|
///
|
||||||
|
/// The renderer never creates, binds or resizes a framebuffer: `begin_frame()`
|
||||||
|
/// takes the framebuffer size for the projection and a `Rect` for the region the
|
||||||
|
/// plot occupies, clears exactly that region (`GL_SCISSOR_TEST` around the clear,
|
||||||
|
/// so a host that has already painted the rest of its surface keeps it) and sets
|
||||||
|
/// `glViewport` to the flipped rect. That is the whole embedding contract of the
|
||||||
|
/// adapters (Qt/GTK/ImGui widgets).
|
||||||
|
///
|
||||||
|
/// Two properties drive the rest of the design:
|
||||||
|
///
|
||||||
|
/// - **Only the visible window is gathered.** A trace's x range is mapped onto
|
||||||
|
/// sample indices and `Series::gather` is asked for that window -- never the
|
||||||
|
/// whole series -- at `pyramid().level_for(n, samples_per_pixel)` whenever a
|
||||||
|
/// pixel covers `dense_threshold_spp` samples or more. A 100M-sample series
|
||||||
|
/// therefore stages O(pixels) vertices, and `FrameStats` records which way each
|
||||||
|
/// trace went.
|
||||||
|
/// - **Vertex data stays in data space.** Segments are staged in data
|
||||||
|
/// coordinates and mapped to pixels by the line program's `u_scale`/`u_offset`
|
||||||
|
/// uniforms, so nothing is ever baked into screen space per primitive; a log
|
||||||
|
/// axis is staged in the transform's pre-affine coordinate (`log10`), which is
|
||||||
|
/// what the affine part expects. Pan and zoom are uniform updates.
|
||||||
|
///
|
||||||
|
/// The vertex transfer itself is one of two strategies, chosen once in `init()`
|
||||||
|
/// from `plot::gl::caps()` and invisible to every caller afterwards: a
|
||||||
|
/// persistent-mapped, fence-guarded ring on 4.3+/ARB_buffer_storage, and the
|
||||||
|
/// orphan-and-refill baseline on 3.3. Setting `PLOT_FORCE_GL33_UPLOAD=1` in the
|
||||||
|
/// environment forces the baseline path, which is how both are exercised on one
|
||||||
|
/// machine.
|
||||||
|
///
|
||||||
|
/// Not thread-safe, and confined to the thread that owns the current context.
|
||||||
|
class Renderer {
|
||||||
|
public:
|
||||||
|
Renderer() noexcept = default;
|
||||||
|
~Renderer() = default;
|
||||||
|
Renderer(const Renderer&) = delete;
|
||||||
|
Renderer& operator=(const Renderer&) = delete;
|
||||||
|
|
||||||
|
/// Compiles both programs, builds the glyph atlas and allocates the vertex
|
||||||
|
/// buffers. The calling thread must have the context current and
|
||||||
|
/// `plot::gl::load()` must have succeeded, otherwise the returned `Status` is
|
||||||
|
/// `Unsupported`; anything the driver refuses is `GLFailure` plus its log.
|
||||||
|
bool init(Status* st = nullptr) noexcept;
|
||||||
|
|
||||||
|
/// Starts a frame: records `fb_w` x `fb_h` for the projection, flips
|
||||||
|
/// `viewport_px` (top-left origin, like every `Rect` in this library) into GL's
|
||||||
|
/// bottom-left origin once, clears that region to `style().background` and
|
||||||
|
/// empties the staging batches.
|
||||||
|
void begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept;
|
||||||
|
|
||||||
|
/// Draws `plot`, refreshing its layout, ticks and grid first.
|
||||||
|
///
|
||||||
|
/// The renderer owns the presentation style: `draw()` copies `style()` into the
|
||||||
|
/// plot, so the strips and tick densities the plot lays out with are the values
|
||||||
|
/// the renderer draws with -- set colours and metrics here, not on the plot.
|
||||||
|
/// The plot also borrows this renderer's font (when it has no backend of its
|
||||||
|
/// own), so the renderer must outlive the plot's use of it.
|
||||||
|
void draw(Plot& plot);
|
||||||
|
|
||||||
|
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
|
||||||
|
/// attributed to a later frame (see `last_frame_had_gl_error()`).
|
||||||
|
///
|
||||||
|
/// The GL state it leaves behind, so a host can embed the plot without guessing:
|
||||||
|
/// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and
|
||||||
|
/// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the
|
||||||
|
/// viewport left at the frame's rect (the caller sets its own for the next pass).
|
||||||
|
/// The framebuffer binding is never touched -- it is the caller's, and so is the
|
||||||
|
/// pixel-pack alignment if `dump_frame_ppm()` ran.
|
||||||
|
void end_frame() noexcept;
|
||||||
|
|
||||||
|
/// The frame's colours and metrics; `draw()` hands them to the plot as well, so
|
||||||
|
/// the layout it computes matches what is drawn. `line_width_px` is the width of
|
||||||
|
/// the strokes framing the data (axis lines, cursor markers) -- grid lines stay
|
||||||
|
/// hairlines and every trace carries its own width.
|
||||||
|
render::Style& style() noexcept { return style_; }
|
||||||
|
const render::Style& style() const noexcept { return style_; }
|
||||||
|
|
||||||
|
/// True when the last `end_frame()` found a GL error. Errors are drained in a
|
||||||
|
/// loop, so a stale one cannot be attributed to a later frame.
|
||||||
|
bool last_frame_had_gl_error() const noexcept { return gl_error_; }
|
||||||
|
|
||||||
|
/// Statistics of the most recently drawn frame.
|
||||||
|
const FrameStats& stats() const noexcept { return stats_; }
|
||||||
|
|
||||||
|
bool ready() const noexcept { return ready_; }
|
||||||
|
/// True when `init()` selected the persistent-mapped transfer (4.3+ with
|
||||||
|
/// ARB_buffer_storage, and `PLOT_FORCE_GL33_UPLOAD` unset).
|
||||||
|
bool using_persistent_upload() const noexcept { return persistent_; }
|
||||||
|
/// The axis text size the renderer actually draws with: `style().axis_font_px`
|
||||||
|
/// rounded to the nearest whole multiple of `render::BitmapFont::kGlyphPx`,
|
||||||
|
/// because a 1-bit glyph only scales cleanly by whole factors.
|
||||||
|
f32 axis_font_px() const noexcept { return font_backend_.cell_px(); }
|
||||||
|
|
||||||
|
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
|
||||||
|
/// channel, rows top-down) so a headless render can be looked at without a
|
||||||
|
/// window. Reads the framebuffer that is bound now, which is the one the frame
|
||||||
|
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an
|
||||||
|
/// unreadable pixel.
|
||||||
|
bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// The bitmap font as the plot layer sees it, at the size the text pipeline
|
||||||
|
/// draws it: `measure`/`height`/`ascent` scale with the glyph cell, so a label
|
||||||
|
/// can never be laid out for one size and drawn at another. The 8x8 font scales
|
||||||
|
/// by whole multiples of its cell only -- anything else lands a 1-bit glyph's
|
||||||
|
/// strokes unevenly -- so `reset()` picks the multiple nearest the requested
|
||||||
|
/// size.
|
||||||
|
class AxisFont final : public render::TextBackend {
|
||||||
|
public:
|
||||||
|
void reset(const render::BitmapFont& font, f32 requested_px) noexcept;
|
||||||
|
f32 measure(const char* utf8) const override;
|
||||||
|
f32 height() const override;
|
||||||
|
f32 ascent() const override;
|
||||||
|
/// On-screen size of one glyph cell, in pixels.
|
||||||
|
f32 cell_px() const noexcept { return cell_px_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
const render::BitmapFont* font_ = nullptr;
|
||||||
|
f32 cell_px_ = 1.0f;
|
||||||
|
f32 scale_ = 1.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One axis pair's data -> pixel mapping, already shifted so the viewport's
|
||||||
|
/// top-left corner is the origin (the shaders emit NDC relative to the GL
|
||||||
|
/// viewport, not the framebuffer).
|
||||||
|
struct Mapping {
|
||||||
|
Vec2 scale{1.0f, 1.0f};
|
||||||
|
Vec2 offset{0.0f, 0.0f};
|
||||||
|
};
|
||||||
|
|
||||||
|
Mapping mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept;
|
||||||
|
/// The identity mapping for geometry already staged in viewport pixels.
|
||||||
|
Mapping pixel_mapping() const noexcept;
|
||||||
|
/// Shifts one framebuffer-space pixel position into viewport space.
|
||||||
|
Vec2 to_viewport(Vec2 px) const noexcept;
|
||||||
|
|
||||||
|
/// The single transfer point of the line pipeline: the staged `LineBatch`
|
||||||
|
/// payload (four floats per instance) goes to whichever strategy `init()`
|
||||||
|
/// selected. Callers of this helper never see which one is live.
|
||||||
|
void upload_vertices(std::span<const f32> interleaved) noexcept;
|
||||||
|
/// Same, for the text pipeline's four-float glyph instances.
|
||||||
|
void upload_text_vertices(std::span<const f32> interleaved) noexcept;
|
||||||
|
|
||||||
|
/// Uploads the staged line batch, maps it with (`scale`, `offset`), rasterises
|
||||||
|
/// it in one colour and empties the batch. A no-op for an empty batch, so the
|
||||||
|
/// caller can flush unconditionally.
|
||||||
|
void flush_lines(const Mapping& map, Color color, f32 width) noexcept;
|
||||||
|
/// Draws every staged glyph in `style().axis_text` and empties the batch. Text
|
||||||
|
/// is texture-bound, so this runs once per frame, after all line geometry.
|
||||||
|
void flush_text() noexcept;
|
||||||
|
|
||||||
|
/// Stages one data-space segment, taking the two axes' log flags into account.
|
||||||
|
void stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y);
|
||||||
|
/// Stages `n` interleaved `x,y` data-space points (a gathered window).
|
||||||
|
void stage_xy(const f32* xy, u32 n, bool log_x, bool log_y);
|
||||||
|
/// Lays one run of text out at a framebuffer position and stages it, shifting
|
||||||
|
/// it into viewport space. Returns the pen past the run.
|
||||||
|
Vec2 stage_text(Vec2 pos_px, const char* utf8);
|
||||||
|
|
||||||
|
void draw_grid(const Plot& plot);
|
||||||
|
void draw_axis_lines(const Plot& plot);
|
||||||
|
void draw_tick_labels(const Plot& plot);
|
||||||
|
void draw_titles(const Plot& plot);
|
||||||
|
void draw_traces(const Plot& plot);
|
||||||
|
void draw_cursors(const Plot& plot);
|
||||||
|
void draw_measures(const Plot& plot);
|
||||||
|
void draw_annotations(const Plot& plot);
|
||||||
|
|
||||||
|
render::BitmapFont font_;
|
||||||
|
AxisFont font_backend_;
|
||||||
|
render::Style style_;
|
||||||
|
|
||||||
|
gl::Texture atlas_;
|
||||||
|
render::LineProgram line_program_;
|
||||||
|
render::TextProgram text_program_;
|
||||||
|
render::LineVertexArray line_vao_;
|
||||||
|
render::TextVertexArray text_vao_;
|
||||||
|
|
||||||
|
render::LineBatch lines_;
|
||||||
|
render::TextBatch glyphs_;
|
||||||
|
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
||||||
|
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
|
||||||
|
|
||||||
|
Rect viewport_; ///< the frame's region, top-left origin, framebuffer px
|
||||||
|
Rect gl_viewport_; ///< the same rect flipped for GL, and for readback
|
||||||
|
u32 fb_w_ = 0;
|
||||||
|
u32 fb_h_ = 0;
|
||||||
|
bool ready_ = false;
|
||||||
|
bool persistent_ = false;
|
||||||
|
bool gl_error_ = false;
|
||||||
|
FrameStats stats_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace plot
|
||||||
@@ -12,16 +12,29 @@ namespace plot::render {
|
|||||||
/// location 0 and `a_p1` at location 1, two floats each, advancing once per
|
/// location 0 and `a_p1` at location 1, two floats each, advancing once per
|
||||||
/// instance. A caller never writes a location, stride or offset; it stages a
|
/// instance. A caller never writes a location, stride or offset; it stages a
|
||||||
/// `LineBatch` and uploads its `data()`.
|
/// `LineBatch` and uploads its `data()`.
|
||||||
|
///
|
||||||
|
/// The instance buffer may sit behind either transfer strategy (see
|
||||||
|
/// `UploadMode`). Only the mapped one moves the instances between slots, so
|
||||||
|
/// `upload()` re-points the attributes when `InstanceStore::epoch()` changes and
|
||||||
|
/// otherwise leaves them alone; the caller never has to know which path is live.
|
||||||
class LineVertexArray {
|
class LineVertexArray {
|
||||||
public:
|
public:
|
||||||
/// Creates the vertex array, the instance buffer and the attribute layout.
|
/// Creates the vertex array, the instance buffer and the attribute layout.
|
||||||
bool create(Status* st = nullptr) noexcept;
|
/// `mode` selects the instance buffer's transfer strategy and defaults to the
|
||||||
|
/// GL 3.3 baseline; a caller asking for `UploadMode::PersistentMapped` must
|
||||||
|
/// have checked the context can do it (see `Renderer::init()`).
|
||||||
|
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept;
|
||||||
|
|
||||||
/// Uploads `segments` instances from interleaved `x0,y0,x1,y1` floats and
|
/// Uploads `segments` instances from interleaved `x0,y0,x1,y1` floats and
|
||||||
/// records how many the next draw should rasterise.
|
/// records how many the next draw should rasterise.
|
||||||
void upload(const f32* interleaved, u32 segments) noexcept;
|
void upload(const f32* interleaved, u32 segments) noexcept;
|
||||||
|
|
||||||
void bind() const noexcept { vao_.bind(); }
|
void bind() const noexcept { vao_.bind(); }
|
||||||
|
/// Records that the draw just issued read the instance buffer's current slot,
|
||||||
|
/// which is what lets the mapped path reuse it safely. A no-op on the baseline
|
||||||
|
/// path, so a caller always calls it the same way.
|
||||||
|
void mark_drawn() noexcept { buffer_.mark_drawn(); }
|
||||||
|
|
||||||
bool valid() const noexcept { return vao_.id() != 0; }
|
bool valid() const noexcept { return vao_.id() != 0; }
|
||||||
u32 instance_count() const noexcept { return instances_; }
|
u32 instance_count() const noexcept { return instances_; }
|
||||||
|
|
||||||
@@ -29,6 +42,8 @@ class LineVertexArray {
|
|||||||
LineBuffer buffer_;
|
LineBuffer buffer_;
|
||||||
gl::VertexArray vao_;
|
gl::VertexArray vao_;
|
||||||
u32 instances_ = 0;
|
u32 instances_ = 0;
|
||||||
|
/// Instance-buffer generation the attribute pointers currently point into.
|
||||||
|
u32 epoch_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0
|
/// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0
|
||||||
@@ -36,12 +51,14 @@ class LineVertexArray {
|
|||||||
/// advancing once per instance.
|
/// advancing once per instance.
|
||||||
class TextVertexArray {
|
class TextVertexArray {
|
||||||
public:
|
public:
|
||||||
bool create(Status* st = nullptr) noexcept;
|
bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept;
|
||||||
|
|
||||||
/// Uploads `glyphs` instances from interleaved `x,y,u,v` floats.
|
/// Uploads `glyphs` instances from interleaved `x,y,u,v` floats.
|
||||||
void upload(const f32* interleaved, u32 glyphs) noexcept;
|
void upload(const f32* interleaved, u32 glyphs) noexcept;
|
||||||
|
|
||||||
void bind() const noexcept { vao_.bind(); }
|
void bind() const noexcept { vao_.bind(); }
|
||||||
|
void mark_drawn() noexcept { buffer_.mark_drawn(); }
|
||||||
|
|
||||||
bool valid() const noexcept { return vao_.id() != 0; }
|
bool valid() const noexcept { return vao_.id() != 0; }
|
||||||
u32 instance_count() const noexcept { return instances_; }
|
u32 instance_count() const noexcept { return instances_; }
|
||||||
|
|
||||||
@@ -49,6 +66,7 @@ class TextVertexArray {
|
|||||||
TextBuffer buffer_;
|
TextBuffer buffer_;
|
||||||
gl::VertexArray vao_;
|
gl::VertexArray vao_;
|
||||||
u32 instances_ = 0;
|
u32 instances_ = 0;
|
||||||
|
u32 epoch_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace plot::render
|
} // namespace plot::render
|
||||||
@@ -109,6 +109,25 @@ void Buffer::upload_sub(const void* data, usize bytes, usize offset) const noexc
|
|||||||
data);
|
data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Buffer::storage(usize bytes, GLbitfield flags) const noexcept {
|
||||||
|
if (glBufferStorage == nullptr) {
|
||||||
|
return; // not resolved on this driver: the caller checked caps() first
|
||||||
|
}
|
||||||
|
bind();
|
||||||
|
glBufferStorage(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(bytes), nullptr, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
void* Buffer::map_range(usize offset, usize bytes, GLbitfield access) const noexcept {
|
||||||
|
bind();
|
||||||
|
return glMapBufferRange(GL_ARRAY_BUFFER, static_cast<GLintptr>(offset),
|
||||||
|
static_cast<GLsizeiptr>(bytes), access);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Buffer::unmap() const noexcept {
|
||||||
|
bind();
|
||||||
|
return glUnmapBuffer(GL_ARRAY_BUFFER) == GL_TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
void Buffer::destroy(GLuint id) noexcept { glDeleteBuffers(1, &id); }
|
void Buffer::destroy(GLuint id) noexcept { glDeleteBuffers(1, &id); }
|
||||||
|
|
||||||
// -------------------------------------------------------------- VertexArray
|
// -------------------------------------------------------------- VertexArray
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ namespace plot::gl {
|
|||||||
// The one definition of the entry point table declared in api.hpp.
|
// The one definition of the entry point table declared in api.hpp.
|
||||||
#define PLOT_GL_DEF(ret, name, args) ret (*name) args = nullptr;
|
#define PLOT_GL_DEF(ret, name, args) ret (*name) args = nullptr;
|
||||||
PLOT_GL_FUNCS(PLOT_GL_DEF)
|
PLOT_GL_FUNCS(PLOT_GL_DEF)
|
||||||
|
PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_DEF)
|
||||||
#undef PLOT_GL_DEF
|
#undef PLOT_GL_DEF
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -171,6 +172,11 @@ bool load(ProcLoader loader) noexcept {
|
|||||||
|
|
||||||
PLOT_GL_FUNCS(PLOT_GL_RESOLVE)
|
PLOT_GL_FUNCS(PLOT_GL_RESOLVE)
|
||||||
|
|
||||||
|
// Above-baseline entries: resolved the same way, but a name the driver does not
|
||||||
|
// have leaves its pointer null instead of failing the load, so a 3.3-only
|
||||||
|
// context keeps working. Callers gate them on caps() (see api.hpp).
|
||||||
|
PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_RESOLVE)
|
||||||
|
|
||||||
#undef PLOT_GL_RESOLVE
|
#undef PLOT_GL_RESOLVE
|
||||||
|
|
||||||
// The table may have changed, so whatever caps() cached for the old one is
|
// The table may have changed, so whatever caps() cached for the old one is
|
||||||
|
|||||||
@@ -284,6 +284,16 @@ const Measurement& Plot::measurement(u32 id) const {
|
|||||||
|
|
||||||
void Plot::invalidate_measurements() noexcept { mark_measures_dirty(); }
|
void Plot::invalidate_measurements() noexcept { mark_measures_dirty(); }
|
||||||
|
|
||||||
|
MeasureSpan Plot::measure_span(u32 id) const noexcept {
|
||||||
|
// An id that names nothing, or a measure built from cursor ids that were never
|
||||||
|
// registered, is not a span between two markers: `valid` says so rather than
|
||||||
|
// letting a caller shade a band between cursors 0 and 1 by accident.
|
||||||
|
if (id >= measures_.size()) return MeasureSpan{};
|
||||||
|
const MeasureSlot& slot = measures_[id];
|
||||||
|
if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{};
|
||||||
|
return MeasureSpan{slot.cursor_a, slot.cursor_b, true};
|
||||||
|
}
|
||||||
|
|
||||||
const PlotLayout& Plot::layout() {
|
const PlotLayout& Plot::layout() {
|
||||||
// Axes still on auto scale refit here, once per layout, so a growing stream
|
// Axes still on auto scale refit here, once per layout, so a growing stream
|
||||||
// stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range),
|
// stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range),
|
||||||
|
|||||||
+95
-9
@@ -1,9 +1,13 @@
|
|||||||
#include <plot/render/buffer.hpp>
|
#include <plot/render/buffer.hpp>
|
||||||
|
|
||||||
|
#include <plot/gl/api.hpp>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
namespace plot::render {
|
namespace plot::render {
|
||||||
namespace detail {
|
namespace detail {
|
||||||
|
|
||||||
bool InstanceStore::create(Status* st) noexcept {
|
bool InstanceStore::create(UploadMode mode, Status* st) noexcept {
|
||||||
// plot::gl::Buffer::create() already reports Unsupported while the entry
|
// plot::gl::Buffer::create() already reports Unsupported while the entry
|
||||||
// points are unresolved and GLFailure when the driver hands out no name, so
|
// points are unresolved and GLFailure when the driver hands out no name, so
|
||||||
// the wrapper only has to propagate its answer.
|
// the wrapper only has to propagate its answer.
|
||||||
@@ -11,21 +15,103 @@ bool InstanceStore::create(Status* st) noexcept {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
capacity_ = 0;
|
capacity_ = 0;
|
||||||
|
slot_bytes_ = 0;
|
||||||
|
slot_ = kSlots - 1u; // the first upload takes slot 0
|
||||||
|
// The mapped path additionally needs its entry points, which are resolved
|
||||||
|
// best-effort (see api.hpp): without them the store would never be able to
|
||||||
|
// write anything, so it stays on the baseline path instead.
|
||||||
|
mode_ = mode == UploadMode::PersistentMapped && gl::glBufferStorage != nullptr &&
|
||||||
|
gl::glMapBufferRange != nullptr
|
||||||
|
? UploadMode::PersistentMapped
|
||||||
|
: UploadMode::Orphan;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InstanceStore::release_storage() noexcept {
|
||||||
|
if (mapped_ != nullptr) {
|
||||||
|
// A persistent mapping outlives the frame that created it, so it has to be
|
||||||
|
// dropped before the buffer it belongs to is deleted.
|
||||||
|
buffer_.unmap();
|
||||||
|
mapped_ = nullptr;
|
||||||
|
}
|
||||||
|
for (gl::Fence& fence : fences_) {
|
||||||
|
fence.reset();
|
||||||
|
}
|
||||||
|
buffer_.reset();
|
||||||
|
slot_bytes_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InstanceStore::grow_persistent(usize bytes) noexcept {
|
||||||
|
// Deleting the old buffer is safe while the GPU may still be reading it: GL
|
||||||
|
// keeps the storage alive until the last command that references it retires.
|
||||||
|
release_storage();
|
||||||
|
Status st;
|
||||||
|
if (!buffer_.create(&st)) {
|
||||||
|
mode_ = UploadMode::Orphan; // nothing left to draw with at all
|
||||||
|
capacity_ = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const gl::GLbitfield flags =
|
||||||
|
gl::GL_MAP_WRITE_BIT | gl::GL_MAP_PERSISTENT_BIT | gl::GL_MAP_COHERENT_BIT;
|
||||||
|
buffer_.storage(kSlots * bytes, flags);
|
||||||
|
mapped_ = static_cast<u8*>(buffer_.map_range(0, kSlots * bytes, flags));
|
||||||
|
if (mapped_ == nullptr) {
|
||||||
|
// The driver refused the storage or the mapping. A frame that draws through
|
||||||
|
// the baseline path beats one that draws nothing, so re-create the empty
|
||||||
|
// buffer the orphan path needs and let the next upload use it.
|
||||||
|
release_storage();
|
||||||
|
mode_ = UploadMode::Orphan;
|
||||||
|
capacity_ = 0;
|
||||||
|
Status retry;
|
||||||
|
(void)buffer_.create(&retry); // a failed retry leaves nothing to draw with
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
slot_bytes_ = bytes;
|
||||||
|
slot_ = kSlots - 1u; // fresh storage: the first upload takes slot 0
|
||||||
|
++epoch_;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) noexcept {
|
void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) noexcept {
|
||||||
if (buffer_.id() == 0 || count == 0 || instance_bytes == 0) {
|
if (buffer_.id() == 0 || data == nullptr || count == 0 || instance_bytes == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (count > capacity_) {
|
const usize bytes = usize(count) * instance_bytes;
|
||||||
// Only a workload larger than anything staged so far pays for an
|
if (mode_ == UploadMode::PersistentMapped && bytes > slot_bytes_ && !grow_persistent(bytes)) {
|
||||||
// allocation; a frame that repeats the previous one writes into the store
|
// The fallback already replaced the store with an orphan buffer.
|
||||||
// that is already there.
|
|
||||||
buffer_.upload(nullptr, static_cast<usize>(count) * instance_bytes, gl::GL_STREAM_DRAW);
|
|
||||||
capacity_ = count;
|
|
||||||
}
|
}
|
||||||
buffer_.upload_sub(data, static_cast<usize>(count) * instance_bytes, 0);
|
|
||||||
|
if (mode_ == UploadMode::Orphan) {
|
||||||
|
if (count > capacity_) {
|
||||||
|
capacity_ = count; // grow only when the workload outgrows the store
|
||||||
|
}
|
||||||
|
buffer_.upload(nullptr, usize(capacity_) * instance_bytes, gl::GL_STREAM_DRAW);
|
||||||
|
buffer_.upload_sub(data, bytes, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the ring, then make sure the draw that last read this slot has
|
||||||
|
// retired. The non-blocking check is the common case; the bounded wait only
|
||||||
|
// costs anything when three frames' geometry is still in flight.
|
||||||
|
slot_ = (slot_ + 1u) % kSlots;
|
||||||
|
if (fences_[slot_]) {
|
||||||
|
if (!fences_[slot_].wait(0)) {
|
||||||
|
(void)fences_[slot_].wait(kFenceWaitNs);
|
||||||
|
}
|
||||||
|
fences_[slot_].reset();
|
||||||
|
}
|
||||||
|
// MAP_COHERENT storage needs no explicit flush, so this is the whole transfer.
|
||||||
|
std::memcpy(mapped_ + usize(slot_) * slot_bytes_, data, bytes);
|
||||||
|
++epoch_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InstanceStore::mark_drawn() noexcept {
|
||||||
|
if (mode_ != UploadMode::PersistentMapped || mapped_ == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Status st;
|
||||||
|
fences_[slot_].reset();
|
||||||
|
(void)fences_[slot_].create(&st);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace detail
|
} // namespace detail
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ void LineBatch::add_polyline(const Vec2* pts, u32 n) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void LineBatch::add_polyline_xy(const f32* xy, u32 n) {
|
||||||
|
if (xy == nullptr || n < 2u) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
v_.reserve(v_.size() + 4u * (n - 1u));
|
||||||
|
for (u32 i = 1; i < n; ++i) {
|
||||||
|
v_.push_back(xy[2u * (i - 1u)]);
|
||||||
|
v_.push_back(xy[2u * (i - 1u) + 1u]);
|
||||||
|
v_.push_back(xy[2u * i]);
|
||||||
|
v_.push_back(xy[2u * i + 1u]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------- TextBatch
|
// ----------------------------------------------------------------- TextBatch
|
||||||
|
|
||||||
void TextBatch::begin() noexcept { v_.clear(); }
|
void TextBatch::begin() noexcept { v_.clear(); }
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
#include <plot/render/renderer.hpp>
|
||||||
|
|
||||||
|
#include <plot/gl/api.hpp>
|
||||||
|
#include <plot/gl/caps.hpp>
|
||||||
|
#include <plot/gl/loader.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace plot {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// `PLOT_FORCE_GL33_UPLOAD=1` forces the baseline transfer, like
|
||||||
|
/// `PLOT_HEADLESS_FORCE_PBUFFER` forces the EGL fallback: presence is the switch,
|
||||||
|
/// so both strategies can be exercised on one machine.
|
||||||
|
bool force_gl33_upload() noexcept { return std::getenv("PLOT_FORCE_GL33_UPLOAD") != nullptr; }
|
||||||
|
|
||||||
|
bool fail(Status* st, ErrorCode code, const char* message) noexcept {
|
||||||
|
if (st != nullptr) {
|
||||||
|
st->code = code;
|
||||||
|
st->message = message;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A data-space point in the coordinate the line program's affine part expects:
|
||||||
|
/// the value itself on a linear axis, `log10` of it on a log axis -- exactly what
|
||||||
|
/// `math::Transform::to_screen_*` applies before scaling. A non-positive value on
|
||||||
|
/// a log axis takes the transform's own clamp, so it lands off-scale rather than
|
||||||
|
/// as NaN.
|
||||||
|
Vec2 pre_affine(f64 x, f64 y, bool log_x, bool log_y) noexcept {
|
||||||
|
return Vec2{static_cast<f32>(log_x ? Transform::log10_positive(x) : x),
|
||||||
|
static_cast<f32>(log_y ? Transform::log10_positive(y) : y)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<f64>(n)) return n;
|
||||||
|
return static_cast<u64>(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;
|
||||||
|
constexpr usize kReadoutChars = 160;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- AxisFont
|
||||||
|
|
||||||
|
void Renderer::AxisFont::reset(const render::BitmapFont& font, f32 requested_px) noexcept {
|
||||||
|
font_ = &font;
|
||||||
|
const f32 native = static_cast<f32>(render::BitmapFont::kGlyphPx);
|
||||||
|
// Whole multiples of the native cell only: a 1-bit glyph drawn at 1.5x lands
|
||||||
|
// its strokes on uneven pixel counts, and the layout has to agree with what the
|
||||||
|
// pipeline draws, so the requested size picks the nearest multiple.
|
||||||
|
f32 steps = std::round(requested_px / native);
|
||||||
|
if (!(steps >= 1.0f)) steps = 1.0f; // also catches NaN
|
||||||
|
cell_px_ = steps * native;
|
||||||
|
scale_ = cell_px_ / native;
|
||||||
|
}
|
||||||
|
|
||||||
|
f32 Renderer::AxisFont::measure(const char* utf8) const {
|
||||||
|
return font_ != nullptr ? font_->measure(utf8) * scale_ : 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
f32 Renderer::AxisFont::height() const {
|
||||||
|
return font_ != nullptr ? font_->height() * scale_ : 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
f32 Renderer::AxisFont::ascent() const {
|
||||||
|
return font_ != nullptr ? font_->ascent() * scale_ : 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ init
|
||||||
|
|
||||||
|
bool Renderer::init(Status* st) noexcept {
|
||||||
|
if (!gl::available()) {
|
||||||
|
return fail(st, ErrorCode::Unsupported,
|
||||||
|
"plot::gl::load() has not resolved the GL entry points");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The transfer strategy is decided once, from the capability probe, and never
|
||||||
|
// revisited. Both pointers are checked as well as the caps: they are resolved
|
||||||
|
// best-effort (see api.hpp), so a context can advertise the extension without
|
||||||
|
// the entry points being reachable.
|
||||||
|
const gl::Caps& caps = gl::caps();
|
||||||
|
persistent_ = caps.buffer_storage && caps.persistent_map && gl::glBufferStorage != nullptr &&
|
||||||
|
gl::glMapBufferRange != nullptr && !force_gl33_upload();
|
||||||
|
const render::UploadMode mode =
|
||||||
|
persistent_ ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan;
|
||||||
|
|
||||||
|
font_backend_.reset(font_, style_.axis_font_px);
|
||||||
|
|
||||||
|
if (!atlas_.create_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight, st)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
atlas_.upload_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight,
|
||||||
|
render::BitmapFont::atlas_image());
|
||||||
|
// Texel-for-texel: the text shader samples the nearest texel centre of the
|
||||||
|
// glyph cell, and linear filtering would smear one cell into its neighbour.
|
||||||
|
atlas_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
|
||||||
|
|
||||||
|
if (!line_program_.create(st) || !text_program_.create(st)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ready_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ frame cycle
|
||||||
|
|
||||||
|
void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
|
||||||
|
fb_w_ = fb_w;
|
||||||
|
fb_h_ = fb_h;
|
||||||
|
viewport_ = viewport_px;
|
||||||
|
lines_.clear();
|
||||||
|
glyphs_.clear();
|
||||||
|
stats_ = FrameStats{};
|
||||||
|
gl_error_ = false;
|
||||||
|
|
||||||
|
// Rect is top-left origin, GL is bottom-left: flip once, here, and never again.
|
||||||
|
gl_viewport_ = Rect{viewport_px.x,
|
||||||
|
static_cast<f32>(fb_h) - (viewport_px.y + viewport_px.h),
|
||||||
|
viewport_px.w, viewport_px.h};
|
||||||
|
if (!ready_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gl::GLint vx = static_cast<gl::GLint>(gl_viewport_.x);
|
||||||
|
const gl::GLint vy = static_cast<gl::GLint>(gl_viewport_.y);
|
||||||
|
const gl::GLsizei vw = static_cast<gl::GLsizei>(gl_viewport_.w);
|
||||||
|
const gl::GLsizei vh = static_cast<gl::GLsizei>(gl_viewport_.h);
|
||||||
|
|
||||||
|
// Clear only the plot's region: a host that embeds the plot in a surface it has
|
||||||
|
// already painted keeps everything outside the rect. The scissor box is in
|
||||||
|
// framebuffer coordinates and is not affected by glViewport, which is why the
|
||||||
|
// clear happens before the viewport is set.
|
||||||
|
gl::glEnable(gl::GL_SCISSOR_TEST);
|
||||||
|
gl::glScissor(vx, vy, vw, vh);
|
||||||
|
const Color& background = style_.background;
|
||||||
|
gl::glClearColor(background.r, background.g, background.b, background.a);
|
||||||
|
gl::glClear(gl::GL_COLOR_BUFFER_BIT);
|
||||||
|
gl::glDisable(gl::GL_SCISSOR_TEST);
|
||||||
|
gl::glViewport(vx, vy, vw, vh);
|
||||||
|
|
||||||
|
// The pipelines write straight alpha and rely on blending being on; setting it
|
||||||
|
// per frame costs nothing and makes the renderer independent of what the host
|
||||||
|
// left behind.
|
||||||
|
gl::glEnable(gl::GL_BLEND);
|
||||||
|
gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw(Plot& plot) {
|
||||||
|
stats_ = FrameStats{};
|
||||||
|
if (!ready_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The plot reserves its label strips from TextBackend metrics, so it must
|
||||||
|
// measure with the size the text pipeline draws with, and re-read the style
|
||||||
|
// each frame so a changed axis_font_px takes effect on the next one.
|
||||||
|
font_backend_.reset(font_, style_.axis_font_px);
|
||||||
|
if (plot.text_backend() == nullptr) {
|
||||||
|
plot.set_text_backend(&font_backend_);
|
||||||
|
}
|
||||||
|
// One style, not two: the plot reserves its strips and picks its tick density
|
||||||
|
// from the values the renderer draws with, so the label pad is never one number
|
||||||
|
// for the layout and another for the text placed inside it.
|
||||||
|
plot.set_style(style_);
|
||||||
|
(void)plot.layout(); // autoscale, ticks and grid for the final axes size
|
||||||
|
|
||||||
|
// Draw order: chrome, then data, then the annotations over it, and the text
|
||||||
|
// last (it is texture-bound, so it is one draw call after every line batch).
|
||||||
|
draw_grid(plot);
|
||||||
|
draw_axis_lines(plot);
|
||||||
|
draw_tick_labels(plot);
|
||||||
|
draw_titles(plot);
|
||||||
|
draw_traces(plot);
|
||||||
|
draw_cursors(plot);
|
||||||
|
draw_measures(plot);
|
||||||
|
draw_annotations(plot);
|
||||||
|
flush_text();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::end_frame() noexcept {
|
||||||
|
if (!ready_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Leave the host's context as close to neutral as a frame can: nothing bound to
|
||||||
|
// a pipeline stage, and off every state bit the frame turned on. What remains
|
||||||
|
// is the viewport (the caller's to set for its next pass), the pixel-pack
|
||||||
|
// alignment a PPM dump may have set, and the vertex buffers' own contents.
|
||||||
|
gl::glBindVertexArray(0);
|
||||||
|
gl::glUseProgram(0);
|
||||||
|
gl::glBindBuffer(gl::GL_ARRAY_BUFFER, 0);
|
||||||
|
gl::glActiveTexture(gl::GL_TEXTURE0);
|
||||||
|
gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
|
||||||
|
gl::glDisable(gl::GL_BLEND);
|
||||||
|
// Drain, do not peek: the first error hides every later one, and a frame that
|
||||||
|
// produced several must be reported as having produced one.
|
||||||
|
while (gl::glGetError() != gl::GL_NO_ERROR) {
|
||||||
|
gl_error_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ submission
|
||||||
|
|
||||||
|
void Renderer::upload_vertices(std::span<const f32> interleaved) noexcept {
|
||||||
|
// Four floats per instance is the line pipeline's layout (see LineInstance).
|
||||||
|
// Both transfer strategies live behind the vertex array, so this is the only
|
||||||
|
// place the line pipeline's staged bytes reach the GPU and no caller of it can
|
||||||
|
// tell which path ran.
|
||||||
|
line_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 4u));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
|
||||||
|
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 4u));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept {
|
||||||
|
const u32 segments = lines_.segment_count();
|
||||||
|
if (segments != 0 && ready_) {
|
||||||
|
upload_vertices(lines_.data());
|
||||||
|
line_program_.bind();
|
||||||
|
line_program_.set_scale(map.scale);
|
||||||
|
line_program_.set_offset(map.offset);
|
||||||
|
line_program_.set_viewport({viewport_.w, viewport_.h});
|
||||||
|
line_program_.set_width(width);
|
||||||
|
line_program_.set_color(color);
|
||||||
|
line_vao_.bind();
|
||||||
|
// One triangle strip per instance, four corners from gl_VertexID.
|
||||||
|
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
|
||||||
|
static_cast<gl::GLsizei>(line_vao_.instance_count()));
|
||||||
|
// The draw is in the command stream now, so this is the point at which the
|
||||||
|
// mapped path can let a fence follow it.
|
||||||
|
line_vao_.mark_drawn();
|
||||||
|
stats_.staged_segments += segments;
|
||||||
|
++stats_.draw_calls;
|
||||||
|
}
|
||||||
|
lines_.clear(); // keeps the capacity, so the next group re-uses the store
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::flush_text() noexcept {
|
||||||
|
const u32 glyphs = glyphs_.glyph_count();
|
||||||
|
if (glyphs != 0 && ready_) {
|
||||||
|
upload_text_vertices(glyphs_.data());
|
||||||
|
text_program_.bind();
|
||||||
|
text_program_.set_viewport({viewport_.w, viewport_.h});
|
||||||
|
const f32 cell = font_backend_.cell_px();
|
||||||
|
text_program_.set_px_range({cell, cell});
|
||||||
|
text_program_.set_texture_size(
|
||||||
|
{static_cast<f32>(render::BitmapFont::kAtlasWidth),
|
||||||
|
static_cast<f32>(render::BitmapFont::kAtlasHeight)});
|
||||||
|
text_program_.set_color(style_.axis_text);
|
||||||
|
atlas_.bind();
|
||||||
|
text_vao_.bind();
|
||||||
|
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
|
||||||
|
static_cast<gl::GLsizei>(text_vao_.instance_count()));
|
||||||
|
text_vao_.mark_drawn();
|
||||||
|
++stats_.draw_calls;
|
||||||
|
}
|
||||||
|
glyphs_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- coordinates
|
||||||
|
|
||||||
|
Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept {
|
||||||
|
const Transform tx = plot.x_transform(axis_x);
|
||||||
|
const Transform ty = plot.y_transform(axis_y);
|
||||||
|
// The plot's transforms already map data to framebuffer pixels; the shaders
|
||||||
|
// emit NDC relative to the GL viewport, so the viewport origin comes off here
|
||||||
|
// -- once per mapping, never per primitive.
|
||||||
|
return Mapping{Vec2{static_cast<f32>(tx.sx), static_cast<f32>(ty.sy)},
|
||||||
|
Vec2{static_cast<f32>(tx.tx) - viewport_.x,
|
||||||
|
static_cast<f32>(ty.ty) - viewport_.y}};
|
||||||
|
}
|
||||||
|
|
||||||
|
Renderer::Mapping Renderer::pixel_mapping() const noexcept {
|
||||||
|
// Geometry already in framebuffer pixels needs no scaling, and the line
|
||||||
|
// program's offset uniform does the viewport shift. (The text pipeline has no
|
||||||
|
// offset uniform, so stage_text() shifts its positions itself.)
|
||||||
|
return Mapping{Vec2{1.0f, 1.0f}, Vec2{-viewport_.x, -viewport_.y}};
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec2 Renderer::to_viewport(Vec2 px) const noexcept {
|
||||||
|
return Vec2{px.x - viewport_.x, px.y - viewport_.y};
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y) {
|
||||||
|
lines_.add_segment(pre_affine(x0, y0, log_x, log_y), pre_affine(x1, y1, log_x, log_y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::stage_xy(const f32* xy, u32 n, bool log_x, bool log_y) {
|
||||||
|
if (!log_x && !log_y) {
|
||||||
|
// The common case: gathered points are already interleaved `x,y`, which is
|
||||||
|
// the batch's own payload, so this is a straight copy.
|
||||||
|
lines_.add_polyline_xy(xy, n);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (u32 i = 0; i + 1u < n; ++i) {
|
||||||
|
stage_segment(xy[2u * i], xy[2u * i + 1u], xy[2u * i + 2u], xy[2u * i + 3u], log_x, log_y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
|
||||||
|
// Text is staged in viewport pixels: the text program has no offset uniform.
|
||||||
|
return glyphs_.add_text(to_viewport(pos_px), utf8, font_, font_backend_.cell_px());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- grid
|
||||||
|
|
||||||
|
void Renderer::draw_grid(const Plot& plot) {
|
||||||
|
const Grid& grid = plot.grid();
|
||||||
|
const Mapping map = mapping(plot, 0, 0);
|
||||||
|
// The grid is built from the primary axis pair, so it is mapped with their
|
||||||
|
// transforms even when a trace uses another pair.
|
||||||
|
const bool log_x = plot.x_axis(0).log;
|
||||||
|
const bool log_y = plot.y_axis(0).log;
|
||||||
|
|
||||||
|
// Two colour groups, one pass over the segments each: `LineBatch::clear()`
|
||||||
|
// keeps its capacity, so the second group re-uses the first one's storage.
|
||||||
|
for (u32 pass = 0; pass < 2u; ++pass) {
|
||||||
|
const bool majors = pass == 0u;
|
||||||
|
for (const GridLine& line : grid.lines()) {
|
||||||
|
if (line.major != majors) continue; // grid.build() already dropped non-finite ones
|
||||||
|
stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y);
|
||||||
|
}
|
||||||
|
flush_lines(map, majors ? style_.grid_major : style_.grid_minor, 1.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- axis chrome
|
||||||
|
|
||||||
|
void Renderer::draw_axis_lines(const Plot& plot) {
|
||||||
|
const Range y_range = plot.y_range(0);
|
||||||
|
const Range x_range = plot.x_range(0);
|
||||||
|
const bool log_x0 = plot.x_axis(0).log;
|
||||||
|
const bool log_y0 = plot.y_axis(0).log;
|
||||||
|
|
||||||
|
// An x axis draws along its edge of the axes rect, spanning its own range; the
|
||||||
|
// perpendicular extent is the primary y axis' range, which is exactly what
|
||||||
|
// maps to the rect's top and bottom.
|
||||||
|
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
|
||||||
|
const Axis& axis = plot.x_axis(i);
|
||||||
|
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
|
||||||
|
stage_segment(axis.range.min, y, axis.range.max, y, axis.log, log_y0);
|
||||||
|
flush_lines(mapping(plot, i, 0), style_.axis_line, style_.line_width_px);
|
||||||
|
}
|
||||||
|
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
|
||||||
|
const Axis& axis = plot.y_axis(i);
|
||||||
|
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
|
||||||
|
stage_segment(x, axis.range.min, x, axis.range.max, log_x0, axis.log);
|
||||||
|
flush_lines(mapping(plot, 0, i), style_.axis_line, style_.line_width_px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_tick_labels(const Plot& plot) {
|
||||||
|
const PlotLayout& layout = plot.layout();
|
||||||
|
const f32 line_h = font_backend_.height();
|
||||||
|
|
||||||
|
// X labels: centred under their tick, on the strip's single text line.
|
||||||
|
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
|
||||||
|
const Transform transform = plot.x_transform(i);
|
||||||
|
for (const Tick& tick : plot.x_axis(i).ticks) {
|
||||||
|
if (!tick.major || tick.label.empty()) continue;
|
||||||
|
const f32 width = font_backend_.measure(tick.label.c_str());
|
||||||
|
stage_text({static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
|
||||||
|
layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)},
|
||||||
|
tick.label.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y labels: right-aligned in their strip, centred on the tick and pulled back
|
||||||
|
// inside the strip at the two extremes (a label centred on the top tick would
|
||||||
|
// otherwise hang above the plot area).
|
||||||
|
const f32 top_limit =
|
||||||
|
std::max(layout.y_label_area.y, layout.y_label_area.bottom() - line_h);
|
||||||
|
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
|
||||||
|
const Transform transform = plot.y_transform(i);
|
||||||
|
for (const Tick& tick : plot.y_axis(i).ticks) {
|
||||||
|
if (!tick.major || tick.label.empty()) continue;
|
||||||
|
const f32 width = font_backend_.measure(tick.label.c_str());
|
||||||
|
const f32 centre = static_cast<f32>(transform.to_screen_y(tick.value));
|
||||||
|
const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit);
|
||||||
|
stage_text({layout.y_label_area.right() - style_.grid_label_pad - width, top},
|
||||||
|
tick.label.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_titles(const Plot& plot) {
|
||||||
|
// The layout reserves one text line per strip for the tick labels, so an axis
|
||||||
|
// title has no room outside the axes rect. It hugs the inside of its axis line
|
||||||
|
// instead, at the far end of the axis, where it needs no extra layout space and
|
||||||
|
// cannot collide with the labels of the axis it names.
|
||||||
|
const Rect& axes = plot.layout().axes;
|
||||||
|
const f32 line_h = font_backend_.height();
|
||||||
|
|
||||||
|
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
|
||||||
|
const Axis& axis = plot.x_axis(i);
|
||||||
|
if (axis.label.empty()) continue;
|
||||||
|
const f32 width = font_backend_.measure(axis.label.c_str());
|
||||||
|
const f32 top = axis.place == AxisPlace::Top ? axes.y + 2.0f : axes.bottom() - line_h - 2.0f;
|
||||||
|
stage_text({axes.right() - width - 2.0f, top}, axis.label.c_str());
|
||||||
|
}
|
||||||
|
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
|
||||||
|
const Axis& axis = plot.y_axis(i);
|
||||||
|
if (axis.label.empty()) continue;
|
||||||
|
const f32 width = font_backend_.measure(axis.label.c_str());
|
||||||
|
const f32 left = axis.place == AxisPlace::Right ? axes.right() - width - 2.0f : axes.x + 2.0f;
|
||||||
|
stage_text({left, axes.y + 2.0f}, axis.label.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- data
|
||||||
|
|
||||||
|
void Renderer::draw_traces(const Plot& plot) {
|
||||||
|
const PlotLayout& layout = plot.layout();
|
||||||
|
if (!(layout.axes.w > 0.0f)) {
|
||||||
|
return; // a collapsed plot area has no pixel to select a level for
|
||||||
|
}
|
||||||
|
|
||||||
|
for (u32 i = 0; i < plot.trace_count(); ++i) {
|
||||||
|
const Trace& trace = plot.trace(i);
|
||||||
|
if (!trace.visible()) continue; // null series, or one with no sample yet
|
||||||
|
const data::Series& series = *trace.series;
|
||||||
|
const f64 dx = series.dx();
|
||||||
|
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.
|
||||||
|
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);
|
||||||
|
if (last <= first) continue;
|
||||||
|
|
||||||
|
// This is the whole performance contract: the window, never the series.
|
||||||
|
const u64 visible = last - first;
|
||||||
|
const f64 samples_per_pixel = static_cast<f64>(visible) / static_cast<f64>(layout.axes.w);
|
||||||
|
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
|
||||||
|
? series.pyramid().level_for(n, samples_per_pixel)
|
||||||
|
: 0u;
|
||||||
|
|
||||||
|
gather_.clear();
|
||||||
|
series.gather(level, first, last, gather_);
|
||||||
|
if (gather_.size() < 4u) continue; // fewer than two points is no segment
|
||||||
|
|
||||||
|
stats_.visible_samples += visible;
|
||||||
|
if (level == 0u) {
|
||||||
|
++stats_.raw_traces;
|
||||||
|
} else {
|
||||||
|
++stats_.lod_traces;
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_xy(gather_.data(), static_cast<u32>(gather_.size() / 2u),
|
||||||
|
plot.x_axis(trace.axis_x).log, plot.y_axis(trace.axis_y).log);
|
||||||
|
flush_lines(mapping(plot, trace.axis_x, trace.axis_y), trace.color, trace.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_cursors(const Plot& plot) {
|
||||||
|
const PlotLayout& layout = plot.layout();
|
||||||
|
const Range y_range = plot.y_range(0);
|
||||||
|
const bool log_y = plot.y_axis(0).log;
|
||||||
|
|
||||||
|
for (u32 i = 0; i < plot.cursor_count(); ++i) {
|
||||||
|
const Cursor& cursor = plot.cursor(i);
|
||||||
|
if (!cursor.visible) continue;
|
||||||
|
const Transform transform = plot.x_transform(cursor.axis_x);
|
||||||
|
const f32 px = static_cast<f32>(transform.to_screen_x(cursor.x));
|
||||||
|
// A cursor scrolled out of the visible x range is off the plot: drawing it
|
||||||
|
// would put a marker over the label strip.
|
||||||
|
if (px < layout.axes.x || px >= layout.axes.right()) continue;
|
||||||
|
|
||||||
|
stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log,
|
||||||
|
log_y);
|
||||||
|
flush_lines(mapping(plot, cursor.axis_x, 0), cursor.color, style_.line_width_px);
|
||||||
|
if (!cursor.label.empty()) {
|
||||||
|
stage_text({px + 3.0f, layout.axes.y + 2.0f}, cursor.label.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_measures(const Plot& plot) {
|
||||||
|
const PlotLayout& layout = plot.layout();
|
||||||
|
const Rect& axes = layout.axes;
|
||||||
|
const Range y_range = plot.y_range(0);
|
||||||
|
const bool log_y = plot.y_axis(0).log;
|
||||||
|
const f32 line_h = font_backend_.height();
|
||||||
|
|
||||||
|
for (u32 i = 0; i < plot.measure_count(); ++i) {
|
||||||
|
const MeasureSpan span = plot.measure_span(i);
|
||||||
|
const Measurement& measure = plot.measurement(i);
|
||||||
|
if (!span.valid || !measure.valid) continue;
|
||||||
|
|
||||||
|
const Cursor& a = plot.cursor(span.cursor_a);
|
||||||
|
const Cursor& b = plot.cursor(span.cursor_b);
|
||||||
|
const Transform transform = plot.x_transform(a.axis_x);
|
||||||
|
f64 left_px = transform.to_screen_x(a.x);
|
||||||
|
f64 right_px = transform.to_screen_x(b.x);
|
||||||
|
if (right_px < left_px) std::swap(left_px, right_px);
|
||||||
|
const f32 x0 = std::max(static_cast<f32>(left_px), axes.x);
|
||||||
|
const f32 x1 = std::min(static_cast<f32>(right_px), axes.right());
|
||||||
|
|
||||||
|
if (x1 > x0) {
|
||||||
|
// The shaded band: one vertical stroke per pixel column of the interval.
|
||||||
|
// The line pipeline has no filled primitive, and the band is bounded by the
|
||||||
|
// plot's width, so a per-column fill stays O(pixels) and needs no second
|
||||||
|
// pipeline. Strokes are staged in data space like everything else, so the
|
||||||
|
// band follows a pan or a zoom through the uniform alone.
|
||||||
|
for (f32 px = x0; px < x1; px += 1.0f) {
|
||||||
|
const f64 x = transform.to_data_x(static_cast<f64>(px) + 0.5);
|
||||||
|
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
|
||||||
|
}
|
||||||
|
flush_lines(mapping(plot, a.axis_x, 0), style_.measure_fill, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
char dx[kNumberChars];
|
||||||
|
char dy[kNumberChars];
|
||||||
|
char rms[kNumberChars];
|
||||||
|
char mean[kNumberChars];
|
||||||
|
format_si(measure.dx, dx, kNumberChars);
|
||||||
|
format_si(measure.dy, dy, kNumberChars);
|
||||||
|
format_si(measure.rms, rms, kNumberChars);
|
||||||
|
format_si(measure.mean, mean, kNumberChars);
|
||||||
|
char readout[kReadoutChars];
|
||||||
|
std::snprintf(readout, kReadoutChars, "dx %s dy %s rms %s mean %s", dx, dy, rms, mean);
|
||||||
|
|
||||||
|
const f32 width = font_backend_.measure(readout);
|
||||||
|
const f32 limit = std::max(axes.x + 2.0f, axes.right() - width - 2.0f);
|
||||||
|
const f32 left = std::clamp(0.5f * (x0 + x1) - 0.5f * width, axes.x + 2.0f, limit);
|
||||||
|
// The second line of the plot area, not the first: the first belongs to the
|
||||||
|
// y axis title (and to cursor labels), and a wide readout clamped to the left
|
||||||
|
// edge would overprint them.
|
||||||
|
stage_text({left, axes.y + line_h + 2.0f}, readout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_annotations(const Plot& plot) {
|
||||||
|
const f32 line_h = font_backend_.height();
|
||||||
|
for (u32 i = 0; i < plot.annotation_count(); ++i) {
|
||||||
|
const Annotation& annotation = plot.annotation(i);
|
||||||
|
if (!annotation.visible) continue;
|
||||||
|
const f32 px = static_cast<f32>(plot.x_transform(annotation.axis_x).to_screen_x(annotation.x));
|
||||||
|
const f32 py = static_cast<f32>(plot.y_transform(annotation.axis_y).to_screen_y(annotation.y));
|
||||||
|
|
||||||
|
// A small cross pins the point; the caption sits to its right. Both are in
|
||||||
|
// framebuffer pixels, hence the identity mapping for the marker.
|
||||||
|
lines_.add_segment({px - 3.0f, py}, {px + 3.0f, py});
|
||||||
|
lines_.add_segment({px, py - 3.0f}, {px, py + 3.0f});
|
||||||
|
flush_lines(pixel_mapping(), annotation.color, 1.0f);
|
||||||
|
if (!annotation.text.empty()) {
|
||||||
|
stage_text({px + 5.0f, py - 0.5f * line_h}, annotation.text.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- dump
|
||||||
|
|
||||||
|
bool Renderer::dump_frame_ppm(const char* path, Status* st) const noexcept {
|
||||||
|
if (path == nullptr || path[0] == '\0') {
|
||||||
|
return fail(st, ErrorCode::InvalidArgument, "PPM path is empty");
|
||||||
|
}
|
||||||
|
if (!ready_) {
|
||||||
|
return fail(st, ErrorCode::Unsupported, "renderer was never initialised");
|
||||||
|
}
|
||||||
|
const u32 width = static_cast<u32>(gl_viewport_.w);
|
||||||
|
const u32 height = static_cast<u32>(gl_viewport_.h);
|
||||||
|
if (width == 0 || height == 0) {
|
||||||
|
return fail(st, ErrorCode::InvalidArgument, "no frame has been drawn yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGBA, not RGB: 4-byte pixels have no row padding whatever the pack alignment,
|
||||||
|
// so a host's GL_PACK_ALIGNMENT cannot make the read overrun the buffer.
|
||||||
|
readback_.assign(usize(width) * height * 4u, 0u);
|
||||||
|
gl::glPixelStorei(gl::GL_PACK_ALIGNMENT, 4);
|
||||||
|
gl::glReadPixels(static_cast<gl::GLint>(gl_viewport_.x), static_cast<gl::GLint>(gl_viewport_.y),
|
||||||
|
static_cast<gl::GLsizei>(width), static_cast<gl::GLsizei>(height), gl::GL_RGBA,
|
||||||
|
gl::GL_UNSIGNED_BYTE, readback_.data());
|
||||||
|
|
||||||
|
std::FILE* file = std::fopen(path, "wb");
|
||||||
|
if (file == nullptr) {
|
||||||
|
return fail(st, ErrorCode::InvalidArgument, "cannot open the PPM path for writing");
|
||||||
|
}
|
||||||
|
std::fprintf(file, "P6\n%u %u\n255\n", width, height);
|
||||||
|
std::vector<u8> row(usize(width) * 3u);
|
||||||
|
bool ok = true;
|
||||||
|
for (u32 y = 0; y < height && ok; ++y) {
|
||||||
|
// glReadPixels hands back rows bottom-up; a PPM's rows run top-down.
|
||||||
|
const u8* src = readback_.data() + usize(height - 1u - y) * usize(width) * 4u;
|
||||||
|
for (u32 x = 0; x < width; ++x) {
|
||||||
|
row[usize(x) * 3u] = src[usize(x) * 4u];
|
||||||
|
row[usize(x) * 3u + 1u] = src[usize(x) * 4u + 1u];
|
||||||
|
row[usize(x) * 3u + 2u] = src[usize(x) * 4u + 2u];
|
||||||
|
}
|
||||||
|
ok = std::fwrite(row.data(), 1, row.size(), file) == row.size();
|
||||||
|
}
|
||||||
|
if (std::fclose(file) != 0) ok = false;
|
||||||
|
if (!ok) {
|
||||||
|
return fail(st, ErrorCode::InvalidArgument, "writing the PPM failed");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace plot
|
||||||
@@ -14,11 +14,11 @@ constexpr gl::GLuint kPerInstance = 1;
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool LineVertexArray::create(Status* st) noexcept {
|
bool LineVertexArray::create(Status* st, UploadMode mode) noexcept {
|
||||||
if (!vao_.create(st)) {
|
if (!vao_.create(st)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!buffer_.create(st)) {
|
if (!buffer_.create(st, mode)) {
|
||||||
vao_.reset(); // do not leave a vertex array behind on the failure path
|
vao_.reset(); // do not leave a vertex array behind on the failure path
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ bool LineVertexArray::create(Status* st) noexcept {
|
|||||||
gl::glVertexAttribDivisor(0u, kPerInstance);
|
gl::glVertexAttribDivisor(0u, kPerInstance);
|
||||||
gl::glVertexAttribDivisor(1u, kPerInstance);
|
gl::glVertexAttribDivisor(1u, kPerInstance);
|
||||||
gl::glBindVertexArray(0); // leave the binding as we found it
|
gl::glBindVertexArray(0); // leave the binding as we found it
|
||||||
|
epoch_ = buffer_.epoch();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,14 +44,26 @@ void LineVertexArray::upload(const f32* interleaved, u32 segments) noexcept {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
buffer_.upload(interleaved, segments);
|
buffer_.upload(interleaved, segments);
|
||||||
|
// Only the mapped transfer moves the instances between slots, and the
|
||||||
|
// attribute offsets have to follow them. The generation changes for a new slot
|
||||||
|
// or a re-created buffer, never otherwise, so the baseline path re-points at
|
||||||
|
// most once.
|
||||||
|
if (buffer_.epoch() != epoch_) {
|
||||||
|
const usize base = buffer_.slot_offset();
|
||||||
|
vao_.bind();
|
||||||
|
buffer_.bind();
|
||||||
|
vao_.attrib(0u, 2, sizeof(LineInstance), base + offsetof(LineInstance, x0));
|
||||||
|
vao_.attrib(1u, 2, sizeof(LineInstance), base + offsetof(LineInstance, x1));
|
||||||
|
epoch_ = buffer_.epoch();
|
||||||
|
}
|
||||||
instances_ = segments;
|
instances_ = segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool TextVertexArray::create(Status* st) noexcept {
|
bool TextVertexArray::create(Status* st, UploadMode mode) noexcept {
|
||||||
if (!vao_.create(st)) {
|
if (!vao_.create(st)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!buffer_.create(st)) {
|
if (!buffer_.create(st, mode)) {
|
||||||
vao_.reset();
|
vao_.reset();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -61,6 +74,7 @@ bool TextVertexArray::create(Status* st) noexcept {
|
|||||||
gl::glVertexAttribDivisor(0u, kPerInstance);
|
gl::glVertexAttribDivisor(0u, kPerInstance);
|
||||||
gl::glVertexAttribDivisor(1u, kPerInstance);
|
gl::glVertexAttribDivisor(1u, kPerInstance);
|
||||||
gl::glBindVertexArray(0);
|
gl::glBindVertexArray(0);
|
||||||
|
epoch_ = buffer_.epoch();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +84,14 @@ void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
buffer_.upload(interleaved, glyphs);
|
buffer_.upload(interleaved, glyphs);
|
||||||
|
if (buffer_.epoch() != epoch_) {
|
||||||
|
const usize base = buffer_.slot_offset();
|
||||||
|
vao_.bind();
|
||||||
|
buffer_.bind();
|
||||||
|
vao_.attrib(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x));
|
||||||
|
vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u));
|
||||||
|
epoch_ = buffer_.epoch();
|
||||||
|
}
|
||||||
instances_ = glyphs;
|
instances_ = glyphs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ add_executable(plot_tests
|
|||||||
test_ticks.cpp
|
test_ticks.cpp
|
||||||
test_measure.cpp
|
test_measure.cpp
|
||||||
test_plot.cpp
|
test_plot.cpp
|
||||||
|
test_render_smoke.cpp
|
||||||
test_controller.cpp
|
test_controller.cpp
|
||||||
egl_headless.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);
|
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_TEST(line_batch_ignores_degenerate_polylines) {
|
||||||
plot::render::LineBatch b;
|
plot::render::LineBatch b;
|
||||||
b.begin();
|
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