fix: brush-spread deposition + deposit-cap governor; retune erosion for a drainage hierarchy (terrain-water task 18)

Deposition now spreads over the same cone brush as carving, and the per-cell
erosion ledger becomes a signed NET displacement ledger, so both caps are
measured from the height the pass found. Brush-spreading alone cut the spike
15.49 -> 6.12 m at task-17 dials, but NOT at hierarchy dials (long paths carry
far more sediment; a loaded droplet meeting a rise dumps min(rise, load) at
once -> 25.5 m). So deposition also gets governor 4, ErosionDepositCap
(default 6 m, <= 0 = unbounded), asserted on exit like the carve cap.

Dial defaults retuned for a drainage HIERARCHY: 250k droplets x 384 steps at
inertia 0.35 / evaporation 0.004, erode 0.12, carve cap 15 m. Diagnosis was
that lifetime 48 let a droplet travel at most 48 px on a ~3000 px island
radius, so paths could not overlap into trunks: the deepest task-17 features
were 43x36 px patches. Now 177 channel systems of 200+ cells at the 3 m
threshold, top one 204x279 px with 14 tributary tips; cells past 5 m up 8.6x
(4,948 -> 42,385) while the fine rills are retained. Verified descending, not
contour-locked: 0/155 components have drop/extent < 0.12 (median 1.13).

EROS body version -> 2 (deposit cap inserted after the carve cap); v1 payloads
are skipped whole by the existing rule. HydraulicErosion.VERSION now reads
BlueprintFormat.EROS_VERSION instead of restating it — the local copy had
already drifted and stamped a v2 body as v1, which readers decode with every
float shifted by one field.

Oracle green on both seeds (1280587109, 1512575962): 1_biomes/0_water
md5-identical erosion-ON vs OFF, BIOM/WBID bitwise equal. Flood guard 0 new
water px, 0 below-sea cells touched, crater zone untouched, island top
unchanged, carve field isotropic to 1.4% across the folded 45deg period.
Erosion pass 34 s at these dials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-10 04:16:33 -04:00
parent 56ac17e670
commit cc6a24d2b9
8 changed files with 105 additions and 59 deletions

View file

