feat: droplet hydraulic erosion — carve+deposit, three governors, sea clamp (terrain-water task 17, Phase C0)
Erosion: "off"|"v1" gate (default off, pending the developer's gate). Output-only: applied to the render map after detail, before the crater carve; the classify path reads pre-erosion heights, so biomes/water stay byte-identical (verified: OFF run bit-identical to the task-14 reference; ON run BIOM/WBID identical to OFF). Governors: droplet count / lifetime / per-cell carve cap (ledger-enforced, thrown on violation). Sea clamp: below-sea cells read-only, land never carved below sea+margin — rendered coastline provably fixed (asserted per generation). Crater excluded at 1.2×R. Deterministic from resolvedSeed+9271 (PCG32). New EROS blueprint section (writer/parser/harness/format doc). Supersedes the reverted D8 approach with the organic droplet model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
34b0e1c9e3
commit
e1e1fe3227
8 changed files with 611 additions and 22 deletions
|
|
@ -57,6 +57,7 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`.
|
|||
| `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. |
|
||||
| `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset`. **When `curveVersion ≥ 5`, a 9 B preset extension follows:** `u8 presetId` (1 = compact, 2 = balanced) · `f32 k5` · `f32 k6` — with the four base knot slots this makes the effective curve unambiguous from the record alone (record total 95 B; the length-framed section + version byte keep every layout change safe) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. |
|
||||
| `TDTL` / `0x4C544454` | 30 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `i32 reliefSeedOffset` · `f32 edgeAmpM` · `f32 edgeFreqIslands` · `i32 edgeSeedOffset` · `f32 edgeMaxShiftM` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve). **`detailVersion` selects the body layout and a reader that does not recognise it SKIPS the section** (leaving detail metadata null) rather than misreading a differently shaped payload — the one section whose body is versioned rather than extended, because v1's layout was retired rather than grown. **v1 (retired, never shipped)** — shelf micro-relief + D8 drainage incision, 38 B; the incision produced grid-aligned artifacts and was reverted whole, so the only v1 payloads that exist are in that batch's own tree. **v2 (current)** — shelf micro-relief (±`reliefAmpM` output metres, shelf-ness weighted) + shelf-edge variation: a per-column shift of the curve's shelf/riser knot block K3/K4/K5, drawn from a Simplex field seeded `resolvedWorldSeed + edgeSeedOffset` at `edgeFreqIslands/MapSize`, amplitude ±`edgeAmpM` **metres of INPUT height** — a displacement of the shelf boundary contour, not an elevation change. `edgeAmpM` is recorded **as applied**, after the clamp to `edgeMaxShiftM` (the preset's band-squeeze bound), so the record always describes the terrain rather than the request. **Metadata only** — heights are already detailed. Machinery: `Tools/Scripts/TerrainDetailPass.cs`. |
|
||||
| `EROS` / `0x534F5245` | 58 B: `u16 erosionVersion` · `i32 dropletCount` · `i32 lifetime` · `i32 brushRadius` · `i32 seedOffset` · `f32 carveCapM` · `f32 seaMarginM` · `f32 inertia` · `f32 capacityFactor` · `f32 minSlopeM` · `f32 erodeRate` · `f32 depositRate` · `f32 evaporation` · `f32 gravity` · `f32 craterExclFactor` | Optional — present iff the droplet hydraulic-erosion pass shaped this blueprint's `HGTS` (config `Erosion: "v1"`, terrain-water task 17). **`erosionVersion` selects the body layout; an unrecognised version is SKIPPED whole** (erosion metadata left null), same rule as `TDTL`. **v1 (current)** — the carve-and-deposit droplet model: `dropletCount`/`lifetime`/`carveCapM` are the three hard governors (total droplets, max steps each, max cumulative erosion depth per cell in metres); `seaMarginM` is the flood-guard clamp (no cell carved below sea + margin; below-sea cells untouched entirely, so the rendered coastline cannot move); droplets are deterministic from `resolvedWorldSeed + seedOffset` (PCG32); no cell within `craterExclFactor × CraterRadius` of the impact centre is modified. Strength constants (`inertia`, `capacityFactor`, `minSlopeM`, `erodeRate`, `depositRate`, `evaporation`, `gravity`) are the standard droplet-model dials; slopes/amounts in metres (1 raw = 251 m). All values recorded **as applied** (post config clamping). **Metadata only** — heights are already eroded, and the classify-side sections (`BIOM`/`WBID`/…) never saw the pass by design. Machinery: `Tools/Scripts/HydraulicErosion.cs`. |
|
||||
| `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. |
|
||||
| `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. |
|
||||
| `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q − 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. |
|
||||
|
|
@ -121,6 +122,15 @@ body).
|
|||
construction (it classifies the retained uncurved heights); town positions and everything 3D
|
||||
follow the curved terrain. When on, `TCRV` records the effective parameters including the
|
||||
per-seed `spikeMax`.
|
||||
- **`Erosion`** (`"v1"` | `"off"`, default `"off"` — opt-in until the developer's gate approves
|
||||
it) — the task-17 droplet hydraulic-erosion pass: carve-and-deposit drainage detailing of
|
||||
`HGTS` after the detail passes and before the crater carve. Governor dials
|
||||
(`ErosionDropletCount` / `ErosionDropletLifetime` / `ErosionCarveCap`, defaults 400 000 / 48 /
|
||||
8 m; bounds clamped loudly at load) plus the sea-clamp margin and strength constants — the
|
||||
full dial list and semantics live in the `EROS` row above and `ConfigManager.cs`. Output-height
|
||||
only: the classify path reads pre-erosion heights, so `BIOM` and every water section stay
|
||||
bit-identical with erosion on or off, and the sea clamp keeps even the RENDERED coastline
|
||||
fixed. When on, `EROS` records the parameters as applied.
|
||||
|
||||
- **`CoastProfile`** (`"wide"` | `"steep"`, default `"wide"`) — the submarine shelf. The height
|
||||
curve is identity at and below sea level, so it never reshaped the seabed; `"wide"` compresses
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ namespace IslaApocalypse.Core
|
|||
public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF"
|
||||
public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV"
|
||||
public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL"
|
||||
public const uint TAG_EROSION = 0x534F5245; // "EROS"
|
||||
|
||||
// TDTL body version. 1 = shelf micro-relief + D8 drainage incision (the
|
||||
// task-10 draft; the incision was reverted, and the only v1 payloads in
|
||||
|
|
@ -42,6 +43,12 @@ namespace IslaApocalypse.Core
|
|||
// differently shaped payload into plausible-looking nonsense.
|
||||
public const ushort TDTL_VERSION = 2;
|
||||
|
||||
// EROS body version (terrain-water task 17). 1 = the droplet hydraulic-
|
||||
// erosion params block: governors (count/lifetime/carve cap), sea clamp,
|
||||
// brush, strength constants, RNG seed offset, crater exclusion factor.
|
||||
// Same reader rule as TDTL: an unknown body version is skipped whole.
|
||||
public const ushort EROS_VERSION = 1;
|
||||
|
||||
// WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L
|
||||
// (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine
|
||||
// level can never encode to 0. Decodes back via (q − 1) / 32768. Covers
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ namespace IslaApocalypse.Core
|
|||
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve));
|
||||
if (bp.TerrainDetail != null)
|
||||
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail));
|
||||
if (bp.Erosion != null)
|
||||
WriteSection(writer, BlueprintFormat.TAG_EROSION, w => WriteErosion(w, bp.Erosion));
|
||||
WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp));
|
||||
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp));
|
||||
|
||||
|
|
@ -163,6 +165,19 @@ namespace IslaApocalypse.Core
|
|||
writer.Write(d.EdgeMaxShiftM); // f32
|
||||
}
|
||||
|
||||
private static void WriteErosion(BinaryWriter writer, ErosionInfo e)
|
||||
{
|
||||
writer.Write(e.Version); // u16
|
||||
writer.Write(e.DropletCount); writer.Write(e.Lifetime); // 2 × i32
|
||||
writer.Write(e.BrushRadius); writer.Write(e.SeedOffset); // 2 × i32
|
||||
writer.Write(e.CarveCapM); writer.Write(e.SeaMarginM); // 2 × f32
|
||||
writer.Write(e.Inertia); writer.Write(e.CapacityFactor); // 2 × f32
|
||||
writer.Write(e.MinSlopeM); // f32
|
||||
writer.Write(e.ErodeRate); writer.Write(e.DepositRate); // 2 × f32
|
||||
writer.Write(e.Evaporation); writer.Write(e.Gravity); // 2 × f32
|
||||
writer.Write(e.CraterExclFactor); // f32
|
||||
}
|
||||
|
||||
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)
|
||||
{
|
||||
int n = bp.MapSize;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,31 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
|||
public static float ShelfReliefAmp = 3.0f;
|
||||
public static float ShelfEdgeVariation = 12.0f;
|
||||
|
||||
// Hydraulic erosion (task 17, Phase C0): droplet-based carve-and-deposit on
|
||||
// the RENDER height map only — the classify path (biomes/water) never sees
|
||||
// it. "off" until the developer's gate approves it; the batch that turns it
|
||||
// on does so explicitly. The three GOVERNORS hard-bound the pass:
|
||||
// DropletCount (cost/detail), DropletLifetime (max steps per droplet),
|
||||
// CarveCap (max erosion depth per cell, metres — the runaway-trench guard
|
||||
// and what keeps erosion a detailing pass). ErosionSeaMargin is the flood
|
||||
// guard: no cell is ever carved below sea + margin, and below-sea cells are
|
||||
// never touched at all, so the rendered coastline cannot move. The remaining
|
||||
// dials are the standard droplet-model strength constants; slopes/amounts
|
||||
// are in METRES (1 raw height unit = 251 m).
|
||||
public static string Erosion = "off";
|
||||
public static int ErosionDropletCount = 400000;
|
||||
public static int ErosionDropletLifetime = 48;
|
||||
public static float ErosionCarveCap = 8.0f; // m per cell
|
||||
public static float ErosionSeaMargin = 0.5f; // m above sea, carve floor
|
||||
public static int ErosionBrushRadius = 2; // px
|
||||
public static float ErosionInertia = 0.05f;
|
||||
public static float ErosionCapacity = 4.0f;
|
||||
public static float ErosionMinSlope = 0.01f; // m per px, capacity floor
|
||||
public static float ErosionErodeRate = 0.3f;
|
||||
public static float ErosionDepositRate = 0.3f;
|
||||
public static float ErosionEvaporation = 0.02f;
|
||||
public static float ErosionGravity = 4.0f;
|
||||
|
||||
// Island falloff shaping (task 11).
|
||||
//
|
||||
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
|
||||
|
|
@ -178,6 +203,48 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
|||
ShelfEdgeVariation = (float)data["ShelfEdgeVariation"];
|
||||
}
|
||||
|
||||
// Extract the erosion gate + dials (task 17)
|
||||
if (data.ContainsKey("Erosion"))
|
||||
{
|
||||
string erosion = (string)data["Erosion"];
|
||||
if (erosion == "off" || erosion == "v1")
|
||||
Erosion = erosion;
|
||||
else
|
||||
GD.PrintErr($"[ConfigManager] Unknown Erosion '{erosion}'. Keeping '{Erosion}'.");
|
||||
}
|
||||
if (data.ContainsKey("ErosionDropletCount")) ErosionDropletCount = (int)data["ErosionDropletCount"];
|
||||
if (data.ContainsKey("ErosionDropletLifetime")) ErosionDropletLifetime = (int)data["ErosionDropletLifetime"];
|
||||
if (data.ContainsKey("ErosionCarveCap")) ErosionCarveCap = (float)data["ErosionCarveCap"];
|
||||
if (data.ContainsKey("ErosionSeaMargin")) ErosionSeaMargin = (float)data["ErosionSeaMargin"];
|
||||
if (data.ContainsKey("ErosionBrushRadius")) ErosionBrushRadius = (int)data["ErosionBrushRadius"];
|
||||
if (data.ContainsKey("ErosionInertia")) ErosionInertia = (float)data["ErosionInertia"];
|
||||
if (data.ContainsKey("ErosionCapacity")) ErosionCapacity = (float)data["ErosionCapacity"];
|
||||
if (data.ContainsKey("ErosionMinSlope")) ErosionMinSlope = (float)data["ErosionMinSlope"];
|
||||
if (data.ContainsKey("ErosionErodeRate")) ErosionErodeRate = (float)data["ErosionErodeRate"];
|
||||
if (data.ContainsKey("ErosionDepositRate")) ErosionDepositRate = (float)data["ErosionDepositRate"];
|
||||
if (data.ContainsKey("ErosionEvaporation")) ErosionEvaporation = (float)data["ErosionEvaporation"];
|
||||
if (data.ContainsKey("ErosionGravity")) ErosionGravity = (float)data["ErosionGravity"];
|
||||
|
||||
// Governor bounds are enforced HERE, loudly, so a bad dial is a refused
|
||||
// dial rather than a silently absurd generation. The clamps are wide —
|
||||
// they exist to catch typos (an extra zero), not to tune.
|
||||
int rawCount = ErosionDropletCount; int rawLife = ErosionDropletLifetime;
|
||||
float rawCap = ErosionCarveCap;
|
||||
ErosionDropletCount = Mathf.Clamp(ErosionDropletCount, 0, 50_000_000);
|
||||
ErosionDropletLifetime = Mathf.Clamp(ErosionDropletLifetime, 1, 4096);
|
||||
ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f);
|
||||
if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap)
|
||||
GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m.");
|
||||
ErosionSeaMargin = Mathf.Clamp(ErosionSeaMargin, 0f, 5f);
|
||||
ErosionBrushRadius = Mathf.Clamp(ErosionBrushRadius, 0, 8);
|
||||
ErosionInertia = Mathf.Clamp(ErosionInertia, 0f, 0.99f);
|
||||
ErosionCapacity = Mathf.Max(ErosionCapacity, 0f);
|
||||
ErosionMinSlope = Mathf.Max(ErosionMinSlope, 0f);
|
||||
ErosionErodeRate = Mathf.Clamp(ErosionErodeRate, 0f, 1f);
|
||||
ErosionDepositRate = Mathf.Clamp(ErosionDepositRate, 0f, 1f);
|
||||
ErosionEvaporation = Mathf.Clamp(ErosionEvaporation, 0f, 0.5f);
|
||||
ErosionGravity = Mathf.Max(ErosionGravity, 0f);
|
||||
|
||||
// Extract the island-falloff dials (task 11)
|
||||
if (data.ContainsKey("CoastProfile"))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -104,6 +104,23 @@ namespace IslaApocalypse.Core
|
|||
public float EdgeMaxShiftM;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The hydraulic-erosion pass that detailed this blueprint's HGTS (v2 EROS
|
||||
/// section, terrain-water task 17): droplet-model governors and strength
|
||||
/// constants, as APPLIED (post config clamping). Null when erosion was off.
|
||||
/// Metadata only — heights are already eroded; the classify-side data (biomes,
|
||||
/// water) never saw the pass by design.
|
||||
/// </summary>
|
||||
public class ErosionInfo
|
||||
{
|
||||
public ushort Version;
|
||||
public int DropletCount, Lifetime, BrushRadius, SeedOffset;
|
||||
public float CarveCapM, SeaMarginM;
|
||||
public float Inertia, CapacityFactor, MinSlopeM;
|
||||
public float ErodeRate, DepositRate, Evaporation, Gravity;
|
||||
public float CraterExclFactor;
|
||||
}
|
||||
|
||||
public class WorldBlueprint
|
||||
{
|
||||
public int MapSize;
|
||||
|
|
@ -134,6 +151,9 @@ namespace IslaApocalypse.Core
|
|||
|
||||
// The detail passes that shaped HeightMap (TDTL section); null = no detail.
|
||||
public TerrainDetailInfo TerrainDetail;
|
||||
|
||||
// The erosion pass that detailed HeightMap (EROS section); null = no erosion.
|
||||
public ErosionInfo Erosion;
|
||||
}
|
||||
|
||||
// 2. The Parser Utility
|
||||
|
|
@ -258,6 +278,7 @@ namespace IslaApocalypse.Core
|
|||
else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint);
|
||||
else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint);
|
||||
else if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(reader, blueprint, payloadLength);
|
||||
else if (tag == BlueprintFormat.TAG_EROSION) sectionOk = ParseErosion(reader, blueprint, payloadLength);
|
||||
else
|
||||
{
|
||||
// The property the redesign exists to buy: future sections (water,
|
||||
|
|
@ -474,6 +495,30 @@ namespace IslaApocalypse.Core
|
|||
return true;
|
||||
}
|
||||
|
||||
private static bool ParseErosion(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength)
|
||||
{
|
||||
var e = new ErosionInfo();
|
||||
e.Version = reader.ReadUInt16();
|
||||
if (e.Version != BlueprintFormat.EROS_VERSION)
|
||||
{
|
||||
// Same rule as TDTL: an unrecognised body version is skipped whole
|
||||
// rather than misread into plausible-looking nonsense.
|
||||
GD.PrintErr($"[MapDataParser] ⚠ EROS version {e.Version} is not the current {BlueprintFormat.EROS_VERSION} — section skipped, erosion metadata unavailable.");
|
||||
reader.BaseStream.Seek((long)payloadLength - 2L, SeekOrigin.Current);
|
||||
return true;
|
||||
}
|
||||
e.DropletCount = reader.ReadInt32(); e.Lifetime = reader.ReadInt32();
|
||||
e.BrushRadius = reader.ReadInt32(); e.SeedOffset = reader.ReadInt32();
|
||||
e.CarveCapM = reader.ReadSingle(); e.SeaMarginM = reader.ReadSingle();
|
||||
e.Inertia = reader.ReadSingle(); e.CapacityFactor = reader.ReadSingle();
|
||||
e.MinSlopeM = reader.ReadSingle();
|
||||
e.ErodeRate = reader.ReadSingle(); e.DepositRate = reader.ReadSingle();
|
||||
e.Evaporation = reader.ReadSingle(); e.Gravity = reader.ReadSingle();
|
||||
e.CraterExclFactor = reader.ReadSingle();
|
||||
blueprint.Erosion = e;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
|
||||
{
|
||||
int pathCount = reader.ReadInt32();
|
||||
|
|
|
|||
310
Tools/Scripts/HydraulicErosion.cs
Normal file
310
Tools/Scripts/HydraulicErosion.cs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
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. 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 cumulative erosion depth per cell, in metres,
|
||||
/// enforced against a per-cell ledger. The runaway-trench
|
||||
/// guard, and what keeps this a DETAILING pass.
|
||||
///
|
||||
/// 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
|
||||
{
|
||||
public const ushort VERSION = 1;
|
||||
|
||||
// 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 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 long ErodedCells; // cells with any net ledger erosion
|
||||
}
|
||||
|
||||
// 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 cumulative-erosion ledger — governor 3's enforcement record.
|
||||
float[,] eroded = 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 at
|
||||
// the OLD position, bilinear over its 4 cells.
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 - eroded[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];
|
||||
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.");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,13 +27,14 @@ public partial class MapGenerator : TextureRect
|
|||
|
||||
private float[,] _heightMap;
|
||||
|
||||
// Classification heightmap (task 05): the UNCURVED heights (plus the crater
|
||||
// carve), i.e. exactly what the curve-off pipeline produces. Biome rules, the
|
||||
// two flood fills, and the shared water predicates read THIS map, so biome and
|
||||
// water output is identical with the curve on or off — the bit-identical-biomes
|
||||
// oracle holds by construction. Towns, roads, diagnostics, and the exported
|
||||
// heights use the curved _heightMap (they live in the 3D world). When the curve
|
||||
// is off this is the SAME array as _heightMap (aliased, no copy).
|
||||
// Classification heightmap (task 05): the UNCURVED, UNERODED heights (plus the
|
||||
// crater carve), i.e. exactly what the curve-off pipeline produces. Biome rules,
|
||||
// the two flood fills, and the shared water predicates read THIS map, so biome
|
||||
// and water output is identical with the curve and erosion on or off — the
|
||||
// bit-identical-biomes oracle holds by construction. Towns, roads, diagnostics,
|
||||
// and the exported heights use the curved (and, when on, eroded) _heightMap
|
||||
// (they live in the 3D world). When every render-only pass is off this is the
|
||||
// SAME array as _heightMap (aliased, no copy).
|
||||
private float[,] _heightMapClassify;
|
||||
private bool _curveOn;
|
||||
|
||||
|
|
@ -49,6 +50,10 @@ public partial class MapGenerator : TextureRect
|
|||
// v5: the selected knot preset; null = curve off.
|
||||
private CurveKnots _curveKnots;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// the curve's bands). The relief noise seeds from resolvedSeed + 7409, the
|
||||
|
|
@ -108,9 +113,13 @@ public partial class MapGenerator : TextureRect
|
|||
_heightMap = new float[MapSize, MapSize];
|
||||
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
|
||||
_curveOn = _curveKnots != null;
|
||||
_erosionOn = ConfigManager.Erosion == "v1";
|
||||
// (The monotonicity assertion now runs inside GenerateTopography, against the
|
||||
// effective per-seed curve, once hMaxSeed is known.)
|
||||
_heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap;
|
||||
// Classify gets its own array whenever ANY render-only pass diverges the two
|
||||
// maps — the curve, or erosion (which must not leak into classify even with
|
||||
// the curve off; aliased arrays would be exactly that leak).
|
||||
_heightMapClassify = (_curveOn || _erosionOn) ? new float[MapSize, MapSize] : _heightMap;
|
||||
_tempMap = new float[MapSize, MapSize];
|
||||
_biomeMap = new Biome[MapSize, MapSize];
|
||||
_isTrueOcean = new bool[MapSize, MapSize];
|
||||
|
|
@ -364,6 +373,26 @@ public partial class MapGenerator : TextureRect
|
|||
StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET,
|
||||
PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6
|
||||
} : null,
|
||||
// EROS: the erosion params AS APPLIED (post config clamping) — a blueprint
|
||||
// with eroded heights is self-describing without the config that made it.
|
||||
Erosion = _erosionOn ? new ErosionInfo
|
||||
{
|
||||
Version = HydraulicErosion.VERSION,
|
||||
DropletCount = ConfigManager.ErosionDropletCount,
|
||||
Lifetime = ConfigManager.ErosionDropletLifetime,
|
||||
BrushRadius = ConfigManager.ErosionBrushRadius,
|
||||
SeedOffset = HydraulicErosion.SEED_OFFSET,
|
||||
CarveCapM = ConfigManager.ErosionCarveCap,
|
||||
SeaMarginM = ConfigManager.ErosionSeaMargin,
|
||||
Inertia = ConfigManager.ErosionInertia,
|
||||
CapacityFactor = ConfigManager.ErosionCapacity,
|
||||
MinSlopeM = ConfigManager.ErosionMinSlope,
|
||||
ErodeRate = ConfigManager.ErosionErodeRate,
|
||||
DepositRate = ConfigManager.ErosionDepositRate,
|
||||
Evaporation = ConfigManager.ErosionEvaporation,
|
||||
Gravity = ConfigManager.ErosionGravity,
|
||||
CraterExclFactor = HydraulicErosion.CRATER_EXCL_FACTOR
|
||||
} : null,
|
||||
TerrainDetail = _detailOn ? new TerrainDetailInfo
|
||||
{
|
||||
Version = TerrainDetailPass.VERSION,
|
||||
|
|
@ -594,7 +623,8 @@ public partial class MapGenerator : TextureRect
|
|||
// curve — after hMaxSeed is known, before any pixel is curved.
|
||||
if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots, _detailOn ? _edgeAmpRaw : 0f);
|
||||
|
||||
// --- PASS 2: curve (task 05/06) + detail (task 10) + crater carve ---
|
||||
// --- PASS 2: curve (task 05/06) + detail (task 10), then erosion (task 17),
|
||||
// then the crater carve — three sub-passes (2a/2b/2c) in that order. ---
|
||||
// Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so
|
||||
// the carve cuts into curved terrain and the rim/bowl shape is untouched by
|
||||
// the curve. Identity at and below sea + this ordering preserve the
|
||||
|
|
@ -646,23 +676,100 @@ public partial class MapGenerator : TextureRect
|
|||
curvedH = raw;
|
||||
}
|
||||
|
||||
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
|
||||
// Both maps are carved from LOCALS and written once, so the curve-off
|
||||
// aliasing (classify and height are the same array) cannot double-carve.
|
||||
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
||||
if (distToCrater < physicalCraterRadius)
|
||||
{
|
||||
float craterDepth = 1.0f - (distToCrater / physicalCraterRadius);
|
||||
// Dialed back to -0.15f as per your excellent instinct!
|
||||
float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
|
||||
classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
|
||||
curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
|
||||
}
|
||||
|
||||
_heightMapClassify[x, y] = classifyH;
|
||||
_heightMap[x, y] = curvedH;
|
||||
}
|
||||
}
|
||||
|
||||
// --- EROSION (task 17): render map ONLY — after detail, BEFORE the crater
|
||||
// carve. Output-only by construction: _heightMapClassify was finalized above
|
||||
// (bar the carve) and the pass never sees it, so biomes/water classify
|
||||
// pre-erosion — the oracle. The pass's sea clamp plus its below-sea
|
||||
// read-only rule mean the RENDERED coastline cannot move either; that is
|
||||
// verified here, not assumed, by counting render-map water pixels A/B.
|
||||
if (_erosionOn)
|
||||
{
|
||||
ulong tEro0 = Time.GetTicksMsec();
|
||||
float[,] seaMap = null;
|
||||
float seaFlat = ConfigManager.SeaLevelValue;
|
||||
if (ConfigManager.SeaLevelModel != "flat")
|
||||
{
|
||||
seaMap = new float[MapSize, MapSize];
|
||||
for (int x = 0; x < MapSize; x++)
|
||||
for (int y = 0; y < MapSize; y++)
|
||||
seaMap[x, y] = GetSeaLevel(_tempMap[x, y]);
|
||||
}
|
||||
long wetBefore = CountRenderWaterPixels(seaMap, seaFlat);
|
||||
|
||||
var p = new HydraulicErosion.Params
|
||||
{
|
||||
DropletCount = ConfigManager.ErosionDropletCount,
|
||||
Lifetime = ConfigManager.ErosionDropletLifetime,
|
||||
CarveCapM = ConfigManager.ErosionCarveCap,
|
||||
SeaMarginM = ConfigManager.ErosionSeaMargin,
|
||||
BrushRadius = ConfigManager.ErosionBrushRadius,
|
||||
Inertia = ConfigManager.ErosionInertia,
|
||||
CapacityFactor = ConfigManager.ErosionCapacity,
|
||||
MinSlopeM = ConfigManager.ErosionMinSlope,
|
||||
ErodeRate = ConfigManager.ErosionErodeRate,
|
||||
DepositRate = ConfigManager.ErosionDepositRate,
|
||||
Evaporation = ConfigManager.ErosionEvaporation,
|
||||
Gravity = ConfigManager.ErosionGravity,
|
||||
Seed = _noise.Seed + HydraulicErosion.SEED_OFFSET
|
||||
};
|
||||
var st = HydraulicErosion.Apply(_heightMap, MapSize, seaMap, seaFlat,
|
||||
_impactCenter.X, _impactCenter.Y,
|
||||
_impactRadius * HydraulicErosion.CRATER_EXCL_FACTOR, 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] 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.ErodedCells} cells " +
|
||||
$"(max cell {st.MaxCellErosionM:F2} m vs cap {p.CarveCapM:F2} m), deposited {st.DepositedVolumeM3:F0} m³. " +
|
||||
$"Deaths: {st.DiedSea} sea / {st.DiedEdge} edge / {st.DiedDry} dry / {st.DiedLifetime} lifetime. " +
|
||||
$"Water pixels {wetBefore} -> {wetAfter} (flood guard holds).");
|
||||
}
|
||||
|
||||
// --- PASS 2c: CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
|
||||
// The carve stays LAST — the final authority on its own terrain (erosion is
|
||||
// also excluded within 1.2× CraterRadius, entirely clear of this 0.8×
|
||||
// physical carve). Both heights are read into locals BEFORE either write, so
|
||||
// the all-passes-off aliasing (classify and height are the same array)
|
||||
// cannot double-carve. We only carve the physical hole at 80% of the radius
|
||||
// to guarantee a landbridge!
|
||||
int cx0 = Mathf.Max(0, (int)(_impactCenter.X - physicalCraterRadius) - 1);
|
||||
int cx1 = Mathf.Min(MapSize - 1, (int)(_impactCenter.X + physicalCraterRadius) + 1);
|
||||
int cy0 = Mathf.Max(0, (int)(_impactCenter.Y - physicalCraterRadius) - 1);
|
||||
int cy1 = Mathf.Min(MapSize - 1, (int)(_impactCenter.Y + physicalCraterRadius) + 1);
|
||||
for (int x = cx0; x <= cx1; x++)
|
||||
{
|
||||
for (int y = cy0; y <= cy1; y++)
|
||||
{
|
||||
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||
if (distToCrater >= physicalCraterRadius) continue;
|
||||
float craterDepth = 1.0f - (distToCrater / physicalCraterRadius);
|
||||
// Dialed back to -0.15f as per your excellent instinct!
|
||||
float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
|
||||
float classifyH = _heightMapClassify[x, y];
|
||||
float curvedH = _heightMap[x, y];
|
||||
_heightMapClassify[x, y] = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
|
||||
_heightMap[x, y] = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render-map water pixel count — the erosion flood-guard's external check.
|
||||
private long CountRenderWaterPixels(float[,] seaMap, float seaFlat)
|
||||
{
|
||||
long wet = 0;
|
||||
for (int x = 0; x < MapSize; x++)
|
||||
for (int y = 0; y < MapSize; y++)
|
||||
if (_heightMap[x, y] < (seaMap != null ? seaMap[x, y] : seaFlat))
|
||||
wet++;
|
||||
return wet;
|
||||
}
|
||||
|
||||
private void CalculateTrueOcean()
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ public partial class RoundTripHarness : Node
|
|||
ok &= CompareWater(a, b);
|
||||
ok &= CompareTerrainCurve(a, b);
|
||||
ok &= CompareTerrainDetail(a, b);
|
||||
ok &= CompareErosion(a, b);
|
||||
|
||||
if (ok)
|
||||
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
||||
|
|
@ -241,6 +242,33 @@ public partial class RoundTripHarness : Node
|
|||
return true;
|
||||
}
|
||||
|
||||
private bool CompareErosion(WorldBlueprint a, WorldBlueprint b)
|
||||
{
|
||||
if (a.Erosion == null && b.Erosion == null)
|
||||
{
|
||||
GD.Print("[Harness] EROS: absent in source — nothing to compare (and none reappeared).");
|
||||
return true;
|
||||
}
|
||||
if (a.Erosion == null || b.Erosion == null)
|
||||
{
|
||||
GD.PrintErr("[Harness] EROS presence mismatch between source and reread.");
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
float[] fa = { ea.CarveCapM, ea.SeaMarginM, ea.Inertia, ea.CapacityFactor, ea.MinSlopeM,
|
||||
ea.ErodeRate, ea.DepositRate, ea.Evaporation, ea.Gravity, ea.CraterExclFactor };
|
||||
float[] fb = { eb.CarveCapM, eb.SeaMarginM, eb.Inertia, eb.CapacityFactor, eb.MinSlopeM,
|
||||
eb.ErodeRate, eb.DepositRate, eb.Evaporation, eb.Gravity, eb.CraterExclFactor };
|
||||
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; }
|
||||
GD.Print($"[Harness] EROS equal (erosion v{ea.Version}).");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
|
||||
{
|
||||
if (a.Count != b.Count)
|
||||
|
|
|
|||
Loading…
Reference in a new issue