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>
This commit is contained in:
parent
85e7bceb72
commit
f810ff44dc
7 changed files with 141 additions and 24 deletions
|
|
@ -38,9 +38,12 @@ using System;
|
|||
/// 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.
|
||||
/// 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,
|
||||
|
|
@ -62,9 +65,25 @@ public static class HydraulicErosion
|
|||
|
||||
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;
|
||||
// --- 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 640–960 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,
|
||||
|
|
@ -91,6 +110,7 @@ public static class HydraulicErosion
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +150,7 @@ public static class HydraulicErosion
|
|||
/// 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)
|
||||
float craterCx, float craterCy, float craterCoreRadius, float craterFeatherRadius, Params p)
|
||||
{
|
||||
var stats = new Stats();
|
||||
var rng = new Pcg32(p.Seed);
|
||||
|
|
@ -173,12 +193,25 @@ public static class HydraulicErosion
|
|||
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)
|
||||
|
||||
// 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;
|
||||
return ddx * ddx + ddy * ddy < exclSq;
|
||||
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++)
|
||||
|
|
@ -261,12 +294,13 @@ public static class HydraulicErosion
|
|||
{
|
||||
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 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];
|
||||
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.
|
||||
|
|
@ -293,11 +327,12 @@ public static class HydraulicErosion
|
|||
{
|
||||
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 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];
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ public partial class MapGenerator : TextureRect
|
|||
// Hydraulic erosion (task 17): output-only droplet pass on the RENDER map,
|
||||
// after detail, before the crater carve. The classify map never sees it.
|
||||
private bool _erosionOn;
|
||||
private byte _craterErosionMode;
|
||||
|
||||
// The carve's radius as a fraction of CraterRadius — the ONLY place this number
|
||||
// lives on the generator side. Erosion's protected core must be at least this
|
||||
// wide or the two passes overlap; see the guard before the erosion call.
|
||||
private const float CRATER_CARVE_FACTOR = 0.80f;
|
||||
|
||||
// Task-10 detail passes (shelf micro-relief + shelf-edge variation): gated by
|
||||
// TerrainDetail, active only with the curve on (both are defined in terms of
|
||||
|
|
@ -114,6 +120,8 @@ public partial class MapGenerator : TextureRect
|
|||
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
|
||||
_curveOn = _curveKnots != null;
|
||||
_erosionOn = ConfigManager.Erosion == "v1";
|
||||
_craterErosionMode = ConfigManager.CraterErosionMode == "feather"
|
||||
? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL;
|
||||
// (The monotonicity assertion now runs inside GenerateTopography, against the
|
||||
// effective per-seed curve, once hMaxSeed is known.)
|
||||
// Classify gets its own array whenever ANY render-only pass diverges the two
|
||||
|
|
@ -392,7 +400,9 @@ public partial class MapGenerator : TextureRect
|
|||
DepositRate = ConfigManager.ErosionDepositRate,
|
||||
Evaporation = ConfigManager.ErosionEvaporation,
|
||||
Gravity = ConfigManager.ErosionGravity,
|
||||
CraterExclFactor = HydraulicErosion.CRATER_EXCL_FACTOR
|
||||
CraterCoreFactor = ConfigManager.CraterErosionCore,
|
||||
CraterFeatherFactor = ConfigManager.CraterErosionFeather,
|
||||
CraterMode = _craterErosionMode
|
||||
} : null,
|
||||
TerrainDetail = _detailOn ? new TerrainDetailInfo
|
||||
{
|
||||
|
|
@ -638,7 +648,7 @@ public partial class MapGenerator : TextureRect
|
|||
// `raw`; neither can move a column below the red ceiling or above the cap,
|
||||
// because K2 and K6 never move and the curve is monotonic between them.
|
||||
float reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f;
|
||||
float physicalCraterRadius = _impactRadius * 0.80f;
|
||||
float physicalCraterRadius = _impactRadius * CRATER_CARVE_FACTOR;
|
||||
for (int x = 0; x < MapSize; x++)
|
||||
{
|
||||
for (int y = 0; y < MapSize; y++)
|
||||
|
|
@ -700,6 +710,17 @@ public partial class MapGenerator : TextureRect
|
|||
for (int y = 0; y < MapSize; y++)
|
||||
seaMap[x, y] = GetSeaLevel(_tempMap[x, y]);
|
||||
}
|
||||
// Erosion's protected core must cover the carve, or the two passes touch the
|
||||
// same cells and the carve — which runs AFTER erosion and scales height
|
||||
// toward the sea target — amplifies erosion's delta across the waterline.
|
||||
// Refusing to fail silently: the in-pass flood guard below cannot see this,
|
||||
// because it measures before the carve.
|
||||
if (ConfigManager.CraterErosionCore < CRATER_CARVE_FACTOR)
|
||||
GD.PrintErr($"[MapGenerator] ⚠ CraterErosionCore {ConfigManager.CraterErosionCore:F2} is " +
|
||||
$"inside the carve radius ({CRATER_CARVE_FACTOR:F2} × CraterRadius). Erosion will modify " +
|
||||
"carve-authored terrain, and the crater carve will then amplify those deltas — expect a " +
|
||||
"handful of rendered-waterline crossings inside the bay that the erosion flood guard cannot see.");
|
||||
|
||||
long wetBefore = CountRenderWaterPixels(seaMap, seaFlat);
|
||||
|
||||
var p = new HydraulicErosion.Params
|
||||
|
|
@ -717,17 +738,22 @@ public partial class MapGenerator : TextureRect
|
|||
DepositRate = ConfigManager.ErosionDepositRate,
|
||||
Evaporation = ConfigManager.ErosionEvaporation,
|
||||
Gravity = ConfigManager.ErosionGravity,
|
||||
CraterMode = _craterErosionMode,
|
||||
Seed = _noise.Seed + HydraulicErosion.SEED_OFFSET
|
||||
};
|
||||
var st = HydraulicErosion.Apply(_heightMap, MapSize, seaMap, seaFlat,
|
||||
_impactCenter.X, _impactCenter.Y,
|
||||
_impactRadius * HydraulicErosion.CRATER_EXCL_FACTOR, p);
|
||||
_impactRadius * ConfigManager.CraterErosionCore,
|
||||
_impactRadius * ConfigManager.CraterErosionFeather, p);
|
||||
|
||||
long wetAfter = CountRenderWaterPixels(seaMap, seaFlat);
|
||||
if (wetAfter != wetBefore)
|
||||
throw new System.InvalidOperationException(
|
||||
$"[MapGenerator] EROSION FLOOD-GUARD VIOLATION: render-map water pixels {wetBefore} -> {wetAfter}. Refusing to generate.");
|
||||
|
||||
GD.Print($"{T()} [Erosion] crater '{ConfigManager.CraterErosionMode}': core {_impactRadius * ConfigManager.CraterErosionCore:F0} px" +
|
||||
(ConfigManager.CraterErosionMode == "feather"
|
||||
? $" -> feather to {_impactRadius * ConfigManager.CraterErosionFeather:F0} px" : " (hard edge, full strength beyond)") + ".");
|
||||
GD.Print($"{T()} [Erosion] v1: {st.Spawned} droplets ({st.SkippedNoLand} skipped), {st.Steps} steps, " +
|
||||
$"{(Time.GetTicksMsec() - tEro0) / 1000.0:F1}s wall. Eroded {st.ErodedVolumeM3:F0} m³ over {st.ModifiedCells} touched cells " +
|
||||
$"(max cell carve {st.MaxCellErosionM:F2} m vs cap {p.CarveCapM:F2} m), deposited {st.DepositedVolumeM3:F0} m³ " +
|
||||
|
|
|
|||
|
|
@ -257,11 +257,12 @@ public partial class RoundTripHarness : Node
|
|||
var ea = a.Erosion; var eb = b.Erosion;
|
||||
bool same = ea.Version == eb.Version
|
||||
&& ea.DropletCount == eb.DropletCount && ea.Lifetime == eb.Lifetime
|
||||
&& ea.BrushRadius == eb.BrushRadius && ea.SeedOffset == eb.SeedOffset;
|
||||
&& ea.BrushRadius == eb.BrushRadius && ea.SeedOffset == eb.SeedOffset
|
||||
&& ea.CraterMode == eb.CraterMode;
|
||||
float[] fa = { ea.CarveCapM, ea.DepositCapM, ea.SeaMarginM, ea.Inertia, ea.CapacityFactor, ea.MinSlopeM,
|
||||
ea.ErodeRate, ea.DepositRate, ea.Evaporation, ea.Gravity, ea.CraterExclFactor };
|
||||
ea.ErodeRate, ea.DepositRate, ea.Evaporation, ea.Gravity, ea.CraterCoreFactor, ea.CraterFeatherFactor };
|
||||
float[] fb = { eb.CarveCapM, eb.DepositCapM, eb.SeaMarginM, eb.Inertia, eb.CapacityFactor, eb.MinSlopeM,
|
||||
eb.ErodeRate, eb.DepositRate, eb.Evaporation, eb.Gravity, eb.CraterExclFactor };
|
||||
eb.ErodeRate, eb.DepositRate, eb.Evaporation, eb.Gravity, eb.CraterCoreFactor, eb.CraterFeatherFactor };
|
||||
for (int i = 0; i < fa.Length; i++)
|
||||
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false;
|
||||
if (!same) { GD.PrintErr("[Harness] EROS fields differ."); return false; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue