feat(core): per-vertex terrain normals via central differences (Phase 3b)

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>
This commit is contained in:
Erik 2026-04-10 22:23:35 +02:00
parent 3268556bd0
commit c95481ea69

View file

@ -24,6 +24,14 @@ public static class LandblockMesh
if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
// Precompute all 81 heights in (x,y) grid order so we can do cheap
// neighbor lookups when computing per-vertex normals by central differences.
// Heights are packed x-major (Height[x*9+y]) matching the fix in cc55c3f.
var heights = new float[VerticesPerSide, VerticesPerSide];
for (int x = 0; x < VerticesPerSide; x++)
for (int y = 0; y < VerticesPerSide; y++)
heights[x, y] = heightTable[block.Height[x * VerticesPerSide + y]];
var vertices = new Vertex[VerticesPerSide * VerticesPerSide];
for (int y = 0; y < VerticesPerSide; y++)
{
@ -31,8 +39,7 @@ public static class LandblockMesh
{
int vi = y * VerticesPerSide + x;
int hi = x * VerticesPerSide + y;
float height = heightTable[block.Height[hi]];
float height = heights[x, y];
// TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type (5-bit
// TerrainTextureType enum), bits 11-15 Scenery. The atlas keys on
@ -42,9 +49,21 @@ public static class LandblockMesh
if (!terrainTypeToLayer.TryGetValue(terrainType, out var layer))
layer = 0;
// Per-vertex normal from central differences on the heightmap.
// Surface is z=h(x,y); tangents Sx=(1,0,dh/dx), Sy=(0,1,dh/dy);
// normal = Sx x Sy = (-dh/dx, -dh/dy, 1), normalized.
// Edge vertices use forward/backward difference instead of central.
int xL = Math.Max(x - 1, 0);
int xR = Math.Min(x + 1, VerticesPerSide - 1);
int yD = Math.Max(y - 1, 0);
int yU = Math.Min(y + 1, VerticesPerSide - 1);
float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
var normal = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
vertices[vi] = new Vertex(
Position: new Vector3(x * CellSize, y * CellSize, height),
Normal: Vector3.UnitZ,
Normal: normal,
TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor),
TerrainLayer: layer);
}