islaApocalypse-v2/Tools/Scripts/ReliefRenderer.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

116 lines
5.1 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 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, &lt;1 shadowed, &gt;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 &gt; 1 → SCREEN : c = tint + (1tint)×(factor1)×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;
}
}
}