@ -10,7 +10,9 @@ using System;
/// then walks downhill with inertia, carrying water and sediment. Where the ground
/// is steep and it moves fast it ERODES (up to capacity, spread over a small brush
/// so no single-cell spikes — the anti-artifact that killed the D8 predecessor);
/// where it flattens out it DEPOSITS, building valley floors and fans. Water
/// where it flattens out it DEPOSITS, building valley floors and fans, over the
/// SAME brush (task 18 — bilinear 4-cell deposition built isolated cones at gully
/// mouths; carving and dumping are now symmetric). Water
/// evaporates each step; the droplet dies at its lifetime, at the map edge, or on
/// reaching the sea (its remaining sediment is lost to the ocean).
///
@ -20,9 +22,16 @@ using System;
/// The three hard governors (the pass provably cannot run away):
/// 1. DropletCount — total droplets (the main detail/cost dial).
/// 2. Lifetime — max steps per droplet; no infinite wandering.
/// 3. CarveCapM — max cumulative erosion depth per cell, in metres,
/// enforced against a per-cell ledger. The runaway-trench
/// guard, and what keeps this a DETAILING pass.
/// 3. CarveCapM — max erosion depth per cell, in metres, measured from the
/// height the pass found and enforced against a per-cell NET
/// displacement ledger. The runaway-trench guard, and the
/// dial that decides how deep trunk channels may cut.
/// 4. DepositCapM — max build-up per cell, the same ledger read the other way
/// (task 18). Brush-spreading alone does not bound a spike:
/// droplets on long paths carry far more sediment, and a
/// loaded droplet meeting a rise dumps min(rise, load) at
/// once. This makes "no deposit cones" a governor rather
/// than a hope. <= 0 disables it (the reference model).
///
/// The sea clamp (the "don't over-flood" guard): erosion never lowers any cell
/// below its local sea level + SeaMarginM, and cells already below sea are
@ -40,7 +49,11 @@ using System;
/// </summary>
public static class HydraulicErosion
{
public const ushort VERSION = 1;
// The EROS body version is owned by the format (Core) and read from there, not
// restated here: the version byte IS the payload layout's identity, so a local
// copy that drifts writes a v2 body stamped v1 and every reader shifts a field.
// (Caught doing exactly that in task 18 — mirrors TerrainDetailPass.VERSION.)
public const ushort VERSION = IslaApocalypse.Core.BlueprintFormat.EROS_VERSION;
// Deterministic RNG stream: seeded from resolvedSeed + this offset, so a seed
// reproduces exactly and the stream is decorrelated from every noise field
@ -68,6 +81,7 @@ public static class HydraulicErosion
public int DropletCount; // governor 1
public int Lifetime; // governor 2
public float CarveCapM; // governor 3 (metres)
public float DepositCapM; // governor 4 (metres); <= 0 = unbounded
public float SeaMarginM; // sea clamp margin (metres)
public int BrushRadius; // erosion brush radius, px
public float Inertia; // 0 = pure gradient descent, 1 = never turns
@ -89,7 +103,8 @@ public static class HydraulicErosion
public double ErodedVolumeM3; // 1 px = 1 m², so metres of depth sum to m³
public double DepositedVolumeM3;
public float MaxCellErosionM; // must end ≤ CarveCapM
public long ErodedCells; // cells with any net ledger erosion
public float MaxCellDepositM; // the deposit-spike metric (task 18)
public long ModifiedCells; // cells the pass touched at all
}
// PCG32 (O'Neill) — tiny, deterministic, trivially portable to C++.
@ -122,8 +137,12 @@ public static class HydraulicErosion
float capUnits = p.CarveCapM / M_PER_UNIT;
if (p.DropletCount <= 0 || capUnits <= 0f) return stats;
// Per-cell cumulative-erosion ledger — governor 3's enforcement record.
float[,] eroded = new float[mapSize, mapSize];
// Per-cell NET displacement ledger, metres, positive = carved below where the
// pass found this cell, negative = built up above it. Governor 3's enforcement
// record: the cap bounds `net`, so it bounds erosion depth measured from the
// ORIGINAL height — deposit-then-carve at one cell cannot smuggle in extra
// depth, and carve-then-deposit correctly frees the headroom back up.
float[,] net = new float[mapSize, mapSize];
// Spawn weighting needs the seed's top height.
float hTop = float.MinValue;
@ -227,18 +246,40 @@ public static class HydraulicErosion
if (dhM > 0f || sedimentM > capacityM)
{
// Moving uphill (fill the pit behind us, at most the rise) or
// over capacity (drop a fraction of the surplus): DEPOSIT at
// the OLD position, bilinear over its 4 cells.
// over capacity (drop a fraction of the surplus): DEPOSIT over
// the SAME cone brush erosion uses (task 18). Bilinear 4-cell
// deposition — the reference model's — concentrated a whole
// droplet's load into one cell at gully mouths and built
// isolated cones (measured 15.5 m on seed 1280587109, task 17
// §6.1). Spreading it makes deposition the symmetric mirror of
// carving; total mass is unchanged, only its footprint.
float amountM = dhM > 0f ? MathF.Min(dhM, sedimentM)
: (sedimentM - capacityM) * p.DepositRate;
if (amountM > 0f)
{
float w00 = (1f - fx) * (1f - fy), w10 = fx * (1f - fy);
float w01 = (1f - fx) * fy, w11 = fx * fy;
sedimentM -= DepositCell(height, eroded, xi, yi, amountM * w00, stats, SeaAt, Excluded)
+ DepositCell(height, eroded, xi + 1, yi, amountM * w10, stats, SeaAt, Excluded)
+ DepositCell(height, eroded, xi, yi + 1, amountM * w01, stats, SeaAt, Excluded)
+ DepositCell(height, eroded, xi + 1, yi + 1, amountM * w11, stats, SeaAt, Excluded);
for (int b = 0; b < brushN; b++)
{
int cx = xi + brushDx[b], cy = yi + brushDy[b];
if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
if (Excluded(cx, cy)) continue;
float hCell = height[cx, cy];
// Below-sea cells are read-only in BOTH directions: no
// submarine deltas, so the rendered coastline cannot move.
if (hCell < SeaAt(cx, cy)) continue;
float give = amountM * brushW[b];
// Governor 4: the ledger read the other way. net is negative
// where the cell has already been built up, so the headroom
// is cap + net.
if (p.DepositCapM > 0f)
give = MathF.Min(give, MathF.Max(0f, p.DepositCapM + net[cx, cy]));
if (give <= 0f) continue;
height[cx, cy] = hCell + give / M_PER_UNIT;
if (net[cx, cy] == 0f) stats.ModifiedCells++;
net[cx, cy] -= give;
if (-net[cx, cy] > stats.MaxCellDepositM) stats.MaxCellDepositM = -net[cx, cy];
sedimentM -= give;
stats.DepositedVolumeM3 += give;
}
}
}
else
@ -258,13 +299,13 @@ public static class HydraulicErosion
if (hCell < sea) continue; // below-sea cells are read-only
float want = amountM * brushW[b];
float bySea = MathF.Max(0f, (hCell - (sea + p.SeaMarginM / M_PER_UNIT)) * M_PER_UNIT);
float byCap = MathF.Max(0f, p.CarveCapM - eroded[cx, cy]);
float byCap = MathF.Max(0f, p.CarveCapM - net[cx, cy]);
float take = MathF.Min(want, MathF.Min(bySea, byCap));
if (take <= 0f) continue;
height[cx, cy] = hCell - take / M_PER_UNIT;
if (eroded[cx, cy] == 0f) stats.ErodedCells++;
eroded[cx, cy] += take;
if (eroded[cx, cy] > stats.MaxCellErosionM) stats.MaxCellErosionM = eroded[cx, cy];
if (net[cx, cy] == 0f) stats.ModifiedCells++;
net[cx, cy] += take;
if (net[cx, cy] > stats.MaxCellErosionM) stats.MaxCellErosionM = net[cx, cy];
sedimentM += take;
stats.ErodedVolumeM3 += take;
}
@ -284,27 +325,11 @@ public static class HydraulicErosion
if (stats.MaxCellErosionM > p.CarveCapM * (1f + 1e-5f))
throw new InvalidOperationException(
$"[HydraulicErosion] CARVE-CAP VIOLATION: a cell accumulated {stats.MaxCellErosionM} m against cap {p.CarveCapM} m. Refusing to generate.");
if (p.DepositCapM > 0f && stats.MaxCellDepositM > p.DepositCapM * (1f + 1e-5f))
throw new InvalidOperationException(
$"[HydraulicErosion] DEPOSIT-CAP VIOLATION: a cell built up {stats.MaxCellDepositM} m against cap {p.DepositCapM} m. Refusing to generate.");
return stats;
}
/// <summary>
/// Deposits up to <paramref name="amountM"/> metres on one cell; returns what was
/// actually placed. Below-sea cells and crater-excluded cells take nothing —
/// deposition only ever raises LAND, so the coastline cannot move and the sea
/// cannot shallow. A cell's ledgered erosion is paid back first, so erode-then-
/// deposit at one cell frees cap headroom instead of double-counting.
/// </summary>
private static float DepositCell(float[,] height, float[,] eroded, int cx, int cy,
float amountM, Stats stats, Func<int, int, float> seaAt, Func<int, int, bool> excluded)
{
if (amountM <= 0f) return 0f;
float hCell = height[cx, cy];
if (hCell < seaAt(cx, cy)) return 0f;
if (excluded(cx, cy)) return 0f;
height[cx, cy] = hCell + amountM / M_PER_UNIT;
eroded[cx, cy] = MathF.Max(0f, eroded[cx, cy] - amountM);
stats.DepositedVolumeM3 += amountM;
return amountM;
}
}