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>
335 lines
15 KiB
C#
335 lines
15 KiB
C#
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 exclusion: no cell within CraterExclRadius of the impact centre is
|
||
/// modified (droplets may traverse). The carve remains the final authority on its
|
||
/// own terrain.
|
||
///
|
||
/// 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 exclusion factor: erosion stays outside 1.2 × CraterRadius — fully
|
||
// clear of both the physical carve (0.80×) and the detail feather (1.05×).
|
||
public const float CRATER_EXCL_FACTOR = 1.2f;
|
||
|
||
// 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 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 craterExclRadius, 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 exclSq = craterExclRadius * craterExclRadius;
|
||
float SeaAt(int cx, int cy) => seaMap != null ? seaMap[cx, cy] : seaFlat;
|
||
bool Excluded(int cx, int cy)
|
||
{
|
||
float ddx = cx - craterCx, ddy = cy - craterCy;
|
||
return ddx * ddx + ddy * ddy < exclSq;
|
||
}
|
||
|
||
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;
|
||
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
|
||
{
|
||
// 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;
|
||
if (Excluded(cx, cy)) 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];
|
||
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;
|
||
}
|
||
|
||
}
|