fix(spectrum): draw the panadapter grid below the FFT trace in the GPU path (#3606)#3713
Conversation
…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>
There was a problem hiding this comment.
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_overlayBgquad (drawn below FFT, ~line 7384) → FFT fill/line →m_ovSrb/m_overlayStaticquad (drawn above FFT, ~line 7424). MovingdrawGridintom_overlayBgtherefore lands the grid behind the trace, matching the software path'sdrawGrid → drawSpectrumordering. Parity confirmed. - Zero-cost claim holds.
m_overlayBgandm_overlayStaticare both rebuilt under the samem_overlayStaticDirtygate 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. drawGridis self-contained (line 7933): sets its own pen, draws relative to the passedspecRect, 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
left a comment
There was a problem hiding this comment.
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:
- Waterfall quad
m_bgSrb/m_overlayBgquad — submitted at line 7384, explicitly before the FFT passes- FFT fill pass + line pass — lines ~7395–7425
m_ovSrb/m_overlayStaticquad — 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_overlayBgis 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
drawBandPlanor 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>
|
Great catch — thank you, @NF0T. You're exactly right: the bg-image branch leaves Fixed in 9389b1c: 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
left a comment
There was a problem hiding this comment.
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_overlayStaticDirtygate for both layers) ✓ - Removal of
drawGridfromm_overlayStaticleaves no painter-state side effects fordrawBandPlan✓ - Single-file Tier 3 change ✓
Approving.
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:The GPU path composites the frame from texture layers. Per
renderGpuFrame()the order is (bottom → top):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)m_overlayStatic— drawn above the FFTThe 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 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 afterdrawSpectrum().Resulting GPU order, now identical to software:
Performance — neutral by construction
The grid is in the render hot path, so worth being explicit: this is zero-cost.
m_overlayBgandm_overlayStaticare both rebuilt under the samem_overlayStaticDirtygate 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
Checklist
AppSettingscalls (N/A)MeterSmoother(N/A)