Ports MapGenerator.GenerateTopography's pass-1 loop (reference ~:553-619 at tag
pre-rewrite-reference / ab78883) into Tools/. Working code and its tuned constants
carried over verbatim, read from source rather than re-derived from a design
summary (D-050). Six elements, in the reference's execution order — the order is
load-bearing: the sinker lands before the Pow (superlinear), the Trench after it
(a raw additive wall), preTrenchFalloff captured between them.
The fractal config is now PINNED. The reference set only NoiseType/Seed/Frequency,
so the island's entire fractal character was Godot 4.7.1 constructor defaults that
nothing recorded. Measured on 4.7.2 (NoiseDefaultsProbe): Fbm / 5 / 0.5 / 2.0 / 0.0
— identical to the 4.7.1 values, so pinning reproduces the look with no delta. The
terrain no longer depends on an engine default, and the probe makes a future drift
visible instead of silent.
Two deliberate departures from the reference's literals, both flagged in-code:
- the latitude noise offset is expressed in map widths, not the reference's raw
+1000 px, so a resize no longer samples a different slice of the noise field
(chat1/00 §4.2). Pinned to 1000/8192 so the 8K realization is unchanged.
- the `if (temperature < 0.65f)` spine gate is dropped as the provable no-op it
is — southernFade already reaches exactly 0 at 0.65. Bit-identical.
The reference's "temperature" is ported as a latitude field and deliberately NOT
named temperature: it is stage-1-local input to terrain geometry, and climate is
stage 3 (D-049 §2, D-056). The ±0.1 wobble is kept — it is what stops the spine's
southern terminus being a ruler-straight line.
Deferred to Phase 2 with the seam open: coast shelf and offshore islets (below-sea,
judged once water renders). Pass1Result exposes PreTrenchFalloff at their exact
consumption point, plus HMaxSeed for the seed-dependent curve.
Render is a direct Godot.Image, not the SubViewport capture path — that machinery
exists to composite vector overlays and is why --headless hangs; Phase 1 has none.
Hypsometric above 0.15, bathymetric below, fixed ramp anchors so seeds and ablation
rungs are comparable. No biome words anywhere.
Verified: land fraction 51.5% identical at 1024, 2048 and 10240 — the scaling
discipline holds across a 10x resize. Full-size 10240 pass: 36.3 s.
No curve, erosion, rivers, water, crater, biomes, roads, or mesher.
148 lines
6 KiB
C#
148 lines
6 KiB
C#
using Godot;
|
|
|
|
namespace IslaApocalypse.Tools
|
|
{
|
|
/// <summary>
|
|
/// Renders a raw pass-1 height field as a HYPSOMETRIC height-gradient PNG.
|
|
///
|
|
/// ═══ ⚠ WHY THIS WRITES A Godot.Image DIRECTLY, AND NOT VIA THE CAPTURE PATH ═══
|
|
///
|
|
/// The reference captured its maps through a SubViewport + a TextureRect whose `_Draw` painted
|
|
/// overlays — roads, town markers, river polylines — and then read the viewport texture back.
|
|
/// That machinery exists to COMPOSITE VECTOR OVERLAYS onto a raster. It also carries a real
|
|
/// cost: it awaits render frames, which is why unattended runs need `xvfb-run` and why
|
|
/// `--headless` HANGS on it.
|
|
///
|
|
/// Phase 1 has no overlays. A heightmap is a raster and nothing else, so it is written
|
|
/// per-pixel into an Image and saved. No viewport, no frame awaits, no display needed.
|
|
///
|
|
/// ⚠ DO NOT REINTRODUCE THE CAPTURE PATH BY HABIT. It becomes correct the moment something
|
|
/// vector needs compositing on top (roads, towns, river centrelines) — and not before.
|
|
///
|
|
/// ═══ THE COLOUR SCHEME ═══
|
|
///
|
|
/// Two ramps meeting at the sea threshold, Hispaniola-style:
|
|
/// below — bathymetric: deep navy → shallow cyan-ish blue
|
|
/// above — hypsometric: shore green → lowland green → tan → brown → grey → white peak
|
|
///
|
|
/// ⚠⚠ THERE ARE NO BIOME WORDS HERE AND THERE MUST NOT BE. Nothing in this file knows about
|
|
/// jungle, wasteland, snow or desert. It reads a single float per column and picks a colour on
|
|
/// an elevation ramp. Biomes are a stage-5 CLASSIFICATION of finished shape (D-049 §2, D-056),
|
|
/// and the whole point of the rewrite's seam is that terrain never learns their names.
|
|
/// "White at the top" is snow-COLOURED cartography, not a snow biome.
|
|
///
|
|
/// ⚠ The sea threshold is a VISUALIZATION boundary only. No water is modelled: no water bodies,
|
|
/// no ocean/lake distinction, no flooding. Phase 2 owns all of that.
|
|
/// </summary>
|
|
public static class HeightMapRenderer
|
|
{
|
|
/// <summary>
|
|
/// FIXED ramp anchors in raw height units, deliberately NOT per-run normalized.
|
|
///
|
|
/// Per-run normalization would make every PNG use its full colour range, which looks better
|
|
/// and lies: two seeds, or two rungs of the ablation ladder, would be coloured by different
|
|
/// scales and could not be compared by eye. Fixed anchors mean the same colour is the same
|
|
/// height in every image in a batch. Out-of-range values clamp, and the run prints the true
|
|
/// min/max so saturation is visible as a number rather than guessed from the picture.
|
|
/// </summary>
|
|
public const float RampTopHeight = 1.45f;
|
|
|
|
/// <summary>The deepest height the bathymetric ramp resolves. Below this, flat abyss colour.</summary>
|
|
public const float RampBottomHeight = -1.00f;
|
|
|
|
// ---- the land ramp: (t in [0,1] above sea) -> colour ----
|
|
private static readonly (float t, Color c)[] LandRamp =
|
|
{
|
|
(0.00f, new Color(0.36f, 0.55f, 0.35f)), // shore green
|
|
(0.12f, new Color(0.47f, 0.62f, 0.36f)), // lowland
|
|
(0.30f, new Color(0.72f, 0.71f, 0.44f)), // dry tan
|
|
(0.50f, new Color(0.72f, 0.57f, 0.36f)), // brown
|
|
(0.70f, new Color(0.60f, 0.45f, 0.34f)), // dark brown
|
|
(0.86f, new Color(0.66f, 0.64f, 0.64f)), // grey rock
|
|
(1.00f, new Color(0.98f, 0.98f, 0.98f)), // white peak
|
|
};
|
|
|
|
// ---- the sea ramp: (t in [0,1], 0 = deepest) -> colour ----
|
|
private static readonly (float t, Color c)[] SeaRamp =
|
|
{
|
|
(0.00f, new Color(0.03f, 0.06f, 0.18f)), // abyss
|
|
(0.45f, new Color(0.06f, 0.16f, 0.36f)), // deep
|
|
(0.75f, new Color(0.11f, 0.30f, 0.52f)), // mid
|
|
(0.92f, new Color(0.20f, 0.47f, 0.66f)), // shelf
|
|
(1.00f, new Color(0.42f, 0.68f, 0.79f)), // shallow, at the waterline
|
|
};
|
|
|
|
/// <summary>Render and save. Returns the absolute path written.</summary>
|
|
public static string SavePng(Pass1Result result, float seaLevel, string absolutePath)
|
|
{
|
|
int n = result.MapSize;
|
|
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
|
|
|
for (int x = 0; x < n; x++)
|
|
{
|
|
for (int y = 0; y < n; y++)
|
|
{
|
|
float h = result.Height[x, y];
|
|
Color c;
|
|
|
|
if (h >= seaLevel)
|
|
{
|
|
float t = Mathf.Clamp((h - seaLevel) / (RampTopHeight - seaLevel), 0f, 1f);
|
|
c = Sample(LandRamp, t);
|
|
}
|
|
else
|
|
{
|
|
float t = Mathf.Clamp((h - RampBottomHeight) / (seaLevel - RampBottomHeight), 0f, 1f);
|
|
c = Sample(SeaRamp, t);
|
|
}
|
|
|
|
// Image is row-major (x = column, y = row); the height field is [x, y] with y
|
|
// increasing SOUTHWARD, matching the reference's convention. So a larger y is
|
|
// further down the image, and "the southern sinker" sinks the bottom of the PNG.
|
|
img.SetPixel(x, y, c);
|
|
}
|
|
}
|
|
|
|
Error err = img.SavePng(absolutePath);
|
|
if (err != Error.Ok)
|
|
GD.PrintErr($"[HeightMapRenderer] SavePng failed ({err}) for {absolutePath}");
|
|
return absolutePath;
|
|
}
|
|
|
|
private static Color Sample((float t, Color c)[] ramp, float t)
|
|
{
|
|
for (int i = 1; i < ramp.Length; i++)
|
|
{
|
|
if (t <= ramp[i].t)
|
|
{
|
|
float span = ramp[i].t - ramp[i - 1].t;
|
|
float local = span <= 0f ? 0f : (t - ramp[i - 1].t) / span;
|
|
return ramp[i - 1].c.Lerp(ramp[i].c, local);
|
|
}
|
|
}
|
|
return ramp[ramp.Length - 1].c;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dump the raw float height field beside the PNG, little-endian float32, x-major.
|
|
///
|
|
/// Cheap, and it is what makes a future BYTE-LEVEL port-fidelity check possible — the kind
|
|
/// of automatic oracle that lets taste-iteration happen without correctness being judged by
|
|
/// eye. → `Design - Tooling - Iteration and Batching.md`, "build the oracle first".
|
|
/// </summary>
|
|
public static string SaveRaw(Pass1Result result, string absolutePath)
|
|
{
|
|
using var f = FileAccess.Open(absolutePath, FileAccess.ModeFlags.Write);
|
|
if (f == null)
|
|
{
|
|
GD.PrintErr($"[HeightMapRenderer] could not open {absolutePath}: {FileAccess.GetOpenError()}");
|
|
return absolutePath;
|
|
}
|
|
int n = result.MapSize;
|
|
for (int x = 0; x < n; x++)
|
|
for (int y = 0; y < n; y++)
|
|
f.StoreFloat(result.Height[x, y]);
|
|
return absolutePath;
|
|
}
|
|
}
|
|
}
|