feat: shelf-edge variation -- the red-line scalloping (terrain-water task 10)

PASS B, replacing the reverted incision. The developer's sketch asked for
the shelf/riser BOUNDARY to be organic, not for water: notches, coves and
small peninsulas where a flat shelf meets its riser, instead of the clean
oval contour the curve produces.

Mechanism (the task's preferred "boundary warp", in its most
monotonic-safe form): every shelf/riser boundary is the contour where the
raw height crosses K3, K4 or K5, so the boundary is warped by SLIDING
THOSE THREE KNOTS per column -- edgeShift = simplex(resolvedSeed + 7507,
12 per island width) x ShelfEdgeVariation. The contours then wander in and
out of the terrain instead of tracing an iso-height line. Noise-warped by
construction, so there is no grid direction for an artifact to line up on
-- the failure mode of the thing this replaces.

Why slide knots rather than perturb a weight or the input height:
monotonicity becomes structural instead of conditional. The curve is
strictly monotonic for ANY ordered knot set, so no derivative bound, no
amplitude-vs-feather-width tuning, no way for a dial to invert a column.
MaxEdgeShift keeps the set ordered (half the smallest margin to a fixed
knot = 12.3 m of input height for the v5 knots); the config dial is
clamped to it, loudly. AssertMonotonic now sweeps 24 corners -- the 8
modulation extremes x {-max, 0, +max} shift -- and checks the bound first.

K1, K2 and K6 never move, which buys the guarantees exactly rather than
statistically: below K2 and above K6 a warped column is bit-identical to
an unwarped one, so the red ceiling still floors every shelf edge (storm
ladder safe), the 420 m cap still caps, and the toe and summit spikes are
untouched. The block slides rigidly, so the bench and mid-riser keep their
exact widths -- shelf interiors stay flat, riser interiors keep their
profile, and only the foothill riser and plateau stretch to absorb it.
The micro-relief mask takes the same shift, so pass A's skin follows the
shelf wherever pass B moved its edge.

Dial ShelfEdgeVariation (default 5 m of INPUT height -- a boundary
displacement, not an elevation change) under the existing TerrainDetail
gate; both passes stay one judged unit. TDTL v2 body records relief and
edge amp/frequency/seed-offset plus the applied clamp bound; writer,
parser and harness follow. No blueprint was written between the revert
and this commit, so v2 only ever means relief + edge warp on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-08 20:31:41 -04:00
parent 3b6d166458
commit 492b56a87c
7 changed files with 182 additions and 69 deletions

View file

@ -158,6 +158,9 @@ namespace IslaApocalypse.Core
writer.Write(d.Version); // u16
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32
writer.Write(d.ReliefSeedOffset); // i32
writer.Write(d.EdgeAmpM); writer.Write(d.EdgeFreqIslands); // 2 × f32
writer.Write(d.EdgeSeedOffset); // i32
writer.Write(d.EdgeMaxShiftM); // f32
}
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)

View file

