LandblockMesh was hardcoding Normal=Vector3.UnitZ for every vertex,
which meant Phase 3a's directional lighting gave every terrain fragment
the same brightness — flat-looking terrain regardless of slope.
Now computes a real per-vertex normal by sampling the 4 heightmap
neighbors and taking central differences on the heights. The surface
is z=h(x,y), tangents are Sx=(1,0,dh/dx) and Sy=(0,1,dh/dy), and the
normal is their cross product: (-dh/dx, -dh/dy, 1) normalized. Edge
vertices use forward/backward difference instead of central.
Heightmap is pre-sampled into a 9x9 float grid before the vertex loop
so neighbor lookups don't hit the heightTable dictionary-style 81 times
per vertex — one pass to precompute, one pass to emit vertices.
Existing tests still pass: flat landblocks produce flat normals
(constant heights → zero derivatives → UnitZ), so the Phase 1 tests'
"all vertices same Z" assertion remains accurate.
Combined with 3268556 (lighting), terrain hills now visually catch the
sun on their sunward slopes and darken on shadowed slopes. Holtburg's
gentle rolling hills should look considerably more three-dimensional.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>