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.
110 lines
5.2 KiB
C#
110 lines
5.2 KiB
C#
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 2048–10240 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);
|
||
}
|
||
}
|