@ -33,11 +33,16 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
// are retired. Default: v5.
public static string TerrainCurve = "v5";
// Terrain detail passes (task 10): "v1" = shelf micro-relief (requires the
// curve; no-op when it is off), "off" disables it. ShelfReliefAmp is the
// micro-relief amplitude in metres. Defaults: v1, 3 m.
// Terrain detail passes (task 10): "v1" = shelf micro-relief + shelf-edge
// variation as one judged unit (requires the curve; no-op when it is off);
// "off" disables both. ShelfReliefAmp is the micro-relief amplitude in metres
// 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 that keeps the curve's knots ordered. Defaults: v1, 3 m, 5 m.
public static string TerrainDetail = "v1";
public static float ShelfReliefAmp = 3.0f;
public static float ShelfEdgeVariation = 5.0f;
public static void LoadConfig()
{
@ -137,6 +142,10 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
{
ShelfReliefAmp = (float)data["ShelfReliefAmp"];
}
if (data.ContainsKey("ShelfEdgeVariation"))
{
ShelfEdgeVariation = (float)data["ShelfEdgeVariation"];
}
switch (profile)
{

View file

@ -89,16 +89,19 @@ namespace IslaApocalypse.Core
/// <summary>
/// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section,
/// terrain-water task 10): the shelf micro-relief parameters. Null when detail
/// was off. Metadata only — heights are already detailed. Body version 1 (the
/// reverted relief + D8-incision layout) is longer and differently shaped; the
/// parser refuses it rather than misreading it.
/// terrain-water task 10): shelf micro-relief + shelf-edge variation parameters.
/// 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>
public class TerrainDetailInfo
{
public ushort Version;
public float ReliefAmpM, ReliefFreqIslands;
public int ReliefSeedOffset;
public float EdgeAmpM, EdgeFreqIslands;
public int EdgeSeedOffset;
public float EdgeMaxShiftM;
}
public class WorldBlueprint
@ -454,14 +457,19 @@ namespace IslaApocalypse.Core
d.Version = reader.ReadUInt16();
if (d.Version != BlueprintFormat.TDTL_VERSION)
{
// Skip the body and leave TerrainDetail null. The heights are still
// whatever they are — we simply refuse to describe them wrongly.
GD.PrintErr($"[MapDataParser] ⚠ TDTL body version {d.Version} is not the current {BlueprintFormat.TDTL_VERSION} — section skipped, detail metadata unavailable.");
// 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.ReliefSeedOffset = reader.ReadInt32();
d.EdgeAmpM = reader.ReadSingle(); d.EdgeFreqIslands = reader.ReadSingle();
d.EdgeSeedOffset = reader.ReadInt32();
d.EdgeMaxShiftM = reader.ReadSingle();
blueprint.TerrainDetail = d;
return true;
}

View file

@ -42,6 +42,11 @@ public sealed class CurveKnots
/// 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,
/// 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>
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));
}
/// <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,
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;
float k3 = k.K3 + edgeShift, k4 = k.K4 + edgeShift, k5 = k.K5 + edgeShift;
float u, s;
if (h < k.K1)
{
@ -104,27 +127,27 @@ public static class HeightCurve
u = (h - k.K1) / (k.K2 - k.K1);
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
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)
}
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
return benchTop + s * (plateauLo - benchTop);
}
if (h < k.K6)
{
u = (h - k.K5) / (k.K6 - k.K5);
u = (h - k5) / (k.K6 - k5);
return plateauLo + u * plateauSpan; // plateau
}
float plateauTop = plateauLo + plateauSpan;
@ -140,15 +163,25 @@ public static class HeightCurve
/// <summary>
/// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for
/// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax.
/// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the
/// sweep proves they stay strictly positive everywhere. Loud throw on failure.
/// the selected preset: all 8 modulation-extreme corners × the shelf-edge warp
/// extremes (±maxEdgeShift and 0) × per-seed spikeMax — 24 corners. The corner
/// 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>
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[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP };
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)
{
@ -156,28 +189,31 @@ public static class HeightCurve
{
foreach (float sp in spans)
{
float prevH = -7f;
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k);
void Check(double hd)
foreach (float es in edgeShifts)
{
float h = (float)hd;
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
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;
}
float prevH = -7f;
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, es);
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);
void Check(double hd)
{
float h = (float)hd;
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}).");
}
}

View file

