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.
55 lines
2 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|