Files
updated-radar/DESIGN.md

607 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Radar Simulation — Class Design and File Layout
Author: Mark Allyn
---
## Class Hierarchy
```
Scope (abstract base)
├── ExhibitIntro
├── AScope (abstract)
│ ├── MarineAScope
│ └── ChainHomeAScope
├── PPIScope (abstract)
│ ├── MarinePPIScope
│ └── ATCPPIScope
└── PARScope
```
---
## Class Descriptions
### Scope (abstract base)
Everything all scopes share:
- Left panel text rendering
- `s` / `S` key handling (scope advance / reverse)
- Auto-advance timer reset on any key or control input
- Pure virtual methods: `render()`, `handleKey()`, `getDescription()`
### ExhibitIntro : public Scope
- Text-only rendering, no radar display
- Header: "WELCOME TO MUSEUM VINTAGE RADAR EXHIBIT" (all caps)
- Emphasizes s/S keys and 120-second auto-advance
### AScope : public Scope (abstract)
Shared A-scope behavior:
- Horizontal range axis, vertical amplitude axis
- Noise floor rendering (rain/wave clutter)
- Incandescent graticule (three horizontal amplitude lines + vertical range lines)
- Bearing control with key-hold acceleration
- Phosphor type as parameter (P1 or P7)
### MarineAScope : public AScope
- P1 phosphor (green)
- Range settings: 2, 4, 6 miles
- Max 2: one interim range at 1
- Max 4: one interim range at 2
- Max 6: one interim range at 4
- Keys: c (bearing CW), v (bearing CCW), u (range up), d (range down)
- u and d are ignored during graticule swap animation
**Graticule swap animation:**
In the period, changing max range required the operator to physically slide the glass
graticule panel upward and out from in front of the CRT, then slide the replacement
graticule (calibrated for the new range) downward into position. This is simulated.
State machine triggered by u or d key:
NORMAL graticule in place, scope operating normally
|
| u or d pressed
v
SLIDING_OUT old graticule translates upward off screen (~0.5 seconds)
|
v
BARE_CRT no graticule rendered; CRT trace and noise floor still running
|
v
SLIDING_IN new graticule slides down into position (~0.5 seconds)
|
v
NORMAL new range now active
- u and d keys ignored while state != NORMAL
- Graticule remains incandescent color throughout (edge-lit glass, not CRT-dependent)
### ChainHomeAScope : public AScope
- P7 phosphor (early implementation, slow decay for slow PRF)
- Goniometer state: H/V mode toggle, azimuth angle, elevation angle
- PRF toggle: 25 Hz / 12.5 Hz
- Calibrator stretch/shrink scale factor
- Fixed 100-mile range (no range change)
- Keys: [ (goniometer H/V toggle), 9 (tune left), 0 (tune right),
. (PRF toggle), n (calibrator shrink), m (calibrator stretch)
### PPIScope : public Scope (abstract)
Shared PPI behavior:
- Clockwise sweep with P7 phosphor persistence
- Immediate beam strike: blue
- Persistence: green/yellow, faded by next sweep pass
- Incandescent bearing graticule:
- Inner ring with 1-degree tick marks (0359, True North = 0)
- Text labels every 15 degrees
- Outer ring
- Yellow cursor: 10-degree arc section + bearing crossline
- Cursor range/bearing readout displayed under scope (white text)
- Bearing offset for boat mode (k = right, j = left)
- Keys: r (cursor bearing right), l (cursor bearing left),
t (cursor range increase), y (cursor range decrease),
k (antenna bearing offset right), j (antenna bearing offset left)
- Cursor range clamped to max range if exceeded
### MarinePPIScope : public PPIScope
- Sweep time: 4 seconds
- Max range settings: 2, 4, 6 miles
- Max 2: rings at 1, 2
- Max 4: rings at 2, 4
- Max 6: rings at 4, 6
- Keys: u (range up), d (range down) — affects only this scope
### ATCPPIScope : public PPIScope
- Sweep time: 5 seconds
- Max range settings: 5, 10, 15, 20 miles
- Max 5: rings at 2.5, 5
- Max 10: rings at 2, 4, 6, 8, 10
- Max 15: rings at 4, 8, 12, 15
- Max 20: rings at 5, 10, 15, 20
- Keys: u (range up), d (range down) — affects only this scope
### PARScope : public Scope
- Two vertically stacked sub-scopes (right panel):
- Top: azimuth (lateral deviation vs. range) — ~1/3 larger
- Bottom: elevation (vertical deviation vs. range)
- P7 phosphor; graticules are incandescent etched glass
- 30 Hz alternating scan between azimuth and elevation planes
(each plane scans at ~15 Hz, i.e., 1/15 second per plane)
- Fixed 10-mile range — no range change control
- Non-linear horizontal scale: inner 5 miles occupies 70% of width
- All targets simulated; no cursor or bearing controls
- Located at south end of Runway 16/34, BLI — active runway 34 (northbound)
---
## Supporting Classes
| Class | Thread | Purpose |
|---|---|---|
| `ScopeManager` | 1 | Owns scope list; handles s/S switching and 120s auto-advance timer |
| `PhosphorRenderer` | 1 | P1 and P7 decay/persistence simulation; shared dependency |
| `Graticule` | 1 | Draws incandescent graticule lines and text; parameterized per scope |
| `LeftPanel` | 1 | Renders scope description text panel (left side of window) |
| `SharedRenderState` | 1,2,3 | Mutex A — state variables Thread 1 reads each frame to push as shader uniforms |
| `TargetBuffer` | 2,4 | Mutex B — target data handoff between Thread 2 (traffic cop) and Thread 4 (simulator) |
| `TrafficCop` | 2 | Polls Simulator and RPi receivers each beam update; writes targets to SharedRenderState |
| `Simulator` | 4 | Runs fake targets independently; returns data to TrafficCop when polled |
| `KnobPanel` | 3 | Future hardware stub — idles without acquiring Mutex A until Arduino hardware is wired; SharedRenderState holds compile-time defaults from settings.h |
| `RPiReceiver` | 2 | Stub — one instance per Raspberry Pi; called by TrafficCop |
---
## Thread Summary
| Thread | Class(es) | Mutex Access |
|---|---|---|
| Thread 1 | ScopeManager, all Scope subclasses, PhosphorRenderer, Graticule, LeftPanel | Reads SharedRenderState under Mutex A |
| Thread 2 | TrafficCop, RPiReceiver | Writes SharedRenderState under Mutex A; reads TargetBuffer under Mutex B |
| Thread 3 | KnobPanel | Writes SharedRenderState under Mutex A |
| Thread 4 | Simulator | Writes TargetBuffer under Mutex B |
Keyboard input arrives via GLFW callback (glfwSetKeyCallback) in Thread 1.
Thread 1 dispatches s/S to ScopeManager and all other keys to the active Scope.
---
## Proposed File Layout
```
src/
main.cpp — GLFW init, thread launch, main loop
scope_manager.h / scope_manager.cpp
scope.h / scope.cpp — abstract Scope base class
scope_intro.h / scope_intro.cpp — ExhibitIntro
scope_ascope.h / scope_ascope.cpp — abstract AScope
scope_marine_a.h / scope_marine_a.cpp
scope_chain_home.h / scope_chain_home.cpp
scope_ppi.h / scope_ppi.cpp — abstract PPIScope
scope_marine_ppi.h / scope_marine_ppi.cpp
scope_atc_ppi.h / scope_atc_ppi.cpp
scope_par.h / scope_par.cpp
phosphor.h / phosphor.cpp
graticule.h / graticule.cpp
left_panel.h / left_panel.cpp
shared_render_state.h / shared_render_state.cpp
target_buffer.h / target_buffer.cpp
traffic_cop.h / traffic_cop.cpp
simulator.h / simulator.cpp
knob_panel.h / knob_panel.cpp
rpi_receiver.h / rpi_receiver.cpp
db_panel.h / db_panel.cpp — Dear ImGui database management panel
(active only when --database flag passed;
no radar rendering in this mode)
settings.h — all tunable constants (no .cpp needed)
imgui/ — Dear ImGui source, compiled into project
imgui.h / imgui.cpp
imgui_impl_glfw.h / imgui_impl_glfw.cpp
imgui_impl_opengl3.h / imgui_impl_opengl3.cpp
imgui_draw.cpp / imgui_tables.cpp / imgui_widgets.cpp
shaders/
phosphor.vert / phosphor.frag — parameterized for P1 and P7 via uniforms
graticule.vert / graticule.frag
text.vert / text.frag
sweep.vert / sweep.frag
bloom.vert / bloom.frag — two-pass bloom: render to FBO, Gaussian
blur bright spots, blend back; used for
target blooming from radar equation output
```
---
## Robustness and Safety
### Input Rate Limiting (the mad-child problem)
GLFW key callbacks can fire hundreds of times per second under key-mashing or hardware
encoder noise. Each control variable carries a `last_event_time` timestamp alongside
its value. Any input arriving within `MIN_INPUT_INTERVAL_MS` of the previous accepted
input for that control is silently discarded. The `KEY_MAX_STEP` / `KEY_GONIO_MAX_STEP`
constants cap how far a single accepted input can move a value. Together these make it
impossible to slam a bearing to an extreme in under a second regardless of how fast the
input fires.
### Control Variable Clamping
Every state variable that a control modifies has companion `MIN_*` and `MAX_*` constants
in `settings.h`. The clamp is applied **at the point of write** using `std::clamp()`, in
every code path: keyboard callback, KnobPanel, and any future source. Thread 1 reads
already-clean values and applies a second `std::clamp()` before passing to the shader as
a second line of defense. No validation logic is needed at the shader boundary.
### Incoming Data Validation
`RPiReceiver::parseFrame()` and `Simulator::poll()` both return `std::optional<TargetData>`.
A return of `std::nullopt` means the frame was malformed or out of bounds. `TrafficCop`
discards nullopt silently — no exception, no abort, no log spam. Fields validated:
bearing (0360), range (0radar max), amplitude (01), altitude (060,000 ft), timestamp
(not stale, not future), target count (truncated to `MAX_SIMULTANEOUS_TARGETS`), and
frame byte length (rejected above `MAX_RPI_FRAME_BYTES`).
### Array Bounds Safety
- All fixed-size target storage uses `std::array<TargetData, MAX_TARGETS>` — never a raw C array.
- Array views passed between functions use `std::span<TargetData>` (C++20) — size always travels with the pointer.
- Ring buffers for phosphor persistence use modulo indexing (`index % CAPACITY`), never raw pointer arithmetic.
- `static_assert` guards validate that `MAX_TARGETS` and similar constants are within sane limits at compile time.
- Bounds-checked access (`.at()`) used in debug builds; validated index used in release.
### Thread Safety Rules
1. **Snapshot pattern**: Thread 1 acquires Mutex A, copies the entire `SharedRenderState` struct, releases the lock immediately, then renders from the local copy. No OpenGL calls are ever made while holding a mutex (Core Guideline CP.22).
2. **Always `std::scoped_lock`**: Mutexes are never locked/unlocked manually. `std::scoped_lock` releases on all exit paths including exceptions (SEI CERT CON51).
3. **Lock ordering**: If code ever needs both Mutex A and Mutex B simultaneously, A must be acquired first. Documented here as a project-wide invariant to prevent deadlock (SEI CERT CON53).
4. **Atomic simple flags**: Boolean toggles that are written by one thread and read by another (`prf_high`, `goniometer_mode`) use `std::atomic<bool>` — no mutex overhead for single-variable reads.
5. **KnobPanel idle guarantee**: Until hardware is connected, KnobPanel's thread loop sleeps and never calls `lock()` on Mutex A. Zero contention on that mutex from Thread 3.
### RAII Requirements
Every resource that is opened must be wrapped in an RAII holder so that it closes on any exit path:
| Resource | RAII mechanism |
|---|---|
| OpenGL VAO / VBO / texture / shader | Thin `GLHandle<T>` wrapper; destructor calls `glDelete*()` |
| GLFW window | `unique_ptr<GLFWwindow, decltype(&glfwDestroyWindow)>` |
| FreeType `FT_Library` / `FT_Face` | Small RAII structs; destructor calls `FT_Done_*` |
| Mutex locks | `std::scoped_lock` or `std::lock_guard` — never bare lock/unlock |
| Worker threads | `std::jthread` (C++20) — auto-joins on destruction; no detached threads |
| File handles (shader source) | `std::ifstream` — RAII by default |
No raw `new` or `delete` anywhere in the codebase. No `malloc`/`free`.
### SEI CERT C++ Compliance — Key Rules
| Rule | Enforcement |
|---|---|
| CON50 | Never destroy a mutex while locked — `std::scoped_lock` makes this structurally impossible |
| CON51 | Release locks on exceptions — `std::scoped_lock` handles automatically |
| CON53 | Consistent lock ordering (A before B) — documented invariant |
| ARR50 | Array indices always validated before use or bounded by `std::array::at()` |
| MEM51 | All resources managed by RAII wrappers; no raw `new`/`delete` |
| ERR50 | No `abort()` or `exit()` in normal operation; bad data is dropped, not fatal |
| INT30 | Bearing arithmetic uses `fmod()`, not unsigned wraparound subtraction |
| FLP30 | No floating-point loop counters; sweep angle is a double accumulator |
| OOP50 | No virtual method calls in constructors or destructors |
### C++ Core Guidelines Compliance — Key Rules
| Guideline | Enforcement |
|---|---|
| I.6 / I.7 | `Expects()` / `Ensures()` (GSL) at function entry/exit in debug builds |
| I.12 | `gsl::not_null<Scope*>` for the active scope pointer in ScopeManager |
| R.1 | All resource management through RAII handles (see table above) |
| CP.20 | `std::scoped_lock` for all mutex acquisition — no bare lock/unlock |
| CP.21 | `std::scoped_lock(mutexA, mutexB)` if both must be held simultaneously |
| CP.22 | No OpenGL calls while holding any mutex — snapshot pattern enforces this |
| Bounds.1 | No pointer arithmetic; `std::span` for array views |
| Bounds.2 | Array access only via `.at()` or pre-validated index |
| Bounds.4 | No `sprintf`, `strcpy`, `gets`; use `std::string` and `std::format` (C++20) |
---
## Key Design Notes
1. **ScopeManager** sits in Thread 1 and holds the active scope pointer. The GLFW
key callback calls ScopeManager::handleKey(), which dispatches s/S to itself
and all other keys to the active scope. The auto-advance timer resets on any
key event or control input.
2. **SharedRenderState** holds two categories: control state (bearing, range,
cursor — written by Thread 1 keyboard callbacks and Thread 3 knob panel) and
target state (written by Thread 2). Thread 1 reads the whole struct once per
frame under Mutex A to push uniforms to the shaders.
3. **TargetBuffer** is separate from SharedRenderState — it is the handoff point
between Thread 2 (traffic cop) and Thread 4 (simulator) under Mutex B.
4. **PhosphorRenderer** is a shared utility. AScope and PPIScope subclasses
receive it as a constructor dependency rather than each reimplementing decay
logic. Pass phosphor type (P1/P7) and decay constant as parameters.
5. **Shaders** are parameterized via uniforms rather than duplicated. A single
phosphor shader pair handles both P1 (green, no persistence) and P7 (blue
strike, yellow-green decay) by passing color and decay time as uniforms.
6. **Shoreline and terrain geometry** is loaded once at Thread 1 startup as a
static VBO. It is read-only after load — no mutex needed.
7. **Each scope's max range and cursor state are independent.** Changing range
on the Marine PPI does not affect the ATC PPI, and vice versa. State is
owned by each scope instance.
8. **PAR sub-scopes** (azimuth and elevation) can be implemented as private
member objects within PARScope rather than as separate Scope subclasses,
since they are never displayed independently.
9. **settings.h** is a header-only file (no .cpp). It will contain only
`constexpr` constants organized into named sections. Every other source
file that needs a tunable value includes this file. No magic numbers
anywhere else in the codebase. Add candidate variables here before
coding begins; values can be refined during debugging and appearance work.
Sections defined: phosphor colors (P1/P7), sweep parameters, graticule
geometry and colors, cursor, noise floor, graticule swap animation timing,
key-hold acceleration, auto-advance timer, window/layout geometry, text
colors/sizes, **general operator control defaults** (Intensity, Focus,
Astigmatism, Gain, Rain Clutter, Wave Clutter, Graticule Intensity),
**per-scope default state** (initial bearing, range, cursor, calibrator
scale for each scope), and **PAR geometry** (non-linear scale breakpoint,
runway heading, glide path angle, scan widths).
10. **General operator controls** (Intensity, Focus, Astigmatism, Gain, Rain
Clutter, Wave Clutter, Graticule Intensity) are placeholders for physical
encoders not yet purchased. Each has a `DEFAULT_*` constant in settings.h
and a corresponding variable in `SharedRenderState`. KnobPanel (Thread 3)
has a stub write path for each. The thread starts and idles, but never
calls `lock()` on Mutex A, so it imposes zero contention. When hardware
arrives, only KnobPanel changes — Thread 1 and the shaders are already
fully wired to consume the values.
11. **MarineAScope graticule swap** is a state machine with four states:
NORMAL → SLIDING_OUT → BARE_CRT → SLIDING_IN → NORMAL. The u and d keys
are blocked during the animation. The new range value is latched when the
key is pressed but not applied to the scope state until SLIDING_IN completes.
Animation duration is approximately 0.5 seconds per slide (out and in).
12. **All dimensions are stored and computed in meters** throughout the system.
Any incoming data from Raspberry Pi receivers or the simulator that arrives in
feet (e.g., altitude from ADS-B in feet, antenna heights) is converted to
meters at the boundary in `RPiReceiver::parseFrame()` or `Simulator::poll()`
before the value enters any shared data structure. No feet values appear
anywhere inside the system after the conversion point.
Conversion: 1 foot = 0.3048 meters exactly.
13. **Default target dimensions** used when a target is first seen with no
database record. All values in meters. `need_update` is set TRUE for all
defaults so the operator knows to fill in real data.
| Category | Length (m) | Fuselage/Beam width (m) | Material |
|---|---|---|---|
| GA aircraft | 4.0 | 1.0 | aluminum |
| Commercial a/c| 30.0 | 5.0 | aluminum |
| AIS vessel | 20.0 | 5.0 | steel |
| Simulator boat| 6.0 | 2.0 | fiberglass |
AIS-sourced vessels default to steel (legally required commercial traffic).
Simulator-sourced boats default to fiberglass (small pleasure craft).
Aircraft source type does not disambiguate GA vs. commercial — the system
defaults to GA and lets the operator correct it.
14. **Bloom post-processing** uses a dedicated `bloom.vert` / `bloom.frag` shader
pair. The pipeline is: render targets to an offscreen FBO at full computed
brightness (from radar equation output), apply a two-pass Gaussian blur to
pixels above a luminance threshold, then additively blend the blurred result
back onto the main framebuffer. Bloom threshold and blur radius are
`constexpr` constants in `settings.h`.
15. **Dear ImGui** is used for the database management panel, activated by the
`--database` command-line flag. In that mode no radar rendering occurs —
main.cpp skips the scope/shader initialization path entirely and starts the
ImGui loop instead. ImGui source files live under `src/imgui/` and are
compiled directly into the project (no separate install step). The panel
provides: a scrollable target table with `need_update` highlighted, inline
edit fields for length/width/height/material, a dropdown for target type,
and a Save button that writes to PostgreSQL via libpq.
16. **Chain Home RCS resonance** is modelled with a multiplier constant
`CHAIN_HOME_RCS_RESONANCE_FACTOR` in `settings.h`. At 30 MHz (λ ≈ 10 m),
aircraft with wingspans of 1030 m are in the Mie/resonant scattering
region; RCS can be 25× the geometric cross section. The default value is
3.0 (a mid-range estimate). This is applied in the radar equation computation
for Chain Home targets only, before the result is passed to the bloom/
brightness pipeline.
17. **Terrain and land clutter** are rendered from pre-processed binary grids
in `map/lidar_processed/`. The offline tool `terrain_preprocess` fuses the
SRTM DEM, both LiDAR surveys, and the S-57 ENC into elevation, material,
and shadow-mask grids. At runtime `TerrainMap` loads these grids once;
`LandClutter` generates a polar clutter texture once per sweep period.
See the TERRAIN CLUTTER VISUAL DESIGN REFERENCE section below for
per-material appearance and speckle tuning guidance.
---
## Terrain Clutter Visual Design Reference
All tunable values described here have corresponding constants in
`src/settings.h`. Edit `settings.h` to change values; this section
explains the perceptual intent behind each constant.
### Purpose
The goal is historical authenticity. Period marine radar operators in the
1950s saw the Bellingham shoreline as a bright, stable ring of returns that
formed a recognizable coastline silhouette. Skilled operators mentally
subtracted this from the display and watched only for moving or changing
returns. That experience should be reproducible on this exhibit.
---
### Material Visual Character
**SOIL** (vegetated land, fields, low hills)
Period appearance: moderate-brightness, moderately grainy returns. The
grain (speckle) is high because vegetation is irregular — individual trees,
bushes, and undulations produce slightly different returns each sweep.
The overall brightness holds steady between sweeps but the texture shimmers.
- σ° constant: `TERRAIN_SIGMA0_SOIL` (~0.010, 20 dB)
- Speckle: `TERRAIN_SPECKLE_SOIL` (suggested starting value: **0.35**)
- Appearance: mid-grey; clearly visible but not dominating. Rolling
hillsides read as a diffuse bright edge along the coastline
and inland ridges.
---
**ROCK** (exposed cliff faces, upper Chuckanut ridgeline, rock outcrops)
Period appearance: brighter than soil, less speckle. Rock faces are
geometrically consistent so returns are stable sweep to sweep. Steep faces
pointing toward the radar return a disproportionately strong echo.
- σ° constant: `TERRAIN_SIGMA0_ROCK` (~0.032, 15 dB)
- Speckle: `TERRAIN_SPECKLE_ROCK` (suggested starting value: **0.20**)
- Appearance: noticeably brighter than soil; Chuckanut Mountain's western
face reads as a bright arc, stable between sweeps. Little
shimmer — the texture is coarser and more consistent.
---
**CONCRETE** (breakwaters, piers, dock structures, Boulevard Park boardwalk,
harbor facilities)
Period appearance: the strongest land returns on the scope after large steel
vessels. Structures built over water (piers on pilings, breakwater walls)
produce corner-reflector effects — the right-angle junction between the
vertical face and the water surface acts as a retroreflector. Operators used
these as navigation aids; the Bellingham harbor entrance is identifiable by
its distinctive bright return pattern.
- σ° constant: `TERRAIN_SIGMA0_CONCRETE` (~0.100, 10 dB)
- Speckle: `TERRAIN_SPECKLE_CONCRETE` (suggested starting value: **0.12**)
- Appearance: bright, stable, low shimmer. Breakwaters and piers appear as
sharp bright lines or arcs. The Boulevard Park boardwalk over
the water appears as a bright thin arc. These features should
be the most prominent land returns on the scope after large
steel ships.
---
**WATER — CALM** (open bay, light wind)
Period appearance: very weak, near-invisible. Calm water reflects most radar
energy away from the antenna (specular reflection). Operators saw a nearly
blank area over open water even at high gain.
- σ° constant: `TERRAIN_SIGMA0_WATER_CALM` (~0.0003, 35 dB)
- Appearance: at normal gain, essentially black. Only visible at extreme
gain settings as a faint salt-and-pepper noise floor.
---
**WATER — ROUGH** (choppy bay, wind >10 knots)
Period appearance: a low-level fuzzy return rising from the noise floor,
affecting the inner ranges most strongly. Sea clutter was a major nuisance
on small-vessel marine radar. The wave clutter filter (keys 5/6) suppresses
this.
- σ° constant: `TERRAIN_SIGMA0_WATER_ROUGH` (~0.010, 20 dB)
- Appearance: at normal gain, a hazy shimmer at short ranges that fades
outward. Heavy speckle — random wave facets scatter
incoherently. The wave clutter filter reduces this.
---
### Speckle / Grain Tuning Guide
Speckle simulates pulse-to-pulse amplitude variation caused by incoherent
scattering from irregular surfaces. Implemented as a per-cell random
fraction multiplied by the computed P_r each sweep:
```
Speckle = P_r × (1.0 + TERRAIN_SPECKLE_xxx × random(1, +1))
```
Values close to 0.0 give stable, solid returns (correct for concrete and
large flat surfaces). Values closer to 0.5 give vigorous shimmer (correct
for vegetation and choppy water). Values above 0.5 are unrealistically noisy
for terrain — avoid unless simulating a very rough or complex surface.
Suggested starting values:
| Constant | Value | Character |
|-----------------------------|-------|-----------------------------------------|
| `TERRAIN_SPECKLE_SOIL` | 0.35 | visible shimmer, naturalistic |
| `TERRAIN_SPECKLE_ROCK` | 0.20 | moderate, stable with some texture |
| `TERRAIN_SPECKLE_CONCRETE` | 0.12 | mostly stable; slight flicker from edge |
**Tuning procedure:**
1. Set max range to 6 miles on the Marine PPI.
2. Rotate to a bearing showing the Bellingham breakwater and open bay.
3. Adjust `TERRAIN_MARINE_CLUTTER_BRIGHTNESS` until the breakwater return
is clearly bright but does not wash out nearby ship targets.
4. Adjust `TERRAIN_SIGMA0_CONCRETE` if breakwater brightness relative to
soil hills looks wrong.
5. Adjust speckle values until soil hillsides shimmer naturally and concrete
structures hold steady between sweeps.
6. Verify at all three marine range settings (2, 4, 6 miles) that clutter
does not overwhelm vessel targets at any range.
---
### Overall Brightness Balance
The most important perceptual tuning parameter is
`TERRAIN_MARINE_CLUTTER_BRIGHTNESS`. It is a linear scale factor applied to
all terrain return brightness before the clutter texture is uploaded to the
GPU. It does not change the relative balance between materials — it scales
all terrain uniformly.
**Target appearance goal:**
- A steel AIS vessel at 3 miles range should be 23× brighter than the
strongest adjacent land clutter return (a concrete breakwater).
- The coastline silhouette should be clearly readable as geography — a
visitor who knows Bellingham Bay should recognize the shape.
- Open water should be visually clean at default gain settings.
Suggested starting value: `TERRAIN_MARINE_CLUTTER_BRIGHTNESS = 0.55`
**ATC PPI:** `ATC_TERRAIN_CLUTTER_SUPPRESSED = true` means land clutter is
hidden by MTI cancellation — no brightness tuning needed for ATC. If
suppression is disabled for debugging, use `TERRAIN_MARINE_CLUTTER_BRIGHTNESS`
as a guide.
---
### Shadow Zones
Shadow zones are the most dramatic visual effect on the Marine PPI. They
appear as dark wedge-shaped gaps in the land clutter pattern, pointing
outward from the radar, behind ridgelines and hills.
Key shadow features visible from the marine bay platform:
- **Chuckanut Mountain** (southwest) casts a prominent shadow into the
southern bay and beyond.
- **Lummi Island** (northwest) shadows a sector of the northern bay.
- **Bellingham waterfront bluff** shadows part of the inner harbor.
These shadows are inherently dark against surrounding clutter — no tuning
required. Shadow = zero amplitude, computed geometrically by the preprocessor.
For the ATC scope with `ATC_TERRAIN_SHADOW_ENABLED = true`, shadows affect
aircraft returns only (not the display background, which is suppressed). An
aircraft descending behind Chuckanut Mountain will fade out and reappear as
it clears the ridge on final approach — authentic behavior of period ASR radar.
---
### Polar Clutter Texture Resolution
`TERRAIN_POLAR_BEARING_BINS` and `TERRAIN_POLAR_RANGE_BINS` control the
resolution of the GPU texture carrying terrain clutter to the shader.
Recommended values:
| Scope | Bearing bins | Range bins | Notes |
|---------------------|-------------|------------|------------------------------|
| Marine PPI (6 mi) | 720 | 512 | 0.5° matches marine beamwidth|
| ATC PPI (20 mi) | 720 | 1024 | wider range needs more bins |
The texture is regenerated once per sweep period (every 45 seconds), not
every frame. Upload cost is not time-critical. Reducing to 360 bearing bins
is acceptable and halves the texture upload cost at the expense of slightly
blocky angular transitions near prominent features.