@ -49,11 +49,16 @@ public partial class MapGenerator : TextureRect
// v5: the selected knot preset; null = curve off.
private CurveKnots _curveKnots;
// Task-10 detail pass (shelf micro-relief): gated by TerrainDetail, active only
// with the curve on (the mask is curve-band defined). The relief noise seeds
// from resolvedSeed + 7409.
// 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 FastNoiseLite _reliefNoise;
private FastNoiseLite _edgeNoise;
private float _edgeAmpRaw;
private float _maxEdgeShiftRaw;
// 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
@ -119,7 +124,23 @@ public partial class MapGenerator : TextureRect
}
_detailOn = _curveOn && ConfigManager.TerrainDetail == "v1";
if (_detailOn)
{
_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)
GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off.");
@ -314,7 +335,11 @@ public partial class MapGenerator : TextureRect
Version = TerrainDetailPass.VERSION,
ReliefAmpM = ConfigManager.ShelfReliefAmp,
ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS,
ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET
ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET,
EdgeAmpM = _edgeAmpRaw * 251f, // as APPLIED (post-clamp), not as configured
EdgeFreqIslands = TerrainDetailPass.EDGE_FREQ_ISLANDS,
EdgeSeedOffset = TerrainDetailPass.EDGE_SEED_OFFSET,
EdgeMaxShiftM = _maxEdgeShiftRaw * 251f
} : null,
FormatVersion = 2,
Params = new BlueprintParams
@ -471,16 +496,20 @@ public partial class MapGenerator : TextureRect
// 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
// 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) + crater carve ---
// Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so
// the carve cuts into curved terrain and the rim/bowl shape is untouched by
// the curve. Identity at and below sea + this ordering preserve the
// Trench/ocean-border guarantee and the crater by construction. classifyH
// stays uncurved — see _heightMapClassify; hMaxSeed never touches it.
// 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 physicalCraterRadius = _impactRadius * 0.80f;
for (int x = 0; x < MapSize; x++)
@ -492,16 +521,19 @@ public partial class MapGenerator : TextureRect
float curvedH;
if (_curveOn)
{
// per-column shelf modulation (v4) — anchors and strength from the
// low-frequency fields; ordering safety by construction.
// v4: per-column shelf modulation — anchors and strength from the
// 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 plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP;
float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f);
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots);
float edgeShift = _detailOn ? _edgeNoise.GetNoise2D(x, y) * _edgeAmpRaw : 0f;
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots, edgeShift);
if (_detailOn)
{
float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots);
float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots, edgeShift);
if (wShelf > 0f)
curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf;
}
@ -512,10 +544,8 @@ public partial class MapGenerator : TextureRect
}
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
// Both maps are carved from LOCALS and written once each, so the
// curve-off aliasing (classify IS the height array) cannot double-carve
// — the failure mode that the separate carve loop introduced and that
// the task-10 continuity oracle caught (fix 1b98fb5, now structural).
// Both maps are carved from LOCALS and written once, so the curve-off
// aliasing (classify and height are the same array) cannot double-carve.
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!

View file

@ -229,8 +229,8 @@ public partial class RoundTripHarness : Node
return false;
}
var da = a.TerrainDetail; var db = b.TerrainDetail;
float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.ReliefSeedOffset };
float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.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.ReliefSeedOffset, db.EdgeAmpM, db.EdgeFreqIslands, db.EdgeSeedOffset, db.EdgeMaxShiftM };
for (int i = 0; i < fa.Length; i++)
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i]))
{

View file

@ -8,16 +8,19 @@ using Godot;
/// default 3 m) weighted by shelf-ness, so the compressed shelves get their
/// rolling texture back while risers and peaks stay untouched.
///
/// Output-height only; the classify map never sees it.
/// PASS B — shelf-edge variation: a per-column shift of the shelf/riser KNOT
/// BLOCK (K3/K4/K5) by a low-frequency noise field, so the boundary where a
/// shelf meets its riser wanders in and out instead of tracing a clean height
/// contour — organic notches, coves and peninsulas at the shelf edge.
///
/// NOTE — what this file deliberately no longer contains: the task-10 draft's D8
/// steepest-descent flow routing, accumulation and drainage incision. It shipped,
/// and it produced the canonical grid artifact — thousands of straight,
/// disconnected, pooling scratches running along the eight D8 neighbour
/// directions, because per-cell steepest descent on a regular grid can only ever
/// route along those eight headings. It is reverted whole. Rivers and erosion are
/// Phase C work: a hydraulic-erosion pass over FINAL terrain, not a routing carve
/// on a grid mid-pipeline.
/// Both are output-height only; the classify map never sees either of them.
///
/// 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>
public static class TerrainDetailPass
{
@ -29,14 +32,38 @@ public static class TerrainDetailPass
public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features)
public const int RELIEF_SEED_OFFSET = 7409;
// Pass B — shelf-edge variation. The amplitude is stated in metres of INPUT
// height (raw × 251): it is how far, in raw-height terms, a shelf boundary
// contour is displaced — not an output elevation change. Lateral wander on
// the map is that displacement divided by the local raw gradient.
public const float EDGE_AMP_DEFAULT_M = 5f; // config dial: ShelfEdgeVariation (metres of input height)
public const float EDGE_FREQ_ISLANDS = 12f; // ~12 undulations per island width — a handful of notches
public const int EDGE_SEED_OFFSET = 7507; // per shelf perimeter, not hundreds of teeth
public const float EDGE_SAFETY_FRACTION = 0.5f; // shift ≤ half the smallest margin to a fixed knot
/// <summary>
/// The largest per-column knot shift this preset can take while keeping the
/// knot set strictly ordered (K2 &lt; K3+d, K5+d &lt; K6) with margin 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 and peak-height guarantees 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>
/// 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
/// 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>
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)