Skip to content

RFC: 3-D stacked-trace spectrum panadapter render mode #3874

Description

@jensenpat

Summary

This is an RFC requesting design feedback on a new "3-Dimensional Spectrum Stream" panadapter render mode — a Yaesu / Rohde-&-Schwarz-style perspective stacked-trace surface.

A new Display-pane option, Spectrum: 2D / 3D, switches the spectrum region between the classic FFT-trace and the spectrum stream surface. The waterfall, frequency scale, slice/VFO markers, spots, memories, and nav-hints all keep working underneath/over it.

Motivation

Stacked-trace 3D spectrum displays make weak signals "in the weeds" easier to spot than a flat 2D trace or a waterfall — ARRL's lab measured Yaesu's as 2–4 dB more sensitive than its own waterfall. Several users have asked for a comparable view. The goal is a fast, good-looking, cross-platform (macOS/Windows/Linux) implementation that reuses our existing Qt + QRhi infrastructure.

Status

Live-verified RX-only on a FLEX-8400M: dense/smooth surface, visible noise floor with a floor→peak colour gradient, dim receding trace lines, fluid pan (panFps ≈ 24, uiLag ≈ 1 ms, 0 % FFT loss); 2D round-trips unchanged.

Design (current)

Rendering — GPU height-map mesh (AETHER_GPU_SPECTRUM, 3D-only):

  • New shaders resources/shaders/dss_mesh.{vert,frag} (compiled to .qsb via the existing qt_add_shaders). They output NDC directly (like spectrum.vert); occlusion is by back-to-front draw order, not a depth test — we don't assume a depth-stencil attachment exists on the QRhiWidget render target on every backend.
  • A static vertex grid (768 cols × 96 rows) of "curtain" quads (ridge vertex + floor vertex) plus a ridge-only line buffer for the outline. Geometry never rebuilds, so pan/zoom are free (just a uniform + redraw).
  • Per-vertex height is sampled from a ring-buffered R16F height texture (cols × rows of dBm). Each new FFT frame uploads one row (~1.5 KB) instead of re-rasterizing/uploading a full image.
  • Two pipelines share the shaders: opaque TriangleStrip fill + alpha-blended LineStrip outline.
  • A ThreeDssRenderer class is the shared smoothed-dBm ring store (peak-preserving downsample + 3-tap spatial + temporal IIR) feeding both the mesh upload and the CPU fallback.

Amplitude / noise floor: ridge height = clamp((dbm − floorDbm) / rangeDb) with a non-linear Z-curve. floorDbm is anchored to the measured noise floor (m_measuredNoiseFloorDbm, the same estimator the 2D auto-floor uses) minus a user "floor depth", so the floor carpet lifts into view. rangeDb is Ref-derived (clamped 45–120 dB). Colour is independent of height: a 256×1 palette LUT gives a vertical floor→peak gradient.

Palette: follows the existing waterfall colour-scheme combo (no new palette UI).

Trace outline: a dim, depth-faded per-row hairline (Yaesu style), distinct from the solid fill.

Controls (Display pane): the Spectrum: 2D / 3D combo + a 3D Floor depth slider (0–24 dB). Both persisted per-panadapter. (Bridge-addressable: spectrumRenderModeCombo, dssFloorDepthSlider.)

Fallback: if the mesh pipeline can't be created (e.g. R16F unsupported), it falls back to a capped cached-QImage quad rendered by DssRenderer on the CPU. 2D is byte-identical to today. Currently, it's pretty poor performance and I wouldn't recommend shipping it.

Key design decisions (seeking validation)

  1. GPU mesh over CPU raster. The CPU painter's-algorithm path couldn't deliver the sample density without choking pan; the mesh makes density, pan, occlusion, and the outline nearly free. CPU path retained as software fallback.
  2. No depth test; back-to-front draw for occlusion. Avoids depending on a depth attachment across Metal/Vulkan/D3D. Correct, but means no per-pixel depth (fine for this geometry).
  3. R16F ring height texture, Nearest sampling. Grid is as dense as the texture, so Nearest is enough; R16F keeps uploads tiny. Fallback path if R16F is unsupported.
  4. 3DSS replaces only the spectrum region, waterfall stays below (matches the 2D split), rather than a full-surface takeover.
  5. Amplitude anchored to the measured noise floor (not the raw Ref window), so the floor is always visible regardless of band.

Open questions — feedback wanted

  1. Default visual style: Yaesu-style stacked ridges vs. an optional R&S/Tektronix density/persistence colour mode (colour = hit-count over freq×amplitude) as an alternate later. Keep stacked as default?
  2. Pan history reprojection: the mesh rows don't yet store per-row centre/bandwidth, so panning smears the history horizontally until it scrolls off (the waterfall reprojects per row).
  3. Controls surface: currently 2 knobs (mode + floor depth) reusing the waterfall palette. Add more (span, Z-curve "shape", scroll speed SLOW1…FAST3, vertical stretch), or keep it minimal?
  4. History depth / cadence: 96 rows at FFT-arrival cadence. Add a decoupled steady ~30 fps scroll + a SPEED control?
  5. Palette: reuse the waterfall scheme (current) vs. add perceptual options (turbo/viridis) for the floor→peak ramp?
  6. Scope/UX: anything about the 2D/3D toggle, persistence, or per-panadapter behaviour that should change before this becomes a PR?

Known limitations / planned follow-ups

  • Per-row frequency reprojection on pan (see Q2).
  • Optional perceptual palette, phosphor-style persistence, and a scroll-SPEED control (researched, deferred).
  • GPU-upload telemetry counters appear unpopulated in this build (unrelated pre-existing gap); fps was validated via GUI-thread metrics.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions