Presentation only. No generation file is touched — Topography, TerrainNoise, IslandFalloff, Pass1Result, TerrainGenConfig and GenerationScale are all unchanged. Batch naming, fixed in code. The prefix is the AUTHORING TASK number, not a running counter: 04_review means "the batch task 04 authored", not "the fifth batch". It had already drifted — tasks 02 and 03 produced 00_smoke through 05_trophy_10240 across two tasks, so no folder name said which task made what. Now the task number is an explicit argument to ToolingPaths.BatchRoot(taskNumber, descriptor), which composes the prefix itself and REFUSES a descriptor that carries its own. All three tools take it as ISLA_TASK. Verified: ISLA_BATCH=05_foo is refused with a stated reason and exit 2, and creates no folder. Also fixed: an exception out of _Ready does not stop Godot — it logs and the process sits there with no main loop, so a misconfigured run HUNG rather than failing. A hang looks like slow work, which is worse than a crash. The batch tools now catch, print what was refused, and exit non-zero. Grayscale mode: normalize a field to its own [min,max]. This is the one place per-image normalization is correct — everywhere else anchors are fixed so images compare, but here the point is to see one field at full contrast. The range is printed and indexed so a shade reads back to a height. You cannot judge noise through a palette: a ramp bends the distribution, a hillshade adds shape the data does not have. The wide Costa-Rica palette, and the reframing the developer asked for: the pretty map is the WIDE GRADIENT RENDERED FLAT, and relief is no longer the hero. On raw pass-1 noise a hillshade has nothing coherent to shade, so it renders fine fractal bumpiness as fuzz that actively hides the elevation the colour is showing. Strong hillshade is retained as diagnostic_relief and labelled a dev view — its bumpiness is exaggerated slope, not extra terrain. Subtle relief is kept for comparison, with the honest note that it still fuzzes until Phase 2 carves coherent landforms. Legend: a colour bar with ticks drawn from the palette's own ramp, so it cannot drift from the map beside it. Ticks are RELATIVE height and the image says NOT METRES — the conversion is Phase 2's, and labelling it "m" would invent a fact in the artefact a reader most trusts. Text comes from a 5x7 bitmap font written for this, specifically so a legend does not drag in the SubViewport capture path.
127 lines
5.5 KiB
C#
127 lines
5.5 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)
|
||
{
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Render to an in-memory image, so a caller can composite onto it (a legend, say) before
|
||
/// saving. ⚠ With <c>HillshadeStrength = 0</c> this is a FLAT hypsometric tint and no relief
|
||
/// at all — which is the intended hero render, not a degenerate case.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|