acdream/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs
Erik 96a425a9a5 fix #108-residual (root cause): terrain drew DOUBLE-SIDED - port retail landPolysDraw eye-side gate as terrain backface cull
The cellar-ascent grass window was the UNDERSIDE of the z~94 grade
sheet. Retail terrain is single-sided: ACRender::landPolysDraw
(0x006b7040) draws each land triangle ONLY when the camera is on the
POSITIVE (upper) side of its plane (Plane::which_side2 vs
Render::FrameCurrent, zFightTerrainAdjust bias) - a below-grade eye
gets NO terrain, so retail shows sky through the cellar door.

We inherited WB's frame-global cull DISABLE (WB GameScene.cs:841 - an
editor camera goes underground by design) and TerrainModernRenderer.Draw
set no cull state of its own -> terrain rasterized both sides. From a
below-grade eye every aperture sight-ray RISES, so the only 'terrain'
it can see is the grade sheet's underside - which painted the exit-door
aperture (the landscape slice's 2D NDC clip planes (nx,ny,0,dw) have no
depth axis and cannot exclude between-eye-and-portal geometry) and slid
off the door exactly as the eye crossed grade. Membership/viewer was
exonerated by the harness in the previous commit.

Fix: TerrainModernRenderer.Draw owns its cull state (the 7th
self-contained-GL-state instance): Enable(CullFace) + CullFace(Back) +
FrontFace(Ccw), set -> draw -> restore the frame-global CW + cull-off
baseline. GL backface culling evaluates retail's per-triangle eye-side
predicate at rasterization; no shader change.

Pins:
- LandblockMeshTests.Build_AllTriangles_WindCounterClockwiseInWorldXY:
  every emitted triangle CCW in world XY across both FSplitNESW split
  directions - the winding invariant culling depends on.
- TerrainCullOrientationTests: under the production camera convention
  (LookAt up=+Z, Numerics perspective) an up-facing triangle winds CCW
  in window space from above (kept) and CW from below (culled) - guards
  FrontFace inversion, which would blank terrain from above.

Oracle note: retail's through-portal clip has NO portal-face near plane
(PView::GetClip / Render::set_view install edge planes only); nearer-
than-portal exclusion comes from the eye-side cull + cell-level
admission. No register row: this PORTS the retail mechanism, retiring
an undocumented WB-heritage deviation.

Gate pending: cellar climb (grass window gone) + outdoor sanity glance
(terrain intact from above).

Suites: App 263+1skip / Core 1443+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:05:31 +02:00

