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

55 lines
2 KiB
C#

using Godot;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Raw height-field I/O: little-endian float32, x-major (<c>index = x * MapSize + y</c>).
///
/// ⭐ THIS FILE IS WHY PRESENTATION AND GENERATION STAY SEPARATE. A relief render reads a dumped
/// height field and re-colours it; it never re-runs the generator. So a look change provably
/// cannot move the terrain — not by convention, but because the colours are computed from a
/// file the renderer cannot write.
///
/// It also makes a byte-level port-fidelity oracle possible later: two generators agree, or
/// their dumps differ. → `Design - Tooling - Iteration and Batching.md`, "build the oracle
/// before the taste-iteration".
/// </summary>
public static class HeightField
{
public static void Save(float[,] height, int mapSize, string absolutePath)
{
using var f = Godot.FileAccess.Open(absolutePath, Godot.FileAccess.ModeFlags.Write);
if (f == null)
{
GD.PrintErr($"[HeightField] cannot write {absolutePath}: {Godot.FileAccess.GetOpenError()}");
return;
}
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
f.StoreFloat(height[x, y]);
}
/// <summary>Load a dump. Returns null if absent or the wrong size for <paramref name="mapSize"/>.</summary>
public static float[,] Load(string absolutePath, int mapSize)
{
if (!Godot.FileAccess.FileExists(absolutePath)) return null;
using var f = Godot.FileAccess.Open(absolutePath, Godot.FileAccess.ModeFlags.Read);
if (f == null) return null;
long expected = (long)mapSize * mapSize * 4;
if ((long)f.GetLength() != expected)
{
GD.PrintErr($"[HeightField] {absolutePath} is {f.GetLength()} bytes, expected {expected} " +
$"for MapSize {mapSize} — ignoring it rather than guessing.");
return null;
}
var h = new float[mapSize, mapSize];
for (int x = 0; x < mapSize; x++)
for (int y = 0; y < mapSize; y++)
h[x, y] = f.GetFloat();
return h;
}
}
}