diff --git a/Tools/README.md b/Tools/README.md
index 0e7bc4a..74b28a5 100644
--- a/Tools/README.md
+++ b/Tools/README.md
@@ -33,10 +33,56 @@ constants, carried over verbatim — not re-derived from a design summary** (→
| `Scripts/IslandFalloff.cs` | `SmoothAbs` + `CREST_EPSILON` (pass-1 half of the reference file) |
| `Scripts/Pass1Result.cs` | The height field **and the Phase-2 seams** |
| `Scripts/TerrainGenConfig.cs` | Config + the per-element ablation toggles |
-| `Scripts/HeightMapRenderer.cs` | Hypsometric PNG + raw `.f32` dump |
+| `Scripts/HeightField.cs` | Raw `.f32` save/load — **the generation/presentation seam** |
| `Scripts/TerrainGenTool.cs` | Batch entry point (ladder + seed batch + `INDEX.md`) |
| `Scenes/TerrainGenTool.tscn` | Run this |
+### The relief render — presentation (Phase 1, the look)
+
+**Presentation only** (→ `Design - Rendering - Roughness Is Presentation.md`): it changes how height
+is *shown*, never how it is *made*. It **cannot** change the terrain — it is handed a height field
+loaded from a `.f32` dump and has no way to produce one.
+
+| File | What it is |
+|---|---|
+| `Scripts/Hillshade.cs` | Horn 3×3 shaded relief; the `ZExaggeration` slope table |
+| `Scripts/ReliefPalette.cs` | The hypsometric palettes, stops placed on measured percentiles |
+| `Scripts/ReliefRenderer.cs` | ⭐ The tint + hillshade blend |
+| `Scripts/LookConfig.cs` | The look dials and the three named variants |
+| `Scripts/ReliefRenderTool.cs` | Taste-gate batch runner |
+| `Scenes/ReliefRenderTool.tscn` | Run this |
+
+```bash
+Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
+ --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ReliefRenderTool.tscn
+```
+
+`ISLA_LOOKS=atlas,relief,dusk` · `ISLA_SOURCE` (batch to read `.f32` from) · `ISLA_MAPSIZE` ·
+`ISLA_SEEDS` · `ISLA_BATCH` · `ISLA_DUMP_RAW=1`
+
+**Three looks, deliberately** — a subjective gate drowns in a wall of near-duplicates, and each pair
+isolates one question: `atlas` vs `relief` asks how strong the relief should be (same palette and
+light); `atlas` vs `dusk` asks about palette and sun angle.
+
+> ### ⚠ Vertical exaggeration is required, and it is a LOOK dial — not a physical claim.
+>
+> Height is in raw noise units over a 1 m grid, so the true per-pixel gradient is tiny: measured
+> median land slope is **0.22°**. Un-exaggerated, the whole island shades as a flat plane. The raw
+> units are **not metres** — the metres-per-unit conversion is Phase 2's, not this renderer's.
+
+**The blend, and why it is not a multiply.** Hillshade on flat ground is `sin(altitude)` ≈ 0.71, so a
+plain `tint × shade` darkens the entire map by 29% before any slope is involved and the hypsometric
+tints are never actually seen. So the shade is normalized by its flat-ground value first — flat
+terrain keeps its true tint, and only *slope* moves the colour — then shadows **multiply** while
+highlights **screen** toward white. That asymmetry is the difference between a colour ramp and a map
+you would frame.
+
+**⚠ Relief fades out with depth below sea.** Not only taste: the deep floor is dominated by the
+Trench, a synthetic additive wall whose gradient is ~1.5× the p99 *land* gradient. Shading it
+faithfully draws a bright rim around the whole map and lights up the abyss with base-noise mottle —
+relief on terrain nobody is meant to look at. The fade puts relief where the bathymetry is real (the
+near-shore shelf) and lets the abyss lie flat, which is the cartographic convention anyway.
+
```bash
Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
--path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/TerrainGenTool.tscn
diff --git a/Tools/Scenes/ReliefRenderTool.tscn b/Tools/Scenes/ReliefRenderTool.tscn
new file mode 100644
index 0000000..3adc84c
--- /dev/null
+++ b/Tools/Scenes/ReliefRenderTool.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3 uid="uid://bqp1x2v3isla3"]
+
+[ext_resource type="Script" path="res://Tools/Scripts/ReliefRenderTool.cs" id="1_rrt"]
+
+[node name="ReliefRenderTool" type="Node"]
+script = ExtResource("1_rrt")
diff --git a/Tools/Scripts/HeightField.cs b/Tools/Scripts/HeightField.cs
new file mode 100644
index 0000000..79641c3
--- /dev/null
+++ b/Tools/Scripts/HeightField.cs
@@ -0,0 +1,55 @@
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// Raw height-field I/O: little-endian float32, x-major (index = x * MapSize + y).
+ ///
+ /// ⭐ THIS FILE IS WHY PRESENTATION AND GENERATION STAY SEPARATE. A relief render reads a dumped
+ /// height field and re-colours it; it never re-runs the generator. So a look change provably
+ /// cannot move the terrain — not by convention, but because the colours are computed from a
+ /// file the renderer cannot write.
+ ///
+ /// It also makes a byte-level port-fidelity oracle possible later: two generators agree, or
+ /// their dumps differ. → `Design - Tooling - Iteration and Batching.md`, "build the oracle
+ /// before the taste-iteration".
+ ///
+ public static class HeightField
+ {
+ public static void Save(float[,] height, int mapSize, string absolutePath)
+ {
+ using var f = Godot.FileAccess.Open(absolutePath, Godot.FileAccess.ModeFlags.Write);
+ if (f == null)
+ {
+ GD.PrintErr($"[HeightField] cannot write {absolutePath}: {Godot.FileAccess.GetOpenError()}");
+ return;
+ }
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ f.StoreFloat(height[x, y]);
+ }
+
+ /// Load a dump. Returns null if absent or the wrong size for .
+ public static float[,] Load(string absolutePath, int mapSize)
+ {
+ if (!Godot.FileAccess.FileExists(absolutePath)) return null;
+
+ using var f = Godot.FileAccess.Open(absolutePath, Godot.FileAccess.ModeFlags.Read);
+ if (f == null) return null;
+
+ long expected = (long)mapSize * mapSize * 4;
+ if ((long)f.GetLength() != expected)
+ {
+ GD.PrintErr($"[HeightField] {absolutePath} is {f.GetLength()} bytes, expected {expected} " +
+ $"for MapSize {mapSize} — ignoring it rather than guessing.");
+ return null;
+ }
+
+ var h = new float[mapSize, mapSize];
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ h[x, y] = f.GetFloat();
+ return h;
+ }
+ }
+}
diff --git a/Tools/Scripts/HeightField.cs.uid b/Tools/Scripts/HeightField.cs.uid
new file mode 100644
index 0000000..7ad8777
--- /dev/null
+++ b/Tools/Scripts/HeightField.cs.uid
@@ -0,0 +1 @@
+uid://cd6vej0ey8wo8
diff --git a/Tools/Scripts/HeightMapRenderer.cs b/Tools/Scripts/HeightMapRenderer.cs
deleted file mode 100644
index d7c3d63..0000000
--- a/Tools/Scripts/HeightMapRenderer.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using Godot;
-
-namespace IslaApocalypse.Tools
-{
- ///
- /// 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.
- ///
- public static class HeightMapRenderer
- {
- ///
- /// 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.
- ///
- public const float RampTopHeight = 1.45f;
-
- /// The deepest height the bathymetric ramp resolves. Below this, flat abyss colour.
- 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
- };
-
- /// Render and save. Returns the absolute path written.
- 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;
- }
-
- ///
- /// 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".
- ///
- 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;
- }
- }
-}
diff --git a/Tools/Scripts/HeightMapRenderer.cs.uid b/Tools/Scripts/HeightMapRenderer.cs.uid
deleted file mode 100644
index 088495a..0000000
--- a/Tools/Scripts/HeightMapRenderer.cs.uid
+++ /dev/null
@@ -1 +0,0 @@
-uid://tufk8dg4g14g
diff --git a/Tools/Scripts/Hillshade.cs b/Tools/Scripts/Hillshade.cs
new file mode 100644
index 0000000..ea77948
--- /dev/null
+++ b/Tools/Scripts/Hillshade.cs
@@ -0,0 +1,110 @@
+using System;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// Classic cartographic SHADED RELIEF from a height field — the slope+aspect view.
+ ///
+ /// ⚠ PRESENTATION ONLY. → `Design - Rendering - Roughness Is Presentation.md`. Nothing here
+ /// touches how height is GENERATED; it changes only how height is SHOWN. A hillshade parameter
+ /// is a look dial, never a claim about the world.
+ ///
+ /// ═══ THE METHOD ═══
+ ///
+ /// HORN'S 3x3 method — the standard for DEM shading, and more robust than a plain central
+ /// difference because it weights the four edge-adjacent neighbours double, so single-pixel noise
+ /// does not dominate the normal. Cell spacing is 1: one column per pixel, 1 m per column
+ /// (D-002, 1:1 scale).
+ ///
+ /// a b c dz/dx = ((c + 2f + i) - (a + 2d + g)) / 8
+ /// d e f dz/dy = ((g + 2h + i) - (a + 2b + c)) / 8
+ /// g h i
+ ///
+ /// ⚠ y INCREASES SOUTHWARD, matching the height field and the rendered image. So dz/dy is a
+ /// north→south gradient, and the light's north component points toward -y.
+ ///
+ /// ⚠ EDGES CLAMP, THEY DO NOT WRAP. Sampling is replicated at the border. Wrapping would fold
+ /// the Trench's outer wall against the opposite edge and smear a hard, deliberate boundary into
+ /// a false slope — the Trench border is a wall, not a seam.
+ ///
+ /// ═══ ⚠⚠ WHY VERTICAL EXAGGERATION IS NOT OPTIONAL HERE ═══
+ ///
+ /// The height field is in RAW NOISE UNITS (sea 0.15, peaks ~1.45) over a 2048–10240 px map, so
+ /// the true per-pixel gradient is tiny. Measured on seed 1063685222 at 2048, over 135k land
+ /// samples: MEDIAN |grad| = 0.0038, i.e. a slope of **0.22°**. Un-exaggerated, the entire island
+ /// shades as a flat plane. That is not a defect in the terrain; it is what a 1:1 metre grid
+ /// looks like.
+ ///
+ /// So is a required look dial. Measured slopes at
+ /// candidate values (median / p90 / p99 of land):
+ ///
+ /// zex 25 → 5.5° / 9.9° / 14.2° too flat to read
+ /// zex 75 → 16.0° / 27.6° / 37.1° natural, atlas-like ← default
+ /// zex 120 → 25.3° / 42.0° / 52.0° dramatic, shape-forward
+ /// zex 300 → 49.0° / 64.5° / 71.7° cartoon; the tint is lost
+ ///
+ /// ⚠ IT IS A LOOK DIAL, NOT A PHYSICAL CLAIM. The raw units are not metres. The metres-per-unit
+ /// conversion belongs to Phase 2's elevation profile, not to this file, and no code should read
+ /// ZExaggeration as if it meant something about the world.
+ ///
+ public static class Hillshade
+ {
+ ///
+ /// Compute the hillshade factor at (x, y): the cosine of the angle between the surface
+ /// normal and the light, clamped to [0, 1]. 0 = fully shadowed, 1 = face-on to the light.
+ ///
+ /// On PERFECTLY FLAT ground this returns sin(altitude) — not 1. That value is the
+ /// neutral point the blend divides by, so flat ground keeps its true hypsometric tint.
+ /// → .
+ ///
+ public static float At(float[,] h, int n, int x, int y, float zExaggeration,
+ float lightX, float lightY, float lightZ)
+ {
+ // Clamped 3x3 window — replicate at the border, never wrap.
+ int xm = x > 0 ? x - 1 : 0, xp = x < n - 1 ? x + 1 : n - 1;
+ int ym = y > 0 ? y - 1 : 0, yp = y < n - 1 ? y + 1 : n - 1;
+
+ float hA = h[xm, ym], hB = h[x, ym], hC = h[xp, ym];
+ float hD = h[xm, y], hF = h[xp, y];
+ float hG = h[xm, yp], hH = h[x, yp], hI = h[xp, yp];
+
+ float dzdx = ((hC + 2f * hF + hI) - (hA + 2f * hD + hG)) / 8f;
+ float dzdy = ((hG + 2f * hH + hI) - (hA + 2f * hB + hC)) / 8f;
+
+ // Surface normal of z = height * zExaggeration, before normalization.
+ float nx = -dzdx * zExaggeration;
+ float ny = -dzdy * zExaggeration;
+ const float nz = 1f;
+
+ float inv = 1f / MathF.Sqrt(nx * nx + ny * ny + nz * nz);
+ float dot = (nx * lightX + ny * lightY + nz * lightZ) * inv;
+
+ return dot < 0f ? 0f : (dot > 1f ? 1f : dot);
+ }
+
+ ///
+ /// The unit vector pointing FROM the surface TOWARD the light.
+ ///
+ /// Azimuth is compass degrees, measured CLOCKWISE FROM NORTH — 315° is the cartographic
+ /// standard, light from the north-west. Altitude is degrees above the horizon; 45° standard.
+ ///
+ /// North is -y in image space (y increases southward), which is why the y component is
+ /// negated. Get this wrong and the relief inverts: ridges read as valleys, which the eye
+ /// notices instantly even when it cannot say why.
+ ///
+ public static (float x, float y, float z) LightVector(float azimuthDegrees, float altitudeDegrees)
+ {
+ float az = azimuthDegrees * MathF.PI / 180f;
+ float alt = altitudeDegrees * MathF.PI / 180f;
+ float cosAlt = MathF.Cos(alt);
+ return (cosAlt * MathF.Sin(az), -cosAlt * MathF.Cos(az), MathF.Sin(alt));
+ }
+
+ ///
+ /// The hillshade value flat ground returns — sin(altitude). The blend normalizes by
+ /// this so that flat terrain is neither darkened nor lightened, and only actual SLOPE moves
+ /// the colour away from its hypsometric tint.
+ ///
+ public static float Neutral(float altitudeDegrees) => MathF.Sin(altitudeDegrees * MathF.PI / 180f);
+ }
+}
diff --git a/Tools/Scripts/Hillshade.cs.uid b/Tools/Scripts/Hillshade.cs.uid
new file mode 100644
index 0000000..b993989
--- /dev/null
+++ b/Tools/Scripts/Hillshade.cs.uid
@@ -0,0 +1 @@
+uid://dy8vvr2w7trpi
diff --git a/Tools/Scripts/LookConfig.cs b/Tools/Scripts/LookConfig.cs
new file mode 100644
index 0000000..4da1d91
--- /dev/null
+++ b/Tools/Scripts/LookConfig.cs
@@ -0,0 +1,99 @@
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// The presentation dials — everything about how a heightmap is SHOWN, and nothing about how it
+ /// is MADE. → `Design - Rendering - Roughness Is Presentation.md`.
+ ///
+ /// ⚠ Config-gated for the same reason every shaping change is: a look decision should be an A/B
+ /// pair, not a memory of last week's render, and a rejected look should cost a flipped default
+ /// rather than a reverted commit. → `Design - Tooling - Iteration and Batching.md`.
+ ///
+ /// ⚠ Kept DELIBERATELY SMALL. This is a taste gate, and iteration fatigue on a subjective gate
+ /// is a real failure mode — a handful of variants the eye can actually judge beats a wall of
+ /// near-duplicates. Three named looks, chosen so each pair isolates one question.
+ ///
+ public sealed class LookConfig
+ {
+ /// Short label, used in output filenames.
+ public string Name = "atlas";
+
+ /// Which hypsometric palette. → .
+ public ReliefPalette.Kind Palette = ReliefPalette.Kind.Atlas;
+
+ ///
+ /// Vertical exaggeration for the relief normal. ⚠ A LOOK DIAL, NOT A PHYSICAL CLAIM — the
+ /// raw height units are not metres. See for the measured slope
+ /// table this was chosen from.
+ ///
+ public float ZExaggeration = 75f;
+
+ /// Compass degrees clockwise from north. 315° (NW) is the cartographic standard.
+ public float LightAzimuth = 315f;
+
+ /// Degrees above the horizon. 45° standard; lower = longer, more dramatic shadows.
+ public float LightAltitude = 45f;
+
+ ///
+ /// How much the relief moves the tint. 0 = flat hypsometric tint, no shading at all;
+ /// 1 = full relief. Above ~0.85 the tint starts washing out and the map reads as a
+ /// greyscale DEM wearing colour.
+ ///
+ public float HillshadeStrength = 0.55f;
+
+ ///
+ /// Relief multiplier BELOW sea level.
+ ///
+ /// ⚠ Deliberately much weaker than on land, and the reason is the deferral: the seabed is
+ /// RAW — no coast shelf, no islets, both Phase-2 items — so it is steep and noisy in a way
+ /// the finished terrain will not be. Shading it at full strength drags the eye to the one
+ /// part of the map that is knowingly unfinished. Cartographic bathymetry is conventionally
+ /// shown flat or lightly shaded anyway, so the convention and the deferral agree.
+ ///
+ public float SeaHillshadeFactor = 0.35f;
+
+ ///
+ /// How much of the lit side is allowed to lift toward white. Highlights SCREEN rather than
+ /// multiply, so sunlit slopes brighten without blowing out to paper.
+ ///
+ public float HighlightGain = 0.5f;
+
+ /// The colour boundary. NOT a water surface — no water is modelled (Phase 2).
+ public float SeaLevel = 0.15f;
+
+ ///
+ /// The three looks offered to the taste gate. Each pair isolates one question:
+ /// atlas vs relief → how strong should the relief be? (same palette and light)
+ /// atlas vs dusk → which palette, and how low a sun? (comparable relief)
+ ///
+ public static LookConfig[] Variants() => new[]
+ {
+ // Classic physical-atlas plate: tint-forward, relief present but polite.
+ new LookConfig
+ {
+ Name = "atlas", Palette = ReliefPalette.Kind.Atlas,
+ ZExaggeration = 75f, LightAzimuth = 315f, LightAltitude = 45f,
+ HillshadeStrength = 0.55f,
+ },
+
+ // Shape-forward: the same palette and light, harder relief. Isolates the relief dial.
+ new LookConfig
+ {
+ Name = "relief", Palette = ReliefPalette.Kind.Atlas,
+ ZExaggeration = 120f, LightAzimuth = 315f, LightAltitude = 45f,
+ HillshadeStrength = 0.80f,
+ },
+
+ // Warmer palette, lower sun, longer shadows. Isolates palette + light.
+ new LookConfig
+ {
+ Name = "dusk", Palette = ReliefPalette.Kind.Dusk,
+ ZExaggeration = 100f, LightAzimuth = 300f, LightAltitude = 35f,
+ HillshadeStrength = 0.70f,
+ },
+ };
+
+ public override string ToString() =>
+ $"{Name}: palette={Palette} zex={ZExaggeration:F0} light={LightAzimuth:F0}°/{LightAltitude:F0}° " +
+ $"strength={HillshadeStrength:F2} sea×{SeaHillshadeFactor:F2}";
+ }
+}
diff --git a/Tools/Scripts/LookConfig.cs.uid b/Tools/Scripts/LookConfig.cs.uid
new file mode 100644
index 0000000..ae72006
--- /dev/null
+++ b/Tools/Scripts/LookConfig.cs.uid
@@ -0,0 +1 @@
+uid://ctf5uq0l5gqr5
diff --git a/Tools/Scripts/ReliefPalette.cs b/Tools/Scripts/ReliefPalette.cs
new file mode 100644
index 0000000..34bc836
--- /dev/null
+++ b/Tools/Scripts/ReliefPalette.cs
@@ -0,0 +1,143 @@
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// The hypsometric palettes — a continuous elevation tint, in the physical-atlas tradition.
+ ///
+ /// ═══ ⚠⚠ THIS IS CARTOGRAPHY, NOT CLASSIFICATION ═══
+ ///
+ /// There are NO BIOME WORDS here and there must never be any. Nothing in this file knows about
+ /// jungle, wasteland, desert or snow. It maps ONE FLOAT — height — onto a continuous colour
+ /// ramp. White at the top is snow-COLOURED cartography; green at the bottom is
+ /// lowland-COLOURED. Biomes are a stage-5 CLASSIFICATION of finished shape (D-049 §2, D-056),
+ /// and the entire point of the rewrite's seam is that terrain never learns their names.
+ ///
+ /// ⚠ CONTINUOUS, NOT BANDED. The stops below are interpolated, never quantized. Hard bands
+ /// would read as discrete classes — exactly the visual language of a biome map, and exactly the
+ /// wrong thing to suggest.
+ ///
+ /// ═══ ⚠ THE STOPS ARE IN ABSOLUTE HEIGHT, AND THEY ARE FIXED ═══
+ ///
+ /// Every stop is a raw height value, not a normalized fraction, and the same stops are used for
+ /// every image in a batch. A map coloured on its own min/max cannot be compared with its
+ /// neighbour — and comparison is the entire point of a batch. Out-of-range values clamp; the
+ /// run prints the true min/max so saturation shows up as a number rather than a guess.
+ ///
+ /// ═══ WHY THE STOPS SIT WHERE THEY DO — measured, not eyeballed ═══
+ ///
+ /// Land height is very bottom-heavy. Measured over all four pinned seeds at 2048 (8.6M land
+ /// columns): p10 0.214 · p25 0.306 · MEDIAN 0.456 · p75 0.648 · p90 0.835 · p99 1.113 ·
+ /// p99.9 1.267 · max 1.415.
+ ///
+ /// A ramp spread linearly from 0.15 to 1.45 would therefore spend 99% of its colour range on
+ /// 1% of the land, and the map would read as "green with a few white dots". So the land stops
+ /// are placed on PERCENTILES of the measured distribution instead — the same thing an atlas
+ /// plate does when it picks non-uniform contour intervals.
+ ///
+ /// Sea is the mirror problem: depth runs to −6.7 but 43% of it is shallower than 0.5 and the
+ /// deep floor is featureless. So the bathymetric ramp is COMPRESSED — it is indexed by
+ /// d / (d + k), which gives the shallow shelf most of the range and lets the abyss
+ /// flatten to one dark tone. The interesting bathymetry is near shore.
+ ///
+ /// ⚠ THE SEABED IS RAW, AND IT IS SUPPOSED TO BE. The submarine coast shelf and the offshore
+ /// islets are DEFERRED Phase-2 items. Plain, steep bathymetry here is the deferral showing, not
+ /// a broken render.
+ ///
+ public static class ReliefPalette
+ {
+ /// Named palettes. Gated so the look can be A/B'd like any other change.
+ public enum Kind { Atlas, Dusk }
+
+ ///
+ /// Bathymetric compression constant, in raw height units. The sea ramp is indexed by
+ /// d / (d + SeaCompression). Smaller = more range spent on the shallows.
+ /// At 0.35: depth 0.05 → 12.5% of the ramp, 0.5 → 59%, 2.0 → 85%, 6.7 → 95%.
+ ///
+ public const float SeaCompression = 0.35f;
+
+ // ---- LAND: (absolute height, colour), interpolated. Placed on measured percentiles. ----
+ private static readonly (float h, Color c)[] AtlasLand =
+ {
+ (0.150f, new Color(0.427f, 0.561f, 0.376f)), // shoreline — soft green
+ (0.220f, new Color(0.475f, 0.596f, 0.388f)), // p10 lowland green
+ (0.310f, new Color(0.549f, 0.635f, 0.404f)), // p25
+ (0.460f, new Color(0.667f, 0.686f, 0.435f)), // p50 yellow-green
+ (0.650f, new Color(0.780f, 0.729f, 0.494f)), // p75 khaki
+ (0.840f, new Color(0.808f, 0.678f, 0.478f)), // p90 tan
+ (0.980f, new Color(0.757f, 0.588f, 0.427f)), // p95 light brown
+ (1.120f, new Color(0.667f, 0.510f, 0.404f)), // p99 brown
+ (1.270f, new Color(0.706f, 0.667f, 0.639f)), // p99.9 grey rock
+ (1.450f, new Color(0.988f, 0.988f, 0.980f)), // peak white
+ };
+
+ private static readonly (float h, Color c)[] AtlasSea =
+ {
+ (0.000f, new Color(0.478f, 0.729f, 0.769f)), // waterline — pale teal
+ (0.125f, new Color(0.325f, 0.596f, 0.706f)), // shelf
+ (0.310f, new Color(0.196f, 0.451f, 0.627f)), // mid blue
+ (0.590f, new Color(0.114f, 0.310f, 0.510f)), // deep
+ (0.760f, new Color(0.063f, 0.196f, 0.376f)), // deeper
+ (0.880f, new Color(0.035f, 0.118f, 0.259f)), // navy
+ (1.000f, new Color(0.020f, 0.063f, 0.157f)), // abyss floor — flat
+ };
+
+ // ---- DUSK: warmer land, deeper cooler water. A genuine alternative, not a tweak. ----
+ private static readonly (float h, Color c)[] DuskLand =
+ {
+ (0.150f, new Color(0.353f, 0.494f, 0.353f)),
+ (0.220f, new Color(0.427f, 0.541f, 0.361f)),
+ (0.310f, new Color(0.529f, 0.588f, 0.376f)),
+ (0.460f, new Color(0.663f, 0.639f, 0.408f)),
+ (0.650f, new Color(0.796f, 0.690f, 0.451f)),
+ (0.840f, new Color(0.831f, 0.627f, 0.412f)),
+ (0.980f, new Color(0.780f, 0.522f, 0.361f)),
+ (1.120f, new Color(0.663f, 0.435f, 0.353f)),
+ (1.270f, new Color(0.678f, 0.612f, 0.588f)),
+ (1.450f, new Color(0.980f, 0.973f, 0.949f)),
+ };
+
+ private static readonly (float h, Color c)[] DuskSea =
+ {
+ (0.000f, new Color(0.412f, 0.686f, 0.706f)),
+ (0.125f, new Color(0.263f, 0.545f, 0.647f)),
+ (0.310f, new Color(0.145f, 0.396f, 0.565f)),
+ (0.590f, new Color(0.078f, 0.263f, 0.443f)),
+ (0.760f, new Color(0.043f, 0.161f, 0.318f)),
+ (0.880f, new Color(0.024f, 0.094f, 0.212f)),
+ (1.000f, new Color(0.012f, 0.047f, 0.125f)),
+ };
+
+ /// The hypsometric tint for a height, before any relief shading.
+ public static Color Tint(Kind kind, float height, float seaLevel)
+ {
+ bool dusk = kind == Kind.Dusk;
+
+ if (height >= seaLevel)
+ return Sample(dusk ? DuskLand : AtlasLand, height);
+
+ // Compressed bathymetry: index by d/(d+k), so the shallows get the range.
+ float depth = seaLevel - height;
+ float t = depth / (depth + SeaCompression);
+ return Sample(dusk ? DuskSea : AtlasSea, t);
+ }
+
+ private static Color Sample((float h, Color c)[] stops, float v)
+ {
+ if (v <= stops[0].h) return stops[0].c;
+ for (int i = 1; i < stops.Length; i++)
+ {
+ if (v <= stops[i].h)
+ {
+ float span = stops[i].h - stops[i - 1].h;
+ float local = span <= 0f ? 0f : (v - stops[i - 1].h) / span;
+ return stops[i - 1].c.Lerp(stops[i].c, local);
+ }
+ }
+ return stops[stops.Length - 1].c;
+ }
+
+ /// The land range the stops cover, for a run header / INDEX.
+ public static (float lo, float hi) LandAnchors => (AtlasLand[0].h, AtlasLand[AtlasLand.Length - 1].h);
+ }
+}
diff --git a/Tools/Scripts/ReliefPalette.cs.uid b/Tools/Scripts/ReliefPalette.cs.uid
new file mode 100644
index 0000000..887dd29
--- /dev/null
+++ b/Tools/Scripts/ReliefPalette.cs.uid
@@ -0,0 +1 @@
+uid://bkso0vgv2ux4d
diff --git a/Tools/Scripts/ReliefRenderTool.cs b/Tools/Scripts/ReliefRenderTool.cs
new file mode 100644
index 0000000..236048e
--- /dev/null
+++ b/Tools/Scripts/ReliefRenderTool.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Godot;
+using IslaApocalypse.Core;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// The taste-gate renderer: re-colours existing height fields into shaded-relief maps across a
+ /// small set of named looks, and writes an INDEX.md for browsing.
+ ///
+ /// ⚠ IT RE-COLOURS; IT DOES NOT REGENERATE. Height fields are LOADED from the `.f32` dumps a
+ /// generation run left behind, so a look change provably cannot move the terrain. Only when a
+ /// dump is missing for the requested size — a showpiece at 4096, say, where no dump exists —
+ /// does it generate one, deterministically from the same seed. Same seed, same island.
+ ///
+ /// ═══ RUNNING IT ═══
+ ///
+ /// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \
+ /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ReliefRenderTool.tscn
+ ///
+ /// ISLA_MAPSIZE side in columns (default 2048)
+ /// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds)
+ /// ISLA_LOOKS comma-separated look names (default: atlas,relief,dusk)
+ /// ISLA_BATCH output batch folder (default 03_relief_taste)
+ /// ISLA_SOURCE batch to read .f32 from (default 01_pass1_port)
+ /// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated
+ ///
+ public partial class ReliefRenderTool : Node
+ {
+ private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 };
+
+ public override void _Ready()
+ {
+ ToolingPaths.Configure(OS.GetUserDataDir());
+
+ int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
+ int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
+ string batch = EnvStr("ISLA_BATCH", "03_relief_taste");
+ string source = EnvStr("ISLA_SOURCE", "01_pass1_port");
+ bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1";
+ LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null));
+
+ string batchRoot = Path.Combine(ToolingPaths.BatchesRoot, batch);
+ string sourceRoot = Path.Combine(ToolingPaths.BatchesRoot, source);
+ DirAccess.MakeDirRecursiveAbsolute(batchRoot);
+ DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
+
+ GD.Print("==================================================================");
+ GD.Print(" SHADED RELIEF — hypsometric tint + hillshade (Phase 1, the look)");
+ GD.Print("==================================================================");
+ GD.Print($"MapSize : {mapSize}");
+ GD.Print($"seeds : {string.Join(", ", seeds)}");
+ GD.Print($"looks : {string.Join(", ", Array.ConvertAll(looks, l => l.Name))}");
+ GD.Print($"source : {sourceRoot} (.f32 dumps; generated only if absent)");
+ GD.Print($"out : {batchRoot}");
+ GD.Print("------------------------------------------------------------------");
+ foreach (var l in looks) GD.Print($" {l}");
+ GD.Print("==================================================================");
+
+ var rows = new List();
+
+ foreach (int seed in seeds)
+ {
+ // --- get the height field: LOAD if a dump exists, generate only as a fallback ---
+ ulong t0 = Time.GetTicksMsec();
+ string dumpPath = Path.Combine(sourceRoot, $"{seed}_full", "height.f32");
+ float[,] height = HeightField.Load(dumpPath, mapSize);
+ string origin;
+
+ if (height != null)
+ {
+ origin = "loaded";
+ }
+ else
+ {
+ var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" };
+ Pass1Result r = Topography.Generate(cfg);
+ height = r.Height;
+ origin = "generated";
+ if (dumpRaw)
+ {
+ string dir = Path.Combine(batchRoot, $"{seed}_source");
+ DirAccess.MakeDirRecursiveAbsolute(dir);
+ HeightField.Save(height, mapSize, Path.Combine(dir, "height.f32"));
+ }
+ }
+ ulong tLoad = Time.GetTicksMsec() - t0;
+
+ float hMin = float.MaxValue, hMax = float.MinValue;
+ for (int x = 0; x < mapSize; x++)
+ for (int y = 0; y < mapSize; y++)
+ {
+ float v = height[x, y];
+ if (v < hMin) hMin = v;
+ if (v > hMax) hMax = v;
+ }
+
+ GD.Print($"\n seed {seed} [{origin} in {tLoad} ms] h[{hMin:F3} .. {hMax:F3}]");
+
+ foreach (LookConfig look in looks)
+ {
+ ulong t1 = Time.GetTicksMsec();
+ string dir = Path.Combine(batchRoot, $"{seed}_{look.Name}");
+ DirAccess.MakeDirRecursiveAbsolute(dir);
+ ReliefRenderer.SavePng(height, mapSize, look, Path.Combine(dir, "relief.png"));
+ ulong ms = Time.GetTicksMsec() - t1;
+
+ GD.Print($" {look.Name,-8} -> {dir}/relief.png {ms} ms");
+ rows.Add($"| `{seed}_{look.Name}` | {seed} | {look.Name} | {look.Palette} | " +
+ $"{look.ZExaggeration:F0} | {look.LightAzimuth:F0}°/{look.LightAltitude:F0}° | " +
+ $"{look.HillshadeStrength:F2} | {ms} ms |");
+ }
+ }
+
+ WriteIndex(batchRoot, mapSize, seeds, looks, source, rows);
+
+ GD.Print("\n==================================================================");
+ GD.Print($" DONE — {rows.Count} renders in {batchRoot}");
+ GD.Print("==================================================================");
+ GetTree().Quit(0);
+ }
+
+ private static LookConfig[] SelectLooks(string csv)
+ {
+ LookConfig[] all = LookConfig.Variants();
+ if (string.IsNullOrWhiteSpace(csv)) return all;
+
+ var picked = new List();
+ foreach (string want in csv.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ foreach (LookConfig l in all)
+ if (string.Equals(l.Name, want.Trim(), StringComparison.OrdinalIgnoreCase))
+ picked.Add(l);
+ return picked.Count > 0 ? picked.ToArray() : all;
+ }
+
+ private static void WriteIndex(string batchRoot, int mapSize, int[] seeds,
+ LookConfig[] looks, string source, List rows)
+ {
+ var (landLo, landHi) = ReliefPalette.LandAnchors;
+ var sb = new StringBuilder();
+ sb.AppendLine("# Batch — shaded relief, the taste gate (Phase 1)");
+ sb.AppendLine();
+ sb.AppendLine("**Presentation only.** These are the *same* height fields as");
+ sb.AppendLine($"`{source}` — loaded from its `.f32` dumps, not regenerated. Nothing here can move the");
+ sb.AppendLine("terrain; only its colours and its lighting differ.");
+ sb.AppendLine();
+ sb.AppendLine($"- **MapSize:** {mapSize}");
+ sb.AppendLine($"- **Sea colour boundary:** 0.15 — a *colour* boundary, **not a water surface.** No water is modelled (Phase 2).");
+ sb.AppendLine($"- **Palette anchors (FIXED across every image):** land {landLo} .. {landHi}; sea compressed by `d/(d+{ReliefPalette.SeaCompression})`");
+ sb.AppendLine($"- **Seeds:** {string.Join(", ", seeds)}");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ Pick a look");
+ sb.AppendLine();
+ sb.AppendLine("Three, deliberately — a subjective gate drowns in a wall of near-duplicates. Each pair");
+ sb.AppendLine("isolates one question:");
+ sb.AppendLine();
+ sb.AppendLine("| Look | Palette | Z-exag | Light | Strength | The question it answers |");
+ sb.AppendLine("|---|---|---|---|---|---|");
+ foreach (LookConfig l in looks)
+ {
+ string q = l.Name switch
+ {
+ "atlas" => "the baseline — classic physical-atlas plate, tint-forward",
+ "relief" => "**vs `atlas`:** how strong should the relief be? (same palette + light)",
+ "dusk" => "**vs `atlas`:** warmer palette and a lower sun — better, or too much?",
+ _ => "",
+ };
+ sb.AppendLine($"| `{l.Name}` | {l.Palette} | {l.ZExaggeration:F0} | {l.LightAzimuth:F0}°/{l.LightAltitude:F0}° | {l.HillshadeStrength:F2} | {q} |");
+ }
+ sb.AppendLine();
+ sb.AppendLine("Compare the **same seed** across the three folders. `1063685222` is the reference's own");
+ sb.AppendLine("commented seed and the one used throughout tasks 02–03.");
+ sb.AppendLine();
+ sb.AppendLine("> ⚠ **The seabed is raw, and that is expected — not unfinished-because-broken.** The");
+ sb.AppendLine("> submarine coast shelf and the offshore islets are DEFERRED Phase-2 items; they act only");
+ sb.AppendLine("> below sea level and are judged once water renders. Relief is deliberately dialled back");
+ sb.AppendLine("> underwater so the eye is not dragged to the one surface that is knowingly incomplete.");
+ sb.AppendLine();
+ sb.AppendLine("> ⚠ **No biome colour here.** This is height + slope + above/below sea, on a continuous");
+ sb.AppendLine("> ramp. White at the top is snow-*coloured cartography*, not a snow biome.");
+ sb.AppendLine();
+ sb.AppendLine("## Renders");
+ sb.AppendLine();
+ sb.AppendLine("| Folder | Seed | Look | Palette | Z-exag | Light | Strength | Time |");
+ sb.AppendLine("|---|---|---|---|---|---|---|---|");
+ foreach (string r in rows) sb.AppendLine(r);
+ sb.AppendLine();
+ sb.AppendLine("`scratch/` is persistent and is never cleaned.");
+
+ using var f = Godot.FileAccess.Open(Path.Combine(batchRoot, "INDEX.md"), Godot.FileAccess.ModeFlags.Write);
+ if (f == null) { GD.PrintErr("could not write INDEX.md"); return; }
+ f.StoreString(sb.ToString());
+ }
+
+ private static string EnvStr(string k, string fallback)
+ {
+ string v = System.Environment.GetEnvironmentVariable(k);
+ return string.IsNullOrWhiteSpace(v) ? fallback : v;
+ }
+
+ private static int EnvInt(string k, int fallback)
+ => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
+
+ private static int[] EnvSeeds(string k, int[] fallback)
+ {
+ string v = EnvStr(k, null);
+ if (v == null) return fallback;
+ var o = new List();
+ foreach (string p in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ if (int.TryParse(p.Trim(), out int s) && s > 0) o.Add(s);
+ return o.Count > 0 ? o.ToArray() : fallback;
+ }
+ }
+}
diff --git a/Tools/Scripts/ReliefRenderTool.cs.uid b/Tools/Scripts/ReliefRenderTool.cs.uid
new file mode 100644
index 0000000..0c07a44
--- /dev/null
+++ b/Tools/Scripts/ReliefRenderTool.cs.uid
@@ -0,0 +1 @@
+uid://ctkcngrni5ood
diff --git a/Tools/Scripts/ReliefRenderer.cs b/Tools/Scripts/ReliefRenderer.cs
new file mode 100644
index 0000000..f902aa0
--- /dev/null
+++ b/Tools/Scripts/ReliefRenderer.cs
@@ -0,0 +1,116 @@
+using Godot;
+
+namespace IslaApocalypse.Tools
+{
+ ///
+ /// ⭐ THE BEAUTY RENDER — hypsometric tint blended with shaded relief. The Phase-1 finish line.
+ ///
+ /// ⚠ PRESENTATION ONLY (→ `Design - Rendering - Roughness Is Presentation.md`). This file
+ /// changes how height is SHOWN, never how it is MADE. It cannot: it is handed a height field
+ /// and has no way to produce one.
+ ///
+ /// ═══ ⚠ WHY THIS WRITES A Godot.Image DIRECTLY, AND NOT VIA THE CAPTURE PATH ═══
+ ///
+ /// The reference captured maps through a SubViewport + a TextureRect._Draw, because it
+ /// composited VECTOR OVERLAYS — roads, town markers, river polylines — onto the raster. That
+ /// machinery awaits render frames, which is why unattended runs need `xvfb-run` and why
+ /// `--headless` hangs on it. Phase 1 has no overlays, so this is a raster and nothing else.
+ /// **Do not reintroduce the capture path by habit** — it becomes correct the moment something
+ /// vector needs compositing, and not before.
+ ///
+ /// ═══ THE BLEND — the difference between a colour ramp and a map you would frame ═══
+ ///
+ /// The naive composite is tint × hillshade. It looks wrong, for a reason worth stating:
+ /// hillshade on FLAT ground is sin(altitude) ≈ 0.71 at a 45° sun, so a plain multiply
+ /// darkens the entire map by 29% before any slope is involved, and the carefully-placed
+ /// hypsometric tints are never actually seen.
+ ///
+ /// So the shade is NORMALIZED BY ITS FLAT-GROUND VALUE first:
+ ///
+ /// lum = shade / sin(altitude) → 1.0 on flat ground, <1 shadowed, >1 lit
+ /// factor = lerp(1, lum, strength) → strength dials relief without touching hue
+ ///
+ /// Flat terrain therefore keeps its true tint, and ONLY SLOPE moves the colour. Then the two
+ /// sides are treated differently, because multiplying both ways blows the highlights to paper:
+ ///
+ /// factor ≤ 1 → MULTIPLY : c = tint × factor (shadows deepen, hue held)
+ /// factor > 1 → SCREEN : c = tint + (1−tint)×(factor−1)×gain (lit slopes lift toward
+ /// white, gently, never clipping)
+ ///
+ /// That asymmetry is the whole trick. Shadows want to keep the tint's hue; highlights want to
+ /// desaturate toward sunlight, exactly as they do on a printed relief plate.
+ ///
+ /// ⚠ NO BIOME WORDS. Continuous height ramp + slope. Nothing here classifies anything.
+ ///
+ public static class ReliefRenderer
+ {
+ /// Render a height field to a shaded-relief PNG. Returns the path written.
+ public static string SavePng(float[,] height, int mapSize, LookConfig look, string absolutePath)
+ {
+ var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8);
+
+ var (lx, ly, lz) = Hillshade.LightVector(look.LightAzimuth, look.LightAltitude);
+ float neutral = Hillshade.Neutral(look.LightAltitude);
+ float invNeutral = neutral > 0.0001f ? 1f / neutral : 1f;
+
+ for (int x = 0; x < mapSize; x++)
+ {
+ for (int y = 0; y < mapSize; y++)
+ {
+ float h = height[x, y];
+ Color tint = ReliefPalette.Tint(look.Palette, h, look.SeaLevel);
+
+ float shade = Hillshade.At(height, mapSize, x, y, look.ZExaggeration, lx, ly, lz);
+
+ // Relief is dialled back below sea, AND FADED OUT WITH DEPTH.
+ //
+ // ⚠ The depth fade is not just taste. The deep floor is dominated by the
+ // TRENCH — a synthetic additive wall (falloff += (dist-0.90)*15) whose gradient
+ // is ~1.5x the p99 LAND gradient. Shading it faithfully draws a bright rim
+ // around the whole map and lights up the abyss with base-noise mottle: relief
+ // on terrain that was never meant to be looked at. Fading with depth puts the
+ // relief where the bathymetry is real — the near-shore shelf — and lets the
+ // abyss lie flat, which is also the cartographic convention.
+ //
+ // Uses the same compressed depth index as the palette, so the tint and the
+ // shading fade together instead of drifting apart.
+ float strength;
+ if (h >= look.SeaLevel)
+ {
+ strength = look.HillshadeStrength;
+ }
+ else
+ {
+ float depth = look.SeaLevel - h;
+ float dt = depth / (depth + ReliefPalette.SeaCompression); // 0 shore → 1 abyss
+ strength = look.HillshadeStrength * look.SeaHillshadeFactor * (1f - dt);
+ }
+
+ float lum = shade * invNeutral;
+ float factor = 1f + (lum - 1f) * strength;
+
+ Color c;
+ if (factor <= 1f)
+ {
+ c = new Color(tint.R * factor, tint.G * factor, tint.B * factor);
+ }
+ else
+ {
+ float lift = (factor - 1f) * look.HighlightGain;
+ c = new Color(
+ tint.R + (1f - tint.R) * lift,
+ tint.G + (1f - tint.G) * lift,
+ tint.B + (1f - tint.B) * lift);
+ }
+
+ img.SetPixel(x, y, new Color(
+ Mathf.Clamp(c.R, 0f, 1f), Mathf.Clamp(c.G, 0f, 1f), Mathf.Clamp(c.B, 0f, 1f)));
+ }
+ }
+
+ Error err = img.SavePng(absolutePath);
+ if (err != Error.Ok) GD.PrintErr($"[ReliefRenderer] SavePng failed ({err}) for {absolutePath}");
+ return absolutePath;
+ }
+ }
+}
diff --git a/Tools/Scripts/ReliefRenderer.cs.uid b/Tools/Scripts/ReliefRenderer.cs.uid
new file mode 100644
index 0000000..7e1a180
--- /dev/null
+++ b/Tools/Scripts/ReliefRenderer.cs.uid
@@ -0,0 +1 @@
+uid://c67yqu44cjno0
diff --git a/Tools/Scripts/TerrainGenTool.cs b/Tools/Scripts/TerrainGenTool.cs
index c5c4d67..9cac756 100644
--- a/Tools/Scripts/TerrainGenTool.cs
+++ b/Tools/Scripts/TerrainGenTool.cs
@@ -123,8 +123,11 @@ namespace IslaApocalypse.Tools
string dir = Path.Combine(batchRoot, $"{cfg.Seed}_{cfg.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
- string png = HeightMapRenderer.SavePng(r, cfg.SeaLevel, Path.Combine(dir, "height.png"));
- if (!skipRaw) HeightMapRenderer.SaveRaw(r, Path.Combine(dir, "height.f32"));
+ // The generator's own quick-look uses the default 'atlas' presentation, so a generation
+ // run and a beauty render are coloured identically and can be compared directly.
+ var look = new LookConfig { SeaLevel = cfg.SeaLevel };
+ ReliefRenderer.SavePng(r.Height, r.MapSize, look, Path.Combine(dir, "height.png"));
+ if (!skipRaw) HeightField.Save(r.Height, r.MapSize, Path.Combine(dir, "height.f32"));
float land = r.LandFraction(cfg.SeaLevel);
GD.Print($" {cfg.VariantLabel,-16} seed {cfg.Seed,-11} " +
@@ -146,7 +149,8 @@ namespace IslaApocalypse.Tools
sb.AppendLine($"- **MapSize:** {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
sb.AppendLine($"- **Noise:** {TerrainNoise.Describe(0, scale).Replace(" · seed 0", "")}");
sb.AppendLine($"- **Sea threshold (visualization only):** 0.15");
- sb.AppendLine($"- **Colour ramp anchors (FIXED across the batch):** sea {HeightMapRenderer.RampBottomHeight} .. 0.15, land 0.15 .. {HeightMapRenderer.RampTopHeight}");
+ var (landLo, landHi) = ReliefPalette.LandAnchors;
+ sb.AppendLine($"- **Palette anchors (FIXED across the batch):** land {landLo} .. {landHi}, sea compressed by d/(d+{ReliefPalette.SeaCompression})");
sb.AppendLine($"- **Seeds:** {string.Join(", ", seeds)}");
sb.AppendLine();
sb.AppendLine("## What to look at");