From c95481ea694c4d8a508231987817aecc365edd2b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 22:23:35 +0200 Subject: [PATCH] feat(core): per-vertex terrain normals via central differences (Phase 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core/Terrain/LandblockMesh.cs | 25 ++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index 860353e..13e51d5 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -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); }