islaApocalypse-v2/Tools/Scripts/ReliefPalette.cs
beezm b1cf282295 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.
2026-08-19 21:58:44 -04:00

143 lines
6.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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