Phase 1: shaded relief and the Hispaniola palette — the grin gate
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.
This commit is contained in:
parent
b60d78b752
commit
b1cf282295
17 changed files with 806 additions and 153 deletions
|
|
@ -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
|
||||
|
|
|
|||
6
Tools/Scenes/ReliefRenderTool.tscn
Normal file
6
Tools/Scenes/ReliefRenderTool.tscn
Normal file
|
|
@ -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")
|
||||
55
Tools/Scripts/HeightField.cs
Normal file
55
Tools/Scripts/HeightField.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using Godot;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw height-field I/O: little-endian float32, x-major (<c>index = x * MapSize + y</c>).
|
||||
///
|
||||
/// ⭐ 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".
|
||||
/// </summary>
|
||||
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]);
|
||||
}
|
||||
|
||||
/// <summary>Load a dump. Returns null if absent or the wrong size for <paramref name="mapSize"/>.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/HeightField.cs.uid
Normal file
1
Tools/Scripts/HeightField.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cd6vej0ey8wo8
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
using Godot;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a raw pass-1 height field as a HYPSOMETRIC height-gradient PNG.
|
||||
///
|
||||
/// ═══ ⚠ WHY THIS WRITES A Godot.Image DIRECTLY, AND NOT VIA THE CAPTURE PATH ═══
|
||||
///
|
||||
/// The reference captured its maps through a SubViewport + a TextureRect whose `_Draw` painted
|
||||
/// overlays — roads, town markers, river polylines — and then read the viewport texture back.
|
||||
/// That machinery exists to COMPOSITE VECTOR OVERLAYS onto a raster. It also carries a real
|
||||
/// cost: it awaits render frames, which is why unattended runs need `xvfb-run` and why
|
||||
/// `--headless` HANGS on it.
|
||||
///
|
||||
/// Phase 1 has no overlays. A heightmap is a raster and nothing else, so it is written
|
||||
/// per-pixel into an Image and saved. No viewport, no frame awaits, no display needed.
|
||||
///
|
||||
/// ⚠ DO NOT REINTRODUCE THE CAPTURE PATH BY HABIT. It becomes correct the moment something
|
||||
/// vector needs compositing on top (roads, towns, river centrelines) — and not before.
|
||||
///
|
||||
/// ═══ THE COLOUR SCHEME ═══
|
||||
///
|
||||
/// Two ramps meeting at the sea threshold, Hispaniola-style:
|
||||
/// below — bathymetric: deep navy → shallow cyan-ish blue
|
||||
/// above — hypsometric: shore green → lowland green → tan → brown → grey → white peak
|
||||
///
|
||||
/// ⚠⚠ THERE ARE NO BIOME WORDS HERE AND THERE MUST NOT BE. Nothing in this file knows about
|
||||
/// jungle, wasteland, snow or desert. It reads a single float per column and picks a colour on
|
||||
/// an elevation ramp. Biomes are a stage-5 CLASSIFICATION of finished shape (D-049 §2, D-056),
|
||||
/// and the whole point of the rewrite's seam is that terrain never learns their names.
|
||||
/// "White at the top" is snow-COLOURED cartography, not a snow biome.
|
||||
///
|
||||
/// ⚠ The sea threshold is a VISUALIZATION boundary only. No water is modelled: no water bodies,
|
||||
/// no ocean/lake distinction, no flooding. Phase 2 owns all of that.
|
||||
/// </summary>
|
||||
public static class HeightMapRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// FIXED ramp anchors in raw height units, deliberately NOT per-run normalized.
|
||||
///
|
||||
/// Per-run normalization would make every PNG use its full colour range, which looks better
|
||||
/// and lies: two seeds, or two rungs of the ablation ladder, would be coloured by different
|
||||
/// scales and could not be compared by eye. Fixed anchors mean the same colour is the same
|
||||
/// height in every image in a batch. Out-of-range values clamp, and the run prints the true
|
||||
/// min/max so saturation is visible as a number rather than guessed from the picture.
|
||||
/// </summary>
|
||||
public const float RampTopHeight = 1.45f;
|
||||
|
||||
/// <summary>The deepest height the bathymetric ramp resolves. Below this, flat abyss colour.</summary>
|
||||
public const float RampBottomHeight = -1.00f;
|
||||
|
||||
// ---- the land ramp: (t in [0,1] above sea) -> colour ----
|
||||
private static readonly (float t, Color c)[] LandRamp =
|
||||
{
|
||||
(0.00f, new Color(0.36f, 0.55f, 0.35f)), // shore green
|
||||
(0.12f, new Color(0.47f, 0.62f, 0.36f)), // lowland
|
||||
(0.30f, new Color(0.72f, 0.71f, 0.44f)), // dry tan
|
||||
(0.50f, new Color(0.72f, 0.57f, 0.36f)), // brown
|
||||
(0.70f, new Color(0.60f, 0.45f, 0.34f)), // dark brown
|
||||
(0.86f, new Color(0.66f, 0.64f, 0.64f)), // grey rock
|
||||
(1.00f, new Color(0.98f, 0.98f, 0.98f)), // white peak
|
||||
};
|
||||
|
||||
// ---- the sea ramp: (t in [0,1], 0 = deepest) -> colour ----
|
||||
private static readonly (float t, Color c)[] SeaRamp =
|
||||
{
|
||||
(0.00f, new Color(0.03f, 0.06f, 0.18f)), // abyss
|
||||
(0.45f, new Color(0.06f, 0.16f, 0.36f)), // deep
|
||||
(0.75f, new Color(0.11f, 0.30f, 0.52f)), // mid
|
||||
(0.92f, new Color(0.20f, 0.47f, 0.66f)), // shelf
|
||||
(1.00f, new Color(0.42f, 0.68f, 0.79f)), // shallow, at the waterline
|
||||
};
|
||||
|
||||
/// <summary>Render and save. Returns the absolute path written.</summary>
|
||||
public static string SavePng(Pass1Result result, float seaLevel, string absolutePath)
|
||||
{
|
||||
int n = result.MapSize;
|
||||
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
||||
|
||||
for (int x = 0; x < n; x++)
|
||||
{
|
||||
for (int y = 0; y < n; y++)
|
||||
{
|
||||
float h = result.Height[x, y];
|
||||
Color c;
|
||||
|
||||
if (h >= seaLevel)
|
||||
{
|
||||
float t = Mathf.Clamp((h - seaLevel) / (RampTopHeight - seaLevel), 0f, 1f);
|
||||
c = Sample(LandRamp, t);
|
||||
}
|
||||
else
|
||||
{
|
||||
float t = Mathf.Clamp((h - RampBottomHeight) / (seaLevel - RampBottomHeight), 0f, 1f);
|
||||
c = Sample(SeaRamp, t);
|
||||
}
|
||||
|
||||
// Image is row-major (x = column, y = row); the height field is [x, y] with y
|
||||
// increasing SOUTHWARD, matching the reference's convention. So a larger y is
|
||||
// further down the image, and "the southern sinker" sinks the bottom of the PNG.
|
||||
img.SetPixel(x, y, c);
|
||||
}
|
||||
}
|
||||
|
||||
Error err = img.SavePng(absolutePath);
|
||||
if (err != Error.Ok)
|
||||
GD.PrintErr($"[HeightMapRenderer] SavePng failed ({err}) for {absolutePath}");
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
private static Color Sample((float t, Color c)[] ramp, float t)
|
||||
{
|
||||
for (int i = 1; i < ramp.Length; i++)
|
||||
{
|
||||
if (t <= ramp[i].t)
|
||||
{
|
||||
float span = ramp[i].t - ramp[i - 1].t;
|
||||
float local = span <= 0f ? 0f : (t - ramp[i - 1].t) / span;
|
||||
return ramp[i - 1].c.Lerp(ramp[i].c, local);
|
||||
}
|
||||
}
|
||||
return ramp[ramp.Length - 1].c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dump the raw float height field beside the PNG, little-endian float32, x-major.
|
||||
///
|
||||
/// Cheap, and it is what makes a future BYTE-LEVEL port-fidelity check possible — the kind
|
||||
/// of automatic oracle that lets taste-iteration happen without correctness being judged by
|
||||
/// eye. → `Design - Tooling - Iteration and Batching.md`, "build the oracle first".
|
||||
/// </summary>
|
||||
public static string SaveRaw(Pass1Result result, string absolutePath)
|
||||
{
|
||||
using var f = FileAccess.Open(absolutePath, FileAccess.ModeFlags.Write);
|
||||
if (f == null)
|
||||
{
|
||||
GD.PrintErr($"[HeightMapRenderer] could not open {absolutePath}: {FileAccess.GetOpenError()}");
|
||||
return absolutePath;
|
||||
}
|
||||
int n = result.MapSize;
|
||||
for (int x = 0; x < n; x++)
|
||||
for (int y = 0; y < n; y++)
|
||||
f.StoreFloat(result.Height[x, y]);
|
||||
return absolutePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://tufk8dg4g14g
|
||||
110
Tools/Scripts/Hillshade.cs
Normal file
110
Tools/Scripts/Hillshade.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="LookConfig.ZExaggeration"/> 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.
|
||||
/// </summary>
|
||||
public static class Hillshade
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <c>sin(altitude)</c> — not 1. That value is the
|
||||
/// neutral point the blend divides by, so flat ground keeps its true hypsometric tint.
|
||||
/// → <see cref="Neutral"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The hillshade value flat ground returns — <c>sin(altitude)</c>. 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.
|
||||
/// </summary>
|
||||
public static float Neutral(float altitudeDegrees) => MathF.Sin(altitudeDegrees * MathF.PI / 180f);
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/Hillshade.cs.uid
Normal file
1
Tools/Scripts/Hillshade.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dy8vvr2w7trpi
|
||||
99
Tools/Scripts/LookConfig.cs
Normal file
99
Tools/Scripts/LookConfig.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class LookConfig
|
||||
{
|
||||
/// <summary>Short label, used in output filenames.</summary>
|
||||
public string Name = "atlas";
|
||||
|
||||
/// <summary>Which hypsometric palette. → <see cref="ReliefPalette.Kind"/>.</summary>
|
||||
public ReliefPalette.Kind Palette = ReliefPalette.Kind.Atlas;
|
||||
|
||||
/// <summary>
|
||||
/// Vertical exaggeration for the relief normal. ⚠ A LOOK DIAL, NOT A PHYSICAL CLAIM — the
|
||||
/// raw height units are not metres. See <see cref="Hillshade"/> for the measured slope
|
||||
/// table this was chosen from.
|
||||
/// </summary>
|
||||
public float ZExaggeration = 75f;
|
||||
|
||||
/// <summary>Compass degrees clockwise from north. 315° (NW) is the cartographic standard.</summary>
|
||||
public float LightAzimuth = 315f;
|
||||
|
||||
/// <summary>Degrees above the horizon. 45° standard; lower = longer, more dramatic shadows.</summary>
|
||||
public float LightAltitude = 45f;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public float HillshadeStrength = 0.55f;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public float SeaHillshadeFactor = 0.35f;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public float HighlightGain = 0.5f;
|
||||
|
||||
/// <summary>The colour boundary. NOT a water surface — no water is modelled (Phase 2).</summary>
|
||||
public float SeaLevel = 0.15f;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/LookConfig.cs.uid
Normal file
1
Tools/Scripts/LookConfig.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ctf5uq0l5gqr5
|
||||
143
Tools/Scripts/ReliefPalette.cs
Normal file
143
Tools/Scripts/ReliefPalette.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using Godot;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>d / (d + k)</c>, 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.
|
||||
/// </summary>
|
||||
public static class ReliefPalette
|
||||
{
|
||||
/// <summary>Named palettes. Gated so the look can be A/B'd like any other change.</summary>
|
||||
public enum Kind { Atlas, Dusk }
|
||||
|
||||
/// <summary>
|
||||
/// Bathymetric compression constant, in raw height units. The sea ramp is indexed by
|
||||
/// <c>d / (d + SeaCompression)</c>. 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%.
|
||||
/// </summary>
|
||||
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)),
|
||||
};
|
||||
|
||||
/// <summary>The hypsometric tint for a height, before any relief shading.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The land range the stops cover, for a run header / INDEX.</summary>
|
||||
public static (float lo, float hi) LandAnchors => (AtlasLand[0].h, AtlasLand[AtlasLand.Length - 1].h);
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/ReliefPalette.cs.uid
Normal file
1
Tools/Scripts/ReliefPalette.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bkso0vgv2ux4d
|
||||
217
Tools/Scripts/ReliefRenderTool.cs
Normal file
217
Tools/Scripts/ReliefRenderTool.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Godot;
|
||||
using IslaApocalypse.Core;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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<string>();
|
||||
|
||||
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<LookConfig>();
|
||||
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<string> 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<int>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/ReliefRenderTool.cs.uid
Normal file
1
Tools/Scripts/ReliefRenderTool.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ctkcngrni5ood
|
||||
116
Tools/Scripts/ReliefRenderer.cs
Normal file
116
Tools/Scripts/ReliefRenderer.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using Godot;
|
||||
|
||||
namespace IslaApocalypse.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// ⭐ 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 <c>tint × hillshade</c>. It looks wrong, for a reason worth stating:
|
||||
/// hillshade on FLAT ground is <c>sin(altitude)</c> ≈ 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.
|
||||
/// </summary>
|
||||
public static class ReliefRenderer
|
||||
{
|
||||
/// <summary>Render a height field to a shaded-relief PNG. Returns the path written.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
Tools/Scripts/ReliefRenderer.cs.uid
Normal file
1
Tools/Scripts/ReliefRenderer.cs.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c67yqu44cjno0
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue