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:
parent
ec59a08db5
commit
9e4772a8f8
4 changed files with 137 additions and 3 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue