Exact-pixel and production-perf comparison of6c79d35c(+ only the A2 normal files, so no terrain mask is needed) againstc51b07efwith no pack: connected as +Acdream, visible window, one isolated config clone per variant, pinned clocks. Open field: the only differences are idle pose, mana digits and a passing flyer. Holtburg: same-binary-twice defines the dynamic mask (9.8%); in the static 90% both self-diffs have ZERO pixels with |d|>=8 while base+normals vs HEAD-off has 841/729 - all streaks inside the animated lifestone. Buildings, ground, trees, sky and UI are clean. Perf (uncapped Release, no automation observer, ACDREAM_FRAME_PROF=1): Holtburg CPU p50 4.7 -> 4.1 ms, Arwic 6.0 -> 5.2 ms, GPU unchanged, alloc/frame 574 KB -> 21 KB. No regression; F5b's '27.8 ms retail CPU' was the observer. Three false alarms recorded so nobody repeats them: the isolated gate settings lack fieldOfView (90 vs the real 86.33 -> a 0.952 zoom); the real %APPDATA% settings still selected acdream.atmospheric/low (pack ON); a minimized GLFW window is throttled and never settles. Tools: tools/vm0/capture-visible.ps1 (pre-campaign gate + -Exe/-Live/ -ConfigDir/-CharacterName/-PreCaptureCommand), tools/vm0/perf-run.sh, and -BuildingDetailTextures on run-offline-pixel-gate.ps1. Baseline patches under docs/research/evidence/vm0/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
149 lines
6.7 KiB
Diff
149 lines
6.7 KiB
Diff
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
|
||
/// <c>gl_VertexID % 6</c> 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 <c>dFdx</c>/<c>dFdy</c>
|
||
-/// 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.
|
||
/// </summary>
|