fix(camera): retail-faithful jump-tracking via contact-plane projection

Original symptom: jumping made the camera swing around the player
vertically — the basis tilted up/down with the player's Z velocity.

Root cause: ComputeHeading used the raw 3D velocity vector as the
heading direction. During a jump, velocity has a substantial Z
component (vy ≈ jump speed), and `normalize((vx, vy, vz))` produced
a heading pointing up. The basis tilted accordingly and the camera
went under/over the player.

Retail's actual ALIGN_WITH_PLANE algorithm (decomp at
acclient_2013_pseudo_c.txt:95644-95795) is different:

  1. Velocity is only used as a gate. If |vx| AND |vy| > epsilon
     (player is moving in XY), proceed; otherwise fall back to the
     LOOK_IN_DIRECTION path (player's facing direction unchanged).
  2. The base heading is `localtoglobalvec(player, (0, 1, 0))` —
     the player's local +Y axis in world space, which in our
     convention is `(cos yaw, sin yaw, 0)`.
  3. Pick a surface normal:
       grounded:  contact_plane.N
       airborne:  (0, 0, 1)  [world up]
  4. Project the base heading onto the plane perpendicular to that
     normal:  projected = forward - normal * dot(forward, normal).
  5. Normalize. Fall back to the base if projection collapses.

Behaviorally:
  * Standing jump (vx≈0, vy≈0):  gate fails → base heading. Camera
    doesn't move with the jump.
  * Running jump (vx, vy, vz all nonzero, airborne):  projects onto
    world up → no-op since base is already horizontal. Camera basis
    stays horizontal; player visibly rises in frame.
  * Walking uphill (grounded, slope normal tilted):  projection
    adds a Z component matching the slope angle. Camera basis tilts
    with the terrain.
  * Walking on flat ground:  projection is a no-op. Camera basis
    horizontal.

Surface changes:
  * RetailChaseCamera.ComputeHeading gains `isOnGround` and
    `contactPlaneNormal` parameters.
  * RetailChaseCamera.Update gains the same two parameters and
    threads them through.
  * GameWindow's two Update call sites pass `result.IsOnGround` and
    `_playerController.ContactPlane.Normal` (already exposed on
    PlayerMovementController — no plumbing change there).
  * Tests: 2 existing heading tests reshaped (Moving* and Uphill);
    2 new tests added (AirborneJumping straight-up + running-jump);
    1 renamed (SlopeAlignDisabled). Net 25 → 27 tests in
    RetailChaseCameraTests; full AcDream.App.Tests: 39 → 41.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 09:32:50 +02:00
parent 8f30e13317
commit b7e954e50b
3 changed files with 188 additions and 34 deletions

View file

@ -4757,7 +4757,11 @@ public sealed class GameWindow : IDisposable
// 4. Recenter chase camera on the new position.
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw, System.Numerics.Vector3.Zero, dt: 1f / 60f);
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
playerVelocity: System.Numerics.Vector3.Zero,
isOnGround: true,
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
dt: 1f / 60f);
// 5. Return to InWorld.
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
@ -6429,10 +6433,18 @@ public sealed class GameWindow : IDisposable
_chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
isOnGround: result.IsOnGround,
dt: (float)dt);
// RetailChaseCamera: takes world velocity for slope-aligned heading;
// jump-feedback falls out of damping naturally, no isOnGround needed.
// RetailChaseCamera: heading is the player's facing direction
// projected onto the contact plane when grounded, or the
// world XY plane when airborne. The contact plane normal
// tilts the camera basis with terrain; the airborne
// fallback keeps the basis horizontal during jumps so the
// player visibly rises in frame without the camera
// swinging vertically (was the symptom of using raw
// velocity-vector heading).
_retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw,
playerVelocity: _playerController.BodyVelocity,
playerVelocity: _playerController.BodyVelocity,
isOnGround: result.IsOnGround,
contactPlaneNormal: _playerController.ContactPlane.Normal,
dt: (float)dt);
// Send outbound movement messages to the live server.

View file

