islaApocalypse/Tools/Scripts/HydraulicErosion.cs
beezm f810ff44dc fix: shrink erosion's crater exclusion to the strike core; add FULL/FEATHER modes (terrain-water task 19)
Task 17's hard 1.2 x CraterRadius cutoff left a visible un-eroded disc with a hard
edge. Measured (radius dump, seed 1280587109): the carve writes only inside 0.80 x
(640 px) and its displacement is EXACTLY 0 beyond that, so the 640-960 px annulus
was 620,811 LAND cells of ordinary terrain held smooth for no geometric reason.
That annulus is now eroded — 98% of those cells are touched.

The protected core is the carve's own extent (CraterErosionCore, 0.80 x).
CraterErosionMode selects the transition: "full" applies full strength at the core
boundary (older-crater look), "feather" ramps 0->full out to CraterErosionFeather
(1.05 x, mirroring the detail pass) for a younger-crater look with no seam.
Implemented as a WEIGHT that scales carve and deposit amounts, not a skip, which is
what makes feather a one-liner. Default "feather" pending the gate.

Core = the carve radius is load-bearing, not tidy: the carve runs AFTER erosion and
scales height toward the sea target, so it amplifies any erosion delta inside its
radius. Measured at a 0.50 core: 79 cells newly below the rendered sea and 113,310
below-sea cells disturbed — the in-pass flood guard cannot see this because it
measures before the carve. At 0.80 the two passes touch disjoint cells and the
guarantee is exact again (0/0/0). A narrower core also reclaims nothing extra, since
the over-protected annulus lies entirely outside the carve. MapGenerator now owns
CRATER_CARVE_FACTOR as the single source of that 0.80 and warns loudly if the
configured core is narrower.

Verified both modes, seed 1280587109: 1_biomes/0_water md5-identical to erosion-OFF,
BIOM/WBID bitwise equal, 0 newly below/above sea, 0 below-sea cells modified, 0
cells modified inside the core, island top 457.65 m unchanged, isotropy 1.005/1.007.
Bay-to-ocean connection demonstrated explicitly by flood fill through rendered
water to the map's north border (2,122,935 cells), not merely argued from the sea
clamp.

EROS body version -> 3: craterExclFactor replaced by craterCoreFactor +
craterFeatherFactor + craterMode. Round-trip harness PASS with a real v3 payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 05:50:22 -04:00

370 lines
17 KiB
C#
Raw Permalink 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.

