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:
parent
3b6d166458
commit
492b56a87c
7 changed files with 182 additions and 69 deletions
|
|
@ -158,6 +158,9 @@ namespace IslaApocalypse.Core
|
||||||
writer.Write(d.Version); // u16
|
writer.Write(d.Version); // u16
|
||||||
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32
|
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands); // 2 × f32
|
||||||
writer.Write(d.ReliefSeedOffset); // i32
|
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)
|
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,16 @@ 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 (requires the
|
// Terrain detail passes (task 10): "v1" = shelf micro-relief + shelf-edge
|
||||||
// curve; no-op when it is off), "off" disables it. ShelfReliefAmp is the
|
// variation as one judged unit (requires the curve; no-op when it is off);
|
||||||
// micro-relief amplitude in metres. Defaults: v1, 3 m.
|
// "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 string TerrainDetail = "v1";
|
||||||
public static float ShelfReliefAmp = 3.0f;
|
public static float ShelfReliefAmp = 3.0f;
|
||||||
|
public static float ShelfEdgeVariation = 5.0f;
|
||||||
|
|
||||||
public static void LoadConfig()
|
public static void LoadConfig()
|
||||||
{
|
{
|
||||||
|
|
@ -137,6 +142,10 @@ 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"];
|
||||||
|
}
|
||||||
|
|
||||||
switch (profile)
|
switch (profile)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -89,16 +89,19 @@ 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): the shelf micro-relief parameters. Null when detail
|
/// terrain-water task 10): shelf micro-relief + shelf-edge variation parameters.
|
||||||
/// was off. Metadata only — heights are already detailed. Body version 1 (the
|
/// Null when detail was off. Metadata only — heights are already detailed.
|
||||||
/// reverted relief + D8-incision layout) is longer and differently shaped; the
|
/// Version 1 (relief + the reverted D8 incision) never left the task-10 batch
|
||||||
/// parser refuses it rather than misreading it.
|
/// 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 int ReliefSeedOffset;
|
public int ReliefSeedOffset;
|
||||||
|
public float EdgeAmpM, EdgeFreqIslands;
|
||||||
|
public int EdgeSeedOffset;
|
||||||
|
public float EdgeMaxShiftM;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WorldBlueprint
|
public class WorldBlueprint
|
||||||
|
|
@ -454,14 +457,19 @@ namespace IslaApocalypse.Core
|
||||||
d.Version = reader.ReadUInt16();
|
d.Version = reader.ReadUInt16();
|
||||||
if (d.Version != BlueprintFormat.TDTL_VERSION)
|
if (d.Version != BlueprintFormat.TDTL_VERSION)
|
||||||
{
|
{
|
||||||
// Skip the body and leave TerrainDetail null. The heights are still
|
// A TDTL v1 payload (the reverted relief+incision layout) is LONGER and
|
||||||
// whatever they are — we simply refuse to describe them wrongly.
|
// laid out differently; reading it as v2 would silently mint plausible
|
||||||
GD.PrintErr($"[MapDataParser] ⚠ TDTL body version {d.Version} is not the current {BlueprintFormat.TDTL_VERSION} — section skipped, detail metadata unavailable.");
|
// 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);
|
reader.BaseStream.Seek((long)payloadLength - 2L, SeekOrigin.Current);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle();
|
d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,33 +163,45 @@ 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)
|
||||||
{
|
{
|
||||||
foreach (float pl in plateauLos)
|
foreach (float pl in plateauLos)
|
||||||
{
|
{
|
||||||
foreach (float sp in spans)
|
foreach (float sp in spans)
|
||||||
|
{
|
||||||
|
foreach (float es in edgeShifts)
|
||||||
{
|
{
|
||||||
float prevH = -7f;
|
float prevH = -7f;
|
||||||
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k);
|
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k, es);
|
||||||
|
|
||||||
void Check(double hd)
|
void Check(double hd)
|
||||||
{
|
{
|
||||||
float h = (float)hd;
|
float h = (float)hd;
|
||||||
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
|
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
|
||||||
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k);
|
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k, es);
|
||||||
if (v <= prev)
|
if (v <= prev)
|
||||||
throw new System.InvalidOperationException(
|
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.");
|
$"[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;
|
prev = v;
|
||||||
prevH = h;
|
prevH = h;
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +213,7 @@ public static class HeightCurve
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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}).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,16 @@ 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 pass (shelf micro-relief): gated by TerrainDetail, active only
|
// Task-10 detail passes (shelf micro-relief + shelf-edge variation): gated by
|
||||||
// with the curve on (the mask is curve-band defined). The relief noise seeds
|
// TerrainDetail, active only with the curve on (both are defined in terms of
|
||||||
// from resolvedSeed + 7409.
|
// 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;
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -119,7 +124,23 @@ 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.");
|
||||||
|
|
||||||
|
|
@ -314,7 +335,11 @@ public partial class MapGenerator : TextureRect
|
||||||
Version = TerrainDetailPass.VERSION,
|
Version = TerrainDetailPass.VERSION,
|
||||||
ReliefAmpM = ConfigManager.ShelfReliefAmp,
|
ReliefAmpM = ConfigManager.ShelfReliefAmp,
|
||||||
ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS,
|
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,
|
} : null,
|
||||||
FormatVersion = 2,
|
FormatVersion = 2,
|
||||||
Params = new BlueprintParams
|
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
|
// 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) + crater carve ---
|
||||||
// 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.
|
||||||
// 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 * 0.80f;
|
float physicalCraterRadius = _impactRadius * 0.80f;
|
||||||
for (int x = 0; x < MapSize; x++)
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
|
@ -492,16 +521,19 @@ public partial class MapGenerator : TextureRect
|
||||||
float curvedH;
|
float curvedH;
|
||||||
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);
|
|
||||||
|
float edgeShift = _detailOn ? _edgeNoise.GetNoise2D(x, y) * _edgeAmpRaw : 0f;
|
||||||
|
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots, edgeShift);
|
||||||
|
|
||||||
if (_detailOn)
|
if (_detailOn)
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -512,10 +544,8 @@ public partial class MapGenerator : TextureRect
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
|
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
|
||||||
// Both maps are carved from LOCALS and written once each, so the
|
// Both maps are carved from LOCALS and written once, so the curve-off
|
||||||
// curve-off aliasing (classify IS the height array) cannot double-carve
|
// aliasing (classify and height are the same 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).
|
|
||||||
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||||
|
|
||||||
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
||||||
|
|
|
||||||
|
|
@ -229,8 +229,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.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 };
|
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]))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,19 @@ using Godot;
|
||||||
/// 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.
|
||||||
///
|
///
|
||||||
/// 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
|
/// Both are output-height only; the classify map never sees either of them.
|
||||||
/// steepest-descent flow routing, accumulation and drainage incision. It shipped,
|
///
|
||||||
/// and it produced the canonical grid artifact — thousands of straight,
|
/// NOTE — what this file deliberately does NOT contain: the task-10 draft's D8
|
||||||
/// disconnected, pooling scratches running along the eight D8 neighbour
|
/// flow routing / accumulation / drainage incision. It shipped, produced the
|
||||||
/// directions, because per-cell steepest descent on a regular grid can only ever
|
/// canonical grid artifact (thousands of straight, disconnected, pooling
|
||||||
/// route along those eight headings. It is reverted whole. Rivers and erosion are
|
/// scratches along the D8 neighbour directions) and was reverted whole. Rivers
|
||||||
/// Phase C work: a hydraulic-erosion pass over FINAL terrain, not a routing carve
|
/// and erosion are Phase C work — a hydraulic-erosion pass over FINAL terrain,
|
||||||
/// on a grid mid-pipeline.
|
/// not a per-cell steepest-descent carve on a grid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TerrainDetailPass
|
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 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 — 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 < K3+d, K5+d < 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>
|
/// <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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue