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

110 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
namespace IslaApocalypse.Tools
{
/// <summary>
/// Classic cartographic SHADED RELIEF from a height field — the slope+aspect view.
///
/// ⚠ PRESENTATION ONLY. → `Design - Rendering - Roughness Is Presentation.md`. Nothing here
/// touches how height is GENERATED; it changes only how height is SHOWN. A hillshade parameter
/// is a look dial, never a claim about the world.
///
/// ═══ THE METHOD ═══
///
/// HORN'S 3x3 method — the standard for DEM shading, and more robust than a plain central
/// difference because it weights the four edge-adjacent neighbours double, so single-pixel noise
/// does not dominate the normal. Cell spacing is 1: one column per pixel, 1 m per column
/// (D-002, 1:1 scale).
///
/// a b c dz/dx = ((c + 2f + i) - (a + 2d + g)) / 8
/// d e f dz/dy = ((g + 2h + i) - (a + 2b + c)) / 8
/// g h i
///
/// ⚠ y INCREASES SOUTHWARD, matching the height field and the rendered image. So dz/dy is a
/// north→south gradient, and the light's north component points toward -y.
///
/// ⚠ EDGES CLAMP, THEY DO NOT WRAP. Sampling is replicated at the border. Wrapping would fold
/// the Trench's outer wall against the opposite edge and smear a hard, deliberate boundary into
/// a false slope — the Trench border is a wall, not a seam.
///
/// ═══ ⚠⚠ WHY VERTICAL EXAGGERATION IS NOT OPTIONAL HERE ═══
///
/// The height field is in RAW NOISE UNITS (sea 0.15, peaks ~1.45) over a 204810240 px map, so
/// the true per-pixel gradient is tiny. Measured on seed 1063685222 at 2048, over 135k land
/// samples: MEDIAN |grad| = 0.0038, i.e. a slope of **0.22°**. Un-exaggerated, the entire island
/// shades as a flat plane. That is not a defect in the terrain; it is what a 1:1 metre grid
/// looks like.
///
/// So <see cref="LookConfig.ZExaggeration"/> is a required look dial. Measured slopes at
/// candidate values (median / p90 / p99 of land):
///
/// zex 25 → 5.5° / 9.9° / 14.2° too flat to read
/// zex 75 → 16.0° / 27.6° / 37.1° natural, atlas-like ← default
/// zex 120 → 25.3° / 42.0° / 52.0° dramatic, shape-forward
/// zex 300 → 49.0° / 64.5° / 71.7° cartoon; the tint is lost
///
/// ⚠ IT IS A LOOK DIAL, NOT A PHYSICAL CLAIM. The raw units are not metres. The metres-per-unit
/// conversion belongs to Phase 2's elevation profile, not to this file, and no code should read
/// ZExaggeration as if it meant something about the world.
/// </summary>
public static class Hillshade
{
/// <summary>
/// Compute the hillshade factor at (x, y): the cosine of the angle between the surface
/// normal and the light, clamped to [0, 1]. 0 = fully shadowed, 1 = face-on to the light.
///
/// On PERFECTLY FLAT ground this returns <c>sin(altitude)</c> — not 1. That value is the
/// neutral point the blend divides by, so flat ground keeps its true hypsometric tint.
/// → <see cref="Neutral"/>.
/// </summary>
public static float At(float[,] h, int n, int x, int y, float zExaggeration,
float lightX, float lightY, float lightZ)
{
// Clamped 3x3 window — replicate at the border, never wrap.
int xm = x > 0 ? x - 1 : 0, xp = x < n - 1 ? x + 1 : n - 1;
int ym = y > 0 ? y - 1 : 0, yp = y < n - 1 ? y + 1 : n - 1;
float hA = h[xm, ym], hB = h[x, ym], hC = h[xp, ym];
float hD = h[xm, y], hF = h[xp, y];
float hG = h[xm, yp], hH = h[x, yp], hI = h[xp, yp];
float dzdx = ((hC + 2f * hF + hI) - (hA + 2f * hD + hG)) / 8f;
float dzdy = ((hG + 2f * hH + hI) - (hA + 2f * hB + hC)) / 8f;
// Surface normal of z = height * zExaggeration, before normalization.
float nx = -dzdx * zExaggeration;
float ny = -dzdy * zExaggeration;
const float nz = 1f;
float inv = 1f / MathF.Sqrt(nx * nx + ny * ny + nz * nz);
float dot = (nx * lightX + ny * lightY + nz * lightZ) * inv;
return dot < 0f ? 0f : (dot > 1f ? 1f : dot);
}
/// <summary>
/// The unit vector pointing FROM the surface TOWARD the light.
///
/// Azimuth is compass degrees, measured CLOCKWISE FROM NORTH — 315° is the cartographic
/// standard, light from the north-west. Altitude is degrees above the horizon; 45° standard.
///
/// North is -y in image space (y increases southward), which is why the y component is
/// negated. Get this wrong and the relief inverts: ridges read as valleys, which the eye
/// notices instantly even when it cannot say why.
/// </summary>
public static (float x, float y, float z) LightVector(float azimuthDegrees, float altitudeDegrees)
{
float az = azimuthDegrees * MathF.PI / 180f;
float alt = altitudeDegrees * MathF.PI / 180f;
float cosAlt = MathF.Cos(alt);
return (cosAlt * MathF.Sin(az), -cosAlt * MathF.Cos(az), MathF.Sin(alt));
}
/// <summary>
/// The hillshade value flat ground returns — <c>sin(altitude)</c>. The blend normalizes by
/// this so that flat terrain is neither darkened nor lightened, and only actual SLOPE moves
/// the colour away from its hypsometric tint.
/// </summary>
public static float Neutral(float altitudeDegrees) => MathF.Sin(altitudeDegrees * MathF.PI / 180f);
}
}