feat: submarine coast shelf + elongation dials (terrain-water task 11)

COAST. The task's premise -- "the island edge is the steepest part" -- is
right, and the diagnostic locates it precisely: it is the SEABED, not the
land. HeightCurve.Apply returns early at and below sea level, so tasks
05-09 reshaped the land and never touched the water. Measured on the same
seed, distance-from-shore vs height:

                       land reaches 10 m   seabed reaches 10 m   gradient
  curve OFF                   31 px               31 px       0.254/0.258
  curve ON (ships)           186 px               31 px       0.038/0.258

So the landward side is ALREADY the broad shallow shelf terrain_design.md
asks for -- 54% of the island sits under 14 m -- and flattening it further
would only make it mushy. What is left is a shoreline that shelves gently
on land and then drops 6.7x steeper the moment it goes under.

CoastShelf fixes that side: depth' = depth * (1 - 0.775*exp(-depth/100 m)).
The seabed leaves the waterline at 22.5% of its former gradient and
recovers smoothly, so deep water and the Trench keep their shape. Measured
after: 5 m depth at 43 px (was 15), 10 m at 75 (was 31), 30 m at 150 (was
94) -- a 2.4-2.9x wider shallows.

It is strictly positive for positive depth, so it CANNOT move the waterline
by one pixel. Biomes and water bodies come out bit-identical, which is why
the coast is separable from elongation in the batch rather than tangled
with it -- contrary to the task's expectation that all coast work moves
biomes.

ELONGATION. IslandAxisX/Y replace the 1.15/0.90 literals (the spine shares
AxisX, as it always shared the literal). Sized by replaying candidate
falloffs against real terrain rather than guessing -- the replay reproduces
the shipped extents exactly, bbox and land count.

That replay says the task's suggested 1.30/0.85 buys +2.0% aspect, because
THE ISLAND IS ALREADY TRENCH-CLAMPED IN X: it spans 90.5% of the map width,
and 1.15 -> 1.40 moves the west coast only 393 -> ~360 px. Aspect responds
almost entirely to AxisY, which costs land:

  1.30/0.85  +2.0% aspect  +2.9% land      1.30/0.80  +4.9%  -2.2%
  1.30/0.82  +3.7% aspect  -0.1% land      1.30/0.75 +11.5%  -7.3%

Default 1.30/0.78 -- a visible step (+~8% aspect, measured 1.137 -> 1.200)
at ~4% land. The developer's eye tunes it from the batch; the table above
is the price list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-08 23:29:49 -04:00
parent 896a428d47
commit 566abe9784
3 changed files with 105 additions and 9 deletions

View file

@ -44,6 +44,27 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
public static float ShelfReliefAmp = 3.0f; public static float ShelfReliefAmp = 3.0f;
public static float ShelfEdgeVariation = 12.0f; public static float ShelfEdgeVariation = 12.0f;
// 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 — measured, land rises from
// the shoreline at 0.038 m/px while the seabed drops at 0.258 m/px. "steep" is
// the pre-task-11 seabed, kept for A/B. The shelf cannot move the waterline, so
// biomes and water are bit-identical either way. Default: wide.
//
// IslandAxisX/Y: the falloff axis ratios (pre-task-11: 1.15 / 0.90). NOTE,
// measured: 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. These MOVE THE COASTLINE, so they move biomes by
// design; elongated seeds have a new biome baseline.
public static string CoastProfile = "wide";
public static float IslandAxisX = 1.30f;
public static float IslandAxisY = 0.78f;
// The pre-task-11 axis ratios, so legacy/steep runs can restore them exactly.
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";
@ -147,6 +168,27 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
ShelfEdgeVariation = (float)data["ShelfEdgeVariation"]; ShelfEdgeVariation = (float)data["ShelfEdgeVariation"];
} }
// 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"];
// 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;
}
switch (profile) switch (profile)
{ {
case "4K": case "4K":

View file

@ -9,14 +9,26 @@ using Godot;
/// terrain, that crease is the single largest slope step anywhere on the map once /// 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 /// the by-design Trench walls are excluded (top 4 of 5999 columns). SmoothAbs
/// rounds the crest without moving its height. /// 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.
///
/// 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> /// </summary>
public static class IslandFalloff public static class IslandFalloff
{ {
// ---- centre-line crest ------------------------------------------------ // ---- centre-line crest ------------------------------------------------
// Rounding radius in normalized spine-width units (1.0 = MapSize/2 * 1.15 // Rounding radius in normalized spine-width units (1.0 = MapSize/2 * IslandAxisX
// ~ 4710 px at 8K). 0.03 ~ 141 px: it cuts the crest's 1-px kink by 99.3% // ~ 4710 px at 8K with the default axis). 0.03 ~ 141 px: it cuts the crest's
// (-7.64e-04 -> -5.41e-06 raw, against a natural profile curvature of ~1.1e-07) // 1-px kink by 99.3% (-7.64e-04 -> -5.41e-06 raw) while filling at most 3.9 m,
// while filling at most 3.9 m, decaying under 0.5 m by ~1100 px from the axis. // decaying under 0.5 m by ~1100 px from the axis.
public const float CREST_EPSILON = 0.03f; public const float CREST_EPSILON = 0.03f;
/// <summary> /// <summary>
@ -30,4 +42,20 @@ public static class IslandFalloff
float a = Mathf.Abs(d); float a = Mathf.Abs(d);
return a * a / Mathf.Sqrt(a * a + epsilon * epsilon); 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));
}
} }

View file

@ -60,6 +60,11 @@ public partial class MapGenerator : TextureRect
private float _edgeAmpRaw; private float _edgeAmpRaw;
private float _maxEdgeShiftRaw; private float _maxEdgeShiftRaw;
// Task-11 island-falloff shaping: the submarine coast shelf. Acts 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;
// 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
// GenerateTopography pass 1; recorded in TCRV (effective, guard applied). // GenerateTopography pass 1; recorded in TCRV (effective, guard applied).
@ -144,6 +149,10 @@ public partial class MapGenerator : TextureRect
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";
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)." : "."));
// 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);
@ -432,6 +441,8 @@ 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;
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++)
@ -443,11 +454,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);
@ -480,7 +493,7 @@ public partial class MapGenerator : TextureRect
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 - IslandFalloff.SmoothAbs(distanceToCenterX, IslandFalloff.CREST_EPSILON); 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;
@ -492,6 +505,19 @@ 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;
finalH = seaHere - IslandFalloff.CoastShelf(depthM) / 251f;
}
if (finalH > _hMaxSeed) _hMaxSeed = finalH; if (finalH > _hMaxSeed) _hMaxSeed = finalH;
_heightMap[x, y] = finalH; _heightMap[x, y] = finalH;
} }