feat: a plot can carry a phosphor density, one image for every acquisition
`Plot::set_density()` gives a plot a **phosphor density**: a screen-sized intensity image behind its traces, into which each acquisition is deposited and which fades by a time constant or by a per-acquisition factor. It is the library's and not a host's because the library is the side that knows a trace's device geometry -- a host would be re-rasterising traces the renderer has already staged, at a second derivation of the same mapping. It is an **image and not a set of retained traces**, and that is the whole of its cost: one `f32` cell per device pixel of the axes rect (`DensityGrid`), a cell's value a *number of sweeps*, so a display of a thousand acquisitions costs what one costs and there is no depth to get wrong. Measured on a 640x360 viewport's axes: **573x313 = 179 349 cells, 717 396 bytes** of grid, and one byte per cell uploaded on the frame the grid moved. - `Plot::accumulate_density()` deposits the acquisition on screen: every visible trace's window is mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it, then walked column by column -- so the deposit is bounded by the axes' columns and not by the samples behind them -- and a cell takes **1.0 once per trace per sweep**, however many of that trace's own samples crossed it. Counting the hits instead put a single dense sweep at the ceiling of the brightness scale and hid the sweeps behind it, and it made a cell mean "how much the beam moved here" rather than "how many acquisitions were here"; the dedupe is a stamp per grid *row* (one `u64`, keyed by trace and column) so two sweeps agreeing on a cell still count twice. Dwell is a different question the library already answers (`Style::intensity_grading`). - `decay_density(dt_s)` fades by the clock, `clear_density()` and switching the density off drop the image at once. `DensityConfig` carries `full_scale` and `gamma`, applied on the CPU when the grid changed: the value a host reads back through `ink()`/`peak()` and the picture it sees are one derivation, and a frame whose grid did not move skips the conversion and the upload entirely (`FrameStats::density_cells`/`density_upload_bytes`, the latter the *frame's* total over every row, which one row's assignment used to overwrite). - The renderer draws it as one attribute-less quad over the axes rect (`kDensityVS`/`kDensityFS`, `DensityProgram`), over the grid and the invalid-span bands and **under the traces**, inside the axes' clip: it reserves no layout space and nothing on it can be picked, exactly as `Plot::set_invalid_spans()` does. `Style::density` is its tint. - The two fades are the two questions a persistence display can be asked -- "still visible a second later" and "the N-th back is gone" -- so the choice is the caller's. Six cases, all in the suite: four plot-level ones (the grid's resolution and memory, the per-acquisition schedule **584 -> 876 -> 1022** ink with `peak_steady=2.000`, `exp(-dt/tau)` off the ink, and the deposit's own geometry including a NaN as a gap) and one on the real driver (`a_phosphor_density_is_drawn_under_the_traces`: `cells=179349 uploaded=179349` on the moved frame and `uploaded=0` on the identical one after it, with the trace's stroke keeping its own colour over the ink). The suite is **396 cases, 0 failed** and all four ctest entries pass.
This commit is contained in:
1 parent
3ad02e5120
commit
6a6915e64e
17 files changed
+1199
-13
No files matched your search
@@ -60,4 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
|
||||
program_.set_uniform("u_tex_size", texels);
|
||||
}
|
||||
|
||||
bool DensityProgram::create(Status* st) noexcept {
|
||||
program_ = gl::Program::from_source(kDensityVS, kDensityFS, st);
|
||||
if (program_.id() == 0) {
|
||||
return false;
|
||||
}
|
||||
// The grid texture is bound to texture unit 0 by plot::gl::Texture::bind(), so the sampler
|
||||
// has to point there. Set once, here, not per frame -- the same shape as the text program's.
|
||||
program_.use();
|
||||
program_.set_uniform_int("u_density", 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void DensityProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
|
||||
|
||||
void DensityProgram::set_rect(Vec2 origin, Vec2 size) const noexcept {
|
||||
program_.set_uniform("u_origin", origin);
|
||||
program_.set_uniform("u_size", size);
|
||||
}
|
||||
|
||||
void DensityProgram::set_color(Color color) const noexcept {
|
||||
program_.set_uniform("u_color", color);
|
||||
}
|
||||
|
||||
} // namespace plot::render
|
||||
+90
-5
@@ -193,14 +193,22 @@ Renderer::~Renderer() {
|
||||
void Renderer::release_gl() noexcept {
|
||||
ready_ = false;
|
||||
// Assigning fresh objects releases every GL name through the handles'
|
||||
// destructors: both programs, both vertex arrays and their instance buffers,
|
||||
// and the atlas texture. Safe to call more than once; every assignment is a
|
||||
// no-op while the renderer is uninitialised.
|
||||
// destructors: all three programs, the vertex arrays and their instance
|
||||
// buffers, the glyph atlas texture and the density's own texture. Safe to call
|
||||
// more than once; every assignment is a no-op while the renderer is
|
||||
// uninitialised.
|
||||
atlas_ = render::TextAtlas{};
|
||||
line_program_ = render::LineProgram{};
|
||||
text_program_ = render::TextProgram{};
|
||||
density_program_ = render::DensityProgram{};
|
||||
line_vao_ = render::LineVertexArray{};
|
||||
text_vao_ = render::TextVertexArray{};
|
||||
density_vao_ = gl::VertexArray{};
|
||||
density_texture_ = gl::Texture{};
|
||||
density_pixels_.clear();
|
||||
density_w_ = 0;
|
||||
density_h_ = 0;
|
||||
density_uploaded_revision_ = 0;
|
||||
lines_.clear();
|
||||
glyphs_under_.clear();
|
||||
glyphs_over_.clear();
|
||||
@@ -252,10 +260,10 @@ bool Renderer::init(Status* st) noexcept {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!line_program_.create(st) || !text_program_.create(st)) {
|
||||
if (!line_program_.create(st) || !text_program_.create(st) || !density_program_.create(st)) {
|
||||
return false;
|
||||
}
|
||||
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) {
|
||||
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode) || !density_vao_.create(st)) {
|
||||
return false;
|
||||
}
|
||||
ready_ = true;
|
||||
@@ -519,6 +527,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
||||
// The holes over the grid and under the data: a band is chrome of the *background*, not an
|
||||
// overlay, so everything a frame draws inside the axes is drawn on top of it.
|
||||
draw_invalid_spans(layout);
|
||||
// ... the density included: it is trace ink and not chrome, so it goes over the bands exactly
|
||||
// as the traces do, and under them for the same reason they are under the data.
|
||||
draw_density(plot, layout);
|
||||
clear_clip();
|
||||
draw_axis_lines(plot);
|
||||
set_clip(layout.axes);
|
||||
@@ -1740,6 +1751,80 @@ void Renderer::draw_invalid_spans(const PlotLayout& layout) {
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
|
||||
const DensityConfig& config = plot.density();
|
||||
const DensityGrid& grid = plot.density_grid();
|
||||
const u32 columns = grid.columns();
|
||||
const u32 rows = grid.rows();
|
||||
if (!config.enabled || columns == 0u || rows == 0u) return;
|
||||
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) return;
|
||||
if (!density_program_.valid()) return; // a pipeline that never linked draws nothing
|
||||
|
||||
// The image as the shader reads it: one byte per cell, `full_scale` and `gamma` already
|
||||
// applied. The conversion lives here rather than in the fragment shader so that a cell's
|
||||
// *count* has exactly one reading -- the plot's, which is also what a host reads back through
|
||||
// `DensityGrid::ink()`/`peak()`, and which decays once per frame on the CPU anyway.
|
||||
const bool resized = columns != density_w_ || rows != density_h_;
|
||||
if (resized) {
|
||||
if (!density_texture_.create_r8(columns, rows, nullptr)) {
|
||||
gl_error_ = true;
|
||||
return;
|
||||
}
|
||||
// One texel per cell and a quad exactly that many pixels across, so every fragment lands
|
||||
// inside its own cell: a point sample is the whole of the filtering this needs, and any
|
||||
// other mode would blend two sweeps' counts at the cell walls.
|
||||
density_texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
|
||||
density_w_ = columns;
|
||||
density_h_ = rows;
|
||||
density_uploaded_revision_ = 0u; // a fresh texture holds nothing
|
||||
}
|
||||
if (resized || grid.revision() != density_uploaded_revision_) {
|
||||
const f32 full = config.full_scale > 0.0f ? config.full_scale : 1.0f;
|
||||
const f32 gamma = config.gamma > 0.0f ? config.gamma : 1.0f;
|
||||
// The gamma through a 256-entry table rather than a `pow` per cell: the output *is* eight
|
||||
// bits, so quantising the count first and lifting the quantised value costs nothing a
|
||||
// per-cell `pow` would have bought, and it keeps the per-frame pass a loop of multiplies.
|
||||
u8 lut[256];
|
||||
for (u32 i = 0u; i < 256u; ++i) {
|
||||
const f32 v = static_cast<f32>(i) / 255.0f;
|
||||
lut[i] = static_cast<u8>(std::clamp(std::pow(v, gamma), 0.0f, 1.0f) * 255.0f + 0.5f);
|
||||
}
|
||||
density_pixels_.resize(grid.cell_count());
|
||||
const f32* cells = grid.cells().data();
|
||||
for (usize i = 0; i < density_pixels_.size(); ++i) {
|
||||
const f32 v = cells[i] / full;
|
||||
const u32 quantised =
|
||||
static_cast<u32>(std::clamp(v, 0.0f, 1.0f) * 255.0f + 0.5f);
|
||||
density_pixels_[i] = lut[std::min(quantised, 255u)];
|
||||
}
|
||||
// `upload_r8` sets GL_UNPACK_ALIGNMENT to 1 and leaves it there (see gl::Texture), so the
|
||||
// host's own value is saved and put back -- the same care `TextAtlas::sync()` takes around
|
||||
// its own upload, since the two share a context and a frame.
|
||||
gl::GLint alignment = 4;
|
||||
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &alignment);
|
||||
density_texture_.upload_r8(columns, rows, density_pixels_.data());
|
||||
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, alignment);
|
||||
density_uploaded_revision_ = grid.revision();
|
||||
// += and not =: a figure draws a density per row, and what this field reports is the frame's
|
||||
// upload, not the last row's.
|
||||
stats_.density_upload_bytes += columns * rows;
|
||||
}
|
||||
|
||||
// The quad covers the cells, not the axes rect's own fractional size: `columns`/`rows` are that
|
||||
// rect rounded up, so mapping uv over them puts texel i exactly on device pixel `axes.x + i`.
|
||||
// Drawing the un-rounded rect would stretch the last row and column of cells over the fraction.
|
||||
density_program_.bind();
|
||||
density_program_.set_viewport({viewport_.w, viewport_.h});
|
||||
density_program_.set_rect({layout.axes.x, layout.axes.y},
|
||||
{static_cast<f32>(columns), static_cast<f32>(rows)});
|
||||
density_program_.set_color(style_.density);
|
||||
density_texture_.bind();
|
||||
density_vao_.bind();
|
||||
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
|
||||
stats_.density_cells = columns * rows;
|
||||
++stats_.draw_calls;
|
||||
}
|
||||
|
||||
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
|
||||
const HotHandles hot = plot.hot_handles();
|
||||
const f32 edge_w = style_.device(style_.line_width_px);
|
||||
|
||||
@@ -235,4 +235,47 @@ void main() {
|
||||
}
|
||||
)";
|
||||
|
||||
const char* const kDensityVS = R"(#version 330 core
|
||||
// No vertex attributes at all: the image is one axis-aligned rectangle whose corners are
|
||||
// uniform state, so the quad comes from gl_VertexID alone and there is no vertex buffer to
|
||||
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
|
||||
uniform vec2 u_viewport; // render target size, pixels
|
||||
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
|
||||
uniform vec2 u_size; // the quad's size in pixels -- the grid's columns and rows
|
||||
|
||||
out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
// A four-vertex triangle strip, like the line and text pipelines: the corner index is the
|
||||
// vertex id's two bits, so 0,1,2,3 are (0,0), (1,0), (0,1), (1,1).
|
||||
vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
|
||||
vec2 px = u_origin + corner * u_size;
|
||||
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
|
||||
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
|
||||
gl_Position = vec4(ndc, 0.0, 1.0);
|
||||
// u_size is the grid's own size in pixels and the texture is one texel per cell, so one
|
||||
// unit of uv is exactly one cell and a GL_NEAREST fetch lands on the cell the fragment is
|
||||
// inside -- there is nothing to filter and nothing to round.
|
||||
v_uv = corner;
|
||||
}
|
||||
)";
|
||||
|
||||
const char* const kDensityFS = R"(#version 330 core
|
||||
in vec2 v_uv; // cell coordinate in [0,1] across the quad
|
||||
|
||||
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_NEAREST
|
||||
uniform vec4 u_color;
|
||||
|
||||
out vec4 o_color;
|
||||
|
||||
void main() {
|
||||
// The cell's count was already turned into a luminance on the CPU, where the count, the
|
||||
// decay and the gamma live -- so this stage has no scale of its own that a host reading the
|
||||
// grid's numbers back could disagree with. All that is left is the tint: the density says how
|
||||
// often a trace passed, never which one, so it is one hue for the whole image.
|
||||
float ink = texture(u_density, v_uv).r;
|
||||
o_color = vec4(u_color.rgb, u_color.a * ink);
|
||||
}
|
||||
)";
|
||||
|
||||
} // namespace plot::render
|
||||
Reference in new issue
Block a user