From 40f120617d6cb8a9a1391028db7633dff3718c6f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Apr 2026 19:42:20 +0200 Subject: [PATCH] feat(physics): use sloped terrain plane in FindEnvCollisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our previous FindEnvCollisions built a FLAT contact plane (Normal = +Z) at the sampled terrain Z, discarding the triangle's actual slope. Retail uses the real terrain polygon's plane (ACE Landblock.cs:125-137 find_terrain_poly → walkable.Plane) which IS sloped. Without a true slope normal, AdjustOffset's projection of horizontal velocity onto the plane produces no slope-aligned Z component — fine for step-subdivision on flat ground, visibly wrong whenever the contact plane is carried across frames (via PhysicsBody.ContactPlane persistence from commit 93cbabb): the projection is a no-op and movement is purely kinematic. With the real slope normal, projected motion correctly follows the slope. Not a user-visible bug fix by itself (DIAG LocalZ shows delta≈0 for the local player everywhere; the "looks too high in water" issue the user reported is actually a missing water-rendering feature, not a physics bug). Landing it anyway because it matches retail behavior and removes the "flat-plane-is-fine" assumption that would bite on any future contact-plane-dependent code. Additions: - TerrainSurface.SampleSurface(localX, localY) → (Z, Normal), deriving the plane normal analytically from the triangle's height gradient. Matches the same triangle SampleZ already interpolates through. - PhysicsEngine.SampleTerrainPlane(worldX, worldY) → System.Numerics.Plane, the wrapper that bridges terrain space to transition space. - TransitionTypes.FindEnvCollisions uses SampleTerrainPlane instead of synthesizing a flat plane from SampleTerrainZ. All 717 tests green. Flat-plane case is unchanged (Normal.Z = 1 when the triangle is level, identical to the old plane). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/PhysicsEngine.cs | 29 +++++++ src/AcDream.Core/Physics/TerrainSurface.cs | 93 +++++++++++++++++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 25 ++++-- 3 files changed, 139 insertions(+), 8 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index aa5fd2b..1ae9a03 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -110,6 +110,35 @@ public sealed class PhysicsEngine return null; } + /// + /// Sample the outdoor terrain plane (Z + sloped normal) at the given + /// world-space XY position. The returned + /// has the true terrain-triangle normal (NOT a flat (0,0,1)), and + /// its D is set so the plane passes through the sampled point. Used + /// by to build a CORRECT contact plane — a flat + /// plane breaks slope tracking because AdjustOffset's projection + /// onto a flat plane cannot impart the Z component that horizontal + /// velocity needs to follow the slope. + /// + public System.Numerics.Plane? SampleTerrainPlane(float worldX, float worldY) + { + foreach (var kvp in _landblocks) + { + var lb = kvp.Value; + float localX = worldX - lb.WorldOffsetX; + float localY = worldY - lb.WorldOffsetY; + if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) + { + var (z, normal) = lb.Terrain.SampleSurface(localX, localY); + // System.Numerics.Plane convention: dot(Normal, P) + D == 0 + // for points P on the plane. Pick P = (worldX, worldY, z). + float d = -(normal.X * worldX + normal.Y * worldY + normal.Z * z); + return new System.Numerics.Plane(normal, d); + } + } + return null; + } + /// /// Resolve an entity's movement from by /// applying (XY only) and computing the correct Z diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index e05cd10..6068c40 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -116,6 +116,99 @@ public sealed class TerrainSurface } } + /// + /// Sample both the terrain Z and the triangle-plane surface normal at + /// (localX, localY). The normal is derived from the gradient of the same + /// triangle interpolates across, so returned + /// (Z, Normal) is exactly the sloped plane the physics should + /// contact against. + /// + /// + /// This matters for : when AdjustOffset + /// projects the per-step movement offset onto the contact plane, a flat + /// plane (Normal = (0,0,1)) cannot impart any Z component to a + /// horizontal velocity — the character walks off a slope and the + /// per-frame step-down budget (~4 cm) can't catch up with the slope + /// descent rate, so the sphere floats above the terrain. A SLOPED plane + /// gives AdjustOffset the info it needs to produce slope-aligned motion + /// with the correct Z component baked in. + /// + /// + /// + /// Retail does this via LandCell.find_terrain_poly → walkable.Plane + /// (ACE Landblock.cs:125-137). We derive the equivalent plane + /// analytically from the chosen triangle's three corner heights. + /// + /// + public (float Z, System.Numerics.Vector3 Normal) SampleSurface(float localX, float localY) + { + 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); + + float tx = fx - cx; + float ty = fy - cy; + + float hBL = _z[cx, cy ]; + float hBR = _z[cx + 1, cy ]; + float hTR = _z[cx + 1, cy + 1]; + float hTL = _z[cx, cy + 1]; + + bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); + + // The SampleZ formula for each triangle is linear in (tx, ty): + // Z = a + b * tx + c * ty + // so dZ/dLocalX = b / CellSize, dZ/dLocalY = c / CellSize. + // Surface normal = normalize((-dZ/dX, -dZ/dY, 1)) — a well-known + // identity for a height-field plane. + float z, dzdx, dzdy; + + if (splitSWtoNE) + { + // Diagonal BL(0,0) → TR(1,1). Triangles: {BL,BR,TR} / {BL,TR,TL}. + if (tx > ty) + { + // {BL,BR,TR}: Z = hBL + (hBR-hBL)·tx + (hTR-hBR)·ty + z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; + dzdx = (hBR - hBL) / CellSize; + dzdy = (hTR - hBR) / CellSize; + } + else + { + // {BL,TR,TL}: Z = hBL + (hTR-hTL)·tx + (hTL-hBL)·ty + z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; + dzdx = (hTR - hTL) / CellSize; + dzdy = (hTL - hBL) / CellSize; + } + } + else + { + // Diagonal BR(1,0) → TL(0,1). Triangles: {BL,BR,TL} / {BR,TR,TL}. + if (tx + ty <= 1f) + { + // {BL,BR,TL}: Z = hBL + (hBR-hBL)·tx + (hTL-hBL)·ty + z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; + dzdx = (hBR - hBL) / CellSize; + dzdy = (hTL - hBL) / CellSize; + } + else + { + // {BR,TR,TL}: Z = hTR + (hTL-hTR)(1-tx) + (hBR-hTR)(1-ty) + // Equivalent linear form: Z = [hBR+hTL-hTR] + (hTR-hTL)·tx + (hTR-hBR)·ty + z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); + dzdx = (hTR - hTL) / CellSize; + dzdy = (hTR - hBR) / CellSize; + } + } + + var normal = System.Numerics.Vector3.Normalize( + new System.Numerics.Vector3(-dzdx, -dzdy, 1f)); + return (z, normal); + } + /// /// Compute the outdoor cell ID for the given landblock-local position. /// diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d4ac066..6e0e670 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -619,16 +619,25 @@ public sealed class Transition } // ── Outdoor terrain collision ──────────────────────────────────── - // Sample terrain Z at the foot sphere's world position. - float? terrainZ = engine.SampleTerrainZ(footCenter.X, footCenter.Y); - if (terrainZ is null) + // Build the terrain contact plane at the foot sphere's world XY. + // + // Retail fidelity: plane Normal must be the terrain TRIANGLE normal + // (sloped on slopes), NOT a flat (0,0,1). AdjustOffset projects the + // per-step movement offset onto this plane — with a flat plane the + // projection just zeros the Z component and horizontal velocity stays + // horizontal, so the sphere cannot follow a descending slope within + // one frame's step-down budget. A sloped plane projects horizontal + // velocity INTO slope-aligned motion with the correct Z component. + // + // ACE reference: Landblock.GetZ (Landblock.cs:125-137) calls + // find_terrain_poly and uses walkable.Plane — the actual triangle's + // plane, not a reconstructed flat one. SampleTerrainPlane returns + // the same thing analytically from the triangle's corner heights. + var planeOpt = engine.SampleTerrainPlane(footCenter.X, footCenter.Y); + if (planeOpt is null) return TransitionState.OK; // no terrain loaded here — allow pass-through - // Build the terrain contact plane (flat ground: Normal = +Z, D = -terrainZ). - var contactPlane = new System.Numerics.Plane( - new Vector3(0f, 0f, 1f), -terrainZ.Value); - - return ValidateWalkable(footCenter, sphereRadius, contactPlane, isWater: false, + return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value, isWater: false, cellId: sp.CheckCellId); }