fix(motion): project anim root motion onto terrain plane (slope staircase)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-05 21:37:42 +02:00
parent ec59a08db5
commit 9e4772a8f8
4 changed files with 137 additions and 3 deletions

View file

@ -6149,15 +6149,42 @@ public sealed class GameWindow : IDisposable
// velocity, keeping legs and body pace synchronized. // velocity, keeping legs and body pace synchronized.
// - Blip-to-tail (tail body) when fail_count > 3. // - Blip-to-tail (tail body) when fail_count > 3.
float maxSpeed = rm.Motion.GetMaxSpeed(); 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( System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt, dt: (double)dt,
currentBodyPosition: rm.Body.Position, currentBodyPosition: rm.Body.Position,
seqVel: seqVel, seqVel: seqVel,
ori: rm.Body.Orientation, ori: rm.Body.Orientation,
interp: rm.Interp, interp: rm.Interp,
maxSpeed: maxSpeed); maxSpeed: maxSpeed,
terrainNormal: terrainNormal);
rm.Body.Position += offset; 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 // Step 2.5: angular velocity → body orientation. Prefer
// ObservedOmega (set explicitly in OnLiveMotionUpdated from // ObservedOmega (set explicitly in OnLiveMotionUpdated from
// the wire's TurnCommand + signed TurnSpeed) over the // the wire's TurnCommand + signed TurnSpeed) over the

View file

@ -169,6 +169,21 @@ public sealed class PhysicsEngine
return null; return null;
} }
/// <summary>
/// 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
/// <see cref="SampleTerrainWalkable"/> without exposing the internal
/// <c>TerrainWalkableSample</c> shape.
/// </summary>
public Vector3? SampleTerrainNormal(float worldX, float worldY)
{
var sample = SampleTerrainWalkable(worldX, worldY);
return sample?.Plane.Normal;
}
/// <summary> /// <summary>
/// Sample the outdoor terrain walkable triangle at the given world-space /// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/> /// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>

View file

@ -34,13 +34,28 @@ public sealed class PositionManager
/// <param name="ori">Body orientation; used to rotate seqVel from body-local to world.</param> /// <param name="ori">Body orientation; used to rotate seqVel from body-local to world.</param>
/// <param name="interp">The remote's InterpolationManager (for AdjustOffset call).</param> /// <param name="interp">The remote's InterpolationManager (for AdjustOffset call).</param>
/// <param name="maxSpeed">From <c>MotionInterpreter.GetMaxSpeed()</c> — passed to AdjustOffset for the catch-up clamp.</param> /// <param name="maxSpeed">From <c>MotionInterpreter.GetMaxSpeed()</c> — passed to AdjustOffset for the catch-up clamp.</param>
/// <param name="terrainNormal">
/// 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
/// <c>CTransition::adjust_offset</c> 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 <c>null</c> on flat ground / when no terrain sample
/// is available — projection is a no-op when normal == +Z.
/// </param>
public Vector3 ComputeOffset( public Vector3 ComputeOffset(
double dt, double dt,
Vector3 currentBodyPosition, Vector3 currentBodyPosition,
Vector3 seqVel, Vector3 seqVel,
Quaternion ori, Quaternion ori,
InterpolationManager interp, InterpolationManager interp,
float maxSpeed) float maxSpeed,
Vector3? terrainNormal = null)
{ {
// Retail-faithful per-frame combiner. Mirrors // Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) + // CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
@ -71,6 +86,23 @@ public sealed class PositionManager
return correction; return correction;
Vector3 rootMotionLocal = seqVel * (float)dt; 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;
} }
} }

View file

@ -176,4 +176,64 @@ public sealed class PositionManagerTests
Assert.Equal(0f, offset.Y, precision: 4); Assert.Equal(0f, offset.Y, precision: 4);
Assert.Equal(0f, offset.Z, 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);
}
} }