Skip to content

perf(gui): per-pixel GPU FFT trace, present coalescing, 60 fps panadapters + get panstats bridge verb#3958

Merged
ten9876 merged 2 commits into
aethersdr:mainfrom
jensenpat:perf/2d-panadapter-qrhi
Jul 2, 2026
Merged

perf(gui): per-pixel GPU FFT trace, present coalescing, 60 fps panadapters + get panstats bridge verb#3958
ten9876 merged 2 commits into
aethersdr:mainfrom
jensenpat:perf/2d-panadapter-qrhi

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

Summary

2D panadapter rendering-path optimization, profiled first and proven after with the agent automation bridge (live FLEX-8400M session, Mac Studio M2 Ultra, Retina 2×, 2140×651 pan).

  • panscope.frag — per-pixel FFT trace. The spectrum trace (gradient/heat-map fill + feather + core stroke) is now evaluated per pixel from a width×1 R32F column texture (R16F fallback where float32 isn't filterable), drawn as one quad. This replaces the per-frame CPU vertex bake — two antialiased triangle-strip line passes plus a 2N fill strip, rebuilt and re-uploaded every frame (~1.4 MB/frame at a 2140 px pan). buildFftDisplayTrace's spatial smoothing + Catmull-Rom interpolation is unchanged, now sampled at exactly one point per device pixel column; stroke width is slope-corrected in the shader via screen-space derivatives so steep peak flanks keep their width, matching the old perpendicular-offset geometry. Cross-platform via qt_add_shaders (SPIR-V / GLSL / HLSL / MSL).
  • Present coalescing. FFT frames and waterfall rows arrive as separate UDP events, each scheduling its own window flush — a narrow pan alone generated ~83 flushes/s (57 FFT + 26 rows), which together with the other animated widgets starves the swapchain drawable pool and blocks the GUI thread in CAMetalLayer nextDrawable (the Detached Waveform making panadapters choppy #3938 mechanism). Data-driven repaints now coalesce into one present per 16 ms slot with a trailing update (no frame is dropped); interactive paths still repaint immediately. Lean mode keeps its ~30 Hz cap.
  • FFT FPS ceiling 30 → 60. The 30 fps limit was purely the client slider — the FLEX-8400M delivers ~57 frames/s when asked. With the flat per-pixel path, 57 fps costs +5.8 whole-process CPU points over 30 fps.
  • get panstats bridge verb (get panstats [<panIndex>|<objectName>] [reset]): per-SpectrumWidget frame-cost counters — gpuFrameMsPerSec (headline main-thread budget), FFT build/upload volume, overlay rebuilds with first-cause attribution (smartMtr/detect/other), waterfall upload volume — for profiler-free before/after proofs. Documented in docs/automation-bridge.md.
  • AETHER_PAN_NO_NATIVE_WINDOW=1 diagnostic to probe the composited (non-native) QRhiWidget path on macOS. Result: still fails to obtain a QRhi on Qt 6.9 (Fix GPU rendering on macOS: Metal API + WA_NativeWindow for QRhiWidge… #714 behavior — parent surface is raster), so the native NSView stays the default; the probe documents the finding for a future Qt.

Measurements

get panstats 0 reset → 40 s → read; identical 2140×651 pan, 2D, 30 fps, idle RX, interleaved same hour:

metric before after
pan main-thread frame prep (gpuFrameMsPerSec) 17.9 ms/s 3.6 ms/s
avg prep per frame 601 µs 118 µs
FFT trace build (fftBuildMsPerSec) 14.2 ms/s 0.8 ms/s
trace GPU upload 40.9 MB/s 0.5 MB/s
ingest / overlay / waterfall 0.45 / ~0 / ~0 ms/s unchanged

Whole-process CPU (cputime delta / 40 s, same session, big window 2400×1300):

config CPU
optimized @ 30 fps 117.1 %
optimized @ 57 fps delivered 122.9 %
baseline @ 30 fps (same hour) 120–131 %

Honest reading: at matched 30 fps, whole-process CPU is at parity-to-slightly-better — the pan's own prep was ~18 ms/s of a ~450 ms/s main thread, and the freed time is partly reabsorbed by other animated widgets that were previously starved behind drawable waits (main-thread sample shows renderGpuFrame dropping 243 → 30 samples/10 s while WAVE/S-meter paints rise). The structural wins are: the pan's cost is now width- and fps-flat, ~2× the frame rate costs ~6 CPU points, and two pans at 57 fps present at ~41–44 frames/s each with <6 ms/s prep each. The remaining window-level costs (raster flushSubWindow blends from the flag/menu widgets over the native pan view, WAVE's QPainter paints) are outside this path — WAVE is addressed by #3955, and the native-window blend tax is documented via the new probe.

Screenshots

Per-pixel trace, 2D + heat-map fill, band plan/scales/markers intact (visual parity with the vertex path):

Per-pixel FFT trace

Running at 57 delivered FFT frames/s (slider at 60):

60 fps panadapter

RX recovery immediately after an ANT2 dummy-load TX key/unkey cycle:

RX after TX

TX / regression proofs (agent automation bridge, live radio)

  • PTT key on ANT2 dummy load → transmitting:true, pan keeps rendering; unkey → instant RX trace + waterfall recovery (grabs above).
  • pan create → both pans on the GPU path (~41–44 presents/s each, <6 ms/s prep each; coalescer merges 57 FFT + 26 WF events/s correctly) → pan close 1 clean.
  • 2D ↔ 3D stacked-trace toggle unaffected (trace build/draw skipped in 3D exactly as before).
  • Lean mode, line-width 0 ("Off"), heat-map and solid fill modes all handled in-shader with the old constants (kFftLineFeatherPx, core α 0.90, feather α 0.22).
  • Software (non-GPU-build) QPainter path untouched.

New automation verbs that helped / follow-ups

  • get panstats (this PR) made every number above reproducible without a profiler.
  • Follow-up candidates: a stats window verb exposing per-window flush/drawable-wait counters (would make the Detached Waveform making panadapters choppy #3938 contention measurable directly), and a wavestats-style counter for SMeterWidget/VfoWidget paint rates.

💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat

🤖 Generated with Claude Code

…g + panstats verb

2D panadapter render-path optimization, profiled and proven live via the
agent automation bridge:

- panscope.frag: the FFT trace (fill + feather + core stroke, heat-map and
  solid modes) is now evaluated per pixel from a width×1 R32F column texture
  (R16F fallback), replacing the per-frame CPU vertex bake (two AA line
  strips + fill strip, ~1.4 MB VBO upload per frame at a 2140 px pan).
  buildFftDisplayTrace's spatial smooth + Catmull-Rom stays, now sampled at
  one point per device pixel. Frame prep: 601 -> 118 µs; trace upload:
  41 MB/s -> 0.5 MB/s at 30 fps.
- Present coalescing in leanCappedUpdate(): FFT frames and waterfall rows
  arrive as separate UDP events; one present per 16 ms slot (trailing update
  keeps the last frame) caps a narrow pan at ~60 flushes/s instead of ~83+,
  which starved the window swapchain drawable pool (aethersdr#3938 class).
- FFT FPS slider ceiling 30 -> 60: the FLEX-8400M happily delivers ~57
  frames/s; with the flat per-pixel path the 30 fps cap was purely
  client-side. 57 fps costs +5.8 whole-process CPU points vs 30 fps.
- get panstats bridge verb: per-SpectrumWidget frame-cost counters
  (gpuFrameMsPerSec headline, fft build/upload, overlay rebuilds with
  dirty-cause attribution, waterfall upload volume) for profiler-free
  before/after proofs. Documented in docs/automation-bridge.md.
- AETHER_PAN_NO_NATIVE_WINDOW=1 diagnostic to probe the composited
  (non-native) QRhiWidget path on macOS — still fails to obtain a QRhi on
  Qt 6.9 (aethersdr#714 behavior), so the native view stays the default.

Live proofs on FLEX-8400M: visual parity grabs (2D trace/fill/heat, band
plan, scales), TX key/unkey on ANT2 dummy load with instant RX recovery,
two-pan render + close, mode 2D/3D toggle unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with a multi-angle pass (line-by-line, removed-behavior, cross-file, reuse, efficiency, altitude, conventions, and GPU/QRHI + shader correctness), then verified each candidate against source. Solid, well-built PR — the per-pixel fragment-shader trace (replacing the ~1.4 MB/frame VBO bake) and the always-on RAII panstats are clean wins, the GPU-off #ifdef guards are correct, and the new get panstats verb is documented.

Several scary-looking candidates were checked and refuted, worth recording:

  • Present coalescing is correct: elapsed() is integer-ms so slotMs - sinceMs >= 1 (never a 0-timeout), it's single-threaded (no m_presentPending race), and the trailing update presents the latest data (no dropped frames).
  • Empty-spectrum trace doesn't freeze: fftTracePointCount is a per-frame local reset to 0, the draw is gated on > 0, and beginPass clears the target each frame.
  • panstats is race-free: AutomationServer runs on the GUI thread (widget calls use DirectConnection; only the audio engine uses BlockingQueuedConnection), so the snapshot read can't overlap renderGpuFrame's write.

Two minor/optional inline nits below. One extra non-blocking note: the paintEvent #else // !AETHER_GPU_SPECTRUM branch (~line 9369) wraps a block in a nested #ifdef AETHER_GPU_SPECTRUM that is unreachable in both build configs — looks pre-existing/copy-paste; worth deleting when convenient.

Nice work — mergeable as-is.

Comment thread resources/shaders/panscope.frag Outdated
Comment thread src/gui/SpectrumWidget.cpp
@jensenpat jensenpat marked this pull request as ready for review July 2, 2026 04:31
@jensenpat jensenpat requested review from a team as code owners July 2, 2026 04:31
Co-authored-by: Jeremy [KK7GWY] <105957118+ten9876@users.noreply.github.com>
Signed-off-by: jensenpat <84298137+jensenpat@users.noreply.github.com>

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff and traced the changed paths against main. This is a well-executed, well-measured rendering-path rewrite — the profile-first/prove-after discipline shows, and it lands cleanly. All 6 CI checks (build, check-windows, check-macos, CodeQL, analyze cpp, a11y) are green. A few notes below; nothing I'd consider blocking.

What looks solid

  • Per-pixel column path is clean. The old per-frame feather/core/fill vertex bake (three dynamic VBOs, ~1.4 MB/frame) is replaced by a single width×1 column texture + one full-viewport quad. releaseResources() is updated for every new resource (m_fftScopePipeline/Srb/Ubo/ColTex/ColSampler), no leaks, and m_fftColTexW is reset. The UBO buffer alloc (5*4*sizeof(float)) matches the float ubo[20] writer and the shader's 5×vec4 U block.
  • R32F→R16F fallback is correct (qfloat16 staging, isTextureFormatSupported gate), and the m_fftColScratch QByteArray is reused per frame and stays alive until cb->resourceUpdate(batch).
  • nativeWindowPreferred() gating is consistent — both setAttribute(WA_NativeWindow) sites (constructor + PanadapterStack::refreshAfterReparent) are gated; the env probe caches via a function-local static, so it's read once. Good.
  • panstats follows the existing pan/pans convention: GUI-thread widget walk via findWidgetsByClass, meta-called through Q_INVOKABLE so core/ needs no GUI header, pointer-dedup for floated containers. RAII scope timers are tidy and exception-safe. No QSettings / no new flat AppSettings keys.

Copilot findings

  • Half-texel offset in panscope.frag (ten9876): already addressed — the committed shader (line 68) contains exactly the suggested remap: texX = (v_uv.x * (plot.z - 1.0) + 0.5) / plot.z, so the trace samples texel centers and lines up with the dBm/frequency grid. Resolved; nice catch by the tool and good that it was folded in.
  • Per-frame darker(300) + full UBO re-upload (ten9876): valid, minor. m_fftFillColor.darker(300) (HSV round-trip + QColor alloc) and the 20-float updateDynamicBuffer run every frame on the 60 fps hot path even though their inputs only change on a UI edit. Negligible on one pan, measurable across a multi-pan wall (which this PR explicitly targets). Optional follow-up: cache the dark color in setFftFillColor() and gate the UBO update behind a small dirty flag — the per-frame column upload has to stay, but the config block doesn't.

Minor observations (non-blocking)

  • get panstats … reset builds the snapshot twice — once to read panIndex for filtering, then again with reset=true (discarded). The comment explains why (the index filter needs the snapshot's own panIndex), and it only happens on an explicit reset, so cost is irrelevant; just flagging that a single-pass structure (snapshot once, reset the counters after) would be slightly cleaner if you touch this again.
  • Present-coalescing trailing update reads correctly: normal mode never drops a frame (a singleShot bound to this fires the trailing present at the slot edge), lean mode keeps its hard ~30 Hz cap, and interactive paths still call update() directly, so input latency is unaffected. The singleShot capturing this is safe against widget teardown.

Scope is tight — every file maps to a bullet in the description, and the AETHER_PAN_NO_NATIVE_WINDOW probe is small, documented, and off by default. Thanks for the thorough measurements and the honest "parity-to-slightly-better at matched fps, but width/fps-flat" reading — that framing is exactly right for a structural win. Nice work, @jensenpat.


🤖 aethersdr-agent · cost: $5.7297 · model: claude-opus-4-8

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at high effort — multi-angle (panscope shader + GPU column-texture path, present coalescing, removed-behavior audit of the ~277 deleted vertex-bake lines, and the get panstats verb).

Strong PR, no correctness/safety blockers:

  • Removed-behavior audit clean: 3D-skip, software QPainter path, lean, line-width-0, heat-map/solid fill, buildFftDisplayTrace (unchanged consumer), overlay z-order, member cleanup, and the SrcAlpha->One premultiplied blend are all preserved/correct.
  • Shader math bounded and correct: texel-center UV mapping (no edge off-by-one), R32F/R16F fallback (no NaN/inf), std140 UBO matches, slope-corrected stroke is capped (the near-vertical 'fill' is the correct stroke, matching the old perpendicular-offset geometry).
  • Coalescing correct for its design: trailing update guaranteed (no dropped frames), interactive paths immediate, multi-pan independent.
  • Thread-safe: AutomationServer/renderGpuFrame/panstatsSnapshot all run on the GUI thread (bridge isn't moveToThread'd), so no race on the panstats counters.

Refuted several finder candidates on verification (shader singularity, fbDpr/dpr resize race, panstats thread race, lean-cap-on-drag).

Three LOW get-panstats polish items filed as a consolidated follow-up (invalid-selector returns empty instead of erroring; verb missing from the start() banner; lenient 'reset' parsing) — none blocking.

LGTM. Nice, measured optimization.

@ten9876 ten9876 merged commit d66d701 into aethersdr:main Jul 2, 2026
6 checks passed
ten9876 added a commit that referenced this pull request Jul 2, 2026
#3961)

## Release documentation prep for **v26.7.1** (2026-07-02)

29 commits since v26.6.5. Docs-only + version bump — no code behavior
change.

### Version bump 26.6.5 → 26.7.1
The three canonical locations (per `AGENTS.md`):
- `CMakeLists.txt:2` — `project(AetherSDR VERSION 26.7.1)` →
`AETHERSDR_VERSION` (the app's version string)
- `README.md` — Current version line
- `AGENTS.md` — Current version line

### `CHANGELOG.md`
Promoted `[Unreleased]` → **`## [v26.7.1] — 2026-07-02`** in house style
(v-prefix, em-dash, "N commits since…" opener + headline), authored from
all merged PRs and categorized:
- **Added:** 3D stacked-trace spectrum (#3899), 3D dBm scale (#3937),
in-process NVIDIA BNR + AFX download-on-demand (#3902), TX meter
mouse-over readouts (#3936), SWR manual sweep range (#3885), CW sidetone
capture (#3895), KiwiSDR metadata scaffolding (#3898), bridge verbs
(#3920)
- **Changed:** BNR container/NIM backend removed, 60 fps + per-pixel GPU
FFT trace (#3958), FlexLib-sourced model capabilities (#3954/#2177), RX
speaker-latency cut (#3897), Display pane sections (#3935)
- **Fixed:** #3922, #3926, #3941, #3940, #3942, #3921, #3903, #3892,
#3924, #3891, #3890, #3900, #3947
- Fresh empty `[Unreleased]` restored above the release block.
Internal/CI/docs/self-fix PRs (#3931/#3916/#3934/#3929) omitted per
house style.

### `ROADMAP.md`
- Cycle header → "post-v26.7.1"
- NVIDIA BNR removed from **In flight** (shipped this cycle)
- Five v26.7.1 highlights prepended to **Recently shipped**

### `README.md`
- Highlights: GPU spectrum bullet now notes the **per-pixel FFT trace at
up to 60 fps** and the **3D stacked-trace** spectrum mode

### Reviewer notes
- Touches protected paths (`*.md` + `AGENTS.md` Tier-1 +
`CMakeLists.txt`) — needs `@aethersdr/infrastructure` sign-off.
- Tagging `v26.7.1` and cutting the release build are **not** part of
this PR (docs prep only).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
aethersdr-agent Bot added a commit that referenced this pull request Jul 2, 2026
…rry 2D trace on old D3D11 GPUs (#3967). Principle II.

Regression from #3958 (per-pixel GPU FFT trace). initSpectrumPipeline
preferred R32F whenever isTextureFormatSupported(R32F) returned true, but
that predicate only reports create/sample support — NOT linear
filterability. On older Windows GPUs (Intel iGPUs, D3D11 feature level
<= 10.0) R32_FLOAT is sample-supported but not linearly filterable, so the
Linear column sampler silently degrades to point sampling. panscope.frag
then slope-corrects stroke width from dFdx(yTrace)/fwidth() of the sampled
trace; point-sampled columns step between texels, dFdx spikes on every
step, and the perpendicular-distance stroke fattens into the blurred band
the reporter sees.

Fix: use R16F unconditionally — the only float format QRhi guarantees is
linear-filterable on every backend. The stored value is a normalized 0..1
amplitude, for which half-float has ample precision, so this removes the
unreliable hardware-parity fork with no shader change and no visible
quality loss on hardware where R32F was previously filterable. Also drops
the now-dead R32F upload branch in renderGpuFrame.

Honors Principle II (radio is authoritative on live state): the panadapter
must faithfully mirror the FFT the radio reports across all client
hardware, not a GPU-degraded approximation of it.

Blast radius: risk_score=0.272, 19 high-risk affected (top:
MainWindow::MainWindow, MainWindow::wirePanadapter, MainWindow::buildMenuBar).
All are file-level SpectrumWidget callers that construct/wire the widget and
are agnostic to the FFT column texture's internal pixel format; the change is
confined to the GPU render path and reaches none of them behaviorally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dsocha added a commit to dsocha/AetherSDR that referenced this pull request Jul 3, 2026
…filt guard

The pan fps ceiling was raised to 60 on main (perf(gui) aethersdr#3958), but every
constant in the adaptive pipeline is counted in frames and was calibrated
at ~25-30 fps. At 60 fps every dwell/hold/confidence window halves in
wall-clock time and — worse — the frame-counted send throttle
(kSendIntervalFrames=4) allows ~15 filt/s, violating RFC aethersdr#3878 cond. 2
(<= ~8/s).

Frames arriving faster than 25 ms apart are now not accepted (native
~30 fps passes untouched; 60 fps is paced to 30 — the calibration rate —
and the measurement cost halves as a bonus). The gate sits before any
state update, so rejected frames are invisible to every frame counter.
glideToward additionally enforces >= 125 ms between filt sends in
wall-clock, making the RFC bound independent of any fps assumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NF0T pushed a commit that referenced this pull request Jul 3, 2026
…u-gated smoothing — Principle XI. (#3975)

## Summary

Fixes #3967. Also fixes #3932. Root-caused with per-commit builds
(pre-#3836, post-#3836, 26.7.1-main, this fix) captured minutes apart
against the same live AM broadcast band on a FLEX-8400M:

![4-way
comparison](https://raw.githubusercontent.com/jensenpat/AetherSDR/pr-assets/pan-trace-regression-4way.png)

## The regression chain (two releases, two causes)

**26.6.5 — #3836's unconditional spatial smoothing ("out of focus",
#3932).** `buildFftDisplayTrace`'s 5-tap binomial smooth at a fixed 0.75
blend exists to melt the radio's RBW stair-steps when zoomed in. Applied
to every frame at every span, it rounds narrow carriers (visible height
loss on AM carriers, panel 2) and defocuses the noise floor. **Fix:**
gate the blend on the measured plateau fraction (adjacent equal-pixel
bins). Zoomed-in plateau runs (frac ≳0.65) keep the full blend — #3836's
stair-step fix is preserved; busy wide spans (frac ≲0.35) get zero — the
pre-#3836 crispness returns; the ramp between prevents a visible mode
flip while zooming.

**26.7.1 — #3958's per-pixel stroke ("spiky above and below the line",
the rest of #3967).** The stroke distance was approximated as vertical
distance scaled by a `dFdx`-derived slope — i.e. distance to the
segment's **infinite line**. On steep segments that line passes near the
entire pixel column, so the stroke drew full-height needles above and
below the trace (panel 3), and the fixed ±0.75 px smoothstep band read
as a soft fat stroke at 1× DPI. **Fix:** true endpoint-clamped distance
to the three adjacent polyline segments (4 column samples — matches the
coverage the old geometry quads had), plus falloff parity with the old
`spectrum.frag`: solid to `halfWidth−1`, 1 px fade, 1 px feather annulus
at α 0.22.

Also folds in #3968's R16F-unconditional column format as hardening
(float32 linear filtering is optional on GLES and
`isTextureFormatSupported()` can't detect filterability). Note it could
**not** have caused #3967: the reporter's own About screenshot shows
`D3D11; Intel(R) HD Graphics 520` — feature level 12.1 hardware where
R32F linear filtering is mandatory. With this folded in, #3968 can be
closed.

Not implicated (left untouched, documented for the record): #3836's
`decodeFFT` over-range renormalization only fires on radio/client
`y_pixels` mismatch (transient), and the DPR-scaled
`x_pixels`/`y_pixels` requests are orthogonal to trace sharpness.

## Why our platforms missed it

The #3958 parity checks ran on 2× Retina, where the extra AA softness is
half the logical size and the spike needles read as band activity in
static grabs. The 4-way same-band comparison above is exactly the test
that should have been run: panel 4 (this fix) restores panel 1
(pre-#3836).

## Verification

- 4-way live comparison above (same band, same radio pan, same display
settings; three grabs per build).
- `get panstats 0`: frame prep unchanged at ~121 µs/frame, 58
presents/s, `fftBuildMsPerSec` 0.28 (plateau gate skips the smoothing
copy on wide spans).
- 3D stacked-trace mode, lean mode, line-width 0, heat/solid fill:
unaffected paths re-checked by inspection; the software (non-QRhi-build)
QPainter renderer shares `buildFftDisplayTrace` and picks up the plateau
gate automatically.

## Also on the #3967 thread (separate follow-ups, not in this PR)

- "3D option no longer exists": the 2D/3D combo is the **last row of the
Display panel**, below Background — on short screens (768p laptops) it
clips off the bottom of the panadapter. Needs a scrollable/compacted
Display panel; the option itself ships fine in 26.7.1.
- The 60 fps FFT slider ceiling from #3958 is unrelated to this report
but worth an explicit release-notes mention.

💻 Generated with Claude Code (claude-fable-5 7/2/26) with architecture
by @jensenpat

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants