acdream/docs/research/evidence/vm0/baseline-normals.patch
Erik 30e72a2af8 research(vm0): Campaign AR's pack-off path IS the pre-campaign renderer - PASS
Exact-pixel and production-perf comparison of 6c79d35c (+ only the A2
normal files, so no terrain mask is needed) against c51b07ef with 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>
2026-08-22 21:40:40 +02:00

149 lines
6.7 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>