using System; namespace IslaApocalypse.Tools { /// /// 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 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. /// public static class Hillshade { /// /// 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 sin(altitude) — not 1. That value is the /// neutral point the blend divides by, so flat ground keeps its true hypsometric tint. /// → . /// 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); } /// /// 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. /// 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)); } /// /// The hillshade value flat ground returns — sin(altitude). 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. /// public static float Neutral(float altitudeDegrees) => MathF.Sin(altitudeDegrees * MathF.PI / 180f); } }