Compare commits
30 commits
1b98fb5a17
...
1eacd22972
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eacd22972 | |||
| 6960c2a2e8 | |||
| a53d4e8512 | |||
| 2de9502e4f | |||
| c60510beb7 | |||
| ae97e48229 | |||
| 78e042b805 | |||
| f810ff44dc | |||
| 85e7bceb72 | |||
| cc6a24d2b9 | |||
| 56ac17e670 | |||
| e1e1fe3227 | |||
| 34b0e1c9e3 | |||
| b9c7675e78 | |||
| 69bf91b3d6 | |||
| 3b5bc5e5d6 | |||
| 9c255a4fc6 | |||
| a6d4b870fc | |||
| 6fd00cbfa5 | |||
| 28095f25c5 | |||
| 5ba868f038 | |||
| 6c318e4df0 | |||
| 566abe9784 | |||
| 896a428d47 | |||
| 59f49ac8a2 | |||
| af1a6d912b | |||
| ed16a7f668 | |||
| 492b56a87c | |||
| 3b6d166458 | |||
| e3f7b508e7 |
30 changed files with 3281 additions and 294 deletions
|
|
@ -17,6 +17,21 @@ A `MeshInstance3D` created per chunk by the server, which then calls `RenderChun
|
||||||
placed by their footprint and carry their full height internally.
|
placed by their footprint and carry their full height internally.
|
||||||
- **Backface rendering.** `CullMode` is disabled, so the world is still visible from underneath or
|
- **Backface rendering.** `CullMode` is disabled, so the world is still visible from underneath or
|
||||||
from inside terrain.
|
from inside terrain.
|
||||||
|
- **Water at rest** (task 13). `BuildWaterSurface` adds a second `MeshInstance3D` as a child, a flat
|
||||||
|
sheet at each cell's water level, from the per-column water data the server read out of the
|
||||||
|
blueprint. Skipped entirely for chunks with no water.
|
||||||
|
|
||||||
|
**Why a separate mesh rather than a water block.** The terrain is a Marching-Cubes iso-surface
|
||||||
|
over the density field. Putting water into that field would not lay a sheet on top of the seabed —
|
||||||
|
it would move the iso-surface, fusing the sea into the terrain as if it were solid ground. A
|
||||||
|
second surface is the only way water can sit at *its* level independent of the ground beneath it.
|
||||||
|
|
||||||
|
That also makes translucency nearly free: a distinct `MeshInstance3D` carries its own
|
||||||
|
`StandardMaterial3D`, so alpha is one flag and Godot sorts transparent surfaces after opaque ones
|
||||||
|
itself. Colour and alpha both ramp with depth (shallow teal and clearer → deep navy and near
|
||||||
|
opaque), so the shelved coast stays readable.
|
||||||
|
|
||||||
|
Water is **flat and static** — no waves, no displacement, no animation. Those are Phase C2+.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,133 @@ namespace IslaApocalypse.Client
|
||||||
0,
|
0,
|
||||||
data.ChunkPosition.Y * Constants.CHUNK_SIZE_Z * Constants.VOXEL_SCALE
|
data.ChunkPosition.Y * Constants.CHUNK_SIZE_Z * Constants.VOXEL_SCALE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 3. Water at rest (task 13) — its own mesh, its own material.
|
||||||
|
BuildWaterSurface(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The water sheet, as a SEPARATE MeshInstance3D child.
|
||||||
|
///
|
||||||
|
/// Why separate rather than a block in the terrain field: the terrain is a
|
||||||
|
/// Marching-Cubes iso-surface over ChunkData.Densities. Writing water into
|
||||||
|
/// that field would not lay a sheet on top of the seabed — it would move the
|
||||||
|
/// iso-surface, fusing the water into the terrain as if the sea were solid
|
||||||
|
/// ground. A second surface is the only way to have water sit AT its own
|
||||||
|
/// level, independent of the ground beneath it.
|
||||||
|
///
|
||||||
|
/// It also makes translucency nearly free, which is why this task ships it
|
||||||
|
/// rather than deferring: a distinct MeshInstance3D carries its own
|
||||||
|
/// StandardMaterial3D, so alpha is one flag and Godot sorts transparent
|
||||||
|
/// surfaces after opaque ones on its own. No mesher change at all.
|
||||||
|
///
|
||||||
|
/// Water at rest is FLAT — one level per cell, taken from the blueprint's own
|
||||||
|
/// body levels. No waves, no displacement, no animation (those are C2+).
|
||||||
|
/// </summary>
|
||||||
|
private void BuildWaterSurface(ChunkData data)
|
||||||
|
{
|
||||||
|
if (!data.HasAnyWater) return; // most inland chunks: nothing to build
|
||||||
|
|
||||||
|
var st = new SurfaceTool();
|
||||||
|
st.Begin(Mesh.PrimitiveType.Triangles);
|
||||||
|
|
||||||
|
int cellsX = Constants.CHUNK_SIZE_X;
|
||||||
|
int cellsZ = Constants.CHUNK_SIZE_Z;
|
||||||
|
bool any = false;
|
||||||
|
|
||||||
|
for (int x = 0; x < cellsX; x++)
|
||||||
|
{
|
||||||
|
for (int z = 0; z < cellsZ; z++)
|
||||||
|
{
|
||||||
|
// A cell is drawn if ANY of its four corners is wet, and it is drawn
|
||||||
|
// FLAT at the deepest-standing level among those wet corners.
|
||||||
|
//
|
||||||
|
// Drawing on a partly-dry cell is deliberate: it carries the sheet
|
||||||
|
// right up under the shoreline, where the opaque terrain in front of
|
||||||
|
// it hides the submerged part. The alternative — only fully-wet
|
||||||
|
// cells — retreats the waterline a metre from shore and leaves a
|
||||||
|
// visible dry gap all the way around every coast and lake.
|
||||||
|
float level = Constants.NO_WATER;
|
||||||
|
float d00 = data.WaterSurfaceY[x, z];
|
||||||
|
float d10 = data.WaterSurfaceY[x + 1, z];
|
||||||
|
float d01 = data.WaterSurfaceY[x, z + 1];
|
||||||
|
float d11 = data.WaterSurfaceY[x + 1, z + 1];
|
||||||
|
if (d00 > level) level = d00;
|
||||||
|
if (d10 > level) level = d10;
|
||||||
|
if (d01 > level) level = d01;
|
||||||
|
if (d11 > level) level = d11;
|
||||||
|
if (level <= Constants.NO_WATER) continue;
|
||||||
|
|
||||||
|
// Depth for colour: the deepest wet corner of the cell, so a shelving
|
||||||
|
// coast reads as a gradient rather than a staircase of flat tiles.
|
||||||
|
float depth = Mathf.Max(
|
||||||
|
Mathf.Max(data.WaterDepthM[x, z], data.WaterDepthM[x + 1, z]),
|
||||||
|
Mathf.Max(data.WaterDepthM[x, z + 1], data.WaterDepthM[x + 1, z + 1]));
|
||||||
|
|
||||||
|
Color c = DepthColor(depth);
|
||||||
|
float s = Constants.VOXEL_SCALE;
|
||||||
|
|
||||||
|
var p00 = new Vector3(x * s, level, z * s);
|
||||||
|
var p10 = new Vector3((x + 1) * s, level, z * s);
|
||||||
|
var p01 = new Vector3(x * s, level, (z + 1) * s);
|
||||||
|
var p11 = new Vector3((x + 1) * s, level, (z + 1) * s);
|
||||||
|
|
||||||
|
// Two triangles, wound so the surface faces up.
|
||||||
|
AddVert(st, c, p00); AddVert(st, c, p01); AddVert(st, c, p11);
|
||||||
|
AddVert(st, c, p00); AddVert(st, c, p11); AddVert(st, c, p10);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!any) return;
|
||||||
|
|
||||||
|
var water = new MeshInstance3D();
|
||||||
|
water.Mesh = st.Commit();
|
||||||
|
|
||||||
|
var mat = new StandardMaterial3D();
|
||||||
|
mat.VertexColorUseAsAlbedo = true;
|
||||||
|
// Vertex alpha is only honoured when the material is actually transparent.
|
||||||
|
mat.Transparency = BaseMaterial3D.TransparencyEnum.Alpha;
|
||||||
|
// Lighting note: the scene's environment is minimal (one default
|
||||||
|
// DirectionalLight3D, a blue ambient), so a metallic surface reads almost
|
||||||
|
// black — metal shows its surroundings, and there is little to show. Water
|
||||||
|
// is therefore non-metallic with moderate roughness: albedo stays visible
|
||||||
|
// under weak ambient, and it still catches a highlight when a light does
|
||||||
|
// hit it. Left deliberately in the same lighting world as the terrain so
|
||||||
|
// the two improve together when the scene gets proper lighting.
|
||||||
|
mat.Roughness = 0.35f;
|
||||||
|
mat.Metallic = 0.0f;
|
||||||
|
// Both faces: the camera starts above, but a player will swim under it.
|
||||||
|
mat.CullMode = BaseMaterial3D.CullModeEnum.Disabled;
|
||||||
|
water.MaterialOverride = mat;
|
||||||
|
|
||||||
|
// Child of this chunk renderer, so it inherits the chunk's world offset
|
||||||
|
// and dies with the chunk.
|
||||||
|
AddChild(water);
|
||||||
|
water.Position = Vector3.Zero;
|
||||||
|
water.Name = "Water";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddVert(SurfaceTool st, Color c, Vector3 p)
|
||||||
|
{
|
||||||
|
// SetColor/SetNormal must be set immediately before AddVertex — same rule
|
||||||
|
// the terrain mesher follows.
|
||||||
|
st.SetColor(c);
|
||||||
|
st.SetNormal(Vector3.Up);
|
||||||
|
st.AddVertex(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shallow teal to deep navy, with alpha closing up as it deepens, so the
|
||||||
|
/// shelved coast stays readable and open ocean does not.
|
||||||
|
/// </summary>
|
||||||
|
private static Color DepthColor(float depthMeters)
|
||||||
|
{
|
||||||
|
float t = Mathf.Clamp(depthMeters / Constants.WATER_DEEP_METERS, 0f, 1f);
|
||||||
|
t = t * t * (3f - 2f * t); // smoothstep: hold the shallows, then fall away
|
||||||
|
Color c = Constants.WATER_SHALLOW.Lerp(Constants.WATER_DEEP, t);
|
||||||
|
c.A = Mathf.Lerp(Constants.WATER_ALPHA_SHALLOW, Constants.WATER_ALPHA_DEEP, t);
|
||||||
|
return c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`.
|
||||||
| `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. |
|
| `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. |
|
||||||
| `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. |
|
| `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. |
|
| `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` | 38 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `f32 incK` · `f32 incP` · `f32 incCapM` · `f32 seaClampRaw` · `f32 craterExclFactor` · `f32 shelfIncWeight` · `i32 reliefSeedOffset` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve): shelf micro-relief (±reliefAmpM, shelf-ness weighted) and D8 drainage incision (`depth = K·accum^p·slope`, capped, riser-masked, sea+1 m clamped, crater-excluded). **Metadata only** — heights are already detailed; the incision channels are the designated future river routes (Phase C). Machinery: `Tools/Scripts/TerrainDetailPass.cs`. |
|
| `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` | 67 B: `u16 erosionVersion` · `i32 dropletCount` · `i32 lifetime` · `i32 brushRadius` · `i32 seedOffset` · `f32 carveCapM` · `f32 depositCapM` · `f32 seaMarginM` · `f32 inertia` · `f32 capacityFactor` · `f32 minSlopeM` · `f32 erodeRate` · `f32 depositRate` · `f32 evaporation` · `f32 gravity` · `f32 craterCoreFactor` · `f32 craterFeatherFactor` · `u8 craterMode` | Optional — present iff the droplet hydraulic-erosion pass shaped this blueprint's `HGTS` (config `Erosion: "v1"`, terrain-water tasks 17–19). **`erosionVersion` selects the body layout; an unrecognised version is SKIPPED whole** (erosion metadata left null), same rule as `TDTL`. **v1 (task 17)** — 58 B, no `depositCapM`; deposition was bilinear over 4 cells and unbounded, which built isolated cones (measured 15.5 m). **v2 (task 18)** — 62 B; deposition brush-spread and per-cell bounded. **v3 (task 19, current)** — replaces v2's single `craterExclFactor` with `craterCoreFactor` (the protected strike core), `craterFeatherFactor` and `craterMode` (`0` = full, `1` = feather). Only v2+ payloads exist outside tasks 17–18's own batch trees. The FOUR governors are `dropletCount`/`lifetime`/`carveCapM`/`depositCapM`; both caps are enforced against one per-cell NET displacement ledger (positive = carved below the height the pass found, negative = built up above it) and asserted on exit. `seaMarginM` is the flood-guard clamp — no cell is carved below sea + margin, and below-sea cells are untouched in BOTH directions, so the rendered coastline cannot move **and the crater's flooded bay can be neither carved open nor silted shut regardless of `craterMode`**. Crater radii are FACTORS of `PRMS.CraterRadius`: nothing inside `craterCoreFactor ×` it is modified; `feather` ramps erosion 0→full from there out to `craterFeatherFactor ×` it, `full` applies full strength immediately. Droplets are deterministic from `resolvedWorldSeed + seedOffset` (PCG32). Remaining fields are the droplet-model strength 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²`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|
@ -113,11 +114,50 @@ body).
|
||||||
shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires, 60 % lowland) shapes
|
shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires, 60 % lowland) shapes
|
||||||
`HGTS`.
|
`HGTS`.
|
||||||
- **`TerrainDetail`** (`"v1"` | `"off"`, default `"v1"`; no-op without the curve) — the task-10
|
- **`TerrainDetail`** (`"v1"` | `"off"`, default `"v1"`; no-op without the curve) — the task-10
|
||||||
detail passes as one judged unit: shelf micro-relief (`ShelfReliefAmp` metres, default 3) and
|
detail passes as one judged unit: shelf micro-relief (`ShelfReliefAmp`, default 3 m of output
|
||||||
drainage incision. When on, `TDTL` records the parameters. Biome/water classification is curve-invariant by
|
height) and shelf-edge variation (`ShelfEdgeVariation`, default 12 m of **input** height — how
|
||||||
|
far the shelf/riser boundary contour wanders, clamped at load to the preset's band-squeeze
|
||||||
|
bound, loudly). Both are output-height only. When on, `TDTL` records the parameters as applied.
|
||||||
|
Biome/water classification is curve-invariant by
|
||||||
construction (it classifies the retained uncurved heights); town positions and everything 3D
|
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
|
follow the curved terrain. When on, `TCRV` records the effective parameters including the
|
||||||
per-seed `spikeMax`.
|
per-seed `spikeMax`.
|
||||||
|
- **`Erosion`** (`"v1"` | `"off"`, default `"off"` — opt-in until the developer's gate approves
|
||||||
|
it) — the droplet hydraulic-erosion pass (tasks 17–18): carve-and-deposit drainage detailing of
|
||||||
|
`HGTS` after the detail passes and before the crater carve. FOUR governor dials
|
||||||
|
(`ErosionDropletCount` / `ErosionDropletLifetime` / `ErosionCarveCap` / `ErosionDepositCap`,
|
||||||
|
defaults 250 000 / 384 / 15 m / 6 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`. The task-18 defaults tune for a drainage HIERARCHY: droplets live long
|
||||||
|
enough (384 steps at inertia 0.35) for their paths to overlap and deepen shared low lines into
|
||||||
|
trunk channels rather than dying as short independent scratches, and the raised carve cap lets
|
||||||
|
those trunks separate from the fine rills instead of both piling against one ceiling.
|
||||||
|
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.
|
||||||
|
- **`CraterErosionMode`** (`"feather"` | `"full"`, default `"feather"` pending the task-19 gate),
|
||||||
|
with **`CraterErosionCore`** (`0.50`) and **`CraterErosionFeather`** (`1.05`), both factors of
|
||||||
|
`CraterRadius` — how erosion treats the crater surrounds. Task 17's hard `1.2 ×` cutoff left a
|
||||||
|
visible un-eroded disc: the carve writes only inside `0.80 ×` and its displacement is exactly 0
|
||||||
|
beyond that, so 620 811 land cells of ordinary terrain were held smooth for no geometric reason
|
||||||
|
(measured, seed 1280587109). Only the deep strike core is protected now; `feather` ramps erosion
|
||||||
|
in across `core → feather` for a younger-looking crater with no seam, `full` weathers the
|
||||||
|
surrounds like any other terrain. Neither mode can affect the flooded bay or its sea connection —
|
||||||
|
that is the sea clamp's guarantee, not the exclusion's.
|
||||||
|
|
||||||
|
- **`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
|
||||||
|
shallow depth so the shallows reach 2.4–2.9× further out. It is strictly positive for positive
|
||||||
|
depth and therefore **cannot move the waterline**, so `HGTS` changes while `BIOM`, `WBID`,
|
||||||
|
`WBTB` and `WSRF` stay bit-identical. `"steep"` is the pre-task-11 seabed.
|
||||||
|
- **`IslandAxisX`** / **`IslandAxisY`** (default `1.30` / `0.78`; pre-task-11 `1.15` / `0.90`) —
|
||||||
|
the falloff axis ratios, and the island's proportions. These **move the coastline**, so unlike
|
||||||
|
the curve and detail work they legitimately change `BIOM` and the water sections: an elongated
|
||||||
|
seed has a new biome baseline. Rejected (with a loud restore to legacy) if ≤ 0.01, which would
|
||||||
|
divide by ~zero and silently yield an all-ocean map.
|
||||||
|
- **`OffshoreIslandDensity`** (default `0.02`, `0` disables, clamped to 0.5) — the fraction of the
|
||||||
|
offshore noise field above the islet threshold, calibrated per seed against the field's own
|
||||||
|
distribution. Adds land, so it also moves `BIOM` and the water sections.
|
||||||
|
|
||||||
### Deliberately NOT a section: basins (`BSIN`)
|
### Deliberately NOT a section: basins (`BSIN`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,21 @@ namespace IslaApocalypse.Core
|
||||||
public const byte WASTELAND_DIRT = 9;
|
public const byte WASTELAND_DIRT = 9;
|
||||||
public const byte ASPHALT = 10; // For our roads!
|
public const byte ASPHALT = 10; // For our roads!
|
||||||
|
|
||||||
|
// Water (terrain-water task 13). Wire-safe to add: block IDs are NEVER
|
||||||
|
// serialized — the blueprint's section table (BLUEPRINT_FORMAT.md) stores
|
||||||
|
// heights, biome ordinals and water data, never block IDs, and chunks are
|
||||||
|
// not persisted yet. So this ID is a runtime-only value and appending to
|
||||||
|
// the table cannot invalidate a .dat.
|
||||||
|
//
|
||||||
|
// NOTE this block is not placed into ChunkData.BlockIDs by the terrain
|
||||||
|
// path. Terrain is a Marching-Cubes iso-surface over a density field, and
|
||||||
|
// writing WATER into that field would FUSE the water into the terrain
|
||||||
|
// surface rather than lay a sheet on top of it. Water is its own mesh
|
||||||
|
// (ChunkRenderer). The entry exists so water has a real identity in the
|
||||||
|
// registry — a name and a colour — for the renderer and for whatever
|
||||||
|
// later needs to ask "what is this".
|
||||||
|
public const byte WATER = 11;
|
||||||
|
|
||||||
// This static constructor runs automatically the first time the registry is accessed
|
// This static constructor runs automatically the first time the registry is accessed
|
||||||
static BlockRegistry()
|
static BlockRegistry()
|
||||||
{
|
{
|
||||||
|
|
@ -42,6 +57,10 @@ namespace IslaApocalypse.Core
|
||||||
|
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
Blocks.Add(ASPHALT, new BlockData(ASPHALT, "Asphalt", true, new Color(0.15f, 0.15f, 0.15f)));
|
Blocks.Add(ASPHALT, new BlockData(ASPHALT, "Asphalt", true, new Color(0.15f, 0.15f, 0.15f)));
|
||||||
|
|
||||||
|
// Water — not solid (you can move through it), shallow-water colour as the
|
||||||
|
// registry's representative value; the renderer shades by depth from there.
|
||||||
|
Blocks.Add(WATER, new BlockData(WATER, "Water", false, new Color(0.24f, 0.55f, 0.62f)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static BlockData GetBlock(byte id)
|
public static BlockData GetBlock(byte id)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,24 @@ namespace IslaApocalypse.Core
|
||||||
public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF"
|
public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF"
|
||||||
public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV"
|
public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV"
|
||||||
public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL"
|
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
|
||||||
|
// existence are in that batch's tree). 2 = the current body. A reader that
|
||||||
|
// meets an unrecognised body version skips it rather than misreading a
|
||||||
|
// differently shaped payload into plausible-looking nonsense.
|
||||||
|
public const ushort TDTL_VERSION = 2;
|
||||||
|
|
||||||
|
// EROS body version. 1 (task 17) = governors count/lifetime/carve cap, sea
|
||||||
|
// clamp, brush, strength constants, RNG seed offset, crater exclusion factor;
|
||||||
|
// the only v1 payloads in existence are in that task's batch tree. 2 (task 18,
|
||||||
|
// current) inserts the DEPOSIT CAP governor after the carve cap — deposition is
|
||||||
|
// now brush-spread and per-cell bounded. Same reader rule as TDTL: an unknown
|
||||||
|
// body version is skipped whole rather than misread into plausible nonsense.
|
||||||
|
// 3 (task 19, current) replaces the single crater exclusion factor with the
|
||||||
|
// protected-core factor, a feather-band factor, and the crater mode byte.
|
||||||
|
public const ushort EROS_VERSION = 3;
|
||||||
|
|
||||||
// WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L
|
// 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
|
// (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ namespace IslaApocalypse.Core
|
||||||
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve));
|
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve));
|
||||||
if (bp.TerrainDetail != null)
|
if (bp.TerrainDetail != null)
|
||||||
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail));
|
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_HEIGHTS, w => WriteHeights(w, bp));
|
||||||
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp));
|
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp));
|
||||||
|
|
||||||
|
|
@ -155,12 +157,28 @@ namespace IslaApocalypse.Core
|
||||||
|
|
||||||
private static void WriteTerrainDetail(BinaryWriter writer, TerrainDetailInfo d)
|
private static void WriteTerrainDetail(BinaryWriter writer, TerrainDetailInfo d)
|
||||||
{
|
{
|
||||||
writer.Write(d.Version);
|
writer.Write(d.Version); // u16
|
||||||
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands);
|
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32
|
||||||
writer.Write(d.IncK); writer.Write(d.IncP); writer.Write(d.IncCapM);
|
writer.Write(d.ReliefSeedOffset); // i32
|
||||||
writer.Write(d.SeaClampRaw); writer.Write(d.CraterExclFactor);
|
writer.Write(d.EdgeAmpM); writer.Write(d.EdgeFreqIslands); // 2 × f32
|
||||||
writer.Write(d.ShelfIncWeight);
|
writer.Write(d.EdgeSeedOffset); // i32
|
||||||
writer.Write(d.ReliefSeedOffset);
|
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.DepositCapM); // 2 × f32
|
||||||
|
writer.Write(e.SeaMarginM); // 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.CraterCoreFactor); // f32
|
||||||
|
writer.Write(e.CraterFeatherFactor); // f32
|
||||||
|
writer.Write(e.CraterMode); // u8
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)
|
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,23 @@ namespace IslaApocalypse.Core
|
||||||
// doesn't get painted like a motorway.
|
// doesn't get painted like a motorway.
|
||||||
public byte[,] ColumnRoadMaterial = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
|
public byte[,] ColumnRoadMaterial = new byte[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
|
||||||
|
|
||||||
|
// --- WATER AT REST (terrain-water task 13) ---------------------------
|
||||||
|
// Per column: the world-space Y of the water SURFACE, or Constants.NO_WATER
|
||||||
|
// where the blueprint says this column is dry. Filled by the server from
|
||||||
|
// the blueprint's WSRF/WBID/WBTB — the runtime never decides where water
|
||||||
|
// is, it only draws what the blueprint already classified.
|
||||||
|
//
|
||||||
|
// WaterDepthM is the TRUE depth in metres (water level minus seabed, both
|
||||||
|
// in blueprint units), kept separately because the rendered seabed is
|
||||||
|
// clamped at Y=2 and would flatten every deep-ocean column to the same
|
||||||
|
// value. Colour reads this; geometry reads WaterSurfaceY.
|
||||||
|
public float[,] WaterSurfaceY = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
|
||||||
|
public float[,] WaterDepthM = new float[Constants.CHUNK_SIZE_X + 1, Constants.CHUNK_SIZE_Z + 1];
|
||||||
|
|
||||||
|
/// <summary>True if any column in this chunk carries water — lets the
|
||||||
|
/// renderer skip building a water mesh for the many inland chunks.</summary>
|
||||||
|
public bool HasAnyWater = false;
|
||||||
|
|
||||||
// For our future delta-save system
|
// For our future delta-save system
|
||||||
public bool IsPlayerProtected = false;
|
public bool IsPlayerProtected = false;
|
||||||
public bool NeedsSaving = false;
|
public bool NeedsSaving = false;
|
||||||
|
|
@ -44,6 +61,12 @@ namespace IslaApocalypse.Core
|
||||||
public ChunkData(Vector2I position)
|
public ChunkData(Vector2I position)
|
||||||
{
|
{
|
||||||
ChunkPosition = position;
|
ChunkPosition = position;
|
||||||
|
|
||||||
|
// float[,] defaults to 0, which is a legal-looking water height. Start
|
||||||
|
// every column explicitly dry instead.
|
||||||
|
for (int x = 0; x <= Constants.CHUNK_SIZE_X; x++)
|
||||||
|
for (int z = 0; z <= Constants.CHUNK_SIZE_Z; z++)
|
||||||
|
WaterSurfaceY[x, z] = Constants.NO_WATER;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,149 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
// are retired. Default: v5.
|
// are retired. Default: v5.
|
||||||
public static string TerrainCurve = "v5";
|
public static string TerrainCurve = "v5";
|
||||||
|
|
||||||
// Terrain detail passes (task 10): "v1" = shelf micro-relief + drainage
|
// Terrain detail passes (task 10): "v1" = shelf micro-relief + shelf-edge
|
||||||
// incision as one judged unit (requires the curve; no-op when it is off);
|
// variation as one judged unit (requires the curve; no-op when it is off);
|
||||||
// "off" disables both. ShelfReliefAmp is the micro-relief amplitude in
|
// "off" disables both. ShelfReliefAmp is the micro-relief amplitude in metres
|
||||||
// metres. Defaults: v1, 3 m.
|
// of OUTPUT height. ShelfEdgeVariation is the shelf-edge warp amplitude in
|
||||||
|
// metres of INPUT height — how far the shelf/riser boundary contour is
|
||||||
|
// displaced, not an elevation change; it is clamped at load time to the
|
||||||
|
// largest shift the curve's bands can absorb. Defaults: v1, 3 m, 12 m.
|
||||||
public static string TerrainDetail = "v1";
|
public static string TerrainDetail = "v1";
|
||||||
public static float ShelfReliefAmp = 3.0f;
|
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).
|
||||||
|
// Task-18 defaults tune for a DRAINAGE HIERARCHY: long-lived, committed
|
||||||
|
// droplets (lifetime 384 at inertia 0.35, evaporation 0.004) travel far
|
||||||
|
// enough down a flank that their paths overlap and deepen shared low lines
|
||||||
|
// into trunk channels, instead of dying as independent 48-px scratches;
|
||||||
|
// the carve cap is raised to 15 m so trunks can separate from the fine
|
||||||
|
// rills instead of both piling up against the same ceiling. A modest
|
||||||
|
// erode rate keeps the total material moved in detailing range.
|
||||||
|
// ErosionDepositCap is governor 4 (task 18): brush-spread deposition alone
|
||||||
|
// does not bound a spike once droplets carry long-path loads.
|
||||||
|
public static string Erosion = "off";
|
||||||
|
public static int ErosionDropletCount = 250000;
|
||||||
|
public static int ErosionDropletLifetime = 384;
|
||||||
|
public static float ErosionCarveCap = 15.0f; // m per cell
|
||||||
|
public static float ErosionDepositCap = 6.0f; // m per cell; <= 0 = unbounded
|
||||||
|
public static float ErosionSeaMargin = 0.5f; // m above sea, carve floor
|
||||||
|
public static int ErosionBrushRadius = 2; // px
|
||||||
|
public static float ErosionInertia = 0.35f;
|
||||||
|
public static float ErosionCapacity = 4.0f;
|
||||||
|
public static float ErosionMinSlope = 0.02f; // m per px, capacity floor
|
||||||
|
public static float ErosionErodeRate = 0.12f;
|
||||||
|
public static float ErosionDepositRate = 0.15f;
|
||||||
|
public static float ErosionEvaporation = 0.004f;
|
||||||
|
public static float ErosionGravity = 4.0f;
|
||||||
|
|
||||||
|
// How erosion treats the crater surrounds (task 19). The task-17 hard
|
||||||
|
// 1.2 × CraterRadius cutoff left a visible un-eroded disc: measured, the carve
|
||||||
|
// writes only inside 0.80 × and its displacement is exactly 0 beyond that, so
|
||||||
|
// 620 811 LAND cells of ordinary terrain were being held smooth for no
|
||||||
|
// geometric reason. Now only the deep strike core is protected.
|
||||||
|
// "full" — full-strength erosion right up to the core boundary. The
|
||||||
|
// crater formed after the terrain and has weathered since.
|
||||||
|
// "feather" — erosion ramps 0→full across CraterErosionCore →
|
||||||
|
// CraterErosionFeather (the detail pass's shape), so the crater
|
||||||
|
// reads as younger, less-weathered, and there is no seam at all.
|
||||||
|
// Radii are FACTORS of CraterRadius. The flooded bay and its sea connection do
|
||||||
|
// NOT depend on these: below-sea cells are read-only in both directions.
|
||||||
|
public static string CraterErosionMode = "feather";
|
||||||
|
public static float CraterErosionCore = CRATER_EROSION_CORE_DEFAULT;
|
||||||
|
public static float CraterErosionFeather = CRATER_EROSION_FEATHER_DEFAULT;
|
||||||
|
// 0.80 = the carve's OWN extent (MapGenerator's physicalCraterRadius). Keeping the
|
||||||
|
// core at least this wide is what makes erosion and the carve touch DISJOINT cells,
|
||||||
|
// which is what keeps the post-carve flood guard exactly zero: the carve runs after
|
||||||
|
// erosion and scales height toward the sea target, so it AMPLIFIES any erosion delta
|
||||||
|
// inside its radius and can push a hair-above-sea cell across the waterline. Measured
|
||||||
|
// at a 0.50 core: 79 cells newly below the rendered sea, 113 310 below-sea cells
|
||||||
|
// disturbed. A smaller core reclaims nothing extra either — the over-protected annulus
|
||||||
|
// is 0.80x-1.2x, entirely outside the carve.
|
||||||
|
public const float CRATER_EROSION_CORE_DEFAULT = 0.80f;
|
||||||
|
public const float CRATER_EROSION_FEATHER_DEFAULT = 1.05f;
|
||||||
|
|
||||||
|
// Rivers (task 22, C0b part 2a): carve the frozen task-21b river plan's
|
||||||
|
// beds into the RENDER map — ocean trunks, routed giants (lowland reach to
|
||||||
|
// the sea), lake-enders. NO WATER yet (part 2b). "off" until the routing-
|
||||||
|
// style gate; the A/B batch turns it on explicitly. RiverRoutingStyle picks
|
||||||
|
// the lowland routing for routed giants: "short" heads direct (lightly
|
||||||
|
// terrain-aware), "lowground" follows the lowest ground and wanders like a
|
||||||
|
// real river — the task-22 gate decides which ships; "lowground" is the
|
||||||
|
// provisional default pending that verdict. Width/depth scales are taste
|
||||||
|
// dials on the flow-proportional bed profile. RiverSeaMargin is the bed's
|
||||||
|
// absolute floor above sea — the erosion flood-guard discipline: no river
|
||||||
|
// bed may create inland below-sea cells, so the rendered coastline cannot
|
||||||
|
// move even with rivers carved.
|
||||||
|
public static string Rivers = "off";
|
||||||
|
public static string RiverRoutingStyle = "lowground";
|
||||||
|
public static float RiverWidthScale = 1.0f;
|
||||||
|
public static float RiverDepthScale = 1.0f;
|
||||||
|
public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
|
||||||
|
|
||||||
|
// Island falloff shaping (task 11).
|
||||||
|
//
|
||||||
|
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
|
||||||
|
// at and below sea, so it never reached the seabed, which still dropped ~6.7x
|
||||||
|
// steeper than the land it meets. "steep" is the pre-task-11 seabed, kept for
|
||||||
|
// A/B. The shelf cannot move the waterline, so biomes and water are identical
|
||||||
|
// either way. Default: wide.
|
||||||
|
//
|
||||||
|
// IslandAxisX/Y: the falloff axis ratios. These MOVE THE COASTLINE and
|
||||||
|
// therefore move biomes, so the DEFAULT is the shape the gate approved.
|
||||||
|
// Task 11 shipped 1.30/0.78 as the default and the developer's gate REJECTED
|
||||||
|
// that elongation; the defaults are back to 1.15/0.90 so that omitting the
|
||||||
|
// keys can no longer silently produce the rejected island (task 12 §4).
|
||||||
|
// NOTE, measured in task 11: the island is already Trench-clamped in x at
|
||||||
|
// ~90% of the map width, so AxisX is a weak lever — aspect responds almost
|
||||||
|
// entirely to AxisY, which trades against land area.
|
||||||
|
//
|
||||||
|
// OffshoreIslandDensity: fraction of the ocean noise field above the islet
|
||||||
|
// threshold. 0 disables the layer. Islets never touch the Trench and are held
|
||||||
|
// off the mainland by a depth moat.
|
||||||
|
public static string CoastProfile = "wide";
|
||||||
|
public static float IslandAxisX = LEGACY_AXIS_X; // 1.15 — the gate's verdict
|
||||||
|
public static float IslandAxisY = LEGACY_AXIS_Y; // 0.90
|
||||||
|
public static float OffshoreIslandDensity = 0.02f;
|
||||||
|
|
||||||
|
// The gate-approved island shape, named so the defaults above and the bad-value
|
||||||
|
// restore below both point at one place. (`const`, so using it in a field
|
||||||
|
// initialiser declared earlier resolves at compile time.)
|
||||||
|
public const float LEGACY_AXIS_X = 1.15f;
|
||||||
|
public const float LEGACY_AXIS_Y = 0.90f;
|
||||||
|
|
||||||
public static void LoadConfig()
|
public static void LoadConfig()
|
||||||
{
|
{
|
||||||
string path = "res://ServerConfig.json";
|
string path = "res://ServerConfig.json";
|
||||||
|
|
||||||
|
// Iteration override (task 22): ISLA_SERVER_CONFIG names an alternate
|
||||||
|
// config FILE to load instead of the repo's ServerConfig.json. Batch and
|
||||||
|
// A/B runs point this at a scratch config, so the developer's live
|
||||||
|
// ServerConfig.json is never written by tooling again — the whole
|
||||||
|
// backup/restore dance (and its task-19 near-miss) goes away. Loud, so
|
||||||
|
// a forgotten env var cannot silently masquerade as the repo config.
|
||||||
|
string envPath = OS.GetEnvironment("ISLA_SERVER_CONFIG");
|
||||||
|
if (!string.IsNullOrEmpty(envPath))
|
||||||
|
{
|
||||||
|
if (FileAccess.FileExists(envPath))
|
||||||
|
{
|
||||||
|
path = envPath;
|
||||||
|
GD.Print($"[ConfigManager] ⚠ ISLA_SERVER_CONFIG override: loading '{envPath}' (NOT the repo ServerConfig.json).");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] ISLA_SERVER_CONFIG set but '{envPath}' does not exist — falling back to the repo config.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!FileAccess.FileExists(path))
|
if (!FileAccess.FileExists(path))
|
||||||
{
|
{
|
||||||
GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K.");
|
GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K.");
|
||||||
|
|
@ -138,6 +270,125 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
{
|
{
|
||||||
ShelfReliefAmp = (float)data["ShelfReliefAmp"];
|
ShelfReliefAmp = (float)data["ShelfReliefAmp"];
|
||||||
}
|
}
|
||||||
|
if (data.ContainsKey("ShelfEdgeVariation"))
|
||||||
|
{
|
||||||
|
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("ErosionDepositCap")) ErosionDepositCap = (float)data["ErosionDepositCap"];
|
||||||
|
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.");
|
||||||
|
// Rivers gate + dials (task 22)
|
||||||
|
if (data.ContainsKey("Rivers"))
|
||||||
|
{
|
||||||
|
string rv = (string)data["Rivers"];
|
||||||
|
if (rv == "off" || rv == "v1")
|
||||||
|
Rivers = rv;
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] Unknown Rivers '{rv}'. Keeping '{Rivers}'.");
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("RiverRoutingStyle"))
|
||||||
|
{
|
||||||
|
string st = (string)data["RiverRoutingStyle"];
|
||||||
|
if (st == "short" || st == "lowground")
|
||||||
|
RiverRoutingStyle = st;
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] Unknown RiverRoutingStyle '{st}'. Keeping '{RiverRoutingStyle}'.");
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"];
|
||||||
|
if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"];
|
||||||
|
if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
|
||||||
|
RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);
|
||||||
|
RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f);
|
||||||
|
RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f);
|
||||||
|
|
||||||
|
// Crater erosion treatment (task 19)
|
||||||
|
if (data.ContainsKey("CraterErosionMode"))
|
||||||
|
{
|
||||||
|
string cm = (string)data["CraterErosionMode"];
|
||||||
|
if (cm == "full" || cm == "feather")
|
||||||
|
CraterErosionMode = cm;
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] Unknown CraterErosionMode '{cm}'. Keeping '{CraterErosionMode}'.");
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("CraterErosionCore")) CraterErosionCore = (float)data["CraterErosionCore"];
|
||||||
|
if (data.ContainsKey("CraterErosionFeather")) CraterErosionFeather = (float)data["CraterErosionFeather"];
|
||||||
|
CraterErosionCore = Mathf.Clamp(CraterErosionCore, 0f, 3f);
|
||||||
|
CraterErosionFeather = Mathf.Clamp(CraterErosionFeather, 0f, 4f);
|
||||||
|
// A feather band that does not extend past the core is not a band; say so
|
||||||
|
// rather than silently behaving like "full".
|
||||||
|
if (CraterErosionMode == "feather" && CraterErosionFeather <= CraterErosionCore)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[ConfigManager] CraterErosionFeather {CraterErosionFeather:F2} must exceed " +
|
||||||
|
$"CraterErosionCore {CraterErosionCore:F2} — the ramp would have zero width. " +
|
||||||
|
$"Restoring {CRATER_EROSION_FEATHER_DEFAULT:F2}.");
|
||||||
|
CraterErosionFeather = CRATER_EROSION_FEATHER_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative is meaningless; 0 is the documented "unbounded" escape hatch.
|
||||||
|
ErosionDepositCap = Mathf.Clamp(ErosionDepositCap, 0f, 60f);
|
||||||
|
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"))
|
||||||
|
{
|
||||||
|
string coast = (string)data["CoastProfile"];
|
||||||
|
if (coast == "steep" || coast == "wide")
|
||||||
|
CoastProfile = coast;
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] Unknown CoastProfile '{coast}'. Keeping '{CoastProfile}'.");
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("IslandAxisX")) IslandAxisX = (float)data["IslandAxisX"];
|
||||||
|
if (data.ContainsKey("IslandAxisY")) IslandAxisY = (float)data["IslandAxisY"];
|
||||||
|
if (data.ContainsKey("OffshoreIslandDensity")) OffshoreIslandDensity = (float)data["OffshoreIslandDensity"];
|
||||||
|
|
||||||
|
// The axis ratios divide map extents; a zero or negative one is a divide-by-
|
||||||
|
// zero that would silently produce an all-ocean map. Refuse it loudly.
|
||||||
|
if (IslandAxisX <= 0.01f || IslandAxisY <= 0.01f)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[ConfigManager] IslandAxisX/Y must be > 0.01 (got {IslandAxisX}/{IslandAxisY}). Restoring legacy {LEGACY_AXIS_X}/{LEGACY_AXIS_Y}.");
|
||||||
|
IslandAxisX = LEGACY_AXIS_X;
|
||||||
|
IslandAxisY = LEGACY_AXIS_Y;
|
||||||
|
}
|
||||||
|
OffshoreIslandDensity = Mathf.Clamp(OffshoreIslandDensity, 0f, 0.5f);
|
||||||
|
|
||||||
switch (profile)
|
switch (profile)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,40 @@ namespace IslaApocalypse.Core
|
||||||
/// <summary>Safety margin added to the road cull, in metres.</summary>
|
/// <summary>Safety margin added to the road cull, in metres.</summary>
|
||||||
public const float ROAD_CULL_MARGIN = 3.0f;
|
public const float ROAD_CULL_MARGIN = 3.0f;
|
||||||
|
|
||||||
|
// --- WATER AT REST (terrain-water task 13) --------------------------
|
||||||
|
// The blueprint has carried water since task 03; this is only about
|
||||||
|
// DRAWING it. Water is a flat sheet at the body's own surface level —
|
||||||
|
// no waves, no flow, no animation. Those are C2+.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sentinel in ChunkData.WaterSurfaceY for "this column has no water".
|
||||||
|
/// Real surface heights are always >= 2 (the terrain clamp's floor), so a
|
||||||
|
/// negative value is unambiguous.
|
||||||
|
/// </summary>
|
||||||
|
public const float NO_WATER = -1.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw blueprint height -> world metres. One raw unit is this many metres,
|
||||||
|
/// and it is exactly the terrain mapping (CHUNK_HEIGHT - 5), named so the
|
||||||
|
/// water surface and the seabed cannot drift apart if the band is retuned.
|
||||||
|
/// </summary>
|
||||||
|
public const float HEIGHT_SCALE = CHUNK_HEIGHT - 5;
|
||||||
|
|
||||||
|
// Depth shading. Depth is measured from the BLUEPRINT heights, not the
|
||||||
|
// rendered geometry: the terrain render clamps its floor at Y=2, so deep
|
||||||
|
// ocean would otherwise all read as one flat ~36 m and the gradient would
|
||||||
|
// die exactly where the ocean gets interesting.
|
||||||
|
public const float WATER_DEEP_METERS = 60.0f; // depth at which the gradient bottoms out
|
||||||
|
public static readonly Color WATER_SHALLOW = new Color(0.38f, 0.76f, 0.78f);
|
||||||
|
public static readonly Color WATER_DEEP = new Color(0.05f, 0.17f, 0.42f);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Water alpha, shallow -> deep. Shallows are clearer, so the shelved coast
|
||||||
|
/// and the seabed under it stay readable; depth closes the surface up.
|
||||||
|
/// </summary>
|
||||||
|
public const float WATER_ALPHA_SHALLOW = 0.62f;
|
||||||
|
public const float WATER_ALPHA_DEEP = 0.97f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up the carve settings for a road tier. One place to tune.
|
/// Looks up the carve settings for a road tier. One place to tune.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -89,16 +89,37 @@ namespace IslaApocalypse.Core
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section,
|
/// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section,
|
||||||
/// terrain-water task 10): shelf micro-relief + drainage incision parameters.
|
/// terrain-water task 10): shelf micro-relief + shelf-edge variation parameters.
|
||||||
/// Null when detail was off. Metadata only — heights are already detailed.
|
/// Null when detail was off. Metadata only — heights are already detailed.
|
||||||
|
/// Version 1 (relief + the reverted D8 incision) never left the task-10 batch
|
||||||
|
/// tree; the parser rejects it rather than misreading its longer payload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TerrainDetailInfo
|
public class TerrainDetailInfo
|
||||||
{
|
{
|
||||||
public ushort Version;
|
public ushort Version;
|
||||||
public float ReliefAmpM, ReliefFreqIslands;
|
public float ReliefAmpM, ReliefFreqIslands;
|
||||||
public float IncK, IncP, IncCapM;
|
|
||||||
public float SeaClampRaw, CraterExclFactor, ShelfIncWeight;
|
|
||||||
public int ReliefSeedOffset;
|
public int ReliefSeedOffset;
|
||||||
|
public float EdgeAmpM, EdgeFreqIslands;
|
||||||
|
public int EdgeSeedOffset;
|
||||||
|
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, DepositCapM, SeaMarginM;
|
||||||
|
public float Inertia, CapacityFactor, MinSlopeM;
|
||||||
|
public float ErodeRate, DepositRate, Evaporation, Gravity;
|
||||||
|
public float CraterCoreFactor, CraterFeatherFactor;
|
||||||
|
public byte CraterMode; // 0 = full, 1 = feather
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WorldBlueprint
|
public class WorldBlueprint
|
||||||
|
|
@ -131,6 +152,9 @@ namespace IslaApocalypse.Core
|
||||||
|
|
||||||
// The detail passes that shaped HeightMap (TDTL section); null = no detail.
|
// The detail passes that shaped HeightMap (TDTL section); null = no detail.
|
||||||
public TerrainDetailInfo TerrainDetail;
|
public TerrainDetailInfo TerrainDetail;
|
||||||
|
|
||||||
|
// The erosion pass that detailed HeightMap (EROS section); null = no erosion.
|
||||||
|
public ErosionInfo Erosion;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. The Parser Utility
|
// 2. The Parser Utility
|
||||||
|
|
@ -254,7 +278,8 @@ namespace IslaApocalypse.Core
|
||||||
else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true);
|
else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true);
|
||||||
else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint);
|
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_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint);
|
||||||
else if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(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
|
else
|
||||||
{
|
{
|
||||||
// The property the redesign exists to buy: future sections (water,
|
// The property the redesign exists to buy: future sections (water,
|
||||||
|
|
@ -448,19 +473,55 @@ namespace IslaApocalypse.Core
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ParseTerrainDetail(BinaryReader reader, WorldBlueprint blueprint)
|
private static bool ParseTerrainDetail(BinaryReader reader, WorldBlueprint blueprint, ulong payloadLength)
|
||||||
{
|
{
|
||||||
var d = new TerrainDetailInfo();
|
var d = new TerrainDetailInfo();
|
||||||
d.Version = reader.ReadUInt16();
|
d.Version = reader.ReadUInt16();
|
||||||
|
if (d.Version != BlueprintFormat.TDTL_VERSION)
|
||||||
|
{
|
||||||
|
// A TDTL v1 payload (the reverted relief+incision layout) is LONGER and
|
||||||
|
// laid out differently; reading it as v2 would silently mint plausible
|
||||||
|
// nonsense. Skip the body and leave TerrainDetail null — the heights are
|
||||||
|
// still whatever they are, we just refuse to describe them wrongly.
|
||||||
|
GD.PrintErr($"[MapDataParser] ⚠ TDTL version {d.Version} is not the current {BlueprintFormat.TDTL_VERSION} — section skipped, detail metadata unavailable.");
|
||||||
|
reader.BaseStream.Seek((long)payloadLength - 2L, SeekOrigin.Current);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle();
|
d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle();
|
||||||
d.IncK = reader.ReadSingle(); d.IncP = reader.ReadSingle(); d.IncCapM = reader.ReadSingle();
|
|
||||||
d.SeaClampRaw = reader.ReadSingle(); d.CraterExclFactor = reader.ReadSingle();
|
|
||||||
d.ShelfIncWeight = reader.ReadSingle();
|
|
||||||
d.ReliefSeedOffset = reader.ReadInt32();
|
d.ReliefSeedOffset = reader.ReadInt32();
|
||||||
|
d.EdgeAmpM = reader.ReadSingle(); d.EdgeFreqIslands = reader.ReadSingle();
|
||||||
|
d.EdgeSeedOffset = reader.ReadInt32();
|
||||||
|
d.EdgeMaxShiftM = reader.ReadSingle();
|
||||||
blueprint.TerrainDetail = d;
|
blueprint.TerrainDetail = d;
|
||||||
return true;
|
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.DepositCapM = 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.CraterCoreFactor = reader.ReadSingle(); e.CraterFeatherFactor = reader.ReadSingle();
|
||||||
|
e.CraterMode = reader.ReadByte();
|
||||||
|
blueprint.Erosion = e;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
|
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
|
||||||
{
|
{
|
||||||
int pathCount = reader.ReadInt32();
|
int pathCount = reader.ReadInt32();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ never track live game events — who is online, which chunks are loaded, what ti
|
||||||
### Voxel materials
|
### Voxel materials
|
||||||
- **`BlockData.cs`** — struct describing one block type (ID, name, IsSolid, BaseColor).
|
- **`BlockData.cs`** — struct describing one block type (ID, name, IsSolid, BaseColor).
|
||||||
- **`BlockRegistry.cs`** — the byte-ID master list (`AIR` 0, `BEDROCK`, `STONE`, `DIRT`, `SAND`, the
|
- **`BlockRegistry.cs`** — the byte-ID master list (`AIR` 0, `BEDROCK`, `STONE`, `DIRT`, `SAND`, the
|
||||||
three grasses, `SNOW`, `WASTELAND_DIRT`, `ASPHALT`) with a safe `GetBlock` lookup.
|
three grasses, `SNOW`, `WASTELAND_DIRT`, `ASPHALT`, `WATER`) with a safe `GetBlock` lookup.
|
||||||
|
**Block IDs are never serialized** — the blueprint stores heights, biome ordinals and water data,
|
||||||
|
never block IDs, and chunks are not persisted — so appending to this table is wire-safe.
|
||||||
|
`WATER` is a registry identity only: it is not written into `ChunkData.BlockIDs`, because water is
|
||||||
|
drawn as its own surface rather than as part of the terrain iso-surface (see `Client/README.md`).
|
||||||
- **`BiomePalette.cs`** — decides which material sits where, given biome, depth, and whether the
|
- **`BiomePalette.cs`** — decides which material sits where, given biome, depth, and whether the
|
||||||
column is roadbed. Two paths on purpose: an **integer** classification that decides what is stored
|
column is roadbed. Two paths on purpose: an **integer** classification that decides what is stored
|
||||||
in each voxel, and a **float-depth** version used for rendering that returns the two materials
|
in each voxel, and a **float-depth** version used for rendering that returns the two materials
|
||||||
|
|
@ -43,11 +47,17 @@ rendering.
|
||||||
flag, and the optional water-bodies data: `WaterBodyIds`, `WaterBodies` table, `WaterSurfaceQ`).
|
flag, and the optional water-bodies data: `WaterBodyIds`, `WaterBodies` table, `WaterSurfaceQ`).
|
||||||
Dispatches on the first byte: v2 tagged-section files get validation (version gate, size
|
Dispatches on the first byte: v2 tagged-section files get validation (version gate, size
|
||||||
bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a
|
bounds, section-length and ordinal range checks); legacy v1 files still load, intact, with a
|
||||||
deprecation warning. Nothing at runtime consumes the water data yet.
|
deprecation warning. **The water sections are consumed at runtime since task 13** — the server
|
||||||
|
reads them per column and the client draws the result (see `Server/README.md`).
|
||||||
- **`ChunkData.cs`** — one chunk's density and block-ID fields, `+1` padded on every axis so the
|
- **`ChunkData.cs`** — one chunk's density and block-ID fields, `+1` padded on every axis so the
|
||||||
mesher can reach into the neighbouring chunk, plus the per-column data the renderer needs.
|
mesher can reach into the neighbouring chunk, plus the per-column data the renderer needs —
|
||||||
|
including `WaterSurfaceY` (world Y of the water surface, or `Constants.NO_WATER`), `WaterDepthM`
|
||||||
|
(true depth from blueprint heights) and `HasAnyWater`.
|
||||||
- **`Constants.cs`** — chunk dimensions, `ISO_LEVEL`, `VOXEL_SCALE`, and the visual/road tunables
|
- **`Constants.cs`** — chunk dimensions, `ISO_LEVEL`, `VOXEL_SCALE`, and the visual/road tunables
|
||||||
(material blend band; per-tier road width, shoulder, grade smoothing and surface).
|
(material blend band; per-tier road width, shoulder, grade smoothing and surface), plus the
|
||||||
|
water-at-rest tunables: `HEIGHT_SCALE` (the one raw-height→metres mapping, shared by the terrain
|
||||||
|
surface and the water sheet so they cannot drift apart), `NO_WATER`, and the depth-shading
|
||||||
|
colour/alpha ramp.
|
||||||
|
|
||||||
### Maths
|
### Maths
|
||||||
- **`MarchingCubes.cs`** — density field → `ArrayMesh`, with analytical normals and deterministic
|
- **`MarchingCubes.cs`** — density field → `ArrayMesh`, with analytical normals and deterministic
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,34 @@
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://c1dxqbgohxr6k" path="res://Server/Scripts/ServerChunkManager.cs" id="1_r150o"]
|
[ext_resource type="Script" uid="uid://c1dxqbgohxr6k" path="res://Server/Scripts/ServerChunkManager.cs" id="1_r150o"]
|
||||||
|
|
||||||
|
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky0"]
|
||||||
|
sky_top_color = Color(0.28, 0.47, 0.79, 1)
|
||||||
|
sky_horizon_color = Color(0.72, 0.8, 0.86, 1)
|
||||||
|
sky_curve = 0.12
|
||||||
|
ground_bottom_color = Color(0.16, 0.15, 0.14, 1)
|
||||||
|
ground_horizon_color = Color(0.58, 0.56, 0.5, 1)
|
||||||
|
ground_curve = 0.05
|
||||||
|
sun_angle_max = 12.0
|
||||||
|
sun_curve = 0.15
|
||||||
|
|
||||||
|
[sub_resource type="Sky" id="Sky_main0"]
|
||||||
|
sky_material = SubResource("ProceduralSkyMaterial_sky0")
|
||||||
|
|
||||||
[sub_resource type="Environment" id="Environment_r150o"]
|
[sub_resource type="Environment" id="Environment_r150o"]
|
||||||
|
background_mode = 2
|
||||||
|
sky = SubResource("Sky_main0")
|
||||||
|
ambient_light_source = 3
|
||||||
ambient_light_color = Color(0.37245482, 0.5677467, 1, 1)
|
ambient_light_color = Color(0.37245482, 0.5677467, 1, 1)
|
||||||
|
ambient_light_energy = 0.45
|
||||||
|
|
||||||
[node name="World" type="Node3D" unique_id=412138339]
|
[node name="World" type="Node3D" unique_id=412138339]
|
||||||
script = ExtResource("1_r150o")
|
script = ExtResource("1_r150o")
|
||||||
|
|
||||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1569534216]
|
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1569534216]
|
||||||
|
transform = Transform3D(-0.7071068, 0, 0.7071068, 0.5572077, 0.6156615, 0.5572077, -0.4353384, 0.7880108, -0.4353384, 0, 0, 0)
|
||||||
|
light_energy = 1.0
|
||||||
|
shadow_enabled = true
|
||||||
|
directional_shadow_max_distance = 2500.0
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="." unique_id=1064925728]
|
[node name="Camera3D" type="Camera3D" parent="." unique_id=1064925728]
|
||||||
transform = Transform3D(1, 0, 0, 0, 0.49999997, -0.86602545, 0, 0.86602545, 0.49999997, 2559.9224, 640.90497, -171.46881)
|
transform = Transform3D(1, 0, 0, 0, 0.49999997, -0.86602545, 0, 0.86602545, 0.49999997, 2559.9224, 640.90497, -171.46881)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,24 @@ vector ends up parallel to the view direction, so camera roll is undefined and G
|
||||||
- **Road culling first.** Only the road segments whose bounding box reaches this chunk are kept,
|
- **Road culling first.** Only the road segments whose bounding box reaches this chunk are kept,
|
||||||
each tagged with its `RoadTier`. The padding is derived from the widest shoulder any tier has plus
|
each tagged with its `RoadTier`. The padding is derived from the widest shoulder any tier has plus
|
||||||
a margin, so widening a road cannot silently truncate it at chunk edges.
|
a margin, so widening a road cannot silently truncate it at chunk edges.
|
||||||
|
- **Water per column** (tasks 13, 15) — the blueprint is the **authority** on where water is and at
|
||||||
|
what level; the server only reads it. `WSRF` gives the surface level per pixel, and the `WBTB`
|
||||||
|
body table is the fallback when a column is flagged wet but carries the `WSRF` no-water sentinel.
|
||||||
|
|
||||||
|
**Presence takes two clauses.** `WBID` (the water stage's own classification output) OR *the
|
||||||
|
rendered ground being under the ocean's surface*. The second exists because `WBID` was classified
|
||||||
|
from the **uncurved** heightmap while the mesh renders the **curved** one. Outside the crater
|
||||||
|
those agree exactly — the curve is identity at sea and monotonic, so `Apply(raw) < sea` iff
|
||||||
|
`raw < sea`. Inside it they do not: the carve lerps two different bases toward one target
|
||||||
|
(classify from `raw`, rendered from `Apply(raw)`, and `Apply(raw) < raw` in the lowland band), so
|
||||||
|
the rendered surface sinks faster and leaves a ring rendering below the waterline that `WBID`
|
||||||
|
still calls dry — 375,824 px on the C1 seed, all of it inside the carve. The second clause tests
|
||||||
|
the height the mesh actually uses against the **ocean body's own level from `WBTB`**, so the
|
||||||
|
runtime still derives nothing, and by the identity above it can only ever fire inside the carve. A body the table does not know leaves the column **dry** rather than
|
||||||
|
guessing a level. The level is scaled by the same `HEIGHT_SCALE` as the terrain, so the sheet and
|
||||||
|
the seabed cannot drift apart. Depth for shading is taken from **blueprint** heights, not rendered
|
||||||
|
geometry — the terrain render clamps its floor at `Y = 2`, which would otherwise flatten every
|
||||||
|
deep-ocean column to one value. Each run logs what it drew (`[Server] Water at rest: …`).
|
||||||
- **Surface height per column** (`GetExactSurface`) — the blueprint height scaled into the chunk's
|
- **Surface height per column** (`GetExactSurface`) — the blueprint height scaled into the chunk's
|
||||||
usable vertical band, then modified by any road carving.
|
usable vertical band, then modified by any road carving.
|
||||||
- **Density per voxel** — `(y − surfaceY)` normalised by the local slope, giving a signed distance to
|
- **Density per voxel** — `(y − surfaceY)` normalised by the local slope, giving a signed distance to
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,17 @@ namespace IslaApocalypse.Server
|
||||||
private Dictionary<Vector2I, ChunkData> _activeChunks = new Dictionary<Vector2I, ChunkData>();
|
private Dictionary<Vector2I, ChunkData> _activeChunks = new Dictionary<Vector2I, ChunkData>();
|
||||||
public int chunkSize = 24;
|
public int chunkSize = 24;
|
||||||
|
|
||||||
|
// Water-at-rest diagnostics (task 13), summed across the chunks generated.
|
||||||
|
private long _waterColumnsRendered = 0;
|
||||||
|
private int _chunksWithWater = 0;
|
||||||
|
private float _waterMinY = float.MaxValue, _waterMaxY = float.MinValue, _waterMaxDepth = 0f;
|
||||||
|
|
||||||
|
// Shoreline seam (task 15). The OCEAN body's own surface level, from the
|
||||||
|
// blueprint's WBTB table; -1 when the blueprint carries no ocean. Columns the
|
||||||
|
// mesh renders below this but WBID calls dry are the seam, and are counted.
|
||||||
|
private float _oceanLevelRaw = -1f;
|
||||||
|
private long _seamColumnsRecovered = 0;
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
@ -55,6 +66,13 @@ namespace IslaApocalypse.Server
|
||||||
$"vs config MapSize {ConfigManager.MapSize}.");
|
$"vs config MapSize {ConfigManager.MapSize}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ocean's surface level, for the shoreline-seam rule below. Taken
|
||||||
|
// from the blueprint's body table (type OCEAN), never derived here —
|
||||||
|
// the runtime does not compute sea level (D-033).
|
||||||
|
if (_blueprint.WaterBodies != null)
|
||||||
|
foreach (var body in _blueprint.WaterBodies)
|
||||||
|
if (body.Type == WaterBodyInfo.TYPE_OCEAN) { _oceanLevelRaw = body.SurfaceLevel; break; }
|
||||||
|
|
||||||
GD.Print("[Server] Blueprint loaded. Locating Capitol City...");
|
GD.Print("[Server] Blueprint loaded. Locating Capitol City...");
|
||||||
|
|
||||||
// 2. Find the Capitol in the parsed data
|
// 2. Find the Capitol in the parsed data
|
||||||
|
|
@ -69,7 +87,8 @@ namespace IslaApocalypse.Server
|
||||||
|
|
||||||
// Old original png map coords
|
// Old original png map coords
|
||||||
// Vector2 capitolPos = new Vector2(2405, 3296); // hardcoded for now since we know exactly where it is in this seed, which is the "paradise" biome hub
|
// Vector2 capitolPos = new Vector2(2405, 3296); // hardcoded for now since we know exactly where it is in this seed, which is the "paradise" biome hub
|
||||||
|
// New Manual Test point
|
||||||
|
// Vector2 capitolPos = new Vector2(4487, 4424);
|
||||||
|
|
||||||
GD.Print($"[Server] Capitol found at {capitolPos}. Generating chunks...");
|
GD.Print($"[Server] Capitol found at {capitolPos}. Generating chunks...");
|
||||||
|
|
||||||
|
|
@ -87,6 +106,16 @@ namespace IslaApocalypse.Server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Say what was actually drawn. The water is the blueprint's, not the
|
||||||
|
// runtime's, so if this reads zero the question is which of the two
|
||||||
|
// sides went quiet — and this line answers it without a debugger.
|
||||||
|
GD.Print($"[Server] Water at rest: {_waterColumnsRendered} water columns across " +
|
||||||
|
$"{_chunksWithWater} of {_activeChunks.Count} chunks" +
|
||||||
|
(_waterColumnsRendered > 0
|
||||||
|
? $"; surface Y {_waterMinY:F1}..{_waterMaxY:F1} m, depth up to {_waterMaxDepth:F1} m; " +
|
||||||
|
$"{_seamColumnsRecovered} shoreline-seam columns recovered (mesh below the ocean surface, WBID dry)."
|
||||||
|
: " — nothing to draw here (check the blueprint carries WBID/WSRF)."));
|
||||||
|
|
||||||
// 5. Teleport the Camera to look down at our creation!
|
// 5. Teleport the Camera to look down at our creation!
|
||||||
Camera3D cam = GetNodeOrNull<Camera3D>("Camera3D");
|
Camera3D cam = GetNodeOrNull<Camera3D>("Camera3D");
|
||||||
if (cam != null)
|
if (cam != null)
|
||||||
|
|
@ -253,6 +282,72 @@ namespace IslaApocalypse.Server
|
||||||
newChunk.ColumnBiomes[x, z] = columnBiome;
|
newChunk.ColumnBiomes[x, z] = columnBiome;
|
||||||
newChunk.ColumnRoadMaterial[x, z] = roadSurface;
|
newChunk.ColumnRoadMaterial[x, z] = roadSurface;
|
||||||
|
|
||||||
|
// --- WATER AT REST (task 13) + SHORELINE SEAM (task 15) ------
|
||||||
|
// The blueprint is the AUTHORITY on where water is and at what
|
||||||
|
// level; the runtime only draws it. WSRF gives the level per pixel,
|
||||||
|
// and the WBTB body table is the fallback if a column is flagged wet
|
||||||
|
// but carries the WSRF no-water sentinel.
|
||||||
|
//
|
||||||
|
// PRESENCE, though, takes two clauses. WBID was classified from the UNCURVED
|
||||||
|
// heightmap — the classify path that keeps the biome/water oracle
|
||||||
|
// byte-identical — while the mesh renders the CURVED one. Outside
|
||||||
|
// the crater those two agree exactly, because the curve is identity
|
||||||
|
// at sea and monotonic, so Apply(raw) < sea iff raw < sea. INSIDE
|
||||||
|
// the crater they do not: the carve lerps two different bases toward
|
||||||
|
// one target (classify from raw, rendered from Apply(raw), and
|
||||||
|
// Apply(raw) < raw throughout the lowland band), so the rendered
|
||||||
|
// surface sinks faster and leaves a ring that renders below the
|
||||||
|
// waterline while WBID still calls it dry. Measured on seed
|
||||||
|
// 1825907253: 375,824 such columns, 100 % of them inside the carve.
|
||||||
|
//
|
||||||
|
// So presence is decided by BOTH: the blueprint's classification,
|
||||||
|
// OR the rendered ground actually being under the ocean's surface.
|
||||||
|
// The second clause can only ever fire inside the carve (see the
|
||||||
|
// identity above), which is precisely the flooded bay it exists for.
|
||||||
|
if (_blueprint.WaterBodyIds != null)
|
||||||
|
{
|
||||||
|
ushort bodyId = _blueprint.WaterBodyIds[globalX, globalZ];
|
||||||
|
float levelRaw = -1f;
|
||||||
|
|
||||||
|
if (bodyId != 0)
|
||||||
|
{
|
||||||
|
if (_blueprint.WaterSurfaceQ != null)
|
||||||
|
{
|
||||||
|
ushort q = _blueprint.WaterSurfaceQ[globalX, globalZ];
|
||||||
|
if (q != 0) levelRaw = BlueprintFormat.DecodeWaterLevel(q);
|
||||||
|
}
|
||||||
|
if (levelRaw < 0f) levelRaw = BodyLevel(bodyId);
|
||||||
|
}
|
||||||
|
else if (_oceanLevelRaw >= 0f
|
||||||
|
&& exactSurfaceY < _oceanLevelRaw * Constants.HEIGHT_SCALE)
|
||||||
|
{
|
||||||
|
// Rendered ground below the ocean's own surface level. The
|
||||||
|
// level still comes from the blueprint (the ocean body's
|
||||||
|
// WBTB entry) — the runtime derives nothing, it only notices
|
||||||
|
// that the ground the MESH draws is under that surface.
|
||||||
|
levelRaw = _oceanLevelRaw;
|
||||||
|
_seamColumnsRecovered++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (levelRaw >= 0f)
|
||||||
|
{
|
||||||
|
// Same mapping as the terrain, so the sheet and the seabed
|
||||||
|
// cannot drift apart.
|
||||||
|
newChunk.WaterSurfaceY[x, z] = Mathf.Clamp(
|
||||||
|
levelRaw * Constants.HEIGHT_SCALE, 2.0f, Constants.CHUNK_HEIGHT - 2.0f);
|
||||||
|
// TRUE depth, from blueprint units — see ChunkData.
|
||||||
|
newChunk.WaterDepthM[x, z] =
|
||||||
|
Mathf.Max(0f, (levelRaw - _blueprint.HeightMap[globalX, globalZ]) * Constants.HEIGHT_SCALE);
|
||||||
|
newChunk.HasAnyWater = true;
|
||||||
|
|
||||||
|
_waterColumnsRendered++;
|
||||||
|
float wy = newChunk.WaterSurfaceY[x, z];
|
||||||
|
if (wy < _waterMinY) _waterMinY = wy;
|
||||||
|
if (wy > _waterMaxY) _waterMaxY = wy;
|
||||||
|
if (newChunk.WaterDepthM[x, z] > _waterMaxDepth) _waterMaxDepth = newChunk.WaterDepthM[x, z];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
float hRight = surfaceRight - exactSurfaceY;
|
float hRight = surfaceRight - exactSurfaceY;
|
||||||
float hFwd = surfaceFwd - exactSurfaceY;
|
float hFwd = surfaceFwd - exactSurfaceY;
|
||||||
float slopeX = hRight / Constants.VOXEL_SCALE;
|
float slopeX = hRight / Constants.VOXEL_SCALE;
|
||||||
|
|
@ -275,6 +370,7 @@ namespace IslaApocalypse.Server
|
||||||
} // End of Z loop
|
} // End of Z loop
|
||||||
} // End of X loop
|
} // End of X loop
|
||||||
|
|
||||||
|
if (newChunk.HasAnyWater) _chunksWithWater++;
|
||||||
_activeChunks.Add(chunkCoord, newChunk);
|
_activeChunks.Add(chunkCoord, newChunk);
|
||||||
|
|
||||||
var renderer = new IslaApocalypse.Client.ChunkRenderer();
|
var renderer = new IslaApocalypse.Client.ChunkRenderer();
|
||||||
|
|
@ -282,6 +378,21 @@ namespace IslaApocalypse.Server
|
||||||
renderer.RenderChunk(newChunk);
|
renderer.RenderChunk(newChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A water body's flat surface level, from the blueprint's WBTB table. Only
|
||||||
|
/// used as the fallback when a column is flagged wet by WBID but its WSRF
|
||||||
|
/// entry is the no-water sentinel — WSRF is the per-pixel authority, this is
|
||||||
|
/// the per-body one. Returns -1 if the body is unknown, which the caller
|
||||||
|
/// reads as "leave this column dry" rather than guessing a level.
|
||||||
|
/// </summary>
|
||||||
|
private float BodyLevel(ushort bodyId)
|
||||||
|
{
|
||||||
|
if (_blueprint.WaterBodies == null) return -1f;
|
||||||
|
foreach (var body in _blueprint.WaterBodies)
|
||||||
|
if (body.Id == bodyId) return body.SurfaceLevel;
|
||||||
|
return -1f;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Turns a map-pixel position into a world surface height, with bounds clamping.
|
/// Turns a map-pixel position into a world surface height, with bounds clamping.
|
||||||
/// Same mapping used everywhere else: raw 0-1 heightmap value scaled into the
|
/// Same mapping used everywhere else: raw 0-1 heightmap value scaled into the
|
||||||
|
|
|
||||||
6
Tools/Scenes/RiverPlanTool.tscn
Normal file
6
Tools/Scenes/RiverPlanTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene format=3 uid="uid://rvplantool21"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/RiverPlanTool.cs" id="1_rpt"]
|
||||||
|
|
||||||
|
[node name="RiverPlanTool" type="Node"]
|
||||||
|
script = ExtResource("1_rpt")
|
||||||
657
Tools/Scripts/DrainageAnalysis.cs
Normal file
657
Tools/Scripts/DrainageAnalysis.cs
Normal file
|
|
@ -0,0 +1,657 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drainage-network promotion — C0b part 1 (terrain-water task 21). PURE ANALYSIS:
|
||||||
|
/// reads the ERODED render heightmap and produces a river PLAN — it changes zero
|
||||||
|
/// terrain and adds zero water. Standalone numeric (D-035 family; no Godot types).
|
||||||
|
///
|
||||||
|
/// Pipeline, built on the task-03 priority-flood family:
|
||||||
|
/// 1. Priority-flood the eroded surface from the map border (Barnes heap+pit
|
||||||
|
/// variant, 8-connected, same as RunPriorityFloodDiagnostics) — but with a
|
||||||
|
/// one-ulp epsilon on pit fills, so every filled cell keeps a STRICTLY
|
||||||
|
/// descending path to its spill. This resolves the ~15,000 erosion pits
|
||||||
|
/// (task-20 finding) for ROUTING ONLY; the terrain itself is never modified.
|
||||||
|
/// 2. Depressions that are deep AND large enough (the endorheic dials) are NOT
|
||||||
|
/// filled through: their cells revert to original heights, so flow entering
|
||||||
|
/// them terminates at the basin minimum. Real closed drainage survives;
|
||||||
|
/// micro-pits route through.
|
||||||
|
/// 3. D8 flow directions on that routing surface. D8 was reverted as a CARVING
|
||||||
|
/// technique (task 10 — grid-aligned scratches in the terrain); using it to
|
||||||
|
/// COMPUTE where water flows is standard hydrology and leaves no mark.
|
||||||
|
/// 4. Flow accumulation by topological (Kahn) propagation — no sort needed.
|
||||||
|
/// 5. Promotion: outlets to the sea ranked by drainage area, top-N (separated)
|
||||||
|
/// become trunks; main stems traced upstream by max-accumulation; the
|
||||||
|
/// mountain-exit point found from the along-stem grade; LEAN tributaries and
|
||||||
|
/// LEAN endorheic terminals marked.
|
||||||
|
///
|
||||||
|
/// The plan's lowland courses are provisional: erosion delivered the UPLAND
|
||||||
|
/// network only (task 18 §3), so below each mountain-exit the traced course is
|
||||||
|
/// "where the routing surface drains", not a designed river. Part 2 (task 22)
|
||||||
|
/// routes the lowland reach properly from the mountain-exit points — which is why
|
||||||
|
/// those points are this analysis's key output.
|
||||||
|
/// </summary>
|
||||||
|
public static class DrainageAnalysis
|
||||||
|
{
|
||||||
|
public const float M_PER_UNIT = 251f;
|
||||||
|
|
||||||
|
// Neighbour order is FIXED (it is the deterministic tiebreak).
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
private static readonly float[] DIST = {
|
||||||
|
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
|
||||||
|
|
||||||
|
public class Params
|
||||||
|
{
|
||||||
|
// Endorheic qualification: a depression this deep AND this large is a real
|
||||||
|
// closed basin and terminates flow; anything smaller is a pit, filled through.
|
||||||
|
public float EndorheicMinDepthM = 2.0f;
|
||||||
|
public int EndorheicMinAreaPx = 10000;
|
||||||
|
// Endorheic REPORTING is lean: only terminals with at least this much
|
||||||
|
// upstream drainage, at most MaxCount of them.
|
||||||
|
public int EndorheicMinInflowPx = 50000;
|
||||||
|
public int EndorheicMaxCount = 3;
|
||||||
|
|
||||||
|
public int TrunkCount = 3; // ~3 sea-reaching trunks (developer)
|
||||||
|
public int GiantCount = 3; // 21b: top endorheic giants promoted
|
||||||
|
public int MinOutletSeparationPx = 400; // don't pick 3 mouths of one delta
|
||||||
|
|
||||||
|
public int StemMinAccPx = 1000; // stem tracing stops below this
|
||||||
|
public int TributaryMinAccPx = 30000; // LEAN: a branch must drain this much
|
||||||
|
public int TributaryMaxPerTrunk = 4; // ...and only the top few are marked
|
||||||
|
|
||||||
|
// Mountain-exit: furthest-downstream stem point where the upstream window
|
||||||
|
// still sustains this grade (m per px) over ExitWindowPx.
|
||||||
|
public float ExitGradeMin = 0.05f;
|
||||||
|
public int ExitWindowPx = 100;
|
||||||
|
|
||||||
|
public float SeaLevel = 0.15f; // flat sea scalar (raw units)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Stream
|
||||||
|
{
|
||||||
|
public List<(float x, float y)> Course = new(); // downstream-first
|
||||||
|
public long DrainageAreaPx;
|
||||||
|
public (float x, float y) Head; // upstream end
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Trunk : Stream
|
||||||
|
{
|
||||||
|
public (float x, float y) Outlet; // last land cell before sea
|
||||||
|
public (float x, float y) MountainExit;
|
||||||
|
public float MountainExitElevM;
|
||||||
|
public bool ExitFound;
|
||||||
|
public List<Stream> Tributaries = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A promoted endorheic giant (task 21b): one of the island's biggest drainage
|
||||||
|
/// systems, which pools inland because erosion could not cross the flats.
|
||||||
|
/// Kind "routed" carries a PROVISIONAL route across the flats to the ocean —
|
||||||
|
/// the path part 2 would carve, drawn for the gate, not water. Kind
|
||||||
|
/// "lake-ender" keeps its lake/lagoon terminal (real geography, developer's
|
||||||
|
/// call). Terminal is where the MAIN STEM actually pools (its sub-minimum),
|
||||||
|
/// which on a flat basin floor is more truthful than the basin's deepest cell.
|
||||||
|
/// </summary>
|
||||||
|
public class Giant : Stream
|
||||||
|
{
|
||||||
|
public (float x, float y) Terminal;
|
||||||
|
public (float x, float y) Spill; // where the basin overtops
|
||||||
|
public float BasinDepthM;
|
||||||
|
public long BasinAreaPx;
|
||||||
|
public string Kind = "routed"; // "routed" | "lake-ender"
|
||||||
|
public bool SouthernCandidate;
|
||||||
|
public bool TerminalInClassifyWater;
|
||||||
|
public List<(float x, float y)> ProvisionalRoute; // null for lake-enders
|
||||||
|
public bool RouteReachedOcean;
|
||||||
|
public (float x, float y) MountainExit;
|
||||||
|
public float MountainExitElevM;
|
||||||
|
public bool ExitFound;
|
||||||
|
public List<Stream> Tributaries = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EndorheicTerminal
|
||||||
|
{
|
||||||
|
public (float x, float y) Terminal; // basin minimum
|
||||||
|
public long DrainageAreaPx;
|
||||||
|
public float BasinDepthM;
|
||||||
|
public long BasinAreaPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Plan
|
||||||
|
{
|
||||||
|
public List<Trunk> Trunks = new();
|
||||||
|
public List<EndorheicTerminal> Endorheics = new();
|
||||||
|
public List<Giant> Giants = new(); // 21b: the promoted giants
|
||||||
|
public int TerminalBasinCount; // basins that qualified as sinks
|
||||||
|
public long PitsFilledCount; // depressions filled through
|
||||||
|
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
|
||||||
|
public List<(float x, float y, long acc)> AllOutletsTop = new(); // top 12, pre-separation
|
||||||
|
public Params P;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="isOcean">Row-major mask of THE OCEAN body (WBID == 1) — the
|
||||||
|
/// only water that counts as "the sea" for sea-reaching trunks. Below-sea
|
||||||
|
/// cells that are NOT ocean (enclosed lagoons, below-datum lake beds) are
|
||||||
|
/// ordinary terrain to the router: as depressions they either qualify as
|
||||||
|
/// terminal basins (a river legitimately ENDING in a lagoon/lake — reported as
|
||||||
|
/// such) or fill and spill onward to the true sea. Without this mask the first
|
||||||
|
/// draft called two of its three "sea-reaching" trunks done at enclosed
|
||||||
|
/// lagoons, which is exactly the overclaim the gate must not inherit.</param>
|
||||||
|
/// <param name="isClassifyWater">Row-major mask of ANY classify water (WBID != 0):
|
||||||
|
/// a giant whose main stem pools inside classify water is a natural lake-ender;
|
||||||
|
/// one pooling on dry ground is a route-to-sea candidate.</param>
|
||||||
|
/// <param name="southX">Southernmost-town position (or -1 for none): the giant
|
||||||
|
/// whose terminal lies closest is flagged the SOUTHERN CANDIDATE and always
|
||||||
|
/// routed provisionally, per the 21b design — shown, not forced.</param>
|
||||||
|
public static Plan Run(float[,] height, int mapSize, bool[] isOcean,
|
||||||
|
bool[] isClassifyWater, float southX, float southY, Params p)
|
||||||
|
{
|
||||||
|
int n = mapSize;
|
||||||
|
int total = n * n;
|
||||||
|
var plan = new Plan { P = p };
|
||||||
|
|
||||||
|
// 1-D row-major copies (idx = x * n + y), same convention as the task-03 pass.
|
||||||
|
float[] original = new float[total];
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
original[x * n + y] = height[x, y];
|
||||||
|
|
||||||
|
float[] plan_fullFilled = null; // set inside step 2, used by 21b routing
|
||||||
|
|
||||||
|
// --- 1. Priority-flood with one-ulp epsilon (routing surface only) ---
|
||||||
|
float[] filled = (float[])original.Clone();
|
||||||
|
{
|
||||||
|
bool[] visited = new bool[total];
|
||||||
|
var heap = new PriorityQueue<int, (float h, int idx)>();
|
||||||
|
var pit = new Queue<int>();
|
||||||
|
void Seed(int idx)
|
||||||
|
{
|
||||||
|
if (visited[idx]) return;
|
||||||
|
visited[idx] = true;
|
||||||
|
heap.Enqueue(idx, (filled[idx], idx)); // idx tiebreak => deterministic
|
||||||
|
}
|
||||||
|
for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); }
|
||||||
|
for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); }
|
||||||
|
|
||||||
|
while (heap.Count > 0 || pit.Count > 0)
|
||||||
|
{
|
||||||
|
int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue();
|
||||||
|
float fc = filled[c];
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (visited[ni]) continue;
|
||||||
|
visited[ni] = true;
|
||||||
|
if (filled[ni] <= fc)
|
||||||
|
{
|
||||||
|
// One ulp above the parent: strictly descending back out, so
|
||||||
|
// D8 never meets an exact flat inside a filled pit.
|
||||||
|
filled[ni] = MathF.BitIncrement(fc);
|
||||||
|
pit.Enqueue(ni);
|
||||||
|
}
|
||||||
|
else heap.Enqueue(ni, (filled[ni], ni));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Depression components; big+deep ones become terminal sinks ---
|
||||||
|
// Components of (filled > original), 8-connected — the pools. Qualifying
|
||||||
|
// pools revert to ORIGINAL height so flow terminates at their minimum.
|
||||||
|
int[] basinId = new int[total]; // 0 = not in a pool
|
||||||
|
var basinDepthM = new List<float> { 0f };
|
||||||
|
var basinAreaPx = new List<long> { 0L };
|
||||||
|
var basinMinCell = new List<int> { -1 };
|
||||||
|
{
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
int nextId = 1;
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (basinId[i] != 0 || filled[i] <= original[i]) continue;
|
||||||
|
int id = nextId++;
|
||||||
|
long area = 0; float depth = 0f; int minCell = i; float minH = original[i];
|
||||||
|
stack.Push(i); basinId[i] = id;
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int c = stack.Pop();
|
||||||
|
area++;
|
||||||
|
float d = (filled[c] - original[c]) * M_PER_UNIT;
|
||||||
|
if (d > depth) depth = d;
|
||||||
|
if (original[c] < minH) { minH = original[c]; minCell = c; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (basinId[ni] == 0 && filled[ni] > original[ni])
|
||||||
|
{ basinId[ni] = id; stack.Push(ni); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
basinDepthM.Add(depth); basinAreaPx.Add(area); basinMinCell.Add(minCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21b: the FULL fill (before terminal reversion) is the provisional-
|
||||||
|
// routing surface — on it, every basin overtops at its spill and drains
|
||||||
|
// to the border, which is exactly "where the water would continue".
|
||||||
|
plan_fullFilled = (float[])filled.Clone();
|
||||||
|
|
||||||
|
bool[] terminal = new bool[nextId];
|
||||||
|
for (int id = 1; id < nextId; id++)
|
||||||
|
{
|
||||||
|
if (basinDepthM[id] >= p.EndorheicMinDepthM && basinAreaPx[id] >= p.EndorheicMinAreaPx)
|
||||||
|
{ terminal[id] = true; plan.TerminalBasinCount++; }
|
||||||
|
else plan.PitsFilledCount++;
|
||||||
|
}
|
||||||
|
// Revert terminal pools to the real surface; re-tag basinId to keep only
|
||||||
|
// terminal pools (routing needs to know "am I in a terminal basin").
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (basinId[i] == 0) continue;
|
||||||
|
if (terminal[basinId[i]]) filled[i] = original[i];
|
||||||
|
else basinId[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. D8 flow directions on the routing surface ---
|
||||||
|
// dir[i] = 0..7 neighbour, SEA (into a below-sea cell), or NONE (sink).
|
||||||
|
const sbyte D_NONE = -1, D_SEA = -2;
|
||||||
|
sbyte[] dir = new sbyte[total];
|
||||||
|
bool IsSea(int idx) => isOcean[idx];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) { dir[i] = D_NONE; continue; }
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
float best = 0f; int bestK = -1; bool bestIsSea = false;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
float drop = (filled[i] - filled[ni]) / DIST[k];
|
||||||
|
if (drop > best) { best = drop; bestK = k; bestIsSea = IsSea(ni); }
|
||||||
|
}
|
||||||
|
dir[i] = bestK < 0 ? D_NONE : (bestIsSea ? D_SEA : (sbyte)bestK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Flow accumulation (Kahn topological propagation) ---
|
||||||
|
int Target(int i)
|
||||||
|
{
|
||||||
|
if (dir[i] < 0) return -1;
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
return (cx + DX[dir[i]]) * n + (cy + DY[dir[i]]);
|
||||||
|
}
|
||||||
|
int[] acc = new int[total];
|
||||||
|
{
|
||||||
|
byte[] indeg = new byte[total];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (dir[i] >= 0) indeg[Target(i)]++;
|
||||||
|
var q = new Queue<int>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) continue;
|
||||||
|
acc[i] = 1;
|
||||||
|
if (indeg[i] == 0) q.Enqueue(i);
|
||||||
|
}
|
||||||
|
while (q.Count > 0)
|
||||||
|
{
|
||||||
|
int c = q.Dequeue();
|
||||||
|
if (dir[c] < 0) continue;
|
||||||
|
int t = Target(c);
|
||||||
|
acc[t] += acc[c];
|
||||||
|
if (--indeg[t] == 0 && !IsSea(t)) q.Enqueue(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookkeeping: where does each cell's flow END — the sea, WHICH terminal
|
||||||
|
// basin, or stuck? Memoised downstream walk. The per-basin totals matter:
|
||||||
|
// crediting a terminal basin only with acc at its deepest cell undercounts
|
||||||
|
// badly when the basin floor is flat (a lagoon bed scatters inflow across
|
||||||
|
// many sub-minima — measured: a 500k-px lagoon system reported under 50k).
|
||||||
|
long[] basinInflow = new long[basinMinCell.Count];
|
||||||
|
int[] dest = new int[total]; // 0 unknown, -1 sea, -2 stuck, >0 basin id
|
||||||
|
{
|
||||||
|
var path = new List<int>(4096);
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i) || dest[i] != 0) continue;
|
||||||
|
int c = i; path.Clear();
|
||||||
|
int result;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (dest[c] != 0) { result = dest[c]; break; }
|
||||||
|
path.Add(c);
|
||||||
|
if (dir[c] == D_SEA) { result = -1; break; }
|
||||||
|
if (dir[c] == D_NONE) { result = basinId[c] != 0 ? basinId[c] : -2; break; }
|
||||||
|
c = Target(c);
|
||||||
|
}
|
||||||
|
foreach (int pc in path) dest[pc] = result;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (IsSea(i)) continue;
|
||||||
|
plan.LandCells++;
|
||||||
|
if (dest[i] == -1) plan.SeaReachingCells++;
|
||||||
|
else if (dest[i] > 0) { plan.EndorheicCells++; basinInflow[dest[i]]++; }
|
||||||
|
else plan.UnroutedCells++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5a. Outlets: land cells whose flow enters the sea, ranked by acc ---
|
||||||
|
var outlets = new List<(int cell, long acc)>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (dir[i] == D_SEA) outlets.Add((i, acc[i]));
|
||||||
|
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||||
|
|
||||||
|
foreach (var (cell, a) in outlets.GetRange(0, Math.Min(12, outlets.Count)))
|
||||||
|
plan.AllOutletsTop.Add((cell / n, cell % n, a));
|
||||||
|
|
||||||
|
// Greedy top-N with separation, so three mouths of one delta can't take
|
||||||
|
// all three trunk slots.
|
||||||
|
var picked = new List<int>();
|
||||||
|
foreach (var (cell, _) in outlets)
|
||||||
|
{
|
||||||
|
if (picked.Count >= p.TrunkCount) break;
|
||||||
|
int cx = cell / n, cy = cell % n;
|
||||||
|
bool far = true;
|
||||||
|
foreach (int pcell in picked)
|
||||||
|
{
|
||||||
|
float ddx = cx - pcell / n, ddy = cy - pcell % n;
|
||||||
|
if (ddx * ddx + ddy * ddy < (float)p.MinOutletSeparationPx * p.MinOutletSeparationPx)
|
||||||
|
{ far = false; break; }
|
||||||
|
}
|
||||||
|
if (far) picked.Add(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstream max-acc walk shared by trunks and tributaries
|
||||||
|
List<int> TraceStem(int fromCell, int minAcc)
|
||||||
|
{
|
||||||
|
var stem = new List<int> { fromCell };
|
||||||
|
int c = fromCell;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
int bestN = -1; long bestA = minAcc - 1;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (dir[ni] >= 0 && Target(ni) == c && acc[ni] > bestA)
|
||||||
|
{ bestA = acc[ni]; bestN = ni; }
|
||||||
|
}
|
||||||
|
if (bestN < 0) break;
|
||||||
|
stem.Add(bestN);
|
||||||
|
c = bestN;
|
||||||
|
}
|
||||||
|
return stem;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(float x, float y)> Decimate(List<int> cells, int step = 4)
|
||||||
|
{
|
||||||
|
var pts = new List<(float, float)>();
|
||||||
|
for (int i = 0; i < cells.Count; i += step)
|
||||||
|
pts.Add((cells[i] / n, cells[i] % n));
|
||||||
|
if ((cells.Count - 1) % step != 0)
|
||||||
|
pts.Add((cells[^1] / n, cells[^1] % n));
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5b. Trunks: stems, mountain exits, LEAN tributaries ---
|
||||||
|
foreach (int outletCell in picked)
|
||||||
|
{
|
||||||
|
var t = new Trunk
|
||||||
|
{
|
||||||
|
Outlet = (outletCell / n, outletCell % n),
|
||||||
|
DrainageAreaPx = acc[outletCell]
|
||||||
|
};
|
||||||
|
var stem = TraceStem(outletCell, p.StemMinAccPx);
|
||||||
|
t.Course = Decimate(stem);
|
||||||
|
t.Head = (stem[^1] / n, stem[^1] % n);
|
||||||
|
|
||||||
|
// Mountain-exit: walk the stem downstream-first; the exit is the
|
||||||
|
// furthest-DOWNSTREAM point whose upstream window still sustains the
|
||||||
|
// grade — i.e. where the mountains hand the river to the flats.
|
||||||
|
// Elevation truth is the ORIGINAL eroded surface, not the fill.
|
||||||
|
int w = p.ExitWindowPx;
|
||||||
|
for (int i = 0; i + w < stem.Count; i++)
|
||||||
|
{
|
||||||
|
float rise = (original[stem[i + w]] - original[stem[i]]) * M_PER_UNIT;
|
||||||
|
if (rise / w >= p.ExitGradeMin)
|
||||||
|
{
|
||||||
|
t.ExitFound = true;
|
||||||
|
t.MountainExit = (stem[i] / n, stem[i] % n);
|
||||||
|
t.MountainExitElevM = original[stem[i]] * M_PER_UNIT;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEAN tributaries: junction branches off the stem with enough drainage,
|
||||||
|
// top few by accumulation.
|
||||||
|
var stemSet = new HashSet<int>(stem);
|
||||||
|
var cands = new List<(int cell, long acc)>();
|
||||||
|
foreach (int sc in stem)
|
||||||
|
{
|
||||||
|
int cx = sc / n, cy = sc % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (stemSet.Contains(ni)) continue;
|
||||||
|
if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
|
||||||
|
cands.Add((ni, acc[ni]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cands.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||||
|
// Dedup: two inflow neighbours at adjacent stem cells are one confluence,
|
||||||
|
// not two tributaries — keep only junctions ≥ 30 px apart.
|
||||||
|
var taken = new List<int>();
|
||||||
|
foreach (var (cell, a) in cands)
|
||||||
|
{
|
||||||
|
if (taken.Count >= p.TributaryMaxPerTrunk) break;
|
||||||
|
int cx2 = cell / n, cy2 = cell % n;
|
||||||
|
bool dup = false;
|
||||||
|
foreach (int tc in taken)
|
||||||
|
{
|
||||||
|
float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
|
||||||
|
if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
|
||||||
|
}
|
||||||
|
if (!dup) taken.Add(cell);
|
||||||
|
}
|
||||||
|
foreach (int cell in taken)
|
||||||
|
{
|
||||||
|
long a = acc[cell];
|
||||||
|
var trib = new Stream { DrainageAreaPx = a };
|
||||||
|
var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(a / 20)));
|
||||||
|
trib.Course = Decimate(ts);
|
||||||
|
trib.Head = (ts[^1] / n, ts[^1] % n);
|
||||||
|
t.Tributaries.Add(trib);
|
||||||
|
}
|
||||||
|
plan.Trunks.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5c. LEAN endorheic terminals: terminal basins ranked by TOTAL inflow ---
|
||||||
|
{
|
||||||
|
var terms = new List<(int id, long inflow)>();
|
||||||
|
for (int id = 1; id < basinMinCell.Count; id++)
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
if (mc < 0 || basinId[mc] != id) continue; // not a terminal basin
|
||||||
|
if (basinInflow[id] >= p.EndorheicMinInflowPx) terms.Add((id, basinInflow[id]));
|
||||||
|
}
|
||||||
|
terms.Sort((a, b) => b.inflow.CompareTo(a.inflow));
|
||||||
|
foreach (var (id, inflow) in terms.GetRange(0, Math.Min(p.EndorheicMaxCount, terms.Count)))
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
plan.Endorheics.Add(new EndorheicTerminal
|
||||||
|
{
|
||||||
|
Terminal = (mc / n, mc % n),
|
||||||
|
DrainageAreaPx = inflow,
|
||||||
|
BasinDepthM = basinDepthM[id],
|
||||||
|
BasinAreaPx = basinAreaPx[id]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5d. The promoted GIANTS (21b): mixed set, provisional routes ---
|
||||||
|
// Top GiantCount terminal basins by TOTAL inflow. Their upland stems are the
|
||||||
|
// island's real big rivers; whether each continues to the sea is the gate's
|
||||||
|
// decision, previewed here.
|
||||||
|
{
|
||||||
|
var giantsRanked = new List<(int id, long inflow)>();
|
||||||
|
for (int id = 1; id < basinMinCell.Count; id++)
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
if (mc < 0 || basinId[mc] != id) continue;
|
||||||
|
if (basinInflow[id] >= p.EndorheicMinInflowPx) giantsRanked.Add((id, basinInflow[id]));
|
||||||
|
}
|
||||||
|
giantsRanked.Sort((a, b) => b.inflow.CompareTo(a.inflow));
|
||||||
|
|
||||||
|
// Does a terminal basin HOLD classify water? The lake-ender test must look
|
||||||
|
// at the whole pool, not the stem's single pooling cell — a stem can pool on
|
||||||
|
// dry ground a few hundred px short of its lagoon and still be a lagoon river.
|
||||||
|
bool[] basinHasLake = new bool[basinMinCell.Count];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (basinId[i] != 0 && isClassifyWater[i] && !isOcean[i])
|
||||||
|
basinHasLake[basinId[i]] = true;
|
||||||
|
|
||||||
|
// The main stem's ENTRY into the basin: the highest-accumulation cell
|
||||||
|
// whose flow terminates in this basin. On a flat basin floor the deepest
|
||||||
|
// cell sees only local trickles (the task-21 lesson), so the stem is
|
||||||
|
// anchored on the strongest feeder instead.
|
||||||
|
var bestEntry = new Dictionary<int, int>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (dest[i] <= 0) continue;
|
||||||
|
if (!bestEntry.TryGetValue(dest[i], out int cur) || acc[i] > acc[cur])
|
||||||
|
bestEntry[dest[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The giant whose pooling point sits closest to the southernmost town is
|
||||||
|
// the SOUTHERN CANDIDATE — always routed provisionally (shown, not forced).
|
||||||
|
int southernPick = -1;
|
||||||
|
if (southX >= 0f)
|
||||||
|
{
|
||||||
|
float bestD = float.MaxValue;
|
||||||
|
foreach (var (id, _) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count)))
|
||||||
|
{
|
||||||
|
int mc = basinMinCell[id];
|
||||||
|
float ddx = mc / n - southX, ddy = mc % n - southY;
|
||||||
|
float d2 = ddx * ddx + ddy * ddy;
|
||||||
|
if (d2 < bestD) { bestD = d2; southernPick = id; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (id, inflow) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count)))
|
||||||
|
{
|
||||||
|
var g = new Giant { DrainageAreaPx = inflow, BasinDepthM = basinDepthM[id], BasinAreaPx = basinAreaPx[id] };
|
||||||
|
if (!bestEntry.TryGetValue(id, out int entry)) entry = basinMinCell[id];
|
||||||
|
|
||||||
|
// Downstream from the strongest feeder to where it actually pools…
|
||||||
|
int t2 = entry;
|
||||||
|
var down = new List<int> { t2 };
|
||||||
|
while (dir[t2] >= 0) { t2 = Target(t2); down.Add(t2); }
|
||||||
|
g.Terminal = (t2 / n, t2 % n);
|
||||||
|
// …then the full main stem, traced upstream from that pooling point.
|
||||||
|
var stem = TraceStem(t2, p.StemMinAccPx);
|
||||||
|
g.Course = Decimate(stem);
|
||||||
|
g.Head = (stem[^1] / n, stem[^1] % n);
|
||||||
|
g.TerminalInClassifyWater = isClassifyWater[t2];
|
||||||
|
|
||||||
|
for (int i = 0; i + p.ExitWindowPx < stem.Count; i++)
|
||||||
|
{
|
||||||
|
float rise = (original[stem[i + p.ExitWindowPx]] - original[stem[i]]) * M_PER_UNIT;
|
||||||
|
if (rise / p.ExitWindowPx >= p.ExitGradeMin)
|
||||||
|
{
|
||||||
|
g.ExitFound = true;
|
||||||
|
g.MountainExit = (stem[i] / n, stem[i] % n);
|
||||||
|
g.MountainExitElevM = original[stem[i]] * M_PER_UNIT;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lean tributaries on the giant's stem, same junction rule as trunks.
|
||||||
|
var stemSet = new HashSet<int>(stem);
|
||||||
|
var cands = new List<(int cell, long acc)>();
|
||||||
|
foreach (int sc in stem)
|
||||||
|
{
|
||||||
|
int cx = sc / n, cy = sc % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (stemSet.Contains(ni)) continue;
|
||||||
|
if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx)
|
||||||
|
cands.Add((ni, acc[ni]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cands.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||||||
|
var takenT = new List<int>();
|
||||||
|
foreach (var (cell, _) in cands)
|
||||||
|
{
|
||||||
|
if (takenT.Count >= p.TributaryMaxPerTrunk) break;
|
||||||
|
int cx2 = cell / n, cy2 = cell % n;
|
||||||
|
bool dup = false;
|
||||||
|
foreach (int tc in takenT)
|
||||||
|
{
|
||||||
|
float ddx = cx2 - tc / n, ddy = cy2 - tc % n;
|
||||||
|
if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; }
|
||||||
|
}
|
||||||
|
if (!dup) takenT.Add(cell);
|
||||||
|
}
|
||||||
|
foreach (int cell in takenT)
|
||||||
|
{
|
||||||
|
var trib = new Stream { DrainageAreaPx = acc[cell] };
|
||||||
|
var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(acc[cell] / 20)));
|
||||||
|
trib.Course = Decimate(ts);
|
||||||
|
trib.Head = (ts[^1] / n, ts[^1] % n);
|
||||||
|
g.Tributaries.Add(trib);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind: the terminal BASIN holds a classify lake → natural lake-ender;
|
||||||
|
// dry pan → route to sea; the southern candidate is always routed.
|
||||||
|
g.SouthernCandidate = id == southernPick;
|
||||||
|
g.TerminalInClassifyWater = g.TerminalInClassifyWater || basinHasLake[id];
|
||||||
|
g.Kind = (basinHasLake[id] && !g.SouthernCandidate) ? "lake-ender" : "routed";
|
||||||
|
|
||||||
|
// PROVISIONAL route (routed giants): walk steepest descent on the FULL
|
||||||
|
// fill from the pooling point — the basin overtops at its spill and
|
||||||
|
// the walk continues along the terrain's own drainage to the ocean.
|
||||||
|
// DRAWN, not carved; part 2 carves along a route like this one.
|
||||||
|
if (g.Kind == "routed")
|
||||||
|
{
|
||||||
|
var route = new List<int>();
|
||||||
|
int c = t2;
|
||||||
|
bool spillRecorded = false;
|
||||||
|
for (int guard = 0; guard < 4 * n; guard++)
|
||||||
|
{
|
||||||
|
route.Add(c);
|
||||||
|
if (isOcean[c]) { g.RouteReachedOcean = true; break; }
|
||||||
|
if (!spillRecorded && basinId[c] != id)
|
||||||
|
{ g.Spill = (c / n, c % n); spillRecorded = true; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
float best = float.MaxValue; int bestN = -1;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (plan_fullFilled[ni] < best) { best = plan_fullFilled[ni]; bestN = ni; }
|
||||||
|
}
|
||||||
|
if (bestN < 0 || plan_fullFilled[bestN] >= plan_fullFilled[c]) break; // stuck (report via flag)
|
||||||
|
c = bestN;
|
||||||
|
}
|
||||||
|
g.ProvisionalRoute = Decimate(route);
|
||||||
|
}
|
||||||
|
plan.Giants.Add(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/DrainageAnalysis.cs.uid
Normal file
1
Tools/Scripts/DrainageAnalysis.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dhwyjpwd58pos
|
||||||
|
|
@ -42,6 +42,11 @@ public sealed class CurveKnots
|
||||||
/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m,
|
/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m,
|
||||||
/// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization,
|
/// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization,
|
||||||
/// modulation fields/seed offsets, the classify-map invariant.
|
/// modulation fields/seed offsets, the classify-map invariant.
|
||||||
|
///
|
||||||
|
/// The curve SHAPE is frozen at v5. Task 10 adds no band, anchor or slope — only
|
||||||
|
/// a per-column `edgeShift` parameter on Apply, which slides the shelf/riser knot
|
||||||
|
/// block K3/K4/K5 so those three boundaries stop being clean iso-height contours.
|
||||||
|
/// It is a new input to the same curve, not a new curve.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class HeightCurve
|
public static class HeightCurve
|
||||||
{
|
{
|
||||||
|
|
@ -87,11 +92,29 @@ public static class HeightCurve
|
||||||
return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f));
|
return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The curve for ONE column. <paramref name="edgeShift"/> (task 10 pass B) slides
|
||||||
|
/// the shelf/riser knot BLOCK — K3/K4/K5 — up or down by a per-column amount,
|
||||||
|
/// leaving K1/K2/K6 fixed. Every shelf↔riser boundary is the contour where the
|
||||||
|
/// raw height crosses one of those three knots, so shifting them makes those
|
||||||
|
/// contours wander instead of tracing a clean iso-height line: the shelf edge
|
||||||
|
/// scallops. Because the block moves rigidly, the bench and mid-riser bands keep
|
||||||
|
/// their exact widths (their interior shapes are translated, not distorted); only
|
||||||
|
/// the foothill riser and the plateau stretch or compress to absorb the shift.
|
||||||
|
/// Monotonicity is structural, not conditional — the curve is monotonic for ANY
|
||||||
|
/// strictly ordered knot set, and TerrainDetailPass.MaxEdgeShift keeps the set
|
||||||
|
/// ordered by construction. Below K2 and above K6 the output is bit-identical to
|
||||||
|
/// an unwarped column, which is what makes the red-ceiling floor and the 420 m
|
||||||
|
/// peak cap exact under the warp.
|
||||||
|
/// </summary>
|
||||||
public static float Apply(float h, float hMaxSeed,
|
public static float Apply(float h, float hMaxSeed,
|
||||||
float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k)
|
float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k,
|
||||||
|
float edgeShift)
|
||||||
{
|
{
|
||||||
if (h <= SEA) return h;
|
if (h <= SEA) return h;
|
||||||
|
|
||||||
|
float k3 = k.K3 + edgeShift, k4 = k.K4 + edgeShift, k5 = k.K5 + edgeShift;
|
||||||
|
|
||||||
float u, s;
|
float u, s;
|
||||||
if (h < k.K1)
|
if (h < k.K1)
|
||||||
{
|
{
|
||||||
|
|
@ -104,27 +127,27 @@ public static class HeightCurve
|
||||||
u = (h - k.K1) / (k.K2 - k.K1);
|
u = (h - k.K1) / (k.K2 - k.K1);
|
||||||
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise
|
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise
|
||||||
}
|
}
|
||||||
if (h < k.K3)
|
if (h < k3)
|
||||||
{
|
{
|
||||||
u = (h - k.K2) / (k.K3 - k.K2);
|
u = (h - k.K2) / (k3 - k.K2);
|
||||||
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1
|
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1
|
||||||
return RED_CEIL + s * (benchLo - RED_CEIL);
|
return RED_CEIL + s * (benchLo - RED_CEIL);
|
||||||
}
|
}
|
||||||
if (h < k.K4)
|
if (h < k4)
|
||||||
{
|
{
|
||||||
u = (h - k.K3) / (k.K4 - k.K3);
|
u = (h - k3) / (k4 - k3);
|
||||||
return benchLo + u * benchSpan; // bench (min span 6 m — fix 3)
|
return benchLo + u * benchSpan; // bench (min span 6 m — fix 3)
|
||||||
}
|
}
|
||||||
float benchTop = benchLo + benchSpan;
|
float benchTop = benchLo + benchSpan;
|
||||||
if (h < k.K5)
|
if (h < k5)
|
||||||
{
|
{
|
||||||
u = (h - k.K4) / (k.K5 - k.K4);
|
u = (h - k4) / (k5 - k4);
|
||||||
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
|
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
|
||||||
return benchTop + s * (plateauLo - benchTop);
|
return benchTop + s * (plateauLo - benchTop);
|
||||||
}
|
}
|
||||||
if (h < k.K6)
|
if (h < k.K6)
|
||||||
{
|
{
|
||||||
u = (h - k.K5) / (k.K6 - k.K5);
|
u = (h - k5) / (k.K6 - k5);
|
||||||
return plateauLo + u * plateauSpan; // plateau
|
return plateauLo + u * plateauSpan; // plateau
|
||||||
}
|
}
|
||||||
float plateauTop = plateauLo + plateauSpan;
|
float plateauTop = plateauLo + plateauSpan;
|
||||||
|
|
@ -140,15 +163,25 @@ public static class HeightCurve
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for
|
/// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for
|
||||||
/// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax.
|
/// the selected preset: all 8 modulation-extreme corners × the shelf-edge warp
|
||||||
/// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the
|
/// extremes (±maxEdgeShift and 0) × per-seed spikeMax — 24 corners. The corner
|
||||||
/// sweep proves they stay strictly positive everywhere. Loud throw on failure.
|
/// fixes lower the slope floors (risers 0.1, spike base 0.05) and the warp
|
||||||
|
/// squeezes the foothill riser and the plateau; the sweep proves every slope
|
||||||
|
/// stays strictly positive at the extremes of both. Also checks the knot set
|
||||||
|
/// itself stays strictly ordered under the warp. Loud throw on failure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void AssertMonotonic(float hMaxSeed, CurveKnots k)
|
public static void AssertMonotonic(float hMaxSeed, CurveKnots k, float maxEdgeShift)
|
||||||
{
|
{
|
||||||
|
if (maxEdgeShift < 0f || k.K2 + maxEdgeShift >= k.K3 || k.K5 + maxEdgeShift >= k.K6)
|
||||||
|
throw new System.InvalidOperationException(
|
||||||
|
$"[HeightCurve] EDGE-SHIFT BOUND VIOLATION: maxEdgeShift={maxEdgeShift} does not keep K2<K3±d and K5±d<K6 (preset {k.Name}). Refusing to generate.");
|
||||||
|
|
||||||
float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP };
|
float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP };
|
||||||
float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP };
|
float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP };
|
||||||
float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX };
|
float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX };
|
||||||
|
float[] edgeShifts = maxEdgeShift > 0f
|
||||||
|
? new float[] { -maxEdgeShift, 0f, maxEdgeShift }
|
||||||
|
: new float[] { 0f };
|
||||||
|
|
||||||
foreach (float bl in benchLos)
|
foreach (float bl in benchLos)
|
||||||
{
|
{
|
||||||
|
|
@ -156,28 +189,31 @@ public static class HeightCurve
|
||||||
{
|
{
|
||||||
foreach (float sp in spans)
|
foreach (float sp in spans)
|
||||||
{
|
{
|
||||||
float prevH = -7f;
|
foreach (float es in edgeShifts)
|
||||||
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k);
|
|
||||||
|
|
||||||
void Check(double hd)
|
|
||||||
{
|
{
|
||||||
float h = (float)hd;
|
float prevH = -7f;
|
||||||
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
|
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, es);
|
||||||
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k);
|
|
||||||
if (v <= prev)
|
|
||||||
throw new System.InvalidOperationException(
|
|
||||||
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate.");
|
|
||||||
prev = v;
|
|
||||||
prevH = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k) + 0.5);
|
void Check(double hd)
|
||||||
for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh);
|
{
|
||||||
for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh);
|
float h = (float)hd;
|
||||||
for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh);
|
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
|
||||||
|
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k, es);
|
||||||
|
if (v <= prev)
|
||||||
|
throw new System.InvalidOperationException(
|
||||||
|
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}, edgeShift={es}): {v} <= {prev}. Refusing to generate.");
|
||||||
|
prev = v;
|
||||||
|
prevH = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k) + 0.5);
|
||||||
|
for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh);
|
||||||
|
for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh);
|
||||||
|
for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6}).");
|
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners × edge shifts ±{maxEdgeShift:F6}, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6}).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
370
Tools/Scripts/HydraulicErosion.cs
Normal file
370
Tools/Scripts/HydraulicErosion.cs
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Droplet-based hydraulic erosion (terrain-water task 17, Phase C0) — the organic
|
||||||
|
/// carve-AND-deposit pass, Lague/Beyer lineage. Pure numeric over the height array
|
||||||
|
/// (D-035; a named future C++ candidate, kept standalone — no Godot types at all,
|
||||||
|
/// System.MathF only, own deterministic PCG32 RNG).
|
||||||
|
///
|
||||||
|
/// Each droplet spawns on land (spawn probability weighted toward high ground),
|
||||||
|
/// then walks downhill with inertia, carrying water and sediment. Where the ground
|
||||||
|
/// is steep and it moves fast it ERODES (up to capacity, spread over a small brush
|
||||||
|
/// so no single-cell spikes — the anti-artifact that killed the D8 predecessor);
|
||||||
|
/// where it flattens out it DEPOSITS, building valley floors and fans, over the
|
||||||
|
/// SAME brush (task 18 — bilinear 4-cell deposition built isolated cones at gully
|
||||||
|
/// mouths; carving and dumping are now symmetric). Water
|
||||||
|
/// evaporates each step; the droplet dies at its lifetime, at the map edge, or on
|
||||||
|
/// reaching the sea (its remaining sediment is lost to the ocean).
|
||||||
|
///
|
||||||
|
/// OUTPUT-ONLY: this pass is applied to the RENDER height map only; the classify
|
||||||
|
/// map never sees it (the caller owns that split — see MapGenerator).
|
||||||
|
///
|
||||||
|
/// The three hard governors (the pass provably cannot run away):
|
||||||
|
/// 1. DropletCount — total droplets (the main detail/cost dial).
|
||||||
|
/// 2. Lifetime — max steps per droplet; no infinite wandering.
|
||||||
|
/// 3. CarveCapM — max erosion depth per cell, in metres, measured from the
|
||||||
|
/// height the pass found and enforced against a per-cell NET
|
||||||
|
/// displacement ledger. The runaway-trench guard, and the
|
||||||
|
/// dial that decides how deep trunk channels may cut.
|
||||||
|
/// 4. DepositCapM — max build-up per cell, the same ledger read the other way
|
||||||
|
/// (task 18). Brush-spreading alone does not bound a spike:
|
||||||
|
/// droplets on long paths carry far more sediment, and a
|
||||||
|
/// loaded droplet meeting a rise dumps min(rise, load) at
|
||||||
|
/// once. This makes "no deposit cones" a governor rather
|
||||||
|
/// than a hope. <= 0 disables it (the reference model).
|
||||||
|
///
|
||||||
|
/// The sea clamp (the "don't over-flood" guard): erosion never lowers any cell
|
||||||
|
/// below its local sea level + SeaMarginM, and cells already below sea are
|
||||||
|
/// read-only — never eroded, never deposited on. Land stays land, sea stays sea;
|
||||||
|
/// the rendered coastline cannot move. Deposition only raises land cells.
|
||||||
|
///
|
||||||
|
/// The crater treatment (task 19): no cell within the protected strike CORE is
|
||||||
|
/// modified (droplets may traverse), and outside it either FULL strength applies
|
||||||
|
/// immediately or FEATHER ramps in across a band. The carve remains the final
|
||||||
|
/// authority on the deep bowl; the bay's sea connection is guaranteed by the sea
|
||||||
|
/// clamp rather than by the exclusion, since below-sea cells are read-only in
|
||||||
|
/// both directions.
|
||||||
|
///
|
||||||
|
/// Heights in the array are raw blueprint units (1 unit = 251 m). All sediment
|
||||||
|
/// accounting below is done in METRES and converted only when a delta is applied,
|
||||||
|
/// so untouched cells keep their exact bit pattern — the invariants above are
|
||||||
|
/// exact, not statistical.
|
||||||
|
/// </summary>
|
||||||
|
public static class HydraulicErosion
|
||||||
|
{
|
||||||
|
// The EROS body version is owned by the format (Core) and read from there, not
|
||||||
|
// restated here: the version byte IS the payload layout's identity, so a local
|
||||||
|
// copy that drifts writes a v2 body stamped v1 and every reader shifts a field.
|
||||||
|
// (Caught doing exactly that in task 18 — mirrors TerrainDetailPass.VERSION.)
|
||||||
|
public const ushort VERSION = IslaApocalypse.Core.BlueprintFormat.EROS_VERSION;
|
||||||
|
|
||||||
|
// Deterministic RNG stream: seeded from resolvedSeed + this offset, so a seed
|
||||||
|
// reproduces exactly and the stream is decorrelated from every noise field
|
||||||
|
// (7409/8117/… are taken; see MakeModulationNoise call sites).
|
||||||
|
public const int SEED_OFFSET = 9271;
|
||||||
|
|
||||||
|
public const float M_PER_UNIT = 251f;
|
||||||
|
|
||||||
|
// --- Crater treatment (task 19) ---
|
||||||
|
//
|
||||||
|
// Task 17 used a hard 1.2 × CraterRadius cutoff. Measured on seed 1280587109
|
||||||
|
// (task-19 radius dump): the carve writes only inside 0.80 × (640 px) and its
|
||||||
|
// displacement is EXACTLY 0 beyond that, so the 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,
|
||||||
|
// after at most SPAWN_TRIES rejection-sampling attempts (then the droplet is
|
||||||
|
// skipped and counted — on any real island this is vanishingly rare).
|
||||||
|
private const int SPAWN_TRIES = 16;
|
||||||
|
private const float SPAWN_FLOOR = 0.15f;
|
||||||
|
|
||||||
|
private const float MIN_WATER = 0.005f; // droplet dies when effectively dry
|
||||||
|
private const float MIN_DIR = 1e-10f; // below this, direction is re-drawn at random
|
||||||
|
|
||||||
|
public struct Params
|
||||||
|
{
|
||||||
|
public int DropletCount; // governor 1
|
||||||
|
public int Lifetime; // governor 2
|
||||||
|
public float CarveCapM; // governor 3 (metres)
|
||||||
|
public float DepositCapM; // governor 4 (metres); <= 0 = unbounded
|
||||||
|
public float SeaMarginM; // sea clamp margin (metres)
|
||||||
|
public int BrushRadius; // erosion brush radius, px
|
||||||
|
public float Inertia; // 0 = pure gradient descent, 1 = never turns
|
||||||
|
public float CapacityFactor; // sediment capacity multiplier
|
||||||
|
public float MinSlopeM; // capacity slope floor, metres per px
|
||||||
|
public float ErodeRate; // fraction of remaining capacity eroded per step
|
||||||
|
public float DepositRate; // fraction of surplus sediment dropped per step
|
||||||
|
public float Evaporation; // water lost per step (fraction)
|
||||||
|
public float Gravity; // speed gain per metre of drop
|
||||||
|
public byte CraterMode; // CRATER_MODE_FULL | CRATER_MODE_FEATHER (task 19)
|
||||||
|
public int Seed; // resolvedSeed + SEED_OFFSET
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Stats
|
||||||
|
{
|
||||||
|
public int Spawned;
|
||||||
|
public int SkippedNoLand;
|
||||||
|
public long Steps;
|
||||||
|
public int DiedLifetime, DiedEdge, DiedSea, DiedDry;
|
||||||
|
public double ErodedVolumeM3; // 1 px = 1 m², so metres of depth sum to m³
|
||||||
|
public double DepositedVolumeM3;
|
||||||
|
public float MaxCellErosionM; // must end ≤ CarveCapM
|
||||||
|
public float MaxCellDepositM; // the deposit-spike metric (task 18)
|
||||||
|
public long ModifiedCells; // cells the pass touched at all
|
||||||
|
}
|
||||||
|
|
||||||
|
// PCG32 (O'Neill) — tiny, deterministic, trivially portable to C++.
|
||||||
|
private struct Pcg32
|
||||||
|
{
|
||||||
|
private ulong _state;
|
||||||
|
public Pcg32(int seed) { _state = 0; NextU(); _state += (ulong)(uint)seed; NextU(); }
|
||||||
|
public uint NextU()
|
||||||
|
{
|
||||||
|
ulong old = _state;
|
||||||
|
_state = old * 6364136223846793005UL + 1442695040888963407UL;
|
||||||
|
uint xorshifted = (uint)(((old >> 18) ^ old) >> 27);
|
||||||
|
int rot = (int)(old >> 59);
|
||||||
|
return (xorshifted >> rot) | (xorshifted << (-rot & 31));
|
||||||
|
}
|
||||||
|
public float NextF() => (NextU() >> 8) * (1f / 16777216f); // [0,1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the pass in place on <paramref name="height"/>. Sea level per cell is
|
||||||
|
/// <paramref name="seaMap"/>[x,y] when non-null, else the flat scalar
|
||||||
|
/// <paramref name="seaFlat"/>. Throws (refusing the generation) if a governor
|
||||||
|
/// bound is violated on exit — the caller treats that as a build failure.
|
||||||
|
/// </summary>
|
||||||
|
public static Stats Apply(float[,] height, int mapSize, float[,] seaMap, float seaFlat,
|
||||||
|
float craterCx, float craterCy, float craterCoreRadius, float craterFeatherRadius, Params p)
|
||||||
|
{
|
||||||
|
var stats = new Stats();
|
||||||
|
var rng = new Pcg32(p.Seed);
|
||||||
|
float capUnits = p.CarveCapM / M_PER_UNIT;
|
||||||
|
if (p.DropletCount <= 0 || capUnits <= 0f) return stats;
|
||||||
|
|
||||||
|
// Per-cell NET displacement ledger, metres, positive = carved below where the
|
||||||
|
// pass found this cell, negative = built up above it. Governor 3's enforcement
|
||||||
|
// record: the cap bounds `net`, so it bounds erosion depth measured from the
|
||||||
|
// ORIGINAL height — deposit-then-carve at one cell cannot smuggle in extra
|
||||||
|
// depth, and carve-then-deposit correctly frees the headroom back up.
|
||||||
|
float[,] net = new float[mapSize, mapSize];
|
||||||
|
|
||||||
|
// Spawn weighting needs the seed's top height.
|
||||||
|
float hTop = float.MinValue;
|
||||||
|
for (int x = 0; x < mapSize; x++)
|
||||||
|
for (int y = 0; y < mapSize; y++)
|
||||||
|
if (height[x, y] > hTop) hTop = height[x, y];
|
||||||
|
|
||||||
|
// Erosion brush: all offsets within BrushRadius, cone-weighted (1 - d/r),
|
||||||
|
// normalized. Radius 0 degrades to the single cell.
|
||||||
|
int r = Math.Max(p.BrushRadius, 0);
|
||||||
|
int brushN = 0;
|
||||||
|
for (int dx = -r; dx <= r; dx++)
|
||||||
|
for (int dy = -r; dy <= r; dy++)
|
||||||
|
if (MathF.Sqrt(dx * dx + dy * dy) <= r + 1e-4f) brushN++;
|
||||||
|
int[] brushDx = new int[brushN], brushDy = new int[brushN];
|
||||||
|
float[] brushW = new float[brushN];
|
||||||
|
{
|
||||||
|
int i = 0; float wSum = 0f;
|
||||||
|
for (int dx = -r; dx <= r; dx++)
|
||||||
|
for (int dy = -r; dy <= r; dy++)
|
||||||
|
{
|
||||||
|
float d = MathF.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (d > r + 1e-4f) continue;
|
||||||
|
brushDx[i] = dx; brushDy[i] = dy;
|
||||||
|
brushW[i] = r > 0 ? 1f - d / (r + 1f) : 1f;
|
||||||
|
wSum += brushW[i]; i++;
|
||||||
|
}
|
||||||
|
for (int j = 0; j < brushN; j++) brushW[j] /= wSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
float SeaAt(int cx, int cy) => seaMap != null ? seaMap[cx, cy] : seaFlat;
|
||||||
|
|
||||||
|
// Crater weight (task 19): 0 inside the protected strike core, 1 where erosion
|
||||||
|
// runs at full strength. FULL steps straight to 1 at the core boundary; FEATHER
|
||||||
|
// ramps linearly out to craterFeatherRadius, mirroring the detail pass's shape,
|
||||||
|
// so the crater reads as younger/less-weathered with no seam. Amounts are SCALED
|
||||||
|
// by this rather than skipped, which is what makes FEATHER a one-liner.
|
||||||
|
float coreSq = craterCoreRadius * craterCoreRadius;
|
||||||
|
bool feather = p.CraterMode == CRATER_MODE_FEATHER
|
||||||
|
&& craterFeatherRadius > craterCoreRadius;
|
||||||
|
float CraterWeight(int cx, int cy)
|
||||||
|
{
|
||||||
|
float ddx = cx - craterCx, ddy = cy - craterCy;
|
||||||
|
float d2 = ddx * ddx + ddy * ddy;
|
||||||
|
if (d2 < coreSq) return 0f;
|
||||||
|
if (!feather) return 1f;
|
||||||
|
float d = MathF.Sqrt(d2);
|
||||||
|
if (d >= craterFeatherRadius) return 1f;
|
||||||
|
return (d - craterCoreRadius) / (craterFeatherRadius - craterCoreRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int drop = 0; drop < p.DropletCount; drop++)
|
||||||
|
{
|
||||||
|
// --- spawn (land only, elevation-weighted) ---
|
||||||
|
float px = -1f, py = -1f;
|
||||||
|
for (int attempt = 0; attempt < SPAWN_TRIES; attempt++)
|
||||||
|
{
|
||||||
|
float sx = 1f + rng.NextF() * (mapSize - 3);
|
||||||
|
float sy = 1f + rng.NextF() * (mapSize - 3);
|
||||||
|
int cx = (int)sx, cy = (int)sy;
|
||||||
|
float h = height[cx, cy];
|
||||||
|
float sea = SeaAt(cx, cy);
|
||||||
|
if (h < sea) { continue; }
|
||||||
|
float rel = hTop > sea ? Math.Clamp((h - sea) / (hTop - sea), 0f, 1f) : 0f;
|
||||||
|
if (rng.NextF() < SPAWN_FLOOR + (1f - SPAWN_FLOOR) * rel) { px = sx; py = sy; break; }
|
||||||
|
}
|
||||||
|
if (px < 0f) { stats.SkippedNoLand++; continue; }
|
||||||
|
stats.Spawned++;
|
||||||
|
|
||||||
|
float dirX = 0f, dirY = 0f, speed = 1f, water = 1f, sedimentM = 0f;
|
||||||
|
|
||||||
|
for (int step = 0; step < p.Lifetime; step++)
|
||||||
|
{
|
||||||
|
stats.Steps++;
|
||||||
|
int xi = (int)px, yi = (int)py;
|
||||||
|
float fx = px - xi, fy = py - yi;
|
||||||
|
|
||||||
|
// Bilinear height + gradient at the current position.
|
||||||
|
float h00 = height[xi, yi], h10 = height[xi + 1, yi];
|
||||||
|
float h01 = height[xi, yi + 1], h11 = height[xi + 1, yi + 1];
|
||||||
|
float gradX = (h10 - h00) * (1f - fy) + (h11 - h01) * fy;
|
||||||
|
float gradY = (h01 - h00) * (1f - fx) + (h11 - h10) * fx;
|
||||||
|
float hOld = h00 * (1f - fx) * (1f - fy) + h10 * fx * (1f - fy)
|
||||||
|
+ h01 * (1f - fx) * fy + h11 * fx * fy;
|
||||||
|
|
||||||
|
// Inertia blend, then one unit step.
|
||||||
|
dirX = dirX * p.Inertia - gradX * (1f - p.Inertia);
|
||||||
|
dirY = dirY * p.Inertia - gradY * (1f - p.Inertia);
|
||||||
|
float len = MathF.Sqrt(dirX * dirX + dirY * dirY);
|
||||||
|
if (len < MIN_DIR)
|
||||||
|
{
|
||||||
|
float ang = rng.NextF() * 2f * MathF.PI;
|
||||||
|
dirX = MathF.Cos(ang); dirY = MathF.Sin(ang); len = 1f;
|
||||||
|
}
|
||||||
|
dirX /= len; dirY /= len;
|
||||||
|
px += dirX; py += dirY;
|
||||||
|
|
||||||
|
if (px < 1f || px >= mapSize - 2 || py < 1f || py >= mapSize - 2)
|
||||||
|
{ stats.DiedEdge++; break; }
|
||||||
|
|
||||||
|
int nxi = (int)px, nyi = (int)py;
|
||||||
|
float nfx = px - nxi, nfy = py - nyi;
|
||||||
|
float n00 = height[nxi, nyi], n10 = height[nxi + 1, nyi];
|
||||||
|
float n01 = height[nxi, nyi + 1], n11 = height[nxi + 1, nyi + 1];
|
||||||
|
float hNew = n00 * (1f - nfx) * (1f - nfy) + n10 * nfx * (1f - nfy)
|
||||||
|
+ n01 * (1f - nfx) * nfy + n11 * nfx * nfy;
|
||||||
|
|
||||||
|
// Reached the sea: die; the sediment is the ocean's now.
|
||||||
|
if (hNew < SeaAt(nxi, nyi)) { stats.DiedSea++; break; }
|
||||||
|
|
||||||
|
float dhM = (hNew - hOld) * M_PER_UNIT;
|
||||||
|
float capacityM = MathF.Max(-dhM, p.MinSlopeM) * speed * water * p.CapacityFactor;
|
||||||
|
|
||||||
|
if (dhM > 0f || sedimentM > capacityM)
|
||||||
|
{
|
||||||
|
// Moving uphill (fill the pit behind us, at most the rise) or
|
||||||
|
// over capacity (drop a fraction of the surplus): DEPOSIT over
|
||||||
|
// the SAME cone brush erosion uses (task 18). Bilinear 4-cell
|
||||||
|
// deposition — the reference model's — concentrated a whole
|
||||||
|
// droplet's load into one cell at gully mouths and built
|
||||||
|
// isolated cones (measured 15.5 m on seed 1280587109, task 17
|
||||||
|
// §6.1). Spreading it makes deposition the symmetric mirror of
|
||||||
|
// carving; total mass is unchanged, only its footprint.
|
||||||
|
float amountM = dhM > 0f ? MathF.Min(dhM, sedimentM)
|
||||||
|
: (sedimentM - capacityM) * p.DepositRate;
|
||||||
|
if (amountM > 0f)
|
||||||
|
{
|
||||||
|
for (int b = 0; b < brushN; b++)
|
||||||
|
{
|
||||||
|
int cx = xi + brushDx[b], cy = yi + brushDy[b];
|
||||||
|
if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
|
||||||
|
float wCrater = CraterWeight(cx, cy);
|
||||||
|
if (wCrater <= 0f) continue;
|
||||||
|
float hCell = height[cx, cy];
|
||||||
|
// Below-sea cells are read-only in BOTH directions: no
|
||||||
|
// submarine deltas, so the rendered coastline cannot move.
|
||||||
|
if (hCell < SeaAt(cx, cy)) continue;
|
||||||
|
float give = amountM * brushW[b] * wCrater;
|
||||||
|
// Governor 4: the ledger read the other way. net is negative
|
||||||
|
// where the cell has already been built up, so the headroom
|
||||||
|
// is cap + net.
|
||||||
|
if (p.DepositCapM > 0f)
|
||||||
|
give = MathF.Min(give, MathF.Max(0f, p.DepositCapM + net[cx, cy]));
|
||||||
|
if (give <= 0f) continue;
|
||||||
|
height[cx, cy] = hCell + give / M_PER_UNIT;
|
||||||
|
if (net[cx, cy] == 0f) stats.ModifiedCells++;
|
||||||
|
net[cx, cy] -= give;
|
||||||
|
if (-net[cx, cy] > stats.MaxCellDepositM) stats.MaxCellDepositM = -net[cx, cy];
|
||||||
|
sedimentM -= give;
|
||||||
|
stats.DepositedVolumeM3 += give;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Under capacity on a downhill move: ERODE, spread over the
|
||||||
|
// brush, never more than the drop itself (no digging pits).
|
||||||
|
float amountM = MathF.Min((capacityM - sedimentM) * p.ErodeRate, -dhM);
|
||||||
|
if (amountM > 0f)
|
||||||
|
{
|
||||||
|
for (int b = 0; b < brushN; b++)
|
||||||
|
{
|
||||||
|
int cx = xi + brushDx[b], cy = yi + brushDy[b];
|
||||||
|
if (cx < 0 || cx >= mapSize || cy < 0 || cy >= mapSize) continue;
|
||||||
|
float wCrater = CraterWeight(cx, cy);
|
||||||
|
if (wCrater <= 0f) continue;
|
||||||
|
float sea = SeaAt(cx, cy);
|
||||||
|
float hCell = height[cx, cy];
|
||||||
|
if (hCell < sea) continue; // below-sea cells are read-only
|
||||||
|
float want = amountM * brushW[b] * wCrater;
|
||||||
|
float bySea = MathF.Max(0f, (hCell - (sea + p.SeaMarginM / M_PER_UNIT)) * M_PER_UNIT);
|
||||||
|
float byCap = MathF.Max(0f, p.CarveCapM - net[cx, cy]);
|
||||||
|
float take = MathF.Min(want, MathF.Min(bySea, byCap));
|
||||||
|
if (take <= 0f) continue;
|
||||||
|
height[cx, cy] = hCell - take / M_PER_UNIT;
|
||||||
|
if (net[cx, cy] == 0f) stats.ModifiedCells++;
|
||||||
|
net[cx, cy] += take;
|
||||||
|
if (net[cx, cy] > stats.MaxCellErosionM) stats.MaxCellErosionM = net[cx, cy];
|
||||||
|
sedimentM += take;
|
||||||
|
stats.ErodedVolumeM3 += take;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
speed = MathF.Sqrt(MathF.Max(0f, speed * speed - dhM * p.Gravity));
|
||||||
|
water *= 1f - p.Evaporation;
|
||||||
|
if (water < MIN_WATER) { stats.DiedDry++; break; }
|
||||||
|
if (step == p.Lifetime - 1) stats.DiedLifetime++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Governor 3, proven on exit rather than assumed: the ledger's maximum must
|
||||||
|
// respect the cap (float addition of clamped takes cannot exceed it by more
|
||||||
|
// than rounding; allow one ulp-scale epsilon).
|
||||||
|
if (stats.MaxCellErosionM > p.CarveCapM * (1f + 1e-5f))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[HydraulicErosion] CARVE-CAP VIOLATION: a cell accumulated {stats.MaxCellErosionM} m against cap {p.CarveCapM} m. Refusing to generate.");
|
||||||
|
if (p.DepositCapM > 0f && stats.MaxCellDepositM > p.DepositCapM * (1f + 1e-5f))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[HydraulicErosion] DEPOSIT-CAP VIOLATION: a cell built up {stats.MaxCellDepositM} m against cap {p.DepositCapM} m. Refusing to generate.");
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
1
Tools/Scripts/HydraulicErosion.cs.uid
Normal file
1
Tools/Scripts/HydraulicErosion.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://hls3pvvnqci5
|
||||||
143
Tools/Scripts/IslandFalloff.cs
Normal file
143
Tools/Scripts/IslandFalloff.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The island-falloff shaping functions (terrain-water task 11) — pure numeric
|
||||||
|
/// functions of their inputs (D-035; a named future C++ candidate, kept standalone).
|
||||||
|
///
|
||||||
|
/// SMOOTH CREST — the mountain spine's ridge axis is the line x = centre, and
|
||||||
|
/// `1 - |x - cx|` peaks there with a slope discontinuity. Measured on the shipped
|
||||||
|
/// terrain, that crease is the single largest slope step anywhere on the map once
|
||||||
|
/// the by-design Trench walls are excluded (top 4 of 5999 columns). SmoothAbs
|
||||||
|
/// rounds the crest without moving its height.
|
||||||
|
///
|
||||||
|
/// COAST SHELF — the height curve is identity at and below sea level, so it never
|
||||||
|
/// touched the SUBMARINE slope. Measured: land rises from the shoreline at
|
||||||
|
/// 0.038 m/px while the seabed drops at 0.258 m/px — the shoreline is a shelf on
|
||||||
|
/// the land side and a ramp on the sea side. CoastShelf compresses shallow depth
|
||||||
|
/// so the shallows extend much further out, leaving deep water and the Trench
|
||||||
|
/// essentially untouched.
|
||||||
|
///
|
||||||
|
/// OFFSHORE BLOB — sparse discoverable islets, seeded from an ocean noise layer.
|
||||||
|
///
|
||||||
|
/// Every one of these is monotone in the sign of (sea - height): none of them can
|
||||||
|
/// turn water into land or land into water on its own. The coast shelf therefore
|
||||||
|
/// leaves the biome and water classification bit-identical, which is why it is
|
||||||
|
/// separable from elongation in the batch.
|
||||||
|
/// </summary>
|
||||||
|
public static class IslandFalloff
|
||||||
|
{
|
||||||
|
// ---- centre-line crest ------------------------------------------------
|
||||||
|
// Rounding radius in normalized spine-width units (1.0 = MapSize/2 * IslandAxisX
|
||||||
|
// ~ 4710 px at 8K with the default axis). 0.03 ~ 141 px: it cuts the crest's
|
||||||
|
// 1-px kink by 99.3% (-7.64e-04 -> -5.41e-06 raw) while filling at most 3.9 m,
|
||||||
|
// decaying under 0.5 m by ~1100 px from the axis.
|
||||||
|
public const float CREST_EPSILON = 0.03f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A C¹ stand-in for |d|: exactly 0 with zero slope at d = 0, and converging to
|
||||||
|
/// |d| within e²/(2|d|) away from it. Replaces the V-shaped crest of the spine
|
||||||
|
/// with a rounded one WITHOUT lowering it — SmoothAbs(0) is 0, so the peak keeps
|
||||||
|
/// its full height.
|
||||||
|
/// </summary>
|
||||||
|
public static float SmoothAbs(float d, float epsilon)
|
||||||
|
{
|
||||||
|
float a = Mathf.Abs(d);
|
||||||
|
return a * a / Mathf.Sqrt(a * a + epsilon * epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- coast shelf ------------------------------------------------------
|
||||||
|
// depth' = depth * (1 - STRENGTH * exp(-depth / SCALE_M)).
|
||||||
|
// At the shoreline the seabed starts at (1 - STRENGTH) of its former gradient and
|
||||||
|
// recovers smoothly, so the shallows widen and the deep ocean keeps its shape.
|
||||||
|
// C^inf everywhere, and strictly positive for positive depth — it cannot move the
|
||||||
|
// waterline by even one pixel.
|
||||||
|
public const float SHELF_STRENGTH = 0.775f; // 0 = off, ->1 = a flat lagoon
|
||||||
|
public const float SHELF_SCALE_M = 100f; // metres of depth over which it relaxes
|
||||||
|
|
||||||
|
/// <summary>Remaps a positive depth in metres. Returns the new depth in metres.</summary>
|
||||||
|
public static float CoastShelf(float depthMetres)
|
||||||
|
{
|
||||||
|
if (depthMetres <= 0f) return depthMetres;
|
||||||
|
return depthMetres * (1f - SHELF_STRENGTH * Mathf.Exp(-depthMetres / SHELF_SCALE_M));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- offshore islands -------------------------------------------------
|
||||||
|
// Islets are placed by lerping the seabed TOWARD a target height, not by adding to
|
||||||
|
// it, so they can surface at any ambient depth instead of only where the seafloor
|
||||||
|
// happens to be shallow.
|
||||||
|
public const float OFFSHORE_FREQ_ISLANDS = 14f; // ~585 px blobs at 8K — few and sizeable,
|
||||||
|
// not a scatter of 50 px debris
|
||||||
|
public const int OFFSHORE_SEED_OFFSET = 7607;
|
||||||
|
public const float OFFSHORE_ISLAND_H_M = 34f; // target crest, metres above sea (pre-curve)
|
||||||
|
public const float OFFSHORE_CORE = 0.45f; // fraction of a blob's excess that saturates
|
||||||
|
|
||||||
|
// The moat that keeps islets off the mainland. The raise is EXACTLY zero wherever
|
||||||
|
// the ambient water is shallower than this, so the ring of water between the
|
||||||
|
// mainland shore and any islet cannot be bridged: a continuous path from shore to
|
||||||
|
// islet must cross this depth contour, and every pixel on it is untouched water.
|
||||||
|
public const float OFFSHORE_MIN_DEPTH_M = 14f;
|
||||||
|
public const float OFFSHORE_DEPTH_FEATHER_M = 10f;
|
||||||
|
|
||||||
|
// ...the margin that keeps them out of the Trench ramp (which starts at 0.90)...
|
||||||
|
public const float OFFSHORE_TRENCH_INNER = 0.78f;
|
||||||
|
public const float OFFSHORE_TRENCH_OUTER = 0.86f;
|
||||||
|
|
||||||
|
// ...and the test for "actually offshore". Depth alone is not enough: a deep LAKE
|
||||||
|
// or the carved crater bay is also below sea level, and islets have no business in
|
||||||
|
// either. The pre-Trench falloff is the honest discriminator — the mainland coast
|
||||||
|
// sits near f = 0.66 (where f^2.5 ~ rawBase - sea), and inland water is far below
|
||||||
|
// that whatever the axis ratios are, because elongation moves WHERE a given f
|
||||||
|
// occurs, not the f at which land ends.
|
||||||
|
public const float OFFSHORE_MIN_FALLOFF = 0.72f;
|
||||||
|
public const float OFFSHORE_FALLOFF_FEATHER = 0.06f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blob weight in [0,1] for one ocean column. <paramref name="threshold"/> comes
|
||||||
|
/// from CalibrateThreshold, NOT from the density directly — Simplex output is
|
||||||
|
/// concentrated well inside [-1,1] (in practice it rarely passes ±0.87), so
|
||||||
|
/// treating density as a fraction of the theoretical range produces a threshold
|
||||||
|
/// almost nothing clears. That bug shipped in the first task-11 build and raised
|
||||||
|
/// 171 pixels on the whole map, none of them above sea.
|
||||||
|
/// </summary>
|
||||||
|
public static float OffshoreBlob(float noise01, float threshold)
|
||||||
|
{
|
||||||
|
if (noise01 <= threshold) return 0f;
|
||||||
|
float core = Mathf.Max((1f - threshold) * OFFSHORE_CORE, 1e-4f);
|
||||||
|
float k = Mathf.Clamp((noise01 - threshold) / core, 0f, 1f);
|
||||||
|
return k * k * (3f - 2f * k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The noise value that <paramref name="density"/> of <paramref name="samples"/>
|
||||||
|
/// exceed. Sorts a copy, so the caller's array is left alone.
|
||||||
|
/// </summary>
|
||||||
|
public static float CalibrateThreshold(float[] samples, float density)
|
||||||
|
{
|
||||||
|
if (samples.Length == 0 || density <= 0f) return 1f;
|
||||||
|
float[] s = (float[])samples.Clone();
|
||||||
|
System.Array.Sort(s);
|
||||||
|
int idx = (int)((1f - Mathf.Clamp(density, 0f, 1f)) * (s.Length - 1));
|
||||||
|
return s[Mathf.Clamp(idx, 0, s.Length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How much of the blob is allowed here: zero in shallow water near the mainland
|
||||||
|
/// (the moat), zero anywhere that is not genuinely outside the island body, zero
|
||||||
|
/// in and near the Trench ramp, full in the open ocean between.
|
||||||
|
/// </summary>
|
||||||
|
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
|
||||||
|
float distX01, float distY01)
|
||||||
|
{
|
||||||
|
if (ambientDepthMetres < OFFSHORE_MIN_DEPTH_M) return 0f;
|
||||||
|
if (preTrenchFalloff < OFFSHORE_MIN_FALLOFF) return 0f;
|
||||||
|
|
||||||
|
float w = Mathf.Clamp((ambientDepthMetres - OFFSHORE_MIN_DEPTH_M) / OFFSHORE_DEPTH_FEATHER_M, 0f, 1f);
|
||||||
|
w *= Mathf.Clamp((preTrenchFalloff - OFFSHORE_MIN_FALLOFF) / OFFSHORE_FALLOFF_FEATHER, 0f, 1f);
|
||||||
|
|
||||||
|
float d = Mathf.Max(distX01, distY01);
|
||||||
|
if (d >= OFFSHORE_TRENCH_OUTER) return 0f;
|
||||||
|
if (d > OFFSHORE_TRENCH_INNER)
|
||||||
|
w *= 1f - (d - OFFSHORE_TRENCH_INNER) / (OFFSHORE_TRENCH_OUTER - OFFSHORE_TRENCH_INNER);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/IslandFalloff.cs.uid
Normal file
1
Tools/Scripts/IslandFalloff.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b2ybhwg0w67pf
|
||||||
|
|
@ -27,13 +27,14 @@ public partial class MapGenerator : TextureRect
|
||||||
|
|
||||||
private float[,] _heightMap;
|
private float[,] _heightMap;
|
||||||
|
|
||||||
// Classification heightmap (task 05): the UNCURVED heights (plus the crater
|
// Classification heightmap (task 05): the UNCURVED, UNERODED heights (plus the
|
||||||
// carve), i.e. exactly what the curve-off pipeline produces. Biome rules, the
|
// crater carve), i.e. exactly what the curve-off pipeline produces. Biome rules,
|
||||||
// two flood fills, and the shared water predicates read THIS map, so biome and
|
// the two flood fills, and the shared water predicates read THIS map, so biome
|
||||||
// water output is identical with the curve on or off — the bit-identical-biomes
|
// and water output is identical with the curve and erosion on or off — the
|
||||||
// oracle holds by construction. Towns, roads, diagnostics, and the exported
|
// bit-identical-biomes oracle holds by construction. Towns, roads, diagnostics,
|
||||||
// heights use the curved _heightMap (they live in the 3D world). When the curve
|
// and the exported heights use the curved (and, when on, eroded) _heightMap
|
||||||
// is off this is the SAME array as _heightMap (aliased, no copy).
|
// (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 float[,] _heightMapClassify;
|
||||||
private bool _curveOn;
|
private bool _curveOn;
|
||||||
|
|
||||||
|
|
@ -49,11 +50,35 @@ public partial class MapGenerator : TextureRect
|
||||||
// v5: the selected knot preset; null = curve off.
|
// v5: the selected knot preset; null = curve off.
|
||||||
private CurveKnots _curveKnots;
|
private CurveKnots _curveKnots;
|
||||||
|
|
||||||
// Task-10 detail passes (shelf micro-relief + drainage incision): gated by
|
// Hydraulic erosion (task 17): output-only droplet pass on the RENDER map,
|
||||||
// TerrainDetail, active only with the curve on (the masks are curve-band
|
// after detail, before the crater carve. The classify map never sees it.
|
||||||
// defined). The relief noise seeds from resolvedSeed + 7409.
|
private bool _erosionOn;
|
||||||
|
private bool _riversOn;
|
||||||
|
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
|
||||||
|
// the curve's bands). The relief noise seeds from resolvedSeed + 7409, the
|
||||||
|
// edge-warp noise from resolvedSeed + 7507. _edgeAmpRaw is the config dial in
|
||||||
|
// raw height units, already clamped to _maxEdgeShiftRaw.
|
||||||
private bool _detailOn;
|
private bool _detailOn;
|
||||||
private FastNoiseLite _reliefNoise;
|
private FastNoiseLite _reliefNoise;
|
||||||
|
private FastNoiseLite _edgeNoise;
|
||||||
|
private float _edgeAmpRaw;
|
||||||
|
private float _maxEdgeShiftRaw;
|
||||||
|
|
||||||
|
// Task-11 island-falloff shaping: the submarine coast shelf and the offshore
|
||||||
|
// islet layer. Both act on BELOW-SEA height in pass 1; the axis ratios that
|
||||||
|
// reshape the island itself are read straight from config in GenerateTopography.
|
||||||
|
private bool _coastWide;
|
||||||
|
private bool _offshoreOn;
|
||||||
|
private FastNoiseLite _offshoreNoise;
|
||||||
|
private float _offshoreThreshold = 1f;
|
||||||
|
|
||||||
// The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine,
|
// The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine,
|
||||||
// pre-carve) — the v2 curve's per-seed spike normalizer. Computed in
|
// pre-carve) — the v2 curve's per-seed spike normalizer. Computed in
|
||||||
|
|
@ -95,9 +120,16 @@ public partial class MapGenerator : TextureRect
|
||||||
_heightMap = new float[MapSize, MapSize];
|
_heightMap = new float[MapSize, MapSize];
|
||||||
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
|
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
|
||||||
_curveOn = _curveKnots != null;
|
_curveOn = _curveKnots != null;
|
||||||
|
_erosionOn = ConfigManager.Erosion == "v1";
|
||||||
|
_riversOn = ConfigManager.Rivers == "v1";
|
||||||
|
_craterErosionMode = ConfigManager.CraterErosionMode == "feather"
|
||||||
|
? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL;
|
||||||
// (The monotonicity assertion now runs inside GenerateTopography, against the
|
// (The monotonicity assertion now runs inside GenerateTopography, against the
|
||||||
// effective per-seed curve, once hMaxSeed is known.)
|
// 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];
|
_tempMap = new float[MapSize, MapSize];
|
||||||
_biomeMap = new Biome[MapSize, MapSize];
|
_biomeMap = new Biome[MapSize, MapSize];
|
||||||
_isTrueOcean = new bool[MapSize, MapSize];
|
_isTrueOcean = new bool[MapSize, MapSize];
|
||||||
|
|
@ -119,10 +151,52 @@ public partial class MapGenerator : TextureRect
|
||||||
}
|
}
|
||||||
_detailOn = _curveOn && ConfigManager.TerrainDetail == "v1";
|
_detailOn = _curveOn && ConfigManager.TerrainDetail == "v1";
|
||||||
if (_detailOn)
|
if (_detailOn)
|
||||||
|
{
|
||||||
_reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS);
|
_reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS);
|
||||||
|
_edgeNoise = MakeModulationNoise(TerrainDetailPass.EDGE_SEED_OFFSET, TerrainDetailPass.EDGE_FREQ_ISLANDS);
|
||||||
|
|
||||||
|
// The edge warp slides K3/K4/K5; the dial is clamped to the largest shift
|
||||||
|
// that keeps the knot set strictly ordered, so monotonicity can never be
|
||||||
|
// a tuning question. Clamping is loud — a silently ignored dial is worse
|
||||||
|
// than a refused one.
|
||||||
|
_maxEdgeShiftRaw = TerrainDetailPass.MaxEdgeShift(_curveKnots);
|
||||||
|
_edgeAmpRaw = Mathf.Max(ConfigManager.ShelfEdgeVariation, 0f) / 251f;
|
||||||
|
if (_edgeAmpRaw > _maxEdgeShiftRaw)
|
||||||
|
{
|
||||||
|
GD.PrintErr($"[MapGenerator] ShelfEdgeVariation {ConfigManager.ShelfEdgeVariation:F2} m exceeds the preset's safe bound {_maxEdgeShiftRaw * 251f:F2} m — clamping.");
|
||||||
|
_edgeAmpRaw = _maxEdgeShiftRaw;
|
||||||
|
}
|
||||||
|
GD.Print($"[MapGenerator] TerrainDetail v1: relief ±{ConfigManager.ShelfReliefAmp:F1} m @ {TerrainDetailPass.RELIEF_FREQ_ISLANDS:F0}/island, edge warp ±{_edgeAmpRaw * 251f:F2} m of input height @ {TerrainDetailPass.EDGE_FREQ_ISLANDS:F0}/island (bound {_maxEdgeShiftRaw * 251f:F2} m).");
|
||||||
|
}
|
||||||
else if (ConfigManager.TerrainDetail == "v1" && !_curveOn)
|
else if (ConfigManager.TerrainDetail == "v1" && !_curveOn)
|
||||||
GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off.");
|
GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off.");
|
||||||
|
|
||||||
|
_coastWide = ConfigManager.CoastProfile == "wide";
|
||||||
|
_offshoreOn = ConfigManager.OffshoreIslandDensity > 0f;
|
||||||
|
if (_offshoreOn)
|
||||||
|
{
|
||||||
|
_offshoreNoise = MakeModulationNoise(IslandFalloff.OFFSHORE_SEED_OFFSET, IslandFalloff.OFFSHORE_FREQ_ISLANDS);
|
||||||
|
|
||||||
|
// Calibrate the islet threshold against the field's ACTUAL distribution
|
||||||
|
// rather than the theoretical [-1,1]: sample on a stride grid and take the
|
||||||
|
// quantile. Deterministic from the seed, and it makes the density dial mean
|
||||||
|
// what it says whatever FastNoiseLite's output range turns out to be.
|
||||||
|
const int stride = 8;
|
||||||
|
int side = MapSize / stride;
|
||||||
|
float[] samples = new float[side * side];
|
||||||
|
for (int i = 0; i < side; i++)
|
||||||
|
for (int j = 0; j < side; j++)
|
||||||
|
samples[i * side + j] = (_offshoreNoise.GetNoise2D(i * stride, j * stride) + 1f) * 0.5f;
|
||||||
|
_offshoreThreshold = IslandFalloff.CalibrateThreshold(samples, ConfigManager.OffshoreIslandDensity);
|
||||||
|
GD.Print($"[MapGenerator] Offshore islet threshold {_offshoreThreshold:F4} " +
|
||||||
|
$"(density {ConfigManager.OffshoreIslandDensity:F3} of {samples.Length} samples; field range {System.Linq.Enumerable.Min(samples):F3}..{System.Linq.Enumerable.Max(samples):F3}).");
|
||||||
|
}
|
||||||
|
GD.Print($"[MapGenerator] Island falloff: axis {ConfigManager.IslandAxisX:F2}x/{ConfigManager.IslandAxisY:F2}y, " +
|
||||||
|
$"coast '{ConfigManager.CoastProfile}'" +
|
||||||
|
(_coastWide ? $" (shelf strength {IslandFalloff.SHELF_STRENGTH:F3}, scale {IslandFalloff.SHELF_SCALE_M:F0} m)" : "") +
|
||||||
|
$", offshore islets density {ConfigManager.OffshoreIslandDensity:F3}" +
|
||||||
|
(_offshoreOn ? $" @ {IslandFalloff.OFFSHORE_FREQ_ISLANDS:F0}/island, crest {IslandFalloff.OFFSHORE_ISLAND_H_M:F0} m, moat {IslandFalloff.OFFSHORE_MIN_DEPTH_M:F0} m" : " (off)") + ".");
|
||||||
|
|
||||||
// THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!)
|
// THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!)
|
||||||
// Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast!
|
// Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast!
|
||||||
float randomX = (float)GD.RandRange(0.35f, 0.65f);
|
float randomX = (float)GD.RandRange(0.35f, 0.65f);
|
||||||
|
|
@ -156,6 +230,15 @@ public partial class MapGenerator : TextureRect
|
||||||
GD.Print($"{T()} Towns placed: {_towns.Count}.");
|
GD.Print($"{T()} Towns placed: {_towns.Count}.");
|
||||||
await CaptureStage("2_towns");
|
await CaptureStage("2_towns");
|
||||||
|
|
||||||
|
// --- RIVERS (task 22, C0b part 2a): carve the frozen plan's beds. ---
|
||||||
|
// AFTER towns (GenerateTowns reads the render map for land/slope/water
|
||||||
|
// checks, so carving first would move towns and destabilise every A/B) and
|
||||||
|
// BEFORE roads (a full run's A* should see the carved beds). RENDER map
|
||||||
|
// only: biomes and WBID are already computed from classify — the oracle is
|
||||||
|
// untouched by construction. NO WATER — part 2b.
|
||||||
|
if (_riversOn)
|
||||||
|
CarveRivers();
|
||||||
|
|
||||||
if (ConfigManager.SkipRoads)
|
if (ConfigManager.SkipRoads)
|
||||||
{
|
{
|
||||||
// Iteration toggle (terrain-water task 03): the road pass is ~25 min of a
|
// Iteration toggle (terrain-water task 03): the road pass is ~25 min of a
|
||||||
|
|
@ -309,17 +392,39 @@ public partial class MapGenerator : TextureRect
|
||||||
StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET,
|
StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET,
|
||||||
PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6
|
PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6
|
||||||
} : null,
|
} : 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,
|
||||||
|
DepositCapM = ConfigManager.ErosionDepositCap,
|
||||||
|
SeaMarginM = ConfigManager.ErosionSeaMargin,
|
||||||
|
Inertia = ConfigManager.ErosionInertia,
|
||||||
|
CapacityFactor = ConfigManager.ErosionCapacity,
|
||||||
|
MinSlopeM = ConfigManager.ErosionMinSlope,
|
||||||
|
ErodeRate = ConfigManager.ErosionErodeRate,
|
||||||
|
DepositRate = ConfigManager.ErosionDepositRate,
|
||||||
|
Evaporation = ConfigManager.ErosionEvaporation,
|
||||||
|
Gravity = ConfigManager.ErosionGravity,
|
||||||
|
CraterCoreFactor = ConfigManager.CraterErosionCore,
|
||||||
|
CraterFeatherFactor = ConfigManager.CraterErosionFeather,
|
||||||
|
CraterMode = _craterErosionMode
|
||||||
|
} : null,
|
||||||
TerrainDetail = _detailOn ? new TerrainDetailInfo
|
TerrainDetail = _detailOn ? new TerrainDetailInfo
|
||||||
{
|
{
|
||||||
Version = TerrainDetailPass.VERSION,
|
Version = TerrainDetailPass.VERSION,
|
||||||
ReliefAmpM = ConfigManager.ShelfReliefAmp,
|
ReliefAmpM = ConfigManager.ShelfReliefAmp,
|
||||||
ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS,
|
ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS,
|
||||||
IncK = TerrainDetailPass.INC_K, IncP = TerrainDetailPass.INC_P,
|
ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET,
|
||||||
IncCapM = TerrainDetailPass.INC_CAP_M,
|
EdgeAmpM = _edgeAmpRaw * 251f, // as APPLIED (post-clamp), not as configured
|
||||||
SeaClampRaw = TerrainDetailPass.SEA_CLAMP,
|
EdgeFreqIslands = TerrainDetailPass.EDGE_FREQ_ISLANDS,
|
||||||
CraterExclFactor = TerrainDetailPass.CRATER_EXCL_FACTOR,
|
EdgeSeedOffset = TerrainDetailPass.EDGE_SEED_OFFSET,
|
||||||
ShelfIncWeight = TerrainDetailPass.SHELF_INC_WEIGHT,
|
EdgeMaxShiftM = _maxEdgeShiftRaw * 251f
|
||||||
ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET
|
|
||||||
} : null,
|
} : null,
|
||||||
FormatVersion = 2,
|
FormatVersion = 2,
|
||||||
Params = new BlueprintParams
|
Params = new BlueprintParams
|
||||||
|
|
@ -412,6 +517,9 @@ public partial class MapGenerator : TextureRect
|
||||||
private void GenerateTopography()
|
private void GenerateTopography()
|
||||||
{
|
{
|
||||||
Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f);
|
Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f);
|
||||||
|
float axisX = ConfigManager.IslandAxisX;
|
||||||
|
float axisY = ConfigManager.IslandAxisY;
|
||||||
|
long offshoreLandPx = 0; // pixels the islet layer lifted from water to land
|
||||||
for (int x = 0; x < MapSize; x++)
|
for (int x = 0; x < MapSize; x++)
|
||||||
{
|
{
|
||||||
for (int y = 0; y < MapSize; y++)
|
for (int y = 0; y < MapSize; y++)
|
||||||
|
|
@ -423,11 +531,13 @@ public partial class MapGenerator : TextureRect
|
||||||
_tempMap[x, y] = temperature;
|
_tempMap[x, y] = temperature;
|
||||||
|
|
||||||
// --- 2. THE ORIGINAL ISLAND FALLOFF ---
|
// --- 2. THE ORIGINAL ISLAND FALLOFF ---
|
||||||
float nx = Mathf.Abs(x - center.X) / (MapSize / 2.0f * 1.15f);
|
// Axis ratios are config dials since task 11 (were the literals
|
||||||
float ny = Mathf.Abs(y - center.Y) / (MapSize / 2.0f * 0.90f);
|
// 1.15 / 0.90). They move the coastline, so they move biomes.
|
||||||
|
float nx = Mathf.Abs(x - center.X) / (MapSize / 2.0f * axisX);
|
||||||
|
float ny = Mathf.Abs(y - center.Y) / (MapSize / 2.0f * axisY);
|
||||||
float squircleFalloff = Mathf.Max(nx, ny);
|
float squircleFalloff = Mathf.Max(nx, ny);
|
||||||
|
|
||||||
Vector2 ellipticalPos = new Vector2((x - center.X) / 1.15f, (y - center.Y) / 0.90f);
|
Vector2 ellipticalPos = new Vector2((x - center.X) / axisX, (y - center.Y) / axisY);
|
||||||
float ellipticalFalloff = ellipticalPos.Length() / (MapSize / 1.3f);
|
float ellipticalFalloff = ellipticalPos.Length() / (MapSize / 1.3f);
|
||||||
|
|
||||||
float finalFalloff = Mathf.Lerp(ellipticalFalloff, squircleFalloff, 0.5f);
|
float finalFalloff = Mathf.Lerp(ellipticalFalloff, squircleFalloff, 0.5f);
|
||||||
|
|
@ -445,6 +555,10 @@ public partial class MapGenerator : TextureRect
|
||||||
finalFalloff += southDepth * 0.6f; // Sink the stretched land bridges!
|
finalFalloff += southDepth * 0.6f; // Sink the stretched land bridges!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Captured before the power and before the Trench wall: this is the
|
||||||
|
// "how far past the island body are we" number the offshore layer reads.
|
||||||
|
float preTrenchFalloff = finalFalloff;
|
||||||
|
|
||||||
finalFalloff = Mathf.Pow(finalFalloff, 2.5f);
|
finalFalloff = Mathf.Pow(finalFalloff, 2.5f);
|
||||||
|
|
||||||
float distX = Mathf.Abs(x - center.X) / (MapSize / 2.0f);
|
float distX = Mathf.Abs(x - center.X) / (MapSize / 2.0f);
|
||||||
|
|
@ -453,11 +567,15 @@ public partial class MapGenerator : TextureRect
|
||||||
if (distY > 0.90f) finalFalloff += (distY - 0.90f) * 15.0f;
|
if (distY > 0.90f) finalFalloff += (distY - 0.90f) * 15.0f;
|
||||||
|
|
||||||
// --- 3. THE MOUNTAIN SPINE ---
|
// --- 3. THE MOUNTAIN SPINE ---
|
||||||
|
// The ridge axis is the line x = centre. `1 - |x - cx|` peaks there with
|
||||||
|
// a slope discontinuity, which put the single largest slope step on the
|
||||||
|
// whole map at exactly that column (task-11 diagnostic). SmoothAbs rounds
|
||||||
|
// the crest — same peak height, continuous slope through it.
|
||||||
float mountainSpine = 0f;
|
float mountainSpine = 0f;
|
||||||
if (temperature < 0.65f)
|
if (temperature < 0.65f)
|
||||||
{
|
{
|
||||||
float distanceToCenterX = Mathf.Abs(x - center.X) / (MapSize / 2.0f * 1.15f);
|
float distanceToCenterX = Mathf.Abs(x - center.X) / (MapSize / 2.0f * axisX);
|
||||||
mountainSpine = 1.0f - distanceToCenterX;
|
mountainSpine = 1.0f - IslandFalloff.SmoothAbs(distanceToCenterX, IslandFalloff.CREST_EPSILON);
|
||||||
float southernFade = Mathf.Clamp((0.65f - temperature) * 4.0f, 0.0f, 1.0f);
|
float southernFade = Mathf.Clamp((0.65f - temperature) * 4.0f, 0.0f, 1.0f);
|
||||||
mountainSpine = Mathf.Pow(mountainSpine, 3.0f) * southernFade * 0.6f;
|
mountainSpine = Mathf.Pow(mountainSpine, 3.0f) * southernFade * 0.6f;
|
||||||
}
|
}
|
||||||
|
|
@ -468,46 +586,111 @@ public partial class MapGenerator : TextureRect
|
||||||
// hMaxSeed) and the crater carve are applied in PASS 2 below.
|
// hMaxSeed) and the crater carve are applied in PASS 2 below.
|
||||||
float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f;
|
float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f;
|
||||||
float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength);
|
float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength);
|
||||||
|
|
||||||
|
// --- 5. THE COAST SHELF (task 11) ---
|
||||||
|
// The height curve is identity at and below sea, so it never reached the
|
||||||
|
// seabed. Compress shallow depth so the shallows reach much further out.
|
||||||
|
// Strictly positive depth stays strictly positive, so the waterline — and
|
||||||
|
// with it every biome and water body — cannot move by a single pixel.
|
||||||
|
float seaHere = GetSeaLevel(temperature);
|
||||||
|
if (_coastWide && finalH < seaHere)
|
||||||
|
{
|
||||||
|
float depthM = (seaHere - finalH) * 251f;
|
||||||
|
// The remap is strictly positive on positive depth, so in exact
|
||||||
|
// arithmetic the waterline cannot move. In float32 it can: for a
|
||||||
|
// pixel a few microns under water, (sea - shallowerDepth) rounds
|
||||||
|
// back up to exactly sea, and `H < sea` then calls it land. That
|
||||||
|
// cost 5 px of 67 M on the first batch — immaterial in itself, but
|
||||||
|
// it falsifies the invariant the whole separability argument rests
|
||||||
|
// on. Hold the result strictly below sea and the invariant is exact.
|
||||||
|
finalH = Mathf.Min(seaHere - IslandFalloff.CoastShelf(depthM) / 251f,
|
||||||
|
System.MathF.BitDecrement(seaHere));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. OFFSHORE ISLANDS (task 11) ---
|
||||||
|
// Sparse islets lerped TOWARD a target crest rather than added to the
|
||||||
|
// seabed, so they surface at any ambient depth. Held off the mainland by
|
||||||
|
// a depth moat and out of the Trench ramp by a distance mask.
|
||||||
|
if (_offshoreOn && finalH < seaHere)
|
||||||
|
{
|
||||||
|
float ambientDepthM = (seaHere - finalH) * 251f;
|
||||||
|
float zone = IslandFalloff.OffshoreZoneWeight(
|
||||||
|
ambientDepthM, preTrenchFalloff,
|
||||||
|
Mathf.Abs(x - center.X) / (MapSize / 2.0f),
|
||||||
|
Mathf.Abs(y - center.Y) / (MapSize / 2.0f));
|
||||||
|
if (zone > 0f)
|
||||||
|
{
|
||||||
|
float v = (_offshoreNoise.GetNoise2D(x, y) + 1.0f) * 0.5f;
|
||||||
|
float blob = IslandFalloff.OffshoreBlob(v, _offshoreThreshold) * zone;
|
||||||
|
if (blob > 0f)
|
||||||
|
{
|
||||||
|
float before = finalH;
|
||||||
|
finalH = Mathf.Lerp(finalH, seaHere + IslandFalloff.OFFSHORE_ISLAND_H_M / 251f, blob);
|
||||||
|
if (before < seaHere && finalH >= seaHere) offshoreLandPx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (finalH > _hMaxSeed) _hMaxSeed = finalH;
|
if (finalH > _hMaxSeed) _hMaxSeed = finalH;
|
||||||
_heightMap[x, y] = finalH;
|
_heightMap[x, y] = finalH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_offshoreOn)
|
||||||
|
GD.Print($"{T()} [Offshore] islet layer lifted {offshoreLandPx} px above sea " +
|
||||||
|
$"({offshoreLandPx / (float)(MapSize * MapSize) * 100f:F3}% of the map).");
|
||||||
|
|
||||||
// The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak
|
// The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak
|
||||||
// band, so the monotonicity assertion must run against the EFFECTIVE per-seed
|
// band, so the monotonicity assertion must run against the EFFECTIVE per-seed
|
||||||
// curve — after hMaxSeed is known, before any pixel is curved.
|
// curve — after hMaxSeed is known, before any pixel is curved.
|
||||||
if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots);
|
if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots, _detailOn ? _edgeAmpRaw : 0f);
|
||||||
|
|
||||||
// --- PASS 2: curve (task 05/06) + 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
|
// 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 carve cuts into curved terrain and the rim/bowl shape is untouched by
|
||||||
// the curve. Identity at and below sea + this ordering preserve the
|
// the curve. Identity at and below sea + this ordering preserve the
|
||||||
// Trench/ocean-border guarantee and the crater by construction. classifyH
|
// Trench/ocean-border guarantee and the crater by construction. classifyH
|
||||||
// stays uncurved — see _heightMapClassify; hMaxSeed never touches it.
|
// stays uncurved — see _heightMapClassify; hMaxSeed never touches it.
|
||||||
// --- PASS 2a: curve + shelf micro-relief (task 10 pass A) ---
|
|
||||||
// classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on,
|
// classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on,
|
||||||
// the shelf-ness-weighted noise skin (risers and peaks untouched).
|
// the shelf-edge warp (pass B — the knot block slides per column, so the
|
||||||
|
// shelf/riser boundary contours scallop) and the shelf-ness-weighted noise
|
||||||
|
// skin (pass A — risers and peaks untouched). Both are read-only consumers of
|
||||||
|
// `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 reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f;
|
||||||
|
float physicalCraterRadius = _impactRadius * CRATER_CARVE_FACTOR;
|
||||||
for (int x = 0; x < MapSize; x++)
|
for (int x = 0; x < MapSize; x++)
|
||||||
{
|
{
|
||||||
for (int y = 0; y < MapSize; y++)
|
for (int y = 0; y < MapSize; y++)
|
||||||
{
|
{
|
||||||
float raw = _heightMap[x, y];
|
float raw = _heightMap[x, y];
|
||||||
|
float classifyH = raw;
|
||||||
float curvedH;
|
float curvedH;
|
||||||
|
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||||
if (_curveOn)
|
if (_curveOn)
|
||||||
{
|
{
|
||||||
// per-column shelf modulation (v4) — anchors and strength from the
|
// v4: per-column shelf modulation — anchors and strength from the
|
||||||
// low-frequency fields; ordering safety by construction.
|
// low-frequency fields; ordering safety by construction (amplitudes
|
||||||
|
// bounded; asserted at all 8 field-extreme corners per generation).
|
||||||
float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP;
|
float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP;
|
||||||
float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP;
|
float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP;
|
||||||
float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f);
|
float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f);
|
||||||
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots);
|
|
||||||
|
|
||||||
if (_detailOn)
|
// Detail yields to the crater: zero inside the carve, feathered just
|
||||||
|
// outside it, so the carve stays the final authority on its terrain.
|
||||||
|
float wCrater = _detailOn
|
||||||
|
? TerrainDetailPass.CraterDetailWeight(distToCrater, _impactRadius)
|
||||||
|
: 0f;
|
||||||
|
|
||||||
|
float edgeShift = _detailOn ? _edgeNoise.GetNoise2D(x, y) * _edgeAmpRaw * wCrater : 0f;
|
||||||
|
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots, edgeShift);
|
||||||
|
|
||||||
|
if (_detailOn && wCrater > 0f)
|
||||||
{
|
{
|
||||||
float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots);
|
float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots, edgeShift);
|
||||||
if (wShelf > 0f)
|
if (wShelf > 0f)
|
||||||
curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf;
|
curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf * wCrater;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -515,105 +698,193 @@ public partial class MapGenerator : TextureRect
|
||||||
curvedH = raw;
|
curvedH = raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
_heightMapClassify[x, y] = raw; // uncurved; carve joins in pass 2c
|
_heightMapClassify[x, y] = classifyH;
|
||||||
_heightMap[x, y] = curvedH;
|
_heightMap[x, y] = curvedH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PASS 2b: drainage incision (task 10 pass B) ---
|
// --- EROSION (task 17): render map ONLY — after detail, BEFORE the crater
|
||||||
if (_curveOn && _detailOn)
|
// carve. Output-only by construction: _heightMapClassify was finalized above
|
||||||
RunIncisionPass();
|
// (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
|
||||||
// --- PASS 2c: THE CRATER CARVE (The Flooded Bay & Landbridge Fix!) ---
|
// read-only rule mean the RENDERED coastline cannot move either; that is
|
||||||
// The carve remains the FINAL authority on its own terrain: applied after
|
// verified here, not assumed, by counting render-map water pixels A/B.
|
||||||
// curve/relief/incision, to both maps, with the original expression.
|
if (_erosionOn)
|
||||||
float physicalCraterRadius = _impactRadius * 0.80f;
|
|
||||||
for (int x = 0; x < MapSize; x++)
|
|
||||||
{
|
{
|
||||||
for (int y = 0; y < MapSize; y++)
|
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]);
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
{
|
||||||
|
DropletCount = ConfigManager.ErosionDropletCount,
|
||||||
|
Lifetime = ConfigManager.ErosionDropletLifetime,
|
||||||
|
CarveCapM = ConfigManager.ErosionCarveCap,
|
||||||
|
DepositCapM = ConfigManager.ErosionDepositCap,
|
||||||
|
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,
|
||||||
|
CraterMode = _craterErosionMode,
|
||||||
|
Seed = _noise.Seed + HydraulicErosion.SEED_OFFSET
|
||||||
|
};
|
||||||
|
var st = HydraulicErosion.Apply(_heightMap, MapSize, seaMap, seaFlat,
|
||||||
|
_impactCenter.X, _impactCenter.Y,
|
||||||
|
_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³ " +
|
||||||
|
$"(max cell deposit {st.MaxCellDepositM:F2} m vs cap {p.DepositCapM:F2} 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);
|
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||||
|
if (distToCrater >= physicalCraterRadius) continue;
|
||||||
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
float craterDepth = 1.0f - (distToCrater / physicalCraterRadius);
|
||||||
if (distToCrater < physicalCraterRadius)
|
// Dialed back to -0.15f as per your excellent instinct!
|
||||||
{
|
float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
|
||||||
float craterDepth = 1.0f - (distToCrater / physicalCraterRadius);
|
float classifyH = _heightMapClassify[x, y];
|
||||||
// Dialed back to -0.15f as per your excellent instinct!
|
float curvedH = _heightMap[x, y];
|
||||||
float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
|
_heightMapClassify[x, y] = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
|
||||||
// Read BOTH before writing EITHER: with the curve off the two maps
|
_heightMap[x, y] = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
|
||||||
// alias the same array, and a sequential read-modify-write carved
|
|
||||||
// the crater twice (caught by the task-10 continuity oracle).
|
|
||||||
float preClassify = _heightMapClassify[x, y];
|
|
||||||
float preCurved = _heightMap[x, y];
|
|
||||||
_heightMapClassify[x, y] = Mathf.Lerp(preClassify, carveTarget, craterDepth * 0.9f);
|
|
||||||
_heightMap[x, y] = Mathf.Lerp(preCurved, carveTarget, craterDepth * 0.9f);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Task-10 pass B: D8 flow accumulation over the curved+relieved land, then
|
/// The river-carving pass (task 22): recomputes the frozen task-21b plan on the
|
||||||
/// depth = K · accum^p · slope, masked to the risers (shelves feathered to 30 %,
|
/// eroded surface, routes the routed giants' lowland reaches (SHORT/LOWGROUND),
|
||||||
/// toe and peaks zero, crater excluded), capped, and clamped to sea + 1 m.
|
/// and carves every promoted bed. Render map only; flood-guarded exactly like
|
||||||
/// Prints its own MEASURED depth distribution — the tuning/report source.
|
/// erosion (water-pixel count A/B around the pass, throw on any change).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RunIncisionPass()
|
private void CarveRivers()
|
||||||
{
|
{
|
||||||
ulong t0 = Time.GetTicksMsec();
|
ulong tRiv0 = Time.GetTicksMsec();
|
||||||
int n = MapSize;
|
float[,] seaMap = null;
|
||||||
int total = n * n;
|
float seaFlat = ConfigManager.SeaLevelValue;
|
||||||
|
if (ConfigManager.SeaLevelModel != "flat")
|
||||||
float[] flat = new float[total];
|
|
||||||
for (int x = 0; x < n; x++)
|
|
||||||
for (int y = 0; y < n; y++)
|
|
||||||
flat[x * n + y] = _heightMap[x, y];
|
|
||||||
|
|
||||||
int[] accum = TerrainDetailPass.FlowAccumulation(flat, n, out float[] drop);
|
|
||||||
double accumSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
|
|
||||||
|
|
||||||
float capRaw = TerrainDetailPass.INC_CAP_M / 251f;
|
|
||||||
float exclR = _impactRadius * TerrainDetailPass.CRATER_EXCL_FACTOR;
|
|
||||||
float featherR = _impactRadius * TerrainDetailPass.CRATER_FEATHER_FACTOR;
|
|
||||||
long incised = 0, clampHits = 0;
|
|
||||||
var depthsM = new System.Collections.Generic.List<float>(1 << 20);
|
|
||||||
|
|
||||||
for (int x = 0; x < n; x++)
|
|
||||||
{
|
{
|
||||||
for (int y = 0; y < n; y++)
|
seaMap = new float[MapSize, MapSize];
|
||||||
{
|
for (int x = 0; x < MapSize; x++)
|
||||||
float raw = _heightMapClassify[x, y];
|
for (int y = 0; y < MapSize; y++)
|
||||||
float w = TerrainDetailPass.IncisionWeight(raw, _curveKnots);
|
seaMap[x, y] = GetSeaLevel(_tempMap[x, y]);
|
||||||
if (w <= 0f) continue;
|
|
||||||
|
|
||||||
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
|
||||||
if (distToCrater < exclR) continue;
|
|
||||||
if (distToCrater < featherR)
|
|
||||||
w *= (distToCrater - exclR) / (featherR - exclR);
|
|
||||||
|
|
||||||
int i = x * n + y;
|
|
||||||
float depth = TerrainDetailPass.INC_K
|
|
||||||
* Mathf.Pow(accum[i], TerrainDetailPass.INC_P) * drop[i];
|
|
||||||
depth = Mathf.Min(depth, capRaw) * w;
|
|
||||||
if (depth <= 0f) continue;
|
|
||||||
|
|
||||||
float nh = _heightMap[x, y] - depth;
|
|
||||||
if (nh < TerrainDetailPass.SEA_CLAMP)
|
|
||||||
{
|
|
||||||
nh = TerrainDetailPass.SEA_CLAMP;
|
|
||||||
clampHits++;
|
|
||||||
}
|
|
||||||
float realized = _heightMap[x, y] - nh;
|
|
||||||
if (realized * 251f >= 0.5f) { incised++; depthsM.Add(realized * 251f); }
|
|
||||||
_heightMap[x, y] = nh;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
long wetBefore = CountRenderWaterPixels(seaMap, seaFlat);
|
||||||
|
float topBefore = 0f;
|
||||||
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
for (int y = 0; y < MapSize; y++)
|
||||||
|
if (_heightMap[x, y] > topBefore) topBefore = _heightMap[x, y];
|
||||||
|
|
||||||
depthsM.Sort();
|
// Masks from the SHARED water predicates — identical by construction to the
|
||||||
float P(double q) => depthsM.Count == 0 ? 0 : depthsM[Mathf.Clamp((int)(q * depthsM.Count), 0, depthsM.Count - 1)];
|
// WBID-derived masks the task-21b tool used.
|
||||||
double totalSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
|
bool[] isOcean = new bool[MapSize * MapSize];
|
||||||
GD.Print($"{T()} [Incision] accumulation {accumSeconds:F1}s, total {totalSeconds:F1}s.");
|
bool[] isClassifyWater = new bool[MapSize * MapSize];
|
||||||
GD.Print($"{T()} [Incision] incised cells (≥0.5 m): {incised}; depth m: p50 {P(0.5):F1}, p90 {P(0.9):F1}, p99 {P(0.99):F1}, max {(depthsM.Count > 0 ? depthsM[depthsM.Count - 1] : 0):F1}; sea-clamp hits {clampHits}.");
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
for (int y = 0; y < MapSize; y++)
|
||||||
|
{
|
||||||
|
isOcean[x * MapSize + y] = IsOceanPixel(x, y);
|
||||||
|
isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
float southX = -1f, southY = -1f;
|
||||||
|
foreach (var t in _towns)
|
||||||
|
if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
|
||||||
|
|
||||||
|
var p = new RiverCarvePass.Params
|
||||||
|
{
|
||||||
|
RoutingStyle = ConfigManager.RiverRoutingStyle == "short"
|
||||||
|
? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND,
|
||||||
|
WidthScale = ConfigManager.RiverWidthScale,
|
||||||
|
DepthScale = ConfigManager.RiverDepthScale,
|
||||||
|
SeaMarginM = ConfigManager.RiverSeaMargin
|
||||||
|
};
|
||||||
|
var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater,
|
||||||
|
southX, southY, seaMap, seaFlat,
|
||||||
|
_impactCenter.X, _impactCenter.Y,
|
||||||
|
_impactRadius * ConfigManager.CraterErosionCore,
|
||||||
|
() => (Time.GetTicksMsec()) / 1000.0, p);
|
||||||
|
|
||||||
|
long wetAfter = CountRenderWaterPixels(seaMap, seaFlat);
|
||||||
|
if (wetAfter != wetBefore)
|
||||||
|
throw new System.InvalidOperationException(
|
||||||
|
$"[MapGenerator] RIVER FLOOD-GUARD VIOLATION: render-map water pixels {wetBefore} -> {wetAfter}. Refusing to generate.");
|
||||||
|
float topAfter = 0f;
|
||||||
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
for (int y = 0; y < MapSize; y++)
|
||||||
|
if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y];
|
||||||
|
|
||||||
|
GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " +
|
||||||
|
$"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " +
|
||||||
|
$"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " +
|
||||||
|
$"{st.CarvedCells} cell-writes, {st.CarvedVolumeM3:F0} m³, max cut {st.MaxCutM:F1} m. " +
|
||||||
|
$"Water pixels {wetBefore} -> {wetAfter} (flood guard holds); island top {topBefore * 251f:F2} -> {topAfter * 251f:F2} m.");
|
||||||
|
foreach (var r in st.Rivers)
|
||||||
|
GD.Print($"{T()} [Rivers] {r.Name} [{r.Kind}{(r.SouthernCandidate ? " SOUTHERN" : "")}]: " +
|
||||||
|
$"drainage {r.DrainagePx}, course {r.CourseLenPx} px" +
|
||||||
|
(r.RouteLenPx > 0 ? $", lowland route {r.RouteLenPx} px (straight {r.RouteStraightPx:F0}, wander {r.WanderRatio:F2})" : "") +
|
||||||
|
$", {(r.ReachedOcean ? "reaches ocean/terminal" : "*** ROUTE INCOMPLETE ***")}, " +
|
||||||
|
$"max cut {r.MaxCutM:F1} m, {r.VolumeM3:F0} m³.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
private void CalculateTrueOcean()
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,21 @@ Generates the entire 2D blueprint. Roughly in order:
|
||||||
2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance
|
2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance
|
||||||
falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor`
|
falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor`
|
||||||
(`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile.
|
(`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile.
|
||||||
|
The island's proportions are the `IslandAxisX`/`IslandAxisY` dials (`1.30`/`0.78`; the
|
||||||
|
pre-task-11 shape was `1.15`/`0.90`). **`IslandAxisX` is a weak lever** — the island is
|
||||||
|
already Trench-clamped at ~90 % of the map width, so aspect responds almost entirely to
|
||||||
|
`IslandAxisY`, which trades against land area. These move the coastline, so they move biomes.
|
||||||
|
The mountain spine shares `IslandAxisX` for its width, and its crest is rounded
|
||||||
|
(`IslandFalloff.SmoothAbs`) — `1 - |x - centre|` used to peak with a slope discontinuity that
|
||||||
|
was, measured, the largest slope step on the map outside the Trench walls. **The spine's AXIS
|
||||||
|
is still a straight line down the map centre; that is known, deferred, and its own task.**
|
||||||
|
With `CoastProfile: "wide"` (the default) the *seabed* leaving the shoreline is shelved
|
||||||
|
(`IslandFalloff.CoastShelf`): the height curve is identity at and below sea level, so it never
|
||||||
|
reached the water, and the shoreline used to shelve gently on land then drop 6.7× steeper the
|
||||||
|
moment it went under. The shelf cannot move the waterline — it is strictly positive for
|
||||||
|
positive depth — so biomes and water are bit-identical with it on or off. `OffshoreIslandDensity`
|
||||||
|
(`0.02`, `0` disables) seeds sparse discoverable islets in the open ocean; they are held off the
|
||||||
|
mainland by a depth moat and out of the Trench by a distance mask, both by construction.
|
||||||
When `TerrainCurve: "v5"` (the default — the task-09 gate's BALANCED winner), the calibrated
|
When `TerrainCurve: "v5"` (the default — the task-09 gate's BALANCED winner), the calibrated
|
||||||
height-redistribution curve
|
height-redistribution curve
|
||||||
(`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable
|
(`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable
|
||||||
|
|
@ -19,10 +34,83 @@ Generates the entire 2D blueprint. Roughly in order:
|
||||||
ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain
|
ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain
|
||||||
after noise/falloff/Trench and before the crater carve; biome classification reads a
|
after noise/falloff/Trench and before the crater carve; biome classification reads a
|
||||||
retained uncurved map, so biomes are identical either way. With `TerrainDetail: "v1"` (the
|
retained uncurved map, so biomes are identical either way. With `TerrainDetail: "v1"` (the
|
||||||
default) two detail passes follow the curve: shelf micro-relief (±3 m rolling skin on the
|
default) two detail passes ride along with the curve (`TerrainDetailPass.cs`): shelf
|
||||||
benches/plateaus) and D8 drainage incision (rain-cut ravines in the risers — the future
|
micro-relief (`ShelfReliefAmp`, ±3 m rolling skin on the benches/plateaus) and shelf-edge
|
||||||
river routes; `TerrainDetailPass.cs`). Drops the `0_height` hillshade snapshot (hypsometric
|
variation (`ShelfEdgeVariation`, 12 m) — a per-column shift of the shelf/riser knot block that
|
||||||
bands × NW hillshade) in both modes.
|
makes the shelf edge scallop into notches, coves and peninsulas instead of tracing a clean
|
||||||
|
height contour. Both touch exported heights only, and neither can reach below the red ceiling
|
||||||
|
or above the 420 m cap: the curve's K2 and K6 knots do not move, so a warped column is
|
||||||
|
bit-identical to an unwarped one outside the shelf/riser stack. Drops the `0_height` hillshade
|
||||||
|
snapshot (hypsometric bands × NW hillshade) in both modes.
|
||||||
|
|
||||||
|
**Erosion (tasks 17–18, Phase C0)** — with `Erosion: "v1"` (default **"off"**, opt-in until the
|
||||||
|
developer's gate approves it) a droplet-based hydraulic erosion pass (`HydraulicErosion.cs`,
|
||||||
|
standalone numeric, deterministic from the resolved seed) details the curved+detailed render
|
||||||
|
map after the detail passes and before the crater carve: droplets walk downhill with inertia,
|
||||||
|
eroding steep fast stretches and depositing where the ground flattens — both spread over the
|
||||||
|
same cone brush, so neither carving nor dumping can spike a single cell. **Four** hard
|
||||||
|
governors bound it (`ErosionDropletCount`/`ErosionDropletLifetime`/`ErosionCarveCap`/
|
||||||
|
`ErosionDepositCap`; both caps are per-cell metres against one net-displacement ledger and are
|
||||||
|
asserted on exit), a sea clamp forbids carving below sea + margin and leaves below-sea cells
|
||||||
|
untouched in both directions (the rendered coastline cannot move — asserted every generation),
|
||||||
|
and the crater is handled by `CraterErosionMode` (task 19): nothing inside the protected strike
|
||||||
|
core (`CraterErosionCore`, **0.80 ×** CraterRadius — the carve's own extent) is modified, and
|
||||||
|
outside it either `"full"` applies full strength at once or `"feather"` ramps 0→full out to
|
||||||
|
`CraterErosionFeather` (1.05 ×). Task 17's hard 1.2 × cutoff was replaced because it held
|
||||||
|
**620 811 land cells** of ordinary terrain smooth for no geometric reason — measured, the carve's
|
||||||
|
displacement is exactly 0 beyond 0.80 × — which read as an un-eroded disc with a hard edge.
|
||||||
|
Keeping the core at the carve radius is what makes erosion and the carve touch **disjoint**
|
||||||
|
cells; a narrower core lets the carve, which runs afterwards and scales height toward the sea
|
||||||
|
target, amplify erosion's deltas across the waterline (measured at a 0.50 core: 79 rendered
|
||||||
|
waterline crossings). The flooded bay and its sea connection never depend on the crater mode —
|
||||||
|
below-sea cells are read-only in both directions, so the bay can be neither carved open nor
|
||||||
|
silted shut. Output-height only: biome/water
|
||||||
|
classification reads the retained pre-erosion map, so `1_biomes`/`0_water` are bit-identical
|
||||||
|
with erosion on or off.
|
||||||
|
The task-18 defaults tune for a drainage **hierarchy** — fine rills everywhere feeding a set of
|
||||||
|
clearly deeper convergent channels — by letting droplets live long enough (384 steps at inertia
|
||||||
|
0.35, low evaporation) that their paths overlap and deepen shared low lines, and by raising the
|
||||||
|
carve cap to 15 m so trunks separate from rills instead of both piling against one ceiling.
|
||||||
|
Measured on seed 1280587109: ~1.9 M cells carved past 0.5 m, 42 k past 5 m, 1.4 k past 10 m,
|
||||||
|
and 177 connected channel systems of 200+ cells at the 3 m threshold. Erosion concentrates
|
||||||
|
~9× on the curve's shelf risers, because that is where sustained slope exists; the flat
|
||||||
|
shelves and lowlands are barely touched, so **the lowland continuation of a trunk is not
|
||||||
|
erosion's to cut** — that is river promotion's job. **No rivers yet** — the carved channels are
|
||||||
|
the designated future river routes (Phase C0b promotes them by flow accumulation).
|
||||||
|
The predecessor D8 drainage-incision pass was written and reverted in task 10 — per-cell
|
||||||
|
steepest descent on a regular grid can only route along eight headings, and at map scale that
|
||||||
|
reads as straight hatching, not drainage; the droplet model is the working replacement (its
|
||||||
|
carve field measures isotropic to within 1.5 % across the folded 45° grid period).
|
||||||
|
**River plan (task 21, C0b part 1)** — `DrainageAnalysis.cs` + the headless
|
||||||
|
`RiverPlanTool.tscn` produce a river PLAN from an erosion-ON blueprint: priority-flood with a
|
||||||
|
one-ulp epsilon resolves the ~15k erosion pits for ROUTING ONLY (terrain untouched), deep+large
|
||||||
|
depressions survive as terminal basins, D8 flow directions + Kahn accumulation build the
|
||||||
|
drainage network, and the top ~3 TRUE-ocean outlets (the ocean body, WBID 1 — enclosed lagoons
|
||||||
|
do not count as "the sea") become trunk candidates with lean tributaries, mountain-exit handoff
|
||||||
|
points, and lean endorheic terminals. Task 21b extends the plan to the developer's MIXED
|
||||||
|
promotion: the ocean trunks PLUS the top endorheic giants (`RIVERPLAN_GIANT_N`, default 3),
|
||||||
|
each classified — a giant whose terminal basin holds a classify lake stays a **lake-ender**
|
||||||
|
(rivers ending in lakes are real geography); a dry-pan giant, and always the **SOUTHERN
|
||||||
|
CANDIDATE** (the giant pooling nearest the southernmost town), gets a **PROVISIONAL route** to
|
||||||
|
the ocean: steepest descent on the full (no-terminal) fill, so the basin overtops at its spill
|
||||||
|
and the walk follows the terrain's own drainage to the sea — drawn dashed for the gate, never
|
||||||
|
carved. Output is a JSON *plan* sidecar + console report — deliberately NOT a blueprint
|
||||||
|
section, so a plan can never masquerade as realized water. Part 2 (task 22) carves and waters
|
||||||
|
the gated plan. Pure analysis: the source blueprint is never written.
|
||||||
|
|
||||||
|
**River carving (task 22, C0b part 2a)** — with `Rivers: "v1"` (default **"off"**, pending the
|
||||||
|
routing-style gate) the frozen task-21b plan is executed as real terrain (`RiverCarvePass.cs`):
|
||||||
|
the drainage analysis reruns in-pipeline (deterministic), each routed giant gets a lowland
|
||||||
|
route to the nearest ocean by deterministic Dijkstra — `RiverRoutingStyle: "short"` (direct,
|
||||||
|
uphill-penalised) or `"lowground"` (cost ≈ elevation; follows the lowest ground and meanders;
|
||||||
|
the provisional default) — and every promoted course is carved as a parabolic channel with a
|
||||||
|
smoothstep shoulder, width/depth growing downstream with drainage (`RiverWidthScale`/
|
||||||
|
`RiverDepthScale`), bed made monotone non-increasing toward the outlet and clamped to
|
||||||
|
sea + `RiverSeaMargin` everywhere (the erosion flood-guard discipline: below-sea cells
|
||||||
|
read-only, zero new below-sea cells, coastline provably fixed; asserted per generation).
|
||||||
|
Slots AFTER towns (town placement reads the render map and must not shift) and BEFORE roads.
|
||||||
|
Crater core excluded. **NO WATER yet** — part 2b waters the gated routing style.
|
||||||
|
|
||||||
3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar
|
3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar
|
||||||
— the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill
|
— the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill
|
||||||
separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass.
|
separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass.
|
||||||
|
|
|
||||||
361
Tools/Scripts/RiverCarvePass.cs
Normal file
361
Tools/Scripts/RiverCarvePass.cs
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// River bed carving — C0b part 2a (terrain-water task 22). The first river pass
|
||||||
|
/// that MODIFIES terrain: executes the frozen task-21b plan by carving channel
|
||||||
|
/// beds for the promoted rivers. NO WATER — part 2b puts water into these beds
|
||||||
|
/// once the routing-style gate picks SHORT or LOWGROUND.
|
||||||
|
///
|
||||||
|
/// Standalone numeric (D-035 family). Runs the task-21 DrainageAnalysis in-
|
||||||
|
/// pipeline (deterministic: same seed → same eroded surface → same plan), then:
|
||||||
|
///
|
||||||
|
/// 1. LOWLAND ROUTING (the A/B): each routed giant gets a route from its
|
||||||
|
/// pooling terminal to the nearest OCEAN cell by deterministic Dijkstra.
|
||||||
|
/// SHORT — cost ≈ distance, uphill penalised: heads direct, avoids walls.
|
||||||
|
/// LOWGROUND — cost ≈ elevation above sea: follows the lowest available
|
||||||
|
/// ground and wanders like a real lowland river.
|
||||||
|
/// 2. BED CARVING: every promoted course (trunks, routed giants + their lowland
|
||||||
|
/// reaches, lake-enders, tributaries) is stamped as a parabolic channel with
|
||||||
|
/// a smoothstep shoulder — a bed for water to sit in, not a canyon. Width and
|
||||||
|
/// depth grow downstream with drainage. The bed elevation is made MONOTONE
|
||||||
|
/// NON-INCREASING toward the outlet (water must flow), and is clamped to
|
||||||
|
/// sea + margin everywhere — the flood-guard discipline erosion established:
|
||||||
|
/// below-sea cells are read-only, no carve may create inland below-sea cells,
|
||||||
|
/// so the rendered coastline cannot move. The bed meets the ocean AT the
|
||||||
|
/// coast, where the terrain itself descends through sea level.
|
||||||
|
/// 3. Crater: nothing inside the protected core is modified (erosion's rule).
|
||||||
|
///
|
||||||
|
/// Output-only: the caller applies this to the RENDER map after classify-side
|
||||||
|
/// data (biomes, WBID) is already computed — the oracle stays byte-identical.
|
||||||
|
/// </summary>
|
||||||
|
public static class RiverCarvePass
|
||||||
|
{
|
||||||
|
public const float M_PER_UNIT = 251f;
|
||||||
|
public const byte STYLE_SHORT = 0;
|
||||||
|
public const byte STYLE_LOWGROUND = 1;
|
||||||
|
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
private static readonly float[] DIST = {
|
||||||
|
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
|
||||||
|
|
||||||
|
// Routing cost constants. SHORT pays lightly for climbing (8 per metre of rise,
|
||||||
|
// so a 10 m wall costs like an 80 px detour — walls are avoided, direction is
|
||||||
|
// kept). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
|
||||||
|
// heavily for climbing, so the cheapest corridor is the lowest ground even when
|
||||||
|
// that wanders.
|
||||||
|
private const float SHORT_UPHILL_PER_M = 8f;
|
||||||
|
private const float LOWGROUND_ELEV_PER_M = 1f;
|
||||||
|
private const float LOWGROUND_BASE = 0.05f;
|
||||||
|
private const float LOWGROUND_UPHILL_PER_M = 50f;
|
||||||
|
|
||||||
|
// Bed geometry: sizes grow downstream from head to mouth, scaled by
|
||||||
|
// sqrt(drainage / 1e6) so a 2.3M px giant carves roughly 2.3× deeper/wider at
|
||||||
|
// the mouth than a 0.4M px trunk. Kept channel-scale, per the erosion
|
||||||
|
// detailing philosophy.
|
||||||
|
private const float DEPTH_HEAD_M = 1.0f;
|
||||||
|
private const float DEPTH_MOUTH_M = 6.0f; // × sizeFactor × DepthScale
|
||||||
|
private const float HALFWIDTH_HEAD_PX = 2.0f;
|
||||||
|
private const float HALFWIDTH_MOUTH_PX = 12.0f; // × sizeFactor × WidthScale
|
||||||
|
private const float MIN_BED_SLOPE = 0.002f; // m per px of enforced descent
|
||||||
|
|
||||||
|
public class Params
|
||||||
|
{
|
||||||
|
public byte RoutingStyle = STYLE_LOWGROUND;
|
||||||
|
public float WidthScale = 1.0f;
|
||||||
|
public float DepthScale = 1.0f;
|
||||||
|
public float SeaMarginM = 0.2f; // bed floor above sea, everywhere
|
||||||
|
public DrainageAnalysis.Params PlanParams = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RiverStat
|
||||||
|
{
|
||||||
|
public string Name;
|
||||||
|
public string Kind;
|
||||||
|
public bool SouthernCandidate;
|
||||||
|
public long DrainagePx;
|
||||||
|
public int CourseLenPx;
|
||||||
|
public int RouteLenPx; // lowland reach only (routed giants)
|
||||||
|
public float RouteStraightPx;
|
||||||
|
public float WanderRatio; // routeLen / straight-line
|
||||||
|
public bool ReachedOcean;
|
||||||
|
public float MaxCutM;
|
||||||
|
public double VolumeM3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Stats
|
||||||
|
{
|
||||||
|
public List<RiverStat> Rivers = new();
|
||||||
|
public long CarvedCells;
|
||||||
|
public double CarvedVolumeM3;
|
||||||
|
public float MaxCutM;
|
||||||
|
public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Stats Apply(float[,] height, int mapSize, bool[] isOcean,
|
||||||
|
bool[] isClassifyWater, float southX, float southY,
|
||||||
|
float[,] seaMap, float seaFlat,
|
||||||
|
float craterCx, float craterCy, float craterCoreRadius,
|
||||||
|
Func<double> secondsNow, Params p)
|
||||||
|
{
|
||||||
|
int n = mapSize;
|
||||||
|
var stats = new Stats();
|
||||||
|
float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
|
||||||
|
float coreSq = craterCoreRadius * craterCoreRadius;
|
||||||
|
|
||||||
|
// --- the frozen plan, recomputed deterministically in-pipeline ---
|
||||||
|
double t0 = secondsNow();
|
||||||
|
var plan = DrainageAnalysis.Run(height, mapSize, isOcean, isClassifyWater, southX, southY, p.PlanParams);
|
||||||
|
stats.AnalysisSeconds = secondsNow() - t0;
|
||||||
|
|
||||||
|
// --- lowland routing for the routed giants (the A/B) ---
|
||||||
|
t0 = secondsNow();
|
||||||
|
var giantRoutes = new List<List<(float x, float y)>>();
|
||||||
|
foreach (var g in plan.Giants)
|
||||||
|
{
|
||||||
|
if (g.Kind != "routed") { giantRoutes.Add(null); continue; }
|
||||||
|
giantRoutes.Add(RouteToOcean(height, n, isOcean,
|
||||||
|
(int)g.Terminal.x, (int)g.Terminal.y, p.RoutingStyle, SeaAt));
|
||||||
|
}
|
||||||
|
stats.RoutingSeconds = secondsNow() - t0;
|
||||||
|
|
||||||
|
// --- carve ---
|
||||||
|
t0 = secondsNow();
|
||||||
|
int riverIdx = 0;
|
||||||
|
foreach (var t in plan.Trunks)
|
||||||
|
{
|
||||||
|
riverIdx++;
|
||||||
|
var course = new List<(float x, float y)>(t.Course);
|
||||||
|
course.Reverse(); // head → mouth
|
||||||
|
var rs = CarveRiver($"trunk{riverIdx}", "ocean-trunk", t.DrainageAreaPx,
|
||||||
|
course, null, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
rs.ReachedOcean = true; // outlet is on the coast by construction
|
||||||
|
foreach (var trib in t.Tributaries)
|
||||||
|
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
}
|
||||||
|
int gi = 0;
|
||||||
|
foreach (var g in plan.Giants)
|
||||||
|
{
|
||||||
|
var route = giantRoutes[gi]; gi++;
|
||||||
|
var course = new List<(float x, float y)>(g.Course);
|
||||||
|
course.Reverse(); // head → terminal
|
||||||
|
var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx,
|
||||||
|
course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
rs.SouthernCandidate = g.SouthernCandidate;
|
||||||
|
rs.ReachedOcean = g.Kind != "routed" || (route != null && route.Count > 0);
|
||||||
|
foreach (var trib in g.Tributaries)
|
||||||
|
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
}
|
||||||
|
stats.CarveSeconds = secondsNow() - t0;
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic Dijkstra from the start cell to the nearest ocean cell under
|
||||||
|
/// the selected style's cost model. Returns the path start → ocean (1-px steps),
|
||||||
|
/// or an empty list if no path exists (reported upstream, never asserted away).
|
||||||
|
/// </summary>
|
||||||
|
private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
|
||||||
|
bool[] isOcean, int sx, int sy, byte style, Func<int, int, float> seaAt)
|
||||||
|
{
|
||||||
|
int total = n * n;
|
||||||
|
var gcost = new float[total];
|
||||||
|
var parent = new int[total];
|
||||||
|
var closed = new bool[total];
|
||||||
|
Array.Fill(gcost, float.MaxValue);
|
||||||
|
Array.Fill(parent, -1);
|
||||||
|
|
||||||
|
float ElevM(int x, int y) => MathF.Max(0f, (height[x, y] - seaAt(x, y)) * M_PER_UNIT);
|
||||||
|
|
||||||
|
var pq = new PriorityQueue<int, (float c, int i)>();
|
||||||
|
int start = sx * n + sy;
|
||||||
|
gcost[start] = 0f;
|
||||||
|
pq.Enqueue(start, (0f, start));
|
||||||
|
int goal = -1;
|
||||||
|
|
||||||
|
while (pq.Count > 0)
|
||||||
|
{
|
||||||
|
int c = pq.Dequeue();
|
||||||
|
if (closed[c]) continue;
|
||||||
|
closed[c] = true;
|
||||||
|
if (isOcean[c]) { goal = c; break; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
float hc = height[cx, cy];
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (closed[ni]) continue;
|
||||||
|
float dhM = MathF.Max(0f, (height[nx, ny] - hc) * M_PER_UNIT);
|
||||||
|
float step = style == STYLE_SHORT
|
||||||
|
? DIST[k] + dhM * SHORT_UPHILL_PER_M
|
||||||
|
: DIST[k] * (LOWGROUND_BASE + ElevM(nx, ny) * LOWGROUND_ELEV_PER_M)
|
||||||
|
+ dhM * LOWGROUND_UPHILL_PER_M;
|
||||||
|
float nc = gcost[c] + step;
|
||||||
|
if (nc < gcost[ni])
|
||||||
|
{
|
||||||
|
gcost[ni] = nc;
|
||||||
|
parent[ni] = c;
|
||||||
|
pq.Enqueue(ni, (nc, ni));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = new List<(float x, float y)>();
|
||||||
|
if (goal >= 0)
|
||||||
|
{
|
||||||
|
for (int c = goal; c >= 0; c = parent[c])
|
||||||
|
path.Add((c / n, c % n));
|
||||||
|
path.Reverse();
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CarveTributary(DrainageAnalysis.Stream trib, float[,] height, int n,
|
||||||
|
Func<int, int, float> seaAt, float coreSq, float craterCx, float craterCy,
|
||||||
|
Params p, Stats stats)
|
||||||
|
{
|
||||||
|
var course = new List<(float x, float y)>(trib.Course);
|
||||||
|
course.Reverse(); // head → confluence
|
||||||
|
CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null,
|
||||||
|
height, n, seaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Carves one river: densify the course, build a monotone-descending clamped
|
||||||
|
/// bed profile, stamp the channel. Returns the per-river stat (also appended
|
||||||
|
/// to stats.Rivers unless name is null — tributaries fold into the totals).
|
||||||
|
/// </summary>
|
||||||
|
private static RiverStat CarveRiver(string name, string kind, long drainagePx,
|
||||||
|
List<(float x, float y)> upland, List<(float x, float y)> lowlandRoute,
|
||||||
|
float[,] height, int n, Func<int, int, float> seaAt,
|
||||||
|
float coreSq, float craterCx, float craterCy, Params p, Stats stats)
|
||||||
|
{
|
||||||
|
// Full head→mouth polyline: upland stem, then the lowland reach if any.
|
||||||
|
var pts = new List<(float x, float y)>(upland);
|
||||||
|
if (lowlandRoute != null && lowlandRoute.Count > 1)
|
||||||
|
pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1));
|
||||||
|
|
||||||
|
// Densify to ~1-px samples (plan courses are decimated ×4).
|
||||||
|
var dense = new List<(float x, float y)>();
|
||||||
|
for (int i = 0; i + 1 < pts.Count; i++)
|
||||||
|
{
|
||||||
|
var a = pts[i]; var b = pts[i + 1];
|
||||||
|
float segLen = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
|
||||||
|
int steps = Math.Max(1, (int)MathF.Ceiling(segLen));
|
||||||
|
for (int s2 = 0; s2 < steps; s2++)
|
||||||
|
dense.Add((a.x + (b.x - a.x) * s2 / steps, a.y + (b.y - a.y) * s2 / steps));
|
||||||
|
}
|
||||||
|
if (pts.Count > 0) dense.Add(pts[^1]);
|
||||||
|
if (dense.Count < 2) return new RiverStat();
|
||||||
|
|
||||||
|
float sizeFactor = MathF.Sqrt(drainagePx / 1_000_000f);
|
||||||
|
var rs = new RiverStat
|
||||||
|
{
|
||||||
|
Name = name, Kind = kind, DrainagePx = drainagePx,
|
||||||
|
CourseLenPx = dense.Count,
|
||||||
|
RouteLenPx = lowlandRoute?.Count ?? 0
|
||||||
|
};
|
||||||
|
if (lowlandRoute != null && lowlandRoute.Count > 1)
|
||||||
|
{
|
||||||
|
var a = lowlandRoute[0]; var b = lowlandRoute[^1];
|
||||||
|
rs.RouteStraightPx = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
|
||||||
|
// Wander = POLYLINE length over straight-line — cell count undercounts
|
||||||
|
// diagonal steps and can read below 1, which is geometrically impossible.
|
||||||
|
float polyLen = 0f;
|
||||||
|
for (int i = 1; i < lowlandRoute.Count; i++)
|
||||||
|
{
|
||||||
|
float sdx = lowlandRoute[i].x - lowlandRoute[i - 1].x;
|
||||||
|
float sdy = lowlandRoute[i].y - lowlandRoute[i - 1].y;
|
||||||
|
polyLen += MathF.Sqrt(sdx * sdx + sdy * sdy);
|
||||||
|
}
|
||||||
|
rs.RouteLenPx = (int)polyLen;
|
||||||
|
rs.WanderRatio = rs.RouteStraightPx > 1f ? polyLen / rs.RouteStraightPx : 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bed profile: raw = terrain − depth(t); then monotone non-increasing
|
||||||
|
// downstream; then clamped to sea + margin. The clamp can flatten the tail
|
||||||
|
// near the mouth — allowed: non-increasing is what water needs, and the
|
||||||
|
// flood guard is absolute.
|
||||||
|
int m = dense.Count;
|
||||||
|
var bed = new float[m];
|
||||||
|
var depth = new float[m];
|
||||||
|
var halfW = new float[m];
|
||||||
|
for (int i = 0; i < m; i++)
|
||||||
|
{
|
||||||
|
float t = m > 1 ? (float)i / (m - 1) : 1f;
|
||||||
|
depth[i] = (DEPTH_HEAD_M + (DEPTH_MOUTH_M * sizeFactor - DEPTH_HEAD_M) * t) * p.DepthScale;
|
||||||
|
if (depth[i] < 0.5f) depth[i] = 0.5f;
|
||||||
|
halfW[i] = (HALFWIDTH_HEAD_PX + (HALFWIDTH_MOUTH_PX * sizeFactor - HALFWIDTH_HEAD_PX) * t) * p.WidthScale;
|
||||||
|
if (halfW[i] < 1.5f) halfW[i] = 1.5f;
|
||||||
|
int cx = (int)dense[i].x, cy = (int)dense[i].y;
|
||||||
|
bed[i] = height[cx, cy] - depth[i] / M_PER_UNIT;
|
||||||
|
}
|
||||||
|
for (int i = 1; i < m; i++)
|
||||||
|
{
|
||||||
|
float maxAllowed = bed[i - 1] - MIN_BED_SLOPE / M_PER_UNIT;
|
||||||
|
if (bed[i] > maxAllowed) bed[i] = maxAllowed;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < m; i++)
|
||||||
|
{
|
||||||
|
int cx = (int)dense[i].x, cy = (int)dense[i].y;
|
||||||
|
float floor = seaAt(cx, cy) + p.SeaMarginM / M_PER_UNIT;
|
||||||
|
if (bed[i] < floor) bed[i] = floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp: parabolic channel to the rim, smoothstep shoulder back to terrain.
|
||||||
|
for (int i = 0; i < m; i++)
|
||||||
|
{
|
||||||
|
float hw = halfW[i];
|
||||||
|
float outer = hw * 2f;
|
||||||
|
int cx0 = (int)MathF.Floor(dense[i].x - outer), cx1 = (int)MathF.Ceiling(dense[i].x + outer);
|
||||||
|
int cy0 = (int)MathF.Floor(dense[i].y - outer), cy1 = (int)MathF.Ceiling(dense[i].y + outer);
|
||||||
|
float rimH = bed[i] + depth[i] / M_PER_UNIT;
|
||||||
|
for (int x = cx0; x <= cx1; x++)
|
||||||
|
{
|
||||||
|
if (x < 0 || x >= n) continue;
|
||||||
|
for (int y = cy0; y <= cy1; y++)
|
||||||
|
{
|
||||||
|
if (y < 0 || y >= n) continue;
|
||||||
|
float rx = x - dense[i].x, ry = y - dense[i].y;
|
||||||
|
float r = MathF.Sqrt(rx * rx + ry * ry);
|
||||||
|
if (r > outer) continue;
|
||||||
|
float ddx = x - craterCx, ddy = y - craterCy;
|
||||||
|
if (ddx * ddx + ddy * ddy < coreSq) continue; // crater core protected
|
||||||
|
float sea = seaAt(x, y);
|
||||||
|
float old = height[x, y];
|
||||||
|
if (old < sea) continue; // below-sea cells read-only
|
||||||
|
float target;
|
||||||
|
if (r <= hw)
|
||||||
|
{
|
||||||
|
float f = r / hw;
|
||||||
|
target = bed[i] + (depth[i] / M_PER_UNIT) * f * f;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float f = (r - hw) / hw; // 0..1 across the shoulder
|
||||||
|
f = f * f * (3f - 2f * f); // smoothstep
|
||||||
|
target = rimH + (old - rimH) * f;
|
||||||
|
}
|
||||||
|
float floor = sea + p.SeaMarginM / M_PER_UNIT;
|
||||||
|
if (target < floor) target = floor;
|
||||||
|
if (target < old)
|
||||||
|
{
|
||||||
|
float cutM = (old - target) * M_PER_UNIT;
|
||||||
|
height[x, y] = target;
|
||||||
|
stats.CarvedCells++;
|
||||||
|
stats.CarvedVolumeM3 += cutM;
|
||||||
|
if (cutM > stats.MaxCutM) stats.MaxCutM = cutM;
|
||||||
|
if (cutM > rs.MaxCutM) rs.MaxCutM = cutM;
|
||||||
|
rs.VolumeM3 += cutM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name != null) stats.Rivers.Add(rs);
|
||||||
|
return rs;
|
||||||
|
}
|
||||||
|
}
|
||||||
286
Tools/Scripts/RiverPlanTool.cs
Normal file
286
Tools/Scripts/RiverPlanTool.cs
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
using Godot;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The river-plan tool (C0b part 1, terrain-water task 21). Headless, harness-style:
|
||||||
|
///
|
||||||
|
/// 1. load an EROSION-ON blueprint through the real parser,
|
||||||
|
/// 2. run DrainageAnalysis over its (eroded) heightmap — pure analysis,
|
||||||
|
/// 3. print the full plan report to the console,
|
||||||
|
/// 4. write the plan as a JSON SIDECAR next to the source file.
|
||||||
|
///
|
||||||
|
/// It never writes the blueprint. The sidecar is deliberately NOT a blueprint
|
||||||
|
/// section: sections are for realized world data, and this is a PLAN the developer
|
||||||
|
/// gates before part 2 carves anything — a plan that read as actual water would be
|
||||||
|
/// exactly the masquerade task 21 forbids. Part 2 owns the durable representation.
|
||||||
|
///
|
||||||
|
/// Run: Godot --headless --path <repo> res://Tools/Scenes/RiverPlanTool.tscn
|
||||||
|
/// Env: RIVERPLAN_SRC (source .dat; default user://MapData_Seed_1280587109.dat),
|
||||||
|
/// RIVERPLAN_OUT (sidecar path; default <src dir>/RiverPlan_Seed_<seed>.json),
|
||||||
|
/// RIVERPLAN_* dial overrides (see ReadParams).
|
||||||
|
/// Exit 0 = plan written, 1 = failure.
|
||||||
|
/// </summary>
|
||||||
|
public partial class RiverPlanTool : Node
|
||||||
|
{
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
try { ok = RunPlan(); }
|
||||||
|
catch (System.Exception e) { GD.PrintErr($"[RiverPlan] EXCEPTION: {e}"); }
|
||||||
|
GD.Print(ok ? "[RiverPlan] RESULT: PLAN WRITTEN" : "[RiverPlan] RESULT: FAIL");
|
||||||
|
GetTree().Quit(ok ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float EnvF(string k, float d) =>
|
||||||
|
float.TryParse(OS.GetEnvironment(k), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : d;
|
||||||
|
private static int EnvI(string k, int d) =>
|
||||||
|
int.TryParse(OS.GetEnvironment(k), out var v) ? v : d;
|
||||||
|
|
||||||
|
private static DrainageAnalysis.Params ReadParams()
|
||||||
|
{
|
||||||
|
var p = new DrainageAnalysis.Params();
|
||||||
|
p.EndorheicMinDepthM = EnvF("RIVERPLAN_ENDO_MIN_DEPTH_M", p.EndorheicMinDepthM);
|
||||||
|
p.EndorheicMinAreaPx = EnvI("RIVERPLAN_ENDO_MIN_AREA_PX", p.EndorheicMinAreaPx);
|
||||||
|
p.EndorheicMinInflowPx = EnvI("RIVERPLAN_ENDO_MIN_INFLOW_PX", p.EndorheicMinInflowPx);
|
||||||
|
p.EndorheicMaxCount = EnvI("RIVERPLAN_ENDO_MAX_COUNT", p.EndorheicMaxCount);
|
||||||
|
p.TrunkCount = EnvI("RIVERPLAN_TRUNK_COUNT", p.TrunkCount);
|
||||||
|
p.TrunkCount = EnvI("RIVERPLAN_OCEAN_N", p.TrunkCount); // 21b alias
|
||||||
|
p.GiantCount = EnvI("RIVERPLAN_GIANT_N", p.GiantCount);
|
||||||
|
p.MinOutletSeparationPx = EnvI("RIVERPLAN_OUTLET_SEPARATION_PX", p.MinOutletSeparationPx);
|
||||||
|
p.StemMinAccPx = EnvI("RIVERPLAN_STEM_MIN_ACC_PX", p.StemMinAccPx);
|
||||||
|
p.TributaryMinAccPx = EnvI("RIVERPLAN_TRIB_MIN_ACC_PX", p.TributaryMinAccPx);
|
||||||
|
p.TributaryMaxPerTrunk = EnvI("RIVERPLAN_TRIB_MAX_PER_TRUNK", p.TributaryMaxPerTrunk);
|
||||||
|
p.ExitGradeMin = EnvF("RIVERPLAN_EXIT_GRADE_MIN", p.ExitGradeMin);
|
||||||
|
p.ExitWindowPx = EnvI("RIVERPLAN_EXIT_WINDOW_PX", p.ExitWindowPx);
|
||||||
|
p.SeaLevel = EnvF("RIVERPLAN_SEA_LEVEL", p.SeaLevel);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RunPlan()
|
||||||
|
{
|
||||||
|
string src = OS.GetEnvironment("RIVERPLAN_SRC");
|
||||||
|
if (string.IsNullOrEmpty(src))
|
||||||
|
src = ProjectSettings.GlobalizePath("user://MapData_Seed_1280587109.dat");
|
||||||
|
GD.Print($"[RiverPlan] source blueprint: {src}");
|
||||||
|
|
||||||
|
ulong t0 = Time.GetTicksMsec();
|
||||||
|
WorldBlueprint bp = MapDataParser.LoadMapDataFromPath(src);
|
||||||
|
if (bp == null) { GD.PrintErr("[RiverPlan] blueprint load failed."); return false; }
|
||||||
|
if (bp.Erosion == null)
|
||||||
|
GD.PrintErr("[RiverPlan] ⚠ source carries no EROS section — analysing an UNERODED " +
|
||||||
|
"surface; the plan will still compute but is not the C0b input the task means.");
|
||||||
|
ulong t1 = Time.GetTicksMsec();
|
||||||
|
GD.Print($"[RiverPlan] loaded in {(t1 - t0) / 1000.0:F1}s " +
|
||||||
|
$"(seed {bp.Params?.WorldSeed}, {bp.MapSize}², erosion {(bp.Erosion != null ? $"v{bp.Erosion.Version}" : "ABSENT")}).");
|
||||||
|
|
||||||
|
if (bp.WaterBodyIds == null)
|
||||||
|
{ GD.PrintErr("[RiverPlan] source carries no WBID — cannot identify THE OCEAN; refusing."); return false; }
|
||||||
|
// THE OCEAN body (WBID == 1) is the only water that counts as "the sea":
|
||||||
|
// enclosed lagoons are depressions a river may legitimately END in, not
|
||||||
|
// destinations that make a trunk "sea-reaching".
|
||||||
|
int nn = bp.MapSize;
|
||||||
|
bool[] isOcean = new bool[nn * nn];
|
||||||
|
bool[] isClassifyWater = new bool[nn * nn];
|
||||||
|
for (int x = 0; x < nn; x++)
|
||||||
|
for (int y = 0; y < nn; y++)
|
||||||
|
{
|
||||||
|
ushort wb = bp.WaterBodyIds[x, y];
|
||||||
|
isOcean[x * nn + y] = wb == 1;
|
||||||
|
isClassifyWater[x * nn + y] = wb != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Southernmost town — the 21b SOUTHERN CANDIDATE anchor (shown, not forced).
|
||||||
|
float southX = -1f, southY = -1f;
|
||||||
|
foreach (var t in bp.Towns)
|
||||||
|
if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
|
||||||
|
|
||||||
|
var p = ReadParams();
|
||||||
|
var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, isClassifyWater, southX, southY, p);
|
||||||
|
ulong t2 = Time.GetTicksMsec();
|
||||||
|
GD.Print($"[RiverPlan] analysis in {(t2 - t1) / 1000.0:F1}s.");
|
||||||
|
|
||||||
|
// ---- console report ----
|
||||||
|
GD.Print($"[RiverPlan] routing: {plan.LandCells} land cells; " +
|
||||||
|
$"{plan.SeaReachingCells} drain to sea ({100.0 * plan.SeaReachingCells / plan.LandCells:F1}%), " +
|
||||||
|
$"{plan.EndorheicCells} endorheic ({100.0 * plan.EndorheicCells / plan.LandCells:F1}%), " +
|
||||||
|
$"{plan.UnroutedCells} unrouted (should be ~0).");
|
||||||
|
GD.Print($"[RiverPlan] depressions: {plan.PitsFilledCount} pits filled through for routing, " +
|
||||||
|
$"{plan.TerminalBasinCount} qualified as terminal basins " +
|
||||||
|
$"(depth ≥ {p.EndorheicMinDepthM} m and area ≥ {p.EndorheicMinAreaPx} px).");
|
||||||
|
GD.Print("[RiverPlan] top outlets by drainage area (pre-separation):");
|
||||||
|
foreach (var (x, y, a) in plan.AllOutletsTop)
|
||||||
|
GD.Print($"[RiverPlan] ({x},{y}) {a} px");
|
||||||
|
int ti = 0;
|
||||||
|
foreach (var t in plan.Trunks)
|
||||||
|
{
|
||||||
|
ti++;
|
||||||
|
GD.Print($"[RiverPlan] TRUNK {ti}: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}), " +
|
||||||
|
$"drainage {t.DrainageAreaPx} px, stem {t.Course.Count * 4} px, " +
|
||||||
|
(t.ExitFound
|
||||||
|
? $"mountain-exit ({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m"
|
||||||
|
: "mountain-exit NOT FOUND (stem never sustains the exit grade)") +
|
||||||
|
$", {t.Tributaries.Count} tributaries.");
|
||||||
|
foreach (var tr in t.Tributaries)
|
||||||
|
GD.Print($"[RiverPlan] trib: joins near head ({tr.Course[0].x:F0},{tr.Course[0].y:F0}), " +
|
||||||
|
$"drainage {tr.DrainageAreaPx} px");
|
||||||
|
}
|
||||||
|
foreach (var e in plan.Endorheics)
|
||||||
|
{
|
||||||
|
ushort wb = bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y];
|
||||||
|
GD.Print($"[RiverPlan] ENDORHEIC terminal ({e.Terminal.x:F0},{e.Terminal.y:F0}): " +
|
||||||
|
$"drainage {e.DrainageAreaPx} px into a basin {e.BasinDepthM:F1} m deep, {e.BasinAreaPx} px" +
|
||||||
|
(wb > 1 ? $" — terminates IN classify lake/lagoon WBID {wb} (river-feeds-lake)" : " — dry closed basin") + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 21b: the promoted giants ----
|
||||||
|
int gi = 0;
|
||||||
|
foreach (var g in plan.Giants)
|
||||||
|
{
|
||||||
|
gi++;
|
||||||
|
GD.Print($"[RiverPlan] GIANT {gi} [{g.Kind.ToUpper()}{(g.SouthernCandidate ? " — SOUTHERN CANDIDATE" : "")}]: " +
|
||||||
|
$"drainage {g.DrainageAreaPx} px, pools at ({g.Terminal.x:F0},{g.Terminal.y:F0}) " +
|
||||||
|
$"({(g.TerminalInClassifyWater ? "in classify water" : "dry pan")}, basin {g.BasinDepthM:F1} m / {g.BasinAreaPx} px), " +
|
||||||
|
(g.ExitFound ? $"mountain-exit ({g.MountainExit.x:F0},{g.MountainExit.y:F0}) at {g.MountainExitElevM:F0} m, " : "") +
|
||||||
|
$"{g.Tributaries.Count} tributaries" +
|
||||||
|
(g.ProvisionalRoute != null
|
||||||
|
? $"; PROVISIONAL route {g.ProvisionalRoute.Count * 4} px via spill ({g.Spill.x:F0},{g.Spill.y:F0}) " +
|
||||||
|
(g.RouteReachedOcean ? "-> reaches the OCEAN" : "-> DID NOT reach the ocean (walk stuck — report)")
|
||||||
|
: "; ends at its lake") + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the southern-town report (filed fact, not a constraint) ----
|
||||||
|
if (bp.Towns.Count > 0)
|
||||||
|
{
|
||||||
|
TownLocation south = bp.Towns[0];
|
||||||
|
foreach (var t in bp.Towns)
|
||||||
|
if (t.Position.Y > south.Position.Y) south = t;
|
||||||
|
GD.Print($"[RiverPlan] southernmost town: tier {south.Tier} at " +
|
||||||
|
$"({south.Position.X:F0},{south.Position.Y:F0}).");
|
||||||
|
ti = 0;
|
||||||
|
foreach (var t in plan.Trunks)
|
||||||
|
{
|
||||||
|
ti++;
|
||||||
|
float best = float.MaxValue;
|
||||||
|
foreach (var (x, y) in t.Course)
|
||||||
|
{
|
||||||
|
float dx = x - south.Position.X, dy = y - south.Position.Y;
|
||||||
|
float d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < best) best = d2;
|
||||||
|
}
|
||||||
|
GD.Print($"[RiverPlan] SOUTH REPORT trunk {ti}: outlet y={t.Outlet.y:F0} " +
|
||||||
|
$"({(t.Outlet.y > bp.MapSize * 0.55f ? "southern" : t.Outlet.y < bp.MapSize * 0.45f ? "northern" : "central")} coast); " +
|
||||||
|
$"course passes {Mathf.Sqrt(best):F0} px from the southernmost town.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON sidecar ----
|
||||||
|
string outPath = OS.GetEnvironment("RIVERPLAN_OUT");
|
||||||
|
if (string.IsNullOrEmpty(outPath))
|
||||||
|
outPath = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(src) ?? ".",
|
||||||
|
$"RiverPlan_Seed_{bp.Params?.WorldSeed}.json");
|
||||||
|
System.IO.File.WriteAllText(outPath, ToJson(bp, plan));
|
||||||
|
GD.Print($"[RiverPlan] plan sidecar written: {outPath}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-rolled, invariant-culture JSON for a fixed schema — deterministic output,
|
||||||
|
// no serializer reflection surprises.
|
||||||
|
private static string ToJson(WorldBlueprint bp, DrainageAnalysis.Plan plan)
|
||||||
|
{
|
||||||
|
var ci = CultureInfo.InvariantCulture;
|
||||||
|
var sb = new StringBuilder(1 << 20);
|
||||||
|
void Pt(StringBuilder b, (float x, float y) v) =>
|
||||||
|
b.Append('[').Append(v.x.ToString("F1", ci)).Append(',').Append(v.y.ToString("F1", ci)).Append(']');
|
||||||
|
void Course(List<(float x, float y)> c)
|
||||||
|
{
|
||||||
|
sb.Append('[');
|
||||||
|
for (int i = 0; i < c.Count; i++) { if (i > 0) sb.Append(','); Pt(sb, c[i]); }
|
||||||
|
sb.Append(']');
|
||||||
|
}
|
||||||
|
sb.Append("{\n\"_WARNING\": \"RIVER *PLAN* — analysis output for the task-21 gate. ");
|
||||||
|
sb.Append("Nothing here is realized water or terrain. Part 2 (task 22) consumes this; ");
|
||||||
|
sb.Append("nothing at runtime may read it as water.\",\n");
|
||||||
|
sb.Append($"\"seed\": {bp.Params?.WorldSeed ?? 0}, \"mapSize\": {bp.MapSize},\n");
|
||||||
|
var p = plan.P;
|
||||||
|
sb.Append($"\"params\": {{\"endoMinDepthM\": {p.EndorheicMinDepthM.ToString(ci)}, ");
|
||||||
|
sb.Append($"\"endoMinAreaPx\": {p.EndorheicMinAreaPx}, \"endoMinInflowPx\": {p.EndorheicMinInflowPx}, ");
|
||||||
|
sb.Append($"\"endoMaxCount\": {p.EndorheicMaxCount}, \"trunkCount\": {p.TrunkCount}, ");
|
||||||
|
sb.Append($"\"minOutletSeparationPx\": {p.MinOutletSeparationPx}, \"stemMinAccPx\": {p.StemMinAccPx}, ");
|
||||||
|
sb.Append($"\"tribMinAccPx\": {p.TributaryMinAccPx}, \"tribMaxPerTrunk\": {p.TributaryMaxPerTrunk}, ");
|
||||||
|
sb.Append($"\"exitGradeMin\": {p.ExitGradeMin.ToString(ci)}, \"exitWindowPx\": {p.ExitWindowPx}, ");
|
||||||
|
sb.Append($"\"seaLevel\": {p.SeaLevel.ToString(ci)}}},\n");
|
||||||
|
sb.Append($"\"routing\": {{\"landCells\": {plan.LandCells}, \"seaReaching\": {plan.SeaReachingCells}, ");
|
||||||
|
sb.Append($"\"endorheic\": {plan.EndorheicCells}, \"unrouted\": {plan.UnroutedCells}, ");
|
||||||
|
sb.Append($"\"pitsFilled\": {plan.PitsFilledCount}, \"terminalBasins\": {plan.TerminalBasinCount}}},\n");
|
||||||
|
sb.Append("\"trunks\": [\n");
|
||||||
|
for (int i = 0; i < plan.Trunks.Count; i++)
|
||||||
|
{
|
||||||
|
var t = plan.Trunks[i];
|
||||||
|
sb.Append(" {\"outlet\": "); Pt(sb, t.Outlet);
|
||||||
|
sb.Append($", \"drainageAreaPx\": {t.DrainageAreaPx}, \"exitFound\": {(t.ExitFound ? "true" : "false")}, ");
|
||||||
|
sb.Append("\"mountainExit\": "); Pt(sb, t.MountainExit);
|
||||||
|
sb.Append($", \"mountainExitElevM\": {t.MountainExitElevM.ToString("F1", ci)},\n \"course\": ");
|
||||||
|
Course(t.Course);
|
||||||
|
sb.Append(",\n \"tributaries\": [");
|
||||||
|
for (int j = 0; j < t.Tributaries.Count; j++)
|
||||||
|
{
|
||||||
|
var tr = t.Tributaries[j];
|
||||||
|
if (j > 0) sb.Append(',');
|
||||||
|
sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": ");
|
||||||
|
Course(tr.Course);
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
sb.Append("]\n }");
|
||||||
|
if (i < plan.Trunks.Count - 1) sb.Append(',');
|
||||||
|
sb.Append('\n');
|
||||||
|
}
|
||||||
|
sb.Append("],\n\"giants\": [\n");
|
||||||
|
for (int i = 0; i < plan.Giants.Count; i++)
|
||||||
|
{
|
||||||
|
var g = plan.Giants[i];
|
||||||
|
sb.Append(" {\"kind\": \"").Append(g.Kind).Append("\", ");
|
||||||
|
sb.Append($"\"southernCandidate\": {(g.SouthernCandidate ? "true" : "false")}, ");
|
||||||
|
sb.Append($"\"drainageAreaPx\": {g.DrainageAreaPx}, ");
|
||||||
|
sb.Append("\"terminal\": "); Pt(sb, g.Terminal);
|
||||||
|
sb.Append($", \"terminalInClassifyWater\": {(g.TerminalInClassifyWater ? "true" : "false")}, ");
|
||||||
|
sb.Append($"\"basinDepthM\": {g.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {g.BasinAreaPx}, ");
|
||||||
|
sb.Append($"\"exitFound\": {(g.ExitFound ? "true" : "false")}, \"mountainExit\": "); Pt(sb, g.MountainExit);
|
||||||
|
sb.Append($", \"mountainExitElevM\": {g.MountainExitElevM.ToString("F1", ci)},\n \"course\": ");
|
||||||
|
Course(g.Course);
|
||||||
|
if (g.ProvisionalRoute != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\n \"spill\": "); Pt(sb, g.Spill);
|
||||||
|
sb.Append($", \"routeReachedOcean\": {(g.RouteReachedOcean ? "true" : "false")}");
|
||||||
|
sb.Append(",\n \"provisionalRoute_NOT_WATER\": ");
|
||||||
|
Course(g.ProvisionalRoute);
|
||||||
|
}
|
||||||
|
sb.Append(",\n \"tributaries\": [");
|
||||||
|
for (int j = 0; j < g.Tributaries.Count; j++)
|
||||||
|
{
|
||||||
|
var tr = g.Tributaries[j];
|
||||||
|
if (j > 0) sb.Append(',');
|
||||||
|
sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": ");
|
||||||
|
Course(tr.Course);
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
sb.Append("]\n }");
|
||||||
|
if (i < plan.Giants.Count - 1) sb.Append(',');
|
||||||
|
sb.Append('\n');
|
||||||
|
}
|
||||||
|
sb.Append("],\n\"endorheics\": [");
|
||||||
|
for (int i = 0; i < plan.Endorheics.Count; i++)
|
||||||
|
{
|
||||||
|
var e = plan.Endorheics[i];
|
||||||
|
if (i > 0) sb.Append(',');
|
||||||
|
sb.Append("\n {\"terminal\": "); Pt(sb, e.Terminal);
|
||||||
|
sb.Append($", \"drainageAreaPx\": {e.DrainageAreaPx}, ");
|
||||||
|
sb.Append($"\"basinDepthM\": {e.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {e.BasinAreaPx}, ");
|
||||||
|
sb.Append($"\"terminalWbid\": {bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y]}}}");
|
||||||
|
}
|
||||||
|
sb.Append("\n]\n}\n");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Tools/Scripts/RiverPlanTool.cs.uid
Normal file
1
Tools/Scripts/RiverPlanTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b37tm1tov0x6m
|
||||||
|
|
@ -122,6 +122,7 @@ public partial class RoundTripHarness : Node
|
||||||
ok &= CompareWater(a, b);
|
ok &= CompareWater(a, b);
|
||||||
ok &= CompareTerrainCurve(a, b);
|
ok &= CompareTerrainCurve(a, b);
|
||||||
ok &= CompareTerrainDetail(a, b);
|
ok &= CompareTerrainDetail(a, b);
|
||||||
|
ok &= CompareErosion(a, b);
|
||||||
|
|
||||||
if (ok)
|
if (ok)
|
||||||
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
||||||
|
|
@ -229,8 +230,8 @@ public partial class RoundTripHarness : Node
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
var da = a.TerrainDetail; var db = b.TerrainDetail;
|
var da = a.TerrainDetail; var db = b.TerrainDetail;
|
||||||
float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.IncK, da.IncP, da.IncCapM, da.SeaClampRaw, da.CraterExclFactor, da.ShelfIncWeight, da.ReliefSeedOffset };
|
float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.ReliefSeedOffset, da.EdgeAmpM, da.EdgeFreqIslands, da.EdgeSeedOffset, da.EdgeMaxShiftM };
|
||||||
float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.IncK, db.IncP, db.IncCapM, db.SeaClampRaw, db.CraterExclFactor, db.ShelfIncWeight, db.ReliefSeedOffset };
|
float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.ReliefSeedOffset, db.EdgeAmpM, db.EdgeFreqIslands, db.EdgeSeedOffset, db.EdgeMaxShiftM };
|
||||||
for (int i = 0; i < fa.Length; i++)
|
for (int i = 0; i < fa.Length; i++)
|
||||||
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i]))
|
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i]))
|
||||||
{
|
{
|
||||||
|
|
@ -241,6 +242,34 @@ public partial class RoundTripHarness : Node
|
||||||
return true;
|
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
|
||||||
|
&& 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.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.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; }
|
||||||
|
GD.Print($"[Harness] EROS equal (erosion v{ea.Version}).");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
|
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
|
||||||
{
|
{
|
||||||
if (a.Count != b.Count)
|
if (a.Count != b.Count)
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,105 @@
|
||||||
using Godot;
|
using Godot;
|
||||||
using System;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The terrain DETAIL passes (terrain-water task 10) — pure numeric array machinery
|
/// The terrain DETAIL passes (terrain-water task 10) — pure numeric functions
|
||||||
/// (D-035; a named future C++ candidate, kept standalone):
|
/// (D-035; a named future C++ candidate, kept standalone):
|
||||||
///
|
///
|
||||||
/// PASS A — shelf micro-relief: a medium-frequency noise skin (±ShelfReliefAmp,
|
/// PASS A — shelf micro-relief: a medium-frequency noise skin (±ShelfReliefAmp,
|
||||||
/// default 3 m) weighted by shelf-ness, so the compressed shelves get their
|
/// default 3 m) weighted by shelf-ness, so the compressed shelves get their
|
||||||
/// rolling texture back while risers and peaks stay untouched.
|
/// rolling texture back while risers and peaks stay untouched.
|
||||||
///
|
///
|
||||||
/// PASS B — drainage incision: D8 steepest-descent flow routing + accumulation
|
/// PASS B — shelf-edge variation: a per-column shift of the shelf/riser KNOT
|
||||||
/// over the curved terrain; depth = K · accum^p · localSlope (capped), masked to
|
/// BLOCK (K3/K4/K5) by a low-frequency noise field, so the boundary where a
|
||||||
/// the risers (feathered ~30 % onto shelves, zero on the toe and above the
|
/// shelf meets its riser wanders in and out instead of tracing a clean height
|
||||||
/// plateau top, zero near the crater), clamped so carved terrain never drops
|
/// contour — organic notches, coves and peninsulas at the shelf edge.
|
||||||
/// below sea + 1 m. The channels double as the future river routes (Phase C).
|
|
||||||
///
|
///
|
||||||
/// Ordering (enforced by the caller): curve → micro-relief → incision → crater
|
/// Both are output-height only; the classify map never sees either of them.
|
||||||
/// carve. The classify map never sees any of it.
|
///
|
||||||
|
/// NOTE — what this file deliberately does NOT contain: the task-10 draft's D8
|
||||||
|
/// flow routing / accumulation / drainage incision. It shipped, produced the
|
||||||
|
/// canonical grid artifact (thousands of straight, disconnected, pooling
|
||||||
|
/// scratches along the D8 neighbour directions) and was reverted whole. Rivers
|
||||||
|
/// and erosion are Phase C work — a hydraulic-erosion pass over FINAL terrain,
|
||||||
|
/// not a per-cell steepest-descent carve on a grid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TerrainDetailPass
|
public static class TerrainDetailPass
|
||||||
{
|
{
|
||||||
public const ushort VERSION = 1;
|
// The TDTL body version is owned by the format (Core); the pass just stamps it.
|
||||||
|
public const ushort VERSION = IslaApocalypse.Core.BlueprintFormat.TDTL_VERSION;
|
||||||
|
|
||||||
// Pass A — micro-relief.
|
// Pass A — micro-relief.
|
||||||
public const float RELIEF_AMP_DEFAULT_M = 3f; // config dial: ShelfReliefAmp (metres)
|
public const float RELIEF_AMP_DEFAULT_M = 3f; // config dial: ShelfReliefAmp (metres)
|
||||||
public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features)
|
public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features)
|
||||||
public const int RELIEF_SEED_OFFSET = 7409;
|
public const int RELIEF_SEED_OFFSET = 7409;
|
||||||
|
|
||||||
// Pass B — incision. K/p tuned against the depth targets (gullies 8–15 m,
|
// Pass B — shelf-edge variation. The amplitude is stated in metres of INPUT
|
||||||
// trunks ~25 m, cap 30 m); the tuning run's achieved distribution is in the
|
// height (raw × 251): it is how far, in raw-height terms, a shelf boundary
|
||||||
// task-10 report.
|
// contour is displaced — not an output elevation change. What the eye sees is
|
||||||
public const float INC_K = 1.40f;
|
// the LATERAL wander, which is that displacement divided by the local raw
|
||||||
public const float INC_P = 0.45f; // concave: many fingers, few deep trunks
|
// gradient. Measured on seed 1375359975: |∇raw| at the K3/K4/K5 contours is
|
||||||
public const float INC_CAP_M = 30f; // IncisionMax
|
// p50 0.00088 raw/px, so 12 m of input height buys a median peak displacement
|
||||||
public const float SEA_CLAMP = 0.15f + 1f / 251f; // carved height ≥ sea + 1 m
|
// of ~54 px and a mean of ~8 px along the boundary — coves and notches, which
|
||||||
public const float SHELF_INC_WEIGHT = 0.3f; // shelves get washes, not gorges
|
// is where the developer's sketch sits. 5 m (the first try) moved the boundary
|
||||||
public const float CRATER_EXCL_FACTOR = 1.2f; // zero incision inside this × CraterRadius
|
// a mean 3.7 px and was invisible at map scale.
|
||||||
public const float CRATER_FEATHER_FACTOR = 1.4f; // ...feathering to full by this × CraterRadius
|
public const float EDGE_AMP_DEFAULT_M = 12f; // config dial: ShelfEdgeVariation (metres of input height)
|
||||||
|
public const float EDGE_FREQ_ISLANDS = 20f; // ~410 px wavelength at 8K — coves and notches at the
|
||||||
|
public const int EDGE_SEED_OFFSET = 7507; // scale of the sketch, not a fringe of teeth
|
||||||
|
|
||||||
|
// The shift squeezes whichever of the foothill riser / plateau bands it moves
|
||||||
|
// into. Bounding it at 2/3 of the smaller band means that band never compresses
|
||||||
|
// below a THIRD of its nominal width — i.e. its slope never more than triples,
|
||||||
|
// even where peak noise lands on a boundary. That is the real constraint; knot
|
||||||
|
// ordering follows from it.
|
||||||
|
public const float EDGE_SAFETY_FRACTION = 2f / 3f;
|
||||||
|
|
||||||
|
// The crater carve is the final authority on its own terrain. Detail is masked
|
||||||
|
// out inside the physical carve radius (0.80 × CraterRadius — exactly where the
|
||||||
|
// carve applies) and feathers to full by 1.05 ×. Without this, detail moves a
|
||||||
|
// column's PRE-carve height, the carve's Lerp passes a fraction of that through,
|
||||||
|
// and columns sitting a metre or two above the sea inside the bowl get pushed
|
||||||
|
// under it — 532 px on seed 1158286446 in the first batch, terrain below the sea
|
||||||
|
// scalar that the (classify-driven, and correctly unchanged) water grid calls dry.
|
||||||
|
public const float CRATER_DETAIL_EXCL_FACTOR = 0.80f;
|
||||||
|
public const float CRATER_DETAIL_FEATHER_FACTOR = 1.05f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detail weight from distance to the impact centre: 0 inside the carve, 1 well
|
||||||
|
/// outside it, linear between. <paramref name="craterRadius"/> is the configured
|
||||||
|
/// CraterRadius (the carve itself uses 0.80 × of it).
|
||||||
|
/// </summary>
|
||||||
|
public static float CraterDetailWeight(float distToCrater, float craterRadius)
|
||||||
|
{
|
||||||
|
float excl = craterRadius * CRATER_DETAIL_EXCL_FACTOR;
|
||||||
|
if (distToCrater <= excl) return 0f;
|
||||||
|
float feather = craterRadius * CRATER_DETAIL_FEATHER_FACTOR;
|
||||||
|
if (distToCrater >= feather) return 1f;
|
||||||
|
return (distToCrater - excl) / (feather - excl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The largest per-column knot shift this preset allows: bounded by the band
|
||||||
|
/// squeeze above, which also keeps the knot set strictly ordered
|
||||||
|
/// (K2 < K3+d, K5+d < K6) with a third of each band to spare. K1/K2/K6 never
|
||||||
|
/// move, so the toe, the orange/red bands and the summit spike are
|
||||||
|
/// bit-identical whatever the warp does — which is what makes the red-ceiling
|
||||||
|
/// floor and the 420 m cap exact rather than statistical.
|
||||||
|
/// </summary>
|
||||||
|
public static float MaxEdgeShift(CurveKnots k)
|
||||||
|
{
|
||||||
|
return EDGE_SAFETY_FRACTION * Mathf.Min(k.K3 - k.K2, k.K6 - k.K5);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0
|
/// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0
|
||||||
/// through the risers (feather extends 30 % of the band half-width past each
|
/// through the risers (feather extends 30 % of the band half-width past each
|
||||||
/// shelf edge). Covers both shelves.
|
/// shelf edge). Covers both shelves. <paramref name="edgeShift"/> is the same
|
||||||
|
/// per-column warp the curve is evaluated with, so the micro-relief skin
|
||||||
|
/// follows the shelf wherever pass B has moved its boundary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static float ShelfWeight(float raw, CurveKnots k)
|
public static float ShelfWeight(float raw, CurveKnots k, float edgeShift)
|
||||||
{
|
{
|
||||||
return Mathf.Max(BandBump(raw, k.K3, k.K4), BandBump(raw, k.K5, k.K6));
|
return Mathf.Max(BandBump(raw, k.K3 + edgeShift, k.K4 + edgeShift),
|
||||||
|
BandBump(raw, k.K5 + edgeShift, k.K6));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float BandBump(float h, float lo, float hi)
|
private static float BandBump(float h, float lo, float hi)
|
||||||
|
|
@ -55,89 +109,4 @@ public static class TerrainDetailPass
|
||||||
// full inside 60 % of the band, linear feather to zero at 130 %
|
// full inside 60 % of the band, linear feather to zero at 130 %
|
||||||
return Mathf.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f);
|
return Mathf.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Incision mask from the RAW input height: 0 below the red-ceiling input (K2)
|
|
||||||
/// and above the plateau top (K6); 1 on the riser bands; SHELF_INC_WEIGHT on the
|
|
||||||
/// shelf bands; smooth feathers (15 % of the local band width) at every boundary.
|
|
||||||
/// </summary>
|
|
||||||
public static float IncisionWeight(float raw, CurveKnots k)
|
|
||||||
{
|
|
||||||
if (raw <= k.K2 || raw >= k.K6) return 0f;
|
|
||||||
if (raw < k.K3) // foothill riser: feather in from K2, feather toward shelf weight at K3
|
|
||||||
return EdgeBlend(raw, k.K2, k.K3, 0f, 1f, SHELF_INC_WEIGHT);
|
|
||||||
if (raw < k.K4) // bench
|
|
||||||
return SHELF_INC_WEIGHT;
|
|
||||||
if (raw < k.K5) // mid riser
|
|
||||||
return EdgeBlend(raw, k.K4, k.K5, SHELF_INC_WEIGHT, 1f, SHELF_INC_WEIGHT);
|
|
||||||
// plateau band: shelf weight, feathering to zero at K6
|
|
||||||
float w = (k.K6 - raw) / ((k.K6 - k.K5) * 0.15f);
|
|
||||||
return Mathf.Min(SHELF_INC_WEIGHT, Mathf.Clamp(w, 0f, 1f) * SHELF_INC_WEIGHT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static float EdgeBlend(float h, float lo, float hi, float wIn, float wMid, float wOut)
|
|
||||||
{
|
|
||||||
float f = (hi - lo) * 0.15f;
|
|
||||||
if (h < lo + f) return Mathf.Lerp(wIn, wMid, (h - lo) / f);
|
|
||||||
if (h > hi - f) return Mathf.Lerp(wMid, wOut, (h - (hi - f)) / f);
|
|
||||||
return wMid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// D8 flow accumulation over a height field (row-major idx = x·n + y).
|
|
||||||
/// Steepest-descent routing (drop / distance, diagonals ÷√2), deterministic
|
|
||||||
/// tie-break (fixed neighbour order, first winner). Cells with no lower
|
|
||||||
/// neighbour are pits/outlets (no outflow). accum = upslope contributing cells
|
|
||||||
/// including self; steepestDrop = drop per pixel toward the chosen neighbour.
|
|
||||||
/// </summary>
|
|
||||||
public static int[] FlowAccumulation(float[] h, int n, out float[] steepestDrop)
|
|
||||||
{
|
|
||||||
int total = n * n;
|
|
||||||
int[] downstream = new int[total];
|
|
||||||
steepestDrop = new float[total];
|
|
||||||
int[] dx = { 1, -1, 0, 0, 1, 1, -1, -1 };
|
|
||||||
int[] dy = { 0, 0, 1, -1, 1, -1, 1, -1 };
|
|
||||||
float[] invDist = { 1f, 1f, 1f, 1f, 0.7071068f, 0.7071068f, 0.7071068f, 0.7071068f };
|
|
||||||
|
|
||||||
for (int x = 0; x < n; x++)
|
|
||||||
{
|
|
||||||
for (int y = 0; y < n; y++)
|
|
||||||
{
|
|
||||||
int i = x * n + y;
|
|
||||||
float hc = h[i];
|
|
||||||
float best = 0f;
|
|
||||||
int bestIdx = -1;
|
|
||||||
for (int d = 0; d < 8; d++)
|
|
||||||
{
|
|
||||||
int nx = x + dx[d], ny = y + dy[d];
|
|
||||||
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
|
||||||
int ni = nx * n + ny;
|
|
||||||
float grade = (hc - h[ni]) * invDist[d];
|
|
||||||
if (grade > best)
|
|
||||||
{
|
|
||||||
best = grade;
|
|
||||||
bestIdx = ni;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downstream[i] = bestIdx;
|
|
||||||
steepestDrop[i] = best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Height-descending order: each cell pushes its accumulated count downstream.
|
|
||||||
float[] keys = (float[])h.Clone();
|
|
||||||
int[] order = new int[total];
|
|
||||||
for (int i = 0; i < total; i++) order[i] = i;
|
|
||||||
Array.Sort(keys, order); // ascending
|
|
||||||
|
|
||||||
int[] accum = new int[total];
|
|
||||||
for (int i = 0; i < total; i++) accum[i] = 1;
|
|
||||||
for (int i = total - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
int c = order[i];
|
|
||||||
int d = downstream[c];
|
|
||||||
if (d >= 0) accum[d] += accum[c];
|
|
||||||
}
|
|
||||||
return accum;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
Tools/Scripts/TerrainDetailPass.cs.uid
Normal file
1
Tools/Scripts/TerrainDetailPass.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bgvlgxhxajciv
|
||||||
Loading…
Reference in a new issue