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.
116 lines
5.1 KiB
C#
116 lines
5.1 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|