@ -83,14 +83,28 @@ public sealed class RetailChaseCamera : ICamera
/// <see cref="View"/>, and <see cref="PlayerTranslucency"/> reflect
/// the new state.
/// </summary>
public void Update(Vector3 playerPosition, float playerYaw, Vector3 playerVelocity, float dt)
public void Update(
Vector3 playerPosition,
float playerYaw,
Vector3 playerVelocity,
bool isOnGround,
Vector3 contactPlaneNormal,
float dt)
{
// 1. Push velocity into 5-frame ring, get average.
PushVelocity(_velocityRing, ref _velocityCount, playerVelocity);
Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount);
// 2. Heading vector — slope-aligned when fast enough, flat fallback otherwise.
Vector3 heading = ComputeHeading(avgVel, playerYaw + YawOffset, CameraDiagnostics.AlignToSlope);
// 2. Heading vector — player's facing projected onto the contact
// plane (grounded) or world XY (airborne). See ComputeHeading
// doc + retail decomp :95644-95795 for why this is facing-based
// rather than velocity-based.
Vector3 heading = ComputeHeading(
avgVel,
playerYaw + YawOffset,
isOnGround,
contactPlaneNormal,
CameraDiagnostics.AlignToSlope);
// 3. Orthonormal heading-frame basis.
var (forward, _, up) = BuildBasis(heading);
@ -165,18 +179,81 @@ public sealed class RetailChaseCamera : ICamera
// Math primitives — pure, internal-static for unit-testability.
/// <summary>
/// Pick the heading vector that drives the camera basis. Slope-
/// aligned when velocity is non-trivial and the toggle is on; flat
/// fallback otherwise. Matches retail's <c>target_status &amp;
/// ALIGN_WITH_PLANE</c> path with the contact-plane branch
/// collapsed into the flat fallback.
/// Pick the heading vector that drives the camera basis. Mirrors
/// retail's <c>CameraManager::UpdateCamera</c> ALIGN_WITH_PLANE
/// path (decomp <c>acclient_2013_pseudo_c.txt:95644-95795</c>):
/// <list type="number">
/// <item><description>Base heading is the player's facing
/// direction in world space — <c>(cos yaw, sin yaw, 0)</c>
/// — not the velocity vector. Velocity only gates whether
/// slope-alignment fires.</description></item>
/// <item><description>If <paramref name="alignToSlope"/> is off
/// OR the player's horizontal velocity is below epsilon (i.e.
/// stationary OR jumping straight up), return that base
/// heading unchanged. This is the bit that keeps the camera
/// from swinging vertically during a jump.</description></item>
/// <item><description>Otherwise project the base heading onto
/// the plane perpendicular to a surface normal:
/// <see cref="System.Numerics.Plane"/>'s <c>Normal</c> when
/// grounded (slope-aligned), world <c>(0, 0, 1)</c> when
/// airborne (which is a no-op since the base is already
/// horizontal).</description></item>
/// <item><description>Normalize. If the projection collapsed
/// (heading parallel to normal), fall back to the unprojected
/// base.</description></item>
/// </list>
/// </summary>
internal static Vector3 ComputeHeading(Vector3 avgVelocity, float yaw, bool alignToSlope)
/// <param name="avgVelocity">5-frame averaged player velocity in world space.</param>
/// <param name="yaw">Player facing yaw + any orbit offset, radians.</param>
/// <param name="isOnGround">Player's <c>transient_state &amp; 1</c> — does <paramref name="contactPlaneNormal"/> describe a valid contact plane?</param>
/// <param name="contactPlaneNormal">Player's current contact plane normal in world space; ignored when <paramref name="isOnGround"/> is false.</param>
/// <param name="alignToSlope">User-tunable; when false skips the projection and returns the flat facing direction.</param>
internal static Vector3 ComputeHeading(
Vector3 avgVelocity,
float yaw,
bool isOnGround,
Vector3 contactPlaneNormal,
bool alignToSlope)
{
if (alignToSlope && avgVelocity.LengthSquared() > 1e-4f)
return Vector3.Normalize(avgVelocity);
// Base heading: player's facing direction in world XY plane.
Vector3 baseHeading = new(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
return new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
if (!alignToSlope) return baseHeading;
// Slope-align gate: player must be moving in XY. Retail tests
// |vx| > 0.0002 AND |vy| > 0.0002 (decomp :95704, :95713). The
// horizontal-magnitude-squared form is a cleaner equivalent.
// Without this, the airborne path would still project against
// world up (no-op) which is fine — but the standing-jump case
// wants the historical `direction` fallback that retail uses.
float hMagSq = avgVelocity.X * avgVelocity.X + avgVelocity.Y * avgVelocity.Y;
if (hMagSq < 1e-4f) return baseHeading;
// Pick the projection plane normal:
// grounded → contact_plane.N (slope-aligned camera basis)
// airborne → world up (projection becomes a no-op because
// baseHeading is already in the XY plane — but
// keeping the code path uniform makes the airborne
// case impossible to swing vertically).
Vector3 normal;
if (isOnGround && contactPlaneNormal.LengthSquared() > 0.01f)
normal = Vector3.Normalize(contactPlaneNormal);
else
normal = new Vector3(0f, 0f, 1f);
// Project baseHeading onto plane perpendicular to normal:
// projected = forward - normal * dot(forward, normal)
// On flat ground this is a no-op (dot ≈ 0). On a slope the
// projected vector gains a Z component matching the slope angle,
// which tilts the camera basis with the terrain.
float dot = Vector3.Dot(baseHeading, normal);
Vector3 projected = baseHeading - normal * dot;
// Degenerate: facing nearly parallel to normal (rare — would
// require player rotated to face into the ground). Fall back to
// the unprojected base heading.
if (projected.LengthSquared() < 1e-4f) return baseHeading;
return Vector3.Normalize(projected);
}
/// <summary>