Skip to content

fix(spectrum): draw the panadapter grid below the FFT trace in the GPU path (#3606)#3713

Merged
NF0T merged 2 commits into
aethersdr:mainfrom
svabi79:fix/spectrum-grid-zorder
Jun 21, 2026
Merged

fix(spectrum): draw the panadapter grid below the FFT trace in the GPU path (#3606)#3713
NF0T merged 2 commits into
aethersdr:mainfrom
svabi79:fix/spectrum-grid-zorder

Conversation

@svabi79

@svabi79 svabi79 commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #3606. In the GPU (QRhi) render path the panadapter grid (the dB / frequency lines that form the rectangular cells) was composited on top of the FFT trace, so it painted over the signal peaks — visible interference in the reporter's screenshot. This moves the grid below the trace, matching the software path and conventional panadapter rendering.

Root cause — GPU vs. software z-order parity gap

The software (QPainter) path already draws the grid correctly, behind the trace:

drawGrid(p, specRect);      // grid first
drawSpectrum(p, specRect);  // trace on top

The GPU path composites the frame from texture layers. Per renderGpuFrame() the order is (bottom → top):

  1. waterfall
  2. background layer m_overlayBg (fill + background image) — explicitly drawn below the FFT (this is what fix(spectrum): GPU path now renders FFT trace ABOVE the background image #3124 fixed for the background image)
  3. FFT fill + line (the trace / peaks)
  4. static overlay m_overlayStatic — drawn above the FFT
  5. slice flags

The grid was baked into the static overlay (step 4), so it composited after — i.e. over — the peaks. #3124 moved the background image below the trace but left the grid on top; this closes the remaining half of that parity gap.

The maintainer triage bot independently reached the same diagnosis and proposed this exact move — see the triage comment on #3606.

The change

Move only the grid into m_overlayBg (the layer already composited below the FFT). Everything else in the static overlay — band plan, slice/TNF markers, frequency scale, indicators — stays on top, exactly as the software path draws them after drawSpectrum().

Resulting GPU order, now identical to software:

waterfall → bg fill/image → grid → FFT fill/line → band plan / markers / scales / flags

Performance — neutral by construction

The grid is in the render hot path, so worth being explicit: this is zero-cost. m_overlayBg and m_overlayStatic are both rebuilt under the same m_overlayStaticDirty gate and uploaded on the same cadence. The grid was already painted once per dirty event; it is now painted into a different cached layer — same paint, same upload, no per-frame work added. No interaction with the waterfall or FFT pipelines.

Constitution principle honored

Principle XI — Fixes Are Demonstrated. Verified visually: grid renders behind the peaks (before/after screenshots below). The software path is the reference for correct z-order.

Test plan

  • Local build passes (Windows, Qt 6.8.3 msvc2022_64, GPU path on)
  • Visual check: grid now composites behind the FFT trace, matching the software path
  • Existing tests pass (CI)
  • Reproduction / expected result documented (Bring FFT in front of the panadapter rectangular background #3606)
  • Cross-platform GPU paths (Metal/Vulkan) — same code path; a macOS/Linux screenshot welcome

Checklist

  • Commits are signed (SSH)
  • No new flat-key AppSettings calls (N/A)
  • Code is clean-room (Principle IV)
  • All meter UI uses MeterSmoother (N/A)
  • Documentation updated if user-visible behavior changed (N/A — code comments at the move sites)
  • Security-sensitive changes reference a GHSA if applicable (N/A)

…U path (aethersdr#3606). Principle XI.

Fixes aethersdr#3606.

The GPU (QRhi) render path composited the static overlay -- which bakes in the
grid (the dB / frequency lines forming the rectangular cells) -- ON TOP of the
FFT trace, so the grid painted over the signal peaks. The software (QPainter)
path already draws it correctly: drawGrid() before drawSpectrum(), i.e. the grid
behind the trace. This was a GPU-vs-software z-order parity gap -- the background
*image* was moved below the trace in aethersdr#3124, but the grid was left on top.

Move only the grid into the background overlay layer (m_overlayBg), which is the
layer already composited below the FFT trace. Everything else in the static
overlay (band plan, markers, scales, slice/TNF indicators) stays on top, exactly
as the software path draws them after drawSpectrum(). Both overlay layers are
rebuilt under the same m_overlayStaticDirty gate, so there is no extra
invalidation and no per-frame cost -- the grid is just painted into a different
cached layer.

Resulting GPU order (bottom -> top): waterfall -> bg fill/image -> grid -> FFT
fill/line -> band plan / markers / scales / flags. Identical to the software path.

Clean-room (Principle IV); touches default review tier (SpectrumWidget).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@svabi79 svabi79 requested a review from a team as a code owner June 21, 2026 09:30

@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 against the surrounding SpectrumWidget.cpp render code — this is correct and well-scoped. Thanks for the careful writeup, @svabi79.

Verified the fix does what it claims:

  • Composite order. In renderGpuFrame() the draw sequence is waterfall → m_bgSrb/m_overlayBg quad (drawn below FFT, ~line 7384) → FFT fill/line → m_ovSrb/m_overlayStatic quad (drawn above FFT, ~line 7424). Moving drawGrid into m_overlayBg therefore lands the grid behind the trace, matching the software path's drawGrid → drawSpectrum ordering. Parity confirmed.
  • Zero-cost claim holds. m_overlayBg and m_overlayStatic are both rebuilt under the same m_overlayStaticDirty gate and uploaded on the same cadence — same paint, just a different cached layer. No per-frame cost added, and the grid now sits correctly above the bg fill/image but below the FFT within that layer.
  • drawGrid is self-contained (line 7933): sets its own pen, draws relative to the passed specRect, and reads only state stable within the dirty gate. It doesn't depend on painter state from the rest of the static-overlay block, so removing it from there is side-effect-free.

No convention concerns (no QSettings/flat-key AppSettings, no null/leak risks), and the change stays within its stated single-file scope. The inline comments at both move sites are a nice touch for the next reader. LGTM. 👍

One optional follow-up, not blocking: as you note in the test plan, a Metal/Vulkan screenshot would be good confirmation — but since this is the same QRhi code path on all backends, I don't see a platform-specific risk here.


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

@NF0T NF0T 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.

Human-directed independent review (Claude / @NF0T) | Request Changes


This review was conducted by Claude under direction from @NF0T, independently of the triage bot's prior pass. Every structural claim in the PR and the bot's review was verified against the current codebase before forming a conclusion — the bot review on file is treated as an unverified starting point, not corroboration. What follows documents the verification trail in full.


What I verified and how

Render order — confirmed from the command buffer, not from comments.

I read renderGpuFrame() directly to establish the actual GPU composite order. The command buffer submission sequence in the current codebase is:

  1. Waterfall quad
  2. m_bgSrb / m_overlayBg quad — submitted at line 7384, explicitly before the FFT passes
  3. FFT fill pass + line pass — lines ~7395–7425
  4. m_ovSrb / m_overlayStatic quad — submitted at line 7427, after the FFT

The PR's description of this ordering is accurate. I did not take the author's summary at face value — I traced the actual cb->setShaderResources and cb->draw calls in sequence to confirm.

The bug — confirmed from the current file, not from the issue.

With the above ordering established, I searched for every drawGrid call site in SpectrumWidget.cpp. There are exactly two: one in the GPU path (line 6997) and one in the software path (line 7627). The GPU path call at line 6997 is inside the if (m_overlayStaticDirty) block that paints m_overlayStatic using painter p — confirmed by the QPainter declaration at line 6994 (QPainter p(&m_overlayStatic)). That layer is the one submitted at step 4 above, above the FFT. The bug is real.

The software path at lines 7627–7628 is drawGrid(p, specRect) immediately followed by drawSpectrum(p, specRect) — grid behind trace. The two paths disagree. The parity gap is confirmed.

Dirty gate and zero-cost claim — verified.

Both the m_overlayBg block (line 6971) and the m_overlayStatic block (line 6992) open with if (m_overlayStaticDirty). Same flag, same rebuild cadence. Moving drawGrid between the two blocks does not change when it runs or how often. The zero-cost claim holds.

Static overlay removal side effects — verified clean.

I confirmed that after removing drawGrid from m_overlayStatic, the next call in that block becomes drawBandPlan. I read drawBandPlan() (line 8127) and confirmed it calls p.setPen(...) early in its own body before any drawing. It does not inherit or depend on whatever pen state drawGrid previously left on the painter. Removing drawGrid from before it introduces no side effects in the static overlay block.


Blocking issue found: painter opacity not reset before drawGrid in the new location

This is what the bot review missed, and how I found it.

After confirming the fix concept was structurally correct, I read the full m_overlayBg paint block — not just the lines adjacent to the diff hunk — to understand the complete painter state at the point where drawGrid(bp, specRect) is inserted. The block, in its entirety, is:

if (m_overlayStaticDirty) {
    m_overlayBg.fill(Qt::transparent);
    QPainter bp(&m_overlayBg);
    bp.setRenderHint(QPainter::Antialiasing, false);
    bp.fillRect(specRect, m_bgFillColor);
    if (!m_leanMode && !m_bgImage.isNull()) {
        if (m_bgScaledSize != specRect.size()) {
            // ... scaling ...
        }
        bp.setOpacity(1.0 - m_bgOpacity / 100.0);   // <── opacity set here
        bp.drawImage(specRect.topLeft(), m_bgScaled);
    }
    // PR inserts drawGrid(bp, specRect) here
    m_overlayBgNeedsUpload = true;
}

QPainter::setOpacity is a persistent state mutation — the painter does not auto-restore opacity when an if block exits. If the conditional branch runs (i.e., the user has configured a background image), bp exits that block with its opacity set to 1.0 - m_bgOpacity / 100.0, and that is the opacity in effect when drawGrid(bp, specRect) executes.

I then read drawGrid() in full (line 7933). It calls p.setPen(...) twice — once for horizontal lines, once for vertical — so it is correctly independent of the caller's pen state. However, it makes no call to setOpacity. It unconditionally inherits whatever opacity the painter carries at call time.

I then checked SpectrumWidget.h for the default value of m_bgOpacity:

int m_bgOpacity{80};  // 0=full image, 100=solid dark (default 80%)

With the default, bp.setOpacity(1.0 - 80/100.0) = bp.setOpacity(0.20). Any user who has configured a background image will have drawGrid execute at 20% opacity — the grid lines become nearly invisible, a direct visual regression introduced by this change.

The unaffected path: when no background image is configured, m_bgImage.isNull() returns true, the conditional branch is skipped entirely, bp retains its default opacity of 1.0, and drawGrid runs correctly. This is why the issue would not appear in the author's screenshot (likely taken without a background image) and why CI or casual testing may not catch it — it is a minority configuration.

The bot's review stated that drawGrid "doesn't depend on painter state from the rest of the static-overlay block, so removing it from there is side-effect-free." That observation is correct about the source location (the static overlay block keeps its opacity at 1.0 throughout). But the bot did not examine the painter state at the destination location, which is where the problem is. The self-containedness of drawGrid with respect to pen state does not extend to opacity state — and the destination context is materially different from the source context in precisely that dimension.

Required fix — one line:

            }  // end bg image block
+           bp.setOpacity(1.0);
            drawGrid(bp, specRect);
            m_overlayBgNeedsUpload = true;

Everything else

With the above issue corrected, the remainder of the PR is sound:

  • The fix concept is correct — m_overlayBg is the right destination layer
  • Both overlay layers rebuild on the same dirty gate — no extra invalidation wiring needed
  • The static overlay removal leaves no painter-state side effects for drawBandPlan or any subsequent call
  • The PR template is fully completed; Principle XI is cited and appropriate
  • Commits are signed per the checklist
  • Scope is one file, one function relocated, eight lines changed — blast radius is minimal

Once the opacity reset is in place, I will approve on the next revision.

…ethersdr#3606)

The m_overlayBg block sets bp opacity to (1 - m_bgOpacity/100) for the
background image and never restores it. The grid relocated into this block by
this PR then inherited that opacity -- with a background image configured
(default m_bgOpacity=80 -> 0.20) the grid rendered at 20% opacity, nearly
invisible. drawGrid sets its own pen but not opacity, so reset to 1.0 first.

Caught in review by @NF0T (the background-image config path). No effect when no
background image is set -- the branch is skipped and bp opacity stays 1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@svabi79

svabi79 commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

Great catch — thank you, @NF0T. You're exactly right: the bg-image branch leaves bp at 1 - m_bgOpacity/100 (0.20 with the default), and the relocated drawGrid inherited it, so the grid would render at ~20% opacity — effectively invisible — for anyone with a background image configured. drawGrid sets its own pen but not opacity, so the destination painter state was exactly the gap.

Fixed in 9389b1c: bp.setOpacity(1.0) immediately before drawGrid. No effect when no background image is set — that branch is skipped and bp opacity stays 1.0.

Appreciate the independent verification right down to the destination painter state; that's the dimension the move actually hinged on, and it's a better PR for it.

@NF0T NF0T 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.

Human-directed independent review (Claude / @NF0T)

Follow-up to the REQUEST_CHANGES posted earlier today — the one blocking issue is resolved.

Blocking issue from prior review — fixed:
bp.setOpacity(1.0) is now present immediately after the bg-image conditional block and before drawGrid(bp, specRect) in m_overlayBg. The opacity reset is correctly placed so the grid renders at full opacity regardless of whether a background image is configured and what m_bgOpacity is set to. The comment at the insertion site accurately documents the invariant and cites the review. ✓

Everything else — carried forward from prior verification:

  • Render order (waterfall → bg → grid → FFT → static overlay) confirmed correct ✓
  • Zero-cost claim holds (same m_overlayStaticDirty gate for both layers) ✓
  • Removal of drawGrid from m_overlayStatic leaves no painter-state side effects for drawBandPlan
  • Single-file Tier 3 change ✓

Approving.

@NF0T NF0T merged commit 756353b into aethersdr:main Jun 21, 2026
6 checks passed
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.

Bring FFT in front of the panadapter rectangular background

2 participants