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)
{
Image img = Render(height, mapSize, look);
Error err = img.SavePng(absolutePath);
if (err != Error.Ok) GD.PrintErr($"[ReliefRenderer] SavePng failed ({err}) for {absolutePath}");
return absolutePath;
}
///
/// Render to an in-memory image, so a caller can composite onto it (a legend, say) before
/// saving. ⚠ With HillshadeStrength = 0 this is a FLAT hypsometric tint and no relief
/// at all — which is the intended hero render, not a degenerate case.
///
public static Image Render(float[,] height, int mapSize, LookConfig look)
{
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)));
}
}
return img;
}
}
}