islaApocalypse/Tools/Scripts/IslandFalloff.cs
beezm 6c318e4df0 feat: offshore islands (terrain-water task 11)
Sparse discoverable islets from a low-frequency ocean noise layer, seeded
resolved+7607 at 14 undulations/island (~585 px blobs).

Placed by LERPING the seabed toward a target crest (34 m raw above sea)
rather than adding to it, so an islet can surface at any ambient depth
instead of only where the seafloor happens to be shallow. After the curve's
toe compresses them they stand 5-8 m above the water: low, sandy, ringed by
beach -- which is what the existing biome rules make of that height, with no
biome-rule change.

THE THREE CONSTRAINTS, each by construction rather than by hope:

  Never merges with the mainland. The raise is EXACTLY zero wherever the
  ambient water is shallower than 14 m, so any continuous path from the
  mainland shore to an islet must cross that depth contour, and every pixel
  on it is untouched water. Measured: 0 of 13 islets merged, closest
  approach 78 px.

  Never touches the Trench. A distance mask zeroes the layer by 0.86 of
  half-map, and the ramp starts at 0.90. Measured max islet reach 0.795.
  All four corners stay below sea.

  Never spawns inland. Depth alone is not a test -- a deep lake and the
  carved crater bay are also below sea. The pre-Trench falloff is the honest
  discriminator (mainland coast sits near f = 0.66; islets need f > 0.72),
  and it is axis-invariant, because elongation moves WHERE a given f occurs,
  not the f at which land ends.

Two bugs found and fixed by measuring instead of trusting the first run:

  1. The density dial treated [-1,1] as the noise's actual range. Simplex
     is concentrated well inside it -- this field measures 0.050..0.958 --
     so "top 6%" resolved to a threshold almost nothing cleared: the first
     build raised 171 px on the entire map, none above sea. The threshold is
     now CALIBRATED against the field's own distribution (quantile over a
     1M-sample stride grid, deterministic from the seed), so the dial means
     what it says whatever FastNoiseLite returns.
  2. At 26/island the layer produced 77 islets under 200 px -- scatter, not
     "a handful". Retuned to 14/island at density 0.02: 13 islets of
     200-4792 px.

The pass prints its own islet area, so every run says what it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 23:30:15 -04:00

143 lines
7.1 KiB
C#

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;
}
}