229 lines
9.3 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 AcDream.Core.Terrain;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Terrain;
public class LandblockMeshTests
{
/// <summary>
/// Synthetic height table with a * 2.0f scale (mirrors Phase 1's ramp so
/// existing test intuition carries through the Phase 3c rewrite).
/// </summary>
private static readonly float[] IdentityHeightTable =
Enumerable.Range(0, 256).Select(i => i * 2f).ToArray();
private static TerrainBlendingContext MakeContext() => new(
TerrainTypeToLayer: new Dictionary<uint, byte> { [0u] = 0 },
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: Array.Empty<byte>(),
SideAlphaLayers: Array.Empty<byte>(),
RoadAlphaLayers: Array.Empty<byte>(),
CornerAlphaTCodes: Array.Empty<uint>(),
SideAlphaTCodes: Array.Empty<uint>(),
RoadAlphaRCodes: Array.Empty<uint>());
private static LandBlock BuildFlatLandBlock(byte heightIndex = 0)
{
var block = new LandBlock
{
HasObjects = false,
Terrain = new TerrainInfo[81],
Height = new byte[81],
};
for (int i = 0; i < 81; i++)
{
block.Terrain[i] = (ushort)0;
block.Height[i] = heightIndex;
}
return block;
}
[Fact]
public void Build_FlatBlock_Produces384VerticesAnd128Triangles()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
// 64 cells × 6 vertices per cell = 384
Assert.Equal(384, mesh.Vertices.Length);
// Each cell emits 2 triangles = 6 indices, 64 cells → 384 indices (= 128 triangles)
Assert.Equal(128 * 3, mesh.Indices.Length);
}
[Fact]
public void Build_Vertices_CoverExactly192x192WorldUnits()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
var minX = mesh.Vertices.Min(v => v.Position.X);
var maxX = mesh.Vertices.Max(v => v.Position.X);
var minY = mesh.Vertices.Min(v => v.Position.Y);
var maxY = mesh.Vertices.Max(v => v.Position.Y);
Assert.Equal(0.0f, minX);
Assert.Equal(192.0f, maxX);
Assert.Equal(0.0f, minY);
Assert.Equal(192.0f, maxY);
}
[Fact]
public void Build_FlatBlock_AllVerticesSameZ()
{
var block = BuildFlatLandBlock(heightIndex: 10);
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
var zs = mesh.Vertices.Select(v => v.Position.Z).Distinct().ToArray();
Assert.Single(zs);
Assert.Equal(20.0f, zs[0]); // heightIndex 10 × IdentityHeightTable[10] = 20
}
[Fact]
public void Build_FlatBlock_NormalsPointStraightUp()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
foreach (var v in mesh.Vertices)
{
Assert.Equal(new Vector3(0, 0, 1), v.Normal);
}
}
[Fact]
public void Build_AllVerticesOfACellShareIdenticalData()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
// Vertices are emitted in strides of 6 per cell. Within each stride,
// Data0..3 must be identical — the vertex shader relies on that when
// it propagates the cell's blend recipe to all 3 fragment-shader outputs.
for (int cellIdx = 0; cellIdx < 64; cellIdx++)
{
int baseIdx = cellIdx * 6;
var d0 = mesh.Vertices[baseIdx].Data0;
var d1 = mesh.Vertices[baseIdx].Data1;
var d2 = mesh.Vertices[baseIdx].Data2;
var d3 = mesh.Vertices[baseIdx].Data3;
for (int i = 1; i < 6; i++)
{
Assert.Equal(d0, mesh.Vertices[baseIdx + i].Data0);
Assert.Equal(d1, mesh.Vertices[baseIdx + i].Data1);
Assert.Equal(d2, mesh.Vertices[baseIdx + i].Data2);
Assert.Equal(d3, mesh.Vertices[baseIdx + i].Data3);
}
}
}
[Fact]
public void Build_SurfaceCacheIsReusedAcrossIdenticalCells()
{
var block = BuildFlatLandBlock(); // every cell has identical all-zero corners
var cache = new Dictionary<uint, SurfaceInfo>();
LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
// A uniform flat landblock produces exactly ONE palette code (all
// corners are type 0, no roads) → BuildSurface called once, cache
// contains a single entry even though 64 cells were processed.
Assert.Single(cache);
}
[Fact]
public void Build_CellsWithDistinctTerrainTypes_ProducesDistinctPaletteCodes()
{
// Put a dirt cell (type 4) at the center of an otherwise grass landblock.
// Grass cells all share one palCode; the "dirt + grass border" cells
// around the center introduce additional palette codes.
var block = BuildFlatLandBlock();
// Type is at bits 2-6, so type=4 → ushort = (4 << 2) = 0x10.
block.Terrain[4 * 9 + 4] = (ushort)(4 << 2);
var ctx = new TerrainBlendingContext(
TerrainTypeToLayer: new Dictionary<uint, byte> { [0u] = 0, [4u] = 1 },
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: new byte[] { 0, 1, 2, 3 },
SideAlphaLayers: Array.Empty<byte>(),
RoadAlphaLayers: Array.Empty<byte>(),
CornerAlphaTCodes: new uint[] { 1, 2, 4, 8 },
SideAlphaTCodes: Array.Empty<uint>(),
RoadAlphaRCodes: Array.Empty<uint>());
var cache = new Dictionary<uint, SurfaceInfo>();
LandblockMesh.Build(block, 0, 0, IdentityHeightTable, ctx, cache);
// Should have more than one palette code now — uniform-grass cells
// plus at least one boundary cell with a non-zero corner type.
Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}");
}
[Fact]
public void Build_AllTriangles_WindCounterClockwiseInWorldXY()
{
// #108-residual winding pin: TerrainModernRenderer enables backface
// culling with FrontFace(Ccw) — the GL port of retail's single-sided
// terrain (ACRender::landPolysDraw 0x006b7040 draws a land triangle
// only when the eye is on the POSITIVE side of its plane). That cull
// is only correct if EVERY emitted triangle winds the same way:
// counter-clockwise in world XY viewed from above (+Z toward the
// viewer), i.e. cross2D(v1-v0, v2-v0) > 0. Varied heights + several
// landblock coords exercise both FSplitNESW split directions across
// the 64 cells. A future emission-order change that flips any
// triangle would silently punch terrain holes under culling.
var block = BuildFlatLandBlock();
for (int i = 0; i < 81; i++)
block.Height[i] = (byte)((i * 37) % 64); // varied, deterministic slopes
foreach (var (lbx, lby) in new[] { (0u, 0u), (0xA9u, 0xB4u), (3u, 7u) })
{
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, lbx, lby, IdentityHeightTable, MakeContext(), cache);
for (int t = 0; t < mesh.Indices.Length; t += 3)
{
var p0 = mesh.Vertices[mesh.Indices[t + 0]].Position;
var p1 = mesh.Vertices[mesh.Indices[t + 1]].Position;
var p2 = mesh.Vertices[mesh.Indices[t + 2]].Position;
float crossZ = (p1.X - p0.X) * (p2.Y - p0.Y) - (p1.Y - p0.Y) * (p2.X - p0.X);
Assert.True(crossZ > 0f,
$"lb=({lbx},{lby}) triangle {t / 3} winds CW in world XY (crossZ={crossZ}) — " +
"backface culling in TerrainModernRenderer would cull its TOP side");
}
}
}
[Fact]
public void Build_HeightmapPackedAsXMajor_NotYMajor()
{
// Regression from the Phase 1 → 2a transpose bug. The underlying
// heightmap is indexed x*9+y; testing this lives on even after the
// per-cell refactor because the corner lookup in the cell loop still
// reads block.Height[cx*9+cy] for the BL corner.
var block = BuildFlatLandBlock();
block.Height[2 * 9 + 0] = 5; // x=2, y=0 → world (48, 0), Z should be 10
var cache = new Dictionary<uint, SurfaceInfo>();
var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache);
// Search the vertex buffer for a vertex at world position (48, 0).
var atX48Y0 = mesh.Vertices.FirstOrDefault(v =>
Math.Abs(v.Position.X - 48f) < 0.01f && Math.Abs(v.Position.Y) < 0.01f);
var atX0Y48 = mesh.Vertices.FirstOrDefault(v =>
Math.Abs(v.Position.X) < 0.01f && Math.Abs(v.Position.Y - 48f) < 0.01f);
Assert.Equal(10.0f, atX48Y0.Position.Z);
Assert.Equal(0.0f, atX0Y48.Position.Z);
}
}