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. ///