diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs
index 81e67249..e3a09fca 100644
--- a/src/AcDream.Core/Terrain/LandblockMesh.cs
+++ b/src/AcDream.Core/Terrain/LandblockMesh.cs
@@ -56,27 +56,21 @@ public static class LandblockMesh
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
// Pre-sample all 81 heights into a 2D array (x-major indexing). This
- // doubles as the source for per-vertex normals via central differences
- // (Phase 3b lighting, preserved through the per-cell refactor).
+ // is also the source for retail's topology-aware vertex normals.
var heights = new float[HeightmapSide, HeightmapSide];
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
heights[x, y] = heightTable[block.Height[x * HeightmapSide + y]];
- // Pre-compute all 81 vertex normals so the inner cell loop is a pure
- // lookup. Central differences on the heightmap → smooth normal field.
- var normals = new Vector3[HeightmapSide, HeightmapSide];
- for (int x = 0; x < HeightmapSide; x++)
- for (int y = 0; y < HeightmapSide; y++)
- {
- int xL = Math.Max(x - 1, 0);
- int xR = Math.Min(x + 1, HeightmapSide - 1);
- int yD = Math.Max(y - 1, 0);
- int yU = Math.Min(y + 1, HeightmapSide - 1);
- float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
- float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
- normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
- }
+ // Retail CLandBlockStruct::calc_lighting accumulates the normalized
+ // plane normal of every incident terrain polygon at each of the 81
+ // shared height-sample vertices, then normalizes the sum. Use the same
+ // split hash and triangle topology as the emitted mesh; this changes
+ // lighting only, never positions, indices, or the collision surface.
+ var normals = BuildRetailVertexNormals(
+ heights,
+ landblockX,
+ landblockY);
var vertices = new TerrainVertex[VerticesPerLandblock];
var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication)
@@ -173,6 +167,85 @@ public static class LandblockMesh
return new LandblockMeshData(vertices, indices);
}
+ private static Vector3[,] BuildRetailVertexNormals(
+ float[,] heights,
+ uint landblockX,
+ uint landblockY)
+ {
+ var normalSums = new Vector3[HeightmapSide, HeightmapSide];
+
+ for (int cy = 0; cy < CellsPerSide; cy++)
+ {
+ for (int cx = 0; cx < CellsPerSide; cx++)
+ {
+ var posBL = new Vector3( cx * CellSize, cy * CellSize, heights[cx, cy ]);
+ var posBR = new Vector3((cx + 1) * CellSize, cy * CellSize, heights[cx + 1, cy ]);
+ var posTR = new Vector3((cx + 1) * CellSize, (cy + 1) * CellSize, heights[cx + 1, cy + 1]);
+ var posTL = new Vector3( cx * CellSize, (cy + 1) * CellSize, heights[cx, cy + 1]);
+
+ var split = TerrainBlending.CalculateSplitDirection(
+ landblockX, (uint)cx, landblockY, (uint)cy);
+
+ if (split == CellSplitDirection.SWtoNE)
+ {
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posBR, cx + 1, cy,
+ posTR, cx + 1, cy + 1);
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posTR, cx + 1, cy + 1,
+ posTL, cx, cy + 1);
+ }
+ else
+ {
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posBR, cx + 1, cy,
+ posTL, cx, cy + 1);
+ AccumulateFaceNormal(
+ normalSums,
+ posBR, cx + 1, cy,
+ posTR, cx + 1, cy + 1,
+ posTL, cx, cy + 1);
+ }
+ }
+ }
+
+ var normals = new Vector3[HeightmapSide, HeightmapSide];
+ for (int x = 0; x < HeightmapSide; x++)
+ {
+ for (int y = 0; y < HeightmapSide; y++)
+ {
+ Vector3 sum = normalSums[x, y];
+ normals[x, y] = sum.LengthSquared() > 0f
+ ? Vector3.Normalize(sum)
+ : Vector3.UnitZ;
+ }
+ }
+
+ return normals;
+ }
+
+ private static void AccumulateFaceNormal(
+ Vector3[,] normalSums,
+ Vector3 p0, int x0, int y0,
+ Vector3 p1, int x1, int y1,
+ Vector3 p2, int x2, int y2)
+ {
+ Vector3 cross = Vector3.Cross(p1 - p0, p2 - p0);
+ if (cross.LengthSquared() <= 0f)
+ return;
+
+ Vector3 faceNormal = Vector3.Normalize(cross);
+ normalSums[x0, y0] += faceNormal;
+ normalSums[x1, y1] += faceNormal;
+ normalSums[x2, y2] += faceNormal;
+ }
+
private static void WriteCell(
TerrainVertex[] verts, ref int vi,
uint d0, uint d1, uint d2, uint d3,
diff --git a/src/AcDream.Core/Terrain/TerrainVertex.cs b/src/AcDream.Core/Terrain/TerrainVertex.cs
index a031e837..69775c23 100644
--- a/src/AcDream.Core/Terrain/TerrainVertex.cs
+++ b/src/AcDream.Core/Terrain/TerrainVertex.cs
@@ -11,11 +11,11 @@ namespace AcDream.Core.Terrain;
/// which of the 4 cell corners a given vertex represents from
/// gl_VertexID % 6 plus the split direction bit.
///
-/// Normal is stored per vertex via Phase 3b's central-difference scheme on
-/// the 9×9 heightmap — this lets the fragment shader interpolate a smooth
-/// normal across triangles (softer than WorldBuilder's dFdx/dFdy
-/// flat-shaded approach). UVs are derived from the corner index in the
-/// vertex shader — not stored here.
+/// Normal is stored per vertex using retail's terrain-lighting rule: each
+/// shared height-sample vertex receives the normalized plane normals of its
+/// incident, split-aware terrain triangles and normalizes their sum. The
+/// fragment shader interpolates that smooth result across triangles. UVs are
+/// derived from the corner index in the vertex shader — not stored here.
///
/// Size: 12 (position) + 12 (normal) + 4*4 (Data0..3) = 40 bytes.
///