diff --git a/docs/data-and-performance.md b/docs/data-and-performance.md index c302bff..59e4bb2 100644 --- a/docs/data-and-performance.md +++ b/docs/data-and-performance.md @@ -355,6 +355,18 @@ also why it cannot be a per-frame accumulator. over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace crossed — the deposit is bounded by the screen, not by the data, which is what makes an accumulation affordable at the trigger's own rate. +- **The brightness ceiling is the fade's own steady state, and the ramp is linear in sweeps.** + `DensityConfig::full_scale` defaults to `1/(1 - 0.5)` -- the geometric sum of the default + per-acquisition fade -- so one sweep reads half ink and the display reaches white only where + acquisitions agree, and `gamma` defaults to 1.0, because a cell's value is a count and half as + many sweeps must read as half the ink. Both defaults were wrong first and the *eye* found it: with + the ceiling at a single sweep and `gamma` at 0.45 (the value `Style::intensity_gamma` wants for a + *dwell fraction*) every cell one sweep touched was already full white, so a display of one sweep + and a display of five looked the same. The case that would have caught it is a **framebuffer** + reading, not the grid's numbers -- `a_phosphor_density_is_drawn_under_the_traces` now measures a + cell at a quarter of the scale against one at the ceiling and against the background + (`empty=16 full=83 quarter=54` at the current defaults, where the old ones read `199` for the + first two alike). - **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`) only tint that byte. The one derivation is deliberate: a value a host reads back through diff --git a/include/plot/plot/density.hpp b/include/plot/plot/density.hpp index 9859db9..a800afa 100644 --- a/include/plot/plot/density.hpp +++ b/include/plot/plot/density.hpp @@ -56,17 +56,23 @@ struct DensityConfig { /// what a phosphor's afterglow looks like sweep to sweep: 1, 0.5, 0.25, 0.125 and by the /// seventh the layer is under 1 %. f32 per_acquisition = 0.5f; - /// The cell value that reads as **full brightness**, in accumulated sweeps: one accumulation - /// deposits 1.0 into every cell its traces cover, so the default of 1.0 draws a single - /// acquisition at full ink and lets the ones behind it add on top (up to saturation). A caller - /// that wants the newest sweep dimmer -- so that agreement between acquisitions is the only - /// thing that reaches white -- passes 2.0 or more. - f32 full_scale = 1.0f; - /// The gamma applied to the normalised cell value before it becomes a luminance. Under 1 -- - /// 0.45, the same default `Style::intensity_gamma` uses -- lifts the faint end, which is what - /// makes a ghost *readable* rather than merely present: without it a cell covered by one in - /// eight sweeps is an eighth of the ink and looks like a smudge of the background. - f32 gamma = 0.45f; + /// The cell value that reads as **full brightness**, in accumulated sweeps. The default is the + /// ink the **default fade settles at** -- `1/(1 - 0.5)`, the geometric sum of + /// `PerAcquisition`'s factor -- and that is the rule worth keeping: a ceiling at *one* sweep + /// puts the newest acquisition at full white and everything above it clamps to the same pixel, + /// so a display of one sweep and a display of five read alike and the fade is invisible + /// (measured: a cell at a quarter of the scale rendered 104 of 255 while one at the ceiling + /// rendered 156, but with the ceiling at one sweep both were 199). A caller whose fade settles + /// elsewhere wants its own sum here. + f32 full_scale = 2.0f; + /// The gamma applied to the normalised cell value before it becomes a luminance. **1.0 by + /// default: linear in sweeps**, because that is what a count means -- a cell covered by half the + /// acquisitions is half the ink. Under 1 lifts the faint end, and that is what + /// `Style::intensity_gamma` is for where the value is a *dwell fraction* rather than a count; + /// here it flattens the top instead (0.45 maps a quarter of the scale to 0.54 and the ceiling to + /// 1.0, which is most of the ramp spent on the last sweep). Over 1 darkens the faint end for a + /// caller that wants only the strong agreement visible. + f32 gamma = 1.0f; }; /// The density's own image: **one `f32` cell per device pixel of the axes rect**, so its diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 6a1603b..6f30f09 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -7638,6 +7638,42 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) { px[2] != ghost_without[2]); PLOT_CHECK(!pixel_is(px, trace_color)); + // **The screen half**: a cell at a quarter of what a full one holds must render measurably + // fainter, and a full cell must not be the background. That is the assertion the *eye* makes -- + // it is what "the colour fading is not applied, it looks white" turned out to be -- and nothing + // in the grid's own numbers can make it: they were right while the picture was flat. + // + // The row measured is the one the *ghost* is in (the sweep before the last), not the trace's own: + // a trace drawn over a cell hides the density in it by construction. + const auto channel = [&](const std::vector& f, plot::u32 x, plot::u32 y, int c) { + return static_cast(f[(plot::usize(y) * kW + x) * 4u + static_cast(c)]); + }; + const int ghost_full = channel(with, mid_x, ghost_row, 0); + // The background to compare against is a row *away* from both: the ghost's own row holds the + // trace in the frame drawn without a density (that is where the trace was before it moved), so + // the difference between the two frames there is the trace, not the ink. + const plot::u32 blank_row = ghost_row + 40u < kH ? ghost_row + 40u : ghost_row - 40u; + const int empty_red = channel(without, mid_x, blank_row, 0); + // A quarter of the ink: `exp(-dt/tau)` is the time fade's own rule, so `dt = tau*ln(4)` is one + // step to a quarter whatever one sweep deposited. + plot::DensityConfig fading = cfg; + fading.decay = plot::DensityDecay::TimeConstant; + fading.time_constant_s = 1.0f; + p.set_density(fading); + const double before = p.density_grid().ink(); + p.decay_density(std::log(4.0)); + const double after = p.density_grid().ink(); + PLOT_CHECK_NEAR(after / before, 0.25, 1e-6); + r.begin_frame(kW, kH, kFrameRect); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + const std::vector faded = read_frame(ctx); + const int ghost_quarter = channel(faded, mid_x, ghost_row, 0); + PLOT_CHECK(ghost_full > empty_red + 20); + PLOT_CHECK(ghost_full > ghost_quarter + 20); + PLOT_CHECK(ghost_quarter > empty_red); + // A frame with nothing new to deposit: the grid did not move, so the conversion and the upload // are skipped -- the frame costs the one draw call -- and the picture is bit-identical. r.begin_frame(kW, kH, kFrameRect); @@ -7647,11 +7683,13 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) { PLOT_CHECK(r.stats().density_cells > 0u); PLOT_CHECK(r.stats().density_upload_bytes == 0u); const std::vector again = read_frame(ctx); - PLOT_CHECK(again == with); + PLOT_CHECK(again == faded); std::printf("[density gl] cells=%u uploaded=%u differing=%u ghost row=%u trace row=%u" " axes=%.0f,%.0f %.0fx%.0f\n", first.density_cells, first.density_upload_bytes, differing, ghost_row, trace_row, axes.x, axes.y, axes.w, axes.h); + std::printf("[density gl] luminance red: empty=%d full=%d quarter=%d (ink %.0f -> %.0f)\n", + empty_red, ghost_full, ghost_quarter, before, after); ctx.destroy(); }