Seafloor realism
Why synthetic seafloors look synthetic, and the rules that fix it.
Living record of realism defects found in rendered scenes, their root causes, and the rules that keep them from coming back. Add to it whenever an image shows something a real seafloor would never do.
Rule of thumb for every relief or texture operator: nothing in a scene may have a rectangular footprint unless the thing being modelled is rectangular. Windows, bounding boxes, zone cells and stamp extents are implementation details; the seafloor must not know they exist.
1. Square seam around pockmarks (and scour) on sandy bottoms
Symptom. Every pockmark (and every scoured object) sits inside a faint smooth square. On mud it is nearly invisible; on sand the square is obvious in the heightmap and the SAS image.
Root cause (verified 2026-09-02, scripts/repro_pockmark_seam.py).
Scene.build() computes protect = heightfield.elevation != 0.0 and
zeroes the additive FFT roughness on those cells. The intent was to keep
object tops and ripple crests smooth. But every windowed relief operator
(pockmark stamp, scour ROI) writes its full window, including Gaussian
tails of 1e-7 m and smaller, so every cell in the window becomes
"nonzero" and loses its roughness. On a 20 m sand test with 3 m pits,
4650 window cells with under 0.1 mm of pit relief lost the full 1.9 cm
roughness field. The pit profile itself is smooth; the square is the
missing roughness, which is why feathering the pit (feather_m) never
fixed it.
Fix (done 2026-09-02, commit dbd5465). Heightfield.protect_mask is an
explicit boolean grid that only object placers set (mark_protected in
box / cylinder / mine primitives / superellipsoid rock / mesh silhouette /
cable / procedural rocks) plus the ripple overlays (crests stay smooth as
before). Scene.build() passes that mask to the builder. Relief operators
(pockmarks, trawl scars, scour, fractal background) never touch it, so
roughness composites over them everywhere and pits carry the same
micro-roughness as the surrounding bed. Guard test:
simulator/tests/test_protect_mask.py asserts the pockmark field changes
the elevation by exactly the pit delta and nothing else.
Rule. Never use a value-based sentinel (!= 0, > eps) on the
elevation grid to infer what produced a cell. Track provenance with an
explicit mask written at stamp time.
2. Rectangular seafloor patches at zone boundaries
Symptom. Zone-grid patchwork backgrounds (rock outcrops in sand, mud/sand mosaics) show perfect rectangles in the SAS image: hard brightness steps and rock fields that stop dead on a straight line.
Root cause (read from code 2026-09-02). SeafloorBuilder.build()
feathers only the roughness RMS map across zone boundaries (Gaussian,
sigma = 15 % of the zone cell). Two other per-zone quantities are still
hard-edged:
- Reflectivity.
Heightfield.generate_scatterers()looks up the zone index per scatterer cell (bottom_type_grid) and draws the Rayleigh amplitude from that zone'smean_reflectivity. The mean jumps at the cell boundary, so the backscatter level steps. - Rocks. Auto rocks are placed per zone cell with
place_procedural_rocks(x_min..x_max, y_min..y_max), a hard rectangle.
Fix (done 2026-09-02, simulator/zone_blend.py). Two operations,
both deterministic in bottom.seed, shared by every zone-grid consumer:
- Boundary warp. The zone lookup coordinate is displaced by a smooth
random field (Gaussian-filtered noise, correlation length 0.5 x zone
pitch, amplitude
zone_warp_fracx zone pitch, default 0.25) before the floor to a zone index. Rectangles become irregular blobs. The warp is computed once per heightfield (zone_warp_for_hf, cached onhf.zone_warp) so the builder's bottom-type map, the rock placement and the ripple-overlay outlines all agree pixel for pixel. - Physical feather. RMS map, reflectivity mean map and the
rock-density indicator are each Gaussian-feathered with sigma =
zone_feather_m(default 1.5 m) xtexture_feather_scale. The reflectivity blend is delivered ashf.reflectivity_gain_grid(= feathered mean / zone mean), applied after the Rayleigh draw so RNG order is untouched; single-zone scenes get no gain grid and stay bit-identical. Rocks use a float keep-probability mask (place_procedural_rocks(mask=<float32>)), so density fades instead of stopping on a line.
texture_feather_scale: 0 still gives hard, unwarped rectangles for the
per-background label use case. The rock-override zones: rectangles are
NOT warped yet (explicit user rects). Guard tests:
simulator/tests/test_zone_blend.py.
Rule. Every per-zone property that reaches the image (RMS, reflectivity, rock density, ripple parameters, spectral exponent) must be rasterised to a per-pixel map and feathered with a physical, metre-scale kernel before use. A zone grid is a label map, not a texture.
3. Ripple-field feather (history + the current rule)
Then (2026-05-08, outward blur). The rippled mask was Gaussian-blurred OUTWARD and the rule was "sigma >= 2x the ripple wavelength": with a sub-wavelength blur of a hard 0/1 mask the amplitude jumped within one crest and read as a visible step at mud/sand boundaries.
Now (2026-09-04, inward ramp; superseded that rule). The feather is
INWARD: amplitude is exactly 0 at the painted boundary and rises as
1 - exp(-d^2 / 2 sigma^2) with d the distance inside it, so a step is
impossible whatever sigma is: the ramp always starts from zero. Sigma is
2x the wavelength, capped per ripple component at a third of its inradius
(floor 1 px) so thin features (lettering, narrow strips) still carry
ripples instead of fading to nothing. For an ordinary one-cell region the
cap bites (13 m cell, 2.7 m wavelength: sigma = 2 m, not 5.4 m): that is
intended, and it is NOT the old sub-wavelength step: do not "fix" it back
to 2 lambda. The 2-lambda minimum applies only to an outward blur of a hard
mask, which no longer exists anywhere in the pipeline.
Two consequences worth knowing when reading an image: the ripple footprint
is exactly the painted mask (no bleed into neighbours), and the ripple
ramp is wider than the 1.5 m material feather (zone_feather_m) that
blends roughness and reflectivity, so inside a wide ripple region the
texture switches over ~1.5 m while the crests fade in over a few metres.
Audit 2026-09-05 (two defects in the first inward version). The EDT was
run on the bbox CROP of the zone mask; scipy treats pixels outside the
array as foreground, so any painted boundary lying on the crop border got
no feather at all (a hard edge on every 1x1 ripple grid and on every cell
edge with zone_warp_frac: 0), and an all-True crop got a spurious ramp
anchored at the crop origin. The feather now runs on a window padded past
the bbox by the warp's reach (simulator.zone_blend.inward_feather), so
every painted boundary inside the scene is a background edge; a region
touching the scene edge is deliberately NOT feathered there (ripples
continue past the scene). Second: the protect mask covered every painted
pixel, so the low-amplitude inner band of the ramp lost the zone's FFT
roughness too: a smooth moat inside every ripple boundary, the seam-1
value-sentinel mistake again. Only pixels with feather weight >= 0.5 are
protected now; the outer band composites roughness over faint ripples.
Guard tests: simulator/tests/test_ripple_feather_audit.py.
Crossfade, not a gate (2026-09-08). The audit above still left a
line: the protect mask was a bool, so the builder's FFT roughness (~20 mm
rms on sand) was present on one side of the feather's 0.5 contour and
absent on the other, a random 27 mm cell-to-cell step along the whole
contour (measured on Isaac's HISAS scene: 2-3 mm between sand cells,
5-10 mm between ripple cells, 27 mm across the contour). The heightfield
now carries protect_weight (float, 1 on object footprints, the feather
under ripple overlays) and SeafloorBuilder.build scales the roughness by
1 - weight, so roughness fades out exactly as the ripples fade in; the
bool protect_mask remains for footprints. Rule: nothing that adds a random
field may switch it on a contour; blend it. Guard:
simulator/tests/test_protect_crossfade.py.
4. Seam audit (2026-09-08): what blends at a zone boundary, and how
Per pair of bottom types four things change; each has its own blend and
simulator/tests/test_zone_seams.py scores all 15 pairs of the built-in
and ripple types on a built scene:
| quantity | mechanism | blend |
|---|---|---|
| elevation: fine roughness amplitude | SeafloorBuilder rms map, one global field |
Gaussian feather, zone_feather_m (1.5 m) |
| elevation: ripple relief | per-type Tang overlay | inward ramp from 0 at the painted edge, sigma 2 lambda capped at inradius/3 |
| elevation: roughness under ripples | protect_weight |
crossfade 1 - feather (section 3) |
| scatterer reflectivity mean | per-zone Rayleigh draw | feathered gain map blended / zone mean |
scatterer height jitter (roughness_std, speckle coherence) |
per-zone normal draw | feathered gain map blended / zone std (added in this audit; it was a hard switch) |
| rock density (zone default rocks) | place_procedural_rocks |
soft keep-probability mask, same feather |
| scatterer density | uniform draw over the scene | nothing to blend |
| zone boundary shape | shared warp field | no straight edges; all of the above use the same warped lookup |
Not blended, by design: object footprints (hard protect_mask, real
edges), rock stamps, and the user-given bbox of a pockmark / trawl-scar /
rock group (a 0.5 m edge feather on the feature density; the extent is
what the user asked for). Known soft spot: two ripple types meeting
each fade to zero at their own painted edge, so the junction is a plain
band about two feather widths wide rather than a crossfade of the two
patterns.
The same inward ramp (with the same inradius cap) is what
fractal_background.exclude_types / only_types uses, so an excluded
feature is exactly flat and the undulation ramps up inside the kept
region (test_fractal_mask.py::test_excluded_thin_feature_stays_flat).
bbox rock groups are placed exactly (2026-09-04)
_consolidate_regions used to convert every bbox-placed bottom.rocks entry
into the zone cells it overlapped and drop the bbox. A 16 x 13 m patch became
whole 13 m cells (a rectangle with cell-edge seams), and on a 1x1 or empty
zone grid it became the entire scene. bbox entries now keep their bbox and are
stamped exactly; only zone-based entries are merged. Scenes that relied on the
snapping change (they now get what their bbox says).