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
@@ -109,6 +109,25 @@ void Buffer::upload_sub(const void* data, usize bytes, usize offset) const noexc
|
||||
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); }
|
||||
|
||||
// -------------------------------------------------------------- VertexArray
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace plot::gl {
|
||||
// The one definition of the entry point table declared in api.hpp.
|
||||
#define PLOT_GL_DEF(ret, name, args) ret (*name) args = nullptr;
|
||||
PLOT_GL_FUNCS(PLOT_GL_DEF)
|
||||
PLOT_GL_OPTIONAL_FUNCS(PLOT_GL_DEF)
|
||||
#undef PLOT_GL_DEF
|
||||
|
||||
namespace {
|
||||
@@ -171,6 +172,11 @@ bool load(ProcLoader loader) noexcept {
|
||||
|
||||
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
|
||||
|
||||
// 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(); }
|
||||
|
||||
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() {
|
||||
// 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),
|
||||
|
||||
+95
-9
@@ -1,9 +1,13 @@
|
||||
#include <plot/render/buffer.hpp>
|
||||
|
||||
#include <plot/gl/api.hpp>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace plot::render {
|
||||
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
|
||||
// points are unresolved and GLFailure when the driver hands out no name, so
|
||||
// the wrapper only has to propagate its answer.
|
||||
@@ -11,21 +15,103 @@ bool InstanceStore::create(Status* st) noexcept {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (count > capacity_) {
|
||||
// Only a workload larger than anything staged so far pays for an
|
||||
// allocation; a frame that repeats the previous one writes into the store
|
||||
// that is already there.
|
||||
buffer_.upload(nullptr, static_cast<usize>(count) * instance_bytes, gl::GL_STREAM_DRAW);
|
||||
capacity_ = count;
|
||||
const usize bytes = usize(count) * instance_bytes;
|
||||
if (mode_ == UploadMode::PersistentMapped && bytes > slot_bytes_ && !grow_persistent(bytes)) {
|
||||
// The fallback already replaced the store with an orphan buffer.
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
bool LineVertexArray::create(Status* st) noexcept {
|
||||
bool LineVertexArray::create(Status* st, UploadMode mode) noexcept {
|
||||
if (!vao_.create(st)) {
|
||||
return false;
|
||||
}
|
||||
if (!buffer_.create(st)) {
|
||||
if (!buffer_.create(st, mode)) {
|
||||
vao_.reset(); // do not leave a vertex array behind on the failure path
|
||||
return false;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ bool LineVertexArray::create(Status* st) noexcept {
|
||||
gl::glVertexAttribDivisor(0u, kPerInstance);
|
||||
gl::glVertexAttribDivisor(1u, kPerInstance);
|
||||
gl::glBindVertexArray(0); // leave the binding as we found it
|
||||
epoch_ = buffer_.epoch();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -43,14 +44,26 @@ void LineVertexArray::upload(const f32* interleaved, u32 segments) noexcept {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
bool TextVertexArray::create(Status* st) noexcept {
|
||||
bool TextVertexArray::create(Status* st, UploadMode mode) noexcept {
|
||||
if (!vao_.create(st)) {
|
||||
return false;
|
||||
}
|
||||
if (!buffer_.create(st)) {
|
||||
if (!buffer_.create(st, mode)) {
|
||||
vao_.reset();
|
||||
return false;
|
||||
}
|
||||
@@ -61,6 +74,7 @@ bool TextVertexArray::create(Status* st) noexcept {
|
||||
gl::glVertexAttribDivisor(0u, kPerInstance);
|
||||
gl::glVertexAttribDivisor(1u, kPerInstance);
|
||||
gl::glBindVertexArray(0);
|
||||
epoch_ = buffer_.epoch();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -70,6 +84,14 @@ void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user