From b1cf282295ca65e7e87b619d7a396ed21d3e48fd Mon Sep 17 00:00:00 2001 From: beezm Date: Wed, 19 Aug 2026 21:58:44 -0400 Subject: [PATCH] =?UTF-8?q?Phase=201:=20shaded=20relief=20and=20the=20Hisp?= =?UTF-8?q?aniola=20palette=20=E2=80=94=20the=20grin=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presentation only. Nothing here touches Topography, the noise, or any generation constant; the renderer is handed a height field LOADED from a .f32 dump and has no way to produce one, so a look change provably cannot move the terrain. Hillshade: Horn 3x3, cell size 1, edges clamped (never wrapped — the Trench border is a wall, not a seam). ZExaggeration is required rather than optional: measured median land slope is 0.22 degrees unexaggerated, so the island shades as a flat plane. Chosen from a measured slope table — zex 75 gives 16/28/37 deg at land median/p90/p99, which reads as natural relief. It is a look dial; raw units are not metres and no code may read it as if they were. Palette: stops placed on the MEASURED height distribution, not spread linearly. Land is bottom-heavy (median 0.456, p99 1.113, max 1.415 over 8.6M land columns across four seeds), so a linear ramp would spend 99% of its range on 1% of the land and the map would read as green with a few white dots. Bathymetry is compressed by d/(d+0.35) so the shallow shelf gets the range and the featureless abyss flattens. Anchors are absolute and fixed across the batch — a map coloured on its own min/max cannot be compared with its neighbour, and comparison is the point of a batch. Blend: the naive tint x hillshade darkens everything by 29% before any slope is involved, because flat ground shades to sin(altitude). So 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 to zero with depth below sea. Not only taste: the deep floor is the Trench, a synthetic wall whose gradient is ~1.5x the p99 land gradient, so shading it faithfully draws a bright rim around the map and lights the abyss with noise mottle. The fade puts relief on the near-shore shelf where the bathymetry is real. Three named looks (atlas / relief / dusk), gated like any other change and kept deliberately few — iteration fatigue on a subjective gate is a real failure mode. Each pair isolates one question. HeightMapRenderer.cs is retired: its raw I/O moved to HeightField.cs and its ramp to ReliefPalette.cs, so there is one palette everywhere and the generator's own quick-look is coloured identically to a beauty render. No biome words, no classification, no water. The 0.15 line is a colour boundary. --- Tools/README.md | 48 +++++- Tools/Scenes/ReliefRenderTool.tscn | 6 + Tools/Scripts/HeightField.cs | 55 +++++++ Tools/Scripts/HeightField.cs.uid | 1 + Tools/Scripts/HeightMapRenderer.cs | 148 ----------------- Tools/Scripts/HeightMapRenderer.cs.uid | 1 - Tools/Scripts/Hillshade.cs | 110 +++++++++++++ Tools/Scripts/Hillshade.cs.uid | 1 + Tools/Scripts/LookConfig.cs | 99 +++++++++++ Tools/Scripts/LookConfig.cs.uid | 1 + Tools/Scripts/ReliefPalette.cs | 143 ++++++++++++++++ Tools/Scripts/ReliefPalette.cs.uid | 1 + Tools/Scripts/ReliefRenderTool.cs | 217 +++++++++++++++++++++++++ Tools/Scripts/ReliefRenderTool.cs.uid | 1 + Tools/Scripts/ReliefRenderer.cs | 116 +++++++++++++ Tools/Scripts/ReliefRenderer.cs.uid | 1 + Tools/Scripts/TerrainGenTool.cs | 10 +- 17 files changed, 806 insertions(+), 153 deletions(-) create mode 100644 Tools/Scenes/ReliefRenderTool.tscn create mode 100644 Tools/Scripts/HeightField.cs create mode 100644 Tools/Scripts/HeightField.cs.uid delete mode 100644 Tools/Scripts/HeightMapRenderer.cs delete mode 100644 Tools/Scripts/HeightMapRenderer.cs.uid create mode 100644 Tools/Scripts/Hillshade.cs create mode 100644 Tools/Scripts/Hillshade.cs.uid create mode 100644 Tools/Scripts/LookConfig.cs create mode 100644 Tools/Scripts/LookConfig.cs.uid create mode 100644 Tools/Scripts/ReliefPalette.cs create mode 100644 Tools/Scripts/ReliefPalette.cs.uid create mode 100644 Tools/Scripts/ReliefRenderTool.cs create mode 100644 Tools/Scripts/ReliefRenderTool.cs.uid create mode 100644 Tools/Scripts/ReliefRenderer.cs create mode 100644 Tools/Scripts/ReliefRenderer.cs.uid 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");