acdream/src/AcDream.Core/Terrain/LandblockMesh.cs
Erik 35b37dfb5f chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments
Triage step from the plan at C:\Users\erikn\.claude\plans\
i-did-some-work-sharded-acorn.md. Four sessions on issue #98 left the
worktree dirty with ~1352 LOC of mixed work. This commit splits the
work into "keep" (defensible + diagnostic) and "drop" (failed
experiments), then commits the keep set with the drops removed.

Plan asked for three commits (diag / fix / revert); consolidated to one
because the diagnostic emits in TransitionTypes.cs are tightly
interleaved with the multi-sphere CellTransit calls and the CellId
switch. Hunk-level splitting in those files for marginal bisect
granularity didn't justify the misclick risk.

Reverted entirely (failed experiments per slice 7 handoff):
- src/AcDream.Core/Physics/PhysicsDataCache.cs — neg-poly storage
  fields (Stippling, PosSurface, NegSurface, HasNegativeSide,
  IsNegativeSide, NegativeSide).
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs — isBuilding flag
  propagation through Register / ShadowEntry.
- tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — 165 lines of
  PolygonWithNegativeSide_* tests.
- tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs —
  isBuilding propagation tests.
- src/AcDream.Core/World/WorldEntity.cs — IsLandblockBuilding field
  (no consumer once ShadowObjectRegistry.isBuilding is gone).
- src/AcDream.Core/World/LandblockLoader.cs — IsLandblockBuilding=true
  setter on building entities (kept BuildBuildingTerrainCells).
- src/AcDream.App/Rendering/GameWindow.cs — isBuilding: arg passed to
  ShadowObjects.Register.
- src/AcDream.Core/Physics/BSPQuery.cs — TryAdjustWalkableSide /
  IsWalkableAt helpers, their callers, the Path 5 / Path 6 neg-poly
  branch split, the BldgCheck-tied clearCell conditional, and the
  neg-poly ResolveCellPolygons writes.
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — neg-poly fields
  in the poly-dump format.
- src/AcDream.Core/Physics/TransitionTypes.cs — SpherePath.BldgCheck +
  SpherePath.HitsInteriorCell fields and every consumer, the
  savedBldgCheck try/finally around FindCollisions, and the neg-poly
  format additions to the dump-on-error helper.
- src/AcDream.Core/Physics/CellTransit.cs — FindCellSet overloads
  with hitsInteriorCell out-param and the BuildCellSetAndPickContaining
  out-param threading.

Kept (defensible correctness fixes + diagnostic infrastructure):
- src/AcDream.App/Rendering/GameWindow.cs — render-vs-physics cell
  origin split: the 0.02m render lift no longer leaks into physics
  BSP caching. lb.BuildingTerrainCells threaded into LandblockMesh.Build.
- src/AcDream.Core/World/LoadedLandblock.cs — BuildingTerrainCells
  record field.
- src/AcDream.Core/World/LandblockLoader.cs — BuildBuildingTerrainCells
  (cy*8+cx from LandBlockInfo.Buildings).
- src/AcDream.Core/Terrain/LandblockMesh.cs — hiddenTerrainCells
  param that collapses owned-cell triangles to a zero-area degenerate.
- src/AcDream.App/Streaming/{GpuWorldState,LandblockStreamer}.cs —
  mechanical BuildingTerrainCells threading through LoadedLandblock
  reconstructions.
- src/AcDream.Core/Physics/CellTransit.cs — multi-sphere
  FindTransitCellsSphere variant + multi-sphere AddAllOutsideCells +
  FindCellSet(IReadOnlyList<Sphere>, …) overload + the
  BSPQuery.SphereIntersectsCellBsp call for loaded neighbours. Matches
  retail CObjCell::find_cell_list / CEnvCell::find_transit_cells.
- src/AcDream.Core/Physics/TransitionTypes.cs — multi-sphere FindCellSet
  call site, retail-faithful CellId switch after CheckOtherCells, the
  outdoor-landcell terrain-walkable fallback in CheckOtherCells, and
  the full diagnostic suite ([step-walk], [walkable-nearest],
  [issue98-walkable-detail], [cell-set-summary], LastBspHitPoly
  emits).
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — ProbeStepWalkEnabled
  gate (ACDREAM_PROBE_STEP_WALK=1) + LogStepWalk helper + FormatVector
  / FormatPlane utilities. All emit-gated.
