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