using System;
/// <summary>
/// Droplet-based hydraulic erosion (terrain-water task 17, Phase C0) — the organic
/// carve-AND-deposit pass, Lague/Beyer lineage. Pure numeric over the height array
/// (D-035; a named future C++ candidate, kept standalone — no Godot types at all,
/// System.MathF only, own deterministic PCG32 RNG).
///
/// Each droplet spawns on land (spawn probability weighted toward high ground),
/// 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, 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).
///
/// OUTPUT-ONLY: this pass is applied to the RENDER height map only; the classify
/// map never sees it (the caller owns that split — see MapGenerator).
///
/// 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 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
/// read-only — never eroded, never deposited on. Land stays land, sea stays sea;
/// the rendered coastline cannot move. Deposition only raises land cells.
///
/// The crater treatment (task 19): no cell within the protected strike CORE is
/// modified (droplets may traverse), and outside it either FULL strength applies
/// immediately or FEATHER ramps in across a band. The carve remains the final
/// authority on the deep bowl; the bay's sea connection is guaranteed by the sea
/// clamp rather than by the exclusion, since below-sea cells are read-only in
/// both directions.
///
/// Heights in the array are raw blueprint units (1 unit = 251 m). All sediment
/// accounting below is done in METRES and converted only when a delta is applied,
/// so untouched cells keep their exact bit pattern — the invariants above are
/// exact, not statistical.
/// </summary>
public static class HydraulicErosion
{
// 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
// (7409/8117/… are taken; see MakeModulationNoise call sites).
public const int SEED_OFFSET = 9271;
public const float M_PER_UNIT = 251f;
// --- Crater treatment (task 19) ---
//
// Task 17 used a hard 1.2 × CraterRadius cutoff. Measured on seed 1280587109
// (task-19 radius dump): the carve writes only inside 0.80 × (640 px) and its
// displacement is EXACTLY 0 beyond that, so the 640960 px annulus was 620,811
// land cells of ordinary terrain held smooth for no geometric reason — a
// visible un-eroded disc against dissected ground, with a hard edge.
//
// The protected core is now the deep strike zone only. The bay itself needs no
// exclusion: below-sea cells are read-only in both directions (the sea clamp),
// so erosion can neither carve the bay's sea connection open nor silt it shut.
// The core exists to stop the BOWL being dissected on seeds where it holds land
// (on 1280587109 there is no land at all inside 0.50 ×, so the core is
// functionally redundant there — the guard is for the general seed).
public const float CRATER_CORE_FACTOR_DEFAULT = 0.50f; // ×CraterRadius
public const float CRATER_FEATHER_FACTOR_DEFAULT = 1.05f; // ×CraterRadius, FEATHER only
public const byte CRATER_MODE_FULL = 0;
public const byte CRATER_MODE_FEATHER = 1;
// Spawn: droplets source in the mountains, never the ocean. A land point is
// accepted with probability SPAWN_FLOOR + (1-SPAWN_FLOOR) · relative elevation,
// after at most SPAWN_TRIES rejection-sampling attempts (then the droplet is
// skipped and counted — on any real island this is vanishingly rare).
private const int SPAWN_TRIES = 16;
private const float SPAWN_FLOOR = 0.15f;
private const float MIN_WATER = 0.005f; // droplet dies when effectively dry
private const float MIN_DIR = 1e-10f; // below this, direction is re-drawn at random
public struct Params
{
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
public float CapacityFactor; // sediment capacity multiplier
public float MinSlopeM; // capacity slope floor, metres per px
public float ErodeRate; // fraction of remaining capacity eroded per step
public float DepositRate; // fraction of surplus sediment dropped per step
public float Evaporation; // water lost per step (fraction)
public float Gravity; // speed gain per metre of drop
public byte CraterMode; // CRATER_MODE_FULL | CRATER_MODE_FEATHER (task 19)
public int Seed; // resolvedSeed + SEED_OFFSET
}
public class Stats
{
public int Spawned;
public int SkippedNoLand;
public long Steps;
public int DiedLifetime, DiedEdge, DiedSea, DiedDry;
public double ErodedVolumeM3; // 1 px = 1 m², so metres of depth sum to m³
public double DepositedVolumeM3;
public float MaxCellErosionM; // must end ≤ CarveCapM
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++.
private struct Pcg32
{
private ulong _state;
public Pcg32(int seed) { _state = 0; NextU(); _state += (ulong)(uint)seed; NextU(); }
public uint NextU()
{
ulong old = _state;
_state = old * 6364136223846793005UL + 1442695040888963407UL;
uint xorshifted = (uint)(((old >> 18) ^ old) >> 27);
int rot = (int)(old >> 59);
return (xorshifted >> rot) | (xorshifted << (-rot & 31));
}
public float NextF() => (NextU() >> 8) * (1f / 16777216f); // [0,1)
}
/// <summary>
/// Runs the pass in place on <paramref name="height"/>. Sea level per cell is
/// <paramref name="seaMap"/>[x,y] when non-null, else the flat scalar
/// <paramref name="seaFlat"/>. Throws (refusing the generation) if a governor
/// bound is violated on exit — the caller treats that as a build failure.
/// </summary>
public static Stats Apply(float[,] height, int mapSize, float[,] seaMap, float seaFlat,
float craterCx, float craterCy, float craterCoreRadius, float craterFeatherRadius, Params p)
{
var stats = new Stats();
var rng = new Pcg32(p.Seed);
float capUnits = p.CarveCapM / M_PER_UNIT;
if (p.DropletCount <= 0 || capUnits <= 0f) return stats;
// 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;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
if (height[x, y] > hTop) hTop = height[x, y];
// Erosion brush: all offsets within BrushRadius, cone-weighted (1 - d/r),
// normalized. Radius 0 degrades to the single cell.
int r = Math.Max(p.BrushRadius, 0);
int brushN = 0;
for (int dx = -r; dx <= r; dx++)
for (int dy = -r; dy <= r; dy++)
if (MathF.Sqrt(dx * dx + dy * dy) <= r + 1e-4f) brushN++;
int[] brushDx = new int[brushN], brushDy = new int[brushN];
float[] brushW = new float[brushN];
{
int i = 0; float wSum = 0f;
for (int dx = -r; dx <= r; dx++)
for (int dy = -r; dy <= r; dy++)
{
float d = MathF.Sqrt(dx * dx + dy * dy);
if (d > r + 1e-4f) continue;
brushDx[i] = dx; brushDy[i] = dy;
brushW[i] = r > 0 ? 1f - d / (r + 1f) : 1f;
wSum += brushW[i]; i++;
}
for (int j = 0; j < brushN; j++) brushW[j] /= wSum;
}
float SeaAt(int cx, int cy) => seaMap != null ? seaMap[cx, cy] : seaFlat;
// Crater weight (task 19): 0 inside the protected strike core, 1 where erosion
// runs at full strength. FULL steps straight to 1 at the core boundary; FEATHER
// ramps linearly out to craterFeatherRadius, mirroring the detail pass's shape,
// so the crater reads as younger/less-weathered with no seam. Amounts are SCALED
// by this rather than skipped, which is what makes FEATHER a one-liner.
float coreSq = craterCoreRadius * craterCoreRadius;
bool feather = p.CraterMode == CRATER_MODE_FEATHER
&& craterFeatherRadius > craterCoreRadius;
float CraterWeight(int cx, int cy)
{
float ddx = cx - craterCx, ddy = cy - craterCy;
float d2 = ddx * ddx + ddy * ddy;
if (d2 < coreSq) return 0f;
if (!feather) return 1f;
float d = MathF.Sqrt(d2);
if (d >= craterFeatherRadius) return 1f;
return (d - craterCoreRadius) / (craterFeatherRadius - craterCoreRadius);
}
for (int drop = 0; drop < p.DropletCount; drop++)
{
// --- spawn (land only, elevation-weighted) ---
float px = -1f, py = -1f;
for (int attempt = 0; attempt < SPAWN_TRIES; attempt++)
{
float sx = 1f + rng.NextF() * (mapSize - 3);
float sy = 1f + rng.NextF() * (mapSize - 3);
int cx = (int)sx, cy = (int)sy;
float h = height[cx, cy];
float sea = SeaAt(cx, cy);
if (h < sea) { continue; }
float rel = hTop > sea ? Math.Clamp((h - sea) / (hTop - sea), 0f, 1f) : 0f;
if (rng.NextF() < SPAWN_FLOOR + (1f - SPAWN_FLOOR) * rel) { px = sx; py = sy; break; }
}
if (px < 0f) { stats.SkippedNoLand++; continue; }
stats.Spawned++;
float dirX = 0f, dirY = 0f, speed = 1f, water = 1f, sedimentM = 0f;
for (int step = 0; step < p.Lifetime; step++)
{
stats.Steps++;
int xi = (int)px, yi = (int)py;
float fx = px - xi, fy = py - yi;
// Bilinear height + gradient at the current position.
float h00 = height[xi, yi], h10 = height[xi + 1, yi];
float h01 = height[xi, yi + 1], h11 = height[xi + 1, yi + 1];
float gradX = (h10 - h00) * (1f - fy) + (h11 - h01) * fy;
float gradY = (h01 - h00) * (1f - fx) + (h11 - h10) * fx;
float hOld = h00 * (1f - fx) * (1f - fy) + h10 * fx * (1f - fy)
+ h01 * (1f - fx) * fy + h11 * fx * fy;
// Inertia blend, then one unit step.
dirX = dirX * p.Inertia - gradX * (1f - p.Inertia);
dirY = dirY * p.Inertia - gradY * (1f - p.Inertia);
float len = MathF.Sqrt(dirX * dirX + dirY * dirY);
if (len < MIN_DIR)
{
float ang = rng.NextF() * 2f * MathF.PI;
dirX = MathF.Cos(ang); dirY = MathF.Sin(ang); len = 1f;
}
dirX /= len; dirY /= len;
px += dirX; py += dirY;
if (px < 1f || px >= mapSize - 2 || py < 1f || py >= mapSize - 2)
{ stats.DiedEdge++; break; }
int nxi = (int)px, nyi = (int)py;
float nfx = px - nxi, nfy = py - nyi;
float n00 = height[nxi, nyi], n10 = height[nxi + 1, nyi];
float n01 = height[nxi, nyi + 1], n11 = height[nxi + 1, nyi + 1];
float hNew = n00 * (1f - nfx) * (1f - nfy) + n10 * nfx * (1f - nfy)
+ n01 * (1f - nfx) * nfy + n11 * nfx * nfy;
// Reached the sea: die; the sediment is the ocean's now.
if (hNew < SeaAt(nxi, nyi)) { stats.DiedSea++; break; }
float dhM = (hNew - hOld) * M_PER_UNIT;
float capacityM = MathF.Max(-dhM, p.MinSlopeM) * speed * water * p.CapacityFactor;
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 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)
{
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;
float wCrater = CraterWeight(cx, cy);
if (wCrater <= 0f) 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] * wCrater;
// 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
{
// Under capacity on a downhill move: ERODE, spread over the
// brush, never more than the drop itself (no digging pits).
float amountM = MathF.Min((capacityM - sedimentM) * p.ErodeRate, -dhM);
if (amountM > 0f)
{
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;
float wCrater = CraterWeight(cx, cy);
if (wCrater <= 0f) continue;
float sea = SeaAt(cx, cy);
float hCell = height[cx, cy];
if (hCell < sea) continue; // below-sea cells are read-only
float want = amountM * brushW[b] * wCrater;
float bySea = MathF.Max(0f, (hCell - (sea + p.SeaMarginM / M_PER_UNIT)) * M_PER_UNIT);
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 (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;
}
}
}
speed = MathF.Sqrt(MathF.Max(0f, speed * speed - dhM * p.Gravity));
water *= 1f - p.Evaporation;
if (water < MIN_WATER) { stats.DiedDry++; break; }
if (step == p.Lifetime - 1) stats.DiedLifetime++;
}
}
// Governor 3, proven on exit rather than assumed: the ledger's maximum must
// respect the cap (float addition of clamped takes cannot exceed it by more
// than rounding; allow one ulp-scale epsilon).
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;
}
}