diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 243156e..d3b6277 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -94,7 +94,6 @@ public sealed class PlayerMovementController // Heartbeat timer. private float _heartbeatAccum; - private float _prevGroundZ; public const float HeartbeatInterval = 0.2f; // 200ms public bool HeartbeatDue { get; private set; } @@ -107,7 +106,6 @@ public sealed class PlayerMovementController { Position = pos; CellId = cellId; - _prevGroundZ = pos.Z; } public MovementResult Update(float dt, MovementInput input) @@ -206,31 +204,10 @@ public sealed class PlayerMovementController } } - // Multi-point Z sampling: on slopes the visual terrain mesh can be - // higher or lower than the center-point physics sample. Sample 4 - // points around the character (forward, back, left, right at ~0.7 - // units — roughly foot distance) and use the MAX Z. This prevents - // feet clipping on both uphill and downhill slopes. - if (!IsAirborne) - { - const float sampleDist = 0.7f; - ReadOnlySpan<(float ox, float oy)> offsets = stackalloc (float, float)[] - { - ( forwardX * sampleDist, forwardY * sampleDist), // forward - (-forwardX * sampleDist, -forwardY * sampleDist), // back - ( forwardY * sampleDist, -forwardX * sampleDist), // right - (-forwardY * sampleDist, forwardX * sampleDist), // left - }; - foreach (var (ox, oy) in offsets) - { - var sampleResult = _physics.Resolve( - new Vector3(result.Position.X + ox, result.Position.Y + oy, newZ), - CellId, Vector3.Zero, StepUpHeight); - if (sampleResult.IsOnGround && sampleResult.Position.Z > newZ) - newZ = sampleResult.Position.Z; - } - } - _prevGroundZ = newZ; + // Triangle-aware Z sampling in TerrainSurface now matches the visual + // terrain mesh exactly (using AC2D's FSplitNESW render formula + + // barycentric interpolation within each triangle). No multi-point + // sampling hacks needed. Position = new Vector3(result.Position.X, result.Position.Y, newZ); CellId = result.CellId; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e5148f1..1d6452a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1419,7 +1419,10 @@ public sealed class GameWindow : IDisposable // surfaces for this landblock. Runs under _datLock (same lock as the // rest of ApplyLoadedTerrainLocked) so dat reads are safe. { - var terrainSurface = new AcDream.Core.Physics.TerrainSurface(lb.Heightmap.Height, _heightTable); + uint lbPhysX = (lb.LandblockId >> 24) & 0xFFu; + uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu; + var terrainSurface = new AcDream.Core.Physics.TerrainSurface( + lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY); var cellSurfaces = new List(); var portalPlanes = new List(); diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index b1bc4cf..e127bfb 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -4,17 +4,14 @@ namespace AcDream.Core.Physics; /// /// Outdoor terrain height resolver for a single landblock. Performs -/// bilinear interpolation of the 9×9 heightmap grid to produce the -/// ground Z at any (localX, localY) within the 192×192 landblock -/// footprint. Also computes the outdoor cell ID for AC's position -/// encoding. +/// per-triangle barycentric Z interpolation matching the visual terrain +/// mesh's triangle split direction (AC2D's FSplitNESW formula). /// /// -/// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined -/// and not reusable). The heightmap is indexed x-major: -/// heights[x * 9 + y]; each byte is a lookup into -/// (256-entry float array from -/// Region.LandDefs.LandHeightTable). +/// Each cell (24×24 units) is split into two triangles along either the +/// SW→NE or SE→NW diagonal. The split direction is determined by the +/// same formula the render mesh uses (0x0CCAC033 constants from AC2D), +/// so the Z this class produces matches the visual terrain surface exactly. /// /// public sealed class TerrainSurface @@ -23,10 +20,12 @@ public sealed class TerrainSurface private const float CellSize = 24f; private const int CellsPerSide = 8; // 192 / 24 - private readonly byte[] _heights; - private readonly float[] _heightTable; + private readonly float[,] _z; // pre-resolved heights [x, y] + private readonly uint _landblockX; + private readonly uint _landblockY; - public TerrainSurface(byte[] heights, float[] heightTable) + public TerrainSurface(byte[] heights, float[] heightTable, + uint landblockX = 0, uint landblockY = 0) { ArgumentNullException.ThrowIfNull(heights); ArgumentNullException.ThrowIfNull(heightTable); @@ -35,40 +34,88 @@ public sealed class TerrainSurface if (heightTable.Length < 256) throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); - _heights = heights; - _heightTable = heightTable; + _landblockX = landblockX; + _landblockY = landblockY; + + // Pre-resolve all 81 heights so SampleZ is a pure lookup + lerp. + _z = new float[HeightmapSide, HeightmapSide]; + for (int x = 0; x < HeightmapSide; x++) + for (int y = 0; y < HeightmapSide; y++) + _z[x, y] = heightTable[heights[x * HeightmapSide + y]]; } /// - /// Bilinear-interpolated terrain Z at (localX, localY) in - /// landblock-local coordinates (0..192 range). + /// Triangle-aware terrain Z at (localX, localY) in landblock-local + /// coordinates (0..192 range). Uses the AC2D FSplitNESW formula to + /// determine which triangle the point falls in, then does barycentric + /// interpolation within that triangle. This matches the visual terrain + /// mesh exactly. /// public float SampleZ(float localX, float localY) { - float fx = Math.Clamp(localX / CellSize, 0f, HeightmapSide - 1f); - float fy = Math.Clamp(localY / CellSize, 0f, HeightmapSide - 1f); + // Which cell? + float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f); + float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f); + int cx = (int)fx; + int cy = (int)fy; + cx = Math.Clamp(cx, 0, CellsPerSide - 1); + cy = Math.Clamp(cy, 0, CellsPerSide - 1); - int x0 = Math.Min((int)fx, HeightmapSide - 2); - int y0 = Math.Min((int)fy, HeightmapSide - 2); - int x1 = x0 + 1; - int y1 = y0 + 1; - float tx = fx - x0; - float ty = fy - y0; + // Fractional position within the cell [0, 1] + float tx = fx - cx; + float ty = fy - cy; - float h00 = _heightTable[_heights[x0 * HeightmapSide + y0]]; - float h10 = _heightTable[_heights[x1 * HeightmapSide + y0]]; - float h01 = _heightTable[_heights[x0 * HeightmapSide + y1]]; - float h11 = _heightTable[_heights[x1 * HeightmapSide + y1]]; + // Four corner heights (BL, BR, TR, TL) + float hBL = _z[cx, cy ]; + float hBR = _z[cx + 1, cy ]; + float hTR = _z[cx + 1, cy + 1]; + float hTL = _z[cx, cy + 1]; - float hx0 = h00 * (1 - tx) + h10 * tx; - float hx1 = h01 * (1 - tx) + h11 * tx; - return hx0 * (1 - ty) + hx1 * ty; + // Split direction using the AC2D render formula + bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); + + if (splitSWtoNE) + { + // Diagonal from BL(0,0) to TR(1,1) + // Triangle 1: BL, BR, TR (below the diagonal: tx >= ty... wait) + // Actually: the diagonal goes from (0,0) to (1,1). + // Points where ty <= tx are in the bottom-right triangle (BL, BR, TR). + // Points where ty > tx are in the top-left triangle (BL, TL, TR). + if (ty <= tx) + { + // Bottom-right triangle: BL(0,0), BR(1,0), TR(1,1) + // Z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty + return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; + } + else + { + // Top-left triangle: BL(0,0), TL(0,1), TR(1,1) + // Z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty + return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; + } + } + else + { + // Diagonal from BR(1,0) to TL(0,1) + // Points where ty <= (1 - tx) are in the bottom-left triangle (BL, BR, TL). + // Points where ty > (1 - tx) are in the top-right triangle (BR, TL, TR). + if (ty <= 1f - tx) + { + // Bottom-left triangle: BL(0,0), BR(1,0), TL(0,1) + // Z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty + return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; + } + else + { + // Top-right triangle: BR(1,0), TL(0,1), TR(1,1) + // Z = hTR + (hTL - hTR) * (1 - tx) + (hBR - hTR) * (1 - ty) + return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); + } + } } /// /// Compute the outdoor cell ID for the given landblock-local position. - /// Outdoor cells are an 8×8 grid of 24×24-unit cells numbered - /// 0x0001..0x0040. Cell (0,0) at position (0,0) is 0x0001. /// public uint ComputeOutdoorCellId(float localX, float localY) { @@ -76,4 +123,16 @@ public sealed class TerrainSurface int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1); return (uint)(1 + cx * CellsPerSide + cy); } + + /// + /// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal. + /// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY). + /// + private static bool IsSplitSWtoNE(uint landblockX, uint cellX, uint landblockY, uint cellY) + { + uint x = landblockX * 8 + cellX; + uint y = landblockY * 8 + cellY; + uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u); + return (dw & 0x80000000u) != 0; + } } diff --git a/src/AcDream.Core/Terrain/TerrainBlending.cs b/src/AcDream.Core/Terrain/TerrainBlending.cs index 4d3380e..21294b9 100644 --- a/src/AcDream.Core/Terrain/TerrainBlending.cs +++ b/src/AcDream.Core/Terrain/TerrainBlending.cs @@ -44,21 +44,26 @@ public static class TerrainBlending /// is designed so that both the visible mesh and the server's collision /// triangulation agree on the same split — diverging from it causes /// visual/physics disagreement at cell boundaries. - /// Ported verbatim from WorldBuilder TerrainUtils.CalculateSplitDirection; - /// the magic constants must stay exact or AC cells won't split correctly. + /// + /// Uses the RENDER-PATH formula from AC2D and ACViewer's + /// LandblockMesh.cs (constants 0x0CCAC033, 0x421BE3BD, 0x6C1AC587, + /// 0x519B8F25), NOT the physics-path formula from WorldBuilder + /// (214614067 / 1813693831). The two produce different splits for + /// some cells, and the render formula is what the real AC client + /// uses for the visible terrain mesh. See + /// docs/research/2026-04-12-movement-deep-dive.md Part 3. /// public static CellSplitDirection CalculateSplitDirection( uint landblockX, uint cellX, uint landblockY, uint cellY) { - uint seedA = (landblockX * 8 + cellX) * 214614067u; - uint seedB = (landblockY * 8 + cellY) * 1109124029u; - uint magicA = seedA + 1813693831u; - uint magicB = seedB; - // uint wraparound is intentional — matches WorldBuilder and AC. - float splitDir = magicA - magicB - 1369149221u; - return splitDir * 2.3283064e-10f >= 0.5f - ? CellSplitDirection.SEtoNW - : CellSplitDirection.SWtoNE; + uint x = landblockX * 8 + cellX; + uint y = landblockY * 8 + cellY; + uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u); + // Bit 31 set = NE/SW diagonal (SWtoNE in our enum) + // Bit 31 clear = NW/SE diagonal (SEtoNW in our enum) + return (dw & 0x80000000u) != 0 + ? CellSplitDirection.SWtoNE + : CellSplitDirection.SEtoNW; } // --- Phase 3c.3 algorithms (surface recipe + vertex data packing) ---