feat: a phosphor density is the trace, one grid per trace in its own colour
A user looked at the display and reported three things at once: "there is clearly a gain offset between latest acquisition and persistent image", "persistent instead of being brighter + opaque is just gray/white", and "last acquisition does not seem to be affected, it looks like persistent image + last acquisition (so for example last acquisition never fade in time mode)". What they add up to is that this was not a phosphor yet: it was a **density with a solid trace drawn over it**. Four changes, and the first is the one the others follow from: - **The current acquisition is deposited like every other, and no solid trace is drawn over the image.** `Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so the newest acquisition is a sweep inside it and fades with it. Drawing the polyline as well put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition", where the last acquisition never faded in time mode. - **One grid per trace, in that trace's own colour.** `Plot::density_grid(trace)` is one screen-sized `f32` grid per trace, deposited from that trace and drawn in `Trace::color`, because a density that had one hue for the plot could not say *which* trace put the ink there -- two signals agreeing in a cell would be one blended pixel. The display's memory is therefore `traces * columns * rows * 4` bytes rather than one plot's worth (a trace with no series, an unbound slot, holds no grid), which is the one cost this change adds: four signals in one 800x480 row are four grids, 6 MB. - **The ceiling is the trace's own brightness, not the fade's steady state.** `full_scale` now defaults to **one sweep**: a cell with one acquisition's ink is drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place every acquisition settles *at* the colour its trace would have drawn (the ceiling clamps) rather than a fraction of a brighter steady state -- the gain offset the user reported. `Style::density`'s near-white 0.85 tint is gone with it, because there is no one tint any more. A caller that wants the fade's ramp on screen instead still sets `full_scale` to the sum the fade settles at and accepts the offset; that is a legitimate choice, just not the one a phosphor makes. The reading is a **framebuffer** one, because neither `ink`/`peak` nor a tint can make a colour or a fade: `a_phosphor_density_is_the_trace_in_its_own_colour` deposits one sweep, checks the saturated cell is *exactly* the trace's colour (`full=224`, the trace's `0xE0` green, against `bg=25` -- the old monochrome tint read ~190 red), then fades it one time constant and checks the same pixel is dimmer (`faded=115`) and still the trace's hue -- which a stroke over the density could not do. It moves the trace and draws without depositing to prove no polyline is drawn (`moved` equals the no-density frame at the moved row), and reads a steady state against a quarter of it (`steady=224`, `quarter=138`), in the signal's colour throughout. `a_density_holds_one_grid_per_trace` reads two traces' grids apart (`267 472 cells each, ink 584/584`, in two rows). The grid's own cases are unchanged where they should be (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` `peak_steady=2.000`, `a_density_fades_by_its_time_constant` `exp(-dt/tau)`). Suite: **397 cases, 0 failed**, 4/4 ctest entries.
This commit is contained in:
1 parent
738205293e
commit
1b8b55d3ee
12 files changed
+534
-299
No files matched your search
@@ -327,27 +327,34 @@ way**, against the 191 the sparse frame gave before. Across the whole sweep from
|
||||
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
|
||||
left to see.
|
||||
|
||||
## A phosphor density accumulates into a screen-sized grid
|
||||
## A phosphor density is the trace, one grid per trace
|
||||
|
||||
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
|
||||
passed, drawn behind them, fading by a time constant or by an acquisition count. It is the
|
||||
library's because the library is the side that knows a trace's device geometry; a host that
|
||||
built one itself would be re-rasterising traces the renderer has already staged.
|
||||
passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- fading by a
|
||||
time constant or by an acquisition count. It is the library's because the library is the side that
|
||||
knows a trace's device geometry; a host that built one itself would be re-rasterising traces the
|
||||
renderer has already staged.
|
||||
|
||||
The split from the grading above is what it costs: grading measures *one* frame's dwell and
|
||||
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
|
||||
also why it cannot be a per-frame accumulator.
|
||||
|
||||
- **The grid is the screen's, not the data's.** One `f32` cell per device pixel of the axes rect
|
||||
(`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360 viewport is a **573x313** grid
|
||||
of **179 349 cells** and **717 396 bytes**, and a thousand acquisitions through it cost the
|
||||
same. A cell's value is a **number of sweeps**: a trace covering it during one
|
||||
- **The grid is the screen's, not the data's, and there is one per trace.** One `f32` cell per
|
||||
device pixel of the axes rect (`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360
|
||||
viewport is a **584x458** grid of **267 472 cells** and **1 069 888 bytes**, and a thousand
|
||||
acquisitions through it cost the same. A plot holds one of these for **each trace it draws**, so
|
||||
the display's memory is that figure times the traces on screen — four signals in one 800x480 row
|
||||
are four grids, 6 MB; a row of eight at 1920x1080 is 66 MB. The colour is the reason: a density
|
||||
that had one hue for the plot could not say *which* trace put the ink there, and two signals
|
||||
agreeing in a cell would read as one blended pixel. `Plot::density_grid(trace)` returns a trace's
|
||||
image; a trace with no series (an unbound slot) holds no grid at all.
|
||||
- **A cell's value is a number of sweeps**, and a trace covering it during one
|
||||
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
|
||||
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
|
||||
hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather
|
||||
than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few
|
||||
kB), keyed by (trace, column), so two sweeps agreeing on a cell still count twice and the two
|
||||
segments that share a column boundary do not.
|
||||
kB, shared by every trace's deposit), keyed by (trace, column), so two sweeps agreeing on a cell
|
||||
still count twice and the two segments that share a column boundary do not.
|
||||
- **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to
|
||||
sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the
|
||||
same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the
|
||||
@@ -355,38 +362,47 @@ 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 newest acquisition is deposited like every other, and no trace is drawn over the image.**
|
||||
`Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so
|
||||
the acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline
|
||||
on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent
|
||||
image + last acquisition" a user reported, where the newest acquisition never faded.
|
||||
- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.**
|
||||
`DensityConfig::full_scale` defaults to **one sweep**, so a cell with one acquisition's ink is
|
||||
drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place
|
||||
every acquisition settles *exactly* at the colour its trace would have drawn — the settlement is
|
||||
a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many
|
||||
sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the
|
||||
first default and is a legitimate alternative: it makes the newest sweep a fraction of the
|
||||
settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest
|
||||
sweep and the image behind it. The case that reads either one off the *screen* rather than the
|
||||
grid is `a_phosphor_density_is_the_trace_in_its_own_colour`.
|
||||
- **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
|
||||
`DensityGrid::ink()`/`peak()` and the picture it sees come from the same numbers.
|
||||
- **Per frame, it is one texture upload and one draw call -- or one draw call.** The upload is one
|
||||
byte per cell (**179 kB** for the axes above); a frame whose grid did not move skips the
|
||||
conversion and the upload entirely and pays the quad alone (`FrameStats::density_upload_bytes`
|
||||
reads 0 for it). The decay is what makes the grid move every frame under `TimeConstant`, and
|
||||
what makes it stand still between acquisitions under `PerAcquisition`.
|
||||
only tint that byte with the trace's colour. The one derivation is deliberate: a value a host
|
||||
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
|
||||
numbers.
|
||||
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.**
|
||||
The upload is one byte per cell (**179 kB** for the axes above); the one texture is re-filled per
|
||||
trace, and a frame whose grids did not move skips the conversion and the uploads entirely and
|
||||
pays the quads alone (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes
|
||||
the grids move every frame under `TimeConstant`, and what makes them stand still between
|
||||
acquisitions under `PerAcquisition`.
|
||||
|
||||
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
|
||||
(**584x458 = 267 472 cells, 1 069 888 bytes**, `ink=584 peak=1` for one flat sweep — the ink of
|
||||
one sweep *is* the columns it covers),
|
||||
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
|
||||
(584 → 876 → 1022 on a 584-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
|
||||
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink, and
|
||||
`a_phosphor_density_is_drawn_under_the_traces` reads the frame: `cells=179349 uploaded=179349`
|
||||
on the frame the grid moved and `uploaded=0` on the identical one after it, with the trace's
|
||||
stroke keeping its own colour over the ink (`min_red < 40`, where a density drawn on top would
|
||||
put the red at ~190).
|
||||
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
|
||||
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**267 472 cells each,
|
||||
ink 584/584**, in two different rows), and
|
||||
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
|
||||
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
|
||||
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
|
||||
near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state
|
||||
(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the
|
||||
background's.
|
||||
|
||||
## One sample or a statistic of many
|
||||
|
||||
|
||||
Reference in new issue
Block a user