From 9e4772a8f87cf2b12aebd7b61543f2a145c3094c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 5 May 2026 21:37:42 +0200 Subject: [PATCH] fix(motion): project anim root motion onto terrain plane (slope staircase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounded player remotes were showing a ~5 Hz Z staircase when running up/down slopes — the rate of server UpdatePositions. Body Z stayed flat between UPs, then ramped over ~100ms during the queue-active chase to each new server position, then went flat again until the next UP. Diagnosis (no diagnostic needed — the math is unambiguous): PositionManager.ComputeOffset has two modes via InterpolationManager.AdjustOffset: - Queue active (body chasing a waypoint): returns `(head − body) / dist × min(catchUpSpeed × dt, dist)`. 3D direction, Z follows server's reported Z naturally. - Queue empty / head-reached (within DESIRED_DISTANCE = 0.05m of the most recent UP): returns Vector3.Zero. ComputeOffset falls back to `seqVel × dt rotated into world` — pure animation root motion. Every locomotion cycle bakes Z=0 in body-local, so the world result has Z=0 too. XY advances at the running pace; Z stays at the last UP. For a runner at maxSpeed ≈ 4 m/s with catchUpSpeed = 2× = 8 m/s and server UPs at ~5 Hz, body covers ~0.8m per UP, chases for ~100ms (queue-active 3D path, Z ramps), then sits in seqVel-only mode for ~100ms (Z flat) until the next UP. Visible as a 5 Hz Z staircase. Fix mirrors retail's CTransition::adjust_offset contact-plane projection (named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded motion, applied at the queue-empty boundary instead of inside the sweep: PositionManager.ComputeOffset gains an optional Vector3? terrainNormal. When the seqVel-only fallback runs AND a non-trivial terrain normal is supplied, project rootMotionWorld onto the plane: result = rootMotionWorld − N × dot(rootMotionWorld, N) Anim XY motion gains a corresponding Z component proportional to slope angle × forward speed, so body Z follows the terrain mesh between UPs. No-op on flat ground (N ≈ +Z, dot ≈ 0); cannot regress L.3 M2's flat-ground verification. GameWindow.TickAnimations grounded-remote path samples PhysicsEngine.SampleTerrainNormal at the body's current XY each tick and passes it to ComputeOffset. SampleTerrainNormal is a thin public wrapper over the existing internal SampleTerrainWalkable that returns just the plane normal (no need to expose the internal sample shape). Diagnostic: ACDREAM_SLOPE_DIAG=1 prints a per-tick [SLOPE] line with guid, body Z before/after, offset, queue active flag, and the sampled plane Nz so we can grep before/after the fix and confirm Z changes continuously between UPs on slopes. Tests: PositionManagerTests gains two cases: - slope projection: 30° east-tilted plane, body running due east at 4 m/s for 1s → expect (3.0, 0, −1.732) (descends along slope, not flat). Math: dot(seqVel, N) = 2.0 → result = (4,0,0) − (0.5,0,0.866) × 2.0 = (3.0, 0, −1.732). - flat-ground no-op: N = +Z, expect identical Y-only motion as the pre-fix behavior. Build green. 357 pass / 6 pre-existing fail (same set as ec59a08; verified by stashing this change). The pre-existing `ComputeOffset_BothActive_Combined` failure reflects an outdated additive-design test docstring; the M2 commit (40d88b9) deliberately changed the implementation to REPLACE semantics to fix the prior 3×-server-pace overshoot. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 29 ++++++++- src/AcDream.Core/Physics/PhysicsEngine.cs | 15 +++++ src/AcDream.Core/Physics/PositionManager.cs | 36 ++++++++++- .../Physics/PositionManagerTests.cs | 60 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9c2e816..01ffc0d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6149,15 +6149,42 @@ public sealed class GameWindow : IDisposable // velocity, keeping legs and body pace synchronized. // - Blip-to-tail (tail − body) when fail_count > 3. float maxSpeed = rm.Motion.GetMaxSpeed(); + // Slope-staircase fix (2026-05-05): sample terrain normal + // at the body's current XY so PositionManager can project + // the seqVel-only fallback onto the local slope. Without + // this, the queue-empty interval between UPs left Z flat + // (anim cycles bake Z=0 body-local) — visible ~5 Hz + // staircase when a remote runs up/down hills. The + // projection is a no-op on flat ground. + System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal( + rm.Body.Position.X, rm.Body.Position.Y); + + System.Numerics.Vector3 bodyPosBefore = rm.Body.Position; System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, currentBodyPosition: rm.Body.Position, seqVel: seqVel, ori: rm.Body.Orientation, interp: rm.Interp, - maxSpeed: maxSpeed); + maxSpeed: maxSpeed, + terrainNormal: terrainNormal); rm.Body.Position += offset; + // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1. + // Prints per-tick body Z trajectory + queue state + projected + // offset.Z so we can grep before/after the fix and confirm Z + // changes continuously between UPs on slopes (no flat + // intervals followed by snaps). + if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1") + { + bool queueActive = rm.Interp.IsActive; + float nz = terrainNormal?.Z ?? 1.0f; + System.Console.WriteLine( + $"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} " + + $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) " + + $"queue={queueActive} cpN.Z={nz:F3}"); + } + // Step 2.5: angular velocity → body orientation. Prefer // ObservedOmega (set explicitly in OnLiveMotionUpdated from // the wire's TurnCommand + signed TurnSpeed) over the diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 671eb2b..fe308ae 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -169,6 +169,21 @@ public sealed class PhysicsEngine return null; } + /// + /// Public surface for callers that only need the local terrain plane + /// normal at a world-space XY (e.g., the grounded-remote tick path + /// projecting anim root motion onto the slope to avoid the staircase + /// between server position updates). Returns null when no registered + /// landblock covers the point. Mirrors the plane component of + /// without exposing the internal + /// TerrainWalkableSample shape. + /// + public Vector3? SampleTerrainNormal(float worldX, float worldY) + { + var sample = SampleTerrainWalkable(worldX, worldY); + return sample?.Plane.Normal; + } + /// /// Sample the outdoor terrain walkable triangle at the given world-space /// XY position. This carries the same plane as diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/PositionManager.cs index be3dbc0..e3ff2ae 100644 --- a/src/AcDream.Core/Physics/PositionManager.cs +++ b/src/AcDream.Core/Physics/PositionManager.cs @@ -34,13 +34,28 @@ public sealed class PositionManager /// Body orientation; used to rotate seqVel from body-local to world. /// The remote's InterpolationManager (for AdjustOffset call). /// From MotionInterpreter.GetMaxSpeed() — passed to AdjustOffset for the catch-up clamp. + /// + /// Optional local terrain plane normal at the body's current XY. When + /// supplied AND the queue-empty / head-reached fallback path runs, the + /// world-space anim root motion is projected onto the plane so XY motion + /// produces a corresponding Z change on slopes. Without this, the + /// fallback advances XY at the locomotion cycle's pace but leaves Z at + /// the last UP's reported Z — visible as a ~5 Hz staircase on slopes + /// (the rate of server UpdatePositions). Mirrors retail's + /// CTransition::adjust_offset contact-plane projection + /// (named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded + /// motion, applied here at the queue-empty boundary instead of inside + /// the sweep. Pass null on flat ground / when no terrain sample + /// is available — projection is a no-op when normal == +Z. + /// public Vector3 ComputeOffset( double dt, Vector3 currentBodyPosition, Vector3 seqVel, Quaternion ori, InterpolationManager interp, - float maxSpeed) + float maxSpeed, + Vector3? terrainNormal = null) { // Retail-faithful per-frame combiner. Mirrors // CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) + @@ -71,6 +86,23 @@ public sealed class PositionManager return correction; Vector3 rootMotionLocal = seqVel * (float)dt; - return Vector3.Transform(rootMotionLocal, ori); + Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori); + + // Slope projection (queue-empty fallback only). Locomotion cycles + // bake Z=0 in body-local, so without projection the body's Z stays + // at the last UP's reported value while XY advances at the running + // pace — visible ~5 Hz staircase between UPs on hills. Projecting + // the world-space anim motion onto the local terrain plane gives + // it a Z component proportional to slope × forward speed, so the + // body follows the terrain mesh smoothly. No-op on flat ground + // (normal ≈ +Z, dot ≈ 0) so it can't regress the M2 flat-ground + // verification. + if (terrainNormal.HasValue && terrainNormal.Value.Z > 0.01f) + { + Vector3 N = terrainNormal.Value; + float into = Vector3.Dot(rootMotionWorld, N); + rootMotionWorld -= N * into; + } + return rootMotionWorld; } } diff --git a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs index 7839bc6..0242c2a 100644 --- a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs @@ -176,4 +176,64 @@ public sealed class PositionManagerTests Assert.Equal(0f, offset.Y, precision: 4); Assert.Equal(0f, offset.Z, precision: 4); } + + // ========================================================================= + // Test 7: slope projection — anim root motion gains Z proportional to slope + // + // Lock-the-fix for the "remote running on a slope shows ~5 Hz Z staircase" + // bug: the queue-empty fallback was returning a flat (Z=0) world motion + // because animation cycles bake Z=0 in body-local. Projecting onto the + // local terrain plane gives the motion a Z component matching slope angle + // × forward speed. + // ========================================================================= + + [Fact] + public void ComputeOffset_SeqVelFallback_SlopedTerrainNormal_ProjectsZOntoSlope() + { + var pm = Make(); + var interp = EmptyInterp(); // queue empty → fallback path runs + + // Slope tilted 30° eastward (+X is downhill). Plane normal points + // up-and-east-of-vertical: (sin 30°, 0, cos 30°) = (0.5, 0, 0.866). + Vector3 N = Vector3.Normalize(new Vector3(0.5f, 0f, MathF.Sqrt(3f) / 2f)); + + // Body running due east at 4 m/s, dt = 1s → rootMotionWorld initially + // (4, 0, 0). After projection onto the plane: + // into = dot((4,0,0), (0.5,0,0.866)) = 2.0 + // result = (4,0,0) - (0.5,0,0.866) * 2.0 = (3.0, 0, -1.732) + // i.e. body moves east AND descends ~1.73m for the second. + Vector3 offset = pm.ComputeOffset( + dt: 1.0, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(4f, 0f, 0f), + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 0f, + terrainNormal: N); + + Assert.Equal( 3.000f, offset.X, precision: 3); + Assert.Equal( 0.000f, offset.Y, precision: 3); + Assert.Equal(-1.732f, offset.Z, precision: 3); + } + + [Fact] + public void ComputeOffset_SeqVelFallback_FlatTerrainNormal_NoZChange() + { + var pm = Make(); + var interp = EmptyInterp(); + + // Flat ground: normal = +Z. Projection should be a no-op. + Vector3 offset = pm.ComputeOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + seqVel: new Vector3(0f, 4f, 0f), + ori: Quaternion.Identity, + interp: interp, + maxSpeed: 0f, + terrainNormal: Vector3.UnitZ); + + Assert.Equal(0f, offset.X, precision: 4); + Assert.Equal(0.4f, offset.Y, precision: 4); + Assert.Equal(0f, offset.Z, precision: 4); + } }