islaApocalypse-v2/Tools/Scripts/ErosionPass.cs
beezm ea291eaab5 chat2/11: hydraulic erosion — the faithful droplet pass on the locked shape, render-only, judged off vs on
Step 0: tag terrain-shape-v1 on a59e52f (the frag_4-locked, gallery-confirmed state).

Core/Scripts/HydraulicErosion.cs is the reference's pass ported verbatim - the four governors
(250,000 droplets, lifetime 384, carve cap 15 m, deposit cap 6 m) on one net-displacement
ledger read both ways and PROVEN on exit (the CARVE-CAP / DEPOSIT-CAP violations throw), the
sea clamp (spawn rejected below sea, death at sea, both brushes skip below-sea cells, the 0.5 m
margin floor on the erode brush), the cone-weighted normalized brush shared by erode and
deposit, the droplet physics (inertia 0.35, capacity 4, min slope 0.02, erode 0.12, deposit
0.15, evaporation 0.004, gravity 4, brush 2), PCG32 seeded at seed + 9271, the crater
exclusion whole. Engine-free; every metres<->raw conversion through WorldScale (no literal
251 - the same multiply/divide, so bit-identical arithmetic).

Tools/Scripts/ErosionPass.cs is the caller (pass 2b): render field only (copied if aliased to
classify), governors clamped as the reference ConfigManager clamped them, the crater exclusion
passed through INERT (no crater => radius 0 => weight 1 everywhere; the reference's
"core < carve factor" warning dormant), and the FLOOD GUARD - render water pixels counted
before and after, any change throws. TerrainGenConfig gains Erosion (default OFF, the anchor
rule) and the governors/physics/crater fields. Pass2Result.WithHeight hands back the eroded
render field. ShadeRenderer is a pure-hillshade plate (land only) because the palette relief's
0.30 hillshade hides half-metre drainage. ErosionTool carries TerrainShapeV1 (the locked shape's
values, pinned once) and the batch: 4 gallery seeds x off/on at 8192, grayscale + .f32 +
relief + shade per field, a mid-slope 1024-px crop off/on (relief and shade), the stats.

Faithful first, no tune: at 8192 the pass touches ~88 % of land cells at a mean 0.14 m, carve
15.00 / deposit 6.00 m at the caps, 0.5 m mean on the massif with 18 % of its cells moved more
than a metre. It reads as dissected summits with radial gully fans and carve/deposit bands
along the slope breaks, not dendritic networks: droplets die of lifetime (170k of 224k) before
they converge. A lifetime probe at 4096 (scratch) shows 1536 and 4096 identical - every
droplet is dead of evaporation by ~1,300 steps - so lifetime is not the lever; droplet count
is. The mid-slope green->yellow transition is not softened at the faithful tune.

Oracle, all passing on 4 seeds: erosion OFF bit-identical to terrain-shape-v1 (the task-10
gallery dumps, 67 M cells each); classify bit-identical off vs on and == the pass-1 field;
region labeling + island tag identical; flood guard (27.5 M water pixels unchanged, every
seed); caps proven; tag/coastline; classify == raw; centre is land; eroded field
bit-identical across two runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 12:19:20 -04:00

