acdream/src/AcDream.Core/Physics/PositionManager.cs
Erik 9e4772a8f8 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>
2026-05-05 21:37:42 +02:00

108 lines
5.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-frame combiner for remote-entity motion: animation root motion
/// + InterpolationManager catch-up correction. Pure function — no
/// side effects, no hidden state.
///
/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730):
/// rootOffset = CPartArray::Update(dt) // animation
/// PositionManager::adjust_offset(rootOffset) // adds correction
/// frame.origin += rootOffset
///
/// In acdream the animation root motion is sourced from
/// AnimationSequencer.CurrentVelocity (body-local velocity from the
/// active locomotion cycle). We rotate that by the body's orientation
/// to get a world-space delta, then add the InterpolationManager's
/// world-space correction.
/// </summary>
public sealed class PositionManager
{
/// <summary>
/// Compute the per-frame world-space delta to add to body.Position.
/// </summary>
/// <param name="dt">Per-frame delta time, seconds.</param>
/// <param name="currentBodyPosition">Body's current world-space position.</param>
/// <param name="seqVel">
/// Body-local velocity from the active animation cycle
/// (from <c>AnimationSequencer.CurrentVelocity</c>); pass
/// <c>Vector3.Zero</c> if the entity has no sequencer or is on a
/// non-locomotion cycle.
/// </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="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(
double dt,
Vector3 currentBodyPosition,
Vector3 seqVel,
Quaternion ori,
InterpolationManager interp,
float maxSpeed,
Vector3? terrainNormal = null)
{
// Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
// InterpolationManager::adjust_offset (@ 0x00555d30):
//
// 1. CPartArray::Update writes rootOffset (animation root motion)
// into the per-tick Frame.
// 2. PositionManager::adjust_offset → InterpolationManager::adjust_offset
// either:
// a) RETURNS EARLY when distance(body, head) < 0.05m
// (NodeCompleted; arg2 unmodified) — body uses root motion.
// b) OVERWRITES arg2 with `direction × min(catchUpSpeed × dt,
// distance)` when body is far from head — catch-up REPLACES
// root motion for this frame.
//
// It is NOT additive. Our prior port added rootMotion + correction
// every frame, which stacked the animation push (≈ RunAnimSpeed ×
// speedMod, ≈ 11.7 m/s) on top of the queue catch-up (capped at
// ≈ 23.5 m/s) so the body advanced at up to ~3× the server's
// broadcast pace and the head-behind-body case produced a backward
// correction every UP — the visible 1-Hz blip the user reported.
//
// AdjustOffset returns Vector3.Zero in two cases mapped to retail's
// early-return: empty queue OR distance < DesiredDistance (0.05m).
// In both, body falls back to animation root motion.
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
if (correction.LengthSquared() > 0f)
return correction;
Vector3 rootMotionLocal = seqVel * (float)dt;
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;
}
}