islaApocalypse-v2/Tools/Scripts/HeightMapRenderer.cs
beezm 9af5c73b04 Phase 1: port the crown-jewel noise and pass-1, and render the island
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.
2026-08-19 21:27:20 -04:00

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