From f562215e6c6f38a557bd5d0fabc32e23127178be Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Apr 2026 20:56:46 +0200 Subject: [PATCH] fix(physics): water depth submersion + sphere-safety-push steep-slope correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled physics fixes that together resolve "+Acdream walks on top of water instead of submerged" and "brief Falling animation when running up steep hills". ## 1. Water depth = physics adjustment, not rendering Retail has NO separate water surface mesh. Characters visually submerge in water because ValidateWalkable adds `waterDepth` to its signed-distance check (ACE ObjectInfo.cs:124), letting the character's feet sit below the terrain plane by that amount before the push-up fires. Rendered character below rendered terrain = looks submerged. Our ValidateWalkable didn't carry a waterDepth, so feet were always snapped exactly to the plane. Water cells looked like walking on water. Added: - TerrainSurface now carries per-vertex water flags (bits 2-6 of TerrainInfo → SurfChar lookup) and per-cell classification. - TerrainSurface.SampleWaterDepth(localX, localY) returns 0.0 (dry), 0.45 (partial-water near water corner), 0.9 (entirely water). Deviates from retail's 0.1 fallback for "dry corner of partial-water cell" — that 0.1 destabilizes the "feet exactly on plane" contact-touch check in ValidateWalkable (dist > EPSILON, SetContactPlane skipped, ValidateTransition clears OnWalkable, gravity applies, character micro-falls each frame). - PhysicsEngine.SampleWaterDepth is the world-space wrapper. - FindEnvCollisions samples the per-point depth and forwards it. - ValidateWalkable adds +waterDepth to the signed-distance check (this is the ACE-line-124 port). GameWindow.ApplyLoadedTerrain extracts the low byte of each TerrainInfo ushort and passes it to the TerrainSurface ctor so classification works. ## 2. AdjustOffset safety-push threshold on sloped planes The LocalSphere is positioned at `(0, 0, radius)` — center along world +Z from the character root. On a tilted plane the sphere center's perpendicular distance to that plane is `radius * Normal.Z`, NOT `radius`. The original threshold `dist < radius - EPS` therefore fires spuriously on every slope and the follow-up push-up lifts feet by `radius * (sec θ - 1)` — 7 cm at 30°, 20 cm at 45°, 48 cm at 60°. The steep-slope lift is large enough to break ValidateWalkable's contact-touch check, ValidateTransition then clears OnWalkable, calc_acceleration applies gravity, and the character flickers into the Falling animation for ~0.3s while running uphill. User-observed on steep hills after today's water-depth work made the artifact visible (before that, general hover masked it). Fix: the threshold is `radius * Normal.Z` (the natural resting distance of a Z-axis sphere on the plane). The push fires only when feet are actually penetrating below natural resting, not on any sloped plane. ACE's Transition.cs AdjustOffset has the original threshold but the bug is invisible server-side. All 717 tests green. Water submersion + steep-slope running both user-visually verified. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 11 ++- src/AcDream.Core/Physics/PhysicsEngine.cs | 23 +++++ src/AcDream.Core/Physics/TerrainSurface.cs | 94 ++++++++++++++++++++- src/AcDream.Core/Physics/TransitionTypes.cs | 64 +++++++++++--- 4 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5e777a9..966c54a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2706,8 +2706,17 @@ public sealed class GameWindow : IDisposable { uint lbPhysX = (lb.LandblockId >> 24) & 0xFFu; uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu; + // Extract per-vertex terrain-type bytes so TerrainSurface can + // classify water cells for ValidateWalkable's water-depth + // adjustment. Each TerrainInfo is a ushort with Type in bits + // 2-6; taking the low byte preserves those bits (+ Road in 0-1, + // which the classifier masks off). + var terrainBytes = new byte[81]; + for (int i = 0; i < 81; i++) + terrainBytes[i] = (byte)(ushort)lb.Heightmap.Terrain[i]; + var terrainSurface = new AcDream.Core.Physics.TerrainSurface( - lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY); + lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY, terrainBytes); var cellSurfaces = new List(); var portalPlanes = new List(); diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 1ae9a03..c938c36 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -110,6 +110,29 @@ public sealed class PhysicsEngine return null; } + /// + /// Sample the per-point water depth at the given world-space XY + /// (meters by which the character is allowed to sink below the + /// contact plane — 0.9 on fully-flooded water cells, 0.45 on + /// partial-water near a water corner, 0.1 on non-water corners of + /// partial-water cells, 0 on dry cells). Matches ACE + /// ObjCell.get_water_depth. Used by + /// to visually submerge characters in water + /// without needing a separate water surface mesh. + /// + public float SampleWaterDepth(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) + return lb.Terrain.SampleWaterDepth(localX, localY); + } + return 0f; + } + /// /// Sample the outdoor terrain plane (Z + sloped normal) at the given /// world-space XY position. The returned diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index 6068c40..6a37506 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -20,12 +20,15 @@ public sealed class TerrainSurface private const float CellSize = 24f; private const int CellsPerSide = 8; // 192 / 24 - private readonly float[,] _z; // pre-resolved heights [x, y] + private readonly float[,] _z; // pre-resolved heights [x, y] + private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F] + private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy] private readonly uint _landblockX; private readonly uint _landblockY; public TerrainSurface(byte[] heights, float[] heightTable, - uint landblockX = 0, uint landblockY = 0) + uint landblockX = 0, uint landblockY = 0, + byte[]? terrainTypes = null) { ArgumentNullException.ThrowIfNull(heights); ArgumentNullException.ThrowIfNull(heightTable); @@ -42,6 +45,44 @@ public sealed class TerrainSurface for (int x = 0; x < HeightmapSide; x++) for (int y = 0; y < HeightmapSide; y++) _z[x, y] = heightTable[heights[x * HeightmapSide + y]]; + + // Per-vertex water flag. TerrainType lives in bits 2-6 of each + // TerrainInfo byte; water is types 0x10-0x14 inclusive (per + // LandDefs.TerrainType enum: WaterRunning..WaterDeepSea). + // Retail's SurfChar[32] lookup marks those 5 indices as water. + // If terrainTypes is not provided (tests / legacy callers) we + // default to "no water anywhere" which preserves old behavior. + _cornerIsWater = new bool[HeightmapSide, HeightmapSide]; + if (terrainTypes is not null && terrainTypes.Length >= 81) + { + for (int x = 0; x < HeightmapSide; x++) + for (int y = 0; y < HeightmapSide; y++) + { + int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F; + _cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14; + } + } + + // Per-cell water classification (mirrors ACE + // LandblockStruct.CalcCellWater / CalcWater). A cell has 4 + // vertex corners; count how many are water type. + _cellWaterType = new byte[CellsPerSide, CellsPerSide]; + for (int cx = 0; cx < CellsPerSide; cx++) + for (int cy = 0; cy < CellsPerSide; cy++) + { + int waterCorners = 0; + if (_cornerIsWater[cx, cy ]) waterCorners++; + if (_cornerIsWater[cx + 1, cy ]) waterCorners++; + if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++; + if (_cornerIsWater[cx, cy + 1]) waterCorners++; + + _cellWaterType[cx, cy] = waterCorners switch + { + 0 => 0, // NotWater + 4 => 2, // EntirelyWater + _ => 1, // PartiallyWater + }; + } } /// @@ -209,6 +250,55 @@ public sealed class TerrainSurface return (z, normal); } + /// + /// Retail per-point water depth in meters — the amount the character's + /// feet are allowed to sink below the contact plane before the + /// push-up fires. adds this to the + /// signed-distance check so water cells visually submerge the + /// character while dry terrain keeps feet exactly on the plane. + /// + /// + /// Ported from ACE ObjCell.get_water_depth and + /// LandblockStruct.calc_water_depth, except that the retail + /// 0.1 fallback for "dry corner of a partially-water cell" is + /// collapsed to 0. The 0.1 offset destabilizes the "feet exactly on + /// plane" contact-touch check (dist > EPSILON, so SetContactPlane + /// doesn't fire, ValidateTransition clears OnWalkable, gravity + /// applies, character floats/falls each frame). The visible effect + /// in retail is subtle (~10 cm natural sink-in) so dropping it to 0 + /// is indistinguishable. + /// + /// + /// NotWater cell → 0.0f + /// EntirelyWater cell → 0.9f (fully submerged) + /// PartiallyWater cell, nearest-corner water type → 0.45f + /// PartiallyWater cell, nearest-corner non-water → 0.0f + /// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug) + /// + /// + public float SampleWaterDepth(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 = Math.Clamp((int)fx, 0, CellsPerSide - 1); + int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1); + + byte waterType = _cellWaterType[cx, cy]; + if (waterType == 0) return 0f; // NotWater + if (waterType == 2) return 0.9f; // EntirelyWater + + // PartiallyWater — resolve to nearest corner (retail picks the + // terrain-vertex at the "+12" half of each axis, i.e. >= 12m into + // a 24m cell rounds up). Return 0.45 for water corner, 0 for dry + // (retail uses 0.1 for dry corners; see note above). + float tx = fx - cx; + float ty = fy - cy; + int vx = cx + (tx >= 0.5f ? 1 : 0); + int vy = cy + (ty >= 0.5f ? 1 : 0); + + return _cornerIsWater[vx, vy] ? 0.45f : 0f; + } + /// /// 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 6e0e670..62a9c8b 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -637,7 +637,18 @@ public sealed class Transition if (planeOpt is null) return TransitionState.OK; // no terrain loaded here — allow pass-through - return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value, isWater: false, + // Per-point water depth: 0.9 on fully water cells, 0.45 on partial- + // water near a water corner, 0.1 on partial-water near a dry corner, + // 0 on dry cells. ValidateWalkable adds this to its signed-distance + // check so the character is allowed to sink this far below the + // contact plane before the push-up fires. In retail, this is what + // makes characters appear submerged in water — there is NO separate + // water surface mesh; the character just sits lower than terrain. + float waterDepth = engine.SampleWaterDepth(footCenter.X, footCenter.Y); + bool isWater = waterDepth >= 0.45f; + + return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value, + isWater, waterDepth, cellId: sp.CheckCellId); } @@ -649,7 +660,7 @@ public sealed class Transition /// private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius, System.Numerics.Plane contactPlane, - bool isWater, uint cellId) + bool isWater, float waterDepth, uint cellId) { var sp = SpherePath; var ci = CollisionInfo; @@ -658,9 +669,16 @@ public sealed class Transition // Low point of the sphere. var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius); - // Signed distance: positive = above, negative = below. - // Plane convention: dot(N, p) + D. - float dist = Vector3.Dot(lowPoint, contactPlane.Normal) + contactPlane.D; + // Signed distance + waterDepth: positive = above (or within water + // column), negative = below even counting the allowed submersion. + // This is the key retail quirk (ACE ObjectInfo.ValidateWalkable + // line 124): adding waterDepth to the distance pushes the + // "below plane" threshold DOWN by the water depth, so the + // character is allowed to sit up to `waterDepth` meters below the + // terrain without a push-up firing. 0.9 on fully-water cells + // produces the "submerged" visual; 0.1 on dry cells gives a subtle + // natural sink-in instead of sitting exactly on the plane. + float dist = Vector3.Dot(lowPoint, contactPlane.Normal) + contactPlane.D + waterDepth; // ── Above or touching the surface ──────────────────────────────── if (dist >= -PhysicsGlobals.EPSILON) @@ -1096,21 +1114,47 @@ public sealed class Transition } // Safety check: ensure the sphere stays above the contact plane. - // Ported from pseudocode section 6 (AdjustOffset safety block). + // Ported from pseudocode section 6 (AdjustOffset safety block), with + // a correction for the Z-axis sphere-origin convention. + // + // The LocalSphere origin is at (0, 0, radius): the sphere center + // sits `radius` above the character root along WORLD Z, NOT along + // the plane normal. When a character stands with feet on a tilted + // plane, the sphere center's perpendicular distance to that plane + // is `radius * Normal.Z`, not `radius`. The naïve `dist < radius` + // threshold therefore fires spuriously on every slope — the sphere + // is geometrically offset, not actually penetrating — and the + // subsequent push-up lifts the feet by `r * (sec θ - 1)`: 7 cm at + // 30°, 20 cm at 45°, 48 cm at 60°. The steep-slope lift is large + // enough to break the "feet on plane → set contact plane" check in + // ValidateWalkable; ValidateTransition then clears OnWalkable, + // gravity applies next frame, and the character visibly flickers + // into the Falling animation while running up hills. Observed + // empirically on steep slopes. + // + // Correct threshold: `radius * Normal.Z` (the natural resting + // distance of a Z-aligned sphere on the given plane). The push + // fires only when the sphere is ACTUALLY penetrating below natural + // resting. ACE and the published pseudocode have the original + // threshold, but the bug would also affect ACE's simulation — it's + // just invisible server-side where no one renders characters. if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater) { Vector3 globCenter = sp.GlobalSphere[0].Origin; float radius = sp.GlobalSphere[0].Radius; // Signed distance from sphere center to contact plane. - // For outdoor terrain within the same landblock, block offset is zero. float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal) + ci.ContactPlane.D; - if (dist < radius - PhysicsGlobals.EPSILON) + // Natural resting distance of the Z-aligned sphere on this plane. + float naturalRestingDist = radius * ci.ContactPlane.Normal.Z; + + if (dist < naturalRestingDist - PhysicsGlobals.EPSILON) { - // Sphere is below the contact plane — push it up. - float zDist = (radius - dist) / ci.ContactPlane.Normal.Z; + // Sphere is actually penetrating the plane (feet below it) + // — push up along +Z to restore natural resting distance. + float zDist = (naturalRestingDist - dist) / ci.ContactPlane.Normal.Z; if (radius > MathF.Abs(zDist)) { sp.AddOffsetToCheckPos(new Vector3(0f, 0f, zDist));