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 (rms types) and one sqrt(b) map per spectral exponent (absolute types), all shaped from ONE noise realisation (section 6) 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

Exception: terrain: true meshes are not protected (2026-09-16). A mesh flagged as terrain is the seafloor, not an object resting on it, so it stamps elevation and then lets the bed's roughness composite on top, the same way the procedural fractal background does. Protecting it would leave a smooth patch the size of the whole tile, which is the very defect this document exists to prevent. It also draws no surface scatterers; the bed already populates that footprint (see scenes-framework.md).

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).

5. Square pedestals under every object (2026-09-17)

Symptom. Every rock, mine, box, cylinder, primitive and mesh sat on a flat square plate the size of its stamp window, raised to exactly z = 0 and stripped of roughness; on the rocky sample_images scenes the plates chained into rafts up to 11 m across with 16-30 cm steps (5.9 % of scene si_0014 was plate). Scoured objects had their body carved by the pit while their scatterers floated above the raster that cast the shadow.

Root cause. Each placer wrote elevation[window] = max(elevation[window], h_local) with h_local == 0 outside the footprint: a value-based clamp over a rectangle, and an object base at absolute z = 0 rather than on the local bed. Scour was applied after placement over the whole ROI, so it dug through the object's own cells.

Rule. The bed is the reference. A stamp writes only its footprint, seated on the local bed through Heightfield.seat (datum = mean bed under the footprint, elevation[footprint] = max(bed, datum + h_local)), and the same datum shifts the object's PS scatterers (seat.z_ned) and FW facets (seat.verts). Never max-merge a window, never protect a window, never place anything at absolute z = 0. Scour digs first on the bare bed; the object then seats on the pre-scour bed lowered by the realised sink (run.py object loop, seat_context), so raster, scatterers and facets move rigidly together. Spec: docs/superpowers/specs/2026-09-17-object-seating-design.md; contract tests: simulator/tests/test_seating.py. PS_LEGACY_SEAT=1 restores the old behaviour for one release.

What burial_fraction means. An object's scour: block carries a burial_fraction whose meaning is selected by burial_is_height_fraction (schema ScourConfig, default false):

  • false (default, the historical behaviour). burial_fraction is the fraction of the equilibrium scour-pit depth at the object's centre that the object settles into. It does nothing without enabled: true, and nothing at all on a sediment that does not scour. Mud and clay never scour in this version: scour.scour_active short-circuits both to False, because cohesive scour is governed by the critical erosion stress, not Shields, and is a V2 feature: so on mud or clay this setting leaves the object fully proud no matter what number it holds. Note also that the pit for a mine-scale object is typically deeper than the object is tall, so on sand a fraction around 0.6 already buries the body outright.
  • true. burial_fraction is the fraction of the object's own height that ends up below the sediment line. It is applied directly to the seating datum, whether or not scour is enabled and whatever the sediment, and it is the object's only sink. The scour pit is still dug into the bed around the object: that depression is real morphology and a real acoustic feature: but it no longer also settles the body. Adding the two used burial_fraction twice: because the equilibrium pit for a mine-scale object is typically deeper than the object is tall, a Manta at burial_fraction: 0.5 on sand came out sunk 0.44 m of its 0.44 m, i.e. a target labelled half buried was fully buried (controller ruling, 2026-09-21). The height comes from the same scene_annotations.object_height_m lookup the dataset labels use, so the geometry and the label cannot disagree. burial_fraction <= 1.0 is validated, which is the only bound the sink needs: the annotator's buried_fraction can never exceed 1.0. There is deliberately no seafloor-floor clamp: the seating pass enforces no such floor, and borrowing apply_scour's -1.5 m digging floor silently cancelled burial wherever the bed was deep (an object on a bed at -1.61 m sank 0.0 m), which on a tilted swath would have correlated the loss with range. 1.0 buries the body completely: the raster keeps the bed, every scatterer falls below it and the global cull drops them.

Which bed it seats on. With the flag on the reference is the bed after this object's own scour pit has been dug (run.py passes ref_elevation=None, so _datum_for reads the live hf.elevation), and the sink goes below that. Flow accelerates around a proud object, undermines it and the object settles into the pit it creates, so the base must end up at or below the adjacent pit floor. Seating on the pre-scour bed instead left the body on an undug column inside its own moat, base 0.3-0.6 m above the pit floor. With the flag off the reference stays the pre-scour bed, because there the sink is the settling term (burial_fraction x pit depth) and using the post-scour bed as well would count the pit twice.

The realised sink in metres is what instantiate returns as object_sinks and what the annotator writes as sink_m / buried_fraction; the YAML number rides along unchanged as burial_fraction. The sample_images dataset sets the flag on every object it writes, because mud is in that dataset specifically to provide the burial regime. Tests: simulator/tests/test_object_burial.py.

6. Roughness spectrum: absolute strength vs rms height (2026-09-22)

Symptom. Slope, the quantity that modulates the image (scatterer normals, Lambert law), was not controllable. Two seeds of one 44 x 100 m scene with the same 15.4 cm rms height gave 4.0 and 6.1 deg range slope, and default sand (2 cm rms) gave 0.65 deg at a 5 cm cell, a factor five below the 3.3 to 4.9 deg Lyons, Olson & Hansen (2022, Table I) measured on three real sand sites.

Root cause. SeafloorBuilder rescaled every power-law realisation to rms_height_m, so spectral_strength did nothing. For a red spectrum the rms height is carried by the few longest modes, so fixing it fixes the wrong quantity: the realised long-mode energy varies ~40 % seed to seed and the rescale pushed that variance onto the slope, which lives in thousands of short modes and would otherwise be stable to ~1 %.

Rule. A built-in type is spectrum_mode: absolute: the surface IS W(k) = b k^-γ (Jackson & Richardson 2007 convention, Lyons 2022 Eq. 9), never rescaled. Its rms height follows the scene size (Eq. 12 / 13) and its slope the cell (Eq. 11); both are as seed-stable as the physics allows. Mode rule, per type:

  1. named in bottom.rms_heights -> rms (legacy normalise-to-height, bit for bit; test_seafloor_spectrum.py pins a hash);
  2. otherwise the type's own spectrum_mode: absolute for every built-in, rms by default for custom_types and ad-hoc BottomTypes (their YAML spectral_strength values predate this and were never used), opt in with spectrum_mode: absolute.

Compositing stays seam-free: one complex noise realisation; rms types share the legacy unit field at the area-weighted exponent through the feathered rms map, and each distinct exponent among absolute types gets the same noise shaped at its own γ through a feathered sqrt(b) map, so a sand | mud boundary crossfades between two coherent surfaces (test_mixed_gamma_boundary_is_continuous, test_zone_seams.py). _last_rms_map reports the expected rms height per pixel in both modes. Values, sources and the Eq. 11 convention trap (total vs one-component slope, sharp cut at 2π/dx vs a central difference on the grid) are in simulator.md, Bottom Types and Roughness spectrum. Expect default sand to carry ~12 cm rms height over 100 m (it was 2 cm): that is long, gentle undulation, the price of a physical slope.