- src/AcDream.Core/Physics/BSPQuery.cs — diagnostic emits to
  LastBspHitPoly at four sites in SphereIntersectsPolyInternal /
  the placement adjustment path.
- Test files for the kept work: CellTransitFindCellSetTests,
  CellTransitFindTransitCellsSphereTests, PhysicsDiagnosticsTests,
  TransitionCheckOtherCellsTests, LandblockMeshTests,
  LandblockLoaderTests.

Verification:
- dotnet build: green, 0 errors, 3 pre-existing warnings.
- dotnet test: 1156 passed + 8 failed (baseline was 1148 + 8 pre-
  existing; the +8 passing are the new tests for the kept defensible
  work). Same 8 pre-existing failures, no new regressions.

Backup of pre-triage worktree state in stash@{0}.

A6.P3 #98 is still open; this is the apparatus-prep step, not a fix.
Next: cell-dump probe (Step 2 of the plan).
2026-05-23 15:11:49 +02:00

207 lines
9.9 KiB
C#
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.

using System.Numerics;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Terrain;
/// <summary>
/// Terrain mesh data for a single landblock: 64 cells × 6 vertices per cell =
/// 384 vertices. All 6 vertices of a cell share the same <c>Data0..Data3</c>
/// blend recipe; the vertex shader derives UVs and corner index from
/// <c>gl_VertexID % 6</c> plus the split-direction bit.
/// </summary>
public sealed record LandblockMeshData(TerrainVertex[] Vertices, uint[] Indices);
public static class LandblockMesh
{
public const int HeightmapSide = 9; // 9×9 heightmap samples
public const int CellsPerSide = 8; // 8×8 cells per landblock
public const int VerticesPerCell = 6; // two triangles
public const int VerticesPerLandblock = CellsPerSide * CellsPerSide * VerticesPerCell; // 384
public const float CellSize = 24.0f;
public const float LandblockSize = CellsPerSide * CellSize; // 192
// TerrainInfo bit layout: bits 0-1 Road, bits 2-6 Type (5-bit
// TerrainTextureType), bits 11-15 Scenery. Road flag is the 2-bit field
// at the LSB; AC's per-corner road value for GetPalCode takes the mask
// as an int 0..3.
private const int RoadMask = 0x3;
private const int TypeShift = 2;
private const int TypeMask = 0x1F;
/// <summary>
/// Build a per-cell terrain mesh for one landblock. Each cell is looked
/// up in the shared <paramref name="surfaceCache"/> by palette code; only
/// palette codes not yet seen in this scene call
/// <see cref="TerrainBlending.BuildSurface"/>.
/// </summary>
/// <param name="block">Landblock dat record (heightmap + terrain info).</param>
/// <param name="landblockX">Landblock X coord (high byte of landblock id) for split-direction hashing.</param>
/// <param name="landblockY">Landblock Y coord (second byte of landblock id).</param>
/// <param name="heightTable">Region.LandDefs.LandHeightTable — 256 float heights.</param>
/// <param name="ctx">TerrainAtlas-derived blending inputs.</param>
/// <param name="surfaceCache">Shared SurfaceInfo cache keyed by palette code.</param>
/// <param name="hiddenTerrainCells">Optional cell indices (cy * 8 + cx) to draw as zero-area triangles.</param>
public static LandblockMeshData Build(
LandBlock block,
uint landblockX,
uint landblockY,
float[] heightTable,
TerrainBlendingContext ctx,
System.Collections.Generic.IDictionary<uint, SurfaceInfo> surfaceCache,
System.Collections.Generic.IReadOnlySet<int>? hiddenTerrainCells = null)
{
ArgumentNullException.ThrowIfNull(block);
ArgumentNullException.ThrowIfNull(heightTable);
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(surfaceCache);
if (heightTable.Length < 256)
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).
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));
}
var vertices = new TerrainVertex[VerticesPerLandblock];
var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication)
int vi = 0;
for (int cy = 0; cy < CellsPerSide; cy++)
{
for (int cx = 0; cx < CellsPerSide; cx++)
{
// Four corner TerrainInfo samples (x-major block.Terrain[x*9+y]).
var tBL = block.Terrain[cx * HeightmapSide + cy];
var tBR = block.Terrain[(cx + 1) * HeightmapSide + cy];
var tTR = block.Terrain[(cx + 1) * HeightmapSide + (cy + 1)];
var tTL = block.Terrain[cx * HeightmapSide + (cy + 1)];
int rBL = tBL.Road & RoadMask;
int rBR = tBR.Road & RoadMask;
int rTR = tTR.Road & RoadMask;
int rTL = tTL.Road & RoadMask;
int ttBL = (int)tBL.Type & TypeMask;
int ttBR = (int)tBR.Type & TypeMask;
int ttTR = (int)tTR.Type & TypeMask;
int ttTL = (int)tTL.Type & TypeMask;
// WorldBuilder's palCode convention: t1=BL, t2=BR, t3=TR, t4=TL.
uint palCode = TerrainBlending.GetPalCode(
rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL);
// Lookup-or-build pattern. Not atomic under concurrent access
// (TryGetValue then assign), but BuildSurface is deterministic —
// two workers building the same palCode produce equal SurfaceInfo,
// last-write-wins is benign.
if (!surfaceCache.TryGetValue(palCode, out var surf))
{
surf = TerrainBlending.BuildSurface(palCode, ctx);
surfaceCache[palCode] = surf;
}
var split = TerrainBlending.CalculateSplitDirection(
landblockX, (uint)cx, landblockY, (uint)cy);
var (d0, d1, d2, d3) = TerrainBlending.FillCellData(surf, split);
// Corner positions in landblock-local space.
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 nBL = normals[cx, cy];
var nBR = normals[cx + 1, cy];
var nTR = normals[cx + 1, cy + 1];
var nTL = normals[cx, cy + 1];
// Emit 6 vertices in an order matching ACE's
// LandblockStruct.ConstructPolygons triangulation. The vertex
// shader maps gl_VertexID % 6 → corner index for UV lookup,
// so the CPU order and shader table must stay in lockstep.
//
// SWtoNE (splitDir=0, SWtoNEcut=true): diagonal BL → TR.
// Triangles: {BL,BR,TR} + {BL,TR,TL} (shared edge BL-TR)
// vIdx: 0 1 2 3 4 5
// corner: 0 1 2 0 2 3
// BL BR TR BL TR TL
// SEtoNW (splitDir=1, SWtoNEcut=false): diagonal BR → TL.
// Triangles: {BL,BR,TL} + {BR,TR,TL} (shared edge BR-TL)
// vIdx: 0 1 2 3 4 5
// corner: 0 1 3 1 2 3
// BL BR TL BR TR TL
//
// 2026-04-21 fix: previous mapping had the enum→geometry
// inversion — SWtoNE built NW-SE-diagonal triangles (ACE's
// SEtoNW geometry) and vice versa, causing remote players
// to hover/clip on sloped cells by up to ~1m.
if (split == CellSplitDirection.SWtoNE)
{
WriteCell(vertices, ref vi, d0, d1, d2, d3,
posBL, nBL, posBR, nBR, posTR, nTR,
posBL, nBL, posTR, nTR, posTL, nTL);
}
else
{
WriteCell(vertices, ref vi, d0, d1, d2, d3,
posBL, nBL, posBR, nBR, posTL, nTL,
posBR, nBR, posTR, nTR, posTL, nTL);
}
}
}
// Indices are trivial 0..383 since we don't deduplicate verts. When
// a building owns an outdoor terrain cell, keep the fixed 384-index
// contract but collapse its two triangles so the building/stair mesh
// can visually own the hole.
for (uint i = 0; i < VerticesPerLandblock; i++)
{
int cellIdx = (int)i / VerticesPerCell;
if (hiddenTerrainCells is not null && hiddenTerrainCells.Contains(cellIdx))
{
indices[i] = (uint)(cellIdx * VerticesPerCell);
continue;
}
indices[i] = i;
}
return new LandblockMeshData(vertices, indices);
}
private static void WriteCell(
TerrainVertex[] verts, ref int vi,
uint d0, uint d1, uint d2, uint d3,
Vector3 p0, Vector3 n0,
Vector3 p1, Vector3 n1,
Vector3 p2, Vector3 n2,
Vector3 p3, Vector3 n3,
Vector3 p4, Vector3 n4,
Vector3 p5, Vector3 n5)
{
verts[vi++] = new TerrainVertex(p0, n0, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p1, n1, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p2, n2, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p3, n3, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p4, n4, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p5, n5, d0, d1, d2, d3);
}
}