126 lines
7.3 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;
using System.Collections.Generic;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ PASS 2b — HYDRAULIC EROSION, THE CALLER (chat2/11). Runs <see cref="HydraulicErosion"/> on
/// the RENDER field of a shaped result — after the curve + detail (pass 2a), before the crater
/// carve (which does not exist yet) — exactly the reference's slot (<c>MapGenerator.cs:737-805</c>).
///
/// ═══ THE THREE THINGS THE CALLER OWNS (ported from the reference caller) ═══
///
/// 1. THE SPLIT. Only the render field is eroded; the classify field is finalized before the pass
/// and never sees it — biomes / water / region labeling classify pre-erosion (the oracle).
/// If the two fields are aliased (curve off), the render field is COPIED first, as the
/// reference allocated a separate classify array when erosion was on.
/// 2. THE FLOOD GUARD. Render-map water pixels are counted BEFORE and AFTER; any change throws
/// "EROSION FLOOD-GUARD VIOLATION". With the pass's sea clamp (below-sea cells read-only in
/// both directions; carve floor at sea + margin) this is the active proof that no coastline
/// moved — mainland and every island alike.
/// 3. THE CRATER EXCLUSION, INERT. The weight is passed whole (core / feather radii, mode) but
/// with no crater in v2 the radius is 0 ⇒ weight 1 everywhere. It activates when the crater
/// carve lands; the reference's "CraterErosionCore &lt; carve factor" warning is DORMANT until
/// then (it concerns the erosion↔carve interaction, which does not exist yet).
///
/// The governors are clamped as the reference's ConfigManager clamped them (count [0, 50 M],
/// lifetime [1, 4096], carve cap [0, 60], deposit cap [0, 60], sea margin [0, 5], inertia
/// [0, 0.99], evaporation [0, 0.5]) and a clamp is reported, not silent.
/// </summary>
public static class ErosionPass
{
public sealed class Result
{
public Pass2Result Shaped; // the result with the eroded RENDER field (classify untouched)
public HydraulicErosion.Stats Stats;
public HydraulicErosion.Params Params;
public long WetBefore, WetAfter;
public ulong Ms;
public List<string> Notes = new();
}
/// <summary>Count render-map water pixels (height below the flat sea) — the flood guard's instrument.</summary>
public static long CountWaterPixels(float[,] height, int mapSize, float sea)
{
long wet = 0;
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
if (height[x, y] < sea) wet++;
return wet;
}
/// <summary>
/// Erode <paramref name="p2"/>'s render field (a copy if aliased to classify) and return the
/// result. Throws on a governor-cap violation (from the pass) or a flood-guard violation.
/// </summary>
public static Result Apply(Pass2Result p2, TerrainGenConfig cfg)
{
ulong t0 = Time.GetTicksMsec();
var r = new Result();
int n = p2.MapSize;
float sea = cfg.SeaLevel;
// 1. THE SPLIT — never erode an array the classify field shares.
float[,] render = p2.Height;
if (p2.FieldsAreAliased)
{
render = (float[,])p2.Height.Clone();
r.Notes.Add("[Erosion] render and classify were aliased (curve off) — the render field was copied before eroding, as the reference allocated a separate classify array when erosion was on.");
}
// The governors, clamped as the reference's ConfigManager clamped them — reported, not silent.
int count = Math.Clamp(cfg.ErosionDropletCount, 0, 50_000_000);
int life = Math.Clamp(cfg.ErosionDropletLifetime, 1, 4096);
float carve = Math.Clamp(cfg.ErosionCarveCapM, 0f, 60f);
float deposit = Math.Clamp(cfg.ErosionDepositCapM, 0f, 60f);
float margin = Math.Clamp(cfg.ErosionSeaMarginM, 0f, 5f);
float inertia = Math.Clamp(cfg.ErosionInertia, 0f, 0.99f);
float evap = Math.Clamp(cfg.ErosionEvaporation, 0f, 0.5f);
if (count != cfg.ErosionDropletCount || life != cfg.ErosionDropletLifetime || carve != cfg.ErosionCarveCapM || deposit != cfg.ErosionDepositCapM
|| margin != cfg.ErosionSeaMarginM || inertia != cfg.ErosionInertia || evap != cfg.ErosionEvaporation)
r.Notes.Add($"[Erosion] ⚠ governor out of bounds — clamped: count {cfg.ErosionDropletCount}→{count}, lifetime {cfg.ErosionDropletLifetime}→{life}, carve {cfg.ErosionCarveCapM}→{carve} m, deposit {cfg.ErosionDepositCapM}→{deposit} m, margin {cfg.ErosionSeaMarginM}→{margin} m, inertia {cfg.ErosionInertia}→{inertia}, evaporation {cfg.ErosionEvaporation}→{evap}.");
// 3. THE CRATER EXCLUSION — inert: no crater ⇒ radius 0 ⇒ weight 1 everywhere.
float craterRadius = cfg.CraterRadius; // 0 in v2 until the crater task
float coreR = craterRadius * cfg.CraterErosionCore;
float featherR = craterRadius * cfg.CraterErosionFeather;
const float CRATER_CARVE_FACTOR = 0.80f; // the reference's carve radius factor
if (craterRadius > 0f && cfg.CraterErosionCore < CRATER_CARVE_FACTOR)
r.Notes.Add($"[Erosion] ⚠ CraterErosionCore {cfg.CraterErosionCore:F2} is inside the carve radius ({CRATER_CARVE_FACTOR:F2} × CraterRadius) — erosion will modify carve-authored terrain and the carve will amplify those deltas across the waterline (the reference's warning; live only once a crater exists).");
// 2. THE FLOOD GUARD — before.
r.WetBefore = CountWaterPixels(render, n, sea);
r.Params = new HydraulicErosion.Params
{
DropletCount = count, Lifetime = life, CarveCapM = carve, DepositCapM = deposit, SeaMarginM = margin,
BrushRadius = Math.Max(0, cfg.ErosionBrushRadius), Inertia = inertia, CapacityFactor = cfg.ErosionCapacity,
MinSlopeM = cfg.ErosionMinSlopeM, ErodeRate = cfg.ErosionErodeRate, DepositRate = cfg.ErosionDepositRate,
Evaporation = evap, Gravity = cfg.ErosionGravity,
CraterMode = cfg.CraterErosionFeatherMode ? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL,
Seed = cfg.Seed + HydraulicErosion.SEED_OFFSET,
};
r.Stats = HydraulicErosion.Apply(render, n, null, sea, cfg.CraterCenterX, cfg.CraterCenterY, coreR, featherR, r.Params);
// 2. THE FLOOD GUARD — after. Any change refuses the generation.
r.WetAfter = CountWaterPixels(render, n, sea);
if (r.WetAfter != r.WetBefore)
throw new InvalidOperationException(
$"[ErosionPass] EROSION FLOOD-GUARD VIOLATION: render-map water pixels {r.WetBefore} -> {r.WetAfter}. Refusing to generate.");
r.Ms = Time.GetTicksMsec() - t0;
var st = r.Stats;
r.Notes.Add($"[Erosion] crater exclusion: {(craterRadius > 0f ? $"core {coreR:F0} px feather {featherR:F0} px" : "INERT (no crater; weight 1 everywhere)")}.");
r.Notes.Add($"[Erosion] v1: {st.Spawned} droplets ({st.SkippedNoLand} skipped), {st.Steps:N0} steps, {r.Ms / 1000.0:F1}s wall. " +
$"Eroded {st.ErodedVolumeM3:F0} m³ over {st.ModifiedCells:N0} touched cells (max cell carve {st.MaxCellErosionM:F2} m vs cap {carve:F2} m), " +
$"deposited {st.DepositedVolumeM3:F0} m³ (max cell deposit {st.MaxCellDepositM:F2} m vs cap {deposit:F2} m). " +
$"Deaths: {st.DiedSea} sea / {st.DiedEdge} edge / {st.DiedDry} dry / {st.DiedLifetime} lifetime. " +
$"Water pixels {r.WetBefore:N0} -> {r.WetAfter:N0} (flood guard holds).");
r.Shaped = p2.WithHeight(render, r.Notes, r.Ms);
return r;
}
}
}