Documentation

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's mean_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:

  1. 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_frac x 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 on hf.zone_warp) so the builder's bottom-type map, the rock placement and the ripple-overlay outlines all agree pixel for pixel.
  2. 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) x texture_feather_scale. The reflectivity blend is delivered as hf.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).