34 KiB
Visual Rendering
This document describes CubicSDR's OpenGL rendering system, canvas hierarchy, GL panel system, font rendering, color themes, and rendering flow. For the visual data processing pipeline (VisualProcessor, distributors, FFT processing), see visual-data-pipeline.md.
Overview
The visual system is built on wxWidgets' OpenGL integration (wxGLCanvas/wxGLContext) and uses a pull-based architecture: worker threads produce FFT/scope data into queues, and UI canvases poll those queues in their OnIdle() or OnPaint() handlers. Worker threads never push data to the UI.
The visual data processing pipeline — how raw IQ samples become display-ready spectrum/scope data, the VisualProcessor template, and the FFT/scope processing internals — is documented in visual-data-pipeline.md.
Canvas Class Hierarchy
wxGLCanvas
|
+-- InteractiveCanvas (base: mouse tracking, frequency mapping, key state)
|
+-- WaterfallCanvas (waterfall display, demod creation, drag operations)
+-- SpectrumCanvas (FFT spectrum display, linked to WaterfallCanvas)
+-- ScopeCanvas (oscilloscope/spectrum display for demod output)
+-- MeterCanvas (signal level meter)
+-- TuningCanvas (fine tuning control)
+-- ModeSelectorCanvas (modem type selection buttons)
+-- GainCanvas (per-gain-stage slider)
+-- UITestCanvas (test/development canvas in src/ui/)
InteractiveCanvas (src/visual/InteractiveCanvas.h)
Base class for all interactive GL canvases. Provides:
- Frequency mapping:
getFrequencyAt(x)converts a normalized x-position [0..1] to a frequency in Hz, given the current center frequency and bandwidth - View state:
setView()/disableView()for zoomed sub-band views vs. full-band views - Mouse tracking:
MouseTrackerinstance with position, button state, drag deltas - Key state:
shiftDown,altDown,ctrlDownflags updated on key/mouse events - Status bar:
setStatusText()writes to the AppFrame status bar
WaterfallCanvas (src/visual/WaterfallCanvas.h)
The primary display canvas. Handles:
- FFT data consumption: Pops
SpectrumVisualDatafromvisualDataQueueat a rate-limited pace (linesPerSecond) - Waterfall rendering: Delegates to
WaterfallPanelfor texture-based scrolling waterfall - Demodulator markers: Draws all active demodulators via
PrimaryGLContext::DrawDemod() - Frequency selector: Draws hover/active frequency markers via
DrawFreqSelector() - Drag operations:
WF_DRAG_FREQUENCY— move demodulator frequencyWF_DRAG_BANDWIDTH_LEFT/WF_DRAG_BANDWIDTH_RIGHT— resize demodulator bandwidthWF_DRAG_RANGE— create new demodulator by range selection
- Zoom: Mouse wheel adjusts
mouseZoom, which smoothly animates to the target zoom level - Frequency nudge: Arrow keys shift center frequency by half a bandwidth; Shift adds 10× the shift
- Visual gain: Shift+Up/Down adjusts
scaleMove(drives visual gain animation toward target scale factor)
Drag state machine:
WF_DRAG_NONE ──(mouse hover over demod)──> WF_DRAG_FREQUENCY / WF_DRAG_BANDWIDTH_*
WF_DRAG_NONE ──(Alt held)──> WF_DRAG_RANGE
WF_DRAG_* ──(mouse released)──> WF_DRAG_NONE
SpectrumCanvas (src/visual/SpectrumCanvas.h)
Displays the FFT spectrum line plot. Linked to WaterfallCanvas:
- Shares the same center frequency and bandwidth
- Receives FFT data from its own
visualDataQueue - Supports dB scale display and dB offset
- Right-drag adjusts visual gain (vertical scale factor)
- Uses
SpectrumPanelfor rendering
ScopeCanvas (src/visual/ScopeCanvas.h)
Displays demodulated audio as oscilloscope or spectrum:
- Pops
ScopeRenderDatafrominputDataqueue - Toggles between scope mode (waveform) and spectrum mode (FFT)
- Supports PPM mode for frequency calibration display
- Uses both
ScopePanelandSpectrumPanelin a composite layout GLPanelhierarchy:parentPanel→scopePanel+spectrumPanel(children);bgPanelis drawn independently with its own identity transform beforeparentPanel
GLPanel System (src/ui/GLPanel.h)
A lightweight retained-mode UI system for OpenGL rendering. Panels form a tree: each panel has position, size, fill, and optional children. The tree is traversed recursively each frame, with each panel computing its transform relative to its parent.
Base Class Properties
GLPanel
├── pos[2], size[2], rot[3] // placement in parent coordinates
├── fill[2] (RGBA4f) // fill colors (solid or gradient endpoints)
├── fillType // rendering mode (see below)
├── borderColor, borderPx // border color (RGBA4f) and per-edge border widths in pixels
├── marginPx // margin in pixels
├── coord // coordinate system (see below)
├── visible, contentsVisible // visibility flags
├── srcBlend, dstBlend // custom blend modes (GLuint)
├── children (vector<GLPanel*>) // child panels
├── transform, transformInverse // computed global matrices
├── localTransform // pos/size/rot composed
├── vmin, vmax // computed screen-space extents
├── umin, umax, ucenter // computed unit-space extents
├── view[2] // viewport dimensions
├── min, mid, max // coordinate system extents (-1/0/1 or 0/0.5/1)
├── pdim, pvec // pixel dimensions and pixel vector
└── (private) glPoints, glColors // pre-computed vertex/color arrays for fill rendering
Derived panels:
GLTextPanel— text rendering with alignment (left/right/center, top/bottom);useNativeFontbypasses scale factor when trueGLTestPanel— debug/test rendering
Panel classes:
| Panel | File | Purpose |
|---|---|---|
WaterfallPanel |
src/panel/WaterfallPanel.h |
Scrolling waterfall texture rendering |
SpectrumPanel |
src/panel/SpectrumPanel.h |
FFT line plot rendering |
ScopePanel |
src/panel/ScopePanel.h |
Oscilloscope waveform rendering |
MeterPanel |
src/panel/MeterPanel.h |
Signal level meter rendering |
Fill Types
| Enum | Vertices | Description |
|---|---|---|
GLPANEL_FILL_NONE |
8 (allocated, not drawn) | Transparent — arrays allocated but draw() skips rendering |
GLPANEL_FILL_SOLID |
4 (1 quad) | Single color (fill[0]) |
GLPANEL_FILL_GRAD_X |
4 (1 quad) | Horizontal gradient: fill[0] (left) → fill[1] (right) |
GLPANEL_FILL_GRAD_Y |
4 (1 quad) | Vertical gradient: fill[0] (bottom) → fill[1] (top) |
GLPANEL_FILL_GRAD_BAR_X |
8 (2 quads) | Symmetric horizontal bar: gradient from edges to center |
GLPANEL_FILL_GRAD_BAR_Y |
8 (2 quads) | Symmetric vertical bar: gradient from edges to center |
The bar fill types create a symmetric gradient: fill[0] at both edges, fill[1] at the center line. This is used for tuning bar indicators where the center value is the strongest.
Coordinate Systems
The GLPanelCoordinateSystem enum controls how the [-1,1] unit square maps to screen space. Each system applies a different matrix to transform content coordinates before drawing:
| Enum | Mapping | Y Direction | Use Case |
|---|---|---|---|
GLPANEL_Y_DOWN_ZERO_ONE |
[0,1] → [-1,1] | Down | Waterfall — origin at top-left |
GLPANEL_Y_UP_ZERO_ONE |
[0,1] → [-1,1] | Up | Most panels — origin at bottom-left |
GLPANEL_Y_UP |
[-1,1] passthrough | Up | Raw unit coordinates |
GLPANEL_Y_DOWN |
[-1,1] flip Y | Down | Raw coordinates with Y flipped |
The coordinate matrix is applied after the panel's transform: finalMatrix = transform * coordMatrix. Content drawn in drawPanelContents() uses this coordinate space.
Transform Pipeline: calcTransform() + draw()
The render pipeline executes in two phases per panel:
Phase 1 — calcTransform(parentTransform) (called by parent before drawing children):
- Compose local transform:
localTransform = translate(pos) * scale(size)(optionally* rotate(rot)) - Compute global transform:
transform = parentTransform * localTransform - Read viewport:
glGetIntegerv(GL_VIEWPORT, vp) - Transform coordinate-system corners through
transformto get screen-spacevmin/vmax(corners are(min,min)and(max,max)where min/max depend on the coordinate system: -1/+1 or 0/+1) - Compute pixel extents:
pdim = vec2((vmax.x - vmin.x) * 0.5 * vp_width, ...) - Apply margin: shrink transform proportionally by
marginPx * 2 * pvecin each axis (pixel-vector-scaled margin) - Compute
transformInverse = mat4::inverse(transform)for hit-testing
Phase 2 — draw() (called recursively):
- Load modelview matrix:
glLoadMatrixf(transform.to_ptr()) - If
visibleand fill type is not NONE:- Enable
GL_BLENDwith the panel's blend mode - Draw pre-computed vertex/color arrays via
glDrawArrays(GL_QUADS, ...)(arrays built earlier when fill properties changed viagenArrays()insetFill()/setFillColor()/setCoordinateSystem()) - If any border is non-zero: enable
GL_LINE_SMOOTH, draw border edges withglBegin(GL_LINES)
- Enable
- If
contentsVisible:- Build coordinate matrix from the panel's coordinate system
glLoadMatrixf((transform * coordMatrix).to_ptr())- Call
drawPanelContents()(virtual — overridden by subclasses)
- Base
drawPanelContents()callsdrawChildren(), which iterateschildrenand callscalcTransform(this->transform)+draw()on each child
Composition pattern: The root panel's draw() is called once. It renders its own fill, then its drawPanelContents() triggers drawChildren(). Each child computes its transform relative to the parent and draws itself, creating a recursive tree traversal.
Hit-Testing
GLPanel::hitTest(vec2 screenPos, vec2 &localResult) transforms a screen-space point into the panel's local [-1,1] coordinate system using the precomputed transformInverse. If the transformed point falls within the unit square, the hit succeeds and the caller receives the local coordinates.
Note: GLPanel hit-testing is used by MeterPanel (in isMeterHit() and getMeterHitValue()) for click-to-set-level interaction within GainCanvas. Other canvases (WaterfallCanvas, SpectrumCanvas, MeterCanvas, etc.) use MouseTracker with their own coordinate mapping instead.
PrimaryGLContext (src/visual/PrimaryGLContext.h)
Shared OpenGL context providing drawing primitives. All canvases share this context via wxGLContext sharing. Uses legacy OpenGL fixed-function pipeline (no shaders).
Drawing Methods
| Method | Purpose |
|---|---|
BeginDraw(r, g, b) |
Clear color+depth buffers, load identity modelview |
EndDraw() |
Finalize frame — currently empty (no-op) |
DrawDemod(demod, color, center_freq=-1, srate=0) |
Draw demodulator bandwidth indicator with label |
DrawDemodInfo(demod, color, center_freq=-1, srate=0, centerline=false) |
Draw demodulator info label (frequency, type, bandwidth) |
DrawFreqSelector(uxPos, color, w=0, center_freq=-1, srate=0) |
Draw frequency selection marker (center line + BW edges) |
DrawRangeSelector(uxPos1, uxPos2, color) |
Draw range selection overlay (two vertical boundary lines) |
DrawFreqBwInfo(freq, bw, color, center_freq=-1, srate=0, stack=false, centerline=false) |
Draw frequency and bandwidth text with shadow |
GL State Management
BeginDraw(r, g, b) (called at frame start):
glClearColor(r, g, b, 1)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
Drawing primitives use immediate-mode OpenGL:
glEnable(GL_BLEND)/glBlendFunc()— additive blending for overlaysglDisable(GL_TEXTURE_2D)before line drawingglBegin(GL_LINES)/glVertex3f()— immediate mode for demod linesglBegin(GL_QUADS)/glVertex3f()— immediate mode for filled regionsglColor4f()/glColor3f()— immediate mode color settingglLineWidth()— variable width for range-selector emphasis (DrawRangeSelector)
Blend modes used:
| Mode | Components | Use |
|---|---|---|
| Additive | GL_SRC_ALPHA, GL_ONE |
Demod bands, frequency selectors |
| Alpha | GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA |
Labels, backgrounds |
| Opaque | GL_ONE, GL_ZERO |
Shadow text (then switched to alpha for main text) |
Buffer Strategy
No persistent GPU buffers for drawing primitives. All PrimaryGLContext drawing is immediate-mode (glBegin/glEnd) for lines and quads. The only persistent GPU resources in the visual system are:
- Font atlas textures — one per font size, uploaded once in
loadFontOnce() - Waterfall textures — two double-buffered textures per waterfall panel, updated in
WaterfallPanel::update()
ScopeContext Extensions
ScopeContext (subclass of PrimaryGLContext) adds scope-specific drawing:
DrawBegin(clear)— clears with scope background color, disables textureDrawTunerTitles(ppmMode)— draws "Frequency", "Bandwidth", "Center Frequency" labelsDrawDeviceName(name)— draws device name in top-rightDrawDivider()— single white vertical line at centerDrawEnd()— finalize scope frame (currently empty, likeEndDraw())
MeterContext (src/visual/MeterContext.h)
Context for the signal level meter canvas. Minimal extension — no font rendering, purely filled geometry.
DrawBegin()— disablesGL_CULL_FACEandGL_DEPTH_TEST, clears with theme background (generalBackground), loads identity modelview, disables texturingDraw(r, g, b, a, level)— draws a vertical meter bar split at x=0 into left/right halves; each half uses a horizontal gradient (dim at outer edges, full brightness at center x=0). Additive blending (GL_ONE, GL_ONE). Thelevelparameter (0.0–1.0) controls fill height. Note: the two halves are not symmetric — the left half dims only RGB at the outer edge, while the right half dims both RGB and alpha.DrawEnd()— no-op
TuningContext (src/visual/TuningContext.h)
Context for the fine-tuning bar canvas. Renders per-digit frequency display with adaptive font sizing.
DrawBegin()— clears with theme background, disables texturingDraw(r, g, b, a, p1, p2)— draws a horizontal tuning bar between positionsp1–p2(normalized 0–1), split at y=0 with additive blending gradient (dim at edges, full at center)DrawTuner(freq, count, displayPos, displayWidth)— renders a frequency value as individual digits, each in its own column with vertical grid lines at 25% alpha. Font size adapts to viewport via two sequential paths: width-based first (<300px → 18px,<500px → 24px, else → 32px), then height-based overrides (<18px → 12px,<24px → 16px,<28px → 18px; height ≥ 28px leaves the width-selected size unchanged)static DrawTunerDigitBox(index, count, displayPos, displayWidth, color)— draws a red highlight box around a single digit position viaGL_LINE_STRIP(note:colorparameter is currently ignored; highlight is always red)GetTunerDigitIndex(mPos, count, displayPos, displayWidth)— hit-test utility converting mouse position to digit indexDrawTunerBarIndexed(start, end, count, displayPos, displayWidth, color, alpha, top, bottom)— draws a colored bar for a range of digit indices on the top/bottom half with additive blending (note:alphaparameter is currently ignored; hardcoded to 0.6)DrawDemodFreqBw(freq, bw, center)— composite method drawing three tuner displays side-by-side: demod frequency, bandwidth, and center frequencyDrawEnd()— no-op
ModeSelectorContext (src/visual/ModeSelectorContext.h)
Context for the modem type selection button canvas. Unique dual-drawing mode for active/inactive states.
DrawBegin()— clears with theme background, disables texturingDrawSelector(label, c, cMax, on, r, g, b, a, px, py)— draws a single mode button. Whenon=true, draws a filled quad (active); whenon=false, draws only an outline (GL_LINE_LOOP). Semi-transparent modes enable alpha blending. Text is black when active, otherwise drawn in the specified color. Buttons are distributed evenly across full height based oncMaxDrawEnd()— no-op
UITestContext (src/ui/UITestContext.h)
Context for the UI test/development canvas. The only context that uses the GLPanel widget hierarchy rather than raw GL calls.
- Constructor builds test panel members: root
testPanel(solid blue, 10px margin),testChildPanel(gradient bar, Y-down coords),testChildPanel2(gradient, red border, withtestText1child showing "Testing 123.."),testChildPanel3(gradient, red border), andtestMeter(meter widget). Currently onlytestMeteris parented totestPanel; the three child panels are instantiated but not added to the rendered tree (commented out) DrawBegin()— disables cull face/depth test, clears with theme background, loads identity modelviewDraw()— computes root panel transform via identity matrix, then callstestPanel.draw()which recursively renders the entire GLPanel treeDrawEnd()— no-op
Hover Alpha
A hoverAlpha float controls the transparency of hover indicators, animated smoothly over time for visual feedback on demodulator hover states.
GLFont System (src/util/GLFont.h)
Bitmap font rendering using BMFont format. Fonts are stored in font/ as PNG + definition files. 11 bitmap font sizes (12-96px) are loaded on demand via loadFontOnce() (lazy initialization). Font file search order:
RES_FOLDER/fonts/*[exe_path]/RES_FOLDER/fonts/*[exe_path]/fonts/*
Font Sizes
| Enum | Pixels | Use Case |
|---|---|---|
GLFONT_SIZE12 |
12px | Small labels |
GLFONT_SIZE16 |
16px | Status text |
GLFONT_SIZE18 |
18px | Medium labels |
GLFONT_SIZE24 |
24px | Frequency display |
GLFONT_SIZE27 |
27px | — |
GLFONT_SIZE32 |
32px | Large labels |
GLFONT_SIZE36 |
36px | — |
GLFONT_SIZE48 |
48px | Header text |
GLFONT_SIZE64 |
64px | — |
GLFONT_SIZE72 |
72px | — |
GLFONT_SIZE96 |
96px | — |
Font Scaling
GLFont::GLFontScale provides user-configurable scaling:
GLFONT_SCALE_NORMAL— 1.0xGLFONT_SCALE_MEDIUM— 1.5xGLFONT_SCALE_LARGE— 2.0x
Changing the scale factor calls clearAllCaches() to flush all string caches, since cached vertex data is baked at a specific scale.
Drawer Proxy
GLFont::Drawer is a lightweight proxy that selects the appropriate font size and scale factor:
GLFont::Drawer drawer = GLFont::getFont(24, scaleFactor);
drawer.drawString("Hello", x, y, GLFONT_ALIGN_LEFT, GLFONT_ALIGN_TOP);
Font selection algorithm (getFont()):
- Compute
targetSize = round(requestedSize * scaleFactor) - Scan the
fonts[]array to find the largest font wherepixHeight <= targetSize - Compute
renderingFontScaleFactor = (double) targetSize / rawPixHeight - The Drawer stores the selected font index and scale factor;
drawString()applies the scale to the final rendering
This means requesting size 24 with scale 1.5x selects the 36px font (if available) or the nearest smaller font scaled up.
String Caching
Each font maintains a stringCache map (wstring → GLFontStringCache) for pre-computed vertex/UV data. The cache key encodes viewport dimensions, pixel height, and the string text.
Cache entry structure:
drawlen— number of renderable charactersvpx,vpy— viewport dimensions at cache time (invalidates on resize)pxHeight— pixel height at cache timemsgWidth— precomputed string widthgc— atomic age counter for evictiongl_vertices— pre-transformed vertex positionsgl_uv— texture coordinates
Cache hit path: drawCacheString() renders all characters in a single glDrawArrays(GL_QUADS, 0, 4 * drawlen) call. No per-character transforms needed — vertices are pre-baked at cache creation.
Cache miss path: cacheString() pre-transforms all character vertices through the final scale+translate matrix, stores them, then draws.
Cache eviction policy:
Every ~50 cacheable draw calls (GC_DRAW_COUNT_PERIOD, fires when gcCounter > 50):
│
▼
doCacheGC() — single pass through stringCache map:
│
├── For each entry: decrement gc counter
│
└── Remove first entry where gc < -GC_DRAW_COUNT_LIMIT (-10)
(not drawn for 10+ GC cycles = 500+ draw calls)
Eviction is amortized — at most one entry removed per GC cycle, preventing frame spikes. On cache hit, gc resets to 0. This LRU-like policy keeps frequently used strings (demod labels, frequency readouts) cached while slowly evicting stale entries (old demod labels, transient status text).
Thread Safety
| Mechanism | Protects | Type |
|---|---|---|
cache_busy |
String cache map access | SpinMutex (atomic flag spinlock) |
currentScale |
Scale factor reads | std::atomic |
| Font data (characters, textures) | Read-only after load | Inherently thread-safe |
The Drawer object is stack-local (not shared between threads), so creating a Drawer per-frame is safe. The SpinMutex ensures only one thread can modify the cache map at a time, while concurrent reads of the font atlas texture are safe since it's immutable after upload.
ColorTheme System (src/visual/ColorTheme.h)
Theme Structure
ColorTheme defines all colors used by the visual system:
| Property | Used By |
|---|---|
waterfallGradient |
Waterfall color mapping (256-entry LUT) |
waterfallHighlight |
Active demodulator marker |
waterfallNew |
New demodulator being created |
waterfallHover |
Hovered demodulator / frequency selector |
waterfallDestroy |
Demodulator being deleted |
wfHighlight |
Waterfall highlight overlay |
fftLine |
Spectrum line color |
fftHighlight |
Highlighted spectrum point |
scopeLine |
Oscilloscope trace color |
tuningBarLight / tuningBarDark |
Tuning bar gradient |
tuningBarUp / tuningBarDown |
Fine tuning direction indicators |
meterLevel / meterValue |
Signal meter colors |
text |
General text color |
freqLine |
Frequency grid lines |
button / buttonHighlight |
UI button colors |
scopeBackground |
Scope canvas background |
fftBackground |
Spectrum canvas background |
generalBackground |
General background color |
name |
Theme display name (string) |
Available Themes
| ID | Name | Class | Description |
|---|---|---|---|
| 0 | Default | DefaultColorTheme |
Google Turbo colormap (rainbow) |
| 1 | DefaultJet | DefaultColorThemeJet |
Original CubicSDR jet colormap |
| 2 | Black & White | BlackAndWhiteColorTheme |
Grayscale waterfall |
| 3 | Sharp | SharpColorTheme |
High-contrast blue-white-yellow-red |
| 4 | Rad | RadColorTheme |
Blue-green-orange-red-white |
| 5 | Touch | TouchColorTheme |
Dark purple/cyan/green/yellow/red |
| 6 | HD | HDColorTheme |
Blue-green-red-yellow-white |
| 7 | Radar | RadarColorTheme |
Green monochrome radar style |
ThemeMgr
Global singleton ThemeMgr::mgr manages theme selection:
setTheme(themeId)setscurrentThemepointergetTheme()returns currentthemeIdthemesmap holds allColorTheme*instances- Current theme is persisted in
AppConfig
Rendering Flow
Per-Frame Rendering (WaterfallCanvas)
All canvases follow the same rendering pattern in OnPaint():
1. glContext->SetCurrent(*this) // bind GL context
2. initGLExtensions() // WGL swap interval (Windows)
3. glViewport(0, 0, ClientSize.x, ClientSize.y)
4. glContext->BeginDraw(r, g, b) // clear buffers, load identity
5. panelTree.calcTransform(identity) // recursive transform computation
6. panelTree.draw() // recursive panel rendering
7. glContext->DrawDemod(...) // demodulator overlay markers
8. glContext->DrawFreqSelector(...) // frequency selection indicators
9. glContext->DrawRangeSelector(...) // range selection overlay (if active)
10. glContext->EndDraw() // finalize (empty)
11. SwapBuffers() // present frame
Key detail: The panel tree is drawn first (step 5-6), then overlays are drawn on top (steps 7-9) using PrimaryGLContext immediate-mode drawing. This two-pass approach ensures overlays always render above the waterfall/spectrum content. Note: DrawFreqBwInfo is called by SpectrumCanvas (not WaterfallCanvas) to show frequency/bandwidth readouts at the cursor position.
OnIdle Processing
Each canvas registers its own EVT_IDLE handler. wxWidgets fires OnIdle when the event loop is empty, giving canvases a chance to poll for new data:
| Canvas | OnIdle Behavior |
|---|---|
WaterfallCanvas |
processInputQueue() + Refresh() |
SpectrumCanvas |
Refresh() + event.RequestMore() |
ScopeCanvas |
Refresh() + event.RequestMore() |
WaterfallCanvas is the only canvas that polls its input queue in OnIdle via processInputQueue(). SpectrumCanvas and ScopeCanvas process their input queues inside OnPaint() instead (via try_pop() at the start of the paint handler). All canvases use non-blocking try_pop() to avoid stalling the UI thread. Refresh() is always called to drive animations (zoom interpolation, hover alpha decay, frequency nudge velocity) even when no new data is available.
WaterfallCanvas Paint Flow (Detailed)
OnPaint():
├── Apply zoom interpolation: mouseZoom += (1.0 - mouseZoom) * 0.2
├── Apply frequency nudge velocity: centerFreq += bandwidth * freqMove * 0.01
├── Apply visual gain animation: scaleFactor interpolation
│
├── GL setup (viewport, clear)
│
├── WaterfallPanel.draw() // texture-based scrolling waterfall
│ ├── Upload new FFT rows as texture strips
│ └── Draw with texture coordinate scrolling
│
├── Draw demodulator markers // one DrawDemod() per active demod
│ └── Color-coded: highlight (active), hover, new, destroy
│
├── Draw frequency selector // hover/active frequency marker
│
└── SwapBuffers()
ScopeCanvas Paint Flow (Detailed)
OnPaint():
├── Pop ScopeRenderData from inputData queue
│
├── GL setup (viewport, clear)
│
├── bgPanel.draw() // independent identity transform, scope background
│
├── Compute panel positions (spring animation)
│ ├── ctr += (ctrTarget - ctr) * 0.2
│ └── Apply 3D perspective: lookat(0, 0, -1.205) + rotation
│
├── Set panel visibility based on scopeVisible()/spectrumVisible()
│ ├── scopePanel.contentsVisible = scopeVisible()
│ └── spectrumPanel.contentsVisible = spectrumVisible()
│
├── parentPanel.draw() // recursive: scopePanel + spectrumPanel
│ └── Uses ScopeVisualProcessor output data
│
├── ScopeContext overlay drawing
│ ├── DrawDeviceName()
│ └── DrawTunerTitles(ppmMode)
│
└── SwapBuffers()
Data Flow Timing
The pull-based architecture creates a producer-consumer pipeline with these timing characteristics:
SDRThread (producer) → SDRPostThread (channelizer/passthrough)
│
├── IQ data at sample rate (e.g., 2.4 Msps → 60 packets/sec at 40,000 samples/packet)
│
▼
SpectrumVisualProcessor (worker thread)
│
├── Processes at pace set by upstream FFTDataDistributor (~30-60/sec)
├── Applies smoothing (double EMA) to reduce flicker
│
▼
WaterfallCanvas (consumer, UI thread)
│
├── OnIdle() polls queue with try_pop()
├── Rate-limits waterfall line insertion (linesPerSecond, default 30)
├── Triggers Refresh() → OnPaint() → SwapBuffers()
│
▼
Display (~30-60 FPS depending on system)
For details on the visual data processing pipeline internals (VisualProcessor, distributors, FFT processing, ScopeVisualProcessor processing logic), see visual-data-pipeline.md.
Mouse Interaction
MouseTracker (src/util/MouseTracker.h)
All canvases share a MouseTracker instance that normalizes mouse coordinates and tracks drag state:
- Coordinates:
mouseX/mouseYnormalized to [0,1] with Y flipped (1.0 - clientY/height) - Deltas:
deltaMouseX/deltaMouseYcomputed each frame - Origin:
originMouseX/originMouseYrecorded on mouse-down for drag distance - Button state:
isMouseDown,isMouseRightDown,isMouseInView - Drag locks:
vertDragLock/horizDragLock— on Windows, physically warp cursor back to lock axis viaWarpPointer(); on macOS/Linux, drag locks are not enforced visually
Event Propagation
All canvas event handlers follow the same pattern:
- Call
InteractiveCanvas::OnMouseMoved(event)— updatesmouseTrackercoordinates and modifier key flags (shiftDown,altDown,ctrlDown) - Perform canvas-specific logic
Modifier keys are tracked as member booleans, updated on every mouse/key event.
WaterfallCanvas Drag State Machine
enum DragState {
WF_DRAG_NONE,
WF_DRAG_BANDWIDTH_LEFT,
WF_DRAG_BANDWIDTH_RIGHT,
WF_DRAG_FREQUENCY,
WF_DRAG_RANGE
};
Hover state resolution (updateHoverState()):
altDown? ──yes──> WF_DRAG_RANGE
│no
▼
Cursor over demodulator (within initial 15 kHz filter, then dynamic buffer of `halfBw + 10kHz * (currentBw / globalBw)`)? ──no──> WF_DRAG_NONE
│yes
▼
!shiftDown? ──no──> WF_DRAG_NONE (shift held bypasses demod-hover entirely)
│yes
▼
|freqDiff| > bandwidth/3? ──yes──> WF_DRAG_BANDWIDTH_LEFT or RIGHT
│no (based on which side of center;
▼ LSB/USB mode exceptions for direction)
WF_DRAG_FREQUENCY
Mouse-down: Captures nextDragState into dragState, records dragOfs (offset from cursor to demod center) and dragBW (initial bandwidth for BW resize).
Mouse-move while dragging:
| State | Behavior |
|---|---|
WF_DRAG_BANDWIDTH_LEFT |
Resize from left edge: bwDiff = deltaMouseX * bandwidth * 2, clamp to [MIN_BANDWIDTH, CHANNELIZER_RATE_MAX] Hz |
WF_DRAG_BANDWIDTH_RIGHT |
Resize from right edge: same formula, opposite sign (direction exceptions for LSB/USB modes) |
WF_DRAG_FREQUENCY |
Move demod center: compute target freq from cursor, apply frequency snap |
WF_DRAG_RANGE |
Visual range selection — highlight region between origin and current cursor |
| (right-drag) | Forward to spectrumCanvas->updateScaleFactorFromYMove(deltaMouseY) for visual gain |
Mouse-release:
| State | Action |
|---|---|
| Zero-drag (click) | Create new demodulator or move existing one to cursor frequency |
WF_DRAG_RANGE |
Create demodulator spanning the selected frequency range |
| All states | Reset dragState = WF_DRAG_NONE |
Frequency snap: When moving a demodulator, the frequency snaps to the nearest configurable snap value (powers of 10: 1, 10, 100, 1000, etc.). Default is 1 Hz (no snapping); the user sets this via the tuning canvas, and it is persisted in AppConfig.
Frequency Zoom State Machine (Mouse Wheel)
Mouse wheel delta
│
▼
mouseZoom = 1.0 - (wheelRotation / linesPerAction) / 1000.0
│
▼ (in OnPaint, per-frame interpolation)
mouseZoom += (1.0 - mouseZoom) * 0.2 // smooth animation toward 1.0
│
▼
|mouseZoom - 1.0| < 0.01? ──yes──> snap to 1.0, exit view mode if zoomed out past sample rate
│no
▼
Apply zoom: bw = ceil(baseBandwidth * currentZoom), clamp to minBandwidth (default 30000 Hz)
The zoom keeps the frequency under the mouse cursor stable by adjusting the center frequency proportionally to the bandwidth change.
Keyboard Navigation
Arrow keys control frequency and display in the WaterfallCanvas:
| Key | Condition | Action |
|---|---|---|
| LEFT | View mode | Pan center frequency by ±1.0% of bandwidth (±5.0% with Shift) |
| LEFT | Not in view | Jump by half bandwidth left (10x bandwidth with Shift) |
| RIGHT | View mode | Pan center frequency right |
| RIGHT | Not in view | Jump by half bandwidth right (10x bandwidth with Shift) |
| UP | No modifier | Zoom in (reduce bandwidth): zoom *= 0.95 |
| DOWN | No modifier | Zoom out (increase bandwidth): zoom *= 1.05 |
| UP | Shift held | Increase visual gain: scaleMove = 1.0 |
| DOWN | Shift held | Decrease visual gain: scaleMove = -1.0 |
Keyboard controls are momentary: on key-up, scaleMove resets to 0 and zoom resets to 1.0 (with UP/DOWN key-up re-arming mouseZoom to 0.95/1.05). freqMove is not hard-reset on key-up; it stops by velocity decay — freqMove -= freqMove * 0.2 per frame, snapping to 0 at 0.01.
SpectrumCanvas Interactions
| Input | Action |
|---|---|
| Left-drag | Horizontal pan: moveCenterFrequency(deltaX * bandwidth) |
| Right-drag | Visual gain: updateScaleFactorFromYMove(deltaMouseY), clamped to [0.25, 10.0] (requires scaleFactorEnabled) |
| Right-click (no vertical drag) | Animate scale factor back to 1.0 and reset the peak-hold accumulator on spectrum/demod processors (only triggers when originDeltaMouseY == 0; the on/off peak-hold toggle is done elsewhere) |
| Mouse wheel | Forwarded to WaterfallCanvas::OnMouseWheelMoved() for coordinated zoom |
| 'B' key | Toggle dB display mode |
The spectrum canvas tracks cumulative bandwidth change (bwChange) and resets the scale factor when bandwidth changes exceed 400,000 Hz to prevent the display from becoming unreadable.
ScopeCanvas Interactions
| Input | Action |
|---|---|
| Left-drag | Panel slide with inertia: dragAccel = 4.0 * deltaMouseX, momentum-based snap |
| Mouse wheel | No action (handler exists but is not registered in the event table) |
Panel position animates with spring-like behavior: ctr += (ctrTarget - ctr) * 0.2. Panels use 3D perspective projection with lookat(0, 0, -1.205, ...) and rotation via atan2(pos, 1.2) for a card-flip effect. The two panels (scope + spectrum) are separated by panelSpacing = 0.4f (total interval = panelWidth * 2.0 + panelSpacing).
Key Constants
| Constant | Value | Purpose |
|---|---|---|
DEFAULT_WATERFALL_LPS |
30 | Default waterfall lines per second |
MIN_BANDWIDTH |
500 | Minimum demodulator bandwidth |
CHANNELIZER_RATE_MAX |
500000 | Maximum channelizer sample rate |